Tag-Ansicht: echte Spieldauer + Ampelfarbe nach Erfolg + Sim-Symbol

Bug: fertig gespielte Laeufe (Farmer-Endbericht = 1 Assessment) erschienen als
kurzer "Abbruch"-Balken (Dauer 0). Fix:
- live.php summary: nutzt duration_ms -> Balken decken die echte Spielzeit ab und
  fluchten am Start; kein Falsch-Abbruch mehr. Neues Feld `score` (0-100) je
  Session (Score/Sterne); laufende Farmer-Sessions bekommen hit_rate als Live-Score.
- teacher.html Tag-Balken: einheitliche Hoehe (Hoehe raus), Farbe = Ampel nach
  Erfolg (gruen->rot), App-Farbe ersetzt durch Sim-Symbol; Klick = Details.
  Legende angepasst.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 12:56:47 +02:00
parent 85ee58c56d
commit 4e38b27c64
2 changed files with 69 additions and 27 deletions
+28
View File
@@ -141,6 +141,14 @@ if ($method === 'GET') {
if (($pl['phase'] ?? '') === 'completed') return true;
return null;
};
// Erfolgs-Score 0100 (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,
+41 -27
View File
@@ -1574,6 +1574,11 @@ function _liveSuccessColor(success) {
unknown: '#7a8a9c',
})[success] || '#7a8a9c';
}
// Ampelfarbe für den Tages-Erfolg (0100): 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 0100 (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 ? '<span style="font-size:.82rem">'+icon+'</span>'
: '<span style="font-size:.82rem">'+icon+'</span> <span style="font-weight:700">'+esc(simName)+'</span>');
var sessKey = s.studentId + ':' + sess.simId + ':' + (sess.lessonLabel||'_') + ':' + sess.startMs;
html += '<div onclick="openLiveSessionDetail(\''+sessKey+'\')" '
+ 'title="'+esc(tip)+'" '
+ 'style="position:absolute;left:'+pctL+'%;width:'+pctW+'%;bottom:2px;height:'+barH+'px;'
+ 'background:'+col+';color:#fff;border-radius:3px;'+border
+ 'padding:0 .3rem;font-size:.6rem;font-weight:700;line-height:'+Math.max(9,barH-3)+'px;'
+ 'style="position:absolute;left:'+pctL+'%;width:'+pctW+'%;top:5px;height:'+barH+'px;'
+ 'background:'+col+';color:#fff;border-radius:4px;'+border
+ 'padding:0 .35rem;font-size:.66rem;line-height:'+barH+'px;'
+ 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;'
+ 'text-shadow:0 1px 1px rgba(0,0,0,.35);box-shadow:0 1px 2px rgba(0,0,0,.12)">'
+ esc(inner) + '</div>';
+ 'text-shadow:0 1px 1px rgba(0,0,0,.3);box-shadow:0 1px 2px rgba(0,0,0,.12)">'
+ inner + '</div>';
if (aborted) {
html += '<span style="position:absolute;left:'+pctR+'%;bottom:-1px;height:28px;line-height:28px;'
+ 'font-size:.74rem;color:#b03a2a;font-weight:800;padding-left:1px;pointer-events:none">✖</span>';
html += '<span style="position:absolute;left:'+pctR+'%;top:0;height:28px;line-height:28px;'
+ 'font-size:.74rem;color:#b03a2a;font-weight:800;padding-left:2px;pointer-events:none">✖</span>';
}
});
html += '</div>';
@@ -2158,10 +2165,17 @@ async function renderLiveSummary() {
html += '</div>';
html += '<div style="margin-top:.8rem;display:flex;gap:1rem;flex-wrap:wrap;font-size:.66rem;color:#5a5a5a;align-items:center">';
html += '<span><b>Farbe</b> = Simulation · <b>Höhe</b> = Erfolg (höher = besser)</span>';
html += '<span><span style="display:inline-block;width:16px;height:11px;background:#8a8a8a;border:1.5px solid rgba(20,20,20,.45);vertical-align:middle;margin-right:.25rem"></span>fertig</span>';
html += '<span><span style="display:inline-block;width:16px;height:11px;background:#8a8a8a;border:1.5px solid rgba(20,20,20,.4);border-right:0;vertical-align:middle;margin-right:.25rem"></span>läuft noch</span>';
html += '<span><span style="display:inline-block;width:16px;height:11px;background:#8a8a8a;border:1.5px dashed rgba(20,20,20,.45);vertical-align:middle;margin-right:.15rem"></span><span style="color:#b03a2a;font-weight:800"></span> Abbruch</span>';
html += '<span style="display:inline-flex;align-items:center;gap:.3rem"><b>Farbe</b> = Erfolg:'
+ '<span style="display:inline-flex;border-radius:2px;overflow:hidden">'
+ '<span style="display:inline-block;width:13px;height:11px;background:#1f7a3d"></span>'
+ '<span style="display:inline-block;width:13px;height:11px;background:#7cb342"></span>'
+ '<span style="display:inline-block;width:13px;height:11px;background:#f9c33b"></span>'
+ '<span style="display:inline-block;width:13px;height:11px;background:#ef8b2c"></span>'
+ '<span style="display:inline-block;width:13px;height:11px;background:#d8453a"></span></span>'
+ ' stark → schwach</span>';
html += '<span><b>Symbol</b> = Simulation · <b>Klick</b> = Details</span>';
html += '<span><span style="display:inline-block;width:16px;height:11px;background:#aab2b8;border:1.5px solid rgba(255,255,255,.55);border-right:2px dotted #fff;vertical-align:middle;margin-right:.25rem"></span>läuft noch</span>';
html += '<span><span style="color:#b03a2a;font-weight:800"></span> Abbruch</span>';
html += '</div>';
html += '</div>';