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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@
"use strict"; "use strict";
/* version shown until /api/version resolves (or if the fetch fails, e.g. offline). /* version shown until /api/version resolves (or if the fetch fails, e.g. offline).
Kept in sync by hand with the VERSION file at the repo root on every release. */ Kept in sync by hand with the VERSION file at the repo root on every release. */
const VERSION = "0.4.56-beta"; const VERSION = "0.4.57-beta";
/* Safari en oudere mobiele browsers behandelen 100vh/100% soms als de hoogte /* Safari en oudere mobiele browsers behandelen 100vh/100% soms als de hoogte
achter hun adres- en navigatiebalk. visualViewport geeft de werkelijk achter hun adres- en navigatiebalk. visualViewport geeft de werkelijk
zichtbare hoogte; de CSS gebruikt deze pixelwaarde als betrouwbare bron en zichtbare hoogte; de CSS gebruikt deze pixelwaarde als betrouwbare bron en
@ -409,6 +409,10 @@ const I18N = {
castActive:"✓ Actief", castPathTitle:"Opdrachtenreeks (optioneel)", castPathPlaceholder:"Naam van de reeks…", castActive:"✓ Actief", castPathTitle:"Opdrachtenreeks (optioneel)", castPathPlaceholder:"Naam van de reeks…",
castPathAdd:" Geselecteerde widget als stap", castPathOnlyLearning:"Reeksen ondersteunen taal- en rekenwidgets.", castPathDefault:"Mijn opdrachtenreeks", castPathHint:"{count} van maximaal 12 stappen. Sleepvolgorde wordt met de pijlen bepaald.", castPathAdd:" Geselecteerde widget als stap", castPathOnlyLearning:"Reeksen ondersteunen taal- en rekenwidgets.", castPathDefault:"Mijn opdrachtenreeks", castPathHint:"{count} van maximaal 12 stappen. Sleepvolgorde wordt met de pijlen bepaald.",
amDashboardTitle:"Klasdashboard", amProgressDetail:"Detail per leerling", amDashboardTitle:"Klasdashboard", amProgressDetail:"Detail per leerling",
amLiveSessions:"Live leersessies", amLiveConnected:"● Live verbonden", amLiveReconnecting:"○ Verbinden…",
amLiveNow:"Nu actief", amLiveRecent:"Meest recente sessies", amLiveNone:"Nog geen leerling actief.", amLiveRecentNone:"Nog geen recente sessies.",
amLiveNew:"Nieuw begonnen", amLiveWorking:"Live bezig", amLiveEnded:"Recent", amLiveStarted:"Gestart", amLiveLastSeen:"Laatst gezien",
amLiveUpdates:"{count} updates", amLiveScore:"{correct}/{attempts} goed", amLiveJustNow:"zojuist", amLiveMinutes:"{count} min. geleden", amLiveHours:"{count} uur geleden",
amDashboardStatus_complete:"Klaar", amDashboardStatus_active:"Bezig", amDashboardStatus_support:"Hulp nodig", amDashboardStatus_notStarted:"Nog niet gestart", amDashboardStatus_complete:"Klaar", amDashboardStatus_active:"Bezig", amDashboardStatus_support:"Hulp nodig", amDashboardStatus_notStarted:"Nog niet gestart",
amDashboardSteps:"{done} van {total} stappen", amDashboardNoActivity:"Nog geen activiteit", amDashboardSteps:"{done} van {total} stappen", amDashboardNoActivity:"Nog geen activiteit",
amProgress:"Voortgang", amProgressEmpty:"Nog geen voortgang voor deze leerling.", amProgress:"Voortgang", amProgressEmpty:"Nog geen voortgang voor deze leerling.",
@ -807,6 +811,10 @@ const I18N = {
castActive:"✓ Active", castPathTitle:"Learning path (optional)", castPathPlaceholder:"Name of the path…", castActive:"✓ Active", castPathTitle:"Learning path (optional)", castPathPlaceholder:"Name of the path…",
castPathAdd:" Add selected widget as step", castPathOnlyLearning:"Paths support language and maths widgets.", castPathDefault:"My learning path", castPathHint:"{count} of up to 12 steps. Use the arrows to set the order.", castPathAdd:" Add selected widget as step", castPathOnlyLearning:"Paths support language and maths widgets.", castPathDefault:"My learning path", castPathHint:"{count} of up to 12 steps. Use the arrows to set the order.",
amDashboardTitle:"Class dashboard", amProgressDetail:"Pupil details", amDashboardTitle:"Class dashboard", amProgressDetail:"Pupil details",
amLiveSessions:"Live learning sessions", amLiveConnected:"● Live connected", amLiveReconnecting:"○ Connecting…",
amLiveNow:"Active now", amLiveRecent:"Most recent sessions", amLiveNone:"No pupil is active yet.", amLiveRecentNone:"No recent sessions yet.",
amLiveNew:"Just started", amLiveWorking:"Working live", amLiveEnded:"Recent", amLiveStarted:"Started", amLiveLastSeen:"Last seen",
amLiveUpdates:"{count} updates", amLiveScore:"{correct}/{attempts} correct", amLiveJustNow:"just now", amLiveMinutes:"{count} min ago", amLiveHours:"{count} hr ago",
amDashboardStatus_complete:"Complete", amDashboardStatus_active:"Working", amDashboardStatus_support:"Needs help", amDashboardStatus_notStarted:"Not started", amDashboardStatus_complete:"Complete", amDashboardStatus_active:"Working", amDashboardStatus_support:"Needs help", amDashboardStatus_notStarted:"Not started",
amDashboardSteps:"{done} of {total} steps", amDashboardNoActivity:"No activity yet", amDashboardSteps:"{done} of {total} steps", amDashboardNoActivity:"No activity yet",
amProgress:"Progress", amProgressEmpty:"No progress for this pupil yet.", amProgress:"Progress", amProgressEmpty:"No progress for this pupil yet.",

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

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

View file

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

View file

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

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

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

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

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