bfc90b78de
Alle $MODULE_HIGHLIGHTS gegen echte Daten (Klasse 102) verifiziert und auf die tatsaechlichen Feldnamen umgestellt (viele waren veraltet/leer, z.B. farmer, sonnensystem, energiemanager, busfahrt, logistik, heli, tourismus*). - kofferdetektiv NEU: Geloest-Quote, Erstversuch, Fehlversuche, Oe Hinweise - weltkueche/fluss NEU - fluggesellschaft NEU (aus Submit-Schema) - Berechnete Quoten via num/den -> Prozent (Erfolgsquote statt Rohzahl): kofferdetektiv, weltkueche, busfahrt, logistik, energiemanager, eu-werkstatt, sonnensystem, tourismustal, fluggesellschaft - heli: Planung/Start/Landung/Level; sim-metrics: Level + Oe Hinweise ergaenzt Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
773 lines
33 KiB
PHP
773 lines
33 KiB
PHP
<?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 Kennzahlen aus assessments.results-JSON (verifiziert
|
||
// gegen echte Daten, Klasse 102). agg = min|max ("besser niedriger/höher").
|
||
// Berechnete Quote: statt 'key' → 'num'+'den' (Wert = num/den·100, in %).
|
||
// unit = Einheit hinter dem Wert. HINWEIS: Feldnamen konsistent mit dem
|
||
// Client-Schema SIM_METRICS in App/assets/js/sim-metrics.js halten.
|
||
$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'=>'%'],
|
||
],
|
||
'kofferdetektiv' => [
|
||
['key'=>'solve_quote', 'num'=>'cases_solved', 'den'=>'cases_total', 'label'=>'Gelöst-Quote', 'agg'=>'max', 'unit'=>'%'],
|
||
['key'=>'correct_first_try', 'label'=>'Erstversuch', 'agg'=>'max'],
|
||
['key'=>'wrong_guesses', 'label'=>'Fehlversuche', 'agg'=>'min'],
|
||
['key'=>'hints_used_avg', 'label'=>'Ø Hinweise', 'agg'=>'min'],
|
||
],
|
||
'weltkueche' => [
|
||
['key'=>'dish_quote', 'num'=>'completedDishes', 'den'=>'totalDishes', 'label'=>'Gerichte-Quote', 'agg'=>'max', 'unit'=>'%'],
|
||
['key'=>'mistakesInDish', 'label'=>'Fehlklicks', 'agg'=>'min'],
|
||
],
|
||
'fluss' => [
|
||
['key'=>'biodiv', 'label'=>'Biodiversität', 'agg'=>'max', 'unit'=>'%'],
|
||
['key'=>'economy', 'label'=>'Wirtschaft', 'agg'=>'max', 'unit'=>'%'],
|
||
['key'=>'flood', 'label'=>'Hochwasser', 'agg'=>'min', 'unit'=>'%'],
|
||
['key'=>'food', 'label'=>'Ernährung', 'agg'=>'max', 'unit'=>'%'],
|
||
],
|
||
'farmer' => [
|
||
['key'=>'hit_rate', 'label'=>'Trefferquote', 'agg'=>'max', 'unit'=>'%'],
|
||
['key'=>'year', 'label'=>'Jahr', 'agg'=>'max'],
|
||
['key'=>'crops_used', 'label'=>'Sorten', 'agg'=>'max'],
|
||
],
|
||
'busfahrt' => [
|
||
['key'=>'order_quote', 'num'=>'wonOrders', 'den'=>'totalOrders', 'label'=>'Aufträge', 'agg'=>'max', 'unit'=>'%'],
|
||
['key'=>'quiz_quote', 'num'=>'quizFirstTry', 'den'=>'totalOrders', 'label'=>'Quiz 1. Versuch','agg'=>'max', 'unit'=>'%'],
|
||
['key'=>'avgTapKm', 'label'=>'Ø Abweichung', 'agg'=>'min', 'unit'=>'km'],
|
||
],
|
||
'logistik' => [
|
||
['key'=>'deliver_quote', 'num'=>'ordersDelivered', 'den'=>'totalOrders', 'label'=>'Geliefert', 'agg'=>'max', 'unit'=>'%'],
|
||
['key'=>'ordersLate', 'label'=>'Verspätet', 'agg'=>'min'],
|
||
['key'=>'finalBalance', 'label'=>'Bilanz', 'agg'=>'max', 'unit'=>'€'],
|
||
],
|
||
'energiemanager' => [
|
||
['key'=>'block_quote', 'num'=>'blocksBalanced', 'den'=>'blocksTotal', 'label'=>'Blöcke ok', 'agg'=>'max', 'unit'=>'%'],
|
||
['key'=>'day_quote', 'num'=>'daysPlayed', 'den'=>'daysTotal', 'label'=>'Tage geschafft','agg'=>'max', 'unit'=>'%'],
|
||
['key'=>'totalGap', 'label'=>'Restdefizit', 'agg'=>'min'],
|
||
],
|
||
'eu-werkstatt' => [
|
||
['key'=>'firsttry_quote', 'num'=>'firstTryHits', 'den'=>'stepsTotal', 'label'=>'Erste Wahl', 'agg'=>'max', 'unit'=>'%'],
|
||
['key'=>'totalAttempts', 'label'=>'Versuche', 'agg'=>'min'],
|
||
['key'=>'muelleimerCount','label'=>'Verworfen', 'agg'=>'min'],
|
||
],
|
||
'sonnensystem' => [
|
||
['key'=>'task_quote', 'num'=>'completed', 'den'=>'total', 'label'=>'Gelöst-Quote', 'agg'=>'max', 'unit'=>'%'],
|
||
['key'=>'completed', 'label'=>'Aufgaben', 'agg'=>'max'],
|
||
],
|
||
'heli' => [
|
||
['key'=>'planPct', 'label'=>'Planung', 'agg'=>'max', 'unit'=>'%'],
|
||
['key'=>'startScore', 'label'=>'Start', 'agg'=>'max'],
|
||
['key'=>'landingScore', 'label'=>'Landung', 'agg'=>'max'],
|
||
['key'=>'level', 'label'=>'Level', 'agg'=>'max'],
|
||
],
|
||
'tourismustal' => [
|
||
['key'=>'goal_quote', 'num'=>'goalsDone', 'den'=>'goalsTotal', 'label'=>'Ziele', 'agg'=>'max', 'unit'=>'%'],
|
||
['key'=>'satisfaction', 'label'=>'Zufriedenheit', 'agg'=>'max', 'unit'=>'%'],
|
||
['key'=>'nature', 'label'=>'Naturwert', 'agg'=>'max', 'unit'=>'%'],
|
||
['key'=>'money', 'label'=>'Konto', 'agg'=>'max', 'unit'=>'€'],
|
||
],
|
||
'tourismusregion' => [
|
||
['key'=>'score', 'label'=>'Gesamtscore', 'agg'=>'max', 'unit'=>'%'],
|
||
],
|
||
'fluggesellschaft' => [
|
||
['key'=>'flight_order_quote', 'num'=>'wonOrders', 'den'=>'totalOrders', 'label'=>'Aufträge', 'agg'=>'max', 'unit'=>'%'],
|
||
['key'=>'flight_quiz_quote', 'num'=>'quizFirstTry', 'den'=>'totalOrders', 'label'=>'Quiz 1. Versuch','agg'=>'max', 'unit'=>'%'],
|
||
['key'=>'co2Kg', 'label'=>'CO₂', 'agg'=>'min', 'unit'=>'kg'],
|
||
['key'=>'earnings', 'label'=>'Gewinn', 'agg'=>'max', 'unit'=>'€'],
|
||
],
|
||
];
|
||
$MODULE_HIGHLIGHTS['klima-3d'] = $MODULE_HIGHLIGHTS['klima'];
|
||
|
||
// 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;
|
||
case 'weeks':
|
||
// Letzte 10 ISO-Kalenderwochen — je Woche eine Säule (für den „Verlauf").
|
||
$sinceSql = "a.submitted_at >= NOW() - INTERVAL 10 WEEK";
|
||
$bucketFn = 'YEARWEEK(a.submitted_at, 3)';
|
||
$buckets = []; $bmap = [];
|
||
for ($i = 9; $i >= 0; $i--) {
|
||
$yw = (int)date('oW', strtotime("-$i week"));
|
||
$buckets[] = 'KW' . date('W', strtotime("-$i week"));
|
||
$bmap[$yw] = 9 - $i;
|
||
}
|
||
$idxMap = function($b) use ($bmap) { return $bmap[(int)$b] ?? -1; };
|
||
$label = 'Letzte 10 Wochen';
|
||
break;
|
||
default: Response::error('range muss day|week|weeks|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) {
|
||
if (isset($h['num'])) {
|
||
// Berechnete Quote: num/den in Prozent (0..100)
|
||
$nu = _hl_get($j, $h['num']);
|
||
$de = _hl_get($j, $h['den']);
|
||
$val = ($nu !== null && $de !== null && (float)$de != 0.0)
|
||
? round((float)$nu / (float)$de * 100) : null;
|
||
} else {
|
||
$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);
|