From 8ccfa5dc8c75b33802a3717c366b89d65d22600b Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 19 Jul 2026 15:56:14 +0200 Subject: [PATCH] feat: voortgang flinke UI-upgrade + bug (1 opdracht i.p.v. groep) + grafiek (v0.4.35-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bug gevonden en bevestigd (niet blind aangenomen): math.js riep onProgress maar op ÉÉN plek aan, in endRound(), NA een heel blok van 10 sommen, met alleen het geaggregeerde totaal. Eén sessie van 10 sommen - "een groep opdrachten" - leverde dus precies 1 rij op in progress_events, niet 10, en maakte "welke som ging fout" onmogelijk te zien (alleen een totaaltelling per blok). - Fix: onProgress verplaatst naar check(), bij elke afgeronde som (goed op de eerste keer, of opgeven na 3 pogingen) - een sessie van 10 sommen levert nu 10 progress_events-rijen op, elk met een nieuw optioneel detail-veld (welke som, gegeven antwoord, juist antwoord). letters.js/ball.js blijven deze ronde ongemoeid (hun granulariteit veroorzaakte niet deze bug). - Nieuwe kolom progress_events.detail (JSONB, nullable, puur additief - db/020_progress_detail.sql). POST /my/progress accepteert het optioneel; te groot/geen plain object wordt stilzwijgend genegeerd zonder de rest van de log-poging te laten falen (detail is decoratief, geen kernfunctionaliteit). - Voortgang-tab (admin.js) herontworpen, zelfde ontwerptaal als de eerdere School-navigator-herbouw: vakgebied-tabs, een klassenmanagement-infobadge (Groep {n} · Niveau {n}, opgehaald via de bestaande /levels/pupil/:id-route) zodat resultaten meteen tegen het toegewezen niveau afgezet kunnen worden, een met de hand getekende SVG-nauwkeurigheidsring + staafdiagram (goed/fout per dag, laatste 14 dagen - geen library, zelfde aanpak als avatar.js) en een "recente fouten"-lijst die precies toont welke som fout ging. De bestaande ruwe tijdlijn blijft eronder staan, niets is verwijderd. - Terzijde gevonden en gefixt: .am-btn.active bestond nergens in de CSS, waardoor de vakgebied-tabs (hier én in Klassenmanagement, v0.4.34-beta) geen enkele visuele aanduiding hadden welke actief was. - Tests: test/progress-logging.test.js uitgebreid met 4 tests voor het detail-veld (opgeslagen, te groot genegeerd, geen plain object genegeerd, afwezig blijft null). Volledige testsuite: 132/132. - Live geverifieerd: een math.js-sessie met een mix van goede/foute sommen doorlopen en bevestigd dat er 3 aparte POST /my/progress- aanroepen gebeuren voor 3 sommen (niet 1 aggregaat), elk met het juiste detail; de herbouwde Voortgang-tab geopend met gemockte data en met screenshots bevestigd dat de nauwkeurigheidsring (38% = 3/8, rekenkundig geverifieerd), het klassenmanagement-infobadge, de staafdiagram en de "recente fouten"-lijst (met de daadwerkelijke som) correct tonen, én dat de active-tab-fix ook op de Klassenmanagement- tab zichtbaar is. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014EPxzBVRXZZnBPvSAaPAbJ --- VERSION | 2 +- db/020_progress_detail.sql | 7 ++ public/css/teach.css | 20 ++++ public/js/admin.js | 181 ++++++++++++++++++++++++++++++---- public/js/core.js | 8 +- public/js/widgets/math.js | 9 +- src/api.js | 21 +++- test/progress-logging.test.js | 38 +++++++ 8 files changed, 260 insertions(+), 26 deletions(-) create mode 100644 db/020_progress_detail.sql diff --git a/VERSION b/VERSION index 98ff426..c717fad 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.34-beta \ No newline at end of file +0.4.35-beta \ No newline at end of file diff --git a/db/020_progress_detail.sql b/db/020_progress_detail.sql new file mode 100644 index 0000000..ce3e69c --- /dev/null +++ b/db/020_progress_detail.sql @@ -0,0 +1,7 @@ +-- Voortgang: optioneel detail per moment (welke som/opdracht, gegeven +-- antwoord, juist antwoord) - tot nu toe werd alleen een totaaltelling per +-- afgerond blok bewaard (bv. math.js: één rij per 10 sommen), waardoor het +-- onmogelijk was te zien WELKE opdracht fout ging. Nullable en puur +-- additief: bestaande rijen en widgets die geen detail meegeven blijven +-- gewoon werken met detail = null. +ALTER TABLE progress_events ADD COLUMN IF NOT EXISTS detail JSONB; diff --git a/public/css/teach.css b/public/css/teach.css index f381a60..d1757bb 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -1449,6 +1449,9 @@ font:inherit; font-weight:700; font-size:12.5px; cursor:pointer; color:var(--ink); line-height:1; } .am-btn:hover,.am-copy:hover{background:var(--surface-3);} +/* actieve knop in een keuzerij (vakgebied-tabs in Klassenmanagement/Voortgang) - + ontbrak nog, waardoor niet te zien was welk vakgebied geselecteerd was. */ +.am-btn.active{border-color:var(--accent); background:var(--accent-soft); color:var(--accent-ink);} .am-del{ border:1px solid var(--line); background:var(--red-soft); border-radius:var(--radius); width:var(--control-h); height:var(--control-h); cursor:pointer; font-size:13px; flex:none; @@ -2243,6 +2246,23 @@ body.hc #settingsModal .am-inp, body.hc #settingsModal .am-sel{border-color:#000 /* compacte subrij binnen een kaart */ #settingsModal .am-card-sub{font-size:12.5px; padding:5px 12px 5px 30px; flex-wrap:wrap;} #settingsModal .am-card-sub .am-name{font-size:12.5px; font-weight:550; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;} + +/* Voortgang-tab: klassenmanagement-koppeling + grafiek (v0.4.35-beta e.v.) */ +#settingsModal .progress-level-badge{ + display:inline-flex; align-self:flex-start; padding:4px 10px; margin-bottom:10px; + border-radius:999px; background:var(--accent-soft); color:var(--accent-ink); + font-size:12.5px; font-weight:700; +} +#settingsModal .progress-summary{ + display:flex; align-items:center; gap:16px; padding:14px; margin-bottom:12px; + border:1px solid var(--line); border-radius:var(--radius-s); background:var(--surface); +} +#settingsModal .progress-ring{width:64px; height:64px; flex:none;} +#settingsModal .progress-ring-pct{font-size:22px; font-weight:800; fill:var(--ink);} +#settingsModal .progress-summary-text{flex:none; min-width:110px;} +#settingsModal .progress-summary-count{font-weight:700; font-size:14px; color:var(--ink);} +#settingsModal .progress-summary-hint{font-size:11.5px; color:var(--muted);} +#settingsModal .progress-bars{flex:1; min-width:0; height:56px;} #settingsModal .am-card-sub .am-role{font-size:11.5px; margin-left:auto;} #settingsModal .am-card-sub .am-edit{flex:none;} #settingsModal .am-card-sub .am-row-tools{width:100%;} diff --git a/public/js/admin.js b/public/js/admin.js index 25ff9ea..1c96378 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -759,6 +759,75 @@ URL.revokeObjectURL(url); } + /* eenvoudige, met de hand getekende SVG-grafiekjes voor de Voortgang-tab - + geen library nodig (zelfde aanpak als avatar.js dit sessie), puur + client-side berekend uit de al opgehaalde events. */ + const SVG_NS = "http://www.w3.org/2000/svg"; + function svgEl(tag, attrs){ + const el = document.createElementNS(SVG_NS, tag); + Object.entries(attrs).forEach(([k,v])=>el.setAttribute(k,v)); + return el; + } + function progressAccuracyRing(correct, total){ + const pct = total ? Math.round((correct/total)*100) : 0; + const r = 40, c = 2*Math.PI*r, offset = c*(1-pct/100); + const svg = svgEl("svg", {viewBox:"0 0 100 100", class:"progress-ring"}); + svg.appendChild(svgEl("circle", {cx:50, cy:50, r, fill:"none", stroke:"var(--surface-3)", "stroke-width":10})); + if(total){ + const color = pct>=80 ? "var(--green)" : pct>=50 ? "var(--yellow)" : "var(--red)"; + const arc = svgEl("circle", {cx:50, cy:50, r, fill:"none", stroke:color, + "stroke-width":10, "stroke-dasharray":c, "stroke-dashoffset":offset, "stroke-linecap":"round"}); + arc.setAttribute("transform", "rotate(-90 50 50)"); + svg.appendChild(arc); + } + const text = svgEl("text", {x:50, y:56, "text-anchor":"middle", class:"progress-ring-pct"}); + text.textContent = total ? pct+"%" : "–"; + svg.appendChild(text); + return svg; + } + /* goed/fout per dag, laatste 14 dagen - attempts/correct i.p.v. een simpele + goed/fout-vlag per event, zodat dit ook klopt voor oudere geaggregeerde + events (bv. een pre-upgrade rekenblok van 10 sommen: attempts:10, + correct:7 telt als 7 goed + 3 fout, niet als één enkel "fout"-moment). */ + function progressDailyBars(events){ + const days = []; + const byDay = new Map(); + const now = new Date(); + for(let i=13; i>=0; i--){ + const d = new Date(now); d.setDate(d.getDate()-i); + const key = d.toISOString().slice(0,10); + byDay.set(key, {correct:0, wrong:0}); + days.push(key); + } + events.forEach(ev=>{ + const key = new Date(ev.createdAt).toISOString().slice(0,10); + const bucket = byDay.get(key); + if(!bucket) return; + bucket.correct += ev.correct || 0; + bucket.wrong += Math.max(0, (ev.attempts||0) - (ev.correct||0)); + }); + const w = 280, h = 70, barW = w/days.length; + const max = Math.max(1, ...days.map(k=>{ const b = byDay.get(k); return b.correct+b.wrong; })); + const svg = svgEl("svg", {viewBox:`0 0 ${w} ${h}`, class:"progress-bars"}); + days.forEach((key,i)=>{ + const b = byDay.get(key); + const total = b.correct+b.wrong; + const totalH = (total/max)*(h-4); + const correctH = total ? (b.correct/total)*totalH : 0; + const wrongH = totalH-correctH; + const x = i*barW+barW*0.22, bw = barW*0.56; + if(wrongH>0) svg.appendChild(svgEl("rect", {x, y:h-totalH, width:bw, height:wrongH, fill:"var(--red)", rx:1})); + if(correctH>0) svg.appendChild(svgEl("rect", {x, y:h-totalH+wrongH, width:bw, height:correctH, fill:"var(--green)", rx:1})); + }); + return svg; + } + /* welk vakgebied hoort bij een widgetType - zelfde categorieën als + REGISTRY's cat in board.js, ook gebruikt door klassenmanagement. */ + function widgetSubject(widgetType){ + const def = REGISTRY.find(d=>d.id===widgetType); + return def ? def.cat : null; + } + /* één regel in de voortgangstijdlijn van een leerling: widgettype, score en tijdstip */ function progressRow(ev){ const row = h("div","am-row"); @@ -771,9 +840,11 @@ return row; } - /* voortgang: leerkracht kiest een leerling (zelfde groepering per klas als - Toewijzingen) en ziet de volledige tijdlijn van diens afgeronde - widget-momenten (ronde sommen, voltooid woord, geknapte ballon). */ + /* 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 + opdracht het was (waar een widget dat detail meegeeft) - de volledige + ruwe tijdlijn blijft eronder beschikbaar, niets verdwijnt. */ function renderVoortgangPanel(panel){ const schoolChosen = currentUser.role!=="super" || !!selSchool; if(!schoolChosen){ @@ -784,20 +855,56 @@ const pupilSel = h("select","am-sel"); fillPupilSelect(pupilSel, pupils); panel.appendChild(pupilSel); - const eventsHolder = h("div","am-list"); - panel.appendChild(eventsHolder); - const moreBtn = h("button","am-btn", T("amShowMore")); - moreBtn.type = "button"; - moreBtn.style.display = "none"; - panel.appendChild(moreBtn); - pupilSel.addEventListener("change", async ()=>{ - eventsHolder.innerHTML = ""; - moreBtn.style.display = "none"; - if(!pupilSel.value) return; - let events = []; - try{ events = (await api("/progress/pupil/"+pupilSel.value)).events || []; } - catch(e){ msg(e.message); return; } - if(!events.length){ eventsHolder.appendChild(h("div","guestnote", T("amProgressEmpty"))); return; } + + const subjectRow = h("div","am-inline"); + panel.appendChild(subjectRow); + const body = h("div"); + panel.appendChild(body); + + let allEvents = []; + let pvSubject = "rekenen"; + + function renderBody(){ + body.innerHTML = ""; + const events = allEvents.filter(ev=>widgetSubject(ev.widgetType)===pvSubject); + if(!events.length){ body.appendChild(h("div","guestnote", T("amProgressEmpty"))); return; } + + const totals = events.reduce((acc,ev)=>({ + correct: acc.correct + (ev.correct||0), + attempts: acc.attempts + (ev.attempts||0), + }), {correct:0, attempts:0}); + + const summary = h("div","progress-summary"); + summary.appendChild(progressAccuracyRing(totals.correct, totals.attempts)); + const summaryText = h("div","progress-summary-text"); + summaryText.appendChild(h("div","progress-summary-count", + `${totals.correct} / ${totals.attempts} ${T("mGoed")}`)); + summaryText.appendChild(h("div","progress-summary-hint", T("amProgressLast14Days"))); + summary.append(summaryText, progressDailyBars(events)); + body.appendChild(summary); + + const mistakes = events.filter(ev=>ev.detail && ev.detail.correct===false).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", new Date(ev.createdAt).toLocaleString(LANG==="nl" ? "nl-NL" : "en-GB"))); + list.appendChild(row); + }); + body.appendChild(list); + } + + body.appendChild(h("div","am-group", T("amProgressTimeline"))); + const eventsHolder = h("div","am-list"); + body.appendChild(eventsHolder); + const moreBtn = h("button","am-btn", T("amShowMore")); + moreBtn.type = "button"; + body.appendChild(moreBtn); /* in porties van 30: de tijdlijn kan 200 regels lang zijn */ let shown = 0; const showMore = ()=>{ @@ -806,9 +913,45 @@ moreBtn.style.display = shown < events.length ? "" : "none"; moreBtn.textContent = `${T("amShowMore")} (${events.length - shown})`; }; - moreBtn.onclick = showMore; showMore(); - }); + moreBtn.onclick = showMore; + } + + async function renderLevelBadge(){ + if(!pupilSel.value) return; + let lvl = null; + try{ lvl = (await api(`/levels/pupil/${pupilSel.value}?subject=${pvSubject}`)).level; }catch(e){} + const old = body.querySelector(".progress-level-badge"); + if(old) old.remove(); + const badge = h("div","progress-level-badge", + lvl ? T("amYearGroupN").replace("{n}", lvl.yearGroup) + " · " + T("lvl"+lvl.level) : T("amLevelNone")); + body.prepend(badge); + } + + async function loadPupil(){ + body.innerHTML = ""; + subjectRow.innerHTML = ""; + if(!pupilSel.value) return; + KM_SUBJECTS.forEach(s=>{ + const b = h("button","am-btn", T("amSubject_"+s)); + b.type = "button"; + b.classList.toggle("active", s===pvSubject); + b.addEventListener("click", ()=>{ + pvSubject = s; + subjectRow.querySelectorAll("button").forEach(x=>x.classList.remove("active")); + b.classList.add("active"); + renderBody(); + renderLevelBadge(); + }); + subjectRow.appendChild(b); + }); + body.appendChild(h("div","guestnote", "…")); + try{ allEvents = (await api("/progress/pupil/"+pupilSel.value)).events || []; } + catch(e){ body.innerHTML = ""; body.appendChild(h("div","am-msg", e.message)); return; } + renderBody(); + renderLevelBadge(); + } + pupilSel.addEventListener("change", loadPupil); panel.appendChild(h("div","am-group", T("amExportClass"))); const classSelExport = classSel(); diff --git a/public/js/core.js b/public/js/core.js index 92405c0..7bba819 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.34-beta"; +const VERSION = "0.4.35-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -349,6 +349,9 @@ const I18N = { castNoClasses:"Geen klassen in deze school.", castNoPupils:"Geen leerlingen in deze school.", castActive:"✓ Actief", amProgress:"Voortgang", amProgressEmpty:"Nog geen voortgang voor deze leerling.", + amProgressLast14Days:"Laatste 14 dagen", amProgressMistakes:"Recente fouten", + amProgressTimeline:"Volledige tijdlijn", + amProgressGiven:"Gegeven: {answer}", amProgressExpected:"Juist: {answer}", 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", @@ -694,6 +697,9 @@ const I18N = { castNoClasses:"No classes in this school.", castNoPupils:"No pupils in this school.", castActive:"✓ Active", amProgress:"Progress", amProgressEmpty:"No progress for this pupil yet.", + amProgressLast14Days:"Last 14 days", amProgressMistakes:"Recent mistakes", + amProgressTimeline:"Full timeline", + amProgressGiven:"Given: {answer}", amProgressExpected:"Correct: {answer}", 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/widgets/math.js b/public/js/widgets/math.js index 022f92d..5c6620d 100644 --- a/public/js/widgets/math.js +++ b/public/js/widgets/math.js @@ -223,7 +223,6 @@ function mountMath(root, initState, mode, opts){ doneEl.querySelector(".dg").textContent = S.good>=8 ? "🌟" : S.good>=5 ? "👍" : "💪"; doneEl.classList.add("show"); if(S.good>=8) play(sndWin); - if(onProgress) onProgress({ attempts: ROUND, correct: S.good, stars: 0 }); } function next(){ S.done++; @@ -239,17 +238,25 @@ function mountMath(root, initState, mode, opts){ showGoodFeedback(); play(sndGood); confetti(el(".mt-card")); + /* per som loggen (niet meer één keer per blok van 10 in endRound()) - + zo levert een sessie ook echt een groep aparte momenten op in de + voortgang, en weet de leerkracht welke som het was. */ + if(onProgress) onProgress({ attempts: S.tries+1, correct: 1, stars: 0, + detail: { question: S.q.txt, answer: +S.input, expected: S.q.ans, correct: true } }); setTimeout(next, 900); }else{ S.tries++; aEl.classList.add("shake"); setTimeout(()=>aEl.classList.remove("shake"), 400); play(sndBad); + const wrongAnswer = S.input; S.input = ""; aEl.textContent = ""; if(S.tries >= 3){ S.busy = true; fbEl.textContent = `${T("mAnsWas")} ${S.q.ans}`; fbEl.classList.add("ans"); + if(onProgress) onProgress({ attempts: S.tries, correct: 0, stars: 0, + detail: { question: S.q.txt, answer: +wrongAnswer, expected: S.q.ans, correct: false } }); setTimeout(next, 1800); } } diff --git a/src/api.js b/src/api.js index 278a693..5456ff0 100644 --- a/src/api.js +++ b/src/api.js @@ -1041,10 +1041,22 @@ export default async function api(app) { // voortgang onder een andere leerkracht kan wegschrijven. const PROGRESS_LIMIT = 200; const clampCount = (n) => Math.max(0, Math.min(1000, Number(n) || 0)); + // Optioneel detail (welke opdracht - som/woord - en het gegeven/juiste + // antwoord): decoratief, nooit een reden om de hele log-poging te laten + // falen. Alleen bewaard als het een plain object is en niet absurd groot; + // anders stilzwijgend genegeerd (detail = null), de rest van de melding + // (attempts/correct/stars) wordt gewoon gelogd. + const sanitizeProgressDetail = (detail) => { + if (!detail || typeof detail !== 'object' || Array.isArray(detail)) return null; + try { + if (JSON.stringify(detail).length > 1000) return null; + } catch { return null; } + return detail; + }; app.post('/my/progress', async (req, reply) => { need(req, reply); if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen'); - const { widgetId, widgetType, attempts, correct, stars } = req.body ?? {}; + const { widgetId, widgetType, attempts, correct, stars, detail } = req.body ?? {}; if (!widgetId || typeof widgetId !== 'string' || widgetId.length > 100) return fail(reply, 400, 'ongeldige widgetId'); if (!widgetType || typeof widgetType !== 'string' || widgetType.length > 40) return fail(reply, 400, 'ongeldige widgetType'); let a = (await pool.query('SELECT * FROM assignments WHERE pupil_id = $1', [req.user.id])).rows[0]; @@ -1062,9 +1074,9 @@ export default async function api(app) { } if (!a) return fail(reply, 400, 'geen toewijzing'); await pool.query( - `INSERT INTO progress_events (pupil_id, teacher_id, board_id, widget_id, widget_type, attempts, correct, stars) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, - [req.user.id, a.teacher_id, a.board_id, widgetId, widgetType, clampCount(attempts), clampCount(correct), clampCount(stars)]); + `INSERT INTO progress_events (pupil_id, teacher_id, board_id, widget_id, widget_type, attempts, correct, stars, detail) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, + [req.user.id, a.teacher_id, a.board_id, widgetId, widgetType, clampCount(attempts), clampCount(correct), clampCount(stars), sanitizeProgressDetail(detail)]); return { ok: true }; }); const describeProgressEvent = (e) => ({ @@ -1073,6 +1085,7 @@ export default async function api(app) { attempts: e.attempts, correct: e.correct, stars: e.stars, + detail: e.detail || null, createdAt: e.created_at, }); // Een gekoppelde ouder mag de voortgang van het eigen kind zien; verder diff --git a/test/progress-logging.test.js b/test/progress-logging.test.js index 9306cd5..0565e7e 100644 --- a/test/progress-logging.test.js +++ b/test/progress-logging.test.js @@ -101,3 +101,41 @@ test('voortgang: ontbrekende widgetId/widgetType levert 400 op, niet een kapotte assert.equal(res.statusCode, 400, res.body); assert.ok(!insertCall(calls)); }); + +/* "1 opdracht i.p.v. een groep" - de fix zelf zit in math.js (per som loggen + i.p.v. per blok van 10), maar het datamodel/de server moeten het optionele + detail (welke som/welk antwoord) wel kunnen bewaren zonder de rest van de + voortgang-logging te breken bij rommelige/te grote input. */ +test('voortgang: detail (welke opdracht) wordt meegegeven aan de insert', async () => { + const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0 }; + const { app, calls } = await makeApp(pupil, { assignmentRows: { byPupil: { teacher_id: 3, board_id: 'bord-a' } } }); + const detail = { question: '7 + 8 =', answer: 14, expected: 15, correct: false }; + const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: { ...validBody, detail } }); + assert.equal(res.statusCode, 200, res.body); + assert.deepEqual(insertCall(calls).params[8], detail); +}); + +test('voortgang: een te grote detail wordt genegeerd zonder de hele log-poging te laten falen', async () => { + const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0 }; + const { app, calls } = await makeApp(pupil, { assignmentRows: { byPupil: { teacher_id: 3, board_id: 'bord-a' } } }); + const detail = { question: 'x'.repeat(2000) }; + const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: { ...validBody, detail } }); + assert.equal(res.statusCode, 200, res.body); + assert.equal(insertCall(calls).params[8], null); +}); + +test('voortgang: een detail dat geen plain object is (array/string) wordt genegeerd', async () => { + const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0 }; + const { app, calls } = await makeApp(pupil, { assignmentRows: { byPupil: { teacher_id: 3, board_id: 'bord-a' } } }); + const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: { ...validBody, detail: ['niet', 'plain'] } }); + assert.equal(res.statusCode, 200, res.body); + assert.equal(insertCall(calls).params[8], null); +}); + +test('voortgang: zonder detail-veld blijft alles zoals voorheen (null, geen fout)', async () => { + const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0 }; + const { app, calls } = await makeApp(pupil, { assignmentRows: { byPupil: { teacher_id: 3, board_id: 'bord-a' } } }); + const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: validBody }); + assert.equal(res.statusCode, 200, res.body); + assert.equal(insertCall(calls).params[8], null); +});