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 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 22:43:41 +02:00
parent 56d8a3217b
commit e750402799
2 changed files with 146 additions and 28 deletions
+66
View File
@@ -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)
// =============================================================================
+80 -28
View File
@@ -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 '<img src="assets/img/avatars/thumbs/avatar-'+encodeURIComponent(emoji.substring(7))+'.webp" alt="" '
+ 'style="width:'+px+'px;height:'+px+'px;border-radius:50%;object-fit:cover;vertical-align:middle" '
+ 'onerror="this.onerror=null;this.src=\'assets/img/avatars/thumbs/avatar-fuchs-explorer.webp\'">';
return '<span style="font-size:'+(px*0.05)+'rem">'+(emoji||'🧑‍🎓')+'</span>';
}
// 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 = '<div class="card" style="margin-top:1.4rem"><div class="card-h">🏆 Top Performers heute</div>';
if (!tt.length) {
html += '<p style="font-size:.74rem;color:#8a8a8a;margin:.2rem 0 .3rem">Heute wurden noch keine Durchgänge abgeschlossen.</p>';
} else {
var medals = ['🥇','🥈','🥉'];
html += '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:.6rem">';
tt.slice(0, 3).forEach(function(r, i) {
var mod = modById[r.moduleId] || { icon:'📚', name:r.moduleId };
html += '<div style="background:#f7f6f3;border-radius:10px;padding:.6rem .8rem;border-left:4px solid '+(i===0?'#e8c547':i===1?'#c0c0c0':'#cd7f32')+'">'
+ '<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.3rem">'
+ '<span style="font-size:1.5rem">'+medals[i]+'</span>'+ivTodayAvatar(r.emoji,32)
+ '<strong style="font-size:.85rem">'+esc(r.displayName)+'</strong></div>'
+ '<div style="font-size:.72rem;color:#4a4a4a">'+mod.icon+' '+esc(mod.name)+'</div>'
+ '<div style="margin-top:.2rem;color:#e0a838;font-size:.9rem;letter-spacing:1px">'+starsHtml(r.stars)
+ ' <span style="color:#8a8a8a;font-size:.66rem;letter-spacing:0">Erfolg '+r.score+'</span></div>'
+ '<button class="btn-sm" style="margin-top:.4rem;font-size:.62rem;padding:.2rem .5rem" onclick="openModuleDetail(\''+r.moduleId+'\')">Modul ansehen</button>'
+ '</div>';
});
html += '</div>';
}
if (newBadges.length) {
html += '<div style="font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:#6a6a6a;margin:1rem 0 .5rem">Neue Abzeichen heute</div>';
html += '<div style="display:flex;flex-wrap:wrap;gap:.5rem">';
newBadges.forEach(function(nb) {
var mod = modById[nb.p.simId] || { icon:'', name:nb.p.simId };
html += '<div style="display:flex;align-items:center;gap:.5rem;background:#f7f6f3;border-radius:8px;padding:.35rem .7rem .35rem .5rem">'
+ '<img src="'+nb.b.icon+'" alt="" style="width:34px;height:34px;object-fit:contain">'
+ ivTodayAvatar(nb.p.emoji, 22)
+ '<span style="font-size:.76rem"><strong>'+esc(nb.p.displayName)+'</strong> · '+esc(nb.b.name)
+ ' <span style="color:#8a8a8a">('+esc(mod.name)+')</span></span></div>';
});
html += '</div>';
}
html += '</div>';
return html;
}
async function loadOverview() {
var host = document.getElementById('tab-overview');
host.innerHTML = '<p style="font-size:.78rem;color:#8a8a8a;padding:1rem">Lade…</p>';
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 = '<p style="color:#c07a6b">'+esc(matrix.error)+'</p>'; 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 += '</div>';
}
// === Sektion C: Top-Performer (3 Karten) ===
if (top.length > 0) {
html += '<div class="card" style="margin-top:1.4rem"><div class="card-h">🏆 Top-Performer</div>';
html += '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:.6rem">';
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)
? '<img src="assets/img/avatars/thumbs/avatar-'+r.emoji.substring(7)+'.webp" alt="" style="width:32px;height:32px;border-radius:50%;object-fit:cover;vertical-align:middle">'
: '<span style="font-size:1.6rem">'+r.emoji+'</span>';
html += '<div style="background:#f7f6f3;border-radius:10px;padding:.6rem .8rem;border-left:4px solid '+(i===0?'#e8c547':i===1?'#c0c0c0':'#cd7f32')+'">'
+ '<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.3rem">'
+ '<span style="font-size:1.6rem">'+medals[i]+'</span>'
+ avatar
+ '<strong style="font-size:.85rem">'+esc(r.displayName)+'</strong>'
+ '</div>'
+ '<div style="font-size:.72rem;color:#4a4a4a">'+mod.icon+' '+esc(mod.name)+'</div>'
+ '<div style="font-size:.66rem;color:#8a8a8a;margin-top:.15rem">'+r.stars+'★ · '+r.plays+'×</div>'
+ '<button class="btn-sm" style="margin-top:.4rem;font-size:.62rem;padding:.2rem .5rem" onclick="openModuleDetail(\''+r.moduleId+'\')">Modul ansehen</button>'
+ '</div>';
});
html += '</div></div>';
}
// === 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 = '<svg viewBox="0 0 '+W+' '+H+'" style="width:100%;height:auto;display:block">';
activity.buckets.forEach(function(label, idx) {
@@ -2488,8 +2533,14 @@ async function loadOverview() {
var h = (v / maxStack) * chartH;
yCursor -= h;
svg += '<rect x="'+x+'" y="'+yCursor+'" width="'+barW+'" height="'+h+'" fill="'+s.color+'"><title>'+s.icon+' '+esc(s.name)+': '+v+'</title></rect>';
// 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 += '<text x="'+(x+barW/2)+'" y="'+(yCursor + h/2 + 2.6)+'" font-size="7.5" text-anchor="middle" '
+ 'fill="#fff" stroke="rgba(0,0,0,.4)" stroke-width=".45" paint-order="stroke" style="pointer-events:none">'+esc(nm)+'</text>';
}
});
svg += '<text x="'+(x+barW/2)+'" y="'+(H-3)+'" font-size="9" text-anchor="middle" fill="#4a4a4a">'+esc(label)+'</text>';
svg += '<text x="'+(x+barW/2)+'" y="'+(H-5)+'" font-size="9" text-anchor="middle" fill="#4a4a4a">'+esc(label)+'</text>';
});
svg += '</svg>';
html += '<div class="card"><div class="card-h">📊 Diese Woche — Durchgänge nach Tag</div>'+svg+'</div>';
@@ -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 += '<th onclick="sortModDetail(\'hl:'+h.key+'\')" title="'+esc(h.label)+(h.agg==='min'?' (weniger ist besser)':' (mehr ist besser)')+'">'+esc(h.label)+' '+arr('hl:'+h.key)+'</th>';
});
html += '<th onclick="sortModDetail(\'last\')">Zuletzt '+arr('last')+'</th>'
+ '<th>Spielzeit</th>'
+ '<th onclick="sortModDetail(\'dur\')">Spielzeit '+arr('dur')+'</th>'
+ '<th></th>'
+ '</tr></thead><tbody>';