32) Response::error('module_id erforderlich'); $state = $body['state'] ?? null; $stateJson = is_string($state) ? $state : json_encode($state, JSON_UNESCAPED_UNICODE); if ($stateJson !== null && strlen($stateJson) > 50000) { // Überlange States kappen (Sicherheitsnetz; State sollte schlank gehalten werden) $stateJson = substr($stateJson, 0, 50000); } $session = $db->fetchOne('SELECT class_id, display_name FROM student_sessions WHERE id = ?', [$sessionId]); if (!$session || !$session['class_id']) Response::ok(['ok' => false, 'reason' => 'no-class']); $student = $db->fetchOne('SELECT id FROM students WHERE class_id = ? AND display_name = ?', [$session['class_id'], $session['display_name']]); if (!$student) Response::ok(['ok' => false, 'reason' => 'no-student']); $studentId = (int)$student['id']; $classId = (int)$session['class_id']; // started_at frisch setzen, wenn (a) anderes Modul oder (b) länger als 30s // kein Heartbeat kam (Sim wurde geschlossen und neu gestartet). $db->execute( 'INSERT INTO live_sessions (student_id, class_id, module_id, state_json, started_at, last_seen) VALUES (?, ?, ?, ?, NOW(), NOW()) ON DUPLICATE KEY UPDATE class_id = VALUES(class_id), module_id = VALUES(module_id), state_json = VALUES(state_json), started_at = IF(module_id <> VALUES(module_id) OR last_seen < NOW() - INTERVAL 30 SECOND, NOW(), started_at), last_seen = NOW()', [$studentId, $classId, $moduleId, $stateJson] ); Response::ok(['ok' => true]); } if ($action === 'end') { $session = $db->fetchOne('SELECT display_name, class_id FROM student_sessions WHERE id = ?', [$sessionId]); $student = $session ? $db->fetchOne('SELECT id FROM students WHERE class_id = ? AND display_name = ?', [$session['class_id'], $session['display_name']]) : null; if ($student) $db->execute('DELETE FROM live_sessions WHERE student_id = ?', [(int)$student['id']]); Response::ok(); } Response::error('Unbekannte Aktion'); } if ($method === 'GET') { $teacherId = Session::requireTeacher(); // Tages-Übersicht: alle Sessions aller Schüler*innen einer Klasse an einem Tag. // Gruppiert pro {student × sim × mission/level} → Balken-Chart-Daten. if (isset($_GET['summary']) && $_GET['summary'] === '1') { $classId = (int)($_GET['class_id'] ?? 0); $date = $_GET['date'] ?? date('Y-m-d'); if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) Response::error('date YYYY-MM-DD erwartet'); if (!$classId) Response::error('class_id erforderlich'); $class = $db->fetchOne('SELECT id, name FROM classes WHERE id = ? AND teacher_id = ?', [$classId, $teacherId]); if (!$class) Response::error('Klasse nicht gefunden', 404); // Alle Assessments des Tages für diese Klasse $rows = $db->fetchAll( "SELECT a.id, a.session_id, a.sim_id, a.results, a.process_log, a.duration_ms, a.submitted_at, s.id AS student_id, s.display_name, s.username, s.emoji_avatar, s.avatar_slug FROM assessments a JOIN student_sessions ss ON ss.id = a.session_id JOIN students s ON s.class_id = ? AND s.display_name = ss.display_name WHERE a.class_id = ? AND a.submitted_at >= ? AND a.submitted_at < ? ORDER BY s.id, a.sim_id, a.submitted_at ASC", [$classId, $classId, $date . ' 00:00:00', $date . ' 23:59:59.999'] ); // Helper: extrahiere "Lektion-Key" (mission/level/scenario) aus assessments-row $extractLessonKey = function($row) { $r = $row['results'] ? json_decode($row['results'], true) : []; $pl = $row['process_log'] ? json_decode($row['process_log'], true) : []; // Heli: missionId in results.results.missionId, results.missionId, process_log.missionId $mid = $r['results']['missionId'] ?? $r['missionId'] ?? $pl['missionId'] ?? null; if ($mid) return ['kind' => 'mission', 'key' => (string)$mid, 'label' => (string)$mid]; // Busfahrt/Fluggesellschaft: levelId / level $lvl = $r['levelId'] ?? $pl['levelId'] ?? $r['level'] ?? $pl['level'] ?? null; if ($lvl) return ['kind' => 'level', 'key' => (string)$lvl, 'label' => 'L' . $lvl]; // EU-Werkstatt: scenarioId $sc = $r['scenarioId'] ?? $pl['scenarioId'] ?? null; if ($sc) return ['kind' => 'scenario', 'key' => (string)$sc, 'label' => (string)$sc]; // Sonnensystem: setId $set = $r['setId'] ?? $pl['setId'] ?? null; if ($set) return ['kind' => 'set', 'key' => (string)$set, 'label' => (string)$set]; // Fallback: nur Sim als „Lektion" return ['kind' => 'sim', 'key' => '_', 'label' => '']; }; // Helper: Sterne aus row extrahieren (mehrere Felder probieren) $extractStars = function($row) { $r = $row['results'] ? json_decode($row['results'], true) : []; $candidates = [ $r['stars'] ?? null, $r['results']['totalStars'] ?? null, $r['results']['sterneGesamt'] ?? null, $r['sterneGesamt'] ?? null, ]; foreach ($candidates as $c) if (is_numeric($c)) return (int)$c; return null; }; $extractCompleted = function($row) { $r = $row['results'] ? json_decode($row['results'], true) : []; $pl = $row['process_log'] ? json_decode($row['process_log'], true) : []; if (isset($r['completed'])) return (bool)$r['completed']; if (isset($r['failed'])) return !$r['failed']; if (isset($r['results']['failed'])) return !$r['results']['failed']; if (($pl['phase'] ?? '') === 'completed') return true; return null; }; // Gruppieren: student_id × sim_id × lesson_key → eine ODER MEHRERE Sessions. // Wichtig: bei Lücke > SESSION_GAP_SEC zwischen aufeinanderfolgenden Submits // wird eine neue Session aufgemacht — sonst zählt eine Vormittags- und eine // Nachmittags-Session als 5 h „Spielzeit", obwohl dazwischen niemand spielt. $SESSION_GAP_SEC = 300; // 5 min ohne Heartbeat = neue Session $modulesInfo = $db->fetchAll('SELECT module_id, title, icon FROM module_info'); $moduleMap = []; foreach ($modulesInfo as $m) $moduleMap[$m['module_id']] = $m; // Zuerst zeitlich sortierte Listen pro (student, sim, lesson) sammeln $clusters = []; foreach ($rows as $row) { $lk = $extractLessonKey($row); $clusterKey = $row['student_id'] . '|' . $row['sim_id'] . '|' . $lk['key']; if (!isset($clusters[$clusterKey])) { $clusters[$clusterKey] = [ 'studentId' => (int)$row['student_id'], 'displayName' => $row['display_name'] ?: $row['username'], 'avatarSlug' => $row['avatar_slug'], 'emoji' => $row['emoji_avatar'], 'simId' => $row['sim_id'], 'lessonKind' => $lk['kind'], 'lessonLabel' => $lk['label'], 'rows' => [], ]; } $clusters[$clusterKey]['rows'][] = $row; } // Pro Cluster: bei Lücke > GAP neue Session aufmachen $sessions = []; foreach ($clusters as $clusterKey => $cl) { usort($cl['rows'], function($a,$b){ return strcmp($a['submitted_at'], $b['submitted_at']); }); $mi = $moduleMap[$cl['simId']] ?? null; $simTitle = $mi['title'] ?? $cl['simId']; $simIcon = $mi['icon'] ?? '📚'; $currentSess = null; $prevTs = null; foreach ($cl['rows'] as $row) { $ts = strtotime($row['submitted_at']); if ($currentSess === null || ($prevTs !== null && ($ts - $prevTs) > $SESSION_GAP_SEC)) { if ($currentSess !== null) $sessions[] = $currentSess; $currentSess = [ 'studentId' => $cl['studentId'], 'displayName' => $cl['displayName'], 'avatarSlug' => $cl['avatarSlug'], 'emoji' => $cl['emoji'], 'simId' => $cl['simId'], 'simTitle' => $simTitle, 'simIcon' => $simIcon, 'lessonKind' => $cl['lessonKind'], 'lessonLabel' => $cl['lessonLabel'], 'startMs' => $ts * 1000, 'endMs' => $ts * 1000, 'stars' => null, 'completed' => null, 'rowCount' => 0, ]; } $currentSess['endMs'] = $ts * 1000; $currentSess['rowCount']++; $st = $extractStars($row); if ($st !== null && ($currentSess['stars'] === null || $st > $currentSess['stars'])) $currentSess['stars'] = $st; $cp = $extractCompleted($row); if ($cp !== null) $currentSess['completed'] = $cp; $prevTs = $ts; } if ($currentSess !== null) $sessions[] = $currentSess; } // Pro Student gruppieren + nach Startzeit sortieren $byStudent = []; foreach ($sessions as $s) { $sid = $s['studentId']; if (!isset($byStudent[$sid])) { $byStudent[$sid] = [ 'studentId' => $sid, 'displayName' => $s['displayName'], 'avatarSlug' => $s['avatarSlug'], 'emoji' => $s['emoji'], 'totalSec' => 0, 'sessions' => [], ]; } $durSec = max(0, intval(($s['endMs'] - $s['startMs']) / 1000)); // Success-Klassifikation $success = 'unknown'; if ($s['completed'] === false) $success = 'aborted'; elseif ($s['stars'] !== null) { if ($s['stars'] >= 4) $success = 'good'; elseif ($s['stars'] >= 2) $success = 'mid'; else $success = 'bad'; } elseif ($s['completed'] === true) $success = 'good'; elseif ($durSec < 60) $success = 'aborted'; else $success = 'running'; $byStudent[$sid]['sessions'][] = [ 'simId' => $s['simId'], 'simTitle' => $s['simTitle'], 'simIcon' => $s['simIcon'], 'lessonKind' => $s['lessonKind'], 'lessonLabel' => $s['lessonLabel'], 'startMs' => $s['startMs'], 'endMs' => $s['endMs'], 'durationSec' => $durSec, 'stars' => $s['stars'], 'completed' => $s['completed'], 'success' => $success, 'rowCount' => $s['rowCount'], ]; $byStudent[$sid]['totalSec'] += $durSec; } foreach ($byStudent as &$st) { usort($st['sessions'], function($a,$b){ return $a['startMs'] <=> $b['startMs']; }); } unset($st); // Aktuell laufende Sessions (live_sessions) als „läuft noch"-Eintrag // hinzufügen, wenn die Schüler:in heute (= angefragter Tag) noch // nichts submitted hat. Ohne diesen Schritt fehlt jeder, der gerade // eine Tour spielt und noch nicht beendet hat — z. B. wenn die Lehrer:in // mitten in der Stunde reinschaut. $isTodayQuery = ($date === date('Y-m-d')); if ($isTodayQuery) { $liveRows = $db->fetchAll( 'SELECT ls.student_id, ls.module_id, ls.state_json, UNIX_TIMESTAMP(ls.started_at) AS started_ts, UNIX_TIMESTAMP(ls.last_seen) AS last_seen_ts, s.display_name, s.username, s.emoji_avatar, s.avatar_slug FROM live_sessions ls JOIN students s ON s.id = ls.student_id WHERE ls.class_id = ?', [$classId] ); foreach ($liveRows as $lr) { $sid = (int)$lr['student_id']; $simId = $lr['module_id']; $state = $lr['state_json'] ? json_decode($lr['state_json'], true) : []; // Lesson-Key wie bei Assessments — mission/level/scenario $lessonKey = '_'; $lessonLabel = ''; $lessonKind = 'sim'; if (!empty($state['missionId'])) { $lessonKey = (string)$state['missionId']; $lessonLabel = $lessonKey; $lessonKind = 'mission'; } elseif (!empty($state['levelId'])) { $lessonKey = (string)$state['levelId']; $lessonLabel = 'L'.$lessonKey; $lessonKind = 'level'; } elseif (!empty($state['setId'])) { $lessonKey = (string)$state['setId']; $lessonLabel = $lessonKey; $lessonKind = 'set'; } // Skip wenn für diese Schüler:in × Sim × Lesson bereits Assessment-Sessions existieren $alreadyHas = false; if (isset($byStudent[$sid])) { foreach ($byStudent[$sid]['sessions'] as $sess) { if ($sess['simId'] === $simId && $sess['lessonKind'] === $lessonKind && ($sess['lessonLabel'] === $lessonLabel || ($lessonKey === '_' && $sess['lessonLabel'] === ''))) { $alreadyHas = true; break; } } } if ($alreadyHas) continue; $mi = $moduleMap[$simId] ?? null; if (!isset($byStudent[$sid])) { $byStudent[$sid] = [ 'studentId' => $sid, 'displayName' => $lr['display_name'] ?: $lr['username'], 'avatarSlug' => $lr['avatar_slug'], 'emoji' => $lr['emoji_avatar'], 'totalSec' => 0, 'sessions' => [], ]; } $startMs = ((int)$lr['started_ts']) * 1000; $endMs = ((int)$lr['last_seen_ts']) * 1000; $durSec = max(0, intval(($endMs - $startMs) / 1000)); $byStudent[$sid]['sessions'][] = [ 'simId' => $simId, 'simTitle' => $mi['title'] ?? $simId, 'simIcon' => $mi['icon'] ?? '📚', 'lessonKind' => $lessonKind, 'lessonLabel' => $lessonLabel, 'startMs' => $startMs, 'endMs' => $endMs, 'durationSec' => $durSec, 'stars' => null, 'completed' => null, 'success' => 'running', 'rowCount' => 1, 'isLive' => true, ]; $byStudent[$sid]['totalSec'] += $durSec; } // Pro Student wieder nach Startzeit sortieren foreach ($byStudent as &$st) { usort($st['sessions'], function($a,$b){ return $a['startMs'] <=> $b['startMs']; }); } unset($st); } // Stille Schüler*innen (keine Sessions an dem Tag) als leere Zeilen ergänzen $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] ); $activeStudentIds = array_keys($byStudent); foreach ($allStudents as $s) { $sid = (int)$s['id']; if (!in_array($sid, $activeStudentIds)) { $byStudent[$sid] = [ 'studentId' => $sid, 'displayName' => $s['display_name'] ?: $s['username'], 'avatarSlug' => $s['avatar_slug'], 'emoji' => $s['emoji_avatar'], 'totalSec' => 0, 'sessions' => [], ]; } } // Sortierung: aktive zuerst (nach totalSec absteigend), dann inaktive alphabetisch $studentsArr = array_values($byStudent); usort($studentsArr, function($a, $b) { if (($a['totalSec'] > 0) !== ($b['totalSec'] > 0)) return $b['totalSec'] <=> $a['totalSec']; if ($a['totalSec'] !== $b['totalSec']) return $b['totalSec'] <=> $a['totalSec']; return strcmp((string)($a['displayName'] ?? ''), (string)($b['displayName'] ?? '')); }); Response::ok([ 'date' => $date, 'classId' => $classId, 'className' => $class['name'], 'students' => $studentsArr, ]); } // Session-Detail: voller letzter Assessment-Eintrag einer Übersicht-Session. // Geliefert wird der NEUESTE Row mit Stars/Results im Zeitfenster. if (isset($_GET['session_detail']) && $_GET['session_detail'] === '1') { $classId = (int)($_GET['class_id'] ?? 0); $studentId = (int)($_GET['student_id'] ?? 0); $simId = (string)($_GET['sim_id'] ?? ''); $startMs = (int)($_GET['start_ms'] ?? 0); $endMs = (int)($_GET['end_ms'] ?? 0); if (!$classId || !$studentId || !$simId) Response::error('class_id, student_id, sim_id erforderlich'); $class = $db->fetchOne('SELECT id FROM classes WHERE id = ? AND teacher_id = ?', [$classId, $teacherId]); if (!$class) Response::error('Klasse nicht gefunden', 404); $student = $db->fetchOne('SELECT id, display_name FROM students WHERE id = ? AND class_id = ?', [$studentId, $classId]); if (!$student) Response::error('Schüler*in nicht gefunden', 404); // Zeitfenster ±60s Toleranz für Sub-Second-Drift $start = date('Y-m-d H:i:s', max(0, (int)floor($startMs/1000) - 60)); $end = date('Y-m-d H:i:s', (int)ceil($endMs/1000) + 60); $rows = $db->fetchAll( 'SELECT a.id, a.results, a.process_log, a.duration_ms, a.submitted_at FROM assessments a JOIN student_sessions ss ON ss.id = a.session_id WHERE a.class_id = ? AND a.sim_id = ? AND ss.display_name = ? AND a.submitted_at BETWEEN ? AND ? ORDER BY a.submitted_at DESC', [$classId, $simId, $student['display_name'], $start, $end] ); if (!$rows) Response::ok(['results' => null, 'assessmentCount' => 0]); // Nimm den Row mit dem reichhaltigsten Results-JSON (Heuristik: hat .stars oder .results.totalStars) $best = null; foreach ($rows as $r) { $j = $r['results'] ? json_decode($r['results'], true) : []; $hasStars = isset($j['stars']) || isset($j['results']['totalStars']) || isset($j['results']['stars']); if ($hasStars) { $best = $r; break; } } if (!$best) $best = $rows[0]; $results = $best['results'] ? json_decode($best['results'], true) : null; Response::ok([ 'results' => $results, 'submittedAt' => $best['submitted_at'], 'durationMs' => (int)$best['duration_ms'], 'assessmentCount' => count($rows), ]); } // Spectator: state eines Schülers if (isset($_GET['student_id'])) { $studentId = (int)$_GET['student_id']; $row = $db->fetchOne( 'SELECT ls.*, UNIX_TIMESTAMP(ls.started_at) AS started_at_ts, UNIX_TIMESTAMP(ls.last_seen) AS last_seen_ts, s.display_name, s.username, s.emoji_avatar, c.id AS owner_class_id FROM live_sessions ls JOIN students s ON s.id = ls.student_id JOIN classes c ON c.id = ls.class_id WHERE ls.student_id = ? AND c.teacher_id = ?', [$studentId, $teacherId] ); if (!$row) Response::ok(['active' => false]); $stale = ((int)$row['last_seen_ts'] < time() - LIVE_ACTIVE_WINDOW_SEC); Response::ok([ 'active' => !$stale, 'student_id' => (int)$row['student_id'], 'username' => $row['username'], 'displayName' => $row['display_name'] ?: $row['username'], 'emoji' => $row['emoji_avatar'] ?: '🧑‍🎓', 'module_id' => $row['module_id'], 'state' => $row['state_json'] ? json_decode($row['state_json'], true) : null, 'started_at' => $row['started_at'], 'last_seen' => $row['last_seen'], 'started_at_ms' => $row['started_at_ts'] ? ((int)$row['started_at_ts']) * 1000 : null, 'last_seen_ms' => $row['last_seen_ts'] ? ((int)$row['last_seen_ts']) * 1000 : null, ]); } // Klassen-Liste: alle aktiven Sessions $classId = (int)($_GET['class_id'] ?? 0); if (!$classId) Response::error('class_id erforderlich'); $class = $db->fetchOne('SELECT id FROM classes WHERE id = ? AND teacher_id = ?', [$classId, $teacherId]); if (!$class) Response::error('Klasse nicht gefunden', 404); // =================================================================== // COCKPIT-MODUS — class_id=X&cockpit=1&range=day|week|month|year // Liefert komplette Klassenliste + Status-Klassifizierung + Top3/Bottom3 + // weeklyByDay (Klassen-Fleiß über Zeit) + needsHelp-Heuristik. // =================================================================== if (isset($_GET['cockpit']) && $_GET['cockpit'] === '1') { $range = $_GET['range'] ?? 'week'; // Bucket + Zeit-Window — gleiche Logik wie results.php view=fleiss. // Schuljahr-Start AT/CH/DE/LI ist Aug oder Sep — hier vorerst pauschal // "vor 12 Monaten" als Window; Frontend kann leere Monate filtern. switch ($range) { case 'day': $sinceSql = "a.submitted_at >= NOW() - INTERVAL 1 DAY"; $bucketFn = 'HOUR(a.submitted_at)'; $buckets = []; for ($h = 0; $h < 25; $h++) $buckets[] = (string)$h; $idxMap = function($b) { return (int)$b; }; break; case 'week': $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; }; break; case 'month': $sinceSql = "a.submitted_at >= NOW() - INTERVAL 30 DAY"; $bucketFn = 'DAY(a.submitted_at)'; $buckets = []; for ($d = 1; $d <= 30; $d++) $buckets[] = (string)$d; $idxMap = function($b) { return (int)$b - 1; }; 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; }; break; default: Response::error('range muss day|week|month|year sein'); } // Modul-Stammdaten + Farb-Map (Farben pflegen wir hier zentral — // die module_info-Tabelle hat keine color-Spalte). $modulesInfo = $db->fetchAll( 'SELECT module_id, title, icon FROM module_info' ); $moduleColors = [ 'klima' => '#4a7c8a', 'klima-3d' => '#3d6a78', 'heli' => '#c85c4a', 'busfahrt' => '#7c3aed', 'farmer' => '#5a8a5e', 'fluss' => '#3a7ca5', 'logistik' => '#8a6a3a', 'energiemanager' => '#e8a247', 'eu-werkstatt' => '#0d47a1', 'sonnensystem' => '#5a4a8a', 'weltkueche' => '#c97b5a', 'fluggesellschaft' => '#4a7c4e', 'entscheidungstag' => '#a85a3c', ]; $moduleMap = []; foreach ($modulesInfo as $m) { $m['color'] = $moduleColors[$m['module_id']] ?? '#888'; $moduleMap[$m['module_id']] = $m; } // 1) Komplette Klassenliste $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] ); // 2) Aktive Live-Sessions (Heartbeat in den letzten 30 s) $liveRows = $db->fetchAll( "SELECT student_id, module_id, state_json, UNIX_TIMESTAMP(last_seen) AS last_seen_ts, UNIX_TIMESTAMP(started_at) AS started_at_ts FROM live_sessions WHERE class_id = ? AND last_seen > NOW() - INTERVAL 30 SECOND", [$classId] ); $activeMap = []; foreach ($liveRows as $r) $activeMap[(int)$r['student_id']] = $r; // 3) Heute aktiv: Schüler:innen mit Heartbeat oder Submit seit 0 Uhr, // auch wenn gerade nicht mehr aktiv. Plus: Letzter Login-Zeitpunkt. $heuteRows = $db->fetchAll( "SELECT DISTINCT s.id AS student_id FROM students s LEFT JOIN student_sessions ss ON ss.class_id = s.class_id AND ss.display_name = s.display_name LEFT JOIN assessments a ON a.session_id = ss.id AND a.submitted_at >= CURDATE() LEFT JOIN live_sessions ls ON ls.student_id = s.id AND ls.last_seen >= CURDATE() WHERE s.class_id = ? AND s.deleted_at IS NULL AND (a.id IS NOT NULL OR ls.student_id IS NOT NULL)", [$classId] ); $heuteSet = []; foreach ($heuteRows as $r) $heuteSet[(int)$r['student_id']] = true; // Letzter Login pro Schüler:in (für „kalt"-Anzeige) $lastLoginRows = $db->fetchAll( "SELECT s.id AS student_id, MAX(GREATEST(IFNULL(a.submitted_at, '1970-01-01'), IFNULL(ls.last_seen, '1970-01-01'))) AS last_seen_any FROM students s LEFT JOIN student_sessions ss ON ss.class_id = s.class_id AND ss.display_name = s.display_name LEFT JOIN assessments a ON a.session_id = ss.id LEFT JOIN live_sessions ls ON ls.student_id = s.id WHERE s.class_id = ? AND s.deleted_at IS NULL GROUP BY s.id", [$classId] ); $lastLoginMap = []; foreach ($lastLoginRows as $r) $lastLoginMap[(int)$r['student_id']] = $r['last_seen_any']; // 4) Benchmark pro Schüler:in über alle Module — echte Engine in Benchmark.php. // Pro Modul max. die 3 besten Sessions, dann Mittelwert über alle gespielten Module. require_once __DIR__ . '/../lib/Benchmark.php'; $benchRows = $db->fetchAll( "SELECT s.id AS student_id, a.sim_id, a.results, a.process_log, a.submitted_at 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] ); // Pro (Schüler × Modul) alle Scores sammeln $scoresByStudentModule = []; foreach ($benchRows as $r) { $sid = (int)$r['student_id']; $mid = $r['sim_id']; $score = Benchmark::scoreSubmission($mid, $r['results'], $r['process_log']); if ($score === null) continue; $k = "$sid|$mid"; if (!isset($scoresByStudentModule[$k])) $scoresByStudentModule[$k] = []; $scoresByStudentModule[$k][] = $score; } // Pro Modul Top 3 Scores → Mittelwert $bestByStudentModule = []; foreach ($scoresByStudentModule as $k => $list) { rsort($list); $top3 = array_slice($list, 0, 3); $bestByStudentModule[$k] = array_sum($top3) / count($top3); } // Gesamt-Benchmark = Mittelwert pro Schüler:in über seine gespielten Module $benchByStudent = []; foreach ($bestByStudentModule as $k => $pct) { [$sid] = explode('|', $k); $sid = (int)$sid; if (!isset($benchByStudent[$sid])) $benchByStudent[$sid] = ['sum'=>0,'n'=>0]; $benchByStudent[$sid]['sum'] += $pct; $benchByStudent[$sid]['n']++; } // 5) needsHelp-Heuristik aus Live-State (pro aktivem Schüler:in) $needsHelp = function($simId, $state) { if (!is_array($state)) return false; switch ($simId) { case 'heli': return ($state['planAttempts'] ?? 0) >= 10; case 'klima': case 'klima-3d': return ($state['budget'] ?? 0) < 0; case 'weltkueche': return ($state['mistakesInDish'] ?? 0) >= 12; case 'farmer': return ($state['cash'] ?? 1000) < 0; case 'logistik': return ($state['balance'] ?? 1000) < 0; case 'eu-werkstatt': return ($state['attemptsThisStep'] ?? 0) >= 6; case 'tourismustal': return ($state['money'] ?? 1) < 0 || ($state['lossStreak'] ?? 0) >= 8; } return false; }; // 6) Klassen-Aggregat pro Bucket × Modul $weeklyRows = $db->fetchAll( "SELECT $bucketFn AS bucket, a.sim_id, SUM(COALESCE(a.duration_ms, 0)) / 1000 AS sec 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 GROUP BY bucket, a.sim_id", [$classId] ); $weeklyByDay = []; $weeklyMaxSec = 0; for ($i = 0; $i < count($buckets); $i++) { $weeklyByDay[$i] = ['sum' => 0, 'byModule' => []]; } foreach ($weeklyRows as $r) { $idx = $idxMap($r['bucket']); if ($idx < 0 || $idx >= count($buckets)) continue; $sec = (int)round($r['sec']); $sim = $r['sim_id']; $weeklyByDay[$idx]['sum'] += $sec; $weeklyByDay[$idx]['byModule'][$sim] = ($weeklyByDay[$idx]['byModule'][$sim] ?? 0) + $sec; if ($weeklyByDay[$idx]['sum'] > $weeklyMaxSec) $weeklyMaxSec = $weeklyByDay[$idx]['sum']; } // 7) Schüler-Liste zusammenbauen $studentsList = []; $nowTs = time(); foreach ($allStudents as $s) { $sid = (int)$s['id']; $active = $activeMap[$sid] ?? null; $lastSeen = $lastLoginMap[$sid] ?? null; $hasHeute = isset($heuteSet[$sid]); $status = 'kalt'; if ($active) $status = 'aktiv'; elseif ($hasHeute) $status = 'heute'; if ($lastSeen === null) $status = 'nie'; $help = false; $state = null; $idleMs = null; $activeMs = null; if ($active) { $state = $active['state_json'] ? json_decode($active['state_json'], true) : null; $help = $needsHelp($active['module_id'], $state); // Aktivitäts-/Idle-Flags aus live-client.js $idleMs = isset($state['__idleMs']) ? (int)$state['__idleMs'] : null; $activeMs = isset($state['__activeMs']) ? (int)$state['__activeMs'] : null; } // Idle: noch im Tab, aber > IDLE_THRESHOLD_MS ohne Interaktion. // Lehrer:in soll das sehen — "stockt", nicht "ist weg". $idleThreshold = defined('GGS_IDLE_THRESHOLD_MS') ? GGS_IDLE_THRESHOLD_MS : 90000; if ($status === 'aktiv' && $idleMs !== null && $idleMs >= $idleThreshold) { $status = 'idle'; } $bench = isset($benchByStudent[$sid]) && $benchByStudent[$sid]['n'] > 0 ? round($benchByStudent[$sid]['sum'] / $benchByStudent[$sid]['n']) : null; $studentsList[] = [ 'studentId' => $sid, 'displayName' => $s['display_name'] ?: $s['username'], 'username' => $s['username'], 'emoji' => $s['emoji_avatar'], 'avatarSlug' => $s['avatar_slug'], 'status' => $status, // aktiv | idle | heute | kalt | nie 'needsHelp' => $help, 'lastSeen' => $lastSeen, 'lastSeenTs' => $lastSeen ? strtotime($lastSeen) : null, 'benchmarkPct' => $bench, 'activeSim' => $active ? $active['module_id'] : null, 'activeSimName'=> $active && isset($moduleMap[$active['module_id']]) ? $moduleMap[$active['module_id']]['title'] : null, 'activeSimIcon'=> $active && isset($moduleMap[$active['module_id']]) ? $moduleMap[$active['module_id']]['icon'] : null, 'state' => $state, 'sessionStartedTs' => $active ? ((int)$active['started_at_ts']) * 1000 : null, 'lastHeartbeatSec' => $active ? max(0, $nowTs - (int)$active['last_seen_ts']) : null, 'idleMs' => $idleMs, 'activeMs' => $activeMs, ]; } // 8) Status-Aggregat $statusCount = ['aktiv'=>0, 'idle'=>0, 'heute'=>0, 'kalt'=>0, 'nie'=>0, 'hilfe'=>0]; foreach ($studentsList as $s) { $statusCount[$s['status']]++; if ($s['needsHelp']) $statusCount['hilfe']++; } // 9) Top 3 + Bottom 3 nach Benchmark (nur Schüler:innen mit benchmarkPct != null) $ranked = array_filter($studentsList, function($s) { return $s['benchmarkPct'] !== null; }); usort($ranked, function($a, $b) { return $b['benchmarkPct'] - $a['benchmarkPct']; }); $top3 = array_slice($ranked, 0, 3); $bot3 = array_slice(array_reverse($ranked), 0, 3); // 10) Modul-Liste mit Farben (für Klassen-Säulen-Legende) $modulesActive = []; foreach ($weeklyByDay as $bucket) { foreach ($bucket['byModule'] as $mid => $sec) { if (!isset($modulesActive[$mid]) && isset($moduleMap[$mid])) { $modulesActive[$mid] = [ 'id' => $mid, 'name' => $moduleMap[$mid]['title'], 'icon' => $moduleMap[$mid]['icon'], 'color' => $moduleMap[$mid]['color'] ?? '#888', ]; } } } Response::ok([ 'classId' => $classId, 'range' => $range, 'buckets' => $buckets, 'weeklyByDay' => array_values($weeklyByDay), 'weeklyMaxSec' => $weeklyMaxSec, 'statusCount' => $statusCount, 'top3' => array_values($top3), 'bottom3' => array_values($bot3), 'students' => $studentsList, 'modulesActive' => array_values($modulesActive), 'serverTs' => $nowTs * 1000, ]); } $rows = $db->fetchAll( 'SELECT ls.student_id, ls.module_id, ls.state_json, ls.started_at, ls.last_seen, UNIX_TIMESTAMP(ls.started_at) AS started_at_ts, UNIX_TIMESTAMP(ls.last_seen) AS last_seen_ts, s.username, s.display_name, s.emoji_avatar, mi.title AS module_name, mi.icon AS module_icon, mi.play_url AS play_url FROM live_sessions ls JOIN students s ON s.id = ls.student_id LEFT JOIN module_info mi ON mi.module_id = ls.module_id WHERE ls.class_id = ? AND ls.last_seen > NOW() - INTERVAL ? SECOND ORDER BY ls.last_seen DESC', [$classId, LIVE_ACTIVE_WINDOW_SEC] ); $list = array_map(function($r) { return [ 'student_id' => (int)$r['student_id'], 'username' => $r['username'], 'displayName' => $r['display_name'] ?: $r['username'], 'emoji' => $r['emoji_avatar'] ?: '🧑‍🎓', 'module_id' => $r['module_id'], 'moduleName' => $r['module_name'] ?: $r['module_id'], 'moduleIcon' => $r['module_icon'] ?: '📚', 'playUrl' => $r['play_url'] ?: $r['module_id'], 'state' => $r['state_json'] ? json_decode($r['state_json'], true) : null, 'started_at' => $r['started_at'], 'last_seen' => $r['last_seen'], 'started_at_ms' => $r['started_at_ts'] ? ((int)$r['started_at_ts']) * 1000 : null, 'last_seen_ms' => $r['last_seen_ts'] ? ((int)$r['last_seen_ts']) * 1000 : null, ]; }, $rows); Response::ok($list); } Response::error('Methode nicht erlaubt', 405);