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