Files
geograsim/App/php/api/live.php
T
Adminator 4e38b27c64 Tag-Ansicht: echte Spieldauer + Ampelfarbe nach Erfolg + Sim-Symbol
Bug: fertig gespielte Laeufe (Farmer-Endbericht = 1 Assessment) erschienen als
kurzer "Abbruch"-Balken (Dauer 0). Fix:
- live.php summary: nutzt duration_ms -> Balken decken die echte Spielzeit ab und
  fluchten am Start; kein Falsch-Abbruch mehr. Neues Feld `score` (0-100) je
  Session (Score/Sterne); laufende Farmer-Sessions bekommen hit_rate als Live-Score.
- teacher.html Tag-Balken: einheitliche Hoehe (Hoehe raus), Farbe = Ampel nach
  Erfolg (gruen->rot), App-Farbe ersetzt durch Sim-Symbol; Klick = Details.
  Legende angepasst.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 12:56:47 +02:00

923 lines
46 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;
// 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.id = ss.student_id
WHERE a.class_id = ?
AND a.submitted_at >= ?
AND a.submitted_at < ?
ORDER BY s.id, a.sim_id, a.submitted_at ASC",
[$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;
};
// Erfolgs-Score 0100 (für die Ampelfarbe der Tages-Balken). Sterne → %,
// sonst der Sim-Score (Farmer/Weltküche liefern `score`).
$extractScore = function($row) {
$r = $row['results'] ? json_decode($row['results'], true) : [];
foreach ([$r['score'] ?? null, $r['results']['score'] ?? null] as $c) if (is_numeric($c)) return max(0, min(100, (int)round($c)));
if (isset($r['stars']) && is_numeric($r['stars'])) return (int)round($r['stars'] / 5 * 100);
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,
'score' => null,
'durationMs' => 0,
'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;
$scv = $extractScore($row);
if ($scv !== null && ($currentSess['score'] === null || $scv > $currentSess['score'])) $currentSess['score'] = $scv;
$dmv = (int)($row['duration_ms'] ?? 0);
if ($dmv > $currentSess['durationMs']) $currentSess['durationMs'] = $dmv;
$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));
// Echte Spieldauer aus duration_ms — ein einzelnes Assessment (z. B. der
// Farmer-Endbericht) hätte sonst Dauer ~0 und würde faelschlich als
// „Abbruch" gewertet. Startzeit entsprechend zurückdatieren, damit der
// Balken die ganze Spielzeit abdeckt (und gleich startende Läufe fluchten).
if (!empty($s['durationMs']) && $s['durationMs'] > $durSec * 1000) {
$durSec = intval($s['durationMs'] / 1000);
$s['startMs'] = $s['endMs'] - $s['durationMs'];
}
// 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['score'] !== null) {
$success = $s['score'] >= 65 ? 'good' : ($s['score'] >= 40 ? 'mid' : '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'],
'score' => $s['score'],
'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));
// Live-Erfolgs-Score für die Ampelfarbe: Farmer = aktuelle Trefferquote.
$liveScore = ($simId === 'farmer' && isset($state['hit_rate'])) ? max(0, min(100, (int)$state['hit_rate'])) : null;
$byStudent[$sid]['sessions'][] = [
'simId' => $simId,
'simTitle' => $mi['title'] ?? $simId,
'simIcon' => $mi['icon'] ?? '📚',
'lessonKind' => $lessonKind,
'lessonLabel' => $lessonLabel,
'startMs' => $startMs,
'endMs' => $endMs,
'durationSec' => $durSec,
'stars' => null,
'score' => $liveScore,
'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.student_id = ?
AND a.submitted_at BETWEEN ? AND ?
ORDER BY a.submitted_at DESC',
[$classId, $simId, $studentId, $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.student_id = s.id
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.student_id = s.id
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.id = ss.student_id
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':
// Konto im Minus ODER dauerhaft schlechte Trefferquote (falsche
// Boden/Klima-Wahl) — auch wenn das Konto (noch) im Plus ist.
return ($state['cash'] ?? 1000) < 0
|| (($state['attempts'] ?? 0) >= 6 && isset($state['hit_rate']) && $state['hit_rate'] < 30);
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.id = ss.student_id
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);