Atlas: Live-View-Feature + Submit-Bug-Fixes + Admin-Styleguide

- Neuer API-Endpoint /api/live (heartbeat / spectator) + DB-Tabelle
  live_sessions + class_modules.paused-Spalte
- Plattform-Live-Client (assets/js/live-client.js): Heartbeat,
  Pause-Overlay, Spectator-Mode bei ?view=teacher
- ggs_inject_live() in _scripts.php als Helper, in alle 11 Sim-Wrapper
  integriert
- Lehrer-Cockpit: tabellarische Klassen-Live-Ansicht pro Sim,
  Pause-Toggle, blinkender Tab-Indikator wenn aktive Sessions
- Submit-Bug erschlagen: progress.php hat jetzt submit_assessment-
  und reflection-Action; saves.php akzeptiert beide Key-Konventionen;
  alle Direkt-Pfad-API-Files mit require_once-Bootstrap
- Avatar-Default: zufaelliger DALL-E-Avatar fuer neue Schueler:innen
- Admin-Styleguide-Seite mit Bildstil, Farb-Tokens, Layout, iPad-Pattern
- Klima 2D + 3D: GGS_LIVE_STATE-Hook, Klima 2D zusaetzlich skipResume
  fuer Klassenaufgaben-Reset
- Test-Lehrer Jakob + 3 Schueler:innen lokal + auf Live angelegt
- Rundbriefe an alle Sim-Instanzen + Submit-Briefe an sonnensystem,
  busfahrt, energiemanager + Glossar-Anfrage zu Grundriess
- Status-Uebergabe in _inbox/zentrale/_status.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 01:20:53 +02:00
parent e354a30a44
commit 33fb423967
56 changed files with 4869 additions and 226 deletions
+110 -19
View File
@@ -4,7 +4,14 @@
* GET /api/modules?class_id=X → Modulstatus für Klasse
* GET /api/modules?class_id=X&matrix=1 → Matrix: alle Schüler × alle Module
* POST /api/modules {action} → Modul freigeben/sperren
*
* Bootstrap: idempotent, damit auch direkter /php/api/modules.php-Aufruf funktioniert
* (live-client.js pollt von der Sim aus diesen Direkt-Pfad).
*/
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();
@@ -13,7 +20,7 @@ $db = Database::get();
// Nur aktive + beta + geplante Module anzeigen (keine archivierten)
$ALL_MODULES = $db->fetchAll(
"SELECT module_id AS id, title AS name, short_desc AS `desc`, icon,
status, play_url, sort_order
card_image, status, play_url, sort_order
FROM module_info
WHERE status IN ('aktiv','beta','geplant')
ORDER BY sort_order, title"
@@ -28,17 +35,37 @@ if ($method === 'GET') {
// Kein Klasse = alle Module frei (Autodidakt)
$result = [];
foreach ($ALL_MODULES as $mod) {
$result[] = ['id' => $mod['id'], 'name' => $mod['name'], 'icon' => $mod['icon'], 'mode' => 'free'];
$result[] = [
'id' => $mod['id'],
'name' => $mod['name'],
'desc' => $mod['desc'],
'icon' => $mod['icon'],
'card_image' => $mod['card_image'],
'status' => $mod['status'],
'play_url' => $mod['play_url'],
'mode' => $mod['status'] === 'geplant' ? 'coming_soon' : 'free',
];
}
Response::ok($result);
}
$classId = $session['class_id'];
$classMap = [];
$settings = $db->fetchAll(
'SELECT module_id, mode, current_level, quiz_enabled, due_date
'SELECT module_id, mode, current_level, quiz_enabled, due_date, started_at, paused
FROM class_modules WHERE class_id = ?', [$classId]);
foreach ($settings as $s) $classMap[$s['module_id']] = $s;
// Pro Sim den letzten Abschluss-Zeitpunkt des Schülers laden (assessments).
// Wird gegen class_modules.started_at verglichen, um teacher_started-
// Aufträge als "erledigt" zu markieren, sobald der Schüler nach dem
// Auftrag-Start einmal abgeschlossen hat.
$lastDone = [];
$rows = $db->fetchAll(
'SELECT sim_id, MAX(submitted_at) AS last_at FROM assessments WHERE session_id = ? GROUP BY sim_id',
[$sessionId]
);
foreach ($rows as $r) $lastDone[$r['sim_id']] = $r['last_at'];
// Individuelle Overrides für diesen Schüler
$studentRow = $db->fetchOne(
'SELECT s.id FROM students s JOIN student_sessions ss ON ss.class_id = s.class_id AND ss.display_name = s.display_name WHERE ss.id = ?',
@@ -58,15 +85,30 @@ if ($method === 'GET') {
if ($filterId && $mod['id'] !== $filterId) continue;
$cm = $classMap[$mod['id']] ?? null;
$mode = $overrides[$mod['id']] ?? ($cm['mode'] ?? 'locked');
// 'geplant' überschreibt jeden mode-Eintrag — Modul ist nicht spielbar.
if ($mod['status'] === 'geplant') $mode = 'coming_soon';
// Lehrer-Auftrag erledigt? (assessment-submit nach class_modules.started_at)
$assignmentCompleted = false;
if ($mode === 'teacher_started' && $cm && !empty($cm['started_at'])) {
$done = $lastDone[$mod['id']] ?? null;
if ($done && $done > $cm['started_at']) $assignmentCompleted = true;
}
$result[] = [
'id' => $mod['id'],
'name' => $mod['name'],
'icon' => $mod['icon'],
'play_url' => $mod['play_url'],
'mode' => $mode,
'forcedLevel' => ($mode === 'teacher_started' && $cm) ? (int)($cm['current_level'] ?? 1) : null,
'quizEnabled' => (bool)($cm['quiz_enabled'] ?? 0),
'dueDate' => $cm['due_date'] ?? null,
'id' => $mod['id'],
'name' => $mod['name'],
'desc' => $mod['desc'],
'icon' => $mod['icon'],
'card_image' => $mod['card_image'],
'status' => $mod['status'],
'play_url' => $mod['play_url'],
'mode' => $mode,
'forcedLevel' => ($mode === 'teacher_started' && $cm) ? (int)($cm['current_level'] ?? 1) : null,
'quizEnabled' => (bool)($cm['quiz_enabled'] ?? 0),
'dueDate' => $cm['due_date'] ?? null,
'paused' => (bool)($cm['paused'] ?? 0),
'assignmentCompleted' => $assignmentCompleted,
];
}
// Wenn einzelnes Modul, liefere Objekt statt Array
@@ -83,10 +125,17 @@ if ($method === 'GET') {
$class = $db->fetchOne('SELECT id FROM classes WHERE id = ? AND teacher_id = ?', [$classId, $teacherId]);
if (!$class) Response::error('Klasse nicht gefunden', 404);
// Klassen-Defaults laden
$settings = $db->fetchAll('SELECT module_id, mode FROM class_modules WHERE class_id = ?', [$classId]);
// Klassen-Defaults laden (inkl. Level + Pause-Status für die Lehrer-UI)
$settings = $db->fetchAll(
'SELECT module_id, mode, current_level, paused, due_date, started_at FROM class_modules WHERE class_id = ?',
[$classId]
);
$classMap = [];
foreach ($settings as $s) $classMap[$s['module_id']] = $s['mode'];
$classMeta = [];
foreach ($settings as $s) {
$classMap[$s['module_id']] = $s['mode'];
$classMeta[$s['module_id']] = $s;
}
// Matrix-Modus: alle Schüler × alle Module
if (isset($_GET['matrix']) && $_GET['matrix'] === '1') {
@@ -128,12 +177,19 @@ if ($method === 'GET') {
// Modul-Infos mit Klassen-Defaults
$modulesInfo = [];
foreach ($ALL_MODULES as $mod) {
$meta = $classMeta[$mod['id']] ?? null;
$modulesInfo[] = [
'id' => $mod['id'],
'name' => $mod['name'],
'desc' => $mod['desc'],
'icon' => $mod['icon'],
'classMode' => $classMap[$mod['id']] ?? 'locked',
'card_image' => $mod['card_image'],
'status' => $mod['status'],
'classMode' => $classMap[$mod['id']] ?? 'locked',
'currentLevel' => $meta ? (int)($meta['current_level'] ?? 1) : 1,
'paused' => $meta ? (bool)($meta['paused'] ?? 0) : false,
'dueDate' => $meta['due_date'] ?? null,
'startedAt' => $meta['started_at'] ?? null,
];
}
@@ -148,6 +204,7 @@ if ($method === 'GET') {
'name' => $mod['name'],
'desc' => $mod['desc'],
'icon' => $mod['icon'],
'status' => $mod['status'],
'mode' => $classMap[$mod['id']] ?? 'locked',
];
}
@@ -166,16 +223,50 @@ if ($method === 'POST') {
$class = $db->fetchOne('SELECT id FROM classes WHERE id = ? AND teacher_id = ?', [$classId, $teacherId]);
if (!$class) Response::error('Klasse nicht gefunden', 404);
// Schutz: 'geplant'-Module dürfen NICHT freigeschaltet werden — sie sind
// im Lehrer-UI auch nur als 'In Vorbereitung' sichtbar.
$modStatus = $db->fetchOne('SELECT status FROM module_info WHERE module_id = ?', [$moduleId]);
if (!$modStatus || $modStatus['status'] === 'geplant' || $modStatus['status'] === 'archiv') {
Response::error('Modul ist noch in Vorbereitung und kann nicht freigeschaltet werden.');
}
// Klassen-Default setzen
if ($action === 'set_mode') {
$mode = $body['mode'] ?? 'locked';
if (!in_array($mode, ['locked', 'free', 'teacher_started'])) Response::error('Ungültiger Modus');
$level = isset($body['currentLevel']) ? max(1, min(3, (int)$body['currentLevel'])) : 1;
$dueDate = !empty($body['dueDate']) ? $body['dueDate'] : null;
$paused = !empty($body['paused']) ? 1 : 0;
$db->execute(
'INSERT INTO class_modules (class_id, module_id, enabled, mode)
VALUES (?, ?, 1, ?)
ON DUPLICATE KEY UPDATE mode = VALUES(mode), enabled = 1',
[$classId, $moduleId, $mode]
'INSERT INTO class_modules (class_id, module_id, enabled, mode, current_level, due_date, paused)
VALUES (?, ?, 1, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE mode = VALUES(mode), enabled = 1,
current_level = VALUES(current_level), due_date = VALUES(due_date), paused = VALUES(paused)',
[$classId, $moduleId, $mode, $level, $dueDate, $paused]
);
// Bei "teacher_started" started_at frisch setzen — markiert Auftragsstart
// und macht alte assessments unter dieser started_at zu "vorher".
if ($mode === 'teacher_started') {
$db->execute(
'UPDATE class_modules SET started_at = NOW() WHERE class_id = ? AND module_id = ?',
[$classId, $moduleId]
);
} else {
$db->execute(
'UPDATE class_modules SET started_at = NULL, paused = 0 WHERE class_id = ? AND module_id = ?',
[$classId, $moduleId]
);
}
Response::ok();
}
// Auftrag pausieren / fortsetzen
if ($action === 'pause' || $action === 'resume') {
$paused = $action === 'pause' ? 1 : 0;
$db->execute(
'UPDATE class_modules SET paused = ? WHERE class_id = ? AND module_id = ?',
[$paused, $classId, $moduleId]
);
Response::ok();
}