Voeg thematische kleurplaten en creatieve niveaus toe (v0.4.65-beta) #3
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;
|
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);}
|
.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{
|
.am-del{
|
||||||
border:1px solid var(--line); background:var(--red-soft); border-radius:var(--radius);
|
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;
|
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 */
|
/* 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{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;}
|
#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-role{font-size:11.5px; margin-left:auto;}
|
||||||
#settingsModal .am-card-sub .am-edit{flex:none;}
|
#settingsModal .am-card-sub .am-edit{flex:none;}
|
||||||
#settingsModal .am-card-sub .am-row-tools{width:100%;}
|
#settingsModal .am-card-sub .am-row-tools{width:100%;}
|
||||||
|
|
|
||||||
|
|
@ -759,6 +759,75 @@
|
||||||
URL.revokeObjectURL(url);
|
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 */
|
/* één regel in de voortgangstijdlijn van een leerling: widgettype, score en tijdstip */
|
||||||
function progressRow(ev){
|
function progressRow(ev){
|
||||||
const row = h("div","am-row");
|
const row = h("div","am-row");
|
||||||
|
|
@ -771,9 +840,11 @@
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* voortgang: leerkracht kiest een leerling (zelfde groepering per klas als
|
/* voortgang: leerkracht kiest een leerling en een vakgebied, ziet meteen
|
||||||
Toewijzingen) en ziet de volledige tijdlijn van diens afgeronde
|
het toegewezen niveau (klassenmanagement-koppeling), een grafiek
|
||||||
widget-momenten (ronde sommen, voltooid woord, geknapte ballon). */
|
(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){
|
function renderVoortgangPanel(panel){
|
||||||
const schoolChosen = currentUser.role!=="super" || !!selSchool;
|
const schoolChosen = currentUser.role!=="super" || !!selSchool;
|
||||||
if(!schoolChosen){
|
if(!schoolChosen){
|
||||||
|
|
@ -784,20 +855,56 @@
|
||||||
const pupilSel = h("select","am-sel");
|
const pupilSel = h("select","am-sel");
|
||||||
fillPupilSelect(pupilSel, pupils);
|
fillPupilSelect(pupilSel, pupils);
|
||||||
panel.appendChild(pupilSel);
|
panel.appendChild(pupilSel);
|
||||||
|
|
||||||
|
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");
|
const eventsHolder = h("div","am-list");
|
||||||
panel.appendChild(eventsHolder);
|
body.appendChild(eventsHolder);
|
||||||
const moreBtn = h("button","am-btn", T("amShowMore"));
|
const moreBtn = h("button","am-btn", T("amShowMore"));
|
||||||
moreBtn.type = "button";
|
moreBtn.type = "button";
|
||||||
moreBtn.style.display = "none";
|
body.appendChild(moreBtn);
|
||||||
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; }
|
|
||||||
/* in porties van 30: de tijdlijn kan 200 regels lang zijn */
|
/* in porties van 30: de tijdlijn kan 200 regels lang zijn */
|
||||||
let shown = 0;
|
let shown = 0;
|
||||||
const showMore = ()=>{
|
const showMore = ()=>{
|
||||||
|
|
@ -806,9 +913,45 @@
|
||||||
moreBtn.style.display = shown < events.length ? "" : "none";
|
moreBtn.style.display = shown < events.length ? "" : "none";
|
||||||
moreBtn.textContent = `${T("amShowMore")} (${events.length - shown})`;
|
moreBtn.textContent = `${T("amShowMore")} (${events.length - shown})`;
|
||||||
};
|
};
|
||||||
moreBtn.onclick = showMore;
|
|
||||||
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")));
|
panel.appendChild(h("div","am-group", T("amExportClass")));
|
||||||
const classSelExport = classSel();
|
const classSelExport = classSel();
|
||||||
|
|
|
||||||
|
|
@ -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.34-beta";
|
const VERSION = "0.4.35-beta";
|
||||||
(function(){
|
(function(){
|
||||||
const tag = document.getElementById("verTag");
|
const tag = document.getElementById("verTag");
|
||||||
tag.textContent = "v"+VERSION;
|
tag.textContent = "v"+VERSION;
|
||||||
|
|
@ -349,6 +349,9 @@ const I18N = {
|
||||||
castNoClasses:"Geen klassen in deze school.", castNoPupils:"Geen leerlingen in deze school.",
|
castNoClasses:"Geen klassen in deze school.", castNoPupils:"Geen leerlingen in deze school.",
|
||||||
castActive:"✓ Actief",
|
castActive:"✓ Actief",
|
||||||
amProgress:"Voortgang", amProgressEmpty:"Nog geen voortgang voor deze leerling.",
|
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.",
|
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",
|
||||||
|
|
@ -694,6 +697,9 @@ const I18N = {
|
||||||
castNoClasses:"No classes in this school.", castNoPupils:"No pupils in this school.",
|
castNoClasses:"No classes in this school.", castNoPupils:"No pupils in this school.",
|
||||||
castActive:"✓ Active",
|
castActive:"✓ Active",
|
||||||
amProgress:"Progress", amProgressEmpty:"No progress for this pupil yet.",
|
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.",
|
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",
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,6 @@ function mountMath(root, initState, mode, opts){
|
||||||
doneEl.querySelector(".dg").textContent = S.good>=8 ? "🌟" : S.good>=5 ? "👍" : "💪";
|
doneEl.querySelector(".dg").textContent = S.good>=8 ? "🌟" : S.good>=5 ? "👍" : "💪";
|
||||||
doneEl.classList.add("show");
|
doneEl.classList.add("show");
|
||||||
if(S.good>=8) play(sndWin);
|
if(S.good>=8) play(sndWin);
|
||||||
if(onProgress) onProgress({ attempts: ROUND, correct: S.good, stars: 0 });
|
|
||||||
}
|
}
|
||||||
function next(){
|
function next(){
|
||||||
S.done++;
|
S.done++;
|
||||||
|
|
@ -239,17 +238,25 @@ function mountMath(root, initState, mode, opts){
|
||||||
showGoodFeedback();
|
showGoodFeedback();
|
||||||
play(sndGood);
|
play(sndGood);
|
||||||
confetti(el(".mt-card"));
|
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);
|
setTimeout(next, 900);
|
||||||
}else{
|
}else{
|
||||||
S.tries++;
|
S.tries++;
|
||||||
aEl.classList.add("shake");
|
aEl.classList.add("shake");
|
||||||
setTimeout(()=>aEl.classList.remove("shake"), 400);
|
setTimeout(()=>aEl.classList.remove("shake"), 400);
|
||||||
play(sndBad);
|
play(sndBad);
|
||||||
|
const wrongAnswer = S.input;
|
||||||
S.input = ""; aEl.textContent = "";
|
S.input = ""; aEl.textContent = "";
|
||||||
if(S.tries >= 3){
|
if(S.tries >= 3){
|
||||||
S.busy = true;
|
S.busy = true;
|
||||||
fbEl.textContent = `${T("mAnsWas")} ${S.q.ans}`;
|
fbEl.textContent = `${T("mAnsWas")} ${S.q.ans}`;
|
||||||
fbEl.classList.add("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);
|
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.
|
// voortgang onder een andere leerkracht kan wegschrijven.
|
||||||
const PROGRESS_LIMIT = 200;
|
const PROGRESS_LIMIT = 200;
|
||||||
const clampCount = (n) => Math.max(0, Math.min(1000, Number(n) || 0));
|
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) => {
|
app.post('/my/progress', async (req, reply) => {
|
||||||
need(req, reply);
|
need(req, reply);
|
||||||
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
|
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 (!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');
|
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];
|
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');
|
if (!a) return fail(reply, 400, 'geen toewijzing');
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO progress_events (pupil_id, teacher_id, board_id, widget_id, widget_type, attempts, correct, 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)`,
|
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)]);
|
[req.user.id, a.teacher_id, a.board_id, widgetId, widgetType, clampCount(attempts), clampCount(correct), clampCount(stars), sanitizeProgressDetail(detail)]);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
const describeProgressEvent = (e) => ({
|
const describeProgressEvent = (e) => ({
|
||||||
|
|
@ -1073,6 +1085,7 @@ export default async function api(app) {
|
||||||
attempts: e.attempts,
|
attempts: e.attempts,
|
||||||
correct: e.correct,
|
correct: e.correct,
|
||||||
stars: e.stars,
|
stars: e.stars,
|
||||||
|
detail: e.detail || null,
|
||||||
createdAt: e.created_at,
|
createdAt: e.created_at,
|
||||||
});
|
});
|
||||||
// Een gekoppelde ouder mag de voortgang van het eigen kind zien; verder
|
// 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.equal(res.statusCode, 400, res.body);
|
||||||
assert.ok(!insertCall(calls));
|
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