diff --git a/VERSION b/VERSION index a9ce227..acfd48e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.62-beta +0.4.63-beta diff --git a/public/css/teach.css b/public/css/teach.css index c901283..cb6adbf 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -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 .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} -.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)} .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)} diff --git a/public/js/admin.js b/public/js/admin.js index 82571e6..6b9c330 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -982,6 +982,27 @@ 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 het toegewezen niveau (klassenmanagement-koppeling), een grafiek (nauwkeurigheid + goed/fout per dag) en de recentste fouten mét welke @@ -1025,16 +1046,15 @@ summary.append(summaryText, progressDailyBars(events)); 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){ body.appendChild(h("div","am-group", T("amProgressMistakes"))); const list = h("div","am-list"); mistakes.forEach(ev=>{ const row = h("div","am-row"); row.appendChild(h("span","am-name", ev.detail.question||"")); - row.appendChild(h("span","am-code", - T("amProgressGiven").replace("{answer}", ev.detail.answer) + " · " + - T("amProgressExpected").replace("{answer}", ev.detail.expected))); + row.appendChild(h("span","am-code", progressMistakeLine(ev))); row.appendChild(h("span","am-code", new Date(ev.createdAt).toLocaleString(LANG==="nl" ? "nl-NL" : "en-GB"))); list.appendChild(row); }); diff --git a/public/js/core.js b/public/js/core.js index 9178383..2a82781 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.4.62-beta"; +const VERSION = "0.4.63-beta"; /* Safari en oudere mobiele browsers behandelen 100vh/100% soms als de hoogte achter hun adres- en navigatiebalk. visualViewport geeft de werkelijk 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", 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.", + 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_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)", @@ -439,6 +440,7 @@ const I18N = { amProgressLast14Days:"Laatste 14 dagen", amProgressMistakes:"Recente fouten", amProgressTimeline:"Volledige tijdlijn", 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.", amExportColPupil:"Leerling", amExportColWidget:"Widget", amExportColAttempts:"Pogingen", 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", 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.", + 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_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)", @@ -861,6 +864,7 @@ const I18N = { amProgressLast14Days:"Last 14 days", amProgressMistakes:"Recent mistakes", amProgressTimeline:"Full timeline", 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.", amExportColPupil:"Pupil", amExportColWidget:"Widget", amExportColAttempts:"Attempts", amExportColCorrect:"Correct", amExportColStars:"Stars", amExportColDate:"Date", diff --git a/public/js/learning-progressions.js b/public/js/learning-progressions.js index 6afdf6f..ef2e1db 100644 --- a/public/js/learning-progressions.js +++ b/public/js/learning-progressions.js @@ -105,6 +105,41 @@ function languageTaskRules(yearGroup,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} } +/* 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){ 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]; diff --git a/public/js/widgets/ball.js b/public/js/widgets/ball.js index a46d0b6..1c9d4ae 100644 --- a/public/js/widgets/ball.js +++ b/public/js/widgets/ball.js @@ -4,7 +4,7 @@ ==========================================================*/ function mountBall(root, initState, opts){ 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:"";} const assignedRules=languageTaskRules(S.yearGroup,S.level),maxLives=assignedRules?.lives||6; const play = f=>{ if(S.snd) f(); }; @@ -56,7 +56,7 @@ function mountBall(root, initState, opts){ S.order = shuffle(list().map((_,i)=>i)); 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"; nextBtn.classList.remove("show"); 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")); nextBtn.classList.add("show"); 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{ S.lives--; + S.wrong.push(l); play(sndBad); drawBalloons(); if(S.lives <= 0){ @@ -90,6 +93,12 @@ function mountBall(root, initState, opts){ msgEl.textContent = `${T("ballLose")} ${word()}`; msgEl.classList.add("lose"); 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 })); } } } diff --git a/public/js/widgets/flits.js b/public/js/widgets/flits.js index fd0e8f7..3432108 100644 --- a/public/js/widgets/flits.js +++ b/public/js/widgets/flits.js @@ -2,7 +2,8 @@ /* ========================================================= 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:"" }; 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; @@ -81,6 +82,11 @@ function mountFlits(root, initState){ function assess(ok){ if(assessed)return;assessed=true; 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; } el(".fl-flash").addEventListener("click", flash); diff --git a/public/js/widgets/hak.js b/public/js/widgets/hak.js index 2620166..2ac819c 100644 --- a/public/js/widgets/hak.js +++ b/public/js/widgets/hak.js @@ -18,8 +18,14 @@ function soundChunks(word, lang=LANG){ /* ========================================================= 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:"" }; + /* 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:"";} const canSpeak = "speechSynthesis" in window; @@ -34,6 +40,10 @@ function mountHak(root, initState){
+
@@ -43,7 +53,7 @@ function mountHak(root, initState){
`; const el = s => root.querySelector(s); 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(){ 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 speak(word,0.85); }); - el(".hk-chop").addEventListener("click", ()=>{ S.chopped = true; display(); play(sndGood); }); - el(".hk-glue").addEventListener("click", ()=>{ S.chopped = false; display(); play(sndGood); }); + function assess(ok){ + 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", ()=>{ 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(); srcSel.addEventListener("change", ()=>{ S.card = srcSel.value; - newRound(); S.chopped = false; display(); + newRound(); S.chopped = false; assessed = false; reflect.hidden = true; display(); }); function labels(){ 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-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-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(); } - document.addEventListener("langchange", ()=>{ if(!S.assignmentWords)S.card=0; labels(); newRound(); S.chopped=false; display(); }); - document.addEventListener("wordschange", ()=>{ buildSrc(); 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; assessed=false; reflect.hidden=true; display(); }); labels(); newRound(); display(); root.classList.toggle("managed-learning-level",S.managedLearningLevel); return { getState: ()=>({card:S.card}) }; diff --git a/public/js/widgets/letters.js b/public/js/widgets/letters.js index 666ca92..f6b222a 100644 --- a/public/js/widgets/letters.js +++ b/public/js/widgets/letters.js @@ -2,7 +2,9 @@ function mountLetterGame(root, initState, opts){ const readonly = !!(opts && opts.readonly); 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){ S.level = initState.level||1; S.yearGroup = initState.yearGroup||null; @@ -145,6 +147,7 @@ function mountLetterGame(root, initState, opts){ play(sndGood); checkComplete(); }else{ + S.wordMistakes.push({expected: slot.dataset.letter, given: l}); slot.classList.add("shake"); setTimeout(()=>slot.classList.remove("shake"), 400); play(sndBad); @@ -256,6 +259,7 @@ function mountLetterGame(root, initState, opts){ if(allOpt) allOpt.textContent = T("all"); const [word, pic] = currentWord(); + S.wordMistakes = []; /* nieuw woord op het scherm: missers tellen per woord */ const literacyMode=languageTaskRules(S.yearGroup,S.level)?.literacyMode||"words"; setPic(picEl, pic); 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.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(){ @@ -752,6 +760,7 @@ function mountLetterGame(root, initState, opts){ play(sndGood); checkComplete(); }else{ + S.wordMistakes.push({expected: target.dataset.letter, given: block.dataset.letter}); target.classList.add("shake"); setTimeout(()=>target.classList.remove("shake"), 400); play(sndBad); diff --git a/src/api.js b/src/api.js index a2bea4d..05c5051 100644 --- a/src/api.js +++ b/src/api.js @@ -881,7 +881,11 @@ export default async function api(app) { ]); const ASSIGNMENT_LIBRARY_BOARD = 'assignment-library'; 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) => { if (rawTheme == null) return { theme: null }; if (!THEMED_ASSIGNMENT_WIDGETS.has(widgetType)) diff --git a/test/language-progress.test.js b/test/language-progress.test.js new file mode 100644 index 0000000..0f062c1 --- /dev/null +++ b/test/language-progress.test.js @@ -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(); +});