feat: breid live klasinteractie grafisch uit
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 47s

This commit is contained in:
Ramon 2026-07-24 13:40:18 +02:00
parent 90851f960a
commit 131ffc2f38
17 changed files with 1441 additions and 360 deletions

View file

@ -1 +1 @@
0.4.57-beta
0.4.58-beta

View file

@ -0,0 +1,25 @@
-- Bidirectionele live klasinteractie. Sessies onthouden het laatste echte
-- voortgangsmoment en een eventuele pauze. Leerkrachtacties worden duurzaam
-- opgeslagen, zodat een korte netwerkonderbreking geen hint of aanmoediging
-- laat verdwijnen.
ALTER TABLE live_learning_sessions
ADD COLUMN IF NOT EXISTS last_progress_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS paused_at TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS teacher_pupil_actions (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
class_id BIGINT NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
pupil_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
teacher_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('hint', 'encourage', 'pause', 'resume', 'easier')),
message TEXT,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
acknowledged_at TIMESTAMPTZ,
CHECK (message IS NULL OR char_length(message) <= 160)
);
CREATE INDEX IF NOT EXISTS idx_teacher_pupil_actions_pending
ON teacher_pupil_actions (pupil_id, acknowledged_at, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_teacher_pupil_actions_class
ON teacher_pupil_actions (class_id, created_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/my/live-actions/ {
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 /api/progress/live/ {
proxy_pass http://app:3000;
proxy_http_version 1.1;

View file

@ -0,0 +1,89 @@
/* Grafisch live-klassenoverzicht */
#settingsModal .live-class-overview{display:grid;gap:14px;margin-top:10px}
#settingsModal .live-class-overview>.live-session-head{position:sticky;top:0;z-index:3;margin:0;padding:10px 12px;border:1px solid color-mix(in srgb,var(--blue) 30%,var(--line));border-radius:13px;background:color-mix(in srgb,var(--blue) 6%,var(--surface));box-shadow:0 5px 15px rgba(25,45,80,.06)}
#settingsModal .live-class-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
#settingsModal .live-class-metric{display:flex;align-items:center;gap:9px;padding:10px;border:1px solid var(--line);border-radius:12px;background:var(--surface)}
#settingsModal .live-class-metric strong{display:grid;width:35px;height:35px;place-items:center;border-radius:10px;background:var(--surface-2);font-size:18px}
#settingsModal .live-class-metric span{font-size:11px;font-weight:850}
#settingsModal .live-class-metric.active strong{color:var(--green)}
#settingsModal .live-class-metric.new strong{color:var(--blue)}
#settingsModal .live-class-metric.support strong{color:var(--orange)}
#settingsModal .live-class-metric.complete strong{color:var(--accent)}
#settingsModal .live-help-signals{padding:11px;border:1px solid color-mix(in srgb,var(--orange) 45%,var(--line));border-radius:13px;background:color-mix(in srgb,var(--orange) 9%,var(--surface))}
#settingsModal .live-help-signals>strong{display:block;margin-bottom:8px;font-size:12px}
#settingsModal .live-help-signals>div{display:flex;flex-wrap:wrap;gap:6px}
#settingsModal .live-help-chip{border:1px solid color-mix(in srgb,var(--orange) 45%,var(--line));border-radius:999px;padding:5px 9px;background:var(--surface);color:var(--ink);font:inherit;font-size:11px;font-weight:800;cursor:pointer}
#settingsModal .live-help-chip.errors{color:var(--red)}#settingsModal .live-help-chip.idle{color:var(--orange)}
#settingsModal .live-grid-controls{display:flex;align-items:center;justify-content:space-between;gap:10px}
#settingsModal .live-grid-controls .am-sel{width:auto;min-width:150px}
#settingsModal .live-class-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:9px}
#settingsModal .live-pupil-tile{overflow:hidden;border:1px solid var(--line);border-radius:14px;background:var(--surface);box-shadow:inset 4px 0 var(--line)}
#settingsModal .live-pupil-tile.active{border-color:color-mix(in srgb,var(--green) 40%,var(--line));box-shadow:inset 4px 0 var(--green)}
#settingsModal .live-pupil-tile.new{border-color:color-mix(in srgb,var(--blue) 45%,var(--line));box-shadow:inset 4px 0 var(--blue)}
#settingsModal .live-pupil-tile.support{border-color:color-mix(in srgb,var(--orange) 55%,var(--line));box-shadow:inset 4px 0 var(--orange)}
#settingsModal .live-pupil-tile.paused{border-color:color-mix(in srgb,var(--purple) 45%,var(--line));box-shadow:inset 4px 0 var(--purple)}
#settingsModal .live-pupil-tile.complete{box-shadow:inset 4px 0 var(--accent)}
#settingsModal .live-pupil-main{display:grid;width:100%;grid-template-columns:minmax(0,1fr) 76px;gap:5px 8px;padding:10px 10px 8px 14px;border:0;background:transparent;color:var(--ink);font:inherit;text-align:left;cursor:pointer}
#settingsModal .live-pupil-main:hover,#settingsModal .live-pupil-main:focus-visible{background:var(--surface-2);outline:2px solid var(--accent-soft);outline-offset:-2px}
#settingsModal .live-pupil-state{grid-column:1/-1;justify-self:start;padding:2px 7px;border-radius:999px;background:var(--surface-2);color:var(--muted);font-size:9px;font-weight:900;text-transform:uppercase}
#settingsModal .live-pupil-tile.active .live-pupil-state,#settingsModal .live-pupil-tile.new .live-pupil-state{background:color-mix(in srgb,var(--green) 14%,var(--surface));color:var(--green)}
#settingsModal .live-pupil-tile.support .live-pupil-state{background:color-mix(in srgb,var(--orange) 15%,var(--surface));color:var(--orange)}
#settingsModal .live-pupil-main>strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px}
#settingsModal .live-pupil-task{grid-column:1/-1;overflow:hidden;color:var(--muted);font-size:11px;text-overflow:ellipsis;white-space:nowrap}
#settingsModal .live-pupil-progress{align-self:center;height:6px;overflow:hidden;border-radius:99px;background:var(--surface-2)}
#settingsModal .live-pupil-progress i{display:block;height:100%;border-radius:inherit;background:var(--accent)}
#settingsModal .live-pupil-main small{grid-column:1;color:var(--muted);font-size:10px}
#settingsModal .live-sparkline{grid-column:2;grid-row:3/6;width:76px;height:28px;align-self:end}
#settingsModal .live-sparkline .line{fill:none;stroke:var(--blue);stroke-width:3;stroke-linecap:round;stroke-linejoin:round}
#settingsModal .live-sparkline .empty{stroke:var(--line);stroke-width:2;stroke-dasharray:3 3}
#settingsModal .live-action-toggle{width:100%;padding:6px 10px;border:0;border-top:1px solid var(--line);background:var(--surface-2);color:var(--accent-ink);font:inherit;font-size:10px;font-weight:900;cursor:pointer}
#settingsModal .live-action-row{display:grid;grid-template-columns:1fr 1fr;gap:5px;padding:7px;background:var(--surface-2)}
#settingsModal .live-action-row[hidden]{display:none}
#settingsModal .live-action-row button{min-width:0;padding:6px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--ink);font:inherit;font-size:10px;font-weight:800;cursor:pointer}
#settingsModal .live-action-row button:hover,#settingsModal .live-action-row button:focus-visible{border-color:var(--accent);outline:2px solid var(--accent-soft)}
#settingsModal .live-action-row button:disabled{opacity:.4;cursor:not-allowed}
#settingsModal .live-dashboard-message{min-height:17px;color:var(--accent-ink);font-size:11px;font-weight:800}
#settingsModal .live-graphics-grid{display:grid;grid-template-columns:minmax(230px,.8fr) minmax(330px,1.2fr);gap:10px}
#settingsModal .live-graphic-card{min-width:0;padding:12px;border:1px solid var(--line);border-radius:14px;background:var(--surface)}
#settingsModal .live-graphic-card>strong{display:block;margin-bottom:10px;font-size:13px}
#settingsModal .live-funnel-row{display:grid;grid-template-columns:82px minmax(70px,1fr) 28px;align-items:center;gap:7px;margin:7px 0;font-size:10px}
#settingsModal .live-funnel-track{height:12px;overflow:hidden;border-radius:99px;background:var(--surface-2)}
#settingsModal .live-funnel-track i{display:block;height:100%;border-radius:inherit;background:hsl(calc(198 + var(--funnel-step)*18) 72% 51%)}
#settingsModal .live-funnel-row>strong{text-align:right}
#settingsModal .live-trend svg{display:block;width:100%;height:auto;max-height:190px}
#settingsModal .live-trend .grid{stroke:var(--line);stroke-width:1}.live-trend .axis{fill:var(--muted);font-size:9px}
#settingsModal .live-trend .completion{fill:color-mix(in srgb,var(--purple) 42%,transparent)}
#settingsModal .live-trend .accuracy{fill:none;stroke:var(--blue);stroke-width:4;stroke-linecap:round;stroke-linejoin:round}
#settingsModal .live-trend .accuracy-dot{fill:var(--surface);stroke:var(--blue);stroke-width:3}
#settingsModal .live-chart-legend{display:flex;justify-content:center;gap:15px;color:var(--muted);font-size:10px}
#settingsModal .live-chart-legend span:before{display:inline-block;width:9px;height:9px;margin-right:5px;border-radius:3px;background:var(--blue);content:""}
#settingsModal .live-chart-legend .completed:before{background:color-mix(in srgb,var(--purple) 55%,var(--surface))}
#settingsModal .live-timeline{display:grid;gap:6px}
#settingsModal .live-timeline-row{display:grid;grid-template-columns:minmax(90px,.7fr) minmax(130px,1fr) minmax(80px,1.1fr) 32px;align-items:center;gap:8px;width:100%;padding:7px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--ink);font:inherit;text-align:left;cursor:pointer}
#settingsModal .live-timeline-row:hover,#settingsModal .live-timeline-row:focus-visible{border-color:var(--accent);outline:2px solid var(--accent-soft)}
#settingsModal .live-timeline-row>strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px}
#settingsModal .live-timeline-row>span{overflow:hidden;color:var(--muted);font-size:10px;text-overflow:ellipsis;white-space:nowrap}
#settingsModal .live-timeline-track{height:8px;border-radius:99px;background:var(--surface-2)}
#settingsModal .live-timeline-track i{display:block;height:100%;border-radius:inherit;background:var(--line)}
#settingsModal .live-timeline-row.live .live-timeline-track i{background:var(--green)}
#settingsModal .live-timeline-duration{text-align:right}
/* Leerling: live bericht en echte pauze */
.pupil-live-notice{position:fixed;top:calc(var(--tbar) + 12px);left:50%;z-index:5900;max-width:min(560px,calc(100vw - 24px));transform:translateX(-50%);padding:11px 16px;border:2px solid var(--blue);border-radius:14px;background:var(--surface);box-shadow:var(--shadow-2);color:var(--ink);font-size:14px;font-weight:850;text-align:center}
.pupil-live-notice.encourage{border-color:var(--green)}.pupil-live-notice.easier{border-color:var(--purple)}
.pupil-live-notice[hidden],.pupil-pause-shield[hidden]{display:none}
.pupil-pause-shield{position:fixed;inset:var(--tbar) 0 0;z-index:5800;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:9px;padding:24px;background:color-mix(in srgb,var(--surface) 88%,transparent);backdrop-filter:blur(5px);color:var(--ink);text-align:center}
.pupil-pause-shield>span:first-child{font-size:46px}.pupil-pause-shield strong{font-size:22px}.pupil-pause-shield>span:last-child{max-width:440px;color:var(--muted);font-size:14px}
body.pupil-session-paused #pupilView .widget-body{pointer-events:none;user-select:none;filter:saturate(.45)}
@media(max-width:760px){
#settingsModal .live-class-metrics{grid-template-columns:1fr 1fr}
#settingsModal .live-graphics-grid{grid-template-columns:1fr}
#settingsModal .live-timeline-row{grid-template-columns:1fr 1fr}
#settingsModal .live-timeline-track{grid-column:1/-1}
}
@media(max-width:480px){
#settingsModal .live-class-grid{grid-template-columns:1fr}
#settingsModal .live-grid-controls{align-items:stretch;flex-direction:column}
#settingsModal .live-grid-controls .am-sel{width:100%}
}

View file

@ -7,6 +7,7 @@
<link rel="icon" type="image/svg+xml" href="img/logo.svg?v=__V__">
<link rel="preload" href="fonts/quicksand.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="css/teach.css?v=__V__">
<link rel="stylesheet" href="css/live-classroom.css?v=__V__">
<link rel="stylesheet" href="css/image-catalog.css?v=__V__">
<!-- alle app-scripts: defer = parallel downloaden, uitvoeren in dézelfde
volgorde na het parsen (de volgorde is dwingend: core levert T()/api(),
@ -14,6 +15,7 @@
?v=__V__ wordt server-side vervangen door de app-versie (cache-busting;
zie src/frontend.js) zodat js/css een jaar immutable gecachet mag worden. -->
<script src="js/core.js?v=__V__" defer></script>
<script src="js/live-classroom-i18n.js?v=__V__" defer></script>
<script src="js/permissions.js?v=__V__" defer></script>
<script src="js/settings.js?v=__V__" defer></script>
<script src="js/curriculum.js?v=__V__" defer></script>
@ -49,8 +51,9 @@
<script src="js/widgets/world.js?v=__V__" defer></script>
<script src="js/board.js?v=__V__" defer></script>
<script src="js/pupil.js?v=__V__" defer></script>
<script src="js/pupil-live-actions.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/live-classroom-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

@ -767,35 +767,13 @@
]);
if(sequence!==dashboardSequence)return;
dashboardBody.innerHTML = "";
renderLiveSessionOverview(dashboardBody,liveData,selectDashboardPupil);
renderLiveClassOverview(
dashboardBody,data,liveData,selectDashboardPupil,
(pupilId,kind,payload)=>api(
'/progress/live/class/'+dashboardClass.value+'/pupil/'+pupilId+'/action',
{method:'POST',body:{kind,...payload}})
);
updateLiveConnectionState(dashboardBody,liveDashboardState);
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",()=>selectDashboardPupil(pupil.pupilId));
grid.appendChild(card);
});
dashboardBody.appendChild(grid);
}catch(e){
if(sequence!==dashboardSequence)return;
dashboardBody.innerHTML="";dashboardBody.appendChild(h("div","am-msg",e.message));

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.57-beta";
const VERSION = "0.4.58-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

View file

@ -0,0 +1,371 @@
/* Grafisch live-klassenoverzicht: klasraster, hulpsignalen, funnel,
zevendaagse voortgang, sessietijdlijn en directe leerkrachtacties. */
"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 liveDuration(start,end){
const seconds=Math.max(1,Math.round((new Date(end).getTime()-new Date(start).getTime())/1000));
if(seconds<60)return seconds+"s";
const minutes=Math.round(seconds/60);
return minutes<60?minutes+"m":Math.round(minutes/60)+"u";
}
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 liveSvg(tag,attrs={}){
const element=document.createElementNS("http://www.w3.org/2000/svg",tag);
Object.entries(attrs).forEach(([key,value])=>element.setAttribute(key,String(value)));
return element;
}
function liveSparkline(points){
const svg=liveSvg("svg",{class:"live-sparkline",viewBox:"0 0 100 28",
role:"img","aria-label":T("amTrendAccuracy")});
const usable=(points||[]).map((point,index)=>({index,value:point.accuracy}))
.filter(point=>Number.isFinite(point.value));
if(!usable.length){
svg.appendChild(liveSvg("line",{x1:2,y1:25,x2:98,y2:25,class:"empty"}));
return svg;
}
const d=usable.map((point,index)=>{
const x=2+(point.index/Math.max(1,(points.length-1)))*96;
const y=26-(point.value/100)*23;
return(index?"L":"M")+x.toFixed(1)+" "+y.toFixed(1);
}).join(" ");
svg.appendChild(liveSvg("path",{d,class:"line"}));
return svg;
}
function renderLiveFunnel(host,dashboard,live){
const card=document.createElement("section");
card.className="live-graphic-card live-funnel";
const title=document.createElement("strong");
title.textContent=T("amFunnelTitle");
card.appendChild(title);
const base=Math.max(1,dashboard.funnel?.total||dashboard.pupils?.length||0);
const active=live.liveCount||0;
const started=Math.max(dashboard.funnel?.started||0,active);
const steps=[
["amFunnelAssigned",dashboard.funnel?.assigned||0],
["amFunnelStarted",started],
["amFunnelActive",active],
["amFunnelCompleted",dashboard.funnel?.completed||0],
];
steps.forEach(([key,value],index)=>{
const row=document.createElement("div");
row.className="live-funnel-row";
const label=document.createElement("span");
label.textContent=T(key);
const track=document.createElement("span");
track.className="live-funnel-track";
const fill=document.createElement("i");
fill.style.width=Math.max(value?6:0,Math.round((value/base)*100))+"%";
fill.style.setProperty("--funnel-step",String(index));
track.appendChild(fill);
const count=document.createElement("strong");
count.textContent=String(value);
row.append(label,track,count);
card.appendChild(row);
});
host.appendChild(card);
}
function renderLiveTrend(host,live){
const card=document.createElement("section");
card.className="live-graphic-card live-trend";
const title=document.createElement("strong");
title.textContent=T("amTrendTitle");
card.appendChild(title);
const points=live.classTrend||[];
const svg=liveSvg("svg",{viewBox:"0 0 600 190",role:"img",
"aria-label":T("amTrendDescription")});
[0,25,50,75,100].forEach(value=>{
const y=155-(value/100)*125;
svg.appendChild(liveSvg("line",{x1:42,y1:y,x2:580,y2:y,class:"grid"}));
const label=liveSvg("text",{x:35,y:y+4,class:"axis","text-anchor":"end"});
label.textContent=value+"%";svg.appendChild(label);
});
const maxCompleted=Math.max(1,...points.map(point=>Number(point.completed||0)));
points.forEach((point,index)=>{
const x=52+index*(520/Math.max(1,points.length-1));
const height=(Number(point.completed||0)/maxCompleted)*48;
svg.appendChild(liveSvg("rect",{x:x-10,y:155-height,width:20,height,
rx:4,class:"completion"}));
const day=liveSvg("text",{x,y:177,class:"axis","text-anchor":"middle"});
day.textContent=new Date(point.date+"T12:00:00").toLocaleDateString(
LANG==="nl"?"nl-NL":"en-GB",{weekday:"short"});
svg.appendChild(day);
});
const usable=points.map((point,index)=>({index,value:point.accuracy}))
.filter(point=>Number.isFinite(point.value));
if(usable.length){
const d=usable.map((point,index)=>{
const x=52+point.index*(520/Math.max(1,points.length-1));
const y=155-(point.value/100)*125;
return(index?"L":"M")+x.toFixed(1)+" "+y.toFixed(1);
}).join(" ");
svg.appendChild(liveSvg("path",{d,class:"accuracy"}));
usable.forEach(point=>{
const x=52+point.index*(520/Math.max(1,points.length-1));
const y=155-(point.value/100)*125;
svg.appendChild(liveSvg("circle",{cx:x,cy:y,r:4,class:"accuracy-dot"}));
});
}
card.appendChild(svg);
const legend=document.createElement("div");
legend.className="live-chart-legend";
const accuracy=document.createElement("span");accuracy.className="accuracy";
accuracy.textContent=T("amTrendAccuracy");
const completed=document.createElement("span");completed.className="completed";
completed.textContent=T("amTrendCompleted");
legend.append(accuracy,completed);card.appendChild(legend);
host.appendChild(card);
}
function renderLiveTimeline(host,sessions,serverTime,onPupil){
const card=document.createElement("section");
card.className="live-graphic-card live-timeline";
const title=document.createElement("strong");
title.textContent=T("amTimelineTitle");
card.appendChild(title);
const items=(sessions||[]).slice(0,12);
if(!items.length){
const empty=document.createElement("p");empty.className="guestnote";
empty.textContent=T("amLiveRecentNone");card.appendChild(empty);
host.appendChild(card);return;
}
const durations=items.map(session=>Math.max(1000,
new Date(session.endedAt||session.lastSeenAt||serverTime).getTime()-new Date(session.startedAt).getTime()));
const max=Math.max(...durations);
items.forEach((session,index)=>{
const row=document.createElement("button");
row.type="button";row.className="live-timeline-row "+session.status;
const name=document.createElement("strong");name.textContent=session.pupilName;
const meta=document.createElement("span");
meta.textContent=(T("wg_"+session.widgetType)||session.widgetType)+" · "+
liveSessionTime(session.startedAt);
const track=document.createElement("span");track.className="live-timeline-track";
const fill=document.createElement("i");
fill.style.width=Math.max(8,Math.round((durations[index]/max)*100))+"%";
track.appendChild(fill);
const duration=document.createElement("span");duration.className="live-timeline-duration";
duration.textContent=session.paused?T("amStatePaused"):
liveDuration(session.startedAt,session.endedAt||session.lastSeenAt||serverTime);
row.append(name,meta,track,duration);
row.addEventListener("click",()=>onPupil(session.pupilId));
card.appendChild(row);
});
host.appendChild(card);
}
function renderLiveClassOverview(host,dashboard,live,onPupil,onAction){
const section=document.createElement("section");
section.className="live-class-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 activeByPupil=new Map();
const recentByPupil=new Map();
(live.sessions||[]).forEach(session=>{
const map=session.status==="live"?activeByPupil:recentByPupil;
if(!map.has(session.pupilId))map.set(session.pupilId,session);
});
const pupils=(dashboard.pupils||[]).map(pupil=>{
const session=activeByPupil.get(pupil.pupilId)||null;
const recent=recentByPupil.get(pupil.pupilId)||null;
const signal=session?.helpSignal||(pupil.status==="support"?"errors":null);
const justStarted=!!session&&new Date(live.serverTime||Date.now()).getTime()
-new Date(session.startedAt).getTime()<60000;
const state=session?.paused?"paused":signal?"support":justStarted?"new":
session?"active":pupil.status;
return{...pupil,session,recent,signal,state,
trend:live.pupilTrends?.[String(pupil.pupilId)]||[]};
});
const metrics=document.createElement("div");
metrics.className="live-class-metrics";
[
["active",T("amMetricWorking"),pupils.filter(p=>p.state==="active").length],
["new",T("amMetricNew"),pupils.filter(p=>p.state==="new").length],
["support",T("amMetricHelp"),pupils.filter(p=>p.signal).length],
["complete",T("amMetricComplete"),pupils.filter(p=>p.status==="complete").length],
].forEach(([state,label,value])=>{
const metric=document.createElement("div");metric.className="live-class-metric "+state;
const count=document.createElement("strong");count.textContent=String(value);
const text=document.createElement("span");text.textContent=label;
metric.append(count,text);metrics.appendChild(metric);
});
section.appendChild(metrics);
const help=pupils.filter(pupil=>pupil.signal);
if(help.length){
const signals=document.createElement("section");signals.className="live-help-signals";
const signalTitle=document.createElement("strong");signalTitle.textContent=T("amHelpSignals");
signals.appendChild(signalTitle);
const list=document.createElement("div");
help.forEach(pupil=>{
const button=document.createElement("button");button.type="button";
button.className="live-help-chip "+pupil.signal;
button.textContent="⚑ "+pupil.pupilName+" · "+T(
pupil.signal==="idle"?"amHelpIdle":"amHelpErrors");
button.addEventListener("click",()=>onPupil(pupil.pupilId));
list.appendChild(button);
});
signals.appendChild(list);section.appendChild(signals);
}
const controls=document.createElement("div");controls.className="live-grid-controls";
const gridTitle=document.createElement("strong");gridTitle.textContent=T("amClassGrid");
const sort=document.createElement("select");sort.className="am-sel";
[["priority","amSortPriority"],["active","amSortActive"],["name","amSortName"]]
.forEach(([value,key])=>sort.appendChild(new Option(T(key),value)));
controls.append(gridTitle,sort);section.appendChild(controls);
const grid=document.createElement("div");grid.className="live-class-grid";
section.appendChild(grid);
const actionMessage=document.createElement("div");
actionMessage.className="live-dashboard-message";actionMessage.setAttribute("role","status");
section.appendChild(actionMessage);
const stateRank={support:0,paused:1,new:2,active:3,notStarted:4,complete:5};
const drawGrid=()=>{
grid.innerHTML="";
if(!pupils.length){
const empty=document.createElement("p");empty.className="guestnote";
empty.textContent=T("castNoPupils");grid.appendChild(empty);return;
}
const ordered=[...pupils].sort((a,b)=>{
if(sort.value==="name")return a.pupilName.localeCompare(b.pupilName);
if(sort.value==="active")return Number(!!b.session)-Number(!!a.session)
||a.pupilName.localeCompare(b.pupilName);
return(stateRank[a.state]??9)-(stateRank[b.state]??9)
||a.pupilName.localeCompare(b.pupilName);
});
ordered.forEach(pupil=>{
const tile=document.createElement("article");
tile.className="live-pupil-tile "+pupil.state;
const top=document.createElement("button");top.type="button";
top.className="live-pupil-main";
const badge=document.createElement("span");badge.className="live-pupil-state";
const stateKey={
support:pupil.signal==="idle"?"amHelpIdle":"amHelpErrors",
paused:"amStatePaused",new:"amLiveNew",active:"amLiveWorking",
complete:"amDashboardStatus_complete",notStarted:"amDashboardStatus_notStarted",
}[pupil.state]||"amDashboardStatus_active";
badge.textContent=T(stateKey);
const name=document.createElement("strong");name.textContent=pupil.pupilName;
const task=document.createElement("span");task.className="live-pupil-task";
const session=pupil.session||pupil.recent;
task.textContent=session?(T("wg_"+session.widgetType)||session.widgetType):
pupil.sequence?.title||T("amDashboardNoActivity");
const progress=document.createElement("span");progress.className="live-pupil-progress";
const fill=document.createElement("i");
const percent=pupil.sequence?Math.round((pupil.sequence.completed/pupil.sequence.total)*100):
pupil.attempts?pupil.accuracy:0;
fill.style.width=Math.max(0,Math.min(100,percent))+"%";
progress.appendChild(fill);
const score=document.createElement("small");
score.textContent=pupil.sequence?
T("amDashboardSteps").replace("{done}",pupil.sequence.completed).replace("{total}",pupil.sequence.total):
pupil.attempts?T("amLiveScore").replace("{correct}",pupil.correct)
.replace("{attempts}",pupil.attempts):T("amDashboardNoActivity");
top.append(badge,name,task,progress,score,liveSparkline(pupil.trend));
top.addEventListener("click",()=>onPupil(pupil.pupilId));
tile.appendChild(top);
const toggle=document.createElement("button");toggle.type="button";
toggle.className="live-action-toggle";toggle.textContent="⚡ "+T("amActions");
const actions=document.createElement("div");actions.className="live-action-row";
actions.hidden=true;
toggle.addEventListener("click",()=>{actions.hidden=!actions.hidden;});
let sending=false;
const trigger=async(kind,payload={})=>{
if(sending)return;
sending=true;tile.classList.add("sending");
actionMessage.textContent=T("amActionSending");
try{
await onAction(pupil.pupilId,kind,payload);
actionMessage.textContent=T("amActionSent");
}catch(error){actionMessage.textContent=error.message;}
finally{sending=false;tile.classList.remove("sending");}
};
const actionButton=(label,kind,handler,disabled=false)=>{
const button=document.createElement("button");button.type="button";
button.textContent=label;button.disabled=disabled;
button.addEventListener("click",handler||(()=>trigger(kind)));
actions.appendChild(button);
};
actionButton("💡 "+T("amActionHint"),"hint",()=>{
const message=window.prompt(T("amActionHintPrompt"),T("amActionHintDefault"));
if(message&&message.trim())trigger("hint",{message:message.trim()});
});
actionButton("👏 "+T("amActionEncourage"),"encourage",()=>{
const message=window.prompt(T("amActionEncouragePrompt"),T("amActionEncourageDefault"));
if(message&&message.trim())trigger("encourage",{message:message.trim()});
});
const subject=typeof REGISTRY!=="undefined"
?REGISTRY.find(def=>def.id===pupil.session?.widgetType)?.cat:null;
actionButton("↘ "+T("amActionEasier"),"easier",
()=>{if(window.confirm(T("amActionEasierConfirm")))trigger("easier",{subject});},
!pupil.session||!["taal","rekenen","world"].includes(subject));
const pauseKind=pupil.session?.paused?"resume":"pause";
actionButton((pauseKind==="pause"?"⏸ ":"▶ ")+
T(pauseKind==="pause"?"amActionPause":"amActionResume"),pauseKind,
()=>trigger(pauseKind),!pupil.session);
tile.append(toggle,actions);grid.appendChild(tile);
});
};
sort.addEventListener("change",drawGrid);drawGrid();
const graphics=document.createElement("div");graphics.className="live-graphics-grid";
renderLiveFunnel(graphics,dashboard,live);
renderLiveTrend(graphics,live);
section.appendChild(graphics);
renderLiveTimeline(section,live.sessions,live.serverTime,onPupil);
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

@ -0,0 +1,89 @@
/* Teksten voor het bidirectionele live-klaslokaal, apart gehouden zodat het
omvangrijke centrale woordenboek overzichtelijk blijft. */
"use strict";
Object.assign(I18N.nl,{
amFunnelTitle:"Opdrachtfunnel",
amFunnelAssigned:"Ontvangen",
amFunnelStarted:"Gestart",
amFunnelActive:"Nu actief",
amFunnelCompleted:"Afgerond",
amTrendTitle:"Voortgang laatste 7 dagen",
amTrendDescription:"Nauwkeurigheid en afgeronde stappen per dag",
amTrendAccuracy:"Nauwkeurigheid",
amTrendCompleted:"Afgeronde opdrachten",
amTimelineTitle:"Sessietijdlijn",
amStatePaused:"Gepauzeerd",
amMetricWorking:"Nu bezig",
amMetricNew:"Net gestart",
amMetricHelp:"Hulp nodig",
amMetricComplete:"Klaar",
amHelpSignals:"Hulpsignalen",
amHelpIdle:"Lang geen voortgang",
amHelpErrors:"Veel fouten",
amClassGrid:"Live klassenraster",
amSortPriority:"Hulp eerst",
amSortActive:"Actief eerst",
amSortName:"Op naam",
amActions:"Directe acties",
amActionSending:"Actie wordt verstuurd…",
amActionSent:"Actie is direct naar de leerling gestuurd.",
amActionHint:"Hint",
amActionHintPrompt:"Welke korte hint wil je sturen?",
amActionHintDefault:"Kijk nog eens rustig naar de opdracht.",
amActionEncourage:"Aanmoedigen",
amActionEncouragePrompt:"Welke positieve boodschap wil je sturen?",
amActionEncourageDefault:"Goed bezig, blijf proberen!",
amActionEasier:"Eenvoudiger",
amActionEasierConfirm:"Weet je zeker dat je voor deze leerling een eenvoudiger individueel niveau wilt instellen?",
amActionPause:"Pauzeren",
amActionResume:"Hervatten",
pupilPausedTitle:"Even pauze",
pupilPausedText:"Je leerkracht heeft deze opdracht tijdelijk gepauzeerd.",
pupilEasierReady:"Een eenvoudiger niveau staat klaar: groep {group}, niveau {level}.",
pupilResumeMessage:"Je kunt weer verder met de opdracht.",
pupilTeacherMessage:"Je leerkracht heeft een bericht gestuurd.",
});
Object.assign(I18N.en,{
amFunnelTitle:"Assignment funnel",
amFunnelAssigned:"Assigned",
amFunnelStarted:"Started",
amFunnelActive:"Active now",
amFunnelCompleted:"Completed",
amTrendTitle:"Progress over the last 7 days",
amTrendDescription:"Accuracy and completed steps per day",
amTrendAccuracy:"Accuracy",
amTrendCompleted:"Completed assignments",
amTimelineTitle:"Session timeline",
amStatePaused:"Paused",
amMetricWorking:"Working now",
amMetricNew:"Just started",
amMetricHelp:"Needs help",
amMetricComplete:"Complete",
amHelpSignals:"Help signals",
amHelpIdle:"No progress for a while",
amHelpErrors:"Many errors",
amClassGrid:"Live class grid",
amSortPriority:"Help first",
amSortActive:"Active first",
amSortName:"By name",
amActions:"Direct actions",
amActionSending:"Sending action…",
amActionSent:"The action was sent directly to the pupil.",
amActionHint:"Hint",
amActionHintPrompt:"Which short hint would you like to send?",
amActionHintDefault:"Take another calm look at the assignment.",
amActionEncourage:"Encourage",
amActionEncouragePrompt:"Which positive message would you like to send?",
amActionEncourageDefault:"You are doing well, keep trying!",
amActionEasier:"Easier",
amActionEasierConfirm:"Are you sure you want to set an easier individual level for this pupil?",
amActionPause:"Pause",
amActionResume:"Resume",
pupilPausedTitle:"Time for a short pause",
pupilPausedText:"Your teacher has temporarily paused this assignment.",
pupilEasierReady:"An easier level is ready: year {group}, level {level}.",
pupilResumeMessage:"You can continue with the assignment.",
pupilTeacherMessage:"Your teacher sent you a message.",
});

View file

@ -1,124 +0,0 @@
/* 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

@ -0,0 +1,115 @@
/* Live terugkanaal voor leerkrachtacties. Berichten worden via SSE direct
gemeld en daarna via de duurzame API opgehaald en bevestigd. */
"use strict";
let pupilActionSource=null;
let pupilActionFallback=null;
let pupilActionNoticeTimer=null;
let pupilActionLoading=false;
function ensurePupilActionUi(){
let notice=document.getElementById("pupilLiveNotice");
if(!notice){
notice=document.createElement("aside");
notice.id="pupilLiveNotice";
notice.className="pupil-live-notice";
notice.setAttribute("role","status");
notice.setAttribute("aria-live","polite");
notice.hidden=true;
document.body.appendChild(notice);
}
let pause=document.getElementById("pupilPauseShield");
if(!pause){
pause=document.createElement("aside");
pause.id="pupilPauseShield";
pause.className="pupil-pause-shield";
pause.setAttribute("role","status");
const icon=document.createElement("span");icon.textContent="⏸";
const title=document.createElement("strong");title.textContent=T("pupilPausedTitle");
const text=document.createElement("span");text.textContent=T("pupilPausedText");
pause.append(icon,title,text);
document.body.appendChild(pause);
}
return{notice,pause};
}
function setPupilPaused(paused){
const ui=ensurePupilActionUi();
document.body.classList.toggle("pupil-session-paused",!!paused);
ui.pause.hidden=!paused;
}
function showPupilLiveAction(action){
const ui=ensurePupilActionUi();
if(action.kind==="pause"){setPupilPaused(true);return;}
if(action.kind==="resume")setPupilPaused(false);
let text=action.message||"";
if(action.kind==="easier"){
text=T("pupilEasierReady")
.replace("{group}",action.payload?.yearGroup||"")
.replace("{level}",action.payload?.level||"");
pupilLastSig=null;
pupilPoll();
}else if(!text){
text=T(action.kind==="resume"?"pupilResumeMessage":"pupilTeacherMessage");
}
ui.notice.className="pupil-live-notice "+action.kind;
ui.notice.textContent=(action.kind==="encourage"?"👏 ":action.kind==="hint"?"💡 ":"")+" "+text;
ui.notice.hidden=false;
clearTimeout(pupilActionNoticeTimer);
pupilActionNoticeTimer=setTimeout(()=>{ui.notice.hidden=true;},8000);
}
async function loadPupilLiveActions(){
if(pupilActionLoading||!currentUser||currentUser.role!=="pupil")return;
pupilActionLoading=true;
try{
const result=await api("/my/live-actions");
setPupilPaused(result.paused);
for(const action of result.actions||[]){
showPupilLiveAction(action);
await api("/my/live-actions/"+encodeURIComponent(action.id)+"/ack",{method:"POST"});
}
}catch(error){
if(error.status===401||error.status===403)stopPupilLiveActions();
}finally{pupilActionLoading=false;}
}
function startPupilLiveActions(){
stopPupilLiveActions();
ensurePupilActionUi();
loadPupilLiveActions();
if(typeof EventSource!=="undefined"){
pupilActionSource=new EventSource("/api/my/live-actions/stream");
pupilActionSource.addEventListener("actions",loadPupilLiveActions);
}
pupilActionFallback=setInterval(loadPupilLiveActions,15000);
}
function stopPupilLiveActions(){
if(pupilActionSource){pupilActionSource.close();pupilActionSource=null;}
clearInterval(pupilActionFallback);pupilActionFallback=null;
clearTimeout(pupilActionNoticeTimer);
setPupilPaused(false);
const notice=document.getElementById("pupilLiveNotice");
if(notice)notice.hidden=true;
}
document.addEventListener("langchange",()=>{
const pause=document.getElementById("pupilPauseShield");
if(pause){
pause.querySelector("strong").textContent=T("pupilPausedTitle");
pause.querySelector("span:last-child").textContent=T("pupilPausedText");
}
});
const startPupilViewWithoutActions=startPupilView;
startPupilView=function(){
startPupilViewWithoutActions();
startPupilLiveActions();
};
const stopPupilViewWithoutActions=stopPupilView;
stopPupilView=function(){
stopPupilLiveActions();
stopPupilViewWithoutActions();
};

View file

@ -17,7 +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';
import registerLiveSessions from './live-classroom.js';
export default async function api(app) {
const pool = app.pg;
@ -1265,6 +1265,8 @@ export default async function api(app) {
if (!a) return fail(reply, 400, 'geen toewijzing');
const cleanAttempts = clampCount(attempts);
const cleanCorrect = clampCount(correct);
if (await liveSessions.isPaused(req.user.id, liveSessionId))
return fail(reply, 409, 'de leerkracht heeft deze opdracht tijdelijk gepauzeerd');
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)`,
@ -1350,6 +1352,9 @@ export default async function api(app) {
const attempts = Number(pupil.attempts || 0);
const correct = Number(pupil.correct || 0);
const accuracy = attempts ? Math.round((correct / attempts) * 100) : 0;
const started = !!assignment && (!!done || (pupil.last_activity
&& new Date(pupil.last_activity) >= new Date(assignment.updated_at)));
const assignmentComplete = !!assignment && (total ? done >= total : started);
let status = 'notStarted';
if (total && done >= total) status = 'complete';
else if (attempts >= 5 && accuracy < 60) status = 'support';
@ -1358,14 +1363,20 @@ export default async function api(app) {
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,
assigned: !!assignment, started, assignmentComplete, 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 };
const funnel = {
total: overview.length,
assigned: overview.filter((pupil) => pupil.assigned).length,
started: overview.filter((pupil) => pupil.started).length,
completed: overview.filter((pupil) => pupil.assignmentComplete).length,
};
return { pupils: overview, summary, funnel };
});
// ---- ouderportaal -----------------------------------------------------------

441
src/live-classroom.js Normal file
View file

@ -0,0 +1,441 @@
// Bidirectionele live klasinteractie.
// PostgreSQL blijft de bron van waarheid; SSE meldt alleen dat een client de
// actuele snapshot opnieuw moet ophalen. Daardoor blijven reconnects veilig.
const LIVE_WINDOW_MS = 45_000;
const HELP_IDLE_MS = 3 * 60_000;
const ACTION_KINDS = new Set(['hint', 'encourage', 'pause', 'resume', 'easier']);
const LEVEL_SUBJECTS = new Set(['taal', 'rekenen', 'world']);
export default function registerLiveClassroom(app, options) {
const {
pool, need, fail, progressRoles, sameSchool, teacherOnly,
classOwnedByTeacher, effectiveAssignment, assignmentWidgets,
} = options;
const classStreams = new Map();
const pupilStreams = new Map();
const publish = (streamMap, key, event) => {
const streams = streamMap.get(String(key));
if (!streams) return;
for (const stream of [...streams]) {
try { stream.write(`event: ${event}\ndata: {}\n\n`); }
catch { streams.delete(stream); }
}
if (!streams.size) streamMap.delete(String(key));
};
const publishClass = (classId) => publish(classStreams, classId, 'sessions');
const publishPupil = (pupilId) => publish(pupilStreams, pupilId, 'actions');
const describeSession = (row) => {
const lastSeenAt = row.last_seen_at || row.started_at;
const live = !row.ended_at && Date.now() - new Date(lastSeenAt).getTime() <= LIVE_WINDOW_MS;
const attempts = Number(row.attempts || 0);
const correct = Number(row.correct || 0);
const startedMs = new Date(row.started_at).getTime();
const progressMs = row.last_progress_at ? new Date(row.last_progress_at).getTime() : 0;
let helpSignal = null;
if (live && !row.paused_at && attempts >= 5 && correct / attempts < 0.6) {
helpSignal = 'errors';
} else if (live && !row.paused_at && Date.now() - startedMs >= HELP_IDLE_MS
&& (!progressMs || Date.now() - progressMs >= HELP_IDLE_MS)) {
helpSignal = 'idle';
}
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,
correct,
lastProgressAt: row.last_progress_at || null,
paused: !!row.paused_at,
pausedAt: row.paused_at || null,
helpSignal,
status: live ? 'live' : 'recent',
};
};
const describeAction = (row) => ({
id: Number(row.id),
kind: row.kind,
message: row.message || '',
payload: row.payload || {},
createdAt: row.created_at,
});
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 accessiblePupil = async (req, reply, classRow) => {
if (!/^\d+$/.test(String(req.params.pupilId))) {
return { response: fail(reply, 400, 'ongeldige leerling') };
}
const pupil = (await pool.query(
'SELECT * FROM users WHERE id = $1 AND role = $2',
[req.params.pupilId, 'pupil'])).rows[0];
if (!pupil) return { response: fail(reply, 404, 'leerling onbekend') };
if (Number(pupil.class_id) !== Number(classRow.id)) {
return { response: fail(reply, 403, 'leerling hoort niet bij deze klas') };
}
return { pupil };
};
const openStream = (req, reply, streamMap, key) => {
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 streamKey = String(key);
if (!streamMap.has(streamKey)) streamMap.set(streamKey, new Set());
streamMap.get(streamKey).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 = streamMap.get(streamKey);
if (streams) {
streams.delete(reply.raw);
if (!streams.size) streamMap.delete(streamKey);
}
};
reply.raw.once('close', cleanup);
};
const trendDays = () => Array.from({ length: 7 }, (_, index) => {
const date = new Date();
date.setHours(12, 0, 0, 0);
date.setDate(date.getDate() - (6 - index));
return date.toISOString().slice(0, 10);
});
const buildTrends = (activityRows, completionRows) => {
const days = trendDays();
const classMap = new Map(days.map((day) => [day, {
date: day, attempts: 0, correct: 0, stepCompleted: 0, updates: 0,
}]));
const pupilMaps = new Map();
const pupilDay = (pupilId, day) => {
const key = String(pupilId);
if (!pupilMaps.has(key)) {
pupilMaps.set(key, new Map(days.map((date) => [date, {
date, attempts: 0, correct: 0, stepCompleted: 0, updates: 0,
}])));
}
return pupilMaps.get(key).get(day);
};
for (const row of activityRows) {
const day = String(row.day).slice(0, 10);
const classPoint = classMap.get(day);
const pupilPoint = pupilDay(row.pupil_id, day);
if (!classPoint || !pupilPoint) continue;
for (const point of [classPoint, pupilPoint]) {
point.attempts += Number(row.attempts || 0);
point.correct += Number(row.correct || 0);
point.updates += Number(row.updates || 0);
}
}
for (const row of completionRows) {
const day = String(row.day).slice(0, 10);
const classPoint = classMap.get(day);
const pupilPoint = pupilDay(row.pupil_id, day);
if (!classPoint || !pupilPoint) continue;
classPoint.stepCompleted += Number(row.completed || 0);
pupilPoint.stepCompleted += Number(row.completed || 0);
}
const finish = (points) => points.map((point) => {
const { stepCompleted, ...values } = point;
return {
...values,
completed: Math.max(point.updates, stepCompleted),
accuracy: point.attempts ? Math.round((point.correct / point.attempts) * 100) : null,
};
});
return {
classTrend: finish([...classMap.values()]),
pupilTrends: Object.fromEntries(
[...pupilMaps.entries()].map(([pupilId, points]) => [pupilId, finish([...points.values()])])),
};
};
const snapshot = async (classId) => {
const [sessionRows, activityRows, completionRows] = await Promise.all([
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]),
pool.query(
`SELECT pe.pupil_id, to_char(pe.created_at, 'YYYY-MM-DD') AS day,
SUM(pe.attempts) AS attempts, SUM(pe.correct) AS correct, COUNT(*) AS updates
FROM progress_events pe JOIN users u ON u.id = pe.pupil_id
WHERE u.class_id = $1 AND pe.created_at >= current_date - interval '6 days'
GROUP BY pe.pupil_id, to_char(pe.created_at, 'YYYY-MM-DD')`, [classId]),
pool.query(
`SELECT asp.pupil_id, to_char(asp.completed_at, 'YYYY-MM-DD') AS day,
COUNT(*) AS completed
FROM assignment_step_progress asp JOIN users u ON u.id = asp.pupil_id
WHERE u.class_id = $1 AND asp.completed_at >= current_date - interval '6 days'
GROUP BY asp.pupil_id, to_char(asp.completed_at, 'YYYY-MM-DD')`, [classId]),
]);
const sessions = sessionRows.rows.map(describeSession);
const trends = buildTrends(activityRows.rows, completionRows.rows);
return {
sessions,
...trends,
liveCount: sessions.filter((session) => session.status === 'live').length,
helpCount: sessions.filter((session) => session.helpSignal).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: describeSession({
...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, paused_at`,
[req.params.id, req.user.id])).rows[0];
if (!row) return fail(reply, 404, 'sessie niet actief');
return { ok: true, paused: !!row.paused_at };
});
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('/my/live-actions', async (req, reply) => {
need(req, reply);
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
const [actions, session] = await Promise.all([
pool.query(
`SELECT * FROM teacher_pupil_actions
WHERE pupil_id = $1 AND acknowledged_at IS NULL
AND created_at >= now() - interval '24 hours'
ORDER BY created_at, id LIMIT 20`, [req.user.id]),
pool.query(
`SELECT id, paused_at FROM live_learning_sessions
WHERE pupil_id = $1 AND ended_at IS NULL
ORDER BY started_at DESC LIMIT 1`, [req.user.id]),
]);
return {
actions: actions.rows.map(describeAction),
paused: !!session.rows[0]?.paused_at,
};
});
app.post('/my/live-actions/:id/ack', 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 actie');
await pool.query(
`UPDATE teacher_pupil_actions SET acknowledged_at = now()
WHERE id = $1 AND pupil_id = $2 AND acknowledged_at IS NULL`,
[req.params.id, req.user.id]);
return { ok: true };
});
app.get('/my/live-actions/stream', async (req, reply) => {
need(req, reply);
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
openStream(req, reply, pupilStreams, req.user.id);
});
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;
openStream(req, reply, classStreams, access.row.id);
});
app.post('/progress/live/class/:id/pupil/:pupilId/action', async (req, reply) => {
need(req, reply, progressRoles);
const access = await accessibleClass(req, reply);
if (!access.row) return access.response;
const pupilAccess = await accessiblePupil(req, reply, access.row);
if (!pupilAccess.pupil) return pupilAccess.response;
const pupil = pupilAccess.pupil;
const { kind, subject } = req.body ?? {};
if (!ACTION_KINDS.has(kind)) return fail(reply, 400, 'ongeldige live-actie');
const rawMessage = typeof req.body?.message === 'string' ? req.body.message.trim() : '';
if (rawMessage.length > 160) return fail(reply, 400, 'bericht is te lang');
if ((kind === 'hint' || kind === 'encourage') && !rawMessage) {
return fail(reply, 400, 'bericht ontbreekt');
}
let payload = {};
if (kind === 'pause' || kind === 'resume') {
const pausedAt = kind === 'pause' ? 'now()' : 'NULL';
const session = (await pool.query(
`UPDATE live_learning_sessions SET paused_at = ${pausedAt}, last_seen_at = now()
WHERE id = (
SELECT id FROM live_learning_sessions
WHERE pupil_id = $1 AND class_id = $2 AND ended_at IS NULL
AND last_seen_at >= now() - interval '45 seconds'
ORDER BY started_at DESC LIMIT 1
) RETURNING id`,
[pupil.id, access.row.id])).rows[0];
if (!session) return fail(reply, 409, 'leerling heeft geen actieve sessie');
}
if (kind === 'easier') {
if (!LEVEL_SUBJECTS.has(subject)) return fail(reply, 400, 'ongeldig vakgebied');
let level = (await pool.query(
'SELECT * FROM pupil_levels WHERE pupil_id = $1 AND subject = $2',
[pupil.id, subject])).rows[0];
if (!level) {
level = (await pool.query(
'SELECT * FROM pupil_levels WHERE class_id = $1 AND subject = $2',
[access.row.id, subject])).rows[0];
}
if (!level) return fail(reply, 409, 'stel eerst een leerjaar voor dit vakgebied in');
const oldYearGroup = Number(level.year_group);
const oldLevel = Number(level.level);
const yearGroup = oldLevel > 1 ? oldYearGroup : Math.max(1, oldYearGroup - 1);
const easierLevel = oldLevel > 1 ? oldLevel - 1 : 1;
await pool.query(
`INSERT INTO pupil_levels (pupil_id, subject, year_group, level)
VALUES ($1,$2,$3,$4)
ON CONFLICT (pupil_id, subject) WHERE pupil_id IS NOT NULL
DO UPDATE SET year_group = $3, level = $4, updated_at = now()`,
[pupil.id, subject, yearGroup, easierLevel]);
payload = { subject, yearGroup, level: easierLevel };
}
await pool.query(
`DELETE FROM teacher_pupil_actions
WHERE pupil_id = $1 AND created_at < now() - interval '30 days'`, [pupil.id]);
const action = (await pool.query(
`INSERT INTO teacher_pupil_actions
(class_id, pupil_id, teacher_id, kind, message, payload)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
[access.row.id, pupil.id, req.user.id, kind, rawMessage || null,
JSON.stringify(payload)])).rows[0];
publishPupil(pupil.id);
publishClass(access.row.id);
return { ok: true, action: describeAction(action) };
});
return {
async isPaused(pupilId, sessionId) {
if (!Number.isSafeInteger(Number(sessionId)) || Number(sessionId) <= 0) return false;
const row = (await pool.query(
`SELECT paused_at FROM live_learning_sessions
WHERE id = $1 AND pupil_id = $2 AND ended_at IS NULL`,
[sessionId, pupilId])).rows[0];
return !!row?.paused_at;
},
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(), last_progress_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 AND paused_at IS NULL
RETURNING class_id`,
[sessionId, pupilId, attempts, correct])).rows[0];
if (row) publishClass(row.class_id);
},
};
}

View file

@ -1,198 +0,0 @@
// 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);
},
};
}

208
test/live-classroom.test.js Normal file
View file

@ -0,0 +1,208 @@
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 teacher = {
id: 3, username: 'juf', display_name: 'Juf', role: 'teacher',
school_id: 2, class_id: null, data: {}, data_rev: 0,
};
const pupil = {
id: 9, username: 'lena', display_name: 'Lena', role: 'pupil',
school_id: 2, class_id: 50, data: {}, data_rev: 0,
};
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('leerkracht kan een actieve leerling direct pauzeren', async () => {
const calls = [];
const query = async (sql, params = []) => {
calls.push({ sql, params });
const login = auth(teacher, sql);
if (login) return login;
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.startsWith('SELECT * FROM users WHERE id = $1 AND role = $2')) return { rows: [pupil] };
if (sql.includes('UPDATE live_learning_sessions SET paused_at = now()')) return { rows: [{ id: 101 }] };
if (sql.includes('INSERT INTO teacher_pupil_actions')) return { rows: [{
id: 301, class_id: 50, pupil_id: 9, teacher_id: 3, kind: 'pause',
message: null, payload: {}, created_at: new Date(),
}] };
return { rows: [] };
};
const app = await makeApp(teacher, query);
const response = await app.inject({
method: 'POST',
url: '/api/progress/live/class/50/pupil/9/action',
cookies,
payload: { kind: 'pause' },
});
assert.equal(response.statusCode, 200, response.body);
assert.equal(response.json().action.kind, 'pause');
assert.ok(calls.some((call) => call.sql.includes("last_seen_at >= now() - interval '45 seconds'")));
const insert = calls.find((call) => call.sql.includes('INSERT INTO teacher_pupil_actions'));
assert.deepEqual(insert.params.slice(0, 5), [50, 9, 3, 'pause', null]);
await app.close();
});
test('directe actie wordt geweigerd voor een leerling buiten de gekozen klas', async () => {
let inserted = false;
const query = async (sql) => {
const login = auth(teacher, sql);
if (login) return login;
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.startsWith('SELECT * FROM users WHERE id = $1 AND role = $2'))
return { rows: [{ ...pupil, class_id: 51 }] };
if (sql.includes('INSERT INTO teacher_pupil_actions')) inserted = true;
return { rows: [] };
};
const app = await makeApp(teacher, query);
const response = await app.inject({
method: 'POST',
url: '/api/progress/live/class/50/pupil/9/action',
cookies,
payload: { kind: 'hint', message: 'Probeer het woord hardop te zeggen.' },
});
assert.equal(response.statusCode, 403);
assert.equal(inserted, false);
await app.close();
});
test('eenvoudiger maakt veilig een individuele niveau-override', async () => {
const calls = [];
const query = async (sql, params = []) => {
calls.push({ sql, params });
const login = auth(teacher, sql);
if (login) return login;
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.startsWith('SELECT * FROM users WHERE id = $1 AND role = $2')) return { rows: [pupil] };
if (sql.startsWith('SELECT * FROM pupil_levels WHERE pupil_id')) return { rows: [] };
if (sql.startsWith('SELECT * FROM pupil_levels WHERE class_id'))
return { rows: [{ class_id: 50, subject: 'rekenen', year_group: 5, level: 2 }] };
if (sql.includes('INSERT INTO pupil_levels')) return { rows: [] };
if (sql.includes('INSERT INTO teacher_pupil_actions')) return { rows: [{
id: 302, kind: 'easier', message: null,
payload: { subject: 'rekenen', yearGroup: 5, level: 1 }, created_at: new Date(),
}] };
return { rows: [] };
};
const app = await makeApp(teacher, query);
const response = await app.inject({
method: 'POST',
url: '/api/progress/live/class/50/pupil/9/action',
cookies,
payload: { kind: 'easier', subject: 'rekenen' },
});
assert.equal(response.statusCode, 200, response.body);
const upsert = calls.find((call) => call.sql.includes('INSERT INTO pupil_levels'));
assert.deepEqual(upsert.params, [9, 'rekenen', 5, 1]);
assert.deepEqual(response.json().action.payload,
{ subject: 'rekenen', yearGroup: 5, level: 1 });
await app.close();
});
test('gepauzeerde sessie accepteert geen nieuwe voortgang', async () => {
let progressInserted = false;
const assignment = {
id: 80, teacher_id: 3, board_id: 'board-live', sequence: [],
};
const query = async (sql) => {
const login = auth(pupil, sql);
if (login) return login;
if (sql === 'SELECT * FROM assignments WHERE pupil_id = $1') return { rows: [assignment] };
if (sql.includes('SELECT paused_at FROM live_learning_sessions'))
return { rows: [{ paused_at: new Date() }] };
if (sql.includes('INSERT INTO progress_events')) progressInserted = true;
return { rows: [] };
};
const app = await makeApp(pupil, query);
const response = await app.inject({
method: 'POST', url: '/api/my/progress', cookies,
payload: {
widgetId: 'math-1', widgetType: 'madd', attempts: 2, correct: 2,
liveSessionId: 101,
},
});
assert.equal(response.statusCode, 409, response.body);
assert.equal(progressInserted, false);
await app.close();
});
test('leerling ontvangt duurzame acties en ziet de actuele pauzestatus', async () => {
const query = async (sql) => {
const login = auth(pupil, sql);
if (login) return login;
if (sql.includes('FROM teacher_pupil_actions')) return { rows: [{
id: 401, kind: 'hint', message: 'Splits het woord in klanken.',
payload: {}, created_at: new Date(),
}] };
if (sql.includes('SELECT id, paused_at FROM live_learning_sessions'))
return { rows: [{ id: 101, paused_at: new Date() }] };
return { rows: [] };
};
const app = await makeApp(pupil, query);
const response = await app.inject({
method: 'GET', url: '/api/my/live-actions', cookies,
});
assert.equal(response.statusCode, 200, response.body);
assert.equal(response.json().paused, true);
assert.equal(response.json().actions[0].message, 'Splits het woord in klanken.');
await app.close();
});
test('grafisch overzicht bevat raster, signalen, funnel, grafieken, tijdlijn en acties', async () => {
const [migration, server, dashboard, pupilActions, styles, i18n, admin, html, version, core] =
await Promise.all([
readFile('db/023_live_classroom_interaction.sql', 'utf8'),
readFile('src/live-classroom.js', 'utf8'),
readFile('public/js/live-classroom-dashboard.js', 'utf8'),
readFile('public/js/pupil-live-actions.js', 'utf8'),
readFile('public/css/live-classroom.css', 'utf8'),
readFile('public/js/live-classroom-i18n.js', 'utf8'),
readFile('public/js/admin.js', 'utf8'),
readFile('public/index.html', 'utf8'),
readFile('VERSION', 'utf8'),
readFile('public/js/core.js', 'utf8'),
]);
for (const value of ['teacher_pupil_actions', 'last_progress_at', 'paused_at'])
assert.ok(migration.includes(value), value);
assert.match(server, /HELP_IDLE_MS/);
assert.match(server, /correct \/ attempts < 0\.6/);
assert.match(server, /pupilStreams/);
assert.match(server, /current_date - interval '6 days'/);
assert.match(server, /ACTION_KINDS/);
for (const value of ['live-class-grid', 'renderLiveFunnel', 'renderLiveTrend',
'renderLiveTimeline', 'amActionEasier', 'amActionPause']) {
assert.ok(dashboard.includes(value), value);
}
assert.match(pupilActions, /new EventSource\("\/api\/my\/live-actions\/stream"\)/);
assert.match(pupilActions, /\/ack/);
assert.match(styles, /pupil-session-paused/);
assert.match(styles, /live-graphics-grid/);
assert.match(admin, /pupil\/'\+pupilId\+'\/action/);
assert.ok(html.indexOf('live-classroom-i18n.js') < html.indexOf('pupil-live-actions.js'));
assert.ok(html.includes('css/live-classroom.css'));
for (const key of ['amFunnelTitle', 'amTimelineTitle', 'amActionHint', 'pupilPausedTitle'])
assert.equal((i18n.match(new RegExp(key + ':', 'g')) || []).length, 2, key);
assert.ok(core.includes('const VERSION = "' + version.trim() + '"'));
});

View file

@ -0,0 +1,61 @@
import test from 'node:test';
import assert from 'node:assert/strict';
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 teacher = {
id: 3, username: 'juf', role: 'teacher', school_id: 2,
class_id: null, data: {}, data_rev: 0,
};
test('live inzicht berekent fout-, stiltesignalen en zevendaagse grafiekdata', async () => {
const now = new Date();
const idleStart = new Date(now.getTime() - 4 * 60_000);
const date = new Date(); date.setHours(12, 0, 0, 0);
const day = date.toISOString().slice(0, 10);
const query = async (sql) => {
if (sql.includes('FROM sessions s JOIN users u')) return { rows: [teacher] };
if (sql.includes('FROM user_roles')) return { rows: [] };
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: 'math-1',
widget_type: 'madd', mode: 'werken', started_at: idleStart,
last_seen_at: now, last_progress_at: now, attempts: 10, correct: 3,
},
{
id: 102, pupil_id: 10, display_name: 'Sam', widget_id: 'letters-1',
widget_type: 'letters', mode: 'werken', started_at: idleStart,
last_seen_at: now, last_progress_at: null, attempts: 0, correct: 0,
},
] };
if (sql.includes('FROM progress_events pe')) return { rows: [{
pupil_id: 9, day, attempts: 10, correct: 7, updates: 2,
}] };
if (sql.includes('FROM assignment_step_progress asp')) return { rows: [{
pupil_id: 9, day, completed: 1,
}] };
return { rows: [] };
};
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();
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.helpCount, 2);
assert.deepEqual(body.sessions.map((session) => session.helpSignal), ['errors', 'idle']);
assert.equal(body.classTrend.at(-1).accuracy, 70);
assert.equal(body.classTrend.at(-1).completed, 2);
assert.equal(body.pupilTrends['9'].at(-1).updates, 2);
await app.close();
});

View file

@ -158,9 +158,9 @@ test('groepsleiding kan geen live sessies van een andere klas volgen', async ()
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('src/live-classroom.js', 'utf8'),
readFile('public/js/pupil.js', 'utf8'),
readFile('public/js/live-dashboard.js', 'utf8'),
readFile('public/js/live-classroom-dashboard.js', 'utf8'),
readFile('public/js/admin.js', 'utf8'),
readFile('public/index.html', 'utf8'),
readFile('deploy/nginx.conf', 'utf8'),
@ -170,7 +170,7 @@ test('live dashboard gebruikt SSE, heartbeats, retentie en ongebufferde proxying
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, /publish\(classStreams, classId, \x27sessions\x27\)/);
assert.match(server, /interval '24 hours'/);
assert.match(pupil, /pointerdown[\s\S]*ensurePupilLiveSession/);
assert.match(pupil, /PUPIL_LIVE_HEARTBEAT_MS/);
@ -178,7 +178,7 @@ test('live dashboard gebruikt SSE, heartbeats, retentie en ongebufferde proxying
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.ok(html.indexOf('js/live-classroom-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);