f7bf0f0901
- live.php: ON DUPLICATE KEY UPDATE-Reihenfolge korrigiert (started_at VOR module_id), sonst wird der Modulwechsel nie erkannt -> Balken behielt alten Startzeitpunkt (Clara EU-Werkstatt seit 14:30). - results.php view=today: liefert todayScores (Pro-Schueler-Aggregat aus student_results, zuverlaessig). - teacher.html HEUTE-Triage: fertige Laeufe aus student_results + laufende aus Summary zusammengefuehrt; Totalversagen heute (<40) -> Foerdern. Ben (klima 36) erscheint jetzt korrekt in Foerdern. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
960 lines
42 KiB
PHP
960 lines
42 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.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]
|
||
);
|
||
$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: TODAY → Top-Leistungen von HEUTE + heute neu vergebene Abzeichen
|
||
// =============================================================================
|
||
// Liefert die heutigen finalen Läufe (topToday) sowie pro Schüler:in×Sim, die
|
||
// heute aktiv war, den Progress-Stand VOR heute und JETZT (aus student_results
|
||
// rekonstruiert). Die Badge-Auswertung passiert clientseitig mit badges.js,
|
||
// damit die Abzeichen-Definition eine einzige Quelle bleibt.
|
||
if ($view === 'today') {
|
||
$rows = $db->fetchAll(
|
||
"SELECT sr.student_id, sr.module_id, sr.score, sr.detail_json, sr.completed_at,
|
||
s.display_name, s.username, s.emoji_avatar
|
||
FROM student_results sr
|
||
JOIN students s ON s.id = sr.student_id
|
||
WHERE sr.class_id = ? AND s.deleted_at IS NULL
|
||
ORDER BY sr.completed_at",
|
||
[$classId]
|
||
);
|
||
$starsOf = function ($score, $dj) {
|
||
$d = json_decode($dj, true);
|
||
if (is_array($d)) {
|
||
if (isset($d['stars'])) return (int)$d['stars'];
|
||
if (isset($d['sterneGesamt'])) return (int)$d['sterneGesamt'];
|
||
}
|
||
return (int)round(((float)$score) / 20);
|
||
};
|
||
$levelFromXp = function ($xp) { return $xp < 50 ? 1 : ($xp < 150 ? 2 : ($xp < 300 ? 3 : 4)); };
|
||
$today = date('Y-m-d');
|
||
|
||
$agg = []; // "sid|sim" => Aggregat vorher/jetzt
|
||
$todayRuns = [];
|
||
foreach ($rows as $r) {
|
||
$sid = (int)$r['student_id'];
|
||
$sim = $r['module_id'];
|
||
$st = $starsOf($r['score'], $r['detail_json']);
|
||
$xp = $st * 10 + ($st >= 5 ? 5 : 0);
|
||
$isToday = substr($r['completed_at'], 0, 10) === $today;
|
||
$k = "$sid|$sim";
|
||
if (!isset($agg[$k])) $agg[$k] = [
|
||
'sid'=>$sid, 'sim'=>$sim, 'name'=>$r['display_name'] ?: $r['username'], 'emoji'=>$r['emoji_avatar'],
|
||
'pB'=>0,'bB'=>0,'xB'=>0, 'pN'=>0,'bN'=>0,'xN'=>0, 'today'=>false,
|
||
];
|
||
$agg[$k]['pN']++; $agg[$k]['bN'] = max($agg[$k]['bN'], $st); $agg[$k]['xN'] += $xp;
|
||
if ($isToday) {
|
||
$agg[$k]['today'] = true;
|
||
$todayRuns[] = ['studentId'=>$sid, 'displayName'=>$r['display_name'] ?: $r['username'],
|
||
'emoji'=>$r['emoji_avatar'] ?: '🧑🎓', 'moduleId'=>$sim,
|
||
'score'=>round((float)$r['score']), 'stars'=>$st, 'at'=>$r['completed_at']];
|
||
} else {
|
||
$agg[$k]['pB']++; $agg[$k]['bB'] = max($agg[$k]['bB'], $st); $agg[$k]['xB'] += $xp;
|
||
}
|
||
}
|
||
usort($todayRuns, function ($a, $b) { return $b['score'] <=> $a['score']; });
|
||
|
||
$progress = [];
|
||
foreach ($agg as $a) {
|
||
if (!$a['today']) continue;
|
||
$progress[] = [
|
||
'studentId'=>$a['sid'], 'displayName'=>$a['name'], 'emoji'=>$a['emoji'] ?: '🧑🎓', 'simId'=>$a['sim'],
|
||
'before'=>['plays'=>$a['pB'], 'best_stars'=>$a['bB'], 'level'=>$levelFromXp($a['xB'])],
|
||
'now' =>['plays'=>$a['pN'], 'best_stars'=>$a['bN'], 'level'=>$levelFromXp($a['xN'])],
|
||
];
|
||
}
|
||
|
||
// Pro-Schüler-Aggregat der HEUTIGEN Läufe (aus student_results — zuverlässig
|
||
// mit student_id, unabhängig vom assessments/student_sessions-Join). Basis für
|
||
// die HEUTE-Empfehlung, damit ALLE heutigen Leistungen zählen (nicht nur die,
|
||
// die es in die assessments-Tabelle geschafft haben).
|
||
$todayScores = []; // sid => ['plays'=>n, 'avgScore'=>0-100, 'minScore'=>..]
|
||
foreach ($rows as $r) {
|
||
if (substr($r['completed_at'], 0, 10) !== $today) continue;
|
||
$sid = (int)$r['student_id'];
|
||
$sc = max(0, min(100, (float)$r['score']));
|
||
if (!isset($todayScores[$sid])) $todayScores[$sid] = ['plays'=>0, '_sum'=>0.0, 'minScore'=>100];
|
||
$todayScores[$sid]['plays']++;
|
||
$todayScores[$sid]['_sum'] += $sc;
|
||
if ($sc < $todayScores[$sid]['minScore']) $todayScores[$sid]['minScore'] = (int)round($sc);
|
||
}
|
||
foreach ($todayScores as $sid => &$t) {
|
||
$t['avgScore'] = (int)round($t['_sum'] / max(1, $t['plays']));
|
||
unset($t['_sum']);
|
||
}
|
||
unset($t);
|
||
|
||
Response::ok(['date'=>$today, 'topToday'=>array_slice($todayRuns, 0, 6), 'progress'=>$progress, 'todayScores'=>$todayScores]);
|
||
}
|
||
|
||
// =============================================================================
|
||
// 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');
|
||
}
|
||
|
||
// Aktueller Bucket-Index (für LAUFENDE, noch nicht abgegebene Sessions).
|
||
$nowBucketIdx = -1;
|
||
switch ($range) {
|
||
case 'day': $nowBucketIdx = (int)date('G'); break; // Stunde 0–23
|
||
case 'week': $nowBucketIdx = (int)date('N') - 1; break; // Mo=0 … So=6
|
||
case 'month': $nowBucketIdx = (int)date('j') - 1; break; // Tag 1–31
|
||
case 'year': $nowBucketIdx = (int)date('n') - 1; break; // Monat 1–12
|
||
case 'weeks': $nowBucketIdx = isset($bmap) ? ($bmap[(int)date('oW')] ?? -1) : -1; break;
|
||
}
|
||
|
||
// 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.id = ss.student_id
|
||
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;
|
||
}
|
||
|
||
// 5b) LAUFENDE Sessions (noch kein Assessment abgegeben) in den aktuellen
|
||
// Bucket einspielen — als „live" markiert, damit die Säule dort blinkt.
|
||
if ($nowBucketIdx >= 0) {
|
||
$liveRows = $db->fetchAll(
|
||
"SELECT ls.student_id, ls.module_id,
|
||
UNIX_TIMESTAMP(ls.started_at) AS started_ts,
|
||
UNIX_TIMESTAMP(ls.last_seen) AS last_seen_ts
|
||
FROM live_sessions ls
|
||
WHERE ls.class_id = ? AND ls.last_seen > NOW() - INTERVAL 90 SECOND",
|
||
[$classId]
|
||
);
|
||
foreach ($liveRows as $lr) {
|
||
$sid = (int)$lr['student_id'];
|
||
if (!isset($byStudent[$sid])) continue;
|
||
$sim = $lr['module_id'];
|
||
if ($moduleId !== 'ALL' && $moduleId !== '' && $sim !== $moduleId) continue;
|
||
$sec = max(0, (int)$lr['last_seen_ts'] - (int)$lr['started_ts']);
|
||
$b = &$byStudent[$sid]['buckets'][$nowBucketIdx];
|
||
$b['sum'] += $sec;
|
||
$byMod = (array)$b['byModule'];
|
||
$byMod[$sim] = ($byMod[$sim] ?? 0) + $sec;
|
||
$b['byModule'] = $byMod;
|
||
$b['live'] = true; // Client lässt diese Säule blinken
|
||
$b['liveModule'] = $sim;
|
||
unset($b);
|
||
$byStudent[$sid]['totalSec'] += $sec;
|
||
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];
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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
|
||
$weekOffset = null; $bucketDates = null; $weekLabel = null;
|
||
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':
|
||
// Kalenderwoche (Mo–So), optional um week_offset Wochen zurück.
|
||
$weekOffset = max(0, (int)($_GET['week_offset'] ?? 0));
|
||
$monday = new DateTime('monday this week');
|
||
if ($weekOffset > 0) $monday->modify('-' . $weekOffset . ' week');
|
||
$sunday = (clone $monday)->modify('+6 day');
|
||
$startD = $monday->format('Y-m-d');
|
||
$endD = (clone $monday)->modify('+7 day')->format('Y-m-d');
|
||
$bucketFn = 'WEEKDAY(submitted_at)';
|
||
$sinceSql = "submitted_at >= '$startD 00:00:00' AND submitted_at < '$endD 00:00:00'";
|
||
$buckets = ['Mo','Di','Mi','Do','Fr','Sa','So'];
|
||
$bucketDates = [];
|
||
$dcur = clone $monday;
|
||
for ($i = 0; $i < 7; $i++) { $bucketDates[] = $dcur->format('d.m.'); $dcur->modify('+1 day'); }
|
||
$weekLabel = 'KW' . $monday->format('W') . ' · ' . $monday->format('d.m.') . '–' . $sunday->format('d.m.Y');
|
||
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.id = ss.student_id
|
||
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,
|
||
'bucketDates' => $bucketDates,
|
||
'weekOffset' => $weekOffset,
|
||
'weekLabel' => $weekLabel,
|
||
'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,
|
||
'bucketDates' => $bucketDates,
|
||
'weekOffset' => $weekOffset,
|
||
'weekLabel' => $weekLabel,
|
||
'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.student_id = ? AND a.sim_id = ?
|
||
ORDER BY a.submitted_at DESC
|
||
LIMIT 100",
|
||
[$studentId, $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);
|
||
|
||
// Optionaler Datumsfilter (Schuljahr-Default liefert der Client).
|
||
$from = $_GET['from'] ?? null;
|
||
$to = $_GET['to'] ?? null;
|
||
$dateSql = ''; $dateParams = [];
|
||
if ($from && preg_match('/^\d{4}-\d{2}-\d{2}$/', (string)$from)
|
||
&& $to && preg_match('/^\d{4}-\d{2}-\d{2}$/', (string)$to)) {
|
||
$dateSql = ' AND sr.completed_at >= ? AND sr.completed_at < ? ';
|
||
$dateParams = [$from . ' 00:00:00', date('Y-m-d', strtotime($to . ' +1 day')) . ' 00:00:00'];
|
||
} else { $from = null; $to = null; }
|
||
|
||
$starsOf = function ($score, $j) {
|
||
if (is_array($j)) {
|
||
if (isset($j['stars'])) return (int)$j['stars'];
|
||
if (isset($j['sterneGesamt'])) return (int)$j['sterneGesamt'];
|
||
}
|
||
return (int)round(((float)$score) / 20);
|
||
};
|
||
$highlights = $MODULE_HIGHLIGHTS[$moduleId] ?? [];
|
||
|
||
// Finale Durchgänge im Zeitraum (student_results: Datum + Score + JSON in einer Tabelle)
|
||
$srows = $db->fetchAll(
|
||
"SELECT sr.student_id, sr.score, sr.detail_json, sr.duration_sec, sr.completed_at
|
||
FROM student_results sr
|
||
WHERE sr.class_id = ? AND sr.module_id = ? $dateSql
|
||
ORDER BY sr.completed_at",
|
||
array_merge([$classId, $moduleId], $dateParams)
|
||
);
|
||
$aggS = []; // student_id => [plays,best,last,dur,hl=>[]]
|
||
foreach ($srows as $r) {
|
||
$sid = (int)$r['student_id'];
|
||
$j = json_decode($r['detail_json'], true);
|
||
$st = $starsOf($r['score'], $j);
|
||
if (!isset($aggS[$sid])) $aggS[$sid] = ['plays'=>0,'best'=>0,'last'=>null,'dur'=>0,'hl'=>[]];
|
||
$aggS[$sid]['plays']++;
|
||
$aggS[$sid]['best'] = max($aggS[$sid]['best'], $st);
|
||
if ($aggS[$sid]['last'] === null || $r['completed_at'] > $aggS[$sid]['last']) $aggS[$sid]['last'] = $r['completed_at'];
|
||
$aggS[$sid]['dur'] += (int)$r['duration_sec'] * 1000;
|
||
if ($highlights && is_array($j)) {
|
||
foreach ($highlights as $h) {
|
||
if (isset($h['num'])) {
|
||
$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($aggS[$sid]['hl'][$h['key']])) $aggS[$sid]['hl'][$h['key']] = $val;
|
||
else $aggS[$sid]['hl'][$h['key']] = ($h['agg'] === 'min')
|
||
? min($aggS[$sid]['hl'][$h['key']], $val) : max($aggS[$sid]['hl'][$h['key']], $val);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Basis: komplette Klassenliste (auch ohne Läufe im Zeitraum)
|
||
$base = $db->fetchAll(
|
||
"SELECT id, display_name, emoji_avatar FROM students WHERE class_id = ? AND deleted_at IS NULL ORDER BY display_name",
|
||
[$classId]
|
||
);
|
||
$students = [];
|
||
$sumPlays = 0; $sumDuration = 0; $sumStars = 0; $countWithStars = 0;
|
||
foreach ($base as $b) {
|
||
$sid = (int)$b['id'];
|
||
$a = $aggS[$sid] ?? ['plays'=>0,'best'=>0,'last'=>null,'dur'=>0,'hl'=>[]];
|
||
$students[] = [
|
||
'id' => $sid,
|
||
'displayName' => $b['display_name'],
|
||
'emoji' => $b['emoji_avatar'] ?: '🧑🎓',
|
||
'best_stars' => (int)$a['best'],
|
||
'plays' => (int)$a['plays'],
|
||
'last_played' => $a['last'],
|
||
'total_duration_ms' => (int)$a['dur'],
|
||
'highlights' => $a['hl'] ? $a['hl'] : new stdClass(),
|
||
];
|
||
$sumPlays += $a['plays']; $sumDuration += $a['dur'];
|
||
if ($a['plays'] > 0) { $sumStars += $a['best']; $countWithStars++; }
|
||
}
|
||
usort($students, function ($x, $y) {
|
||
if ($y['best_stars'] !== $x['best_stars']) return $y['best_stars'] - $x['best_stars'];
|
||
if ($y['plays'] !== $x['plays']) return $y['plays'] - $x['plays'];
|
||
return strcmp($x['displayName'] ?? '', $y['displayName'] ?? '');
|
||
});
|
||
|
||
$classStats = [
|
||
'totalPlays' => $sumPlays,
|
||
'totalDurationMs' => $sumDuration,
|
||
'avgStars' => $countWithStars ? round($sumStars / $countWithStars, 2) : null,
|
||
'studentsActive' => $countWithStars,
|
||
'studentsTotal' => count($students),
|
||
];
|
||
|
||
// Klassen-Champion je Highlight (bester Wert aller Schüler:innen)
|
||
$champions = [];
|
||
foreach ($highlights as $h) {
|
||
$best = null; $bestSid = null;
|
||
foreach ($students as $st) {
|
||
$hv = is_array($st['highlights']) ? ($st['highlights'][$h['key']] ?? null) : null;
|
||
if ($hv === null) continue;
|
||
if ($best === null || ($h['agg'] === 'min' ? $hv < $best : $hv > $best)) { $best = $hv; $bestSid = $st['id']; }
|
||
}
|
||
if ($bestSid !== null) $champions[$h['key']] = $bestSid;
|
||
}
|
||
|
||
Response::ok([
|
||
'module' => $modInfo,
|
||
'classStats' => $classStats,
|
||
'highlights' => $highlights,
|
||
'champions' => (object)$champions,
|
||
'students' => $students,
|
||
'from' => $from,
|
||
'to' => $to,
|
||
]);
|
||
}
|
||
|
||
// =============================================================================
|
||
// VIEW: MOD_STATS — pro Modul plays/avgStars/studentsActive im Zeitraum
|
||
// =============================================================================
|
||
// Speist die Modul-Cards der Übersicht mit datumsgefilterten Werten.
|
||
if ($view === 'mod_stats') {
|
||
$from = $_GET['from'] ?? null; $to = $_GET['to'] ?? null;
|
||
$dateSql = ''; $dateParams = [];
|
||
if ($from && preg_match('/^\d{4}-\d{2}-\d{2}$/', (string)$from)
|
||
&& $to && preg_match('/^\d{4}-\d{2}-\d{2}$/', (string)$to)) {
|
||
$dateSql = ' AND sr.completed_at >= ? AND sr.completed_at < ? ';
|
||
$dateParams = [$from . ' 00:00:00', date('Y-m-d', strtotime($to . ' +1 day')) . ' 00:00:00'];
|
||
} else { $from = null; $to = null; }
|
||
|
||
$rows = $db->fetchAll(
|
||
"SELECT sr.module_id, sr.student_id, sr.score, sr.detail_json
|
||
FROM student_results sr
|
||
WHERE sr.class_id = ? $dateSql",
|
||
array_merge([$classId], $dateParams)
|
||
);
|
||
$starsOf = function ($score, $dj) {
|
||
$j = json_decode($dj, true);
|
||
if (is_array($j)) {
|
||
if (isset($j['stars'])) return (int)$j['stars'];
|
||
if (isset($j['sterneGesamt'])) return (int)$j['sterneGesamt'];
|
||
}
|
||
return (int)round(((float)$score) / 20);
|
||
};
|
||
$agg = [];
|
||
foreach ($rows as $r) {
|
||
$m = $r['module_id']; $sid = (int)$r['student_id']; $st = $starsOf($r['score'], $r['detail_json']);
|
||
if (!isset($agg[$m])) $agg[$m] = ['plays'=>0, 'students'=>[], 'best'=>[]];
|
||
$agg[$m]['plays']++;
|
||
$agg[$m]['students'][$sid] = 1;
|
||
$agg[$m]['best'][$sid] = max($agg[$m]['best'][$sid] ?? 0, $st);
|
||
}
|
||
$out = [];
|
||
foreach ($agg as $m => $a) {
|
||
$best = array_values($a['best']);
|
||
$out[$m] = [
|
||
'plays' => $a['plays'],
|
||
'studentsActive' => count($a['students']),
|
||
'avgStars' => $best ? round(array_sum($best) / count($best), 1) : null,
|
||
];
|
||
}
|
||
Response::ok(['from'=>$from, 'to'=>$to, 'stats'=>(object)$out]);
|
||
}
|
||
|
||
// =============================================================================
|
||
// 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.id = ss.student_id 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);
|