Verbeter grafische kwaliteit van kleurplaten (v0.4.67-beta) #5

Open
bes-r wants to merge 208 commits from bes-r/coloring-quality-v0.4.67 into main AGit
22 changed files with 554 additions and 67 deletions
Showing only changes of commit fabe721991 - Show all commits

View file

@ -1 +1 @@
0.4.40-beta 0.4.41-beta

View file

@ -661,6 +661,10 @@
.wiki-status strong{white-space:nowrap}.wiki-status span{color:var(--muted)} .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-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-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-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-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} .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-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-status{grid-template-columns:1fr}.wiki-status strong{white-space:normal}
.wiki-grid{grid-template-columns:1fr} .wiki-grid{grid-template-columns:1fr}
.wiki-profile-grid{grid-template-columns:1fr}
} }
/* ========================================================= /* =========================================================
Letter game widget 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-row-tools .am-field{flex:0 1 auto; min-width:150px;}
.am-inline{display:flex; gap:6px; align-items:center;} .am-inline{display:flex; gap:6px; align-items:center;}
.am-inline > .am-inp, .am-inline > .am-pw{flex:1 1 auto; min-width:0;} .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 */ /* 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{background:var(--red-soft); color:var(--red); font-weight:800;}
.am-btn.am-danger.armed{background:var(--red); color:#fff;} .am-btn.am-danger.armed{background:var(--red); color:#fff;}

View file

@ -20,6 +20,7 @@
<script src="js/wiki.js?v=__V__" defer></script> <script src="js/wiki.js?v=__V__" defer></script>
<script src="js/cms.js?v=__V__" defer></script> <script src="js/cms.js?v=__V__" defer></script>
<script src="js/data.js?v=__V__" defer></script> <script src="js/data.js?v=__V__" defer></script>
<script src="js/learning-progressions.js?v=__V__" defer></script>
<script src="js/avatar.js?v=__V__" defer></script> <script src="js/avatar.js?v=__V__" defer></script>
<script src="js/explorer.js?v=__V__" defer></script> <script src="js/explorer.js?v=__V__" defer></script>
<script src="js/picker.js?v=__V__" defer></script> <script src="js/picker.js?v=__V__" defer></script>

View file

@ -1239,7 +1239,8 @@
} }
function levelSel(current){ function levelSel(current){
const sel = h("select","am-sel"); 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; sel.value = current ?? 1;
return sel; return sel;
} }
@ -1258,6 +1259,12 @@
const ygSel = yearGroupSel(current && current.yearGroup); const ygSel = yearGroupSel(current && current.yearGroup);
const lvlSel = levelSel(current && current.level); const lvlSel = levelSel(current && current.level);
row.append(field("amLevelYearGroup", ygSel), field("amLevelLevel", lvlSel)); 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")); const saveBtn = h("button","am-btn", T("save"));
saveBtn.type = "button"; saveBtn.type = "button";
saveBtn.addEventListener("click", async ()=>{ saveBtn.addEventListener("click", async ()=>{
@ -1269,6 +1276,7 @@
}); });
row.appendChild(saveBtn); row.appendChild(saveBtn);
box.appendChild(row); box.appendChild(row);
box.appendChild(summary);
return box; return box;
} }

View file

@ -354,7 +354,16 @@ function makeWidget(def, saved){
}); });
board.appendChild(win); 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.min) setMin(true);
if(saved && saved.max) setMax(true); if(saved && saved.max) setMax(true);
const inst = { def, win, api, wid: (saved && saved.wid) || genWidgetId() }; const inst = { def, win, api, wid: (saved && saved.wid) || genWidgetId() };

View file

@ -2,7 +2,7 @@
"use strict"; "use strict";
/* version shown until /api/version resolves (or if the fetch fails, e.g. offline). /* 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. */ 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(){ (function(){
const tag = document.getElementById("verTag"); const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION; tag.textContent = "v"+VERSION;
@ -245,6 +245,9 @@ const I18N = {
wikiOfficialSource:"Open officiële SLO-publicatie", wikiOfficialSource:"Open officiële SLO-publicatie",
wikiOfficialArea:"Bekijk {area} in de officiële publicatie", wikiOfficialArea:"Bekijk {area} in de officiële publicatie",
wikiTuleTitle:"TULE en de nieuwe leerlijnen", 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.", 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", wikiTuleSource:"Open TULE bij SLO", wikiProgressionsSource:"Volg de ontwikkeling van nieuwe leerlijnen",
wikiSearchLabel:"Doorzoek de curriculumwiki", wikiSearchLabel:"Doorzoek de curriculumwiki",
@ -320,6 +323,9 @@ const I18N = {
parentUnlink:"Ontkoppel", parentUnlink:"Ontkoppel",
parentUnlinkWarn:"Verwijdert alleen de koppeling; het account van je kind blijft bestaan.", parentUnlinkWarn:"Verwijdert alleen de koppeling; het account van je kind blijft bestaan.",
parentProgress:"📈 Voortgang", parentProgressEmpty:"Nog geen voortgang.", 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", dtLen:"Lengte", dtContrast:"Hoog contrast",
dtLock:"Vastzetten: kan niet meer verschuiven of draaien, alleen nog langs tekenen", 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.", 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", wikiOfficialSource:"Open the official SLO publication",
wikiOfficialArea:"View {area} in the official publication", wikiOfficialArea:"View {area} in the official publication",
wikiTuleTitle:"TULE and the new learning progressions", 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.", 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", wikiTuleSource:"Open TULE at SLO", wikiProgressionsSource:"Follow the development of new progressions",
wikiSearchLabel:"Search the curriculum wiki", wikiSearchLabel:"Search the curriculum wiki",
@ -700,6 +709,9 @@ const I18N = {
parentUnlink:"Unlink", parentUnlink:"Unlink",
parentUnlinkWarn:"Removes only the link; your child's account remains.", parentUnlinkWarn:"Removes only the link; your child's account remains.",
parentProgress:"📈 Progress", parentProgressEmpty:"No progress yet.", 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", dtLen:"Length", dtContrast:"High contrast",
dtLock:"Lock in place: can no longer move or rotate, only draw along it", 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.", 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(); stopPupilView();
stopParentView(); 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 res = prefetchedData || await api("/me/data");
const d = res.data || {}; const d = res.data || {};
DATA_REV = typeof res.rev === "number" ? res.rev : 0; DATA_REV = typeof res.rev === "number" ? res.rev : 0;
@ -1014,6 +1030,7 @@ async function hydrateFromServer(prefetchedData){
} }
function resetToGuest(){ function resetToGuest(){
currentUser = null; currentUser = null;
if(typeof refreshSelfLearningLevels === "function") refreshSelfLearningLevels();
stopPupilView(); stopPupilView();
stopParentView(); stopParentView();
THEMES = {nl:[],en:[]}; THEMES = {nl:[],en:[]};

View file

@ -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&&currentUser.role==="pupil"&&currentUser.schoolId==null,activate(){showSettingsDom("learning-level");return render()},keywords:()=>[text().title,text().year,"taal","rekenen","groep","niveau"]})
})();

View file

@ -98,6 +98,51 @@ function parentChildRow(c){
const actions = prH("div","am-actions"); const actions = prH("div","am-actions");
actions.appendChild(progBtn); 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 /* thuiskind zonder lopende aanmelding: aanmelden bij een school via een
doorzoekbare lijst (input + datalist gevuld uit de publieke /schools) */ doorzoekbare lijst (input + datalist gevuld uit de publieke /schools) */
const applyHolder = prH("div","am-add"); const applyHolder = prH("div","am-add");
@ -133,6 +178,7 @@ function parentChildRow(c){
catch(e){ prMsg(e.message); } catch(e){ prMsg(e.message); }
})); }));
tools.appendChild(actions); tools.appendChild(actions);
tools.appendChild(levelHolder);
tools.appendChild(applyHolder); tools.appendChild(applyHolder);
tools.appendChild(progHolder); tools.appendChild(progHolder);
row.appendChild(tools); row.appendChild(tools);

View file

@ -81,15 +81,16 @@ async function renderPupilWidgets(widgets, mode, preview=false){
api("/my/progress", { method:"POST", body: { widgetId, widgetType: def.id, ...p } }) api("/my/progress", { method:"POST", body: { widgetId, widgetType: def.id, ...p } })
.catch(e=>console.warn("voortgang loggen mislukt:", e.message)); .catch(e=>console.warn("voortgang loggen mislukt:", e.message));
}}; }};
/* alleen aanvullen wat het bord zelf nog niet expliciet heeft ingesteld - /* De vakinstelling van de leerkracht is per leerling en dus leidend boven
een leerkracht die zelf al een niveau op dit bord koos, blijft leidend. */ de algemene widgetstate van het bord. Zo krijgt dezelfde opdracht voor
twee leerlingen toch passende woorden en getalgrenzen. */
const lvl = levels[def.cat]; const lvl = levels[def.cat];
const state = w.state ? { ...w.state } : {}; const state = typeof applyLearningLevelToState === "function"
if(lvl){ ? applyLearningLevelToState(def, w.state, lvl, true)
if(state.level == null) state.level = lvl.level; : (w.state ? { ...w.state } : {});
if(def.cat === "rekenen" && lvl.yearGroup != null) state.yearGroup = lvl.yearGroup;
}
def.mount(body, Object.keys(state).length ? state : null, opts); def.mount(body, Object.keys(state).length ? state : null, opts);
if(typeof appendLearningProfileBadge === "function")
appendLearningProfileBadge(body, def.cat, state, def.id);
}); });
} }

View file

@ -4,8 +4,9 @@
==========================================================*/ ==========================================================*/
function mountBall(root, initState, opts){ function mountBall(root, initState, opts){
const onProgress = opts && opts.onProgress; const onProgress = opts && opts.onProgress;
const S = { card:0, snd:true, lives:6, guessed:new Set(), over:false, order:[], idx:0, stars:0 }; 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.card = initState.card ?? 0; S.snd = initState.snd !== false; } 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(); }; const play = f=>{ if(S.snd) f(); };
root.innerHTML = ` root.innerHTML = `
@ -29,7 +30,7 @@ function mountBall(root, initState, opts){
nextBtn = el(".bl-next"), kbdEl = el(".bl-kbd"), nextBtn = el(".bl-next"), kbdEl = el(".bl-kbd"),
scoreEl = el(".bl-score"), sndBtn = el(".lg-snd"); 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); } function buildSrc(){ fillWordSourceSel(srcSel, S.card); }
"abcdefghijklmnopqrstuvwxyz".split("").forEach(l=>{ "abcdefghijklmnopqrstuvwxyz".split("").forEach(l=>{
const b = document.createElement("button"); 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(" "); wordEl.textContent = word().split("").map(l=>(reveal || S.guessed.has(l)) ? l : "_").join(" ");
} }
function drawBalloons(){ function drawBalloons(){
balloonsEl.textContent = "🎈".repeat(S.lives) + "💥".repeat(6-S.lives); balloonsEl.textContent = "🎈".repeat(S.lives) + "💥".repeat(maxLives-S.lives);
} }
function newWord(){ function newWord(){
if(!S.order.length || S.idx >= S.order.length){ if(!S.order.length || S.idx >= S.order.length){
S.order = shuffle(list().map((_,i)=>i)); S.order = shuffle(list().map((_,i)=>i));
S.idx = 0; 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"; msgEl.textContent = ""; msgEl.className = "bl-msg";
nextBtn.classList.remove("show"); nextBtn.classList.remove("show");
kbdEl.querySelectorAll("button").forEach(b=>{ b.disabled = false; b.style.opacity = 1; }); 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("langchange", ()=>{ S.card=0; S.order=[]; labels(); newWord(); });
document.addEventListener("wordschange", ()=>{ buildSrc(); S.order=[]; newWord(); }); document.addEventListener("wordschange", ()=>{ buildSrc(); S.order=[]; newWord(); });
labels(); newWord(); labels(); newWord();
root.classList.toggle("managed-learning-level",S.managedLearningLevel);
return { getState: ()=>({card:S.card, snd:S.snd}) }; return { getState: ()=>({card:S.card, snd:S.snd}) };
} }

View file

@ -3,8 +3,9 @@
Flash words widget Flash words widget
==========================================================*/ ==========================================================*/
function mountFlits(root, initState){ function mountFlits(root, initState){
const S = { card:0, dur:2, idx:0, order:[], score:0, tries:0 }; const S = { level:1, yearGroup:null, managedLearningLevel:false, 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; } 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; let flashTimer = null, assessed=false;
root.innerHTML = ` root.innerHTML = `
@ -33,7 +34,7 @@ function mountFlits(root, initState){
const srcSel = el(".fl-src"), durSel = el(".fl-dur"), 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"); 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(){ function buildSrc(){
fillWordSourceSel(srcSel, S.card); fillWordSourceSel(srcSel, S.card);
durSel.innerHTML = ""; durSel.innerHTML = "";
@ -106,5 +107,6 @@ function mountFlits(root, initState){
document.addEventListener("langchange", ()=>{ S.card=0; labels(); newRound(); hide(); }); document.addEventListener("langchange", ()=>{ S.card=0; labels(); newRound(); hide(); });
document.addEventListener("wordschange", ()=>{ buildSrc(); newRound(); hide(); }); document.addEventListener("wordschange", ()=>{ buildSrc(); newRound(); hide(); });
labels(); newRound(); labels(); newRound();
root.classList.toggle("managed-learning-level",S.managedLearningLevel);
return { getState: ()=>({card:S.card, dur:S.dur, score:S.score, tries:S.tries}) }; return { getState: ()=>({card:S.card, dur:S.dur, score:S.score, tries:S.tries}) };
} }

View file

@ -19,8 +19,8 @@ function soundChunks(word, lang=LANG){
Chop & blend widget words in sounds, with read-aloud Chop & blend widget words in sounds, with read-aloud
==========================================================*/ ==========================================================*/
function mountHak(root, initState){ function mountHak(root, initState){
const S = { card:0, idx:0, order:[], chopped:false }; const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, idx:0, order:[], chopped:false };
if(initState) S.card = initState.card ?? 0; 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; const canSpeak = "speechSynthesis" in window;
root.innerHTML = ` root.innerHTML = `
@ -45,7 +45,7 @@ function mountHak(root, initState){
const srcSel = el(".hk-src"), wordEl = el(".hk-word"), const srcSel = el(".hk-src"), wordEl = el(".hk-word"),
picEl = el(".hk-pic"), cntEl = el(".hk-cnt"); 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 buildSrc(){ fillWordSourceSel(srcSel, S.card); }
function cur(){ return list()[S.order[S.idx]]; } function cur(){ return list()[S.order[S.idx]]; }
function newRound(){ S.order = shuffle(list().map((_,i)=>i)); S.idx = 0; } 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("langchange", ()=>{ S.card=0; labels(); newRound(); S.chopped=false; display(); });
document.addEventListener("wordschange", ()=>{ buildSrc(); newRound(); S.chopped=false; display(); }); document.addEventListener("wordschange", ()=>{ buildSrc(); newRound(); S.chopped=false; display(); });
labels(); newRound(); display(); labels(); newRound(); display();
root.classList.toggle("managed-learning-level",S.managedLearningLevel);
return { getState: ()=>({card:S.card}) }; return { getState: ()=>({card:S.card}) };
} }

View file

@ -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 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 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 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){ 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 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<d;n++){const c=document.createElement('button');c.type='button';c.style.setProperty('--fraction-hue',d*31);c.textContent=S.show?`1/${d}`:'';c.className='fraction-piece';c.classList.toggle('selected',S.activeD===d&&n<S.activeN);c.setAttribute('aria-label',`${n+1}/${d}`);c.onclick=()=>{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)}; 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<d;n++){const c=document.createElement('button');c.type='button';c.style.setProperty('--fraction-hue',d*31);c.textContent=S.show?`1/${d}`:'';c.className='fraction-piece';c.classList.toggle('selected',S.activeD===d&&n<S.activeN);c.setAttribute('aria-label',`${n+1}/${d}`);c.onclick=()=>{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 clockTurnDelta=(next,previous)=>((next-previous+540)%360)-180;
const clockHourCrossing=(next,previous)=>{const delta=clockTurnDelta(next,previous);return delta>0&&next<previous?1:delta<0&&next>previous?-1:0}; const clockHourCrossing=(next,previous)=>{const delta=clockTurnDelta(next,previous);return delta>0&&next<previous?1:delta<0&&next>previous?-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 clock24Angle=(angle,delta)=>(angle+delta+720)%720;
const clock24Hour=(angle,minute)=>((Math.round((angle-minute/2)/30)%24)+24)%24; const clock24Hour=(angle,minute)=>((Math.round((angle-minute/2)/30)%24)+24)%24;
function mountClock(root,init){ 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||{}); 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));S.amode=S.amode==='type'?'type':'blocks';S.input=String(S.input||'').replace(/\D/g,'').slice(0,4); 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))); 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}; 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 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 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}; 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==='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)}} 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'&&currentUser&&currentUser.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)}; 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'&&currentUser&&currentUser.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 PLACE_COLUMNS=['HD','TD','D','H','T','E'];
const placeLevelDigits=level=>level===1?2:level===2?3:6; const placeLevelDigits=level=>level===1?2:level===2?3:6;
function mountPlacevalue(root,init){ 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||{}); 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));S.game=S.game==='build'?'build':'explore';if(!Array.isArray(S.answerDigits))S.answerDigits=null; 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'); 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; 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()}); 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); 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()}; 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(); 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){ 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); 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))); 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; 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'}; 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==='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())} 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'&&currentUser&&currentUser.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)}; 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'&&currentUser&&currentUser.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){ 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 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 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 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){ 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'); 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');

View file

@ -2,9 +2,11 @@
function mountLetterGame(root, initState, opts){ function mountLetterGame(root, initState, opts){
const readonly = !!(opts && opts.readonly); const readonly = !!(opts && opts.readonly);
const onProgress = opts && opts.onProgress; 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){ if(initState){
S.level = initState.level||1; S.level = initState.level||1;
S.yearGroup = initState.yearGroup||null;
S.managedLearningLevel = !!initState.managedLearningLevel;
S.card = initState.card ?? "g"; S.card = initState.card ?? "g";
S.count = initState.count||6; S.count = initState.count||6;
S.snd = initState.snd !== false; S.snd = initState.snd !== false;
@ -167,6 +169,7 @@ function mountLetterGame(root, initState, opts){
root.querySelectorAll(".lg-levels button").forEach(b=>{ root.querySelectorAll(".lg-levels button").forEach(b=>{
b.addEventListener("click", ()=>{ S.level = +b.dataset.l; newRound(); render(); }); b.addEventListener("click", ()=>{ S.level = +b.dataset.l; newRound(); render(); });
b.disabled = S.managedLearningLevel;
}); });
nextBtn.addEventListener("click", ()=>{ nextBtn.addEventListener("click", ()=>{
S.wordIdx++; S.wordIdx++;
@ -182,7 +185,7 @@ function mountLetterGame(root, initState, opts){
function fullList(){ function fullList(){
S.card = normWordSource(S.card); S.card = normWordSource(S.card);
return wordSourceList(S.card); return languageWordsForYear(wordSourceList(S.card),S.yearGroup,S.level);
} }
function newRound(){ function newRound(){
const list = fullList(); const list = fullList();
@ -751,13 +754,16 @@ function mountLetterGame(root, initState, opts){
} }
buildNumSel(); 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(); updateSndBtn();
newRound(); newRound();
render(); render();
document.addEventListener("langchange", updateSndBtn); document.addEventListener("langchange", updateSndBtn);
return { return {
getState: ()=>({ level:S.level, card:S.card, count:S.count, snd:S.snd, amode:S.amode }), 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); setTimeout(()=>c.remove(), 3200);
} }
} }

View file

@ -5,13 +5,14 @@
function mountMath(root, initState, mode, opts){ function mountMath(root, initState, mode, opts){
const ROUND = 10; const ROUND = 10;
const onProgress = opts && opts.onProgress; 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){ if(initState){
S.level = initState.level||1; S.level = initState.level||1;
/* yearGroup komt van klassenmanagement (pupil.js), niet van het bord zelf /* yearGroup komt van klassenmanagement (pupil.js), niet van het bord zelf
- bewust niet in getState() opgenomen, dit is een per-leerling waarde, - bewust niet in getState() opgenomen, dit is een per-leerling waarde,
geen door de leerkracht op het bord ingestelde inhoud. */ geen door de leerkracht op het bord ingestelde inhoud. */
S.yearGroup = initState.yearGroup || null; S.yearGroup = initState.yearGroup || null;
S.managedLearningLevel = !!initState.managedLearningLevel;
S.snd = initState.snd!==false; S.snd = initState.snd!==false;
S.amode = initState.amode||"blocks"; S.amode = initState.amode||"blocks";
} }
@ -171,28 +172,29 @@ function mountMath(root, initState, mode, opts){
klassenmanagement ingesteld voor deze leerling/dit bord) blijft het klassenmanagement ingesteld voor deze leerling/dit bord) blijft het
oude gedrag exact hetzelfde. */ oude gedrag exact hetzelfde. */
const slo = mathRangeFor(S.yearGroup, L); const slo = mathRangeFor(S.yearGroup, L);
const rules = mathTaskRules(S.yearGroup, L);
let a, b, ans, txt; let a, b, ans, txt;
if(mode==="add"){ 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; a = rnd(max+1); b = rnd(max-a+1); ans = a+b;
txt = `${a} + ${b} =`; txt = `${a} + ${b} =`;
}else if(mode==="sub"){ }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; a = rnd(max+1); b = rnd(a+1); ans = a-b;
txt = `${a} ${b} =`; txt = `${a} ${b} =`;
}else if(mode==="mul"){ }else if(mode==="mul"){
const tmax = slo ? slo.timesTable : (L===1 ? 5 : L===2 ? 10 : 12); const tmax = rules ? rules.timesTable : (slo ? slo.timesTable : (L===1 ? 5 : L===2 ? 10 : 12));
a = 1+rnd(tmax); b = 1+rnd(L===3 ? 12 : 10); ans = a*b; a = 1+rnd(tmax); b = 1+rnd(rules ? rules.factorMax : (L===3 ? 12 : 10)); ans = a*b;
txt = `${a} × ${b} =`; txt = `${a} × ${b} =`;
}else if(mode==="div"){ }else if(mode==="div"){
const tmax = slo ? slo.timesTable : (L===1 ? 5 : L===2 ? 10 : 12); const tmax = rules ? rules.timesTable : (slo ? slo.timesTable : (L===1 ? 5 : L===2 ? 10 : 12));
const t = 1+rnd(tmax), q = 1+rnd(L===3 ? 12 : 10); const t = 1+rnd(tmax), q = 1+rnd(rules ? rules.factorMax : (L===3 ? 12 : 10));
a = t*q; ans = q; a = t*q; ans = q;
txt = `${a} ${S.lang==="nl" || LANG==="nl" ? ":" : "÷"} ${t} =`; txt = `${a} ${S.lang==="nl" || LANG==="nl" ? ":" : "÷"} ${t} =`;
}else{ /* pct */ }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 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)); const base = step*(1+rnd(12));
ans = p*base/100; ans = p*base/100;
txt = `${p}% ${T("mOf")} ${base} =`; txt = `${p}% ${T("mOf")} ${base} =`;
@ -270,7 +272,7 @@ function mountMath(root, initState, mode, opts){
if(doneEl.classList.contains("show")) return; if(doneEl.classList.contains("show")) return;
if(k==="⌫"){ if(!S.busy){ S.input = S.input.slice(0,-1); aEl.textContent = S.input; } } if(k==="⌫"){ if(!S.busy){ S.input = S.input.slice(0,-1); aEl.textContent = S.input; } }
else if(k==="✓") check(); 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); padEl.appendChild(b);
}); });
@ -281,6 +283,7 @@ function mountMath(root, initState, mode, opts){
newQuestion(); newQuestion();
}); });
root.querySelectorAll(".lg-levels button").forEach(b=>{ root.querySelectorAll(".lg-levels button").forEach(b=>{
b.disabled = S.managedLearningLevel;
b.addEventListener("click", ()=>{ b.addEventListener("click", ()=>{
S.level = +b.dataset.l; S.level = +b.dataset.l;
S.done = 0; S.good = 0; S.done = 0; S.good = 0;
@ -316,9 +319,9 @@ function mountMath(root, initState, mode, opts){
labels(); labels();
newQuestion(); newQuestion();
root.classList.toggle("managed-learning-level",S.managedLearningLevel);
return { return {
getState: ()=>({ level:S.level, snd:S.snd, amode:S.amode }), 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(); } }
}; };
} }

View file

@ -1,11 +1,13 @@
/* widget: interactieve getallenlijn */ /* widget: interactieve getallenlijn */
function mountNumberline(root, initState){ 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=` root.innerHTML=`
<div class="nlw"> <div class="nlw">
<div class="nlw-settings"> <div class="nlw-settings">
<label><span class="nlw-from-label"></span><input class="nlw-min" type="number" min="-100" max="99"></label> <label><span class="nlw-from-label"></span><input class="nlw-min" type="number" min="-1000000" max="999999"></label>
<label><span class="nlw-to-label"></span><input class="nlw-max" type="number" min="-99" max="200"></label> <label><span class="nlw-to-label"></span><input class="nlw-max" type="number" min="-999999" max="1000000"></label>
<label><span class="nlw-step-label"></span><select class="nlw-step"><option>1</option><option>2</option><option>5</option><option>10</option></select></label> <label><span class="nlw-step-label"></span><select class="nlw-step"><option>1</option><option>2</option><option>5</option><option>10</option></select></label>
<button class="tbtn ghost nlw-new" type="button"></button> <button class="tbtn ghost nlw-new" type="button"></button>
</div> </div>
@ -21,9 +23,11 @@ function mountNumberline(root, initState){
</div>`; </div>`;
const q=s=>root.querySelector(s), minEl=q('.nlw-min'), maxEl=q('.nlw-max'), stepEl=q('.nlw-step'), 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'); 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.value=S.min; maxEl.value=S.max; stepEl.value=S.step;
[minEl,maxEl,stepEl].forEach(el=>el.disabled=!!S.managedLearningLevel);
function normalize(){ 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) max=min+1; const step=+stepEl.value||1;
if((max-min)/step>60) max=min+60*step; if((max-min)/step>60) max=min+60*step;
S.min=min; S.max=max; S.step=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-step-label').textContent=T('numberlineStep'); q('.nlw-new').textContent=T('numberlineNew');
q('.nlw-check').textContent=T('numberlineCheck'); render(); q('.nlw-check').textContent=T('numberlineCheck'); render();
} }
root.classList.toggle("managed-learning-level",!!S.managedLearningLevel);
document.addEventListener('langchange',labels); labels(); normalize(); 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})};
} }

View file

@ -1,13 +1,27 @@
/* widget: zinsbouwer met aanpasbare woordbanken */ /* widget: zinsbouwer met aanpasbare woordbanken */
function mountSentence(root, initState){ 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'], 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'], 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'], 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'], 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=` root.innerHTML=`
<div class="sb"> <div class="sb">
<div class="sb-sentence" aria-live="polite"></div> <div class="sb-sentence" aria-live="polite"></div>
@ -53,6 +67,7 @@ function mountSentence(root, initState){
[...kind.options].forEach(o=>o.textContent=T('sentence_'+o.value)); word.placeholder=T('sentenceAdd'); [...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(); 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(); document.addEventListener('langchange',labels);labels();
return {getState:()=>({banks:S.banks,sentence:S.sentence})}; return {getState:()=>({banks:S.banks,sentence:S.sentence})};
} }

View file

@ -126,6 +126,25 @@
tule.appendChild(sourceLinks); tule.appendChild(sourceLinks);
root.appendChild(tule); 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"))); if(LANG==="en")root.appendChild(node("p","wiki-language-note",T("wikiLanguageNote")));
const searchWrap=node("div","wiki-search"); const searchWrap=node("div","wiki-search");
@ -187,7 +206,7 @@
visible:()=>!!currentUser&&can("curriculum.view"), visible:()=>!!currentUser&&can("curriculum.view"),
activate(payload){showSettingsDom("curriculum");renderCurriculumWiki(payload);}, activate(payload){showSettingsDom("curriculum");renderCurriculumWiki(payload);},
keywords:()=>[ 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]) ...CURRICULUM_PO_2026.areas.flatMap(area=>[area.title,area.description])
], ],
searchItems:needle=>{ searchItems:needle=>{

View file

@ -932,7 +932,7 @@ export default async function api(app) {
const yg = Number(yearGroup); const yg = Number(yearGroup);
if (!Number.isInteger(yg) || yg < 1 || yg > 8) return fail(reply, 400, 'ongeldige groep'); if (!Number.isInteger(yg) || yg < 1 || yg > 8) return fail(reply, 400, 'ongeldige groep');
const lvl = Number(level); 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 }; return { subject, yearGroup: yg, level: lvl };
}; };
@ -1034,6 +1034,24 @@ export default async function api(app) {
return row ? describePupilLevel(row) : { yearGroup: null, level: 1 }; 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) --------------------------------------------- // ---- voortgang (leerling-omgeving) ---------------------------------------------
// Leerlingen loggen elk afgerond "moment" in een widget (ronde sommen, voltooid // Leerlingen loggen elk afgerond "moment" in een widget (ronde sommen, voltooid
// woord, geknapte ballon). teacher_id/board_id komen server-side uit de effectieve // 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. // Kind aanmaken: pupil zonder school (thuiskind), meteen gekoppeld.
// Het gegenereerde wachtwoord wordt eenmalig teruggegeven. // Het gegenereerde wachtwoord wordt eenmalig teruggegeven.
app.post('/parent/children', async (req, reply) => { 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'); if (e.code === '23505') return fail(reply, 409, 'gebruikersnaam bestaat al binnen deze school');
throw e; 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 // het kind hoort nu bij deze school: ook aanmeldingen bij andere
// scholen zijn daarmee vervallen // scholen zijn daarmee vervallen
await pool.query('DELETE FROM school_link_requests WHERE pupil_id = $1', [r.pupil_id]); await pool.query('DELETE FROM school_link_requests WHERE pupil_id = $1', [r.pupil_id]);

View file

@ -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<late.length);
assert.ok(early.every(([word])=>!['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')<html.indexOf('js/learning-progressions.js'));
assert.ok(html.indexOf('js/learning-progressions.js')<html.indexOf('js/widgets/letters.js'));
assert.match(board,/learningStateForWidget/);
assert.match(pupil,/applyLearningLevelToState/);
assert.match(pupil,/appendLearningProfileBadge/);
assert.match(progressions,/managedLearningLevel/);
for(const source of [letters,flits,hak,ball])assert.match(source,/languageWordsForYear/);
assert.match(math,/mathTaskRules/);
assert.match(numberline,/mathTaskRules/);
for(const mount of ['mountFractions','mountClock','mountPlacevalue','mountMoney','mountWordtypes']){
assert.ok(learning.includes('function '+mount),mount);
}
});

View file

@ -85,6 +85,43 @@ test('ouder ziet gekoppelde kinderen met school en aanmeldstatus', async () => {
await app.close(); 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 () => { test('parent-routes zijn niet voor andere rollen', async () => {
const { app } = await makeApp({ user: admin }); const { app } = await makeApp({ user: admin });
const res = await app.inject({ method: 'GET', url: '/api/parent/children', cookies }); 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); assert.equal(res.statusCode, 200, res.body);
const upd = calls.find((c) => c.sql.includes('UPDATE users SET school_id')); const upd = calls.find((c) => c.sql.includes('UPDATE users SET school_id'));
assert.deepEqual(upd.params, [2, 5, null, 30]); 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'))); assert.ok(calls.some((c) => c.sql.startsWith('DELETE FROM school_link_requests WHERE pupil_id')));
await app.close(); await app.close();
}); });

View file

@ -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 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 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 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 () => { test('klassenmanagement: klas-standaard opslaan doet de juiste upsert-query', async () => {
const { app, calls } = await makeApp(admin); 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); 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 () => { test('klassenmanagement: GET class levert klas-standaard én leerling-overrides in één keer', async () => {
const { app } = await makeApp(admin, { const { app } = await makeApp(admin, {
classDefault: { year_group: 4, level: 2 }, 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); 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/ /* Echte (niet regex-)test van de SLO-getalgrenzen: data.js's SLO_MATH_RANGES/
mathRangeFor zijn pure functies zonder DOM-afhankelijkheid, dus rechtstreeks mathRangeFor zijn pure functies zonder DOM-afhankelijkheid, dus rechtstreeks
uitvoerbaar in een geïsoleerde VM-context - zelfde techniek als elders in 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); 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'); const source = await readFile('public/js/widgets/math.js', 'utf8');
assert.match(source, /const slo = mathRangeFor\(S\.yearGroup, L\)/); 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 rules = mathTaskRules\(S\.yearGroup, L\)/);
assert.match(source, /const tmax = slo \? slo\.timesTable : \(L===1 \? 5 : L===2 \? 10 : 12\)/); 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')); assert.ok(source.includes('S.yearGroup = initState.yearGroup || null'));
}); });