Voeg 28 herkenbare kleurplaten en creatieve niveaus toe (v0.4.66-beta) #4
17 changed files with 1441 additions and 360 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.4.57-beta
|
0.4.58-beta
|
||||||
|
|
|
||||||
25
db/023_live_classroom_interaction.sql
Normal file
25
db/023_live_classroom_interaction.sql
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
-- Bidirectionele live klasinteractie. Sessies onthouden het laatste echte
|
||||||
|
-- voortgangsmoment en een eventuele pauze. Leerkrachtacties worden duurzaam
|
||||||
|
-- opgeslagen, zodat een korte netwerkonderbreking geen hint of aanmoediging
|
||||||
|
-- laat verdwijnen.
|
||||||
|
ALTER TABLE live_learning_sessions
|
||||||
|
ADD COLUMN IF NOT EXISTS last_progress_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS paused_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS teacher_pupil_actions (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
class_id BIGINT NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
|
||||||
|
pupil_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
teacher_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('hint', 'encourage', 'pause', 'resume', 'easier')),
|
||||||
|
message TEXT,
|
||||||
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
acknowledged_at TIMESTAMPTZ,
|
||||||
|
CHECK (message IS NULL OR char_length(message) <= 160)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_teacher_pupil_actions_pending
|
||||||
|
ON teacher_pupil_actions (pupil_id, acknowledged_at, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_teacher_pupil_actions_class
|
||||||
|
ON teacher_pupil_actions (class_id, created_at DESC);
|
||||||
|
|
@ -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/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/ {
|
location /api/progress/live/ {
|
||||||
proxy_pass http://app:3000;
|
proxy_pass http://app:3000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
|
||||||
89
public/css/live-classroom.css
Normal file
89
public/css/live-classroom.css
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
/* Grafisch live-klassenoverzicht */
|
||||||
|
#settingsModal .live-class-overview{display:grid;gap:14px;margin-top:10px}
|
||||||
|
#settingsModal .live-class-overview>.live-session-head{position:sticky;top:0;z-index:3;margin:0;padding:10px 12px;border:1px solid color-mix(in srgb,var(--blue) 30%,var(--line));border-radius:13px;background:color-mix(in srgb,var(--blue) 6%,var(--surface));box-shadow:0 5px 15px rgba(25,45,80,.06)}
|
||||||
|
#settingsModal .live-class-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
|
||||||
|
#settingsModal .live-class-metric{display:flex;align-items:center;gap:9px;padding:10px;border:1px solid var(--line);border-radius:12px;background:var(--surface)}
|
||||||
|
#settingsModal .live-class-metric strong{display:grid;width:35px;height:35px;place-items:center;border-radius:10px;background:var(--surface-2);font-size:18px}
|
||||||
|
#settingsModal .live-class-metric span{font-size:11px;font-weight:850}
|
||||||
|
#settingsModal .live-class-metric.active strong{color:var(--green)}
|
||||||
|
#settingsModal .live-class-metric.new strong{color:var(--blue)}
|
||||||
|
#settingsModal .live-class-metric.support strong{color:var(--orange)}
|
||||||
|
#settingsModal .live-class-metric.complete strong{color:var(--accent)}
|
||||||
|
#settingsModal .live-help-signals{padding:11px;border:1px solid color-mix(in srgb,var(--orange) 45%,var(--line));border-radius:13px;background:color-mix(in srgb,var(--orange) 9%,var(--surface))}
|
||||||
|
#settingsModal .live-help-signals>strong{display:block;margin-bottom:8px;font-size:12px}
|
||||||
|
#settingsModal .live-help-signals>div{display:flex;flex-wrap:wrap;gap:6px}
|
||||||
|
#settingsModal .live-help-chip{border:1px solid color-mix(in srgb,var(--orange) 45%,var(--line));border-radius:999px;padding:5px 9px;background:var(--surface);color:var(--ink);font:inherit;font-size:11px;font-weight:800;cursor:pointer}
|
||||||
|
#settingsModal .live-help-chip.errors{color:var(--red)}#settingsModal .live-help-chip.idle{color:var(--orange)}
|
||||||
|
#settingsModal .live-grid-controls{display:flex;align-items:center;justify-content:space-between;gap:10px}
|
||||||
|
#settingsModal .live-grid-controls .am-sel{width:auto;min-width:150px}
|
||||||
|
#settingsModal .live-class-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:9px}
|
||||||
|
#settingsModal .live-pupil-tile{overflow:hidden;border:1px solid var(--line);border-radius:14px;background:var(--surface);box-shadow:inset 4px 0 var(--line)}
|
||||||
|
#settingsModal .live-pupil-tile.active{border-color:color-mix(in srgb,var(--green) 40%,var(--line));box-shadow:inset 4px 0 var(--green)}
|
||||||
|
#settingsModal .live-pupil-tile.new{border-color:color-mix(in srgb,var(--blue) 45%,var(--line));box-shadow:inset 4px 0 var(--blue)}
|
||||||
|
#settingsModal .live-pupil-tile.support{border-color:color-mix(in srgb,var(--orange) 55%,var(--line));box-shadow:inset 4px 0 var(--orange)}
|
||||||
|
#settingsModal .live-pupil-tile.paused{border-color:color-mix(in srgb,var(--purple) 45%,var(--line));box-shadow:inset 4px 0 var(--purple)}
|
||||||
|
#settingsModal .live-pupil-tile.complete{box-shadow:inset 4px 0 var(--accent)}
|
||||||
|
#settingsModal .live-pupil-main{display:grid;width:100%;grid-template-columns:minmax(0,1fr) 76px;gap:5px 8px;padding:10px 10px 8px 14px;border:0;background:transparent;color:var(--ink);font:inherit;text-align:left;cursor:pointer}
|
||||||
|
#settingsModal .live-pupil-main:hover,#settingsModal .live-pupil-main:focus-visible{background:var(--surface-2);outline:2px solid var(--accent-soft);outline-offset:-2px}
|
||||||
|
#settingsModal .live-pupil-state{grid-column:1/-1;justify-self:start;padding:2px 7px;border-radius:999px;background:var(--surface-2);color:var(--muted);font-size:9px;font-weight:900;text-transform:uppercase}
|
||||||
|
#settingsModal .live-pupil-tile.active .live-pupil-state,#settingsModal .live-pupil-tile.new .live-pupil-state{background:color-mix(in srgb,var(--green) 14%,var(--surface));color:var(--green)}
|
||||||
|
#settingsModal .live-pupil-tile.support .live-pupil-state{background:color-mix(in srgb,var(--orange) 15%,var(--surface));color:var(--orange)}
|
||||||
|
#settingsModal .live-pupil-main>strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px}
|
||||||
|
#settingsModal .live-pupil-task{grid-column:1/-1;overflow:hidden;color:var(--muted);font-size:11px;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
#settingsModal .live-pupil-progress{align-self:center;height:6px;overflow:hidden;border-radius:99px;background:var(--surface-2)}
|
||||||
|
#settingsModal .live-pupil-progress i{display:block;height:100%;border-radius:inherit;background:var(--accent)}
|
||||||
|
#settingsModal .live-pupil-main small{grid-column:1;color:var(--muted);font-size:10px}
|
||||||
|
#settingsModal .live-sparkline{grid-column:2;grid-row:3/6;width:76px;height:28px;align-self:end}
|
||||||
|
#settingsModal .live-sparkline .line{fill:none;stroke:var(--blue);stroke-width:3;stroke-linecap:round;stroke-linejoin:round}
|
||||||
|
#settingsModal .live-sparkline .empty{stroke:var(--line);stroke-width:2;stroke-dasharray:3 3}
|
||||||
|
#settingsModal .live-action-toggle{width:100%;padding:6px 10px;border:0;border-top:1px solid var(--line);background:var(--surface-2);color:var(--accent-ink);font:inherit;font-size:10px;font-weight:900;cursor:pointer}
|
||||||
|
#settingsModal .live-action-row{display:grid;grid-template-columns:1fr 1fr;gap:5px;padding:7px;background:var(--surface-2)}
|
||||||
|
#settingsModal .live-action-row[hidden]{display:none}
|
||||||
|
#settingsModal .live-action-row button{min-width:0;padding:6px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--ink);font:inherit;font-size:10px;font-weight:800;cursor:pointer}
|
||||||
|
#settingsModal .live-action-row button:hover,#settingsModal .live-action-row button:focus-visible{border-color:var(--accent);outline:2px solid var(--accent-soft)}
|
||||||
|
#settingsModal .live-action-row button:disabled{opacity:.4;cursor:not-allowed}
|
||||||
|
#settingsModal .live-dashboard-message{min-height:17px;color:var(--accent-ink);font-size:11px;font-weight:800}
|
||||||
|
#settingsModal .live-graphics-grid{display:grid;grid-template-columns:minmax(230px,.8fr) minmax(330px,1.2fr);gap:10px}
|
||||||
|
#settingsModal .live-graphic-card{min-width:0;padding:12px;border:1px solid var(--line);border-radius:14px;background:var(--surface)}
|
||||||
|
#settingsModal .live-graphic-card>strong{display:block;margin-bottom:10px;font-size:13px}
|
||||||
|
#settingsModal .live-funnel-row{display:grid;grid-template-columns:82px minmax(70px,1fr) 28px;align-items:center;gap:7px;margin:7px 0;font-size:10px}
|
||||||
|
#settingsModal .live-funnel-track{height:12px;overflow:hidden;border-radius:99px;background:var(--surface-2)}
|
||||||
|
#settingsModal .live-funnel-track i{display:block;height:100%;border-radius:inherit;background:hsl(calc(198 + var(--funnel-step)*18) 72% 51%)}
|
||||||
|
#settingsModal .live-funnel-row>strong{text-align:right}
|
||||||
|
#settingsModal .live-trend svg{display:block;width:100%;height:auto;max-height:190px}
|
||||||
|
#settingsModal .live-trend .grid{stroke:var(--line);stroke-width:1}.live-trend .axis{fill:var(--muted);font-size:9px}
|
||||||
|
#settingsModal .live-trend .completion{fill:color-mix(in srgb,var(--purple) 42%,transparent)}
|
||||||
|
#settingsModal .live-trend .accuracy{fill:none;stroke:var(--blue);stroke-width:4;stroke-linecap:round;stroke-linejoin:round}
|
||||||
|
#settingsModal .live-trend .accuracy-dot{fill:var(--surface);stroke:var(--blue);stroke-width:3}
|
||||||
|
#settingsModal .live-chart-legend{display:flex;justify-content:center;gap:15px;color:var(--muted);font-size:10px}
|
||||||
|
#settingsModal .live-chart-legend span:before{display:inline-block;width:9px;height:9px;margin-right:5px;border-radius:3px;background:var(--blue);content:""}
|
||||||
|
#settingsModal .live-chart-legend .completed:before{background:color-mix(in srgb,var(--purple) 55%,var(--surface))}
|
||||||
|
#settingsModal .live-timeline{display:grid;gap:6px}
|
||||||
|
#settingsModal .live-timeline-row{display:grid;grid-template-columns:minmax(90px,.7fr) minmax(130px,1fr) minmax(80px,1.1fr) 32px;align-items:center;gap:8px;width:100%;padding:7px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--ink);font:inherit;text-align:left;cursor:pointer}
|
||||||
|
#settingsModal .live-timeline-row:hover,#settingsModal .live-timeline-row:focus-visible{border-color:var(--accent);outline:2px solid var(--accent-soft)}
|
||||||
|
#settingsModal .live-timeline-row>strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px}
|
||||||
|
#settingsModal .live-timeline-row>span{overflow:hidden;color:var(--muted);font-size:10px;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
#settingsModal .live-timeline-track{height:8px;border-radius:99px;background:var(--surface-2)}
|
||||||
|
#settingsModal .live-timeline-track i{display:block;height:100%;border-radius:inherit;background:var(--line)}
|
||||||
|
#settingsModal .live-timeline-row.live .live-timeline-track i{background:var(--green)}
|
||||||
|
#settingsModal .live-timeline-duration{text-align:right}
|
||||||
|
|
||||||
|
/* Leerling: live bericht en echte pauze */
|
||||||
|
.pupil-live-notice{position:fixed;top:calc(var(--tbar) + 12px);left:50%;z-index:5900;max-width:min(560px,calc(100vw - 24px));transform:translateX(-50%);padding:11px 16px;border:2px solid var(--blue);border-radius:14px;background:var(--surface);box-shadow:var(--shadow-2);color:var(--ink);font-size:14px;font-weight:850;text-align:center}
|
||||||
|
.pupil-live-notice.encourage{border-color:var(--green)}.pupil-live-notice.easier{border-color:var(--purple)}
|
||||||
|
.pupil-live-notice[hidden],.pupil-pause-shield[hidden]{display:none}
|
||||||
|
.pupil-pause-shield{position:fixed;inset:var(--tbar) 0 0;z-index:5800;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:9px;padding:24px;background:color-mix(in srgb,var(--surface) 88%,transparent);backdrop-filter:blur(5px);color:var(--ink);text-align:center}
|
||||||
|
.pupil-pause-shield>span:first-child{font-size:46px}.pupil-pause-shield strong{font-size:22px}.pupil-pause-shield>span:last-child{max-width:440px;color:var(--muted);font-size:14px}
|
||||||
|
body.pupil-session-paused #pupilView .widget-body{pointer-events:none;user-select:none;filter:saturate(.45)}
|
||||||
|
|
||||||
|
@media(max-width:760px){
|
||||||
|
#settingsModal .live-class-metrics{grid-template-columns:1fr 1fr}
|
||||||
|
#settingsModal .live-graphics-grid{grid-template-columns:1fr}
|
||||||
|
#settingsModal .live-timeline-row{grid-template-columns:1fr 1fr}
|
||||||
|
#settingsModal .live-timeline-track{grid-column:1/-1}
|
||||||
|
}
|
||||||
|
@media(max-width:480px){
|
||||||
|
#settingsModal .live-class-grid{grid-template-columns:1fr}
|
||||||
|
#settingsModal .live-grid-controls{align-items:stretch;flex-direction:column}
|
||||||
|
#settingsModal .live-grid-controls .am-sel{width:100%}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
<link rel="icon" type="image/svg+xml" href="img/logo.svg?v=__V__">
|
<link rel="icon" type="image/svg+xml" href="img/logo.svg?v=__V__">
|
||||||
<link rel="preload" href="fonts/quicksand.woff2" as="font" type="font/woff2" crossorigin>
|
<link rel="preload" href="fonts/quicksand.woff2" as="font" type="font/woff2" crossorigin>
|
||||||
<link rel="stylesheet" href="css/teach.css?v=__V__">
|
<link rel="stylesheet" href="css/teach.css?v=__V__">
|
||||||
|
<link rel="stylesheet" href="css/live-classroom.css?v=__V__">
|
||||||
<link rel="stylesheet" href="css/image-catalog.css?v=__V__">
|
<link rel="stylesheet" href="css/image-catalog.css?v=__V__">
|
||||||
<!-- alle app-scripts: defer = parallel downloaden, uitvoeren in dézelfde
|
<!-- alle app-scripts: defer = parallel downloaden, uitvoeren in dézelfde
|
||||||
volgorde na het parsen (de volgorde is dwingend: core levert T()/api(),
|
volgorde na het parsen (de volgorde is dwingend: core levert T()/api(),
|
||||||
|
|
@ -14,6 +15,7 @@
|
||||||
?v=__V__ wordt server-side vervangen door de app-versie (cache-busting;
|
?v=__V__ wordt server-side vervangen door de app-versie (cache-busting;
|
||||||
zie src/frontend.js) zodat js/css een jaar immutable gecachet mag worden. -->
|
zie src/frontend.js) zodat js/css een jaar immutable gecachet mag worden. -->
|
||||||
<script src="js/core.js?v=__V__" defer></script>
|
<script src="js/core.js?v=__V__" defer></script>
|
||||||
|
<script src="js/live-classroom-i18n.js?v=__V__" defer></script>
|
||||||
<script src="js/permissions.js?v=__V__" defer></script>
|
<script src="js/permissions.js?v=__V__" defer></script>
|
||||||
<script src="js/settings.js?v=__V__" defer></script>
|
<script src="js/settings.js?v=__V__" defer></script>
|
||||||
<script src="js/curriculum.js?v=__V__" defer></script>
|
<script src="js/curriculum.js?v=__V__" defer></script>
|
||||||
|
|
@ -49,8 +51,9 @@
|
||||||
<script src="js/widgets/world.js?v=__V__" defer></script>
|
<script src="js/widgets/world.js?v=__V__" defer></script>
|
||||||
<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/pupil-live-actions.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/live-classroom-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>
|
||||||
|
|
|
||||||
|
|
@ -767,35 +767,13 @@
|
||||||
]);
|
]);
|
||||||
if(sequence!==dashboardSequence)return;
|
if(sequence!==dashboardSequence)return;
|
||||||
dashboardBody.innerHTML = "";
|
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);
|
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){
|
}catch(e){
|
||||||
if(sequence!==dashboardSequence)return;
|
if(sequence!==dashboardSequence)return;
|
||||||
dashboardBody.innerHTML="";dashboardBody.appendChild(h("div","am-msg",e.message));
|
dashboardBody.innerHTML="";dashboardBody.appendChild(h("div","am-msg",e.message));
|
||||||
|
|
|
||||||
|
|
@ -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.57-beta";
|
const VERSION = "0.4.58-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
|
||||||
|
|
|
||||||
371
public/js/live-classroom-dashboard.js
Normal file
371
public/js/live-classroom-dashboard.js
Normal file
|
|
@ -0,0 +1,371 @@
|
||||||
|
/* Grafisch live-klassenoverzicht: klasraster, hulpsignalen, funnel,
|
||||||
|
zevendaagse voortgang, sessietijdlijn en directe leerkrachtacties. */
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
function liveSessionTime(value){
|
||||||
|
const seconds=Math.max(0,Math.round((Date.now()-new Date(value).getTime())/1000));
|
||||||
|
if(seconds<60)return T("amLiveJustNow");
|
||||||
|
const minutes=Math.floor(seconds/60);
|
||||||
|
if(minutes<60)return T("amLiveMinutes").replace("{count}",minutes);
|
||||||
|
const hours=Math.floor(minutes/60);
|
||||||
|
return T("amLiveHours").replace("{count}",hours);
|
||||||
|
}
|
||||||
|
|
||||||
|
function liveDuration(start,end){
|
||||||
|
const seconds=Math.max(1,Math.round((new Date(end).getTime()-new Date(start).getTime())/1000));
|
||||||
|
if(seconds<60)return seconds+"s";
|
||||||
|
const minutes=Math.round(seconds/60);
|
||||||
|
return minutes<60?minutes+"m":Math.round(minutes/60)+"u";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLiveConnectionState(host,state){
|
||||||
|
const badge=host.querySelector(".live-connection");
|
||||||
|
if(!badge)return;
|
||||||
|
badge.className="live-connection "+state;
|
||||||
|
badge.textContent=T(state==="connected"?"amLiveConnected":"amLiveReconnecting");
|
||||||
|
}
|
||||||
|
|
||||||
|
function liveSvg(tag,attrs={}){
|
||||||
|
const element=document.createElementNS("http://www.w3.org/2000/svg",tag);
|
||||||
|
Object.entries(attrs).forEach(([key,value])=>element.setAttribute(key,String(value)));
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
function liveSparkline(points){
|
||||||
|
const svg=liveSvg("svg",{class:"live-sparkline",viewBox:"0 0 100 28",
|
||||||
|
role:"img","aria-label":T("amTrendAccuracy")});
|
||||||
|
const usable=(points||[]).map((point,index)=>({index,value:point.accuracy}))
|
||||||
|
.filter(point=>Number.isFinite(point.value));
|
||||||
|
if(!usable.length){
|
||||||
|
svg.appendChild(liveSvg("line",{x1:2,y1:25,x2:98,y2:25,class:"empty"}));
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
const d=usable.map((point,index)=>{
|
||||||
|
const x=2+(point.index/Math.max(1,(points.length-1)))*96;
|
||||||
|
const y=26-(point.value/100)*23;
|
||||||
|
return(index?"L":"M")+x.toFixed(1)+" "+y.toFixed(1);
|
||||||
|
}).join(" ");
|
||||||
|
svg.appendChild(liveSvg("path",{d,class:"line"}));
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLiveFunnel(host,dashboard,live){
|
||||||
|
const card=document.createElement("section");
|
||||||
|
card.className="live-graphic-card live-funnel";
|
||||||
|
const title=document.createElement("strong");
|
||||||
|
title.textContent=T("amFunnelTitle");
|
||||||
|
card.appendChild(title);
|
||||||
|
const base=Math.max(1,dashboard.funnel?.total||dashboard.pupils?.length||0);
|
||||||
|
const active=live.liveCount||0;
|
||||||
|
const started=Math.max(dashboard.funnel?.started||0,active);
|
||||||
|
const steps=[
|
||||||
|
["amFunnelAssigned",dashboard.funnel?.assigned||0],
|
||||||
|
["amFunnelStarted",started],
|
||||||
|
["amFunnelActive",active],
|
||||||
|
["amFunnelCompleted",dashboard.funnel?.completed||0],
|
||||||
|
];
|
||||||
|
steps.forEach(([key,value],index)=>{
|
||||||
|
const row=document.createElement("div");
|
||||||
|
row.className="live-funnel-row";
|
||||||
|
const label=document.createElement("span");
|
||||||
|
label.textContent=T(key);
|
||||||
|
const track=document.createElement("span");
|
||||||
|
track.className="live-funnel-track";
|
||||||
|
const fill=document.createElement("i");
|
||||||
|
fill.style.width=Math.max(value?6:0,Math.round((value/base)*100))+"%";
|
||||||
|
fill.style.setProperty("--funnel-step",String(index));
|
||||||
|
track.appendChild(fill);
|
||||||
|
const count=document.createElement("strong");
|
||||||
|
count.textContent=String(value);
|
||||||
|
row.append(label,track,count);
|
||||||
|
card.appendChild(row);
|
||||||
|
});
|
||||||
|
host.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLiveTrend(host,live){
|
||||||
|
const card=document.createElement("section");
|
||||||
|
card.className="live-graphic-card live-trend";
|
||||||
|
const title=document.createElement("strong");
|
||||||
|
title.textContent=T("amTrendTitle");
|
||||||
|
card.appendChild(title);
|
||||||
|
const points=live.classTrend||[];
|
||||||
|
const svg=liveSvg("svg",{viewBox:"0 0 600 190",role:"img",
|
||||||
|
"aria-label":T("amTrendDescription")});
|
||||||
|
[0,25,50,75,100].forEach(value=>{
|
||||||
|
const y=155-(value/100)*125;
|
||||||
|
svg.appendChild(liveSvg("line",{x1:42,y1:y,x2:580,y2:y,class:"grid"}));
|
||||||
|
const label=liveSvg("text",{x:35,y:y+4,class:"axis","text-anchor":"end"});
|
||||||
|
label.textContent=value+"%";svg.appendChild(label);
|
||||||
|
});
|
||||||
|
const maxCompleted=Math.max(1,...points.map(point=>Number(point.completed||0)));
|
||||||
|
points.forEach((point,index)=>{
|
||||||
|
const x=52+index*(520/Math.max(1,points.length-1));
|
||||||
|
const height=(Number(point.completed||0)/maxCompleted)*48;
|
||||||
|
svg.appendChild(liveSvg("rect",{x:x-10,y:155-height,width:20,height,
|
||||||
|
rx:4,class:"completion"}));
|
||||||
|
const day=liveSvg("text",{x,y:177,class:"axis","text-anchor":"middle"});
|
||||||
|
day.textContent=new Date(point.date+"T12:00:00").toLocaleDateString(
|
||||||
|
LANG==="nl"?"nl-NL":"en-GB",{weekday:"short"});
|
||||||
|
svg.appendChild(day);
|
||||||
|
});
|
||||||
|
const usable=points.map((point,index)=>({index,value:point.accuracy}))
|
||||||
|
.filter(point=>Number.isFinite(point.value));
|
||||||
|
if(usable.length){
|
||||||
|
const d=usable.map((point,index)=>{
|
||||||
|
const x=52+point.index*(520/Math.max(1,points.length-1));
|
||||||
|
const y=155-(point.value/100)*125;
|
||||||
|
return(index?"L":"M")+x.toFixed(1)+" "+y.toFixed(1);
|
||||||
|
}).join(" ");
|
||||||
|
svg.appendChild(liveSvg("path",{d,class:"accuracy"}));
|
||||||
|
usable.forEach(point=>{
|
||||||
|
const x=52+point.index*(520/Math.max(1,points.length-1));
|
||||||
|
const y=155-(point.value/100)*125;
|
||||||
|
svg.appendChild(liveSvg("circle",{cx:x,cy:y,r:4,class:"accuracy-dot"}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
card.appendChild(svg);
|
||||||
|
const legend=document.createElement("div");
|
||||||
|
legend.className="live-chart-legend";
|
||||||
|
const accuracy=document.createElement("span");accuracy.className="accuracy";
|
||||||
|
accuracy.textContent=T("amTrendAccuracy");
|
||||||
|
const completed=document.createElement("span");completed.className="completed";
|
||||||
|
completed.textContent=T("amTrendCompleted");
|
||||||
|
legend.append(accuracy,completed);card.appendChild(legend);
|
||||||
|
host.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLiveTimeline(host,sessions,serverTime,onPupil){
|
||||||
|
const card=document.createElement("section");
|
||||||
|
card.className="live-graphic-card live-timeline";
|
||||||
|
const title=document.createElement("strong");
|
||||||
|
title.textContent=T("amTimelineTitle");
|
||||||
|
card.appendChild(title);
|
||||||
|
const items=(sessions||[]).slice(0,12);
|
||||||
|
if(!items.length){
|
||||||
|
const empty=document.createElement("p");empty.className="guestnote";
|
||||||
|
empty.textContent=T("amLiveRecentNone");card.appendChild(empty);
|
||||||
|
host.appendChild(card);return;
|
||||||
|
}
|
||||||
|
const durations=items.map(session=>Math.max(1000,
|
||||||
|
new Date(session.endedAt||session.lastSeenAt||serverTime).getTime()-new Date(session.startedAt).getTime()));
|
||||||
|
const max=Math.max(...durations);
|
||||||
|
items.forEach((session,index)=>{
|
||||||
|
const row=document.createElement("button");
|
||||||
|
row.type="button";row.className="live-timeline-row "+session.status;
|
||||||
|
const name=document.createElement("strong");name.textContent=session.pupilName;
|
||||||
|
const meta=document.createElement("span");
|
||||||
|
meta.textContent=(T("wg_"+session.widgetType)||session.widgetType)+" · "+
|
||||||
|
liveSessionTime(session.startedAt);
|
||||||
|
const track=document.createElement("span");track.className="live-timeline-track";
|
||||||
|
const fill=document.createElement("i");
|
||||||
|
fill.style.width=Math.max(8,Math.round((durations[index]/max)*100))+"%";
|
||||||
|
track.appendChild(fill);
|
||||||
|
const duration=document.createElement("span");duration.className="live-timeline-duration";
|
||||||
|
duration.textContent=session.paused?T("amStatePaused"):
|
||||||
|
liveDuration(session.startedAt,session.endedAt||session.lastSeenAt||serverTime);
|
||||||
|
row.append(name,meta,track,duration);
|
||||||
|
row.addEventListener("click",()=>onPupil(session.pupilId));
|
||||||
|
card.appendChild(row);
|
||||||
|
});
|
||||||
|
host.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLiveClassOverview(host,dashboard,live,onPupil,onAction){
|
||||||
|
const section=document.createElement("section");
|
||||||
|
section.className="live-class-overview";
|
||||||
|
const head=document.createElement("div");
|
||||||
|
head.className="live-session-head";
|
||||||
|
const title=document.createElement("strong");title.textContent=T("amLiveSessions");
|
||||||
|
const connection=document.createElement("span");
|
||||||
|
connection.className="live-connection connecting";
|
||||||
|
connection.textContent=T("amLiveReconnecting");
|
||||||
|
head.append(title,connection);section.appendChild(head);
|
||||||
|
|
||||||
|
const activeByPupil=new Map();
|
||||||
|
const recentByPupil=new Map();
|
||||||
|
(live.sessions||[]).forEach(session=>{
|
||||||
|
const map=session.status==="live"?activeByPupil:recentByPupil;
|
||||||
|
if(!map.has(session.pupilId))map.set(session.pupilId,session);
|
||||||
|
});
|
||||||
|
const pupils=(dashboard.pupils||[]).map(pupil=>{
|
||||||
|
const session=activeByPupil.get(pupil.pupilId)||null;
|
||||||
|
const recent=recentByPupil.get(pupil.pupilId)||null;
|
||||||
|
const signal=session?.helpSignal||(pupil.status==="support"?"errors":null);
|
||||||
|
const justStarted=!!session&&new Date(live.serverTime||Date.now()).getTime()
|
||||||
|
-new Date(session.startedAt).getTime()<60000;
|
||||||
|
const state=session?.paused?"paused":signal?"support":justStarted?"new":
|
||||||
|
session?"active":pupil.status;
|
||||||
|
return{...pupil,session,recent,signal,state,
|
||||||
|
trend:live.pupilTrends?.[String(pupil.pupilId)]||[]};
|
||||||
|
});
|
||||||
|
|
||||||
|
const metrics=document.createElement("div");
|
||||||
|
metrics.className="live-class-metrics";
|
||||||
|
[
|
||||||
|
["active",T("amMetricWorking"),pupils.filter(p=>p.state==="active").length],
|
||||||
|
["new",T("amMetricNew"),pupils.filter(p=>p.state==="new").length],
|
||||||
|
["support",T("amMetricHelp"),pupils.filter(p=>p.signal).length],
|
||||||
|
["complete",T("amMetricComplete"),pupils.filter(p=>p.status==="complete").length],
|
||||||
|
].forEach(([state,label,value])=>{
|
||||||
|
const metric=document.createElement("div");metric.className="live-class-metric "+state;
|
||||||
|
const count=document.createElement("strong");count.textContent=String(value);
|
||||||
|
const text=document.createElement("span");text.textContent=label;
|
||||||
|
metric.append(count,text);metrics.appendChild(metric);
|
||||||
|
});
|
||||||
|
section.appendChild(metrics);
|
||||||
|
|
||||||
|
const help=pupils.filter(pupil=>pupil.signal);
|
||||||
|
if(help.length){
|
||||||
|
const signals=document.createElement("section");signals.className="live-help-signals";
|
||||||
|
const signalTitle=document.createElement("strong");signalTitle.textContent=T("amHelpSignals");
|
||||||
|
signals.appendChild(signalTitle);
|
||||||
|
const list=document.createElement("div");
|
||||||
|
help.forEach(pupil=>{
|
||||||
|
const button=document.createElement("button");button.type="button";
|
||||||
|
button.className="live-help-chip "+pupil.signal;
|
||||||
|
button.textContent="⚑ "+pupil.pupilName+" · "+T(
|
||||||
|
pupil.signal==="idle"?"amHelpIdle":"amHelpErrors");
|
||||||
|
button.addEventListener("click",()=>onPupil(pupil.pupilId));
|
||||||
|
list.appendChild(button);
|
||||||
|
});
|
||||||
|
signals.appendChild(list);section.appendChild(signals);
|
||||||
|
}
|
||||||
|
|
||||||
|
const controls=document.createElement("div");controls.className="live-grid-controls";
|
||||||
|
const gridTitle=document.createElement("strong");gridTitle.textContent=T("amClassGrid");
|
||||||
|
const sort=document.createElement("select");sort.className="am-sel";
|
||||||
|
[["priority","amSortPriority"],["active","amSortActive"],["name","amSortName"]]
|
||||||
|
.forEach(([value,key])=>sort.appendChild(new Option(T(key),value)));
|
||||||
|
controls.append(gridTitle,sort);section.appendChild(controls);
|
||||||
|
const grid=document.createElement("div");grid.className="live-class-grid";
|
||||||
|
section.appendChild(grid);
|
||||||
|
const actionMessage=document.createElement("div");
|
||||||
|
actionMessage.className="live-dashboard-message";actionMessage.setAttribute("role","status");
|
||||||
|
section.appendChild(actionMessage);
|
||||||
|
|
||||||
|
const stateRank={support:0,paused:1,new:2,active:3,notStarted:4,complete:5};
|
||||||
|
const drawGrid=()=>{
|
||||||
|
grid.innerHTML="";
|
||||||
|
if(!pupils.length){
|
||||||
|
const empty=document.createElement("p");empty.className="guestnote";
|
||||||
|
empty.textContent=T("castNoPupils");grid.appendChild(empty);return;
|
||||||
|
}
|
||||||
|
const ordered=[...pupils].sort((a,b)=>{
|
||||||
|
if(sort.value==="name")return a.pupilName.localeCompare(b.pupilName);
|
||||||
|
if(sort.value==="active")return Number(!!b.session)-Number(!!a.session)
|
||||||
|
||a.pupilName.localeCompare(b.pupilName);
|
||||||
|
return(stateRank[a.state]??9)-(stateRank[b.state]??9)
|
||||||
|
||a.pupilName.localeCompare(b.pupilName);
|
||||||
|
});
|
||||||
|
ordered.forEach(pupil=>{
|
||||||
|
const tile=document.createElement("article");
|
||||||
|
tile.className="live-pupil-tile "+pupil.state;
|
||||||
|
const top=document.createElement("button");top.type="button";
|
||||||
|
top.className="live-pupil-main";
|
||||||
|
const badge=document.createElement("span");badge.className="live-pupil-state";
|
||||||
|
const stateKey={
|
||||||
|
support:pupil.signal==="idle"?"amHelpIdle":"amHelpErrors",
|
||||||
|
paused:"amStatePaused",new:"amLiveNew",active:"amLiveWorking",
|
||||||
|
complete:"amDashboardStatus_complete",notStarted:"amDashboardStatus_notStarted",
|
||||||
|
}[pupil.state]||"amDashboardStatus_active";
|
||||||
|
badge.textContent=T(stateKey);
|
||||||
|
const name=document.createElement("strong");name.textContent=pupil.pupilName;
|
||||||
|
const task=document.createElement("span");task.className="live-pupil-task";
|
||||||
|
const session=pupil.session||pupil.recent;
|
||||||
|
task.textContent=session?(T("wg_"+session.widgetType)||session.widgetType):
|
||||||
|
pupil.sequence?.title||T("amDashboardNoActivity");
|
||||||
|
const progress=document.createElement("span");progress.className="live-pupil-progress";
|
||||||
|
const fill=document.createElement("i");
|
||||||
|
const percent=pupil.sequence?Math.round((pupil.sequence.completed/pupil.sequence.total)*100):
|
||||||
|
pupil.attempts?pupil.accuracy:0;
|
||||||
|
fill.style.width=Math.max(0,Math.min(100,percent))+"%";
|
||||||
|
progress.appendChild(fill);
|
||||||
|
const score=document.createElement("small");
|
||||||
|
score.textContent=pupil.sequence?
|
||||||
|
T("amDashboardSteps").replace("{done}",pupil.sequence.completed).replace("{total}",pupil.sequence.total):
|
||||||
|
pupil.attempts?T("amLiveScore").replace("{correct}",pupil.correct)
|
||||||
|
.replace("{attempts}",pupil.attempts):T("amDashboardNoActivity");
|
||||||
|
top.append(badge,name,task,progress,score,liveSparkline(pupil.trend));
|
||||||
|
top.addEventListener("click",()=>onPupil(pupil.pupilId));
|
||||||
|
tile.appendChild(top);
|
||||||
|
|
||||||
|
const toggle=document.createElement("button");toggle.type="button";
|
||||||
|
toggle.className="live-action-toggle";toggle.textContent="⚡ "+T("amActions");
|
||||||
|
const actions=document.createElement("div");actions.className="live-action-row";
|
||||||
|
actions.hidden=true;
|
||||||
|
toggle.addEventListener("click",()=>{actions.hidden=!actions.hidden;});
|
||||||
|
let sending=false;
|
||||||
|
const trigger=async(kind,payload={})=>{
|
||||||
|
if(sending)return;
|
||||||
|
sending=true;tile.classList.add("sending");
|
||||||
|
actionMessage.textContent=T("amActionSending");
|
||||||
|
try{
|
||||||
|
await onAction(pupil.pupilId,kind,payload);
|
||||||
|
actionMessage.textContent=T("amActionSent");
|
||||||
|
}catch(error){actionMessage.textContent=error.message;}
|
||||||
|
finally{sending=false;tile.classList.remove("sending");}
|
||||||
|
};
|
||||||
|
const actionButton=(label,kind,handler,disabled=false)=>{
|
||||||
|
const button=document.createElement("button");button.type="button";
|
||||||
|
button.textContent=label;button.disabled=disabled;
|
||||||
|
button.addEventListener("click",handler||(()=>trigger(kind)));
|
||||||
|
actions.appendChild(button);
|
||||||
|
};
|
||||||
|
actionButton("💡 "+T("amActionHint"),"hint",()=>{
|
||||||
|
const message=window.prompt(T("amActionHintPrompt"),T("amActionHintDefault"));
|
||||||
|
if(message&&message.trim())trigger("hint",{message:message.trim()});
|
||||||
|
});
|
||||||
|
actionButton("👏 "+T("amActionEncourage"),"encourage",()=>{
|
||||||
|
const message=window.prompt(T("amActionEncouragePrompt"),T("amActionEncourageDefault"));
|
||||||
|
if(message&&message.trim())trigger("encourage",{message:message.trim()});
|
||||||
|
});
|
||||||
|
const subject=typeof REGISTRY!=="undefined"
|
||||||
|
?REGISTRY.find(def=>def.id===pupil.session?.widgetType)?.cat:null;
|
||||||
|
actionButton("↘ "+T("amActionEasier"),"easier",
|
||||||
|
()=>{if(window.confirm(T("amActionEasierConfirm")))trigger("easier",{subject});},
|
||||||
|
!pupil.session||!["taal","rekenen","world"].includes(subject));
|
||||||
|
const pauseKind=pupil.session?.paused?"resume":"pause";
|
||||||
|
actionButton((pauseKind==="pause"?"⏸ ":"▶ ")+
|
||||||
|
T(pauseKind==="pause"?"amActionPause":"amActionResume"),pauseKind,
|
||||||
|
()=>trigger(pauseKind),!pupil.session);
|
||||||
|
tile.append(toggle,actions);grid.appendChild(tile);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
sort.addEventListener("change",drawGrid);drawGrid();
|
||||||
|
|
||||||
|
const graphics=document.createElement("div");graphics.className="live-graphics-grid";
|
||||||
|
renderLiveFunnel(graphics,dashboard,live);
|
||||||
|
renderLiveTrend(graphics,live);
|
||||||
|
section.appendChild(graphics);
|
||||||
|
renderLiveTimeline(section,live.sessions,live.serverTime,onPupil);
|
||||||
|
host.appendChild(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectLiveClassDashboard(classId,onChange,isActive,onState){
|
||||||
|
let source=null;
|
||||||
|
let stopped=false;
|
||||||
|
let reloadTimer=null;
|
||||||
|
const queueReload=()=>{
|
||||||
|
clearTimeout(reloadTimer);
|
||||||
|
reloadTimer=setTimeout(()=>{if(!stopped&&isActive())onChange();},180);
|
||||||
|
};
|
||||||
|
if(typeof EventSource!=="undefined"){
|
||||||
|
source=new EventSource("/api/progress/live/class/"+encodeURIComponent(classId)+"/stream");
|
||||||
|
source.onopen=()=>onState("connected");
|
||||||
|
source.onerror=()=>onState("reconnecting");
|
||||||
|
source.addEventListener("sessions",queueReload);
|
||||||
|
}
|
||||||
|
const fallback=setInterval(()=>{
|
||||||
|
if(!isActive()){stop();return;}
|
||||||
|
onChange();
|
||||||
|
},30000);
|
||||||
|
function stop(){
|
||||||
|
if(stopped)return;
|
||||||
|
stopped=true;
|
||||||
|
clearInterval(fallback);
|
||||||
|
clearTimeout(reloadTimer);
|
||||||
|
if(source)source.close();
|
||||||
|
}
|
||||||
|
return stop;
|
||||||
|
}
|
||||||
89
public/js/live-classroom-i18n.js
Normal file
89
public/js/live-classroom-i18n.js
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
/* Teksten voor het bidirectionele live-klaslokaal, apart gehouden zodat het
|
||||||
|
omvangrijke centrale woordenboek overzichtelijk blijft. */
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
Object.assign(I18N.nl,{
|
||||||
|
amFunnelTitle:"Opdrachtfunnel",
|
||||||
|
amFunnelAssigned:"Ontvangen",
|
||||||
|
amFunnelStarted:"Gestart",
|
||||||
|
amFunnelActive:"Nu actief",
|
||||||
|
amFunnelCompleted:"Afgerond",
|
||||||
|
amTrendTitle:"Voortgang laatste 7 dagen",
|
||||||
|
amTrendDescription:"Nauwkeurigheid en afgeronde stappen per dag",
|
||||||
|
amTrendAccuracy:"Nauwkeurigheid",
|
||||||
|
amTrendCompleted:"Afgeronde opdrachten",
|
||||||
|
amTimelineTitle:"Sessietijdlijn",
|
||||||
|
amStatePaused:"Gepauzeerd",
|
||||||
|
amMetricWorking:"Nu bezig",
|
||||||
|
amMetricNew:"Net gestart",
|
||||||
|
amMetricHelp:"Hulp nodig",
|
||||||
|
amMetricComplete:"Klaar",
|
||||||
|
amHelpSignals:"Hulpsignalen",
|
||||||
|
amHelpIdle:"Lang geen voortgang",
|
||||||
|
amHelpErrors:"Veel fouten",
|
||||||
|
amClassGrid:"Live klassenraster",
|
||||||
|
amSortPriority:"Hulp eerst",
|
||||||
|
amSortActive:"Actief eerst",
|
||||||
|
amSortName:"Op naam",
|
||||||
|
amActions:"Directe acties",
|
||||||
|
amActionSending:"Actie wordt verstuurd…",
|
||||||
|
amActionSent:"Actie is direct naar de leerling gestuurd.",
|
||||||
|
amActionHint:"Hint",
|
||||||
|
amActionHintPrompt:"Welke korte hint wil je sturen?",
|
||||||
|
amActionHintDefault:"Kijk nog eens rustig naar de opdracht.",
|
||||||
|
amActionEncourage:"Aanmoedigen",
|
||||||
|
amActionEncouragePrompt:"Welke positieve boodschap wil je sturen?",
|
||||||
|
amActionEncourageDefault:"Goed bezig, blijf proberen!",
|
||||||
|
amActionEasier:"Eenvoudiger",
|
||||||
|
amActionEasierConfirm:"Weet je zeker dat je voor deze leerling een eenvoudiger individueel niveau wilt instellen?",
|
||||||
|
amActionPause:"Pauzeren",
|
||||||
|
amActionResume:"Hervatten",
|
||||||
|
pupilPausedTitle:"Even pauze",
|
||||||
|
pupilPausedText:"Je leerkracht heeft deze opdracht tijdelijk gepauzeerd.",
|
||||||
|
pupilEasierReady:"Een eenvoudiger niveau staat klaar: groep {group}, niveau {level}.",
|
||||||
|
pupilResumeMessage:"Je kunt weer verder met de opdracht.",
|
||||||
|
pupilTeacherMessage:"Je leerkracht heeft een bericht gestuurd.",
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.assign(I18N.en,{
|
||||||
|
amFunnelTitle:"Assignment funnel",
|
||||||
|
amFunnelAssigned:"Assigned",
|
||||||
|
amFunnelStarted:"Started",
|
||||||
|
amFunnelActive:"Active now",
|
||||||
|
amFunnelCompleted:"Completed",
|
||||||
|
amTrendTitle:"Progress over the last 7 days",
|
||||||
|
amTrendDescription:"Accuracy and completed steps per day",
|
||||||
|
amTrendAccuracy:"Accuracy",
|
||||||
|
amTrendCompleted:"Completed assignments",
|
||||||
|
amTimelineTitle:"Session timeline",
|
||||||
|
amStatePaused:"Paused",
|
||||||
|
amMetricWorking:"Working now",
|
||||||
|
amMetricNew:"Just started",
|
||||||
|
amMetricHelp:"Needs help",
|
||||||
|
amMetricComplete:"Complete",
|
||||||
|
amHelpSignals:"Help signals",
|
||||||
|
amHelpIdle:"No progress for a while",
|
||||||
|
amHelpErrors:"Many errors",
|
||||||
|
amClassGrid:"Live class grid",
|
||||||
|
amSortPriority:"Help first",
|
||||||
|
amSortActive:"Active first",
|
||||||
|
amSortName:"By name",
|
||||||
|
amActions:"Direct actions",
|
||||||
|
amActionSending:"Sending action…",
|
||||||
|
amActionSent:"The action was sent directly to the pupil.",
|
||||||
|
amActionHint:"Hint",
|
||||||
|
amActionHintPrompt:"Which short hint would you like to send?",
|
||||||
|
amActionHintDefault:"Take another calm look at the assignment.",
|
||||||
|
amActionEncourage:"Encourage",
|
||||||
|
amActionEncouragePrompt:"Which positive message would you like to send?",
|
||||||
|
amActionEncourageDefault:"You are doing well, keep trying!",
|
||||||
|
amActionEasier:"Easier",
|
||||||
|
amActionEasierConfirm:"Are you sure you want to set an easier individual level for this pupil?",
|
||||||
|
amActionPause:"Pause",
|
||||||
|
amActionResume:"Resume",
|
||||||
|
pupilPausedTitle:"Time for a short pause",
|
||||||
|
pupilPausedText:"Your teacher has temporarily paused this assignment.",
|
||||||
|
pupilEasierReady:"An easier level is ready: year {group}, level {level}.",
|
||||||
|
pupilResumeMessage:"You can continue with the assignment.",
|
||||||
|
pupilTeacherMessage:"Your teacher sent you a message.",
|
||||||
|
});
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
115
public/js/pupil-live-actions.js
Normal file
115
public/js/pupil-live-actions.js
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
/* Live terugkanaal voor leerkrachtacties. Berichten worden via SSE direct
|
||||||
|
gemeld en daarna via de duurzame API opgehaald en bevestigd. */
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
let pupilActionSource=null;
|
||||||
|
let pupilActionFallback=null;
|
||||||
|
let pupilActionNoticeTimer=null;
|
||||||
|
let pupilActionLoading=false;
|
||||||
|
|
||||||
|
function ensurePupilActionUi(){
|
||||||
|
let notice=document.getElementById("pupilLiveNotice");
|
||||||
|
if(!notice){
|
||||||
|
notice=document.createElement("aside");
|
||||||
|
notice.id="pupilLiveNotice";
|
||||||
|
notice.className="pupil-live-notice";
|
||||||
|
notice.setAttribute("role","status");
|
||||||
|
notice.setAttribute("aria-live","polite");
|
||||||
|
notice.hidden=true;
|
||||||
|
document.body.appendChild(notice);
|
||||||
|
}
|
||||||
|
let pause=document.getElementById("pupilPauseShield");
|
||||||
|
if(!pause){
|
||||||
|
pause=document.createElement("aside");
|
||||||
|
pause.id="pupilPauseShield";
|
||||||
|
pause.className="pupil-pause-shield";
|
||||||
|
pause.setAttribute("role","status");
|
||||||
|
const icon=document.createElement("span");icon.textContent="⏸";
|
||||||
|
const title=document.createElement("strong");title.textContent=T("pupilPausedTitle");
|
||||||
|
const text=document.createElement("span");text.textContent=T("pupilPausedText");
|
||||||
|
pause.append(icon,title,text);
|
||||||
|
document.body.appendChild(pause);
|
||||||
|
}
|
||||||
|
return{notice,pause};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPupilPaused(paused){
|
||||||
|
const ui=ensurePupilActionUi();
|
||||||
|
document.body.classList.toggle("pupil-session-paused",!!paused);
|
||||||
|
ui.pause.hidden=!paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPupilLiveAction(action){
|
||||||
|
const ui=ensurePupilActionUi();
|
||||||
|
if(action.kind==="pause"){setPupilPaused(true);return;}
|
||||||
|
if(action.kind==="resume")setPupilPaused(false);
|
||||||
|
let text=action.message||"";
|
||||||
|
if(action.kind==="easier"){
|
||||||
|
text=T("pupilEasierReady")
|
||||||
|
.replace("{group}",action.payload?.yearGroup||"–")
|
||||||
|
.replace("{level}",action.payload?.level||"–");
|
||||||
|
pupilLastSig=null;
|
||||||
|
pupilPoll();
|
||||||
|
}else if(!text){
|
||||||
|
text=T(action.kind==="resume"?"pupilResumeMessage":"pupilTeacherMessage");
|
||||||
|
}
|
||||||
|
ui.notice.className="pupil-live-notice "+action.kind;
|
||||||
|
ui.notice.textContent=(action.kind==="encourage"?"👏 ":action.kind==="hint"?"💡 ":"")+" "+text;
|
||||||
|
ui.notice.hidden=false;
|
||||||
|
clearTimeout(pupilActionNoticeTimer);
|
||||||
|
pupilActionNoticeTimer=setTimeout(()=>{ui.notice.hidden=true;},8000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPupilLiveActions(){
|
||||||
|
if(pupilActionLoading||!currentUser||currentUser.role!=="pupil")return;
|
||||||
|
pupilActionLoading=true;
|
||||||
|
try{
|
||||||
|
const result=await api("/my/live-actions");
|
||||||
|
setPupilPaused(result.paused);
|
||||||
|
for(const action of result.actions||[]){
|
||||||
|
showPupilLiveAction(action);
|
||||||
|
await api("/my/live-actions/"+encodeURIComponent(action.id)+"/ack",{method:"POST"});
|
||||||
|
}
|
||||||
|
}catch(error){
|
||||||
|
if(error.status===401||error.status===403)stopPupilLiveActions();
|
||||||
|
}finally{pupilActionLoading=false;}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPupilLiveActions(){
|
||||||
|
stopPupilLiveActions();
|
||||||
|
ensurePupilActionUi();
|
||||||
|
loadPupilLiveActions();
|
||||||
|
if(typeof EventSource!=="undefined"){
|
||||||
|
pupilActionSource=new EventSource("/api/my/live-actions/stream");
|
||||||
|
pupilActionSource.addEventListener("actions",loadPupilLiveActions);
|
||||||
|
}
|
||||||
|
pupilActionFallback=setInterval(loadPupilLiveActions,15000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPupilLiveActions(){
|
||||||
|
if(pupilActionSource){pupilActionSource.close();pupilActionSource=null;}
|
||||||
|
clearInterval(pupilActionFallback);pupilActionFallback=null;
|
||||||
|
clearTimeout(pupilActionNoticeTimer);
|
||||||
|
setPupilPaused(false);
|
||||||
|
const notice=document.getElementById("pupilLiveNotice");
|
||||||
|
if(notice)notice.hidden=true;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("langchange",()=>{
|
||||||
|
const pause=document.getElementById("pupilPauseShield");
|
||||||
|
if(pause){
|
||||||
|
pause.querySelector("strong").textContent=T("pupilPausedTitle");
|
||||||
|
pause.querySelector("span:last-child").textContent=T("pupilPausedText");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const startPupilViewWithoutActions=startPupilView;
|
||||||
|
startPupilView=function(){
|
||||||
|
startPupilViewWithoutActions();
|
||||||
|
startPupilLiveActions();
|
||||||
|
};
|
||||||
|
const stopPupilViewWithoutActions=stopPupilView;
|
||||||
|
stopPupilView=function(){
|
||||||
|
stopPupilLiveActions();
|
||||||
|
stopPupilViewWithoutActions();
|
||||||
|
};
|
||||||
17
src/api.js
17
src/api.js
|
|
@ -17,7 +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';
|
import registerLiveSessions from './live-classroom.js';
|
||||||
|
|
||||||
export default async function api(app) {
|
export default async function api(app) {
|
||||||
const pool = app.pg;
|
const pool = app.pg;
|
||||||
|
|
@ -1265,6 +1265,8 @@ export default async function api(app) {
|
||||||
if (!a) return fail(reply, 400, 'geen toewijzing');
|
if (!a) return fail(reply, 400, 'geen toewijzing');
|
||||||
const cleanAttempts = clampCount(attempts);
|
const cleanAttempts = clampCount(attempts);
|
||||||
const cleanCorrect = clampCount(correct);
|
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(
|
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)`,
|
||||||
|
|
@ -1350,6 +1352,9 @@ export default async function api(app) {
|
||||||
const attempts = Number(pupil.attempts || 0);
|
const attempts = Number(pupil.attempts || 0);
|
||||||
const correct = Number(pupil.correct || 0);
|
const correct = Number(pupil.correct || 0);
|
||||||
const accuracy = attempts ? Math.round((correct / attempts) * 100) : 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';
|
let status = 'notStarted';
|
||||||
if (total && done >= total) status = 'complete';
|
if (total && done >= total) status = 'complete';
|
||||||
else if (attempts >= 5 && accuracy < 60) status = 'support';
|
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,
|
pupilId: Number(pupil.id), pupilName: pupil.display_name || pupil.username,
|
||||||
attempts, correct, accuracy, lastActivity: pupil.last_activity,
|
attempts, correct, accuracy, lastActivity: pupil.last_activity,
|
||||||
sequence: assignment && total ? { title: assignment.title || '', completed: done, total } : null,
|
sequence: assignment && total ? { title: assignment.title || '', completed: done, total } : null,
|
||||||
status,
|
assigned: !!assignment, started, assignmentComplete, status,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const summary = overview.reduce((counts, pupil) => {
|
const summary = overview.reduce((counts, pupil) => {
|
||||||
counts[pupil.status] += 1;
|
counts[pupil.status] += 1;
|
||||||
return counts;
|
return counts;
|
||||||
}, { complete: 0, active: 0, support: 0, notStarted: 0 });
|
}, { 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 -----------------------------------------------------------
|
// ---- ouderportaal -----------------------------------------------------------
|
||||||
|
|
|
||||||
441
src/live-classroom.js
Normal file
441
src/live-classroom.js
Normal file
|
|
@ -0,0 +1,441 @@
|
||||||
|
// Bidirectionele live klasinteractie.
|
||||||
|
// PostgreSQL blijft de bron van waarheid; SSE meldt alleen dat een client de
|
||||||
|
// actuele snapshot opnieuw moet ophalen. Daardoor blijven reconnects veilig.
|
||||||
|
|
||||||
|
const LIVE_WINDOW_MS = 45_000;
|
||||||
|
const HELP_IDLE_MS = 3 * 60_000;
|
||||||
|
const ACTION_KINDS = new Set(['hint', 'encourage', 'pause', 'resume', 'easier']);
|
||||||
|
const LEVEL_SUBJECTS = new Set(['taal', 'rekenen', 'world']);
|
||||||
|
|
||||||
|
export default function registerLiveClassroom(app, options) {
|
||||||
|
const {
|
||||||
|
pool, need, fail, progressRoles, sameSchool, teacherOnly,
|
||||||
|
classOwnedByTeacher, effectiveAssignment, assignmentWidgets,
|
||||||
|
} = options;
|
||||||
|
const classStreams = new Map();
|
||||||
|
const pupilStreams = new Map();
|
||||||
|
|
||||||
|
const publish = (streamMap, key, event) => {
|
||||||
|
const streams = streamMap.get(String(key));
|
||||||
|
if (!streams) return;
|
||||||
|
for (const stream of [...streams]) {
|
||||||
|
try { stream.write(`event: ${event}\ndata: {}\n\n`); }
|
||||||
|
catch { streams.delete(stream); }
|
||||||
|
}
|
||||||
|
if (!streams.size) streamMap.delete(String(key));
|
||||||
|
};
|
||||||
|
const publishClass = (classId) => publish(classStreams, classId, 'sessions');
|
||||||
|
const publishPupil = (pupilId) => publish(pupilStreams, pupilId, 'actions');
|
||||||
|
|
||||||
|
const describeSession = (row) => {
|
||||||
|
const lastSeenAt = row.last_seen_at || row.started_at;
|
||||||
|
const live = !row.ended_at && Date.now() - new Date(lastSeenAt).getTime() <= LIVE_WINDOW_MS;
|
||||||
|
const attempts = Number(row.attempts || 0);
|
||||||
|
const correct = Number(row.correct || 0);
|
||||||
|
const startedMs = new Date(row.started_at).getTime();
|
||||||
|
const progressMs = row.last_progress_at ? new Date(row.last_progress_at).getTime() : 0;
|
||||||
|
let helpSignal = null;
|
||||||
|
if (live && !row.paused_at && attempts >= 5 && correct / attempts < 0.6) {
|
||||||
|
helpSignal = 'errors';
|
||||||
|
} else if (live && !row.paused_at && Date.now() - startedMs >= HELP_IDLE_MS
|
||||||
|
&& (!progressMs || Date.now() - progressMs >= HELP_IDLE_MS)) {
|
||||||
|
helpSignal = 'idle';
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: Number(row.id),
|
||||||
|
pupilId: Number(row.pupil_id),
|
||||||
|
pupilName: row.display_name || row.username,
|
||||||
|
widgetId: row.widget_id,
|
||||||
|
widgetType: row.widget_type,
|
||||||
|
mode: row.mode,
|
||||||
|
startedAt: row.started_at,
|
||||||
|
lastSeenAt,
|
||||||
|
endedAt: row.ended_at || null,
|
||||||
|
progressCount: Number(row.progress_count || 0),
|
||||||
|
attempts,
|
||||||
|
correct,
|
||||||
|
lastProgressAt: row.last_progress_at || null,
|
||||||
|
paused: !!row.paused_at,
|
||||||
|
pausedAt: row.paused_at || null,
|
||||||
|
helpSignal,
|
||||||
|
status: live ? 'live' : 'recent',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const describeAction = (row) => ({
|
||||||
|
id: Number(row.id),
|
||||||
|
kind: row.kind,
|
||||||
|
message: row.message || '',
|
||||||
|
payload: row.payload || {},
|
||||||
|
createdAt: row.created_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
const accessibleClass = async (req, reply) => {
|
||||||
|
const row = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0];
|
||||||
|
if (!row) return { response: fail(reply, 404, 'klas onbekend') };
|
||||||
|
if (!sameSchool(req, row)) return { response: fail(reply, 403, 'geen rechten') };
|
||||||
|
if (teacherOnly(req) && !(await classOwnedByTeacher(row.id, req.user.id))) {
|
||||||
|
return { response: fail(reply, 403, 'geen rechten') };
|
||||||
|
}
|
||||||
|
return { row };
|
||||||
|
};
|
||||||
|
|
||||||
|
const accessiblePupil = async (req, reply, classRow) => {
|
||||||
|
if (!/^\d+$/.test(String(req.params.pupilId))) {
|
||||||
|
return { response: fail(reply, 400, 'ongeldige leerling') };
|
||||||
|
}
|
||||||
|
const pupil = (await pool.query(
|
||||||
|
'SELECT * FROM users WHERE id = $1 AND role = $2',
|
||||||
|
[req.params.pupilId, 'pupil'])).rows[0];
|
||||||
|
if (!pupil) return { response: fail(reply, 404, 'leerling onbekend') };
|
||||||
|
if (Number(pupil.class_id) !== Number(classRow.id)) {
|
||||||
|
return { response: fail(reply, 403, 'leerling hoort niet bij deze klas') };
|
||||||
|
}
|
||||||
|
return { pupil };
|
||||||
|
};
|
||||||
|
|
||||||
|
const openStream = (req, reply, streamMap, key) => {
|
||||||
|
reply.hijack();
|
||||||
|
reply.raw.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
});
|
||||||
|
reply.raw.write('event: connected\ndata: {}\n\n');
|
||||||
|
const streamKey = String(key);
|
||||||
|
if (!streamMap.has(streamKey)) streamMap.set(streamKey, new Set());
|
||||||
|
streamMap.get(streamKey).add(reply.raw);
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
try { reply.raw.write(': heartbeat\n\n'); } catch { /* close ruimt op */ }
|
||||||
|
}, 15_000);
|
||||||
|
heartbeat.unref?.();
|
||||||
|
const cleanup = () => {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
const streams = streamMap.get(streamKey);
|
||||||
|
if (streams) {
|
||||||
|
streams.delete(reply.raw);
|
||||||
|
if (!streams.size) streamMap.delete(streamKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reply.raw.once('close', cleanup);
|
||||||
|
};
|
||||||
|
|
||||||
|
const trendDays = () => Array.from({ length: 7 }, (_, index) => {
|
||||||
|
const date = new Date();
|
||||||
|
date.setHours(12, 0, 0, 0);
|
||||||
|
date.setDate(date.getDate() - (6 - index));
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildTrends = (activityRows, completionRows) => {
|
||||||
|
const days = trendDays();
|
||||||
|
const classMap = new Map(days.map((day) => [day, {
|
||||||
|
date: day, attempts: 0, correct: 0, stepCompleted: 0, updates: 0,
|
||||||
|
}]));
|
||||||
|
const pupilMaps = new Map();
|
||||||
|
const pupilDay = (pupilId, day) => {
|
||||||
|
const key = String(pupilId);
|
||||||
|
if (!pupilMaps.has(key)) {
|
||||||
|
pupilMaps.set(key, new Map(days.map((date) => [date, {
|
||||||
|
date, attempts: 0, correct: 0, stepCompleted: 0, updates: 0,
|
||||||
|
}])));
|
||||||
|
}
|
||||||
|
return pupilMaps.get(key).get(day);
|
||||||
|
};
|
||||||
|
for (const row of activityRows) {
|
||||||
|
const day = String(row.day).slice(0, 10);
|
||||||
|
const classPoint = classMap.get(day);
|
||||||
|
const pupilPoint = pupilDay(row.pupil_id, day);
|
||||||
|
if (!classPoint || !pupilPoint) continue;
|
||||||
|
for (const point of [classPoint, pupilPoint]) {
|
||||||
|
point.attempts += Number(row.attempts || 0);
|
||||||
|
point.correct += Number(row.correct || 0);
|
||||||
|
point.updates += Number(row.updates || 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const row of completionRows) {
|
||||||
|
const day = String(row.day).slice(0, 10);
|
||||||
|
const classPoint = classMap.get(day);
|
||||||
|
const pupilPoint = pupilDay(row.pupil_id, day);
|
||||||
|
if (!classPoint || !pupilPoint) continue;
|
||||||
|
classPoint.stepCompleted += Number(row.completed || 0);
|
||||||
|
pupilPoint.stepCompleted += Number(row.completed || 0);
|
||||||
|
}
|
||||||
|
const finish = (points) => points.map((point) => {
|
||||||
|
const { stepCompleted, ...values } = point;
|
||||||
|
return {
|
||||||
|
...values,
|
||||||
|
completed: Math.max(point.updates, stepCompleted),
|
||||||
|
accuracy: point.attempts ? Math.round((point.correct / point.attempts) * 100) : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
classTrend: finish([...classMap.values()]),
|
||||||
|
pupilTrends: Object.fromEntries(
|
||||||
|
[...pupilMaps.entries()].map(([pupilId, points]) => [pupilId, finish([...points.values()])])),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapshot = async (classId) => {
|
||||||
|
const [sessionRows, activityRows, completionRows] = await Promise.all([
|
||||||
|
pool.query(
|
||||||
|
`SELECT ls.*, u.display_name, u.username
|
||||||
|
FROM live_learning_sessions ls JOIN users u ON u.id = ls.pupil_id
|
||||||
|
WHERE ls.class_id = $1
|
||||||
|
AND COALESCE(ls.last_seen_at, ls.started_at) >= now() - interval '24 hours'
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN ls.ended_at IS NULL AND ls.last_seen_at >= now() - interval '45 seconds' THEN 0 ELSE 1 END,
|
||||||
|
COALESCE(ls.last_seen_at, ls.started_at) DESC
|
||||||
|
LIMIT 80`, [classId]),
|
||||||
|
pool.query(
|
||||||
|
`SELECT pe.pupil_id, to_char(pe.created_at, 'YYYY-MM-DD') AS day,
|
||||||
|
SUM(pe.attempts) AS attempts, SUM(pe.correct) AS correct, COUNT(*) AS updates
|
||||||
|
FROM progress_events pe JOIN users u ON u.id = pe.pupil_id
|
||||||
|
WHERE u.class_id = $1 AND pe.created_at >= current_date - interval '6 days'
|
||||||
|
GROUP BY pe.pupil_id, to_char(pe.created_at, 'YYYY-MM-DD')`, [classId]),
|
||||||
|
pool.query(
|
||||||
|
`SELECT asp.pupil_id, to_char(asp.completed_at, 'YYYY-MM-DD') AS day,
|
||||||
|
COUNT(*) AS completed
|
||||||
|
FROM assignment_step_progress asp JOIN users u ON u.id = asp.pupil_id
|
||||||
|
WHERE u.class_id = $1 AND asp.completed_at >= current_date - interval '6 days'
|
||||||
|
GROUP BY asp.pupil_id, to_char(asp.completed_at, 'YYYY-MM-DD')`, [classId]),
|
||||||
|
]);
|
||||||
|
const sessions = sessionRows.rows.map(describeSession);
|
||||||
|
const trends = buildTrends(activityRows.rows, completionRows.rows);
|
||||||
|
return {
|
||||||
|
sessions,
|
||||||
|
...trends,
|
||||||
|
liveCount: sessions.filter((session) => session.status === 'live').length,
|
||||||
|
helpCount: sessions.filter((session) => session.helpSignal).length,
|
||||||
|
serverTime: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
app.post('/my/live-session/start', async (req, reply) => {
|
||||||
|
need(req, reply);
|
||||||
|
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
|
||||||
|
if (!req.user.class_id) return fail(reply, 400, 'leerling heeft geen klas');
|
||||||
|
const { widgetId, widgetType, mode } = req.body ?? {};
|
||||||
|
if (!widgetId || typeof widgetId !== 'string' || widgetId.length > 100) {
|
||||||
|
return fail(reply, 400, 'ongeldige widgetId');
|
||||||
|
}
|
||||||
|
if (!widgetType || typeof widgetType !== 'string' || widgetType.length > 40) {
|
||||||
|
return fail(reply, 400, 'ongeldige widgetType');
|
||||||
|
}
|
||||||
|
if (mode !== 'werken' && mode !== 'kijken') return fail(reply, 400, 'ongeldige modus');
|
||||||
|
|
||||||
|
const assignment = await effectiveAssignment(req.user);
|
||||||
|
if (!assignment) return fail(reply, 400, 'geen toewijzing');
|
||||||
|
const steps = Array.isArray(assignment.sequence) ? assignment.sequence : [];
|
||||||
|
let boardId = assignment.board_id;
|
||||||
|
let assignedWidgetId = assignment.widget_id;
|
||||||
|
let assignedMode = assignment.mode || 'werken';
|
||||||
|
if (steps.length) {
|
||||||
|
const completed = new Set((await pool.query(
|
||||||
|
'SELECT step_id FROM assignment_step_progress WHERE assignment_id = $1 AND pupil_id = $2',
|
||||||
|
[assignment.id, req.user.id])).rows.map((row) => row.step_id));
|
||||||
|
const current = steps.find((step) => !completed.has(step.id));
|
||||||
|
if (!current) return fail(reply, 409, 'opdrachtenreeks is al afgerond');
|
||||||
|
boardId = current.boardId;
|
||||||
|
assignedWidgetId = current.widgetId;
|
||||||
|
assignedMode = current.mode;
|
||||||
|
}
|
||||||
|
if (assignedWidgetId && assignedWidgetId !== widgetId) {
|
||||||
|
return fail(reply, 409, 'deze widget is niet de huidige opdracht');
|
||||||
|
}
|
||||||
|
if (assignedMode !== mode) return fail(reply, 409, 'de opdrachtmodus is gewijzigd');
|
||||||
|
|
||||||
|
const widgets = await assignmentWidgets(assignment, boardId, assignedWidgetId);
|
||||||
|
const known = widgets.some((widget) =>
|
||||||
|
widget.id === widgetType && (widget.wid ? widget.wid === widgetId : true));
|
||||||
|
if (!known) return fail(reply, 409, 'widget staat niet meer in de toewijzing');
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE live_learning_sessions
|
||||||
|
SET ended_at = now(), last_seen_at = now()
|
||||||
|
WHERE pupil_id = $1 AND ended_at IS NULL`, [req.user.id]);
|
||||||
|
await pool.query(
|
||||||
|
`DELETE FROM live_learning_sessions
|
||||||
|
WHERE pupil_id = $1 AND started_at < now() - interval '30 days'`, [req.user.id]);
|
||||||
|
const row = (await pool.query(
|
||||||
|
`INSERT INTO live_learning_sessions
|
||||||
|
(pupil_id, teacher_id, class_id, assignment_id, board_id, widget_id, widget_type, mode)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
|
||||||
|
[req.user.id, assignment.teacher_id, req.user.class_id, assignment.id,
|
||||||
|
boardId, widgetId, widgetType, mode])).rows[0];
|
||||||
|
publishClass(req.user.class_id);
|
||||||
|
return {
|
||||||
|
session: describeSession({
|
||||||
|
...row, display_name: req.user.display_name, username: req.user.username,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/my/live-session/:id/heartbeat', async (req, reply) => {
|
||||||
|
need(req, reply);
|
||||||
|
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
|
||||||
|
if (!/^\d+$/.test(String(req.params.id))) return fail(reply, 400, 'ongeldige sessie');
|
||||||
|
const row = (await pool.query(
|
||||||
|
`UPDATE live_learning_sessions SET last_seen_at = now()
|
||||||
|
WHERE id = $1 AND pupil_id = $2 AND ended_at IS NULL
|
||||||
|
RETURNING id, paused_at`,
|
||||||
|
[req.params.id, req.user.id])).rows[0];
|
||||||
|
if (!row) return fail(reply, 404, 'sessie niet actief');
|
||||||
|
return { ok: true, paused: !!row.paused_at };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/my/live-session/:id/end', async (req, reply) => {
|
||||||
|
need(req, reply);
|
||||||
|
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
|
||||||
|
if (!/^\d+$/.test(String(req.params.id))) return fail(reply, 400, 'ongeldige sessie');
|
||||||
|
const row = (await pool.query(
|
||||||
|
`UPDATE live_learning_sessions SET ended_at = now(), last_seen_at = now()
|
||||||
|
WHERE id = $1 AND pupil_id = $2 AND ended_at IS NULL RETURNING class_id`,
|
||||||
|
[req.params.id, req.user.id])).rows[0];
|
||||||
|
if (row) publishClass(row.class_id);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/my/live-actions', async (req, reply) => {
|
||||||
|
need(req, reply);
|
||||||
|
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
|
||||||
|
const [actions, session] = await Promise.all([
|
||||||
|
pool.query(
|
||||||
|
`SELECT * FROM teacher_pupil_actions
|
||||||
|
WHERE pupil_id = $1 AND acknowledged_at IS NULL
|
||||||
|
AND created_at >= now() - interval '24 hours'
|
||||||
|
ORDER BY created_at, id LIMIT 20`, [req.user.id]),
|
||||||
|
pool.query(
|
||||||
|
`SELECT id, paused_at FROM live_learning_sessions
|
||||||
|
WHERE pupil_id = $1 AND ended_at IS NULL
|
||||||
|
ORDER BY started_at DESC LIMIT 1`, [req.user.id]),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
actions: actions.rows.map(describeAction),
|
||||||
|
paused: !!session.rows[0]?.paused_at,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/my/live-actions/:id/ack', async (req, reply) => {
|
||||||
|
need(req, reply);
|
||||||
|
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
|
||||||
|
if (!/^\d+$/.test(String(req.params.id))) return fail(reply, 400, 'ongeldige actie');
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE teacher_pupil_actions SET acknowledged_at = now()
|
||||||
|
WHERE id = $1 AND pupil_id = $2 AND acknowledged_at IS NULL`,
|
||||||
|
[req.params.id, req.user.id]);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/my/live-actions/stream', async (req, reply) => {
|
||||||
|
need(req, reply);
|
||||||
|
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
|
||||||
|
openStream(req, reply, pupilStreams, req.user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/progress/live/class/:id', async (req, reply) => {
|
||||||
|
need(req, reply, progressRoles);
|
||||||
|
const access = await accessibleClass(req, reply);
|
||||||
|
if (!access.row) return access.response;
|
||||||
|
return snapshot(access.row.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/progress/live/class/:id/stream', async (req, reply) => {
|
||||||
|
need(req, reply, progressRoles);
|
||||||
|
const access = await accessibleClass(req, reply);
|
||||||
|
if (!access.row) return access.response;
|
||||||
|
openStream(req, reply, classStreams, access.row.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/progress/live/class/:id/pupil/:pupilId/action', async (req, reply) => {
|
||||||
|
need(req, reply, progressRoles);
|
||||||
|
const access = await accessibleClass(req, reply);
|
||||||
|
if (!access.row) return access.response;
|
||||||
|
const pupilAccess = await accessiblePupil(req, reply, access.row);
|
||||||
|
if (!pupilAccess.pupil) return pupilAccess.response;
|
||||||
|
const pupil = pupilAccess.pupil;
|
||||||
|
const { kind, subject } = req.body ?? {};
|
||||||
|
if (!ACTION_KINDS.has(kind)) return fail(reply, 400, 'ongeldige live-actie');
|
||||||
|
const rawMessage = typeof req.body?.message === 'string' ? req.body.message.trim() : '';
|
||||||
|
if (rawMessage.length > 160) return fail(reply, 400, 'bericht is te lang');
|
||||||
|
if ((kind === 'hint' || kind === 'encourage') && !rawMessage) {
|
||||||
|
return fail(reply, 400, 'bericht ontbreekt');
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = {};
|
||||||
|
if (kind === 'pause' || kind === 'resume') {
|
||||||
|
const pausedAt = kind === 'pause' ? 'now()' : 'NULL';
|
||||||
|
const session = (await pool.query(
|
||||||
|
`UPDATE live_learning_sessions SET paused_at = ${pausedAt}, last_seen_at = now()
|
||||||
|
WHERE id = (
|
||||||
|
SELECT id FROM live_learning_sessions
|
||||||
|
WHERE pupil_id = $1 AND class_id = $2 AND ended_at IS NULL
|
||||||
|
AND last_seen_at >= now() - interval '45 seconds'
|
||||||
|
ORDER BY started_at DESC LIMIT 1
|
||||||
|
) RETURNING id`,
|
||||||
|
[pupil.id, access.row.id])).rows[0];
|
||||||
|
if (!session) return fail(reply, 409, 'leerling heeft geen actieve sessie');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'easier') {
|
||||||
|
if (!LEVEL_SUBJECTS.has(subject)) return fail(reply, 400, 'ongeldig vakgebied');
|
||||||
|
let level = (await pool.query(
|
||||||
|
'SELECT * FROM pupil_levels WHERE pupil_id = $1 AND subject = $2',
|
||||||
|
[pupil.id, subject])).rows[0];
|
||||||
|
if (!level) {
|
||||||
|
level = (await pool.query(
|
||||||
|
'SELECT * FROM pupil_levels WHERE class_id = $1 AND subject = $2',
|
||||||
|
[access.row.id, subject])).rows[0];
|
||||||
|
}
|
||||||
|
if (!level) return fail(reply, 409, 'stel eerst een leerjaar voor dit vakgebied in');
|
||||||
|
const oldYearGroup = Number(level.year_group);
|
||||||
|
const oldLevel = Number(level.level);
|
||||||
|
const yearGroup = oldLevel > 1 ? oldYearGroup : Math.max(1, oldYearGroup - 1);
|
||||||
|
const easierLevel = oldLevel > 1 ? oldLevel - 1 : 1;
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO pupil_levels (pupil_id, subject, year_group, level)
|
||||||
|
VALUES ($1,$2,$3,$4)
|
||||||
|
ON CONFLICT (pupil_id, subject) WHERE pupil_id IS NOT NULL
|
||||||
|
DO UPDATE SET year_group = $3, level = $4, updated_at = now()`,
|
||||||
|
[pupil.id, subject, yearGroup, easierLevel]);
|
||||||
|
payload = { subject, yearGroup, level: easierLevel };
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`DELETE FROM teacher_pupil_actions
|
||||||
|
WHERE pupil_id = $1 AND created_at < now() - interval '30 days'`, [pupil.id]);
|
||||||
|
const action = (await pool.query(
|
||||||
|
`INSERT INTO teacher_pupil_actions
|
||||||
|
(class_id, pupil_id, teacher_id, kind, message, payload)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
|
||||||
|
[access.row.id, pupil.id, req.user.id, kind, rawMessage || null,
|
||||||
|
JSON.stringify(payload)])).rows[0];
|
||||||
|
publishPupil(pupil.id);
|
||||||
|
publishClass(access.row.id);
|
||||||
|
return { ok: true, action: describeAction(action) };
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
async isPaused(pupilId, sessionId) {
|
||||||
|
if (!Number.isSafeInteger(Number(sessionId)) || Number(sessionId) <= 0) return false;
|
||||||
|
const row = (await pool.query(
|
||||||
|
`SELECT paused_at FROM live_learning_sessions
|
||||||
|
WHERE id = $1 AND pupil_id = $2 AND ended_at IS NULL`,
|
||||||
|
[sessionId, pupilId])).rows[0];
|
||||||
|
return !!row?.paused_at;
|
||||||
|
},
|
||||||
|
async recordProgress(pupilId, sessionId, attempts, correct) {
|
||||||
|
if (!Number.isSafeInteger(Number(sessionId)) || Number(sessionId) <= 0) return;
|
||||||
|
const row = (await pool.query(
|
||||||
|
`UPDATE live_learning_sessions
|
||||||
|
SET last_seen_at = now(), last_progress_at = now(),
|
||||||
|
progress_count = progress_count + 1,
|
||||||
|
attempts = attempts + $3, correct = correct + $4
|
||||||
|
WHERE id = $1 AND pupil_id = $2 AND ended_at IS NULL AND paused_at IS NULL
|
||||||
|
RETURNING class_id`,
|
||||||
|
[sessionId, pupilId, attempts, correct])).rows[0];
|
||||||
|
if (row) publishClass(row.class_id);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
// Persistente live leersessies + Server-Sent Events voor het klasdashboard.
|
|
||||||
// De database is de bron van waarheid. SSE verstuurt alleen een ververssignaal,
|
|
||||||
// zodat reconnects en gemiste mobiele netwerkberichten geen status verliezen.
|
|
||||||
|
|
||||||
const LIVE_WINDOW_MS = 45_000;
|
|
||||||
|
|
||||||
export default function registerLiveSessions(app, options) {
|
|
||||||
const {
|
|
||||||
pool, need, fail, progressRoles, sameSchool, teacherOnly,
|
|
||||||
classOwnedByTeacher, effectiveAssignment, assignmentWidgets,
|
|
||||||
} = options;
|
|
||||||
const classStreams = new Map();
|
|
||||||
|
|
||||||
const publishClass = (classId) => {
|
|
||||||
const streams = classStreams.get(String(classId));
|
|
||||||
if (!streams) return;
|
|
||||||
for (const stream of [...streams]) {
|
|
||||||
try { stream.write('event: sessions\ndata: {}\n\n'); }
|
|
||||||
catch { streams.delete(stream); }
|
|
||||||
}
|
|
||||||
if (!streams.size) classStreams.delete(String(classId));
|
|
||||||
};
|
|
||||||
|
|
||||||
const describe = (row) => {
|
|
||||||
const lastSeenAt = row.last_seen_at || row.started_at;
|
|
||||||
const live = !row.ended_at && Date.now() - new Date(lastSeenAt).getTime() <= LIVE_WINDOW_MS;
|
|
||||||
return {
|
|
||||||
id: Number(row.id),
|
|
||||||
pupilId: Number(row.pupil_id),
|
|
||||||
pupilName: row.display_name || row.username,
|
|
||||||
widgetId: row.widget_id,
|
|
||||||
widgetType: row.widget_type,
|
|
||||||
mode: row.mode,
|
|
||||||
startedAt: row.started_at,
|
|
||||||
lastSeenAt,
|
|
||||||
endedAt: row.ended_at || null,
|
|
||||||
progressCount: Number(row.progress_count || 0),
|
|
||||||
attempts: Number(row.attempts || 0),
|
|
||||||
correct: Number(row.correct || 0),
|
|
||||||
status: live ? 'live' : 'recent',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const accessibleClass = async (req, reply) => {
|
|
||||||
const row = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0];
|
|
||||||
if (!row) return { response: fail(reply, 404, 'klas onbekend') };
|
|
||||||
if (!sameSchool(req, row)) return { response: fail(reply, 403, 'geen rechten') };
|
|
||||||
if (teacherOnly(req) && !(await classOwnedByTeacher(row.id, req.user.id)))
|
|
||||||
return { response: fail(reply, 403, 'geen rechten') };
|
|
||||||
return { row };
|
|
||||||
};
|
|
||||||
|
|
||||||
const snapshot = async (classId) => {
|
|
||||||
const rows = (await pool.query(
|
|
||||||
`SELECT ls.*, u.display_name, u.username
|
|
||||||
FROM live_learning_sessions ls JOIN users u ON u.id = ls.pupil_id
|
|
||||||
WHERE ls.class_id = $1
|
|
||||||
AND COALESCE(ls.last_seen_at, ls.started_at) >= now() - interval '24 hours'
|
|
||||||
ORDER BY
|
|
||||||
CASE WHEN ls.ended_at IS NULL AND ls.last_seen_at >= now() - interval '45 seconds' THEN 0 ELSE 1 END,
|
|
||||||
COALESCE(ls.last_seen_at, ls.started_at) DESC
|
|
||||||
LIMIT 80`, [classId])).rows;
|
|
||||||
const sessions = rows.map(describe);
|
|
||||||
return {
|
|
||||||
sessions,
|
|
||||||
liveCount: sessions.filter((session) => session.status === 'live').length,
|
|
||||||
serverTime: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
app.post('/my/live-session/start', async (req, reply) => {
|
|
||||||
need(req, reply);
|
|
||||||
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
|
|
||||||
if (!req.user.class_id) return fail(reply, 400, 'leerling heeft geen klas');
|
|
||||||
const { widgetId, widgetType, mode } = req.body ?? {};
|
|
||||||
if (!widgetId || typeof widgetId !== 'string' || widgetId.length > 100)
|
|
||||||
return fail(reply, 400, 'ongeldige widgetId');
|
|
||||||
if (!widgetType || typeof widgetType !== 'string' || widgetType.length > 40)
|
|
||||||
return fail(reply, 400, 'ongeldige widgetType');
|
|
||||||
if (mode !== 'werken' && mode !== 'kijken') return fail(reply, 400, 'ongeldige modus');
|
|
||||||
|
|
||||||
const assignment = await effectiveAssignment(req.user);
|
|
||||||
if (!assignment) return fail(reply, 400, 'geen toewijzing');
|
|
||||||
const steps = Array.isArray(assignment.sequence) ? assignment.sequence : [];
|
|
||||||
let boardId = assignment.board_id;
|
|
||||||
let assignedWidgetId = assignment.widget_id;
|
|
||||||
let assignedMode = assignment.mode || 'werken';
|
|
||||||
if (steps.length) {
|
|
||||||
const completed = new Set((await pool.query(
|
|
||||||
'SELECT step_id FROM assignment_step_progress WHERE assignment_id = $1 AND pupil_id = $2',
|
|
||||||
[assignment.id, req.user.id])).rows.map((row) => row.step_id));
|
|
||||||
const current = steps.find((step) => !completed.has(step.id));
|
|
||||||
if (!current) return fail(reply, 409, 'opdrachtenreeks is al afgerond');
|
|
||||||
boardId = current.boardId;
|
|
||||||
assignedWidgetId = current.widgetId;
|
|
||||||
assignedMode = current.mode;
|
|
||||||
}
|
|
||||||
if (assignedWidgetId && assignedWidgetId !== widgetId)
|
|
||||||
return fail(reply, 409, 'deze widget is niet de huidige opdracht');
|
|
||||||
if (assignedMode !== mode) return fail(reply, 409, 'de opdrachtmodus is gewijzigd');
|
|
||||||
|
|
||||||
const widgets = await assignmentWidgets(assignment, boardId, assignedWidgetId);
|
|
||||||
const known = widgets.some((widget) =>
|
|
||||||
widget.id === widgetType && (widget.wid ? widget.wid === widgetId : true));
|
|
||||||
if (!known) return fail(reply, 409, 'widget staat niet meer in de toewijzing');
|
|
||||||
|
|
||||||
await pool.query(
|
|
||||||
`UPDATE live_learning_sessions
|
|
||||||
SET ended_at = now(), last_seen_at = now()
|
|
||||||
WHERE pupil_id = $1 AND ended_at IS NULL`, [req.user.id]);
|
|
||||||
await pool.query(
|
|
||||||
`DELETE FROM live_learning_sessions
|
|
||||||
WHERE pupil_id = $1 AND started_at < now() - interval '30 days'`, [req.user.id]);
|
|
||||||
const row = (await pool.query(
|
|
||||||
`INSERT INTO live_learning_sessions
|
|
||||||
(pupil_id, teacher_id, class_id, assignment_id, board_id, widget_id, widget_type, mode)
|
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
|
|
||||||
[req.user.id, assignment.teacher_id, req.user.class_id, assignment.id,
|
|
||||||
boardId, widgetId, widgetType, mode])).rows[0];
|
|
||||||
publishClass(req.user.class_id);
|
|
||||||
return { session: describe({ ...row, display_name: req.user.display_name, username: req.user.username }) };
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/my/live-session/:id/heartbeat', async (req, reply) => {
|
|
||||||
need(req, reply);
|
|
||||||
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
|
|
||||||
if (!/^\d+$/.test(String(req.params.id))) return fail(reply, 400, 'ongeldige sessie');
|
|
||||||
const row = (await pool.query(
|
|
||||||
`UPDATE live_learning_sessions SET last_seen_at = now()
|
|
||||||
WHERE id = $1 AND pupil_id = $2 AND ended_at IS NULL RETURNING id`,
|
|
||||||
[req.params.id, req.user.id])).rows[0];
|
|
||||||
if (!row) return fail(reply, 404, 'sessie niet actief');
|
|
||||||
return { ok: true };
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/my/live-session/:id/end', async (req, reply) => {
|
|
||||||
need(req, reply);
|
|
||||||
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
|
|
||||||
if (!/^\d+$/.test(String(req.params.id))) return fail(reply, 400, 'ongeldige sessie');
|
|
||||||
const row = (await pool.query(
|
|
||||||
`UPDATE live_learning_sessions SET ended_at = now(), last_seen_at = now()
|
|
||||||
WHERE id = $1 AND pupil_id = $2 AND ended_at IS NULL RETURNING class_id`,
|
|
||||||
[req.params.id, req.user.id])).rows[0];
|
|
||||||
if (row) publishClass(row.class_id);
|
|
||||||
return { ok: true };
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/progress/live/class/:id', async (req, reply) => {
|
|
||||||
need(req, reply, progressRoles);
|
|
||||||
const access = await accessibleClass(req, reply);
|
|
||||||
if (!access.row) return access.response;
|
|
||||||
return snapshot(access.row.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/progress/live/class/:id/stream', async (req, reply) => {
|
|
||||||
need(req, reply, progressRoles);
|
|
||||||
const access = await accessibleClass(req, reply);
|
|
||||||
if (!access.row) return access.response;
|
|
||||||
reply.hijack();
|
|
||||||
reply.raw.writeHead(200, {
|
|
||||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
||||||
'Cache-Control': 'no-cache, no-transform',
|
|
||||||
Connection: 'keep-alive',
|
|
||||||
'X-Accel-Buffering': 'no',
|
|
||||||
});
|
|
||||||
reply.raw.write('event: connected\ndata: {}\n\n');
|
|
||||||
const key = String(access.row.id);
|
|
||||||
if (!classStreams.has(key)) classStreams.set(key, new Set());
|
|
||||||
classStreams.get(key).add(reply.raw);
|
|
||||||
const heartbeat = setInterval(() => {
|
|
||||||
try { reply.raw.write(': heartbeat\n\n'); } catch { /* close ruimt op */ }
|
|
||||||
}, 15_000);
|
|
||||||
heartbeat.unref?.();
|
|
||||||
const cleanup = () => {
|
|
||||||
clearInterval(heartbeat);
|
|
||||||
const streams = classStreams.get(key);
|
|
||||||
if (streams) {
|
|
||||||
streams.delete(reply.raw);
|
|
||||||
if (!streams.size) classStreams.delete(key);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
req.raw.once('close', cleanup);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
async recordProgress(pupilId, sessionId, attempts, correct) {
|
|
||||||
if (!Number.isSafeInteger(Number(sessionId)) || Number(sessionId) <= 0) return;
|
|
||||||
const row = (await pool.query(
|
|
||||||
`UPDATE live_learning_sessions
|
|
||||||
SET last_seen_at = now(), progress_count = progress_count + 1,
|
|
||||||
attempts = attempts + $3, correct = correct + $4
|
|
||||||
WHERE id = $1 AND pupil_id = $2 AND ended_at IS NULL
|
|
||||||
RETURNING class_id`,
|
|
||||||
[sessionId, pupilId, attempts, correct])).rows[0];
|
|
||||||
if (row) publishClass(row.class_id);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
208
test/live-classroom.test.js
Normal file
208
test/live-classroom.test.js
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import cookie from '@fastify/cookie';
|
||||||
|
import rateLimit from '@fastify/rate-limit';
|
||||||
|
import api from '../src/api.js';
|
||||||
|
|
||||||
|
const cookies = { teach_session: 'x'.repeat(64) };
|
||||||
|
const teacher = {
|
||||||
|
id: 3, username: 'juf', display_name: 'Juf', role: 'teacher',
|
||||||
|
school_id: 2, class_id: null, data: {}, data_rev: 0,
|
||||||
|
};
|
||||||
|
const pupil = {
|
||||||
|
id: 9, username: 'lena', display_name: 'Lena', role: 'pupil',
|
||||||
|
school_id: 2, class_id: 50, data: {}, data_rev: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function makeApp(user, query) {
|
||||||
|
const app = Fastify({ trustProxy: 2 });
|
||||||
|
await app.register(cookie);
|
||||||
|
await app.register(rateLimit, { global: false });
|
||||||
|
app.decorate('pg', { query });
|
||||||
|
await app.register(api, { prefix: '/api' });
|
||||||
|
await app.ready();
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function auth(user, sql) {
|
||||||
|
if (sql.includes('FROM sessions s JOIN users u')) return { rows: [user] };
|
||||||
|
if (sql.includes('FROM user_roles')) return { rows: [] };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('leerkracht kan een actieve leerling direct pauzeren', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const query = async (sql, params = []) => {
|
||||||
|
calls.push({ sql, params });
|
||||||
|
const login = auth(teacher, sql);
|
||||||
|
if (login) return login;
|
||||||
|
if (sql.startsWith('SELECT * FROM classes')) return { rows: [{ id: 50, school_id: 2 }] };
|
||||||
|
if (sql.startsWith('SELECT 1 FROM class_teachers')) return { rows: [{ ok: 1 }] };
|
||||||
|
if (sql.startsWith('SELECT * FROM users WHERE id = $1 AND role = $2')) return { rows: [pupil] };
|
||||||
|
if (sql.includes('UPDATE live_learning_sessions SET paused_at = now()')) return { rows: [{ id: 101 }] };
|
||||||
|
if (sql.includes('INSERT INTO teacher_pupil_actions')) return { rows: [{
|
||||||
|
id: 301, class_id: 50, pupil_id: 9, teacher_id: 3, kind: 'pause',
|
||||||
|
message: null, payload: {}, created_at: new Date(),
|
||||||
|
}] };
|
||||||
|
return { rows: [] };
|
||||||
|
};
|
||||||
|
const app = await makeApp(teacher, query);
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/progress/live/class/50/pupil/9/action',
|
||||||
|
cookies,
|
||||||
|
payload: { kind: 'pause' },
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 200, response.body);
|
||||||
|
assert.equal(response.json().action.kind, 'pause');
|
||||||
|
assert.ok(calls.some((call) => call.sql.includes("last_seen_at >= now() - interval '45 seconds'")));
|
||||||
|
const insert = calls.find((call) => call.sql.includes('INSERT INTO teacher_pupil_actions'));
|
||||||
|
assert.deepEqual(insert.params.slice(0, 5), [50, 9, 3, 'pause', null]);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('directe actie wordt geweigerd voor een leerling buiten de gekozen klas', async () => {
|
||||||
|
let inserted = false;
|
||||||
|
const query = async (sql) => {
|
||||||
|
const login = auth(teacher, sql);
|
||||||
|
if (login) return login;
|
||||||
|
if (sql.startsWith('SELECT * FROM classes')) return { rows: [{ id: 50, school_id: 2 }] };
|
||||||
|
if (sql.startsWith('SELECT 1 FROM class_teachers')) return { rows: [{ ok: 1 }] };
|
||||||
|
if (sql.startsWith('SELECT * FROM users WHERE id = $1 AND role = $2'))
|
||||||
|
return { rows: [{ ...pupil, class_id: 51 }] };
|
||||||
|
if (sql.includes('INSERT INTO teacher_pupil_actions')) inserted = true;
|
||||||
|
return { rows: [] };
|
||||||
|
};
|
||||||
|
const app = await makeApp(teacher, query);
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/progress/live/class/50/pupil/9/action',
|
||||||
|
cookies,
|
||||||
|
payload: { kind: 'hint', message: 'Probeer het woord hardop te zeggen.' },
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 403);
|
||||||
|
assert.equal(inserted, false);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eenvoudiger maakt veilig een individuele niveau-override', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const query = async (sql, params = []) => {
|
||||||
|
calls.push({ sql, params });
|
||||||
|
const login = auth(teacher, sql);
|
||||||
|
if (login) return login;
|
||||||
|
if (sql.startsWith('SELECT * FROM classes')) return { rows: [{ id: 50, school_id: 2 }] };
|
||||||
|
if (sql.startsWith('SELECT 1 FROM class_teachers')) return { rows: [{ ok: 1 }] };
|
||||||
|
if (sql.startsWith('SELECT * FROM users WHERE id = $1 AND role = $2')) return { rows: [pupil] };
|
||||||
|
if (sql.startsWith('SELECT * FROM pupil_levels WHERE pupil_id')) return { rows: [] };
|
||||||
|
if (sql.startsWith('SELECT * FROM pupil_levels WHERE class_id'))
|
||||||
|
return { rows: [{ class_id: 50, subject: 'rekenen', year_group: 5, level: 2 }] };
|
||||||
|
if (sql.includes('INSERT INTO pupil_levels')) return { rows: [] };
|
||||||
|
if (sql.includes('INSERT INTO teacher_pupil_actions')) return { rows: [{
|
||||||
|
id: 302, kind: 'easier', message: null,
|
||||||
|
payload: { subject: 'rekenen', yearGroup: 5, level: 1 }, created_at: new Date(),
|
||||||
|
}] };
|
||||||
|
return { rows: [] };
|
||||||
|
};
|
||||||
|
const app = await makeApp(teacher, query);
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/progress/live/class/50/pupil/9/action',
|
||||||
|
cookies,
|
||||||
|
payload: { kind: 'easier', subject: 'rekenen' },
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 200, response.body);
|
||||||
|
const upsert = calls.find((call) => call.sql.includes('INSERT INTO pupil_levels'));
|
||||||
|
assert.deepEqual(upsert.params, [9, 'rekenen', 5, 1]);
|
||||||
|
assert.deepEqual(response.json().action.payload,
|
||||||
|
{ subject: 'rekenen', yearGroup: 5, level: 1 });
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gepauzeerde sessie accepteert geen nieuwe voortgang', async () => {
|
||||||
|
let progressInserted = false;
|
||||||
|
const assignment = {
|
||||||
|
id: 80, teacher_id: 3, board_id: 'board-live', sequence: [],
|
||||||
|
};
|
||||||
|
const query = async (sql) => {
|
||||||
|
const login = auth(pupil, sql);
|
||||||
|
if (login) return login;
|
||||||
|
if (sql === 'SELECT * FROM assignments WHERE pupil_id = $1') return { rows: [assignment] };
|
||||||
|
if (sql.includes('SELECT paused_at FROM live_learning_sessions'))
|
||||||
|
return { rows: [{ paused_at: new Date() }] };
|
||||||
|
if (sql.includes('INSERT INTO progress_events')) progressInserted = true;
|
||||||
|
return { rows: [] };
|
||||||
|
};
|
||||||
|
const app = await makeApp(pupil, query);
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST', url: '/api/my/progress', cookies,
|
||||||
|
payload: {
|
||||||
|
widgetId: 'math-1', widgetType: 'madd', attempts: 2, correct: 2,
|
||||||
|
liveSessionId: 101,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 409, response.body);
|
||||||
|
assert.equal(progressInserted, false);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leerling ontvangt duurzame acties en ziet de actuele pauzestatus', async () => {
|
||||||
|
const query = async (sql) => {
|
||||||
|
const login = auth(pupil, sql);
|
||||||
|
if (login) return login;
|
||||||
|
if (sql.includes('FROM teacher_pupil_actions')) return { rows: [{
|
||||||
|
id: 401, kind: 'hint', message: 'Splits het woord in klanken.',
|
||||||
|
payload: {}, created_at: new Date(),
|
||||||
|
}] };
|
||||||
|
if (sql.includes('SELECT id, paused_at FROM live_learning_sessions'))
|
||||||
|
return { rows: [{ id: 101, paused_at: new Date() }] };
|
||||||
|
return { rows: [] };
|
||||||
|
};
|
||||||
|
const app = await makeApp(pupil, query);
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET', url: '/api/my/live-actions', cookies,
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 200, response.body);
|
||||||
|
assert.equal(response.json().paused, true);
|
||||||
|
assert.equal(response.json().actions[0].message, 'Splits het woord in klanken.');
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('grafisch overzicht bevat raster, signalen, funnel, grafieken, tijdlijn en acties', async () => {
|
||||||
|
const [migration, server, dashboard, pupilActions, styles, i18n, admin, html, version, core] =
|
||||||
|
await Promise.all([
|
||||||
|
readFile('db/023_live_classroom_interaction.sql', 'utf8'),
|
||||||
|
readFile('src/live-classroom.js', 'utf8'),
|
||||||
|
readFile('public/js/live-classroom-dashboard.js', 'utf8'),
|
||||||
|
readFile('public/js/pupil-live-actions.js', 'utf8'),
|
||||||
|
readFile('public/css/live-classroom.css', 'utf8'),
|
||||||
|
readFile('public/js/live-classroom-i18n.js', 'utf8'),
|
||||||
|
readFile('public/js/admin.js', 'utf8'),
|
||||||
|
readFile('public/index.html', 'utf8'),
|
||||||
|
readFile('VERSION', 'utf8'),
|
||||||
|
readFile('public/js/core.js', 'utf8'),
|
||||||
|
]);
|
||||||
|
for (const value of ['teacher_pupil_actions', 'last_progress_at', 'paused_at'])
|
||||||
|
assert.ok(migration.includes(value), value);
|
||||||
|
assert.match(server, /HELP_IDLE_MS/);
|
||||||
|
assert.match(server, /correct \/ attempts < 0\.6/);
|
||||||
|
assert.match(server, /pupilStreams/);
|
||||||
|
assert.match(server, /current_date - interval '6 days'/);
|
||||||
|
assert.match(server, /ACTION_KINDS/);
|
||||||
|
for (const value of ['live-class-grid', 'renderLiveFunnel', 'renderLiveTrend',
|
||||||
|
'renderLiveTimeline', 'amActionEasier', 'amActionPause']) {
|
||||||
|
assert.ok(dashboard.includes(value), value);
|
||||||
|
}
|
||||||
|
assert.match(pupilActions, /new EventSource\("\/api\/my\/live-actions\/stream"\)/);
|
||||||
|
assert.match(pupilActions, /\/ack/);
|
||||||
|
assert.match(styles, /pupil-session-paused/);
|
||||||
|
assert.match(styles, /live-graphics-grid/);
|
||||||
|
assert.match(admin, /pupil\/'\+pupilId\+'\/action/);
|
||||||
|
assert.ok(html.indexOf('live-classroom-i18n.js') < html.indexOf('pupil-live-actions.js'));
|
||||||
|
assert.ok(html.includes('css/live-classroom.css'));
|
||||||
|
for (const key of ['amFunnelTitle', 'amTimelineTitle', 'amActionHint', 'pupilPausedTitle'])
|
||||||
|
assert.equal((i18n.match(new RegExp(key + ':', 'g')) || []).length, 2, key);
|
||||||
|
assert.ok(core.includes('const VERSION = "' + version.trim() + '"'));
|
||||||
|
});
|
||||||
61
test/live-insights.test.js
Normal file
61
test/live-insights.test.js
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import cookie from '@fastify/cookie';
|
||||||
|
import rateLimit from '@fastify/rate-limit';
|
||||||
|
import api from '../src/api.js';
|
||||||
|
|
||||||
|
const cookies = { teach_session: 'x'.repeat(64) };
|
||||||
|
const teacher = {
|
||||||
|
id: 3, username: 'juf', role: 'teacher', school_id: 2,
|
||||||
|
class_id: null, data: {}, data_rev: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
test('live inzicht berekent fout-, stiltesignalen en zevendaagse grafiekdata', async () => {
|
||||||
|
const now = new Date();
|
||||||
|
const idleStart = new Date(now.getTime() - 4 * 60_000);
|
||||||
|
const date = new Date(); date.setHours(12, 0, 0, 0);
|
||||||
|
const day = date.toISOString().slice(0, 10);
|
||||||
|
const query = async (sql) => {
|
||||||
|
if (sql.includes('FROM sessions s JOIN users u')) return { rows: [teacher] };
|
||||||
|
if (sql.includes('FROM user_roles')) return { rows: [] };
|
||||||
|
if (sql.startsWith('SELECT * FROM classes')) return { rows: [{ id: 50, school_id: 2 }] };
|
||||||
|
if (sql.startsWith('SELECT 1 FROM class_teachers')) return { rows: [{ ok: 1 }] };
|
||||||
|
if (sql.includes('FROM live_learning_sessions ls')) return { rows: [
|
||||||
|
{
|
||||||
|
id: 101, pupil_id: 9, display_name: 'Lena', widget_id: 'math-1',
|
||||||
|
widget_type: 'madd', mode: 'werken', started_at: idleStart,
|
||||||
|
last_seen_at: now, last_progress_at: now, attempts: 10, correct: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 102, pupil_id: 10, display_name: 'Sam', widget_id: 'letters-1',
|
||||||
|
widget_type: 'letters', mode: 'werken', started_at: idleStart,
|
||||||
|
last_seen_at: now, last_progress_at: null, attempts: 0, correct: 0,
|
||||||
|
},
|
||||||
|
] };
|
||||||
|
if (sql.includes('FROM progress_events pe')) return { rows: [{
|
||||||
|
pupil_id: 9, day, attempts: 10, correct: 7, updates: 2,
|
||||||
|
}] };
|
||||||
|
if (sql.includes('FROM assignment_step_progress asp')) return { rows: [{
|
||||||
|
pupil_id: 9, day, completed: 1,
|
||||||
|
}] };
|
||||||
|
return { rows: [] };
|
||||||
|
};
|
||||||
|
const app = Fastify({ trustProxy: 2 });
|
||||||
|
await app.register(cookie);
|
||||||
|
await app.register(rateLimit, { global: false });
|
||||||
|
app.decorate('pg', { query });
|
||||||
|
await app.register(api, { prefix: '/api' });
|
||||||
|
await app.ready();
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET', url: '/api/progress/live/class/50', cookies,
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 200, response.body);
|
||||||
|
const body = response.json();
|
||||||
|
assert.equal(body.helpCount, 2);
|
||||||
|
assert.deepEqual(body.sessions.map((session) => session.helpSignal), ['errors', 'idle']);
|
||||||
|
assert.equal(body.classTrend.at(-1).accuracy, 70);
|
||||||
|
assert.equal(body.classTrend.at(-1).completed, 2);
|
||||||
|
assert.equal(body.pupilTrends['9'].at(-1).updates, 2);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
@ -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 () => {
|
test('live dashboard gebruikt SSE, heartbeats, retentie en ongebufferde proxying', async () => {
|
||||||
const [migration, server, pupil, dashboard, admin, html, nginx, core, version] = await Promise.all([
|
const [migration, server, pupil, dashboard, admin, html, nginx, core, version] = await Promise.all([
|
||||||
readFile('db/022_live_learning_sessions.sql', 'utf8'),
|
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/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/js/admin.js', 'utf8'),
|
||||||
readFile('public/index.html', 'utf8'),
|
readFile('public/index.html', 'utf8'),
|
||||||
readFile('deploy/nginx.conf', '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'])
|
for (const column of ['started_at', 'last_seen_at', 'ended_at', 'progress_count'])
|
||||||
assert.ok(migration.includes(column), column);
|
assert.ok(migration.includes(column), column);
|
||||||
assert.match(server, /text\/event-stream/);
|
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(server, /interval '24 hours'/);
|
||||||
assert.match(pupil, /pointerdown[\s\S]*ensurePupilLiveSession/);
|
assert.match(pupil, /pointerdown[\s\S]*ensurePupilLiveSession/);
|
||||||
assert.match(pupil, /PUPIL_LIVE_HEARTBEAT_MS/);
|
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, /new EventSource/);
|
||||||
assert.match(dashboard, /setInterval[\s\S]*30000/);
|
assert.match(dashboard, /setInterval[\s\S]*30000/);
|
||||||
assert.match(admin, /progress\/live\/class/);
|
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/);
|
assert.match(nginx, /location \/api\/progress\/live\/[\s\S]*proxy_buffering off/);
|
||||||
for (const key of ['amLiveSessions', 'amLiveNew', 'amLiveRecent', 'amLiveConnected'])
|
for (const key of ['amLiveSessions', 'amLiveNew', 'amLiveRecent', 'amLiveConnected'])
|
||||||
assert.equal((core.match(new RegExp(key + ':', 'g')) || []).length, 2, key);
|
assert.equal((core.match(new RegExp(key + ':', 'g')) || []).length, 2, key);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue