ebdd1ce438
- LIVE_PRIMARY_FIELDS.weltkueche: Gericht/Herkunft/Zutaten/Gerichte/Fehlklicks (statt Rohdaten-Dump aller State-Keys) — 5 Spalten, kein Quer-Scroll - Idle/Pause: live.php Fenster 30s->2h (LIVE_IDLE_WINDOW_SEC); Aktuell zeigt laenger inaktive Sessions als "pausiert" (⏸, grau) statt sie zu verlieren; braucht-Hilfe + aktiv-Zaehler nur fuer wirklich aktive Sessions Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
892 lines
44 KiB
PHP
892 lines
44 KiB
PHP
<?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;
|
||
// Pausierte Sessions (Tab weg / lange idle) bleiben so lange in der Live-Liste
|
||
// sichtbar — der/die Lernende kann später weitermachen, die Lehrkraft sieht die
|
||
// Session als „pausiert" statt sie fälschlich als beendet zu verlieren.
|
||
const LIVE_IDLE_WINDOW_SEC = 7200; // 2 h
|
||
|
||
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,
|
||
]);
|
||
}
|
||
|
||
// ===================================================================
|
||
// SCHÜLER-DRILLDOWN — student_runs=<id>
|
||
// Aktuelle Sim-Parameter: letzte Läufe (detail_json) + Live-State + Recency.
|
||
// ===================================================================
|
||
if (isset($_GET['student_runs'])) {
|
||
$studentId = (int)$_GET['student_runs'];
|
||
$stu = $db->fetchOne(
|
||
'SELECT s.id, s.display_name, s.username, s.emoji_avatar
|
||
FROM students s JOIN classes c ON c.id = s.class_id
|
||
WHERE s.id = ? AND c.teacher_id = ?',
|
||
[$studentId, $teacherId]);
|
||
if (!$stu) Response::error('Schüler:in nicht gefunden', 404);
|
||
|
||
$runs = $db->fetchAll(
|
||
'SELECT module_id, score, duration_sec, detail_json, completed_at
|
||
FROM student_results WHERE student_id = ? ORDER BY completed_at DESC LIMIT 12',
|
||
[$studentId]);
|
||
$live = $db->fetchOne(
|
||
'SELECT module_id, state_json, UNIX_TIMESTAMP(last_seen) AS last_seen_ts
|
||
FROM live_sessions WHERE student_id = ? ORDER BY last_seen DESC LIMIT 1',
|
||
[$studentId]);
|
||
$active = $live && ((int)$live['last_seen_ts'] >= time() - LIVE_ACTIVE_WINDOW_SEC);
|
||
|
||
$progress = $db->fetchAll(
|
||
'SELECT sim_id, plays, best_stars, level FROM player_progress WHERE student_id = ?',
|
||
[$studentId]);
|
||
|
||
// Recency-Kennzahlen aus den Läufen
|
||
$recentScores = []; $daysSince = null;
|
||
foreach ($runs as $i => $r) {
|
||
if ($daysSince === null && $r['completed_at']) $daysSince = (int)floor((time() - strtotime($r['completed_at'])) / 86400);
|
||
if ($i < 3 && $r['score'] !== null) $recentScores[] = (float)$r['score'];
|
||
}
|
||
$recentErfolg = $recentScores ? (int)round(array_sum($recentScores) / count($recentScores)) : null;
|
||
|
||
Response::ok([
|
||
'studentId' => $studentId,
|
||
'displayName' => $stu['display_name'] ?: $stu['username'],
|
||
'emoji' => $stu['emoji_avatar'] ?: '🧑🎓',
|
||
'recentErfolg' => $recentErfolg,
|
||
'daysSince' => $daysSince,
|
||
'progress' => array_map(function ($p) {
|
||
return ['simId'=>$p['sim_id'], 'plays'=>(int)$p['plays'], 'best_stars'=>(int)$p['best_stars'], 'level'=>(int)$p['level']];
|
||
}, $progress),
|
||
'live' => $active ? ['moduleId' => $live['module_id'], 'state' => json_decode($live['state_json'], true)] : null,
|
||
'runs' => array_map(function ($r) {
|
||
return [
|
||
'moduleId' => $r['module_id'],
|
||
'score' => $r['score'] !== null ? (float)$r['score'] : null,
|
||
'durationSec' => (int)$r['duration_sec'],
|
||
'completedAt' => $r['completed_at'],
|
||
'params' => $r['detail_json'] ? json_decode($r['detail_json'], true) : null,
|
||
];
|
||
}, $runs),
|
||
]);
|
||
}
|
||
|
||
// 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_IDLE_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);
|