teach/public/js/pupil.js
Ramon e7bb76b76d
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m9s
feat: klassenmanagement - niveaugroep + niveau per leerling per vakgebied (v0.4.34-beta)
- 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
2026-07-19 15:31:26 +02:00

128 lines
5.7 KiB
JavaScript

/* teach - leerling-omgeving: toont enkel de widgets die de leerkracht heeft
klaargezet (via een toewijzing aan de klas of de leerling zelf), in
speelmodus (readonly) en zonder de sleep/vergroot/sluit-chrome van het bord.
Geen momentopname: /my/assignment zoekt live in het bord van de leerkracht,
dus deze pagina pollt periodiek zodat nieuwe woorden vanzelf doorkomen.
Modus per toewijzing: "werken" (default, leerling werkt in de widget en
voortgang wordt gelogd) of "kijken" (alleen meekijken: alle interactie
geblokkeerd via een overlay, snellere poll zodat wijzigingen van de
leerkracht vlot doorkomen). */
const PUPIL_WIDGET_CATS = ["taal", "rekenen"];
const PUPIL_POLL_MS = { kijken: 5000, werken: 20000 };
const pupilView = document.getElementById("pupilView");
let pupilPollTimer = null;
let pupilLastSig = null;
let pupilMode = "werken";
function pupilAllowedDefs(){
return REGISTRY.filter(d => PUPIL_WIDGET_CATS.includes(d.cat));
}
async function renderPupilWidgets(widgets, mode){
pupilView.innerHTML = "";
const defs = pupilAllowedDefs();
const watch = mode === "kijken";
const usable = widgets
.map(w => ({ w, def: defs.find(d => d.id === w.id) }))
.filter(x => x.def);
if(!usable.length){
const hint = document.createElement("div");
hint.className = "pupil-empty";
hint.textContent = T("pupilEmpty");
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" : "");
card.dataset.widget = def.id;
card.style.width = `min(100%, ${def.w}px)`;
card.style.minWidth = "0";
card.style.minHeight = def.h + "px";
const body = document.createElement("div");
body.className = "widget-body";
card.appendChild(body);
if(watch){
const badge = document.createElement("div");
badge.className = "pupil-watch-badge";
badge.textContent = "👁 " + T("pupilWatching");
card.appendChild(badge);
}
pupilView.appendChild(card);
/* borden die vóór de invoering van stabiele widget-ids zijn opgeslagen
hebben nog geen wid - dan is positie in de lijst de terugval-identiteit,
stabiel zolang de leerkracht de widgets op het bord niet herschikt */
const widgetId = w.wid || (def.id + ":" + idx);
/* in kijken-modus geen voortgang: de leerling doet zelf niets. In
werken-modus moet de leerling juist wél kunnen werken - readonly:true
stond hier sinds v0.3.15-beta abusievelijk ook in de werken-tak (een
kopieerfout toen kijken/werken werd toegevoegd). De twee widgets die
opts.readonly echt lezen (letters.js/anchor.js) gebruiken het alleen om
hun eigen bewerk-gereedschap (woordenlijst-editor, opslaan/bibliotheek)
te verbergen, niet om de kernoefening te blokkeren - maar dat gereedschap
hoort een leerling in werken-modus wél te zien, dus dit was hoe dan ook
verkeerd om, los van de vraag of dit "voortgang werkt niet" verklaart. */
const opts = watch ? { readonly:true } : { onProgress: (p)=>{
/* eerder een stille .catch(()=>{}) - een mislukte log (verlopen sessie,
geen toewijzing meer, servergolf) verdween daarmee spoorloos, wat een
"voortgang werkt niet"-klacht onmogelijk te onderzoeken maakt. Nu
zichtbaar in de console (geen storende UI voor de leerling, wél een
spoor voor wie het probleem moet natrekken). */
api("/my/progress", { method:"POST", body: { widgetId, widgetType: def.id, ...p } })
.catch(e=>console.warn("voortgang loggen mislukt:", e.message));
}};
/* alleen aanvullen wat het bord zelf nog niet expliciet heeft ingesteld -
een leerkracht die zelf al een niveau op dit bord koos, blijft leidend. */
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);
});
}
async function pupilPoll(){
try{
const res = await api("/my/assignment");
const mode = res.mode === "kijken" ? "kijken" : "werken";
/* modus in de signature zodat een moduswissel ook zonder widget-wijziging
een her-render (en dus de juiste overlay) oplevert */
const sig = mode + "|" + JSON.stringify(res.widgets || []);
if(sig !== pupilLastSig){
pupilLastSig = sig;
await renderPupilWidgets(res.widgets || [], mode);
}
if(mode !== pupilMode){
pupilMode = mode;
clearInterval(pupilPollTimer);
pupilPollTimer = setInterval(pupilPoll, PUPIL_POLL_MS[mode]);
}
}catch(e){ /* stille mislukking - volgende poll probeert opnieuw */ }
}
function startPupilView(){
document.body.classList.add("pupil-mode");
pupilLastSig = null;
pupilMode = "werken";
pupilPoll();
clearInterval(pupilPollTimer);
pupilPollTimer = setInterval(pupilPoll, PUPIL_POLL_MS[pupilMode]);
}
function stopPupilView(){
document.body.classList.remove("pupil-mode");
clearInterval(pupilPollTimer);
pupilPollTimer = null;
pupilView.innerHTML = "";
}