Files
geograsim/App/php/api/results.php
T
Adminator 8047778a5b Tourismusregion: Komplett-Integration (zweite neue Sim, beta)
Wellenbasierte Routen-Sim (DefensegeograSimMac) nach dem Tourismustal-
Playbook integriert: Sim nach App/sims/tourismusregion (game.html,
Sprites 47MB->9.5MB, ohne Eigen-Backend/Editor/Dashboard/Keys),
Plattform-Hooks (GGS_LIVE_STATE mit Welle/Budget/Umwelt/Sterne/Lenkung,
Submit bei Partie-Ende mit fertigem 0-100-Score, GGS_TUTORIAL, Home-
Button, Musik leise). Wrapper + Modul-Detailseite. SQL: module_info
(beta, Karte), Glossar-Kernbegriff Besucherlenkung (+Bild, Leichte
Sprache) + 12 Zuordnungen, Lehrplan-Mapping auf tourismus-
raumentwicklung (primaer) + verkehrserziehung/systemisches-denken.
LEHRPLAN.md mit Schwerpunkt Besucherlenkung & Mobilitaet. Lehrer-
Backend: Benchmark (Sim-Score direkt), Live-Cockpit-Spalten,
needsHelp, Highlights.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:14:12 +02:00

736 lines
30 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: Lehrer-Ergebnisse
*
* GET /api/results?class_id=X&view=matrix
* → Schüler*in × Modul Matrix mit Sterne/Durchgänge/XP plus Cross-Modul-Total
*
* GET /api/results?class_id=X&view=activity&range=day|week|month|year
* → Stacked-Bar-Buckets pro Zeit-Einheit, gefärbt nach Modul
*
* GET /api/results?class_id=X&view=module&module_id=Y
* → Modul-Detail: Schüler-Ranking + Klassen-Aggregat
*
* GET /api/results?class_id=X&view=module&module_id=Y&student_id=Z
* → Einzel-Schüler Detail: alle Durchgänge mit results-JSON
*
* Auth: Session::requireTeacher() + Klassen-Owner-Check.
* Performance: ein Aggregations-SQL pro View (kein N+1).
*/
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
Response::error('Nur GET erlaubt', 405);
}
$teacherId = Session::requireTeacher();
$db = Database::get();
// Pro Modul die wichtigsten Felder aus assessments.results-JSON.
// agg = min|max ("besser ist niedriger" vs "besser ist höher")
// format = percent|integer (Anzeige-Hint), unit = optionale Einheit hinter dem Wert.
$MODULE_HIGHLIGHTS = [
'klima' => [
['key'=>'final_co2', 'label'=>'CO₂', 'agg'=>'min', 'unit'=>'ppm'],
['key'=>'final_temperature', 'label'=>'ΔTemp', 'agg'=>'min', 'unit'=>'°C'],
['key'=>'final_budget', 'label'=>'Budget', 'agg'=>'max', 'unit'=>'Mio'],
['key'=>'final_flooded_pct', 'label'=>'Überflutet','agg'=>'min', 'unit'=>'%'],
],
'klima-3d' => [
['key'=>'final_co2', 'label'=>'CO₂', 'agg'=>'min', 'unit'=>'ppm'],
['key'=>'final_temperature', 'label'=>'ΔTemp', 'agg'=>'min', 'unit'=>'°C'],
['key'=>'final_budget', 'label'=>'Budget', 'agg'=>'max', 'unit'=>'Mio'],
['key'=>'final_flooded_pct', 'label'=>'Überflutet','agg'=>'min', 'unit'=>'%'],
],
'farmer' => [
['key'=>'results.money', 'label'=>'Geld', 'agg'=>'max', 'unit'=>'€'],
['key'=>'results.success_rate', 'label'=>'Trefferquote','agg'=>'max','format'=>'percent'],
['key'=>'results.year_reached', 'label'=>'Jahr', 'agg'=>'max'],
['key'=>'results.co2_kg', 'label'=>'CO₂', 'agg'=>'min', 'unit'=>'kg'],
['key'=>'results.crops_used', 'label'=>'Sorten', 'agg'=>'max'],
],
'eu-werkstatt' => [
['key'=>'firstTryHits', 'label'=>'Erste Wahl','agg'=>'max'],
['key'=>'totalAttempts', 'label'=>'Versuche', 'agg'=>'min'],
['key'=>'muelleimerCount','label'=>'Mülleimer', 'agg'=>'min'],
],
'sonnensystem' => [
['key'=>'firstTryHits', 'label'=>'Erste Wahl','agg'=>'max'],
['key'=>'totalAttempts', 'label'=>'Versuche', 'agg'=>'min'],
],
'energiemanager' => [
['key'=>'blocksCorrect', 'label'=>'Blöcke ok', 'agg'=>'max'],
['key'=>'deficit', 'label'=>'Defizit', 'agg'=>'min'],
],
'busfahrt' => [
['key'=>'cities_correct', 'label'=>'Treffer', 'agg'=>'max'],
['key'=>'tries_avg', 'label'=>'Ø Versuche','agg'=>'min'],
],
'logistik' => [
['key'=>'contracts_done', 'label'=>'Aufträge', 'agg'=>'max'],
['key'=>'co2_total', 'label'=>'CO₂', 'agg'=>'min', 'unit'=>'kg'],
['key'=>'profit', 'label'=>'Gewinn', 'agg'=>'max', 'unit'=>'€'],
],
'heli' => [
['key'=>'routes_completed','label'=>'Routen', 'agg'=>'max'],
['key'=>'avg_accuracy', 'label'=>'Genauigkeit','agg'=>'max','format'=>'percent'],
],
'tourismusregion' => [
['key'=>'results.score', 'label'=>'Score', 'agg'=>'max'],
['key'=>'results.avgStars', 'label'=>'Ø Sterne', 'agg'=>'max'],
['key'=>'results.revenue', 'label'=>'Wertschöpfung','agg'=>'max', 'unit'=>'€'],
['key'=>'results.env', 'label'=>'Umwelt', 'agg'=>'max'],
['key'=>'results.goalsDone','label'=>'Ziele', 'agg'=>'max'],
],
'tourismustal' => [
['key'=>'results.money', 'label'=>'Konto', 'agg'=>'max', 'unit'=>'€'],
['key'=>'results.maxGuests', 'label'=>'Gäste-Rekord','agg'=>'max'],
['key'=>'results.maxSummerGuests', 'label'=>'Sommer-Rekord','agg'=>'max'],
['key'=>'results.nature', 'label'=>'Naturwert', 'agg'=>'max', 'unit'=>'%'],
['key'=>'results.goalsDone', 'label'=>'Ziele', 'agg'=>'max'],
],
];
// Hilfsfunktion: holt einen Wert aus einem JSON-Pfad ("results.money" → $json['results']['money']).
function _hl_get($json, $path) {
$parts = explode('.', $path);
$v = $json;
foreach ($parts as $p) {
if (!is_array($v) || !array_key_exists($p, $v)) return null;
$v = $v[$p];
}
return is_scalar($v) ? $v : null;
}
$classId = (int)($_GET['class_id'] ?? 0);
if (!$classId) Response::error('class_id erforderlich');
// Klasse muss diesem Lehrer gehören
$class = $db->fetchOne('SELECT id, name FROM classes WHERE id = ? AND teacher_id = ?', [$classId, $teacherId]);
if (!$class) Response::error('Klasse nicht gefunden', 404);
$view = $_GET['view'] ?? 'matrix';
// === Modul-Katalog (gemeinsam genutzt) ===
$modules = $db->fetchAll(
"SELECT module_id AS id, title AS name, icon, card_image, sort_order, status
FROM module_info
WHERE status IN ('aktiv','beta')
ORDER BY sort_order"
);
// Konsistente Modul-Farben aus dem hash des module_id (deterministisch).
$palette = ['#1f4b37','#4a7c4e','#e8c547','#b04a3e','#7099a0','#8b5a2b','#a37800','#5a8a8e','#c07a6b','#3d6671','#a3c167','#d8453a'];
foreach ($modules as &$m) {
$h = 0;
for ($i = 0; $i < strlen($m['id']); $i++) $h = ($h * 31 + ord($m['id'][$i])) & 0xFFFFFFFF;
$m['color'] = $palette[abs($h) % count($palette)];
}
unset($m);
// =============================================================================
// VIEW 1: MATRIX
// =============================================================================
if ($view === 'matrix') {
// Alle Schüler der Klasse + ihre player_progress-Einträge in EINEM Query.
$rows = $db->fetchAll(
"SELECT s.id AS student_id, s.username, s.display_name, s.emoji_avatar,
pp.sim_id, pp.best_stars, pp.plays, pp.xp, pp.level
FROM students s
LEFT JOIN player_progress pp ON pp.student_id = s.id
WHERE s.class_id = ? AND s.deleted_at IS NULL
ORDER BY s.display_name, s.username",
[$classId]
);
$students = [];
$cells = []; // {studentId: {moduleId: {stars, plays, xp, level, score, benchmarkPct}}}
$totals = []; // {studentId: {score, plays, xp, benchmarkPct}}
foreach ($rows as $r) {
$sid = (int)$r['student_id'];
if (!isset($students[$sid])) {
$students[$sid] = [
'id' => $sid,
'username' => $r['username'],
'displayName' => $r['display_name'] ?: $r['username'],
'emoji' => $r['emoji_avatar'] ?: '🧑‍🎓',
];
$cells[$sid] = [];
$totals[$sid] = ['score' => 0.0, 'plays' => 0, 'xp' => 0];
}
if ($r['sim_id']) {
$stars = (int)$r['best_stars'];
$plays = (int)$r['plays'];
$xp = (int)$r['xp'];
// score = stars × √plays (Qualität dominant, Übung logarithmisch).
// Bleibt für Sort/Tooltip erhalten; benchmarkPct (siehe unten) ist
// die didaktisch saubere Modul-Bewertung pro Submission.
$score = $stars * sqrt(max(1, $plays));
$cells[$sid][$r['sim_id']] = [
'stars' => $stars,
'plays' => $plays,
'xp' => $xp,
'level' => (int)$r['level'],
'score' => round($score, 2),
];
$totals[$sid]['score'] += $score;
$totals[$sid]['plays'] += $plays;
$totals[$sid]['xp'] += $xp;
}
}
foreach ($totals as &$t) $t['score'] = round($t['score'], 1);
unset($t);
// Benchmark-Score pro Schüler:in × Modul — gleiche Engine wie im Live-Cockpit.
// Pro Modul max. die 3 besten Sessions, Mittelwert; Gesamt = Mittel pro Schüler:in
// über die gespielten Module. Cells/Totals werden non-destructiv ergänzt
// — vorhandene UI (stars/plays/score) bleibt unverändert.
require_once __DIR__ . '/../lib/Benchmark.php';
$benchRows = $db->fetchAll(
"SELECT s.id AS student_id, a.sim_id, a.results, a.process_log
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]
);
$scoresByStudentModule = [];
foreach ($benchRows as $r) {
$sid = (int)$r['student_id'];
$mid = $r['sim_id'];
$score = Benchmark::scoreSubmission($mid, $r['results'], $r['process_log'] ?? null);
if ($score === null) continue;
$k = "$sid|$mid";
if (!isset($scoresByStudentModule[$k])) $scoresByStudentModule[$k] = [];
$scoresByStudentModule[$k][] = $score;
}
foreach ($scoresByStudentModule as $k => $list) {
rsort($list);
$top3 = array_slice($list, 0, 3);
$pct = (int)round(array_sum($top3) / count($top3));
[$sid, $mid] = explode('|', $k, 2);
$sid = (int)$sid;
if (isset($cells[$sid][$mid])) {
$cells[$sid][$mid]['benchmarkPct'] = $pct;
}
}
// Pro Schüler:in Gesamt-Benchmark = Mittelwert über alle Modul-Benchmarks
foreach ($cells as $sid => $cellMap) {
$modPcts = [];
foreach ($cellMap as $c) if (isset($c['benchmarkPct'])) $modPcts[] = $c['benchmarkPct'];
if ($modPcts) $totals[$sid]['benchmarkPct'] = (int)round(array_sum($modPcts) / count($modPcts));
}
Response::ok([
'students' => array_values($students),
'modules' => $modules,
'cells' => $cells,
'totals' => $totals,
]);
}
// =============================================================================
// VIEW: FLEISS (Lernzeit pro Schüler*in, optional Modul-Filter)
// =============================================================================
// Eine Zeile pro Schüler*in (komplette Klassenliste, auch stille).
// Wert: aktive Lernzeit in Sekunden im gewählten Zeitraum.
// Optional gefiltert auf ein Modul; Default ALL (alle Module summiert).
if ($view === 'fleiss') {
$range = $_GET['range'] ?? 'week';
$moduleId = $_GET['module_id'] ?? 'ALL';
// Zeit-Window + Bucket-Funktion je Range — pro Bucket eine Säule.
switch ($range) {
case 'day':
$sinceSql = "a.submitted_at >= NOW() - INTERVAL 1 DAY";
$bucketFn = 'HOUR(a.submitted_at)';
$buckets = []; for ($h = 0; $h < 24; $h++) $buckets[] = sprintf('%02d', $h);
$idxMap = function($b) { return (int)$b; };
$label = 'Heute (24 Stunden)';
break;
case 'week':
// Mo=0, So=6 (WEEKDAY)
$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; };
$label = 'Diese Woche (7 Tage)';
break;
case 'month':
$sinceSql = "a.submitted_at >= NOW() - INTERVAL 31 DAY";
$bucketFn = 'DAY(a.submitted_at)';
$buckets = []; for ($d = 1; $d <= 31; $d++) $buckets[] = (string)$d;
$idxMap = function($b) { return (int)$b - 1; };
$label = 'Letzte 31 Tage';
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; };
$label = 'Letzte 12 Monate';
break;
default: Response::error('range muss day|week|month|year sein');
}
// Optional Modul-Filter
$params = [$classId];
$modSql = '';
if ($moduleId !== 'ALL' && $moduleId !== '') {
$modSql = ' AND a.sim_id = ?';
$params[] = $moduleId;
}
// 1) Pro (Schüler × Bucket × Modul) aggregieren — Lernzeit aus duration_ms.
// Eine Säule pro Bucket im Frontend, gestapelt nach Modul.
$rows = $db->fetchAll(
"SELECT s.id AS student_id,
$bucketFn AS bucket,
a.sim_id,
SUM(COALESCE(a.duration_ms, 0)) / 1000 AS sec,
COUNT(*) AS sessions
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 $modSql
GROUP BY s.id, bucket, a.sim_id",
$params
);
// 2) Komplette Klassenliste (auch stille Schüler*innen)
$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]
);
// 3) Modul-Stammdaten (Icons, Namen, Farben aus modules-Liste oben)
$moduleMap = [];
foreach ($modules as $m) {
$moduleMap[$m['id']] = $m;
}
// 4) Pro Schüler*in eine Struktur mit leeren Bucket-Säulen vorbereiten
$byStudent = [];
foreach ($allStudents as $s) {
$sid = (int)$s['id'];
$emptyBuckets = [];
for ($i = 0; $i < count($buckets); $i++) {
$emptyBuckets[] = ['sum' => 0, 'byModule' => new stdClass()];
}
$byStudent[$sid] = [
'studentId' => $sid,
'displayName' => $s['display_name'] ?: $s['username'],
'avatarSlug' => $s['avatar_slug'],
'emoji' => $s['emoji_avatar'],
'totalSec' => 0,
'sessions' => 0,
'buckets' => $emptyBuckets, // [{sum, byModule:{simId:sec}}, ...]
];
}
// 5) Bucket-Daten einsortieren + Aggregat pro Schüler:in
$moduleAgg = [];
$globalMaxBucketSec = 0;
foreach ($rows as $r) {
$sid = (int)$r['student_id'];
if (!isset($byStudent[$sid])) continue;
$bIdx = $idxMap($r['bucket']);
if ($bIdx < 0 || $bIdx >= count($buckets)) continue;
$sec = (int)round($r['sec']);
$sim = $r['sim_id'];
// In Säule eintragen
$b = &$byStudent[$sid]['buckets'][$bIdx];
$b['sum'] += $sec;
$byMod = (array)$b['byModule'];
$byMod[$sim] = ($byMod[$sim] ?? 0) + $sec;
$b['byModule'] = $byMod;
unset($b);
// Aggregate
$byStudent[$sid]['totalSec'] += $sec;
$byStudent[$sid]['sessions'] += (int)$r['sessions'];
// Modul-Übersicht (für Legende + Filter-Dropdown)
if (!isset($moduleAgg[$sim])) {
$info = $moduleMap[$sim] ?? null;
$moduleAgg[$sim] = [
'id' => $sim,
'name' => $info ? $info['name'] : $sim,
'icon' => $info ? $info['icon'] : '📘',
'color' => $info ? $info['color'] : '#888',
'totalSec' => 0,
];
}
$moduleAgg[$sim]['totalSec'] += $sec;
}
// 6) Global-Max-Bucket-Sec für klassenweite Säulen-Skalierung
foreach ($byStudent as $sid => &$st) {
foreach ($st['buckets'] as $b) {
if ($b['sum'] > $globalMaxBucketSec) $globalMaxBucketSec = $b['sum'];
}
}
unset($st);
// 7) Sortierung: Fleiß absteigend (totalSec), bei Gleichstand alphabetisch
$studentsList = array_values($byStudent);
usort($studentsList, function($a, $b) {
if ($b['totalSec'] !== $a['totalSec']) return $b['totalSec'] - $a['totalSec'];
return strcmp($a['displayName'] ?? '', $b['displayName'] ?? '');
});
Response::ok([
'range' => $range,
'rangeLabel' => $label,
'moduleFilter' => $moduleId,
'buckets' => $buckets, // ['Mo','Di',...] — Beschriftung der X-Achse
'globalMaxBucketSec' => $globalMaxBucketSec,// Max einer Säule (für lineare Skala)
'students' => $studentsList,
'modules' => array_values($moduleAgg),
]);
}
// =============================================================================
// VIEW 2: ACTIVITY (Heatmap-Buckets)
// =============================================================================
if ($view === 'activity') {
$range = $_GET['range'] ?? 'week';
// groupBy: 'module' (default — wie bisher) oder 'student' (pro Schüler*in)
$groupBy = $_GET['groupBy'] ?? 'module';
// Bucket-Funktion + Zeit-Window je Range
switch ($range) {
case 'day':
$bucketFn = 'HOUR(submitted_at)';
$sinceSql = "submitted_at >= NOW() - INTERVAL 1 DAY";
$buckets = []; for ($h = 0; $h < 24; $h++) $buckets[] = sprintf('%02d', $h);
break;
case 'week':
// Mo=1, So=7 (WEEKDAY: Mo=0 .. So=6)
$bucketFn = 'WEEKDAY(submitted_at)';
$sinceSql = "submitted_at >= NOW() - INTERVAL 7 DAY";
$buckets = ['Mo','Di','Mi','Do','Fr','Sa','So'];
break;
case 'month':
$bucketFn = 'DAY(submitted_at)';
$sinceSql = "submitted_at >= NOW() - INTERVAL 31 DAY";
$buckets = []; for ($d = 1; $d <= 31; $d++) $buckets[] = (string)$d;
break;
case 'year':
$bucketFn = 'MONTH(submitted_at)';
$sinceSql = "submitted_at >= NOW() - INTERVAL 12 MONTH";
$buckets = ['Jän','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'];
break;
default:
Response::error('range muss day|week|month|year sein');
}
// Index-Mapping je Range
$idxMap = function($b) use ($range) {
$i = (int)$b;
if ($range === 'month' || $range === 'year') $i -= 1;
return $i;
};
if ($groupBy === 'student') {
// Pro Schüler*in gruppieren. Verbindung Assessment → Student geht
// über student_sessions (assessments hat keinen direkten user_id-
// FK, sondern session_id; sessions matchen students über
// class_id + display_name).
$sinceSqlA = str_replace('submitted_at', 'a.submitted_at', $sinceSql);
$bucketA = str_replace('submitted_at', 'a.submitted_at', $bucketFn);
$rows = $db->fetchAll(
"SELECT $bucketA AS bucket, s.id AS student_id, s.display_name AS name, s.emoji_avatar AS emoji, COUNT(*) AS n
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 $sinceSqlA AND s.deleted_at IS NULL
GROUP BY bucket, s.id",
[$classId]
);
$palette = ['#4a7c8a','#c07a6b','#5a8a5e','#c9913a','#7c3aed','#0d47a1','#e8a247','#1f4b37','#8d6e63','#b04a3e','#4a7c4e','#1565c0'];
$series = [];
$cIdx = 0;
foreach ($rows as $r) {
$sid = (int)$r['student_id'];
if (!isset($series[$sid])) {
$series[$sid] = [
'studentId' => $sid,
'name' => $r['name'] ?: ('Schüler*in #'.$sid),
'icon' => '👤',
'emoji' => $r['emoji'],
'color' => $palette[$cIdx++ % count($palette)],
'values' => array_fill(0, count($buckets), 0),
];
}
$idx = $idxMap($r['bucket']);
if ($idx >= 0 && $idx < count($buckets)) {
$series[$sid]['values'][$idx] += (int)$r['n'];
}
}
$seriesList = array_values($series);
usort($seriesList, function($a,$b){
return array_sum($b['values']) - array_sum($a['values']); // aktivste zuerst
});
Response::ok([
'range' => $range,
'groupBy' => 'student',
'buckets' => $buckets,
'series' => $seriesList,
]);
}
// Default: per-Modul
$rows = $db->fetchAll(
"SELECT $bucketFn AS bucket, sim_id, COUNT(*) AS n
FROM assessments
WHERE class_id = ? AND $sinceSql
GROUP BY bucket, sim_id",
[$classId]
);
$series = [];
foreach ($modules as $m) {
$series[$m['id']] = [
'moduleId' => $m['id'],
'name' => $m['name'],
'icon' => $m['icon'],
'color' => $m['color'],
'values' => array_fill(0, count($buckets), 0),
];
}
foreach ($rows as $r) {
$sim = $r['sim_id'];
if (!isset($series[$sim])) continue;
$idx = $idxMap($r['bucket']);
if ($idx >= 0 && $idx < count($buckets)) {
$series[$sim]['values'][$idx] += (int)$r['n'];
}
}
$seriesList = array_values(array_filter($series, function($s) {
return array_sum($s['values']) > 0;
}));
Response::ok([
'range' => $range,
'groupBy' => 'module',
'buckets' => $buckets,
'series' => $seriesList,
]);
}
// =============================================================================
// VIEW 3: MODULE-DETAIL
// =============================================================================
if ($view === 'module') {
$moduleId = $_GET['module_id'] ?? '';
if (!$moduleId) Response::error('module_id erforderlich');
// Wenn student_id mitkommt: Drill-Down auf Einzel-Schüler-Verlauf
$studentId = (int)($_GET['student_id'] ?? 0);
if ($studentId) {
// Erst Schüler-Klasse-Check
$student = $db->fetchOne(
'SELECT id, class_id, display_name FROM students WHERE id = ? AND class_id = ? AND deleted_at IS NULL',
[$studentId, $classId]
);
if (!$student) Response::error('Schüler*in nicht in dieser Klasse', 404);
// Alle Durchgänge: über student_sessions matchen
$runs = $db->fetchAll(
"SELECT a.id, a.submitted_at, a.duration_ms, a.results, a.reflections
FROM assessments a
JOIN student_sessions ss ON ss.id = a.session_id
WHERE ss.class_id = ? AND ss.display_name = ? AND a.sim_id = ?
ORDER BY a.submitted_at DESC
LIMIT 100",
[$classId, $student['display_name'], $moduleId]
);
// results + reflections JSON parsen (Frontend sieht's lieber dekodiert)
foreach ($runs as &$run) {
$r = $run['results'];
$run['results'] = $r ? json_decode($r, true) : null;
$rf = $run['reflections'];
$run['reflections'] = $rf ? json_decode($rf, true) : null;
}
Response::ok(['runs' => $runs]);
}
// Modul-Übersicht: alle Schüler*innen der Klasse mit Modul-Stats
$modInfo = $db->fetchOne(
"SELECT module_id AS id, title AS name, icon, short_desc, card_image, status
FROM module_info WHERE module_id = ?",
[$moduleId]
);
if (!$modInfo) Response::error('Modul nicht gefunden', 404);
// Schüler + Progress + Aggregat über assessments (Single-Query mit Sub-Selects)
$rows = $db->fetchAll(
"SELECT s.id, s.display_name, s.emoji_avatar,
pp.best_stars, pp.plays, pp.xp, pp.level,
(SELECT MAX(a.submitted_at)
FROM assessments a
JOIN student_sessions ss ON ss.id = a.session_id
WHERE ss.class_id = s.class_id AND ss.display_name = s.display_name
AND a.sim_id = ?) AS last_played,
(SELECT COALESCE(SUM(a.duration_ms), 0)
FROM assessments a
JOIN student_sessions ss ON ss.id = a.session_id
WHERE ss.class_id = s.class_id AND ss.display_name = s.display_name
AND a.sim_id = ?) AS total_duration_ms
FROM students s
LEFT JOIN player_progress pp ON pp.student_id = s.id AND pp.sim_id = ?
WHERE s.class_id = ? AND s.deleted_at IS NULL
ORDER BY pp.best_stars DESC, pp.plays DESC, s.display_name",
[$moduleId, $moduleId, $moduleId, $classId]
);
$students = [];
$sumPlays = 0; $sumDuration = 0; $sumStars = 0; $countWithStars = 0;
foreach ($rows as $r) {
$stars = (int)($r['best_stars'] ?? 0);
$plays = (int)($r['plays'] ?? 0);
$students[] = [
'id' => (int)$r['id'],
'displayName' => $r['display_name'],
'emoji' => $r['emoji_avatar'] ?: '🧑‍🎓',
'best_stars' => $stars,
'plays' => $plays,
'xp' => (int)($r['xp'] ?? 0),
'level' => (int)($r['level'] ?? 1),
'last_played' => $r['last_played'],
'total_duration_ms' => (int)($r['total_duration_ms'] ?? 0),
];
$sumPlays += $plays;
$sumDuration += (int)($r['total_duration_ms'] ?? 0);
if ($plays > 0) { $sumStars += $stars; $countWithStars++; }
}
$classStats = [
'totalPlays' => $sumPlays,
'totalDurationMs' => $sumDuration,
'avgStars' => $countWithStars ? round($sumStars / $countWithStars, 2) : null,
'studentsActive' => $countWithStars,
'studentsTotal' => count($students),
];
// === Modul-Highlights aus assessments.results aggregieren ===
$highlights = $MODULE_HIGHLIGHTS[$moduleId] ?? [];
if ($highlights) {
// Alle Assessments dieser Klasse für dieses Modul + zugeordneter Schueler
$rows = $db->fetchAll(
"SELECT s.id AS student_id, a.results
FROM students s
JOIN student_sessions ss ON ss.class_id = s.class_id AND ss.display_name = s.display_name
JOIN assessments a ON a.session_id = ss.id AND a.sim_id = ?
WHERE s.class_id = ? AND s.deleted_at IS NULL AND a.results IS NOT NULL",
[$moduleId, $classId]
);
// Pro Student: Aggregat berechnen
$perStudent = []; // student_id => { key => bestValue }
foreach ($rows as $r) {
$sid = (int)$r['student_id'];
$j = json_decode($r['results'], true);
if (!is_array($j)) continue;
if (!isset($perStudent[$sid])) $perStudent[$sid] = [];
foreach ($highlights as $h) {
$val = _hl_get($j, $h['key']);
if ($val === null) continue;
$val = is_numeric($val) ? (float)$val : $val;
if (!isset($perStudent[$sid][$h['key']])) {
$perStudent[$sid][$h['key']] = $val;
} else {
$cur = $perStudent[$sid][$h['key']];
$perStudent[$sid][$h['key']] = ($h['agg'] === 'min')
? min($cur, $val)
: max($cur, $val);
}
}
}
// Klassen-Champion pro Highlight (best of all students)
$champions = []; // key => student_id
foreach ($highlights as $h) {
$best = null; $bestSid = null;
foreach ($perStudent as $sid => $vals) {
if (!isset($vals[$h['key']])) continue;
$v = $vals[$h['key']];
if ($best === null || ($h['agg'] === 'min' ? $v < $best : $v > $best)) {
$best = $v; $bestSid = $sid;
}
}
if ($bestSid !== null) $champions[$h['key']] = $bestSid;
}
// Highlights an students-Liste anreichern
foreach ($students as &$st) {
$st['highlights'] = $perStudent[$st['id']] ?? new stdClass();
}
unset($st);
}
Response::ok([
'module' => $modInfo,
'classStats' => $classStats,
'highlights' => $highlights,
'champions' => $champions ?? new stdClass(),
'students' => $students,
]);
}
// =============================================================================
// VIEW 4: REFLECTIONS — alle Reflexions-Antworten der Klasse, alle Sims
// =============================================================================
if ($view === 'reflections') {
$simFilter = $_GET['module_id'] ?? null; // optional filter auf ein Modul
$limit = max(1, min(500, (int)($_GET['limit'] ?? 200)));
$sql = "SELECT a.id, a.session_id, a.sim_id, a.submitted_at, a.reflections,
ss.display_name, s.id AS student_id, s.emoji_avatar
FROM assessments a
JOIN student_sessions ss ON ss.id = a.session_id
LEFT JOIN students s ON s.class_id = ss.class_id AND s.display_name = ss.display_name AND s.deleted_at IS NULL
WHERE a.class_id = ?
AND a.reflections IS NOT NULL
AND JSON_LENGTH(a.reflections) > 0";
$params = [$classId];
if ($simFilter) {
$sql .= " AND a.sim_id = ?";
$params[] = $simFilter;
}
$sql .= " ORDER BY a.submitted_at DESC LIMIT $limit";
$rows = $db->fetchAll($sql, $params);
// Reflexionen flatten: jeder JSON-Eintrag wird ein eigenes Item
$items = [];
foreach ($rows as $r) {
$arr = $r['reflections'] ? json_decode($r['reflections'], true) : [];
if (!is_array($arr)) continue;
foreach ($arr as $rf) {
if (!is_array($rf)) continue;
$items[] = [
'assessmentId' => (int)$r['id'],
'studentId' => $r['student_id'] ? (int)$r['student_id'] : null,
'displayName' => $r['display_name'],
'emoji' => $r['emoji_avatar'] ?: '🧑‍🎓',
'simId' => $r['sim_id'],
'level' => $rf['level'] ?? null,
'question' => $rf['question'] ?? '',
'answer' => $rf['answer'] ?? '',
'recordedAt' => $rf['recorded_at'] ?? $r['submitted_at'],
];
}
}
// Sortieren nach Zeit (neueste zuerst)
usort($items, function($a, $b) { return strcmp($b['recordedAt'], $a['recordedAt']); });
Response::ok([
'items' => array_slice($items, 0, $limit),
'total' => count($items),
'simFilter' => $simFilter,
]);
}
Response::error('Unbekannte view: ' . $view);