Files
geograsim/App/php/api/live.php
T
Adminator 1f00d83232 Übersicht: Empfehlung nur aus AKTUELLER Leistung (Recency)
Denkfehler behoben — eine Empfehlung reagiert auf das Zuletzt-Gesehene, nicht auf
einen Allzeit-Rückblick. live.php cockpit liefert jetzt recentErfolg (Ø letzte 3
Läufe), earlyErfolg (frühere Läufe), daysSince, recentPlays. Triage rechnet darauf:
>14 Tage weg → Nachfassen; recent schwach → Fördern; recent+viel → Begleiten; recent
stark, kaum gefordert → Fordern; recent > früher → Bestätigen. Neue Spalte „Zuletzt",
Legende erklärt: Erfolg = letzte Läufe, Verlauf = nur Historie. 4b-Daten mit
kontrollierter Recency neu (jede Gruppe besetzt).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:11:35 +02:00

831 lines
41 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* API: Live-Sessions (Lehrer schaut Schüler*innen live beim Spielen zu)
*
* POST /api/live {action:'heartbeat', module_id, state} → Schüler-Sim, alle 3-5s
* GET /api/live?class_id=X → Lehrer: alle aktiven Sessions der Klasse
* GET /api/live?student_id=X → Lehrer: state eines Schülers für Spectator-View
*
* "Aktiv" = letzter Heartbeat liegt höchstens 30 s zurück.
*
* Bootstrap: idempotent, falls die Datei direkt (nicht über index.php) angesprochen wird.
*/
require_once __DIR__ . '/../config/app.php';
require_once __DIR__ . '/../lib/Database.php';
require_once __DIR__ . '/../lib/Session.php';
require_once __DIR__ . '/../lib/Response.php';
$method = $_SERVER['REQUEST_METHOD'];
$db = Database::get();
const LIVE_ACTIVE_WINDOW_SEC = 30;
if ($method === 'POST') {
$sessionId = Session::requireStudent();
$body = json_decode(file_get_contents('php://input'), true);
$action = $body['action'] ?? 'heartbeat';
if ($action === 'heartbeat') {
$moduleId = (string)($body['module_id'] ?? $body['moduleId'] ?? '');
if ($moduleId === '' || strlen($moduleId) > 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']++;
}
// 4b) RECENCY — Grundlage für die Interventions-Empfehlung: nur die AKTUELLE
// Leistung zählt (kein Allzeit-Rückblick). Aus student_results:
// recentErfolg = Ø der letzten 3 Läufe · daysSince = Tage seit letztem Lauf
// recentPlays = Läufe der letzten 14 Tage
$recentByStudent = [];
foreach ($db->fetchAll(
"SELECT student_id,
DATEDIFF(NOW(), MAX(completed_at)) AS days_since,
SUM(completed_at >= NOW() - INTERVAL 14 DAY) AS recent_plays
FROM student_results WHERE class_id = ? GROUP BY student_id",
[$classId]) as $r) {
$recentByStudent[(int)$r['student_id']] = [
'daysSince' => $r['days_since'] !== null ? (int)$r['days_since'] : null,
'recentPlays' => (int)$r['recent_plays'],
'recentErfolg' => null,
];
}
foreach ($db->fetchAll(
"SELECT student_id,
ROUND(AVG(CASE WHEN rn <= 3 THEN score END)) AS recent_erfolg,
ROUND(AVG(CASE WHEN rn > 3 THEN score END)) AS early_erfolg
FROM (
SELECT student_id, score,
ROW_NUMBER() OVER (PARTITION BY student_id ORDER BY completed_at DESC, id DESC) rn
FROM student_results WHERE class_id = ?
) x GROUP BY student_id",
[$classId]) as $r) {
$sid = (int)$r['student_id'];
if (!isset($recentByStudent[$sid])) $recentByStudent[$sid] = ['daysSince'=>null,'recentPlays'=>0,'recentErfolg'=>null];
$recentByStudent[$sid]['recentErfolg'] = $r['recent_erfolg'] !== null ? (int)$r['recent_erfolg'] : null;
$recentByStudent[$sid]['earlyErfolg'] = $r['early_erfolg'] !== null ? (int)$r['early_erfolg'] : null;
}
// 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;
case 'tourismusregion':
return ($state['budget'] ?? 1) < -400 || ($state['env'] ?? 100) < 30;
}
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,
'recentErfolg' => $recentByStudent[$sid]['recentErfolg'] ?? null,
'earlyErfolg' => $recentByStudent[$sid]['earlyErfolg'] ?? null,
'daysSince' => $recentByStudent[$sid]['daysSince'] ?? null,
'recentPlays' => $recentByStudent[$sid]['recentPlays'] ?? 0,
'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);