diff --git a/VERSION b/VERSION index 4f42abb..8489daf 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.54-beta +0.4.55-beta diff --git a/db/021_learning_paths.sql b/db/021_learning_paths.sql new file mode 100644 index 0000000..feb55ac --- /dev/null +++ b/db/021_learning_paths.sql @@ -0,0 +1,24 @@ +ALTER TABLE assignments + ADD COLUMN IF NOT EXISTS title TEXT, + ADD COLUMN IF NOT EXISTS sequence JSONB NOT NULL DEFAULT '[]'::jsonb; + +CREATE TABLE IF NOT EXISTS assignment_step_progress ( + assignment_id BIGINT NOT NULL REFERENCES assignments(id) ON DELETE CASCADE, + pupil_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + step_id TEXT NOT NULL, + completed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (assignment_id, pupil_id, step_id) +); + +CREATE INDEX IF NOT EXISTS idx_assignment_step_progress_pupil + ON assignment_step_progress (pupil_id, assignment_id); + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS reward_points INTEGER NOT NULL DEFAULT 0; + +CREATE TABLE IF NOT EXISTS user_avatar_rewards ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + reward_key TEXT NOT NULL, + unlocked_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, reward_key) +); diff --git a/public/css/teach.css b/public/css/teach.css index 4c67de5..4354849 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -164,6 +164,18 @@ box-shadow:0 2px 8px rgba(30,50,90,.15); } .pupil-empty{margin:auto; color:var(--muted); font-size:16px; font-weight:600;} + .pupil-path{flex:1 0 100%;display:grid;grid-template-columns:1fr auto;gap:5px 16px;max-width:860px;margin:0 auto;padding:14px 18px;box-sizing:border-box;border:1px solid var(--line);border-radius:16px;background:var(--surface);box-shadow:0 5px 18px rgba(30,50,90,.08)} + .pupil-path-title{font-size:18px;font-weight:900;color:var(--ink)} + .pupil-path-progress{font-size:13px;font-weight:800;color:var(--muted)} + .pupil-path-dots{grid-column:1/-1;display:flex;align-items:center;gap:7px;overflow:auto} + .pupil-path-dots span{width:28px;height:28px;flex:none;display:grid;place-items:center;border-radius:50%;background:var(--surface-2);border:2px solid var(--line);color:var(--muted);font-weight:900;font-size:12px} + .pupil-path-dots span.complete{background:var(--green);border-color:var(--green);color:#fff} + .pupil-path-dots span.current{border-color:var(--accent);color:var(--accent-ink);background:var(--accent-soft)} + .pupil-path-actions{flex:1 0 100%;display:flex;justify-content:center;padding:4px 0 24px} + .pupil-path-actions button{min-height:44px;padding:0 22px} + .pupil-path-finish{position:relative;overflow:hidden;min-height:280px;flex:1 0 100%;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px;text-align:center;border-radius:20px;background:linear-gradient(145deg,var(--accent-soft),var(--surface))} + .pupil-path-finish strong{font-size:clamp(24px,5vw,38px);color:var(--accent-ink)} + .pupil-path-unlocked{font-weight:900;color:var(--green)} /* ---------- Ouderportaal ---------- */ /* ouders zien geen whiteboard: alleen het portaal met hun kinderen */ #parentView{display:none;} @@ -1802,12 +1814,14 @@ body.hc #penbar .pen-current,body.hc #penbar .pen-popover{border:2px solid #000; .avatar-dance-stage.av-dance-spin .avatar-face{animation:avDanceSpin 2.8s cubic-bezier(.4,0,.2,1) both;} .avatar-dance-stage.av-dance-shuffle .avatar-face{animation:avDanceShuffle 2.8s ease-in-out both;} .avatar-dance-stage.av-dance-wiggle .avatar-face{animation:avDanceWiggle 2.8s ease-in-out both;} +.avatar-dance-stage.av-dance-robot .avatar-face{animation:avDanceRobot 2.8s steps(2,end) both;} @keyframes avDanceStage{0%{opacity:0}8%,82%{opacity:1}100%{opacity:0}} @keyframes avDanceBounce{0%,100%{transform:translateY(0) scale(1)}10%,36%,62%{transform:translateY(5px) scale(1.08,.92)}22%,48%,74%{transform:translateY(-34px) scale(.96,1.04)}86%{transform:translateY(-12px) rotate(5deg)}} @keyframes avDanceTwist{0%,100%{transform:rotate(0) translateX(0)}12%,36%,60%,84%{transform:rotate(-13deg) translateX(-10px)}24%,48%,72%{transform:rotate(13deg) translateX(10px)}90%{transform:scale(1.08)}} @keyframes avDanceSpin{0%,10%,100%{transform:rotate(0) scale(1)}20%{transform:translateY(-12px) rotate(-12deg)}62%{transform:translateY(-20px) rotate(372deg) scale(1.08)}78%{transform:translateY(0) rotate(360deg) scale(1)}88%{transform:scale(1.1,.9)}} @keyframes avDanceShuffle{0%,100%{transform:translateX(0) rotate(0)}12%,36%,60%{transform:translateX(-30px) translateY(-7px) rotate(-7deg)}24%,48%,72%{transform:translateX(30px) translateY(0) rotate(7deg)}82%{transform:translateX(-10px) scale(1.08,.92)}90%{transform:translateX(10px) scale(.96,1.04)}} @keyframes avDanceWiggle{0%,100%{transform:rotate(0) scale(1)}10%,30%,50%,70%{transform:rotate(-10deg) scale(1.04)}20%,40%,60%,80%{transform:rotate(10deg) scale(1.04)}88%{transform:translateY(-24px) scale(.95,1.08)}94%{transform:translateY(0) scale(1.1,.9)}} +@keyframes avDanceRobot{0%,100%{transform:translate(0) rotate(0)}15%{transform:translateX(-24px) rotate(-8deg)}30%{transform:translateX(-24px) rotate(8deg)}45%{transform:translateY(-26px) scale(1.06)}60%{transform:translateX(24px) rotate(8deg)}75%{transform:translateX(24px) rotate(-8deg)}90%{transform:translateY(-10px) scale(.96,1.08)}} .avatar-dance-stage.av-dance-reduced{animation:none;} .avatar-dance-stage.av-dance-reduced .avatar-face{animation:none;transform:none;} .avatar-editor{display:flex; flex-direction:column; gap:12px; padding:14px; height:100%; min-height:0; overflow:auto;} @@ -2408,6 +2422,35 @@ body.hc #settingsModal .am-inp, body.hc #settingsModal .am-sel{border-color:#000 #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;} +/* Opdrachtenreeks in het cast-venster */ +.cast-path-list{display:flex;flex-direction:column;gap:6px;margin-top:8px} +.cast-path-step{display:grid;grid-template-columns:28px minmax(0,1fr) auto auto auto;align-items:center;gap:6px;padding:7px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2)} +.cast-path-number{width:24px;height:24px;display:grid;place-items:center;border-radius:50%;background:var(--accent);color:#fff;font-size:12px;font-weight:900} +.cast-path-step .am-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.cast-path-step .am-btn{min-width:30px;padding:4px 7px} + +/* Leerkrachtdashboard: scanbare status per leerling */ +#settingsModal .teacher-dashboard{margin:10px 0 20px} +#settingsModal .dashboard-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-bottom:10px} +#settingsModal .dashboard-summary-chip{display:flex;align-items:center;gap:7px;padding:9px 10px;border-radius:10px;background:var(--surface-2);color:var(--muted)} +#settingsModal .dashboard-summary-chip strong{font-size:20px;color:var(--ink)} +#settingsModal .dashboard-summary-chip span{font-size:11px;font-weight:800} +#settingsModal .dashboard-summary-chip.complete{box-shadow:inset 3px 0 var(--green)} +#settingsModal .dashboard-summary-chip.active{box-shadow:inset 3px 0 var(--blue)} +#settingsModal .dashboard-summary-chip.support{box-shadow:inset 3px 0 var(--orange)} +#settingsModal .dashboard-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:8px} +#settingsModal .dashboard-card{display:flex;flex-direction:column;align-items:stretch;gap:5px;text-align:left;padding:11px;border:1px solid var(--line);border-radius:12px;background:var(--surface);color:var(--ink);font:inherit;cursor:pointer} +#settingsModal .dashboard-card:hover{border-color:var(--accent);box-shadow:0 4px 14px rgba(30,50,90,.1)} +#settingsModal .dashboard-status{align-self:flex-start;padding:2px 7px;border-radius:99px;background:var(--surface-2);color:var(--muted);font-size:10px;font-weight:900} +#settingsModal .dashboard-card.complete .dashboard-status{background:color-mix(in srgb,var(--green) 16%,var(--surface));color:var(--green)} +#settingsModal .dashboard-card.support .dashboard-status{background:color-mix(in srgb,var(--orange) 16%,var(--surface));color:var(--orange)} +#settingsModal .dashboard-name{font-size:14px} +#settingsModal .dashboard-path{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +#settingsModal .dashboard-path-bar{display:block;height:5px;border-radius:99px;background:var(--surface-3);overflow:hidden} +#settingsModal .dashboard-path-bar i{display:block;height:100%;border-radius:inherit;background:var(--green)} +#settingsModal .dashboard-score{font-size:11.5px;font-weight:800;color:var(--muted)} +@media (max-width:600px){#settingsModal .dashboard-summary{grid-template-columns:1fr 1fr}.cast-path-step{grid-template-columns:26px minmax(0,1fr) auto auto auto}} + /* 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; diff --git a/public/js/admin.js b/public/js/admin.js index 7041fc2..8ef9b6b 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -725,6 +725,52 @@ panel.appendChild(h("div","am-group", T("amAssignPupils"))); const pupils = USERS.filter(u=>u.role==="pupil"); + + panel.appendChild(h("div","am-group", T("amDashboardTitle"))); + const dashboardClass = classSel(); + panel.appendChild(dashboardClass); + const dashboardBody = h("div","teacher-dashboard"); + panel.appendChild(dashboardBody); + async function loadDashboard(){ + dashboardBody.innerHTML = ""; + if(!dashboardClass.value){ dashboardBody.appendChild(h("div","guestnote",T("amPickClassHint"))); return; } + dashboardBody.appendChild(h("div","guestnote","…")); + try{ + const data = await api("/progress/dashboard/class/"+dashboardClass.value); + dashboardBody.innerHTML = ""; + const summary = h("div","dashboard-summary"); + ["complete","active","support","notStarted"].forEach(status=>{ + const chip = h("div","dashboard-summary-chip "+status); + chip.appendChild(h("strong",null,String(data.summary?.[status]||0))); + chip.appendChild(h("span",null,T("amDashboardStatus_"+status))); + summary.appendChild(chip); + }); + dashboardBody.appendChild(summary); + if(!(data.pupils||[]).length){ dashboardBody.appendChild(h("div","guestnote",T("castNoPupils"))); return; } + const grid = h("div","dashboard-grid"); + (data.pupils||[]).forEach(pupil=>{ + const card = h("button","dashboard-card "+pupil.status); + card.type = "button"; + card.appendChild(h("span","dashboard-status",T("amDashboardStatus_"+pupil.status))); + card.appendChild(h("strong","dashboard-name",pupil.pupilName)); + if(pupil.sequence){ + card.appendChild(h("span","dashboard-path",pupil.sequence.title)); + const bar = h("span","dashboard-path-bar"); + const fill = h("i"); fill.style.width = Math.round((pupil.sequence.completed/pupil.sequence.total)*100)+"%"; + bar.appendChild(fill); card.appendChild(bar); + card.appendChild(h("span","am-role",T("amDashboardSteps").replace("{done}",pupil.sequence.completed).replace("{total}",pupil.sequence.total))); + } + card.appendChild(h("span","dashboard-score",pupil.attempts ? pupil.accuracy+"% · "+pupil.correct+"/"+pupil.attempts : T("amDashboardNoActivity"))); + card.addEventListener("click",()=>{ pupilSel.value=String(pupil.pupilId); pupilSel.dispatchEvent(new Event("change")); }); + grid.appendChild(card); + }); + dashboardBody.appendChild(grid); + }catch(e){ dashboardBody.innerHTML=""; dashboardBody.appendChild(h("div","am-msg",e.message)); } + } + dashboardClass.addEventListener("change",loadDashboard); + loadDashboard(); + + panel.appendChild(h("div","am-group", T("amProgressDetail"))); const pupilSel = h("select","am-sel"); fillPupilSelect(pupilSel, pupils); panel.appendChild(pupilSel); diff --git a/public/js/avatar.js b/public/js/avatar.js index 70e8303..98f4b86 100644 --- a/public/js/avatar.js +++ b/public/js/avatar.js @@ -12,9 +12,19 @@ const AVATAR_VARIANTS = Object.freeze({ animal: ["cat", "dog", "rabbit", "bear"], }); const AVATAR_HAIR = ["none", "tuft", "spiky", "curly", "wavy", "bob", "ponytail", "braids", "mohawk"]; -const AVATAR_HEAD_ACC = ["none", "cap", "beanie", "crown", "wizardhat", "glasses", "sunglasses", "eyepatch", "bow", "flower", "headphones", "partyhat"]; -const AVATAR_BODY_ACC = ["none", "scarf", "cape", "badge", "necklace", "bowtie", "belt", "backpack", "satchel", "wings"]; -const AVATAR_DANCES = Object.freeze(["bounce", "twist", "spin", "shuffle", "wiggle"]); +const AVATAR_HEAD_ACC = ["none", "cap", "beanie", "crown", "wizardhat", "glasses", "sunglasses", "eyepatch", "bow", "flower", "headphones", "partyhat", "star"]; +const AVATAR_BODY_ACC = ["none", "scarf", "cape", "badge", "necklace", "bowtie", "belt", "backpack", "satchel", "wings", "medal"]; +const AVATAR_DANCES = Object.freeze(["bounce", "twist", "spin", "shuffle", "wiggle", "robot"]); +const AVATAR_REWARD_ITEMS = Object.freeze({star:"head:star", medal:"body:medal", robot:"dance:robot"}); +let avatarRewardKeys = new Set(); +let avatarRewardPoints = 0; +async function loadAvatarRewards(){ + const data = await api("/me/avatar-rewards"); + avatarRewardKeys = new Set(data.rewards || []); + avatarRewardPoints = Number(data.points || 0); + return data; +} +function avatarAcceptRewards(keys){ (keys || []).forEach(key=>avatarRewardKeys.add(key)); } const AVATAR_DEFAULT = Object.freeze({ species: "dino", speciesVariant: "rex", bodyColor: "#3fae6a", hairStyle: "none", hairColor: "#5b3a29", accessoryHead: "none", accessoryBody: "none", @@ -382,6 +392,9 @@ const AVATAR_HEAD_ACC_MARKUP = { `, + star: () => ` + + `, }; /* Oogaccessoires volgen afwijkende oogindelingen van cycloop en alien. */ @@ -473,6 +486,13 @@ const AVATAR_BODY_ACC_MARKUP = { `, front: () => ``, }, + medal: { + back: () => "", + front: () => ` + + + `, + }, }; /* Pet en muts bedekken de voorste haarlaag. Lange stijlen houden hun @@ -511,9 +531,10 @@ function renderAvatarFace(config, size){ anatomische ankers blijven. Zonder expliciete keuze rouleren de dansen. */ let avatarDanceCursor = -1; function avatarDanceChoice(preferred){ - if(AVATAR_DANCES.includes(preferred)) return preferred; - avatarDanceCursor = (avatarDanceCursor + 1) % AVATAR_DANCES.length; - return AVATAR_DANCES[avatarDanceCursor]; + const available = AVATAR_DANCES.filter(dance=>dance !== "robot" || avatarRewardKeys.has("dance:robot")); + if(available.includes(preferred)) return preferred; + avatarDanceCursor = (avatarDanceCursor + 1) % available.length; + return available[avatarDanceCursor]; } function celebrateAvatar(host, options){ if(!host || typeof currentUser === "undefined" || !currentUser || !currentUser.avatar) return null; @@ -628,9 +649,17 @@ function buildAvatarEditor(container, initialConfig, onChange){ hairSpan.textContent = T("avatarHairStyle"); [...hairSel.options].forEach(o=>o.textContent = T("avatarHair_" + o.value)); headAccSpan.textContent = T("avatarAccHead"); - [...headAccSel.options].forEach(o=>o.textContent = T("avatarAcc_" + o.value)); + [...headAccSel.options].forEach(o=>{ + const key = AVATAR_REWARD_ITEMS[o.value]; + o.disabled = !!key && !avatarRewardKeys.has(key); + o.textContent = T("avatarAcc_" + o.value) + (o.disabled ? " 🔒" : ""); + }); bodyAccSpan.textContent = T("avatarAccBody"); - [...bodyAccSel.options].forEach(o=>o.textContent = T("avatarAcc_" + o.value)); + [...bodyAccSel.options].forEach(o=>{ + const key = AVATAR_REWARD_ITEMS[o.value]; + o.disabled = !!key && !avatarRewardKeys.has(key); + o.textContent = T("avatarAcc_" + o.value) + (o.disabled ? " 🔒" : ""); + }); } function update(){ const variants = AVATAR_VARIANTS[config.species]; @@ -688,7 +717,7 @@ async function saveAvatar(config){ id: "avatar", icon: "🦖", group: "personal", order: 3, label: ()=>T("stAvatar"), sub: ()=>T("stAvatarSub"), visible: ()=>!!currentUser, - activate(){ showSettingsDom("avatar"); build(); }, + activate(){ showSettingsDom("avatar"); loadAvatarRewards().then(build).catch(build); }, keywords: ()=>[T("avatarSpecies_dino"), T("avatarSpecies_monster"), T("avatarSpecies_animal"), ...Object.values(AVATAR_VARIANTS).flat().map(id=>T("avatarVariant_" + id)), "personage","avatar","dinosaurus","monster","dier","haar","hair","kleur","color"], @@ -719,7 +748,7 @@ function mountAvatarWidget(root){ host.appendChild(saveBtn); host.appendChild(msg); } - build(); + loadAvatarRewards().then(build).catch(build); document.addEventListener("langchange", ()=>{ root.querySelector(".play-mission").textContent = T("wg_avatar_d"); }); /* currentUser.avatar is de bron van waarheid (server), niet het widget-eigen initState - geen dubbele opslag van dezelfde data op twee plekken. */ diff --git a/public/js/cast.js b/public/js/cast.js index 6346b1a..4861cc2 100644 --- a/public/js/cast.js +++ b/public/js/cast.js @@ -26,6 +26,7 @@ let castOpen = false; let castLoading = false; let castClasses = [], castPupils = []; let castSelWidget = "", castSelMode = "werken"; +let castPathTitle = "", castPathSteps = []; /* toewijzingen die deze sessie al gelukt zijn ("type:id"), zodat je hetzelfde bord naar meerdere klassen/leerlingen achter elkaar kunt casten zonder dat de sheet na elke keer sluit */ @@ -34,13 +35,18 @@ let castError = ""; /* widgets van het live-open bord, zelfde bron als serializeBoard() (board.js) */ function currentBoardWidgets(){ - return [...instances].map(inst=>({ wid: inst.wid, id: inst.def.id })).filter(w=>w.wid); + return [...instances].map(inst=>({ wid: inst.wid, id: inst.def.id, cat: inst.def.cat })).filter(w=>w.wid); +} +function castPathWidgetAllowed(widgetId){ + const widget = currentBoardWidgets().find(item=>item.wid===widgetId); + return !!widget && ["taal","rekenen"].includes(widget.cat); } btnCast.addEventListener("click", async ()=>{ stashCurrent(); /* verse widget-state, zelfde als btnFolders doet */ castOpen = true; castSelWidget = ""; castSelMode = "werken"; + castPathTitle = ""; castPathSteps = []; castActiveKeys = new Set(); castError = ""; castLoading = true; renderCast(); @@ -68,7 +74,10 @@ function castTargetRow(target){ row.addEventListener("click", async ()=>{ try{ const body = { boardId: curSlot().id }; - if(castSelWidget){ body.widgetId = castSelWidget; body.mode = castSelMode; } + if(castPathSteps.length){ + body.title = castPathTitle.trim(); + body.sequence = castPathSteps.map(step=>({boardId:step.boardId, widgetId:step.widgetId, mode:step.mode})); + }else if(castSelWidget){ body.widgetId = castSelWidget; body.mode = castSelMode; } await api(`/assignments/${target.type}/${target.id}`, { method:"PUT", body }); castActiveKeys.add(key); castError = ""; @@ -112,6 +121,7 @@ function renderCast(){ widgetSel.addEventListener("change", ()=>{ castSelWidget = widgetSel.value; modeSel.style.display = castSelWidget ? "" : "none"; + addStep.disabled = !castPathWidgetAllowed(castSelWidget) || castPathSteps.length >= 12; }); modeSel.addEventListener("change", ()=>{ castSelMode = modeSel.value; }); const whatRow = h("div","am-inline"); @@ -119,6 +129,49 @@ function renderCast(){ whatRow.appendChild(modeSel); sheet.appendChild(whatRow); + sheet.appendChild(h("div","am-group", T("castPathTitle"))); + const pathTitle = h("input","am-inp"); + pathTitle.type = "text"; + pathTitle.maxLength = 80; + pathTitle.placeholder = T("castPathPlaceholder"); + pathTitle.value = castPathTitle; + pathTitle.addEventListener("input", ()=>{ castPathTitle = pathTitle.value; castActiveKeys.clear(); }); + sheet.appendChild(pathTitle); + const addStep = h("button","am-btn", T("castPathAdd")); + addStep.type = "button"; + addStep.disabled = !castPathWidgetAllowed(castSelWidget) || castPathSteps.length >= 12; + addStep.title = T("castPathOnlyLearning"); + addStep.addEventListener("click", ()=>{ + if(!castPathWidgetAllowed(castSelWidget) || castPathSteps.length >= 12) return; + const widget = currentBoardWidgets().find(item=>item.wid===castSelWidget); + castPathSteps.push({boardId:curSlot().id, widgetId:castSelWidget, widgetType:widget?.id || "", mode:castSelMode}); + if(!castPathTitle) castPathTitle = T("castPathDefault"); + castActiveKeys.clear(); + renderCast(); + }); + sheet.appendChild(addStep); + if(castPathSteps.length){ + const pathList = h("div","cast-path-list"); + castPathSteps.forEach((step,index)=>{ + const row = h("div","cast-path-step"); + row.appendChild(h("span","cast-path-number", String(index+1))); + const label = h("span","am-name", T("wg_"+step.widgetType)); + label.appendChild(h("small","am-role", " · "+T(step.mode==="kijken"?"amModeWatch":"amModeWork"))); + row.appendChild(label); + const up = h("button","am-btn","↑"); up.type="button"; up.disabled=index===0; + up.addEventListener("click",()=>{ [castPathSteps[index-1],castPathSteps[index]]=[castPathSteps[index],castPathSteps[index-1]]; castActiveKeys.clear(); renderCast(); }); + const down = h("button","am-btn","↓"); down.type="button"; down.disabled=index===castPathSteps.length-1; + down.addEventListener("click",()=>{ [castPathSteps[index+1],castPathSteps[index]]=[castPathSteps[index],castPathSteps[index+1]]; castActiveKeys.clear(); renderCast(); }); + const remove = h("button","am-btn","×"); remove.type="button"; + remove.setAttribute("aria-label",T("amDelete")); + remove.addEventListener("click",()=>{ castPathSteps.splice(index,1); castActiveKeys.clear(); renderCast(); }); + row.append(up,down,remove); + pathList.appendChild(row); + }); + sheet.appendChild(pathList); + sheet.appendChild(h("div","guestnote", T("castPathHint").replace("{count}",castPathSteps.length))); + } + sheet.appendChild(h("div","am-group", T("castClasses"))); const classList = h("div","am-list"); if(castLoading) classList.appendChild(h("div","guestnote", "…")); diff --git a/public/js/core.js b/public/js/core.js index 9536e81..9c7a773 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.54-beta"; +const VERSION = "0.4.55-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 @@ -188,7 +188,7 @@ const I18N = { avatarAcc_glasses:"Bril", avatarAcc_sunglasses:"Zonnebril", avatarAcc_eyepatch:"Ooglapje", avatarAcc_bow:"Strik", avatarAcc_flower:"Bloem", avatarAcc_headphones:"Koptelefoon", avatarAcc_partyhat:"Feesthoedje", avatarAcc_scarf:"Sjaal", avatarAcc_cape:"Cape", avatarAcc_badge:"Insigne", avatarAcc_necklace:"Ketting", avatarAcc_bowtie:"Vlinderdas", avatarAcc_belt:"Riem", - avatarAcc_backpack:"Rugtas", avatarAcc_satchel:"Schoudertas", avatarAcc_wings:"Vleugels", + avatarAcc_backpack:"Rugtas", avatarAcc_satchel:"Schoudertas", avatarAcc_wings:"Vleugels", avatarAcc_star:"Gouden ster", avatarAcc_medal:"Medaille", avatarSave:"Opslaan", avatarSaved:"Personage opgeslagen ✓", clock24Hour:"24-uursklok", widgetSettings:"Instellingen", clockWelcome:"Oefenen met de klok", clockWelcomeHint:"Kies bovenaan niveau 1, 2 of 3. Kies bij Instellingen hoe de opdracht wordt getoond en start daarna.", clockSetTitle:"Zet de wijzers op de goede tijd", clockReadTitle:"Bekijk de opdracht en maak de tijd", clockChooseTitle:"Kies het juiste tijdblok", clockYourTask:"Jouw opdracht", clockLinkHands:"Grote wijzer draait mee", clockTypeHint:"Voer de tijd in als UU:MM.", clockLevel1Hint:"Niveau 1: hele en halve uren", clockLevel2Hint:"Niveau 2: stappen van vijf minuten", clockLevel3Hint:"Niveau 3: alle minuten, met optioneel 24 uur en dagdelen", clockBuildHint:"Sleep vier cijfers naar de vakken en maak UU:MM.", clockDigitGhost:"?", clockDigit:"Cijfer {digit}", clockIncomplete:"Maak eerst de volledige tijd af.", clockCorrect:"Goed gedaan! Kies de volgende opdracht. 🌟", clockTryAgain:"Bijna! Probeer het nog een keer.", clockStartTask:"Start opdracht", clockNextTask:"Volgende opdracht", clockLevel:"Niveau", clockLevelHour:"Hele uren", clockLevelQuarter:"Kwartieren", clockLevelFive:"Vijf minuten", clockLevelMinute:"Iedere minuut", clockLearningStyle:"Opdracht tonen", clockStyleAnalogue:"Analoge klok", clockStyleDigital:"Grote digitale tijd", clockStyleWords:"In woorden", clockStyleBoth:"Klok, cijfers en woorden", clockDragHint:"Sleep de wijzers om de tijd te veranderen.", clockGame:"Spel", clockGameSet:"Klok zetten", clockGameRead:"Tijd aflezen", clockGameChoose:"Kies de tijd", clockStart:"Kies ‘Nieuwe opdracht’ om te beginnen", clockRead:"Welke tijd zie je? Stel je antwoord hieronder in.", clockChoose:"Welke digitale tijd hoort bij deze klok?", clockTry:"Nog niet. Het goede antwoord is {time}.", clockScore:"Score: {score} · reeks: {streak}", clockWordsHour:"{hour} uur", clockWordsQuarterPast:"kwart over {hour}", clockWordsHalf:"half {hour}", clockWordsQuarterTo:"kwart voor {hour}", clockWordsPast:"{minutes} over {hour}", clockWordsTo:"{minutes} voor {hour}", clockMorning:"in de ochtend", clockAfternoon:"in de middag", clockEvening:"in de avond", clockNight:"in de nacht", fractionRows:"Rijen", fractionLabels:"Breuken tonen", fractionHint:"Tik in een rij tot waar je de breuk wilt kleuren.", fractionSelected:"Gekozen breuk: {fraction}", fractionClear:"Leegmaken", clockTask:"Oefenopdracht", clockSet:"Zet de klok op {time}", clockHour:"Uur", clockMinute:"Minuten", placeNumber:"Getal", placeGame:"Spel", placeGameExplore:"Verkennen", placeGameBuild:"Bouwen", placeLevel:"Niveau", placeLevel1Hint:"Niveau 1: getallen tot 100 (T en E)", placeLevel2Hint:"Niveau 2: getallen tot 1000 (H, T en E)", placeLevel3Hint:"Niveau 3: getallen tot 1.000.000 (HD, TD, D, H, T en E)", placeBuildTitle:"Bouw het getal", placeYourTask:"Jouw opdracht", placeBuildHint:"Sleep de cijfers naar de vakken en bouw het getal.", placeDigitGhost:"?", placeIncomplete:"Maak eerst het hele getal af.", placeCorrect:"Goed gedaan! Kies de volgende opdracht. 🌟", placeTryAgain:"Bijna! Probeer het nog een keer.", placeNextTask:"Volgende opdracht", placeScore:"Score: {score} · reeks: {streak}", moneyMake:"Maak € {amount}", wordtypeSentence:"Zin", wordtypeHint:"Tik: blauw zelfstandig naamwoord · groen werkwoord · geel bijvoeglijk naamwoord", scheduleActivity:"activiteit…", scheduleEmpty:"Voeg de eerste activiteit toe.", scheduleCount:"{count} momenten op de planning", pollReset:"Opnieuw", pollQuestion:"Vraag", pollOptions:"Antwoorden (één per regel)", pollTotal:"{count} stemmen", moodTotal:"{count} kinderen hebben gekozen", moodVote:"Kies {face}", galleryFavorites:"Favorieten", galleryFavorite:"Favoriet maken", galleryEmpty:"Geen widgets gevonden.", gallerySearch:"Zoek widgets…", templateMath:"Rekenles", templateLanguage:"Taalles", templateDay:"Dagstart", exportBoard:"Exporteren", importBoard:"Importeren", importError:"Dit is geen geldig Teach-bordbestand.", widgetDuplicate:"Dupliceren", widgetLock:"Vastzetten", widgetUnlock:"Losmaken", widgetPreview:"Voorbeeld zonder bediening", undo:"Ongedaan maken", redo:"Opnieuw", @@ -347,8 +347,11 @@ const I18N = { dtLock:"Vastzetten: kan niet meer verschuiven of draaien, alleen nog langs tekenen", dtMode:"Basis: grote hoofdhoeken en hele centimeters. Volledig: graden per 2° met dubbele schaal, millimeters en evenwijdige hulplijnen.", dtModeBasic:"basis", dtModeFull:"volledig", - pupilEmpty:"Je leerkracht heeft nog niets voor je klaargezet.", - pupilWatching:"meekijken", + pupilEmpty:"Je leerkracht heeft nog niets voor je klaargezet.", pupilWatching:"meekijken", + pupilPath:"Mijn opdrachtenreeks", pupilPathStep:"Opdracht {current} van {total}", pupilPathComplete:"Reeks voltooid", + pupilPathWellDone:"Heel goed gedaan!", pupilPathReward:"Je personage viert het met een dansje.", + pupilPathNext:"Stap afronden en verder", pupilPathFirst:"Rond eerst de opdracht hierboven af", + pupilRewardUnlocked:"{count} nieuwe avatarbeloning(en) vrijgespeeld!", amUsers:"Gebruikers", amClasses:"Klassen", amSchools:"Scholen", amOverarching:"Systeem", amPickSchoolHint:"Kies hierboven eerst een school.", amPickSchool:"Kies een school", amPupils:"Leerlingen", amPupilOne:"Leerling", amEdit:"Bewerken", @@ -402,7 +405,11 @@ const I18N = { cast:"Cast", castTitle:"Cast bord", castWhat:"Wat cast je?", castClasses:"Klassen", castPupils:"Leerlingen", castNoClasses:"Geen klassen in deze school.", castNoPupils:"Geen leerlingen in deze school.", - castActive:"✓ Actief", + castActive:"✓ Actief", castPathTitle:"Opdrachtenreeks (optioneel)", castPathPlaceholder:"Naam van de reeks…", + castPathAdd:"+ Geselecteerde widget als stap", castPathOnlyLearning:"Reeksen ondersteunen taal- en rekenwidgets.", castPathDefault:"Mijn opdrachtenreeks", castPathHint:"{count} van maximaal 12 stappen. Sleepvolgorde wordt met de pijlen bepaald.", + amDashboardTitle:"Klasdashboard", amProgressDetail:"Detail per leerling", + amDashboardStatus_complete:"Klaar", amDashboardStatus_active:"Bezig", amDashboardStatus_support:"Hulp nodig", amDashboardStatus_notStarted:"Nog niet gestart", + amDashboardSteps:"{done} van {total} stappen", amDashboardNoActivity:"Nog geen activiteit", amProgress:"Voortgang", amProgressEmpty:"Nog geen voortgang voor deze leerling.", amProgressLast14Days:"Laatste 14 dagen", amProgressMistakes:"Recente fouten", amProgressTimeline:"Volledige tijdlijn", @@ -578,7 +585,7 @@ const I18N = { avatarAcc_glasses:"Glasses", avatarAcc_sunglasses:"Sunglasses", avatarAcc_eyepatch:"Eye patch", avatarAcc_bow:"Bow", avatarAcc_flower:"Flower", avatarAcc_headphones:"Headphones", avatarAcc_partyhat:"Party hat", avatarAcc_scarf:"Scarf", avatarAcc_cape:"Cape", avatarAcc_badge:"Badge", avatarAcc_necklace:"Necklace", avatarAcc_bowtie:"Bow tie", avatarAcc_belt:"Belt", - avatarAcc_backpack:"Backpack", avatarAcc_satchel:"Satchel", avatarAcc_wings:"Wings", + avatarAcc_backpack:"Backpack", avatarAcc_satchel:"Satchel", avatarAcc_wings:"Wings", avatarAcc_star:"Golden star", avatarAcc_medal:"Medal", avatarSave:"Save", avatarSaved:"Character saved ✓", clock24Hour:"24-hour clock", widgetSettings:"Settings", clockWelcome:"Practise telling time", clockWelcomeHint:"Choose level 1, 2 or 3 at the top. In Settings, choose how the task is shown, then start.", clockSetTitle:"Move the hands to the correct time", clockReadTitle:"View the task and make the time", clockChooseTitle:"Choose the correct time block", clockYourTask:"Your task", clockLinkHands:"Move the minute hand along", clockTypeHint:"Enter the time as HH:MM.", clockLevel1Hint:"Level 1: whole and half hours", clockLevel2Hint:"Level 2: five-minute steps", clockLevel3Hint:"Level 3: every minute, with optional 24-hour time and day parts", clockBuildHint:"Drag four digits into the slots to make HH:MM.", clockDigitGhost:"?", clockDigit:"Digit {digit}", clockIncomplete:"Complete the full time first.", clockCorrect:"Well done! Choose the next task. 🌟", clockTryAgain:"Almost! Try again.", clockStartTask:"Start task", clockNextTask:"Next task", clockLevel:"Level", clockLevelHour:"Whole hours", clockLevelQuarter:"Quarter hours", clockLevelFive:"Five minutes", clockLevelMinute:"Every minute", clockLearningStyle:"Show task as", clockStyleAnalogue:"Analogue clock", clockStyleDigital:"Large digital time", clockStyleWords:"In words", clockStyleBoth:"Clock, digits and words", clockDragHint:"Drag the hands to change the time.", clockGame:"Game", clockGameSet:"Set the clock", clockGameRead:"Read the time", clockGameChoose:"Choose the time", clockStart:"Choose ‘New task’ to begin", clockRead:"What time is it? Set your answer below.", clockChoose:"Which digital time matches this clock?", clockTry:"Not yet. The correct answer is {time}.", clockScore:"Score: {score} · streak: {streak}", clockWordsHour:"{hour} o'clock", clockWordsQuarterPast:"quarter past {hour}", clockWordsHalf:"half past {hour}", clockWordsQuarterTo:"quarter to {hour}", clockWordsPast:"{minutes} past {hour}", clockWordsTo:"{minutes} to {hour}", clockMorning:"in the morning", clockAfternoon:"in the afternoon", clockEvening:"in the evening", clockNight:"at night", fractionRows:"Rows", fractionLabels:"Show fractions", fractionHint:"Tap a row up to the part you want to colour.", fractionSelected:"Selected fraction: {fraction}", fractionClear:"Clear", clockTask:"Practice task", clockSet:"Set the clock to {time}", clockHour:"Hour", clockMinute:"Minutes", placeNumber:"Number", placeGame:"Game", placeGameExplore:"Explore", placeGameBuild:"Build", placeLevel:"Level", placeLevel1Hint:"Level 1: numbers up to 100 (T and E)", placeLevel2Hint:"Level 2: numbers up to 1000 (H, T and E)", placeLevel3Hint:"Level 3: numbers up to 1,000,000 (HD, TD, D, H, T and E)", placeBuildTitle:"Build the number", placeYourTask:"Your task", placeBuildHint:"Drag the digits into the slots to build the number.", placeDigitGhost:"?", placeIncomplete:"Complete the whole number first.", placeCorrect:"Well done! Choose the next task. 🌟", placeTryAgain:"Almost! Try again.", placeNextTask:"Next task", placeScore:"Score: {score} · streak: {streak}", moneyMake:"Make € {amount}", wordtypeSentence:"Sentence", wordtypeHint:"Tap: blue noun · green verb · yellow adjective", scheduleActivity:"activity…", scheduleEmpty:"Add the first activity.", scheduleCount:"{count} moments in the schedule", pollReset:"Reset", pollQuestion:"Question", pollOptions:"Answers (one per line)", pollTotal:"{count} votes", moodTotal:"{count} children have chosen", moodVote:"Choose {face}", galleryFavorites:"Favourites", galleryFavorite:"Add favourite", galleryEmpty:"No widgets found.", gallerySearch:"Search widgets…", templateMath:"Maths lesson", templateLanguage:"Language lesson", templateDay:"Day start", exportBoard:"Export", importBoard:"Import", importError:"This is not a valid Teach board file.", widgetDuplicate:"Duplicate", widgetLock:"Lock", widgetUnlock:"Unlock", widgetPreview:"Preview without controls", undo:"Undo", redo:"Redo", @@ -737,8 +744,11 @@ const I18N = { dtLock:"Lock in place: can no longer move or rotate, only draw along it", dtMode:"Basic: big main angles and whole centimetres. Full: degrees every 2° with a double scale, millimetres and parallel guide lines.", dtModeBasic:"basic", dtModeFull:"full", - pupilEmpty:"Your teacher hasn't set up anything for you yet.", - pupilWatching:"watching", + pupilEmpty:"Your teacher hasn't set up anything for you yet.", pupilWatching:"watching", + pupilPath:"My learning path", pupilPathStep:"Task {current} of {total}", pupilPathComplete:"Path completed", + pupilPathWellDone:"Very well done!", pupilPathReward:"Your character celebrates with a dance.", + pupilPathNext:"Complete step and continue", pupilPathFirst:"Complete the task above first", + pupilRewardUnlocked:"{count} new avatar reward(s) unlocked!", amUsers:"Users", amClasses:"Classes", amSchools:"Schools", amOverarching:"System", amPickSchoolHint:"First pick a school above.", amPickSchool:"Pick a school", amPupils:"Pupils", amPupilOne:"Pupil", amEdit:"Edit", @@ -792,7 +802,11 @@ const I18N = { cast:"Cast", castTitle:"Cast board", castWhat:"What are you casting?", castClasses:"Classes", castPupils:"Pupils", castNoClasses:"No classes in this school.", castNoPupils:"No pupils in this school.", - castActive:"✓ Active", + castActive:"✓ Active", castPathTitle:"Learning path (optional)", castPathPlaceholder:"Name of the path…", + castPathAdd:"+ Add selected widget as step", castPathOnlyLearning:"Paths support language and maths widgets.", castPathDefault:"My learning path", castPathHint:"{count} of up to 12 steps. Use the arrows to set the order.", + amDashboardTitle:"Class dashboard", amProgressDetail:"Pupil details", + amDashboardStatus_complete:"Complete", amDashboardStatus_active:"Working", amDashboardStatus_support:"Needs help", amDashboardStatus_notStarted:"Not started", + amDashboardSteps:"{done} of {total} steps", amDashboardNoActivity:"No activity yet", amProgress:"Progress", amProgressEmpty:"No progress for this pupil yet.", amProgressLast14Days:"Last 14 days", amProgressMistakes:"Recent mistakes", amProgressTimeline:"Full timeline", diff --git a/public/js/pupil.js b/public/js/pupil.js index 83ae954..62164e1 100644 --- a/public/js/pupil.js +++ b/public/js/pupil.js @@ -13,15 +13,54 @@ const pupilView = document.getElementById("pupilView"); let pupilPollTimer = null; let pupilLastSig = null; let pupilMode = "werken"; +let pupilPendingRewards = []; function pupilAllowedDefs(){ return REGISTRY.filter(d => PUPIL_WIDGET_CATS.includes(d.cat)); } -async function renderPupilWidgets(widgets, mode, preview=false){ +async function renderPupilWidgets(widgets, mode, preview=false, sequence=null){ pupilView.innerHTML = ""; const defs = pupilAllowedDefs(); const watch = mode === "kijken"; + if(sequence){ + const path = document.createElement("section"); + path.className = "pupil-path" + (sequence.done ? " done" : ""); + const title = document.createElement("div"); + title.className = "pupil-path-title"; + title.textContent = sequence.title || T("pupilPath"); + const progress = document.createElement("div"); + progress.className = "pupil-path-progress"; + progress.textContent = sequence.done ? T("pupilPathComplete") + : T("pupilPathStep").replace("{current}", sequence.currentIndex + 1).replace("{total}", sequence.total); + const dots = document.createElement("div"); + dots.className = "pupil-path-dots"; + (sequence.steps || []).forEach((step, index)=>{ + const dot = document.createElement("span"); + dot.className = step.completed ? "complete" : index === sequence.currentIndex ? "current" : ""; + dot.textContent = step.completed ? "✓" : String(index + 1); + dots.appendChild(dot); + }); + path.append(title, progress, dots); + pupilView.appendChild(path); + if(sequence.done){ + const finish = document.createElement("div"); + finish.className = "pupil-path-finish"; + const heading = document.createElement("strong"); heading.textContent = T("pupilPathWellDone"); + const rewardText = document.createElement("span"); rewardText.textContent = T("pupilPathReward"); + finish.append(heading, rewardText); + if(pupilPendingRewards.length){ + const reward = document.createElement("span"); + reward.className = "pupil-path-unlocked"; + reward.textContent = T("pupilRewardUnlocked").replace("{count}", pupilPendingRewards.length); + finish.appendChild(reward); + pupilPendingRewards = []; + } + pupilView.appendChild(finish); + if(typeof celebrateAvatar === "function") celebrateAvatar(finish); + return; + } + } const usable = widgets .map(w => ({ w, def: defs.find(d => d.id === w.id) })) .filter(x => x.def); @@ -42,6 +81,8 @@ async function renderPupilWidgets(widgets, mode, preview=false){ try{ levels[subject] = await api("/my/level?subject="+subject); } catch(e){ levels[subject] = null; } })); + let stepButton = null; + let stepReady = watch || !!sequence?.ready; usable.forEach(({ w, def }, idx)=>{ const card = document.createElement("div"); card.className = "widget pupil-card" + (watch ? " watch" : ""); @@ -79,6 +120,10 @@ async function renderPupilWidgets(widgets, mode, preview=false){ zichtbaar in de console (geen storende UI voor de leerling, wél een spoor voor wie het probleem moet natrekken). */ api("/my/progress", { method:"POST", body: { widgetId, widgetType: def.id, ...p } }) + .then(()=>{ + stepReady = true; + if(stepButton){ stepButton.disabled = false; stepButton.textContent = T("pupilPathNext"); } + }) .catch(e=>console.warn("voortgang loggen mislukt:", e.message)); }}; /* De vakinstelling van de leerkracht is per leerling en dus leidend boven @@ -92,6 +137,29 @@ async function renderPupilWidgets(widgets, mode, preview=false){ if(typeof appendLearningProfileBadge === "function") appendLearningProfileBadge(body, def.cat, state, def.id); }); + if(sequence && !preview){ + const actions = document.createElement("div"); + actions.className = "pupil-path-actions"; + stepButton = document.createElement("button"); + stepButton.type = "button"; + stepButton.className = "primary"; + stepButton.disabled = !stepReady; + stepButton.textContent = stepReady ? T("pupilPathNext") : T("pupilPathFirst"); + stepButton.addEventListener("click", async ()=>{ + const step = sequence.steps[sequence.currentIndex]; + if(!step) return; + stepButton.disabled = true; + try{ + const result = await api("/my/assignment/steps/"+encodeURIComponent(step.id)+"/complete", {method:"POST"}); + pupilPendingRewards = result.unlocked || []; + if(typeof avatarAcceptRewards === "function") avatarAcceptRewards(pupilPendingRewards); + pupilLastSig = null; + await pupilPoll(); + }catch(e){ stepButton.disabled = false; stepButton.textContent = e.message; } + }); + actions.appendChild(stepButton); + pupilView.appendChild(actions); + } } async function pupilPoll(){ @@ -100,10 +168,10 @@ async function pupilPoll(){ const mode = res.mode === "kijken" ? "kijken" : "werken"; /* modus in de signature zodat een moduswissel ook zonder widget-wijziging een her-render (en dus de juiste overlay) oplevert */ - const sig = mode + "|" + JSON.stringify(res.widgets || []); + const sig = mode + "|" + JSON.stringify(res.widgets || []) + "|" + JSON.stringify(res.sequence || null); if(sig !== pupilLastSig){ pupilLastSig = sig; - await renderPupilWidgets(res.widgets || [], mode); + await renderPupilWidgets(res.widgets || [], mode, false, res.sequence || null); } if(mode !== pupilMode){ pupilMode = mode; diff --git a/src/api.js b/src/api.js index bf790a0..43e8291 100644 --- a/src/api.js +++ b/src/api.js @@ -201,9 +201,25 @@ export default async function api(app) { animal: ['cat', 'dog', 'rabbit', 'bear'], }; const AVATAR_HAIR = ['none', 'tuft', 'spiky', 'curly', 'wavy', 'bob', 'ponytail', 'braids', 'mohawk']; - const AVATAR_HEAD_ACC = ['none', 'cap', 'beanie', 'crown', 'wizardhat', 'glasses', 'sunglasses', 'eyepatch', 'bow', 'flower', 'headphones', 'partyhat']; - const AVATAR_BODY_ACC = ['none', 'scarf', 'cape', 'badge', 'necklace', 'bowtie', 'belt', 'backpack', 'satchel', 'wings']; + const AVATAR_HEAD_ACC = ['none', 'cap', 'beanie', 'crown', 'wizardhat', 'glasses', 'sunglasses', 'eyepatch', 'bow', 'flower', 'headphones', 'partyhat', 'star']; + const AVATAR_BODY_ACC = ['none', 'scarf', 'cape', 'badge', 'necklace', 'bowtie', 'belt', 'backpack', 'satchel', 'wings', 'medal']; + const AVATAR_REWARD_CATALOG = [ + { key: 'head:star', type: 'head', item: 'star', points: 1 }, + { key: 'body:medal', type: 'body', item: 'medal', points: 3 }, + { key: 'dance:robot', type: 'dance', item: 'robot', points: 6 }, + ]; + const avatarRewardKey = (type, item) => + AVATAR_REWARD_CATALOG.find((reward) => reward.type === type && reward.item === item)?.key || null; const isHexColor = (v) => typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v); + app.get('/me/avatar-rewards', async (req, reply) => { + need(req, reply); + const points = Number((await pool.query( + 'SELECT reward_points FROM users WHERE id = $1', [req.user.id])).rows[0]?.reward_points || 0); + const rewards = (await pool.query( + 'SELECT reward_key FROM user_avatar_rewards WHERE user_id = $1 ORDER BY unlocked_at', + [req.user.id])).rows.map((row) => row.reward_key); + return { points, rewards, catalog: AVATAR_REWARD_CATALOG }; + }); app.put('/me/avatar', async (req, reply) => { need(req, reply); const b = req.body ?? {}; @@ -216,6 +232,16 @@ export default async function api(app) { if (!isHexColor(b.hairColor)) return fail(reply, 400, 'ongeldige haarkleur'); if (!AVATAR_HEAD_ACC.includes(b.accessoryHead)) return fail(reply, 400, 'ongeldig hoofdaccessoire'); if (!AVATAR_BODY_ACC.includes(b.accessoryBody)) return fail(reply, 400, 'ongeldig lichaamsaccessoire'); + const requiredRewards = [ + avatarRewardKey('head', b.accessoryHead), + avatarRewardKey('body', b.accessoryBody), + ].filter(Boolean); + if (requiredRewards.length) { + const owned = (await pool.query( + 'SELECT reward_key FROM user_avatar_rewards WHERE user_id = $1 AND reward_key = ANY($2::text[])', + [req.user.id, requiredRewards])).rows.map((row) => row.reward_key); + if (requiredRewards.some((key) => !owned.includes(key))) return fail(reply, 403, 'avatarbeloning nog niet vrijgespeeld'); + } const avatar = { species: b.species, speciesVariant, bodyColor: b.bodyColor, hairStyle: b.hairStyle, hairColor: b.hairColor, accessoryHead: b.accessoryHead, accessoryBody: b.accessoryBody, @@ -807,6 +833,11 @@ export default async function api(app) { const t = (await pool.query('SELECT display_name, username, data FROM users WHERE id = $1', [a.teacher_id])).rows[0]; const board = t ? findBoardById(t.data, a.board_id) : null; const widget = a.widget_id ? (board?.data?.widgets || []).find((w) => w.wid === a.widget_id) : null; + const sequence = Array.isArray(a.sequence) ? a.sequence.map((step) => { + const stepBoard = t ? findBoardById(t.data, step.boardId) : null; + const stepWidget = (stepBoard?.data?.widgets || []).find((w) => w.wid === step.widgetId); + return { ...step, boardName: stepBoard?.name || null, widgetType: stepWidget?.id || null }; + }) : []; return { teacherId: Number(a.teacher_id), teacherName: t ? (t.display_name || t.username) : null, @@ -815,6 +846,8 @@ export default async function api(app) { widgetId: a.widget_id || null, widgetType: widget ? widget.id : null, mode: a.mode || 'werken', + title: a.title || '', + sequence, updatedAt: a.updated_at, }; }; @@ -834,6 +867,50 @@ export default async function api(app) { } return { boardId, widgetId: widgetId ?? null, mode: m }; }; + const PUPIL_ASSIGNMENT_WIDGETS = new Set([ + 'anchor', 'letters', 'flits', 'hak', 'ball', 'sentence', 'wordtypes', + 'madd', 'msub', 'mmul', 'mdiv', 'mpct', 'numberline', 'fractions', 'clock', 'placevalue', 'money', + ]); + const parseAssignmentBody = (req, reply) => { + const raw = req.body ?? {}; + if (!Array.isArray(raw.sequence) || raw.sequence.length === 0) { + const share = parseShareBody(req, reply); + if (share.error) return share; + return { ...share, title: '', sequence: [] }; + } + if (raw.sequence.length > 12) return fail(reply, 400, 'een opdrachtenreeks heeft maximaal 12 stappen'); + const title = typeof raw.title === 'string' ? raw.title.trim() : ''; + if (!title || title.length > 80) return fail(reply, 400, 'geef de opdrachtenreeks een titel van maximaal 80 tekens'); + const sequence = []; + for (let index = 0; index < raw.sequence.length; index += 1) { + const item = raw.sequence[index] ?? {}; + const boardId = item.boardId || raw.boardId; + const board = typeof boardId === 'string' ? findBoardById(req.user.data, boardId) : null; + if (!board) return fail(reply, 400, `bord van stap ${index + 1} onbekend`); + if (!item.widgetId || typeof item.widgetId !== 'string' || item.widgetId.length > 100) + return fail(reply, 400, `ongeldige widget in stap ${index + 1}`); + const widget = (board.data?.widgets || []).find((candidate) => candidate.wid === item.widgetId); + if (!widget) return fail(reply, 400, `widget van stap ${index + 1} staat niet op dit bord`); + if (!PUPIL_ASSIGNMENT_WIDGETS.has(widget.id)) + return fail(reply, 400, `widget van stap ${index + 1} is niet geschikt voor de leerlingomgeving`); + const mode = item.mode ?? 'werken'; + if (mode !== 'kijken' && mode !== 'werken') return fail(reply, 400, `ongeldige modus in stap ${index + 1}`); + sequence.push({ id: `step-${index + 1}`, boardId, widgetId: item.widgetId, mode }); + } + const first = sequence[0]; + return { boardId: first.boardId, widgetId: first.widgetId, mode: first.mode, title, sequence }; + }; + const saveAssignment = async (targetColumn, targetId, teacherId, share) => { + const row = (await pool.query( + `INSERT INTO assignments (${targetColumn}, teacher_id, board_id, widget_id, mode, title, sequence) + VALUES ($1,$2,$3,$4,$5,$6,$7) + ON CONFLICT (${targetColumn}) WHERE ${targetColumn} IS NOT NULL + DO UPDATE SET teacher_id = $2, board_id = $3, widget_id = $4, mode = $5, + title = $6, sequence = $7, updated_at = now() + RETURNING id`, + [targetId, teacherId, share.boardId, share.widgetId, share.mode, share.title, JSON.stringify(share.sequence)])).rows[0]; + if (row?.id) await pool.query('DELETE FROM assignment_step_progress WHERE assignment_id = $1', [row.id]); + }; app.get('/assignments/class/:id', async (req, reply) => { need(req, reply, PERMISSIONS['assignments.manage']); @@ -846,17 +923,13 @@ export default async function api(app) { }); app.put('/assignments/class/:id', async (req, reply) => { need(req, reply, PERMISSIONS['assignments.manage']); - const share = parseShareBody(req, reply); + const share = parseAssignmentBody(req, reply); if (share.error) return share; const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0]; if (!c) return fail(reply, 404, 'klas onbekend'); if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten'); if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten'); - await pool.query( - `INSERT INTO assignments (class_id, teacher_id, board_id, widget_id, mode) VALUES ($1,$2,$3,$4,$5) - ON CONFLICT (class_id) WHERE class_id IS NOT NULL - DO UPDATE SET teacher_id = $2, board_id = $3, widget_id = $4, mode = $5, updated_at = now()`, - [c.id, req.user.id, share.boardId, share.widgetId, share.mode]); + await saveAssignment('class_id', c.id, req.user.id, share); return { ok: true }; }); app.delete('/assignments/class/:id', async (req, reply) => { @@ -884,16 +957,12 @@ export default async function api(app) { }); app.put('/assignments/pupil/:id', async (req, reply) => { need(req, reply, PERMISSIONS['assignments.manage']); - const share = parseShareBody(req, reply); + const share = parseAssignmentBody(req, reply); if (share.error) return share; const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0]; if (!u) return fail(reply, 404, 'gebruiker onbekend'); if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten'); - await pool.query( - `INSERT INTO assignments (pupil_id, teacher_id, board_id, widget_id, mode) VALUES ($1,$2,$3,$4,$5) - ON CONFLICT (pupil_id) WHERE pupil_id IS NOT NULL - DO UPDATE SET teacher_id = $2, board_id = $3, widget_id = $4, mode = $5, updated_at = now()`, - [u.id, req.user.id, share.boardId, share.widgetId, share.mode]); + await saveAssignment('pupil_id', u.id, req.user.id, share); return { ok: true }; }); app.delete('/assignments/pupil/:id', async (req, reply) => { @@ -906,23 +975,109 @@ export default async function api(app) { }); // Leerling-kant: de effectieve toewijzing (eigen override, anders de klas). - // Live opgezocht (geen kopie) zodat nieuwe woorden bij de leerkracht meteen - // doorkomen zodra de leerling-pagina opnieuw ophaalt (polling). + // Bij een reeks wordt uitsluitend de eerste nog niet afgeronde stap live uit + // het bord van de leerkracht geladen; losse toewijzingen blijven ongewijzigd. + const effectiveAssignment = async (user) => { + let assignment = (await pool.query('SELECT * FROM assignments WHERE pupil_id = $1', [user.id])).rows[0]; + if (!assignment && user.class_id) { + assignment = (await pool.query('SELECT * FROM assignments WHERE class_id = $1', [user.class_id])).rows[0]; + } + return assignment || null; + }; + const assignmentWidgets = async (assignment, boardId, widgetId) => { + const teacher = (await pool.query('SELECT data FROM users WHERE id = $1', [assignment.teacher_id])).rows[0]; + const board = teacher ? findBoardById(teacher.data, boardId) : null; + let widgets = (board?.data?.widgets || []).map((widget) => ({ id: widget.id, wid: widget.wid, state: widget.state })); + if (widgetId) widgets = widgets.filter((widget) => widget.wid === widgetId); + return widgets; + }; + app.get('/my/assignment', async (req, reply) => { need(req, reply); if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen'); - let a = (await pool.query('SELECT * FROM assignments WHERE pupil_id = $1', [req.user.id])).rows[0]; - if (!a && req.user.class_id) { - a = (await pool.query('SELECT * FROM assignments WHERE class_id = $1', [req.user.class_id])).rows[0]; + const assignment = await effectiveAssignment(req.user); + if (!assignment) return { widgets: [], mode: 'werken', updatedAt: null, sequence: null }; + const steps = Array.isArray(assignment.sequence) ? assignment.sequence : []; + if (!steps.length) { + return { + widgets: await assignmentWidgets(assignment, assignment.board_id, assignment.widget_id), + mode: assignment.mode || 'werken', updatedAt: assignment.updated_at, sequence: null, + }; } - if (!a) return { widgets: [], mode: 'werken', updatedAt: null }; - const t = (await pool.query('SELECT data FROM users WHERE id = $1', [a.teacher_id])).rows[0]; - const board = t ? findBoardById(t.data, a.board_id) : null; - let widgets = (board?.data?.widgets || []).map((w) => ({ id: w.id, wid: w.wid, state: w.state })); - // één-widget-toewijzing: alleen die widget; is hij inmiddels van het bord - // verwijderd, dan een lege lijst (zelfde weergave als geen toewijzing) - if (a.widget_id) widgets = widgets.filter((w) => w.wid === a.widget_id); - return { widgets, mode: a.mode || 'werken', updatedAt: a.updated_at }; + const completedRows = (await pool.query( + 'SELECT step_id, completed_at FROM assignment_step_progress WHERE assignment_id = $1 AND pupil_id = $2', + [assignment.id, req.user.id])).rows; + const completedIds = new Set(completedRows.map((row) => row.step_id)); + const currentIndex = steps.findIndex((step) => !completedIds.has(step.id)); + const current = currentIndex >= 0 ? steps[currentIndex] : null; + const previous = currentIndex > 0 ? steps[currentIndex - 1] : null; + const readySince = previous + ? (completedRows.find((row) => row.step_id === previous.id)?.completed_at || assignment.updated_at) + : assignment.updated_at; + const ready = !current || current.mode === 'kijken' || !!(await pool.query( + `SELECT 1 FROM progress_events + WHERE pupil_id = $1 AND widget_id = $2 AND created_at >= $3 LIMIT 1`, + [req.user.id, current.widgetId, readySince])).rows[0]; + return { + widgets: current ? await assignmentWidgets(assignment, current.boardId, current.widgetId) : [], + mode: current?.mode || 'werken', + updatedAt: assignment.updated_at, + sequence: { + title: assignment.title || '', + steps: steps.map((step) => ({ id: step.id, mode: step.mode, completed: completedIds.has(step.id) })), + currentIndex: currentIndex < 0 ? steps.length : currentIndex, + completed: completedIds.size, + total: steps.length, + done: currentIndex < 0, + ready, + }, + }; + }); + + app.post('/my/assignment/steps/:stepId/complete', async (req, reply) => { + need(req, reply); + if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen'); + const assignment = await effectiveAssignment(req.user); + const steps = Array.isArray(assignment?.sequence) ? assignment.sequence : []; + if (!assignment || !steps.length) return fail(reply, 400, 'geen actieve opdrachtenreeks'); + const completedRows = (await pool.query( + 'SELECT step_id, completed_at FROM assignment_step_progress WHERE assignment_id = $1 AND pupil_id = $2', + [assignment.id, req.user.id])).rows; + const completedIds = new Set(completedRows.map((row) => row.step_id)); + const currentIndex = steps.findIndex((step) => !completedIds.has(step.id)); + const current = currentIndex >= 0 ? steps[currentIndex] : null; + if (!current || current.id !== req.params.stepId) return fail(reply, 409, 'deze stap is niet de huidige opdracht'); + if (current.mode !== 'kijken') { + const previous = currentIndex > 0 ? steps[currentIndex - 1] : null; + const readySince = previous + ? (completedRows.find((row) => row.step_id === previous.id)?.completed_at || assignment.updated_at) + : assignment.updated_at; + const evidence = (await pool.query( + `SELECT 1 FROM progress_events + WHERE pupil_id = $1 AND widget_id = $2 AND created_at >= $3 LIMIT 1`, + [req.user.id, current.widgetId, readySince])).rows[0]; + if (!evidence) return fail(reply, 409, 'rond eerst een opdracht in de widget af'); + } + const inserted = (await pool.query( + `INSERT INTO assignment_step_progress (assignment_id, pupil_id, step_id) + VALUES ($1,$2,$3) ON CONFLICT DO NOTHING RETURNING step_id`, + [assignment.id, req.user.id, current.id])).rows[0]; + let points = Number(req.user.reward_points || 0); + let unlocked = []; + if (inserted) { + points = Number((await pool.query( + 'UPDATE users SET reward_points = reward_points + 1 WHERE id = $1 RETURNING reward_points', + [req.user.id])).rows[0]?.reward_points || points + 1); + unlocked = (await pool.query( + `INSERT INTO user_avatar_rewards (user_id, reward_key) + SELECT $1, reward_key FROM (VALUES + ('head:star', 1), ('body:medal', 3), ('dance:robot', 6) + ) AS rewards(reward_key, required_points) + WHERE required_points <= $2 + ON CONFLICT DO NOTHING RETURNING reward_key`, + [req.user.id, points])).rows.map((row) => row.reward_key); + } + return { ok: true, points, unlocked, done: completedIds.size + (inserted ? 1 : 0) >= steps.length }; }); // ---- klassenmanagement: niveaugroep + niveau per leerling per vakgebied ------- @@ -1085,9 +1240,13 @@ export default async function api(app) { 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]; - if (!a && req.user.class_id) { - a = (await pool.query('SELECT * FROM assignments WHERE class_id = $1', [req.user.class_id])).rows[0]; + let a = await effectiveAssignment(req.user); + if (a && Array.isArray(a.sequence) && a.sequence.length) { + const completed = new Set((await pool.query( + 'SELECT step_id FROM assignment_step_progress WHERE assignment_id = $1 AND pupil_id = $2', + [a.id, req.user.id])).rows.map((row) => row.step_id)); + const current = a.sequence.find((step) => !completed.has(step.id)); + if (!current || current.widgetId !== widgetId) return fail(reply, 409, 'deze widget is niet de huidige stap'); } // thuiskind (leerling zonder school, ouderportaal): geen toewijzing nodig, // de voortgang wordt gelogd met de (eerste) gekoppelde ouder als @@ -1152,6 +1311,55 @@ export default async function api(app) { return { events: rows.map((e) => ({ ...describeProgressEvent(e), pupilId: Number(e.pupil_id), pupilName: e.display_name || e.username })) }; }); + app.get('/progress/dashboard/class/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['progress.view']); + const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0]; + if (!c) return fail(reply, 404, 'klas onbekend'); + if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten'); + if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten'); + const pupils = (await pool.query( + `SELECT u.id, u.display_name, u.username, + COALESCE(SUM(pe.attempts), 0) AS attempts, + COALESCE(SUM(pe.correct), 0) AS correct, + MAX(pe.created_at) AS last_activity + FROM users u LEFT JOIN progress_events pe ON pe.pupil_id = u.id + WHERE u.class_id = $1 AND u.role = 'pupil' + GROUP BY u.id, u.display_name, u.username + ORDER BY COALESCE(u.display_name, u.username)`, [c.id])).rows; + const assignments = (await pool.query( + `SELECT * FROM assignments + WHERE class_id = $1 OR pupil_id IN (SELECT id FROM users WHERE class_id = $1 AND role = 'pupil')`, [c.id])).rows; + const completed = (await pool.query( + `SELECT asp.assignment_id, asp.pupil_id, COUNT(*) AS completed + FROM assignment_step_progress asp JOIN users u ON u.id = asp.pupil_id + WHERE u.class_id = $1 GROUP BY asp.assignment_id, asp.pupil_id`, [c.id])).rows; + const classAssignment = assignments.find((assignment) => Number(assignment.class_id) === Number(c.id)); + const completedMap = new Map(completed.map((row) => [`${row.assignment_id}:${row.pupil_id}`, Number(row.completed)])); + const overview = pupils.map((pupil) => { + const assignment = assignments.find((item) => Number(item.pupil_id) === Number(pupil.id)) || classAssignment; + const total = Array.isArray(assignment?.sequence) ? assignment.sequence.length : 0; + const done = assignment ? (completedMap.get(`${assignment.id}:${pupil.id}`) || 0) : 0; + const attempts = Number(pupil.attempts || 0); + const correct = Number(pupil.correct || 0); + const accuracy = attempts ? Math.round((correct / attempts) * 100) : 0; + let status = 'notStarted'; + if (total && done >= total) status = 'complete'; + else if (attempts >= 5 && accuracy < 60) status = 'support'; + else if (attempts || done) status = 'active'; + return { + pupilId: Number(pupil.id), pupilName: pupil.display_name || pupil.username, + attempts, correct, accuracy, lastActivity: pupil.last_activity, + sequence: assignment && total ? { title: assignment.title || '', completed: done, total } : null, + status, + }; + }); + const summary = overview.reduce((counts, pupil) => { + counts[pupil.status] += 1; + return counts; + }, { complete: 0, active: 0, support: 0, notStarted: 0 }); + return { pupils: overview, summary }; + }); + // ---- ouderportaal ----------------------------------------------------------- // Ouders (role 'parent', school_id NULL) registreren zichzelf zonder school, // beheren gekoppelde kind-accounts en melden thuiskinderen aan bij een school. diff --git a/test/avatar.test.js b/test/avatar.test.js index a883b08..129243f 100644 --- a/test/avatar.test.js +++ b/test/avatar.test.js @@ -27,7 +27,7 @@ async function loadAvatarRenderer(){ document:{createElement:fakeAvatarElement,createElementNS:fakeAvatarElement}, }; runInNewContext(source.slice(0,cutoff)+ - ';globalThis.__avatar={renderAvatarFace,avatarBodyMarkup,sanitize:avatarSanitize,variants:AVATAR_VARIANTS,hair:AVATAR_HAIR_MARKUP,bodyAccessories:AVATAR_BODY_ACC_MARKUP,headAccessories:AVATAR_HEAD_ACC_MARKUP,hairIds:AVATAR_HAIR,headIds:AVATAR_HEAD_ACC,bodyIds:AVATAR_BODY_ACC,dances:AVATAR_DANCES,nextDance:avatarDanceChoice,celebrate:celebrateAvatar};', + ';globalThis.__avatar={renderAvatarFace,avatarBodyMarkup,sanitize:avatarSanitize,variants:AVATAR_VARIANTS,hair:AVATAR_HAIR_MARKUP,bodyAccessories:AVATAR_BODY_ACC_MARKUP,headAccessories:AVATAR_HEAD_ACC_MARKUP,hairIds:AVATAR_HAIR,headIds:AVATAR_HEAD_ACC,bodyIds:AVATAR_BODY_ACC,dances:AVATAR_DANCES,nextDance:avatarDanceChoice,celebrate:celebrateAvatar,unlock:avatarAcceptRewards};', context); context.__avatar.timers=timers; return context.__avatar; @@ -101,13 +101,16 @@ test("iedere categorie heeft vier anatomisch herkenbare avatarvarianten",async() assert.equal(api.sanitize({species:"monster",speciesVariant:"rabbit"}).speciesVariant,"horned"); }); -test("voltooide opdrachten rouleren door vijf herkenbare avatardansen",async()=>{ +test("voltooide opdrachten rouleren door vijf basisdansen en een vrijspeelbare robotdans",async()=>{ const api=await loadAvatarRenderer(); - const dances=["bounce","twist","spin","shuffle","wiggle"]; + const basic=["bounce","twist","spin","shuffle","wiggle"]; + const dances=[...basic,"robot"]; assert.deepEqual(Array.from(api.dances),dances); - assert.deepEqual(dances.map(()=>api.nextDance()),dances); + assert.deepEqual(basic.map(()=>api.nextDance()),basic); + assert.equal(api.nextDance("robot"),"bounce","robotdans blijft eerst vergrendeld"); + api.unlock(["dance:robot"]); + assert.equal(api.nextDance("robot"),"robot"); assert.equal(api.nextDance("spin"),"spin"); - assert.equal(api.nextDance("onbekend"),"bounce"); const hostClasses=new Set(),stages=[]; const host={ @@ -204,8 +207,8 @@ test("haar volgt de kop met natuurlijke details en correcte lagen",async()=>{ test('accessoires gebruiken vaste ankers en correcte voor- en achterlagen',async()=>{ const api=await loadAvatarRenderer(); - assert.deepEqual(Array.from(api.headIds),['none','cap','beanie','crown','wizardhat','glasses','sunglasses','eyepatch','bow','flower','headphones','partyhat']); - assert.deepEqual(Array.from(api.bodyIds),['none','scarf','cape','badge','necklace','bowtie','belt','backpack','satchel','wings']); + assert.deepEqual(Array.from(api.headIds),['none','cap','beanie','crown','wizardhat','glasses','sunglasses','eyepatch','bow','flower','headphones','partyhat','star']); + assert.deepEqual(Array.from(api.bodyIds),['none','scarf','cape','badge','necklace','bowtie','belt','backpack','satchel','wings','medal']); for(const id of api.bodyIds){ assert.equal(typeof api.bodyAccessories[id].back,'function',id+' mist achterlaag'); @@ -240,13 +243,13 @@ test('accessoires gebruiken vaste ankers en correcte voor- en achterlagen',async assert.ok(wings.indexOf('av-wings')wings.indexOf('av-head-large')); - for(const [id,part] of [['scarf','av-scarf'],['badge','av-badge'],['necklace','av-necklace'],['bowtie','av-bowtie'],['belt','av-belt']]){ + for(const [id,part] of [['scarf','av-scarf'],['badge','av-badge'],['necklace','av-necklace'],['bowtie','av-bowtie'],['belt','av-belt'],['medal','av-reward-medal']]){ const markup=api.renderAvatarFace({...base,accessoryBody:id},'lg').innerHTML; assert.ok(markup.indexOf(part)>markup.indexOf('av-head-large'),id+' hoort voor op het lijf'); } }); -test("alle 12960 variant-, haar- en accessoirecombinaties renderen volledig",async()=>{ +test("alle 15444 variant-, haar- en accessoirecombinaties renderen volledig",async()=>{ const api=await loadAvatarRenderer(); let combinations=0; for(const speciesId of ["dino","monster","animal"]){ @@ -273,7 +276,7 @@ test("alle 12960 variant-, haar- en accessoirecombinaties renderen volledig",asy } } } - assert.equal(combinations,12960); + assert.equal(combinations,15444); }); test("client, API en beide talen bevatten dezelfde avataropties",async()=>{ const [apiSource,core]=await Promise.all([ @@ -281,7 +284,7 @@ test("client, API en beide talen bevatten dezelfde avataropties",async()=>{ readFile("public/js/core.js","utf8"), ]); const hair=["tuft","wavy","bob","braids"]; - const accessories=["beanie","wizardhat","eyepatch","flower","bowtie","belt","satchel","wings"]; + const accessories=["beanie","wizardhat","eyepatch","flower","bowtie","belt","satchel","wings","star","medal"]; const variants=["rex","triceratops","stegosaurus","raptor","horned","cyclops","slime","alien","cat","dog","rabbit","bear"]; for(const id of hair){ assert.ok(apiSource.includes("'"+id+"'"),id+" ontbreekt in API-validatie"); diff --git a/test/learning-paths.test.js b/test/learning-paths.test.js new file mode 100644 index 0000000..5a7eeff --- /dev/null +++ b/test/learning-paths.test.js @@ -0,0 +1,223 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import Fastify from 'fastify'; +import cookie from '@fastify/cookie'; +import rateLimit from '@fastify/rate-limit'; +import api from '../src/api.js'; + +const cookies = { teach_session: 'x'.repeat(64) }; +const teacherData = { + boards: { + folders: [{ + boards: [{ + id: 'board-1', + name: 'Taalbord', + data: { + widgets: [ + { id: 'letters', wid: 'letters-1', state: { words: ['boom'] } }, + { id: 'sentence', wid: 'sentence-1', state: { level: 1 } }, + ], + }, + }], + }], + }, +}; + +async function fastifyWith(user, query) { + 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(); + return app; +} + +function authRows(user, sql) { + if (sql.includes('FROM sessions s JOIN users u')) return { rows: [user] }; + if (sql.includes('FROM user_roles')) return { rows: [] }; + return null; +} + +test('opdrachtenreeks toont alleen de eerste nog niet afgeronde live widget', async () => { + const pupil = { + id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, + data: {}, data_rev: 0, reward_points: 2, + }; + const assignment = { + id: 80, pupil_id: 9, teacher_id: 3, board_id: 'board-1', + widget_id: 'letters-1', mode: 'werken', title: 'Taalroute', + updated_at: new Date('2026-07-23T08:00:00Z'), + sequence: [ + { id: 'step-1', boardId: 'board-1', widgetId: 'letters-1', mode: 'werken' }, + { id: 'step-2', boardId: 'board-1', widgetId: 'sentence-1', mode: 'kijken' }, + ], + }; + const query = async (sql) => { + const auth = authRows(pupil, sql); + if (auth) return auth; + if (sql === 'SELECT * FROM assignments WHERE pupil_id = $1') return { rows: [assignment] }; + if (sql.startsWith('SELECT step_id') && sql.includes('FROM assignment_step_progress')) return { rows: [{ step_id: 'step-1' }] }; + if (sql === 'SELECT data FROM users WHERE id = $1') return { rows: [{ data: teacherData }] }; + return { rows: [] }; + }; + const app = await fastifyWith(pupil, query); + const response = await app.inject({ method: 'GET', url: '/api/my/assignment', cookies }); + assert.equal(response.statusCode, 200, response.body); + const body = response.json(); + assert.equal(body.widgets.length, 1); + assert.equal(body.widgets[0].wid, 'sentence-1'); + assert.equal(body.mode, 'kijken'); + assert.deepEqual(body.sequence, { + title: 'Taalroute', + steps: [ + { id: 'step-1', mode: 'werken', completed: true }, + { id: 'step-2', mode: 'kijken', completed: false }, + ], + currentIndex: 1, completed: 1, total: 2, done: false, ready: true, + }); + await app.close(); +}); + +test('stapafronding is opeenvolgend en kent punt en nieuwe beloning één keer toe', async () => { + const pupil = { + id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, + data: {}, data_rev: 0, reward_points: 2, + }; + const assignment = { + id: 80, pupil_id: 9, teacher_id: 3, board_id: 'board-1', updated_at: new Date(), + sequence: [ + { id: 'step-1', boardId: 'board-1', widgetId: 'letters-1', mode: 'werken' }, + { id: 'step-2', boardId: 'board-1', widgetId: 'sentence-1', mode: 'kijken' }, + ], + }; + const calls = []; + const query = async (sql, params = []) => { + calls.push({ sql, params }); + const auth = authRows(pupil, sql); + if (auth) return auth; + if (sql === 'SELECT * FROM assignments WHERE pupil_id = $1') return { rows: [assignment] }; + if (sql.startsWith('SELECT step_id') && sql.includes('FROM assignment_step_progress')) return { rows: [{ step_id: 'step-1' }] }; + if (sql.startsWith('INSERT INTO assignment_step_progress')) return { rows: [{ step_id: 'step-2' }] }; + if (sql.startsWith('UPDATE users SET reward_points')) return { rows: [{ reward_points: 3 }] }; + if (sql.startsWith('INSERT INTO user_avatar_rewards')) return { rows: [{ reward_key: 'body:medal' }] }; + return { rows: [] }; + }; + const app = await fastifyWith(pupil, query); + const skipped = await app.inject({ + method: 'POST', url: '/api/my/assignment/steps/step-1/complete', cookies, + }); + assert.equal(skipped.statusCode, 409); + const response = await app.inject({ + method: 'POST', url: '/api/my/assignment/steps/step-2/complete', cookies, + }); + assert.equal(response.statusCode, 200, response.body); + assert.deepEqual(response.json(), { + ok: true, points: 3, unlocked: ['body:medal'], done: true, + }); + assert.equal(calls.filter((call) => call.sql.startsWith('UPDATE users SET reward_points')).length, 1); + await app.close(); +}); + +test('leerkracht kan een gevalideerde reeks van maximaal twaalf stappen opslaan', async () => { + const teacher = { + id: 3, username: 'juf', display_name: 'Juf', role: 'teacher', + school_id: 2, class_id: null, data: teacherData, data_rev: 0, + }; + const calls = []; + const query = async (sql, params = []) => { + calls.push({ sql, params }); + const auth = authRows(teacher, sql); + if (auth) return auth; + if (sql.startsWith('SELECT * FROM classes')) return { rows: [{ id: 50, school_id: 2 }] }; + if (sql.startsWith('SELECT 1 FROM class_teachers')) return { rows: [{ '?column?': 1 }] }; + if (sql.includes('INSERT INTO assignments')) return { rows: [{ id: 80 }] }; + return { rows: [] }; + }; + const app = await fastifyWith(teacher, query); + const payload = { + boardId: 'board-1', title: 'Taalroute', + sequence: [ + { widgetId: 'letters-1', mode: 'werken' }, + { widgetId: 'sentence-1', mode: 'kijken' }, + ], + }; + const response = await app.inject({ + method: 'PUT', url: '/api/assignments/class/50', cookies, payload, + }); + assert.equal(response.statusCode, 200, response.body); + const insert = calls.find((call) => call.sql.includes('INSERT INTO assignments')); + assert.ok(insert); + assert.equal(insert.params[5], 'Taalroute'); + assert.deepEqual(JSON.parse(insert.params[6]), [ + { id: 'step-1', boardId: 'board-1', widgetId: 'letters-1', mode: 'werken' }, + { id: 'step-2', boardId: 'board-1', widgetId: 'sentence-1', mode: 'kijken' }, + ]); + const tooLong = await app.inject({ + method: 'PUT', url: '/api/assignments/class/50', cookies, + payload: { ...payload, sequence: Array.from({ length: 13 }, () => payload.sequence[0]) }, + }); + assert.equal(tooLong.statusCode, 400); + await app.close(); +}); + +test('dashboard markeert klaar, bezig, hulp nodig en nog niet gestart', async () => { + const teacher = { + id: 3, username: 'juf', role: 'teacher', school_id: 2, class_id: null, + data: teacherData, data_rev: 0, + }; + const query = async (sql) => { + const auth = authRows(teacher, sql); + if (auth) return auth; + if (sql.startsWith('SELECT * FROM classes')) return { rows: [{ id: 50, school_id: 2 }] }; + if (sql.startsWith('SELECT 1 FROM class_teachers')) return { rows: [{ '?column?': 1 }] }; + if (sql.includes('COALESCE(SUM(pe.attempts)')) return { rows: [ + { id: 1, display_name: 'A', attempts: 10, correct: 10 }, + { id: 2, display_name: 'B', attempts: 8, correct: 3 }, + { id: 3, display_name: 'C', attempts: 2, correct: 2 }, + { id: 4, display_name: 'D', attempts: 0, correct: 0 }, + ] }; + if (sql.startsWith('SELECT * FROM assignments')) return { rows: [{ + id: 80, class_id: 50, title: 'Route', + sequence: [{ id: 'step-1' }, { id: 'step-2' }], + }] }; + if (sql.includes('FROM assignment_step_progress asp')) return { rows: [ + { assignment_id: 80, pupil_id: 1, completed: 2 }, + ] }; + return { rows: [] }; + }; + const app = await fastifyWith(teacher, query); + const response = await app.inject({ + method: 'GET', url: '/api/progress/dashboard/class/50', cookies, + }); + assert.equal(response.statusCode, 200, response.body); + assert.deepEqual(response.json().pupils.map((pupil) => pupil.status), + ['complete', 'support', 'active', 'notStarted']); + assert.deepEqual(response.json().summary, + { complete: 1, active: 1, support: 1, notStarted: 1 }); + await app.close(); +}); + +test('database, leerlingweergave en beheer bevatten de drie samenhangende uitbreidingen', async () => { + const [migration, pupil, cast, admin, avatar, css, core] = await Promise.all([ + readFile('db/021_learning_paths.sql', 'utf8'), + readFile('public/js/pupil.js', 'utf8'), + readFile('public/js/cast.js', 'utf8'), + readFile('public/js/admin.js', 'utf8'), + readFile('public/js/avatar.js', 'utf8'), + readFile('public/css/teach.css', 'utf8'), + readFile('public/js/core.js', 'utf8'), + ]); + for (const table of ['assignment_step_progress', 'user_avatar_rewards']) assert.ok(migration.includes(table)); + assert.match(pupil, /pupil-path[\s\S]*assignment\/steps/); + assert.match(cast, /castPathSteps[\s\S]*body\.sequence/); + assert.match(admin, /progress\/dashboard\/class/); + for (const reward of ['head:star', 'body:medal', 'dance:robot']) { + assert.ok(avatar.includes(reward)); + } + assert.match(css, /av-dance-robot/); + for (const key of ['pupilPathStep', 'castPathAdd', 'amDashboardTitle']) { + assert.equal(core.split(`${key}:`).length - 1, 2, `${key} moet tweetalig zijn`); + } +}); diff --git a/test/role-preview.test.js b/test/role-preview.test.js index aca8c0e..727d2c6 100644 --- a/test/role-preview.test.js +++ b/test/role-preview.test.js @@ -66,6 +66,6 @@ test('voorbeeldrol begrenst clientmenu’s maar verandert serverpermissies niet' assert.match(admin,/activeRoles\(\)\.flatMap/); assert.match(admin,/show:\(\)=>activeRole\(\)==="super"/); assert.match(explorer,/activeRole\(\) === "pupil"/); - assert.match(pupil,/async function renderPupilWidgets\(widgets, mode, preview=false\)/); + assert.match(pupil,/async function renderPupilWidgets\(widgets, mode, preview=false, sequence=null\)/); assert.match(pupil,/if\(!preview\)await Promise\.all/); }); diff --git a/test/save-system.test.js b/test/save-system.test.js index f63a031..b5a4960 100644 --- a/test/save-system.test.js +++ b/test/save-system.test.js @@ -8,7 +8,7 @@ import api from '../src/api.js'; // Ingelogde leerkracht via een gemockte sessie: elke query die de sessie-join // doet levert de gebruiker, de data-update legt zijn SQL+params vast zodat de // test de rev-voorwaarde en het 409-pad kan controleren. -function makeApp({ dataRev = 3, updateSucceeds = true } = {}) { +function makeApp({ dataRev = 3, updateSucceeds = true, ownedRewards = [] } = {}) { const calls = []; const pool = { async query(sql, params = []) { @@ -18,6 +18,9 @@ function makeApp({ dataRev = 3, updateSucceeds = true } = {}) { school_id: 2, class_id: null, data: {}, data_rev: dataRev, }] }; if (sql.includes('FROM user_roles')) return { rows: [] }; + if (sql.startsWith('SELECT reward_key FROM user_avatar_rewards')) { + return { rows: ownedRewards.map((reward_key) => ({ reward_key })) }; + } if (sql.startsWith('SELECT data, data_rev')) return { rows: [{ data: { boards: {} }, data_rev: dataRev }] }; if (sql.startsWith('UPDATE users SET data =')) { return updateSucceeds ? { rows: [{ data_rev: dataRev + 1 }] } : { rows: [] }; @@ -71,6 +74,24 @@ test("avatar-API weigert een variant uit een andere categorie", async () => { assert.equal(calls.some((c) => c.sql.startsWith("UPDATE users SET avatar_data")),false); await app.close(); }); +test("avatar-API weigert een beloningsaccessoire totdat het is vrijgespeeld", async () => { + const locked = await makeApp(); + const denied = await locked.app.inject({ + method: "PUT", url: "/api/me/avatar", cookies: sessionCookie, + payload: { ...avatarPayload, accessoryHead: "star" }, + }); + assert.equal(denied.statusCode, 403, denied.body); + assert.equal(locked.calls.some((call) => call.sql.startsWith("UPDATE users SET avatar_data")), false); + await locked.app.close(); + + const unlocked = await makeApp({ ownedRewards: ["head:star"] }); + const allowed = await unlocked.app.inject({ + method: "PUT", url: "/api/me/avatar", cookies: sessionCookie, + payload: { ...avatarPayload, accessoryHead: "star" }, + }); + assert.equal(allowed.statusCode, 200, allowed.body); + await unlocked.app.close(); +}); test('GET /me/data geeft data én rev terug', async () => { const { app } = await makeApp({ dataRev: 3 }); const res = await app.inject({ method: 'GET', url: '/api/me/data', cookies: sessionCookie });