[ ['key'=>'final_co2', 'label'=>'CO₂', 'agg'=>'min', 'unit'=>'ppm'], ['key'=>'final_temperature', 'label'=>'ΔTemp', 'agg'=>'min', 'unit'=>'°C'], ['key'=>'final_budget', 'label'=>'Budget', 'agg'=>'max', 'unit'=>'Mio'], ['key'=>'final_flooded_pct', 'label'=>'Überflutet','agg'=>'min', 'unit'=>'%'], ], 'kofferdetektiv' => [ ['key'=>'solve_quote', 'num'=>'cases_solved', 'den'=>'cases_total', 'label'=>'Gelöst-Quote', 'agg'=>'max', 'unit'=>'%'], ['key'=>'correct_first_try', 'label'=>'Erstversuch', 'agg'=>'max'], ['key'=>'wrong_guesses', 'label'=>'Fehlversuche', 'agg'=>'min'], ['key'=>'hints_used_avg', 'label'=>'Ø Hinweise', 'agg'=>'min'], ], 'weltkueche' => [ ['key'=>'dish_quote', 'num'=>'completedDishes', 'den'=>'totalDishes', 'label'=>'Gerichte-Quote', 'agg'=>'max', 'unit'=>'%'], ['key'=>'mistakesInDish', 'label'=>'Fehlklicks', 'agg'=>'min'], ], 'fluss' => [ ['key'=>'biodiv', 'label'=>'Biodiversität', 'agg'=>'max', 'unit'=>'%'], ['key'=>'economy', 'label'=>'Wirtschaft', 'agg'=>'max', 'unit'=>'%'], ['key'=>'flood', 'label'=>'Hochwasser', 'agg'=>'min', 'unit'=>'%'], ['key'=>'food', 'label'=>'Ernährung', 'agg'=>'max', 'unit'=>'%'], ], 'farmer' => [ ['key'=>'hit_rate', 'label'=>'Trefferquote', 'agg'=>'max', 'unit'=>'%'], ['key'=>'year', 'label'=>'Jahr', 'agg'=>'max'], ['key'=>'crops_used', 'label'=>'Sorten', 'agg'=>'max'], ], 'busfahrt' => [ ['key'=>'order_quote', 'num'=>'wonOrders', 'den'=>'totalOrders', 'label'=>'Aufträge', 'agg'=>'max', 'unit'=>'%'], ['key'=>'quiz_quote', 'num'=>'quizFirstTry', 'den'=>'totalOrders', 'label'=>'Quiz 1. Versuch','agg'=>'max', 'unit'=>'%'], ['key'=>'avgTapKm', 'label'=>'Ø Abweichung', 'agg'=>'min', 'unit'=>'km'], ], 'logistik' => [ ['key'=>'deliver_quote', 'num'=>'ordersDelivered', 'den'=>'totalOrders', 'label'=>'Geliefert', 'agg'=>'max', 'unit'=>'%'], ['key'=>'ordersLate', 'label'=>'Verspätet', 'agg'=>'min'], ['key'=>'finalBalance', 'label'=>'Bilanz', 'agg'=>'max', 'unit'=>'€'], ], 'energiemanager' => [ ['key'=>'block_quote', 'num'=>'blocksBalanced', 'den'=>'blocksTotal', 'label'=>'Blöcke ok', 'agg'=>'max', 'unit'=>'%'], ['key'=>'day_quote', 'num'=>'daysPlayed', 'den'=>'daysTotal', 'label'=>'Tage geschafft','agg'=>'max', 'unit'=>'%'], ['key'=>'totalGap', 'label'=>'Restdefizit', 'agg'=>'min'], ], 'eu-werkstatt' => [ ['key'=>'firsttry_quote', 'num'=>'firstTryHits', 'den'=>'stepsTotal', 'label'=>'Erste Wahl', 'agg'=>'max', 'unit'=>'%'], ['key'=>'totalAttempts', 'label'=>'Versuche', 'agg'=>'min'], ['key'=>'muelleimerCount','label'=>'Verworfen', 'agg'=>'min'], ], 'sonnensystem' => [ ['key'=>'task_quote', 'num'=>'completed', 'den'=>'total', 'label'=>'Gelöst-Quote', 'agg'=>'max', 'unit'=>'%'], ['key'=>'completed', 'label'=>'Aufgaben', 'agg'=>'max'], ], 'heli' => [ ['key'=>'planPct', 'label'=>'Planung', 'agg'=>'max', 'unit'=>'%'], ['key'=>'startScore', 'label'=>'Start', 'agg'=>'max'], ['key'=>'landingScore', 'label'=>'Landung', 'agg'=>'max'], ['key'=>'level', 'label'=>'Level', 'agg'=>'max'], ], 'tourismustal' => [ ['key'=>'goal_quote', 'num'=>'goalsDone', 'den'=>'goalsTotal', 'label'=>'Ziele', 'agg'=>'max', 'unit'=>'%'], ['key'=>'satisfaction', 'label'=>'Zufriedenheit', 'agg'=>'max', 'unit'=>'%'], ['key'=>'nature', 'label'=>'Naturwert', 'agg'=>'max', 'unit'=>'%'], ['key'=>'money', 'label'=>'Konto', 'agg'=>'max', 'unit'=>'€'], ], 'tourismusregion' => [ ['key'=>'score', 'label'=>'Gesamtscore', 'agg'=>'max', 'unit'=>'%'], ], 'fluggesellschaft' => [ ['key'=>'flight_order_quote', 'num'=>'wonOrders', 'den'=>'totalOrders', 'label'=>'Aufträge', 'agg'=>'max', 'unit'=>'%'], ['key'=>'flight_quiz_quote', 'num'=>'quizFirstTry', 'den'=>'totalOrders', 'label'=>'Quiz 1. Versuch','agg'=>'max', 'unit'=>'%'], ['key'=>'co2Kg', 'label'=>'CO₂', 'agg'=>'min', 'unit'=>'kg'], ['key'=>'earnings', 'label'=>'Gewinn', 'agg'=>'max', 'unit'=>'€'], ], ]; $MODULE_HIGHLIGHTS['klima-3d'] = $MODULE_HIGHLIGHTS['klima']; // Hilfsfunktion: holt einen Wert aus einem JSON-Pfad ("results.money" → $json['results']['money']). function _hl_get($json, $path) { $parts = explode('.', $path); $v = $json; foreach ($parts as $p) { if (!is_array($v) || !array_key_exists($p, $v)) return null; $v = $v[$p]; } return is_scalar($v) ? $v : null; } $classId = (int)($_GET['class_id'] ?? 0); if (!$classId) Response::error('class_id erforderlich'); // Klasse muss diesem Lehrer gehören $class = $db->fetchOne('SELECT id, name FROM classes WHERE id = ? AND teacher_id = ?', [$classId, $teacherId]); if (!$class) Response::error('Klasse nicht gefunden', 404); $view = $_GET['view'] ?? 'matrix'; // === Modul-Katalog (gemeinsam genutzt) === $modules = $db->fetchAll( "SELECT module_id AS id, title AS name, icon, card_image, sort_order, status FROM module_info WHERE status IN ('aktiv','beta') ORDER BY sort_order" ); // Konsistente Modul-Farben aus dem hash des module_id (deterministisch). $palette = ['#1f4b37','#4a7c4e','#e8c547','#b04a3e','#7099a0','#8b5a2b','#a37800','#5a8a8e','#c07a6b','#3d6671','#a3c167','#d8453a']; foreach ($modules as &$m) { $h = 0; for ($i = 0; $i < strlen($m['id']); $i++) $h = ($h * 31 + ord($m['id'][$i])) & 0xFFFFFFFF; $m['color'] = $palette[abs($h) % count($palette)]; } unset($m); // ============================================================================= // VIEW 1: MATRIX // ============================================================================= if ($view === 'matrix') { // Alle Schüler der Klasse + ihre player_progress-Einträge in EINEM Query. $rows = $db->fetchAll( "SELECT s.id AS student_id, s.username, s.display_name, s.emoji_avatar, pp.sim_id, pp.best_stars, pp.plays, pp.xp, pp.level FROM students s LEFT JOIN player_progress pp ON pp.student_id = s.id WHERE s.class_id = ? AND s.deleted_at IS NULL ORDER BY s.display_name, s.username", [$classId] ); $students = []; $cells = []; // {studentId: {moduleId: {stars, plays, xp, level, score, benchmarkPct}}} $totals = []; // {studentId: {score, plays, xp, benchmarkPct}} foreach ($rows as $r) { $sid = (int)$r['student_id']; if (!isset($students[$sid])) { $students[$sid] = [ 'id' => $sid, 'username' => $r['username'], 'displayName' => $r['display_name'] ?: $r['username'], 'emoji' => $r['emoji_avatar'] ?: '🧑‍🎓', ]; $cells[$sid] = []; $totals[$sid] = ['score' => 0.0, 'plays' => 0, 'xp' => 0]; } if ($r['sim_id']) { $stars = (int)$r['best_stars']; $plays = (int)$r['plays']; $xp = (int)$r['xp']; // score = stars × √plays (Qualität dominant, Übung logarithmisch). // Bleibt für Sort/Tooltip erhalten; benchmarkPct (siehe unten) ist // die didaktisch saubere Modul-Bewertung pro Submission. $score = $stars * sqrt(max(1, $plays)); $cells[$sid][$r['sim_id']] = [ 'stars' => $stars, 'plays' => $plays, 'xp' => $xp, 'level' => (int)$r['level'], 'score' => round($score, 2), ]; $totals[$sid]['score'] += $score; $totals[$sid]['plays'] += $plays; $totals[$sid]['xp'] += $xp; } } foreach ($totals as &$t) $t['score'] = round($t['score'], 1); unset($t); // Benchmark-Score pro Schüler:in × Modul — gleiche Engine wie im Live-Cockpit. // Pro Modul max. die 3 besten Sessions, Mittelwert; Gesamt = Mittel pro Schüler:in // über die gespielten Module. Cells/Totals werden non-destructiv ergänzt // — vorhandene UI (stars/plays/score) bleibt unverändert. require_once __DIR__ . '/../lib/Benchmark.php'; $benchRows = $db->fetchAll( "SELECT s.id AS student_id, a.sim_id, a.results, a.process_log FROM assessments a JOIN student_sessions ss ON ss.id = a.session_id JOIN students s ON s.class_id = ss.class_id AND s.display_name = ss.display_name WHERE a.class_id = ? AND s.deleted_at IS NULL ORDER BY s.id, a.sim_id, a.submitted_at DESC", [$classId] ); $scoresByStudentModule = []; foreach ($benchRows as $r) { $sid = (int)$r['student_id']; $mid = $r['sim_id']; $score = Benchmark::scoreSubmission($mid, $r['results'], $r['process_log'] ?? null); if ($score === null) continue; $k = "$sid|$mid"; if (!isset($scoresByStudentModule[$k])) $scoresByStudentModule[$k] = []; $scoresByStudentModule[$k][] = $score; } foreach ($scoresByStudentModule as $k => $list) { rsort($list); $top3 = array_slice($list, 0, 3); $pct = (int)round(array_sum($top3) / count($top3)); [$sid, $mid] = explode('|', $k, 2); $sid = (int)$sid; if (isset($cells[$sid][$mid])) { $cells[$sid][$mid]['benchmarkPct'] = $pct; } } // Pro Schüler:in Gesamt-Benchmark = Mittelwert über alle Modul-Benchmarks foreach ($cells as $sid => $cellMap) { $modPcts = []; foreach ($cellMap as $c) if (isset($c['benchmarkPct'])) $modPcts[] = $c['benchmarkPct']; if ($modPcts) $totals[$sid]['benchmarkPct'] = (int)round(array_sum($modPcts) / count($modPcts)); } Response::ok([ 'students' => array_values($students), 'modules' => $modules, 'cells' => $cells, 'totals' => $totals, ]); } // ============================================================================= // 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) // ============================================================================= // Eine Zeile pro Schüler*in (komplette Klassenliste, auch stille). // Wert: aktive Lernzeit in Sekunden im gewählten Zeitraum. // Optional gefiltert auf ein Modul; Default ALL (alle Module summiert). if ($view === 'fleiss') { $range = $_GET['range'] ?? 'week'; $moduleId = $_GET['module_id'] ?? 'ALL'; // Zeit-Window + Bucket-Funktion je Range — pro Bucket eine Säule. switch ($range) { case 'day': $sinceSql = "a.submitted_at >= NOW() - INTERVAL 1 DAY"; $bucketFn = 'HOUR(a.submitted_at)'; $buckets = []; for ($h = 0; $h < 24; $h++) $buckets[] = sprintf('%02d', $h); $idxMap = function($b) { return (int)$b; }; $label = 'Heute (24 Stunden)'; break; case 'week': // Mo=0, So=6 (WEEKDAY) $sinceSql = "a.submitted_at >= NOW() - INTERVAL 7 DAY"; $bucketFn = 'WEEKDAY(a.submitted_at)'; $buckets = ['Mo','Di','Mi','Do','Fr','Sa','So']; $idxMap = function($b) { return (int)$b; }; $label = 'Diese Woche (7 Tage)'; break; case 'month': $sinceSql = "a.submitted_at >= NOW() - INTERVAL 31 DAY"; $bucketFn = 'DAY(a.submitted_at)'; $buckets = []; for ($d = 1; $d <= 31; $d++) $buckets[] = (string)$d; $idxMap = function($b) { return (int)$b - 1; }; $label = 'Letzte 31 Tage'; break; case 'year': $sinceSql = "a.submitted_at >= NOW() - INTERVAL 12 MONTH"; $bucketFn = 'MONTH(a.submitted_at)'; $buckets = ['Jän','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez']; $idxMap = function($b) { return (int)$b - 1; }; $label = 'Letzte 12 Monate'; break; case 'weeks': // Letzte 10 ISO-Kalenderwochen — je Woche eine Säule (für den „Verlauf"). $sinceSql = "a.submitted_at >= NOW() - INTERVAL 10 WEEK"; $bucketFn = 'YEARWEEK(a.submitted_at, 3)'; $buckets = []; $bmap = []; for ($i = 9; $i >= 0; $i--) { $yw = (int)date('oW', strtotime("-$i week")); $buckets[] = 'KW' . date('W', strtotime("-$i week")); $bmap[$yw] = 9 - $i; } $idxMap = function($b) use ($bmap) { return $bmap[(int)$b] ?? -1; }; $label = 'Letzte 10 Wochen'; break; default: Response::error('range muss day|week|weeks|month|year sein'); } // Optional Modul-Filter $params = [$classId]; $modSql = ''; if ($moduleId !== 'ALL' && $moduleId !== '') { $modSql = ' AND a.sim_id = ?'; $params[] = $moduleId; } // 1) Pro (Schüler × Bucket × Modul) aggregieren — Lernzeit aus duration_ms. // Eine Säule pro Bucket im Frontend, gestapelt nach Modul. $rows = $db->fetchAll( "SELECT s.id AS student_id, $bucketFn AS bucket, a.sim_id, SUM(COALESCE(a.duration_ms, 0)) / 1000 AS sec, COUNT(*) AS sessions FROM assessments a JOIN student_sessions ss ON ss.id = a.session_id JOIN students s ON s.class_id = ss.class_id AND s.display_name = ss.display_name WHERE a.class_id = ? AND $sinceSql AND s.deleted_at IS NULL $modSql GROUP BY s.id, bucket, a.sim_id", $params ); // 2) Komplette Klassenliste (auch stille Schüler*innen) $allStudents = $db->fetchAll( 'SELECT id, display_name, username, emoji_avatar, avatar_slug FROM students WHERE class_id = ? AND deleted_at IS NULL ORDER BY COALESCE(display_name, username)', [$classId] ); // 3) Modul-Stammdaten (Icons, Namen, Farben aus modules-Liste oben) $moduleMap = []; foreach ($modules as $m) { $moduleMap[$m['id']] = $m; } // 4) Pro Schüler*in eine Struktur mit leeren Bucket-Säulen vorbereiten $byStudent = []; foreach ($allStudents as $s) { $sid = (int)$s['id']; $emptyBuckets = []; for ($i = 0; $i < count($buckets); $i++) { $emptyBuckets[] = ['sum' => 0, 'byModule' => new stdClass()]; } $byStudent[$sid] = [ 'studentId' => $sid, 'displayName' => $s['display_name'] ?: $s['username'], 'avatarSlug' => $s['avatar_slug'], 'emoji' => $s['emoji_avatar'], 'totalSec' => 0, 'sessions' => 0, 'buckets' => $emptyBuckets, // [{sum, byModule:{simId:sec}}, ...] ]; } // 5) Bucket-Daten einsortieren + Aggregat pro Schüler:in $moduleAgg = []; $globalMaxBucketSec = 0; foreach ($rows as $r) { $sid = (int)$r['student_id']; if (!isset($byStudent[$sid])) continue; $bIdx = $idxMap($r['bucket']); if ($bIdx < 0 || $bIdx >= count($buckets)) continue; $sec = (int)round($r['sec']); $sim = $r['sim_id']; // In Säule eintragen $b = &$byStudent[$sid]['buckets'][$bIdx]; $b['sum'] += $sec; $byMod = (array)$b['byModule']; $byMod[$sim] = ($byMod[$sim] ?? 0) + $sec; $b['byModule'] = $byMod; unset($b); // Aggregate $byStudent[$sid]['totalSec'] += $sec; $byStudent[$sid]['sessions'] += (int)$r['sessions']; // Modul-Übersicht (für Legende + Filter-Dropdown) if (!isset($moduleAgg[$sim])) { $info = $moduleMap[$sim] ?? null; $moduleAgg[$sim] = [ 'id' => $sim, 'name' => $info ? $info['name'] : $sim, 'icon' => $info ? $info['icon'] : '📘', 'color' => $info ? $info['color'] : '#888', 'totalSec' => 0, ]; } $moduleAgg[$sim]['totalSec'] += $sec; } // 6) Global-Max-Bucket-Sec für klassenweite Säulen-Skalierung foreach ($byStudent as $sid => &$st) { foreach ($st['buckets'] as $b) { if ($b['sum'] > $globalMaxBucketSec) $globalMaxBucketSec = $b['sum']; } } unset($st); // 7) Sortierung: Fleiß absteigend (totalSec), bei Gleichstand alphabetisch $studentsList = array_values($byStudent); usort($studentsList, function($a, $b) { if ($b['totalSec'] !== $a['totalSec']) return $b['totalSec'] - $a['totalSec']; return strcmp($a['displayName'] ?? '', $b['displayName'] ?? ''); }); Response::ok([ 'range' => $range, 'rangeLabel' => $label, 'moduleFilter' => $moduleId, 'buckets' => $buckets, // ['Mo','Di',...] — Beschriftung der X-Achse 'globalMaxBucketSec' => $globalMaxBucketSec,// Max einer Säule (für lineare Skala) 'students' => $studentsList, 'modules' => array_values($moduleAgg), ]); } // ============================================================================= // VIEW 2: ACTIVITY (Heatmap-Buckets) // ============================================================================= if ($view === 'activity') { $range = $_GET['range'] ?? 'week'; // groupBy: 'module' (default — wie bisher) oder 'student' (pro Schüler*in) $groupBy = $_GET['groupBy'] ?? 'module'; // Bucket-Funktion + Zeit-Window je Range switch ($range) { case 'day': $bucketFn = 'HOUR(submitted_at)'; $sinceSql = "submitted_at >= NOW() - INTERVAL 1 DAY"; $buckets = []; for ($h = 0; $h < 24; $h++) $buckets[] = sprintf('%02d', $h); break; case 'week': // Mo=1, So=7 (WEEKDAY: Mo=0 .. So=6) $bucketFn = 'WEEKDAY(submitted_at)'; $sinceSql = "submitted_at >= NOW() - INTERVAL 7 DAY"; $buckets = ['Mo','Di','Mi','Do','Fr','Sa','So']; break; case 'month': $bucketFn = 'DAY(submitted_at)'; $sinceSql = "submitted_at >= NOW() - INTERVAL 31 DAY"; $buckets = []; for ($d = 1; $d <= 31; $d++) $buckets[] = (string)$d; break; case 'year': $bucketFn = 'MONTH(submitted_at)'; $sinceSql = "submitted_at >= NOW() - INTERVAL 12 MONTH"; $buckets = ['Jän','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez']; break; default: Response::error('range muss day|week|month|year sein'); } // Index-Mapping je Range $idxMap = function($b) use ($range) { $i = (int)$b; if ($range === 'month' || $range === 'year') $i -= 1; return $i; }; if ($groupBy === 'student') { // Pro Schüler*in gruppieren. Verbindung Assessment → Student geht // über student_sessions (assessments hat keinen direkten user_id- // FK, sondern session_id; sessions matchen students über // class_id + display_name). $sinceSqlA = str_replace('submitted_at', 'a.submitted_at', $sinceSql); $bucketA = str_replace('submitted_at', 'a.submitted_at', $bucketFn); $rows = $db->fetchAll( "SELECT $bucketA AS bucket, s.id AS student_id, s.display_name AS name, s.emoji_avatar AS emoji, COUNT(*) AS n FROM assessments a JOIN student_sessions ss ON ss.id = a.session_id JOIN students s ON s.class_id = ss.class_id AND s.display_name = ss.display_name WHERE a.class_id = ? AND $sinceSqlA AND s.deleted_at IS NULL GROUP BY bucket, s.id", [$classId] ); $palette = ['#4a7c8a','#c07a6b','#5a8a5e','#c9913a','#7c3aed','#0d47a1','#e8a247','#1f4b37','#8d6e63','#b04a3e','#4a7c4e','#1565c0']; $series = []; $cIdx = 0; foreach ($rows as $r) { $sid = (int)$r['student_id']; if (!isset($series[$sid])) { $series[$sid] = [ 'studentId' => $sid, 'name' => $r['name'] ?: ('Schüler*in #'.$sid), 'icon' => '👤', 'emoji' => $r['emoji'], 'color' => $palette[$cIdx++ % count($palette)], 'values' => array_fill(0, count($buckets), 0), ]; } $idx = $idxMap($r['bucket']); if ($idx >= 0 && $idx < count($buckets)) { $series[$sid]['values'][$idx] += (int)$r['n']; } } $seriesList = array_values($series); usort($seriesList, function($a,$b){ return array_sum($b['values']) - array_sum($a['values']); // aktivste zuerst }); Response::ok([ 'range' => $range, 'groupBy' => 'student', 'buckets' => $buckets, 'series' => $seriesList, ]); } // Default: per-Modul $rows = $db->fetchAll( "SELECT $bucketFn AS bucket, sim_id, COUNT(*) AS n FROM assessments WHERE class_id = ? AND $sinceSql GROUP BY bucket, sim_id", [$classId] ); $series = []; foreach ($modules as $m) { $series[$m['id']] = [ 'moduleId' => $m['id'], 'name' => $m['name'], 'icon' => $m['icon'], 'color' => $m['color'], 'values' => array_fill(0, count($buckets), 0), ]; } foreach ($rows as $r) { $sim = $r['sim_id']; if (!isset($series[$sim])) continue; $idx = $idxMap($r['bucket']); if ($idx >= 0 && $idx < count($buckets)) { $series[$sim]['values'][$idx] += (int)$r['n']; } } $seriesList = array_values(array_filter($series, function($s) { return array_sum($s['values']) > 0; })); Response::ok([ 'range' => $range, 'groupBy' => 'module', 'buckets' => $buckets, 'series' => $seriesList, ]); } // ============================================================================= // VIEW 3: MODULE-DETAIL // ============================================================================= if ($view === 'module') { $moduleId = $_GET['module_id'] ?? ''; if (!$moduleId) Response::error('module_id erforderlich'); // Wenn student_id mitkommt: Drill-Down auf Einzel-Schüler-Verlauf $studentId = (int)($_GET['student_id'] ?? 0); if ($studentId) { // Erst Schüler-Klasse-Check $student = $db->fetchOne( 'SELECT id, class_id, display_name FROM students WHERE id = ? AND class_id = ? AND deleted_at IS NULL', [$studentId, $classId] ); if (!$student) Response::error('Schüler*in nicht in dieser Klasse', 404); // Alle Durchgänge: über student_sessions matchen $runs = $db->fetchAll( "SELECT a.id, a.submitted_at, a.duration_ms, a.results, a.reflections FROM assessments a JOIN student_sessions ss ON ss.id = a.session_id WHERE ss.class_id = ? AND ss.display_name = ? AND a.sim_id = ? ORDER BY a.submitted_at DESC LIMIT 100", [$classId, $student['display_name'], $moduleId] ); // results + reflections JSON parsen (Frontend sieht's lieber dekodiert) foreach ($runs as &$run) { $r = $run['results']; $run['results'] = $r ? json_decode($r, true) : null; $rf = $run['reflections']; $run['reflections'] = $rf ? json_decode($rf, true) : null; } Response::ok(['runs' => $runs]); } // Modul-Übersicht: alle Schüler*innen der Klasse mit Modul-Stats $modInfo = $db->fetchOne( "SELECT module_id AS id, title AS name, icon, short_desc, card_image, status FROM module_info WHERE module_id = ?", [$moduleId] ); 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] ); $students = []; $sumPlays = 0; $sumDuration = 0; $sumStars = 0; $countWithStars = 0; foreach ($rows as $r) { $stars = (int)($r['best_stars'] ?? 0); $plays = (int)($r['plays'] ?? 0); $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), ]; $sumPlays += $plays; $sumDuration += (int)($r['total_duration_ms'] ?? 0); if ($plays > 0) { $sumStars += $stars; $countWithStars++; } } $classStats = [ 'totalPlays' => $sumPlays, 'totalDurationMs' => $sumDuration, 'avgStars' => $countWithStars ? round($sumStars / $countWithStars, 2) : null, 'studentsActive' => $countWithStars, '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 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); } Response::ok([ 'module' => $modInfo, 'classStats' => $classStats, 'highlights' => $highlights, 'champions' => $champions ?? new stdClass(), 'students' => $students, ]); } // ============================================================================= // VIEW 4: REFLECTIONS — alle Reflexions-Antworten der Klasse, alle Sims // ============================================================================= if ($view === 'reflections') { $simFilter = $_GET['module_id'] ?? null; // optional filter auf ein Modul $limit = max(1, min(500, (int)($_GET['limit'] ?? 200))); $sql = "SELECT a.id, a.session_id, a.sim_id, a.submitted_at, a.reflections, ss.display_name, s.id AS student_id, s.emoji_avatar FROM assessments a JOIN student_sessions ss ON ss.id = a.session_id LEFT JOIN students s ON s.class_id = ss.class_id AND s.display_name = ss.display_name AND s.deleted_at IS NULL WHERE a.class_id = ? AND a.reflections IS NOT NULL AND JSON_LENGTH(a.reflections) > 0"; $params = [$classId]; if ($simFilter) { $sql .= " AND a.sim_id = ?"; $params[] = $simFilter; } $sql .= " ORDER BY a.submitted_at DESC LIMIT $limit"; $rows = $db->fetchAll($sql, $params); // Reflexionen flatten: jeder JSON-Eintrag wird ein eigenes Item $items = []; foreach ($rows as $r) { $arr = $r['reflections'] ? json_decode($r['reflections'], true) : []; if (!is_array($arr)) continue; foreach ($arr as $rf) { if (!is_array($rf)) continue; $items[] = [ 'assessmentId' => (int)$r['id'], 'studentId' => $r['student_id'] ? (int)$r['student_id'] : null, 'displayName' => $r['display_name'], 'emoji' => $r['emoji_avatar'] ?: '🧑‍🎓', 'simId' => $r['sim_id'], 'level' => $rf['level'] ?? null, 'question' => $rf['question'] ?? '', 'answer' => $rf['answer'] ?? '', 'recordedAt' => $rf['recorded_at'] ?? $r['submitted_at'], ]; } } // Sortieren nach Zeit (neueste zuerst) usort($items, function($a, $b) { return strcmp($b['recordedAt'], $a['recordedAt']); }); Response::ok([ 'items' => array_slice($items, 0, $limit), 'total' => count($items), 'simFilter' => $simFilter, ]); } Response::error('Unbekannte view: ' . $view);