teach/public/js/live-classroom-dashboard.js
Ramon 131ffc2f38
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 47s
feat: breid live klasinteractie grafisch uit
2026-07-24 13:40:18 +02:00

371 lines
17 KiB
JavaScript

/* 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;
}