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';
|
$groupBy = $_GET['groupBy'] ?? 'module';
|
||||||
|
|
||||||
// Bucket-Funktion + Zeit-Window je Range
|
// Bucket-Funktion + Zeit-Window je Range
|
||||||
|
$weekOffset = null; $bucketDates = null; $weekLabel = null;
|
||||||
switch ($range) {
|
switch ($range) {
|
||||||
case 'day':
|
case 'day':
|
||||||
$bucketFn = 'HOUR(submitted_at)';
|
$bucketFn = 'HOUR(submitted_at)';
|
||||||
@@ -505,10 +506,20 @@ if ($view === 'activity') {
|
|||||||
$buckets = []; for ($h = 0; $h < 24; $h++) $buckets[] = sprintf('%02d', $h);
|
$buckets = []; for ($h = 0; $h < 24; $h++) $buckets[] = sprintf('%02d', $h);
|
||||||
break;
|
break;
|
||||||
case 'week':
|
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)';
|
$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'];
|
$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;
|
break;
|
||||||
case 'month':
|
case 'month':
|
||||||
$bucketFn = 'DAY(submitted_at)';
|
$bucketFn = 'DAY(submitted_at)';
|
||||||
@@ -572,10 +583,13 @@ if ($view === 'activity') {
|
|||||||
return array_sum($b['values']) - array_sum($a['values']); // aktivste zuerst
|
return array_sum($b['values']) - array_sum($a['values']); // aktivste zuerst
|
||||||
});
|
});
|
||||||
Response::ok([
|
Response::ok([
|
||||||
'range' => $range,
|
'range' => $range,
|
||||||
'groupBy' => 'student',
|
'groupBy' => 'student',
|
||||||
'buckets' => $buckets,
|
'buckets' => $buckets,
|
||||||
'series' => $seriesList,
|
'bucketDates' => $bucketDates,
|
||||||
|
'weekOffset' => $weekOffset,
|
||||||
|
'weekLabel' => $weekLabel,
|
||||||
|
'series' => $seriesList,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,10 +623,13 @@ if ($view === 'activity') {
|
|||||||
return array_sum($s['values']) > 0;
|
return array_sum($s['values']) > 0;
|
||||||
}));
|
}));
|
||||||
Response::ok([
|
Response::ok([
|
||||||
'range' => $range,
|
'range' => $range,
|
||||||
'groupBy' => 'module',
|
'groupBy' => 'module',
|
||||||
'buckets' => $buckets,
|
'buckets' => $buckets,
|
||||||
'series' => $seriesList,
|
'bucketDates' => $bucketDates,
|
||||||
|
'weekOffset' => $weekOffset,
|
||||||
|
'weekLabel' => $weekLabel,
|
||||||
|
'series' => $seriesList,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -662,47 +679,88 @@ if ($view === 'module') {
|
|||||||
);
|
);
|
||||||
if (!$modInfo) Response::error('Modul nicht gefunden', 404);
|
if (!$modInfo) Response::error('Modul nicht gefunden', 404);
|
||||||
|
|
||||||
// Schüler + Progress + Aggregat über assessments (Single-Query mit Sub-Selects)
|
// Optionaler Datumsfilter (Schuljahr-Default liefert der Client).
|
||||||
$rows = $db->fetchAll(
|
$from = $_GET['from'] ?? null;
|
||||||
"SELECT s.id, s.display_name, s.emoji_avatar,
|
$to = $_GET['to'] ?? null;
|
||||||
pp.best_stars, pp.plays, pp.xp, pp.level,
|
$dateSql = ''; $dateParams = [];
|
||||||
(SELECT MAX(a.submitted_at)
|
if ($from && preg_match('/^\d{4}-\d{2}-\d{2}$/', (string)$from)
|
||||||
FROM assessments a
|
&& $to && preg_match('/^\d{4}-\d{2}-\d{2}$/', (string)$to)) {
|
||||||
JOIN student_sessions ss ON ss.id = a.session_id
|
$dateSql = ' AND sr.completed_at >= ? AND sr.completed_at < ? ';
|
||||||
WHERE ss.class_id = s.class_id AND ss.display_name = s.display_name
|
$dateParams = [$from . ' 00:00:00', date('Y-m-d', strtotime($to . ' +1 day')) . ' 00:00:00'];
|
||||||
AND a.sim_id = ?) AS last_played,
|
} else { $from = null; $to = null; }
|
||||||
(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]
|
|
||||||
);
|
|
||||||
|
|
||||||
|
$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 = [];
|
$students = [];
|
||||||
$sumPlays = 0; $sumDuration = 0; $sumStars = 0; $countWithStars = 0;
|
$sumPlays = 0; $sumDuration = 0; $sumStars = 0; $countWithStars = 0;
|
||||||
foreach ($rows as $r) {
|
foreach ($base as $b) {
|
||||||
$stars = (int)($r['best_stars'] ?? 0);
|
$sid = (int)$b['id'];
|
||||||
$plays = (int)($r['plays'] ?? 0);
|
$a = $aggS[$sid] ?? ['plays'=>0,'best'=>0,'last'=>null,'dur'=>0,'hl'=>[]];
|
||||||
$students[] = [
|
$students[] = [
|
||||||
'id' => (int)$r['id'],
|
'id' => $sid,
|
||||||
'displayName' => $r['display_name'],
|
'displayName' => $b['display_name'],
|
||||||
'emoji' => $r['emoji_avatar'] ?: '🧑🎓',
|
'emoji' => $b['emoji_avatar'] ?: '🧑🎓',
|
||||||
'best_stars' => $stars,
|
'best_stars' => (int)$a['best'],
|
||||||
'plays' => $plays,
|
'plays' => (int)$a['plays'],
|
||||||
'xp' => (int)($r['xp'] ?? 0),
|
'last_played' => $a['last'],
|
||||||
'level' => (int)($r['level'] ?? 1),
|
'total_duration_ms' => (int)$a['dur'],
|
||||||
'last_played' => $r['last_played'],
|
'highlights' => $a['hl'] ? $a['hl'] : new stdClass(),
|
||||||
'total_duration_ms' => (int)($r['total_duration_ms'] ?? 0),
|
|
||||||
];
|
];
|
||||||
$sumPlays += $plays;
|
$sumPlays += $a['plays']; $sumDuration += $a['dur'];
|
||||||
$sumDuration += (int)($r['total_duration_ms'] ?? 0);
|
if ($a['plays'] > 0) { $sumStars += $a['best']; $countWithStars++; }
|
||||||
if ($plays > 0) { $sumStars += $stars; $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 = [
|
$classStats = [
|
||||||
'totalPlays' => $sumPlays,
|
'totalPlays' => $sumPlays,
|
||||||
@@ -712,76 +770,76 @@ if ($view === 'module') {
|
|||||||
'studentsTotal' => count($students),
|
'studentsTotal' => count($students),
|
||||||
];
|
];
|
||||||
|
|
||||||
// === Modul-Highlights aus assessments.results aggregieren ===
|
// Klassen-Champion je Highlight (bester Wert aller Schüler:innen)
|
||||||
$highlights = $MODULE_HIGHLIGHTS[$moduleId] ?? [];
|
$champions = [];
|
||||||
if ($highlights) {
|
foreach ($highlights as $h) {
|
||||||
// Alle Assessments dieser Klasse für dieses Modul + zugeordneter Schueler
|
$best = null; $bestSid = null;
|
||||||
$rows = $db->fetchAll(
|
foreach ($students as $st) {
|
||||||
"SELECT s.id AS student_id, a.results
|
$hv = is_array($st['highlights']) ? ($st['highlights'][$h['key']] ?? null) : null;
|
||||||
FROM students s
|
if ($hv === null) continue;
|
||||||
JOIN student_sessions ss ON ss.class_id = s.class_id AND ss.display_name = s.display_name
|
if ($best === null || ($h['agg'] === 'min' ? $hv < $best : $hv > $best)) { $best = $hv; $bestSid = $st['id']; }
|
||||||
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)
|
if ($bestSid !== null) $champions[$h['key']] = $bestSid;
|
||||||
$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([
|
Response::ok([
|
||||||
'module' => $modInfo,
|
'module' => $modInfo,
|
||||||
'classStats' => $classStats,
|
'classStats' => $classStats,
|
||||||
'highlights' => $highlights,
|
'highlights' => $highlights,
|
||||||
'champions' => $champions ?? new stdClass(),
|
'champions' => (object)$champions,
|
||||||
'students' => $students,
|
'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
|
// VIEW 4: REFLECTIONS — alle Reflexions-Antworten der Klasse, alle Sims
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
+155
-63
@@ -2112,6 +2112,7 @@ var IV_CSS = ''
|
|||||||
+ '.iv-ic svg{width:19px;height:19px;stroke:currentColor;stroke-width:1.9;fill:none;stroke-linecap:round;stroke-linejoin:round}'
|
+ '.iv-ic svg{width:19px;height:19px;stroke:currentColor;stroke-width:1.9;fill:none;stroke-linecap:round;stroke-linejoin:round}'
|
||||||
+ '.iv-ct{flex:1}.iv-ct h4{margin:0;font-size:.9rem;font-weight:750;color:var(--iva)}.iv-ct p{margin:0;font-size:.72rem;color:var(--ivmut)}'
|
+ '.iv-ct{flex:1}.iv-ct h4{margin:0;font-size:.9rem;font-weight:750;color:var(--iva)}.iv-ct p{margin:0;font-size:.72rem;color:var(--ivmut)}'
|
||||||
+ '.iv-cn{font-size:1.05rem;font-weight:750;color:var(--iva)}'
|
+ '.iv-cn{font-size:1.05rem;font-weight:750;color:var(--iva)}'
|
||||||
|
+ '.iv-cempty{font-size:.72rem;color:var(--ivmut);font-style:italic;padding:.7rem .2rem .3rem}'
|
||||||
+ '.iv-cols{display:grid;grid-template-columns:72px 1fr 28px 50px 46px 14px;gap:9px;padding:0 8px 3px;font-size:.52rem;font-weight:700;text-transform:uppercase;letter-spacing:.03em;color:var(--ivmut);border-bottom:1px solid var(--ivline)}'
|
+ '.iv-cols{display:grid;grid-template-columns:72px 1fr 28px 50px 46px 14px;gap:9px;padding:0 8px 3px;font-size:.52rem;font-weight:700;text-transform:uppercase;letter-spacing:.03em;color:var(--ivmut);border-bottom:1px solid var(--ivline)}'
|
||||||
+ '.iv-cols span:nth-child(2){justify-self:end}.iv-cols span:nth-child(3),.iv-cols span:nth-child(4){justify-self:center}.iv-cols span:nth-child(5){justify-self:end}'
|
+ '.iv-cols span:nth-child(2){justify-self:end}.iv-cols span:nth-child(3),.iv-cols span:nth-child(4){justify-self:center}.iv-cols span:nth-child(5){justify-self:end}'
|
||||||
+ '.iv-prow{display:grid;grid-template-columns:72px 1fr 28px 50px 46px 14px;gap:9px;align-items:center;width:100%;text-align:left;cursor:pointer;background:none;border:0;border-radius:8px;padding:7px 8px;font:inherit;color:inherit}'
|
+ '.iv-prow{display:grid;grid-template-columns:72px 1fr 28px 50px 46px 14px;gap:9px;align-items:center;width:100%;text-align:left;cursor:pointer;background:none;border:0;border-radius:8px;padding:7px 8px;font:inherit;color:inherit}'
|
||||||
@@ -2254,13 +2255,17 @@ function ivBuildOverview(cockpit, weeks, studentsCount, matrix){
|
|||||||
var need=groups.support.length+groups.more.length+groups.work.length+groups.good.length+groups.idle.length;
|
var need=groups.support.length+groups.more.length+groups.work.length+groups.good.length+groups.idle.length;
|
||||||
h+='<div class="iv-sec">Empfohlene Aktivität für dich.<span>'+need+' von '+studentsCount+'</span></div><div class="iv-grid">';
|
h+='<div class="iv-sec">Empfohlene Aktivität für dich.<span>'+need+' von '+studentsCount+'</span></div><div class="iv-grid">';
|
||||||
['support','more','work','good','idle'].forEach(function(key){
|
['support','more','work','good','idle'].forEach(function(key){
|
||||||
var rows=groups[key]; if(!rows.length) return;
|
var rows=groups[key];
|
||||||
var m=IV_META[key];
|
var m=IV_META[key];
|
||||||
h+='<div class="iv-card" style="--iva:var('+m[2]+')"><div class="iv-chead"><span class="iv-ic"><svg viewBox="0 0 24 24">'+IV_ICON[key]+'</svg></span><div class="iv-ct"><h4>'+m[0]+'</h4><p>'+m[1]+'</p></div><span class="iv-cn num">'+rows.length+'</span></div>';
|
h+='<div class="iv-card" style="--iva:var('+m[2]+')"><div class="iv-chead"><span class="iv-ic"><svg viewBox="0 0 24 24">'+IV_ICON[key]+'</svg></span><div class="iv-ct"><h4>'+m[0]+'</h4><p>'+m[1]+'</p></div><span class="iv-cn num">'+rows.length+'</span></div>';
|
||||||
h+='<div class="iv-cols"><span></span><span title="Ø der letzten 3 Läufe, 0–100">Erfolg</span><span title="arbeitet gerade? blinkt live">Aktiv</span><span title="aktive Zeit pro Woche">Verlauf</span><span title="Tage seit dem letzten Lauf">Zuletzt</span><span></span></div>';
|
if(rows.length){
|
||||||
rows.forEach(function(r){
|
h+='<div class="iv-cols"><span></span><span title="Ø der letzten 3 Läufe, 0–100">Erfolg</span><span title="arbeitet gerade? blinkt live">Aktiv</span><span title="aktive Zeit pro Woche">Verlauf</span><span title="Tage seit dem letzten Lauf">Zuletzt</span><span></span></div>';
|
||||||
h+='<button class="iv-prow" onclick="ivOpenStudent('+r.sid+')"><span class="iv-nm">'+esc(r.name)+'</span><span style="display:contents">'+ivErf(r.erf)+ivAkt(r.status)+ivSpark(r.secs,maxSec,IV_WEEK_CAP)+'</span>'+ivZuletzt(r.days)+'<svg class="iv-chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></svg></button>';
|
rows.forEach(function(r){
|
||||||
});
|
h+='<button class="iv-prow" onclick="ivOpenStudent('+r.sid+')"><span class="iv-nm">'+esc(r.name)+'</span><span style="display:contents">'+ivErf(r.erf)+ivAkt(r.status)+ivSpark(r.secs,maxSec,IV_WEEK_CAP)+'</span>'+ivZuletzt(r.days)+'<svg class="iv-chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></svg></button>';
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
h+='<div class="iv-cempty">Aktuell niemand betroffen.</div>';
|
||||||
|
}
|
||||||
h+='</div>';
|
h+='</div>';
|
||||||
});
|
});
|
||||||
h+='</div>';
|
h+='</div>';
|
||||||
@@ -2367,6 +2372,133 @@ async function ivOpenStudent(sid){
|
|||||||
// === ÜBERSICHT ===
|
// === ÜBERSICHT ===
|
||||||
// Lehrer-Übersicht: Stat-Bar + aktive Aufgaben + Top-Performer + Aktivitäts-Sparkline + Modul-Cards.
|
// Lehrer-Übersicht: Stat-Bar + aktive Aufgaben + Top-Performer + Aktivitäts-Sparkline + Modul-Cards.
|
||||||
// Drei parallele API-Calls (matrix + activity-week + assignments) — alle existing.
|
// Drei parallele API-Calls (matrix + activity-week + assignments) — alle existing.
|
||||||
|
// === Schuljahr-Datumsfilter für die Modul-Sektion ===
|
||||||
|
var _modFilter = { from: null, to: null };
|
||||||
|
function ggsTodayStr() {
|
||||||
|
var d = new Date();
|
||||||
|
return d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0');
|
||||||
|
}
|
||||||
|
function ggsSchoolYearStart() {
|
||||||
|
var d = new Date();
|
||||||
|
// Schuljahr beginnt 1. August: ab August das laufende Jahr, davor das Vorjahr.
|
||||||
|
var y = (d.getMonth() >= 7) ? d.getFullYear() : d.getFullYear() - 1;
|
||||||
|
return y + '-08-01';
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Wochen-Säulengrafik mit Navigation (prev/next Woche) ===
|
||||||
|
var _weekOffset = 0;
|
||||||
|
function weekChartCard(a) {
|
||||||
|
var series = (a && a.series) || [];
|
||||||
|
var buckets = (a && a.buckets) || ['Mo','Di','Mi','Do','Fr','Sa','So'];
|
||||||
|
var dates = (a && a.bucketDates) || [];
|
||||||
|
var offset = (a && a.weekOffset != null) ? a.weekOffset : _weekOffset;
|
||||||
|
_weekOffset = offset;
|
||||||
|
var label = (a && a.weekLabel) || 'Diese Woche';
|
||||||
|
|
||||||
|
var maxStack = 0;
|
||||||
|
for (var i = 0; i < buckets.length; i++) {
|
||||||
|
var sum = series.reduce(function(acc, s){ return acc + (s.values[i]||0); }, 0);
|
||||||
|
if (sum > maxStack) maxStack = sum;
|
||||||
|
}
|
||||||
|
var hasData = maxStack > 0;
|
||||||
|
function niceStep(m){ if(m<=5)return 1; if(m<=10)return 2; if(m<=20)return 5; if(m<=50)return 10; if(m<=100)return 20; return Math.ceil(m/250)*50; }
|
||||||
|
var step = niceStep(maxStack || 1);
|
||||||
|
var topTick = Math.max(step, Math.ceil((maxStack||1)/step) * step);
|
||||||
|
|
||||||
|
var W = 600, H = 172, padL = 24, padR = 8, padT = 10, padB = 28;
|
||||||
|
var chartW = W - padL - padR, chartH = H - padT - padB;
|
||||||
|
var stepX = chartW / buckets.length, barW = stepX * 0.74;
|
||||||
|
|
||||||
|
var svg = '<svg viewBox="0 0 '+W+' '+H+'" style="width:100%;height:auto;display:block" '
|
||||||
|
+ 'font-family="\'Arial Narrow\',\'Roboto Condensed\',\'Segoe UI\',system-ui,sans-serif">';
|
||||||
|
// Y-Skala (Anzahl) links + Hilfslinien
|
||||||
|
for (var t = 0; t <= topTick; t += step) {
|
||||||
|
var gy = padT + chartH - (t / topTick) * chartH;
|
||||||
|
svg += '<line x1="'+padL+'" y1="'+gy+'" x2="'+(W-padR)+'" y2="'+gy+'" stroke="#ece8dc" stroke-width="1"/>';
|
||||||
|
svg += '<text x="'+(padL-3)+'" y="'+(gy+2.4)+'" font-size="6" text-anchor="end" fill="#9a9a9a">'+t+'</text>';
|
||||||
|
}
|
||||||
|
// Säulen (gestapelt nach Modul)
|
||||||
|
buckets.forEach(function(lbl, idx) {
|
||||||
|
var x = padL + idx * stepX + (stepX - barW) / 2;
|
||||||
|
var yCursor = padT + chartH;
|
||||||
|
series.forEach(function(s) {
|
||||||
|
var v = s.values[idx] || 0; if (!v) return;
|
||||||
|
var h = (v / topTick) * chartH; yCursor -= h;
|
||||||
|
svg += '<rect x="'+x+'" y="'+yCursor+'" width="'+barW+'" height="'+h+'" fill="'+s.color+'"><title>'+esc(s.name)+': '+v+'</title></rect>';
|
||||||
|
if (h >= 9) {
|
||||||
|
var nm = s.name.length > 15 ? s.name.slice(0, 14) + '…' : s.name;
|
||||||
|
svg += '<text x="'+(x+barW/2)+'" y="'+(yCursor + h/2 + 1.7)+'" font-size="4" text-anchor="middle" '
|
||||||
|
+ 'fill="#fff" stroke="rgba(0,0,0,.4)" stroke-width=".3" paint-order="stroke" style="pointer-events:none">'+esc(nm)+'</text>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Wochentag + Datum (halbe Schrift, condensed)
|
||||||
|
svg += '<text x="'+(x+barW/2)+'" y="'+(H-13)+'" font-size="5" text-anchor="middle" fill="#4a4a4a" font-weight="700">'+esc(lbl)+'</text>';
|
||||||
|
if (dates[idx]) svg += '<text x="'+(x+barW/2)+'" y="'+(H-6)+'" font-size="4.5" text-anchor="middle" fill="#9a9a9a">'+esc(dates[idx])+'</text>';
|
||||||
|
});
|
||||||
|
svg += '</svg>';
|
||||||
|
|
||||||
|
var canNext = offset > 0;
|
||||||
|
var head = '<div class="card-h" style="display:flex;align-items:center;gap:.4rem">'
|
||||||
|
+ '<span style="flex:1">📊 Durchgänge nach Wochentag</span>'
|
||||||
|
+ '<button class="btn-sm" style="padding:.05rem .55rem;font-size:.9rem;line-height:1" onclick="loadWeekChart('+(offset+1)+')" title="Woche zurück">‹</button>'
|
||||||
|
+ '<span style="font-size:.64rem;color:#6a6a6a;min-width:158px;text-align:center;font-weight:400">'+esc(label)+'</span>'
|
||||||
|
+ '<button class="btn-sm" style="padding:.05rem .55rem;font-size:.9rem;line-height:1'+(canNext?'':';opacity:.3;pointer-events:none')+'" onclick="loadWeekChart('+(offset-1)+')" title="Woche vor">›</button>'
|
||||||
|
+ '</div>';
|
||||||
|
var body = hasData ? svg : '<p style="font-size:.74rem;color:#8a8a8a;padding:1.2rem 0;text-align:center">Keine Durchgänge in dieser Woche.</p>';
|
||||||
|
return '<div class="card">' + head + body + '</div>';
|
||||||
|
}
|
||||||
|
async function loadWeekChart(offset) {
|
||||||
|
offset = Math.max(0, offset); _weekOffset = offset;
|
||||||
|
var hostEl = document.getElementById('ov-weekchart'); if (!hostEl) return;
|
||||||
|
hostEl.style.opacity = '.5';
|
||||||
|
var a = await api('results?class_id=' + currentClassId + '&view=activity&range=week&week_offset=' + offset);
|
||||||
|
if (!a || a.error) a = { series: [], buckets: ['Mo','Di','Mi','Do','Fr','Sa','So'], weekOffset: offset };
|
||||||
|
hostEl.style.opacity = '';
|
||||||
|
hostEl.innerHTML = weekChartCard(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Modul-Sektion mit Zeitfilter ===
|
||||||
|
var _ovModules = [], _ovStudentCount = 0;
|
||||||
|
function renderModulesSection(modStats) {
|
||||||
|
var stats = (modStats && modStats.stats) || {};
|
||||||
|
var html = '<div class="card"><div class="card-h" style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">'
|
||||||
|
+ '<span style="flex:1;min-width:160px">Module — Stand der Klasse</span>'
|
||||||
|
+ '<span style="display:inline-flex;align-items:center;gap:.3rem;font-weight:400;font-size:.7rem;color:#6a6a6a">von '
|
||||||
|
+ '<input type="date" id="mf-from" value="'+_modFilter.from+'" onchange="applyModFilter()" style="font:inherit;padding:.12rem .3rem;border:1px solid #ddd;border-radius:5px"> bis '
|
||||||
|
+ '<input type="date" id="mf-to" value="'+_modFilter.to+'" onchange="applyModFilter()" style="font:inherit;padding:.12rem .3rem;border:1px solid #ddd;border-radius:5px">'
|
||||||
|
+ '<button class="btn-sm" style="padding:.15rem .5rem" onclick="resetModFilter()" title="Auf Schuljahresbeginn zurücksetzen">↺</button>'
|
||||||
|
+ '</span></div>';
|
||||||
|
html += '<div class="result-mod-grid">';
|
||||||
|
_ovModules.forEach(function(mod) {
|
||||||
|
var st = stats[mod.id] || { plays:0, studentsActive:0, avgStars:null };
|
||||||
|
var avgStars = (st.avgStars != null) ? Number(st.avgStars).toFixed(1) : '–';
|
||||||
|
var imgSrc = mod.card_image ? 'assets/img/' + mod.card_image : null;
|
||||||
|
html += '<div class="result-mod-card" onclick="openModuleDetail(\''+mod.id+'\')">'
|
||||||
|
+ '<div class="rm-img">' + (imgSrc ? '<img src="'+imgSrc+'" alt="">' : '<span style="font-size:2.2rem">'+(mod.icon||'📚')+'</span>') + '</div>'
|
||||||
|
+ '<div class="rm-body"><div class="rm-name">'+(mod.icon||'')+' '+esc(mod.name)+'</div>'
|
||||||
|
+ '<div class="rm-stats">⭐ '+avgStars+' · '+st.plays+'× · '+st.studentsActive+'/'+_ovStudentCount+'</div>'
|
||||||
|
+ '</div></div>';
|
||||||
|
});
|
||||||
|
html += '</div></div>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
async function applyModFilter() {
|
||||||
|
var f = document.getElementById('mf-from'), t = document.getElementById('mf-to');
|
||||||
|
if (f && f.value) _modFilter.from = f.value;
|
||||||
|
if (t && t.value) _modFilter.to = t.value;
|
||||||
|
var host = document.getElementById('ov-modules'); if (!host) return;
|
||||||
|
host.style.opacity = '.5';
|
||||||
|
var ms = await api('results?class_id=' + currentClassId + '&view=mod_stats&from=' + _modFilter.from + '&to=' + _modFilter.to);
|
||||||
|
if (!ms || ms.error) ms = { stats: {} };
|
||||||
|
host.style.opacity = '';
|
||||||
|
host.innerHTML = renderModulesSection(ms);
|
||||||
|
}
|
||||||
|
function resetModFilter() {
|
||||||
|
_modFilter.from = ggsSchoolYearStart();
|
||||||
|
_modFilter.to = ggsTodayStr();
|
||||||
|
applyModFilter();
|
||||||
|
}
|
||||||
|
|
||||||
// Avatar-Bild oder Emoji, gegebene Pixelgröße
|
// Avatar-Bild oder Emoji, gegebene Pixelgröße
|
||||||
function ivTodayAvatar(emoji, px) {
|
function ivTodayAvatar(emoji, px) {
|
||||||
if (typeof emoji === 'string' && emoji.indexOf('avatar:') === 0)
|
if (typeof emoji === 'string' && emoji.indexOf('avatar:') === 0)
|
||||||
@@ -2435,7 +2567,11 @@ async function loadOverview() {
|
|||||||
var host = document.getElementById('tab-overview');
|
var host = document.getElementById('tab-overview');
|
||||||
host.innerHTML = '<p style="font-size:.78rem;color:#8a8a8a;padding:1rem">Lade…</p>';
|
host.innerHTML = '<p style="font-size:.78rem;color:#8a8a8a;padding:1rem">Lade…</p>';
|
||||||
|
|
||||||
var [matrix, activity, assignments, students, cockpit, weeks, today] = await Promise.all([
|
// Modul-Datumsfilter: Default bis=heute, von=Schuljahresbeginn (1. August)
|
||||||
|
if (!_modFilter.from) _modFilter.from = ggsSchoolYearStart();
|
||||||
|
if (!_modFilter.to) _modFilter.to = ggsTodayStr();
|
||||||
|
|
||||||
|
var [matrix, activity, assignments, students, cockpit, weeks, today, modStats] = await Promise.all([
|
||||||
api('results?class_id=' + currentClassId + '&view=matrix'),
|
api('results?class_id=' + currentClassId + '&view=matrix'),
|
||||||
api('results?class_id=' + currentClassId + '&view=activity&range=week'),
|
api('results?class_id=' + currentClassId + '&view=activity&range=week'),
|
||||||
api('assignments?class_id=' + currentClassId),
|
api('assignments?class_id=' + currentClassId),
|
||||||
@@ -2443,6 +2579,7 @@ async function loadOverview() {
|
|||||||
api('live?class_id=' + currentClassId + '&cockpit=1'),
|
api('live?class_id=' + currentClassId + '&cockpit=1'),
|
||||||
api('results?class_id=' + currentClassId + '&view=fleiss&range=weeks'),
|
api('results?class_id=' + currentClassId + '&view=fleiss&range=weeks'),
|
||||||
api('results?class_id=' + currentClassId + '&view=today'),
|
api('results?class_id=' + currentClassId + '&view=today'),
|
||||||
|
api('results?class_id=' + currentClassId + '&view=mod_stats&from=' + _modFilter.from + '&to=' + _modFilter.to),
|
||||||
]);
|
]);
|
||||||
if (matrix.error) { host.innerHTML = '<p style="color:#c07a6b">'+esc(matrix.error)+'</p>'; return; }
|
if (matrix.error) { host.innerHTML = '<p style="color:#c07a6b">'+esc(matrix.error)+'</p>'; return; }
|
||||||
if (activity.error) activity = {series:[], buckets:[]};
|
if (activity.error) activity = {series:[], buckets:[]};
|
||||||
@@ -2451,6 +2588,7 @@ async function loadOverview() {
|
|||||||
if (!cockpit || cockpit.error) cockpit = {students:[], statusCounts:{}};
|
if (!cockpit || cockpit.error) cockpit = {students:[], statusCounts:{}};
|
||||||
if (!weeks || weeks.error) weeks = {students:[], globalMaxBucketSec:0};
|
if (!weeks || weeks.error) weeks = {students:[], globalMaxBucketSec:0};
|
||||||
if (!today || today.error) today = {topToday:[], progress:[]};
|
if (!today || today.error) today = {topToday:[], progress:[]};
|
||||||
|
if (!modStats || modStats.error) modStats = {stats:{}};
|
||||||
|
|
||||||
// === Top 3 Performer aus Matrix-Cells ableiten ===
|
// === Top 3 Performer aus Matrix-Cells ableiten ===
|
||||||
var topRuns = [];
|
var topRuns = [];
|
||||||
@@ -2509,62 +2647,13 @@ async function loadOverview() {
|
|||||||
// === Sektion C: Top Performers heute + heute neu vergebene Abzeichen ===
|
// === Sektion C: Top Performers heute + heute neu vergebene Abzeichen ===
|
||||||
html += ivBuildTodaySection(today, modById);
|
html += ivBuildTodaySection(today, modById);
|
||||||
|
|
||||||
// === Sektion D: Aktivitäts-Sparkline (kompakt, Woche) ===
|
// === Sektion D: Wochen-Säulengrafik mit Navigation ===
|
||||||
if (activity.series && activity.series.length > 0) {
|
html += '<div id="ov-weekchart">' + weekChartCard(activity) + '</div>';
|
||||||
var maxStack = 0;
|
|
||||||
for (var i = 0; i < activity.buckets.length; i++) {
|
|
||||||
var sum = activity.series.reduce(function(acc, s){ return acc + (s.values[i]||0); }, 0);
|
|
||||||
if (sum > maxStack) maxStack = sum;
|
|
||||||
}
|
|
||||||
if (maxStack < 1) maxStack = 1;
|
|
||||||
|
|
||||||
var W = 600, H = 160, padL = 22, padR = 6, padT = 10, padB = 20;
|
// === Sektion E: Module — Stand der Klasse (mit Zeitfilter) ===
|
||||||
var chartW = W - padL - padR, chartH = H - padT - padB;
|
_ovModules = matrix.modules;
|
||||||
var step = chartW / activity.buckets.length;
|
_ovStudentCount = matrix.students.length;
|
||||||
var barW = step * 0.74;
|
html += '<div id="ov-modules">' + renderModulesSection(modStats) + '</div>';
|
||||||
|
|
||||||
var svg = '<svg viewBox="0 0 '+W+' '+H+'" style="width:100%;height:auto;display:block">';
|
|
||||||
activity.buckets.forEach(function(label, idx) {
|
|
||||||
var x = padL + idx * step + (step - barW)/2;
|
|
||||||
var yCursor = padT + chartH;
|
|
||||||
activity.series.forEach(function(s) {
|
|
||||||
var v = s.values[idx] || 0;
|
|
||||||
if (!v) return;
|
|
||||||
var h = (v / maxStack) * chartH;
|
|
||||||
yCursor -= h;
|
|
||||||
svg += '<rect x="'+x+'" y="'+yCursor+'" width="'+barW+'" height="'+h+'" fill="'+s.color+'"><title>'+s.icon+' '+esc(s.name)+': '+v+'</title></rect>';
|
|
||||||
// Sim-Titel klein in den Abschnitt schreiben, wenn hoch genug
|
|
||||||
if (h >= 12) {
|
|
||||||
var nm = s.name.length > 14 ? s.name.slice(0, 13) + '…' : s.name;
|
|
||||||
svg += '<text x="'+(x+barW/2)+'" y="'+(yCursor + h/2 + 2.6)+'" font-size="7.5" text-anchor="middle" '
|
|
||||||
+ 'fill="#fff" stroke="rgba(0,0,0,.4)" stroke-width=".45" paint-order="stroke" style="pointer-events:none">'+esc(nm)+'</text>';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
svg += '<text x="'+(x+barW/2)+'" y="'+(H-5)+'" font-size="9" text-anchor="middle" fill="#4a4a4a">'+esc(label)+'</text>';
|
|
||||||
});
|
|
||||||
svg += '</svg>';
|
|
||||||
html += '<div class="card"><div class="card-h">📊 Diese Woche — Durchgänge nach Tag</div>'+svg+'</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Sektion E: Modul-Performance-Cards (kompakt, alle aktiven Module) ===
|
|
||||||
html += '<div class="card"><div class="card-h">Module — Stand der Klasse</div>';
|
|
||||||
html += '<div class="result-mod-grid">';
|
|
||||||
matrix.modules.forEach(function(mod) {
|
|
||||||
var totalPlaysM = 0, totalStars = 0, withPlays = 0;
|
|
||||||
matrix.students.forEach(function(s) {
|
|
||||||
var c = matrix.cells[s.id]?.[mod.id];
|
|
||||||
if (c && c.plays > 0) { totalPlaysM += c.plays; totalStars += c.stars; withPlays++; }
|
|
||||||
});
|
|
||||||
var avgStars = withPlays ? (totalStars / withPlays).toFixed(1) : '–';
|
|
||||||
var imgSrc = mod.card_image ? 'assets/img/' + mod.card_image : null;
|
|
||||||
html += '<div class="result-mod-card" onclick="openModuleDetail(\''+mod.id+'\')">'
|
|
||||||
+ '<div class="rm-img">' + (imgSrc ? '<img src="'+imgSrc+'" alt="">' : '<span style="font-size:2.2rem">'+(mod.icon||'📚')+'</span>') + '</div>'
|
|
||||||
+ '<div class="rm-body">'
|
|
||||||
+ '<div class="rm-name">'+(mod.icon||'')+' '+esc(mod.name)+'</div>'
|
|
||||||
+ '<div class="rm-stats">⭐ '+avgStars+' · '+totalPlaysM+'× · '+withPlays+'/'+matrix.students.length+'</div>'
|
|
||||||
+ '</div></div>';
|
|
||||||
});
|
|
||||||
html += '</div></div>';
|
|
||||||
|
|
||||||
host.innerHTML = html;
|
host.innerHTML = html;
|
||||||
}
|
}
|
||||||
@@ -3562,7 +3651,8 @@ function renderModuleCards() {
|
|||||||
async function openModuleDetail(moduleId) {
|
async function openModuleDetail(moduleId) {
|
||||||
showModal('modal-mod-detail');
|
showModal('modal-mod-detail');
|
||||||
document.getElementById('mod-detail-body').innerHTML = '<p style="font-size:.7rem;color:#8a8a8a">Lade…</p>';
|
document.getElementById('mod-detail-body').innerHTML = '<p style="font-size:.7rem;color:#8a8a8a">Lade…</p>';
|
||||||
var d = await api('results?class_id=' + currentClassId + '&view=module&module_id=' + moduleId);
|
var dq = (_modFilter.from && _modFilter.to) ? ('&from=' + _modFilter.from + '&to=' + _modFilter.to) : '';
|
||||||
|
var d = await api('results?class_id=' + currentClassId + '&view=module&module_id=' + moduleId + dq);
|
||||||
if (d.error) { document.getElementById('mod-detail-body').innerHTML = '<p style="color:#c07a6b">'+esc(d.error)+'</p>'; return; }
|
if (d.error) { document.getElementById('mod-detail-body').innerHTML = '<p style="color:#c07a6b">'+esc(d.error)+'</p>'; return; }
|
||||||
_modDetail = { moduleId: moduleId, data: d, sortKey: 'best_stars', sortDir: 'desc' };
|
_modDetail = { moduleId: moduleId, data: d, sortKey: 'best_stars', sortDir: 'desc' };
|
||||||
renderModDetail();
|
renderModDetail();
|
||||||
@@ -3593,7 +3683,9 @@ function renderModDetail() {
|
|||||||
var html = '<div style="display:flex;align-items:center;gap:.6rem;margin-bottom:.6rem">';
|
var html = '<div style="display:flex;align-items:center;gap:.6rem;margin-bottom:.6rem">';
|
||||||
html += '<span style="font-size:2rem">'+d.module.icon+'</span>';
|
html += '<span style="font-size:2rem">'+d.module.icon+'</span>';
|
||||||
html += '<div><h3 style="margin:0">'+esc(d.module.name)+'</h3>';
|
html += '<div><h3 style="margin:0">'+esc(d.module.name)+'</h3>';
|
||||||
html += '<div style="font-size:.7rem;color:#8a8a8a">Klasse: '+d.classStats.totalPlays+' Durchgänge · '+totalH+'h Spielzeit · Ø '+(d.classStats.avgStars||'–')+'★ · '+d.classStats.studentsActive+'/'+d.classStats.studentsTotal+' aktiv</div></div></div>';
|
function dmy(s){ if(!s) return ''; var p=s.split('-'); return p[2]+'.'+p[1]+'.'+p[0].slice(2); }
|
||||||
|
var zeit = (d.from && d.to) ? ' · <b style="color:#4a6a4e">'+dmy(d.from)+'–'+dmy(d.to)+'</b>' : '';
|
||||||
|
html += '<div style="font-size:.7rem;color:#8a8a8a">Klasse: '+d.classStats.totalPlays+' Durchgänge · '+totalH+'h Spielzeit · Ø '+(d.classStats.avgStars||'–')+'★ · '+d.classStats.studentsActive+'/'+d.classStats.studentsTotal+' aktiv'+zeit+'</div></div></div>';
|
||||||
|
|
||||||
var hl = d.highlights || [];
|
var hl = d.highlights || [];
|
||||||
var champs = d.champions || {};
|
var champs = d.champions || {};
|
||||||
|
|||||||
Reference in New Issue
Block a user