diff --git a/VERSION b/VERSION
index 07990fe..1063753 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.4.57-beta
+0.4.58-beta
diff --git a/db/023_live_classroom_interaction.sql b/db/023_live_classroom_interaction.sql
new file mode 100644
index 0000000..fa68730
--- /dev/null
+++ b/db/023_live_classroom_interaction.sql
@@ -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);
diff --git a/deploy/nginx.conf b/deploy/nginx.conf
index df3e0c8..a9df52a 100644
--- a/deploy/nginx.conf
+++ b/deploy/nginx.conf
@@ -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;
diff --git a/public/css/live-classroom.css b/public/css/live-classroom.css
new file mode 100644
index 0000000..1bc2c76
--- /dev/null
+++ b/public/css/live-classroom.css
@@ -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%}
+}
diff --git a/public/index.html b/public/index.html
index 1924afe..a3c7284 100644
--- a/public/index.html
+++ b/public/index.html
@@ -7,6 +7,7 @@
+
+
@@ -49,8 +51,9 @@
+
-
+
diff --git a/public/js/admin.js b/public/js/admin.js
index ef5d92d..d2ca84f 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -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));
diff --git a/public/js/core.js b/public/js/core.js
index 25b48af..31b8e1a 100644
--- a/public/js/core.js
+++ b/public/js/core.js
@@ -2,7 +2,7 @@
"use strict";
/* version — shown until /api/version resolves (or if the fetch fails, e.g. offline).
Kept in sync by hand with the VERSION file at the repo root on every release. */
-const VERSION = "0.4.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
diff --git a/public/js/live-classroom-dashboard.js b/public/js/live-classroom-dashboard.js
new file mode 100644
index 0000000..aad76e3
--- /dev/null
+++ b/public/js/live-classroom-dashboard.js
@@ -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;
+}
diff --git a/public/js/live-classroom-i18n.js b/public/js/live-classroom-i18n.js
new file mode 100644
index 0000000..ff77b42
--- /dev/null
+++ b/public/js/live-classroom-i18n.js
@@ -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.",
+});
diff --git a/public/js/live-dashboard.js b/public/js/live-dashboard.js
deleted file mode 100644
index d278b68..0000000
--- a/public/js/live-dashboard.js
+++ /dev/null
@@ -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;
-}
diff --git a/public/js/pupil-live-actions.js b/public/js/pupil-live-actions.js
new file mode 100644
index 0000000..1ec5f58
--- /dev/null
+++ b/public/js/pupil-live-actions.js
@@ -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();
+};
diff --git a/src/api.js b/src/api.js
index a87243f..a9ce3ea 100644
--- a/src/api.js
+++ b/src/api.js
@@ -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 -----------------------------------------------------------
diff --git a/src/live-classroom.js b/src/live-classroom.js
new file mode 100644
index 0000000..b3144fd
--- /dev/null
+++ b/src/live-classroom.js
@@ -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);
+ },
+ };
+}
diff --git a/src/live-sessions.js b/src/live-sessions.js
deleted file mode 100644
index f2c0719..0000000
--- a/src/live-sessions.js
+++ /dev/null
@@ -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);
- },
- };
-}
diff --git a/test/live-classroom.test.js b/test/live-classroom.test.js
new file mode 100644
index 0000000..e00290e
--- /dev/null
+++ b/test/live-classroom.test.js
@@ -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() + '"'));
+});
diff --git a/test/live-insights.test.js b/test/live-insights.test.js
new file mode 100644
index 0000000..c88c9cf
--- /dev/null
+++ b/test/live-insights.test.js
@@ -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();
+});
diff --git a/test/live-sessions.test.js b/test/live-sessions.test.js
index 2f2e9ae..e21a8e4 100644
--- a/test/live-sessions.test.js
+++ b/test/live-sessions.test.js
@@ -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);