diff --git a/VERSION b/VERSION index 9eb3e8f..02c6787 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.40-beta +0.4.41-beta diff --git a/public/css/teach.css b/public/css/teach.css index 81c5219..2c2381c 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -661,6 +661,10 @@ .wiki-status strong{white-space:nowrap}.wiki-status span{color:var(--muted)} .wiki-link{width:max-content;max-width:100%;color:var(--accent);font-size:12px;font-weight:700;text-decoration:none}.wiki-link:hover,.wiki-link:focus-visible{text-decoration:underline} .wiki-tule{border:1px solid var(--line);border-radius:9px;background:var(--surface)}.wiki-tule summary{cursor:pointer;padding:10px 12px;font-weight:700}.wiki-tule p{margin:0;padding:0 12px 12px;color:var(--muted);font-size:12.5px;line-height:1.55} + .wiki-profile-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;padding:0 12px 12px} + .wiki-profile-column{display:flex;flex-direction:column;gap:6px}.wiki-profile-column h3{margin:0 0 3px} + .wiki-profile-row{display:grid;grid-template-columns:74px 1fr;gap:3px 8px;padding:8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2)} + .wiki-profile-row small{grid-column:1/-1;color:var(--muted);line-height:1.4} .wiki-source-links{display:flex;flex-wrap:wrap;gap:8px;padding:0 12px 12px} .wiki-language-note{margin:0;padding:8px 11px;border-radius:7px;background:var(--surface-2);color:var(--muted);font-size:12px} .wiki-search{display:flex;flex-direction:column;gap:5px}.wiki-search label{font-size:12px;font-weight:700} @@ -709,6 +713,7 @@ .wiki-hero{flex-direction:column}.wiki-facts{grid-template-columns:auto auto;border-left:0;border-top:3px solid var(--accent)} .wiki-status{grid-template-columns:1fr}.wiki-status strong{white-space:normal} .wiki-grid{grid-template-columns:1fr} + .wiki-profile-grid{grid-template-columns:1fr} } /* ========================================================= Letter game widget @@ -1498,6 +1503,27 @@ body.tb-left #rolePreviewBanner,body.tb-right #rolePreviewBanner{top:8px} .am-row-tools .am-field{flex:0 1 auto; min-width:150px;} .am-inline{display:flex; gap:6px; align-items:center;} .am-inline > .am-inp, .am-inline > .am-pw{flex:1 1 auto; min-width:0;} + +.learning-profile-badge{ + margin:8px 10px 0;padding:6px 10px;border-radius:10px; + background:color-mix(in srgb,var(--accent) 12%,var(--surface)); + color:var(--muted);font-size:12px;font-weight:700;line-height:1.3; +} +.learning-level-card{ + margin:10px 0;padding:12px;border:1px solid var(--line);border-radius:12px; + background:var(--surface-2); +} +.learning-level-card h3{margin:0 0 6px;} +.learning-level-card .guestnote{margin:4px 0 10px;} +.learning-source-note{margin-top:14px;} +.parent-levels{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:8px;} +.parent-levels .learning-level-card{margin:0;} +.learning-level-summary{margin:8px 0 0;} +.managed-learning-level .lg-levels, +.managed-learning-level .clock-level-control, +.managed-learning-level .place-level-control, +.managed-learning-level .money-level-control{display:none;} +@media(max-width:620px){.learning-level-card .am-inline{align-items:stretch;flex-direction:column;}} /* gevaarlijke acties: afwijkende kleur + tweede klik als bevestiging */ .am-btn.am-danger{background:var(--red-soft); color:var(--red); font-weight:800;} .am-btn.am-danger.armed{background:var(--red); color:#fff;} diff --git a/public/index.html b/public/index.html index a9ddc2d..d4b8504 100644 --- a/public/index.html +++ b/public/index.html @@ -20,6 +20,7 @@ + diff --git a/public/js/admin.js b/public/js/admin.js index b18bc6e..7041fc2 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -1239,7 +1239,8 @@ } function levelSel(current){ const sel = h("select","am-sel"); - [1,2,3].forEach(n=>sel.appendChild(new Option(T("lvl"+n), n))); + [1,2,3].forEach(n=>sel.appendChild(new Option( + typeof learningLevelLabel==="function" ? learningLevelLabel(n) : T("lvl"+n), n))); sel.value = current ?? 1; return sel; } @@ -1258,6 +1259,12 @@ const ygSel = yearGroupSel(current && current.yearGroup); const lvlSel = levelSel(current && current.level); row.append(field("amLevelYearGroup", ygSel), field("amLevelLevel", lvlSel)); + const summary = h("div","guestnote learning-level-summary"); + const updateSummary=()=>{ + const p=typeof learningProfile==="function" ? learningProfile(subject,+ygSel.value,+lvlSel.value) : null; + summary.textContent=p ? p.title+" — "+p.goals.join(" · ") : ""; + }; + ygSel.addEventListener("change",updateSummary);lvlSel.addEventListener("change",updateSummary);updateSummary(); const saveBtn = h("button","am-btn", T("save")); saveBtn.type = "button"; saveBtn.addEventListener("click", async ()=>{ @@ -1269,6 +1276,7 @@ }); row.appendChild(saveBtn); box.appendChild(row); + box.appendChild(summary); return box; } diff --git a/public/js/board.js b/public/js/board.js index 4fd06ed..72aca91 100644 --- a/public/js/board.js +++ b/public/js/board.js @@ -354,7 +354,16 @@ function makeWidget(def, saved){ }); board.appendChild(win); - const api = def.mount(body, saved ? saved.state : null) || {}; + /* Op het eigen bord van een thuiskind is de centrale leerjaarinstelling + leidend. Staf- en gastborden behouden exact hun opgeslagen widgetstate. */ + const mountedState = typeof learningStateForWidget === "function" + ? learningStateForWidget(def, saved ? saved.state : null) + : (saved ? saved.state : null); + const api = def.mount(body, mountedState) || {}; + if(typeof appendLearningProfileBadge === "function" && mountedState && + ["taal","rekenen"].includes(def.cat)){ + appendLearningProfileBadge(body, def.cat, mountedState, def.id); + } if(saved && saved.min) setMin(true); if(saved && saved.max) setMax(true); const inst = { def, win, api, wid: (saved && saved.wid) || genWidgetId() }; diff --git a/public/js/core.js b/public/js/core.js index cfebc7f..6fbc792 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.4.40-beta"; +const VERSION = "0.4.41-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -245,6 +245,9 @@ const I18N = { wikiOfficialSource:"Open officiële SLO-publicatie", wikiOfficialArea:"Bekijk {area} in de officiële publicatie", wikiTuleTitle:"TULE en de nieuwe leerlijnen", + wikiYearProfiles:"Leerjaarprofielen taal en rekenen", + wikiYearProfilesText:"Praktische Teach-uitwerking voor widgetopdrachten per groep. Gebaseerd op de kerndoelen 2026 en TULE; geen officiële SLO-jaarnormen.", + wikiYear:"Groep {n}", wikiProfileGoals:"Leerdoelen", wikiTuleText:"TULE werkt de kerndoelen uit 2006 voorbeeldmatig uit. Nieuwe SLO-leerlijnen bij de doelen van 2026 zijn nog in ontwikkeling. Een niveau-indeling in Teach is daarom geen officiële SLO-leerlijn.", wikiTuleSource:"Open TULE bij SLO", wikiProgressionsSource:"Volg de ontwikkeling van nieuwe leerlijnen", wikiSearchLabel:"Doorzoek de curriculumwiki", @@ -320,6 +323,9 @@ const I18N = { parentUnlink:"Ontkoppel", parentUnlinkWarn:"Verwijdert alleen de koppeling; het account van je kind blijft bestaan.", parentProgress:"📈 Voortgang", parentProgressEmpty:"Nog geen voortgang.", + parentLevel:"🎯 Leerniveau", parentLevelTaal:"Taal", parentLevelRekenen:"Rekenen", + parentLevelYear:"Groep", parentLevelPath:"Uitwerking", parentLevelSave:"Opslaan", + parentLevelSaved:"Leerniveau opgeslagen.", dtLen:"Lengte", dtContrast:"Hoog contrast", dtLock:"Vastzetten: kan niet meer verschuiven of draaien, alleen nog langs tekenen", dtMode:"Basis: grote hoofdhoeken en hele centimeters. Volledig: graden per 2° met dubbele schaal, millimeters en evenwijdige hulplijnen.", @@ -625,6 +631,9 @@ const I18N = { wikiOfficialSource:"Open the official SLO publication", wikiOfficialArea:"View {area} in the official publication", wikiTuleTitle:"TULE and the new learning progressions", + wikiYearProfiles:"Language and maths year profiles", + wikiYearProfilesText:"Practical Teach interpretation for widget tasks per year. Based on the 2026 targets and TULE; not official SLO year standards.", + wikiYear:"Year {n}", wikiProfileGoals:"Learning goals", wikiTuleText:"TULE is an illustrative elaboration of the 2006 targets. New SLO learning progressions for the 2026 targets are still being developed. Teach levels are therefore not official SLO progressions.", wikiTuleSource:"Open TULE at SLO", wikiProgressionsSource:"Follow the development of new progressions", wikiSearchLabel:"Search the curriculum wiki", @@ -700,6 +709,9 @@ const I18N = { parentUnlink:"Unlink", parentUnlinkWarn:"Removes only the link; your child's account remains.", parentProgress:"📈 Progress", parentProgressEmpty:"No progress yet.", + parentLevel:"🎯 Learning level", parentLevelTaal:"Language", parentLevelRekenen:"Maths", + parentLevelYear:"Year", parentLevelPath:"Path", parentLevelSave:"Save", + parentLevelSaved:"Learning level saved.", dtLen:"Length", dtContrast:"High contrast", dtLock:"Lock in place: can no longer move or rotate, only draw along it", dtMode:"Basic: big main angles and whole centimetres. Full: degrees every 2° with a double scale, millimetres and parallel guide lines.", @@ -1001,6 +1013,10 @@ async function hydrateFromServer(prefetchedData){ } stopPupilView(); stopParentView(); + /* Een thuiskind beheert het eigen leerjaar. Haal dit op voordat de + bordwidgets mounten, zodat de eerste opdracht meteen bij de keuze past. */ + if(currentUser && currentUser.role === "pupil" && !currentUser.schoolId && + typeof refreshSelfLearningLevels === "function") await refreshSelfLearningLevels(); const res = prefetchedData || await api("/me/data"); const d = res.data || {}; DATA_REV = typeof res.rev === "number" ? res.rev : 0; @@ -1014,6 +1030,7 @@ async function hydrateFromServer(prefetchedData){ } function resetToGuest(){ currentUser = null; + if(typeof refreshSelfLearningLevels === "function") refreshSelfLearningLevels(); stopPupilView(); stopParentView(); THEMES = {nl:[],en:[]}; diff --git a/public/js/learning-progressions.js b/public/js/learning-progressions.js new file mode 100644 index 0000000..a2b4500 --- /dev/null +++ b/public/js/learning-progressions.js @@ -0,0 +1,133 @@ +/* teach - gedeelde leerjaarprofielen voor taal en rekenen + Praktische Teach-uitwerking van de nieuwe kerndoelen po (SLO, juli 2026) + en TULE. SLO ontwikkelt de nieuwe leerlijnen nog en kent ze geen wettelijke + status toe; deze profielen zijn daarom geen officiele SLO-jaarnormen. */ +"use strict"; + +const LEARNING_LEVEL_LABELS=Object.freeze({ + nl:{1:"Basis",2:"Op niveau",3:"Extra uitdaging"}, + en:{1:"Foundation",2:"At level",3:"Extra challenge"} +}); +const lp=(phase,title,goals)=>Object.freeze({phase,title,goals:Object.freeze(goals)}); +const LEARNING_YEAR_PROFILES=Object.freeze({ + taal:Object.freeze([ + null, + lp("1-2","Luisteren, spreken en taal ontdekken",["luisteren en vertellen","rijm en klanken herkennen","woordenschat rond de leefwereld"]), + lp("1-2","Klanken, letters en beginnende geletterdheid",["klanken aan letters koppelen","begin- en eindklank herkennen","eenvoudige woorden en zinnen begrijpen"]), + lp("3-4","Aanvankelijk lezen en spellen",["klankzuivere woorden lezen en spellen","woorden vlot herkennen","eenvoudige zinnen maken en begrijpen"]), + lp("3-4","Vlot lezen, spellingpatronen en zinnen",["clusters en tweetekenklanken lezen","veelvoorkomende spellingpatronen toepassen","zinnen uitbreiden en teksten begrijpen"]), + lp("5-6","Lezen met begrip en doelgericht schrijven",["leesstrategieen inzetten","meerlettergrepige woorden spellen","teksten en zinnen doelgericht opbouwen"]), + lp("5-6","Taalverzorging, woordenschat en tekstverband",["spellingregels beredeneren","onbekende woorden uit context begrijpen","zinsbouw en tekstverband verzorgen"]), + lp("7-8","Bronnen, argumentatie en verzorgde taal",["informatie vergelijken en beoordelen","meningen onderbouwen","werkwoordspelling en zinsbouw verzorgen"]), + lp("7-8","Kritisch en bewust taal gebruiken",["bronnen kritisch verwerken","teksten plannen, formuleren en reviseren","taalkeuzes en strategieen verantwoorden"]) + ]), + rekenen:Object.freeze([ + null, + lp("1-2","Tellen, vergelijken en ordenen",["hoeveelheden tot 20 herkennen","tellen, vergelijken en splitsen","vormen, patronen en dagelijkse tijd verkennen"]), + lp("1-2","Getalbegrip en informeel rekenen",["getallen tot 100 verkennen","optellen en aftrekken tot 20","meten, geld en tijd in situaties gebruiken"]), + lp("3-4","Basisbewerkingen tot 20 en getallen tot 100",["optellen en aftrekken tot 20 automatiseren","getalstructuur tot 100 gebruiken","tafels van 2, 5 en 10 opbouwen"]), + lp("3-4","Rekenen tot 100 en tafels automatiseren",["handig optellen en aftrekken tot 100","tafels en deeltafels automatiseren","tijd, geld en meten met passende eenheden"]), + lp("5-6","Getallen tot 1.000 en breuken",["bewerkingen tot 1.000 uitvoeren","tafels flexibel gebruiken","breuken als deel, verhouding en getal herkennen"]), + lp("5-6","Grote getallen, kommagetallen en procenten",["standaardprocedures gebruiken","kommagetallen en breuken verbinden","eenvoudige procenten en verhoudingen berekenen"]), + lp("7-8","Verhoudingen, procenten en meten",["breuken, kommagetallen en procenten verbinden","bewerkingen doelmatig kiezen","meetkundige problemen oplossen"]), + lp("7-8","Redeneren en rekenen in samenhang",["meer-stapsproblemen modelleren","schatten en uitkomsten beoordelen","rekenwijzen helder uitleggen"]) + ]) +}); +const LANGUAGE_WIDGET_TASKS=Object.freeze({ + letters:["","letters en beginklanken","letters en ontbrekende klanken","klankzuivere woorden opbouwen","clusters en tweetekenklanken","meerlettergrepige woorden","regelwoorden en langere woorden","complexe spelling en werkwoorden","spellingkennis zelfstandig toepassen"], + flits:["","korte bekende woorden","korte klankzuivere woorden","woorden vlot herkennen","langere woorden automatiseren","complexere woorden vlot lezen","schooltaalwoorden automatiseren","laagfrequente schooltaalwoorden","complexe woorden nauwkeurig lezen"], + hak:["","woorden in klanken horen","klanken hakken en plakken","klankgroepen samenvoegen","spellingpatronen herkennen","lettergrepen onderzoeken","complexe klankgroepen","woordopbouw en morfemen","woordstructuur verklaren"], + ball:["","korte beeldwoorden","korte woorden raden","woorden actief herkennen","langere woorden spellen","complexere spelling","woordenschat consolideren","complexe woordbeelden","gevorderde spelling"], + sentence:["","eenvoudige zinnen vertellen","wie-doet-wat-zinnen","enkelvoudige zinnen","zinnen uitbreiden","informatieve zinnen","zinsverband en leestekens","argumenterende zinnen","gevarieerde teksten formuleren"], + wordtypes:["","woorden benoemen","naamwoorden en doe-woorden","naamwoord en werkwoord","drie woordsoorten","woordsoorten in context","woordsoorten functioneel benoemen","woordsoort en zinsfunctie","taalstructuur analyseren"] +}); +const MATH_WIDGET_TASKS=Object.freeze({ + madd:["","optellen tot 10","optellen tot 20","optellen tot 20","optellen tot 100","optellen tot 1.000","optellen tot 10.000","grote getallen optellen","optellen en schatten"], + msub:["","aftrekken tot 10","aftrekken tot 20","aftrekken tot 20","aftrekken tot 100","aftrekken tot 1.000","aftrekken tot 10.000","grote getallen aftrekken","verschillen en schattingen"], + mmul:["","groepjes maken","herhaald optellen","tafels 2, 5 en 10","tafels tot en met 10","tafels flexibel gebruiken","grotere vermenigvuldigingen","vermenigvuldigen in context","samengestelde vermenigvuldigingen"], + mdiv:["","eerlijk verdelen","verdelen in groepjes","delen vanuit bekende tafels","deeltafels tot en met 10","delen met tafelfeiten","delen en rest interpreteren","delen in context","verhoudingsgewijs delen"], + mpct:["","deel van een geheel","helft en geheel","helft en kwart","breuk als deel","breuk en percentage","10%, 25%, 50%, 75%, 100%","veelvoorkomende percentages","percentages en verhoudingen"], + numberline:["","tellen tot 20","sprongen tot 50","getallen tot 100","sprongen tot 100","grotere sprongen","getallen positioneren","negatieve en grote getallen","schaal en bereik"], + fractions:["","helft en geheel","helft en kwart","eenvoudige breuken","breuken vergelijken","gelijkwaardige breuken","vereenvoudigen en vergelijken","breuken en verhoudingen","breuk, verhouding en percentage"], + clock:["","dagelijkse tijd","hele en halve uren","hele, halve en kwartieren","tijd per vijf minuten","tijd en tijdsduur","24-uurs tijd","24-uurs tijdsproblemen","tijdzones en tijdsduur"], + placevalue:["","hoeveelheid en cijfer","tientallen en eenheden","getallen tot 100","getallen tot 1.000","getallen tot 10.000","getallen tot 100.000","getallen tot 1.000.000","grote getalstructuur"], + money:["","munten herkennen","hele eurobedragen","eenvoudige bedragen","euros en centen","bedragen en teruggeven","precies rekenen met centen","korting en wisselgeld","budget en procentuele verandering"] +}); + +function learningYearGroup(value){const year=Number(value);return Number.isInteger(year)&&year>=1&&year<=8?year:null} +function learningLevel(value){return Math.max(1,Math.min(3,Number(value)||1))} +function learningProfile(subject,yearGroup,level){ + const year=learningYearGroup(yearGroup),profile=year&&LEARNING_YEAR_PROFILES[subject]&&LEARNING_YEAR_PROFILES[subject][year]; + return profile?Object.assign({subject:subject,yearGroup:year,level:learningLevel(level)},profile):null +} +function learningLevelLabel(level){return (LEARNING_LEVEL_LABELS[LANG]||LEARNING_LEVEL_LABELS.nl)[learningLevel(level)]} +function learningProfileSummary(subject,yearGroup,level,widgetId){ + const p=learningProfile(subject,yearGroup,level);if(!p)return""; + const tasks=subject==="taal"?LANGUAGE_WIDGET_TASKS:MATH_WIDGET_TASKS,task=tasks[widgetId]&&tasks[widgetId][p.yearGroup]; + return (LANG==="nl"?"Groep ":"Year ")+p.yearGroup+" | "+learningLevelLabel(p.level)+" | "+(task||p.title) +} +function languageWordComplexity(word){ + const w=String(word||"").toLocaleLowerCase("nl-NL").replace(/[^a-z]/g,""); + const clusters=(w.match(/[bcdfghjklmnpqrstvwxz]{2,}/g)||[]).reduce((n,x)=>n+x.length-1,0); + const patterns=(w.match(/aai|ooi|oei|eeuw|ieuw|sch|ng|nk|ch|aa|ee|oo|uu|oe|ie|ei|ij|ou|au|ui|eu/g)||[]).length; + const syllables=(w.match(/[aeiouy]+/g)||[]).length; + return w.length+clusters*2+patterns+Math.max(0,syllables-1)*2 +} +function languageWordsForYear(words,yearGroup,level){ + if(!Array.isArray(words)||!words.length)return[]; + const year=learningYearGroup(yearGroup);if(!year)return words; + const caps=[0,5,7,9,12,15,18,22,99],cap=caps[year]+(learningLevel(level)-2)*2; + const filtered=words.filter(item=>languageWordComplexity(Array.isArray(item)?item[0]:item)<=cap); + if(filtered.length>=Math.min(4,words.length))return filtered; + return [...words].sort((a,b)=>languageWordComplexity(a[0])-languageWordComplexity(b[0])).slice(0,Math.min(4,words.length)) +} +function languageTaskRules(yearGroup,level){ + const year=learningYearGroup(yearGroup);if(!year)return null;const l=learningLevel(level); + return{letterLevel:year<=2?Math.min(2,l):year===3?Math.max(2,l):3,flashSeconds:year<=2?3:year<=4?2:year<=6?1:.5,lives:year<=3?7:year<=5?6:5,wordtypeCount:year<=3?2:year===4?3:4} +} +function mathTaskRules(yearGroup,level){ + const year=learningYearGroup(yearGroup);if(!year)return null;const l=learningLevel(level),factor=l===1?.25:l===2?.6:1; + const addCap=[0,10,20,20,100,1000,10000,100000,1000000][year],numberMax=[0,20,50,100,100,1000,10000,100000,1000000][year]; + const lineStep=[0,1,1,1,2,20,200,2000,20000][year]; + return{addSub:Math.max(10,Math.round(addCap*factor/5)*5),timesTable:year<3?2:year===3?(l===1?2:l===2?5:10):year===4?(l===1?5:10):12,factorMax:year<=3?5:year<=4?10:year<=6?12:20,numberlineMax:Math.max(20,Math.round(numberMax*(l===1?.4:l===2?.7:1))),numberlineMin:year>=7&&l>=2?-100:0,numberlineStep:lineStep,fractionDen:year===1?2:year<=3?4:year===4?8:12,clockLevel:year<=2?1:year<=4?2:3,clockMinutes:year===1?[0]:year===2?[0,30]:year===3?[0,15,30,45]:null,placeLevel:year<=2?1:year<=4?2:3,moneyLevel:year<=2?1:year<=4?2:3,percentSets:year<=5?[50,100]:year===6?[10,25,50,75,100]:year===7?[5,10,20,25,50,75,100]:[5,10,15,20,25,30,40,50,60,70,75,80,90,100]} +} + +let SELF_LEARNING_LEVELS={taal:null,rekenen:null}; +async function refreshSelfLearningLevels(){ + if(!currentUser||currentUser.role!=="pupil"||currentUser.schoolId!=null){SELF_LEARNING_LEVELS={taal:null,rekenen:null};return SELF_LEARNING_LEVELS} + const pairs=await Promise.all(["taal","rekenen"].map(async subject=>{try{return[subject,await api("/my/level?subject="+subject)]}catch(_){return[subject,null]}})); + SELF_LEARNING_LEVELS=Object.fromEntries(pairs);return SELF_LEARNING_LEVELS +} +function applyLearningLevelToState(def,state,level,managed){ + const next=state?Object.assign({},state):{};if(!def||!["taal","rekenen"].includes(def.cat)||!level||level.yearGroup==null)return next; + next.yearGroup=learningYearGroup(level.yearGroup);next.level=learningLevel(level.level);next.managedLearningLevel=managed!==false;return next +} +function learningStateForWidget(def,state){ + if(!currentUser||currentUser.role!=="pupil"||currentUser.schoolId!=null)return state; + return applyLearningLevelToState(def,state,SELF_LEARNING_LEVELS[def.cat],true) +} +function appendLearningProfileBadge(root,subject,state,widgetId){ + const summary=learningProfileSummary(subject,state&&state.yearGroup,state&&state.level,widgetId);if(!summary)return; + const old=root.querySelector(":scope > .learning-profile-badge");if(old)old.remove(); + const badge=document.createElement("div");badge.className="learning-profile-badge";badge.textContent=summary; + const p=learningProfile(subject,state.yearGroup,state.level);badge.title=p?p.goals.join(" | "):"";root.prepend(badge) +} + +(function registerOwnLearningLevelSettings(){ + const host=document.createElement("div");host.className="st-section";host.dataset.section="learning-level";host.style.display="none";host.id="learningLevelSettings";document.getElementById("stContent").appendChild(host); + const text=()=>LANG==="nl"?{title:"Leerniveau",sub:"Kies zelf het passende leerjaar voor taal en rekenen.",note:"Deze keuze bepaalt woorden, getalgrenzen en opdrachten. De profielen zijn een Teach-uitwerking op basis van SLO/TULE; geen officiele SLO-jaarnormen.",save:"Opslaan",saved:"Leerniveau opgeslagen.",year:"Groep",taal:"Taal",rekenen:"Rekenen"}:{title:"Learning level",sub:"Choose the suitable year for language and maths.",note:"This choice determines words, number ranges and tasks. The profiles are a Teach interpretation based on SLO/TULE; not official SLO year standards.",save:"Save",saved:"Learning level saved.",year:"Year",taal:"Language",rekenen:"Maths"}; + const render=async()=>{ + host.innerHTML="";const tx=text(),msg=document.createElement("div");msg.className="formMsg";host.appendChild(msg);await refreshSelfLearningLevels(); + for(const subject of ["taal","rekenen"]){ + const current=SELF_LEARNING_LEVELS[subject]||{yearGroup:4,level:2},card=document.createElement("section");card.className="learning-level-card"; + const h=document.createElement("h3");h.textContent=tx[subject];const summary=document.createElement("p");summary.className="guestnote";const controls=document.createElement("div");controls.className="am-inline"; + const year=document.createElement("select"),level=document.createElement("select");year.className=level.className="am-sel"; + for(let n=1;n<=8;n++)year.add(new Option(tx.year+" "+n,n));for(let n=1;n<=3;n++)level.add(new Option(learningLevelLabel(n),n));year.value=current.yearGroup||4;level.value=current.level||2; + const update=()=>{const p=learningProfile(subject,+year.value,+level.value);summary.textContent=p?p.title+" - "+p.goals.join(" | "):""};year.onchange=level.onchange=update;update(); + const save=document.createElement("button");save.type="button";save.className="am-btn";save.textContent=tx.save;save.onclick=async()=>{try{await api("/my/level",{method:"PUT",body:{subject:subject,yearGroup:+year.value,level:+level.value}});await refreshSelfLearningLevels();msg.textContent=tx.saved;msg.classList.add("ok");loadCurrentSlot()}catch(e){msg.textContent=e.message;msg.classList.remove("ok")}}; + controls.append(year,level,save);card.append(h,summary,controls);host.appendChild(card) + } + const note=document.createElement("p");note.className="guestnote learning-source-note";note.textContent=tx.note;host.appendChild(note) + }; + registerSettingsSection({id:"learning-level",icon:"🎯",group:"personal",order:1.5,label:()=>text().title,sub:()=>text().sub,visible:()=>!!currentUser&¤tUser.role==="pupil"&¤tUser.schoolId==null,activate(){showSettingsDom("learning-level");return render()},keywords:()=>[text().title,text().year,"taal","rekenen","groep","niveau"]}) +})(); diff --git a/public/js/parent.js b/public/js/parent.js index 04ff852..9276193 100644 --- a/public/js/parent.js +++ b/public/js/parent.js @@ -98,6 +98,51 @@ function parentChildRow(c){ const actions = prH("div","am-actions"); actions.appendChild(progBtn); + /* Ouder en thuiskind delen exact dezelfde instelling in pupil_levels. + Schoolkinderen staan hier bewust niet tussen: hun leerkracht is leidend. */ + const levelHolder = prH("div","parent-levels"); + levelHolder.style.display = "none"; + async function loadLevels(){ + levelHolder.innerHTML = ""; + try{ + const data = await api(`/parent/children/${c.id}/levels`); + ["taal","rekenen"].forEach(subject=>{ + const current = data.levels?.[subject] || {yearGroup:4,level:2}; + const card = prH("section","learning-level-card"); + card.appendChild(prH("h3",null,T(subject==="taal"?"parentLevelTaal":"parentLevelRekenen"))); + const summary = prH("p","guestnote"); + const controls = prH("div","am-inline"); + const year = prH("select","am-sel"), level = prH("select","am-sel"); + for(let n=1;n<=8;n++) year.add(new Option(T("parentLevelYear")+" "+n,n)); + for(let n=1;n<=3;n++) level.add(new Option(learningLevelLabel(n),n)); + year.value=current.yearGroup; level.value=current.level; + const update=()=>{ + const p=learningProfile(subject,+year.value,+level.value); + summary.textContent=p ? p.title+" — "+p.goals.join(" · ") : ""; + }; + year.onchange=level.onchange=update; update(); + const save=prH("button","am-btn",T("parentLevelSave"));save.type="button"; + save.onclick=async()=>{ + try{ + await api(`/parent/children/${c.id}/level`,{method:"PUT",body:{subject,yearGroup:+year.value,level:+level.value}}); + prMsg(T("parentLevelSaved")); await loadLevels(); + }catch(e){prMsg(e.message);} + }; + controls.append(year,level,save);card.append(summary,controls);levelHolder.appendChild(card); + }); + }catch(e){levelHolder.appendChild(prH("div","guestnote",e.message));} + } + if(!c.schoolId){ + const levelBtn=prH("button","am-btn",T("parentLevel"));levelBtn.type="button"; + levelBtn.addEventListener("click",()=>{ + const key="level:"+c.id; + if(parentOpen.has(key)){parentOpen.delete(key);levelHolder.style.display="none";} + else{parentOpen.add(key);levelHolder.style.display="";loadLevels();} + }); + actions.appendChild(levelBtn); + if(parentOpen.has("level:"+c.id)){levelHolder.style.display="";loadLevels();} + } + /* thuiskind zonder lopende aanmelding: aanmelden bij een school via een doorzoekbare lijst (input + datalist gevuld uit de publieke /schools) */ const applyHolder = prH("div","am-add"); @@ -133,6 +178,7 @@ function parentChildRow(c){ catch(e){ prMsg(e.message); } })); tools.appendChild(actions); + tools.appendChild(levelHolder); tools.appendChild(applyHolder); tools.appendChild(progHolder); row.appendChild(tools); diff --git a/public/js/pupil.js b/public/js/pupil.js index ce44286..83ae954 100644 --- a/public/js/pupil.js +++ b/public/js/pupil.js @@ -81,15 +81,16 @@ async function renderPupilWidgets(widgets, mode, preview=false){ api("/my/progress", { method:"POST", body: { widgetId, widgetType: def.id, ...p } }) .catch(e=>console.warn("voortgang loggen mislukt:", e.message)); }}; - /* alleen aanvullen wat het bord zelf nog niet expliciet heeft ingesteld - - een leerkracht die zelf al een niveau op dit bord koos, blijft leidend. */ + /* De vakinstelling van de leerkracht is per leerling en dus leidend boven + de algemene widgetstate van het bord. Zo krijgt dezelfde opdracht voor + twee leerlingen toch passende woorden en getalgrenzen. */ const lvl = levels[def.cat]; - const state = w.state ? { ...w.state } : {}; - if(lvl){ - if(state.level == null) state.level = lvl.level; - if(def.cat === "rekenen" && lvl.yearGroup != null) state.yearGroup = lvl.yearGroup; - } + const state = typeof applyLearningLevelToState === "function" + ? applyLearningLevelToState(def, w.state, lvl, true) + : (w.state ? { ...w.state } : {}); def.mount(body, Object.keys(state).length ? state : null, opts); + if(typeof appendLearningProfileBadge === "function") + appendLearningProfileBadge(body, def.cat, state, def.id); }); } diff --git a/public/js/widgets/ball.js b/public/js/widgets/ball.js index 68820df..9284782 100644 --- a/public/js/widgets/ball.js +++ b/public/js/widgets/ball.js @@ -4,8 +4,9 @@ ==========================================================*/ function mountBall(root, initState, opts){ const onProgress = opts && opts.onProgress; - const S = { card:0, snd:true, lives:6, guessed:new Set(), over:false, order:[], idx:0, stars:0 }; - if(initState){ S.card = initState.card ?? 0; S.snd = initState.snd !== false; } + const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, snd:true, lives:6, guessed:new Set(), over:false, order:[], idx:0, stars:0 }; + if(initState){S.level=initState.level||1;S.yearGroup=initState.yearGroup||null;S.managedLearningLevel=!!initState.managedLearningLevel;S.card = initState.card ?? 0; S.snd = initState.snd !== false;} + const assignedRules=languageTaskRules(S.yearGroup,S.level),maxLives=assignedRules?.lives||6; const play = f=>{ if(S.snd) f(); }; root.innerHTML = ` @@ -29,7 +30,7 @@ function mountBall(root, initState, opts){ nextBtn = el(".bl-next"), kbdEl = el(".bl-kbd"), scoreEl = el(".bl-score"), sndBtn = el(".lg-snd"); - function list(){ S.card = normWordSource(S.card); return wordSourceList(S.card); } + function list(){ S.card = normWordSource(S.card); return languageWordsForYear(wordSourceList(S.card),S.yearGroup,S.level); } function buildSrc(){ fillWordSourceSel(srcSel, S.card); } "abcdefghijklmnopqrstuvwxyz".split("").forEach(l=>{ const b = document.createElement("button"); @@ -43,14 +44,14 @@ function mountBall(root, initState, opts){ wordEl.textContent = word().split("").map(l=>(reveal || S.guessed.has(l)) ? l : "_").join(" "); } function drawBalloons(){ - balloonsEl.textContent = "🎈".repeat(S.lives) + "💥".repeat(6-S.lives); + balloonsEl.textContent = "🎈".repeat(S.lives) + "💥".repeat(maxLives-S.lives); } function newWord(){ if(!S.order.length || S.idx >= S.order.length){ S.order = shuffle(list().map((_,i)=>i)); S.idx = 0; } - S.lives = 6; S.guessed = new Set(); S.over = false; + S.lives = maxLives; S.guessed = new Set(); S.over = false; msgEl.textContent = ""; msgEl.className = "bl-msg"; nextBtn.classList.remove("show"); kbdEl.querySelectorAll("button").forEach(b=>{ b.disabled = false; b.style.opacity = 1; }); @@ -106,6 +107,6 @@ function mountBall(root, initState, opts){ document.addEventListener("langchange", ()=>{ S.card=0; S.order=[]; labels(); newWord(); }); document.addEventListener("wordschange", ()=>{ buildSrc(); S.order=[]; newWord(); }); labels(); newWord(); + root.classList.toggle("managed-learning-level",S.managedLearningLevel); return { getState: ()=>({card:S.card, snd:S.snd}) }; } - diff --git a/public/js/widgets/flits.js b/public/js/widgets/flits.js index c182491..4a15686 100644 --- a/public/js/widgets/flits.js +++ b/public/js/widgets/flits.js @@ -3,8 +3,9 @@ Flash words widget ==========================================================*/ function mountFlits(root, initState){ - const S = { card:0, dur:2, idx:0, order:[], score:0, tries:0 }; - if(initState){ S.card = initState.card ?? 0; S.dur = initState.dur || 2; S.score=+initState.score||0; S.tries=+initState.tries||0; } + const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, dur:2, idx:0, order:[], score:0, tries:0 }; + if(initState){ S.level=initState.level||1;S.yearGroup=initState.yearGroup||null;S.managedLearningLevel=!!initState.managedLearningLevel;S.card = initState.card ?? 0; S.dur = initState.dur || 2; S.score=+initState.score||0; S.tries=+initState.tries||0; } + const assignedRules=languageTaskRules(S.yearGroup,S.level);if(S.managedLearningLevel&&assignedRules)S.dur=assignedRules.flashSeconds; let flashTimer = null, assessed=false; root.innerHTML = ` @@ -33,7 +34,7 @@ function mountFlits(root, initState){ const srcSel = el(".fl-src"), durSel = el(".fl-dur"), wordEl = el(".fl-word"), picEl = el(".fl-pic"), cntEl = el(".fl-cnt"), reflect=el(".fl-reflect"); - function list(){ S.card = normWordSource(S.card); return wordSourceList(S.card); } + function list(){ S.card = normWordSource(S.card); return languageWordsForYear(wordSourceList(S.card),S.yearGroup,S.level); } function buildSrc(){ fillWordSourceSel(srcSel, S.card); durSel.innerHTML = ""; @@ -106,5 +107,6 @@ function mountFlits(root, initState){ document.addEventListener("langchange", ()=>{ S.card=0; labels(); newRound(); hide(); }); document.addEventListener("wordschange", ()=>{ buildSrc(); newRound(); hide(); }); labels(); newRound(); + root.classList.toggle("managed-learning-level",S.managedLearningLevel); return { getState: ()=>({card:S.card, dur:S.dur, score:S.score, tries:S.tries}) }; } diff --git a/public/js/widgets/hak.js b/public/js/widgets/hak.js index f9ae24c..e08b087 100644 --- a/public/js/widgets/hak.js +++ b/public/js/widgets/hak.js @@ -19,8 +19,8 @@ function soundChunks(word, lang=LANG){ Chop & blend widget — words in sounds, with read-aloud ==========================================================*/ function mountHak(root, initState){ - const S = { card:0, idx:0, order:[], chopped:false }; - if(initState) S.card = initState.card ?? 0; + const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, idx:0, order:[], chopped:false }; + if(initState){S.level=initState.level||1;S.yearGroup=initState.yearGroup||null;S.managedLearningLevel=!!initState.managedLearningLevel;S.card = initState.card ?? 0;} const canSpeak = "speechSynthesis" in window; root.innerHTML = ` @@ -45,7 +45,7 @@ function mountHak(root, initState){ const srcSel = el(".hk-src"), wordEl = el(".hk-word"), picEl = el(".hk-pic"), cntEl = el(".hk-cnt"); - function list(){ S.card = normWordSource(S.card); return wordSourceList(S.card); } + function list(){ S.card = normWordSource(S.card); return languageWordsForYear(wordSourceList(S.card),S.yearGroup,S.level); } function buildSrc(){ fillWordSourceSel(srcSel, S.card); } function cur(){ return list()[S.order[S.idx]]; } function newRound(){ S.order = shuffle(list().map((_,i)=>i)); S.idx = 0; } @@ -107,5 +107,6 @@ function mountHak(root, initState){ document.addEventListener("langchange", ()=>{ S.card=0; labels(); newRound(); S.chopped=false; display(); }); document.addEventListener("wordschange", ()=>{ buildSrc(); newRound(); S.chopped=false; display(); }); labels(); newRound(); display(); + root.classList.toggle("managed-learning-level",S.managedLearningLevel); return { getState: ()=>({card:S.card}) }; } diff --git a/public/js/widgets/learning.js b/public/js/widgets/learning.js index a77f51b..f26a3ce 100644 --- a/public/js/widgets/learning.js +++ b/public/js/widgets/learning.js @@ -647,12 +647,15 @@ function learningShell(root, settings, stage){ function field(label, el){const w=document.createElement('label');const s=document.createElement('span');s.textContent=label;w.append(s,el);return w} function btn(text,fn,kind='ghost'){const b=document.createElement('button');b.type='button';b.className='tbtn '+kind;b.textContent=text;b.onclick=fn;return b} function num(value,min,max){const i=document.createElement('input');i.type='number';i.value=value;i.min=min;i.max=max;return i} +function persistedLearningState(state,extra={}){ + const copy={...state,...extra};delete copy.yearGroup;delete copy.managedLearningLevel;return copy +} function mountFractions(root,init){ - const S=Object.assign({den:4,show:true,activeD:0,activeN:0},init||{}), den=num(S.den,2,12), show=document.createElement('input'), stage=document.createElement('div');show.type='checkbox';show.checked=S.show; + const S=Object.assign({level:1,yearGroup:null,managedLearningLevel:false,den:4,show:true,activeD:0,activeN:0},init||{}),rules=mathTaskRules(S.yearGroup,S.level);if(S.managedLearningLevel&&rules)S.den=rules.fractionDen;const den=num(S.den,2,12), show=document.createElement('input'), stage=document.createElement('div');show.type='checkbox';show.checked=S.show;den.disabled=!!S.managedLearningLevel; const gcd=(a,b)=>b?gcd(b,a%b):a,summary=()=>{if(!S.activeD)return'';const divisor=gcd(S.activeN,S.activeD),fraction=`${S.activeN}/${S.activeD}`,simple=`${S.activeN/divisor}/${S.activeD/divisor}`;return T('fractionSelected').replace('{fraction}',divisor>1?`${fraction} = ${simple}`:fraction)}; const draw=()=>{S.den=Math.max(2,Math.min(12,+den.value||4));S.show=show.checked;if(S.activeD>S.den){S.activeD=0;S.activeN=0}stage.className='fraction-game';stage.innerHTML='';const hint=document.createElement('p');hint.className='play-mission';hint.textContent=T('fractionHint');const result=document.createElement('output');result.className='fraction-summary';result.textContent=summary();const wall=document.createElement('div');wall.className='fraction-wall';for(let d=1;d<=S.den;d++){const row=document.createElement('div');for(let n=0;n{S.activeD=d;S.activeN=n+1;draw()};row.append(c)}wall.append(row)}const clear=btn(T('fractionClear'),()=>{S.activeD=0;S.activeN=0;draw()});clear.classList.add('fraction-clear');stage.append(hint,result,wall,clear)}; - den.onchange=show.onchange=draw;learningShell(root,[field(T('fractionRows'),den),field(T('fractionLabels'),show)],stage);draw();return{getState:()=>({...S})}; + den.onchange=show.onchange=draw;learningShell(root,[field(T('fractionRows'),den),field(T('fractionLabels'),show)],stage);root.classList.toggle('managed-learning-level',!!S.managedLearningLevel);draw();return{getState:()=>persistedLearningState(S)}; } const clockTurnDelta=(next,previous)=>((next-previous+540)%360)-180; const clockHourCrossing=(next,previous)=>{const delta=clockTurnDelta(next,previous);return delta>0&&nextprevious?-1:0}; @@ -660,11 +663,11 @@ const clockStepHour=(hour,step,use24)=>use24?(hour+step+24)%24:((hour-1+step+12) const clock24Angle=(angle,delta)=>(angle+delta+720)%720; const clock24Hour=(angle,minute)=>((Math.round((angle-minute/2)/30)%24)+24)%24; function mountClock(root,init){ - const S=Object.assign({h:12,m:0,level:1,style:'both',game:'set',amode:'blocks',input:'',linkHands:true,use24:true,task:true,targetH:3,targetM:30,score:0,streak:0,solved:false,feedback:'',choices:[],answerDigits:[null,null,null,null]},init||{}); - if(typeof S.level==='string')S.level={hour:1,quarter:2,five:2,minute:3}[S.level]||1;S.level=Math.max(1,Math.min(3,+S.level||1));S.amode=S.amode==='type'?'type':'blocks';S.input=String(S.input||'').replace(/\D/g,'').slice(0,4); + const S=Object.assign({h:12,m:0,level:1,yearGroup:null,managedLearningLevel:false,style:'both',game:'set',amode:'blocks',input:'',linkHands:true,use24:true,task:true,targetH:3,targetM:30,score:0,streak:0,solved:false,feedback:'',choices:[],answerDigits:[null,null,null,null]},init||{}); + if(typeof S.level==='string')S.level={hour:1,quarter:2,five:2,minute:3}[S.level]||1;S.level=Math.max(1,Math.min(3,+S.level||1));const assignedRules=mathTaskRules(S.yearGroup,S.level);if(S.managedLearningLevel&&assignedRules)S.level=assignedRules.clockLevel;S.amode=S.amode==='type'?'type':'blocks';S.input=String(S.input||'').replace(/\D/g,'').slice(0,4); const style=document.createElement('select'),game=document.createElement('select'),linkHands=document.createElement('input'),use24=document.createElement('input'),stage=document.createElement('div'),add=(el,items)=>items.forEach(([value,key])=>el.add(new Option(T(key),value))); add(style,[['analogue','clockStyleAnalogue'],['digital','clockStyleDigital'],['words','clockStyleWords'],['both','clockStyleBoth']]);add(game,[['set','clockGameSet'],['read','clockGameRead'],['choose','clockGameChoose']]);style.value=S.style;game.value=S.game;linkHands.type='checkbox';linkHands.checked=S.linkHands;linkHands.onchange=()=>{S.linkHands=linkHands.checked};use24.type='checkbox';use24.checked=S.use24;use24.onchange=()=>{S.use24=use24.checked;newTask()};const linkSetting=field(T('clockLinkHands'),linkHands),hour24Setting=field(T('clock24Hour'),use24),syncSettings=()=>{linkSetting.hidden=game.value!=='set';hour24Setting.hidden=S.level!==3}; - const minuteValues=()=>S.level===1?[0,30]:S.level===2?Array.from({length:12},(_,i)=>i*5):Array.from({length:60},(_,i)=>i),time=(hour,minute)=>`${hour}:${String(minute).padStart(2,'0')}`; + const minuteValues=()=>assignedRules?.clockMinutes||(S.level===1?[0,30]:S.level===2?Array.from({length:12},(_,i)=>i*5):Array.from({length:60},(_,i)=>i)),time=(hour,minute)=>`${hour}:${String(minute).padStart(2,'0')}`; const uses24=()=>S.level===3&&S.use24; const daypartKey=hour=>hour<6?'clockNight':hour<12?'clockMorning':hour<18?'clockAfternoon':'clockEvening',clockHour=hour=>hour%12||12; const words=(hour,minute)=>{const shown=clockHour(hour),next=shown===12?1:shown+1;let result;if(minute===0)result=T('clockWordsHour').replace('{hour}',shown);else if(minute===15)result=T('clockWordsQuarterPast').replace('{hour}',shown);else if(minute===30)result=T('clockWordsHalf').replace('{hour}',next);else if(minute===45)result=T('clockWordsQuarterTo').replace('{hour}',next);else result=T(minute<30?'clockWordsPast':'clockWordsTo').replace('{minutes}',minute<30?minute:60-minute).replace('{hour}',minute<30?shown:next);return uses24()?result+' '+T(daypartKey(hour)):result}; @@ -711,13 +714,13 @@ function mountClock(root,init){ if(S.task&&S.game==='read'){const layout=document.createElement('div');layout.className='clock-read-layout';layout.append(assignment(S.targetH,S.targetM),S.amode==='blocks'?blockAnswer():typeAnswer());card.append(layout)} if(S.task&&S.game==='choose'){card.append(assignment(S.targetH,S.targetM));if(!S.solved){const choices=document.createElement('div');choices.className='clock-choices '+(S.amode==='blocks'?'wood':'plain');const asWords=['digital','both'].includes(S.style);S.choices.forEach(option=>{const choose=()=>setFeedback(option.h===S.targetH&&option.m===S.targetM);if(asWords){const button=btn(words(option.h,option.m),choose);button.classList.add('clock-word-choice');choices.append(button)}else if(S.amode==='blocks')choices.append(timeBlocks(option.h,option.m,choose));else{const button=btn(time(option.h,option.m),choose);button.classList.add('clock-digital-choice');choices.append(button)}});card.append(choices)}} const feedback=document.createElement('p');feedback.className='clock-feedback '+(S.feedback==='good'?'ok':S.feedback?'bad':'');feedback.setAttribute('role','status');if(S.feedback)feedback.textContent=T(S.feedback==='good'?'clockCorrect':S.feedback==='incomplete'?'clockIncomplete':'clockTryAgain');if(S.feedback==='good'&¤tUser&¤tUser.avatar){const cheer=renderAvatarFace(currentUser.avatar,'md');cheer.classList.add('av-cheer');feedback.append(cheer)}card.append(feedback);const actions=document.createElement('div');actions.className='clock-actions';if(S.task&&S.game!=='choose'&&!S.solved&&!(S.game==='read'&&S.amode==='type'))actions.append(btn(T('numberlineCheck'),check,''));actions.append(btn(T('clockNextTask'),newTask,'ghost'));card.append(actions);if(S.task&&S.game==='set'){const hint=document.createElement('small');hint.className='clock-drag-hint';hint.textContent=T('clockDragHint');card.append(hint)}stage.append(card)}; - style.onchange=draw;game.onchange=()=>{syncSettings();newTask()};learningShell(root,[field(T('clockLearningStyle'),style),field(T('clockGame'),game),linkSetting,hour24Setting],stage);syncSettings();newTask();return{getState:()=>({...S,choices:S.choices.map(x=>({...x})),answerDigits:[...S.answerDigits]})}; + style.onchange=draw;game.onchange=()=>{syncSettings();newTask()};learningShell(root,[field(T('clockLearningStyle'),style),field(T('clockGame'),game),linkSetting,hour24Setting],stage);root.classList.toggle('managed-learning-level',!!S.managedLearningLevel);syncSettings();newTask();return{getState:()=>persistedLearningState(S,{choices:S.choices.map(x=>({...x})),answerDigits:[...S.answerDigits]})}; } const PLACE_COLUMNS=['HD','TD','D','H','T','E']; const placeLevelDigits=level=>level===1?2:level===2?3:6; function mountPlacevalue(root,init){ - const S=Object.assign({number:1342,game:'explore',level:1,target:null,answerDigits:null,score:0,streak:0,solved:false,feedback:''},init||{}); - S.level=Math.max(1,Math.min(3,+S.level||1));S.game=S.game==='build'?'build':'explore';if(!Array.isArray(S.answerDigits))S.answerDigits=null; + const S=Object.assign({number:1342,game:'explore',level:1,yearGroup:null,managedLearningLevel:false,target:null,answerDigits:null,score:0,streak:0,solved:false,feedback:''},init||{}); + S.level=Math.max(1,Math.min(3,+S.level||1));const assignedRules=mathTaskRules(S.yearGroup,S.level);if(S.managedLearningLevel&&assignedRules)S.level=assignedRules.placeLevel;S.game=S.game==='build'?'build':'explore';if(!Array.isArray(S.answerDigits))S.answerDigits=null; const input=num(S.number,0,999999),game=document.createElement('select'),stage=document.createElement('div'); game.add(new Option(T('placeGameExplore'),'explore'));game.add(new Option(T('placeGameBuild'),'build'));game.value=S.game; const gameSetting=field(T('placeGame'),game),numberSetting=field(T('placeNumber'),input),diceBtn=btn('🎲',()=>{input.value=Math.floor(Math.random()*999999);drawExplore()}); @@ -763,14 +766,14 @@ function mountPlacevalue(root,init){ const actions=document.createElement('div');actions.className='place-actions';if(!S.solved)actions.append(btn(T('numberlineCheck'),check,''));actions.append(btn(T('placeNextTask'),newTask,'ghost'));card.append(actions);stage.append(card); }; input.oninput=drawExplore;game.onchange=()=>{S.game=game.value;syncSettings();if(S.game!=='build')drawExplore();else if(needsNewTask())newTask();else drawBuild()}; - learningShell(root,[gameSetting,numberSetting,diceBtn],stage);syncSettings(); + learningShell(root,[gameSetting,numberSetting,diceBtn],stage);root.classList.toggle('managed-learning-level',!!S.managedLearningLevel);syncSettings(); if(S.game!=='build')drawExplore();else if(needsNewTask())newTask();else drawBuild(); - return{getState:()=>({...S,answerDigits:S.answerDigits?[...S.answerDigits]:null})}; + return{getState:()=>persistedLearningState(S,{answerDigits:S.answerDigits?[...S.answerDigits]:null})}; } function mountMoney(root,init){ - const legacy=init||{},S=Object.assign({level:1,style:'both',game:'make',category:'all',productId:'juice',amode:'blocks',targetCents:375,paidCents:500,selected:[],given:[],input:'',answerDigits:[0,null,0,0],score:0,streak:0,solved:false,feedback:''},legacy); + const legacy=init||{},S=Object.assign({level:1,yearGroup:null,managedLearningLevel:false,style:'both',game:'make',category:'all',productId:'juice',amode:'blocks',targetCents:375,paidCents:500,selected:[],given:[],input:'',answerDigits:[0,null,0,0],score:0,streak:0,solved:false,feedback:''},legacy); if(legacy.targetCents==null&&Number.isFinite(legacy.target))S.targetCents=Math.round(legacy.target*100); - S.level=Math.max(1,Math.min(3,+S.level||1));S.style=['amount','words','both'].includes(S.style)?S.style:'both';S.game=['make','count','change'].includes(S.game)?S.game:'make';S.category=['all','food','school','play'].includes(S.category)?S.category:'all';S.amode=S.amode==='type'?'type':'blocks';S.selected=Array.isArray(S.selected)?S.selected.map(Number).filter(Number.isFinite):[];S.given=Array.isArray(S.given)?S.given.map(Number).filter(Number.isFinite):[]; + S.level=Math.max(1,Math.min(3,+S.level||1));const assignedRules=mathTaskRules(S.yearGroup,S.level);if(S.managedLearningLevel&&assignedRules)S.level=assignedRules.moneyLevel;S.style=['amount','words','both'].includes(S.style)?S.style:'both';S.game=['make','count','change'].includes(S.game)?S.game:'make';S.category=['all','food','school','play'].includes(S.category)?S.category:'all';S.amode=S.amode==='type'?'type':'blocks';S.selected=Array.isArray(S.selected)?S.selected.map(Number).filter(Number.isFinite):[];S.given=Array.isArray(S.given)?S.given.map(Number).filter(Number.isFinite):[]; const style=document.createElement('select'),game=document.createElement('select'),category=document.createElement('select'),stage=document.createElement('div'),add=(el,items)=>items.forEach(([value,key])=>el.add(new Option(T(key),value))); add(style,[['amount','moneyStyleAmount'],['words','moneyStyleWords'],['both','moneyStyleBoth']]);add(game,[['make','moneyGameMake'],['count','moneyGameCount'],['change','moneyGameChange']]);add(category,[['all','moneyCategoryAll'],['food','moneyCategoryFood'],['school','moneyCategorySchool'],['play','moneyCategoryPlay']]);style.value=S.style;game.value=S.game;category.value=S.category; const styleSetting=field(T('moneyLearningStyle'),style),gameSetting=field(T('moneyGame'),game),categorySetting=field(T('moneyCategory'),category),syncSettings=()=>{styleSetting.hidden=S.game==='count';categorySetting.hidden=S.game!=='change'}; @@ -810,12 +813,12 @@ function mountMoney(root,init){ if(S.game==='count'){const target=document.createElement('div');target.className='money-count-task';target.append(pile(S.given));card.append(target,S.amode==='blocks'?digitAnswer():typeAnswer())} if(S.game==='change'){const item=currentProduct(),shop=document.createElement('div');shop.className='money-shop-task';const product=document.createElement('div');product.className='money-product';const emoji=document.createElement('span'),productName=document.createElement('strong');emoji.className='money-product-icon';emoji.textContent=item.icon;productName.className='money-product-name';productName.textContent=LANG==='nl'?item.nl:item.en;const priceLabel=document.createElement('b');priceLabel.textContent=T('moneyPrice');product.append(emoji,productName,priceLabel,amountDisplay(S.targetCents,true));const paid=document.createElement('div');paid.className='money-paid';const paidLabel=document.createElement('b');paidLabel.textContent=T('moneyPaid');const paidAmount=document.createElement('output');paidAmount.textContent=format(S.paidCents);paid.append(paidLabel,paidAmount,pile([S.paidCents]));shop.append(product,paid);card.append(shop,S.amode==='blocks'?digitAnswer():typeAnswer())} const feedback=document.createElement('p');feedback.className='money-feedback '+(S.feedback==='good'?'ok':S.feedback?'bad':'');feedback.setAttribute('role','status');feedback.textContent=S.feedback?feedbackText():'';if(S.feedback==='good'&¤tUser&¤tUser.avatar){const cheer=renderAvatarFace(currentUser.avatar,'md');cheer.classList.add('av-cheer');feedback.append(cheer)}card.append(feedback);const actions=document.createElement('div');actions.className='money-actions';if(!S.solved)actions.append(btn(T('numberlineCheck'),check,''));if(!S.solved)actions.append(btn(T('moneyClear'),resetAnswer,'ghost'));actions.append(btn(T('moneyNextTask'),newTask,'ghost'));card.append(actions);stage.append(card)}; - style.onchange=draw;game.onchange=()=>{S.game=game.value;syncSettings();newTask()};category.onchange=()=>{S.category=category.value;newTask()};learningShell(root,[styleSetting,gameSetting,categorySetting],stage);syncSettings();newTask();return{getState:()=>({...S,selected:[...S.selected],given:[...S.given],answerDigits:[...S.answerDigits]})}; + style.onchange=draw;game.onchange=()=>{S.game=game.value;syncSettings();newTask()};category.onchange=()=>{S.category=category.value;newTask()};learningShell(root,[styleSetting,gameSetting,categorySetting],stage);root.classList.toggle('managed-learning-level',!!S.managedLearningLevel);syncSettings();newTask();return{getState:()=>persistedLearningState(S,{selected:[...S.selected],given:[...S.given],answerDigits:[...S.answerDigits]})}; } function mountWordtypes(root,init){ - const S=Object.assign({sentence:LANG==='nl'?'De vrolijke hond rent snel':'The cheerful dog runs quickly',answers:{}},init||{}),input=document.createElement('input'),stage=document.createElement('div');input.value=S.sentence; - const types=['noun','verb','adjective','other'],draw=()=>{S.sentence=input.value.slice(0,120);stage.className='wordtypes';stage.innerHTML='';S.sentence.split(/\s+/).filter(Boolean).forEach((word,i)=>{const w=document.createElement('button');w.type='button';w.textContent=word;const type=S.answers[i]||'other';w.dataset.type=type;w.onclick=()=>{S.answers[i]=types[(types.indexOf(w.dataset.type)+1)%types.length];draw()};stage.append(w)});const legend=document.createElement('p');legend.textContent=T('wordtypeHint');stage.append(legend)};input.oninput=draw;learningShell(root,[field(T('wordtypeSentence'),input)],stage);draw();return{getState:()=>({...S})}; + const year=learningYearGroup(init?.yearGroup),defaultSentence=year&&year<=3?(LANG==='nl'?'De hond rent':'The dog runs'):year&&year>=7?(LANG==='nl'?'De zorgvuldige onderzoeker beoordeelt de bron kritisch':'The careful researcher assesses the source critically'):(LANG==='nl'?'De vrolijke hond rent snel':'The cheerful dog runs quickly'),savedSentence=init?.sentence,isLegacyDefault=['De vrolijke hond rent snel','The cheerful dog runs quickly'].includes(savedSentence),base=init?.managedLearningLevel&&isLegacyDefault?{...init,sentence:defaultSentence}:init,S=Object.assign({level:1,yearGroup:null,managedLearningLevel:false,sentence:defaultSentence,answers:{}},base||{}),input=document.createElement('input'),stage=document.createElement('div');input.value=S.sentence; + const rules=languageTaskRules(S.yearGroup,S.level),allTypes=['noun','verb','adjective','other'],types=rules?allTypes.slice(0,rules.wordtypeCount):allTypes,draw=()=>{S.sentence=input.value.slice(0,120);stage.className='wordtypes';stage.innerHTML='';S.sentence.split(/\s+/).filter(Boolean).forEach((word,i)=>{const w=document.createElement('button');w.type='button';w.textContent=word;const type=S.answers[i]||'other';w.dataset.type=type;w.onclick=()=>{S.answers[i]=types[(types.indexOf(w.dataset.type)+1)%types.length];draw()};stage.append(w)});const legend=document.createElement('p');legend.textContent=T('wordtypeHint');stage.append(legend)};input.oninput=draw;learningShell(root,[field(T('wordtypeSentence'),input)],stage);root.classList.toggle('managed-learning-level',!!S.managedLearningLevel);draw();return{getState:()=>persistedLearningState(S)}; } function mountSchedule(root,init){ const defaults=LANG==='nl'?[{time:'08:30',text:'Start'},{time:'10:15',text:'Pauze'}]:[{time:'08:30',text:'Start'},{time:'10:15',text:'Break'}],S=Object.assign({items:defaults},init||{}),time=document.createElement('input'),text=document.createElement('input'),stage=document.createElement('div');time.type='time';time.value='09:00';text.placeholder=T('scheduleActivity'); diff --git a/public/js/widgets/letters.js b/public/js/widgets/letters.js index a44e168..c611910 100644 --- a/public/js/widgets/letters.js +++ b/public/js/widgets/letters.js @@ -2,9 +2,11 @@ function mountLetterGame(root, initState, opts){ const readonly = !!(opts && opts.readonly); const onProgress = opts && opts.onProgress; - const S = { level:1, card:0, wordIdx:0, stars:0, lang:LANG, count:6, order:[], snd:true, amode:"blocks" }; + const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, wordIdx:0, stars:0, lang:LANG, count:6, order:[], snd:true, amode:"blocks" }; if(initState){ S.level = initState.level||1; + S.yearGroup = initState.yearGroup||null; + S.managedLearningLevel = !!initState.managedLearningLevel; S.card = initState.card ?? "g"; S.count = initState.count||6; S.snd = initState.snd !== false; @@ -167,6 +169,7 @@ function mountLetterGame(root, initState, opts){ root.querySelectorAll(".lg-levels button").forEach(b=>{ b.addEventListener("click", ()=>{ S.level = +b.dataset.l; newRound(); render(); }); + b.disabled = S.managedLearningLevel; }); nextBtn.addEventListener("click", ()=>{ S.wordIdx++; @@ -182,7 +185,7 @@ function mountLetterGame(root, initState, opts){ function fullList(){ S.card = normWordSource(S.card); - return wordSourceList(S.card); + return languageWordsForYear(wordSourceList(S.card),S.yearGroup,S.level); } function newRound(){ const list = fullList(); @@ -751,13 +754,16 @@ function mountLetterGame(root, initState, opts){ } buildNumSel(); + const assignedRules=languageTaskRules(S.yearGroup,S.level); + if(S.managedLearningLevel&&assignedRules)S.level=assignedRules.letterLevel; + root.classList.toggle("managed-learning-level",S.managedLearningLevel); updateSndBtn(); newRound(); render(); document.addEventListener("langchange", updateSndBtn); return { getState: ()=>({ level:S.level, card:S.card, count:S.count, snd:S.snd, amode:S.amode }), - setState: st=>{ if(st){ S.level=st.level||1; S.card=st.card??0; S.count=st.count||6; S.snd=st.snd!==false; S.amode=st.amode||"blocks"; buildNumSel(); updateSndBtn(); newRound(); render(); } } + setState: st=>{ if(st){ S.level=st.level||1; S.yearGroup=st.yearGroup||null; S.managedLearningLevel=!!st.managedLearningLevel; const rules=languageTaskRules(S.yearGroup,S.level);if(S.managedLearningLevel&&rules)S.level=rules.letterLevel; S.card=st.card??0; S.count=st.count||6; S.snd=st.snd!==false; S.amode=st.amode||"blocks"; buildNumSel(); updateSndBtn(); newRound(); render(); } } }; } @@ -775,4 +781,3 @@ function confetti(container){ setTimeout(()=>c.remove(), 3200); } } - diff --git a/public/js/widgets/math.js b/public/js/widgets/math.js index 5c6620d..c3c0f2a 100644 --- a/public/js/widgets/math.js +++ b/public/js/widgets/math.js @@ -5,13 +5,14 @@ function mountMath(root, initState, mode, opts){ const ROUND = 10; const onProgress = opts && opts.onProgress; - const S = { level:1, yearGroup:null, snd:true, amode:"blocks", q:null, tries:0, done:0, good:0, input:"", busy:false }; + const S = { level:1, yearGroup:null, managedLearningLevel:false, snd:true, amode:"blocks", q:null, tries:0, done:0, good:0, input:"", busy:false }; if(initState){ S.level = initState.level||1; /* yearGroup komt van klassenmanagement (pupil.js), niet van het bord zelf - bewust niet in getState() opgenomen, dit is een per-leerling waarde, geen door de leerkracht op het bord ingestelde inhoud. */ S.yearGroup = initState.yearGroup || null; + S.managedLearningLevel = !!initState.managedLearningLevel; S.snd = initState.snd!==false; S.amode = initState.amode||"blocks"; } @@ -171,28 +172,29 @@ function mountMath(root, initState, mode, opts){ klassenmanagement ingesteld voor deze leerling/dit bord) blijft het oude gedrag exact hetzelfde. */ const slo = mathRangeFor(S.yearGroup, L); + const rules = mathTaskRules(S.yearGroup, L); let a, b, ans, txt; if(mode==="add"){ - const max = slo ? slo.addSub : (L===1 ? 10 : L===2 ? 20 : 100); + const max = rules ? rules.addSub : (slo ? slo.addSub : (L===1 ? 10 : L===2 ? 20 : 100)); a = rnd(max+1); b = rnd(max-a+1); ans = a+b; txt = `${a} + ${b} =`; }else if(mode==="sub"){ - const max = slo ? slo.addSub : (L===1 ? 10 : L===2 ? 20 : 100); + const max = rules ? rules.addSub : (slo ? slo.addSub : (L===1 ? 10 : L===2 ? 20 : 100)); a = rnd(max+1); b = rnd(a+1); ans = a-b; txt = `${a} − ${b} =`; }else if(mode==="mul"){ - const tmax = slo ? slo.timesTable : (L===1 ? 5 : L===2 ? 10 : 12); - a = 1+rnd(tmax); b = 1+rnd(L===3 ? 12 : 10); ans = a*b; + const tmax = rules ? rules.timesTable : (slo ? slo.timesTable : (L===1 ? 5 : L===2 ? 10 : 12)); + a = 1+rnd(tmax); b = 1+rnd(rules ? rules.factorMax : (L===3 ? 12 : 10)); ans = a*b; txt = `${a} × ${b} =`; }else if(mode==="div"){ - const tmax = slo ? slo.timesTable : (L===1 ? 5 : L===2 ? 10 : 12); - const t = 1+rnd(tmax), q = 1+rnd(L===3 ? 12 : 10); + const tmax = rules ? rules.timesTable : (slo ? slo.timesTable : (L===1 ? 5 : L===2 ? 10 : 12)); + const t = 1+rnd(tmax), q = 1+rnd(rules ? rules.factorMax : (L===3 ? 12 : 10)); a = t*q; ans = q; txt = `${a} ${S.lang==="nl" || LANG==="nl" ? ":" : "÷"} ${t} =`; }else{ /* pct */ - const sets = L===1 ? [10,50,100] : L===2 ? [10,20,25,50,75,100] : [5,10,20,25,30,40,50,60,70,75,80,90]; + const sets = rules ? rules.percentSets : (L===1 ? [10,50,100] : L===2 ? [10,20,25,50,75,100] : [5,10,20,25,30,40,50,60,70,75,80,90]); const p = sets[rnd(sets.length)]; - const step = {5:20,10:10,20:5,25:4,30:10,40:5,50:2,60:5,70:10,75:4,80:5,90:10,100:1}[p]; + const step = {5:20,10:10,15:20,20:5,25:4,30:10,40:5,50:2,60:5,70:10,75:4,80:5,90:10,100:1}[p]; const base = step*(1+rnd(12)); ans = p*base/100; txt = `${p}% ${T("mOf")} ${base} =`; @@ -270,7 +272,7 @@ function mountMath(root, initState, mode, opts){ if(doneEl.classList.contains("show")) return; if(k==="⌫"){ if(!S.busy){ S.input = S.input.slice(0,-1); aEl.textContent = S.input; } } else if(k==="✓") check(); - else if(!S.busy && S.input.length<4){ S.input += k; aEl.textContent = S.input; } + else if(!S.busy && S.input.length<8){ S.input += k; aEl.textContent = S.input; } }); padEl.appendChild(b); }); @@ -281,6 +283,7 @@ function mountMath(root, initState, mode, opts){ newQuestion(); }); root.querySelectorAll(".lg-levels button").forEach(b=>{ + b.disabled = S.managedLearningLevel; b.addEventListener("click", ()=>{ S.level = +b.dataset.l; S.done = 0; S.good = 0; @@ -316,9 +319,9 @@ function mountMath(root, initState, mode, opts){ labels(); newQuestion(); + root.classList.toggle("managed-learning-level",S.managedLearningLevel); return { getState: ()=>({ level:S.level, snd:S.snd, amode:S.amode }), - setState: st=>{ if(st){ S.level=st.level||1; S.snd=st.snd!==false; S.amode=st.amode||"blocks"; labels(); newQuestion(); } } + setState: st=>{ if(st){ S.level=st.level||1;S.yearGroup=st.yearGroup||null;S.managedLearningLevel=!!st.managedLearningLevel; S.snd=st.snd!==false; S.amode=st.amode||"blocks"; labels(); newQuestion(); } } }; } - diff --git a/public/js/widgets/numberline.js b/public/js/widgets/numberline.js index ee8fdc9..5107887 100644 --- a/public/js/widgets/numberline.js +++ b/public/js/widgets/numberline.js @@ -1,11 +1,13 @@ /* widget: interactieve getallenlijn */ function mountNumberline(root, initState){ - const S=Object.assign({min:0,max:20,step:1,value:0,target:10,score:0,streak:0,solved:false},initState||{}); + const S=Object.assign({level:1,yearGroup:null,managedLearningLevel:false,min:0,max:20,step:1,value:0,target:10,score:0,streak:0,solved:false},initState||{}); + const rules=mathTaskRules(S.yearGroup,S.level); + if(S.managedLearningLevel&&rules){S.min=rules.numberlineMin;S.max=rules.numberlineMax;S.step=rules.numberlineStep;S.value=S.min;S.target=S.min+S.step;} root.innerHTML=`
- - + +
@@ -21,9 +23,11 @@ function mountNumberline(root, initState){
`; const q=s=>root.querySelector(s), minEl=q('.nlw-min'), maxEl=q('.nlw-max'), stepEl=q('.nlw-step'), line=q('.nlw-line'), valueEl=q('.nlw-value'), task=q('.nlw-task'), feedback=q('.nlw-feedback'),score=q('.nlw-score'); + if(![...stepEl.options].some(o=>+o.value===S.step))stepEl.add(new Option(S.step,S.step)); minEl.value=S.min; maxEl.value=S.max; stepEl.value=S.step; + [minEl,maxEl,stepEl].forEach(el=>el.disabled=!!S.managedLearningLevel); function normalize(){ - let min=Math.max(-100,Math.min(99,+minEl.value||0)); let max=Math.max(-99,Math.min(200,+maxEl.value||20)); + let min=Math.max(-1000000,Math.min(999999,+minEl.value||0)); let max=Math.max(-999999,Math.min(1000000,+maxEl.value||20)); if(max<=min) max=min+1; const step=+stepEl.value||1; if((max-min)/step>60) max=min+60*step; S.min=min; S.max=max; S.step=step; @@ -62,6 +66,7 @@ function mountNumberline(root, initState){ q('.nlw-step-label').textContent=T('numberlineStep'); q('.nlw-new').textContent=T('numberlineNew'); q('.nlw-check').textContent=T('numberlineCheck'); render(); } + root.classList.toggle("managed-learning-level",!!S.managedLearningLevel); document.addEventListener('langchange',labels); labels(); normalize(); - return {getState:()=>({...S})}; + return {getState:()=>({min:S.min,max:S.max,step:S.step,value:S.value,target:S.target,score:S.score,streak:S.streak,solved:S.solved})}; } diff --git a/public/js/widgets/sentence.js b/public/js/widgets/sentence.js index 79107f1..b266e31 100644 --- a/public/js/widgets/sentence.js +++ b/public/js/widgets/sentence.js @@ -1,13 +1,27 @@ /* widget: zinsbouwer met aanpasbare woordbanken */ function mountSentence(root, initState){ - const defaults=LANG==='nl' ? { + const year=learningYearGroup(initState?.yearGroup); + const early=year&&year<=3,advanced=year&&year>=7; + const defaults=LANG==='nl' ? (early ? { + who:['De hond','Ik','Sam','De juf'],does:['leest','speelt','maakt','ziet'],what:['een boek','buiten','een huis','de bal'], + } : advanced ? { + who:['Hoewel de bron','De schrijver','Onze klas','Volgens de onderzoeker'],does:['vergelijkt','beargumenteert','verklaart','beoordeelt'],what:['de informatie kritisch','twee standpunten','de uitkomst zorgvuldig','het bewijs in de tekst'], + } : { who:['De hond','Mijn vriend','De juf','Sara'], does:['lees','speelt','maakt','ziet'], what:['een boek','op het plein','een mooie tekening','de rode bal'], + }) : (early ? { + who:['The dog','I','Sam','The teacher'],does:['reads','plays','makes','sees'],what:['a book','outside','a house','the ball'], + } : advanced ? { + who:['Although the source','The writer','Our class','According to the researcher'],does:['compares','argues','explains','assesses'],what:['the information critically','two viewpoints','the result carefully','the evidence in the text'], } : { who:['The dog','My friend','The teacher','Sarah'], does:['reads','plays','makes','sees'], what:['a book','in the playground','a beautiful drawing','the red ball'], - }; - const S={banks:JSON.parse(JSON.stringify(initState?.banks||defaults)),sentence:[...(initState?.sentence||[])]}; + }); + const legacyNl={who:['De hond','Mijn vriend','De juf','Sara'],does:['lees','speelt','maakt','ziet'],what:['een boek','op het plein','een mooie tekening','de rode bal']}; + const legacyEn={who:['The dog','My friend','The teacher','Sarah'],does:['reads','plays','makes','sees'],what:['a book','in the playground','a beautiful drawing','the red ball']}; + const savedBanks=initState?.banks,isLegacyDefault=savedBanks&&[legacyNl,legacyEn].some(bank=>JSON.stringify(bank)===JSON.stringify(savedBanks)); + const chosenBanks=initState?.managedLearningLevel&&isLegacyDefault?defaults:(savedBanks||defaults); + const S={banks:JSON.parse(JSON.stringify(chosenBanks)),sentence:[...(initState?.sentence||[])]}; root.innerHTML=`
@@ -53,6 +67,7 @@ function mountSentence(root, initState){ [...kind.options].forEach(o=>o.textContent=T('sentence_'+o.value)); word.placeholder=T('sentenceAdd'); q('.sb-surprise').textContent=T('sentenceSurprise');q('.sb-clear').textContent=T('sentenceClear'); q('.sb-speak').textContent=T('sentenceSpeak'); renderSentence();renderBanks(); } + root.classList.toggle("managed-learning-level",!!initState?.managedLearningLevel); document.addEventListener('langchange',labels);labels(); return {getState:()=>({banks:S.banks,sentence:S.sentence})}; } diff --git a/public/js/wiki.js b/public/js/wiki.js index 0f3a3be..bc588e0 100644 --- a/public/js/wiki.js +++ b/public/js/wiki.js @@ -126,6 +126,25 @@ tule.appendChild(sourceLinks); root.appendChild(tule); + const profiles=node("details","wiki-tule wiki-year-profiles"); + profiles.appendChild(node("summary",null,T("wikiYearProfiles"))); + profiles.appendChild(node("p",null,T("wikiYearProfilesText"))); + const profileGrid=node("div","wiki-profile-grid"); + [["taal","📖"],["rekenen","🔢"]].forEach(([subject,icon])=>{ + const column=node("section","wiki-profile-column"); + column.appendChild(node("h3",null,icon+" "+(subject==="taal"?T("parentLevelTaal"):T("parentLevelRekenen")))); + LEARNING_YEAR_PROFILES[subject].slice(1).forEach((profile,index)=>{ + const row=node("article","wiki-profile-row"); + row.appendChild(node("strong",null,T("wikiYear").replace("{n}",index+1))); + row.appendChild(node("span","wiki-profile-title",profile.title)); + row.appendChild(node("small",null,T("wikiProfileGoals")+": "+profile.goals.join(" · "))); + column.appendChild(row); + }); + profileGrid.appendChild(column); + }); + profiles.appendChild(profileGrid); + root.appendChild(profiles); + if(LANG==="en")root.appendChild(node("p","wiki-language-note",T("wikiLanguageNote"))); const searchWrap=node("div","wiki-search"); @@ -187,7 +206,7 @@ visible:()=>!!currentUser&&can("curriculum.view"), activate(payload){showSettingsDom("curriculum");renderCurriculumWiki(payload);}, keywords:()=>[ - T("wikiTitle"),T("wikiSub"),"SLO","kerndoelen","curriculum","leerlijnen","TULE", + T("wikiTitle"),T("wikiSub"),T("wikiYearProfiles"),"SLO","kerndoelen","curriculum","leerlijnen","TULE", ...CURRICULUM_PO_2026.areas.flatMap(area=>[area.title,area.description]) ], searchItems:needle=>{ diff --git a/src/api.js b/src/api.js index 5456ff0..3d8d67a 100644 --- a/src/api.js +++ b/src/api.js @@ -932,7 +932,7 @@ export default async function api(app) { const yg = Number(yearGroup); if (!Number.isInteger(yg) || yg < 1 || yg > 8) return fail(reply, 400, 'ongeldige groep'); const lvl = Number(level); - if (!Number.isInteger(lvl) || lvl < 1) return fail(reply, 400, 'ongeldig niveau'); + if (!Number.isInteger(lvl) || lvl < 1 || lvl > 3) return fail(reply, 400, 'ongeldig niveau'); return { subject, yearGroup: yg, level: lvl }; }; @@ -1034,6 +1034,24 @@ export default async function api(app) { return row ? describePupilLevel(row) : { yearGroup: null, level: 1 }; }); + // Alleen een leerling zonder school beheert het eigen niveau. Zodra een + // school het account adopteert, neemt de klas/leerlinginstelling van de + // leerkracht het automatisch over en wordt zelf wijzigen geweigerd. + app.put('/my/level', async (req, reply) => { + need(req, reply); + if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen'); + if (req.user.school_id != null) return fail(reply, 403, 'het leerniveau wordt door de school beheerd'); + const body = parseLevelBody(req, reply); + if (body.error) return body; + if (!['taal', 'rekenen'].includes(body.subject)) return fail(reply, 400, 'ongeldig vakgebied'); + await pool.query( + `INSERT INTO pupil_levels (pupil_id, subject, year_group, level) VALUES ($1,$2,$3,$4) + ON CONFLICT (pupil_id, subject) WHERE pupil_id IS NOT NULL + DO UPDATE SET year_group = $3, level = $4, updated_at = now()`, + [req.user.id, body.subject, body.yearGroup, body.level]); + return { ok: true, level: { yearGroup: body.yearGroup, level: body.level } }; + }); + // ---- voortgang (leerling-omgeving) --------------------------------------------- // Leerlingen loggen elk afgerond "moment" in een widget (ronde sommen, voltooid // woord, geknapte ballon). teacher_id/board_id komen server-side uit de effectieve @@ -1185,6 +1203,38 @@ export default async function api(app) { })) }; }); + app.get('/parent/children/:id/levels', async (req, reply) => { + needParent(req, reply); + if (!(await parentLinked(req.user.id, req.params.id))) return fail(reply, 403, 'geen rechten'); + const child = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0]; + if (!child || child.role !== 'pupil') return fail(reply, 404, 'kind onbekend'); + if (child.school_id != null) return { schoolManaged: true, levels: {} }; + const rows = (await pool.query( + "SELECT * FROM pupil_levels WHERE pupil_id = $1 AND subject IN ('taal','rekenen')", + [child.id])).rows; + return { + schoolManaged: false, + levels: Object.fromEntries(rows.map((row) => [row.subject, describePupilLevel(row)])), + }; + }); + + app.put('/parent/children/:id/level', async (req, reply) => { + needParent(req, reply); + if (!(await parentLinked(req.user.id, req.params.id))) return fail(reply, 403, 'geen rechten'); + const child = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0]; + if (!child || child.role !== 'pupil') return fail(reply, 404, 'kind onbekend'); + if (child.school_id != null) return fail(reply, 403, 'het leerniveau wordt door de school beheerd'); + const body = parseLevelBody(req, reply); + if (body.error) return body; + if (!['taal', 'rekenen'].includes(body.subject)) return fail(reply, 400, 'ongeldig vakgebied'); + await pool.query( + `INSERT INTO pupil_levels (pupil_id, subject, year_group, level) VALUES ($1,$2,$3,$4) + ON CONFLICT (pupil_id, subject) WHERE pupil_id IS NOT NULL + DO UPDATE SET year_group = $3, level = $4, updated_at = now()`, + [child.id, body.subject, body.yearGroup, body.level]); + return { ok: true }; + }); + // Kind aanmaken: pupil zonder school (thuiskind), meteen gekoppeld. // Het gegenereerde wachtwoord wordt eenmalig teruggegeven. app.post('/parent/children', async (req, reply) => { @@ -1329,6 +1379,9 @@ export default async function api(app) { if (e.code === '23505') return fail(reply, 409, 'gebruikersnaam bestaat al binnen deze school'); throw e; } + // De thuiskeuze mag na adoptie niet als leerling-override boven de + // klasinstelling blijven hangen; vanaf nu bepaalt de leerkracht dit. + await pool.query('DELETE FROM pupil_levels WHERE pupil_id = $1', [r.pupil_id]); // het kind hoort nu bij deze school: ook aanmeldingen bij andere // scholen zijn daarmee vervallen await pool.query('DELETE FROM school_link_requests WHERE pupil_id = $1', [r.pupil_id]); diff --git a/test/learning-progressions.test.js b/test/learning-progressions.test.js new file mode 100644 index 0000000..9c95046 --- /dev/null +++ b/test/learning-progressions.test.js @@ -0,0 +1,74 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +import {runInNewContext} from 'node:vm'; + +async function progressionContext(){ + const source=await readFile('public/js/learning-progressions.js','utf8'); + const pure=source.slice(0,source.indexOf('(function registerOwnLearningLevelSettings')); + const context={LANG:'nl'}; + runInNewContext(pure+` + ;globalThis.__profiles=LEARNING_YEAR_PROFILES; + globalThis.__languageWordsForYear=languageWordsForYear; + globalThis.__languageTaskRules=languageTaskRules; + globalThis.__mathTaskRules=mathTaskRules; + `,context); + return context; +} + +test('taal en rekenen hebben een compleet inhoudelijk profiel voor groep 1 tot en met 8',async()=>{ + const {__profiles:profiles}=await progressionContext(); + for(const subject of ['taal','rekenen']){ + assert.equal(profiles[subject].length,9); + for(let year=1;year<=8;year++){ + const profile=profiles[subject][year]; + assert.ok(profile.phase&&profile.title,subject+' groep '+year); + assert.equal(profile.goals.length,3,subject+' groep '+year+' heeft drie concrete accenten'); + } + } +}); + +test('taalprofiel selecteert eenvoudiger woorden in de onderbouw en bouwt taaksteun af',async()=>{ + const c=await progressionContext(); + const words=[['kat','x'],['vis','x'],['pen','x'],['maan','x'],['boek','x'],['fiets','x'], + ['brandweerauto','x'],['onderzoeker','x'],['verantwoordelijkheid','x']]; + const early=JSON.parse(JSON.stringify(c.__languageWordsForYear(words,2,2))); + const late=JSON.parse(JSON.stringify(c.__languageWordsForYear(words,8,2))); + assert.ok(early.length!['brandweerauto','verantwoordelijkheid'].includes(word))); + assert.equal(c.__languageTaskRules(2,2).flashSeconds,3); + assert.equal(c.__languageTaskRules(7,2).flashSeconds,.5); + assert.ok(c.__languageTaskRules(2,2).lives>c.__languageTaskRules(7,2).lives); +}); + +test('rekenprofiel vergroot getalgebied, tafels en breuken per leerjaar',async()=>{ + const c=await progressionContext(); + assert.equal(c.__mathTaskRules(2,3).addSub,20); + assert.equal(c.__mathTaskRules(3,1).timesTable,2); + assert.equal(c.__mathTaskRules(3,3).timesTable,10); + assert.equal(c.__mathTaskRules(4,3).addSub,100); + assert.equal(c.__mathTaskRules(6,3).addSub,10000); + assert.equal(c.__mathTaskRules(1,2).fractionDen,2); + assert.equal(c.__mathTaskRules(8,3).fractionDen,12); + assert.deepEqual(JSON.parse(JSON.stringify(c.__mathTaskRules(3,2).clockMinutes)),[0,15,30,45]); +}); + +test('alle taal- en rekenwidgets ontvangen de centrale leerjaarstate en tonen het profiel',async()=>{ + const [html,board,pupil,progressions,letters,flits,hak,ball,math,numberline,learning]=await Promise.all([ + readFile('public/index.html','utf8'),readFile('public/js/board.js','utf8'), + readFile('public/js/pupil.js','utf8'),readFile('public/js/learning-progressions.js','utf8'), + ...['letters','flits','hak','ball','math','numberline','learning'].map(name=>readFile('public/js/widgets/'+name+'.js','utf8')), + ]); + assert.ok(html.indexOf('js/data.js') { await app.close(); }); +test('ouder leest en wijzigt het leerniveau van een gekoppeld thuiskind', async () => { + const respond = (sql) => { + if (sql.includes('FROM parent_children WHERE parent_id')) return { rows: [{ 1: 1 }] }; + if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [homeChild] }; + if (sql.includes("subject IN ('taal','rekenen')")) return { rows: [ + { subject:'taal',year_group:3,level:2 },{ subject:'rekenen',year_group:4,level:1 }, + ] }; + return undefined; + }; + const {app,calls}=await makeApp({user:parent,respond}); + const get=await app.inject({method:'GET',url:'/api/parent/children/30/levels',cookies}); + assert.equal(get.statusCode,200,get.body); + assert.deepEqual(get.json().levels,{taal:{yearGroup:3,level:2},rekenen:{yearGroup:4,level:1}}); + const put=await app.inject({method:'PUT',url:'/api/parent/children/30/level',cookies, + payload:{subject:'rekenen',yearGroup:5,level:3}}); + assert.equal(put.statusCode,200,put.body); + const insert=calls.find(call=>call.sql.startsWith('INSERT INTO pupil_levels')); + assert.deepEqual(insert.params,[30,'rekenen',5,3]); + await app.close(); +}); + +test('ouder kan schoolniveau of niveau van een vreemd kind niet overschrijven', async () => { + const {app}=await makeApp({user:parent,respond:(sql)=>{ + if(sql.includes('FROM parent_children WHERE parent_id'))return{rows:[{1:1}]}; + if(sql.startsWith('SELECT * FROM users WHERE id'))return{rows:[{...homeChild,school_id:2}]}; + return undefined; + }}); + const school=await app.inject({method:'PUT',url:'/api/parent/children/30/level',cookies, + payload:{subject:'taal',yearGroup:4,level:2}}); + assert.equal(school.statusCode,403,school.body); + const {app:other}=await makeApp({user:parent}); + const strange=await other.inject({method:'PUT',url:'/api/parent/children/99/level',cookies, + payload:{subject:'taal',yearGroup:4,level:2}}); + assert.equal(strange.statusCode,403,strange.body); + await app.close();await other.close(); +}); + test('parent-routes zijn niet voor andere rollen', async () => { const { app } = await makeApp({ user: admin }); const res = await app.inject({ method: 'GET', url: '/api/parent/children', cookies }); @@ -213,6 +250,8 @@ test('adopteren zet school en klas op het thuisaccount en ruimt verzoeken op', a assert.equal(res.statusCode, 200, res.body); const upd = calls.find((c) => c.sql.includes('UPDATE users SET school_id')); assert.deepEqual(upd.params, [2, 5, null, 30]); + assert.ok(calls.some((c) => c.sql.startsWith('DELETE FROM pupil_levels')), + 'de oude thuiskeuze vervalt wanneer de leerkracht de regie krijgt'); assert.ok(calls.some((c) => c.sql.startsWith('DELETE FROM school_link_requests WHERE pupil_id'))); await app.close(); }); diff --git a/test/pupil-levels.test.js b/test/pupil-levels.test.js index 16aee04..47bd1e7 100644 --- a/test/pupil-levels.test.js +++ b/test/pupil-levels.test.js @@ -43,6 +43,7 @@ const cookies = { teach_session: 'x'.repeat(64) }; const teacher = { id: 3, username: 'juf', role: 'teacher', school_id: 2, class_id: null, data: {}, data_rev: 0 }; const admin = { id: 4, username: 'beheer', role: 'admin', school_id: 2, class_id: null, data: {}, data_rev: 0 }; const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 100, data: {}, data_rev: 0 }; +const homePupil = { id: 10, username: 'thuis', role: 'pupil', school_id: null, class_id: null, data: {}, data_rev: 0 }; test('klassenmanagement: klas-standaard opslaan doet de juiste upsert-query', async () => { const { app, calls } = await makeApp(admin); @@ -74,6 +75,15 @@ test('klassenmanagement: groep buiten 1-8 geeft 400', async () => { assert.equal(res.statusCode, 400, res.body); }); +test('klassenmanagement: alleen de drie beschreven uitwerkingen zijn geldig', async () => { + const { app } = await makeApp(teacher); + const res = await app.inject({ + method: 'PUT', url: '/api/levels/class/100', cookies, + payload: { subject: 'rekenen', yearGroup: 4, level: 4 }, + }); + assert.equal(res.statusCode, 400, res.body); +}); + test('klassenmanagement: GET class levert klas-standaard én leerling-overrides in één keer', async () => { const { app } = await makeApp(admin, { classDefault: { year_group: 4, level: 2 }, @@ -113,6 +123,21 @@ test('klassenmanagement: leerkracht-rol wordt geweigerd op /my/level (alleen lee assert.equal(res.statusCode, 403, res.body); }); +test('thuiskind kan eigen taalniveau opslaan, schoolleerling niet', async () => { + const { app, calls } = await makeApp(homePupil); + const res = await app.inject({ method: 'PUT', url: '/api/my/level', cookies, + payload: { subject: 'taal', yearGroup: 5, level: 2 } }); + assert.equal(res.statusCode, 200, res.body); + const insert = calls.find(call=>call.sql.startsWith('INSERT INTO pupil_levels')); + assert.deepEqual(insert.params,[10,'taal',5,2]); + + const { app: schoolApp } = await makeApp(pupil); + const denied = await schoolApp.inject({ method: 'PUT', url: '/api/my/level', cookies, + payload: { subject: 'taal', yearGroup: 5, level: 2 } }); + assert.equal(denied.statusCode,403,denied.body); + await app.close();await schoolApp.close(); +}); + /* Echte (niet regex-)test van de SLO-getalgrenzen: data.js's SLO_MATH_RANGES/ mathRangeFor zijn pure functies zonder DOM-afhankelijkheid, dus rechtstreeks uitvoerbaar in een geïsoleerde VM-context - zelfde techniek als elders in @@ -137,10 +162,11 @@ test('SLO-rekengrenzen: groep bepaalt de daadwerkelijke getalgrens, niet meer de assert.equal(mathRangeFor(undefined, 2), null); }); -test('math.js: gen() gebruikt mathRangeFor als er een groep is, anders de oude vaste klem', async () => { +test('math.js: gen() gebruikt leerjaarregels en behoudt zonder groep de oude vaste klem', async () => { const source = await readFile('public/js/widgets/math.js', 'utf8'); assert.match(source, /const slo = mathRangeFor\(S\.yearGroup, L\)/); - assert.match(source, /const max = slo \? slo\.addSub : \(L===1 \? 10 : L===2 \? 20 : 100\)/); - assert.match(source, /const tmax = slo \? slo\.timesTable : \(L===1 \? 5 : L===2 \? 10 : 12\)/); + assert.match(source, /const rules = mathTaskRules\(S\.yearGroup, L\)/); + assert.match(source, /const max = rules \? rules\.addSub/); + assert.match(source, /const tmax = rules \? rules\.timesTable/); assert.ok(source.includes('S.yearGroup = initState.yearGroup || null')); });