All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 44s
371 lines
17 KiB
JavaScript
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","creatief"].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;
|
|
}
|