From 131ffc2f383ce9fe2096c23ac80c56f4aa536df8 Mon Sep 17 00:00:00 2001 From: Ramon Date: Fri, 24 Jul 2026 13:40:18 +0200 Subject: [PATCH] feat: breid live klasinteractie grafisch uit --- VERSION | 2 +- db/023_live_classroom_interaction.sql | 25 ++ deploy/nginx.conf | 12 + public/css/live-classroom.css | 89 ++++++ public/index.html | 5 +- public/js/admin.js | 34 +- public/js/core.js | 2 +- public/js/live-classroom-dashboard.js | 371 ++++++++++++++++++++++ public/js/live-classroom-i18n.js | 89 ++++++ public/js/live-dashboard.js | 124 -------- public/js/pupil-live-actions.js | 115 +++++++ src/api.js | 17 +- src/live-classroom.js | 441 ++++++++++++++++++++++++++ src/live-sessions.js | 198 ------------ test/live-classroom.test.js | 208 ++++++++++++ test/live-insights.test.js | 61 ++++ test/live-sessions.test.js | 8 +- 17 files changed, 1441 insertions(+), 360 deletions(-) create mode 100644 db/023_live_classroom_interaction.sql create mode 100644 public/css/live-classroom.css create mode 100644 public/js/live-classroom-dashboard.js create mode 100644 public/js/live-classroom-i18n.js delete mode 100644 public/js/live-dashboard.js create mode 100644 public/js/pupil-live-actions.js create mode 100644 src/live-classroom.js delete mode 100644 src/live-sessions.js create mode 100644 test/live-classroom.test.js create mode 100644 test/live-insights.test.js 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);