Verbeter grafische kwaliteit van kleurplaten (v0.4.67-beta) #5

Open
bes-r wants to merge 208 commits from bes-r/coloring-quality-v0.4.67 into main AGit
13 changed files with 800 additions and 68 deletions
Showing only changes of commit ae8d0d4bb4 - Show all commits

View file

@ -1 +1 @@
0.4.54-beta 0.4.55-beta

24
db/021_learning_paths.sql Normal file
View file

@ -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)
);

View file

@ -164,6 +164,18 @@
box-shadow:0 2px 8px rgba(30,50,90,.15); box-shadow:0 2px 8px rgba(30,50,90,.15);
} }
.pupil-empty{margin:auto; color:var(--muted); font-size:16px; font-weight:600;} .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 ---------- */ /* ---------- Ouderportaal ---------- */
/* ouders zien geen whiteboard: alleen het portaal met hun kinderen */ /* ouders zien geen whiteboard: alleen het portaal met hun kinderen */
#parentView{display:none;} #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-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-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-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 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 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 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 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 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 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{animation:none;}
.avatar-dance-stage.av-dance-reduced .avatar-face{animation:none;transform: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;} .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{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;}
/* 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.) */ /* Voortgang-tab: klassenmanagement-koppeling + grafiek (v0.4.35-beta e.v.) */
#settingsModal .progress-level-badge{ #settingsModal .progress-level-badge{
display:inline-flex; align-self:flex-start; padding:4px 10px; margin-bottom:10px; display:inline-flex; align-self:flex-start; padding:4px 10px; margin-bottom:10px;

View file

@ -725,6 +725,52 @@
panel.appendChild(h("div","am-group", T("amAssignPupils"))); panel.appendChild(h("div","am-group", T("amAssignPupils")));
const pupils = USERS.filter(u=>u.role==="pupil"); 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"); const pupilSel = h("select","am-sel");
fillPupilSelect(pupilSel, pupils); fillPupilSelect(pupilSel, pupils);
panel.appendChild(pupilSel); panel.appendChild(pupilSel);

View file

@ -12,9 +12,19 @@ const AVATAR_VARIANTS = Object.freeze({
animal: ["cat", "dog", "rabbit", "bear"], animal: ["cat", "dog", "rabbit", "bear"],
}); });
const AVATAR_HAIR = ["none", "tuft", "spiky", "curly", "wavy", "bob", "ponytail", "braids", "mohawk"]; 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_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"]; 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"]); 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({ const AVATAR_DEFAULT = Object.freeze({
species: "dino", speciesVariant: "rex", bodyColor: "#3fae6a", hairStyle: "none", hairColor: "#5b3a29", species: "dino", speciesVariant: "rex", bodyColor: "#3fae6a", hairStyle: "none", hairColor: "#5b3a29",
accessoryHead: "none", accessoryBody: "none", accessoryHead: "none", accessoryBody: "none",
@ -382,6 +392,9 @@ const AVATAR_HEAD_ACC_MARKUP = {
<path d="M 36.5 17 C 45 21 55 21 63.5 17" stroke="#f4c34e" stroke-width="2.2" fill="none"/> <path d="M 36.5 17 C 45 21 55 21 63.5 17" stroke="#f4c34e" stroke-width="2.2" fill="none"/>
<circle cx="45" cy="3" r="2" fill="#f0b73a"/><circle cx="56" cy="8" r="2" fill="#3a8ee0"/><circle cx="47" cy="14" r="2" fill="#f0b73a"/> <circle cx="45" cy="3" r="2" fill="#f0b73a"/><circle cx="56" cy="8" r="2" fill="#3a8ee0"/><circle cx="47" cy="14" r="2" fill="#f0b73a"/>
<circle cx="50" cy="-15" r="4.2" fill="#f0b73a"/>`, <circle cx="50" cy="-15" r="4.2" fill="#f0b73a"/>`,
star: () => `
<path class="av-part av-headwear av-reward-star" d="M 50 -18 L 55 -7 L 67 -6 L 58 2 L 61 14 L 50 8 L 39 14 L 42 2 L 33 -6 L 45 -7 Z" fill="#ffd84d" stroke="#b8750b" stroke-width="1.5" stroke-linejoin="round"/>
<path d="M 45 -5 L 49 -11 M 43 1 L 50 5" stroke="#fff4a8" stroke-width="1.6" stroke-linecap="round"/>`,
}; };
/* Oogaccessoires volgen afwijkende oogindelingen van cycloop en alien. */ /* Oogaccessoires volgen afwijkende oogindelingen van cycloop en alien. */
@ -473,6 +486,13 @@ const AVATAR_BODY_ACC_MARKUP = {
<path d="M 11 58 C 23 61 30 67 37 74 M 89 58 C 77 61 70 67 63 74" stroke="#fff" stroke-width="2" fill="none" opacity=".7"/>`, <path d="M 11 58 C 23 61 30 67 37 74 M 89 58 C 77 61 70 67 63 74" stroke="#fff" stroke-width="2" fill="none" opacity=".7"/>`,
front: () => `<circle class="av-part av-wing-clasp" cx="37" cy="65" r="2.4" fill="#5799ad"/><circle cx="63" cy="65" r="2.4" fill="#5799ad"/>`, front: () => `<circle class="av-part av-wing-clasp" cx="37" cy="65" r="2.4" fill="#5799ad"/><circle cx="63" cy="65" r="2.4" fill="#5799ad"/>`,
}, },
medal: {
back: () => "",
front: () => `
<path class="av-part av-reward-medal" d="M 43 61 L 48 76 L 52 76 L 57 61" fill="none" stroke="#2879c9" stroke-width="4" stroke-linejoin="round"/>
<circle cx="50" cy="79" r="9" fill="#ffd84d" stroke="#b8750b" stroke-width="1.6"/>
<path d="M 50 73 L 52 77 L 57 78 L 53 81 L 54 86 L 50 83 L 46 86 L 47 81 L 43 78 L 48 77 Z" fill="#fff2a3"/>`,
},
}; };
/* Pet en muts bedekken de voorste haarlaag. Lange stijlen houden hun /* 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. */ anatomische ankers blijven. Zonder expliciete keuze rouleren de dansen. */
let avatarDanceCursor = -1; let avatarDanceCursor = -1;
function avatarDanceChoice(preferred){ function avatarDanceChoice(preferred){
if(AVATAR_DANCES.includes(preferred)) return preferred; const available = AVATAR_DANCES.filter(dance=>dance !== "robot" || avatarRewardKeys.has("dance:robot"));
avatarDanceCursor = (avatarDanceCursor + 1) % AVATAR_DANCES.length; if(available.includes(preferred)) return preferred;
return AVATAR_DANCES[avatarDanceCursor]; avatarDanceCursor = (avatarDanceCursor + 1) % available.length;
return available[avatarDanceCursor];
} }
function celebrateAvatar(host, options){ function celebrateAvatar(host, options){
if(!host || typeof currentUser === "undefined" || !currentUser || !currentUser.avatar) return null; if(!host || typeof currentUser === "undefined" || !currentUser || !currentUser.avatar) return null;
@ -628,9 +649,17 @@ function buildAvatarEditor(container, initialConfig, onChange){
hairSpan.textContent = T("avatarHairStyle"); hairSpan.textContent = T("avatarHairStyle");
[...hairSel.options].forEach(o=>o.textContent = T("avatarHair_" + o.value)); [...hairSel.options].forEach(o=>o.textContent = T("avatarHair_" + o.value));
headAccSpan.textContent = T("avatarAccHead"); 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"); 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(){ function update(){
const variants = AVATAR_VARIANTS[config.species]; const variants = AVATAR_VARIANTS[config.species];
@ -688,7 +717,7 @@ async function saveAvatar(config){
id: "avatar", icon: "🦖", group: "personal", order: 3, id: "avatar", icon: "🦖", group: "personal", order: 3,
label: ()=>T("stAvatar"), sub: ()=>T("stAvatarSub"), label: ()=>T("stAvatar"), sub: ()=>T("stAvatarSub"),
visible: ()=>!!currentUser, visible: ()=>!!currentUser,
activate(){ showSettingsDom("avatar"); build(); }, activate(){ showSettingsDom("avatar"); loadAvatarRewards().then(build).catch(build); },
keywords: ()=>[T("avatarSpecies_dino"), T("avatarSpecies_monster"), T("avatarSpecies_animal"), keywords: ()=>[T("avatarSpecies_dino"), T("avatarSpecies_monster"), T("avatarSpecies_animal"),
...Object.values(AVATAR_VARIANTS).flat().map(id=>T("avatarVariant_" + id)), ...Object.values(AVATAR_VARIANTS).flat().map(id=>T("avatarVariant_" + id)),
"personage","avatar","dinosaurus","monster","dier","haar","hair","kleur","color"], "personage","avatar","dinosaurus","monster","dier","haar","hair","kleur","color"],
@ -719,7 +748,7 @@ function mountAvatarWidget(root){
host.appendChild(saveBtn); host.appendChild(saveBtn);
host.appendChild(msg); host.appendChild(msg);
} }
build(); loadAvatarRewards().then(build).catch(build);
document.addEventListener("langchange", ()=>{ root.querySelector(".play-mission").textContent = T("wg_avatar_d"); }); document.addEventListener("langchange", ()=>{ root.querySelector(".play-mission").textContent = T("wg_avatar_d"); });
/* currentUser.avatar is de bron van waarheid (server), niet het widget-eigen /* currentUser.avatar is de bron van waarheid (server), niet het widget-eigen
initState - geen dubbele opslag van dezelfde data op twee plekken. */ initState - geen dubbele opslag van dezelfde data op twee plekken. */

View file

@ -26,6 +26,7 @@ let castOpen = false;
let castLoading = false; let castLoading = false;
let castClasses = [], castPupils = []; let castClasses = [], castPupils = [];
let castSelWidget = "", castSelMode = "werken"; let castSelWidget = "", castSelMode = "werken";
let castPathTitle = "", castPathSteps = [];
/* toewijzingen die deze sessie al gelukt zijn ("type:id"), zodat je hetzelfde /* toewijzingen die deze sessie al gelukt zijn ("type:id"), zodat je hetzelfde
bord naar meerdere klassen/leerlingen achter elkaar kunt casten zonder dat bord naar meerdere klassen/leerlingen achter elkaar kunt casten zonder dat
de sheet na elke keer sluit */ de sheet na elke keer sluit */
@ -34,13 +35,18 @@ let castError = "";
/* widgets van het live-open bord, zelfde bron als serializeBoard() (board.js) */ /* widgets van het live-open bord, zelfde bron als serializeBoard() (board.js) */
function currentBoardWidgets(){ 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 ()=>{ btnCast.addEventListener("click", async ()=>{
stashCurrent(); /* verse widget-state, zelfde als btnFolders doet */ stashCurrent(); /* verse widget-state, zelfde als btnFolders doet */
castOpen = true; castOpen = true;
castSelWidget = ""; castSelMode = "werken"; castSelWidget = ""; castSelMode = "werken";
castPathTitle = ""; castPathSteps = [];
castActiveKeys = new Set(); castError = ""; castActiveKeys = new Set(); castError = "";
castLoading = true; castLoading = true;
renderCast(); renderCast();
@ -68,7 +74,10 @@ function castTargetRow(target){
row.addEventListener("click", async ()=>{ row.addEventListener("click", async ()=>{
try{ try{
const body = { boardId: curSlot().id }; 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 }); await api(`/assignments/${target.type}/${target.id}`, { method:"PUT", body });
castActiveKeys.add(key); castActiveKeys.add(key);
castError = ""; castError = "";
@ -112,6 +121,7 @@ function renderCast(){
widgetSel.addEventListener("change", ()=>{ widgetSel.addEventListener("change", ()=>{
castSelWidget = widgetSel.value; castSelWidget = widgetSel.value;
modeSel.style.display = castSelWidget ? "" : "none"; modeSel.style.display = castSelWidget ? "" : "none";
addStep.disabled = !castPathWidgetAllowed(castSelWidget) || castPathSteps.length >= 12;
}); });
modeSel.addEventListener("change", ()=>{ castSelMode = modeSel.value; }); modeSel.addEventListener("change", ()=>{ castSelMode = modeSel.value; });
const whatRow = h("div","am-inline"); const whatRow = h("div","am-inline");
@ -119,6 +129,49 @@ function renderCast(){
whatRow.appendChild(modeSel); whatRow.appendChild(modeSel);
sheet.appendChild(whatRow); 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"))); sheet.appendChild(h("div","am-group", T("castClasses")));
const classList = h("div","am-list"); const classList = h("div","am-list");
if(castLoading) classList.appendChild(h("div","guestnote", "…")); if(castLoading) classList.appendChild(h("div","guestnote", "…"));

View file

@ -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.54-beta"; const VERSION = "0.4.55-beta";
/* Safari en oudere mobiele browsers behandelen 100vh/100% soms als de hoogte /* Safari en oudere mobiele browsers behandelen 100vh/100% soms als de hoogte
achter hun adres- en navigatiebalk. visualViewport geeft de werkelijk achter hun adres- en navigatiebalk. visualViewport geeft de werkelijk
zichtbare hoogte; de CSS gebruikt deze pixelwaarde als betrouwbare bron en 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_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_headphones:"Koptelefoon", avatarAcc_partyhat:"Feesthoedje", avatarAcc_scarf:"Sjaal", avatarAcc_cape:"Cape",
avatarAcc_badge:"Insigne", avatarAcc_necklace:"Ketting", avatarAcc_bowtie:"Vlinderdas", avatarAcc_belt:"Riem", 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 ✓", avatarSave:"Opslaan", avatarSaved:"Personage opgeslagen ✓",
clock24Hour:"24-uursklok", 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", 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", 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.", dtMode:"Basis: grote hoofdhoeken en hele centimeters. Volledig: graden per 2° met dubbele schaal, millimeters en evenwijdige hulplijnen.",
dtModeBasic:"basis", dtModeFull:"volledig", dtModeBasic:"basis", dtModeFull:"volledig",
pupilEmpty:"Je leerkracht heeft nog niets voor je klaargezet.", pupilEmpty:"Je leerkracht heeft nog niets voor je klaargezet.", pupilWatching:"meekijken",
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", amUsers:"Gebruikers", amClasses:"Klassen", amSchools:"Scholen", amOverarching:"Systeem",
amPickSchoolHint:"Kies hierboven eerst een school.", amPickSchool:"Kies een school", amPickSchoolHint:"Kies hierboven eerst een school.", amPickSchool:"Kies een school",
amPupils:"Leerlingen", amPupilOne:"Leerling", amEdit:"Bewerken", amPupils:"Leerlingen", amPupilOne:"Leerling", amEdit:"Bewerken",
@ -402,7 +405,11 @@ const I18N = {
cast:"Cast", castTitle:"Cast bord", castWhat:"Wat cast je?", cast:"Cast", castTitle:"Cast bord", castWhat:"Wat cast je?",
castClasses:"Klassen", castPupils:"Leerlingen", castClasses:"Klassen", castPupils:"Leerlingen",
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", 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.", amProgress:"Voortgang", amProgressEmpty:"Nog geen voortgang voor deze leerling.",
amProgressLast14Days:"Laatste 14 dagen", amProgressMistakes:"Recente fouten", amProgressLast14Days:"Laatste 14 dagen", amProgressMistakes:"Recente fouten",
amProgressTimeline:"Volledige tijdlijn", 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_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_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_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 ✓", avatarSave:"Save", avatarSaved:"Character saved ✓",
clock24Hour:"24-hour clock", 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", 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", 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.", 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", dtModeBasic:"basic", dtModeFull:"full",
pupilEmpty:"Your teacher hasn't set up anything for you yet.", pupilEmpty:"Your teacher hasn't set up anything for you yet.", pupilWatching:"watching",
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", amUsers:"Users", amClasses:"Classes", amSchools:"Schools", amOverarching:"System",
amPickSchoolHint:"First pick a school above.", amPickSchool:"Pick a school", amPickSchoolHint:"First pick a school above.", amPickSchool:"Pick a school",
amPupils:"Pupils", amPupilOne:"Pupil", amEdit:"Edit", amPupils:"Pupils", amPupilOne:"Pupil", amEdit:"Edit",
@ -792,7 +802,11 @@ const I18N = {
cast:"Cast", castTitle:"Cast board", castWhat:"What are you casting?", cast:"Cast", castTitle:"Cast board", castWhat:"What are you casting?",
castClasses:"Classes", castPupils:"Pupils", castClasses:"Classes", castPupils:"Pupils",
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", 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.", amProgress:"Progress", amProgressEmpty:"No progress for this pupil yet.",
amProgressLast14Days:"Last 14 days", amProgressMistakes:"Recent mistakes", amProgressLast14Days:"Last 14 days", amProgressMistakes:"Recent mistakes",
amProgressTimeline:"Full timeline", amProgressTimeline:"Full timeline",

View file

@ -13,15 +13,54 @@ const pupilView = document.getElementById("pupilView");
let pupilPollTimer = null; let pupilPollTimer = null;
let pupilLastSig = null; let pupilLastSig = null;
let pupilMode = "werken"; let pupilMode = "werken";
let pupilPendingRewards = [];
function pupilAllowedDefs(){ function pupilAllowedDefs(){
return REGISTRY.filter(d => PUPIL_WIDGET_CATS.includes(d.cat)); 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 = ""; pupilView.innerHTML = "";
const defs = pupilAllowedDefs(); const defs = pupilAllowedDefs();
const watch = mode === "kijken"; 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 const usable = widgets
.map(w => ({ w, def: defs.find(d => d.id === w.id) })) .map(w => ({ w, def: defs.find(d => d.id === w.id) }))
.filter(x => x.def); .filter(x => x.def);
@ -42,6 +81,8 @@ async function renderPupilWidgets(widgets, mode, preview=false){
try{ levels[subject] = await api("/my/level?subject="+subject); } try{ levels[subject] = await api("/my/level?subject="+subject); }
catch(e){ levels[subject] = null; } catch(e){ levels[subject] = null; }
})); }));
let stepButton = null;
let stepReady = watch || !!sequence?.ready;
usable.forEach(({ w, def }, idx)=>{ usable.forEach(({ w, def }, idx)=>{
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "widget pupil-card" + (watch ? " watch" : ""); 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 zichtbaar in de console (geen storende UI voor de leerling, wél een
spoor voor wie het probleem moet natrekken). */ spoor voor wie het probleem moet natrekken). */
api("/my/progress", { method:"POST", body: { widgetId, widgetType: def.id, ...p } }) 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)); .catch(e=>console.warn("voortgang loggen mislukt:", e.message));
}}; }};
/* De vakinstelling van de leerkracht is per leerling en dus leidend boven /* 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") if(typeof appendLearningProfileBadge === "function")
appendLearningProfileBadge(body, def.cat, state, def.id); 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(){ async function pupilPoll(){
@ -100,10 +168,10 @@ async function pupilPoll(){
const mode = res.mode === "kijken" ? "kijken" : "werken"; const mode = res.mode === "kijken" ? "kijken" : "werken";
/* modus in de signature zodat een moduswissel ook zonder widget-wijziging /* modus in de signature zodat een moduswissel ook zonder widget-wijziging
een her-render (en dus de juiste overlay) oplevert */ 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){ if(sig !== pupilLastSig){
pupilLastSig = sig; pupilLastSig = sig;
await renderPupilWidgets(res.widgets || [], mode); await renderPupilWidgets(res.widgets || [], mode, false, res.sequence || null);
} }
if(mode !== pupilMode){ if(mode !== pupilMode){
pupilMode = mode; pupilMode = mode;

View file

@ -201,9 +201,25 @@ export default async function api(app) {
animal: ['cat', 'dog', 'rabbit', 'bear'], animal: ['cat', 'dog', 'rabbit', 'bear'],
}; };
const AVATAR_HAIR = ['none', 'tuft', 'spiky', 'curly', 'wavy', 'bob', 'ponytail', 'braids', 'mohawk']; 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_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']; 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); 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) => { app.put('/me/avatar', async (req, reply) => {
need(req, reply); need(req, reply);
const b = req.body ?? {}; 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 (!isHexColor(b.hairColor)) return fail(reply, 400, 'ongeldige haarkleur');
if (!AVATAR_HEAD_ACC.includes(b.accessoryHead)) return fail(reply, 400, 'ongeldig hoofdaccessoire'); 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'); 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 = { const avatar = {
species: b.species, speciesVariant, bodyColor: b.bodyColor, hairStyle: b.hairStyle, hairColor: b.hairColor, species: b.species, speciesVariant, bodyColor: b.bodyColor, hairStyle: b.hairStyle, hairColor: b.hairColor,
accessoryHead: b.accessoryHead, accessoryBody: b.accessoryBody, 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 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 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 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 { return {
teacherId: Number(a.teacher_id), teacherId: Number(a.teacher_id),
teacherName: t ? (t.display_name || t.username) : null, teacherName: t ? (t.display_name || t.username) : null,
@ -815,6 +846,8 @@ export default async function api(app) {
widgetId: a.widget_id || null, widgetId: a.widget_id || null,
widgetType: widget ? widget.id : null, widgetType: widget ? widget.id : null,
mode: a.mode || 'werken', mode: a.mode || 'werken',
title: a.title || '',
sequence,
updatedAt: a.updated_at, updatedAt: a.updated_at,
}; };
}; };
@ -834,6 +867,50 @@ export default async function api(app) {
} }
return { boardId, widgetId: widgetId ?? null, mode: m }; 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) => { app.get('/assignments/class/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['assignments.manage']); need(req, reply, PERMISSIONS['assignments.manage']);
@ -846,17 +923,13 @@ export default async function api(app) {
}); });
app.put('/assignments/class/:id', async (req, reply) => { app.put('/assignments/class/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['assignments.manage']); need(req, reply, PERMISSIONS['assignments.manage']);
const share = parseShareBody(req, reply); const share = parseAssignmentBody(req, reply);
if (share.error) return share; if (share.error) return share;
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0]; 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 (!c) return fail(reply, 404, 'klas onbekend');
if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten'); 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'); if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten');
await pool.query( await saveAssignment('class_id', c.id, req.user.id, share);
`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]);
return { ok: true }; return { ok: true };
}); });
app.delete('/assignments/class/:id', async (req, reply) => { 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) => { app.put('/assignments/pupil/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['assignments.manage']); need(req, reply, PERMISSIONS['assignments.manage']);
const share = parseShareBody(req, reply); const share = parseAssignmentBody(req, reply);
if (share.error) return share; if (share.error) return share;
const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0]; 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 (!u) return fail(reply, 404, 'gebruiker onbekend');
if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten'); if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten');
await pool.query( await saveAssignment('pupil_id', u.id, req.user.id, share);
`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]);
return { ok: true }; return { ok: true };
}); });
app.delete('/assignments/pupil/:id', async (req, reply) => { 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). // Leerling-kant: de effectieve toewijzing (eigen override, anders de klas).
// Live opgezocht (geen kopie) zodat nieuwe woorden bij de leerkracht meteen // Bij een reeks wordt uitsluitend de eerste nog niet afgeronde stap live uit
// doorkomen zodra de leerling-pagina opnieuw ophaalt (polling). // 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) => { app.get('/my/assignment', 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');
let a = (await pool.query('SELECT * FROM assignments WHERE pupil_id = $1', [req.user.id])).rows[0]; const assignment = await effectiveAssignment(req.user);
if (!a && req.user.class_id) { if (!assignment) return { widgets: [], mode: 'werken', updatedAt: null, sequence: null };
a = (await pool.query('SELECT * FROM assignments WHERE class_id = $1', [req.user.class_id])).rows[0]; 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 completedRows = (await pool.query(
const t = (await pool.query('SELECT data FROM users WHERE id = $1', [a.teacher_id])).rows[0]; 'SELECT step_id, completed_at FROM assignment_step_progress WHERE assignment_id = $1 AND pupil_id = $2',
const board = t ? findBoardById(t.data, a.board_id) : null; [assignment.id, req.user.id])).rows;
let widgets = (board?.data?.widgets || []).map((w) => ({ id: w.id, wid: w.wid, state: w.state })); const completedIds = new Set(completedRows.map((row) => row.step_id));
// één-widget-toewijzing: alleen die widget; is hij inmiddels van het bord const currentIndex = steps.findIndex((step) => !completedIds.has(step.id));
// verwijderd, dan een lege lijst (zelfde weergave als geen toewijzing) const current = currentIndex >= 0 ? steps[currentIndex] : null;
if (a.widget_id) widgets = widgets.filter((w) => w.wid === a.widget_id); const previous = currentIndex > 0 ? steps[currentIndex - 1] : null;
return { widgets, mode: a.mode || 'werken', updatedAt: a.updated_at }; 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 ------- // ---- 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 ?? {}; 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 effectiveAssignment(req.user);
if (!a && req.user.class_id) { if (a && Array.isArray(a.sequence) && a.sequence.length) {
a = (await pool.query('SELECT * FROM assignments WHERE class_id = $1', [req.user.class_id])).rows[0]; 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, // thuiskind (leerling zonder school, ouderportaal): geen toewijzing nodig,
// de voortgang wordt gelogd met de (eerste) gekoppelde ouder als // 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 })) }; 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 ----------------------------------------------------------- // ---- ouderportaal -----------------------------------------------------------
// Ouders (role 'parent', school_id NULL) registreren zichzelf zonder school, // Ouders (role 'parent', school_id NULL) registreren zichzelf zonder school,
// beheren gekoppelde kind-accounts en melden thuiskinderen aan bij een school. // beheren gekoppelde kind-accounts en melden thuiskinderen aan bij een school.

View file

@ -27,7 +27,7 @@ async function loadAvatarRenderer(){
document:{createElement:fakeAvatarElement,createElementNS:fakeAvatarElement}, document:{createElement:fakeAvatarElement,createElementNS:fakeAvatarElement},
}; };
runInNewContext(source.slice(0,cutoff)+ 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);
context.__avatar.timers=timers; context.__avatar.timers=timers;
return context.__avatar; 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"); 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 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(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("spin"),"spin");
assert.equal(api.nextDance("onbekend"),"bounce");
const hostClasses=new Set(),stages=[]; const hostClasses=new Set(),stages=[];
const host={ 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()=>{ test('accessoires gebruiken vaste ankers en correcte voor- en achterlagen',async()=>{
const api=await loadAvatarRenderer(); 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.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']); assert.deepEqual(Array.from(api.bodyIds),['none','scarf','cape','badge','necklace','bowtie','belt','backpack','satchel','wings','medal']);
for(const id of api.bodyIds){ for(const id of api.bodyIds){
assert.equal(typeof api.bodyAccessories[id].back,'function',id+' mist achterlaag'); 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-torso')); assert.ok(wings.indexOf('av-wings')<wings.indexOf('av-torso'));
assert.ok(wings.indexOf('av-wing-clasp')>wings.indexOf('av-head-large')); assert.ok(wings.indexOf('av-wing-clasp')>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; 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'); 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(); const api=await loadAvatarRenderer();
let combinations=0; let combinations=0;
for(const speciesId of ["dino","monster","animal"]){ 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()=>{ test("client, API en beide talen bevatten dezelfde avataropties",async()=>{
const [apiSource,core]=await Promise.all([ 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"), readFile("public/js/core.js","utf8"),
]); ]);
const hair=["tuft","wavy","bob","braids"]; 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"]; const variants=["rex","triceratops","stegosaurus","raptor","horned","cyclops","slime","alien","cat","dog","rabbit","bear"];
for(const id of hair){ for(const id of hair){
assert.ok(apiSource.includes("'"+id+"'"),id+" ontbreekt in API-validatie"); assert.ok(apiSource.includes("'"+id+"'"),id+" ontbreekt in API-validatie");

223
test/learning-paths.test.js Normal file
View file

@ -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`);
}
});

View file

@ -66,6 +66,6 @@ test('voorbeeldrol begrenst clientmenus maar verandert serverpermissies niet'
assert.match(admin,/activeRoles\(\)\.flatMap/); assert.match(admin,/activeRoles\(\)\.flatMap/);
assert.match(admin,/show:\(\)=>activeRole\(\)==="super"/); assert.match(admin,/show:\(\)=>activeRole\(\)==="super"/);
assert.match(explorer,/activeRole\(\) === "pupil"/); 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/); assert.match(pupil,/if\(!preview\)await Promise\.all/);
}); });

View file

@ -8,7 +8,7 @@ import api from '../src/api.js';
// Ingelogde leerkracht via een gemockte sessie: elke query die de sessie-join // 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 // doet levert de gebruiker, de data-update legt zijn SQL+params vast zodat de
// test de rev-voorwaarde en het 409-pad kan controleren. // 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 calls = [];
const pool = { const pool = {
async query(sql, params = []) { async query(sql, params = []) {
@ -18,6 +18,9 @@ function makeApp({ dataRev = 3, updateSucceeds = true } = {}) {
school_id: 2, class_id: null, data: {}, data_rev: dataRev, school_id: 2, class_id: null, data: {}, data_rev: dataRev,
}] }; }] };
if (sql.includes('FROM user_roles')) return { rows: [] }; 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('SELECT data, data_rev')) return { rows: [{ data: { boards: {} }, data_rev: dataRev }] };
if (sql.startsWith('UPDATE users SET data =')) { if (sql.startsWith('UPDATE users SET data =')) {
return updateSucceeds ? { rows: [{ data_rev: dataRev + 1 }] } : { rows: [] }; 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); assert.equal(calls.some((c) => c.sql.startsWith("UPDATE users SET avatar_data")),false);
await app.close(); 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 () => { test('GET /me/data geeft data én rev terug', async () => {
const { app } = await makeApp({ dataRev: 3 }); const { app } = await makeApp({ dataRev: 3 });
const res = await app.inject({ method: 'GET', url: '/api/me/data', cookies: sessionCookie }); const res = await app.inject({ method: 'GET', url: '/api/me/data', cookies: sessionCookie });