feat: klassenmanagement - niveaugroep + niveau per leerling per vakgebied (v0.4.34-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m9s

- Grootste openstaande roadmap-punt. Nieuwe tabel pupil_levels
  (db/019_pupil_levels.sql, zelfde class_id/pupil_id-vorm als
  assignments) legt per vakgebied (taal/rekenen/world) een niveaugroep
  vast (groep 1-8, gebaseerd op de leerjaren van de basisschool waaraan
  de leerdoelen gekoppeld zijn) + niveau (1-3, fijnmazige verfijning
  BINNEN die groep). Klas-standaard met individuele leerling-overrides,
  net zo laagdrempelig instelbaar als de standaard - het speciaal-
  onderwijs-geval waar dat vanaf het begin de norm is.
- Server: GET/PUT/DELETE /levels/class/:id en /levels/pupil/:id
  (src/api.js) hergebruiken exact het assignments-patroon (upsert via
  partial-unique-index, pupil-eerst-dan-klas-resolutie), plus
  GET /my/level voor de leerling-kant. Zelfde permissie
  (assignments.manage) en sameSchool/teacherOnly/pupilAccessible-
  guards als de bestaande toewijzingsroutes.
- Nieuwe admin-tab "Klassenmanagement": klas + vakgebied kiezen, een
  klas-standaard instellen, en per leerling de effectieve groep/niveau
  zien (overerft van de klas tenzij een eigen afwijking is ingesteld)
  met bewerk- en terug-naar-standaard-knoppen.
- ECHTE inhoudskoppeling, niet alleen een standaardwaarde: rekenen is
  het eerste vakgebied waarvan de SLO/TULE-leerlijn direct in
  getalgrenzen te vertalen is (groep 3 t/m 20, groep 4 t/m 100, groep 5
  t/m 1000, groep 6 t/m 10.000). Nieuwe SLO_MATH_RANGES/mathRangeFor()
  in data.js; math.js's gen() gebruikt dit voor de daadwerkelijke
  getallen zodra een leerling een groep heeft (S.yearGroup), met het
  bestaande niveau 1-3 als verfijning daarbinnen. Zonder groep blijft
  het oude vaste 10/20/100-gedrag exact bestaan.
- pupil.js geeft groep/niveau door aan taal/rekenen-widgets bij het
  mounten: niveau is een standaardwaarde (een al opgeslagen bord-niveau
  blijft leidend), groep wordt voor rekenen-widgets altijd meegegeven
  (er is geen "oud" groep-concept om te respecteren). Terzijde ook de
  stille .catch() op de nieuwe async renderPupilWidgets() opgelost.
- Taal/wereldoriëntatie krijgen deze ronde nog geen eigen
  inhoudskoppeling (vraagt een eigen contentbron per groep, nog niet
  uitgezocht) - eerlijk benoemd als vervolgstap, geen loze belofte.
- Tests: test/pupil-levels.test.js (10 tests) dekt de server-routes
  (mocked-pool-patroon) én - via dezelfde vm-techniek als elders in
  test/widgets.test.js voor MONEY_LEVELS - een ECHTE (niet regex-)test
  van mathRangeFor die bevestigt dat elke groep de juiste getalgrens
  oplevert en niveau nooit boven de groep-grens uitkomt. Twee bugs
  gevonden en gefixt tijdens het schrijven van deze tests: null-yearGroup
  werd per ongeluk als groep 1 behandeld i.p.v. "geen groep", en de
  testrol had geen klas-eigenaarschap waardoor 403 in plaats van 200
  terugkwam. Volledige testsuite: 128/128.
- Live geverifieerd in de sandbox: klas-standaard + leerling-override
  instellen in de nieuwe admin-tab, en drie scenario's in de
  leerling-omgeving die bevestigen dat de daadwerkelijk gegenereerde
  sommen binnen de juiste groep-grens blijven (geen klassenmanagement →
  ongewijzigd oud gedrag; groep zonder eigen bord-niveau → volledige
  groep-grens; groep mét een al opgeslagen bord-niveau → dat niveau
  blijft leidend, geschaald naar de juiste groep-grens).
- public/js/data.js bevatte al niet-gerelateerde, nog niet gecommitte
  wijzigingen van vóór deze sessie (extra taalthema's/woorden) - alleen
  de SLO_MATH_RANGES/mathRangeFor-hunk is hier meegenomen (git add -p),
  de rest blijft bewust ongemoeid staan, net als test/language-library.test.js.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EPxzBVRXZZnBPvSAaPAbJ
This commit is contained in:
Ramon 2026-07-19 15:31:26 +02:00
parent 36695a06f6
commit e7bb76b76d
9 changed files with 527 additions and 11 deletions

View file

@ -1 +1 @@
0.4.33-beta
0.4.34-beta

24
db/019_pupil_levels.sql Normal file
View file

@ -0,0 +1,24 @@
-- Klassenmanagement: per vakgebied (taal/rekenen/world) een niveaugroep
-- (groep 1-8, gebaseerd op de leerjaren van de basisschool, waaraan de
-- leerdoelen gekoppeld zijn) en een niveau (1-3, fijnmazige verfijning
-- BINNEN die groep - niet aan 3 geklemd in het datamodel zelf, widgets
-- klemmen voorlopig zelf nog wel). Zelfde vorm als assignments: precies
-- één van class_id/pupil_id gezet (klas-standaard, of een individuele
-- afwijking - in het speciaal onderwijs vaak vanaf het begin de norm),
-- nu met vakgebied als extra dimensie, dus één rij per (klas|leerling,
-- vakgebied) i.p.v. één rij per klas|leerling.
CREATE TABLE IF NOT EXISTS pupil_levels (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
class_id BIGINT REFERENCES classes(id) ON DELETE CASCADE,
pupil_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
subject TEXT NOT NULL,
year_group INT NOT NULL,
level INT NOT NULL DEFAULT 1,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (num_nonnulls(class_id, pupil_id) = 1),
CHECK (subject IN ('taal','rekenen','world')),
CHECK (year_group BETWEEN 1 AND 8),
CHECK (level >= 1)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_pupil_levels_class ON pupil_levels(class_id, subject) WHERE class_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_pupil_levels_pupil ON pupil_levels(pupil_id, subject) WHERE pupil_id IS NOT NULL;

View file

@ -21,6 +21,10 @@
let resetResults = null;
/* actief tabblad, blijft behouden over her-renders heen (na elke actie) */
let adminTab = "school";
/* Klassenmanagement: welke klas/vakgebied nu getoond wordt, blijft behouden
over her-renders heen zoals adminTab hierboven. */
let kmClass = null, kmSubject = "rekenen";
const KM_SUBJECTS = ["taal", "rekenen", "world"];
/* navigatie binnen "School": welk niveau van de boom je ziet.
{type:"overview"} - tegels (scholen op "alle scholen", anders
@ -229,6 +233,10 @@
{ tab:"voortgang", icon:"📈", group:"school", order:13, labelKey:"amProgress", subKey:"stVoortgangSub",
show:()=>can("progress.view"),
keywords:()=>["voortgang","progress","score","resultaten","results","tijdlijn","timeline"] },
{ tab:"klassenmanagement", icon:"🎓", group:"school", order:14, labelKey:"amLevels", subKey:"amLevelsSub",
show:()=>can("assignments.manage"),
keywords:()=>[T("amLevelYearGroup"), T("amLevelLevel"), "niveau","level","groep","niveaugroep",
"leerjaar","year group","differentiatie"] },
{ tab:"opslag", icon:"🖼", group:"system", order:32, labelKey:"stOpslag", subKey:"stOpslagSub",
show:()=>currentUser.role==="super",
keywords:()=>["opslag","storage","afbeeldingen","images","quota"] },
@ -1080,6 +1088,156 @@
return box;
}
function yearGroupSel(current){
const sel = h("select","am-sel");
for(let g=1; g<=8; g++) sel.appendChild(new Option(T("amYearGroupN").replace("{n}", g), g));
sel.value = current ?? 4;
return sel;
}
function levelSel(current){
const sel = h("select","am-sel");
[1,2,3].forEach(n=>sel.appendChild(new Option(T("lvl"+n), n)));
sel.value = current ?? 1;
return sel;
}
function describeLevel(effective, isOverride){
if(!effective) return T("amLevelNone");
const base = `${T("amYearGroupN").replace("{n}", effective.yearGroup)} · ${T("lvl"+effective.level)}`;
return isOverride ? base : `${base} (${T("amLevelInherited")})`;
}
/* klas-standaard: geldt voor iedereen in de klas tenzij een leerling een
eigen afwijking heeft (zie renderPupilLevelRow hieronder). */
function renderClassDefaultCard(classId, subject, current, onSaved){
const box = h("div","am-card");
box.appendChild(h("div","am-card-title", T("amLevelClassDefault")));
const row = h("div","am-inline");
const ygSel = yearGroupSel(current && current.yearGroup);
const lvlSel = levelSel(current && current.level);
row.append(field("amLevelYearGroup", ygSel), field("amLevelLevel", lvlSel));
const saveBtn = h("button","am-btn", T("save"));
saveBtn.type = "button";
saveBtn.addEventListener("click", async ()=>{
try{
await api(`/levels/class/${classId}`, {method:"PUT", body:{subject, yearGroup:+ygSel.value, level:+lvlSel.value}});
msg(T("amLevelSaved"));
await onSaved();
}catch(e){ msg(e.message); }
});
row.appendChild(saveBtn);
box.appendChild(row);
return box;
}
/* één leerling: toont de EFFECTIEVE groep/niveau (eigen afwijking, anders de
klas-standaard, gedempt met "erft van klas"). Instellen van een eigen
afwijking is even laagdrempelig als de klas-standaard - het
speciaal-onderwijs-geval waar dat vanaf het begin de norm is, mag geen
verstopt uitzonderingspad zijn. */
function renderPupilLevelRow(pupil, subject, override, classDefault, onSaved){
const effective = override || classDefault;
const wrap = h("div");
const row = h("div","am-row am-person");
const info = h("div","am-person-info");
info.appendChild(h("span","am-name", pupil.displayName));
info.appendChild(h("span","am-role", describeLevel(effective, !!override)));
row.appendChild(info);
const editBox = h("div","am-inline");
editBox.style.display = "none";
const ygSel = yearGroupSel(effective && effective.yearGroup);
const lvlSel = levelSel(effective && effective.level);
editBox.append(field("amLevelYearGroup", ygSel), field("amLevelLevel", lvlSel));
const saveBtn = h("button","am-btn", T("save"));
saveBtn.type = "button";
saveBtn.addEventListener("click", async ()=>{
try{
await api(`/levels/pupil/${pupil.id}`, {method:"PUT", body:{subject, yearGroup:+ygSel.value, level:+lvlSel.value}});
msg(T("amLevelSaved"));
await onSaved();
}catch(e){ msg(e.message); }
});
editBox.appendChild(saveBtn);
const tools = h("div","am-tools");
const editBtn = h("button","am-btn", T("amEdit"));
editBtn.type = "button";
editBtn.addEventListener("click", ()=>{
editBox.style.display = editBox.style.display==="none" ? "flex" : "none";
});
tools.appendChild(editBtn);
if(override){
const resetBtn = h("button","am-btn", T("amLevelReset"));
resetBtn.type = "button";
resetBtn.addEventListener("click", async ()=>{
try{
await api(`/levels/pupil/${pupil.id}?subject=${subject}`, {method:"DELETE"});
msg(T("amLevelSaved"));
await onSaved();
}catch(e){ msg(e.message); }
});
tools.appendChild(resetBtn);
}
row.appendChild(tools);
wrap.append(row, editBox);
return wrap;
}
/* klassenmanagement: per vakgebied een niveaugroep (leerjaar 1-8, waaraan
de leerdoelen gekoppeld zijn) + niveau (1-3) instellen, met een
klas-standaard en individuele afwijkingen - zie roadmap-memo. */
function renderKlassenmanagementPanel(panel){
const schoolChosen = currentUser.role!=="super" || !!selSchool;
if(!schoolChosen){
panel.appendChild(h("div","guestnote", T("amPickSchoolHint")));
return;
}
panel.appendChild(h("div","am-group", T("amLevels")));
const clsSel = classSel(kmClass ?? "");
panel.appendChild(clsSel);
const subjectRow = h("div","am-inline");
panel.appendChild(subjectRow);
const bodyHolder = h("div");
panel.appendChild(bodyHolder);
async function loadBody(){
bodyHolder.innerHTML = "";
if(!kmClass) return;
bodyHolder.appendChild(h("div","guestnote", "…"));
let data;
try{ data = await api(`/levels/class/${kmClass}?subject=${kmSubject}`); }
catch(e){ bodyHolder.innerHTML = ""; bodyHolder.appendChild(h("div","am-msg", e.message)); return; }
bodyHolder.innerHTML = "";
bodyHolder.appendChild(renderClassDefaultCard(kmClass, kmSubject, data.classDefault, loadBody));
bodyHolder.appendChild(h("div","am-group", T("amLevelPupils")));
const list = h("div","am-list");
const overrideMap = new Map(data.pupilOverrides.map(o=>[o.pupilId, o]));
const pupilsInClass = USERS.filter(u=>u.role==="pupil" && String(u.classId)===String(kmClass));
if(!pupilsInClass.length) list.appendChild(h("div","guestnote", T("amNoClass")));
pupilsInClass.forEach(u=>{
list.appendChild(renderPupilLevelRow(u, kmSubject, overrideMap.get(u.id), data.classDefault, loadBody));
});
bodyHolder.appendChild(list);
}
KM_SUBJECTS.forEach(s=>{
const b = h("button","am-btn", T("amSubject_"+s));
b.type = "button";
b.classList.toggle("active", s===kmSubject);
b.addEventListener("click", ()=>{
kmSubject = s;
subjectRow.querySelectorAll("button").forEach(x=>x.classList.remove("active"));
b.classList.add("active");
loadBody();
});
subjectRow.appendChild(b);
});
clsSel.addEventListener("change", ()=>{ kmClass = clsSel.value || null; loadBody(); });
if(kmClass) loadBody();
}
/* niveau 0: "alle scholen" - alleen systeemmanager. Tegel per school +
Systeemmanagers/Ouders/Zonder school + "+ School". */
function renderRootOverview(panel){
@ -1392,7 +1550,7 @@
const tabDefs = adminTabDefs();
if(!tabDefs.some(([t])=>t===adminTab)) adminTab = tabDefs[0][0];
const panels = { school: renderSchoolNavigator, toewijzingen: renderToewijzingenPanel, voortgang: renderVoortgangPanel, opslag: renderImageStoragePanel };
const panels = { school: renderSchoolNavigator, toewijzingen: renderToewijzingenPanel, voortgang: renderVoortgangPanel, klassenmanagement: renderKlassenmanagementPanel, opslag: renderImageStoragePanel };
tabDefs.forEach(([t])=>{
const panel = h("div","am-tabpanel");
panel.dataset.panel = t;

View file

@ -2,7 +2,7 @@
"use strict";
/* version shown until /api/version resolves (or if the fetch fails, e.g. offline).
Kept in sync by hand with the VERSION file at the repo root on every release. */
const VERSION = "0.4.33-beta";
const VERSION = "0.4.34-beta";
(function(){
const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION;
@ -354,6 +354,12 @@ const I18N = {
amExportColCorrect:"Goed", amExportColStars:"Sterren", amExportColDate:"Datum",
amExportColUsername:"Gebruikersnaam",
amExportDone:"Export gedownload ✓",
amLevels:"Klassenmanagement", amLevelsSub:"Stel per vakgebied een niveaugroep en niveau in.",
amLevelYearGroup:"Groep", amLevelLevel:"Niveau", amYearGroupN:"Groep {n}",
amLevelClassDefault:"Klas-standaard", amLevelPupils:"Leerlingen",
amLevelInherited:"erft van klas", amLevelNone:"nog niet ingesteld",
amLevelReset:"terug naar klas-standaard", amLevelSaved:"Opgeslagen ✓",
amSubject_taal:"Taal", amSubject_rekenen:"Rekenen", amSubject_world:"Wereldoriëntatie",
amConfirmDel:"Weet je zeker dat je deze gebruiker wilt verwijderen?",
amCopy:"kopieer", amCopyAll:"kopieer alles",
amResetClass:"reset wachtwoorden klas", amResetSchool:"reset alle leerlingwachtwoorden",
@ -693,6 +699,12 @@ const I18N = {
amExportColCorrect:"Correct", amExportColStars:"Stars", amExportColDate:"Date",
amExportColUsername:"Username",
amExportDone:"Export downloaded ✓",
amLevels:"Class management", amLevelsSub:"Set a year group and level per subject.",
amLevelYearGroup:"Year group", amLevelLevel:"Level", amYearGroupN:"Year {n}",
amLevelClassDefault:"Class default", amLevelPupils:"Pupils",
amLevelInherited:"inherited from class", amLevelNone:"not set yet",
amLevelReset:"reset to class default", amLevelSaved:"Saved ✓",
amSubject_taal:"Language", amSubject_rekenen:"Maths", amSubject_world:"World orientation",
amConfirmDel:"Are you sure you want to delete this user?",
amCopy:"copy", amCopyAll:"copy all",
amResetClass:"reset class passwords", amResetSchool:"reset all pupil passwords",

View file

@ -16,6 +16,37 @@ const DIGIT_DOTCODE = {
"0":[], "1":[2], "2":[2,5], "3":[1,2,3], "4":[1,3,4,6],
"5":[1,2,3,4,5], "6":[1,2,3,4,5,6], "7":[1,2,4,5], "8":[1,2,3,4,6], "9":[1,4,6]
};
/* Klassenmanagement (v0.4.34-beta e.v.): rekenen is het eerste vakgebied waarvan
de leerlijn direct in getalgrenzen te vertalen is (SLO/TULE: groep 3
automatiseert optellen/aftrekken t/m 20, groep 4 t/m 100, groep 5 t/m 1000,
groep 6 t/m 10.000 met de tafels verder uitgebouwd, groep 7-8 bouwen daarop
voort). math.js gebruikt dit als de daadwerkelijke inhoud zodra een leerling
een groep heeft (zie mathRangeFor hieronder) - zonder groep blijft het oude,
vaste 10/20/100-gedrag exact bestaan. */
const SLO_MATH_RANGES = {
1:{addSub:10, timesTable:5},
2:{addSub:10, timesTable:5},
3:{addSub:20, timesTable:5},
4:{addSub:100, timesTable:10},
5:{addSub:1000, timesTable:10},
6:{addSub:10000,timesTable:12},
7:{addSub:10000,timesTable:12},
8:{addSub:10000,timesTable:12},
};
/* niveau (1-3) is de verfijning BINNEN een groep - een breukdeel van de
groep-grens, nooit onder de kleinste bestaande standaardwaarde (10) of
onder tafel 3. Levert bij ontbrekend yearGroup expres geen waarde (math.js
valt dan terug op zijn eigen oude vaste 10/20/100-logica). */
function mathRangeFor(yearGroup, level){
if(yearGroup == null) return null;
const g = SLO_MATH_RANGES[Math.max(1, Math.min(8, +yearGroup || 1))];
if(!g) return null;
const frac = level===1 ? .25 : level===2 ? .6 : 1;
return {
addSub: Math.max(10, Math.round(g.addSub*frac/5)*5),
timesTable: Math.max(3, Math.round(g.timesTable*frac)),
};
}
const VOWELS = "aeiou";
function dotGrid(letter){
const code = (Object.prototype.hasOwnProperty.call(DIGIT_DOTCODE, letter) ? DIGIT_DOTCODE[letter] : DOTCODE[letter]) || [];

View file

@ -18,7 +18,7 @@ function pupilAllowedDefs(){
return REGISTRY.filter(d => PUPIL_WIDGET_CATS.includes(d.cat));
}
function renderPupilWidgets(widgets, mode){
async function renderPupilWidgets(widgets, mode){
pupilView.innerHTML = "";
const defs = pupilAllowedDefs();
const watch = mode === "kijken";
@ -32,6 +32,16 @@ function renderPupilWidgets(widgets, mode){
pupilView.appendChild(hint);
return;
}
/* klassenmanagement: groep/niveau per vakgebied ophalen vóór het mounten,
zodat een widget zonder eigen opgeslagen niveau met de door de
groepsleiding ingestelde waarde start i.p.v. altijd stilzwijgend niveau 1
- één keer per vakgebied dat hier daadwerkelijk voorkomt, niet per widget. */
const subjects = [...new Set(usable.map(x => x.def.cat))];
const levels = {};
await Promise.all(subjects.map(async subject=>{
try{ levels[subject] = await api("/my/level?subject="+subject); }
catch(e){ levels[subject] = null; }
}));
usable.forEach(({ w, def }, idx)=>{
const card = document.createElement("div");
card.className = "widget pupil-card" + (watch ? " watch" : "");
@ -71,7 +81,15 @@ function renderPupilWidgets(widgets, mode){
api("/my/progress", { method:"POST", body: { widgetId, widgetType: def.id, ...p } })
.catch(e=>console.warn("voortgang loggen mislukt:", e.message));
}};
def.mount(body, w.state || null, opts);
/* alleen aanvullen wat het bord zelf nog niet expliciet heeft ingesteld -
een leerkracht die zelf al een niveau op dit bord koos, blijft leidend. */
const lvl = levels[def.cat];
const state = w.state ? { ...w.state } : {};
if(lvl){
if(state.level == null) state.level = lvl.level;
if(def.cat === "rekenen" && lvl.yearGroup != null) state.yearGroup = lvl.yearGroup;
}
def.mount(body, Object.keys(state).length ? state : null, opts);
});
}
@ -84,7 +102,7 @@ async function pupilPoll(){
const sig = mode + "|" + JSON.stringify(res.widgets || []);
if(sig !== pupilLastSig){
pupilLastSig = sig;
renderPupilWidgets(res.widgets || [], mode);
await renderPupilWidgets(res.widgets || [], mode);
}
if(mode !== pupilMode){
pupilMode = mode;

View file

@ -5,9 +5,13 @@
function mountMath(root, initState, mode, opts){
const ROUND = 10;
const onProgress = opts && opts.onProgress;
const S = { level:1, snd:true, amode:"blocks", q:null, tries:0, done:0, good:0, input:"", busy:false };
const S = { level:1, yearGroup:null, snd:true, amode:"blocks", q:null, tries:0, done:0, good:0, input:"", busy:false };
if(initState){
S.level = initState.level||1;
/* yearGroup komt van klassenmanagement (pupil.js), niet van het bord zelf
- bewust niet in getState() opgenomen, dit is een per-leerling waarde,
geen door de leerkracht op het bord ingestelde inhoud. */
S.yearGroup = initState.yearGroup || null;
S.snd = initState.snd!==false;
S.amode = initState.amode||"blocks";
}
@ -161,21 +165,27 @@ function mountMath(root, initState, mode, opts){
function gen(){
const L = S.level;
/* klassenmanagement geeft een groep (leerjaar) mee via yearGroup - dan
sturen de SLO-gebaseerde getalgrenzen (data.js: mathRangeFor) de
inhoud, niet meer de vaste 10/20/100-klem. Zonder groep (geen
klassenmanagement ingesteld voor deze leerling/dit bord) blijft het
oude gedrag exact hetzelfde. */
const slo = mathRangeFor(S.yearGroup, L);
let a, b, ans, txt;
if(mode==="add"){
const max = L===1 ? 10 : L===2 ? 20 : 100;
const max = slo ? slo.addSub : (L===1 ? 10 : L===2 ? 20 : 100);
a = rnd(max+1); b = rnd(max-a+1); ans = a+b;
txt = `${a} + ${b} =`;
}else if(mode==="sub"){
const max = L===1 ? 10 : L===2 ? 20 : 100;
const max = slo ? slo.addSub : (L===1 ? 10 : L===2 ? 20 : 100);
a = rnd(max+1); b = rnd(a+1); ans = a-b;
txt = `${a} ${b} =`;
}else if(mode==="mul"){
const tmax = L===1 ? 5 : L===2 ? 10 : 12;
const tmax = slo ? slo.timesTable : (L===1 ? 5 : L===2 ? 10 : 12);
a = 1+rnd(tmax); b = 1+rnd(L===3 ? 12 : 10); ans = a*b;
txt = `${a} × ${b} =`;
}else if(mode==="div"){
const tmax = L===1 ? 5 : L===2 ? 10 : 12;
const tmax = slo ? slo.timesTable : (L===1 ? 5 : L===2 ? 10 : 12);
const t = 1+rnd(tmax), q = 1+rnd(L===3 ? 12 : 10);
a = t*q; ans = q;
txt = `${a} ${S.lang==="nl" || LANG==="nl" ? ":" : "÷"} ${t} =`;

View file

@ -917,6 +917,123 @@ export default async function api(app) {
return { widgets, mode: a.mode || 'werken', updatedAt: a.updated_at };
});
// ---- klassenmanagement: niveaugroep + niveau per leerling per vakgebied -------
// Zelfde klas-standaard-met-leerling-override-vorm als assignments hierboven,
// nu met vakgebied als extra dimensie (subject: taal/rekenen/world - dezelfde
// waarden als REGISTRY's cat in board.js). "groep" (year_group, 1-8) is de
// niveaugroep gebaseerd op de leerjaren van de basisschool waaraan de
// leerdoelen gekoppeld zijn; "level" (1-3, niet geklemd in het datamodel) is
// de fijnmazige verfijning daarbinnen die widgets nu al kennen.
const LEVEL_SUBJECTS = ['taal', 'rekenen', 'world'];
const describePupilLevel = (row) => ({ yearGroup: Number(row.year_group), level: Number(row.level) });
const parseLevelBody = (req, reply) => {
const { subject, yearGroup, level } = req.body ?? {};
if (!LEVEL_SUBJECTS.includes(subject)) return fail(reply, 400, 'ongeldig vakgebied');
const yg = Number(yearGroup);
if (!Number.isInteger(yg) || yg < 1 || yg > 8) return fail(reply, 400, 'ongeldige groep');
const lvl = Number(level);
if (!Number.isInteger(lvl) || lvl < 1) return fail(reply, 400, 'ongeldig niveau');
return { subject, yearGroup: yg, level: lvl };
};
app.get('/levels/class/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['assignments.manage']);
const subject = req.query.subject;
if (!LEVEL_SUBJECTS.includes(subject)) return fail(reply, 400, 'ongeldig vakgebied');
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0];
if (!c) return fail(reply, 404, 'klas onbekend');
if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten');
if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten');
const cls = (await pool.query(
'SELECT * FROM pupil_levels WHERE class_id = $1 AND subject = $2', [c.id, subject])).rows[0];
const overrides = (await pool.query(
`SELECT pl.* FROM pupil_levels pl JOIN users u ON u.id = pl.pupil_id
WHERE u.class_id = $1 AND pl.subject = $2`, [c.id, subject])).rows;
return {
classDefault: cls ? describePupilLevel(cls) : null,
pupilOverrides: overrides.map((r) => ({ pupilId: Number(r.pupil_id), ...describePupilLevel(r) })),
};
});
app.put('/levels/class/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['assignments.manage']);
const body = parseLevelBody(req, reply);
if (body.error) return body;
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0];
if (!c) return fail(reply, 404, 'klas onbekend');
if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten');
if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten');
await pool.query(
`INSERT INTO pupil_levels (class_id, subject, year_group, level) VALUES ($1,$2,$3,$4)
ON CONFLICT (class_id, subject) WHERE class_id IS NOT NULL
DO UPDATE SET year_group = $3, level = $4, updated_at = now()`,
[c.id, body.subject, body.yearGroup, body.level]);
return { ok: true };
});
app.delete('/levels/class/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['assignments.manage']);
const subject = req.query.subject;
if (!LEVEL_SUBJECTS.includes(subject)) return fail(reply, 400, 'ongeldig vakgebied');
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0];
if (!c) return fail(reply, 404, 'klas onbekend');
if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten');
if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten');
await pool.query('DELETE FROM pupil_levels WHERE class_id = $1 AND subject = $2', [c.id, subject]);
return { ok: true };
});
app.get('/levels/pupil/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['assignments.manage']);
const subject = req.query.subject;
if (!LEVEL_SUBJECTS.includes(subject)) return fail(reply, 400, 'ongeldig vakgebied');
const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0];
if (!u) return fail(reply, 404, 'gebruiker onbekend');
if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten');
const row = (await pool.query(
'SELECT * FROM pupil_levels WHERE pupil_id = $1 AND subject = $2', [u.id, subject])).rows[0];
return { level: row ? describePupilLevel(row) : null };
});
app.put('/levels/pupil/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['assignments.manage']);
const body = parseLevelBody(req, reply);
if (body.error) return body;
const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0];
if (!u) return fail(reply, 404, 'gebruiker onbekend');
if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten');
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()`,
[u.id, body.subject, body.yearGroup, body.level]);
return { ok: true };
});
app.delete('/levels/pupil/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['assignments.manage']);
const subject = req.query.subject;
if (!LEVEL_SUBJECTS.includes(subject)) return fail(reply, 400, 'ongeldig vakgebied');
const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0];
if (!u) return fail(reply, 404, 'gebruiker onbekend');
if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten');
await pool.query('DELETE FROM pupil_levels WHERE pupil_id = $1 AND subject = $2', [u.id, subject]);
return { ok: true };
});
// Leerling-kant: effectieve groep/niveau (eigen override wint van de klas),
// zelfde resolutievorm als /my/assignment hierboven. Geen instelling ->
// {yearGroup:null, level:1}, gelijk aan de bestaande widget-standaardwaarde.
app.get('/my/level', async (req, reply) => {
need(req, reply);
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
const subject = req.query.subject;
if (!LEVEL_SUBJECTS.includes(subject)) return fail(reply, 400, 'ongeldig vakgebied');
let row = (await pool.query(
'SELECT * FROM pupil_levels WHERE pupil_id = $1 AND subject = $2', [req.user.id, subject])).rows[0];
if (!row && req.user.class_id) {
row = (await pool.query(
'SELECT * FROM pupil_levels WHERE class_id = $1 AND subject = $2', [req.user.class_id, subject])).rows[0];
}
return row ? describePupilLevel(row) : { yearGroup: null, level: 1 };
});
// ---- voortgang (leerling-omgeving) ---------------------------------------------
// Leerlingen loggen elk afgerond "moment" in een widget (ronde sommen, voltooid
// woord, geknapte ballon). teacher_id/board_id komen server-side uit de effectieve

146
test/pupil-levels.test.js Normal file
View file

@ -0,0 +1,146 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { runInNewContext } from 'node:vm';
import Fastify from 'fastify';
import cookie from '@fastify/cookie';
import rateLimit from '@fastify/rate-limit';
import api from '../src/api.js';
/* Klassenmanagement: niveaugroep (groep 1-8) + niveau (1-3) per leerling per
vakgebied. Zelfde soort dekking als test/progress-logging.test.js voor het
server-pad, plus een echte (niet regex-)test van de SLO-getalgrenzen die
math.js gebruikt zodra een leerling een groep heeft. */
function makeApp(user, { classRow = { id: 100, school_id: 2, name: 'Groep 4' }, classDefault = null, pupilOverride = null, classPupilOverrides = [] } = {}) {
const calls = [];
const query = async (sql, params = []) => {
calls.push({ sql, params });
if (sql.includes('FROM sessions s JOIN users u')) return { rows: [user] };
if (sql.includes('FROM user_roles')) return { rows: [] };
if (sql.startsWith('SELECT * FROM classes WHERE id')) return { rows: classRow ? [classRow] : [] };
if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [user] };
if (sql === 'SELECT * FROM pupil_levels WHERE class_id = $1 AND subject = $2') return { rows: classDefault ? [classDefault] : [] };
if (sql === 'SELECT * FROM pupil_levels WHERE pupil_id = $1 AND subject = $2') return { rows: pupilOverride ? [pupilOverride] : [] };
if (sql.includes('FROM pupil_levels pl JOIN users u')) return { rows: classPupilOverrides };
if (sql.startsWith('INSERT INTO pupil_levels')) return { rows: [] };
if (sql.startsWith('DELETE FROM pupil_levels')) return { rows: [] };
return { rows: [] };
};
const pool = { query, async connect() { return { query, release() {} }; } };
return (async () => {
const app = Fastify({ trustProxy: 2 });
await app.register(cookie);
await app.register(rateLimit, { global: false });
app.decorate('pg', pool);
await app.register(api, { prefix: '/api' });
await app.ready();
return { app, calls };
})();
}
const cookies = { teach_session: 'x'.repeat(64) };
const teacher = { id: 3, username: 'juf', role: 'teacher', school_id: 2, class_id: null, data: {}, data_rev: 0 };
const admin = { id: 4, username: 'beheer', role: 'admin', school_id: 2, class_id: null, data: {}, data_rev: 0 };
const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 100, data: {}, data_rev: 0 };
test('klassenmanagement: klas-standaard opslaan doet de juiste upsert-query', async () => {
const { app, calls } = await makeApp(admin);
const res = await app.inject({
method: 'PUT', url: '/api/levels/class/100', cookies,
payload: { subject: 'rekenen', yearGroup: 4, level: 2 },
});
assert.equal(res.statusCode, 200, res.body);
const ins = calls.find(c => c.sql.startsWith('INSERT INTO pupil_levels'));
assert.ok(ins);
assert.deepEqual(ins.params, [100, 'rekenen', 4, 2]);
});
test('klassenmanagement: ongeldig vakgebied geeft 400', async () => {
const { app } = await makeApp(teacher);
const res = await app.inject({
method: 'PUT', url: '/api/levels/class/100', cookies,
payload: { subject: 'gym', yearGroup: 4, level: 2 },
});
assert.equal(res.statusCode, 400, res.body);
});
test('klassenmanagement: groep buiten 1-8 geeft 400', async () => {
const { app } = await makeApp(teacher);
const res = await app.inject({
method: 'PUT', url: '/api/levels/class/100', cookies,
payload: { subject: 'rekenen', yearGroup: 12, level: 2 },
});
assert.equal(res.statusCode, 400, res.body);
});
test('klassenmanagement: GET class levert klas-standaard én leerling-overrides in één keer', async () => {
const { app } = await makeApp(admin, {
classDefault: { year_group: 4, level: 2 },
classPupilOverrides: [{ pupil_id: 9, year_group: 6, level: 3 }],
});
const res = await app.inject({ method: 'GET', url: '/api/levels/class/100?subject=rekenen', cookies });
assert.equal(res.statusCode, 200, res.body);
const body = res.json();
assert.deepEqual(body.classDefault, { yearGroup: 4, level: 2 });
assert.deepEqual(body.pupilOverrides, [{ pupilId: 9, yearGroup: 6, level: 3 }]);
});
test('klassenmanagement: /my/level - leerling-override wint van klas-standaard', async () => {
const { app } = await makeApp(pupil, { pupilOverride: { year_group: 6, level: 3 } });
const res = await app.inject({ method: 'GET', url: '/api/my/level?subject=rekenen', cookies });
assert.equal(res.statusCode, 200, res.body);
assert.deepEqual(res.json(), { yearGroup: 6, level: 3 });
});
test('klassenmanagement: /my/level - valt terug op de klas-standaard zonder eigen override', async () => {
const { app } = await makeApp(pupil, { classDefault: { year_group: 4, level: 2 } });
const res = await app.inject({ method: 'GET', url: '/api/my/level?subject=rekenen', cookies });
assert.equal(res.statusCode, 200, res.body);
assert.deepEqual(res.json(), { yearGroup: 4, level: 2 });
});
test('klassenmanagement: /my/level - niets ingesteld geeft de bestaande widget-standaardwaarde', async () => {
const { app } = await makeApp(pupil);
const res = await app.inject({ method: 'GET', url: '/api/my/level?subject=rekenen', cookies });
assert.equal(res.statusCode, 200, res.body);
assert.deepEqual(res.json(), { yearGroup: null, level: 1 });
});
test('klassenmanagement: leerkracht-rol wordt geweigerd op /my/level (alleen leerlingen)', async () => {
const { app } = await makeApp(teacher);
const res = await app.inject({ method: 'GET', url: '/api/my/level?subject=rekenen', cookies });
assert.equal(res.statusCode, 403, res.body);
});
/* Echte (niet regex-)test van de SLO-getalgrenzen: data.js's SLO_MATH_RANGES/
mathRangeFor zijn pure functies zonder DOM-afhankelijkheid, dus rechtstreeks
uitvoerbaar in een geïsoleerde VM-context - zelfde techniek als elders in
test/widgets.test.js voor MONEY_LEVELS/createMoneyTask. */
test('SLO-rekengrenzen: groep bepaalt de daadwerkelijke getalgrens, niet meer de vaste 10/20/100-klem', async () => {
const source = await readFile('public/js/data.js', 'utf8'), context = {};
runInNewContext(source + ';globalThis.__mathRange = mathRangeFor', context);
const mathRangeFor = context.__mathRange;
assert.equal(mathRangeFor(3, 3).addSub, 20, 'groep 3 t/m 20');
assert.equal(mathRangeFor(4, 3).addSub, 100, 'groep 4 t/m 100');
assert.equal(mathRangeFor(5, 3).addSub, 1000, 'groep 5 t/m 1000');
assert.equal(mathRangeFor(6, 3).addSub, 10000, 'groep 6 t/m 10.000');
// niveau is een verfijning BINNEN de groep: nooit hoger dan de groep-grens
for (let g = 1; g <= 8; g++) {
const r1 = mathRangeFor(g, 1), r2 = mathRangeFor(g, 2), r3 = mathRangeFor(g, 3);
assert.ok(r1.addSub <= r2.addSub && r2.addSub <= r3.addSub, `groep ${g}: niveau moet oplopen`);
assert.ok(r1.addSub >= 10, `groep ${g} niveau 1 nooit onder de kleinste bestaande standaardwaarde`);
}
// zonder groep (geen klassenmanagement ingesteld) geeft mathRangeFor niets terug -
// math.js valt dan terug op zijn eigen oude vaste logica
assert.equal(mathRangeFor(null, 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 () => {
const source = await readFile('public/js/widgets/math.js', 'utf8');
assert.match(source, /const slo = mathRangeFor\(S\.yearGroup, L\)/);
assert.match(source, /const max = slo \? slo\.addSub : \(L===1 \? 10 : L===2 \? 20 : 100\)/);
assert.match(source, /const tmax = slo \? slo\.timesTable : \(L===1 \? 5 : L===2 \? 10 : 12\)/);
assert.ok(source.includes('S.yearGroup = initState.yearGroup || null'));
});