From 52e9fafd92f8b37ed27f7b8192bd095f2a036989 Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 21 Jul 2026 11:43:12 +0200 Subject: [PATCH] Uebersicht: Wochengrafik mit Navigation, Triage-Cards immer, Modul-Datumsfilter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wochen-Saeulengrafik: - Pfeile Woche vor/zurueck (Kalenderwoche, view=activity&week_offset) - Datum unter jedem Wochentag, halbierte + condensed Schrift - Y-Skala links mit Anzahl, skaliert auf Max-Saeulenhoehe - rendert auch bei leerer Woche (mit Hinweis) fuer die Navigation Triage: alle 5 Cards immer sichtbar, leere mit "Aktuell niemand betroffen". Module — Stand der Klasse: - von/bis-Datumsfilter (Default bis=heute, von=Schuljahresbeginn 1. August) - neuer view=mod_stats speist die Cards datumsgefiltert - view=module auf student_results umgestellt (Datum+Score+JSON), from/to-Filter wirkt auf Tabelle, Highlights, Champions, classStats; Zeitraum im Modal-Header Co-Authored-By: Claude Opus 4.8 --- App/php/api/results.php | 266 ++++++++++++++++++++++++---------------- App/teacher.html | 218 ++++++++++++++++++++++---------- 2 files changed, 317 insertions(+), 167 deletions(-) diff --git a/App/php/api/results.php b/App/php/api/results.php index 9f20b65..a270b39 100644 --- a/App/php/api/results.php +++ b/App/php/api/results.php @@ -498,6 +498,7 @@ if ($view === 'activity') { $groupBy = $_GET['groupBy'] ?? 'module'; // Bucket-Funktion + Zeit-Window je Range + $weekOffset = null; $bucketDates = null; $weekLabel = null; switch ($range) { case 'day': $bucketFn = 'HOUR(submitted_at)'; @@ -505,10 +506,20 @@ if ($view === 'activity') { $buckets = []; for ($h = 0; $h < 24; $h++) $buckets[] = sprintf('%02d', $h); break; case 'week': - // Mo=1, So=7 (WEEKDAY: Mo=0 .. So=6) + // Kalenderwoche (Mo–So), optional um week_offset Wochen zurück. + $weekOffset = max(0, (int)($_GET['week_offset'] ?? 0)); + $monday = new DateTime('monday this week'); + if ($weekOffset > 0) $monday->modify('-' . $weekOffset . ' week'); + $sunday = (clone $monday)->modify('+6 day'); + $startD = $monday->format('Y-m-d'); + $endD = (clone $monday)->modify('+7 day')->format('Y-m-d'); $bucketFn = 'WEEKDAY(submitted_at)'; - $sinceSql = "submitted_at >= NOW() - INTERVAL 7 DAY"; + $sinceSql = "submitted_at >= '$startD 00:00:00' AND submitted_at < '$endD 00:00:00'"; $buckets = ['Mo','Di','Mi','Do','Fr','Sa','So']; + $bucketDates = []; + $dcur = clone $monday; + for ($i = 0; $i < 7; $i++) { $bucketDates[] = $dcur->format('d.m.'); $dcur->modify('+1 day'); } + $weekLabel = 'KW' . $monday->format('W') . ' · ' . $monday->format('d.m.') . '–' . $sunday->format('d.m.Y'); break; case 'month': $bucketFn = 'DAY(submitted_at)'; @@ -572,10 +583,13 @@ if ($view === 'activity') { return array_sum($b['values']) - array_sum($a['values']); // aktivste zuerst }); Response::ok([ - 'range' => $range, - 'groupBy' => 'student', - 'buckets' => $buckets, - 'series' => $seriesList, + 'range' => $range, + 'groupBy' => 'student', + 'buckets' => $buckets, + 'bucketDates' => $bucketDates, + 'weekOffset' => $weekOffset, + 'weekLabel' => $weekLabel, + 'series' => $seriesList, ]); } @@ -609,10 +623,13 @@ if ($view === 'activity') { return array_sum($s['values']) > 0; })); Response::ok([ - 'range' => $range, - 'groupBy' => 'module', - 'buckets' => $buckets, - 'series' => $seriesList, + 'range' => $range, + 'groupBy' => 'module', + 'buckets' => $buckets, + 'bucketDates' => $bucketDates, + 'weekOffset' => $weekOffset, + 'weekLabel' => $weekLabel, + 'series' => $seriesList, ]); } @@ -662,47 +679,88 @@ if ($view === 'module') { ); if (!$modInfo) Response::error('Modul nicht gefunden', 404); - // Schüler + Progress + Aggregat über assessments (Single-Query mit Sub-Selects) - $rows = $db->fetchAll( - "SELECT s.id, s.display_name, s.emoji_avatar, - pp.best_stars, pp.plays, pp.xp, pp.level, - (SELECT MAX(a.submitted_at) - FROM assessments a - JOIN student_sessions ss ON ss.id = a.session_id - WHERE ss.class_id = s.class_id AND ss.display_name = s.display_name - AND a.sim_id = ?) AS last_played, - (SELECT COALESCE(SUM(a.duration_ms), 0) - FROM assessments a - JOIN student_sessions ss ON ss.id = a.session_id - WHERE ss.class_id = s.class_id AND ss.display_name = s.display_name - AND a.sim_id = ?) AS total_duration_ms - FROM students s - LEFT JOIN player_progress pp ON pp.student_id = s.id AND pp.sim_id = ? - WHERE s.class_id = ? AND s.deleted_at IS NULL - ORDER BY pp.best_stars DESC, pp.plays DESC, s.display_name", - [$moduleId, $moduleId, $moduleId, $classId] - ); + // Optionaler Datumsfilter (Schuljahr-Default liefert der Client). + $from = $_GET['from'] ?? null; + $to = $_GET['to'] ?? null; + $dateSql = ''; $dateParams = []; + if ($from && preg_match('/^\d{4}-\d{2}-\d{2}$/', (string)$from) + && $to && preg_match('/^\d{4}-\d{2}-\d{2}$/', (string)$to)) { + $dateSql = ' AND sr.completed_at >= ? AND sr.completed_at < ? '; + $dateParams = [$from . ' 00:00:00', date('Y-m-d', strtotime($to . ' +1 day')) . ' 00:00:00']; + } else { $from = null; $to = null; } + $starsOf = function ($score, $j) { + if (is_array($j)) { + if (isset($j['stars'])) return (int)$j['stars']; + if (isset($j['sterneGesamt'])) return (int)$j['sterneGesamt']; + } + return (int)round(((float)$score) / 20); + }; + $highlights = $MODULE_HIGHLIGHTS[$moduleId] ?? []; + + // Finale Durchgänge im Zeitraum (student_results: Datum + Score + JSON in einer Tabelle) + $srows = $db->fetchAll( + "SELECT sr.student_id, sr.score, sr.detail_json, sr.duration_sec, sr.completed_at + FROM student_results sr + WHERE sr.class_id = ? AND sr.module_id = ? $dateSql + ORDER BY sr.completed_at", + array_merge([$classId, $moduleId], $dateParams) + ); + $aggS = []; // student_id => [plays,best,last,dur,hl=>[]] + foreach ($srows as $r) { + $sid = (int)$r['student_id']; + $j = json_decode($r['detail_json'], true); + $st = $starsOf($r['score'], $j); + if (!isset($aggS[$sid])) $aggS[$sid] = ['plays'=>0,'best'=>0,'last'=>null,'dur'=>0,'hl'=>[]]; + $aggS[$sid]['plays']++; + $aggS[$sid]['best'] = max($aggS[$sid]['best'], $st); + if ($aggS[$sid]['last'] === null || $r['completed_at'] > $aggS[$sid]['last']) $aggS[$sid]['last'] = $r['completed_at']; + $aggS[$sid]['dur'] += (int)$r['duration_sec'] * 1000; + if ($highlights && is_array($j)) { + foreach ($highlights as $h) { + if (isset($h['num'])) { + $nu = _hl_get($j, $h['num']); $de = _hl_get($j, $h['den']); + $val = ($nu !== null && $de !== null && (float)$de != 0.0) ? round((float)$nu / (float)$de * 100) : null; + } else { + $val = _hl_get($j, $h['key']); + } + if ($val === null) continue; + $val = is_numeric($val) ? (float)$val : $val; + if (!isset($aggS[$sid]['hl'][$h['key']])) $aggS[$sid]['hl'][$h['key']] = $val; + else $aggS[$sid]['hl'][$h['key']] = ($h['agg'] === 'min') + ? min($aggS[$sid]['hl'][$h['key']], $val) : max($aggS[$sid]['hl'][$h['key']], $val); + } + } + } + + // Basis: komplette Klassenliste (auch ohne Läufe im Zeitraum) + $base = $db->fetchAll( + "SELECT id, display_name, emoji_avatar FROM students WHERE class_id = ? AND deleted_at IS NULL ORDER BY display_name", + [$classId] + ); $students = []; $sumPlays = 0; $sumDuration = 0; $sumStars = 0; $countWithStars = 0; - foreach ($rows as $r) { - $stars = (int)($r['best_stars'] ?? 0); - $plays = (int)($r['plays'] ?? 0); + foreach ($base as $b) { + $sid = (int)$b['id']; + $a = $aggS[$sid] ?? ['plays'=>0,'best'=>0,'last'=>null,'dur'=>0,'hl'=>[]]; $students[] = [ - 'id' => (int)$r['id'], - 'displayName' => $r['display_name'], - 'emoji' => $r['emoji_avatar'] ?: '🧑‍🎓', - 'best_stars' => $stars, - 'plays' => $plays, - 'xp' => (int)($r['xp'] ?? 0), - 'level' => (int)($r['level'] ?? 1), - 'last_played' => $r['last_played'], - 'total_duration_ms' => (int)($r['total_duration_ms'] ?? 0), + 'id' => $sid, + 'displayName' => $b['display_name'], + 'emoji' => $b['emoji_avatar'] ?: '🧑‍🎓', + 'best_stars' => (int)$a['best'], + 'plays' => (int)$a['plays'], + 'last_played' => $a['last'], + 'total_duration_ms' => (int)$a['dur'], + 'highlights' => $a['hl'] ? $a['hl'] : new stdClass(), ]; - $sumPlays += $plays; - $sumDuration += (int)($r['total_duration_ms'] ?? 0); - if ($plays > 0) { $sumStars += $stars; $countWithStars++; } + $sumPlays += $a['plays']; $sumDuration += $a['dur']; + if ($a['plays'] > 0) { $sumStars += $a['best']; $countWithStars++; } } + usort($students, function ($x, $y) { + if ($y['best_stars'] !== $x['best_stars']) return $y['best_stars'] - $x['best_stars']; + if ($y['plays'] !== $x['plays']) return $y['plays'] - $x['plays']; + return strcmp($x['displayName'] ?? '', $y['displayName'] ?? ''); + }); $classStats = [ 'totalPlays' => $sumPlays, @@ -712,76 +770,76 @@ if ($view === 'module') { 'studentsTotal' => count($students), ]; - // === Modul-Highlights aus assessments.results aggregieren === - $highlights = $MODULE_HIGHLIGHTS[$moduleId] ?? []; - if ($highlights) { - // Alle Assessments dieser Klasse für dieses Modul + zugeordneter Schueler - $rows = $db->fetchAll( - "SELECT s.id AS student_id, a.results - FROM students s - JOIN student_sessions ss ON ss.class_id = s.class_id AND ss.display_name = s.display_name - JOIN assessments a ON a.session_id = ss.id AND a.sim_id = ? - WHERE s.class_id = ? AND s.deleted_at IS NULL AND a.results IS NOT NULL", - [$moduleId, $classId] - ); - // Pro Student: Aggregat berechnen - $perStudent = []; // student_id => { key => bestValue } - foreach ($rows as $r) { - $sid = (int)$r['student_id']; - $j = json_decode($r['results'], true); - if (!is_array($j)) continue; - if (!isset($perStudent[$sid])) $perStudent[$sid] = []; - foreach ($highlights as $h) { - if (isset($h['num'])) { - // Berechnete Quote: num/den in Prozent (0..100) - $nu = _hl_get($j, $h['num']); - $de = _hl_get($j, $h['den']); - $val = ($nu !== null && $de !== null && (float)$de != 0.0) - ? round((float)$nu / (float)$de * 100) : null; - } else { - $val = _hl_get($j, $h['key']); - } - if ($val === null) continue; - $val = is_numeric($val) ? (float)$val : $val; - if (!isset($perStudent[$sid][$h['key']])) { - $perStudent[$sid][$h['key']] = $val; - } else { - $cur = $perStudent[$sid][$h['key']]; - $perStudent[$sid][$h['key']] = ($h['agg'] === 'min') - ? min($cur, $val) - : max($cur, $val); - } - } + // Klassen-Champion je Highlight (bester Wert aller Schüler:innen) + $champions = []; + foreach ($highlights as $h) { + $best = null; $bestSid = null; + foreach ($students as $st) { + $hv = is_array($st['highlights']) ? ($st['highlights'][$h['key']] ?? null) : null; + if ($hv === null) continue; + if ($best === null || ($h['agg'] === 'min' ? $hv < $best : $hv > $best)) { $best = $hv; $bestSid = $st['id']; } } - // Klassen-Champion pro Highlight (best of all students) - $champions = []; // key => student_id - foreach ($highlights as $h) { - $best = null; $bestSid = null; - foreach ($perStudent as $sid => $vals) { - if (!isset($vals[$h['key']])) continue; - $v = $vals[$h['key']]; - if ($best === null || ($h['agg'] === 'min' ? $v < $best : $v > $best)) { - $best = $v; $bestSid = $sid; - } - } - if ($bestSid !== null) $champions[$h['key']] = $bestSid; - } - // Highlights an students-Liste anreichern - foreach ($students as &$st) { - $st['highlights'] = $perStudent[$st['id']] ?? new stdClass(); - } - unset($st); + if ($bestSid !== null) $champions[$h['key']] = $bestSid; } Response::ok([ 'module' => $modInfo, 'classStats' => $classStats, 'highlights' => $highlights, - 'champions' => $champions ?? new stdClass(), + 'champions' => (object)$champions, 'students' => $students, + 'from' => $from, + 'to' => $to, ]); } +// ============================================================================= +// VIEW: MOD_STATS — pro Modul plays/avgStars/studentsActive im Zeitraum +// ============================================================================= +// Speist die Modul-Cards der Übersicht mit datumsgefilterten Werten. +if ($view === 'mod_stats') { + $from = $_GET['from'] ?? null; $to = $_GET['to'] ?? null; + $dateSql = ''; $dateParams = []; + if ($from && preg_match('/^\d{4}-\d{2}-\d{2}$/', (string)$from) + && $to && preg_match('/^\d{4}-\d{2}-\d{2}$/', (string)$to)) { + $dateSql = ' AND sr.completed_at >= ? AND sr.completed_at < ? '; + $dateParams = [$from . ' 00:00:00', date('Y-m-d', strtotime($to . ' +1 day')) . ' 00:00:00']; + } else { $from = null; $to = null; } + + $rows = $db->fetchAll( + "SELECT sr.module_id, sr.student_id, sr.score, sr.detail_json + FROM student_results sr + WHERE sr.class_id = ? $dateSql", + array_merge([$classId], $dateParams) + ); + $starsOf = function ($score, $dj) { + $j = json_decode($dj, true); + if (is_array($j)) { + if (isset($j['stars'])) return (int)$j['stars']; + if (isset($j['sterneGesamt'])) return (int)$j['sterneGesamt']; + } + return (int)round(((float)$score) / 20); + }; + $agg = []; + foreach ($rows as $r) { + $m = $r['module_id']; $sid = (int)$r['student_id']; $st = $starsOf($r['score'], $r['detail_json']); + if (!isset($agg[$m])) $agg[$m] = ['plays'=>0, 'students'=>[], 'best'=>[]]; + $agg[$m]['plays']++; + $agg[$m]['students'][$sid] = 1; + $agg[$m]['best'][$sid] = max($agg[$m]['best'][$sid] ?? 0, $st); + } + $out = []; + foreach ($agg as $m => $a) { + $best = array_values($a['best']); + $out[$m] = [ + 'plays' => $a['plays'], + 'studentsActive' => count($a['students']), + 'avgStars' => $best ? round(array_sum($best) / count($best), 1) : null, + ]; + } + Response::ok(['from'=>$from, 'to'=>$to, 'stats'=>(object)$out]); +} + // ============================================================================= // VIEW 4: REFLECTIONS — alle Reflexions-Antworten der Klasse, alle Sims // ============================================================================= diff --git a/App/teacher.html b/App/teacher.html index 6ef5a56..adb33d9 100644 --- a/App/teacher.html +++ b/App/teacher.html @@ -2112,6 +2112,7 @@ var IV_CSS = '' + '.iv-ic svg{width:19px;height:19px;stroke:currentColor;stroke-width:1.9;fill:none;stroke-linecap:round;stroke-linejoin:round}' + '.iv-ct{flex:1}.iv-ct h4{margin:0;font-size:.9rem;font-weight:750;color:var(--iva)}.iv-ct p{margin:0;font-size:.72rem;color:var(--ivmut)}' + '.iv-cn{font-size:1.05rem;font-weight:750;color:var(--iva)}' ++ '.iv-cempty{font-size:.72rem;color:var(--ivmut);font-style:italic;padding:.7rem .2rem .3rem}' + '.iv-cols{display:grid;grid-template-columns:72px 1fr 28px 50px 46px 14px;gap:9px;padding:0 8px 3px;font-size:.52rem;font-weight:700;text-transform:uppercase;letter-spacing:.03em;color:var(--ivmut);border-bottom:1px solid var(--ivline)}' + '.iv-cols span:nth-child(2){justify-self:end}.iv-cols span:nth-child(3),.iv-cols span:nth-child(4){justify-self:center}.iv-cols span:nth-child(5){justify-self:end}' + '.iv-prow{display:grid;grid-template-columns:72px 1fr 28px 50px 46px 14px;gap:9px;align-items:center;width:100%;text-align:left;cursor:pointer;background:none;border:0;border-radius:8px;padding:7px 8px;font:inherit;color:inherit}' @@ -2254,13 +2255,17 @@ function ivBuildOverview(cockpit, weeks, studentsCount, matrix){ var need=groups.support.length+groups.more.length+groups.work.length+groups.good.length+groups.idle.length; h+='
Empfohlene Aktivität für dich.'+need+' von '+studentsCount+'
'; ['support','more','work','good','idle'].forEach(function(key){ - var rows=groups[key]; if(!rows.length) return; + var rows=groups[key]; var m=IV_META[key]; h+='
'+IV_ICON[key]+'

'+m[0]+'

'+m[1]+'

'+rows.length+'
'; - h+='
ErfolgAktivVerlaufZuletzt
'; - rows.forEach(function(r){ - h+=''; - }); + if(rows.length){ + h+='
ErfolgAktivVerlaufZuletzt
'; + rows.forEach(function(r){ + h+=''; + }); + } else { + h+='
Aktuell niemand betroffen.
'; + } h+='
'; }); h+='
'; @@ -2367,6 +2372,133 @@ 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. +// === Schuljahr-Datumsfilter für die Modul-Sektion === +var _modFilter = { from: null, to: null }; +function ggsTodayStr() { + var d = new Date(); + return d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0'); +} +function ggsSchoolYearStart() { + var d = new Date(); + // Schuljahr beginnt 1. August: ab August das laufende Jahr, davor das Vorjahr. + var y = (d.getMonth() >= 7) ? d.getFullYear() : d.getFullYear() - 1; + return y + '-08-01'; +} + +// === Wochen-Säulengrafik mit Navigation (prev/next Woche) === +var _weekOffset = 0; +function weekChartCard(a) { + var series = (a && a.series) || []; + var buckets = (a && a.buckets) || ['Mo','Di','Mi','Do','Fr','Sa','So']; + var dates = (a && a.bucketDates) || []; + var offset = (a && a.weekOffset != null) ? a.weekOffset : _weekOffset; + _weekOffset = offset; + var label = (a && a.weekLabel) || 'Diese Woche'; + + var maxStack = 0; + for (var i = 0; i < buckets.length; i++) { + var sum = series.reduce(function(acc, s){ return acc + (s.values[i]||0); }, 0); + if (sum > maxStack) maxStack = sum; + } + var hasData = maxStack > 0; + function niceStep(m){ if(m<=5)return 1; if(m<=10)return 2; if(m<=20)return 5; if(m<=50)return 10; if(m<=100)return 20; return Math.ceil(m/250)*50; } + var step = niceStep(maxStack || 1); + var topTick = Math.max(step, Math.ceil((maxStack||1)/step) * step); + + var W = 600, H = 172, padL = 24, padR = 8, padT = 10, padB = 28; + var chartW = W - padL - padR, chartH = H - padT - padB; + var stepX = chartW / buckets.length, barW = stepX * 0.74; + + var svg = ''; + // Y-Skala (Anzahl) links + Hilfslinien + for (var t = 0; t <= topTick; t += step) { + var gy = padT + chartH - (t / topTick) * chartH; + svg += ''; + svg += ''+t+''; + } + // Säulen (gestapelt nach Modul) + buckets.forEach(function(lbl, idx) { + var x = padL + idx * stepX + (stepX - barW) / 2; + var yCursor = padT + chartH; + series.forEach(function(s) { + var v = s.values[idx] || 0; if (!v) return; + var h = (v / topTick) * chartH; yCursor -= h; + svg += ''+esc(s.name)+': '+v+''; + if (h >= 9) { + var nm = s.name.length > 15 ? s.name.slice(0, 14) + '…' : s.name; + svg += ''+esc(nm)+''; + } + }); + // Wochentag + Datum (halbe Schrift, condensed) + svg += ''+esc(lbl)+''; + if (dates[idx]) svg += ''+esc(dates[idx])+''; + }); + svg += ''; + + var canNext = offset > 0; + var head = '
' + + '📊 Durchgänge nach Wochentag' + + '' + + ''+esc(label)+'' + + '' + + '
'; + var body = hasData ? svg : '

Keine Durchgänge in dieser Woche.

'; + return '
' + head + body + '
'; +} +async function loadWeekChart(offset) { + offset = Math.max(0, offset); _weekOffset = offset; + var hostEl = document.getElementById('ov-weekchart'); if (!hostEl) return; + hostEl.style.opacity = '.5'; + var a = await api('results?class_id=' + currentClassId + '&view=activity&range=week&week_offset=' + offset); + if (!a || a.error) a = { series: [], buckets: ['Mo','Di','Mi','Do','Fr','Sa','So'], weekOffset: offset }; + hostEl.style.opacity = ''; + hostEl.innerHTML = weekChartCard(a); +} + +// === Modul-Sektion mit Zeitfilter === +var _ovModules = [], _ovStudentCount = 0; +function renderModulesSection(modStats) { + var stats = (modStats && modStats.stats) || {}; + var html = '
' + + 'Module — Stand der Klasse' + + 'von ' + + ' bis ' + + '' + + '' + + '
'; + html += '
'; + _ovModules.forEach(function(mod) { + var st = stats[mod.id] || { plays:0, studentsActive:0, avgStars:null }; + var avgStars = (st.avgStars != null) ? Number(st.avgStars).toFixed(1) : '–'; + var imgSrc = mod.card_image ? 'assets/img/' + mod.card_image : null; + html += '
' + + '
' + (imgSrc ? '' : ''+(mod.icon||'📚')+'') + '
' + + '
'+(mod.icon||'')+' '+esc(mod.name)+'
' + + '
⭐ '+avgStars+' · '+st.plays+'× · '+st.studentsActive+'/'+_ovStudentCount+'
' + + '
'; + }); + html += '
'; + return html; +} +async function applyModFilter() { + var f = document.getElementById('mf-from'), t = document.getElementById('mf-to'); + if (f && f.value) _modFilter.from = f.value; + if (t && t.value) _modFilter.to = t.value; + var host = document.getElementById('ov-modules'); if (!host) return; + host.style.opacity = '.5'; + var ms = await api('results?class_id=' + currentClassId + '&view=mod_stats&from=' + _modFilter.from + '&to=' + _modFilter.to); + if (!ms || ms.error) ms = { stats: {} }; + host.style.opacity = ''; + host.innerHTML = renderModulesSection(ms); +} +function resetModFilter() { + _modFilter.from = ggsSchoolYearStart(); + _modFilter.to = ggsTodayStr(); + applyModFilter(); +} + // Avatar-Bild oder Emoji, gegebene Pixelgröße function ivTodayAvatar(emoji, px) { if (typeof emoji === 'string' && emoji.indexOf('avatar:') === 0) @@ -2435,7 +2567,11 @@ async function loadOverview() { var host = document.getElementById('tab-overview'); host.innerHTML = '

Lade…

'; - var [matrix, activity, assignments, students, cockpit, weeks, today] = await Promise.all([ + // Modul-Datumsfilter: Default bis=heute, von=Schuljahresbeginn (1. August) + if (!_modFilter.from) _modFilter.from = ggsSchoolYearStart(); + if (!_modFilter.to) _modFilter.to = ggsTodayStr(); + + var [matrix, activity, assignments, students, cockpit, weeks, today, modStats] = await Promise.all([ api('results?class_id=' + currentClassId + '&view=matrix'), api('results?class_id=' + currentClassId + '&view=activity&range=week'), api('assignments?class_id=' + currentClassId), @@ -2443,6 +2579,7 @@ async function loadOverview() { api('live?class_id=' + currentClassId + '&cockpit=1'), api('results?class_id=' + currentClassId + '&view=fleiss&range=weeks'), api('results?class_id=' + currentClassId + '&view=today'), + api('results?class_id=' + currentClassId + '&view=mod_stats&from=' + _modFilter.from + '&to=' + _modFilter.to), ]); if (matrix.error) { host.innerHTML = '

'+esc(matrix.error)+'

'; return; } if (activity.error) activity = {series:[], buckets:[]}; @@ -2451,6 +2588,7 @@ async function loadOverview() { if (!cockpit || cockpit.error) cockpit = {students:[], statusCounts:{}}; if (!weeks || weeks.error) weeks = {students:[], globalMaxBucketSec:0}; if (!today || today.error) today = {topToday:[], progress:[]}; + if (!modStats || modStats.error) modStats = {stats:{}}; // === Top 3 Performer aus Matrix-Cells ableiten === var topRuns = []; @@ -2509,62 +2647,13 @@ async function loadOverview() { // === 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) { - var maxStack = 0; - for (var i = 0; i < activity.buckets.length; i++) { - var sum = activity.series.reduce(function(acc, s){ return acc + (s.values[i]||0); }, 0); - if (sum > maxStack) maxStack = sum; - } - if (maxStack < 1) maxStack = 1; + // === Sektion D: Wochen-Säulengrafik mit Navigation === + html += '
' + weekChartCard(activity) + '
'; - 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.74; - - var svg = ''; - activity.buckets.forEach(function(label, idx) { - var x = padL + idx * step + (step - barW)/2; - var yCursor = padT + chartH; - activity.series.forEach(function(s) { - var v = s.values[idx] || 0; - if (!v) return; - 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 += ''; - html += '
📊 Diese Woche — Durchgänge nach Tag
'+svg+'
'; - } - - // === Sektion E: Modul-Performance-Cards (kompakt, alle aktiven Module) === - html += '
Module — Stand der Klasse
'; - html += '
'; - matrix.modules.forEach(function(mod) { - var totalPlaysM = 0, totalStars = 0, withPlays = 0; - matrix.students.forEach(function(s) { - var c = matrix.cells[s.id]?.[mod.id]; - if (c && c.plays > 0) { totalPlaysM += c.plays; totalStars += c.stars; withPlays++; } - }); - var avgStars = withPlays ? (totalStars / withPlays).toFixed(1) : '–'; - var imgSrc = mod.card_image ? 'assets/img/' + mod.card_image : null; - html += '
' - + '
' + (imgSrc ? '' : ''+(mod.icon||'📚')+'') + '
' - + '
' - + '
'+(mod.icon||'')+' '+esc(mod.name)+'
' - + '
⭐ '+avgStars+' · '+totalPlaysM+'× · '+withPlays+'/'+matrix.students.length+'
' - + '
'; - }); - html += '
'; + // === Sektion E: Module — Stand der Klasse (mit Zeitfilter) === + _ovModules = matrix.modules; + _ovStudentCount = matrix.students.length; + html += '
' + renderModulesSection(modStats) + '
'; host.innerHTML = html; } @@ -3562,7 +3651,8 @@ function renderModuleCards() { async function openModuleDetail(moduleId) { showModal('modal-mod-detail'); document.getElementById('mod-detail-body').innerHTML = '

Lade…

'; - var d = await api('results?class_id=' + currentClassId + '&view=module&module_id=' + moduleId); + var dq = (_modFilter.from && _modFilter.to) ? ('&from=' + _modFilter.from + '&to=' + _modFilter.to) : ''; + var d = await api('results?class_id=' + currentClassId + '&view=module&module_id=' + moduleId + dq); if (d.error) { document.getElementById('mod-detail-body').innerHTML = '

'+esc(d.error)+'

'; return; } _modDetail = { moduleId: moduleId, data: d, sortKey: 'best_stars', sortDir: 'desc' }; renderModDetail(); @@ -3593,7 +3683,9 @@ function renderModDetail() { var html = '
'; html += ''+d.module.icon+''; html += '

'+esc(d.module.name)+'

'; - html += '
Klasse: '+d.classStats.totalPlays+' Durchgänge · '+totalH+'h Spielzeit · Ø '+(d.classStats.avgStars||'–')+'★ · '+d.classStats.studentsActive+'/'+d.classStats.studentsTotal+' aktiv
'; + function dmy(s){ if(!s) return ''; var p=s.split('-'); return p[2]+'.'+p[1]+'.'+p[0].slice(2); } + var zeit = (d.from && d.to) ? ' · '+dmy(d.from)+'–'+dmy(d.to)+'' : ''; + html += '
Klasse: '+d.classStats.totalPlays+' Durchgänge · '+totalH+'h Spielzeit · Ø '+(d.classStats.avgStars||'–')+'★ · '+d.classStats.studentsActive+'/'+d.classStats.studentsTotal+' aktiv'+zeit+'
'; var hl = d.highlights || []; var champs = d.champions || {};