Voeg thematische kleurplaten en creatieve niveaus toe (v0.4.65-beta) #3

Open
bes-r wants to merge 207 commits from bes-r/kleurplaten-v0-4-65 into main AGit
12 changed files with 686 additions and 13 deletions
Showing only changes of commit 90851f960a - Show all commits

View file

@ -1 +1 @@
0.4.56-beta
0.4.57-beta

View file

@ -0,0 +1,25 @@
-- Realtime leerlingactiviteit: een sessie start pas bij echte interactie met
-- een toegewezen widget. Heartbeats houden de sessie live; afgesloten en
-- weggevallen sessies blijven kort terug te zien in het klasdashboard.
CREATE TABLE IF NOT EXISTS live_learning_sessions (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
pupil_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
teacher_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
class_id BIGINT NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
assignment_id BIGINT REFERENCES assignments(id) ON DELETE SET NULL,
board_id TEXT NOT NULL,
widget_id TEXT NOT NULL,
widget_type TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'werken' CHECK (mode IN ('werken', 'kijken')),
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ended_at TIMESTAMPTZ,
progress_count INTEGER NOT NULL DEFAULT 0,
attempts INTEGER NOT NULL DEFAULT 0,
correct INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_live_learning_sessions_class_recent
ON live_learning_sessions (class_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_live_learning_sessions_pupil_open
ON live_learning_sessions (pupil_id, ended_at, last_seen_at DESC);

View file

@ -22,6 +22,18 @@ server {
gzip_min_length 1024;
gzip_types text/css application/javascript application/json image/svg+xml text/plain;
location /api/progress/live/ {
proxy_pass http://app:3000;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
gzip off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $teach_forwarded_proto;
}
location / {
proxy_pass http://app:3000;
proxy_http_version 1.1;

View file

@ -2455,6 +2455,26 @@ body.hc #settingsModal .am-inp, body.hc #settingsModal .am-sel{border-color:#000
@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.) */
/* Realtime leersessies bovenin het klasdashboard */
#settingsModal .live-session-overview{margin-bottom:14px;padding:12px;border:1px solid color-mix(in srgb,var(--blue) 32%,var(--line));border-radius:14px;background:color-mix(in srgb,var(--blue) 5%,var(--surface))}
#settingsModal .live-session-head,#settingsModal .live-session-group-head{display:flex;align-items:center;justify-content:space-between;gap:8px}
#settingsModal .live-session-head{margin-bottom:10px;font-size:14px}
#settingsModal .live-connection{padding:3px 8px;border-radius:99px;background:var(--surface-2);color:var(--orange);font-size:10px;font-weight:900}
#settingsModal .live-connection.connected{background:color-mix(in srgb,var(--green) 14%,var(--surface));color:var(--green)}
#settingsModal .live-session-group+.live-session-group{margin-top:12px}
#settingsModal .live-session-group-head{margin-bottom:6px;color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.35px}
#settingsModal .live-session-group-head span{min-width:22px;padding:2px 6px;border-radius:99px;background:var(--surface-2);text-align:center;font-weight:900}
#settingsModal .live-session-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:7px}
#settingsModal .live-session-card{display:flex;min-width:0;flex-direction:column;align-items:flex-start;gap:4px;padding:10px;border:1px solid var(--line);border-radius:11px;background:var(--surface);color:var(--ink);font:inherit;text-align:left;cursor:pointer}
#settingsModal .live-session-card:hover,#settingsModal .live-session-card:focus-visible{border-color:var(--accent);outline:2px solid var(--accent-soft);box-shadow:0 4px 12px rgba(30,50,90,.08)}
#settingsModal .live-session-card.live{border-color:color-mix(in srgb,var(--green) 45%,var(--line));box-shadow:inset 3px 0 var(--green)}
#settingsModal .live-session-card.recent{box-shadow:inset 3px 0 var(--line)}
#settingsModal .live-session-status{padding:2px 7px;border-radius:99px;background:var(--surface-2);color:var(--muted);font-size:10px;font-weight:900}
#settingsModal .live-session-card.live .live-session-status{background:color-mix(in srgb,var(--green) 15%,var(--surface));color:var(--green)}
#settingsModal .live-session-name{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px}
#settingsModal .live-session-widget,#settingsModal .live-session-time,#settingsModal .live-session-score{max-width:100%;color:var(--muted);font-size:11px;line-height:1.35}
#settingsModal .live-session-score{font-weight:850;color:var(--accent-ink)}
#settingsModal .progress-level-badge{
display:inline-flex; align-self:flex-start; padding:4px 10px; margin-bottom:10px;
border-radius:999px; background:var(--accent-soft); color:var(--accent-ink);

View file

@ -50,6 +50,7 @@
<script src="js/board.js?v=__V__" defer></script>
<script src="js/pupil.js?v=__V__" defer></script>
<script src="js/parent.js?v=__V__" defer></script>
<script src="js/live-dashboard.js?v=__V__" defer></script>
<script src="js/admin.js?v=__V__" defer></script>
<script src="js/cast.js?v=__V__" defer></script>
<script src="js/image-catalog.js?v=__V__" defer></script>

View file

@ -25,6 +25,8 @@
over her-renders heen zoals adminTab hierboven. */
let kmClass = null, kmSubject = "rekenen";
const KM_SUBJECTS = ["taal", "rekenen", "world"];
let stopDashboardLive=()=>{};
let liveDashboardState="reconnecting";
/* navigatie binnen "School": welk niveau van de boom je ziet.
{type:"overview"} - tegels (scholen op "alle scholen", anders
@ -713,6 +715,8 @@
/* leerling-omgeving: welk eigen bord ziet een klas (standaard), of een individuele
leerling (uitzondering voor wie op een ander niveau werkt) - zie roadmap-memo. */
function renderToewijzingenPanel(panel){
stopDashboardLive();stopDashboardLive=()=>{};
liveDashboardState="reconnecting";
const schoolChosen = currentUser.role!=="super" || !!selSchool;
if(!schoolChosen){
panel.appendChild(h("div","guestnote", T("amPickSchoolHint")));
@ -728,17 +732,43 @@
const pupils = USERS.filter(u=>u.role==="pupil");
panel.appendChild(h("div","am-group", T("amDashboardTitle")));
const dashboardClass = classSel();
const dashboardClass = classSel(CLASSES.length===1?CLASSES[0].id:"");
panel.appendChild(dashboardClass);
const dashboardBody = h("div","teacher-dashboard");
panel.appendChild(dashboardBody);
async function loadDashboard(){
let dashboardSequence=0;
const selectDashboardPupil=pupilId=>{
pupilSel.value=String(pupilId);
pupilSel.dispatchEvent(new Event("change"));
};
async function loadDashboard(connect=false){
const sequence=++dashboardSequence;
dashboardBody.innerHTML = "";
if(!dashboardClass.value){ dashboardBody.appendChild(h("div","guestnote",T("amPickClassHint"))); return; }
if(!dashboardClass.value){
stopDashboardLive();stopDashboardLive=()=>{};
dashboardBody.appendChild(h("div","guestnote",T("amPickClassHint")));
return;
}
if(connect){
stopDashboardLive();
liveDashboardState="reconnecting";
stopDashboardLive=connectLiveClassDashboard(
dashboardClass.value,
()=>loadDashboard(false),
()=>dashboardBody.isConnected&&panel.style.display!=="none"&&document.getElementById("settingsWrap").classList.contains("open"),
state=>{liveDashboardState=state;updateLiveConnectionState(dashboardBody,state);}
);
}
dashboardBody.appendChild(h("div","guestnote","…"));
try{
const data = await api("/progress/dashboard/class/"+dashboardClass.value);
const [data,liveData] = await Promise.all([
api("/progress/dashboard/class/"+dashboardClass.value),
api("/progress/live/class/"+dashboardClass.value),
]);
if(sequence!==dashboardSequence)return;
dashboardBody.innerHTML = "";
renderLiveSessionOverview(dashboardBody,liveData,selectDashboardPupil);
updateLiveConnectionState(dashboardBody,liveDashboardState);
const summary = h("div","dashboard-summary");
["complete","active","support","notStarted"].forEach(status=>{
const chip = h("div","dashboard-summary-chip "+status);
@ -762,14 +792,17 @@
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")); });
card.addEventListener("click",()=>selectDashboardPupil(pupil.pupilId));
grid.appendChild(card);
});
dashboardBody.appendChild(grid);
}catch(e){ dashboardBody.innerHTML=""; dashboardBody.appendChild(h("div","am-msg",e.message)); }
}catch(e){
if(sequence!==dashboardSequence)return;
dashboardBody.innerHTML="";dashboardBody.appendChild(h("div","am-msg",e.message));
}
}
dashboardClass.addEventListener("change",loadDashboard);
loadDashboard();
dashboardClass.addEventListener("change",()=>loadDashboard(true));
loadDashboard(true);
panel.appendChild(h("div","am-group", T("amProgressDetail")));
const pupilSel = h("select","am-sel");

View file

@ -2,7 +2,7 @@
"use strict";
/* version shown until /api/version resolves (or if the fetch fails, e.g. offline).
Kept in sync by hand with the VERSION file at the repo root on every release. */
const VERSION = "0.4.56-beta";
const VERSION = "0.4.57-beta";
/* Safari en oudere mobiele browsers behandelen 100vh/100% soms als de hoogte
achter hun adres- en navigatiebalk. visualViewport geeft de werkelijk
zichtbare hoogte; de CSS gebruikt deze pixelwaarde als betrouwbare bron en
@ -409,6 +409,10 @@ const I18N = {
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",
amLiveSessions:"Live leersessies", amLiveConnected:"● Live verbonden", amLiveReconnecting:"○ Verbinden…",
amLiveNow:"Nu actief", amLiveRecent:"Meest recente sessies", amLiveNone:"Nog geen leerling actief.", amLiveRecentNone:"Nog geen recente sessies.",
amLiveNew:"Nieuw begonnen", amLiveWorking:"Live bezig", amLiveEnded:"Recent", amLiveStarted:"Gestart", amLiveLastSeen:"Laatst gezien",
amLiveUpdates:"{count} updates", amLiveScore:"{correct}/{attempts} goed", amLiveJustNow:"zojuist", amLiveMinutes:"{count} min. geleden", amLiveHours:"{count} uur geleden",
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.",
@ -807,6 +811,10 @@ const I18N = {
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",
amLiveSessions:"Live learning sessions", amLiveConnected:"● Live connected", amLiveReconnecting:"○ Connecting…",
amLiveNow:"Active now", amLiveRecent:"Most recent sessions", amLiveNone:"No pupil is active yet.", amLiveRecentNone:"No recent sessions yet.",
amLiveNew:"Just started", amLiveWorking:"Working live", amLiveEnded:"Recent", amLiveStarted:"Started", amLiveLastSeen:"Last seen",
amLiveUpdates:"{count} updates", amLiveScore:"{correct}/{attempts} correct", amLiveJustNow:"just now", amLiveMinutes:"{count} min ago", amLiveHours:"{count} hr ago",
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.",

124
public/js/live-dashboard.js Normal file
View file

@ -0,0 +1,124 @@
/* Live klasdashboard: rendert actieve en recente leersessies en houdt de
weergave via Server-Sent Events actueel. Een 30-secondencontrole is alleen
een vangnet voor tijdelijk weggevallen mobiele/proxyverbindingen. */
"use strict";
function liveSessionTime(value){
const seconds=Math.max(0,Math.round((Date.now()-new Date(value).getTime())/1000));
if(seconds<60)return T("amLiveJustNow");
const minutes=Math.floor(seconds/60);
if(minutes<60)return T("amLiveMinutes").replace("{count}",minutes);
const hours=Math.floor(minutes/60);
return T("amLiveHours").replace("{count}",hours);
}
function updateLiveConnectionState(host,state){
const badge=host.querySelector(".live-connection");
if(!badge)return;
badge.className="live-connection "+state;
badge.textContent=T(state==="connected"?"amLiveConnected":"amLiveReconnecting");
}
function renderLiveSessionOverview(host,data,onPupil){
const section=document.createElement("section");
section.className="live-session-overview";
const head=document.createElement("div");
head.className="live-session-head";
const title=document.createElement("strong");
title.textContent=T("amLiveSessions");
const connection=document.createElement("span");
connection.className="live-connection connecting";
connection.textContent=T("amLiveReconnecting");
head.append(title,connection);
section.appendChild(head);
const sessions=data.sessions||[];
const active=sessions.filter(session=>session.status==="live");
const recent=sessions.filter(session=>session.status!=="live").slice(0,20);
const renderGroup=(label,items,emptyKey)=>{
const group=document.createElement("div");
group.className="live-session-group";
const groupHead=document.createElement("div");
groupHead.className="live-session-group-head";
const groupTitle=document.createElement("strong");
groupTitle.textContent=label;
const count=document.createElement("span");
count.textContent=String(items.length);
groupHead.append(groupTitle,count);
group.appendChild(groupHead);
if(!items.length){
const empty=document.createElement("div");
empty.className="guestnote";
empty.textContent=T(emptyKey);
group.appendChild(empty);
section.appendChild(group);
return;
}
const grid=document.createElement("div");
grid.className="live-session-grid";
items.forEach(session=>{
const card=document.createElement("button");
card.type="button";
const justStarted=session.status==="live"
&& new Date(data.serverTime||Date.now()).getTime()-new Date(session.startedAt).getTime()<60000;
card.className="live-session-card "+(session.status==="live"?"live":"recent");
const status=document.createElement("span");
status.className="live-session-status";
status.textContent=T(justStarted?"amLiveNew":session.status==="live"?"amLiveWorking":"amLiveEnded");
const name=document.createElement("strong");
name.className="live-session-name";
name.textContent=session.pupilName;
const widget=document.createElement("span");
widget.className="live-session-widget";
widget.textContent=(T("wg_"+session.widgetType)||session.widgetType)+" · "+T(session.mode==="kijken"?"amModeWatch":"amModeWork");
const time=document.createElement("span");
time.className="live-session-time";
time.textContent=T(session.status==="live"?"amLiveStarted":"amLiveLastSeen")+" "+
liveSessionTime(session.status==="live"?session.startedAt:session.lastSeenAt);
card.append(status,name,widget,time);
if(session.progressCount){
const score=document.createElement("span");
score.className="live-session-score";
score.textContent=T("amLiveUpdates").replace("{count}",session.progressCount);
if(session.attempts)score.textContent+=" · "+T("amLiveScore")
.replace("{correct}",session.correct).replace("{attempts}",session.attempts);
card.appendChild(score);
}
card.addEventListener("click",()=>onPupil(session.pupilId));
grid.appendChild(card);
});
group.appendChild(grid);
section.appendChild(group);
};
renderGroup(T("amLiveNow"),active,"amLiveNone");
renderGroup(T("amLiveRecent"),recent,"amLiveRecentNone");
host.appendChild(section);
}
function connectLiveClassDashboard(classId,onChange,isActive,onState){
let source=null;
let stopped=false;
let reloadTimer=null;
const queueReload=()=>{
clearTimeout(reloadTimer);
reloadTimer=setTimeout(()=>{if(!stopped&&isActive())onChange();},180);
};
if(typeof EventSource!=="undefined"){
source=new EventSource("/api/progress/live/class/"+encodeURIComponent(classId)+"/stream");
source.onopen=()=>onState("connected");
source.onerror=()=>onState("reconnecting");
source.addEventListener("sessions",queueReload);
}
const fallback=setInterval(()=>{
if(!isActive()){stop();return;}
onChange();
},30000);
function stop(){
if(stopped)return;
stopped=true;
clearInterval(fallback);
clearTimeout(reloadTimer);
if(source)source.close();
}
return stop;
}

View file

@ -9,17 +9,61 @@
leerkracht vlot doorkomen). */
const PUPIL_WIDGET_CATS = ["taal", "rekenen"];
const PUPIL_POLL_MS = { kijken: 5000, werken: 20000 };
const PUPIL_LIVE_HEARTBEAT_MS = 12000;
const pupilView = document.getElementById("pupilView");
let pupilPollTimer = null;
let pupilLastSig = null;
let pupilMode = "werken";
let pupilPendingRewards = [];
let pupilLiveSession = null;
let pupilLiveStart = null;
let pupilLiveHeartbeatTimer = null;
async function endPupilLiveSession(keepalive=false){
const current=pupilLiveSession;
pupilLiveSession=null;pupilLiveStart=null;
if(!current)return;
const path="/api/my/live-session/"+encodeURIComponent(current.id)+"/end";
if(keepalive){
fetch(path,{method:"POST",credentials:"same-origin",keepalive:true}).catch(()=>{});
return;
}
try{await api("/my/live-session/"+encodeURIComponent(current.id)+"/end",{method:"POST"});}
catch(e){ /* een verlopen sessie is al effectief afgesloten */ }
}
async function ensurePupilLiveSession(widgetId,widgetType,mode){
const key=widgetId+"|"+widgetType+"|"+mode;
if(pupilLiveSession?.key===key)return pupilLiveSession.id;
if(pupilLiveStart?.key===key)return pupilLiveStart.promise;
if(pupilLiveSession)await endPupilLiveSession();
const promise=api("/my/live-session/start",{method:"POST",body:{widgetId,widgetType,mode}})
.then(result=>{
pupilLiveSession={id:result.session.id,key};
pupilLiveStart=null;
return pupilLiveSession.id;
})
.catch(error=>{
pupilLiveStart=null;
console.warn("live sessie starten mislukt:",error.message);
return null;
});
pupilLiveStart={key,promise};
return promise;
}
async function heartbeatPupilLiveSession(){
const current=pupilLiveSession;
if(!current||document.visibilityState==="hidden")return;
try{await api("/my/live-session/"+encodeURIComponent(current.id)+"/heartbeat",{method:"POST"});}
catch(e){if(pupilLiveSession?.id===current.id)pupilLiveSession=null;}
}
window.addEventListener("pagehide",()=>endPupilLiveSession(true));
function pupilAllowedDefs(){
return REGISTRY.filter(d => PUPIL_WIDGET_CATS.includes(d.cat));
}
async function renderPupilWidgets(widgets, mode, preview=false, sequence=null){
if(!preview)await endPupilLiveSession();
pupilView.innerHTML = "";
const defs = pupilAllowedDefs();
const watch = mode === "kijken";
@ -104,6 +148,13 @@ async function renderPupilWidgets(widgets, mode, preview=false, sequence=null){
hebben nog geen wid - dan is positie in de lijst de terugval-identiteit,
stabiel zolang de leerkracht de widgets op het bord niet herschikt */
const widgetId = w.wid || (def.id + ":" + idx);
const startLive=()=>ensurePupilLiveSession(widgetId,def.id,mode);
if(!preview){
card.addEventListener("pointerdown",startLive);
card.addEventListener("focusin",startLive);
card.addEventListener("keydown",startLive);
if(watch&&idx===0)startLive();
}
/* in kijken-modus geen voortgang: de leerling doet zelf niets. In
werken-modus moet de leerling juist wél kunnen werken - readonly:true
stond hier sinds v0.3.15-beta abusievelijk ook in de werken-tak (een
@ -119,7 +170,9 @@ async function renderPupilWidgets(widgets, mode, preview=false, sequence=null){
"voortgang werkt niet"-klacht onmogelijk te onderzoeken maakt. Nu
zichtbaar in de console (geen storende UI voor de leerling, wél een
spoor voor wie het probleem moet natrekken). */
api("/my/progress", { method:"POST", body: { widgetId, widgetType: def.id, ...p } })
ensurePupilLiveSession(widgetId,def.id,mode)
.then(liveSessionId=>api("/my/progress", { method:"POST",
body: { widgetId, widgetType: def.id, liveSessionId, ...p } }))
.then(()=>{
stepReady = true;
if(stepButton){ stepButton.disabled = false; stepButton.textContent = T("pupilPathNext"); }
@ -188,10 +241,15 @@ function startPupilView(){
pupilPoll();
clearInterval(pupilPollTimer);
pupilPollTimer = setInterval(pupilPoll, PUPIL_POLL_MS[pupilMode]);
clearInterval(pupilLiveHeartbeatTimer);
pupilLiveHeartbeatTimer=setInterval(heartbeatPupilLiveSession,PUPIL_LIVE_HEARTBEAT_MS);
}
function stopPupilView(){
document.body.classList.remove("pupil-mode");
clearInterval(pupilPollTimer);
pupilPollTimer = null;
clearInterval(pupilLiveHeartbeatTimer);
pupilLiveHeartbeatTimer=null;
endPupilLiveSession();
pupilView.innerHTML = "";
}

View file

@ -17,6 +17,7 @@ import {
SESSION_COOKIE, hashSessionToken, sessionTokenFromRequest, sessionDaysForRole,
} from './auth.js';
import { PERMISSIONS, CREATABLE_ROLES, can } from './permissions.js';
import registerLiveSessions from './live-sessions.js';
export default async function api(app) {
const pool = app.pg;
@ -991,6 +992,10 @@ export default async function api(app) {
if (widgetId) widgets = widgets.filter((widget) => widget.wid === widgetId);
return widgets;
};
const liveSessions = registerLiveSessions(app, {
pool, need, fail, progressRoles: PERMISSIONS['progress.view'],
sameSchool, teacherOnly, classOwnedByTeacher, effectiveAssignment, assignmentWidgets,
});
app.get('/my/assignment', async (req, reply) => {
need(req, reply);
@ -1237,7 +1242,7 @@ export default async function api(app) {
app.post('/my/progress', async (req, reply) => {
need(req, reply);
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
const { widgetId, widgetType, attempts, correct, stars, detail } = req.body ?? {};
const { widgetId, widgetType, attempts, correct, stars, detail, liveSessionId } = req.body ?? {};
if (!widgetId || typeof widgetId !== 'string' || widgetId.length > 100) return fail(reply, 400, 'ongeldige widgetId');
if (!widgetType || typeof widgetType !== 'string' || widgetType.length > 40) return fail(reply, 400, 'ongeldige widgetType');
let a = await effectiveAssignment(req.user);
@ -1258,10 +1263,13 @@ export default async function api(app) {
if (p) a = { teacher_id: p.parent_id, board_id: 'thuis' };
}
if (!a) return fail(reply, 400, 'geen toewijzing');
const cleanAttempts = clampCount(attempts);
const cleanCorrect = clampCount(correct);
await pool.query(
`INSERT INTO progress_events (pupil_id, teacher_id, board_id, widget_id, widget_type, attempts, correct, stars, detail)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
[req.user.id, a.teacher_id, a.board_id, widgetId, widgetType, clampCount(attempts), clampCount(correct), clampCount(stars), sanitizeProgressDetail(detail)]);
[req.user.id, a.teacher_id, a.board_id, widgetId, widgetType, cleanAttempts, cleanCorrect, clampCount(stars), sanitizeProgressDetail(detail)]);
await liveSessions.recordProgress(req.user.id, liveSessionId, cleanAttempts, cleanCorrect);
return { ok: true };
});
const describeProgressEvent = (e) => ({

198
src/live-sessions.js Normal file
View file

@ -0,0 +1,198 @@
// Persistente live leersessies + Server-Sent Events voor het klasdashboard.
// De database is de bron van waarheid. SSE verstuurt alleen een ververssignaal,
// zodat reconnects en gemiste mobiele netwerkberichten geen status verliezen.
const LIVE_WINDOW_MS = 45_000;
export default function registerLiveSessions(app, options) {
const {
pool, need, fail, progressRoles, sameSchool, teacherOnly,
classOwnedByTeacher, effectiveAssignment, assignmentWidgets,
} = options;
const classStreams = new Map();
const publishClass = (classId) => {
const streams = classStreams.get(String(classId));
if (!streams) return;
for (const stream of [...streams]) {
try { stream.write('event: sessions\ndata: {}\n\n'); }
catch { streams.delete(stream); }
}
if (!streams.size) classStreams.delete(String(classId));
};
const describe = (row) => {
const lastSeenAt = row.last_seen_at || row.started_at;
const live = !row.ended_at && Date.now() - new Date(lastSeenAt).getTime() <= LIVE_WINDOW_MS;
return {
id: Number(row.id),
pupilId: Number(row.pupil_id),
pupilName: row.display_name || row.username,
widgetId: row.widget_id,
widgetType: row.widget_type,
mode: row.mode,
startedAt: row.started_at,
lastSeenAt,
endedAt: row.ended_at || null,
progressCount: Number(row.progress_count || 0),
attempts: Number(row.attempts || 0),
correct: Number(row.correct || 0),
status: live ? 'live' : 'recent',
};
};
const accessibleClass = async (req, reply) => {
const row = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0];
if (!row) return { response: fail(reply, 404, 'klas onbekend') };
if (!sameSchool(req, row)) return { response: fail(reply, 403, 'geen rechten') };
if (teacherOnly(req) && !(await classOwnedByTeacher(row.id, req.user.id)))
return { response: fail(reply, 403, 'geen rechten') };
return { row };
};
const snapshot = async (classId) => {
const rows = (await pool.query(
`SELECT ls.*, u.display_name, u.username
FROM live_learning_sessions ls JOIN users u ON u.id = ls.pupil_id
WHERE ls.class_id = $1
AND COALESCE(ls.last_seen_at, ls.started_at) >= now() - interval '24 hours'
ORDER BY
CASE WHEN ls.ended_at IS NULL AND ls.last_seen_at >= now() - interval '45 seconds' THEN 0 ELSE 1 END,
COALESCE(ls.last_seen_at, ls.started_at) DESC
LIMIT 80`, [classId])).rows;
const sessions = rows.map(describe);
return {
sessions,
liveCount: sessions.filter((session) => session.status === 'live').length,
serverTime: new Date().toISOString(),
};
};
app.post('/my/live-session/start', async (req, reply) => {
need(req, reply);
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
if (!req.user.class_id) return fail(reply, 400, 'leerling heeft geen klas');
const { widgetId, widgetType, mode } = req.body ?? {};
if (!widgetId || typeof widgetId !== 'string' || widgetId.length > 100)
return fail(reply, 400, 'ongeldige widgetId');
if (!widgetType || typeof widgetType !== 'string' || widgetType.length > 40)
return fail(reply, 400, 'ongeldige widgetType');
if (mode !== 'werken' && mode !== 'kijken') return fail(reply, 400, 'ongeldige modus');
const assignment = await effectiveAssignment(req.user);
if (!assignment) return fail(reply, 400, 'geen toewijzing');
const steps = Array.isArray(assignment.sequence) ? assignment.sequence : [];
let boardId = assignment.board_id;
let assignedWidgetId = assignment.widget_id;
let assignedMode = assignment.mode || 'werken';
if (steps.length) {
const completed = new Set((await pool.query(
'SELECT step_id FROM assignment_step_progress WHERE assignment_id = $1 AND pupil_id = $2',
[assignment.id, req.user.id])).rows.map((row) => row.step_id));
const current = steps.find((step) => !completed.has(step.id));
if (!current) return fail(reply, 409, 'opdrachtenreeks is al afgerond');
boardId = current.boardId;
assignedWidgetId = current.widgetId;
assignedMode = current.mode;
}
if (assignedWidgetId && assignedWidgetId !== widgetId)
return fail(reply, 409, 'deze widget is niet de huidige opdracht');
if (assignedMode !== mode) return fail(reply, 409, 'de opdrachtmodus is gewijzigd');
const widgets = await assignmentWidgets(assignment, boardId, assignedWidgetId);
const known = widgets.some((widget) =>
widget.id === widgetType && (widget.wid ? widget.wid === widgetId : true));
if (!known) return fail(reply, 409, 'widget staat niet meer in de toewijzing');
await pool.query(
`UPDATE live_learning_sessions
SET ended_at = now(), last_seen_at = now()
WHERE pupil_id = $1 AND ended_at IS NULL`, [req.user.id]);
await pool.query(
`DELETE FROM live_learning_sessions
WHERE pupil_id = $1 AND started_at < now() - interval '30 days'`, [req.user.id]);
const row = (await pool.query(
`INSERT INTO live_learning_sessions
(pupil_id, teacher_id, class_id, assignment_id, board_id, widget_id, widget_type, mode)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
[req.user.id, assignment.teacher_id, req.user.class_id, assignment.id,
boardId, widgetId, widgetType, mode])).rows[0];
publishClass(req.user.class_id);
return { session: describe({ ...row, display_name: req.user.display_name, username: req.user.username }) };
});
app.post('/my/live-session/:id/heartbeat', async (req, reply) => {
need(req, reply);
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
if (!/^\d+$/.test(String(req.params.id))) return fail(reply, 400, 'ongeldige sessie');
const row = (await pool.query(
`UPDATE live_learning_sessions SET last_seen_at = now()
WHERE id = $1 AND pupil_id = $2 AND ended_at IS NULL RETURNING id`,
[req.params.id, req.user.id])).rows[0];
if (!row) return fail(reply, 404, 'sessie niet actief');
return { ok: true };
});
app.post('/my/live-session/:id/end', async (req, reply) => {
need(req, reply);
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
if (!/^\d+$/.test(String(req.params.id))) return fail(reply, 400, 'ongeldige sessie');
const row = (await pool.query(
`UPDATE live_learning_sessions SET ended_at = now(), last_seen_at = now()
WHERE id = $1 AND pupil_id = $2 AND ended_at IS NULL RETURNING class_id`,
[req.params.id, req.user.id])).rows[0];
if (row) publishClass(row.class_id);
return { ok: true };
});
app.get('/progress/live/class/:id', async (req, reply) => {
need(req, reply, progressRoles);
const access = await accessibleClass(req, reply);
if (!access.row) return access.response;
return snapshot(access.row.id);
});
app.get('/progress/live/class/:id/stream', async (req, reply) => {
need(req, reply, progressRoles);
const access = await accessibleClass(req, reply);
if (!access.row) return access.response;
reply.hijack();
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
reply.raw.write('event: connected\ndata: {}\n\n');
const key = String(access.row.id);
if (!classStreams.has(key)) classStreams.set(key, new Set());
classStreams.get(key).add(reply.raw);
const heartbeat = setInterval(() => {
try { reply.raw.write(': heartbeat\n\n'); } catch { /* close ruimt op */ }
}, 15_000);
heartbeat.unref?.();
const cleanup = () => {
clearInterval(heartbeat);
const streams = classStreams.get(key);
if (streams) {
streams.delete(reply.raw);
if (!streams.size) classStreams.delete(key);
}
};
req.raw.once('close', cleanup);
});
return {
async recordProgress(pupilId, sessionId, attempts, correct) {
if (!Number.isSafeInteger(Number(sessionId)) || Number(sessionId) <= 0) return;
const row = (await pool.query(
`UPDATE live_learning_sessions
SET last_seen_at = now(), progress_count = progress_count + 1,
attempts = attempts + $3, correct = correct + $4
WHERE id = $1 AND pupil_id = $2 AND ended_at IS NULL
RETURNING class_id`,
[sessionId, pupilId, attempts, correct])).rows[0];
if (row) publishClass(row.class_id);
},
};
}

186
test/live-sessions.test.js Normal file
View file

@ -0,0 +1,186 @@
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 boardData = {
boards: { folders: [{ boards: [{
id: 'board-live', name: 'Live bord',
data: { widgets: [{ id: 'letters', wid: 'letters-1', state: { words: ['boom'] } }] },
}] }] },
};
async function makeApp(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 auth(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('echte widgetinteractie start een beveiligde live leersessie', async () => {
const pupil = {
id: 9, username: 'lena', display_name: 'Lena', role: 'pupil',
school_id: 2, class_id: 50, data: {}, data_rev: 0,
};
const assignment = {
id: 80, class_id: 50, teacher_id: 3, board_id: 'board-live',
widget_id: 'letters-1', mode: 'werken', sequence: [],
};
const calls = [];
const query = async (sql, params = []) => {
calls.push({ sql, params });
const a = auth(pupil, sql);
if (a) return a;
if (sql === 'SELECT * FROM assignments WHERE pupil_id = $1') return { rows: [assignment] };
if (sql === 'SELECT data FROM users WHERE id = $1') return { rows: [{ data: boardData }] };
if (sql.includes('INSERT INTO live_learning_sessions')) return { rows: [{
id: 101, pupil_id: 9, teacher_id: 3, class_id: 50, assignment_id: 80,
board_id: 'board-live', widget_id: 'letters-1', widget_type: 'letters',
mode: 'werken', started_at: new Date(), last_seen_at: new Date(),
}] };
return { rows: [] };
};
const app = await makeApp(pupil, query);
const response = await app.inject({
method: 'POST', url: '/api/my/live-session/start', cookies,
payload: { widgetId: 'letters-1', widgetType: 'letters', mode: 'werken' },
});
assert.equal(response.statusCode, 200, response.body);
assert.equal(response.json().session.status, 'live');
const insert = calls.find((call) => call.sql.includes('INSERT INTO live_learning_sessions'));
assert.deepEqual(insert.params, [9, 3, 50, 80, 'board-live', 'letters-1', 'letters', 'werken']);
assert.ok(calls.some((call) => call.sql.includes('SET ended_at = now()')),
'een vorige open sessie wordt eerst recent gemaakt');
await app.close();
});
test('voortgang houdt dezelfde live sessie direct actueel', async () => {
const pupil = {
id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50,
data: {}, data_rev: 0,
};
const calls = [];
const query = async (sql, params = []) => {
calls.push({ sql, params });
const a = auth(pupil, sql);
if (a) return a;
if (sql === 'SELECT * FROM assignments WHERE pupil_id = $1')
return { rows: [{ id: 80, teacher_id: 3, board_id: 'board-live', sequence: [] }] };
if (sql.includes('INSERT INTO progress_events')) return { rows: [] };
if (sql.includes('progress_count = progress_count + 1')) return { rows: [{ class_id: 50 }] };
return { rows: [] };
};
const app = await makeApp(pupil, query);
const response = await app.inject({
method: 'POST', url: '/api/my/progress', cookies,
payload: {
widgetId: 'letters-1', widgetType: 'letters', attempts: 4, correct: 3,
stars: 1, liveSessionId: 101,
},
});
assert.equal(response.statusCode, 200, response.body);
const update = calls.find((call) => call.sql.includes('progress_count = progress_count + 1'));
assert.deepEqual(update.params, [101, 9, 4, 3]);
await app.close();
});
test('leerkracht ziet nieuwe actieve en meest recente sessies van de eigen klas', async () => {
const teacher = {
id: 3, username: 'juf', role: 'teacher', school_id: 2,
class_id: null, data: boardData, data_rev: 0,
};
const now = new Date();
const query = async (sql) => {
const a = auth(teacher, sql);
if (a) return a;
if (sql.startsWith('SELECT * FROM classes')) return { rows: [{ id: 50, school_id: 2 }] };
if (sql.startsWith('SELECT 1 FROM class_teachers')) return { rows: [{ ok: 1 }] };
if (sql.includes('FROM live_learning_sessions ls')) return { rows: [
{
id: 101, pupil_id: 9, display_name: 'Lena', widget_id: 'letters-1',
widget_type: 'letters', mode: 'werken', started_at: now, last_seen_at: now,
progress_count: 1, attempts: 2, correct: 2, ended_at: null,
},
{
id: 100, pupil_id: 8, display_name: 'Sam', widget_id: 'math-1',
widget_type: 'madd', mode: 'werken', started_at: new Date(now - 120000),
last_seen_at: new Date(now - 60000), progress_count: 2, attempts: 4,
correct: 3, ended_at: new Date(now - 60000),
},
] };
return { rows: [] };
};
const app = await makeApp(teacher, query);
const response = await app.inject({
method: 'GET', url: '/api/progress/live/class/50', cookies,
});
assert.equal(response.statusCode, 200, response.body);
const body = response.json();
assert.equal(body.liveCount, 1);
assert.deepEqual(body.sessions.map((session) => session.status), ['live', 'recent']);
assert.equal(body.sessions[0].pupilName, 'Lena');
await app.close();
});
test('groepsleiding kan geen live sessies van een andere klas volgen', async () => {
const teacher = {
id: 3, username: 'juf', role: 'teacher', school_id: 2,
class_id: null, data: boardData, data_rev: 0,
};
const query = async (sql) => {
const a = auth(teacher, sql);
if (a) return a;
if (sql.startsWith('SELECT * FROM classes')) return { rows: [{ id: 51, school_id: 2 }] };
if (sql.startsWith('SELECT 1 FROM class_teachers')) return { rows: [] };
return { rows: [] };
};
const app = await makeApp(teacher, query);
const response = await app.inject({
method: 'GET', url: '/api/progress/live/class/51', cookies,
});
assert.equal(response.statusCode, 403);
await app.close();
});
test('live dashboard gebruikt SSE, heartbeats, retentie en ongebufferde proxying', async () => {
const [migration, server, pupil, dashboard, admin, html, nginx, core, version] = await Promise.all([
readFile('db/022_live_learning_sessions.sql', 'utf8'),
readFile('src/live-sessions.js', 'utf8'),
readFile('public/js/pupil.js', 'utf8'),
readFile('public/js/live-dashboard.js', 'utf8'),
readFile('public/js/admin.js', 'utf8'),
readFile('public/index.html', 'utf8'),
readFile('deploy/nginx.conf', 'utf8'),
readFile('public/js/core.js', 'utf8'),
readFile('VERSION', 'utf8'),
]);
for (const column of ['started_at', 'last_seen_at', 'ended_at', 'progress_count'])
assert.ok(migration.includes(column), column);
assert.match(server, /text\/event-stream/);
assert.match(server, /event: sessions/);
assert.match(server, /interval '24 hours'/);
assert.match(pupil, /pointerdown[\s\S]*ensurePupilLiveSession/);
assert.match(pupil, /PUPIL_LIVE_HEARTBEAT_MS/);
assert.match(pupil, /liveSessionId/);
assert.match(dashboard, /new EventSource/);
assert.match(dashboard, /setInterval[\s\S]*30000/);
assert.match(admin, /progress\/live\/class/);
assert.ok(html.indexOf('js/live-dashboard.js') < html.indexOf('js/admin.js'));
assert.match(nginx, /location \/api\/progress\/live\/[\s\S]*proxy_buffering off/);
for (const key of ['amLiveSessions', 'amLiveNew', 'amLiveRecent', 'amLiveConnected'])
assert.equal((core.match(new RegExp(key + ':', 'g')) || []).length, 2, key);
assert.ok(core.includes('const VERSION = "' + version.trim() + '"'));
});