feat: voortgang flinke UI-upgrade + bug (1 opdracht i.p.v. groep) + grafiek (v0.4.35-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m17s
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m17s
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EPxzBVRXZZnBPvSAaPAbJ
This commit is contained in:
parent
e7bb76b76d
commit
8ccfa5dc8c
8 changed files with 260 additions and 26 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.4.34-beta
|
||||
0.4.35-beta
|
||||
7
db/020_progress_detail.sql
Normal file
7
db/020_progress_detail.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -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%;}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
21
src/api.js
21
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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue