diff --git a/App/php/api/live.php b/App/php/api/live.php index 222b22b..d66310a 100644 --- a/App/php/api/live.php +++ b/App/php/api/live.php @@ -141,6 +141,14 @@ if ($method === 'GET') { if (($pl['phase'] ?? '') === 'completed') return true; return null; }; + // Erfolgs-Score 0–100 (für die Ampelfarbe der Tages-Balken). Sterne → %, + // sonst der Sim-Score (Farmer/Weltküche liefern `score`). + $extractScore = function($row) { + $r = $row['results'] ? json_decode($row['results'], true) : []; + foreach ([$r['score'] ?? null, $r['results']['score'] ?? null] as $c) if (is_numeric($c)) return max(0, min(100, (int)round($c))); + if (isset($r['stars']) && is_numeric($r['stars'])) return (int)round($r['stars'] / 5 * 100); + return null; + }; // Gruppieren: student_id × sim_id × lesson_key → eine ODER MEHRERE Sessions. // Wichtig: bei Lücke > SESSION_GAP_SEC zwischen aufeinanderfolgenden Submits @@ -197,6 +205,8 @@ if ($method === 'GET') { 'startMs' => $ts * 1000, 'endMs' => $ts * 1000, 'stars' => null, + 'score' => null, + 'durationMs' => 0, 'completed' => null, 'rowCount' => 0, ]; @@ -205,6 +215,10 @@ if ($method === 'GET') { $currentSess['rowCount']++; $st = $extractStars($row); if ($st !== null && ($currentSess['stars'] === null || $st > $currentSess['stars'])) $currentSess['stars'] = $st; + $scv = $extractScore($row); + if ($scv !== null && ($currentSess['score'] === null || $scv > $currentSess['score'])) $currentSess['score'] = $scv; + $dmv = (int)($row['duration_ms'] ?? 0); + if ($dmv > $currentSess['durationMs']) $currentSess['durationMs'] = $dmv; $cp = $extractCompleted($row); if ($cp !== null) $currentSess['completed'] = $cp; $prevTs = $ts; @@ -227,6 +241,14 @@ if ($method === 'GET') { ]; } $durSec = max(0, intval(($s['endMs'] - $s['startMs']) / 1000)); + // Echte Spieldauer aus duration_ms — ein einzelnes Assessment (z. B. der + // Farmer-Endbericht) hätte sonst Dauer ~0 und würde faelschlich als + // „Abbruch" gewertet. Startzeit entsprechend zurückdatieren, damit der + // Balken die ganze Spielzeit abdeckt (und gleich startende Läufe fluchten). + if (!empty($s['durationMs']) && $s['durationMs'] > $durSec * 1000) { + $durSec = intval($s['durationMs'] / 1000); + $s['startMs'] = $s['endMs'] - $s['durationMs']; + } // Success-Klassifikation $success = 'unknown'; if ($s['completed'] === false) $success = 'aborted'; @@ -234,6 +256,8 @@ if ($method === 'GET') { if ($s['stars'] >= 4) $success = 'good'; elseif ($s['stars'] >= 2) $success = 'mid'; else $success = 'bad'; + } elseif ($s['score'] !== null) { + $success = $s['score'] >= 65 ? 'good' : ($s['score'] >= 40 ? 'mid' : 'bad'); } elseif ($s['completed'] === true) $success = 'good'; elseif ($durSec < 60) $success = 'aborted'; else $success = 'running'; @@ -247,6 +271,7 @@ if ($method === 'GET') { 'endMs' => $s['endMs'], 'durationSec' => $durSec, 'stars' => $s['stars'], + 'score' => $s['score'], 'completed' => $s['completed'], 'success' => $success, 'rowCount' => $s['rowCount'], @@ -314,6 +339,8 @@ if ($method === 'GET') { $startMs = ((int)$lr['started_ts']) * 1000; $endMs = ((int)$lr['last_seen_ts']) * 1000; $durSec = max(0, intval(($endMs - $startMs) / 1000)); + // Live-Erfolgs-Score für die Ampelfarbe: Farmer = aktuelle Trefferquote. + $liveScore = ($simId === 'farmer' && isset($state['hit_rate'])) ? max(0, min(100, (int)$state['hit_rate'])) : null; $byStudent[$sid]['sessions'][] = [ 'simId' => $simId, 'simTitle' => $mi['title'] ?? $simId, @@ -324,6 +351,7 @@ if ($method === 'GET') { 'endMs' => $endMs, 'durationSec' => $durSec, 'stars' => null, + 'score' => $liveScore, 'completed' => null, 'success' => 'running', 'rowCount' => 1, diff --git a/App/teacher.html b/App/teacher.html index f06dc78..9956861 100644 --- a/App/teacher.html +++ b/App/teacher.html @@ -1574,6 +1574,11 @@ function _liveSuccessColor(success) { unknown: '#7a8a9c', })[success] || '#7a8a9c'; } +// Ampelfarbe für den Tages-Erfolg (0–100): grün → hellgrün → gelb → orange → rot. +function _tagTraffic(v) { + v = Math.max(0, Math.min(100, v || 0)); + return v >= 80 ? '#1f7a3d' : v >= 65 ? '#7cb342' : v >= 50 ? '#f9c33b' : v >= 35 ? '#ef8b2c' : '#d8453a'; +} function _liveSuccessIcon(stars, success) { if (success === 'aborted') return '✗'; if (success === 'running') return '⏳'; @@ -2119,38 +2124,40 @@ async function renderLiveSummary() { var pctL = _msToPct(sess.startMs); var pctR = _msToPct(endMs); var pctW = Math.max(0.8, pctR - pctL); // mindestens 0.8 % sichtbar - var simW = simWord(sess.simId), simC = simCode(sess.simId); - var col = simColor(sess.simId); // eine Farbe je Sim - var stars = (sess.stars != null) ? Math.max(0, Math.min(5, sess.stars)) : null; + var icon = sess.simIcon || '📚'; + var simName = sess.simTitle || simWord(sess.simId); + // Erfolg 0–100 (Score, sonst Sterne → %). Bestimmt die AMPELFARBE. + var score = (sess.score != null) ? sess.score + : (sess.stars != null ? Math.round(Math.max(0,Math.min(5,sess.stars))/5*100) : null); var aborted = sess.success === 'aborted'; - var completed = !aborted && stars != null; // fertig gespielt - var inProgress = !aborted && stars == null; // noch dran / kein Ergebnis - // Balkenhöhe = Maß für den Erfolg (mehr Sterne → höher). Abbruch/läuft: flach. - var barH = completed ? (9 + Math.round(stars / 5 * 15)) : (aborted ? 9 : 12); - // Rahmen kodiert den Zustand: fertig = geschlossen · noch dran = rechts offen · - // Abbruch = gestrichelt (+ ✖ am Ende). - var border = completed ? 'border:1.5px solid rgba(20,20,20,.45);' - : inProgress ? 'border:1.5px solid rgba(20,20,20,.4);border-right:0;' - : 'border:1.5px dashed rgba(20,20,20,.45);'; - var stateWord = completed ? 'fertig' : aborted ? 'abgebrochen' : 'läuft noch'; + var inProgress = !aborted && (sess.isLive || sess.success === 'running'); + // Farbe = Erfolg (Ampel). Kein Wert → neutral grau. Abbruch → rot-gestrichelt. + var col = aborted ? '#c0392b' : (score != null ? _tagTraffic(score) : '#aab2b8'); + var barH = 18; // EINHEITLICHE Höhe — der Erfolg steckt in der Farbe, nicht in der Höhe + // Rahmen kodiert den Zustand: fertig = geschlossen · läuft = rechts gepunktet · Abbruch = gestrichelt + var border = aborted ? 'border:1.5px dashed rgba(255,255,255,.7);' + : inProgress ? 'border:1.5px solid rgba(255,255,255,.55);border-right:2px dotted #fff;' + : 'border:1.5px solid rgba(20,20,20,.25);'; + var stateWord = inProgress ? 'läuft noch' : aborted ? 'abgebrochen' : 'fertig'; var tip = (sess.simTitle||sess.simId) + (sess.lessonLabel?' · '+sess.lessonLabel:'') + ' · ' + _fmtHM(sess.startMs) + '–' + _fmtHM(endMs) + ' · ' + _liveFmtMin(realDurSec) + ' · ' + stateWord - + (stars!=null?' · '+stars+'/5★':''); - // enger Balken → Kürzel, mittel → Wort, breit → Wort + Lektion - var inner = (pctW < 2.5) ? '' : (pctW < 6) ? simC : (pctW < 11) ? simW : (simW + (sess.lessonLabel ? ' '+sess.lessonLabel : '')); + + (score!=null?' · Erfolg '+score+' %':''); + // Symbol immer sichtbar; ab etwas Breite zusätzlich der Sim-Name + var inner = (pctW < 2) ? '' : (pctW < 9 ? ''+icon+'' + : ''+icon+' '+esc(simName)+''); var sessKey = s.studentId + ':' + sess.simId + ':' + (sess.lessonLabel||'_') + ':' + sess.startMs; html += '