feat: voeg voortgangdetail toe aan de taalwidgets (v0.4.63-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m56s

Gewijzigde functionaliteiten:

- Letterblokken logt per afgerond woord hoeveel pogingen het kostte en welke
  letter op welke plek fout ging (beide invoerwijzen: blokken slepen en typen).

- Ballonnenwoord logt nu ook een verloren woord; dat leverde tot nu toe geen
  enkele voortgang op. Een gewonnen woord telt de fout geraden letters mee.

- Flitswoorden stuurt de bestaande zelfcheck ("Gelukt" / "Nog oefenen") door
  naar de voortgang; die uitkomst werd eerder weggegooid.

- Hakken & plakken krijgt dezelfde zelfcheck en daarmee als laatste taalwidget
  een goed/fout-moment; de oefening zelf verandert niet.

- Voortgang-tab toont de fouten van taal: naast fout afgesloten opdrachten ook
  "na N pogingen goed" (letterblokken kent geen faalmoment) en zelfchecks.

- Reeksstappen met flitswoorden of hakken & plakken wachten nu op een echt
  afgerond moment in plaats van meteen te completeren.

Tests: 192 geslaagd (7 nieuw), plus live nagelopen in de leerlingweergave met
echte interactie per widget en een screenshot van de Voortgang-tab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0126hjBPD2ggZ2LEoDYZSW7S
This commit is contained in:
Ramon 2026-07-26 03:16:28 +02:00
parent 043831e1dd
commit 50b9f32e91
11 changed files with 280 additions and 22 deletions

View file

@ -1 +1 @@
0.4.62-beta 0.4.63-beta

View file

@ -2055,7 +2055,7 @@ body.dark .world-fact{background:#1d3b2a;color:#a9e5b7}body.dark .world-fact .wo
.widget-body{background-image:radial-gradient(circle at 96% 4%,color-mix(in srgb,var(--widget-accent) 8%,transparent) 0 54px,transparent 55px)} .widget-body{background-image:radial-gradient(circle at 96% 4%,color-mix(in srgb,var(--widget-accent) 8%,transparent) 0 54px,transparent 55px)}
.widget-body .tbtn{transition:transform .12s,filter .12s,box-shadow .12s}.widget-body .tbtn:not(.ghost){background:var(--widget-accent);box-shadow:0 3px 0 color-mix(in srgb,var(--widget-accent) 72%,#18233a)}.widget-body .tbtn:active{transform:translateY(2px) scale(.985);box-shadow:none}.widget-body :is(button,input,select,textarea):focus-visible{outline:3px solid color-mix(in srgb,var(--widget-accent) 72%,#fff);outline-offset:2px} .widget-body .tbtn{transition:transform .12s,filter .12s,box-shadow .12s}.widget-body .tbtn:not(.ghost){background:var(--widget-accent);box-shadow:0 3px 0 color-mix(in srgb,var(--widget-accent) 72%,#18233a)}.widget-body .tbtn:active{transform:translateY(2px) scale(.985);box-shadow:none}.widget-body :is(button,input,select,textarea):focus-visible{outline:3px solid color-mix(in srgb,var(--widget-accent) 72%,#fff);outline-offset:2px}
.play-mission{width:min(100%,560px);margin:0 auto;padding:8px 12px;border:1px solid color-mix(in srgb,var(--widget-accent) 25%,var(--line));border-radius:13px;background:var(--widget-soft);color:color-mix(in srgb,var(--widget-accent) 68%,var(--ink));font-size:13px;font-weight:850;line-height:1.35;text-align:center} .play-mission{width:min(100%,560px);margin:0 auto;padding:8px 12px;border:1px solid color-mix(in srgb,var(--widget-accent) 25%,var(--line));border-radius:13px;background:var(--widget-soft);color:color-mix(in srgb,var(--widget-accent) 68%,var(--ink));font-size:13px;font-weight:850;line-height:1.35;text-align:center}
.fl-reflect{width:100%;padding:8px 10px;border-radius:14px;background:var(--widget-soft);text-align:center}.fl-reflect p{margin:0 0 7px;font-weight:900}.fl-reflect>div{display:flex;justify-content:center;gap:7px;flex-wrap:wrap}.fl-reflect[hidden]{display:none} .fl-reflect,.hk-reflect{width:100%;padding:8px 10px;border-radius:14px;background:var(--widget-soft);text-align:center}.fl-reflect p,.hk-reflect p{margin:0 0 7px;font-weight:900}.fl-reflect>div,.hk-reflect>div{display:flex;justify-content:center;gap:7px;flex-wrap:wrap}.fl-reflect[hidden],.hk-reflect[hidden]{display:none}
.dc-history{min-height:28px;display:flex;align-items:center;justify-content:center;gap:5px;flex-wrap:wrap}.dc-history span{margin-right:3px;color:var(--muted);font-size:11px;font-weight:850}.dc-history b{min-width:28px;height:28px;display:grid;place-items:center;border-radius:9px;background:var(--widget-soft);color:var(--widget-accent)} .dc-history{min-height:28px;display:flex;align-items:center;justify-content:center;gap:5px;flex-wrap:wrap}.dc-history span{margin-right:3px;color:var(--muted);font-size:11px;font-weight:850}.dc-history b{min-width:28px;height:28px;display:grid;place-items:center;border-radius:9px;background:var(--widget-soft);color:var(--widget-accent)}
.notes-app{height:100%;display:flex;flex-direction:column;background:#fff8cf;transition:background .15s}.notes-app[data-theme="mint"]{background:#e5f7df}.notes-app[data-theme="sky"]{background:#e2f3ff}.notes-app[data-theme="lilac"]{background:#f0e8ff}.notes-tools{min-height:42px;display:flex;align-items:center;gap:7px;padding:6px 8px;border-bottom:1px solid rgba(29,52,97,.12);color:#52627b;font-size:11px;font-weight:900}.notes-swatches{display:flex;gap:4px}.notes-swatches button{width:24px;height:24px;padding:0;border:2px solid rgba(29,52,97,.16);border-radius:50%;cursor:pointer}.notes-swatches button[data-theme="sun"]{background:#fff0a8}.notes-swatches button[data-theme="mint"]{background:#cdeebe}.notes-swatches button[data-theme="sky"]{background:#c6e9ff}.notes-swatches button[data-theme="lilac"]{background:#dfd0ff}.notes-swatches button.on{outline:2px solid var(--ink);outline-offset:1px}.notes-size{height:28px;padding:0 7px;border:1px solid rgba(29,52,97,.18);border-radius:8px;background:rgba(255,255,255,.5);color:#33445f;font-weight:900;cursor:pointer}.notes-size.on{background:#fff}.notes-count{margin-left:auto;white-space:nowrap} .notes-app{height:100%;display:flex;flex-direction:column;background:#fff8cf;transition:background .15s}.notes-app[data-theme="mint"]{background:#e5f7df}.notes-app[data-theme="sky"]{background:#e2f3ff}.notes-app[data-theme="lilac"]{background:#f0e8ff}.notes-tools{min-height:42px;display:flex;align-items:center;gap:7px;padding:6px 8px;border-bottom:1px solid rgba(29,52,97,.12);color:#52627b;font-size:11px;font-weight:900}.notes-swatches{display:flex;gap:4px}.notes-swatches button{width:24px;height:24px;padding:0;border:2px solid rgba(29,52,97,.16);border-radius:50%;cursor:pointer}.notes-swatches button[data-theme="sun"]{background:#fff0a8}.notes-swatches button[data-theme="mint"]{background:#cdeebe}.notes-swatches button[data-theme="sky"]{background:#c6e9ff}.notes-swatches button[data-theme="lilac"]{background:#dfd0ff}.notes-swatches button.on{outline:2px solid var(--ink);outline-offset:1px}.notes-size{height:28px;padding:0 7px;border:1px solid rgba(29,52,97,.18);border-radius:8px;background:rgba(255,255,255,.5);color:#33445f;font-weight:900;cursor:pointer}.notes-size.on{background:#fff}.notes-count{margin-left:auto;white-space:nowrap}
.mt-card,.lg-card,.money-task-card,.clock-task-card,.world-task-card,.sb-sentence,.nlw-scroll{box-shadow:0 7px 20px rgba(29,52,97,.09)} .mt-card,.lg-card,.money-task-card,.clock-task-card,.world-task-card,.sb-sentence,.nlw-scroll{box-shadow:0 7px 20px rgba(29,52,97,.09)}

View file

@ -982,6 +982,27 @@
return row; return row;
} }
/* de rechterkolom van een foutregel. Drie vormen, want de widgets leveren
drie soorten momenten: een fout afgesloten opdracht (som opgegeven,
ballonwoord verloren), een zelfcheck van de leerling (flitswoorden,
hakken & plakken - daar bestaat geen ingetypt antwoord) en een opdracht die
goed eindigde maar pogingen kostte (letterblokken kent geen faalmoment) */
function progressMistakeLine(ev){
const d = ev.detail || {}, misses = d.mistakes || [];
if(ev.correct > 0 && misses.length){
/* een letter die op een andere plek hoorde tonen we als "a k"; bij het
ballonnenwoord bestaat er geen letter-voor-letter verwachting, dan
volstaat de rij fout geraden letters */
const shown = misses.slice(0,3).map(m=>
m.expected && m.expected!==d.question ? m.given+" → "+m.expected : m.given).join(", ");
return T("amProgressRetried").replace("{count}", ev.attempts || misses.length+1)
+ (shown ? " · " + shown : "");
}
if(d.selfCheck || d.answer == null) return T("amProgressSelfCheck");
return T("amProgressGiven").replace("{answer}", d.answer) + " · " +
T("amProgressExpected").replace("{answer}", d.expected);
}
/* voortgang: leerkracht kiest een leerling en een vakgebied, ziet meteen /* voortgang: leerkracht kiest een leerling en een vakgebied, ziet meteen
het toegewezen niveau (klassenmanagement-koppeling), een grafiek het toegewezen niveau (klassenmanagement-koppeling), een grafiek
(nauwkeurigheid + goed/fout per dag) en de recentste fouten mét welke (nauwkeurigheid + goed/fout per dag) en de recentste fouten mét welke
@ -1025,16 +1046,15 @@
summary.append(summaryText, progressDailyBars(events)); summary.append(summaryText, progressDailyBars(events));
body.appendChild(summary); body.appendChild(summary);
const mistakes = events.filter(ev=>ev.detail && ev.detail.correct===false).slice(0, 10); const mistakes = events.filter(ev=>ev.detail
&& (ev.detail.correct===false || (ev.detail.mistakes||[]).length)).slice(0, 10);
if(mistakes.length){ if(mistakes.length){
body.appendChild(h("div","am-group", T("amProgressMistakes"))); body.appendChild(h("div","am-group", T("amProgressMistakes")));
const list = h("div","am-list"); const list = h("div","am-list");
mistakes.forEach(ev=>{ mistakes.forEach(ev=>{
const row = h("div","am-row"); const row = h("div","am-row");
row.appendChild(h("span","am-name", ev.detail.question||"")); row.appendChild(h("span","am-name", ev.detail.question||""));
row.appendChild(h("span","am-code", row.appendChild(h("span","am-code", progressMistakeLine(ev)));
T("amProgressGiven").replace("{answer}", ev.detail.answer) + " · " +
T("amProgressExpected").replace("{answer}", ev.detail.expected)));
row.appendChild(h("span","am-code", new Date(ev.createdAt).toLocaleString(LANG==="nl" ? "nl-NL" : "en-GB"))); row.appendChild(h("span","am-code", new Date(ev.createdAt).toLocaleString(LANG==="nl" ? "nl-NL" : "en-GB")));
list.appendChild(row); list.appendChild(row);
}); });

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.62-beta"; const VERSION = "0.4.63-beta";
/* Safari en oudere mobiele browsers behandelen 100vh/100% soms als de hoogte /* Safari en oudere mobiele browsers behandelen 100vh/100% soms als de hoogte
achter hun adres- en navigatiebalk. visualViewport geeft de werkelijk achter hun adres- en navigatiebalk. visualViewport geeft de werkelijk
zichtbare hoogte; de CSS gebruikt deze pixelwaarde als betrouwbare bron en zichtbare hoogte; de CSS gebruikt deze pixelwaarde als betrouwbare bron en
@ -119,6 +119,7 @@ const I18N = {
namesDone:"Iedereen is geweest! 🎉", namesReset:"Opnieuw", namesProgress:"{used} van {total} geweest", namesDone:"Iedereen is geweest! 🎉", namesReset:"Opnieuw", namesProgress:"{used} van {total} geweest",
wg_hak:"Hakken & plakken", wg_hak_d:"Woorden in klanken hakken en weer plakken, met voorlezen.", wg_hak:"Hakken & plakken", wg_hak_d:"Woorden in klanken hakken en weer plakken, met voorlezen.",
hakChop:"✂️ Hak", hakGlue:"🔗 Plak", hakSay:"🔊 Lees voor", hakHint:"Bekijk en hoor de klankgroepen in het woord.", hakChop:"✂️ Hak", hakGlue:"🔗 Plak", hakSay:"🔊 Lees voor", hakHint:"Bekijk en hoor de klankgroepen in het woord.",
hakCheck:"Kon je het woord zelf hakken en plakken?", hakCheckLetter:"Hoorde je de eerste letter zelf goed?",
wg_ball:"Ballonnenwoord", wg_ball_d:"Raad het woord letter voor letter voordat de ballonnen knappen.", wg_ball:"Ballonnenwoord", wg_ball_d:"Raad het woord letter voor letter voordat de ballonnen knappen.",
wg_anchor:"Ankerwoorden", wg_anchor_d:"Woordweb voor woordenschatonderwijs: leer woorden in samenhang rond een themawoord (het anker), met plaatjes. Elk woord verankert aan de woorden eromheen.", wg_anchor:"Ankerwoorden", wg_anchor_d:"Woordweb voor woordenschatonderwijs: leer woorden in samenhang rond een themawoord (het anker), met plaatjes. Elk woord verankert aan de woorden eromheen.",
fabAnchor:"Ankerdiagram (woordweb)", fabAnchor:"Ankerdiagram (woordweb)",
@ -439,6 +440,7 @@ const I18N = {
amProgressLast14Days:"Laatste 14 dagen", amProgressMistakes:"Recente fouten", amProgressLast14Days:"Laatste 14 dagen", amProgressMistakes:"Recente fouten",
amProgressTimeline:"Volledige tijdlijn", amProgressTimeline:"Volledige tijdlijn",
amProgressGiven:"Gegeven: {answer}", amProgressExpected:"Juist: {answer}", amProgressGiven:"Gegeven: {answer}", amProgressExpected:"Juist: {answer}",
amProgressRetried:"Na {count} pogingen goed", amProgressSelfCheck:"Zelf aangegeven: nog niet gelukt",
amExportClass:"Exporteren per klas", amExportCsv:"Exporteer als CSV", amPickClassHint:"Kies eerst een klas.", amExportClass:"Exporteren per klas", amExportCsv:"Exporteer als CSV", amPickClassHint:"Kies eerst een klas.",
amExportColPupil:"Leerling", amExportColWidget:"Widget", amExportColAttempts:"Pogingen", amExportColPupil:"Leerling", amExportColWidget:"Widget", amExportColAttempts:"Pogingen",
amExportColCorrect:"Goed", amExportColStars:"Sterren", amExportColDate:"Datum", amExportColCorrect:"Goed", amExportColStars:"Sterren", amExportColDate:"Datum",
@ -541,6 +543,7 @@ const I18N = {
namesDone:"Everyone has had a turn! 🎉", namesReset:"Start over", namesProgress:"{used} of {total} picked", namesDone:"Everyone has had a turn! 🎉", namesReset:"Start over", namesProgress:"{used} of {total} picked",
wg_hak:"Chop & blend", wg_hak_d:"Chop words into sounds and blend them back, with read-aloud.", wg_hak:"Chop & blend", wg_hak_d:"Chop words into sounds and blend them back, with read-aloud.",
hakChop:"✂️ Chop", hakGlue:"🔗 Blend", hakSay:"🔊 Read aloud", hakHint:"See and hear the sound groups in the word.", hakChop:"✂️ Chop", hakGlue:"🔗 Blend", hakSay:"🔊 Read aloud", hakHint:"See and hear the sound groups in the word.",
hakCheck:"Could you chop and blend the word yourself?", hakCheckLetter:"Did you hear the first letter correctly yourself?",
wg_ball:"Balloon word", wg_ball_d:"Guess the word letter by letter before the balloons pop.", wg_ball:"Balloon word", wg_ball_d:"Guess the word letter by letter before the balloons pop.",
wg_anchor:"Anchor words", wg_anchor_d:"Word web for vocabulary teaching: learn words in meaningful clusters around a theme word (the anchor), with pictures. Every word anchors to its neighbours.", wg_anchor:"Anchor words", wg_anchor_d:"Word web for vocabulary teaching: learn words in meaningful clusters around a theme word (the anchor), with pictures. Every word anchors to its neighbours.",
fabAnchor:"Anchor diagram (word web)", fabAnchor:"Anchor diagram (word web)",
@ -861,6 +864,7 @@ const I18N = {
amProgressLast14Days:"Last 14 days", amProgressMistakes:"Recent mistakes", amProgressLast14Days:"Last 14 days", amProgressMistakes:"Recent mistakes",
amProgressTimeline:"Full timeline", amProgressTimeline:"Full timeline",
amProgressGiven:"Given: {answer}", amProgressExpected:"Correct: {answer}", amProgressGiven:"Given: {answer}", amProgressExpected:"Correct: {answer}",
amProgressRetried:"Correct after {count} tries", amProgressSelfCheck:"Self-reported: not yet",
amExportClass:"Export by class", amExportCsv:"Export as CSV", amPickClassHint:"Pick a class first.", amExportClass:"Export by class", amExportCsv:"Export as CSV", amPickClassHint:"Pick a class first.",
amExportColPupil:"Pupil", amExportColWidget:"Widget", amExportColAttempts:"Attempts", amExportColPupil:"Pupil", amExportColWidget:"Widget", amExportColAttempts:"Attempts",
amExportColCorrect:"Correct", amExportColStars:"Stars", amExportColDate:"Date", amExportColCorrect:"Correct", amExportColStars:"Stars", amExportColDate:"Date",

View file

@ -105,6 +105,41 @@ function languageTaskRules(yearGroup,level){
const year=learningYearGroup(yearGroup);if(!year)return null;const l=learningLevel(level); const year=learningYearGroup(yearGroup);if(!year)return null;const l=learningLevel(level);
return{literacyMode:year===1?"letters":year===2?"sounds":"words",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} return{literacyMode:year===1?"letters":year===2?"sounds":"words",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}
} }
/* Voortgang bij taal: één gedeelde vorm voor het "moment" dat een taalwidget
aflevert. math.js logt sinds v0.4.35-beta per som mét een detail (welke som,
gegeven en juist antwoord); de taalwidgets leverden alleen een kale telling,
waardoor de leerkracht bij taal wel zag DAT er geoefend was maar nooit welk
woord moeite kostte. attempts telt de pogingen (1 + aantal fouten) zodat de
goed/fout-grafiek in de Voortgang-tab (wrong = attempts - correct) klopt.
detail.correct betekent hier "foutloos afgerond": letterblokken kent geen
faalmoment (je kunt blijven proberen), dus zonder dat criterium zou de
foutenlijst daar altijd leeg blijven. */
function languageProgressMistakes(mistakes){
return (Array.isArray(mistakes)?mistakes:[])
.filter(m=>m&&(m.given!=null||m.expected!=null))
.slice(0,6)
.map(m=>({expected:String(m.expected==null?"":m.expected).slice(0,40),given:String(m.given==null?"":m.given).slice(0,40)}))
}
function languageProgressPayload(options){
const o=options||{},list=languageProgressMistakes(o.mistakes),first=list[0]||null;
const task=String(o.word==null?"":o.word).slice(0,80),solved=o.solved!==false;
const detail={
question:task,
expected:o.expected!==undefined?String(o.expected==null?"":o.expected).slice(0,80):(first?first.expected:task),
/* bij een zelfcheck (flits/hak) typt de leerling niets in: er ís geen
antwoord om aan de leerkracht te tonen, alleen "lukte het zelf of niet" */
answer:o.answer!==undefined?(o.answer==null?null:String(o.answer).slice(0,80)):(o.selfCheck?null:(first?first.given:task)),
correct:solved&&!list.length
};
if(list.length)detail.mistakes=list;
if(o.selfCheck)detail.selfCheck=true;
return{
attempts:((solved?1:0)+list.length)||1,
correct:solved?1:0,
stars:solved?Math.max(0,Number(o.stars)||0):0,
detail
}
}
function mathTaskRules(yearGroup,level){ 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 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 addCap=[0,10,20,20,100,1000,10000,100000,1000000][year],numberMax=[0,20,50,100,100,1000,10000,100000,1000000][year];

View file

@ -4,7 +4,7 @@
==========================================================*/ ==========================================================*/
function mountBall(root, initState, opts){ function mountBall(root, initState, opts){
const onProgress = opts && opts.onProgress; const onProgress = opts && opts.onProgress;
const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, snd:true, lives:6, guessed:new Set(), over:false, order:[], idx:0, stars:0, assignmentWords:null, assignmentThemeName:"" }; const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, snd:true, lives:6, guessed:new Set(), over:false, order:[], idx:0, stars:0, assignmentWords:null, assignmentThemeName:"", wrong:[] };
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;S.assignmentWords=Array.isArray(initState.assignmentWords)?initState.assignmentWords:null;S.assignmentThemeName=typeof initState.assignmentThemeName==="string"?initState.assignmentThemeName:"";} 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;S.assignmentWords=Array.isArray(initState.assignmentWords)?initState.assignmentWords:null;S.assignmentThemeName=typeof initState.assignmentThemeName==="string"?initState.assignmentThemeName:"";}
const assignedRules=languageTaskRules(S.yearGroup,S.level),maxLives=assignedRules?.lives||6; 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(); };
@ -56,7 +56,7 @@ function mountBall(root, initState, opts){
S.order = shuffle(list().map((_,i)=>i)); S.order = shuffle(list().map((_,i)=>i));
S.idx = 0; S.idx = 0;
} }
S.lives = maxLives; S.guessed = new Set(); S.over = false; S.lives = maxLives; S.guessed = new Set(); S.over = false; S.wrong = [];
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; });
@ -78,10 +78,13 @@ function mountBall(root, initState, opts){
play(sndWin); confetti(el(".mt-card")); play(sndWin); confetti(el(".mt-card"));
nextBtn.classList.add("show"); nextBtn.classList.add("show");
scoreEl.textContent = S.stars+" ★"; scoreEl.textContent = S.stars+" ★";
if(onProgress) onProgress({ attempts: 1, correct: 1, stars: 1 }); if(onProgress) onProgress(languageProgressPayload({
word: word(), mistakes: S.wrong.map(x=>({given:x, expected:word()})),
answer: S.wrong.join(", ") || word(), expected: word(), solved: true, stars: 1 }));
} }
}else{ }else{
S.lives--; S.lives--;
S.wrong.push(l);
play(sndBad); play(sndBad);
drawBalloons(); drawBalloons();
if(S.lives <= 0){ if(S.lives <= 0){
@ -90,6 +93,12 @@ function mountBall(root, initState, opts){
msgEl.textContent = `${T("ballLose")} ${word()}`; msgEl.textContent = `${T("ballLose")} ${word()}`;
msgEl.classList.add("lose"); msgEl.classList.add("lose");
nextBtn.classList.add("show"); nextBtn.classList.add("show");
/* een kapotgegokt woord leverde tot nu toe géén enkele voortgang op -
precies de opdracht die de leerkracht wél zou willen zien */
if(onProgress) onProgress(languageProgressPayload({
word: word(), mistakes: S.wrong.map(x=>({given:x, expected:word()})),
answer: word().split("").map(x=>S.guessed.has(x)?x:"_").join(""),
expected: word(), solved: false }));
} }
} }
} }

View file

@ -2,7 +2,8 @@
/* ========================================================= /* =========================================================
Flash words widget Flash words widget
==========================================================*/ ==========================================================*/
function mountFlits(root, initState){ function mountFlits(root, initState, opts){
const onProgress = opts && opts.onProgress;
const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, dur:2, idx:0, order:[], score:0, tries:0, assignmentWords:null, assignmentThemeName:"" }; const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, dur:2, idx:0, order:[], score:0, tries:0, assignmentWords:null, assignmentThemeName:"" };
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;S.assignmentWords=Array.isArray(initState.assignmentWords)?initState.assignmentWords:null;S.assignmentThemeName=typeof initState.assignmentThemeName==="string"?initState.assignmentThemeName:""; } 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;S.assignmentWords=Array.isArray(initState.assignmentWords)?initState.assignmentWords:null;S.assignmentThemeName=typeof initState.assignmentThemeName==="string"?initState.assignmentThemeName:""; }
const assignedRules=languageTaskRules(S.yearGroup,S.level);if(S.managedLearningLevel&&assignedRules)S.dur=assignedRules.flashSeconds; const assignedRules=languageTaskRules(S.yearGroup,S.level);if(S.managedLearningLevel&&assignedRules)S.dur=assignedRules.flashSeconds;
@ -81,6 +82,11 @@ function mountFlits(root, initState){
function assess(ok){ function assess(ok){
if(assessed)return;assessed=true; if(assessed)return;assessed=true;
S.tries++; if(ok){S.score++;sndGood();confetti(el('.mt-card'));}else sndBad(); S.tries++; if(ok){S.score++;sndGood();confetti(el('.mt-card'));}else sndBad();
/* de zelfcheck van de leerling ("gelukt" / "nog oefenen") was tot nu toe
alleen een sessieteller; het is het enige goed/fout-signaal dat deze
widget heeft en dus precies wat de voortgang bij taal miste */
if(onProgress) onProgress(languageProgressPayload({
word: cur()[0], solved: ok, stars: ok?1:0, selfCheck: true }));
score(); reflect.hidden=true; score(); reflect.hidden=true;
} }
el(".fl-flash").addEventListener("click", flash); el(".fl-flash").addEventListener("click", flash);

View file

@ -18,8 +18,14 @@ 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, opts){
const onProgress = opts && opts.onProgress;
const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, idx:0, order:[], chopped:false, assignmentWords:null, assignmentThemeName:"" }; const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, idx:0, order:[], chopped:false, assignmentWords:null, assignmentThemeName:"" };
/* deze widget had als enige taalwidget geen goed/fout-moment: hakken, plakken
en voorlezen kun je niet fout doen. De zelfcheck van flits.js (lukte het
zelf?) geeft de leerling er één zonder de oefening zelf te veranderen -
hij verschijnt pas nadat het woord onthuld is, dus na een eerlijke poging */
let assessed = false;
if(initState){S.level=initState.level||1;S.yearGroup=initState.yearGroup||null;S.managedLearningLevel=!!initState.managedLearningLevel;S.card = initState.card ?? 0;S.assignmentWords=Array.isArray(initState.assignmentWords)?initState.assignmentWords:null;S.assignmentThemeName=typeof initState.assignmentThemeName==="string"?initState.assignmentThemeName:"";} if(initState){S.level=initState.level||1;S.yearGroup=initState.yearGroup||null;S.managedLearningLevel=!!initState.managedLearningLevel;S.card = initState.card ?? 0;S.assignmentWords=Array.isArray(initState.assignmentWords)?initState.assignmentWords:null;S.assignmentThemeName=typeof initState.assignmentThemeName==="string"?initState.assignmentThemeName:"";}
const canSpeak = "speechSynthesis" in window; const canSpeak = "speechSynthesis" in window;
@ -34,6 +40,10 @@ function mountHak(root, initState){
<div class="fl-pic hk-pic"></div> <div class="fl-pic hk-pic"></div>
<div class="hk-word"></div> <div class="hk-word"></div>
</div> </div>
<div class="hk-reflect" hidden>
<p class="hk-question"></p>
<div><button class="tbtn hk-got"></button><button class="tbtn ghost hk-again"></button></div>
</div>
<div class="trow" style="display:flex;gap:8px;justify-content:center;flex-wrap:wrap;flex:none;"> <div class="trow" style="display:flex;gap:8px;justify-content:center;flex-wrap:wrap;flex:none;">
<button class="tbtn hk-say" ${canSpeak?"":'style="display:none"'}></button> <button class="tbtn hk-say" ${canSpeak?"":'style="display:none"'}></button>
<button class="tbtn ghost hk-chop"></button> <button class="tbtn ghost hk-chop"></button>
@ -43,7 +53,7 @@ function mountHak(root, initState){
</div>`; </div>`;
const el = s => root.querySelector(s); const el = s => root.querySelector(s);
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"), reflect = el(".hk-reflect");
function list(){ function list(){
if(S.assignmentWords?.length)return languageWidgetWords(S.assignmentWords,S.yearGroup,S.level,"hak"); if(S.assignmentWords?.length)return languageWidgetWords(S.assignmentWords,S.yearGroup,S.level,"hak");
@ -96,27 +106,41 @@ function mountHak(root, initState){
else if(S.chopped)speak(soundChunks(word).join(", "),0.7); else if(S.chopped)speak(soundChunks(word).join(", "),0.7);
else speak(word,0.85); else speak(word,0.85);
}); });
el(".hk-chop").addEventListener("click", ()=>{ S.chopped = true; display(); play(sndGood); }); function assess(ok){
el(".hk-glue").addEventListener("click", ()=>{ S.chopped = false; display(); play(sndGood); }); if(assessed) return;
assessed = true;
if(ok){ sndGood(); confetti(el(".mt-card")); } else sndBad();
if(onProgress) onProgress(languageProgressPayload({
word: cur()[0], solved: ok, stars: ok?1:0, selfCheck: true }));
reflect.hidden = true;
}
function askSelfCheck(){ reflect.hidden = assessed; }
el(".hk-chop").addEventListener("click", ()=>{ S.chopped = true; display(); play(sndGood); askSelfCheck(); });
el(".hk-glue").addEventListener("click", ()=>{ S.chopped = false; display(); play(sndGood); askSelfCheck(); });
el(".hk-got").addEventListener("click", ()=>assess(true));
el(".hk-again").addEventListener("click", ()=>assess(false));
el(".hk-next").addEventListener("click", ()=>{ el(".hk-next").addEventListener("click", ()=>{
S.idx++; if(S.idx >= S.order.length) newRound(); S.idx++; if(S.idx >= S.order.length) newRound();
S.chopped = false; display(); S.chopped = false; assessed = false; reflect.hidden = true; display();
}); });
const play = f=>f(); const play = f=>f();
srcSel.addEventListener("change", ()=>{ srcSel.addEventListener("change", ()=>{
S.card = srcSel.value; S.card = srcSel.value;
newRound(); S.chopped = false; display(); newRound(); S.chopped = false; assessed = false; reflect.hidden = true; display();
}); });
function labels(){ function labels(){
const letters=languageTaskRules(S.yearGroup,S.level)?.literacyMode==="letters"; const letters=languageTaskRules(S.yearGroup,S.level)?.literacyMode==="letters";
el(".hk-question").textContent = T(letters?"hakCheckLetter":"hakCheck");
el(".hk-got").textContent = T("flGotIt");
el(".hk-again").textContent = T("flAgain");
el(".hk-say").textContent = T("hakSay"); el(".hk-say").textContent = T("hakSay");
el(".hk-chop").textContent = letters?(LANG==="nl"?"Toon letter":"Show letter"):T("hakChop"); el(".hk-chop").textContent = letters?(LANG==="nl"?"Toon letter":"Show letter"):T("hakChop");
el(".hk-glue").textContent = letters?(LANG==="nl"?"Verberg letter":"Hide letter"):T("hakGlue"); el(".hk-glue").textContent = letters?(LANG==="nl"?"Verberg letter":"Hide letter"):T("hakGlue");
el(".hk-hint").textContent = letters?(LANG==="nl"?"Luister naar het woord. Welke letter hoor je vooraan?":"Listen to the word. Which letter do you hear first?"):T("hakHint"); el(".hk-hint").textContent = letters?(LANG==="nl"?"Luister naar het woord. Welke letter hoor je vooraan?":"Listen to the word. Which letter do you hear first?"):T("hakHint");
buildSrc(); buildSrc();
} }
document.addEventListener("langchange", ()=>{ if(!S.assignmentWords)S.card=0; labels(); newRound(); S.chopped=false; display(); }); document.addEventListener("langchange", ()=>{ if(!S.assignmentWords)S.card=0; labels(); newRound(); S.chopped=false; assessed=false; reflect.hidden=true; display(); });
document.addEventListener("wordschange", ()=>{ buildSrc(); newRound(); S.chopped=false; display(); }); document.addEventListener("wordschange", ()=>{ buildSrc(); newRound(); S.chopped=false; assessed=false; reflect.hidden=true; display(); });
labels(); newRound(); display(); labels(); newRound(); display();
root.classList.toggle("managed-learning-level",S.managedLearningLevel); root.classList.toggle("managed-learning-level",S.managedLearningLevel);
return { getState: ()=>({card:S.card}) }; return { getState: ()=>({card:S.card}) };

View file

@ -2,7 +2,9 @@
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, yearGroup:null, managedLearningLevel:false, card:0, wordIdx:0, stars:0, lang:LANG, count:6, order:[], snd:true, amode:"blocks", assignmentWords:null, assignmentThemeName:"" }; /* wordMistakes: de missers binnen het huidige woord (welke letter hoorde er,
welke koos de leerling) - gaat mee als voortgangdetail zodra het woord af is */
const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, wordIdx:0, stars:0, lang:LANG, count:6, order:[], snd:true, amode:"blocks", assignmentWords:null, assignmentThemeName:"", wordMistakes:[] };
if(initState){ if(initState){
S.level = initState.level||1; S.level = initState.level||1;
S.yearGroup = initState.yearGroup||null; S.yearGroup = initState.yearGroup||null;
@ -145,6 +147,7 @@ function mountLetterGame(root, initState, opts){
play(sndGood); play(sndGood);
checkComplete(); checkComplete();
}else{ }else{
S.wordMistakes.push({expected: slot.dataset.letter, given: l});
slot.classList.add("shake"); slot.classList.add("shake");
setTimeout(()=>slot.classList.remove("shake"), 400); setTimeout(()=>slot.classList.remove("shake"), 400);
play(sndBad); play(sndBad);
@ -256,6 +259,7 @@ function mountLetterGame(root, initState, opts){
if(allOpt) allOpt.textContent = T("all"); if(allOpt) allOpt.textContent = T("all");
const [word, pic] = currentWord(); const [word, pic] = currentWord();
S.wordMistakes = []; /* nieuw woord op het scherm: missers tellen per woord */
const literacyMode=languageTaskRules(S.yearGroup,S.level)?.literacyMode||"words"; const literacyMode=languageTaskRules(S.yearGroup,S.level)?.literacyMode||"words";
setPic(picEl, pic); setPic(picEl, pic);
countEl.textContent = `${T("word")} ${S.wordIdx+1}/${S.order.length}`; countEl.textContent = `${T("word")} ${S.wordIdx+1}/${S.order.length}`;
@ -309,7 +313,11 @@ function mountLetterGame(root, initState, opts){
doneEl.querySelector(".dp").textContent = currentWord()[0]; doneEl.querySelector(".dp").textContent = currentWord()[0];
doneEl.classList.add("show"); doneEl.classList.add("show");
nextBtn.classList.add("show"); nextBtn.classList.add("show");
if(onProgress) onProgress({ attempts: 1, correct: 1, stars: 1 }); /* het woord is af (er is geen faalmoment), maar de missers onderweg
vertellen de leerkracht welke letter lastig was */
if(onProgress) onProgress(languageProgressPayload({
word: currentWord()[0], mistakes: S.wordMistakes, solved: true, stars: 1 }));
S.wordMistakes = [];
} }
} }
function showCardDone(){ function showCardDone(){
@ -752,6 +760,7 @@ function mountLetterGame(root, initState, opts){
play(sndGood); play(sndGood);
checkComplete(); checkComplete();
}else{ }else{
S.wordMistakes.push({expected: target.dataset.letter, given: block.dataset.letter});
target.classList.add("shake"); target.classList.add("shake");
setTimeout(()=>target.classList.remove("shake"), 400); setTimeout(()=>target.classList.remove("shake"), 400);
play(sndBad); play(sndBad);

View file

@ -881,7 +881,11 @@ export default async function api(app) {
]); ]);
const ASSIGNMENT_LIBRARY_BOARD = 'assignment-library'; const ASSIGNMENT_LIBRARY_BOARD = 'assignment-library';
const THEMED_ASSIGNMENT_WIDGETS = new Set(['letters', 'flits', 'hak', 'ball']); const THEMED_ASSIGNMENT_WIDGETS = new Set(['letters', 'flits', 'hak', 'ball']);
const PROGRESS_ASSIGNMENT_WIDGETS = new Set(['letters', 'ball', 'madd', 'msub', 'mmul', 'mdiv', 'mpct']); // Widgets die een echt afgerond moment loggen; alleen dan kan een stap in een
// reeks op bewijs wachten. flits/hak kwamen erbij toen ze een zelfcheck
// ("lukte het zelf?") kregen - daarvoor completeerden die stappen meteen,
// omdat er geen enkel voortgangsevent kón bestaan.
const PROGRESS_ASSIGNMENT_WIDGETS = new Set(['letters', 'ball', 'flits', 'hak', 'madd', 'msub', 'mmul', 'mdiv', 'mpct']);
const parseAssignmentTheme = (rawTheme, widgetType, index, reply) => { const parseAssignmentTheme = (rawTheme, widgetType, index, reply) => {
if (rawTheme == null) return { theme: null }; if (rawTheme == null) return { theme: null };
if (!THEMED_ASSIGNMENT_WIDGETS.has(widgetType)) if (!THEMED_ASSIGNMENT_WIDGETS.has(widgetType))

View file

@ -0,0 +1,147 @@
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';
/* Voortgang bij taal. math.js logt sinds v0.4.35-beta per som mét detail; de
taalwidgets leverden alleen een kale telling op, waardoor de leerkracht bij
taal nooit zag WELK woord moeite kostte. De payloadvorm zit nu in één pure
functie, die hier echt uitgevoerd wordt (niet alleen op tekst gecontroleerd),
zelfde vm-techniek als de SLO-getalgrenzen in test/pupil-levels.test.js. */
async function loadPayloadHelper() {
const source = await readFile('public/js/learning-progressions.js', 'utf8');
const cut = source.indexOf('let SELF_LEARNING_LEVELS'); // daaronder hangt alles aan de DOM/api
const context = { LANG: 'nl', document: undefined };
runInNewContext(`${source.slice(0, cut)};globalThis.__payload = languageProgressPayload`, context);
// objecten uit een vm-context delen onze prototypes niet: platslaan zodat
// deepEqual over de wáárden gaat en niet over de realm
return (options) => JSON.parse(JSON.stringify(context.__payload(options)));
}
test('taalvoortgang: een foutloos woord levert één schone poging op', async () => {
const payload = await loadPayloadHelper();
const result = payload({ word: 'boom', mistakes: [], solved: true, stars: 1 });
assert.deepEqual(result, {
attempts: 1, correct: 1, stars: 1,
detail: { question: 'boom', expected: 'boom', answer: 'boom', correct: true },
});
assert.ok(!('mistakes' in result.detail), 'zonder missers geen mistakes-lijst');
});
test('taalvoortgang: goed na fouten telt de pogingen en bewaart welke letter fout ging', async () => {
const payload = await loadPayloadHelper();
const result = payload({
word: 'boom', stars: 1, solved: true,
mistakes: [{ expected: 'o', given: 'a' }, { expected: 'm', given: 'n' }],
});
assert.equal(result.attempts, 3, '1 goede plaatsing + 2 missers');
assert.equal(result.correct, 1);
// de grafiek rekent wrong = attempts - correct: 2 fouten, 1 goed
assert.equal(result.attempts - result.correct, 2);
assert.equal(result.detail.correct, false, 'niet foutloos: hoort in de foutenlijst');
assert.deepEqual(result.detail.mistakes, [
{ expected: 'o', given: 'a' }, { expected: 'm', given: 'n' },
]);
assert.equal(result.detail.answer, 'a', 'eerste misser vult het gegeven antwoord');
assert.equal(result.detail.expected, 'o');
});
test('taalvoortgang: een verloren ballonwoord logt een echte fout', async () => {
const payload = await loadPayloadHelper();
const result = payload({
word: 'bloem', solved: false, expected: 'bloem', answer: 'b_o__',
mistakes: ['k', 's', 'r', 'a', 'd', 'p'].map((given) => ({ given, expected: 'bloem' })),
});
assert.equal(result.correct, 0);
assert.equal(result.stars, 0, 'geen ster voor een verloren woord');
assert.equal(result.attempts, 6);
assert.equal(result.detail.correct, false);
assert.equal(result.detail.answer, 'b_o__', 'wat de leerling nog open had staan');
assert.equal(result.detail.expected, 'bloem');
});
test('taalvoortgang: een zelfcheck zonder ingetypt antwoord blijft één poging', async () => {
const payload = await loadPayloadHelper();
const nietGelukt = payload({ word: 'schaap', solved: false, selfCheck: true });
assert.equal(nietGelukt.attempts, 1, 'ook zonder missers telt dit als één poging');
assert.equal(nietGelukt.correct, 0);
assert.equal(nietGelukt.detail.answer, null, 'er is geen antwoord om te tonen');
assert.equal(nietGelukt.detail.selfCheck, true);
const gelukt = payload({ word: 'schaap', solved: true, stars: 1, selfCheck: true });
assert.equal(gelukt.correct, 1);
assert.equal(gelukt.detail.correct, true);
});
test('taalvoortgang: het detail blijft binnen de servergrens van 1000 tekens', async () => {
const payload = await loadPayloadHelper();
const result = payload({
word: 'w'.repeat(300), solved: true, stars: 1,
mistakes: Array.from({ length: 20 }, (_, i) => ({ expected: 'x'.repeat(80), given: String(i) })),
});
assert.equal(result.detail.mistakes.length, 6, 'hooguit zes missers bewaren');
assert.equal(result.detail.question.length, 80, 'woord afgekapt');
assert.ok(JSON.stringify(result.detail).length < 1000,
'anders gooit sanitizeProgressDetail het hele detail weg');
});
/* De vier taalwidgets moeten de gedeelde helper ook echt gebruiken; de werking
in de widget zelf (drag & drop, ballonnen, zelfcheck) is in de browser
nagelopen met echte interactie. */
test('taalwidgets gebruiken de gedeelde payload en ontvangen opts', async () => {
const [letters, ball, flits, hak] = await Promise.all([
readFile('public/js/widgets/letters.js', 'utf8'),
readFile('public/js/widgets/ball.js', 'utf8'),
readFile('public/js/widgets/flits.js', 'utf8'),
readFile('public/js/widgets/hak.js', 'utf8'),
]);
for (const [name, source] of [['letters', letters], ['ball', ball], ['flits', flits], ['hak', hak]]) {
assert.match(source, /onProgress\(languageProgressPayload\(/, `${name} logt via de gedeelde vorm`);
assert.doesNotMatch(source, /onProgress\(\s*\{\s*attempts/, `${name} heeft geen losse payload meer`);
}
assert.match(flits, /function mountFlits\(root, initState, opts\)/);
assert.match(hak, /function mountHak\(root, initState, opts\)/);
// ballon logt op twee plekken: gewonnen én verloren woord
assert.equal((ball.match(/languageProgressPayload\(/g) || []).length, 2);
// letterblokken telt missers in beide invoerwijzen (toetsenbord en blokken)
assert.equal((letters.match(/S\.wordMistakes\.push\(/g) || []).length, 2);
});
test('reeks: een flitsstap wacht nu op een echt afgerond moment', async () => {
const teacher = {
id: 3, username: 'juf', role: 'teacher', school_id: 2, class_id: null,
data: {}, data_rev: 0,
};
const calls = [];
const query = async (sql, params = []) => {
calls.push({ sql, params });
if (sql.includes('FROM sessions s JOIN users u')) return { rows: [teacher] };
if (sql.includes('FROM user_roles')) return { rows: [] };
if (sql.startsWith('SELECT * FROM classes WHERE id')) return { rows: [{ id: 50, school_id: 2 }] };
if (sql.includes('FROM class_teachers')) return { rows: [{ class_id: 50 }] };
if (sql.includes('INSERT INTO assignments')) return { rows: [{ id: 90 }] };
return { rows: [] };
};
const app = Fastify({ trustProxy: 2 });
await app.register(cookie);
await app.register(rateLimit, { global: false });
app.decorate('pg', { query });
await app.register(api, { prefix: '/api' });
await app.ready();
const response = await app.inject({
method: 'PUT', url: '/api/assignments/class/50', cookies: { teach_session: 'x'.repeat(64) },
payload: {
title: 'Taalroute',
sequence: [{ widgetType: 'flits', mode: 'werken' }, { widgetType: 'hak', mode: 'werken' }],
},
});
assert.equal(response.statusCode, 200, response.body);
const insert = calls.find((call) => call.sql.includes('INSERT INTO assignments'));
const sequence = JSON.parse(insert.params[6]);
assert.equal(sequence[0].requiresProgress, true, 'flits heeft nu een zelfcheck als bewijs');
assert.equal(sequence[1].requiresProgress, true, 'hakken & plakken ook');
await app.close();
});