Uebersicht: Wochengrafik mit Navigation, Triage-Cards immer, Modul-Datumsfilter
Wochen-Saeulengrafik: - Pfeile Woche vor/zurueck (Kalenderwoche, view=activity&week_offset) - Datum unter jedem Wochentag, halbierte + condensed Schrift - Y-Skala links mit Anzahl, skaliert auf Max-Saeulenhoehe - rendert auch bei leerer Woche (mit Hinweis) fuer die Navigation Triage: alle 5 Cards immer sichtbar, leere mit "Aktuell niemand betroffen". Module — Stand der Klasse: - von/bis-Datumsfilter (Default bis=heute, von=Schuljahresbeginn 1. August) - neuer view=mod_stats speist die Cards datumsgefiltert - view=module auf student_results umgestellt (Datum+Score+JSON), from/to-Filter wirkt auf Tabelle, Highlights, Champions, classStats; Zeitraum im Modal-Header Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+162
-104
@@ -498,6 +498,7 @@ if ($view === 'activity') {
|
||||
$groupBy = $_GET['groupBy'] ?? 'module';
|
||||
|
||||
// Bucket-Funktion + Zeit-Window je Range
|
||||
$weekOffset = null; $bucketDates = null; $weekLabel = null;
|
||||
switch ($range) {
|
||||
case 'day':
|
||||
$bucketFn = 'HOUR(submitted_at)';
|
||||
@@ -505,10 +506,20 @@ if ($view === 'activity') {
|
||||
$buckets = []; for ($h = 0; $h < 24; $h++) $buckets[] = sprintf('%02d', $h);
|
||||
break;
|
||||
case 'week':
|
||||
// Mo=1, So=7 (WEEKDAY: Mo=0 .. So=6)
|
||||
// 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 >= NOW() - INTERVAL 7 DAY";
|
||||
$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)';
|
||||
@@ -572,10 +583,13 @@ if ($view === 'activity') {
|
||||
return array_sum($b['values']) - array_sum($a['values']); // aktivste zuerst
|
||||
});
|
||||
Response::ok([
|
||||
'range' => $range,
|
||||
'groupBy' => 'student',
|
||||
'buckets' => $buckets,
|
||||
'series' => $seriesList,
|
||||
'range' => $range,
|
||||
'groupBy' => 'student',
|
||||
'buckets' => $buckets,
|
||||
'bucketDates' => $bucketDates,
|
||||
'weekOffset' => $weekOffset,
|
||||
'weekLabel' => $weekLabel,
|
||||
'series' => $seriesList,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -609,10 +623,13 @@ if ($view === 'activity') {
|
||||
return array_sum($s['values']) > 0;
|
||||
}));
|
||||
Response::ok([
|
||||
'range' => $range,
|
||||
'groupBy' => 'module',
|
||||
'buckets' => $buckets,
|
||||
'series' => $seriesList,
|
||||
'range' => $range,
|
||||
'groupBy' => 'module',
|
||||
'buckets' => $buckets,
|
||||
'bucketDates' => $bucketDates,
|
||||
'weekOffset' => $weekOffset,
|
||||
'weekLabel' => $weekLabel,
|
||||
'series' => $seriesList,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -662,47 +679,88 @@ if ($view === 'module') {
|
||||
);
|
||||
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]
|
||||
);
|
||||
// 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 ($rows as $r) {
|
||||
$stars = (int)($r['best_stars'] ?? 0);
|
||||
$plays = (int)($r['plays'] ?? 0);
|
||||
foreach ($base as $b) {
|
||||
$sid = (int)$b['id'];
|
||||
$a = $aggS[$sid] ?? ['plays'=>0,'best'=>0,'last'=>null,'dur'=>0,'hl'=>[]];
|
||||
$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),
|
||||
'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 += $plays;
|
||||
$sumDuration += (int)($r['total_duration_ms'] ?? 0);
|
||||
if ($plays > 0) { $sumStars += $stars; $countWithStars++; }
|
||||
$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,
|
||||
@@ -712,76 +770,76 @@ if ($view === 'module') {
|
||||
'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 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']; }
|
||||
}
|
||||
// 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);
|
||||
if ($bestSid !== null) $champions[$h['key']] = $bestSid;
|
||||
}
|
||||
|
||||
Response::ok([
|
||||
'module' => $modInfo,
|
||||
'classStats' => $classStats,
|
||||
'highlights' => $highlights,
|
||||
'champions' => $champions ?? new stdClass(),
|
||||
'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
|
||||
// =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user