From e75040279942dcb2248fa99f912277b3e7f766df Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 20 Jul 2026 22:43:41 +0200 Subject: [PATCH] Uebersicht: Top Performers heute + neue Abzeichen heute; Wochengrafik-Labels; Spielzeit sortierbar - results.php view=today: heutige finale Laeufe (topToday) + vorher/nachher-Progress je Schueler:in x Sim (aus student_results rekonstruiert) fuer Badge-Erkennung - teacher.html: Sektion "Top Performers heute" (heutige Wertung, Sterne + Erfolg) ersetzt Allzeit-Top; darunter "Neue Abzeichen heute" (badges.js, jetzt erfuellt aber vor heute nicht) - Wochen-Saeulengrafik: Sim-Titel klein in die Balkenabschnitte, hoehere Grafik - Modul-Tabelle: Spielzeit-Spalte sortierbar (Quoten-Spalten schon sortierbar) Co-Authored-By: Claude Opus 4.8 --- App/php/api/results.php | 66 ++++++++++++++++++++++++ App/teacher.html | 108 +++++++++++++++++++++++++++++----------- 2 files changed, 146 insertions(+), 28 deletions(-) diff --git a/App/php/api/results.php b/App/php/api/results.php index 074a176..9f20b65 100644 --- a/App/php/api/results.php +++ b/App/php/api/results.php @@ -246,6 +246,72 @@ if ($view === 'matrix') { ]); } +// ============================================================================= +// VIEW: TODAY → Top-Leistungen von HEUTE + heute neu vergebene Abzeichen +// ============================================================================= +// Liefert die heutigen finalen Läufe (topToday) sowie pro Schüler:in×Sim, die +// heute aktiv war, den Progress-Stand VOR heute und JETZT (aus student_results +// rekonstruiert). Die Badge-Auswertung passiert clientseitig mit badges.js, +// damit die Abzeichen-Definition eine einzige Quelle bleibt. +if ($view === 'today') { + $rows = $db->fetchAll( + "SELECT sr.student_id, sr.module_id, sr.score, sr.detail_json, sr.completed_at, + s.display_name, s.username, s.emoji_avatar + FROM student_results sr + JOIN students s ON s.id = sr.student_id + WHERE sr.class_id = ? AND s.deleted_at IS NULL + ORDER BY sr.completed_at", + [$classId] + ); + $starsOf = function ($score, $dj) { + $d = json_decode($dj, true); + if (is_array($d)) { + if (isset($d['stars'])) return (int)$d['stars']; + if (isset($d['sterneGesamt'])) return (int)$d['sterneGesamt']; + } + return (int)round(((float)$score) / 20); + }; + $levelFromXp = function ($xp) { return $xp < 50 ? 1 : ($xp < 150 ? 2 : ($xp < 300 ? 3 : 4)); }; + $today = date('Y-m-d'); + + $agg = []; // "sid|sim" => Aggregat vorher/jetzt + $todayRuns = []; + foreach ($rows as $r) { + $sid = (int)$r['student_id']; + $sim = $r['module_id']; + $st = $starsOf($r['score'], $r['detail_json']); + $xp = $st * 10 + ($st >= 5 ? 5 : 0); + $isToday = substr($r['completed_at'], 0, 10) === $today; + $k = "$sid|$sim"; + if (!isset($agg[$k])) $agg[$k] = [ + 'sid'=>$sid, 'sim'=>$sim, 'name'=>$r['display_name'] ?: $r['username'], 'emoji'=>$r['emoji_avatar'], + 'pB'=>0,'bB'=>0,'xB'=>0, 'pN'=>0,'bN'=>0,'xN'=>0, 'today'=>false, + ]; + $agg[$k]['pN']++; $agg[$k]['bN'] = max($agg[$k]['bN'], $st); $agg[$k]['xN'] += $xp; + if ($isToday) { + $agg[$k]['today'] = true; + $todayRuns[] = ['studentId'=>$sid, 'displayName'=>$r['display_name'] ?: $r['username'], + 'emoji'=>$r['emoji_avatar'] ?: '🧑‍🎓', 'moduleId'=>$sim, + 'score'=>round((float)$r['score']), 'stars'=>$st, 'at'=>$r['completed_at']]; + } else { + $agg[$k]['pB']++; $agg[$k]['bB'] = max($agg[$k]['bB'], $st); $agg[$k]['xB'] += $xp; + } + } + usort($todayRuns, function ($a, $b) { return $b['score'] <=> $a['score']; }); + + $progress = []; + foreach ($agg as $a) { + if (!$a['today']) continue; + $progress[] = [ + 'studentId'=>$a['sid'], 'displayName'=>$a['name'], 'emoji'=>$a['emoji'] ?: '🧑‍🎓', 'simId'=>$a['sim'], + 'before'=>['plays'=>$a['pB'], 'best_stars'=>$a['bB'], 'level'=>$levelFromXp($a['xB'])], + 'now' =>['plays'=>$a['pN'], 'best_stars'=>$a['bN'], 'level'=>$levelFromXp($a['xN'])], + ]; + } + + Response::ok(['date'=>$today, 'topToday'=>array_slice($todayRuns, 0, 6), 'progress'=>$progress]); +} + // ============================================================================= // VIEW: FLEISS (Lernzeit pro Schüler*in, optional Modul-Filter) // ============================================================================= diff --git a/App/teacher.html b/App/teacher.html index c8deb26..6ef5a56 100644 --- a/App/teacher.html +++ b/App/teacher.html @@ -2367,17 +2367,82 @@ async function ivOpenStudent(sid){ // === ÜBERSICHT === // Lehrer-Übersicht: Stat-Bar + aktive Aufgaben + Top-Performer + Aktivitäts-Sparkline + Modul-Cards. // Drei parallele API-Calls (matrix + activity-week + assignments) — alle existing. +// Avatar-Bild oder Emoji, gegebene Pixelgröße +function ivTodayAvatar(emoji, px) { + if (typeof emoji === 'string' && emoji.indexOf('avatar:') === 0) + return ''; + return ''+(emoji||'🧑‍🎓')+''; +} +// Sektion: Top Performers heute + heute neu vergebene Abzeichen +function ivBuildTodaySection(today, modById) { + var tt = (today && today.topToday) || []; + var prog = (today && today.progress) || []; + var starsHtml = (typeof ggsStarsHtml === 'function') ? ggsStarsHtml : function(n){ return '★'.repeat(Math.max(0,Math.min(5,n))); }; + + // Heute neu erreichte Abzeichen: Bedingung jetzt erfüllt, vor heute nicht. + var newBadges = []; + if (typeof ggsSimBadges === 'function') { + prog.forEach(function(p) { + ggsSimBadges(p.simId).forEach(function(bd) { + var okNow = false, okBefore = false; + try { okNow = bd.cond(p.now); okBefore = bd.cond(p.before); } catch (e) {} + if (okNow && !okBefore) newBadges.push({ p: p, b: bd }); + }); + }); + } + + var html = '
🏆 Top Performers heute
'; + if (!tt.length) { + html += '

Heute wurden noch keine Durchgänge abgeschlossen.

'; + } else { + var medals = ['🥇','🥈','🥉']; + html += '
'; + tt.slice(0, 3).forEach(function(r, i) { + var mod = modById[r.moduleId] || { icon:'📚', name:r.moduleId }; + html += '
' + + '
' + + ''+medals[i]+''+ivTodayAvatar(r.emoji,32) + + ''+esc(r.displayName)+'
' + + '
'+mod.icon+' '+esc(mod.name)+'
' + + '
'+starsHtml(r.stars) + + ' Erfolg '+r.score+'
' + + '' + + '
'; + }); + html += '
'; + } + + if (newBadges.length) { + html += '
Neue Abzeichen heute
'; + html += '
'; + newBadges.forEach(function(nb) { + var mod = modById[nb.p.simId] || { icon:'', name:nb.p.simId }; + html += '
' + + '' + + ivTodayAvatar(nb.p.emoji, 22) + + ''+esc(nb.p.displayName)+' · '+esc(nb.b.name) + + ' ('+esc(mod.name)+')
'; + }); + html += '
'; + } + html += '
'; + return html; +} + async function loadOverview() { var host = document.getElementById('tab-overview'); host.innerHTML = '

Lade…

'; - var [matrix, activity, assignments, students, cockpit, weeks] = await Promise.all([ + var [matrix, activity, assignments, students, cockpit, weeks, today] = await Promise.all([ api('results?class_id=' + currentClassId + '&view=matrix'), api('results?class_id=' + currentClassId + '&view=activity&range=week'), api('assignments?class_id=' + currentClassId), api('students?class_id=' + currentClassId), api('live?class_id=' + currentClassId + '&cockpit=1'), api('results?class_id=' + currentClassId + '&view=fleiss&range=weeks'), + api('results?class_id=' + currentClassId + '&view=today'), ]); if (matrix.error) { host.innerHTML = '

'+esc(matrix.error)+'

'; return; } if (activity.error) activity = {series:[], buckets:[]}; @@ -2385,6 +2450,7 @@ async function loadOverview() { if (students.error) students = []; if (!cockpit || cockpit.error) cockpit = {students:[], statusCounts:{}}; if (!weeks || weeks.error) weeks = {students:[], globalMaxBucketSec:0}; + if (!today || today.error) today = {topToday:[], progress:[]}; // === Top 3 Performer aus Matrix-Cells ableiten === var topRuns = []; @@ -2440,29 +2506,8 @@ async function loadOverview() { html += ''; } - // === Sektion C: Top-Performer (3 Karten) === - if (top.length > 0) { - html += '
🏆 Top-Performer
'; - html += '
'; - var medals = ['🥇','🥈','🥉']; - top.forEach(function(r, i) { - var mod = modById[r.moduleId] || {icon:'📚', name:r.moduleId}; - var avatar = (typeof r.emoji === 'string' && r.emoji.indexOf('avatar:') === 0) - ? '' - : ''+r.emoji+''; - html += '
' - + '
' - + ''+medals[i]+'' - + avatar - + ''+esc(r.displayName)+'' - + '
' - + '
'+mod.icon+' '+esc(mod.name)+'
' - + '
'+r.stars+'★ · '+r.plays+'×
' - + '' - + '
'; - }); - html += '
'; - } + // === Sektion C: Top Performers heute + heute neu vergebene Abzeichen === + html += ivBuildTodaySection(today, modById); // === Sektion D: Aktivitäts-Sparkline (kompakt, Woche) === if (activity.series && activity.series.length > 0) { @@ -2473,10 +2518,10 @@ async function loadOverview() { } if (maxStack < 1) maxStack = 1; - var W = 600, H = 90, padL = 22, padR = 6, padT = 8, padB = 18; + var W = 600, H = 160, padL = 22, padR = 6, padT = 10, padB = 20; var chartW = W - padL - padR, chartH = H - padT - padB; var step = chartW / activity.buckets.length; - var barW = step * 0.7; + var barW = step * 0.74; var svg = ''; activity.buckets.forEach(function(label, idx) { @@ -2488,8 +2533,14 @@ async function loadOverview() { var h = (v / maxStack) * chartH; yCursor -= h; svg += ''+s.icon+' '+esc(s.name)+': '+v+''; + // Sim-Titel klein in den Abschnitt schreiben, wenn hoch genug + if (h >= 12) { + var nm = s.name.length > 14 ? s.name.slice(0, 13) + '…' : s.name; + svg += ''+esc(nm)+''; + } }); - svg += ''+esc(label)+''; + svg += ''+esc(label)+''; }); svg += ''; html += '
📊 Diese Woche — Durchgänge nach Tag
'+svg+'
'; @@ -3526,6 +3577,7 @@ function renderModDetail() { var ka, kb; if (key === 'name') { ka = (a.displayName||'').toLowerCase(); kb = (b.displayName||'').toLowerCase(); } else if (key === 'last') { ka = a.last_played || ''; kb = b.last_played || ''; } + else if (key === 'dur') { ka = a.total_duration_ms || 0; kb = b.total_duration_ms || 0; } else if (key.indexOf('hl:') === 0) { var hk = key.substring(3); ka = (a.highlights && a.highlights[hk] !== undefined && a.highlights[hk] !== null) ? a.highlights[hk] : -Infinity; @@ -3598,7 +3650,7 @@ function renderModDetail() { html += ''+esc(h.label)+' '+arr('hl:'+h.key)+''; }); html += 'Zuletzt '+arr('last')+'' - + 'Spielzeit' + + 'Spielzeit '+arr('dur')+'' + '' + '';