Files
geograsim/App/teacher.html
T
Adminator 3dba841830 Lizenz-Feinschliff: CSV-Mailanhang, Lehrer-Listenspalten, Klassenliste-Haken
- Mailanhang bei grossen Chargen: zusaetzlich zur .txt jetzt auch .csv
  (gleiche reine Codeliste), Dateiname <ExtBestellnr|Datum>_geograsim_<intNr>.
- Lehrer-Lizenzlisten (zugewiesen + frei): vorne laufende Nr., Eingeloest am,
  Gueltig bis (Backend liefert redeemed_at + valid_until).
- Klassenliste Lizenz-Spalte: nur Haken (gruen) bzw. "fehlt" (rot); Tooltip
  zeigt Lizenznummer + gueltig bis. "Gueltig" via unlicensed-Liste (nicht
  storniert/abgelaufen). Bulk-Auswahl/Zaehler nutzen jetzt dieselbe Wahrheit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 11:33:44 +02:00

5636 lines
335 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoGraSim — Dashboard für Lehrpersonen</title>
<link rel="icon" type="image/png" href="favicon-96x96.png" sizes="96x96"><link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="stylesheet" href="assets/fonts/inter.css">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Inter',sans-serif;background:#f7f6f3;color:#1a1a1a;min-height:100vh}
/* Topbar */
.topbar{background:#fff;padding:.5rem 1rem;display:flex;align-items:center;gap:.6rem;border-bottom:1px solid rgba(0,0,0,.06);position:sticky;top:0;z-index:10;flex-wrap:wrap}
.topbar .logo{height:28px}
.topbar .spacer{flex:1}
.topbar .user{font-size:.7rem;color:#8a8a8a}
.topbar .user-pill{display:flex;align-items:center;gap:.35rem;padding:.15rem .6rem .15rem .15rem;background:#dae8ec;border-radius:99px;font-size:.72rem;font-weight:600;color:#4a7c8a;text-decoration:none;cursor:pointer}
.topbar .user-pill:hover{background:#c8dce2}
.topbar .user-pill .emoji{font-size:1.2rem;width:28px;height:28px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:#fff;overflow:hidden}
.topbar .user-pill .emoji img{width:100%;height:100%;object-fit:cover;display:block}
.btn-sm{padding:.25rem .55rem;border:1px solid rgba(0,0,0,.08);border-radius:6px;background:#fff;font-size:.65rem;font-weight:600;cursor:pointer;color:#4a4a4a;text-decoration:none;display:inline-flex;align-items:center;gap:.2rem}
.btn-sm:hover{background:#dae8ec;color:#4a7c8a}
/* Klassen-Bar */
.class-bar{background:#fff;padding:.4rem 1rem;border-bottom:1px solid rgba(0,0,0,.04);display:flex;align-items:center;gap:.6rem;flex-wrap:wrap}
.class-bar label{font-size:.68rem;font-weight:600;color:#8a8a8a}
.class-bar select{padding:.3rem .6rem;border:1.5px solid rgba(0,0,0,.1);border-radius:6px;font-size:.78rem;font-family:inherit;font-weight:600;color:#4a7c8a;background:#fff;cursor:pointer}
.class-bar .code-box{background:#dae8ec;border-radius:5px;padding:.2rem .5rem;font-family:'Courier New',monospace;font-weight:800;font-size:.75rem;color:#4a7c8a;letter-spacing:.06em;cursor:pointer}
.class-bar .code-box:hover{background:#c8dce2}
/* Tabs */
.tabs{display:flex;gap:0;background:#fff;border-bottom:2px solid rgba(0,0,0,.04);padding:0 1rem}
.tab{padding:.6rem 1rem;font-size:.78rem;font-weight:600;color:#8a8a8a;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-2px;transition:all .15s}
.tab:hover{color:#4a7c8a}
.tab.on{color:#4a7c8a;border-bottom-color:#4a7c8a}
/* Live-Tab-Indikator: idle = grau, aktiv = grün und blinkt */
.live-dot{display:inline-block;width:.55em;height:.55em;border-radius:50%;background:#c0c0c0;margin-right:.25em;vertical-align:middle;transition:background .2s}
#tab-btn-live.live-active .live-dot{background:#5a8a5e;box-shadow:0 0 0 0 rgba(90,138,94,.7);animation:livePulse 1.4s infinite}
.live-count{display:none;margin-left:.3em;font-size:.62rem;background:#5a8a5e;color:#fff;border-radius:999px;padding:.05rem .4rem;font-weight:700;vertical-align:middle}
#tab-btn-live.live-active .live-count{display:inline-block}
@keyframes livePulse{
0% {box-shadow:0 0 0 0 rgba(90,138,94,.55)}
70% {box-shadow:0 0 0 7px rgba(90,138,94,0)}
100% {box-shadow:0 0 0 0 rgba(90,138,94,0)}
}
@keyframes _tagblink{ 0%,100%{opacity:1} 50%{opacity:.5} }
/* Run-Detail (Pretty-Print für Lehrer-Drilldown) */
.run-list{display:flex;flex-direction:column;gap:.5rem;margin-top:.4rem}
.run-card{background:#fdfdfb;border:1px solid rgba(0,0,0,.07);border-radius:8px;padding:.6rem .75rem}
.run-head{display:flex;justify-content:space-between;align-items:flex-start;gap:.6rem;margin-bottom:.4rem}
.run-meta{font-size:.66rem;color:#8a8a8a;margin-top:.15rem}
.run-stars{font-size:.95rem;color:#e8c547;letter-spacing:.05em}
.run-stars-empty{opacity:.25}
.run-stats{display:flex;gap:1rem;font-size:.7rem;color:#8a8a8a;margin-bottom:.4rem;flex-wrap:wrap}
.run-stats b{color:#1f4b37;font-weight:700}
.run-steps{width:100%;border-collapse:collapse;font-size:.7rem;margin-top:.3rem}
.run-steps th{text-align:left;padding:.25rem .35rem;background:#f7f6f3;font-size:.62rem;color:#8a8a8a;font-weight:600}
.run-steps td{padding:.3rem .35rem;border-bottom:1px solid rgba(0,0,0,.03);vertical-align:top}
.run-detour{display:inline-block;padding:1px 6px;border-radius:6px;font-size:.62rem;font-weight:700;margin-right:.25rem;margin-bottom:.15rem}
.run-detour.kind-muelleimer{background:#b04a3e;color:#fff}
.run-detour.kind-soft{background:#fff3cf;color:#1f4b37;border:1px solid #e8c547}
.run-json{font-size:.6rem;background:#f7f6f3;padding:.4rem;margin-top:.2rem;border-radius:4px;white-space:pre-wrap;max-height:240px;overflow-y:auto}
/* Content */
.wrap{max-width:1600px;width:calc(100% - 1.6rem);margin:0 auto;padding:.6rem .8rem}
.card{background:#fff;border-radius:10px;padding:.8rem;box-shadow:0 1px 4px rgba(0,0,0,.03);border:1px solid rgba(0,0,0,.04);margin-bottom:.8rem}
/* Live-Tab: volle Breite ohne Scrollbar — Tabelle füllt den Card aus, Scrollbar nur als Notnagel wenn wirklich zu eng */
#tab-live .card{padding:.5rem .7rem;margin-bottom:.5rem}
#tab-live .card-h{margin-bottom:.2rem}
#tab-live table{table-layout:auto}
#tab-live th,#tab-live td{padding:.3rem .35rem !important}
/* Auf großen Screens braucht's keinen Overflow-Scroll mehr */
@media (min-width:1100px){
#tab-live .live-table-wrap{overflow-x:visible}
}
/* Mini-Bars für Live-Tabelle: Wert + Balken in einer Zelle, farbcodiert */
.lv-cell{display:inline-flex;flex-direction:column;align-items:flex-end;min-width:64px;gap:2px;line-height:1.1}
.lv-cell.left{align-items:flex-start}
.lv-cell.center{align-items:center}
.lv-val{font-variant-numeric:tabular-nums;font-weight:700;font-size:.74rem}
.lv-bar{width:100%;max-width:80px;height:4px;background:rgba(0,0,0,.06);border-radius:99px;overflow:hidden}
.lv-bar-fill{height:100%;border-radius:99px;transition:width .4s ease-out}
.lv-good{color:#3d6b3f} .lv-good .lv-bar-fill{background:#5a8a5e}
.lv-warn{color:#a16a14} .lv-warn .lv-bar-fill{background:#e8a541}
.lv-bad{color:#9a3a2c} .lv-bad .lv-bar-fill{background:#c07a6b}
.lv-neutral{color:#3d5165} .lv-neutral .lv-bar-fill{background:#7a8a9c}
.lv-muted{color:#8a8a8a}
.card-h{font-size:.6rem;font-weight:700;color:#4a7c8a;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.4rem}
.btn{padding:.4rem .8rem;border:none;border-radius:6px;background:#4a7c8a;color:#fff;font-size:.72rem;font-weight:700;cursor:pointer}
.btn:hover{background:#3a6470}
.btn-outline{background:transparent;color:#4a7c8a;border:1.5px solid #4a7c8a}
.btn-danger{background:#c07a6b}
/* Stats */
.stat-row{display:flex;gap:.5rem;margin-bottom:.5rem}
.stat{flex:1;text-align:center;background:#f7f6f3;border-radius:8px;padding:.5rem}
.stat .n{font-size:1.2rem;font-weight:900;color:#4a7c8a}
.stat .l{font-size:.55rem;color:#8a8a8a}
/* Module Cards */
.mod-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1rem;margin-bottom:1rem}
.mod-card{background:#fff;border:1.5px solid rgba(0,0,0,.06);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;transition:all .2s;position:relative}
.mod-card.coming{opacity:.6}
.mod-card.coming .mc-img{filter:grayscale(.5)}
.mc-img{height:210px;background:linear-gradient(135deg,#dae8ec,#c8dce2);position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;font-size:2.5rem}
.mc-img img{width:100%;height:100%;object-fit:cover}
.mc-mode-badge{position:absolute;top:8px;right:8px;background:rgba(255,255,255,.95);border-radius:8px;padding:.3rem .55rem;font-size:.78rem;font-weight:700;cursor:pointer;border:1.5px solid rgba(0,0,0,.08);transition:all .15s;display:inline-flex;align-items:center;gap:.25rem}
.mc-mode-badge:hover:not(.disabled){border-color:#4a7c8a;background:#fff}
.mc-mode-badge.locked{color:#8a8a8a}
.mc-mode-badge.free{color:#5a8a5e;background:#dceadd}
.mc-mode-badge.teacher_started{color:#4a7c8a;background:#dae8ec}
.mc-mode-badge.disabled{cursor:not-allowed;opacity:.5}
.mc-coming-overlay{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(255,255,255,.92);border-radius:8px;padding:.35rem .7rem;font-size:.78rem;font-weight:700;color:#a37800}
.mc-body{padding:.7rem .85rem .85rem;flex:1;display:flex;flex-direction:column}
.mc-name{font-size:.92rem;font-weight:700;line-height:1.2;margin-bottom:.25rem}
.mc-desc{font-size:.72rem;color:#4a4a4a;line-height:1.4;margin-bottom:.55rem;flex:1}
.mc-actions{display:flex;flex-wrap:wrap;gap:.35rem;margin-top:auto}
.mc-btn{display:inline-flex;align-items:center;gap:.25rem;padding:.42rem .65rem;border-radius:7px;font-size:.7rem;font-weight:600;text-decoration:none;border:1.5px solid rgba(0,0,0,.08);background:#f7f6f3;color:#4a4a4a;transition:all .12s;min-height:32px;white-space:nowrap}
.mc-btn:hover{background:#fff;border-color:rgba(74,124,138,.4);color:#1a1a1a}
.mc-btn.primary{background:#e8833a;color:#fff;border-color:#e8833a;flex:1;justify-content:center}
.mc-btn.primary:hover{background:#d06f2a;color:#fff}
/* Matrix */
.matrix-wrap{overflow-x:auto}
.matrix{border-collapse:collapse;font-size:.68rem;width:100%}
.matrix th{background:#f7f6f3;padding:.3rem .25rem;font-weight:600;font-size:.6rem;color:#8a8a8a;text-align:center;position:sticky;top:0;z-index:1}
.matrix th.student-col{text-align:left;min-width:120px}
.matrix th .mod-emoji{font-size:1rem;display:block}
.matrix th .mod-short{font-size:.45rem;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:55px}
.matrix td{padding:.2rem .25rem;text-align:center;border-bottom:1px solid rgba(0,0,0,.02)}
.matrix td.student-cell{text-align:left;font-weight:600;white-space:nowrap}
.m-cell{width:30px;height:28px;border-radius:5px;border:1.5px solid rgba(0,0,0,.06);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;font-size:.75rem;transition:all .12s;background:#fff}
.m-cell:hover{border-color:#4a7c8a;transform:scale(1.15)}
.m-cell.locked{background:#f7f6f3;color:#bbb}
.m-cell.free{background:#dceadd;color:#5a8a5e}
.m-cell.teacher_started{background:#dae8ec;color:#4a7c8a}
/* Schüler-Tabelle */
table.students{width:100%;border-collapse:collapse;font-size:.72rem}
table.students th{background:#f7f6f3;padding:.35rem .4rem;text-align:left;font-weight:600;font-size:.62rem;color:#8a8a8a}
table.students td{padding:.35rem .4rem;border-bottom:1px solid rgba(0,0,0,.03)}
.toolbar{display:flex;gap:.3rem;margin-bottom:.5rem;flex-wrap:wrap}
/* Modals */
.modal{position:fixed;inset:0;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;z-index:100}
.modal-card{background:#fff;border-radius:12px;padding:1.2rem;max-width:420px;width:90%;max-height:85vh;overflow-y:auto}
.modal-card h3{font-size:.9rem;font-weight:700;margin-bottom:.5rem}
.modal-card label{display:block;font-size:.68rem;font-weight:600;margin-top:.5rem;color:#4a4a4a}
.modal-card input,.modal-card textarea{width:100%;padding:.4rem .6rem;border:1.5px solid rgba(0,0,0,.1);border-radius:6px;font-size:.78rem;margin-top:.15rem;font-family:inherit}
.hidden{display:none}
/* === Ergebnisse-Tab: kompakte Modulcards (4 in einer Reihe) === */
.result-mod-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:.6rem;margin-bottom:.4rem}
.result-mod-card{background:#fff;border:1.5px solid rgba(0,0,0,.06);border-radius:10px;overflow:hidden;cursor:pointer;transition:all .18s;display:flex;flex-direction:column}
.result-mod-card:hover{border-color:#4a7c8a;transform:translateY(-2px);box-shadow:0 6px 16px rgba(74,124,138,.1)}
.rm-img{height:90px;background:#dae8ec;overflow:hidden;display:flex;align-items:center;justify-content:center}
.rm-img img{width:100%;height:100%;object-fit:cover}
.rm-body{padding:.45rem .55rem .55rem}
.rm-name{font-size:.78rem;font-weight:700;line-height:1.15;margin-bottom:.15rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.rm-stats{font-size:.65rem;color:#6a6a6a}
@media(max-width:700px){.result-mod-grid{grid-template-columns:repeat(2,1fr)}}
/* === Ergebnisse-Tab === */
.result-matrix{border-collapse:collapse;width:100%;font-size:.7rem}
.result-matrix th{background:#f7f6f3;padding:.45rem .35rem;font-weight:700;cursor:pointer;text-align:center;border-bottom:1.5px solid rgba(0,0,0,.1);user-select:none;white-space:nowrap}
.result-matrix th:hover{background:#eee9dd}
.result-matrix th.r-stu{text-align:left;min-width:140px}
.result-matrix td{padding:.4rem .3rem;text-align:center;border-bottom:1px solid rgba(0,0,0,.04)}
.result-matrix td.r-stu{text-align:left;font-weight:600;white-space:nowrap}
.result-matrix td.r-cell{padding:.3rem;border-radius:0}
.result-matrix td.r-cell .rs{display:block;font-weight:700;color:#1a1a1a}
.result-matrix td.r-cell .rp{display:block;font-size:.6rem;color:#6a6a6a;margin-top:.05rem}
.result-matrix td.r-empty{color:#cbc6b8}
.result-matrix td.r-total{background:#dae8ec;font-weight:700}
.result-matrix td.r-total .rp{display:block;font-size:.6rem;color:#4a7c8a;font-weight:500}
.result-matrix .sort-i{font-size:.6rem;color:#bbb;margin-left:.2rem}
.result-matrix .sort-i.active{color:#4a7c8a}
.activity-chart{margin:.5rem 0}
.activity-svg{width:100%;height:auto;display:block}
.activity-legend{display:flex;flex-wrap:wrap;gap:.6rem;font-size:.66rem;color:#4a4a4a;margin-top:.4rem}
.activity-legend .lg-item{display:inline-flex;align-items:center;gap:.25rem}
.activity-legend .lg-dot{width:10px;height:10px;border-radius:2px;display:inline-block}
.activity-legend b{color:#1f4b37;font-weight:700}
@media(max-width:700px){.mod-cards{grid-template-columns:1fr}.stat-row{flex-wrap:wrap}}
</style>
</head>
<body>
<!-- Topbar -->
<div class="topbar">
<a href="teacher.html" aria-label="Dashboard"><img src="assets/img/geograsim_t.svg" alt="GeoGraSim" class="logo"></a>
<span class="spacer"></span>
<a href="profil.html" class="user-pill" id="user-pill" title="Profil">
<span class="emoji" id="user-emoji">👩‍🏫</span><span id="user-name"></span>
</a>
<button class="btn-sm" onclick="logout()">Abmelden</button>
</div>
<!-- Klassen-Bar -->
<div class="class-bar">
<label>Klasse:</label>
<select id="class-select" onchange="onClassChange()"></select>
<span class="code-box" id="class-code" onclick="showCode()" title="Einladungscode kopieren"></span>
<button class="btn-sm" onclick="showModal('modal-class')">+ Neue Klasse</button>
<button class="btn-sm" onclick="openTickets()">🎫 Tickets</button>
</div>
<!-- Lizenz-Warnung (Schüler:innen ohne gültige Lizenz) -->
<div id="license-warning" style="display:none"></div>
<!-- Tabs -->
<div class="tabs" id="tab-bar">
<div class="tab on" onclick="switchTab('overview')">Übersicht</div>
<div class="tab" id="tab-btn-live" onclick="switchTab('live')"><span class="live-dot"></span> Ergebnisse<span class="live-count" id="live-count"></span></div>
<div class="tab" onclick="switchTab('modules')">Modulfreigabe</div>
<div class="tab" onclick="switchTab('students')">Klasse</div>
<div class="tab" onclick="switchTab('licenses')">Lizenzen</div>
<div class="tab" onclick="switchTab('classes')">Klassen</div>
<a class="tab" href="buch" style="margin-left:auto;text-decoration:none">📖 Schulbuch</a>
</div>
<!-- Content -->
<div class="wrap">
<div id="tab-overview"></div>
<div id="tab-modules" class="hidden"></div>
<div id="tab-students" class="hidden"></div>
<div id="tab-live" class="hidden"></div>
<div id="tab-results" class="hidden"></div>
<div id="tab-licenses" class="hidden"></div>
<div id="tab-classes" class="hidden"></div>
</div>
<!-- Modal: Klasse erstellen -->
<div class="modal hidden" id="modal-class">
<div class="modal-card">
<h3>Neue Klasse</h3>
<label>Klassenname</label>
<input type="text" id="mc-name" placeholder="z.B. 1A">
<label>Schuljahr</label>
<input type="text" id="mc-year" placeholder="z.B. 2025/26">
<div style="display:flex;gap:.4rem;margin-top:.8rem">
<button class="btn" onclick="saveClass()">Speichern</button>
<button class="btn btn-outline" onclick="hideModal('modal-class')">Abbrechen</button>
</div>
</div>
</div>
<!-- Modal: Schüler erstellen -->
<div class="modal hidden" id="modal-student">
<div class="modal-card">
<h3>Schüler*in erstellen</h3>
<label>Benutzername *</label>
<input type="text" id="ms-user" placeholder="z.B. max01">
<label>Passwort *</label>
<input type="text" id="ms-pass" placeholder="mind. 4 Zeichen">
<label>Anzeigename (optional)</label>
<input type="text" id="ms-name" placeholder="z.B. Max M.">
<label>Vorname (optional)</label>
<input type="text" id="ms-first">
<label>Nachname (optional)</label>
<input type="text" id="ms-last">
<label>E-Mail (optional)</label>
<input type="email" id="ms-email">
<label style="margin-top:.6rem"><input type="checkbox" id="ms-license" checked> Lizenz automatisch zuweisen</label>
<div style="display:flex;gap:.4rem;margin-top:.8rem">
<button class="btn" onclick="saveStudent()">Erstellen</button>
<button class="btn btn-outline" onclick="hideModal('modal-student')">Abbrechen</button>
</div>
</div>
</div>
<!-- Modal: Batch -->
<div class="modal hidden" id="modal-batch">
<div class="modal-card">
<h3>Mehrere Schüler*innen erstellen</h3>
<label>Prefix</label>
<input type="text" id="mb-prefix" value="schueler">
<label>Anzahl</label>
<input type="number" id="mb-count" value="25" min="1" max="50">
<div style="display:flex;gap:.4rem;margin-top:.8rem">
<button class="btn" onclick="batchCreate()">Erstellen</button>
<button class="btn btn-outline" onclick="hideModal('modal-batch')">Abbrechen</button>
</div>
<div id="batch-result" style="margin-top:.5rem;font-size:.65rem;max-height:200px;overflow-y:auto"></div>
</div>
</div>
<!-- Modal: Bulk-PIN-Reset Ergebnis -->
<div class="modal hidden" id="modal-pins">
<div class="modal-card" style="max-width:560px">
<div id="pins-result"></div>
</div>
</div>
<!-- Modal: Modul-Detail (Ergebnisse-Tab) -->
<div class="modal hidden" id="modal-mod-detail">
<div class="modal-card" style="max-width:920px;width:96%">
<div style="display:flex;justify-content:flex-end;margin-bottom:.4rem">
<button class="btn btn-outline" style="font-size:.7rem;padding:.25rem .6rem" onclick="hideModal('modal-mod-detail')">Schließen</button>
</div>
<div id="mod-detail-body"></div>
</div>
</div>
<!-- Modal: CSV-Import -->
<div class="modal hidden" id="modal-csv">
<div class="modal-card" style="max-width:560px">
<h3>Schüler*innen aus CSV importieren</h3>
<p style="font-size:.7rem;color:#4a4a4a;margin-bottom:.4rem">
Eine Zeile pro Schüler*in. Pflicht: <b>username</b>. Optional: <b>display_name</b>, <b>password</b>, <b>first_name</b>, <b>last_name</b>, <b>email</b>, <b>easy</b> (0/1).
Wenn kein Passwort angegeben ist, wird ein 4-stelliges generiert.
</p>
<details style="margin-bottom:.4rem;font-size:.65rem">
<summary style="cursor:pointer;color:#4a7c8a;font-weight:600">Beispiel-Format anzeigen</summary>
<pre style="background:#f7f6f3;padding:.5rem;border-radius:6px;margin-top:.3rem;white-space:pre-wrap;font-family:'Courier New',monospace">username,display_name,password,first_name,last_name,email,easy
ben01,Ben,,Benjamin,Müller,,
anna01,Anna,kuh3,Anna,Schmid,,1
schueler01,Max M.,,,,,</pre>
</details>
<label>CSV-Datei (UTF-8, mit Header-Zeile)</label>
<input type="file" id="csv-file" accept=".csv,.txt" style="margin-bottom:.5rem">
<label>… oder direkt einfügen</label>
<textarea id="csv-text" rows="6" style="width:100%;font-family:'Courier New',monospace;font-size:.7rem;padding:.4rem"></textarea>
<label style="margin-top:.6rem"><input type="checkbox" id="csv-license" checked> Lizenz automatisch zuweisen</label>
<div style="display:flex;gap:.4rem;margin-top:.8rem">
<button class="btn" onclick="importCsv()">Importieren</button>
<button class="btn btn-outline" onclick="hideModal('modal-csv')">Abbrechen</button>
</div>
<div id="csv-result" style="margin-top:.5rem;font-size:.65rem;max-height:240px;overflow-y:auto"></div>
</div>
</div>
<!-- Modal: Schüler bearbeiten -->
<div class="modal hidden" id="modal-edit">
<div class="modal-card">
<h3>Schüler*in bearbeiten</h3>
<input type="hidden" id="me-id">
<label>Benutzername</label>
<input type="text" id="me-user" disabled style="background:#f7f6f3;color:#8a8a8a">
<label>Anzeigename</label>
<input type="text" id="me-name">
<label>Neues Passwort (leer = unverändert)</label>
<input type="text" id="me-pass" placeholder="mind. 4 Zeichen">
<p style="font-size:.62rem;color:#8a8a8a;margin-top:.3rem">Oder neue 4-stellige PIN automatisch generieren:</p>
<button class="btn btn-outline" style="font-size:.7rem" onclick="resetPinFromModal()">🔄 PIN neu vergeben</button>
<div style="display:flex;gap:.4rem;margin-top:.8rem">
<button class="btn" onclick="updateStudent()">Speichern</button>
<button class="btn btn-outline" onclick="hideModal('modal-edit')">Abbrechen</button>
</div>
</div>
</div>
<!-- Modal: Live-Detailansicht (Spectator) -->
<div class="modal hidden" id="modal-spectate">
<div class="modal-card" style="max-width:500px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:.6rem">
<h3 style="margin:0;font-size:.95rem" id="spec-title">Live-Ansicht</h3>
<button class="btn btn-outline" onclick="closeSpectator()" style="font-size:.7rem;padding:.3rem .7rem">Schließen</button>
</div>
<div id="spec-body" style="min-height:120px"></div>
</div>
</div>
<!-- Modal: Session-Detail (Klick auf Balken in Live-Übersicht) -->
<div class="modal hidden" id="modal-sessiondetail">
<div class="modal-card" style="max-width:540px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:.6rem">
<h3 style="margin:0;font-size:.95rem" id="sessdet-title">Session-Detail</h3>
<button class="btn btn-outline" onclick="hideModal('modal-sessiondetail')" style="font-size:.7rem;padding:.3rem .7rem">Schließen</button>
</div>
<div id="sessdet-body" style="min-height:140px;font-size:.78rem"></div>
</div>
</div>
<!-- Modal: Klassen-Aufgabe erteilen -->
<div class="modal hidden" id="modal-startsim">
<div class="modal-card">
<h3>🚀 Klassen-Aufgabe erteilen</h3>
<p style="font-size:.7rem;color:#8a8a8a;margin-bottom:.5rem">Modul und Stufe wählen, Frist setzen. Die Aufgabe erscheint bei allen Schüler*innen ganz oben.</p>
<label>Modul</label>
<select id="ss-module" onchange="ssUpdateSummary()" style="width:100%;padding:.4rem .6rem;border:1.5px solid rgba(0,0,0,.1);border-radius:6px;font-size:.78rem;font-family:inherit"></select>
<label>Stufe</label>
<select id="ss-level" onchange="ssUpdateSummary()" style="width:100%;padding:.4rem .6rem;border:1.5px solid rgba(0,0,0,.1);border-radius:6px;font-size:.78rem;font-family:inherit">
<option value="1">Stufe 1 — Einsteiger</option>
<option value="2">Stufe 2 — Fortgeschritten</option>
<option value="3">Stufe 3 — Profi</option>
</select>
<label>Ab wann?</label>
<div style="display:flex;gap:.3rem;margin-bottom:.4rem">
<button type="button" id="ss-start-now" class="btn" style="font-size:.7rem;padding:.3rem .7rem" onclick="ssStartMode('now')">▶ Sofort</button>
<button type="button" id="ss-start-at" class="btn btn-outline" style="font-size:.7rem;padding:.3rem .7rem" onclick="ssStartMode('at')">🕒 Zeitpunkt wählen</button>
</div>
<div id="ss-start-fields" style="display:none;gap:.4rem;margin-bottom:.5rem">
<input type="date" id="ss-start-date" onchange="ssUpdateSummary()" style="padding:.35rem .5rem;border:1.5px solid rgba(0,0,0,.12);border-radius:6px;font-size:.78rem;font-family:inherit">
<input type="time" id="ss-start-time" value="08:00" onchange="ssUpdateSummary()" style="padding:.35rem .5rem;border:1.5px solid rgba(0,0,0,.12);border-radius:6px;font-size:.78rem;font-family:inherit">
</div>
<label>Bis wann (Frist)</label>
<div style="display:flex;gap:.3rem;flex-wrap:wrap;margin-bottom:.4rem">
<button type="button" class="btn btn-outline" style="font-size:.66rem;padding:.25rem .55rem" onclick="ssSetDue('heute')">Heute 13:00</button>
<button type="button" class="btn btn-outline" style="font-size:.66rem;padding:.25rem .55rem" onclick="ssSetDue('morgen')">Morgen</button>
<button type="button" class="btn btn-outline" style="font-size:.66rem;padding:.25rem .55rem" onclick="ssSetDue('woche')">+1 Woche</button>
<button type="button" class="btn btn-outline" style="font-size:.66rem;padding:.25rem .55rem" onclick="ssSetDue('')">Kein Limit</button>
</div>
<div style="display:flex;gap:.4rem;align-items:center">
<input type="date" id="ss-end-date" onchange="ssUpdateSummary()" style="padding:.35rem .5rem;border:1.5px solid rgba(0,0,0,.12);border-radius:6px;font-size:.78rem;font-family:inherit">
<input type="time" id="ss-end-time" value="13:00" onchange="ssUpdateSummary()" style="padding:.35rem .5rem;border:1.5px solid rgba(0,0,0,.12);border-radius:6px;font-size:.78rem;font-family:inherit">
</div>
<div id="ss-summary" style="margin-top:.7rem;padding:.5rem .7rem;background:#f5f3eb;border:1px solid rgba(0,0,0,.06);border-radius:8px;font-size:.72rem;color:#3a3a3a"></div>
<div style="display:flex;gap:.4rem;margin-top:.7rem">
<button class="btn" onclick="startSimulation()">✓ Auftrag erteilen</button>
<button class="btn btn-outline" onclick="hideModal('modal-startsim')">Abbrechen</button>
</div>
</div>
</div>
<!-- Modal: Aufgabe beenden -->
<div class="modal hidden" id="modal-stopassign">
<div class="modal-card">
<h3>■ Aufgabe beenden</h3>
<p style="font-size:.82rem;color:#3a3a3a;line-height:1.5">Möchtest du die Aufgabe <b id="stopassign-name"></b> wirklich beenden?</p>
<p style="font-size:.72rem;color:#8a8a8a;line-height:1.5;margin-top:.4rem">Die Schüler:innen sehen sie danach nicht mehr als Auftrag. Bereits erledigte Durchgänge und Ergebnisse bleiben erhalten — das Modul bleibt zum freien Üben verfügbar (sofern freigegeben).</p>
<div style="display:flex;gap:.4rem;margin-top:1rem">
<button class="btn" style="background:#c07a6b" onclick="doStopAssignment()">■ Aufgabe beenden</button>
<button class="btn btn-outline" onclick="hideModal('modal-stopassign')">Abbrechen</button>
</div>
</div>
</div>
<!-- Modal: Lizenzen einlösen -->
<div class="modal hidden" id="modal-license">
<div class="modal-card">
<h3>🔑 Lizenzen einlösen</h3>
<p style="font-size:.7rem;color:#8a8a8a;margin-bottom:.5rem">Ein oder mehrere Codes (einer pro Zeile) — oder eine .txt/.csv-Datei ablegen:</p>
<textarea id="ml-codes" rows="4" style="font-family:'Courier New',monospace" placeholder="ABCD-EFGH-JKLM-NPQR-STUV"></textarea>
<div id="ml-drop" style="margin-top:.4rem;border:1.5px dashed #cbd8dc;border-radius:8px;padding:.5rem;text-align:center;font-size:.66rem;color:#8a8a8a;cursor:pointer" ondragover="event.preventDefault();this.style.background='#eef6f8'" ondragleave="this.style.background=''" ondrop="licenseDrop(event)">
📄 .txt/.csv hierher ziehen oder <label style="color:#4a7c8a;font-weight:700;cursor:pointer;text-decoration:underline">auswählen<input type="file" id="ml-file" accept=".txt,.csv,text/plain,text/csv" style="display:none" onchange="licenseFilePick(this)"></label>
<div id="ml-fileinfo" style="margin-top:.2rem"></div>
</div>
<div style="display:flex;gap:.4rem;margin-top:.5rem">
<button class="btn" onclick="redeemLicenses()">Einlösen</button>
<button class="btn btn-outline" onclick="hideModal('modal-license')">Abbrechen</button>
</div>
<div id="license-result" style="margin-top:.4rem;font-size:.65rem"></div>
</div>
</div>
<script src="assets/js/badges.js"></script>
<script src="assets/js/sim-metrics.js"></script>
<script>
var BASE = (window.__GGS__?.baseUrl) || '';
if (!BASE) { var p = location.pathname, i = p.indexOf('/geograsim'); if (i !== -1) { var rest = p.substring(i + '/geograsim'.length); BASE = rest.indexOf('/App/') === 0 ? p.substring(0, i) + '/geograsim/App' : p.substring(0, i) + '/geograsim'; } }
var allClasses = [], currentClassId = null, currentClass = null;
var currentTab = 'overview';
async function api(endpoint, data) {
var opts = data ? {method:'POST',headers:{'Content-Type':'application/json'},credentials:'same-origin',body:JSON.stringify(data)} : {credentials:'same-origin'};
return (await fetch(BASE + '/api/' + endpoint, opts)).json();
}
function esc(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
function showModal(id) { document.getElementById(id).classList.remove('hidden'); }
function hideModal(id) { document.getElementById(id).classList.add('hidden'); }
function logout() { api('auth', {action:'logout'}).then(function() { location.href = 'index.html'; }); }
// === INIT ===
api('auth', {action:'status'}).then(function(r) {
if (r.role !== 'teacher') { location.href = 'login.html'; return; }
// Profile separat holen für Avatar + display_name
api('profile').then(function(p) {
var prof = (p && p.profile) || {};
document.getElementById('user-name').textContent = prof.display_name || r.user?.display_name || 'Lehrperson';
var av = prof.emoji_avatar || '👩‍🏫';
var emojiEl = document.getElementById('user-emoji');
if (typeof av === 'string' && av.indexOf('avatar:') === 0) {
emojiEl.innerHTML = '<img src="' + BASE + '/assets/img/avatars/thumbs/avatar-' + av.substring(7) + '.webp" alt="">';
} else {
emojiEl.textContent = av;
}
});
loadClasses();
});
// === TABS ===
function switchTab(tab) {
currentTab = tab;
var tabs = ['overview','live','modules','students','licenses','classes'];
document.querySelectorAll('#tab-bar .tab').forEach(function(t, i) { t.classList.toggle('on', tabs[i] === tab); });
tabs.forEach(function(t) { document.getElementById('tab-' + t).classList.toggle('hidden', t !== tab); });
// Live-Tab darf seinen Polling-Timer beim Verlassen aufräumen
if (tab !== 'live' && _liveTimer) { clearInterval(_liveTimer); _liveTimer = null; }
// Betreten des Ergebnisse-Tabs → immer HEUTE + Auto-Zoom (Datum/Regler zurücksetzen).
if (tab === 'live') { _liveDate = new Date().toISOString().slice(0,10); _liveWin = {from:null, to:null}; }
// "classes"-Tab funktioniert ohne aktive Klasse
if (tab === 'classes') { loadClassesAdmin(); return; }
if (currentClassId) loadTab(tab);
}
function loadTab(tab) {
if (tab === 'overview') loadOverview();
if (tab === 'modules') loadModuleMatrix();
if (tab === 'students') loadStudents();
if (tab === 'live') loadLive();
if (tab === 'results') loadResults();
if (tab === 'licenses') loadLicenses();
if (tab === 'classes') loadClassesAdmin();
}
// === LIVE-VIEW ===
var _liveTimer = null;
var _liveMode = 'uebersicht'; // Default „Tag" · 'uebersicht' | 'cockpit' | 'aktuell' | 'vergleich'
// Cockpit-Zeitraum für Klassen-Fleißsäulen (auch in Ergebnisse-Fleiß-Sektion).
// Wird in localStorage persistent gespeichert.
var _cockpitRange = (function(){
try { return localStorage.getItem('ggs-fleiss-range') || 'week'; } catch(_) { return 'week'; }
})();
function setCockpitRange(r) {
_cockpitRange = r;
try { localStorage.setItem('ggs-fleiss-range', r); } catch(_) {}
if (_resultsState) _resultsState.fleissRange = r;
if (_liveMode === 'cockpit') renderLiveCockpit();
}
window.setCockpitRange = setCockpitRange;
var _liveCompareSim = null; // null = auto (häufigste aktive Sim)
var _liveKlimaSort = {col:'gesamt', dir:'desc'}; // Sortierung im Klima-Vergleich
var _liveDate = new Date().toISOString().slice(0,10); // YYYY-MM-DD, default heute
async function loadLive() {
await _renderLiveTab();
if (_liveTimer) clearInterval(_liveTimer);
// Auto-Refresh: aktuell alle 4s, übersicht/vergleich nur wenn heute
var refreshMs = (_liveMode === 'aktuell') ? 4000
: (_liveMode === 'gesamt') ? 0
: (_liveDate === new Date().toISOString().slice(0,10)) ? 60000 : 0;
if (refreshMs) _liveTimer = setInterval(_renderLiveTab, refreshMs);
}
async function _renderLiveTab() {
if (_liveMode === 'uebersicht') return renderLiveSummary();
if (_liveMode === 'vergleich') return renderLiveCompare();
if (_liveMode === 'gesamt') return renderLiveGesamt();
return renderLive(); // 'aktuell'
}
// „Gesamt" = die klassische Ergebnis-/Auswertungsansicht (früher eigener Tab),
// jetzt als 4. Unteransicht in „Ergebnisse". Rendert die results.php-Auswertung
// in einen eingebetteten Container innerhalb von #tab-live.
async function renderLiveGesamt() {
var host = document.getElementById('tab-live');
if (!host) return;
host.innerHTML = _liveToggleHtml()
+ '<div id="results-embed"><p style="font-size:.78rem;color:#8a8a8a;padding:1rem">Lade Gesamt-Auswertung…</p></div>';
_resultsHost = 'results-embed';
await loadResults();
}
window.setCompareSim = function(simId){ _liveCompareSim = simId; loadLive(); };
function setLiveMode(mode) {
console.log('[live] setLiveMode('+mode+')');
_liveMode = mode;
// Menüwechsel → immer zurück auf HEUTE + Auto-Zoom (Regler-Auswahl verwerfen).
_liveDate = new Date().toISOString().slice(0,10);
_liveWin = {from:null, to:null};
loadLive(); // re-init mit passendem Refresh-Intervall
}
// Auf window expose — falls Bundler/Scope-Probleme bei inline-onclick
window.setLiveMode = setLiveMode;
window.setLiveDate = function(d){ _liveDate = d; _liveWin={from:null,to:null}; loadLive(); };
window.shiftLiveDate= function(n){
var d = new Date(_liveDate + 'T12:00:00'); d.setDate(d.getDate()+n);
_liveDate = d.toISOString().slice(0,10); _liveWin={from:null,to:null}; loadLive();
};
window.liveDateToday= function(){ _liveDate = new Date().toISOString().slice(0,10); _liveWin={from:null,to:null}; loadLive(); };
function setLiveDate(dateStr) {
_liveDate = dateStr;
_liveWin = {from:null, to:null}; // neue Datumswahl → Auto-Zoom auf diesen Tag
loadLive();
}
function shiftLiveDate(deltaDays) {
var d = new Date(_liveDate + 'T12:00:00');
d.setDate(d.getDate() + deltaDays);
setLiveDate(d.toISOString().slice(0,10));
}
function liveDateToday() { setLiveDate(new Date().toISOString().slice(0,10)); }
function _liveToggleHtml() {
var today = new Date().toISOString().slice(0,10);
var isToday = (_liveDate === today);
return '<div style="display:flex;gap:.4rem;align-items:center;padding:.4rem 0 .8rem;flex-wrap:wrap">'
+ '<div style="display:inline-flex;border:1.5px solid rgba(0,0,0,.08);border-radius:6px;overflow:hidden">'
+ ' <button class="btn-sm" onclick="setLiveMode(\'uebersicht\')" style="padding:.35rem .8rem;border:0;background:'+(_liveMode==='uebersicht'?'#4a7c8a':'transparent')+';color:'+(_liveMode==='uebersicht'?'#fff':'#3d5165')+';font-weight:700;font-size:.74rem">📅 Tag</button>'
+ ' <button class="btn-sm" onclick="setLiveMode(\'aktuell\')" style="padding:.35rem .8rem;border:0;background:'+(_liveMode==='aktuell'?'#4a7c8a':'transparent')+';color:'+(_liveMode==='aktuell'?'#fff':'#3d5165')+';font-weight:700;font-size:.74rem">🔴 Aktuell</button>'
+ ' <button class="btn-sm" onclick="setLiveMode(\'vergleich\')" style="padding:.35rem .8rem;border:0;background:'+(_liveMode==='vergleich'?'#4a7c8a':'transparent')+';color:'+(_liveMode==='vergleich'?'#fff':'#3d5165')+';font-weight:700;font-size:.74rem">📊 Fortschritt</button>'
+ ' <button class="btn-sm" onclick="setLiveMode(\'gesamt\')" style="padding:.35rem .8rem;border:0;border-left:1.5px solid rgba(0,0,0,.08);background:'+(_liveMode==='gesamt'?'#4a7c8a':'transparent')+';color:'+(_liveMode==='gesamt'?'#fff':'#3d5165')+';font-weight:700;font-size:.74rem">📚 Gesamt</button>'
+ '</div>'
+ (_liveMode==='uebersicht' ?
'<div style="display:inline-flex;gap:.3rem;align-items:center;margin-left:.6rem">'
+ '<button class="btn-sm" onclick="shiftLiveDate(-1)" style="padding:.3rem .6rem">◀</button>'
+ '<input type="date" value="'+_liveDate+'" onchange="setLiveDate(this.value)" style="padding:.3rem .5rem;font-size:.78rem;border:1.5px solid rgba(0,0,0,.1);border-radius:5px">'
+ '<button class="btn-sm" onclick="shiftLiveDate(1)" style="padding:.3rem .6rem">▶</button>'
+ '<button class="btn-sm" onclick="liveDateToday()" style="padding:.3rem .7rem;background:'+(isToday?'#e8c547':'transparent')+';color:#3d5165;font-weight:700">Heute</button>'
+ '<span style="color:#8a8a8a;font-size:.7rem;margin-left:.3rem">'+(isToday?'· Auto-Refresh aktiv':'· statische Daten')+'</span>'
+ '</div>' : '')
+ '</div>';
}
// Pro Sim eine Auswahl an "Vergleichs-Spalten" (Reihenfolge ist UX-Reihenfolge).
// Felder, die nicht in dieser Liste stehen, werden in einer Klapp-Sektion gezeigt.
// Sim-Instanzen sollten ihren GGS_LIVE_STATE so füllen, dass diese Keys vorhanden sind.
// Deutsche Übersetzungen für gängige Enum-Werte. Werden in den Spalten-Formatter-Funktionen verwendet.
var LIVE_VALUE_LABELS = {
screen: { start:'Auswahl', play:'Spielt', end:'Endscreen' },
difficulty: { easy:'Einfach', expert:'Profi' },
stepType: { pick:'Karte wählen', sentence:'Lückentext', multichoice:'4 Antworten', animation:'Animation' },
health: {
good: '<span style="color:#5a8a5e">● gut</span>',
ok: '<span style="color:#c9913a">● ok</span>',
struggling: '<span style="color:#c07a6b">● stockt</span>',
struggle: '<span style="color:#c07a6b">● stockt</span>',
},
};
function _fmtSeconds(s) {
s = Math.max(0, Math.round(s||0));
var mm = Math.floor(s/60), ss = s%60;
return mm + ':' + (ss<10?'0':'') + ss;
}
// Range-Bar — Mini-Balken statt nackter Zahl in Cockpit-Spalten.
// goodWhen 'high' → grün ab >= 60% der Range, gelb ab >= 30%, sonst rot
// goodWhen 'low' → invers (z. B. Fehlerzahl, idleMs)
// goodWhen 'mid' → neutral grau (z. B. Progress)
function _liveBar(val, min, max, goodWhen, dec) {
if (val == null || isNaN(val)) return '<span style="color:#9a9a9a">—</span>';
var span = (max - min) || 1;
var pct = Math.max(0, Math.min(100, ((val - min) / span) * 100));
var c = _liveBarColor(pct, goodWhen);
var disp = (dec != null) ? Number(val).toFixed(dec) : Math.round(val);
return ''
+ '<span style="display:inline-flex;align-items:center;gap:.35rem;font-variant-numeric:tabular-nums">'
+ '<span aria-hidden="true" style="display:inline-block;width:42px;height:7px;border-radius:99px;background:rgba(0,0,0,.08);overflow:hidden">'
+ '<span style="display:block;height:100%;width:'+pct.toFixed(0)+'%;background:'+c+'"></span>'
+ '</span>'
+ '<span style="color:'+c+';font-weight:700;font-size:.8rem;min-width:1.6em;text-align:right">'+disp+'</span>'
+ '</span>';
}
// Farbwahl nach „gut wenn" (aus _liveBar extrahiert) — für die Mini-Balken.
function _liveBarColor(pct, goodWhen) {
pct = Math.max(0, Math.min(100, pct||0));
if (goodWhen === 'mid') return '#9a9a9a';
// EINE Skala (_pctColor). „Niedrig ist gut" invertiert, damit Grün weiter „gut" heißt.
return _pctColor(goodWhen === 'low' ? 100 - pct : pct);
}
// Mini-Balken UNTER der Zahl (Cockpit „Aktuell"). pct 0 100.
function _liveMiniBar(pct, color) {
pct = Math.max(0, Math.min(100, pct||0));
return '<div style="margin-top:3px;height:3px;border-radius:99px;background:rgba(0,0,0,.06);overflow:hidden">'
+ '<div style="height:100%;width:'+pct.toFixed(0)+'%;background:'+color+'"></div></div>';
}
// ===== EINHEITLICHE LEISTUNGS-SKALA (Styleguide „Leistungs-Skala", 5 Stufen) =====
// EINE Farbfunktion für Schülerleistung überall. Grenzen 50/65 = Triage-Grenzen.
function _pctColor(v) {
v = Math.max(0, Math.min(100, v || 0));
// ≥90 Dunkelgrün · ≥80 Hellgrün · ≥60 Gelb · ≥50 Orange · <50 Rot
return v >= 90 ? '#1f7a3d' : v >= 80 ? '#7cb342' : v >= 60 ? '#f9c33b' : v >= 50 ? '#ef8b2c' : '#d8453a';
}
// Standard-Anzeige: Prozent in Ampelfarbe + dünner Balken darunter.
function _pctBar(pct) {
if (pct == null || isNaN(pct)) return '<span style="color:#9a9a9a">—</span>';
pct = Math.max(0, Math.min(100, Math.round(pct)));
var c = _pctColor(pct);
// Kompakt: schmaler Balken (max ~34px), damit die Spalte wenig Platz braucht.
return '<div style="width:34px">'
+ '<div style="font-weight:800;color:'+c+';font-size:.72rem;font-variant-numeric:tabular-nums;line-height:1">'+pct+'<span style="font-size:.58rem"> %</span></div>'
+ '<div style="margin-top:2px;height:4px;border-radius:99px;background:rgba(0,0,0,.07);overflow:hidden"><div style="height:100%;width:'+pct+'%;background:'+c+'"></div></div>'
+ '</div>';
}
// Größere %+Balken-Plakette für Run-Karten (Ersatz für Sterne) — Balken statt
// Sterne, weil auch für Farbenblinde am Füllstand unterscheidbar.
function _pctBadge(pct) {
if (pct == null || isNaN(pct)) return '<span style="color:#9a9a9a">—</span>';
pct = Math.max(0, Math.min(100, Math.round(pct)));
var c = _pctColor(pct);
return '<span style="display:inline-flex;align-items:center;gap:.4rem">'
+ '<span style="width:64px;height:8px;border-radius:99px;background:rgba(0,0,0,.09);overflow:hidden;display:inline-block"><span style="display:block;height:100%;width:'+pct+'%;background:'+c+'"></span></span>'
+ '<b style="color:'+c+';font-size:.85rem;font-variant-numeric:tabular-nums">'+pct+' %</b></span>';
}
// Das EINE Leistungs-% (0100) je Sim aus dem Live-State. Sims mit eigenem
// 0100-Score nutzen ihn direkt; sonst aus den vorhandenen Kennzahlen abgeleitet.
function _leistungPct(simId, s) {
if (!s || typeof s !== 'object') return null;
var cl = function(v,a,b){ return Math.max(a, Math.min(b, v)); };
// Von der Sim selbst geliefertes Leistungs-% hat Vorrang (Fundament-Umbau) —
// sonst zentrale Ableitung als Fallback.
if (typeof s.leistung_pct === 'number' && !isNaN(s.leistung_pct)) return cl(Math.round(s.leistung_pct), 0, 100);
// Sims mit echtem 0100-Live-Score (klima, fluss, kofferdetektiv, heli) direkt:
if (typeof s.score === 'number' && simId !== 'weltkueche' && simId !== 'busfahrt' && simId !== 'fluggesellschaft')
return cl(Math.round(s.score), 0, 100);
switch (simId) {
case 'klima': case 'klima-3d': {
var sp = (s.level >= 3) ? 7000 : 6000;
return Math.round(cl(35-((s.temp||16)-15.5)*20,0,35) + Math.max(0,25*(1-(s.floodedPct||0)/50)) + cl((s.budget||0)/10,0,20) + cl((s.population||0)/sp*20,0,20));
}
case 'fluss': return (s.biodiv!=null) ? cl(Math.round((s.biodiv+s.economy+s.flood)/3) - (s.settlementsLost||0)*5, 0, 100) : null;
case 'heli': return (s.totalStars!=null) ? Math.round(s.totalStars/5*100) : (s.planTotal ? Math.round((s.planCorrect||0)/s.planTotal*100) : null);
case 'farmer': {
if (s.hit_rate==null && s.cash==null) return null;
var money=cl(50+(s.cash||0)/300,0,100), co2=cl(100-(s.co2_kg||0)/80,0,100), dis=cl(100-(s.diseases||0)*20,0,100), crop=cl((s.crops_used||0)/10*100,0,100), country=cl((s.countries||0)/6*100,0,100);
return Math.round(0.25*money + 0.20*(s.hit_rate||0) + 0.15*co2 + 0.10*dis + 0.10*crop + 0.10*country + 0.10*70);
}
case 'energiemanager': { var d=(s.day||1)*(s.blockTotal||8); return cl(Math.round((s.setBlocksBalanced||0)/d*100) - 15*(s.blackouts||0), 0, 100); }
case 'eu-werkstatt': return s.stepsTotal ? cl(Math.round((s.firstTryHits||0)/s.stepsTotal*100) - 5*(s.muelleimerCount||0), 0, 100) : null;
case 'weltkueche': { var t=(s.correctClicks||0)+(s.wrongClicks||0); return t ? Math.round((s.correctClicks||0)/t*100) : null; }
case 'busfahrt': case 'fluggesellschaft':
return s.topoMax ? cl(Math.round((s.topoPoints||0)/s.topoMax*100),0,100)
: (s.totalOrders ? Math.round((s.wonOrders||0)/s.totalOrders*100) : null);
case 'sonnensystem': return (s.progressPct!=null) ? cl(Math.round(s.progressPct),0,100) : (s.taskTotal ? Math.round((s.completed||0)/s.taskTotal*100) : null);
case 'tourismustal': return s.goalsTotal ? Math.round(0.5*((s.goalsDone||0)/s.goalsTotal*100) + 0.3*(s.satisfaction||0) + 0.2*(s.nature||0)) : null;
case 'tourismusregion': return (s.avgStars!=null) ? Math.round(0.6*(s.avgStars/5*100) + 0.4*(s.env||0)) : null;
case 'logistik': return (s.balance!=null) ? cl(Math.round(s.balance/16000*100),0,100) : null;
}
return (typeof s.score === 'number') ? cl(Math.round(s.score),0,100) : null;
}
var LIVE_PRIMARY_FIELDS = {
farmer: [
{k:'year', label:'Jahr', bar:{kind:'abs',max:10,good:'mid'}, fmt:function(v){ return (v||0) + '<span style="color:#8a8a8a"> / 10</span>'; }},
{k:'market_de', label:'Markt', align:'left', fmt:function(v){ return v ? '🌍 '+esc(v) : '<span style="color:#8a8a8a">—</span>'; }},
{k:'cash', label:'Konto', bar:{kind:'rel',good:'signed'}, fmt:function(v){
if (v == null) return '<span style="color:#9a9a9a">—</span>';
var color = v < 0 ? '#c0392b' : '#5a8a5e';
return '<span style="color:'+color+';font-weight:700">'+Number(v).toLocaleString('de-AT')+' €</span>';
}},
{k:'hit_rate', label:'Trefferquote', unit:'%', bar:{kind:'abs',max:100,good:'high'}, fmt:function(v){ return (v!=null? v : 0); }},
{k:'crops_used', label:'Sorten', bar:{kind:'abs',max:10,good:'high'}, fmt:function(v){ return (v||0) + '<span style="color:#8a8a8a"> / 10</span>'; }},
{k:'countries', label:'Länder', bar:{kind:'rel',good:'high'}, fmt:function(v){ return (v!=null? v : 0); }},
{k:'fields', label:'Felder', bar:{kind:'rel',good:'mid'}, fmt:function(v){ return (v!=null? v : 0); }},
{k:'attempts', label:'Versuche',bar:{kind:'rel',good:'mid'}, fmt:function(v){ return (v!=null? v : 0); }},
{k:'co2_kg', label:'CO₂', unit:'kg', bar:{kind:'rel',good:'low'}, fmt:function(v){
if (v == null) return '<span style="color:#9a9a9a">—</span>';
return '<span style="font-variant-numeric:tabular-nums">'+Number(Math.round(v)).toLocaleString('de-AT')+'</span>';
}},
{k:'diseases', label:'Krankheiten', align:'center', bar:{kind:'abs',max:5,good:'low'}, fmt:function(v){
if (v == null) return '<span style="color:#9a9a9a">—</span>';
var color = (v > 0) ? '#c07a6b' : '#8a8a8a';
return '<span style="color:'+color+';font-weight:'+(v>0?'700':'400')+'">'+v+'</span>';
}},
],
'tourismusregion': [
{k:'mapId', label:'Karte', align:'left', fmt:function(v){
return {talschleife:'🏞 Talschleife', seerunde:'🚣 Seerunde', serpentinen:'⛰ Serpentinen',
kreuzung:'✚ Kreuzung', passstrasse:'🛣 Passstraße'}[v] || v || '—';
}},
{k:'wave', label:'Welle', fmt:function(v, row){
var s = row.state || {};
if (v === undefined) return '—';
var status = s.waveStatus === 'pause' ? ' <span style="color:#8a8a8a">⏸ Baupause</span>' : '';
return v + '<span style="color:#8a8a8a"> / ' + (s.waveTotal || 8) + '</span>' + status;
}},
{k:'season', label:'Saison', align:'center'},
{k:'budget', label:'Budget', fmt:function(v){
if (v === undefined) return '—';
var color = v < 0 ? '#c0392b' : '#5a8a5e';
return '<span style="color:'+color+';font-weight:700">'+Number(v).toLocaleString('de-AT')+' €</span>';
}},
{k:'revenue', label:'Wertsch.', unit:'€'},
{k:'avgStars', label:'Ø ★', fmt:function(v){
if (v === undefined) return '—';
var color = v >= 3.5 ? '#5a8a5e' : v >= 2.5 ? '#c9913a' : '#c07a6b';
return '<span style="color:'+color+';font-weight:700">'+v.toFixed(2)+'</span>';
}},
{k:'env', label:'Umwelt', fmt:function(v){ return _liveBar(v, 0, 100, 'high'); }},
{k:'visitors', label:'Gäste'},
{k:'goalsDone', label:'Ziele', fmt:function(v, row){
var s = row.state || {};
if (v === undefined || !s.goalsTotal) return '—';
var color = v === s.goalsTotal ? '#5a8a5e' : v >= 2 ? '#c9913a' : '#8a8a8a';
return '<span style="color:'+color+';font-weight:700">'+v+'/'+s.goalsTotal+'</span>';
}},
],
'tourismustal': [
{k:'scene', label:'Szene', align:'left', fmt:function(v){
return {pionierzeit:'⛏️ 1975', boom:'📈 Boom 2000', klimawandel:'🌡️ 2035'}[v] || v || '—';
}},
{k:'season', label:'Saison', align:'center'},
{k:'year', label:'Jahr'},
{k:'money', label:'Konto', fmt:function(v){
if (v === undefined) return '—';
var color = v < 0 ? '#c0392b' : v < 4000 ? '#c9913a' : '#5a8a5e';
return '<span style="color:'+color+';font-weight:700">'+Number(v).toLocaleString('de-AT')+' €</span>';
}},
{k:'guestsToday', label:'Gäste'},
{k:'satisfaction', label:'Zufr.', fmt:function(v){ return _liveBar(v, 0, 100, 'high'); }},
{k:'nature', label:'Natur', fmt:function(v){ return _liveBar(v, 0, 100, 'high'); }},
{k:'goalsDone', label:'Ziele', fmt:function(v, row){
var s = row.state || {};
if (v === undefined || !s.goalsTotal) return '—';
var color = v === s.goalsTotal ? '#5a8a5e' : v >= 2 ? '#c9913a' : '#8a8a8a';
return '<span style="color:'+color+';font-weight:700">'+v+'/'+s.goalsTotal+'</span>';
}},
{k:'buildings', label:'Geb.'},
{k:'bottleneck', label:'Engpass', align:'left', fmt:function(v){
return v ? '<span style="color:#c07a6b">⚠️ '+v+'</span>' : '<span style="color:#8a8a8a">—</span>';
}},
],
klima: [
{k:'level', label:'Stufe'},
{k:'year', label:'Jahr'},
// Kennzahlen mit Ampel-Linien (grün→rot), absolute Skalen wie im Spiel:
{k:'co2', label:'CO₂', unit:'ppm', fmt:function(v){ return _liveBar(v, 280, 700, 'low'); }},
{k:'temp', label:'Temp', unit:'°C', fmt:function(v){ return _liveBar(v, 14, 20, 'low', 1); }},
{k:'floodedPct', label:'Geflutet', unit:'%', fmt:function(v){
// Ampel am Spielziel „Stadt schützen < 40 %": grün < 25, gelb 2540, rot > 40.
if (v==null||isNaN(v)) return '<span style="color:#9a9a9a">—</span>';
var c = v < 25 ? '#5a8a5e' : v <= 40 ? '#c9913a' : '#c07a6b';
var pct = Math.max(0, Math.min(100, v));
return '<span style="display:inline-flex;align-items:center;gap:.35rem;font-variant-numeric:tabular-nums">'
+ '<span aria-hidden="true" style="display:inline-block;width:42px;height:7px;border-radius:99px;background:rgba(0,0,0,.08);overflow:hidden"><span style="display:block;height:100%;width:'+pct.toFixed(0)+'%;background:'+c+'"></span></span>'
+ '<span style="color:'+c+';font-weight:700;font-size:.8rem;min-width:1.6em;text-align:right">'+Math.round(v)+'</span></span>';
}},
{k:'budget', label:'Budget', unit:'Mio €', fmt:function(v){ return _liveBar(v, -500, 3000, 'high'); }},
{k:'population', label:'Bev.', fmt:function(v, row){
// Relativ zur Startbevölkerung (Level 3 = 7000, sonst 6000): unter Start = rot,
// gehalten = gelb, gewachsen = grün. Fixe Skala klemmte sonst alle bei 100 %.
var sp = (((row&&row.state)||{}).level >= 3) ? 7000 : 6000;
return _liveBar(v, sp*0.3, sp*1.6, 'high');
}},
{k:'measures', label:'Maßn.'},
{k:'speed', label:'Speed', unit:'×'},
],
'sonnensystem': [
{k:'health', label:'Status', align:'center', fmt:function(v){ return LIVE_VALUE_LABELS.health[v] || '—'; }},
{k:'screen', label:'Modus', fmt:function(v){ return v === 'play' ? '📚 Aufgabe' : '🚀 Erkunden'; }},
{k:'taskTitle', label:'Aufgabe', align:'left', fmt:function(v, row){
var s = row.state || {};
if (s.screen !== 'play' || !v) return '<span style="color:#8a8a8a">—</span>';
return v + ' <span style="color:#8a8a8a;font-weight:400">('+s.taskIndex+'/'+s.taskTotal+')</span>';
}},
{k:'completed', label:'Gelöst', fmt:function(v, row){ var s = row.state||{}; return s.taskTotal ? v+' <span style="color:#8a8a8a">/ '+s.taskTotal+'</span>' : '—'; }},
{k:'progressPct', label:'Quote', fmt:function(v){ return _liveBar(v, 0, 100, 'high'); }},
{k:'simDate', label:'Sim-Datum', align:'center'},
{k:'achievementsUnlocked', label:'Erfolge', fmt:function(v, row){ var s = row.state||{}; if (v === undefined) return '—'; return v+' <span style="color:#8a8a8a">/ '+(s.achievementsTotal||6)+'</span>'; }},
],
'energiemanager': [
{k:'health', label:'Status', align:'center', fmt:function(v){ return LIVE_VALUE_LABELS.health[v] || '—'; }},
{k:'setTitle', label:'Set', align:'left', fmt:function(v, row){
var s = row.state || {};
var icon = s.setIcon ? '<span style="margin-right:.25rem">'+s.setIcon+'</span>' : '';
var idxBadge = (s.setIndex && s.setTotal) ? ' <span style="color:#8a8a8a;font-weight:400">'+s.setIndex+'/'+s.setTotal+'</span>' : '';
return v ? icon + v + idxBadge : '—';
}},
{k:'day', label:'Tag', fmt:function(v, row){ var s = row.state||{}; return s.dayTotal ? v+' <span style="color:#8a8a8a">/ '+s.dayTotal+'</span>' : '—'; }},
{k:'block', label:'Block', fmt:function(v, row){ var s = row.state||{}; if (s.phase === 'blackout') return '<span style="color:#d8453a;font-weight:700">🔌 neu planen</span>'; if (s.phase === 'plan') return '<span style="color:#8a8a8a">plant</span>'; return s.blockTotal ? (v+1)+' <span style="color:#8a8a8a">/ '+s.blockTotal+'</span>' : '—'; }},
{k:'blackouts', label:'🔌', align:'center', fmt:function(v){ if (!v) return '<span style="color:#8a8a8a"></span>'; var c = v >= 2 ? '#d8453a' : '#c9913a'; return '<span style="color:'+c+';font-weight:700">'+v+'</span>'; }},
{k:'balanced', label:'Tag ausg.', fmt:function(v, row){ var s = row.state||{}; if (s.phase !== 'done') return '<span style="color:#8a8a8a">—</span>'; var color = v === s.blockTotal ? '#5a8a5e' : v >= 4 ? '#c9913a' : '#c07a6b'; return '<span style="color:'+color+';font-weight:700">'+v+'/'+s.blockTotal+'</span>'; }},
{k:'setBlocksBalanced', label:'Set ausg.', fmt:function(v, row){ var s = row.state||{}; var max = (s.setTotal && s.dayTotal && s.blockTotal) ? s.dayTotal * s.blockTotal : 0; if (!max) return '—'; var color = v >= max*0.87 ? '#5a8a5e' : v >= max*0.5 ? '#c9913a' : '#c07a6b'; return '<span style="color:'+color+';font-weight:700">'+v+'/'+max+'</span>'; }},
{k:'upperLake', label:'⬆ See', unit:'GWh'},
{k:'lowerLake', label:'⬇ See', unit:'GWh'},
],
'eu-werkstatt': [
{k:'health', label:'Status', align:'center', fmt:function(v){ return LIVE_VALUE_LABELS.health[v] || '—'; }},
{k:'scenarioTitle', label:'Szenario', align:'left', fmt:function(v, row){
var s = row.state || {};
var icon = s.scenarioIcon ? '<span style="margin-right:.25rem">'+s.scenarioIcon+'</span>' : '';
return v ? icon + v : '<span style="color:#8a8a8a">Szenario-Auswahl</span>';
}},
{k:'step', label:'Schritt', fmt:function(v, row){
var s = row.state || {};
if (!s.stepsTotal) return '—';
return v + ' <span style="color:#8a8a8a">/ ' + s.stepsTotal + '</span>';
}},
{k:'difficulty', label:'Modus', fmt:function(v){ return LIVE_VALUE_LABELS.difficulty[v] || '—'; }},
{k:'firstTryHits', label:'Treffer'},
{k:'muelleimerCount', label:'Mülleimer', fmt:function(v){
if (v === undefined || v === null) return '—';
var color = v === 0 ? '#5a8a5e' : v >= 3 ? '#c07a6b' : '#c9913a';
return '<span style="color:'+color+';font-weight:700">'+v+'</span>';
}},
{k:'reshuffles', label:'🔄 Probier', align:'center', fmt:function(v){
if (!v) return '<span style="color:#8a8a8a"></span>';
var color = v >= 5 ? '#c07a6b' : v >= 2 ? '#c9913a' : '#5a8a5e';
return '<span style="color:'+color+';font-weight:700">'+v+'</span>';
}},
{k:'secondsElapsed', label:'Dauer', fmt:function(v){ return v ? _fmtSeconds(v) : '—'; }},
{k:'achievementsUnlocked', label:'Erfolge', fmt:function(v, row){
var s = row.state || {};
if (v === undefined || v === null) return '—';
return v + ' <span style="color:#8a8a8a">/ ' + (s.achievementsTotal||6) + '</span>';
}},
],
'weltkueche': [
{k:'dishName', label:'Gericht', align:'left', fmt:function(v){
return v ? '<b>'+esc(v)+'</b>' : '<span style="color:#8a8a8a">Auswahl</span>';
}},
// Fleiß in dieser Sitzung: fertige Gerichte / gesamt
{k:'completed', label:'Gerichte', align:'center', fmt:function(v, row){
var s = row.state||{}; var t = s.completedTotal||0;
if (!t) return '<span style="color:#8a8a8a">—</span>';
var done = v||0; var c = done>=t ? '#5a8a5e' : '#3d5165';
return '<b style="color:'+c+'">'+done+'</b><span style="color:#8a8a8a"> / '+t+'</span>';
}},
{k:'score', label:'🏆 Punkte', align:'center', fmt:function(v){ return v!=null ? '<b style="color:#3d5165;font-variant-numeric:tabular-nums">'+Number(v).toLocaleString('de-AT')+'</b>' : '<span style="color:#8a8a8a">—</span>'; }},
{k:'ingSolved', label:'Zutaten akt.', fmt:function(v, row){
var s = row.state||{}; var t = s.ingTotal||0;
if (!s.dishName || !t) return '<span style="color:#8a8a8a">—</span>';
var done = v||0; var pct = done/t; var c = pct>=1 ? '#5a8a5e' : pct>=.5 ? '#c9913a' : '#8a7a4a';
return '<span style="color:'+c+';font-weight:700">'+done+' <span style="color:#8a8a8a;font-weight:400">/ '+t+'</span></span>';
}},
// Qualität = grafisches Verhältnis richtige ↔ falsche Klicks (blödeln vs. gut)
{k:'__quality', label:'Qualität', align:'left', fmt:function(v, row){ return _liveQualityFromState(row.module_id, row.state); }},
{k:'mistakesInDish', label:'Fehler (akt.)', align:'center', fmt:function(v){
var n = v||0; var c = n>=12 ? '#c07a6b' : n>=5 ? '#c9913a' : '#5a8a5e';
return '<span style="color:'+c+';font-weight:700">'+n+'</span>';
}},
{k:'__lage', label:'Lage', align:'center', fmt:function(v, row){ return _liveMoodBadge(row.module_id, row.state); }},
],
'busfahrt': [
{k:'levelId', label:'Stufe', align:'center'},
{k:'orderIndex', label:'Auftrag', fmt:function(v, row){ var s = row.state||{}; return s.totalOrders ? (v+1)+' / '+s.totalOrders : '—'; }},
{k:'budget', label:'Budget', unit:'€', fmt:function(v){ if (v==null) return '—'; var c = v >= 5000 ? '#5a8a5e' : v >= 0 ? '#c9913a' : '#c07a6b'; return '<span style="color:'+c+';font-weight:700">'+v.toLocaleString('de-AT')+' €</span>'; }},
{k:'wonOrders', label:'Aufträge ✓'},
{k:'lastCityTitle', label:'Letzte Stadt', align:'left'},
{k:'lastMargin', label:'Letzte Marge', unit:'€'},
{k:'quizFirstTry', label:'🧠 1.-Versuch', fmt:function(v, row){ var s=row.state||{}; var t = s.orderIndex || 0; return t ? v+' / '+t : '—'; }},
{k:'avgTapKm', label:'📍 Ø km', align:'center', fmt:function(v){ if (v==null) return '<span style="color:#8a8a8a">—</span>'; var n=Math.round(v); var c = n<=50?'#5a8a5e':n<=150?'#c9913a':'#c07a6b'; return '<span style="color:'+c+';font-weight:700">'+n+' km</span>'; }},
],
'fluggesellschaft': [
{k:'levelId', label:'Stufe', align:'center'},
{k:'orderIndex', label:'Flug', fmt:function(v, row){ var s = row.state||{}; return s.totalOrders ? (v+1)+' / '+s.totalOrders : '—'; }},
{k:'budget', label:'Budget', unit:'€', fmt:function(v){ if (v==null) return '—'; var c = v >= 8000 ? '#5a8a5e' : v >= 0 ? '#c9913a' : '#c07a6b'; return '<span style="color:'+c+';font-weight:700">'+v.toLocaleString('de-AT')+' €</span>'; }},
{k:'wonOrders', label:'Flüge ✓'},
{k:'co2Kg', label:'🌍 CO₂', fmt:function(v){ return v==null ? '—' : (v/1000).toFixed(1)+' t'; }},
{k:'lastCityTitle', label:'Letztes Ziel', align:'left'},
{k:'lastTzDiff', label:'Zeitzone', fmt:function(v){ if (v==null) return '—'; return (v>0?'+':'')+v+' h'; }},
{k:'quizFirstTry', label:'🧠 1.-Versuch', fmt:function(v, row){ var s=row.state||{}; var t = s.orderIndex || 0; return t ? v+' / '+t : '—'; }},
],
'heli': [
{k:'phase', label:'Phase', align:'center', fmt:function(v){
var m = {mission:'📋 Auftragswahl', plan:'🗺️ Routenplan', briefing:'📋 Briefing', start:'🛫 Start', flight:'✈️ Flug', approach:'🎯 Anflug', landing:'🛬 Landung', results:'🏁 Ende'};
return m[v] || (v ? v : '—');
}},
{k:'missionId', label:'Auftrag', align:'left', fmt:function(v, row){
var s = row.state||{}; var title = s.missionTitle || '';
return v ? '<b>'+v+'</b>'+(title?' <span style="color:#8a8a8a;font-weight:400">'+title+'</span>':'') : '—';
}},
// Mission-Dauer wird global in „Spielt seit" gezeigt — wir lesen dort
// state.missionStartedAt mit Vorrang, sodass die Zeit pro Mission startet.
{k:'planIndex', label:'🗺️ Wegpunkt', fmt:function(v, row){
var s = row.state||{}; var t = s.planTotal||0;
if (!t) return '—';
var done = (v||0); var pct = done/t;
var c = pct >= 1 ? '#5a8a5e' : pct >= .5 ? '#c9913a' : '#c07a6b';
return '<span style="color:'+c+';font-weight:700">'+done+' / '+t+'</span>';
}},
{k:'planCorrect', label:'🎯 Treffer', fmt:function(v, row){
var s = row.state||{}; var t = s.planTotal||0;
if (!t) return '—';
var done = (v||0); var pct = done/t;
var c = pct >= .9 ? '#5a8a5e' : pct >= .5 ? '#c9913a' : '#c07a6b';
return '<span style="color:'+c+';font-weight:700">'+done+' / '+t+'</span>';
}},
// Versuche / Fehlklicks: didaktisch wichtig (zeigt Übung + Frustration)
{k:'planAttempts', label:'❌ Fehlkl.', align:'center', fmt:function(v){
var n = v||0;
var c = n <= 2 ? '#5a8a5e' : n <= 8 ? '#c9913a' : '#c07a6b';
return n === 0 ? '<span style="color:#5a8a5e;font-weight:700">0</span>'
: '<span style="color:'+c+';font-weight:700">'+n+'</span>';
}},
// Flugplan = Routenplanungs-Phase kumuliert (Wegpunkt-Genauigkeit Fehlklick-Abzug).
{k:'__flugplan', label:'🗺️ Flugplan', fmt:function(v, row){
var s = row.state||{}; if (!s.planTotal) return '<span style="color:#9a9a9a">—</span>';
var p = Math.max(0, Math.min(100, Math.round((s.planCorrect||0)/s.planTotal*100) - Math.min(40, (s.planAttempts||0)*4)));
return _pctBar(p);
}},
{k:'startScore', label:'🛫 Start', fmt:function(v){
return v==null ? '<span style="color:#9a9a9a">—</span>' : _pctBar(Math.round(Math.max(0,Math.min(5,v))/5*100));
}},
{k:'landingScore', label:'🛬 Landung', fmt:function(v){
return v==null ? '<span style="color:#9a9a9a">—</span>' : _pctBar(Math.round(Math.max(0,Math.min(5,v))/5*100));
}},
{k:'failed', label:'Status', align:'center', fmt:function(v, row){
var s = row.state||{};
if (v === true) return '<span style="color:#c07a6b;font-weight:700">⚠ Chefpilot</span>';
if (s.phase === 'results') return '<span style="color:#5a8a5e;font-weight:700">✓ Erfolg</span>';
if (s.phase === 'mission' || !s.phase) {
if (s.lastResult) return s.lastFailed
? '<span style="color:#c07a6b;font-weight:600" title="Werte = letzter Einsatz">⚠ letzter Einsatz</span>'
: '<span style="color:#5a8a5e;font-weight:600" title="Werte = letzter Einsatz">✓ letzter Einsatz</span>';
return '<span style="color:#8a8a8a">wählt Auftrag</span>';
}
return '<span style="color:#4a7c8a;font-weight:600">🟢 unterwegs</span>';
}},
],
'fluss': [
{k:'health', label:'Status', align:'center', fmt:function(v){ return LIVE_VALUE_LABELS.health[v] || '—'; }},
{k:'levelId', label:'Stufe', align:'center'},
{k:'year', label:'Jahr', fmt:function(v, row){ var s=row.state||{}; var max = 100; return s.progressPct!=null ? (v||0)+' <span style="color:#8a8a8a">/ '+max+'</span>' : (v||'—'); }},
{k:'score', label:'🏅 Score', fmt:function(v){ return _liveBar(v, 0, 100, 'high'); }},
{k:'progressPct', label:'Fortschritt', fmt:function(v){ return _liveBar(v, 0, 100, 'mid'); }},
{k:'budget', label:'Budget', unit:'Mio €', fmt:function(v){ if (v==null) return '—'; var c = v>=20 ? '#5a8a5e' : v>=0 ? '#c9913a' : '#c07a6b'; return '<span style="color:'+c+';font-weight:700">'+v+'</span>'; }},
{k:'biodiv', label:'🌳 Biodiv.', fmt:function(v){ return _liveBar(v, 0, 100, 'high'); }},
{k:'economy', label:'💰 Wirtsch.', fmt:function(v){ return _liveBar(v, 0, 100, 'high'); }},
{k:'flood', label:'🛡️ Hochw.-Schutz', fmt:function(v){ return _liveBar(v, 0, 100, 'high'); }},
{k:'population', label:'👥 Einw.', align:'center', fmt:function(v){ return v!=null ? '<b style="color:#3d5165;font-variant-numeric:tabular-nums">'+v+'</b>' : '<span style="color:#8a8a8a">—</span>'; }},
{k:'food', label:'🌾 Ernte', fmt:function(v){ return _liveBar(v, 0, 100, 'high'); }},
{k:'settlementsLost', label:'🏚️ Verlust', align:'center', fmt:function(v){ if (!v) return '<span style="color:#5a8a5e">0</span>'; var c = v>=3 ? '#c07a6b' : '#c9913a'; return '<span style="color:'+c+';font-weight:700">'+v+'</span>'; }},
],
'kofferdetektiv': [
{k:'health', label:'Status', align:'center', fmt:function(v){ return LIVE_VALUE_LABELS.health[v] || '—'; }},
{k:'phase', label:'Phase'},
{k:'progressPct', label:'Fortschritt', fmt:function(v){ return _liveBar(v, 0, 100, 'mid'); }},
{k:'score', label:'🏅 Score', fmt:function(v){ return _liveBar(v, 0, 100, 'high'); }},
{k:'cases_solved', label:'Gelöst', align:'center', fmt:function(v, row){ var s=row.state||{}; var t=s.cases_total||0; return t ? v+' <span style="color:#8a8a8a">/ '+t+'</span>' : (v||'—'); }},
{k:'case_iso2', label:'🎯 Ziel', align:'left', fmt:function(v, row){
// Datenschutz: Sim liefert nur ISO2 während des Falls (Antwort bleibt
// verdeckt). case_country wird erst nach Lösung gesetzt.
var s = row.state || {};
if (!v) return '—';
var iso = String(v).toUpperCase();
if (s.case_country) return '<span style="font-weight:600;color:#5a8a5e">'+esc(s.case_country)+'</span>';
return '<span style="font-weight:600;color:#b04a3e" title="Antwort verdeckt — Schüler:in sucht noch">'+iso+' (suchend)</span>';
}},
{k:'hints_revealed', label:'Hinweise', align:'center'},
{k:'wrong_guesses_total', label:'❌ Fehler', fmt:function(v){ return _liveBar(v, 0, 10, 'low'); }},
{k:'time_left_s', label:'⏱ Rest', align:'center', fmt:function(v){ if (v==null) return '<span style="color:#8a8a8a">∞</span>'; var c = v<=30 ? '#c07a6b' : v<=120 ? '#c9913a' : '#5a8a5e'; return '<span style="color:'+c+';font-weight:700">'+_fmtSeconds(v)+'</span>'; }},
{k:'airport', label:'✈ Flughafen', align:'left'},
],
'logistik': [
{k:'levelId', label:'Stufe', align:'center'},
{k:'balance', label:'Budget', fmt:function(v){
if (v==null) return '—';
var c = v>=5000 ? '#5a8a5e' : v>=0 ? '#c9913a' : '#c07a6b';
return '<span style="color:'+c+';font-weight:700">'+Number(v).toLocaleString('de-AT')+' €</span>';
}},
{k:'ordersDelivered', label:'Geliefert', align:'center', fmt:function(v, row){
var s = row.state||{}; var t = s.totalOrders||0; var done = v||0;
var c = done>0 ? '#5a8a5e' : '#8a8a8a';
return '<b style="color:'+c+'">'+done+'</b>'+(t?'<span style="color:#8a8a8a"> / '+t+'</span>':'');
}},
{k:'ordersInFlight', label:'Unterwegs', align:'center'},
{k:'ordersOpen', label:'Offen', align:'center'},
{k:'ordersLate', label:'Verspätet', align:'center', fmt:function(v){
var n = v||0;
return n>0 ? '<span style="color:#c07a6b;font-weight:700">'+n+'</span>' : '<span style="color:#5a8a5e">0</span>';
}},
{k:'progressPct', label:'Fortschritt', fmt:function(v){ return _liveBar(v, 0, 100, 'high'); }},
],
// Default-Schema: zeigt einfach den state, soweit Felder benannt sind.
_default: null,
};
function _liveFmt(v, unit, fieldDef, row) {
// Custom-Formatter pro Spalte hat Vorrang
if (fieldDef && typeof fieldDef.fmt === 'function') {
try {
var formatted = fieldDef.fmt(v, row);
return (formatted === undefined || formatted === null || formatted === '') ? '—' : formatted;
} catch (_) { /* fall through */ }
}
if (v === undefined || v === null || v === '') return '—';
if (typeof v === 'object') return JSON.stringify(v).slice(0, 30);
if (typeof v === 'boolean') return v ? '✓' : '';
return v + (unit ? ' <span style="color:#8a8a8a;font-weight:400">'+unit+'</span>' : '');
}
// ============================================================
// LIVE-COCKPIT (NEU 2026-06-11) — 4 Ebenen:
// Status-Tiles → Ranking → Klassen-Säulen → Schülerliste
// ============================================================
function _cockpitStatusTile(num, label, hint, cls) {
var color = ({aktiv:'#5a8a5e', idle:'#8a7a4a', heute:'#c9913a', kalt:'#9a9a9a', hilfe:'#b04a3e'})[cls] || '#888';
var bg = ({aktiv:'#dceadd', idle:'#f0ebd5', heute:'#fbe6c2', kalt:'#ece9df', hilfe:'#f4d8d2'})[cls] || '#eee';
var op = (num === 0 && (cls === 'hilfe' || cls === 'idle')) ? 'opacity:.5' : '';
return '<div style="padding:.9rem 1rem;border-radius:10px;border:1px solid '+color+'33;background:'+bg+';display:flex;align-items:center;gap:.7rem">'
+ '<span style="font-size:1.7rem;font-weight:800;color:'+color+';line-height:1;font-variant-numeric:tabular-nums;'+op+'">'+num+'</span>'
+ '<span style="font-size:.78rem;line-height:1.25"><b>'+label+'</b><br><span style="color:#6a6a6a;font-size:.7rem">'+hint+'</span></span>'
+ '</div>';
}
function _cockpitFmtTime(sec) {
if (!sec || sec < 1) return '—';
if (sec < 60) return Math.round(sec) + 's';
var m = Math.round(sec / 60);
if (m < 60) return m + ' min';
var h = Math.floor(m / 60); var mm = m % 60;
return h + ' h ' + (mm < 10 ? '0' : '') + mm;
}
function _cockpitFmtAgo(sec) {
if (sec == null) return '';
if (sec < 60) return 'vor ' + sec + ' s';
var m = Math.floor(sec / 60);
if (m < 60) return 'vor ' + m + ' min';
var h = Math.floor(m / 60);
return 'vor ' + h + ' h';
}
function _cockpitAvatar(s) {
if (typeof s.emoji === 'string' && s.emoji.indexOf('avatar:') === 0) {
return '<img src="assets/img/avatars/thumbs/avatar-'+s.emoji.substring(7)+'.webp" alt="" style="width:32px;height:32px;border-radius:50%;object-fit:cover;display:block">';
}
var bg = (s.status === 'nie') ? '#e0dccd' : 'linear-gradient(135deg,#dceadd,#a8c3aa)';
var content = (s.status === 'nie') ? '?' : (s.emoji || '🧑‍🎓');
return '<div style="width:32px;height:32px;border-radius:50%;background:'+bg+';display:flex;align-items:center;justify-content:center;font-size:1.05rem">'+content+'</div>';
}
function _cockpitRankRow(rank, s, kind) {
var color = (kind === 'top') ? '#3a6a3e' : '#8a6516';
var barColor = (kind === 'top') ? '#5a8a5e' : '#c9913a';
var pct = (s.benchmarkPct != null) ? s.benchmarkPct : 0;
return '<div style="background:#fff;border:1px solid rgba(0,0,0,.08);border-radius:8px;padding:.4rem .6rem;display:grid;grid-template-columns:auto auto 1fr auto;gap:.55rem;align-items:center">'
+ '<span style="font-weight:800;color:'+color+';font-size:.78rem;min-width:1.4em;text-align:center">'+rank+'.</span>'
+ _cockpitAvatar(s)
+ '<span style="font-size:.8rem;font-weight:600;color:#3a3a3a;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(s.displayName)+'</span>'
+ '<span style="font-size:.85rem;font-weight:800;color:'+color+';font-variant-numeric:tabular-nums">'+(s.benchmarkPct != null ? pct + ' %' : '')+'</span>'
+ '<div style="grid-column:2/-1;height:5px;background:#f0ede5;border-radius:3px;overflow:hidden;margin-top:.1rem"><div style="height:100%;width:'+pct+'%;background:'+barColor+'"></div></div>'
+ '</div>';
}
function _cockpitWeeklyBars(data, modulesActive) {
if (!data || !data.buckets || !data.buckets.length) return '';
var todayBucketIdx = -1;
if (data.range === 'week') {
var d = new Date(); var w = d.getDay(); w = (w === 0) ? 6 : w - 1; // Mo=0 .. So=6
todayBucketIdx = w;
} else if (data.range === 'month') {
todayBucketIdx = new Date().getDate() - 1;
}
var modColor = {};
(modulesActive || []).forEach(function(m) { modColor[m.id] = m.color; });
var maxSec = data.weeklyMaxSec || 1;
var html = '<div style="display:grid;grid-template-columns:repeat('+data.buckets.length+',1fr);gap:.25rem;align-items:end;padding:.5rem 0 .25rem">';
data.buckets.forEach(function(label, i) {
var b = data.weeklyByDay[i] || {sum:0, byModule:{}};
var isToday = (i === todayBucketIdx);
if (!b.sum) {
html += '<div style="display:flex;flex-direction:column;align-items:center;gap:.18rem;padding:.2rem 0'+(isToday?';box-shadow:0 0 0 2px #3a6a3e;border-radius:3px':'')+'">'
+ '<div style="height:60px;display:flex;align-items:flex-end;justify-content:center;width:100%"><span style="display:block;width:50%;border-top:1px dashed rgba(0,0,0,.10)"></span></div>'
+ '<span style="font-size:.6rem;color:#6a6a6a;font-weight:600">'+(isToday?'<b>'+esc(label)+'</b>':esc(label))+'</span>'
+ '</div>';
return;
}
var hPct = Math.max(2, Math.round(b.sum / maxSec * 100));
var stack = '';
Object.entries(b.byModule).sort(function(a,b){ return b[1]-a[1]; }).forEach(function(entry) {
var segPct = (entry[1] / b.sum) * 100;
var col = modColor[entry[0]] || '#888';
stack += '<div style="background:'+col+';height:'+segPct.toFixed(1)+'%;min-height:1px"></div>';
});
var title = esc(label) + ': ' + _cockpitFmtTime(b.sum);
Object.entries(b.byModule).forEach(function(e) { title += '\n ' + e[0] + ': ' + _cockpitFmtTime(e[1]); });
html += '<div style="display:flex;flex-direction:column;align-items:center;gap:.18rem;padding:.2rem 0'+(isToday?';box-shadow:0 0 0 2px #3a6a3e;border-radius:3px':'')+'" title="'+title+'">'
+ '<div style="height:60px;display:flex;align-items:flex-end;justify-content:center;width:100%">'
+ ' <div style="width:65%;height:'+hPct+'%;display:flex;flex-direction:column-reverse;border-radius:2px 2px 0 0;overflow:hidden;box-shadow:0 0 0 1px rgba(0,0,0,.05)">'+stack+'</div>'
+ '</div>'
+ '<span style="font-size:.6rem;color:'+(isToday?'#3a6a3e':'#6a6a6a')+';font-weight:600">'+(isToday?'<b>'+esc(label)+'</b>':esc(label))+'</span>'
+ '</div>';
});
html += '</div>';
return html;
}
function _cockpitStudentRow(s) {
var statusClr = {aktiv:'#5a8a5e', idle:'#8a7a4a', heute:'#c9913a', kalt:'#9a9a9a', nie:'#9a9a9a', hilfe:'#b04a3e'};
var statusIco = {aktiv:'🟢', idle:'📵', heute:'🟡', kalt:'⚫', nie:'⚫', hilfe:'🆘'};
var key = s.needsHelp ? 'hilfe' : s.status;
var col = statusClr[key] || '#9a9a9a';
var bg = ({aktiv:'#fbfdf9', idle:'#faf7ec', heute:'#fdfaf2', kalt:'#fafaf8', nie:'#fafaf8', hilfe:'#fdf5f3'})[key];
var pulse = (s.status === 'aktiv') ? '<span style="display:inline-block;width:7px;height:7px;background:'+(s.needsHelp?'#b04a3e':'#5a8a5e')+';border-radius:50%;animation:_pulse 1.5s infinite;margin-left:.3rem"></span>' : '';
var nameLine = '<b style="font-size:.86rem;color:#3a3a3a">' + esc(s.displayName) + '</b>' + pulse;
var sub = '';
if ((s.status === 'aktiv' || s.status === 'idle') && s.activeSim) {
sub = '<span style="background:#f0ede5;padding:.06rem .4rem;border-radius:99px;font-size:.7rem;font-weight:600;color:#3a3a3a;margin-right:.4rem">'+(s.activeSimIcon||'')+' '+esc(s.activeSimName||s.activeSim)+'</span>';
if (s.state && s.state.phase) sub += '<span style="font-style:italic;color:#3a3a3a;margin-right:.4rem">'+esc(s.state.phase)+'</span>';
sub += '<span style="color:'+col+';font-size:.7rem;font-weight:600">⏱ '+_cockpitFmtAgo(s.lastHeartbeatSec)+'</span>';
// Idle-Hinweis bei Bedarf
if (s.status === 'idle' && s.idleMs != null) {
var idleMin = Math.floor(s.idleMs / 60000);
var idleSec = Math.round((s.idleMs % 60000) / 1000);
var idleStr = idleMin > 0 ? idleMin + ' min' : idleSec + ' s';
sub += ' <span style="color:#8a7a4a;font-size:.7rem;font-weight:600;margin-left:.4rem">📵 idle seit '+idleStr+' — Zeit pausiert</span>';
}
} else if (s.status === 'heute') {
var when = s.lastSeen ? _hhmm(s.lastSeen) : '';
sub = '<span style="color:#6a6a6a;font-size:.74rem">heute aktiv · zuletzt um '+when+'</span>';
} else if (s.status === 'kalt') {
sub = '<span style="color:#8a8a8a;font-size:.74rem;font-style:italic">heute noch nicht eingeloggt'+(s.lastSeen?' · zuletzt '+_cockpitFmtAgo(Math.floor((Date.now()-new Date(s.lastSeen).getTime())/1000)):'')+'</span>';
} else if (s.status === 'nie') {
sub = '<span style="color:#8a8a8a;font-size:.74rem;font-style:italic">noch nie eingeloggt</span>';
}
var actions = '';
if (s.status === 'aktiv' || s.status === 'idle') {
actions = '<button class="btn-sm" onclick="openLiveSpectator('+s.studentId+',\''+(s.activeSim||'')+'\')" style="padding:.3rem .65rem;background:#3a6a3e;color:#fff;border:0;border-radius:6px;font-weight:600;font-size:.72rem;cursor:pointer">Live anschauen →</button>';
} else if (s.status === 'kalt') {
actions = '<button class="btn-sm" style="padding:.3rem .65rem;background:#fff;border:1px solid rgba(0,0,0,.12);color:#3a3a3a;border-radius:6px;font-size:.72rem">Verlauf</button>';
}
var bench = (s.benchmarkPct != null) ? '<span style="font-size:.72rem;color:#3a3a3a;font-variant-numeric:tabular-nums;margin-left:.5rem"><b>'+s.benchmarkPct+' %</b> Benchmark</span>' : '';
return '<div style="background:'+bg+';border:1px solid rgba(0,0,0,.06);border-left:4px solid '+col+';border-radius:8px;padding:.55rem .7rem;display:grid;grid-template-columns:auto 1fr auto;gap:.7rem;align-items:center;margin-bottom:.35rem">'
+ '<div style="display:flex;align-items:center;gap:.5rem"><span style="font-size:1rem">'+statusIco[key]+'</span>'+_cockpitAvatar(s)+'</div>'
+ '<div>'+nameLine+'<div style="margin-top:.2rem">'+sub+bench+'</div></div>'
+ '<div style="display:flex;gap:.3rem">'+actions+'</div>'
+ '</div>';
}
async function renderLiveCockpit() {
var host = document.getElementById('tab-live');
if (!host) return;
var data = await api('live?class_id=' + currentClassId + '&cockpit=1&range=' + encodeURIComponent(_cockpitRange));
if (data.error) {
host.innerHTML = _liveToggleHtml() + '<p style="color:#c07a6b">'+esc(data.error)+'</p>';
return;
}
// CSS für Pulse-Animation (einmalig injizieren)
if (!document.getElementById('_cockpit-css')) {
var st = document.createElement('style'); st.id = '_cockpit-css';
st.innerHTML = '@keyframes _pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.5;transform:scale(.8)} }';
document.head.appendChild(st);
}
var html = _liveToggleHtml();
// === A) Status-Tiles ===
var sc = data.statusCount || {};
html += '<div class="card">'
+ '<div class="card-h">📡 Klassen-Status</div>'
+ '<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:.7rem;margin-bottom:.7rem">'
+ _cockpitStatusTile(sc.aktiv||0, 'jetzt aktiv', 'Heartbeat ≤ 30 s · Maus/Tastatur ≤ 90 s', 'aktiv')
+ _cockpitStatusTile(sc.idle||0, 'idle', 'Tab offen, aber > 90 s keine Eingabe · Zeit pausiert', 'idle')
+ _cockpitStatusTile(sc.heute||0, 'heute aktiv', 'heute schon gearbeitet', 'heute')
+ _cockpitStatusTile((sc.kalt||0)+(sc.nie||0), 'heute noch nichts', 'kein Login seit heute 0:00', 'kalt')
+ _cockpitStatusTile(sc.hilfe||0, 'brauchen Hilfe', 'stockt · viele Fehlversuche', 'hilfe')
+ '</div>';
// === B) Top 3 + Bottom 3 ===
html += '<div style="display:flex;justify-content:flex-end;border-top:1px solid rgba(0,0,0,.08);padding-top:.6rem;margin-bottom:.1rem">'
+ '<a href="handbuch#leistungsbeurteilung" target="_blank" rel="noopener" style="font-size:.68rem;color:#4a7c8a;text-decoration:none"> Wie sind diese Werte zu verstehen? Pädagogischer Hintergrund &rarr;</a></div>';
html += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;padding-top:.4rem">';
html += '<div><h3 style="font-size:.78rem;color:#3a6a3e;text-transform:uppercase;letter-spacing:.04em;margin-bottom:.5rem">🏆 Top 3 — Benchmark gesamt</h3>';
if (data.top3 && data.top3.length) {
data.top3.forEach(function(s,i) { html += _cockpitRankRow(i+1, s, 'top'); });
} else {
html += '<p style="font-size:.74rem;color:#8a8a8a">Noch keine Benchmark-Daten.</p>';
}
html += '</div>';
html += '<div><h3 style="font-size:.78rem;color:#8a6516;text-transform:uppercase;letter-spacing:.04em;margin-bottom:.5rem">👀 Schwächste 3 — Benchmark gesamt</h3>';
if (data.bottom3 && data.bottom3.length) {
data.bottom3.forEach(function(s,i) { html += _cockpitRankRow(data.students.length - i, s, 'bottom'); });
} else {
html += '<p style="font-size:.74rem;color:#8a8a8a">Noch keine Benchmark-Daten.</p>';
}
html += '</div></div>';
// === C) Klassen-Fleiß über Zeit + Zeitraum-Filter ===
var rangeBtns = [['day','Tag','Heute · 25 Stunden 0 24 · Schulkernzeit 8 13 Uhr hervorgehoben'],
['week','Woche','Aktuelle Kalenderwoche · MoSo · heutiger Tag markiert'],
['month','Monat','Letzte 30 Tage · alle Tage sichtbar'],
['year','Schuljahr','Aktuelles Schuljahr · ab Schulanfang (Land-abh.) bis Schulende']];
var pills = rangeBtns.map(function(r) {
var active = (r[0] === _cockpitRange);
return '<button onclick="setCockpitRange(\''+r[0]+'\')" title="'+esc(r[2])+'" style="padding:.28rem .6rem;border-radius:99px;border:1px solid rgba(0,0,0,.12);background:'+(active?'#3a6a3e':'#fff')+';color:'+(active?'#fff':'#3a3a3a')+';font-size:.7rem;font-weight:600;cursor:pointer">'+r[1]+'</button>';
}).join('');
html += '<div style="margin-top:1rem;padding-top:.9rem;border-top:1px solid rgba(0,0,0,.08)">';
html += '<div style="display:flex;align-items:center;gap:.4rem;flex-wrap:wrap;padding:.55rem .7rem;background:#f5f3eb;border:1px solid rgba(0,0,0,.06);border-radius:8px;margin-bottom:.7rem">';
html += '<span style="font-size:.7rem;color:#6a6a6a;font-weight:600;text-transform:uppercase;letter-spacing:.04em">Zeitraum:</span>';
html += pills;
html += '<span style="margin-left:auto;font-size:.66rem;color:#8a8a8a" title="Auswahl wird in localStorage ggs-fleiss-range gemerkt">💾 gemerkt</span>';
html += '<span style="font-size:.66rem;color:#4a7c8a;border-left:1px solid rgba(0,0,0,.1);padding-left:.5rem" title="Filter gilt auch für die Schüler:innen-Fleiß-Säulen im Ergebnisse-Tab">🔗 wirkt auch auf Ergebnisse</span>';
html += '</div>';
html += '<h3 style="font-size:.78rem;text-transform:uppercase;letter-spacing:.04em;margin-bottom:.4rem">📊 Klassen-Fleiß über Zeit</h3>';
html += _cockpitWeeklyBars(data, data.modulesActive);
// Legende
if (data.modulesActive && data.modulesActive.length) {
html += '<div style="display:flex;flex-wrap:wrap;gap:.4rem .8rem;margin-top:.5rem;font-size:.7rem;color:#3a3a3a">';
data.modulesActive.forEach(function(m) {
html += '<span style="display:inline-flex;align-items:center;gap:.3rem"><span style="display:inline-block;width:10px;height:10px;background:'+m.color+';border-radius:2px"></span>'+(m.icon||'')+' '+esc(m.name)+'</span>';
});
html += '</div>';
}
html += '</div>';
html += '</div>'; // /card Klassen-Status
// === D) Schülerliste — sortiert nach Status (aktiv → idle → heute → kalt → nie) ===
var statusOrder = {aktiv:0, idle:1, heute:2, kalt:3, nie:4};
var students = (data.students || []).slice().sort(function(a,b) {
// Hilfe-Fälle ganz oben
if (a.needsHelp !== b.needsHelp) return a.needsHelp ? -1 : 1;
if (statusOrder[a.status] !== statusOrder[b.status]) return statusOrder[a.status] - statusOrder[b.status];
return (a.displayName||'').localeCompare(b.displayName||'');
});
html += '<div class="card"><div class="card-h">👥 Klassenliste</div>';
html += '<p style="font-size:.7rem;color:#8a8a8a;margin-bottom:.6rem">Sortiert nach Status (Hilfe → aktiv → heute → kalt). Auto-Refresh alle 4 s.</p>';
if (!students.length) {
html += '<p style="font-size:.78rem;color:#8a8a8a">Keine Schüler:innen in dieser Klasse.</p>';
} else {
students.forEach(function(s) { html += _cockpitStudentRow(s); });
}
html += '</div>';
// === E) Reflexionen aus der Klasse (letzte 10) ===
html += '<div class="card" id="cockpit-reflections">'
+ '<div class="card-h">💭 Reflexionen — letzte Antworten der Klasse</div>'
+ '<p style="font-size:.7rem;color:#8a8a8a;margin-bottom:.6rem">Was die Bearbeiter:innen am Ende eines Durchgangs als wichtigsten Eindruck festgehalten haben.</p>'
+ '<div id="cockpit-reflections-list">Lade …</div>'
+ '</div>';
host.innerHTML = html;
// Reflexionen asynchron nachladen (eigene API, nicht im /live-Block)
_loadCockpitReflections();
}
async function _loadCockpitReflections() {
try {
var data = await api('results?class_id=' + currentClassId + '&view=reflections&limit=10');
if (data.error) { document.getElementById('cockpit-reflections-list').innerHTML = '<p style="color:#c07a6b;font-size:.78rem">' + esc(data.error) + '</p>'; return; }
var items = data.items || [];
var host = document.getElementById('cockpit-reflections-list');
if (!host) return;
if (!items.length) {
_liveReflCache = '<p style="font-size:.78rem;color:#8a8a8a;font-style:italic">Noch keine Reflexionen aus dieser Klasse. Sobald Lernende einen Durchgang abschließen und die Reflexionsfrage beantworten, erscheint die Antwort hier.</p>';
host.innerHTML = _liveReflCache;
return;
}
var simIcons = {};
(data.modules || []).forEach(function(m){ simIcons[m.id] = m.icon || ''; });
var html = '<div style="display:flex;flex-direction:column;gap:.55rem">';
items.forEach(function(it) {
var when = it.recordedAt ? _vdt(it.recordedAt) : '';
html += '<div style="background:#fbfaf6;border:1px solid rgba(0,0,0,.06);border-left:4px solid #c9913a;border-radius:8px;padding:.55rem .8rem">'
+ '<div style="display:flex;justify-content:space-between;font-size:.7rem;color:#6a6a6a;margin-bottom:.2rem">'
+ '<span><b style="color:#3a3a3a">' + _avatarHtml(it.avatarSlug, it.emoji, 1.2) + ' ' + esc(it.displayName||'—') + '</b>'
+ ' · <span style="background:#f0ede5;padding:.05rem .35rem;border-radius:99px">' + esc(it.simId) + '</span>'
+ (it.level ? ' · Stufe ' + esc(it.level) : '') + '</span>'
+ '<span>' + esc(when) + '</span>'
+ '</div>'
+ '<div style="font-size:.78rem;color:#8a8a8a;font-style:italic;margin-bottom:.2rem">' + esc(it.question || '—') + '</div>'
+ '<div style="font-size:.92rem;color:#2a2a2a;line-height:1.4">' + esc(it.answer || '—') + '</div>'
+ '</div>';
});
html += '</div>';
if (data.total > items.length) {
html += '<p style="font-size:.7rem;color:#8a8a8a;margin-top:.5rem">Zeige ' + items.length + ' von ' + data.total + ' Reflexionen.</p>';
}
_liveReflCache = html;
host.innerHTML = html;
} catch (e) {
var host = document.getElementById('cockpit-reflections-list');
if (host) host.innerHTML = '<p style="color:#c07a6b;font-size:.78rem">Reflexionen konnten nicht geladen werden.</p>';
}
}
// „Braucht Hilfe" live aus dem Sim-State (spiegelt die Heuristik in live.php)
function _liveNeedsHelp(simId, s) {
if (!s || typeof s !== 'object') return false;
switch (simId) {
case 'heli': return (s.planAttempts||0) >= 10;
case 'klima': case 'klima-3d': return (s.budget||0) < 0;
case 'weltkueche': return (s.mistakesInDish||0) >= 12;
case 'farmer': return (s.cash==null?1000:s.cash) < 0 || ((s.attempts||0) >= 6 && s.hit_rate != null && s.hit_rate < 30);
case 'logistik': return (s.balance==null?1000:s.balance) < 0;
case 'eu-werkstatt': return (s.attemptsThisStep||0) >= 6 || (s.reshufflesThisStep||0) >= 5;
case 'energiemanager': return (s.blackouts||0) >= 2 || (s.phase === 'done' && (s.balanced||0) < 4);
case 'tourismustal': return (s.money==null?1:s.money) < 0 || (s.lossStreak||0) >= 8;
case 'tourismusregion': return (s.budget==null?1:s.budget) < -400 || (s.env==null?100:s.env) < 30;
}
return false;
}
// Allgemeiner „aktueller Erfolg": Fortschritt (% durch die Sim) + Lage (Ampel).
// Aus dem Live-State jeder Sim ableitbar → über alle Module vergleichbar.
function _liveProgressPct(simId, s) {
if (!s) return null;
var f = function(n, d) { return d > 0 ? Math.max(0, Math.min(100, Math.round((n||0)/d*100))) : null; };
switch (simId) {
case 'weltkueche': return f(s.completed, s.completedTotal);
case 'sonnensystem': return s.taskTotal ? f(s.completed, s.taskTotal) : (s.progressPct!=null?s.progressPct:null);
case 'kofferdetektiv': return s.cases_total ? f(s.cases_solved, s.cases_total) : (s.progressPct!=null?s.progressPct:null);
case 'eu-werkstatt': return f(s.step, s.stepsTotal);
case 'energiemanager': return f(s.day, s.dayTotal);
case 'busfahrt': case 'fluggesellschaft': return f(s.orderIndex, s.totalOrders);
case 'heli': return f(s.planIndex, s.planTotal);
case 'tourismusregion': return f(s.wave, s.waveTotal);
case 'tourismustal': return f(s.goalsDone, s.goalsTotal);
case 'fluss': return s.progressPct!=null ? s.progressPct : null;
}
return s.progressPct!=null ? s.progressPct : null;
}
function _liveMood(simId, s) {
if (!s) return null;
// Einheitliche Skala: Leistungs-% steuert die Stimmung (≥80 gut · ≥50 mittel · <50 schlecht),
// deckungsgleich mit Farbe + Triage (Läuft gut / Begleiten / Fördern).
var _p = _leistungPct(simId, s);
if (_p != null) return _p >= 80 ? 'good' : _p >= 50 ? 'mid' : 'bad';
if (s.health) return ({good:'good',ok:'good',warning:'mid',bad:'bad',critical:'bad'})[s.health] || 'mid';
if (_liveNeedsHelp(simId, s)) return 'bad';
switch (simId) {
case 'weltkueche': return (s.mistakesInDish||0) <= 2 ? 'good' : (s.mistakesInDish >= 8 ? 'bad' : 'mid');
case 'farmer': return (s.hit_rate==null) ? 'mid' : (s.hit_rate >= 60 ? 'good' : s.hit_rate >= 30 ? 'mid' : 'bad');
case 'klima': case 'klima-3d': return (typeof s.score === 'number')
? (s.score >= 65 ? 'good' : s.score >= 40 ? 'mid' : 'bad')
: (((s.floodedPct==null?100:s.floodedPct) < 25 && (s.budget||0) >= 0) ? 'good' : 'mid');
case 'busfahrt': case 'fluggesellschaft': return (s.budget==null?0:s.budget) >= 5000 ? 'good' : (s.budget < 0 ? 'bad' : 'mid');
case 'tourismustal': return (s.money==null?0:s.money) >= 4000 ? 'good' : (s.money < 0 ? 'bad' : 'mid');
case 'heli': return (s.totalStars||0) >= 4 ? 'good' : 'mid';
case 'fluss': case 'kofferdetektiv': case 'sonnensystem':
return s.score!=null ? (s.score>=70?'good':s.score>=40?'mid':'bad') : 'mid';
}
return 'mid';
}
function _liveProgressBar(simId, s) {
var p = _liveProgressPct(simId, s);
if (p == null) return '<span style="color:#8a8a8a">—</span>';
var c = p >= 75 ? '#5a8a5e' : p >= 40 ? '#c9913a' : '#c07a6b';
return '<span style="display:inline-flex;align-items:center;gap:.3rem">'
+ '<span style="display:inline-block;width:40px;height:6px;border-radius:3px;background:#ece8dc;overflow:hidden;vertical-align:middle">'
+ '<span style="display:block;height:100%;width:'+p+'%;background:'+c+'"></span></span>'
+ '<span style="font-size:.66rem;color:#6a6a6a">'+p+'%</span></span>';
}
function _liveMoodBadge(simId, s) {
var m = _liveMood(simId, s);
if (!m) return '<span style="color:#8a8a8a">—</span>';
// Klartext statt Jargon („Kritisch"): stark / solide / viele Fehler.
var map = { good:['#5a8a5e','Stark'], mid:['#c9913a','Solide'], bad:['#c07a6b','Viele Fehler'] };
var x = map[m] || map.mid;
return '<span style="display:inline-flex;align-items:center;gap:.3rem">'
+ '<span style="width:8px;height:8px;border-radius:50%;background:'+x[0]+';display:inline-block"></span>'
+ '<span style="font-size:.66rem;color:'+x[0]+';font-weight:700">'+x[1]+'</span></span>';
}
// KENNZAHL QUALITÄT — grafisches Verhältnis richtige ↔ falsche Klicks.
// Zweifarbiger Balken (grün = richtig-Anteil, rot = Fehler-Anteil) + %-Wert.
// Funktioniert live (GGS_LIVE_STATE.correctClicks/wrongClicks) UND aus
// gespeicherten assessment.data-Feldern (gleiche Keys).
function _liveClicks(simId, s) {
if (!s) return null;
if (typeof s.correctClicks === 'number' || typeof s.wrongClicks === 'number')
return { correct: s.correctClicks || 0, wrong: s.wrongClicks || 0 };
// Fallbacks für Sims, die (noch) keine correct/wrongClicks liefern:
switch (simId) {
case 'heli':
if (s.planTotal) return { correct: s.planCorrect || 0, wrong: Math.max(0, s.planTotal - (s.planCorrect || 0)) };
break;
}
return null;
}
function _liveQualityBar(correct, wrong) {
correct = Math.max(0, correct || 0);
wrong = Math.max(0, wrong || 0);
var tot = correct + wrong;
if (!tot) return '<span style="color:#8a8a8a">—</span>';
var gp = Math.round(correct / tot * 100);
var rp = 100 - gp;
var c = gp >= 80 ? '#5a8a5e' : gp >= 55 ? '#c9913a' : '#c07a6b';
return '<span title="'+correct+' richtig · '+wrong+' Fehler ('+gp+' % richtig)" '
+ 'style="display:inline-flex;align-items:center;gap:.35rem;font-variant-numeric:tabular-nums">'
+ '<span aria-hidden="true" style="display:inline-flex;width:56px;height:9px;border-radius:99px;overflow:hidden;background:#ece8dc">'
+ '<span style="width:'+gp+'%;background:#5a8a5e"></span>'
+ '<span style="width:'+rp+'%;background:#d98b7a"></span>'
+ '</span>'
+ '<span style="font-size:.72rem;font-weight:700;color:'+c+';min-width:2.3em;text-align:right">'+gp+' %</span>'
+ '</span>';
}
// Bequemer Wrapper aus einem Live-State (für Vergleich/Aktuell-Tabellen).
function _liveQualityFromState(simId, s) {
var cw = _liveClicks(simId, s);
return cw ? _liveQualityBar(cw.correct, cw.wrong) : '<span style="color:#8a8a8a">—</span>';
}
var _liveReflCache = '', _liveReflAt = 0;
// Aktiv = Lebenszeichen in den letzten 90 s. Ältere Sessions bleiben sichtbar,
// aber als „pausiert" (Lernende:r macht Pause, kann später weitermachen).
var _LIVE_PAUSE_SEC = 90;
function _liveIsActive(ls) {
return ls.last_seen_ms && (Date.now() - ls.last_seen_ms) <= _LIVE_PAUSE_SEC * 1000;
}
// Uhrzeit/Datum IMMER in österreichischer Zeit (Europe/Vienna) — unabhängig von der
// Zeitzone des Servers (läuft in UTC) ODER des Anzeigegeräts.
// Eingabe: ms-Epoch (Zahl) ODER MySQL-Datetime-String (wird als UTC interpretiert).
function _asMs(t){
if (t == null || t === '') return 0;
if (typeof t === 'number') return t;
var s = String(t).trim();
if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/.test(s) && !/[zZ]|[+-]\d{2}:?\d{2}$/.test(s))
return new Date(s.replace(' ', 'T') + 'Z').getTime(); // DB-Zeit ist UTC
return new Date(s).getTime();
}
function _hhmm(t){ var ms=_asMs(t); if(!ms) return '—'; return new Date(ms).toLocaleTimeString('de-AT',{timeZone:'Europe/Vienna',hour:'2-digit',minute:'2-digit'}); }
function _vdt(t){ var ms=_asMs(t); if(!ms) return '—'; return new Date(ms).toLocaleString('de-AT',{timeZone:'Europe/Vienna',day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}); }
// Avatar als Bild rendern — egal ob der Slug in avatar_slug ODER als
// „avatar:<slug>" im emoji_avatar-Feld steckt (Daten sind uneinheitlich).
// Sonst wird sonst der rohe String „avatar:kartografin" als Text angezeigt.
function _avatarHtml(slug, emoji, sizeEm){
sizeEm = sizeEm || 1.4;
var st = 'width:'+sizeEm+'em;height:'+sizeEm+'em;border-radius:50%;object-fit:cover;vertical-align:middle';
if (slug) return '<img src="assets/img/avatars/thumbs/avatar-'+slug+'.webp" alt="" style="'+st+'">';
if (typeof emoji === 'string' && emoji.indexOf('avatar:') === 0)
return '<img src="assets/img/avatars/thumbs/avatar-'+emoji.substring(7)+'.webp" alt="" style="'+st+'">';
return '<span style="font-size:'+(sizeEm*0.8).toFixed(2)+'rem">'+(emoji||'🧑‍🎓')+'</span>';
}
// „Heute nicht mehr aktiv" — Schüler:innen, die HEUTE gespielt haben, gerade aber
// offline sind: zuletzt aktiv (Uhrzeit) + letzte Aktivität + deren Leistung.
// Wiederverwendet in „Aktuell" und „Fortschritt". activeSet = {studentId:true}
// der gerade Online-Schüler:innen (die stehen oben in der aktiven Tabelle).
async function _renderInactiveToday(activeSet) {
var today = new Date().toISOString().slice(0,10);
var data;
try { data = await api('live?summary=1&class_id=' + currentClassId + '&date=' + encodeURIComponent(today)); }
catch(e){ return ''; }
if (!data || data.error || !Array.isArray(data.students)) return '';
var groups = [];
data.students.forEach(function(s){
// Alle HEUTE abgeschlossenen Durchgänge (das gerade laufende Spiel steht
// oben in der Live-Liste und wird hier ausgelassen). Auch für Schüler:innen,
// die gerade online sind — ihre schon fertigen Läufe sollen sichtbar bleiben.
var sess = (s.sessions||[]).filter(function(x){ return x.startMs && !x.isLive && x.success!=='running'; });
if (!sess.length) return; // heute nichts abgeschlossen
sess.sort(function(a,b){ return (a.startMs||0)-(b.startMs||0); }); // chronologisch
var lastEnd = 0; sess.forEach(function(x){ if((x.endMs||0)>lastEnd) lastEnd = x.endMs||0; });
groups.push({ s:s, sess:sess, lastEnd:lastEnd });
});
if (!groups.length) return '';
groups.sort(function(a,b){ return b.lastEnd - a.lastEnd; }); // zuletzt aktive Person oben
var totalRuns = groups.reduce(function(n,g){ return n + g.sess.length; }, 0);
var html = '<div class="card" style="margin-top:.8rem"><div class="card-h">Heute bereits erledigt · '+groups.length+' Schüler*innen · '+totalRuns+' Durchgänge</div>';
html += '<p style="font-size:.7rem;color:#8a8a8a;margin-bottom:.5rem">Alle heute abgeschlossenen Durchgänge (Weltküche, Klima, Farmer …) mit Uhrzeit und Leistung. Gerade laufende Spiele stehen oben.</p>';
html += '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:.74rem">';
html += '<thead><tr style="background:#f7f6f3;border-bottom:2px solid rgba(0,0,0,.08)">'
+ '<th style="text-align:left;padding:.4rem .5rem;white-space:nowrap">Schüler*in</th>'
+ '<th style="text-align:left;padding:.4rem .5rem;white-space:nowrap">Zuletzt aktiv</th>'
+ '<th style="text-align:left;padding:.4rem .5rem;white-space:nowrap">Aktivität</th>'
+ '<th style="text-align:right;padding:.4rem .5rem;white-space:nowrap">Leistung</th></tr></thead><tbody>';
groups.forEach(function(g){
var s = g.s;
var av = (typeof s.emoji === 'string' && s.emoji.indexOf('avatar:')===0)
? '<img src="assets/img/avatars/thumbs/avatar-'+s.emoji.substring(7)+'.webp" alt="" style="width:1.5em;height:1.5em;border-radius:50%;object-fit:cover;vertical-align:middle">'
: '<span style="font-size:1.05rem">'+(s.emoji||'🧑‍🎓')+'</span>';
g.sess.forEach(function(last, j){
var score = (last.score!=null)?last.score:(last.stars!=null?Math.round(last.stars/5*100):null);
var perf = score!=null
? '<span style="font-weight:700;color:'+_tagTraffic(score)+'">'+score+' %</span>'
: (last.success==='aborted' ? '<span style="color:#b03a2a">abgebrochen</span>' : '<span style="color:#8a8a8a">—</span>');
var nameCell = (j===0)
? '<span style="display:inline-flex;align-items:center;gap:.4rem">'+av+'<strong>'+esc(s.displayName)+'</strong></span>'
: '';
var sep = (j===0) ? 'border-top:2px solid rgba(0,0,0,.07);' : ''; // neue Person → Trennlinie
html += '<tr style="border-bottom:1px solid rgba(0,0,0,.03);'+sep+'cursor:pointer" onclick="ivOpenStudent('+s.studentId+')">'
+ '<td style="padding:.35rem .5rem;white-space:nowrap">'+nameCell+'</td>'
+ '<td style="padding:.35rem .5rem;color:#3a3a3a;font-variant-numeric:tabular-nums">'+_hhmm(last.endMs)+' Uhr</td>'
+ '<td style="padding:.35rem .5rem">'+esc((last.simIcon||'📚')+' '+(last.simTitle||last.simId))+(last.lessonLabel?' · '+esc(last.lessonLabel):'')+'</td>'
+ '<td style="text-align:right;padding:.35rem .5rem">'+perf+'</td></tr>';
});
});
html += '</tbody></table></div></div>';
return html;
}
async function renderLive() {
var sessions = await api('live?class_id=' + currentClassId);
if (sessions.error) { document.getElementById('tab-live').innerHTML = '<p style="color:#c07a6b">'+esc(sessions.error)+'</p>'; return; }
var host = document.getElementById('tab-live');
var html = _liveToggleHtml() + '<div class="card"><div class="card-h">Live · Klassen-Vergleich</div>';
html += '<p style="font-size:.7rem;color:#8a8a8a;margin-bottom:.6rem">Aktualisiert sich alle 4 Sekunden. Aktiv = Lebenszeichen ≤ 90 s · pausierte Sessions (⏸) bleiben sichtbar, bis der/die Lernende weitermacht. Klick auf eine Zeile öffnet die Detailansicht.</p>';
if (!sessions.length) {
html += '<p style="font-size:.78rem;color:#8a8a8a">Gerade niemand online — sobald eine Schülerin oder ein Schüler eine Simulation startet, taucht sie hier auf.</p>';
host.innerHTML = html + '</div>' + (await _renderInactiveToday({}));
return;
}
// „Braucht Hilfe"-Banner (live) — nur aktuell aktive Sessions
var helpList = sessions.filter(function(ls){ return _liveIsActive(ls) && _liveNeedsHelp(ls.module_id, ls.state); });
if (helpList.length) {
html += '<div style="background:#fdf3ee;border:1px solid #e6c3b4;border-left:4px solid #c0574a;border-radius:8px;padding:.5rem .8rem;margin-bottom:.7rem;font-size:.76rem;color:#8a3a2a">'
+ '🆘 <b>'+helpList.length+' brauchen evtl. Hilfe</b> — '
+ helpList.map(function(ls){ return esc(ls.displayName); }).join(' · ')
+ '</div>';
}
// Nach Modul gruppieren
var byModule = {};
var _activeSet = {};
sessions.forEach(function(ls) {
if (!byModule[ls.module_id]) byModule[ls.module_id] = { meta: ls, rows: [] };
byModule[ls.module_id].rows.push(ls);
_activeSet[ls.student_id] = true;
});
Object.keys(byModule).forEach(function(modId) {
var grp = byModule[modId];
var meta = grp.meta;
// Spalten-Schema: bevorzugt LIVE_PRIMARY_FIELDS, sonst Vereinigung aller state-Keys.
var fields = LIVE_PRIMARY_FIELDS[modId];
if (!fields) {
// Kein kuratiertes Schema → nur sinnvolle State-Keys zeigen.
// Interne Heartbeat-Keys (__…) und Rausch-Felder NIE anzeigen.
var _hideKeys = {simId:1, timestamp:1, screen:1};
var keySet = {};
grp.rows.forEach(function(r) {
var s = r.state || {};
Object.keys(s).forEach(function(k) {
if (k.indexOf('__') === 0 || _hideKeys[k]) return;
keySet[k] = true;
});
});
fields = Object.keys(keySet).map(function(k) { return {k:k, label:k}; });
}
var anyState = grp.rows.some(function(r) { return r.state && Object.keys(r.state).length > 0; });
html += '<div style="margin-bottom:1.2rem">';
html += '<div style="display:flex;align-items:center;gap:.4rem;margin:.5rem 0 .35rem;font-size:.85rem;font-weight:700;color:#1f4b37">'
+ '<span style="font-size:1.1rem">'+(meta.moduleIcon||'📚')+'</span>'
+ '<span>'+esc(meta.moduleName||modId)+'</span>'
+ (function(){ var a=grp.rows.filter(_liveIsActive).length, p=grp.rows.length-a;
return '<span style="font-size:.62rem;font-weight:500;color:#8a8a8a">· '+a+' aktiv'+(p?' · '+p+' pausiert':'')+'</span>'; })()
+ '</div>';
if (!anyState) {
html += '<p style="font-size:.7rem;color:#c07a6b;background:#fdf3ee;border-left:3px solid #e8833a;padding:.4rem .6rem;border-radius:5px;margin-bottom:.4rem">⚠ Diese Simulation meldet noch keine Live-Werte. Solange sie keinen State-Snapshot über <code>window.GGS_LIVE_STATE</code> liefert, siehst du nur „wer ist online".</p>';
}
// Tabelle
html += '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:.74rem">';
html += '<thead><tr style="background:#f7f6f3;border-bottom:2px solid rgba(0,0,0,.08)">';
html += '<th style="text-align:left;padding:.4rem .5rem;white-space:nowrap">Schüler*in</th>';
html += '<th style="text-align:left;padding:.4rem .5rem;white-space:nowrap;color:#8a8a8a;font-weight:500" title="oben: aktuelle Mission/Lektion · unten: gesamte Sim-Session">Seit</th>';
html += '<th style="text-align:center;padding:.4rem .3rem;white-space:nowrap;background:#eef7f0;font-size:.68rem" title="Einheitliches Leistungs-% (0100) — grün = läuft gut, orange/rot = Hilfe nötig">🎯 Leistung</th>';
fields.forEach(function(f) {
var align = f.align || 'right';
html += '<th style="text-align:'+align+';padding:.4rem .5rem;white-space:nowrap">'+esc(f.label)+(f.unit?' <span style="color:#8a8a8a;font-weight:400;font-size:.66rem">'+f.unit+'</span>':'')+'</th>';
});
html += '<th style="width:1.2em"></th>';
html += '</tr></thead><tbody>';
// Spalten-Maxima für relative Mini-Balken (über alle Zeilen dieser Sim) —
// für ALLE Zahlenspalten, damit jede Zahl einen kleinen Vergleichs-Balken bekommt.
var colMax = {};
fields.forEach(function(f) {
var mx = 0;
grp.rows.forEach(function(r) { var val = (r.state||{})[f.k]; if (typeof val === 'number' && !isNaN(val)) mx = Math.max(mx, Math.abs(val)); });
colMax[f.k] = mx;
});
grp.rows.forEach(function(ls) {
var avatarHtml = (typeof ls.emoji === 'string' && ls.emoji.indexOf('avatar:') === 0)
? '<img src="assets/img/avatars/thumbs/avatar-'+ls.emoji.substring(7)+'.webp" alt="" style="width:1.6em;height:1.6em;border-radius:50%;object-fit:cover;vertical-align:middle">'
: '<span style="font-size:1.1rem">'+(ls.emoji||'🧑‍🎓')+'</span>';
// „Seit" doppelt: oben aktuelle Mission/Lektion (state.missionStartedAt
// oder analog), unten ganze Sim-Session (started_at_ms vom Server,
// tz-sicher als ms-Epoch).
var sinceSimBase = ls.started_at_ms || 0;
var sinceMissionBase = (ls.state && ls.state.missionStartedAt)
|| (ls.state && ls.state.levelStartedAt)
|| (ls.state && ls.state.scenarioStartedAt)
|| 0;
var sinceSim = sinceSimBase ? Math.max(0, Math.round((Date.now() - sinceSimBase)/1000)) : 0;
var sinceMission = sinceMissionBase ? Math.max(0, Math.round((Date.now() - sinceMissionBase)/1000)) : 0;
function _fmtSince(sec) { return sec < 60 ? sec+' s' : Math.floor(sec/60)+' min'; }
var simStr = sinceSimBase ? _fmtSince(sinceSim) : '—';
var missionStr = sinceMissionBase ? _fmtSince(sinceMission) : '—';
var lastSeen = ls.last_seen_ms ? Math.max(0, Math.round((Date.now() - ls.last_seen_ms)/1000)) : 0;
var s = ls.state || {};
// Pausiert = veralteter Heartbeat ODER die Sim/Plattform meldet Pause
// (Idle-Overlay __idlePaused / Sim-Pause __paused). Sonst gilt ein idle-
// pausierter, aber weiter heartbeatender Schüler fälschlich als „aktiv".
var paused = !_liveIsActive(ls) || !!(s.__idlePaused || s.__paused);
var dotColor = lastSeen < 8 ? '#5a8a5e' : lastSeen < 20 ? '#e8833a' : '#c07a6b';
var help = !paused && _liveNeedsHelp(ls.module_id, s);
var restBg = help ? '#fdf3ee' : (paused ? '#f6f5f2' : '');
var hoverBg = help ? '#f9e7e0' : '#f0efe9';
var accent = help ? 'inset 3px 0 0 #c0574a' : (paused ? 'inset 3px 0 0 #cfcabb' : 'none');
html += '<tr style="border-bottom:1px solid rgba(0,0,0,.04);cursor:pointer;background:'+restBg+';'+(paused?'color:#9a9a9a;':'')+'box-shadow:'+accent+'" onclick="openLiveSpectator('+ls.student_id+',\''+ls.module_id+'\')" onmouseover="this.style.background=\''+hoverBg+'\'" onmouseout="this.style.background=\''+restBg+'\'">';
html += '<td style="padding:.4rem .5rem;white-space:nowrap"><span style="display:inline-flex;align-items:center;gap:.4rem">'+(help?'<span title="stockt · braucht evtl. Hilfe">🆘</span>':(paused?'<span title="pausiert — kann später weitermachen">⏸</span>':''))+avatarHtml+'<strong>'+esc(ls.displayName)+'</strong></span></td>';
html += '<td style="padding:.4rem .5rem;white-space:nowrap;line-height:1.15" title="Aktuelle Mission/Lektion · Gesamte Sim-Session"><div style="font-size:.78rem;color:#3a3a3a;font-weight:600">'+missionStr+'</div><div style="font-size:.62rem;color:#8a8a8a">'+simStr+' ges.</div></td>';
html += '<td style="padding:.4rem .3rem;vertical-align:top">'+_pctBar(_leistungPct(ls.module_id, s))+'</td>';
fields.forEach(function(f) {
var align = f.align || 'right';
var cell = _liveFmt(s[f.k], f.unit, f, ls);
if (f.bar) {
var raw = s[f.k];
if (typeof raw === 'number' && !isNaN(raw)) {
var pct = 0, bcol;
if (f.bar.kind === 'abs') {
pct = (raw / (f.bar.max || 100)) * 100;
bcol = _liveBarColor(pct, f.bar.good);
} else {
var mx = colMax[f.k] || 0;
pct = mx > 0 ? (Math.abs(raw) / mx) * 100 : 0;
bcol = (f.bar.good === 'signed') ? (raw < 0 ? '#c0392b' : '#5a8a5e') : _liveBarColor(pct, f.bar.good);
}
cell = '<div style="line-height:1.1">'+cell+'</div>' + _liveMiniBar(pct, bcol);
}
} else if (typeof s[f.k] === 'number' && !isNaN(s[f.k]) && (colMax[f.k]||0) > 0 && cell.indexOf('border-radius:99px') < 0) {
// Mini-Balken auch für alle übrigen Zahlenspalten: relativ zum Spalten-Max (neutral grau).
var apct = (Math.abs(s[f.k]) / colMax[f.k]) * 100;
cell = '<div style="line-height:1.1">'+cell+'</div>' + _liveMiniBar(apct, '#9aa7b0');
}
html += '<td style="text-align:'+align+';padding:.4rem .5rem;font-variant-numeric:tabular-nums;vertical-align:top">'+cell+'</td>';
});
html += '<td style="text-align:center">'+(paused
? '<span style="font-size:.6rem;color:#9a9a9a;white-space:nowrap" title="seit '+_fmtSince(lastSeen)+' keine Aktivität — Session bleibt offen">⏸ '+_fmtSince(lastSeen)+'</span>'
: '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:'+dotColor+'" title="Letztes Lebenszeichen vor '+lastSeen+' s"></span>')+'</td>';
html += '</tr>';
});
html += '</tbody></table></div></div>';
});
html += '</div>';
html += await _renderInactiveToday(_activeSet);
// Reflexionen (letzte Antworten der Klasse) — gecacht, damit der 4s-Refresh nicht flackert
html += '<div class="card" id="cockpit-reflections">'
+ '<div class="card-h">💭 Reflexionen — letzte Antworten der Klasse</div>'
+ '<p style="font-size:.7rem;color:#8a8a8a;margin-bottom:.6rem">Was die Bearbeiter:innen am Ende eines Durchgangs als wichtigsten Eindruck festgehalten haben.</p>'
+ '<div id="cockpit-reflections-list">' + (_liveReflCache || 'Lade …') + '</div>'
+ '</div>';
host.innerHTML = html;
if (!_liveReflCache || Date.now() - _liveReflAt > 30000) {
_liveReflAt = Date.now();
_loadCockpitReflections();
}
}
// === LIVE-ÜBERSICHT (Tages-Balkenchart pro Schüler*in) ===
// Kurz-Form pro Sim für Balken-Label
// Kanonische Sim-Bezeichner: EIN Wort + 3-Buchstaben-Kürzel je App.
// Wort wo Platz ist, Kürzel in engen Balken. Zentrale Quelle — auch anderswo
// nutzbar (Tages-Balken, Kompakt-Anzeigen). Kürzel sind eindeutig.
var SIM_LABELS = {
weltkueche: { w:'Weltküche', c:'WEK' },
klima: { w:'Klima', c:'KLI' },
'klima-3d': { w:'Klima 3D', c:'K3D' },
staustufen: { w:'Staustufen', c:'STU' },
fluss: { w:'Fluss', c:'FLU' },
heli: { w:'Heli', c:'HEL' },
energiemanager: { w:'Energie', c:'ENE' },
'eu-werkstatt': { w:'EU-Werkstatt', c:'EUW' },
regenwald: { w:'Regenwald', c:'RGW' },
erdbeben: { w:'Erdbeben', c:'ERD' },
energiemix: { w:'Energiemix', c:'EMX' },
lieferketten: { w:'Lieferketten', c:'LIF' },
farmer: { w:'Farmer', c:'FAR' },
busfahrt: { w:'Busfahrt', c:'BUS' },
sonnensystem: { w:'Sonne', c:'SON' },
tourismustal: { w:'Tourismustal', c:'TAL' },
tourismusregion: { w:'Tourismusregion', c:'REG' },
logistik: { w:'Logistik', c:'LOG' },
fluggesellschaft: { w:'Flug', c:'FLG' },
vulkan: { w:'Vulkan', c:'VUL' },
wal: { w:'Wal', c:'WAL' },
stadt: { w:'Stadt', c:'STD' },
kofferdetektiv: { w:'Koffer', c:'KOF' },
entscheidungstag: { w:'Alltag', c:'ALT' },
};
function simWord(id){ return (SIM_LABELS[id] && SIM_LABELS[id].w) || id; }
function simCode(id){ return (SIM_LABELS[id] && SIM_LABELS[id].c) || String(id||'').slice(0,3).toUpperCase(); }
// Stabile, unterscheidbare Farbe je Sim (eine Farbe pro Simulation) — Hash→Farbton.
function simColor(id){
if (SIM_LABELS[id] && SIM_LABELS[id].col) return SIM_LABELS[id].col;
var s = String(id||''), h = 0;
for (var i=0;i<s.length;i++) h = (h*33 + s.charCodeAt(i)) % 360;
return 'hsl('+h+',48%,42%)';
}
// Rückwärtskompatibel: LIVE_SIM_SHORT[id] = Wort je Sim.
var LIVE_SIM_SHORT = Object.keys(SIM_LABELS).reduce(function(o,k){ o[k]=SIM_LABELS[k].w; return o; }, {});
function _liveFmtMin(sec) {
if (sec < 60) return sec + 's';
var m = Math.floor(sec/60); var s = sec%60;
return m + ':' + (s<10?'0':'') + s;
}
function _liveSuccessColor(success) {
return ({
good: '#5a8a5e',
mid: '#c9913a',
bad: '#c07a6b',
aborted: '#9a9a9a',
running: '#e8833a',
unknown: '#7a8a9c',
})[success] || '#7a8a9c';
}
// Ampelfarbe für den Tages-Erfolg (0100): grün → hellgrün → gelb → orange → rot.
function _tagTraffic(v) { return _pctColor(v); } // EINE Skala (siehe _pctColor)
function _liveSuccessIcon(stars, success) {
if (success === 'aborted') return '✗';
if (success === 'running') return '⏳';
if (stars != null) return '★'.repeat(stars) + (stars < 5 ? '☆'.repeat(5-stars) : '');
if (success === 'good') return '✓';
return '·';
}
// === KLIMA-VERGLEICH (Balken-Profil pro aktive Schülerin) ===========
// Vier didaktische Dimensionen + Gesamt-Score, alles als Balken (0-100 %).
// Sortierbar pro Spalte. Zebra-Hinterlegung für ruhige Augenführung.
//
// Skalen-Mapping (jeweils 0-100):
// - Klima: 15.5°C → 100, 18.5°C → 0 (linear klemmen)
// - Insel: 100 floodedPct
// - Wirt.: budget; 0 → ~40 %, 500 → 90 %, ≥1000 → 100, < 0 → < 40
// - Volk: population / startPopulation × 100 (gekappt 0-110)
// - Gesamt: Mittelwert der vier
function _klimaScoreTemp(t) {
if (t == null) return 0;
var v = (18.5 - t) / 3.0 * 100; // 15.5 → 100, 18.5 → 0
return Math.max(0, Math.min(100, v));
}
function _klimaScoreInsel(floodedPct) {
if (floodedPct == null) return 0;
return Math.max(0, Math.min(100, 100 - floodedPct));
}
function _klimaScoreBudget(b) {
if (b == null) return 0;
// -200 → 0, 0 → 40, 500 → 90, ≥1000 → 100
var v;
if (b <= -200) v = 0;
else if (b < 0) v = 40 + (b / -200) * -40; // 40 → 0
else if (b < 500) v = 40 + (b / 500) * 50; // 40 → 90
else v = 90 + Math.min(10, (b - 500) / 50);
return Math.max(0, Math.min(100, v));
}
function _klimaScorePop(pop, startPop) {
if (!pop || !startPop) return 0;
// 60 % → 0, 75 % → 50, 90 % → 90, 100 %+ → 100
var ratio = pop / startPop * 100;
if (ratio <= 60) return 0;
if (ratio <= 90) return (ratio - 60) / 30 * 90;
return Math.min(100, 90 + (ratio - 90));
}
function _klimaBarColor(score) {
if (score >= 75) return '#5a8a5e'; // grün
if (score >= 50) return '#c9913a'; // gelb/orange
if (score >= 25) return '#c07a6b'; // rot-orange
return '#a04a3a'; // rot
}
function _klimaBar(score, label, width) {
width = width || 110;
var pct = Math.max(0, Math.min(100, score));
var col = _klimaBarColor(pct);
// Balken-Container + Füllung + Label
return '<div style="display:inline-flex;align-items:center;gap:.4rem;width:100%">'
+ '<div style="position:relative;flex:1 1 '+width+'px;min-width:60px;height:14px;background:#ececec;border-radius:3px;overflow:hidden">'
+ ' <div style="position:absolute;left:0;top:0;height:100%;width:'+pct.toFixed(1)+'%;background:'+col+';transition:width .3s ease"></div>'
+ '</div>'
+ '<span style="font-size:.7rem;color:#3a3a3a;font-variant-numeric:tabular-nums;min-width:3em;text-align:right;font-weight:600">'+label+'</span>'
+ '</div>';
}
function _liveCleanName(dn, fallbackIdx) {
if (!dn) return 'Schüler*in ' + fallbackIdx;
var s = String(dn).trim();
if (s === '' || /^avatar:/i.test(s)) return 'Schüler*in ' + fallbackIdx;
return s;
}
function setKlimaSort(col) {
if (_liveKlimaSort.col === col) {
_liveKlimaSort.dir = _liveKlimaSort.dir === 'desc' ? 'asc' : 'desc';
} else {
_liveKlimaSort.col = col;
_liveKlimaSort.dir = (col === 'name') ? 'asc' : 'desc';
}
loadLive();
}
window.setKlimaSort = setKlimaSort;
// Sim-Gruppen für Filter: Klima-2D + Klima-3D zusammen, sonst pro Modul
function _compareSimGroup(modId) {
if (modId === 'klima' || modId === 'klima-3d') return 'klima';
return modId;
}
function _compareGroupLabel(g) {
return ({
klima: '🌍 Klimawächter', heli: '🚁 Hubschrauber',
busfahrt: '🚌 Busfahrt', fluggesellschaft: '✈️ Fluggesellschaft',
sonnensystem: '🪐 Sonnensystem', 'eu-werkstatt': '🇪🇺 EU-Werkstatt',
energiemanager: '⚡ Energiemanager', farmer: '🌾 Farmer',
fluss: '🌊 Fluss', logistik: '📦 Logistik', stadt: '🏙 Stadt',
weltkueche: '🍽️ Weltküche', entscheidungstag: '📅 Entscheidungstag',
})[g] || g;
}
async function renderLiveCompare() {
var host = document.getElementById('tab-live');
if (!host) return;
var res = await Promise.all([
api('live?class_id=' + currentClassId),
api('assignments?class_id=' + currentClassId),
api('results?class_id=' + currentClassId + '&view=matrix'),
]);
var sessions = res[0], assignments = res[1], matrix = res[2];
if (sessions.error) { host.innerHTML = _liveToggleHtml() + '<p style="color:#c07a6b">'+esc(sessions.error)+'</p>'; return; }
if (!Array.isArray(assignments)) assignments = [];
if (!matrix || matrix.error) matrix = { students:[], modules:[], cells:{} };
// Aktive Klassenaufgabe (erste aktive) → Fokus-Modul für den Vergleich
var activeAssign = assignments.filter(function(a){ return a.active; })[0] || null;
var assignGroup = activeAssign ? _compareSimGroup(activeAssign.module_id) : null;
// Online-Sessions nach Sim gruppieren
var byGroup = {};
(sessions || []).forEach(function(ls){
var g = _compareSimGroup(ls.module_id);
if (!byGroup[g]) byGroup[g] = { count: 0, sessions: [] };
byGroup[g].count++;
byGroup[g].sessions.push(ls);
});
var groups = Object.keys(byGroup).sort(function(a,b){ return byGroup[b].count - byGroup[a].count; });
// Chips = Online-Gruppen, Aufgabe immer vorne (auch wenn noch niemand aktiv)
var chipGroups = groups.slice();
if (assignGroup) chipGroups = [assignGroup].concat(chipGroups.filter(function(g){ return g !== assignGroup; }));
if (!chipGroups.length) {
host.innerHTML = _liveToggleHtml()
+ '<div class="card"><div class="card-h">Vergleich</div>'
+ '<p style="font-size:.78rem;color:#8a8a8a">Gerade niemand online und keine aktive Klassenaufgabe — sobald Schüler*innen eine Simulation öffnen (oder du eine Aufgabe freigibst), kannst du hier vergleichen.</p>'
+ '</div>';
return;
}
// Sim wählen: explizite User-Wahl > aktive Aufgabe > meiste Online
var sim;
if (_liveCompareSim && chipGroups.indexOf(_liveCompareSim) >= 0) sim = _liveCompareSim;
else if (assignGroup) sim = assignGroup;
else sim = groups[0];
_liveCompareSim = sim;
var html = _liveToggleHtml();
// Aufgaben-Banner, wenn eine Klassenaufgabe aktiv ist
if (activeAssign) {
var modId = activeAssign.module_id;
var N = (matrix.students || []).length;
var begun = 0;
(matrix.students || []).forEach(function(s){
var c = (matrix.cells && matrix.cells[s.id]) ? matrix.cells[s.id][modId] : null;
if (c && c.plays > 0) begun++;
});
var onlineNow = byGroup[assignGroup] ? byGroup[assignGroup].sessions : [];
var progVals = onlineNow.map(function(ls){ return _liveProgressPct(ls.module_id, ls.state); }).filter(function(v){ return v != null; });
var avgProg = progVals.length ? Math.round(progVals.reduce(function(a,b){return a+b;},0)/progVals.length) : null;
var due = activeAssign.ends_at ? ' · bis ' + new Date(activeAssign.ends_at).toLocaleDateString('de-AT',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) : '';
html += '<div style="background:#f0f5f1;border:1px solid #cfe0d4;border-left:4px solid #3a6a3e;border-radius:10px;padding:.6rem .9rem;margin-bottom:.7rem">'
+ '<div style="font-size:.82rem;font-weight:800;color:#2a5a2e">📋 Aufgabe: '+esc(_compareGroupLabel(assignGroup))+'<span style="font-weight:500;color:#6a8a6e;font-size:.7rem"> · Level '+esc(activeAssign.level)+due+'</span></div>'
+ '<div style="display:flex;gap:1.2rem;flex-wrap:wrap;margin-top:.35rem;font-size:.74rem;color:#3a3a3a">'
+ '<span>begonnen <b>'+begun+'</b> / '+N+'</span>'
+ '<span>gerade aktiv <b>'+onlineNow.length+'</b></span>'
+ '<span>Ø Fortschritt <b>'+(avgProg!=null?avgProg+' %':'')+'</b></span>'
+ '</div></div>';
}
// Sim-Filter-Chips
html += '<div style="display:flex;gap:.4rem;flex-wrap:wrap;margin-bottom:.6rem;padding:.3rem 0">';
chipGroups.forEach(function(g){
var active = g === sim;
var isAssign = (g === assignGroup);
var bg = active ? '#1f4b37' : '#fff';
var col = active ? '#fff' : '#3d5165';
var border = active ? '#1f4b37' : (isAssign ? '#3a6a3e' : 'rgba(0,0,0,.12)');
var cnt = byGroup[g] ? byGroup[g].count : 0;
html += '<button onclick="setCompareSim(\''+g+'\')" style="padding:.35rem .7rem;border:1.5px solid '+border+';background:'+bg+';color:'+col+';border-radius:18px;font-size:.74rem;font-weight:700;cursor:pointer;display:inline-flex;align-items:center;gap:.4rem">'
+ (isAssign ? '<span title="aktive Klassenaufgabe">📋</span>' : '')
+ '<span>'+esc(_compareGroupLabel(g))+'</span>'
+ '<span style="background:'+(active?'rgba(255,255,255,.25)':'rgba(0,0,0,.07)')+';padding:0 .4rem;border-radius:8px;font-size:.66rem;font-weight:800">'+cnt+'</span>'
+ '</button>';
});
html += '</div>';
// Körper
var simSessions = byGroup[sim] ? byGroup[sim].sessions : [];
if (!simSessions.length) {
html += '<div class="card"><div class="card-h">'+esc(_compareGroupLabel(sim))+' · Fortschritt</div>'
+ '<p style="font-size:.78rem;color:#8a8a8a">Noch niemand arbeitet gerade an dieser Simulation. Sobald Kinder starten, erscheinen sie hier.</p></div>';
} else if (sim === 'klima') {
html += _renderKlimaCompareBody(simSessions);
} else {
html += _renderGenericCompareBody(simSessions, sim);
}
var _cActive = {};
(Array.isArray(sessions) ? sessions : []).forEach(function(ls){ _cActive[ls.student_id] = true; });
html += await _renderInactiveToday(_cActive);
host.innerHTML = html;
}
function _renderKlimaCompareBody(klimaRows) {
// Pro Zeile Scores berechnen
var rows = klimaRows.map(function(ls, idx) {
var s = ls.state || {};
var temp = (typeof s.temp === 'number') ? s.temp : null;
var floodedPct = (typeof s.floodedPct === 'number') ? s.floodedPct : null;
var budget = (typeof s.budget === 'number') ? s.budget : null;
var population = (typeof s.population === 'number') ? s.population : null;
// startPopulation aus Level ableiten (1=6000, 2=6000, 3=7000) — auch
// ohne explizites Feld im Live-State.
var startPop = (s.level === 3) ? 7000 : 6000;
var sTemp = _klimaScoreTemp(temp);
var sInsel = _klimaScoreInsel(floodedPct);
var sBudget = _klimaScoreBudget(budget);
var sVolk = _klimaScorePop(population, startPop);
var gesamt = (sTemp + sInsel + sBudget + sVolk) / 4;
return {
ls: ls, idx: idx,
displayName: _liveCleanName(ls.displayName, idx+1),
module: ls.module_id,
level: s.level || 1,
year: s.year || (s.tick ? 2025 + s.tick : null),
phase: s.phase || '',
temp: temp, floodedPct: floodedPct, budget: budget, population: population, startPop: startPop,
sTemp: sTemp, sInsel: sInsel, sBudget: sBudget, sVolk: sVolk, gesamt: gesamt,
};
});
// Sortieren
var col = _liveKlimaSort.col, dir = _liveKlimaSort.dir, sgn = dir === 'asc' ? 1 : -1;
rows.sort(function(a,b){
var ka, kb;
switch (col) {
case 'name': ka = (a.displayName||'').toLowerCase(); kb = (b.displayName||'').toLowerCase(); break;
case 'level': ka = a.level; kb = b.level; break;
case 'year': ka = a.year||0; kb = b.year||0; break;
case 'temp': ka = a.sTemp; kb = b.sTemp; break;
case 'insel': ka = a.sInsel; kb = b.sInsel; break;
case 'budget': ka = a.sBudget; kb = b.sBudget; break;
case 'volk': ka = a.sVolk; kb = b.sVolk; break;
case 'gesamt':
default: ka = a.gesamt; kb = b.gesamt; break;
}
if (ka < kb) return -sgn;
if (ka > kb) return sgn;
return 0;
});
var html = '<div class="card"><div class="card-h">Klimawächter · Live-Vergleich</div>';
html += '<p style="font-size:.7rem;color:#8a8a8a;margin-bottom:.5rem">'
+ rows.length + ' aktiv · sortiert: ' + ({name:'Name', level:'Stufe', year:'Jahr', temp:'Klima', insel:'Insel', budget:'Wirtschaft', volk:'Volk', gesamt:'Gesamt'}[col] || col)
+ ' ' + (dir==='desc'?'↓':'↑') + ' · Klick auf Header sortiert · Klick auf Zeile öffnet Detail</p>';
// Sortier-Pfeil Helper
function _arr(c){ return col===c ? (dir==='desc'?' ↓':' ↑') : ''; }
function _hdr(c, label, align) {
align = align || 'left';
var active = col === c;
return '<th onclick="setKlimaSort(\''+c+'\')" style="text-align:'+align+';padding:.45rem .5rem;cursor:pointer;font-size:.7rem;text-transform:uppercase;letter-spacing:.04em;color:'+(active?'#1f4b37':'#6a6a6a')+';font-weight:'+(active?800:600)+';border-bottom:2px solid '+(active?'#4a7c8a':'rgba(0,0,0,.08)')+';white-space:nowrap;user-select:none">'+label+_arr(c)+'</th>';
}
// Top-3 / Bottom-3 sanft hinterlegen (nur bei mindestens 6 Zeilen sinnvoll)
var nRows = rows.length;
var topIdx = nRows >= 6 ? 3 : 0;
var botIdx = nRows >= 6 ? nRows - 3 : nRows;
html += '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:.78rem">';
html += '<thead><tr>'
+ _hdr('name', 'Name')
+ _hdr('level', 'Stufe', 'center')
+ _hdr('year', 'Jahr', 'right')
+ _hdr('temp', '🌡 Klima')
+ _hdr('insel', '🏖 Insel')
+ _hdr('budget', '💰 Wirtschaft')
+ _hdr('volk', '👥 Volk')
+ _hdr('gesamt', '∑ Gesamt')
+ '</tr></thead><tbody>';
rows.forEach(function(r, i){
// Zebra alle 5 Zeilen (zarter Hintergrund) + Top/Bottom-Markierung
var bg = '';
if (col === 'gesamt' && dir === 'desc') {
if (i < topIdx) bg = 'background:rgba(90,138,94,.06)';
else if (i >= botIdx) bg = 'background:rgba(192,122,107,.06)';
else if (Math.floor(i/5) % 2 === 1) bg = 'background:rgba(0,0,0,.02)';
} else {
if (Math.floor(i/5) % 2 === 1) bg = 'background:rgba(0,0,0,.02)';
}
var avatarHtml = (typeof r.ls.emoji === 'string' && r.ls.emoji.indexOf('avatar:') === 0)
? '<img src="assets/img/avatars/thumbs/avatar-'+r.ls.emoji.substring(7)+'.webp" alt="" style="width:1.4em;height:1.4em;border-radius:50%;object-fit:cover;vertical-align:middle">'
: '<span style="font-size:1rem">'+(r.ls.emoji||'🧑‍🎓')+'</span>';
var levelBadge = ({1:'🟢',2:'🟡',3:'🔴'})[r.level] || '·';
var phaseHint = r.phase === 'end' ? ' <span style="color:#c07a6b;font-size:.65rem">· beendet</span>'
: r.phase === 'citizen' ? ' <span style="color:#c9913a;font-size:.65rem">· Dialog</span>'
: '';
html += '<tr style="border-bottom:1px solid rgba(0,0,0,.04);'+bg+';cursor:pointer" '
+ 'onclick="openLiveSpectator('+r.ls.student_id+',\''+r.ls.module_id+'\')" '
+ 'onmouseover="this.style.background=\'rgba(74,124,138,.08)\'" '
+ 'onmouseout="this.style.background=\''+bg.replace('background:','')+'\'">';
html += '<td style="padding:.4rem .5rem;white-space:nowrap"><span style="display:inline-flex;align-items:center;gap:.4rem">'+avatarHtml+'<strong>'+esc(r.displayName)+'</strong>'+phaseHint+'</span></td>';
html += '<td style="text-align:center;padding:.4rem .5rem">'+levelBadge+' <span style="color:#8a8a8a;font-size:.7rem">L'+r.level+'</span></td>';
html += '<td style="text-align:right;padding:.4rem .5rem;color:#6a6a6a;font-variant-numeric:tabular-nums">'+(r.year||'—')+'</td>';
html += '<td style="padding:.4rem .5rem;min-width:140px">'+_klimaBar(r.sTemp, (r.temp!=null?r.temp.toFixed(1)+'°':'—'))+'</td>';
html += '<td style="padding:.4rem .5rem;min-width:140px">'+_klimaBar(r.sInsel, (r.floodedPct!=null?(100-r.floodedPct)+'%':'—'))+'</td>';
html += '<td style="padding:.4rem .5rem;min-width:140px">'+_klimaBar(r.sBudget,(r.budget!=null?r.budget:'—'))+'</td>';
html += '<td style="padding:.4rem .5rem;min-width:140px">'+_klimaBar(r.sVolk, (r.population && r.startPop ? Math.round(r.population/r.startPop*100)+'%' : '—'))+'</td>';
html += '<td style="padding:.4rem .5rem;min-width:140px;border-left:2px solid rgba(0,0,0,.05)">'+_klimaBar(r.gesamt, Math.round(r.gesamt))+'</td>';
html += '</tr>';
});
html += '</tbody></table></div>';
html += '<div style="margin-top:.7rem;font-size:.65rem;color:#8a8a8a;display:flex;gap:.7rem;flex-wrap:wrap">'
+ '<span><span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:#5a8a5e;vertical-align:middle;margin-right:.25rem"></span>≥ 75 — gut</span>'
+ '<span><span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:#c9913a;vertical-align:middle;margin-right:.25rem"></span>50 74 — mittel</span>'
+ '<span><span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:#c07a6b;vertical-align:middle;margin-right:.25rem"></span>25 49 — schwach</span>'
+ '<span><span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:#a04a3a;vertical-align:middle;margin-right:.25rem"></span>&lt; 25 — kritisch</span>'
+ '</div>';
html += '</div>';
return html;
}
// Generischer Vergleich für Sims ohne dedizierte Score-Definition.
// Zeigt 1) wie lange aktiv 2) Phase 3) ein Mini-Status-Balken aus Fortschritt
// (planIndex/totalOrders/tick) falls verfügbar — alles auf Score 0-100 normiert.
function _renderGenericCompareBody(rows, simId) {
if (!rows.length) {
return '<div class="card"><div class="card-h">'+esc(_compareGroupLabel(simId))+' · Vergleich</div>'
+ '<p style="font-size:.78rem;color:#8a8a8a">Niemand aktiv.</p></div>';
}
// Fortschritts-Score je Sim-Familie ableiten
function progressScore(s) {
if (!s) return 0;
// Heli: planIndex / planTotal (Wegpunkte)
if (typeof s.planIndex === 'number' && s.planTotal) return Math.min(100, s.planIndex / s.planTotal * 100);
// Busfahrt/Flug: orderIndex / totalOrders
if (typeof s.orderIndex === 'number' && s.totalOrders) return Math.min(100, (s.orderIndex+1) / s.totalOrders * 100);
// Energiemanager: setIndex/setTotal
if (typeof s.setIndex === 'number' && s.setTotal) return Math.min(100, s.setIndex / s.setTotal * 100);
// Sonnensystem: progressPct
if (typeof s.progressPct === 'number') return s.progressPct;
// EU-Werkstatt: step/stepsTotal
if (typeof s.step === 'number' && s.stepsTotal) return Math.min(100, s.step / s.stepsTotal * 100);
return 0;
}
function qualityScore(s) {
// Sims mit echten richtig/falsch-Klicks (z. B. Weltküche): Anteil richtig
if (typeof s.correctClicks === 'number' || typeof s.wrongClicks === 'number') {
var t = (s.correctClicks||0) + (s.wrongClicks||0);
return t ? (s.correctClicks||0) / t * 100 : null;
}
// Heli: planCorrect / planTotal
if (typeof s.planCorrect === 'number' && s.planTotal) return Math.min(100, s.planCorrect / s.planTotal * 100);
// Busfahrt: quizFirstTry / orderIndex
if (typeof s.quizFirstTry === 'number' && s.orderIndex) return Math.min(100, s.quizFirstTry / s.orderIndex * 100);
// Farmer: Trefferquote = Anteil gut geeigneter Anbau-Versuche
if (typeof s.hit_rate === 'number') return s.hit_rate;
return null;
}
var data = rows.map(function(ls, idx){
var s = ls.state || {};
var p = progressScore(s);
var q = qualityScore(s);
return {
ls: ls, idx: idx,
displayName: _liveCleanName(ls.displayName, idx+1),
phase: s.phase || '',
progress: p, quality: q,
// grafischer richtig/falsch-Balken, wenn Klick-Daten vorhanden
qualityHtml: _liveClicks(ls.module_id, s) ? _liveQualityFromState(ls.module_id, s) : null,
stars: (typeof s.totalStars === 'number') ? s.totalStars : null,
};
});
data.sort(function(a,b){
if (b.progress !== a.progress) return b.progress - a.progress;
return (a.displayName||'').localeCompare(b.displayName||'');
});
var html = '<div class="card"><div class="card-h">'+esc(_compareGroupLabel(simId))+' · Fortschritt &amp; Qualität</div>';
html += '<p style="font-size:.7rem;color:#8a8a8a;margin-bottom:.5rem">'+data.length+' aktiv · Klick auf Zeile öffnet Detail.</p>';
html += '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:.78rem">';
html += '<thead><tr>'
+ '<th style="text-align:left;padding:.45rem .5rem;font-size:.7rem;text-transform:uppercase;color:#6a6a6a;border-bottom:2px solid rgba(0,0,0,.08)">Name</th>'
+ '<th style="text-align:left;padding:.45rem .5rem;font-size:.7rem;text-transform:uppercase;color:#6a6a6a;border-bottom:2px solid rgba(0,0,0,.08);min-width:180px">📈 Fortschritt</th>'
+ '<th style="text-align:left;padding:.45rem .5rem;font-size:.7rem;text-transform:uppercase;color:#6a6a6a;border-bottom:2px solid rgba(0,0,0,.08);min-width:180px">🎯 Qualität</th>'
+ '<th style="text-align:right;padding:.45rem .5rem;font-size:.7rem;text-transform:uppercase;color:#6a6a6a;border-bottom:2px solid rgba(0,0,0,.08)">Gerade dabei</th>'
+ '</tr></thead><tbody>';
data.forEach(function(r, i){
var bg = (Math.floor(i/5) % 2 === 1) ? 'background:rgba(0,0,0,.02)' : '';
var avatarHtml = (typeof r.ls.emoji === 'string' && r.ls.emoji.indexOf('avatar:') === 0)
? '<img src="assets/img/avatars/thumbs/avatar-'+r.ls.emoji.substring(7)+'.webp" alt="" style="width:1.4em;height:1.4em;border-radius:50%;object-fit:cover;vertical-align:middle">'
: '<span style="font-size:1rem">'+(r.ls.emoji||'🧑‍🎓')+'</span>';
html += '<tr style="border-bottom:1px solid rgba(0,0,0,.04);'+bg+';cursor:pointer" '
+ 'onclick="openLiveSpectator('+r.ls.student_id+',\''+r.ls.module_id+'\')">';
html += '<td style="padding:.4rem .5rem;white-space:nowrap"><span style="display:inline-flex;align-items:center;gap:.4rem">'+avatarHtml+'<strong>'+esc(r.displayName)+'</strong></span></td>';
html += '<td style="padding:.4rem .5rem">'+_klimaBar(r.progress, Math.round(r.progress)+'%')+'</td>';
html += '<td style="padding:.4rem .5rem">'+(r.qualityHtml ? r.qualityHtml : (r.quality!=null ? _klimaBar(r.quality, Math.round(r.quality)+'%') : '<span style="color:#aaa">—</span>'))+'</td>';
html += '<td style="text-align:right;padding:.4rem .5rem;color:#6a6a6a;font-size:.72rem">'+esc(r.phase||'—')+'</td>';
html += '</tr>';
});
html += '</tbody></table></div></div>';
return html;
}
// Zeitfenster-Filter für die Tag-Ansicht (Stunden 024; null = Auto = erste bis
// letzte Aktivität). Wird vom Doppel-Schieberegler über der Tabelle gesetzt.
var _liveWin = { from: null, to: null };
function _pad2(n){ return ('0'+n).slice(-2); }
// Stunde/Minute in Wiener Zeit aus ms-Epoch.
function _vHM(ms){
var s = new Date(ms).toLocaleString('en-GB', {timeZone:'Europe/Vienna', hour:'2-digit', minute:'2-digit', hour12:false});
var p = s.split(':'); return { h: parseInt(p[0],10)||0, m: parseInt(p[1],10)||0 };
}
// Gestauchte Tages-Skala: Nacht 0006 und Abend 2124 je NUR EIN Block, dazwischen
// (0621) stündlich. 17 Blöcke → „Einheiten" 0..17. Stunde ⇄ Einheit.
function _hourToUnit(h){
h = Math.max(0, Math.min(24, h));
if (h <= 6) return h/6; // 0006 → Einheit 0..1
if (h <= 21) return 1 + (h-6); // 0621 → Einheit 1..16 (1 pro Stunde)
return 16 + (h-21)/3; // 2124 → Einheit 16..17
}
function _unitToHour(u){
u = Math.max(0, Math.min(17, u));
if (u <= 1) return u*6; // 0→00, 1→06
if (u <= 16) return 6 + (u-1); // 1→06 … 16→21
return 21 + (u-16)*3; // 16→21, 17→24
}
var TS_STOPS = [0,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,24]; // Einheit-Index → Stunde
function _tsInjectCss(){
if (document.getElementById('ts-css')) return;
var s = document.createElement('style'); s.id = 'ts-css';
s.textContent =
'.ts-wrap{margin:.2rem 0 .7rem}'
+'.ts-head{font-size:.74rem;color:#5a5a5a;margin-bottom:.5rem;display:flex;align-items:center;gap:.6rem;flex-wrap:wrap}'
+'.ts-head b{color:#1f4b37;font-variant-numeric:tabular-nums}'
+'.ts-reset{font-size:.66rem;padding:.2rem .5rem;border:1px solid rgba(0,0,0,.15);border-radius:6px;background:#fff;color:#4a7c8a;cursor:pointer;font-weight:600}'
+'.ts-reset:hover{background:#f0f8fa}'
+'.ts-slider{position:relative;height:34px;margin:0 10px}'
+'.ts-bg{position:absolute;left:0;right:0;top:6px;height:22px;border-radius:5px;background:#eeece3;overflow:hidden;z-index:0}'
+'.ts-night{position:absolute;top:0;height:100%;background:rgba(31,75,55,.17)}' /* Nachtzeiten dunkler */
+'.ts-div{position:absolute;top:0;height:100%;width:1px;background:rgba(0,0,0,.10)}' /* stündliche Trennlinie */
+'.ts-range{position:absolute;top:6px;height:22px;border-radius:4px;background:rgba(74,124,138,.30);border:1px solid #4a7c8a;z-index:1;box-sizing:border-box}'
+'.ts-slider input[type=range]{position:absolute;left:0;width:100%;top:0;height:34px;margin:0;background:none;pointer-events:none;-webkit-appearance:none;appearance:none;z-index:2}'
+'.ts-slider input[type=range]::-webkit-slider-thumb{pointer-events:auto;-webkit-appearance:none;appearance:none;width:22px;height:22px;border-radius:50%;background:#4a7c8a;border:3px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.35);cursor:pointer;margin-top:0}'
+'.ts-slider input[type=range]::-moz-range-thumb{pointer-events:auto;width:20px;height:20px;border-radius:50%;background:#4a7c8a;border:3px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.35);cursor:pointer}'
+'.ts-slider input[type=range]::-webkit-slider-runnable-track{background:none;height:34px}'
+'.ts-ticks{position:relative;height:12px;margin:0 10px}'
+'.ts-ticks span{position:absolute;top:0;transform:translateX(-50%);font-size:.54rem;color:#a8a8a8}';
document.head.appendChild(s);
}
// Schieberegler-Interaktion (global, für inline-Handler).
// Regler arbeitet in EINHEITEN 0..17 (gestauchte Skala). from/to = Einheit-Index.
function _tsClamp(){
var fe=document.getElementById('tsFrom'), te=document.getElementById('tsTo');
if(!fe||!te) return null;
var f=+fe.value, t=+te.value;
if(f>=t){ if(f>=17){f=16;} t=Math.min(17,f+1); if(+te.value<t) te.value=t; if(+fe.value>f) fe.value=f; }
return {f:f,t:t};
}
function _tsVisual(){
var v=_tsClamp(); if(!v) return;
var r=document.getElementById('tsRange'); if(r){ r.style.left=(v.f/17*100)+'%'; r.style.width=((v.t-v.f)/17*100)+'%'; }
var l=document.getElementById('tsLbl'); if(l) l.textContent=_pad2(_unitToHour(v.f))+':00 '+_pad2(_unitToHour(v.t))+':00';
}
function _tsCommit(){
var v=_tsClamp(); if(!v) return;
_liveWin.from=v.f; _liveWin.to=v.t; // Einheiten
renderLiveSummary();
}
function _tsReset(){ _liveWin.from=null; _liveWin.to=null; renderLiveSummary(); }
window._tsVisual=_tsVisual; window._tsCommit=_tsCommit; window._tsReset=_tsReset;
async function renderLiveSummary() {
console.log('[live-summary] renderLiveSummary() aufgerufen, date='+_liveDate);
var host = document.getElementById('tab-live');
if (!host) { console.warn('[live-summary] kein #tab-live'); return; }
host.innerHTML = _liveToggleHtml() + '<p style="color:#8a8a8a;padding:.5rem">Lade Übersicht…</p>';
var url = 'live?summary=1&class_id=' + currentClassId + '&date=' + encodeURIComponent(_liveDate);
var data;
try {
var resp = await fetch(BASE + '/api/' + url, {credentials:'same-origin'});
var rawText = await resp.text();
try {
data = JSON.parse(rawText);
} catch (parseErr) {
console.error('[live-summary] JSON-Parse-Fehler. Status='+resp.status+' Body-Anfang:', rawText.substring(0,400));
host.innerHTML = _liveToggleHtml()
+ '<div style="color:#c07a6b;background:#fdf3ee;border-left:3px solid #c07a6b;padding:.6rem .9rem;font-size:.75rem;border-radius:5px">'
+ '<b>API-Fehler:</b> Server liefert HTML statt JSON (HTTP '+resp.status+').<br>'
+ 'Konsole + Network-Tab in den DevTools zeigt den vollen Inhalt.<br>'
+ '<details style="margin-top:.4rem"><summary style="cursor:pointer">Vorschau (erste 300 Zeichen)</summary><pre style="font-size:.65rem;background:#fff;padding:.4rem;margin-top:.3rem;white-space:pre-wrap;word-break:break-all">'+esc(rawText.substring(0,300))+'</pre></details>'
+ '</div>';
return;
}
} catch (netErr) {
console.error('[live-summary] Netzwerk-Fehler:', netErr);
host.innerHTML = _liveToggleHtml() + '<p style="color:#c07a6b">Netzwerk-Fehler: '+esc(netErr.message||netErr)+'</p>';
return;
}
if (data.error) { host.innerHTML = _liveToggleHtml() + '<p style="color:#c07a6b">API: '+esc(data.error)+'</p>'; return; }
// displayName-Sanitizer: wenn der DB-Name noch der Avatar-Slug ist
// ("avatar:delfin-meer"), klingt das im UI nach Datenbruch. Liefere
// einen sauberen Anzeige-Namen oder eine Platzhalter-Nummer zurück.
function _liveCleanName(dn, fallbackIdx) {
if (!dn) return 'Schüler*in ' + fallbackIdx;
var s = String(dn).trim();
if (s === '' || /^avatar:/i.test(s)) return 'Schüler*in ' + fallbackIdx;
return s;
}
// Echte Spiel-Dauer pro Session: rowCount * Heartbeat-Intervall (≈ 4 s).
// Sicherer als (endMs - startMs), wenn die Sim kurz ist oder die heartbeats
// nicht ganz lückenlos kommen. Für sehr alte Sessions (rowCount fehlt) Fallback.
function _liveRealDurSec(sess) {
if (sess.rowCount && sess.rowCount > 1) {
var byRows = sess.rowCount * 4; // 4 s Heartbeat-Takt
var bySpan = Math.max(0, Math.round((sess.endMs - sess.startMs)/1000));
return Math.max(byRows, Math.min(bySpan, byRows * 2));
}
return Math.max(15, Math.round((sess.endMs - sess.startMs)/1000));
}
window._liveSessionMap = {};
(data.students || []).forEach(function(s, sIdx){
(s.sessions || []).forEach(function(sess){
var k = s.studentId + ':' + sess.simId + ':' + (sess.lessonLabel||'_') + ':' + sess.startMs;
window._liveSessionMap[k] = Object.assign({}, sess, {
studentDisplayName: _liveCleanName(s.displayName, sIdx+1),
studentAvatarSlug: s.avatarSlug,
studentEmoji: s.emoji,
studentId: s.studentId,
});
});
});
var students = data.students || [];
var html = _liveToggleHtml();
html += '<div class="card"><div class="card-h">Tages-Übersicht · ' + esc(data.className||'') + '</div>';
if (!students.length) {
html += '<p style="font-size:.78rem;color:#8a8a8a">Keine Aktivität an diesem Tag.</p></div>';
host.innerHTML = html;
return;
}
// Auto-Zoom: minStart / maxEnd über ALLE Sessions ermitteln. Die Achse HUGT
// die tatsächliche Aktivität — sie beginnt knapp vor dem ersten Spiel des Tages
// (nur kleines Polster), NICHT auf einer gerundeten halben Stunde davor. Sonst
// stünde vor dem ersten Balken bis zu 45 min leerer Platz.
var minMs = Infinity, maxMs = -Infinity;
students.forEach(function(s){
(s.sessions||[]).forEach(function(sess){
if (sess.startMs < minMs) minMs = sess.startMs;
var realDur = _liveRealDurSec(sess);
var endHere = sess.startMs + realDur*1000;
if (endHere > maxMs) maxMs = endHere;
});
});
var dayBase = new Date(_liveDate+'T00:00:00').getTime();
if (!isFinite(minMs)) { minMs = dayBase + 8*3600*1000; maxMs = dayBase + 14*3600*1000; }
// Auto-Grenzen (volle Stunden, Wiener Zeit): erste bis letzte Aktivität.
var _lo=_vHM(minMs), _hi=_vHM(maxMs);
var autoFromH = Math.max(0, _lo.h - 1); // 1 h VOR der ersten Aktivität
var autoToH = Math.min(24, _hi.h + ((_hi.m>0)?1:0) + 1); // 1 h NACH der letzten Aktivität
// Auto-Grenzen als EINHEITEN der gestauchten Skala (auf Block-Grenzen gerundet).
var autoFromU = Math.floor(_hourToUnit(autoFromH));
var autoToU = Math.ceil(_hourToUnit(autoToH));
if (autoToU <= autoFromU) autoToU = Math.min(17, autoFromU+1);
// Effektives Fenster in Einheiten: Regler-Auswahl ODER Auto.
var fromU = (_liveWin.from!=null) ? _liveWin.from : autoFromU;
var toU = (_liveWin.to !=null) ? _liveWin.to : autoToU;
if (toU <= fromU) toU = Math.min(17, fromU+1);
var fromH = _unitToHour(fromU), toH = _unitToHour(toU);
var winStart = dayBase + fromH*3600*1000;
var winEnd = dayBase + toH*3600*1000;
var winSpanMs = winEnd - winStart;
// Position auf der GESTAUCHTEN Skala (Nacht/Abend gestaucht, Tag stündlich) —
// nicht linear in der Zeit.
function _msToPct(ms){
var h = (ms - dayBase)/3600000;
if (toU === fromU) return 0;
return ((_hourToUnit(h) - fromU) / (toU - fromU)) * 100;
}
function _fmtHM(ms) {
return new Date(ms).toLocaleTimeString('de-AT', {timeZone:'Europe/Vienna', hour:'2-digit', minute:'2-digit'});
}
var activeCount = students.filter(function(s){return (s.sessions||[]).length>0;}).length;
html += '<p style="font-size:.7rem;color:#8a8a8a;margin-bottom:.4rem">'
+ activeCount + ' von ' + students.length + ' Schüler*innen aktiv · Zeitachse '
+ _fmtHM(winStart) + '' + _fmtHM(winEnd)
+ ' · Klick auf Balken öffnet Details.</p>';
// Zeitfenster-Schieberegler (0024 Uhr, stündlich; zwei Fader). Default = Auto
// (erste bis letzte Aktivität). onchange committet + rendert neu, oninput nur Visual.
_tsInjectCss();
var _isAuto = (_liveWin.from==null && _liveWin.to==null);
// Gestauchte Skala (17 gleich breite Blöcke): Nacht 0006 = Block 0, Tag 0621
// stündlich = Blöcke 115, Abend 2124 = Block 16. Dunklere Nacht-/Abend-Blöcke
// OHNE interne Linien; Stunden-Trennlinien nur im Tag.
var _u2p = function(u){ return (u/17*100); };
var _bg = '<span class="ts-night" style="left:0;width:'+_u2p(1)+'%"></span>' // 0006
+ '<span class="ts-night" style="left:'+_u2p(16)+'%;width:'+(_u2p(17)-_u2p(16))+'%"></span>'; // 2124
for (var _d=2; _d<16; _d++){ _bg += '<span class="ts-div" style="left:'+_u2p(_d)+'%"></span>'; } // Tag-Stundenlinien
// Beschriftung an jeder Block-Grenze: 00,06,07,…,21,24.
var _ticks=''; for (var _u=0;_u<=17;_u++){ _ticks += '<span style="left:'+_u2p(_u)+'%">'+_pad2(_unitToHour(_u))+'</span>'; }
html += '<div class="ts-wrap">'
+ '<div class="ts-head">🕒 Zeitraum <b id="tsLbl">'+_pad2(fromH)+':00 '+_pad2(toH)+':00</b>'
+ (_isAuto ? '<span style="color:#8a8a8a;font-size:.66rem">Auto: erste letzte Aktivität</span>'
: '<button class="ts-reset" onclick="_tsReset()">↺ ganzer Zeitraum</button>')
+ '</div>'
+ '<div class="ts-slider">'
+ '<div class="ts-bg">'+_bg+'</div>'
+ '<div class="ts-range" id="tsRange" style="left:'+_u2p(fromU)+'%;width:'+(_u2p(toU)-_u2p(fromU))+'%"></div>'
+ '<input type="range" id="tsFrom" min="0" max="17" step="1" value="'+fromU+'" aria-label="Von" oninput="_tsVisual()" onchange="_tsCommit()">'
+ '<input type="range" id="tsTo" min="0" max="17" step="1" value="'+toU+'" aria-label="Bis" oninput="_tsVisual()" onchange="_tsCommit()">'
+ '</div>'
+ '<div class="ts-ticks">'+_ticks+'</div>'
+ '</div>';
// Marker an den Block-Grenzen der gestauchten Skala (Stopp-Stunden 0,6,7,…,21,24),
// soweit sie im gewählten Fenster liegen. So verdichtet sich nichts in den
// gestauchten Nacht-/Abend-Blöcken.
var scaleMarks = [];
TS_STOPS.forEach(function(hh){
if (hh < fromH - 0.001 || hh > toH + 0.001) return;
scaleMarks.push({ ms: dayBase + hh*3600*1000, label: _pad2(hh)+':00', isHour: true });
});
html += '<div style="display:grid;grid-template-columns:140px 1fr;gap:.5rem;font-size:.72rem;align-items:start">';
html += '<div></div><div style="position:relative;height:20px;border-bottom:1.5px solid rgba(0,0,0,.1);margin-bottom:.3rem">';
scaleMarks.forEach(function(mk){
var pct = _msToPct(mk.ms);
var col = mk.isHour ? '#5a5a5a' : '#a8a8a8';
html += '<span style="position:absolute;left:'+pct+'%;top:0;font-size:'+(mk.isHour?'.64rem':'.58rem')+';color:'+col+';transform:translateX(-50%)">'+mk.label+'</span>';
html += '<span style="position:absolute;left:'+pct+'%;bottom:0;width:1px;height:'+(mk.isHour?7:4)+'px;background:'+(mk.isHour?'rgba(0,0,0,.25)':'rgba(0,0,0,.1)')+'"></span>';
});
html += '</div>';
students.forEach(function(s, sIdx){
var nameClean = _liveCleanName(s.displayName, sIdx+1);
var avatarHtml = s.avatarSlug
? '<img src="assets/img/avatars/thumbs/avatar-'+s.avatarSlug+'.webp" alt="" style="width:1.4em;height:1.4em;border-radius:50%;object-fit:cover;vertical-align:middle">'
: (s.emoji && s.emoji.indexOf('avatar:') === 0
? '<img src="assets/img/avatars/thumbs/avatar-'+s.emoji.substring(7)+'.webp" alt="" style="width:1.4em;height:1.4em;border-radius:50%;object-fit:cover;vertical-align:middle">'
: '<span style="font-size:.9rem">'+(s.emoji||'🧑‍🎓')+'</span>');
var sessions = (s.sessions||[]);
var inactive = sessions.length === 0;
var nameStyle = inactive ? 'color:#aaa;font-style:italic' : 'color:#1f4b37;font-weight:700';
html += '<div style="'+nameStyle+';padding:.35rem .4rem 0 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">'
+ avatarHtml + ' ' + esc(nameClean) + '</div>';
if (inactive) {
html += '<div style="height:28px;background:repeating-linear-gradient(45deg,rgba(0,0,0,.02) 0 6px,transparent 6px 12px);border-radius:4px;display:flex;align-items:center;padding:0 .6rem;font-size:.66rem;color:#aaa;font-style:italic">nicht gespielt</div>';
return;
}
html += '<div style="position:relative;height:28px;background:#fafaf6;border-radius:4px;overflow:hidden">';
// Stunden-Hilfslinien im Hintergrund
scaleMarks.forEach(function(mk){
if (!mk.isHour) return;
var pct = _msToPct(mk.ms);
html += '<span style="position:absolute;left:'+pct+'%;top:0;width:1px;height:100%;background:rgba(0,0,0,.05)"></span>';
});
sessions.forEach(function(sess){
var realDurSec = _liveRealDurSec(sess);
var endMs = sess.startMs + realDurSec * 1000;
var pctL = _msToPct(sess.startMs);
// Pausierter Lauf: der Hauptbalken (volle Höhe) reicht nur bis zum Pausenbeginn,
// danach zeichnen wir einen halbhohen „Pausen-Schwanz" bis jetzt weiter.
var isPausedRun = (sess.isLive || sess.success === 'running') && sess.paused
&& sess.activeUntilMs && sess.activeUntilMs > sess.startMs && sess.activeUntilMs < endMs;
var mainEndMs = isPausedRun ? sess.activeUntilMs : endMs;
var pctR = _msToPct(mainEndMs);
var pctW = Math.max(0.8, pctR - pctL); // mindestens 0.8 % sichtbar
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 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');
// Energiemanager-Blackout: eigener Fehlversuch (score 0). Als 🔌 kennzeichnen,
// solange die Session nicht doch noch gut abgeschlossen wurde.
var isBlackout = !!sess.blackout && sess.success !== 'good' && (score == null || score < 50);
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,.7);border-right:2px dotted #fff;'
: 'border:1.5px solid rgba(20,20,20,.25);';
// Laufende Spiele: langsam blinkender Block. Pausiert → Hauptbalken ruhig
// (der halbhohe Schwanz blinkt stattdessen).
var anim = (inProgress && !sess.paused) ? 'animation:_tagblink 1.8s ease-in-out infinite;' : '';
var stateWord = isBlackout ? 'Blackout 🔌' : (!inProgress ? (aborted ? 'abgebrochen' : 'fertig') : (sess.paused ? 'pausiert ⏸' : 'läuft noch'));
if (isBlackout) icon = '🔌';
var tip = (sess.simTitle||sess.simId) + (sess.lessonLabel?' · '+sess.lessonLabel:'')
+ ' · ' + _fmtHM(sess.startMs) + '' + _fmtHM(endMs)
+ ' · ' + _liveFmtMin(realDurSec) + ' · ' + stateWord
+ (score!=null?' · Erfolg '+score+' %':'');
// Sim-Name nur bei breiterem Balken IM Balken (mit Platz für das Symbol links);
// das App-Symbol selbst kommt separat als IMMER sichtbares Overlay (s.u.).
var inner = (pctW < 9) ? '' : '<span style="font-weight:700;padding-left:1.15rem">'+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+'%;top:5px;height:'+barH+'px;'
+ 'background:'+col+';color:#fff;border-radius:4px;'+border+anim
+ '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,.3);box-shadow:0 1px 2px rgba(0,0,0,.12)">'
+ inner + '</div>';
// App-Symbol IMMER sichtbar am Balkenstart — auch bei winzigem Balken (steht dann
// über den Rand hinaus, das ist gewollt). Schatten macht es auf Balken UND Hintergrund lesbar.
html += '<span onclick="openLiveSessionDetail(\''+sessKey+'\')" title="'+esc(tip)+'" '
+ 'style="position:absolute;left:calc('+pctL+'% + 2px);top:5px;height:'+barH+'px;line-height:'+barH+'px;'
+ 'font-size:.82rem;z-index:4;cursor:pointer;white-space:nowrap;'
+ 'text-shadow:0 0 3px rgba(0,0,0,.55),0 1px 2px rgba(0,0,0,.55)">'+icon+'</span>';
// Pausen im Verlauf: jede geloggte Pausen-Spanne bleibt als halbhohes,
// gedämpftes Segment im Balken stehen — auch nachdem die Schüler:in wieder
// weiterarbeitet. So sieht man, WANN und WIE LANGE pausiert wurde.
var spans = sess.pauseSpans || [];
var halfH = 9, halfTop = 5 + barH - halfH;
if (spans.length) {
spans.forEach(function(sp){
if (!sp || !sp.s || !sp.e) return;
var a = _msToPct(sp.s), b = _msToPct(sp.e);
var w = Math.max(0.4, b - a);
var live = (Math.abs(sp.e - endMs) < 6000); // die offene (laufende) Pause blinkt
html += '<div title="Pause '+_fmtHM(sp.s)+''+_fmtHM(sp.e)+'" '
+ 'style="position:absolute;left:'+a+'%;width:'+w+'%;top:'+halfTop+'px;height:'+halfH+'px;'
+ 'background:'+col+';opacity:.4;border-radius:2px;border:1px dashed rgba(255,255,255,.55);'
+ (live?'animation:_tagblink 2.4s ease-in-out infinite;':'')+'"></div>';
});
} else if (isPausedRun) {
// Fallback ohne geloggte Spannen (z. B. Sim-Pause via phase): aktueller Schwanz.
var tailW = Math.max(0.6, _msToPct(endMs) - pctR);
html += '<div title="pausiert seit '+_fmtHM(mainEndMs)+'" '
+ 'style="position:absolute;left:'+pctR+'%;width:'+tailW+'%;top:'+halfTop+'px;height:'+halfH+'px;'
+ 'background:'+col+';opacity:.42;border-radius:0 3px 3px 0;'
+ 'border:1px dashed rgba(255,255,255,.6);animation:_tagblink 2.4s ease-in-out infinite"></div>';
}
if (aborted) {
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>';
});
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 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="display:inline-flex;align-items:flex-end;width:16px;height:11px;vertical-align:middle;margin-right:.25rem"><span style="width:8px;height:11px;background:#aab2b8"></span><span style="width:8px;height:6px;background:#aab2b8;opacity:.42"></span></span>pausiert</span>';
html += '<span><span style="color:#b03a2a;font-weight:800">✖</span> Abbruch</span>';
html += '</div>';
html += '</div>';
host.innerHTML = html;
}
// Detail-Modal für eine Session aus dem Übersicht-Balken-Click.
// Zeigt: Avatar+Name, Sim+Mission, Dauer/Sterne, sim-spezifische Detailfelder (Heli: Wegpunkte/Start/Landung)
async function openLiveSessionDetail(sessionKey) {
var sess = (window._liveSessionMap || {})[sessionKey];
if (!sess) { console.warn('[live-detail] keine Session für '+sessionKey); return; }
showModal('modal-sessiondetail');
document.getElementById('sessdet-title').textContent =
(sess.simIcon||'📚') + ' ' + (sess.simTitle||sess.simId) + (sess.lessonLabel?' · '+sess.lessonLabel:'');
var body = document.getElementById('sessdet-body');
var avatarHtml = _avatarHtml(sess.studentAvatarSlug, sess.studentEmoji, 2);
var startTime = _vdt(sess.startMs);
var endTime = _hhmm(sess.endMs);
var dur = _liveFmtMin(sess.durationSec||0);
var stars = sess.stars;
var starsHtml = stars != null
? _pctBadge(Math.round(Math.max(0,Math.min(5,stars))/5*100))
: '<span style="color:#aaa;font-size:.74rem">noch keine Wertung</span>';
var statusLabels = {good:'<span style="color:#5a8a5e;font-weight:700">✓ gut</span>',
mid:'<span style="color:#c9913a;font-weight:700">⚠ mittel</span>',
bad:'<span style="color:#c07a6b;font-weight:700">✗ schwach</span>',
aborted:'<span style="color:#9a9a9a;font-weight:700">abgebrochen</span>',
running:'<span style="color:#e8833a;font-weight:700">⏳ läuft</span>'};
var statusHtml = statusLabels[sess.success] || sess.success;
body.innerHTML = ''
+ '<div style="display:flex;align-items:center;gap:.7rem;margin-bottom:.7rem;padding-bottom:.5rem;border-bottom:1px solid rgba(0,0,0,.06)">'
+ avatarHtml + '<div><strong style="font-size:.95rem">'+esc(sess.studentDisplayName||'')+'</strong>'
+ '<div style="font-size:.7rem;color:#8a8a8a">'+startTime+' '+endTime+' · '+dur+'</div></div></div>'
+ '<div style="display:grid;grid-template-columns:auto 1fr;gap:.4rem 1rem;font-size:.78rem">'
+ '<div style="color:#8a8a8a">Dauer:</div><div><b>'+dur+'</b></div>'
+ '<div style="color:#8a8a8a">Sterne:</div><div>'+starsHtml+(stars!=null?' <span style="color:#8a8a8a;font-size:.7rem">('+stars+'/5)</span>':'')+'</div>'
+ '<div style="color:#8a8a8a">Status:</div><div>'+statusHtml+'</div>'
+ '<div style="color:#8a8a8a">Sim:</div><div>'+esc(sess.simTitle||sess.simId)+'</div>'
+ (sess.lessonLabel?'<div style="color:#8a8a8a">Lektion:</div><div><code style="font-size:.74rem">'+esc(sess.lessonLabel)+'</code></div>':'')
+ '</div>'
+ '<div id="sessdet-loaddetails" style="margin-top:.8rem;font-size:.7rem;color:#8a8a8a">Lade vollständige Leistungs-Daten…</div>';
// Volle Detail-Daten nachladen (letzter assessments-Eintrag dieser Session)
try {
var url = 'live?session_detail=1&class_id='+currentClassId
+ '&student_id='+sess.studentId
+ '&sim_id='+encodeURIComponent(sess.simId)
+ '&start_ms='+sess.startMs
+ '&end_ms='+sess.endMs;
var resp = await fetch(BASE + '/api/' + url, {credentials:'same-origin'});
var det = await resp.json();
var slot = document.getElementById('sessdet-loaddetails');
if (!slot) return;
if (det.error) { slot.innerHTML = '<span style="color:#c07a6b">Detail-Fehler: '+esc(det.error)+'</span>'; return; }
var r = det.results || {};
var nested = r.results || {}; // heli und ähnliche nesten ihre Sim-Felder
var rows = [];
// Heli-spezifische Felder
if (sess.simId === 'heli') {
if (nested.missionTitle) rows.push(['Auftrag', nested.missionTitle]);
if (nested.planCorrect != null) rows.push(['🗺️ Treffer', nested.planCorrect + ' / ' + (nested.planTotal||'?')]);
if (nested.planPct != null) rows.push(['🎯 Genauigkeit', nested.planPct + ' %']);
if (nested.startScore != null) rows.push(['🛫 Start', Math.round((nested.startScore||0)/5*100) + ' %']);
if (nested.landingScore != null)rows.push(['🛬 Landung', Math.round((nested.landingScore||0)/5*100) + ' %']);
if (nested.planTakeover) rows.push(['Routenplanung', '<span style="color:#c07a6b">Chefpilot übernahm</span>']);
if (nested.failed) rows.push(['Mission', '<span style="color:#c07a6b">gescheitert</span>'+(nested.failReason?': '+esc(nested.failReason):'')]);
}
// Generische Felder, falls vorhanden
if (r.score != null) rows.push(['Score', r.score]);
if (rows.length) {
slot.innerHTML = '<div style="font-weight:700;color:#4a7c8a;text-transform:uppercase;font-size:.62rem;letter-spacing:.05em;margin-bottom:.4rem">Leistung im Detail</div>'
+ '<div style="display:grid;grid-template-columns:auto 1fr;gap:.3rem 1rem;font-size:.76rem">'
+ rows.map(function(p){ return '<div style="color:#8a8a8a">'+p[0]+':</div><div>'+p[1]+'</div>'; }).join('')
+ '</div>'
+ '<div style="margin-top:.7rem;font-size:.66rem;color:#aaa">'+det.assessmentCount+' Events aufgezeichnet</div>';
} else {
slot.innerHTML = '<span style="color:#8a8a8a">Keine zusätzlichen Detail-Daten gespeichert.</span>';
}
} catch (e) {
var slot = document.getElementById('sessdet-loaddetails');
if (slot) slot.innerHTML = '<span style="color:#c07a6b">Fehler beim Laden: '+esc(e.message||e)+'</span>';
}
}
window.openLiveSessionDetail = openLiveSessionDetail;
// Inline-Spectator-Modal: pollt /api/live?student_id=X alle 3s und rendert
// den State-Snapshot, den die Sim via window.GGS_LIVE_STATE meldet.
var _spectateTimer = null;
async function openLiveSpectator(studentId, moduleId) {
showModal('modal-spectate');
await renderSpectator(studentId);
if (_spectateTimer) clearInterval(_spectateTimer);
_spectateTimer = setInterval(function() { renderSpectator(studentId); }, 3000);
}
function closeSpectator() {
if (_spectateTimer) { clearInterval(_spectateTimer); _spectateTimer = null; }
hideModal('modal-spectate');
}
async function renderSpectator(studentId) {
var data = await api('live?student_id=' + studentId);
if (data.error) { document.getElementById('spec-body').innerHTML = '<p style="color:#c07a6b">'+esc(data.error)+'</p>'; return; }
var host = document.getElementById('spec-body');
if (!data.active) {
host.innerHTML = '<p style="color:#8a8a8a;font-size:.78rem">Schüler*in ist gerade nicht aktiv (letztes Lebenszeichen vor mehr als 30 Sekunden).</p>';
document.getElementById('spec-title').textContent = (data.displayName || data.username || 'Schüler*in') + ' · offline';
return;
}
var avatarHtml = (typeof data.emoji === 'string' && data.emoji.indexOf('avatar:') === 0)
? '<img src="assets/img/avatars/thumbs/avatar-'+data.emoji.substring(7)+'.webp" alt="" style="width:2.4em;height:2.4em;border-radius:50%;object-fit:cover">'
: '<span style="font-size:1.8rem">'+(data.emoji||'🧑‍🎓')+'</span>';
// „seit" doppelt: aktuelle Mission/Lektion + gesamte Sim-Session.
var sinceSimBase = data.started_at_ms || 0;
var sinceMissionBase = (data.state && data.state.missionStartedAt)
|| (data.state && data.state.levelStartedAt)
|| (data.state && data.state.scenarioStartedAt)
|| 0;
function _fmtSinceLong(sec) {
return sec < 60 ? sec+' s' : Math.floor(sec/60)+' min '+(sec%60)+' s';
}
var sinceSim = sinceSimBase ? Math.max(0, Math.round((Date.now() - sinceSimBase)/1000)) : 0;
var sinceMission = sinceMissionBase ? Math.max(0, Math.round((Date.now() - sinceMissionBase)/1000)) : 0;
var sinceStrSim = sinceSimBase ? _fmtSinceLong(sinceSim) : '—';
var sinceStrMission = sinceMissionBase ? _fmtSinceLong(sinceMission) : null;
document.getElementById('spec-title').textContent = (data.displayName || data.username) + ' · ' + data.module_id;
var sinceLine = sinceStrMission
? 'Mission seit '+sinceStrMission+' · Sim seit '+sinceStrSim
: 'seit '+sinceStrSim;
var html = '<div style="display:flex;align-items:center;gap:.7rem;margin-bottom:.8rem">'+avatarHtml+'<div><strong style="font-size:.92rem">'+esc(data.displayName||data.username)+'</strong><div style="font-size:.7rem;color:#8a8a8a">'+sinceLine+'</div></div></div>';
var s = data.state || {};
if (Object.keys(s).length === 0) {
html += '<p style="font-size:.74rem;color:#8a8a8a">Diese Sim sendet noch keinen Live-State (<code>window.GGS_LIVE_STATE</code> nicht implementiert).</p>';
} else {
// Sim-spezifische Spalten aus LIVE_PRIMARY_FIELDS bevorzugen — Fallback: alle State-Keys generisch
var fields = LIVE_PRIMARY_FIELDS[data.module_id];
if (!fields) {
fields = Object.keys(s).map(function(k){ return {k:k, label:k}; });
}
function specRow(label, val) {
if (val === undefined || val === null || val === '') return '';
return '<div style="color:#8a8a8a">'+label+'</div><div style="font-weight:700">'+val+'</div>';
}
html += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:.4rem .8rem;font-size:.78rem">';
fields.forEach(function(f) {
var v = s[f.k];
var rendered = _liveFmt(v, f.unit, f, data);
// Leerwerte (—) trotzdem zeigen, damit Lehrkraft alle vereinbarten Felder sieht
html += '<div style="color:#8a8a8a">'+esc(f.label)+'</div><div style="font-weight:700">'+rendered+'</div>';
});
html += '</div>';
// „Gewonnen?"-Zeile nur, wenn Sim ein levelWon/won-Feld liefert
if (typeof s.levelWon !== 'undefined' || typeof s.won !== 'undefined') {
var wonVal = (s.levelWon || s.won) ? '<span style="color:#5a8a5e;font-weight:700">ja</span>' : (s.screen === 'end' ? '<span style="color:#c07a6b">nein</span>' : '<span style="color:#8a8a8a">noch offen</span>');
html += '<div style="margin-top:.6rem;padding-top:.5rem;border-top:1px dashed rgba(0,0,0,.1);font-size:.78rem"><span style="color:#8a8a8a">Erfolgreich abgeschlossen: </span><strong>'+wonVal+'</strong></div>';
}
}
html += '<div style="margin-top:.8rem;font-size:.62rem;color:#8a8a8a">Aktualisiert alle 3 Sekunden · letztes Lebenszeichen: '+(data.last_seen_ms?_hhmm(data.last_seen_ms):'?')+'</div>';
host.innerHTML = html;
}
// === KLASSEN-VERWALTUNG (Tab) ===
async function loadClassesAdmin() {
var classes = await api('classes');
if (classes.error) classes = [];
var html = '<div class="card"><div class="card-h">Meine Klassen verwalten</div>';
html += '<div class="toolbar"><button class="btn" onclick="newClassFromAdmin()">+ Neue Klasse anlegen</button></div>';
if (!classes.length) {
html += '<p style="font-size:.72rem;color:#8a8a8a">Noch keine Klassen.</p>';
} else {
html += '<table class="students"><tr><th>Name</th><th>Schuljahr</th><th>Klassencode</th><th>Schüler*innen</th><th>Erstellt</th><th></th></tr>';
classes.forEach(function(c) {
var n = parseInt(c.student_count || 0);
var canDelete = n === 0;
var created = c.created_at ? new Date(c.created_at).toLocaleDateString('de-AT') : '—';
html += '<tr>'
+ '<td><strong>'+esc(c.name)+'</strong></td>'
+ '<td>'+esc(c.school_year || '')+'</td>'
+ '<td><span style="font-family:monospace;font-weight:700">'+esc(c.join_code)+'</span></td>'
+ '<td>'+(n > 0 ? '<strong>'+n+'</strong>' : '<span style="color:#8a8a8a">leer</span>')+'</td>'
+ '<td>'+created+'</td>'
+ '<td>'
+ '<button class="btn-sm" onclick="editClassAdmin('+c.id+',\''+esc(c.name).replace(/\\\'/g,"\\\\'")+'\',\''+esc(c.school_year||'').replace(/\\\'/g,"\\\\'")+'\')">Umbenennen</button> '
+ (canDelete
? '<button class="btn-sm" style="color:#c07a6b" onclick="deleteClassAdmin('+c.id+',\''+esc(c.name).replace(/\\\'/g,"\\\\'")+'\')">Löschen</button>'
: '<button class="btn-sm" disabled title="Klasse hat noch Schüler*innen — erst dort löschen" style="opacity:.4;cursor:not-allowed">Löschen</button>')
+ '</td></tr>';
});
html += '</table>';
}
html += '</div>';
document.getElementById('tab-classes').innerHTML = html;
}
async function newClassFromAdmin() {
var name = prompt('Name der neuen Klasse:');
if (!name) return;
var year = prompt('Schuljahr (z.B. 2026/27, optional):') || '';
var r = await api('classes', {action:'create', name:name, schoolYear:year});
if (r.error) { alert(r.error); return; }
await loadClasses();
loadClassesAdmin();
}
async function editClassAdmin(id, name, year) {
var newName = prompt('Neuer Klassenname:', name);
if (!newName || newName === name) {
var newYear = prompt('Schuljahr:', year || '');
if (newYear === null) return;
var r = await api('classes', {action:'update', classId:id, name:name, schoolYear:newYear});
if (r.error) { alert(r.error); return; }
} else {
var newYear2 = prompt('Schuljahr:', year || '');
if (newYear2 === null) newYear2 = year || '';
var r2 = await api('classes', {action:'update', classId:id, name:newName, schoolYear:newYear2});
if (r2.error) { alert(r2.error); return; }
}
await loadClasses();
loadClassesAdmin();
}
async function deleteClassAdmin(id, name) {
if (!confirm('Klasse „' + name + '" wirklich löschen? Das geht nur, wenn keine Schüler*innen mehr drin sind.')) return;
var r = await api('classes', {action:'delete', classId:id});
if (r.error) { alert(r.error); return; }
await loadClasses();
loadClassesAdmin();
}
// === KLASSEN-DROPDOWN ===
async function loadClasses() {
var classes = await api('classes');
if (classes.error) classes = [];
allClasses = classes;
var sel = document.getElementById('class-select');
sel.innerHTML = '';
if (classes.length === 0) {
sel.innerHTML = '<option value="">Keine Klassen</option>';
document.getElementById('class-code').textContent = '—';
return;
}
classes.forEach(function(c) {
var opt = document.createElement('option');
opt.value = c.id; opt.textContent = c.name + ' (' + c.school_year + ')';
sel.appendChild(opt);
});
// Default-Klasse: zuletzt gewählte (persistent gemerkt), sonst die ÄLTESTE.
if (!currentClassId) {
var saved = null;
try { saved = parseInt(localStorage.getItem('ggs-last-class'), 10); } catch(_) {}
if (saved && classes.some(function(c){ return c.id === saved; })) {
currentClassId = saved;
} else {
var byAge = classes.slice().sort(function(a,b){ return (a.created_at||'') < (b.created_at||'') ? -1 : 1; });
currentClassId = byAge[0].id; // älteste zuerst
}
}
sel.value = currentClassId;
currentClass = classes.find(function(c) { return c.id === currentClassId; });
document.getElementById('class-code').textContent = currentClass?.join_code || '—';
// Hash-Routing — Profil-Seite linkt mit teacher.html#students etc. zurück,
// damit die Lehrperson direkt im gewünschten Tab landet.
var validTabs = {overview:1,modules:1,students:1,live:1,results:1,licenses:1,classes:1};
var initialTab = (location.hash || '').replace('#','');
if (validTabs[initialTab]) currentTab = initialTab;
loadTab(currentTab);
if (validTabs[initialTab]) switchTab(initialTab);
checkLicenseWarning();
window.addEventListener('hashchange', function() {
var t = (location.hash || '').replace('#','');
if (validTabs[t]) switchTab(t);
});
startLiveStatusPoll();
}
function onClassChange() {
currentClassId = parseInt(document.getElementById('class-select').value);
try { localStorage.setItem('ggs-last-class', currentClassId); } catch(_) {} // „letzter Aufruf" merken
currentClass = allClasses.find(function(c) { return c.id === currentClassId; });
document.getElementById('class-code').textContent = currentClass?.join_code || '—';
loadTab(currentTab);
startLiveStatusPoll();
checkLicenseWarning();
}
// Rote Cockpit-Warnung: Schüler:innen der Klasse ohne gültige Lizenz.
async function checkLicenseWarning() {
var el = document.getElementById('license-warning');
if (!el) return;
if (!currentClassId) { el.style.display='none'; return; }
try {
var r = await api('licenses?class_id=' + currentClassId);
var n = (r && r.unlicensed) ? r.unlicensed.length : 0;
if (n > 0) {
el.innerHTML = '<div style="background:#fdece9;border-bottom:2px solid #c0392b;color:#7a2318;padding:.6rem 1rem;font-size:.8rem;display:flex;align-items:center;gap:.6rem;flex-wrap:wrap">'
+ '<span style="font-size:1.1rem">🔑</span>'
+ '<span><strong>'+n+' '+(n===1?'Schüler:in hat':'Schüler:innen haben')+' noch keine gültige Lizenz</strong> — ohne Lizenz können sie keine Simulationen nutzen.</span>'
+ '<button class="btn-sm" style="margin-left:auto" onclick="switchTab(\'licenses\')">Jetzt zuweisen →</button>'
+ '</div>';
el.style.display = 'block';
} else { el.style.display='none'; el.innerHTML=''; }
} catch(e) { el.style.display='none'; }
}
async function saveClass() {
var name = document.getElementById('mc-name').value.trim();
var year = document.getElementById('mc-year').value.trim();
if (!name) return;
var r = await api('classes', {action:'create', name:name, schoolYear:year});
hideModal('modal-class');
if (r.id) currentClassId = r.id;
loadClasses();
}
function showCode() {
var code = currentClass?.join_code;
if (!code) return;
navigator.clipboard.writeText(code).then(function() {
var el = document.getElementById('class-code');
el.textContent = 'Kopiert!';
setTimeout(function() { el.textContent = code; }, 1200);
});
}
function openTickets() { if (currentClassId) window.open(BASE + '/api/tickets?class_id=' + currentClassId, '_blank'); }
// === LIVE-TAB-INDIKATOR (klassen-weite Hintergrund-Pollung) ===
// Unabhängig vom Live-Tab-Polling — pulsiert den Live-Tab-Punkt nur, wenn
// gerade jemand aktiv ist (≥ 1 Session mit Heartbeat ≤ 30 s).
var _liveStatusTimer = null;
async function pollLiveStatus() {
if (!currentClassId) return;
try {
var sessions = await api('live?class_id=' + currentClassId);
var n = Array.isArray(sessions) ? sessions.length : 0;
var btn = document.getElementById('tab-btn-live');
var cnt = document.getElementById('live-count');
if (btn) btn.classList.toggle('live-active', n > 0);
if (cnt) cnt.textContent = n > 0 ? n : '';
} catch (_) { /* still */ }
}
function startLiveStatusPoll() {
pollLiveStatus();
if (_liveStatusTimer) clearInterval(_liveStatusTimer);
_liveStatusTimer = setInterval(pollLiveStatus, 10000);
}
// === INTERVENTIONS-ÜBERSICHT (neu, oben im Übersicht-Tab) ===
var IV_CSS = ''
+ '.iv-wrap{--ivg:#1f7a3d;--ivli:#7cb342;--ivy:#f9c33b;--ivo:#ef8b2c;--ivr:#d8453a;'
+ '--ivns:#b04a3e;--ivnm:#155e2a;--ivnw:#d98b2c;--ivngd:#6a72b0;--ivni:#8c857c;'
+ '--ivtrack:rgba(41,52,46,.13);--ivline:rgba(0,0,0,.08);--ivsoft:#f5f3ec;--ivmut:#8a8a8a;font-size:14px}'
+ '.iv-wrap *{box-sizing:border-box}'
+ '.iv-h1{font-size:1.15rem;font-weight:800;margin:0 0 14px;color:#3d4a44}'
+ '.iv-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px;margin-bottom:20px}'
+ '.iv-stat{background:#fff;border:1px solid var(--ivline);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:8px}'
+ '.iv-lab{font-size:.6rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--ivmut);display:flex;justify-content:space-between;gap:8px}'
+ '.iv-maxv{text-transform:none;letter-spacing:0}.iv-maxv b{color:#2f3a34}'
+ '.iv-gfx{height:32px;display:flex;align-items:center;gap:9px}'
+ '.iv-exp{font-size:.7rem;color:var(--ivmut)}'
+ '.iv-ebar2{flex:1;height:10px;border-radius:5px;background:var(--ivtrack);overflow:hidden}.iv-ebar2>i{display:block;height:100%;border-radius:5px;background:var(--ivli)}'
+ '.iv-bign{font-size:1.4rem;font-weight:800;letter-spacing:-.02em}'
+ '.iv-stripe{display:flex;width:100%;height:16px;border-radius:5px;overflow:hidden;border:1px solid var(--ivline)}.iv-stripe i{height:100%}'
+ '.iv-cav{display:flex;align-items:flex-end;gap:2px;height:32px;width:100%}.iv-cav i{flex:1;border-radius:2px;min-width:2px}'
+ '.iv-g{background:var(--ivg)!important}.iv-li{background:var(--ivli)!important}.iv-y{background:var(--ivy)!important}.iv-o{background:var(--ivo)!important}.iv-r{background:var(--ivr)!important}.iv-z{background:var(--ivtrack)!important}'
+ '.iv-sec{font-size:1rem;font-weight:700;margin:0 0 12px;color:#3d4a44}.iv-sec span{font-size:.78rem;font-weight:400;color:var(--ivmut);margin-left:8px}'
+ '.iv-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:14px;align-items:start;margin-bottom:8px}'
+ '.iv-card{background:#fff;border:1px solid var(--ivline);border-radius:12px;padding:12px 14px;border-top:3px solid var(--iva)}'
+ '.iv-chead{display:flex;align-items:center;gap:10px;margin-bottom:6px}'
+ '.iv-ic{width:32px;height:32px;flex:none;border-radius:8px;display:grid;place-items:center;background:color-mix(in srgb,var(--iva) 16%,transparent);color:var(--iva)}'
+ '.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-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:minmax(0,1fr) auto 22px 44px auto 12px;gap:8px;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-prow{display:grid;grid-template-columns:minmax(0,1fr) auto 22px 44px auto 12px;gap:8px;align-items:center;width:100%;max-width:100%;text-align:left;cursor:pointer;background:none;border:0;border-radius:8px;padding:7px 8px;font:inherit;color:inherit;box-sizing:border-box}'
+ '.iv-last{justify-self:end;font-size:.7rem;color:var(--ivmut);white-space:nowrap}.iv-last-old{color:var(--ivr);font-weight:700}'
+ '.iv-prow:hover{background:var(--ivsoft)}'
+ '.iv-nm{font-size:.86rem;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}'
+ '.iv-erf{display:flex;align-items:center;gap:6px;justify-self:end}.iv-ebar{width:52px;height:9px;border-radius:5px;background:var(--ivtrack);overflow:hidden;flex:none}.iv-ebar>i{display:block;height:100%;border-radius:5px}.iv-erf b{font-size:.78rem;font-weight:700;width:1.7em;text-align:right}'
+ '.iv-akt{justify-self:center}.iv-dotA{display:block;width:13px;height:13px;border-radius:50%}'
+ '.iv-hot .iv-dotA{background:var(--ivg);animation:ivblink .55s ease-in-out infinite}.iv-on .iv-dotA{background:var(--ivg);animation:ivblink 1.2s ease-in-out infinite}.iv-mid .iv-dotA{background:var(--ivy);animation:ivblink 2.2s ease-in-out infinite}.iv-off .iv-dotA{background:var(--ivr);opacity:.5}'
+ '@keyframes ivblink{0%,100%{opacity:1}50%{opacity:.28}}'
+ '.iv-vl{display:inline-flex;align-items:flex-end;gap:2px;height:18px;justify-self:center}.iv-vl i{width:4px;border-radius:1.5px;min-height:2px}'
+ '.iv-vl i.iv-cap{box-shadow:inset 0 2px 0 0 rgba(255,255,255,.7),0 -2px 0 0 var(--ivr)}'
+ '.iv-chev{width:15px;height:15px;fill:none;stroke:var(--ivmut);stroke-width:2;stroke-linecap:round;stroke-linejoin:round;opacity:.45}.iv-prow:hover .iv-chev{opacity:1}'
+ '.iv-calm{background:#fff;border:1px solid var(--ivline);border-radius:12px;padding:11px 16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:8px}.iv-calm .iv-dot{width:9px;height:9px;border-radius:50%;background:var(--ivg)}.iv-calm b{font-weight:750}.iv-calm .iv-names{font-size:.8rem;color:var(--ivmut)}'
+ '.iv-legend{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:12px 24px;background:#fff;border:1px solid var(--ivline);border-radius:12px;padding:14px 16px;margin-top:6px;font-size:.74rem;color:var(--ivmut)}.iv-legend b{color:#2f3a34}'
+ '.iv-lgdot{display:inline-block;width:11px;height:11px;border-radius:50%;vertical-align:middle;margin:0 4px}'
+ '.iv-lgrow{display:flex;align-items:center;gap:8px;margin-top:5px}'
+ '.iv-lgbar{width:56px;height:8px;border-radius:4px;background:linear-gradient(90deg,var(--ivr),var(--ivy),var(--ivg))}'
+ '.iv-lgv{display:inline-flex;align-items:flex-end;gap:2px;height:14px}.iv-lgv i{width:4px;border-radius:1px}'
+ '.iv-ov{position:fixed;inset:0;background:rgba(20,26,23,.5);display:flex;align-items:flex-start;justify-content:center;padding:4vh 20px;z-index:9999;overflow:auto}'
+ '.iv-modal{background:#fff;border-radius:14px;max-width:1120px;width:100%;box-shadow:0 20px 60px rgba(0,0,0,.3);padding:18px 24px 22px}'
+ '.iv-mh{display:flex;align-items:center;gap:10px;margin-bottom:12px}.iv-mh .em{font-size:1.6rem}.iv-mh h3{margin:0;font-size:1.1rem;font-weight:750;flex:1}.iv-mh .x{cursor:pointer;border:0;background:none;font-size:1.5rem;color:#8a8a8a;line-height:1}'
+ '.iv-sum{display:flex;gap:20px;flex-wrap:wrap;background:var(--ivsoft);border-radius:10px;padding:10px 14px;margin-bottom:14px;font-size:.8rem}.iv-sum b{font-size:1.05rem;margin-left:4px}'
+ '.iv-live{background:color-mix(in srgb,var(--ivg) 12%,transparent);border:1px solid color-mix(in srgb,var(--ivg) 30%,var(--ivline));border-radius:10px;padding:10px 14px;margin-bottom:14px;font-size:.82rem}'
+ '.iv-run{border:1px solid var(--ivline);border-radius:10px;padding:9px 12px;margin-bottom:8px}'
+ '.iv-run .rh{display:flex;align-items:center;gap:8px;font-size:.84rem;font-weight:700;margin-bottom:6px}.iv-run .rh .sc{font-weight:800}.iv-run .rh .d{margin-left:auto;font-size:.68rem;color:var(--ivmut);font-weight:400}'
+ '.ivm-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(112px,1fr));gap:7px}'
+ '.ivm{background:var(--ivsoft);border-radius:8px;padding:6px 9px;min-width:0}'
+ '.ivm-l{display:block;font-size:.58rem;color:var(--ivmut);text-transform:uppercase;letter-spacing:.03em;margin-bottom:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}'
+ '.ivm-v{font-size:.92rem;font-weight:750}.ivm-u{font-size:.66rem;font-weight:600;color:var(--ivmut)}.ivm-txt{font-size:.8rem;font-weight:700}'
+ '.ivm-stars{color:#e0a838;font-size:.95rem;letter-spacing:1px}.ivm-stars.sm{font-size:.8rem}.ivm-star-off{color:var(--ivtrack)}'
+ '.ivm-bar{display:block;height:7px;border-radius:4px;background:var(--ivtrack);overflow:hidden;margin-top:3px}.ivm-bar>i{display:block;height:100%;border-radius:4px;background:currentColor}'
+ '.ivm-g{color:var(--ivg)}.ivm-li{color:var(--ivli)}.ivm-y{color:var(--ivy)}.ivm-o{color:var(--ivo)}.ivm-r{color:var(--ivr)}'
+ '.iv-badges{display:flex;flex-wrap:wrap;gap:6px;margin-top:2px}.iv-badge{width:38px;height:38px}.iv-badge img{width:100%;height:100%;object-fit:contain}.iv-badge.locked img{filter:grayscale(.9);opacity:.4}'
+ '.iv-mav{width:38px;height:38px;border-radius:50%;object-fit:cover}'
+ '@media (prefers-reduced-motion:reduce){.iv-dotA{animation:none!important}}';
function ivInjectCss(){ if(!document.getElementById('iv-css')){ var s=document.createElement('style'); s.id='iv-css'; s.textContent=IV_CSS; document.head.appendChild(s); } }
function ivAmp(v){ return v<50?'iv-r':v<60?'iv-o':v<80?'iv-y':v<90?'iv-li':'iv-g'; } // = _pctColor-Grenzen
function ivErf(v){
if (v==null||isNaN(v)) return '<span class="iv-erf"><span class="iv-ebar"><i style="width:0"></i></span><b style="color:var(--ivmut)"></b></span>';
v=Math.round(v);
return '<span class="iv-erf"><span class="iv-ebar"><i class="'+ivAmp(v)+'" style="width:'+v+'%"></i></span><b class="num">'+v+'</b></span>';
}
function ivAkt(status){
var m={aktiv:['iv-on','arbeitet gerade'],idle:['iv-mid','Tab offen, keine Eingabe'],heute:['iv-off','heute schon fertig'],kalt:['iv-off','heute nichts'],nie:['iv-off','noch nie']};
var x=m[status]||m.kalt;
return '<span class="iv-akt '+x[0]+'" title="'+x[1]+'"><span class="iv-dotA"></span></span>';
}
function ivSpark(secArr, maxSec, cap){
maxSec = maxSec||1;
var bars = secArr.map(function(sec){
var frac = sec/maxSec; // 0..1
var h = Math.max(2, Math.round(frac*18));
var cls = sec<=0 ? 'iv-z' : (frac<.2?'iv-r':frac<.4?'iv-o':frac<.62?'iv-y':frac<.82?'iv-li':'iv-g');
var capd = (cap && sec>=cap) ? ' iv-cap' : ''; // Woche am 3-h-Deckel
var tt = capd ? ' title="≥ 3 h gedeckelt (mehr wird nicht gewertet)"' : '';
return '<i class="'+cls+capd+'" style="height:'+h+'px"'+tt+'></i>';
}).join('');
return '<span class="iv-vl">'+bars+'</span>';
}
var IV_ICON={
support:'<path d="M12 21c-4-2.6-8-5.7-8-10a4 4 0 0 1 8-1 4 4 0 0 1 8 1c0 1.9-.8 3.5-2 5"/><path d="M15 16.5h4M17 14.5v4"/>',
more:'<path d="M12 19V6"/><path d="m6 12 6-6 6 6"/><path d="M6 20h12"/>',
work:'<circle cx="12" cy="12" r="9"/><path d="m15 9-3.6 1.4L10 14l3.6-1.4L15 9Z"/>',
good:'<path d="m4 15 5-5 4 4 7-8"/><path d="M17 6h3v3"/>',
idle:'<circle cx="12" cy="12" r="9" stroke-dasharray="3 3"/><path d="M12 8v4l2 2"/>',
calm:'<circle cx="12" cy="12" r="9"/><path d="m8.5 12.5 2.5 2.5 4.5-5"/>'};
var IV_META={support:['Fördern','schwach (< 50 %) — braucht Hilfe','--ivns'],more:['Fordern','kann alles (≥ 90 %) — mehr herausfordern','--ivnm'],
work:['Begleiten','schwach, aber fleißiger als der Klassenschnitt','--ivnw'],good:['Bestätigen','zuletzt besser geworden','--ivngd'],
calm:['Läuft gut','solide unterwegs (5089 %)','--ivg'],idle:['Nicht aktiv','länger nicht mehr dran (oder noch nie)','--ivni']};
var IV_WEEK_CAP = 10800; // eine Woche zählt max. 3 h aktive Zeit (Ausreißer-Schutz)
// Rendert die 5 Triage-Karten (support/more/work/good/idle) aus fertigen Gruppen.
// Wiederverwendet für „HEUTE" und „insgesamt". todayMode passt Untertitel/Tooltips an.
function _ivTriageCards(groups, maxSec, todayMode){
var h='<div class="iv-grid">';
// 4 Handlungs-Felder (aus dem Leistungs-% + Fleiß) + „Nicht aktiv". „Bestätigen"
// entfällt — geht in „Läuft gut" auf.
['support','work','calm','more','idle'].forEach(function(key){
var rows=groups[key], m=IV_META[key];
var sub=(todayMode && key==='idle')?'heute noch nicht dran':m[1];
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>'+sub+'</p></div><span class="iv-cn num">'+rows.length+'</span></div>';
if(rows.length){
h+='<div class="iv-cols"><span></span><span title="'+(todayMode?'Ø Erfolg der heutigen Läufe':'Ø 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="'+(todayMode?'heute aktiv?':'Tage seit dem letzten Lauf')+'">Zuletzt</span><span></span></div>';
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">'+(todayMode?'Heute niemand.':'Aktuell niemand betroffen.')+'</div>';
}
h+='</div>';
});
h+='</div>';
return h;
}
// Triage NUR aus der AKTUELLEN Leistung (Empfehlung = Reaktion auf das Zuletzt-
// Gesehene, kein Allzeit-Rückblick): recentErfolg = Ø letzte 3 Läufe, daysSince =
// Tage seit letztem Lauf, recentPlays = Läufe der letzten 14 Tage, rising =
// zuletzt besser als der bisherige Schnitt.
// Triage aus dem Leistungs-% (0100) × Fleiß-im-Klassenvergleich.
// ≥90 → Fordern (kann alles) · 5089 → Läuft gut · <50 & fleißig → Begleiten ·
// <50 & nicht fleißig → Fördern. Länger als 14 Tage weg / kein Wert → Nicht aktiv.
function ivTriage(erf, daysSince, fleissig){
if (daysSince == null || daysSince > 14) return 'idle';
if (erf == null) return 'idle';
if (erf >= 90) return 'more'; // Fordern
if (erf >= 50) return 'calm'; // Läuft gut
return fleissig ? 'work' : 'support'; // Begleiten : Fördern
}
function ivZuletzt(days){
if (days == null) return '<span class="iv-last iv-last-old">nie</span>';
var t = days<=0 ? 'heute' : (days==1 ? 'gestern' : 'vor '+days+' T');
return '<span class="iv-last'+(days>14?' iv-last-old':'')+'">'+t+'</span>';
}
// Kurzer Klartext-Grund für „braucht Hilfe" in der Heute-Zeile (spiegelt _liveNeedsHelp).
function _todayHelpReason(simId, st){
st = st || {};
switch(simId){
case 'farmer':
if (st.hit_rate!=null && st.hit_rate < 30) return 'Trefferquote '+st.hit_rate+' %';
if (st.cash!=null && st.cash < 0) return 'Konto im Minus';
return 'stockt';
case 'weltkueche': return (st.mistakesInDish||0)+' Fehler im Gericht';
case 'klima': case 'klima-3d': return 'Budget im Minus';
case 'heli': return 'viele Fehlversuche';
case 'logistik': return 'Konto im Minus';
case 'eu-werkstatt': return 'viele Fehlversuche';
case 'tourismustal': case 'tourismusregion': return 'Budget/Umwelt kritisch';
}
return 'stockt · braucht evtl. Hilfe';
}
function ivBuildOverview(cockpit, weeks, studentsCount, matrix, todaySummary, today){
ivInjectCss();
var cs = (cockpit && cockpit.students) || [];
var sc = (cockpit && cockpit.statusCounts) || {};
// Verlauf-Map je Schüler. CAP gegen Ausreißer (z. B. Zwischenspeicher-Logs
// mit aufgeblähter duration): eine Woche zählt max. 3 h aktive Zeit (IV_WEEK_CAP).
var wById={}, maxSec=1;
((weeks&&weeks.students)||[]).forEach(function(w){ wById[w.studentId]=w; });
// Plays je Schüler aus Matrix
var playsById={};
((matrix&&matrix.students)||[]).forEach(function(s){ playsById[s.id]=(matrix.totals[s.id]||{}).plays||0; });
// Fleiß-Referenz (Klassenschnitt): aktive Zeit (Wochen-Buckets) + Durchgänge.
var _secTot={}, _secSum=0, _secCnt=0, _playSum=0, _playCnt=0;
cs.forEach(function(s){
var sid=s.studentId||s.id;
var w=wById[sid]; var tsec=w?w.buckets.reduce(function(a,b){return a+Math.min(IV_WEEK_CAP,b.sum||0);},0):0;
_secTot[sid]=tsec; if(tsec>0){_secSum+=tsec;_secCnt++;}
var pl=playsById[sid]||0; if(pl>0){_playSum+=pl;_playCnt++;}
});
var _avgSec=_secCnt?_secSum/_secCnt:1, _avgPlay=_playCnt?_playSum/_playCnt:1;
// „fleißig" = kombiniert (Zeit + Durchgänge) mindestens Klassenschnitt.
function _fleissig(sid, plays){
var t=(_secTot[sid]||0)/Math.max(1,_avgSec);
var p=(plays!=null?plays:(playsById[sid]||0))/Math.max(1,_avgPlay);
return (0.5*t + 0.5*p) >= 1;
}
var groups={support:[],more:[],work:[],good:[],calm:[],idle:[]}, erfSum=0,erfN=0, weekTotals=[];
cs.forEach(function(s){
var sid=s.studentId||s.id;
var recErf=(s.recentErfolg!=null)?s.recentErfolg:null; // Ø letzte 3 Läufe
var allErf=(s.benchmarkPct!=null)?s.benchmarkPct:null; // Allzeit (nur für „rising")
var days=(s.daysSince!=null)?s.daysSince:null;
var rplays=s.recentPlays||0;
var w=wById[sid], secs=w?w.buckets.map(function(b){return Math.min(IV_WEEK_CAP, b.sum||0);}):[];
while(secs.length<10) secs.unshift(0);
secs.forEach(function(v){ if(v>maxSec) maxSec=v; });
var rising=(recErf!=null && s.earlyErfolg!=null && recErf > s.earlyErfolg+10); // letzte 3 besser als frühere
if(recErf!=null){ erfSum+=recErf; erfN++; }
weekTotals.push(secs);
var g=ivTriage(recErf, days, _fleissig(sid, playsById[sid]));
var row={sid:sid, name:s.displayName, erf:recErf, status:s.status, secs:secs, days:days};
if(g) groups[g].push(row); else groups.calm.push(row);
});
var erfAvg = erfN? Math.round(erfSum/erfN):null;
var aktiv=(sc.aktiv||0), idle=(sc.idle||0), rest=Math.max(0,studentsCount-aktiv-idle);
var tot=Math.max(1,aktiv+idle+rest);
// Klassen-Verlauf = Summe je Woche
var classWeeks=[]; for(var i=0;i<10;i++){ var sum=0; weekTotals.forEach(function(a){sum+=(a[i]||0);}); classWeeks.push(sum); }
var classMax=Math.max.apply(null,classWeeks.concat([1]));
var maxLabel = classMax>=3600 ? (classMax/3600).toFixed(1)+' h' : Math.round(classMax/60)+' min';
var simTotal=0; ((matrix&&matrix.students)||[]).forEach(function(s){ simTotal+=(matrix.totals[s.id]||{}).plays||0; });
var h='<div class="iv-wrap">';
h+='<div class="iv-h1">Klassenübersicht</div>';
// „Heute in der Klasse" — Live-Zeile ganz oben: wer ist gerade dran, wer braucht JETZT Hilfe.
var tActive = cs.filter(function(s){ return s.status==='aktiv' || s.status==='idle'; });
var tHelp = tActive.filter(function(s){ return s.needsHelp; });
var tSims={}; tActive.forEach(function(s){ if(s.activeSimName) tSims[s.activeSimName]=((s.activeSimIcon?s.activeSimIcon+' ':'')+s.activeSimName); });
var tSimsStr = Object.keys(tSims).map(function(k){return tSims[k];}).join(' · ');
h+='<div style="display:flex;flex-wrap:wrap;gap:.5rem 1.1rem;align-items:center;background:#f2f7f4;border:1px solid #cfe0d4;border-left:4px solid #3a6a3e;border-radius:10px;padding:.55rem .9rem;margin:0 0 1rem">';
h+='<div style="font-size:.82rem;font-weight:800;color:#2a5a2e;white-space:nowrap"><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#3a8a3e;margin-right:.4rem;'+(tActive.length?'animation:_pulse 1.5s infinite':'opacity:.4')+'"></span>Heute: '+tActive.length+' aktiv</div>';
if(tSimsStr) h+='<div style="font-size:.74rem;color:#3a3a3a">'+esc(tSimsStr)+'</div>';
if(tHelp.length){
h+='<div style="display:flex;flex-wrap:wrap;gap:.35rem;flex:1 1 auto;justify-content:flex-end">';
tHelp.forEach(function(s){
h+='<button onclick="ivOpenStudent('+(s.studentId||s.id)+')" title="Details öffnen" style="cursor:pointer;background:#fdeae4;color:#b03a2a;border:1px solid #eabfb4;border-radius:999px;padding:.25rem .6rem;font-size:.72rem;font-weight:700;white-space:nowrap">🆘 '+esc(s.displayName)+' · '+esc((s.activeSimIcon?s.activeSimIcon+' ':'')+_todayHelpReason(s.activeSim, s.state))+'</button>';
});
h+='</div>';
} else {
h+='<div style="font-size:.74rem;color:#3a7a3e;flex:1 1 auto;text-align:right">✓ Niemand braucht gerade akut Hilfe</div>';
}
h+='</div>';
// Stat-Karten
h+='<div class="iv-stats">';
h+='<div class="iv-stat"><span class="iv-lab">Erfolg Klassenschnitt</span><div class="iv-gfx"><span class="iv-ebar2"><i class="'+(erfAvg!=null?ivAmp(erfAvg):'')+'" style="width:'+(erfAvg||0)+'%"></i></span><span class="iv-bign num" style="font-size:1.05rem">'+(erfAvg!=null?erfAvg:'')+'</span></div><span class="iv-exp">0 100, aus den letzten Läufen</span></div>';
h+='<div class="iv-stat"><span class="iv-lab">Aktuell aktiv</span><div class="iv-gfx"><span class="iv-stripe"><i class="iv-g" style="width:'+(aktiv/tot*100)+'%"></i><i class="iv-y" style="width:'+(idle/tot*100)+'%"></i><i class="iv-r" style="width:'+(rest/tot*100)+'%"></i></span></div><span class="iv-exp">'+aktiv+' aktiv · '+idle+' teilweise · '+rest+' inaktiv</span></div>';
h+='<div class="iv-stat"><span class="iv-lab">Verlauf Klassenschnitt<span class="iv-maxv">max <b class="num">'+maxLabel+'</b></span></span><div class="iv-gfx"><span class="iv-cav">'+classWeeks.map(function(sec){var frac=sec/classMax;var cls=sec<=0?'iv-z':(frac<.2?'iv-r':frac<.4?'iv-o':frac<.62?'iv-y':frac<.82?'iv-li':'iv-g');return '<i class="'+cls+'" style="height:'+Math.max(2,Math.round(frac*30))+'px"></i>';}).join('')+'</span></div><span class="iv-exp">aktive Zeit pro Woche · 10 Wochen</span></div>';
h+='<div class="iv-stat"><span class="iv-lab">Schüler:innen</span><div class="iv-gfx"><span class="iv-bign num">'+studentsCount+'</span></div><span class="iv-exp">'+aktiv+' gerade aktiv</span></div>';
h+='<div class="iv-stat"><span class="iv-lab">Simulationen</span><div class="iv-gfx"><span class="iv-bign num">'+simTotal+'</span></div><span class="iv-exp">Durchläufe insgesamt</span></div>';
h+='</div>';
// Triage
// === HEUTE-Triage: ALLE heutigen Läufe. Quelle 1 = student_results (fertige
// Läufe, zuverlässig mit student_id); Quelle 2 = Summary NUR für laufende
// Sessions (die stehen noch nicht in student_results) → kein Doppelzählen. ===
var _tScore={}, _tPlays={}, _tMin={};
var _tsrc=(today&&today.todayScores)||{};
Object.keys(_tsrc).forEach(function(sid){
var t=_tsrc[sid]; _tPlays[sid]=(_tPlays[sid]||0)+(t.plays||0);
_tScore[sid]=(_tScore[sid]||0)+(t.avgScore||0)*(t.plays||0);
if(t.minScore!=null) _tMin[sid]=Math.min(_tMin[sid]==null?100:_tMin[sid], t.minScore);
});
((todaySummary&&todaySummary.students)||[]).forEach(function(su){
(su.sessions||[]).forEach(function(x){
if(!x.startMs || !(x.isLive||x.success==='running')) return; // nur laufende
var v=(x.score!=null)?x.score:(x.stars!=null?Math.round(x.stars/5*100):null); if(v==null) return;
var sid=su.studentId;
_tPlays[sid]=(_tPlays[sid]||0)+1; _tScore[sid]=(_tScore[sid]||0)+v;
_tMin[sid]=Math.min(_tMin[sid]==null?100:_tMin[sid], v);
});
});
var todayErfById={}, todayPlaysById={}, todayMinById={};
Object.keys(_tPlays).forEach(function(sid){
todayPlaysById[sid]=_tPlays[sid];
todayErfById[sid]=Math.round(_tScore[sid]/Math.max(1,_tPlays[sid]));
todayMinById[sid]=_tMin[sid];
});
var heuteGroups={support:[],more:[],work:[],good:[],calm:[],idle:[]};
cs.forEach(function(s){
var sid=s.studentId||s.id;
var played=(todayPlaysById[sid]||0)>0;
var tErf=todayErfById[sid];
var w2=wById[sid], secs2=w2?w2.buckets.map(function(b){return Math.min(IV_WEEK_CAP, b.sum||0);}):[];
while(secs2.length<10) secs2.unshift(0);
var g2 = !played ? 'idle' : ivTriage(tErf, 0, _fleissig(sid, todayPlaysById[sid]));
var row2={sid:sid, name:s.displayName, erf:played?tErf:null, status:s.status, secs:secs2, days:played?0:(s.daysSince!=null?s.daysSince:null)};
if(g2) heuteGroups[g2].push(row2); else heuteGroups.calm.push(row2);
});
var heutePlayed=Object.keys(todayPlaysById).length;
var need=groups.support.length+groups.work.length+groups.more.length+groups.idle.length;
// 1) HEUTE (oben) — genau dieselbe Darstellung, nur auf heute gefiltert
h+='<div class="iv-sec">Empfohlene Aktivität für dich — <b style="color:#2a5a2e">HEUTE</b><span>'+heutePlayed+' heute aktiv</span></div>';
h+=_ivTriageCards(heuteGroups, maxSec, true);
// 2) insgesamt (darunter) — der bisherige Allzeit-/Zuletzt-Blick
h+='<div class="iv-sec" style="margin-top:1.5rem">Empfohlene Aktivität für dich — insgesamt<span>'+need+' von '+studentsCount+'</span></div>';
h+=_ivTriageCards(groups, maxSec, false);
// Legende
h+='<div class="iv-legend">'
+'<div style="grid-column:1/-1;font-size:.72rem;color:#8a8a8a;margin-bottom:-.2rem">Die <b>Empfehlung</b> beruht nur auf der <b>aktuellen</b> Leistung: dem Erfolg der letzten Läufe und den Tagen seit dem letzten Mal. Der Verlauf daneben ist nur Historie.</div>'
+'<div><b>Erfolg</b> Ø der <b>letzten 3 Läufe</b> (aktueller Stand), 0 100: 0 = gescheitert, 100 = „sehr gut". Alte Durchläufe zählen für die Empfehlung nicht mehr mit.'
+'<div class="iv-lgrow"><span class="iv-lgbar"></span><span>rot → grün</span></div></div>'
+'<div><b>Zuletzt</b> Tage seit dem letzten Lauf. Wer <b>länger als 14 Tage</b> nicht mehr dran war (oder noch nie), landet bei „Nachfassen" — unabhängig von früheren Werten.'
+'<div class="iv-lgrow"><span class="iv-last">heute</span><span style="color:#cbc6b8">·</span><span class="iv-last iv-last-old">vor 20 T</span></div></div>'
+'<div><b>Aktuell aktiv</b> Interaktionen der letzten Minuten, live. Blinkt bei jeder Eingabe.'
+'<div class="iv-lgrow"><span class="iv-lgdot" style="background:var(--ivg)"></span>aktiv'
+'<span class="iv-lgdot" style="background:var(--ivy)"></span>teilweise'
+'<span class="iv-lgdot" style="background:var(--ivr)"></span>inaktiv (10 Min nichts)</div></div>'
+'<div><b>Verlauf</b> Aktive Zeit pro Woche, letzte 10 Wochen — nur <b>Historie</b>, nicht Grundlage der Empfehlung. Rote Kappe = bei 3 h gedeckelt.'
+'<div class="iv-lgrow"><span class="iv-lgv"><i class="iv-r" style="height:4px"></i><i class="iv-o" style="height:6px"></i><i class="iv-y" style="height:9px"></i><i class="iv-li" style="height:11px"></i><i class="iv-g" style="height:14px"></i></span><span>wenig → viel</span></div></div>'
+'</div>';
h+='</div>';
return h;
}
// === SCHÜLER-DRILLDOWN (Klick auf Zeile) — aktuelle Simulationsparameter, cockpit-artig ===
function ivModMeta(id){
var m = (typeof GGS_SIMS!=='undefined') ? GGS_SIMS.filter(function(s){return s.id===id;})[0] : null;
return m || {icon:'📘', title:id};
}
var IV_PLABEL={final_co2:'CO₂',final_temperature:'Temperatur',final_budget:'Budget',final_flooded_pct:'Überflutet %',
final_population:'Bevölkerung',measures_bought:'Maßnahmen',events_survived:'Ereignisse',mistakesInDish:'Fehlklicks',
completedDishes:'Gerichte',totalDishes:'von',sterneGesamt:'Sterne',planPct:'Plan %',startScore:'Start',landingScore:'Landung',
planCorrect:'Plan richtig',hit_rate:'Trefferquote',year:'Jahr',crops_used:'Sorten',biodiv:'Biodiversität',economy:'Wirtschaft',
flood:'Hochwasser',food:'Ernährung',ordersDelivered:'geliefert',totalOrders:'Aufträge',ordersLate:'verspätet',finalBalance:'Bilanz',
blocksBalanced:'Blöcke ok',blocksTotal:'von',daysPlayed:'Tage',cases_solved:'Fälle gelöst',cases_total:'von',correct_first_try:'Erstversuch',
wrong_guesses:'Fehlversuche',goalsDone:'Ziele',goalsTotal:'von',satisfaction:'Zufriedenheit',nature:'Naturwert',money:'Konto',
completed:'gelöst',total:'gesamt',firstTry:'Erstversuch',wonOrders:'gewonnen',quizFirstTry:'Quiz 1. Versuch',avgTapKm:'Ø Abweichung km',
stepsTotal:'Schritte',firstTryHits:'1. Wahl',muelleimerCount:'verworfen'};
function ivPLabel(k){ return IV_PLABEL[k] || k.replace(/_/g,' '); }
function ivParams(p){
if (!p || typeof p!=='object') return '<span style="font-size:.7rem;color:var(--ivmut)">keine Parameter</span>';
var src = (p.results && typeof p.results==='object') ? p.results : p, chips=[];
Object.keys(src).forEach(function(k){
if (k==='duration_ms' || k==='score') return;
var v = src[k];
if (v==null || typeof v==='object') return;
if (typeof v==='boolean') v = v?'ja':'nein';
chips.push('<span class="iv-pchip"><span class="pk">'+esc(ivPLabel(k))+'</span><span class="pv">'+esc(String(v))+'</span></span>');
});
return chips.length ? chips.join('') : '<span style="font-size:.7rem;color:var(--ivmut)">keine Parameter</span>';
}
function ivAvatar(emoji){
if (typeof emoji==='string' && emoji.indexOf('avatar:')===0){
var slug = emoji.slice(7).toLowerCase();
return '<img class="iv-mav" src="assets/img/avatars/thumbs/avatar-'+encodeURIComponent(slug)+'.webp" alt="" '
+ 'onerror="this.onerror=null;this.src=\'assets/img/avatars/thumbs/avatar-fuchs-explorer.webp\'">';
}
return '<span class="em" style="font-size:1.6rem">'+(emoji||'🧑‍🎓')+'</span>';
}
function ivStars(n){ return _pctBar(Math.round(Math.max(0,Math.min(5,n||0))/5*100)); }
async function ivOpenStudent(sid){
ivInjectCss();
var d = await api('live?student_runs='+sid);
var old = document.getElementById('iv-ov'); if (old) old.remove();
var ov = document.createElement('div'); ov.id='iv-ov'; ov.className='iv-ov';
ov.onclick = function(e){ if (e.target===ov) ov.remove(); };
document.addEventListener('keydown', function esc0(e){ if(e.key==='Escape'){ var o=document.getElementById('iv-ov'); if(o)o.remove(); document.removeEventListener('keydown', esc0);} });
if (!d || d.error){ ov.innerHTML='<div class="iv-modal">Konnte nicht laden.</div>'; document.body.appendChild(ov); return; }
var sm = (typeof renderSimMetrics==='function') ? renderSimMetrics : function(){return '';};
var erfStars = (typeof ggsErfolgToStars==='function' && d.recentErfolg!=null) ? ggsErfolgToStars(d.recentErfolg) : null;
var h='<div class="iv-modal"><div class="iv-mh">'+ivAvatar(d.emoji)+'<h3>'+esc(d.displayName)+'</h3>'
+ '<button class="x" onclick="document.getElementById(\'iv-ov\').remove()">×</button></div>';
h+='<div class="iv-sum">'
+ '<span>Erfolg (letzte Läufe) '+(erfStars!=null?'<b>'+ivStars(erfStars)+'</b> <span class="ivm-u">'+d.recentErfolg+'/100</span>':'<b></b>')+'</span>'
+ '<span>Zuletzt aktiv <b>'+(d.daysSince==null?'nie':(d.daysSince<=0?'heute':(d.daysSince==1?'gestern':'vor '+d.daysSince+' T')))+'</b></span></div>';
if (d.live){ var lm=ivModMeta(d.live.moduleId);
h+='<div class="iv-live"><b>Arbeitet gerade:</b> '+lm.icon+' '+esc(lm.title)+'<div style="margin-top:8px">'+sm(d.live.moduleId, d.live.state)+'</div></div>'; }
// Abzeichen (aus player_progress, nur gespielte Sims)
if (typeof GGS_SIMS!=='undefined' && d.progress && d.progress.length){
var pmap={}; d.progress.forEach(function(p){ pmap[p.simId]=p; });
var anyBadge=false, bh='';
GGS_SIMS.forEach(function(sim){
var p=pmap[sim.id]; if(!p) return; anyBadge=true;
bh+='<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px"><span style="font-size:.76rem;font-weight:700;width:118px;flex:none">'+sim.icon+' '+esc(sim.title)+'</span><div class="iv-badges">';
ggsSimBadges(sim.id).forEach(function(b){ var e=b.cond(p);
bh+='<span class="iv-badge'+(e?'':' locked')+'" title="'+esc(e?b.name:'Ziel: '+b.desc)+'"><img src="'+b.icon+'" alt="'+esc(b.name)+'"></span>'; });
bh+='</div></div>';
});
if(anyBadge) h+='<div style="font-size:.66rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--ivmut);margin:2px 0 7px">Abzeichen</div>'+bh;
}
h+='<div style="font-size:.66rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--ivmut);margin:8px 0 7px">Letzte Durchläufe</div>';
if (!d.runs || !d.runs.length){ h+='<p style="font-size:.8rem;color:var(--ivmut)">Noch keine Durchläufe.</p>'; }
(d.runs||[]).forEach(function(r){
var m=ivModMeta(r.moduleId);
var dt = r.completedAt ? new Date(r.completedAt.replace(' ','T')).toLocaleDateString('de-AT',{day:'2-digit',month:'2-digit',year:'2-digit'}) : '';
var rst=(typeof ggsErfolgToStars==='function'&&r.score!=null)?ggsErfolgToStars(r.score):null;
h+='<div class="iv-run"><div class="rh">'+m.icon+' '+esc(m.title)
+ '<span class="sc" style="color:#e0a838">'+(rst!=null?ivStars(rst):(r.score!=null?Math.round(r.score):''))+'</span>'
+ '<span class="d">'+dt+' · '+Math.round((r.durationSec||0)/60)+' min</span></div>'
+ sm(r.moduleId, r.params)+'</div>';
});
h+='</div>';
ov.innerHTML=h; document.body.appendChild(ov);
}
// === ÜBERSICHT ===
// Lehrer-Übersicht: Stat-Bar + aktive Aufgaben + Top-Performer + Aktivitäts-Sparkline + Modul-Cards.
// Drei parallele API-Calls (matrix + activity-week + assignments) — alle existing.
// === Schuljahr-Datumsfilter für die Modul-Sektion (global, persistent) ===
var _modFilter = { from: null, to: null };
try {
var _mfStored = JSON.parse(localStorage.getItem('ggs-mod-filter') || 'null');
if (_mfStored && _mfStored.from && _mfStored.to) { _modFilter.from = _mfStored.from; _modFilter.to = _mfStored.to; }
} catch (e) {}
function saveModFilter() { try { localStorage.setItem('ggs-mod-filter', JSON.stringify(_modFilter)); } catch (e) {} }
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 refreshModulesSection() {
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);
}
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;
saveModFilter();
await refreshModulesSection();
}
function resetModFilter() {
_modFilter.from = ggsSchoolYearStart();
_modFilter.to = ggsTodayStr();
saveModFilter();
applyModFilter();
}
// Vom Modul-Detail-Modal aus den globalen Zeitraum ändern
function setModDate(which, val) {
if (!val) return;
_modFilter[which] = val; saveModFilter();
if (_modDetail && _modDetail.moduleId) openModuleDetail(_modDetail.moduleId);
refreshModulesSection();
}
function resetModDateModal() {
_modFilter.from = ggsSchoolYearStart();
_modFilter.to = ggsTodayStr(); saveModFilter();
if (_modDetail && _modDetail.moduleId) openModuleDetail(_modDetail.moduleId);
refreshModulesSection();
}
// Avatar-Bild oder Emoji, gegebene Pixelgröße
function ivTodayAvatar(emoji, px) {
if (typeof emoji === 'string' && emoji.indexOf('avatar:') === 0)
return '<img src="assets/img/avatars/thumbs/avatar-'+encodeURIComponent(emoji.substring(7))+'.webp" alt="" '
+ 'style="width:'+px+'px;height:'+px+'px;border-radius:50%;object-fit:cover;vertical-align:middle" '
+ 'onerror="this.onerror=null;this.src=\'assets/img/avatars/thumbs/avatar-fuchs-explorer.webp\'">';
return '<span style="font-size:'+(px*0.05)+'rem">'+(emoji||'🧑‍🎓')+'</span>';
}
// Sektion: Top Performers heute + heute neu vergebene Abzeichen
function ivBuildTodaySection(today, modById) {
var tt = (today && today.topToday) || [];
var prog = (today && today.progress) || [];
var starsHtml = function(n){ return _pctBadge(Math.round(Math.max(0,Math.min(5,n||0))/5*100)); };
// Heute neu erreichte Abzeichen: Bedingung jetzt erfüllt, vor heute nicht.
var newBadges = [];
if (typeof ggsSimBadges === 'function') {
prog.forEach(function(p) {
ggsSimBadges(p.simId).forEach(function(bd) {
var okNow = false, okBefore = false;
try { okNow = bd.cond(p.now); okBefore = bd.cond(p.before); } catch (e) {}
if (okNow && !okBefore) newBadges.push({ p: p, b: bd });
});
});
}
var html = '<div class="card" style="margin-top:1.4rem"><div class="card-h">🏆 Top Performers heute</div>';
if (!tt.length) {
html += '<p style="font-size:.74rem;color:#8a8a8a;margin:.2rem 0 .3rem">Heute wurden noch keine Durchgänge abgeschlossen.</p>';
} else {
var medals = ['🥇','🥈','🥉'];
html += '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:.6rem">';
tt.slice(0, 3).forEach(function(r, i) {
var mod = modById[r.moduleId] || { icon:'📚', name:r.moduleId };
html += '<div style="background:#f7f6f3;border-radius:10px;padding:.6rem .8rem;border-left:4px solid '+(i===0?'#e8c547':i===1?'#c0c0c0':'#cd7f32')+'">'
+ '<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.3rem">'
+ '<span style="font-size:1.5rem">'+medals[i]+'</span>'+ivTodayAvatar(r.emoji,32)
+ '<strong style="font-size:.85rem">'+esc(r.displayName)+'</strong></div>'
+ '<div style="font-size:.72rem;color:#4a4a4a">'+mod.icon+' '+esc(mod.name)+'</div>'
+ '<div style="margin-top:.2rem;color:#e0a838;font-size:.9rem;letter-spacing:1px">'+starsHtml(r.stars)
+ ' <span style="color:#8a8a8a;font-size:.66rem;letter-spacing:0">Erfolg '+r.score+'</span></div>'
+ '<button class="btn-sm" style="margin-top:.4rem;font-size:.62rem;padding:.2rem .5rem" onclick="openModuleDetail(\''+r.moduleId+'\')">Modul ansehen</button>'
+ '</div>';
});
html += '</div>';
}
if (newBadges.length) {
html += '<div style="font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:#6a6a6a;margin:1rem 0 .5rem">Neue Abzeichen heute</div>';
html += '<div style="display:flex;flex-wrap:wrap;gap:.5rem">';
newBadges.forEach(function(nb) {
var mod = modById[nb.p.simId] || { icon:'', name:nb.p.simId };
html += '<div style="display:flex;align-items:center;gap:.5rem;background:#f7f6f3;border-radius:8px;padding:.35rem .7rem .35rem .5rem">'
+ '<img src="'+nb.b.icon+'" alt="" style="width:34px;height:34px;object-fit:contain">'
+ ivTodayAvatar(nb.p.emoji, 22)
+ '<span style="font-size:.76rem"><strong>'+esc(nb.p.displayName)+'</strong> · '+esc(nb.b.name)
+ ' <span style="color:#8a8a8a">('+esc(mod.name)+')</span></span></div>';
});
html += '</div>';
}
html += '</div>';
return html;
}
async function loadOverview() {
var host = document.getElementById('tab-overview');
host.innerHTML = '<p style="font-size:.78rem;color:#8a8a8a;padding:1rem">Lade…</p>';
// 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, todaySum] = await Promise.all([
api('results?class_id=' + currentClassId + '&view=matrix'),
api('results?class_id=' + currentClassId + '&view=activity&range=week'),
api('assignments?class_id=' + currentClassId),
api('students?class_id=' + currentClassId),
api('live?class_id=' + currentClassId + '&cockpit=1'),
api('results?class_id=' + currentClassId + '&view=fleiss&range=weeks'),
api('results?class_id=' + currentClassId + '&view=today'),
api('results?class_id=' + currentClassId + '&view=mod_stats&from=' + _modFilter.from + '&to=' + _modFilter.to),
api('live?summary=1&class_id=' + currentClassId + '&date=' + ggsTodayStr()),
]);
if (!todaySum || todaySum.error) todaySum = {students:[]};
if (matrix.error) { host.innerHTML = '<p style="color:#c07a6b">'+esc(matrix.error)+'</p>'; return; }
if (activity.error) activity = {series:[], buckets:[]};
if (assignments.error) assignments = [];
if (students.error) students = [];
if (!cockpit || cockpit.error) cockpit = {students:[], statusCounts:{}};
if (!weeks || weeks.error) weeks = {students:[], globalMaxBucketSec:0};
if (!today || today.error) today = {topToday:[], progress:[]};
if (!modStats || modStats.error) modStats = {stats:{}};
// === Top 3 Performer aus Matrix-Cells ableiten ===
var topRuns = [];
matrix.students.forEach(function(s) {
Object.keys(matrix.cells[s.id] || {}).forEach(function(mid) {
var c = matrix.cells[s.id][mid];
if (c && c.plays > 0) {
topRuns.push({
studentId: s.id, displayName: s.displayName, emoji: s.emoji,
moduleId: mid, score: c.score, stars: c.stars, plays: c.plays,
});
}
});
});
topRuns.sort(function(a, b) { return b.score - a.score; });
var top = topRuns.slice(0, 3);
// Modul-Lookup für Bilder/Farben
var modById = {};
matrix.modules.forEach(function(m) { modById[m.id] = m; });
// Total-Stats
var totalPlays = 0, totalScore = 0, activeStudents = 0;
matrix.students.forEach(function(s) {
var t = matrix.totals[s.id] || {plays:0, score:0};
totalPlays += t.plays; totalScore += t.score;
if (t.plays > 0) activeStudents++;
});
var html = '';
// === NEU: Interventions-Übersicht (Erfolg · Aktiv · Verlauf + Triage) ===
html += ivBuildOverview(cockpit, weeks, students.length, matrix, todaySum, today);
// === Sektion B: Aktive Klassen-Simulationen ===
var active = (assignments || []).filter(function(a){return a.active;});
if (active.length > 0) {
html += '<div class="card" style="border-color:#e8833a;border-width:2px"><div class="card-h" style="color:#e8833a">📋 Aktive Klassen-Aufgaben</div>';
active.forEach(function(a) {
var mod = modById[a.module_id] || {icon:'📋', name:a.module_id};
var compl = (matrix.cells && Object.values(matrix.cells).filter(function(c){return c[a.module_id]?.plays > 0;}).length) || 0;
var pct = students.length > 0 ? Math.round(compl / students.length * 100) : 0;
var due = a.ends_at ? ' · bis ' + new Date(a.ends_at).toLocaleDateString('de-AT',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) : '';
html += '<div style="display:flex;align-items:center;gap:.5rem;margin:.4rem 0">'
+ '<span style="font-size:1.5rem">'+mod.icon+'</span>'
+ '<div style="flex:1">'
+ '<strong style="font-size:.82rem">'+esc(mod.name)+'</strong>'
+ '<span style="font-size:.65rem;color:#8a8a8a"> · Level '+a.level+due+'</span>'
+ '<div style="background:#f0eeea;border-radius:4px;height:6px;margin-top:.25rem;overflow:hidden"><div style="background:#e8833a;height:100%;width:'+pct+'%;transition:width .3s"></div></div>'
+ '<div style="font-size:.62rem;color:#8a8a8a;margin-top:.15rem">'+compl+'/'+students.length+' fertig</div>'
+ '</div></div>';
});
html += '</div>';
}
// === Sektion C: Top Performers heute + heute neu vergebene Abzeichen ===
html += ivBuildTodaySection(today, modById);
// === Sektion D: Wochen-Säulengrafik mit Navigation ===
html += '<div id="ov-weekchart">' + weekChartCard(activity) + '</div>';
// === Sektion E: Module — Stand der Klasse (mit Zeitfilter) ===
_ovModules = matrix.modules;
_ovStudentCount = matrix.students.length;
html += '<div id="ov-modules">' + renderModulesSection(modStats) + '</div>';
host.innerHTML = html;
}
// === MODULFREIGABE (Matrix) ===
async function loadModuleMatrix() {
var data = await api('modules?class_id=' + currentClassId + '&matrix=1');
if (data.error) { document.getElementById('tab-modules').innerHTML = '<p style="color:#c07a6b">'+esc(data.error)+'</p>'; return; }
var mods = data.modules, students = data.students;
// Aktive Aufgaben laden
var assignments = await api('assignments?class_id=' + currentClassId);
if (assignments.error) assignments = [];
var activeAssign = assignments.filter(function(a) { return a.active; });
// Aktive Aufgaben anzeigen
var html = '';
if (activeAssign.length > 0) {
html += '<div class="card" style="border-color:#e8833a;border-width:2px"><div class="card-h" style="color:#e8833a">📋 Aktive Klassen-Aufgabe</div>';
activeAssign.forEach(function(a) {
var mod = mods.find(function(m){return m.id===a.module_id;});
var img = (mod && mod.card_image) ? 'assets/img/'+mod.card_image : null;
var thumb = img
? '<img src="'+img+'" alt="" style="width:50px;height:50px;border-radius:9px;object-fit:cover;flex:none">'
: '<span style="font-size:1.6rem;width:50px;text-align:center">'+(mod?mod.icon:'📋')+'</span>';
var end = a.ends_at ? 'fällig ' + new Date(a.ends_at.replace(' ','T')).toLocaleDateString('de-AT',{weekday:'short',day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) : 'ohne Zeitlimit';
var startMs = a.starts_at ? new Date(a.starts_at.replace(' ','T')).getTime() : 0;
if (startMs && startMs > Date.now()) end = 'ab ' + new Date(startMs).toLocaleDateString('de-AT',{weekday:'short',day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) + ' · ' + end;
var modName = mod ? mod.name : a.module_id;
html += '<div style="display:flex;align-items:center;gap:.7rem;padding:.5rem 0;border-bottom:1px solid rgba(0,0,0,.05)">';
html += thumb;
html += '<div style="flex:1;min-width:0"><div style="font-size:.86rem;font-weight:800;color:#3a3a3a">'+esc(modName)+' <span style="font-weight:500;color:#8a8a8a;font-size:.72rem">· Stufe '+a.level+'</span></div>';
html += '<div style="font-size:.66rem;color:#8a8a8a;margin-top:.2rem"><span style="background:#e5f2e7;color:#2a7a3a;border:1px solid #b9dcc0;padding:.08rem .5rem;border-radius:99px;font-size:.62rem;font-weight:700">▶ Läuft</span> <span style="margin-left:.35rem">'+end+'</span></div></div>';
html += '<button class="btn" style="background:#c07a6b;font-size:.66rem;padding:.4rem .7rem;flex:none" onclick="stopAssignment('+a.id+',\''+esc(modName).replace(/\x27/g,"\\x27")+'\')">■ Beenden</button>';
html += '</div>';
});
html += '</div>';
}
// Start-Button
html += '<div style="margin-bottom:.6rem"><button class="btn" onclick="showStartSim()" style="font-size:.75rem">🚀 Klassen-Aufgabe erteilen</button></div>';
// Vorbereitung: spielbare vs. geplante Module trennen (vor Matrix nutzen)
var modsActive = mods.filter(function(m){ return m.status !== 'geplant' && m.status !== 'archiv'; });
var modsComing = mods.filter(function(m){ return m.status === 'geplant' || m.status === 'archiv'; });
// Matrix oben: Individuelle Freigabe — nur spielbare Module
html += '<div class="card"><div class="card-h">Individuelle Freigabe</div>';
if (students.length === 0) {
html += '<p style="font-size:.72rem;color:#8a8a8a">Noch keine Schüler*innen in dieser Klasse.</p>';
} else {
html += '<div class="matrix-wrap"><table class="matrix">';
html += '<tr><th class="student-col">Schüler*in</th>';
modsActive.forEach(function(m) {
var hint = (m.classMode === 'free' ? 'alle ausschalten' : 'alle einschalten');
html += '<th class="mod-th-clickable" onclick="toggleClassForModule(\''+m.id+'\',\''+m.classMode+'\')" title="Klick: '+hint+'" style="cursor:pointer">'
+ '<span class="mod-emoji">'+m.icon+'</span><span class="mod-short">'+esc(m.name)+'</span></th>';
});
html += '</tr>';
students.forEach(function(s) {
var avatarHtml = (typeof s.emoji === 'string' && s.emoji.indexOf('avatar:') === 0)
? '<img src="assets/img/avatars/thumbs/avatar-'+s.emoji.substring(7)+'.webp" alt="" style="width:1.4em;height:1.4em;border-radius:50%;object-fit:cover;vertical-align:middle">'
: '<span style="font-size:.9rem">'+s.emoji+'</span>';
html += '<tr><td class="student-cell">'+avatarHtml+' '+esc(s.displayName)+'</td>';
modsActive.forEach(function(m) {
var mode = s.modules[m.id] || 'locked';
var icon = mode === 'locked' ? '🔒' : mode === 'free' ? '🟢' : '▶';
html += '<td><span class="m-cell '+mode+'" onclick="cycleStudentMode('+s.id+',\''+m.id+'\',\''+mode+'\')">'+icon+'</span></td>';
});
html += '</tr>';
});
html += '</table></div>';
}
html += '</div>';
// Modul-Cards: spielbar zuerst, "in Vorbereitung" am Ende.
// Pro Card: Bild + Beschreibung + Modus-Toggle + 4 Aktionen.
html += '<div class="card"><div class="card-h">Module — Klassen-Default</div>';
function _renderModRow(m) {
var coming = (m.status === 'geplant' || m.status === 'archiv');
var isAssignment = m.classMode === 'teacher_started';
// Termin-Ankündigung: "ab DD.MM. verfügbar!" falls available_from in der Zukunft
var pendingDate = '';
if (m.available_from) {
var todayIso = new Date().toISOString().slice(0,10);
if (m.available_from > todayIso) {
var d = new Date(m.available_from);
pendingDate = ('0'+d.getDate()).slice(-2) + '.' + ('0'+(d.getMonth()+1)).slice(-2) + '.';
}
}
var modeIcon = coming ? '🚧' : (m.classMode === 'locked' ? '🔒' : m.classMode === 'free' ? '🟢' : (m.paused ? '⏸' : '▶'));
var modeText = coming ? (pendingDate ? 'ab ' + pendingDate + ' verfügbar!' : 'In Vorbereitung')
: (m.classMode === 'locked' ? 'Gesperrt'
: m.classMode === 'free' ? 'Frei'
: ('Auftrag · L' + (m.currentLevel || 1) + (m.paused ? ' · pausiert' : '')));
var modeBadgeCls = coming ? 'disabled' : (m.classMode || 'locked');
var modeClick = coming ? '' : 'onclick="cycleClassMode(\''+m.id+'\',\''+m.classMode+'\')"';
var cardImg = m.card_image ? '<img src="assets/img/'+m.card_image+'" alt="">' : '<span>'+(m.icon||'📚')+'</span>';
var overlayIcon = pendingDate ? '🗓' : '🚧';
var overlay = coming ? '<div class="mc-coming-overlay">'+overlayIcon+' '+modeText+'</div>' : '';
var modeBadge = '<span class="mc-mode-badge '+modeBadgeCls+'" '+modeClick+' title="'+modeText+'">'+modeIcon+' '+modeText+'</span>';
// NEU-Pill: zeigt sich wenn available_from in den letzten 30 Tagen ODER in den nächsten 14 Tagen liegt
// (= frisch live oder gerade ankommend, also ein "neu auf der Plattform"-Hinweis)
var newPill = '';
if (!coming && m.available_from) {
var nowMs = Date.now();
var afMs = new Date(m.available_from).getTime();
var diffDays = (nowMs - afMs) / 86400000;
if (diffDays >= -14 && diffDays <= 30) {
newPill = '<span style="position:absolute;top:8px;right:8px;background:#c97b5a;color:#fff;font-size:.66rem;font-weight:800;padding:3px 10px;border-radius:9999px;letter-spacing:.06em;box-shadow:0 2px 4px rgba(0,0,0,.18);z-index:5">✨ NEU</span>';
}
}
// Pause-Toggle nur sichtbar wenn Klassen-Auftrag aktiv
var pauseBtn = isAssignment
? '<button class="btn-sm" style="position:absolute;top:8px;left:8px;background:rgba(255,255,255,.95);border:1.5px solid rgba(0,0,0,.08);font-size:.66rem;padding:.18rem .42rem" onclick="event.stopPropagation();toggleAssignmentPause(\''+m.id+'\','+(m.paused?'true':'false')+')" title="'+(m.paused?'Auftrag fortsetzen':'Auftrag pausieren')+'">'+(m.paused?'▶ Weiter':'⏸ Pause')+'</button>'
: '';
var s = '<div class="mod-card '+(coming?'coming':'')+'">';
s += '<div class="mc-img" style="position:relative">'+cardImg+overlay+pauseBtn+newPill+modeBadge+'</div>';
s += '<div class="mc-body">';
s += '<div class="mc-name">'+(m.icon||'📚')+' '+esc(m.name)+'</div>';
s += '<div class="mc-desc">'+esc(m.desc||'')+'</div>';
s += '<div class="mc-actions">';
if (!coming) {
s += '<button class="mc-btn primary" onclick="cycleClassMode(\''+m.id+'\',\''+m.classMode+'\')" title="Modus wechseln">🚀 Klassen-Mode</button>';
}
// Vorlese-Modus (Audio-Info) pro Klasse erlauben/sperren — Sims mit Sprachausgabe
if (!coming && AUDIO_INFO_MODULES[m.id]) {
var aOn = m.audioInfo !== false;
s += '<button class="mc-btn" onclick="toggleAudioInfo(\''+m.id+'\','+(aOn?'true':'false')+')" title="Vorlese-Modus (gesprochene Meldungen) für die Klasse '+(aOn?'sperren':'erlauben')+'">'+(aOn?'🔊 Vorlesen an':'🔇 Vorlesen aus')+'</button>';
}
s += '<a href="modul-'+m.id+'" class="mc-btn" target="_blank" rel="noopener" title="Detail-Seite">📖 Infos</a>';
s += '<a href="glossar?module='+m.id+'" class="mc-btn" target="_blank" rel="noopener" title="Glossar gefiltert">📚 Glossar</a>';
s += '<a href="lehrplan?module='+m.id+'" class="mc-btn" target="_blank" rel="noopener" title="Lehrplan-Bezug">📋 Lehrplan</a>';
s += '</div></div></div>';
return s;
}
html += '<div class="mod-cards">';
modsActive.forEach(function(m){ html += _renderModRow(m); });
html += '</div>';
if (modsComing.length) {
html += '<div style="font-size:.74rem;font-weight:700;color:#8a8a8a;text-transform:uppercase;letter-spacing:.06em;padding:1rem .2rem .5rem;border-top:1px solid rgba(0,0,0,.06);margin-top:.5rem">In Vorbereitung</div>';
html += '<div class="mod-cards">';
modsComing.forEach(function(m){ html += _renderModRow(m); });
html += '</div>';
}
html += '</div>';
document.getElementById('tab-modules').innerHTML = html;
}
// === KLASSEN-SIMULATION ===
// ALL_MODS wird zur Laufzeit aus /api/modules?class_id=... geholt — kein Hardcode.
var ALL_MODS = [];
async function loadAllMods() {
if (ALL_MODS.length || !currentClassId) return ALL_MODS;
var r = await api('modules?class_id=' + currentClassId);
// Nur wirklich laufende Module sind als Aufgabe vergebbar (geplant/archiv raus).
if (Array.isArray(r)) ALL_MODS = r.filter(function(m){return m.status!=='archiv' && m.status!=='geplant';});
return ALL_MODS;
}
var _ssStartMode = 'now';
async function showStartSim(preselectedModuleId) {
await loadAllMods();
var sel = document.getElementById('ss-module');
sel.innerHTML = '';
ALL_MODS.forEach(function(m) { var o=document.createElement('option'); o.value=m.id; o.textContent=(m.icon||'')+' '+m.name; sel.appendChild(o); });
if (preselectedModuleId) sel.value = preselectedModuleId;
document.getElementById('ss-level').value = '1';
var now = new Date();
document.getElementById('ss-start-date').value = _ssDate(now);
document.getElementById('ss-start-time').value = '08:00';
ssStartMode('now');
ssSetDue('woche'); // Default-Frist: +1 Woche
showModal('modal-startsim');
}
function _ssPad(n){ return String(n).padStart(2,'0'); }
function _ssDate(d){ return d.getFullYear()+'-'+_ssPad(d.getMonth()+1)+'-'+_ssPad(d.getDate()); }
function ssStartMode(mode){
_ssStartMode = mode;
var now = document.getElementById('ss-start-now'), at = document.getElementById('ss-start-at');
var fields = document.getElementById('ss-start-fields');
now.className = mode==='now' ? 'btn' : 'btn btn-outline';
at.className = mode==='at' ? 'btn' : 'btn btn-outline';
fields.style.display = mode==='at' ? 'flex' : 'none';
ssUpdateSummary();
}
function ssSetDue(kind){
var dd = document.getElementById('ss-end-date'), tt = document.getElementById('ss-end-time');
if (kind === '') { dd.value = ''; ssUpdateSummary(); return; }
var d = new Date();
if (kind === 'morgen') d.setDate(d.getDate()+1);
else if (kind === 'woche') d.setDate(d.getDate()+7);
dd.value = _ssDate(d);
tt.value = '13:00';
ssUpdateSummary();
}
// Kombiniert date+time zu 'YYYY-MM-DDTHH:MM' (oder null, wenn kein Datum)
function _ssCombine(dateId, timeId){
var d = document.getElementById(dateId).value;
if (!d) return null;
var t = document.getElementById(timeId).value || '00:00';
return d + 'T' + t;
}
function ssStartValue(){ return _ssStartMode === 'now' ? null : _ssCombine('ss-start-date','ss-start-time'); }
function ssEndValue(){ return _ssCombine('ss-end-date','ss-end-time'); }
function _ssFmt(v){ return new Date(v).toLocaleDateString('de-AT',{weekday:'short',day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}); }
function ssUpdateSummary(){
var el = document.getElementById('ss-summary'); if (!el) return;
var modSel = document.getElementById('ss-module');
var modTxt = (modSel && modSel.options[modSel.selectedIndex]) ? modSel.options[modSel.selectedIndex].text : '—';
var lvl = document.getElementById('ss-level').value;
var startsAt = ssStartValue();
var endsAt = ssEndValue();
var startTxt = startsAt ? ('ab <b>'+esc(_ssFmt(startsAt))+'</b>') : '▶ sofort';
var endTxt = endsAt ? ('fällig <b>'+esc(_ssFmt(endsAt))+'</b>') : 'ohne Frist';
el.innerHTML = '<b>'+esc(modTxt)+'</b> · Stufe '+lvl+' · '+startTxt+' · '+endTxt;
}
async function startSimulation() {
var moduleId = document.getElementById('ss-module').value;
var level = parseInt(document.getElementById('ss-level').value);
var startsAt = ssStartValue();
var endsAt = ssEndValue();
// Aufgabe ist ab Vergabe sofort startbar (keine Tempo-/Freigabe-Sperre mehr).
var rm = await api('modules', {action:'set_mode', classId:currentClassId, moduleId:moduleId, mode:'teacher_started', currentLevel:level, dueDate:endsAt, paused:false});
if (rm && rm.error) { alert(rm.error); return; }
var r = await api('assignments', {action:'create', classId:currentClassId, moduleId:moduleId, level:level, startsAt:startsAt, endsAt:endsAt});
if (r.error) { alert(r.error); return; }
hideModal('modal-startsim');
loadModuleMatrix();
}
var _stopAssignId = null;
function stopAssignment(id, name) {
_stopAssignId = id;
document.getElementById('stopassign-name').textContent = name || 'diese Aufgabe';
showModal('modal-stopassign');
}
async function doStopAssignment() {
if (!_stopAssignId) return;
await api('assignments', {action:'stop', assignmentId:_stopAssignId});
_stopAssignId = null;
hideModal('modal-stopassign');
loadModuleMatrix();
}
function cycleClassMode(moduleId, current) {
var next = current === 'locked' ? 'free' : current === 'free' ? 'teacher_started' : 'locked';
// Wechsel auf teacher_started → Auftrags-Modal öffnen (Level + Pause + Zeitlimit
// bewusst gesetzt, statt blindem Toggle).
if (next === 'teacher_started') { showStartSim(moduleId); return; }
api('modules', {action:'set_mode', classId:currentClassId, moduleId:moduleId, mode:next}).then(loadModuleMatrix);
}
async function toggleAssignmentPause(moduleId, paused) {
await api('modules', {action: paused ? 'resume' : 'pause', classId:currentClassId, moduleId:moduleId});
loadModuleMatrix();
}
// Sims mit Vorlese-Modus (gesprochene Info-Meldungen) — Lehrer-Schalter pro Klasse
var AUDIO_INFO_MODULES = { tourismustal: true };
async function toggleAudioInfo(moduleId, current) {
await api('modules', {action:'set_audio_info', classId:currentClassId, moduleId:moduleId, enabled: !current});
loadModuleMatrix();
}
// Klick auf Modul-Spalte in Matrix: toggelt alle Schueler dieser Klasse fuer
// das Modul. Setzt Klassen-Default auf locked oder free und loescht alle
// individuellen Overrides — Schueler erben den Default.
async function toggleClassForModule(moduleId, classMode) {
var next = classMode === 'free' ? 'locked' : 'free';
await api('modules', {action:'set_mode', classId:currentClassId, moduleId:moduleId, mode:next});
await api('modules', {action:'reset_overrides', classId:currentClassId, moduleId:moduleId});
loadModuleMatrix();
}
function cycleStudentMode(studentId, moduleId, current) {
var next = current === 'locked' ? 'free' : current === 'free' ? 'teacher_started' : 'locked';
api('modules', {action:'set_student_mode', classId:currentClassId, studentId:studentId, moduleId:moduleId, mode:next}).then(loadModuleMatrix);
}
// === SCHÜLER ===
async function loadStudents() {
var students = await api('students?class_id=' + currentClassId);
if (students.error) students = [];
// Lizenzen laden für Anzeige
var licData = await api('licenses?class_id=' + currentClassId);
var assignedMap = {}, unlic = {};
if (licData.assigned) licData.assigned.forEach(function(l) { assignedMap[l.student_id] = l; });
if (licData.unlicensed) licData.unlicensed.forEach(function(u) { unlic[u.id] = 1; });
// Count ohne GÜLTIGE Lizenz
var withoutLicense = students.filter(function(s) { return unlic[s.id]; }).length;
var html = '<div class="card"><div class="card-h">Klasse — ' + esc(currentClass?.name || '') + '</div>';
html += '<div class="toolbar">';
html += '<button class="btn" onclick="showModal(\'modal-student\')">+ Einzeln erstellen</button>';
html += '<button class="btn btn-outline" onclick="showModal(\'modal-batch\')">+ Mehrere erstellen</button>';
html += '<button class="btn btn-outline" onclick="showModal(\'modal-csv\')">📄 CSV importieren</button>';
html += '</div>';
// Bulk-Toolbar (hidden until selection)
html += '<div id="bulk-bar" class="hidden" style="display:none;align-items:center;gap:.4rem;padding:.4rem .6rem;background:#dae8ec;border-radius:6px;margin-bottom:.5rem;font-size:.7rem">';
html += '<strong id="bulk-count">0</strong> ausgewählt';
html += '<button class="btn" style="font-size:.65rem;padding:.25rem .6rem;margin-left:.5rem" onclick="bulkAssignLicense()">🔑 Lizenz zuweisen</button>';
html += '<button class="btn btn-outline" style="font-size:.65rem;padding:.25rem .6rem" onclick="bulkSelectNone()">Auswahl aufheben</button>';
html += '</div>';
if (students.length > 0) {
html += '<table class="students"><tr><th style="width:28px"><input type="checkbox" onchange="bulkToggleAll(this)"></th><th></th><th>Benutzer</th><th>Name</th><th title="PIN anzeigen — klick auf Schlüssel">🔑 PIN</th><th>Lizenz</th><th title="Leichte Sprache">📖</th><th>Letzter Login</th><th>Ø</th><th></th></tr>';
students.forEach(function(s) {
var avg = s.total_score?.avg_score ? Math.round(parseFloat(s.total_score.avg_score)) : '—';
var last = s.last_login ? new Date(s.last_login).toLocaleDateString('de-AT') : '—';
var emoji = s.emoji_avatar || '🧑‍🎓';
if (typeof emoji === 'string' && emoji.indexOf('avatar:') === 0) {
emoji = '<img src="assets/img/avatars/thumbs/avatar-' + emoji.substring(7) + '.webp" alt="" style="width:1.4em;height:1.4em;border-radius:50%;object-fit:cover;vertical-align:middle">';
}
var name = s.display_name || s.username;
if (s.first_name || s.last_name) name = [s.first_name, s.last_name].filter(Boolean).join(' ');
var la = assignedMap[s.id];
var hasValid = !unlic[s.id];
var licHtml = hasValid
? '<span style="color:#2e7d32;font-weight:800;cursor:default" title="'+(la ? ('Lizenz '+la.code+' · gültig bis '+fmtLicDate(la.valid_until)) : 'gültige Lizenz')+'">✓</span>'
: '<span style="color:#c0392b;font-weight:700;font-size:.66rem;cursor:default" title="Keine gültige Lizenz">fehlt</span>';
var easyChecked = s.easy_language ? ' checked' : '';
var pinHtml;
if (s.password_plaintext) {
pinHtml = '<span class="pin-cell" id="pin-'+s.id+'">'
+ '<button class="btn-sm" onclick="togglePin('+s.id+')" title="PIN anzeigen/verbergen">🔑</button>'
+ '<span class="pin-val" data-pin="'+esc(s.password_plaintext)+'" style="display:none;font-family:monospace;font-weight:700;margin-left:.3rem">'+esc(s.password_plaintext)+'</span></span>';
} else {
pinHtml = '<span style="font-size:.6rem;color:#8a8a8a" title="Passwort wurde von der/dem Schüler*in selbst gesetzt — Lehrkraft kann zurücksetzen">selbst gesetzt</span>';
}
html += '<tr>';
html += '<td><input type="checkbox" class="bulk-cb" value="'+s.id+'" onchange="bulkUpdate()"'+(hasValid?' disabled title="Hat bereits eine gültige Lizenz"':'')+'></td>';
html += '<td style="font-size:1.1rem;text-align:center">'+emoji+'</td>';
html += '<td><strong>'+esc(s.username)+'</strong></td>';
html += '<td>'+esc(name)+'</td>';
html += '<td>'+pinHtml+'</td>';
html += '<td>'+licHtml+'</td>';
html += '<td style="text-align:center"><input type="checkbox"'+easyChecked+' onchange="toggleEasyLanguage('+s.id+', this.checked)" title="Leichte Sprache für diese*n Schüler*in aktivieren"></td>';
html += '<td>'+last+'</td><td>'+avg+'</td>';
html += '<td>'
+ '<button class="btn-sm" onclick="editStudent('+s.id+',\''+esc(s.username).replace(/'/g,"\\'")+'\',\''+esc(name).replace(/'/g,"\\'")+'\')">Bearbeiten</button> '
+ '<button class="btn-sm" onclick="resetPin('+s.id+',\''+esc(s.username).replace(/'/g,"\\'")+'\')" title="Neue PIN vergeben">🔄 PIN</button> '
+ '<button class="btn-sm" style="color:#c07a6b" onclick="deleteStudent('+s.id+')">Löschen</button></td></tr>';
});
html += '</table>';
html += '<div style="margin-top:.6rem;display:flex;gap:.4rem;flex-wrap:wrap">';
html += '<button class="btn btn-outline" style="font-size:.65rem" onclick="window.open(BASE+\'/api/tickets?class_id=\'+currentClassId, \'_blank\')">🎫 Tickets drucken (alle)</button>';
html += '<button class="btn btn-outline" style="font-size:.65rem" onclick="exportStudentsCsv()">📥 CSV exportieren</button>';
html += '<button class="btn btn-outline" style="font-size:.65rem;color:#c07a6b" onclick="resetSelectedPins()">🔄 PIN für Auswahl neu vergeben</button>';
html += '</div>';
if (withoutLicense > 0) {
html += '<div style="margin-top:.5rem"><button class="btn btn-outline" style="font-size:.65rem" onclick="bulkSelectWithout()">Alle ohne Lizenz auswählen ('+withoutLicense+')</button></div>';
}
} else {
html += '<p style="font-size:.72rem;color:#8a8a8a">Noch keine Schüler*innen in dieser Klasse.</p>';
}
html += '</div>';
document.getElementById('tab-students').innerHTML = html;
}
async function saveStudent() {
var user = document.getElementById('ms-user').value.trim();
var pass = document.getElementById('ms-pass').value;
if (!user || !pass) { alert('Benutzername und Passwort sind Pflichtfelder.'); return; }
var r = await api('students', {
action:'create', classId:currentClassId, username:user, password:pass,
displayName: document.getElementById('ms-name').value.trim(),
firstName: document.getElementById('ms-first').value.trim(),
lastName: document.getElementById('ms-last').value.trim(),
email: document.getElementById('ms-email').value.trim()
});
if (r.error) { alert(r.error); return; }
if (document.getElementById('ms-license').checked && r.id) {
await api('licenses', {action:'auto_assign', studentId:r.id}).catch(function(){});
}
hideModal('modal-student');
['ms-user','ms-pass','ms-name','ms-first','ms-last','ms-email'].forEach(function(id) { document.getElementById(id).value = ''; });
loadStudents(); loadLicenses();
}
// CSV-Import: parse Header + Zeilen, sende rows-Array an Backend
async function importCsv() {
var resultDiv = document.getElementById('csv-result');
resultDiv.innerHTML = '';
var text = document.getElementById('csv-text').value;
var file = document.getElementById('csv-file').files[0];
if (file && !text) {
text = await file.text();
}
if (!text || !text.trim()) { resultDiv.innerHTML = '<span style="color:#c00">Bitte CSV-Datei wählen oder Text einfügen.</span>'; return; }
// Einfacher CSV-Parser: Komma- oder Strichpunkt-getrennt.
// Excel-Hint "sep=;" am Anfang wird übersprungen.
var lines = text.split(/\r?\n/).filter(function(l){return l.trim();});
if (lines[0] && /^sep=.$/i.test(lines[0].trim())) lines.shift();
if (lines.length < 2) { resultDiv.innerHTML = '<span style="color:#c00">Mindestens Header + 1 Datenzeile.</span>'; return; }
var sep = lines[0].indexOf(';') > -1 && lines[0].indexOf(',') === -1 ? ';' : ',';
var headers = lines[0].split(sep).map(function(h){return h.trim().toLowerCase();});
var rows = [];
for (var i = 1; i < lines.length; i++) {
var cols = lines[i].split(sep);
var row = {};
headers.forEach(function(h, idx) { row[h] = (cols[idx] || '').trim(); });
rows.push(row);
}
var r = await api('students', {action:'create_csv', classId:currentClassId, rows:rows});
if (r.error) { resultDiv.innerHTML = '<span style="color:#c00">' + r.error + '</span>'; return; }
var created = r.created || [];
var skipped = r.skipped || [];
var html = '<p style="color:#5a8a5e;font-weight:600">✓ ' + created.length + ' Schüler*innen angelegt'
+ (skipped.length ? ', ' + skipped.length + ' übersprungen' : '') + '</p>';
if (created.length) {
html += '<details open><summary style="font-weight:600;cursor:pointer">Zugangsdaten (zum Drucken)</summary>';
html += '<table style="font-size:.65rem;border-collapse:collapse;width:100%"><tr><th>Username</th><th>Passwort</th><th>Anzeigename</th></tr>';
created.forEach(function(s) {
html += '<tr><td>' + esc(s.username) + '</td><td><b>' + esc(s.password) + '</b></td><td>' + esc(s.displayName) + '</td></tr>';
});
html += '</table></details>';
}
if (skipped.length) {
html += '<details><summary style="cursor:pointer;color:#a37800">' + skipped.length + ' übersprungene Zeilen</summary><ul>';
skipped.forEach(function(s) { html += '<li>' + (s.row.username || '?') + ': ' + s.reason + '</li>'; });
html += '</ul></details>';
}
resultDiv.innerHTML = html;
// Lizenzen ggf. zuweisen + Liste neu laden
if (document.getElementById('csv-license').checked && created.length) {
for (var j = 0; j < created.length; j++) {
try { await api('licenses', {action:'assign', studentId:created[j].id}); } catch(e) {}
}
}
loadStudents();
}
// Reset-PIN aus dem Bearbeiten-Modal: holt neue PIN, schreibt sie ins Feld
async function resetPinFromModal() {
var id = parseInt(document.getElementById('me-id').value);
if (!id) return;
if (!confirm('Neue PIN vergeben? Die alte PIN funktioniert dann nicht mehr.')) return;
var r = await api('students', {action:'reset_password', studentId:id});
if (r.error) { alert(r.error); return; }
document.getElementById('me-pass').value = r.password;
alert('Neue PIN: ' + r.password + '\n(Sie ist bereits gespeichert.)');
loadStudents();
}
// CSV-Export der Schüler-Liste (mit PIN, falls vorhanden)
async function exportStudentsCsv() {
var students = await api('students?class_id=' + currentClassId);
if (students.error) { alert('Fehler beim Laden: ' + students.error); return; }
if (!Array.isArray(students) || !students.length) { alert('Keine Schüler*innen.'); return; }
function csvCell(v) {
if (v === null || v === undefined) return '';
var s = String(v);
// Excel mag Anführungszeichen rund um Felder mit Sonderzeichen
if (/[";\n\r]/.test(s)) s = '"' + s.replace(/"/g, '""') + '"';
return s;
}
var SEP = ';'; // deutsches Excel erwartet Semikolon (Komma = Dezimaltrenner)
var header = ['username','display_name','first_name','last_name','email','easy','pin'];
var lines = [
'sep=' + SEP, // Excel-Hint: Trennzeichen explizit
header.join(SEP)
];
students.forEach(function(s) {
var row = [
csvCell(s.username),
csvCell(s.display_name || ''),
csvCell(s.first_name || ''),
csvCell(s.last_name || ''),
csvCell(s.email || ''),
s.easy_language ? '1' : '0',
csvCell(s.password_plaintext || '')
];
lines.push(row.join(SEP));
});
// BOM voran für UTF-8-Erkennung in Excel + Windows-Zeilenenden
var csv = '' + lines.join('\r\n') + '\r\n';
var blob = new Blob([csv], {type: 'text/csv;charset=utf-8'});
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
var fname = 'schueler_' + (currentClass?.name || 'klasse').replace(/[^a-z0-9_-]/gi,'_') + '_' + new Date().toISOString().slice(0,10) + '.csv';
a.href = url; a.download = fname;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// PIN anzeigen/verbergen pro Schüler-Zeile
function togglePin(studentId) {
var box = document.getElementById('pin-' + studentId);
if (!box) return;
var v = box.querySelector('.pin-val');
v.style.display = v.style.display === 'none' ? 'inline' : 'none';
}
// Einzel-PIN-Reset → bestätigen, dann neue PIN setzen + im Modal anzeigen
async function resetPin(studentId, username) {
if (!confirm('Neue PIN für „' + username + '" vergeben? Die alte PIN funktioniert dann nicht mehr.')) return;
var r = await api('students', {action:'reset_password', studentId:studentId});
if (r.error) { alert(r.error); return; }
alert('Neue PIN für ' + username + ': ' + r.password);
loadStudents();
}
// Bulk-PIN-Reset für die per Checkbox ausgewählten Schüler*innen
async function resetSelectedPins() {
var ids = Array.prototype.slice.call(document.querySelectorAll('.bulk-cb:checked')).map(function(c){return parseInt(c.value);});
if (!ids.length) { alert('Bitte zuerst Schüler*innen mit den Checkboxen auswählen.'); return; }
if (!confirm('Neue PINs für ' + ids.length + ' Schüler*innen vergeben? Die alten PINs funktionieren dann nicht mehr.')) return;
var r = await api('students', {action:'reset_class_passwords', classId:currentClassId, studentIds:ids});
if (r.error) { alert(r.error); return; }
// Liste in Druck-Modal zeigen
var html = '<h3>Neue PINs — bitte drucken oder notieren</h3>';
html += '<table style="font-size:.7rem;width:100%;border-collapse:collapse;margin-top:.5rem">';
html += '<tr><th style="text-align:left;border-bottom:1px solid #ddd;padding:.3rem">Benutzer</th><th style="text-align:left;border-bottom:1px solid #ddd;padding:.3rem">Anzeigename</th><th style="text-align:left;border-bottom:1px solid #ddd;padding:.3rem">Neue PIN</th></tr>';
r.reset.forEach(function(s) {
html += '<tr><td style="padding:.3rem">'+esc(s.username)+'</td><td style="padding:.3rem">'+esc(s.displayName)+'</td><td style="padding:.3rem;font-family:monospace;font-weight:700">'+esc(s.password)+'</td></tr>';
});
html += '</table>';
html += '<div style="margin-top:.8rem;display:flex;gap:.4rem">';
html += '<button class="btn" onclick="window.open(BASE+\'/api/tickets?class_id=\'+currentClassId,\'_blank\')">🎫 Tickets-PDF drucken</button>';
html += '<button class="btn btn-outline" onclick="hideModal(\'modal-pins\')">Schließen</button>';
html += '</div>';
document.getElementById('pins-result').innerHTML = html;
showModal('modal-pins');
loadStudents();
}
async function batchCreate() {
var prefix = document.getElementById('mb-prefix').value.trim() || 'schueler';
var count = parseInt(document.getElementById('mb-count').value) || 25;
var r = await api('students', {action:'create_batch', classId:currentClassId, prefix:prefix, count:count});
if (r.error) { alert(r.error); return; }
var html = '<strong>Erstellt!</strong><br><table style="font-size:.6rem;width:100%"><tr><th>Benutzer</th><th>Passwort</th></tr>';
r.created.forEach(function(s) { html += '<tr><td>'+s.username+'</td><td><code style="background:#dae8ec;padding:1px 4px;border-radius:3px">'+s.password+'</code></td></tr>'; });
html += '</table><br><button class="btn" onclick="printBatchTickets()">🎫 Tickets drucken</button>';
document.getElementById('batch-result').innerHTML = html;
window._lastBatch = r.created;
loadStudents();
}
function printBatchTickets() {
if (!window._lastBatch) return;
var cls = currentClass, w = window.open('','_blank');
var html = '<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Zugangskarten</title><style>@page{size:A4;margin:10mm}*{margin:0;padding:0;box-sizing:border-box}body{font-family:Inter,system-ui,sans-serif}.tickets{display:grid;grid-template-columns:repeat(3,1fr);gap:8mm;padding:5mm}.ticket{border:2px solid #4a7c8a;border-radius:10px;padding:10px;page-break-inside:avoid}.ticket-top{display:flex;align-items:center;gap:8px;margin-bottom:6px;border-bottom:1px dashed #dae8ec;padding-bottom:6px}.ticket-emoji{font-size:1.8rem}.ticket-brand{font-size:.6rem;font-weight:800;color:#4a7c8a}.ticket-row{display:flex;gap:4px;margin-bottom:4px;font-size:.7rem}.ticket-label{color:#8a8a8a;font-weight:500;min-width:80px}.ticket-val{font-weight:700;font-family:monospace}.ticket-url{font-size:.55rem;color:#4a7c8a;text-align:center;margin-top:6px;font-weight:600}.no-print{text-align:center;padding:1rem}.no-print button{padding:.5rem 1.5rem;background:#4a7c8a;color:#fff;border:none;border-radius:8px;font-weight:700;cursor:pointer}@media print{.no-print{display:none}}</style></head><body><div class="no-print"><button onclick="window.print()">Drucken / PDF</button></div><div class="tickets">';
window._lastBatch.forEach(function(s) {
html += '<div class="ticket"><div class="ticket-top"><span class="ticket-emoji">🧑‍🎓</span><div><div class="ticket-brand">GeoGraSim</div><div style="font-size:.55rem;color:#8a8a8a">Klasse '+(cls?.name||'')+'</div></div></div>';
html += '<div class="ticket-row"><span class="ticket-label">Klassencode:</span><span class="ticket-val">'+(cls?.join_code||'')+'</span></div>';
html += '<div class="ticket-row"><span class="ticket-label">Benutzer:</span><span class="ticket-val">'+s.username+'</span></div>';
html += '<div class="ticket-row"><span class="ticket-label">Passwort:</span><span class="ticket-val">'+s.password+'</span></div>';
html += '<div class="ticket-url">geograsim.at</div></div>';
});
html += '</div></body></html>';
w.document.write(html); w.document.close();
}
function editStudent(id, username, displayName) {
document.getElementById('me-id').value = id;
document.getElementById('me-user').value = username;
document.getElementById('me-name').value = displayName;
document.getElementById('me-pass').value = '';
showModal('modal-edit');
}
async function updateStudent() {
var id = parseInt(document.getElementById('me-id').value);
var name = document.getElementById('me-name').value.trim();
var pass = document.getElementById('me-pass').value;
var data = {action:'update', studentId:id};
if (name) data.displayName = name;
if (pass) data.newPassword = pass;
var r = await api('students', data);
if (r.error) { alert(r.error); return; }
hideModal('modal-edit');
loadStudents();
}
async function deleteStudent(id) {
if (!confirm('Schüler*in wirklich löschen?')) return;
await api('students', {action:'delete', studentId:id});
loadStudents();
}
async function toggleEasyLanguage(studentId, enabled) {
await api('students', {action:'update', studentId:studentId, easyLanguage: enabled});
// Kein Reload nötig — Checkbox-Zustand wird direkt am Element reflektiert
}
// === ERGEBNISSE ===
// === ERGEBNISSE — drei Sektionen: Matrix, Aktivitäts-Heatmap, Modul-Drill-Down ===
// Performance: zwei parallele API-Calls pro Tab-Wechsel, clientseitige Sortierung,
// Modul-Detail lazy bei Klick.
// Ziel-Container der Gesamt-Auswertung. Default #tab-results (Legacy), im
// neuen Menü rendert die „Gesamt"-Unteransicht in #results-embed (in #tab-live).
var _resultsHost = 'tab-results';
var _resultsState = {
matrix: null, // {students, modules, cells, totals}
activity: null, // {range, buckets, series}
fleiss: null, // {students, modules, maxSec, range, rangeLabel}
range: 'week',
sortKey: 'name', // für Matrix
sortDir: 'asc',
fleissModule: 'ALL', // Modul-Filter für Fleiß-Sektion (ALL oder sim-id)
fleissRange: 'week',// Zeitraum für Fleiß-Sektion
fleissScale: 'lin', // 'lin' oder 'log'
};
async function loadResults() {
var host = document.getElementById(_resultsHost) || document.getElementById('tab-results');
host.innerHTML = '<p style="font-size:.78rem;color:#8a8a8a;padding:1rem">Lade Ergebnisse…</p>';
// Drei parallele API-Calls: Fleiß (NEU oben) + Matrix + Activity (alt)
var [fleiss, matrix, activity] = await Promise.all([
api('results?class_id=' + currentClassId + '&view=fleiss&range=' + _resultsState.fleissRange + '&module_id=' + encodeURIComponent(_resultsState.fleissModule)),
api('results?class_id=' + currentClassId + '&view=matrix'),
api('results?class_id=' + currentClassId + '&view=activity&range=' + _resultsState.range + '&groupBy=' + (_resultsState.groupBy || 'module')),
]);
if (matrix.error) { host.innerHTML = '<p style="color:#c07a6b">'+esc(matrix.error)+'</p>'; return; }
if (activity.error) { host.innerHTML = '<p style="color:#c07a6b">'+esc(activity.error)+'</p>'; return; }
// fleiss-Error nicht fatal — view ist neu, alte Backends antworten ohne Daten
_resultsState.fleiss = (fleiss && !fleiss.error) ? fleiss : null;
_resultsState.matrix = matrix;
_resultsState.activity = activity;
renderResults();
}
function renderResults() {
var host = document.getElementById(_resultsHost) || document.getElementById('tab-results');
host.innerHTML = ''
+ '<div id="res-fleiss"></div>'
+ '<div id="res-matrix"></div>'
+ '<div id="res-activity"></div>'
+ '<div id="res-modules"></div>';
renderFleiss();
renderMatrix();
renderActivity();
renderModuleCards();
}
// ---------- Sektion 0 (NEU oben): Fleiß pro Schüler:in ----------
// Layout: Matrix mit Zeilen=Schüler:innen, Spalten=Zeitbuckets (MoSo
// bei Woche, 24 Stunden bei Tag etc.). Pro Zelle eine vertikale Säule;
// die Höhe entspricht der aktiven Zeit, die Farbe dem Modul (gestapelt
// wenn am gleichen Tag mehrere Module gespielt wurden). Klassenweite
// Skalierung: höchste Säule der Klasse = 100 % Säulenhöhe.
function _fleissFmtTime(sec) {
if (!sec || sec < 1) return '—';
if (sec < 60) return Math.round(sec) + ' s';
var m = Math.round(sec / 60);
if (m < 60) return m + ' min';
var h = Math.floor(m / 60); var mm = m % 60;
return h + ' h ' + (mm < 10 ? '0' : '') + mm;
}
function _fleissBar(value, max, scale) {
if (!max || max <= 0) return 0;
if (scale === 'log') {
return Math.max(0, Math.min(100, Math.log10(Math.max(1, value) + 1) / Math.log10(max + 1) * 100));
}
return Math.max(0, Math.min(100, value / max * 100));
}
function setFleissModule(modId) {
_resultsState.fleissModule = modId;
reloadFleiss();
}
function setFleissRange(r) {
_resultsState.fleissRange = r;
reloadFleiss();
}
function toggleFleissScale() {
_resultsState.fleissScale = (_resultsState.fleissScale === 'lin') ? 'log' : 'lin';
renderFleiss();
}
async function reloadFleiss() {
var f = await api('results?class_id=' + currentClassId + '&view=fleiss&range=' + _resultsState.fleissRange + '&module_id=' + encodeURIComponent(_resultsState.fleissModule));
if (f && !f.error) { _resultsState.fleiss = f; renderFleiss(); }
}
window.setFleissModule = setFleissModule;
window.setFleissRange = setFleissRange;
window.toggleFleissScale = toggleFleissScale;
function renderFleiss() {
var host = document.getElementById('res-fleiss');
if (!host) return;
var f = _resultsState.fleiss;
if (!f) {
host.innerHTML = '<div class="card"><div class="card-h">Fleiß pro Schüler*in</div><p style="font-size:.78rem;color:#8a8a8a">Backend liefert noch keine Fleiß-Daten.</p></div>';
return;
}
var scale = _resultsState.fleissScale || 'lin';
// Filter-Bar: Modul-Dropdown, Zeitraum-Buttons, Skala-Toggle
var rangeBtns = ['day','week','month','year'].map(function(r) {
var label = {day:'Tag',week:'Woche',month:'Monat',year:'Jahr'}[r];
var cls = r === _resultsState.fleissRange ? 'btn' : 'btn btn-outline';
return '<button class="'+cls+'" style="font-size:.7rem;padding:.3rem .7rem" onclick="setFleissRange(\''+r+'\')">'+label+'</button>';
}).join('');
// Modul-Optionen aus Matrix (vollständig) + ALL
var modOpts = '<option value="ALL"' + (_resultsState.fleissModule === 'ALL' ? ' selected' : '') + '>Alle Module</option>';
if (_resultsState.matrix && _resultsState.matrix.modules) {
_resultsState.matrix.modules.forEach(function(m) {
var sel = (m.id === _resultsState.fleissModule) ? ' selected' : '';
modOpts += '<option value="'+esc(m.id)+'"'+sel+'>'+esc((m.icon||'') + ' ' + m.name)+'</option>';
});
}
var scaleBtn = '<button class="btn btn-outline" style="font-size:.7rem;padding:.3rem .7rem" onclick="toggleFleissScale()" title="Linear oder logarithmisch — Log staucht Ausreißer">' + (scale === 'log' ? '○ linear · ● log' : '● linear · ○ log') + '</button>';
var html = '<div class="card">';
html += '<div class="card-h" style="display:flex;justify-content:space-between;align-items:center;gap:.6rem;flex-wrap:wrap">';
html += '<span>📊 Fleiß pro Schüler*in</span>';
html += '<span style="font-size:.66rem;color:#8a8a8a;font-weight:400">Pro Spalte ein Zeitabschnitt · Säulenhöhe = aktive Zeit · Farben = Modul</span>';
html += '</div>';
html += '<div style="display:flex;gap:.4rem;margin-bottom:.7rem;flex-wrap:wrap;align-items:center">';
html += '<label style="font-size:.7rem;color:#6a6a6a">Modul:</label>';
html += '<select onchange="setFleissModule(this.value)" style="font-size:.7rem;padding:.28rem .5rem;border:1px solid rgba(0,0,0,.15);border-radius:6px">'+modOpts+'</select>';
html += '<div style="width:1px;height:1.4rem;background:rgba(0,0,0,.12);margin:0 .2rem"></div>';
html += '<div style="display:flex;gap:.25rem">'+rangeBtns+'</div>';
html += '<div style="width:1px;height:1.4rem;background:rgba(0,0,0,.12);margin:0 .2rem"></div>';
html += scaleBtn;
html += '</div>';
if (!f.students || !f.students.length || !f.buckets || !f.buckets.length) {
html += '<p style="font-size:.72rem;color:#8a8a8a;padding:.4rem 0">Keine Schüler*innen in der Klasse.</p></div>';
host.innerHTML = html;
return;
}
// Modul-Farben-Lookup
var modColor = {};
var modName = {};
var modIcon = {};
f.modules.forEach(function(m) { modColor[m.id] = m.color; modName[m.id] = m.name; modIcon[m.id] = m.icon; });
// Zusammenfassung
var totalSec = 0, withActivity = 0;
f.students.forEach(function(s) { if (s.totalSec > 0) withActivity++; totalSec += s.totalSec; });
var avgSec = withActivity > 0 ? Math.round(totalSec / withActivity) : 0;
html += '<p style="font-size:.7rem;color:#6a6a6a;margin:-.2rem 0 .6rem">';
html += '<b>'+f.rangeLabel+'</b> · '+withActivity+'/'+f.students.length+' aktiv · Klassensumme '+_fleissFmtTime(totalSec)+' · ø pro aktive Person '+_fleissFmtTime(avgSec);
if (f.globalMaxBucketSec > 0) html += ' · höchste Säule = '+_fleissFmtTime(f.globalMaxBucketSec);
html += '</p>';
// Tabellen-Layout: Avatar+Name | Säulen-Grid | Total
var bucketCount = f.buckets.length;
var maxBarHeight = 38; // px — max Säulenhöhe pro Zelle
var sumMax = f.globalMaxBucketSec || 1;
// Spalten-Header
html += '<div style="overflow-x:auto"><div style="min-width:480px">';
html += '<div style="display:grid;grid-template-columns:170px repeat('+bucketCount+',minmax(22px,1fr)) auto;gap:.25rem;align-items:end;padding:0 0 .25rem;border-bottom:1px solid rgba(0,0,0,.06)">';
html += '<span></span>';
f.buckets.forEach(function(b) {
html += '<span style="font-size:.62rem;text-align:center;color:#6a6a6a;font-weight:600">'+esc(b)+'</span>';
});
html += '<span style="font-size:.62rem;color:#6a6a6a;font-weight:600;text-align:right">Σ</span>';
html += '</div>';
// Zeilen
f.students.forEach(function(s) {
var avatarHtml = (typeof s.emoji === 'string' && s.emoji.indexOf('avatar:') === 0)
? '<img src="assets/img/avatars/thumbs/avatar-'+s.emoji.substring(7)+'.webp" alt="" style="width:1.4em;height:1.4em;border-radius:50%;object-fit:cover">'
: '<span style="font-size:1.05rem">'+(s.emoji || '🧑‍🎓')+'</span>';
var nameCell = '<span style="display:inline-flex;align-items:center;gap:.4rem"><span>'+avatarHtml+'</span><b style="font-size:.78rem">'+esc(s.displayName)+'</b></span>';
html += '<div style="display:grid;grid-template-columns:170px repeat('+bucketCount+',minmax(22px,1fr)) auto;gap:.25rem;align-items:end;padding:.18rem 0;border-bottom:1px solid rgba(0,0,0,.03)">';
html += '<div style="align-self:center">'+nameCell+'</div>';
// Bucket-Säulen
s.buckets.forEach(function(b, idx) {
if (!b || !b.sum) {
// Leere Zelle: zarte gestrichelte Linie als Baseline
html += '<div style="height:'+maxBarHeight+'px;display:flex;align-items:flex-end;justify-content:center"><span style="display:block;width:60%;height:1px;border-top:1px dashed rgba(0,0,0,.10)"></span></div>';
return;
}
var hPct = _fleissBar(b.sum, sumMax, scale); // 0 100 %
var hPx = Math.max(2, Math.round(hPct / 100 * maxBarHeight));
// Tooltip: Datum/Bucket + Modul-Aufteilung
var titleLines = [esc(f.buckets[idx]) + ' · ' + _fleissFmtTime(b.sum)];
Object.keys(b.byModule || {}).forEach(function(modId) {
var nm = modName[modId] || modId;
titleLines.push(' ' + (modIcon[modId] || '') + ' ' + nm + ': ' + _fleissFmtTime(b.byModule[modId]));
});
var title = titleLines.join('\n');
// Säule: Stack von Modul-Segmenten von unten nach oben.
// Wir zeichnen Segmente proportional zur Modulzeit (bezogen auf b.sum, NICHT auf sumMax —
// sonst stimmt die Stapelung nicht, wenn die Säule kürzer ist).
var stackHtml = '';
var moduleEntries = Object.entries(b.byModule || {}).sort(function(a,b){ return b[1]-a[1]; });
moduleEntries.forEach(function(entry) {
var modId = entry[0]; var sec = entry[1];
var segPct = (sec / b.sum) * 100;
var col = modColor[modId] || '#888';
stackHtml += '<div style="background:'+col+';height:'+segPct.toFixed(1)+'%;min-height:1px"></div>';
});
// Laufende Session in diesem Bucket → Säule blinkt + roter Rahmen.
var liveB = b.live ? 'animation:_tagblink 1.7s ease-in-out infinite;box-shadow:0 0 0 1.5px #c0392b;' : 'box-shadow:0 0 0 1px rgba(0,0,0,.05);';
var cellTitle = b.live ? (title + '\n▶ läuft gerade') : title;
html += '<div style="height:'+maxBarHeight+'px;display:flex;align-items:flex-end;justify-content:center" title="'+cellTitle+'">';
html += '<div style="width:65%;height:'+hPx+'px;display:flex;flex-direction:column-reverse;border-radius:2px;overflow:hidden;'+liveB+'">' + stackHtml + '</div>';
html += '</div>';
});
// Total
var totalCell = '<span style="color:#3a3a3a;font-size:.72rem;font-variant-numeric:tabular-nums;text-align:right;align-self:center">' +
(s.totalSec > 0 ? '<b>'+_fleissFmtTime(s.totalSec)+'</b>' : '<span style="color:#aaa;font-style:italic">—</span>') +
'</span>';
html += totalCell;
html += '</div>';
});
html += '</div></div>'; // overflow-x + min-width
// Legende — nur Module mit Aktivität
if (f.modules && f.modules.length) {
html += '<div style="display:flex;flex-wrap:wrap;gap:.5rem .9rem;margin-top:.7rem;padding-top:.5rem;border-top:1px solid rgba(0,0,0,.06)">';
html += '<span style="font-size:.66rem;color:#6a6a6a;font-weight:600">Module:</span>';
f.modules.slice().sort(function(a,b){ return b.totalSec - a.totalSec; }).forEach(function(m) {
html += '<span style="display:inline-flex;align-items:center;gap:.3rem;font-size:.7rem;color:#3a3a3a">';
html += '<span style="display:inline-block;width:11px;height:11px;background:'+m.color+';border-radius:2px;box-shadow:0 0 0 1px rgba(0,0,0,.08)"></span>';
html += esc((m.icon||'') + ' ' + m.name);
html += ' <span style="color:#8a8a8a;font-variant-numeric:tabular-nums">('+_fleissFmtTime(m.totalSec)+')</span>';
html += '</span>';
});
html += '</div>';
}
html += '</div>';
host.innerHTML = html;
}
// ---------- Sektion 1: Matrix ----------
function renderMatrix() {
var m = _resultsState.matrix;
if (!m) return;
var key = _resultsState.sortKey;
var dir = _resultsState.sortDir;
// Score je Schüler je Modul holen + sortieren
function rowKey(stu) {
if (key === 'name') return (stu.displayName || '').toLowerCase();
if (key === 'total') return -(m.totals[stu.id]?.score || 0);
return -(m.cells[stu.id]?.[key]?.score || 0); // Modul-Score (negativ = absteigend)
}
var students = m.students.slice().sort(function(a, b) {
var ka = rowKey(a), kb = rowKey(b);
var cmp = ka < kb ? -1 : ka > kb ? 1 : 0;
return dir === 'asc' ? cmp : -cmp;
});
function arrow(thisKey) {
if (key !== thisKey) return '<span class="sort-i">↕</span>';
return '<span class="sort-i active">' + (dir === 'asc' ? '↑' : '↓') + '</span>';
}
function cellColor(stars) {
if (!stars) return '#f4f1e8';
if (stars <= 2) return '#fff1c8';
if (stars <= 4) return '#dceadd';
return '#a3c167';
}
var html = '<div class="card"><div class="card-h">Klassen-Leistung pro Modul</div>';
html += '<p style="font-size:.66rem;color:#8a8a8a;margin-bottom:.5rem">Pro Zelle: <b>★ Sterne</b> &nbsp;(<b>n×</b> Durchgänge). Score = ★ × √Durchgänge. Klick auf Spalten-Kopf sortiert.</p>';
html += '<div class="matrix-wrap"><table class="result-matrix">';
html += '<thead><tr><th class="r-stu" onclick="sortMatrix(\'name\')">Schüler*in '+arrow('name')+'</th>';
m.modules.forEach(function(mod) {
html += '<th class="r-mod" onclick="sortMatrix(\''+mod.id+'\')" title="'+esc(mod.name)+'">'+mod.icon+' '+arrow(mod.id)+'</th>';
});
html += '<th class="r-total" onclick="sortMatrix(\'total\')">Gesamt '+arrow('total')+'</th>';
var hasBadges = (typeof ggsBadgeCount === 'function');
if (hasBadges) html += '<th class="r-total" title="Freigeschaltete Abzeichen (Basis + thematisch)">Abzeichen</th>';
html += '</tr></thead><tbody>';
students.forEach(function(s) {
var avatarHtml = (typeof s.emoji === 'string' && s.emoji.indexOf('avatar:') === 0)
? '<img src="assets/img/avatars/thumbs/avatar-'+s.emoji.substring(7)+'.webp" alt="" style="width:1.2em;height:1.2em;border-radius:50%;object-fit:cover;vertical-align:middle">'
: (s.emoji || '🧑‍🎓');
html += '<tr><td class="r-stu">'+avatarHtml+' '+esc(s.displayName)+'</td>';
m.modules.forEach(function(mod) {
var c = m.cells[s.id]?.[mod.id];
if (c && c.plays > 0) {
html += '<td class="r-cell" style="background:'+cellColor(c.stars)+'"><span class="rs">'+c.stars+'★</span><span class="rp">'+c.plays+'×</span></td>';
} else {
html += '<td class="r-cell r-empty">·</td>';
}
});
var t = m.totals[s.id] || {score:0, plays:0};
html += '<td class="r-total"><b>'+t.score.toFixed(1)+'</b><span class="rp">'+t.plays+'×</span></td>';
if (hasBadges) {
var bc = 0, bmax = 0;
m.modules.forEach(function(mod){
var c = m.cells[s.id]?.[mod.id];
bmax += ggsSimBadges(mod.id).length;
if (c) bc += ggsBadgeCount({plays:c.plays, best_stars:c.stars, level:c.level}, mod.id);
});
html += '<td class="r-total"><b>'+bc+'</b><span class="rp">von '+bmax+'</span></td>';
}
html += '</tr>';
});
html += '</tbody></table></div></div>';
document.getElementById('res-matrix').innerHTML = html;
}
function sortMatrix(key) {
if (_resultsState.sortKey === key) {
_resultsState.sortDir = _resultsState.sortDir === 'asc' ? 'desc' : 'asc';
} else {
_resultsState.sortKey = key;
_resultsState.sortDir = (key === 'name') ? 'asc' : 'desc';
}
renderMatrix();
}
// ---------- Sektion 2: Aktivitäts-Heatmap (SVG-Stacked-Bar) ----------
function renderActivity() {
var a = _resultsState.activity;
var range = _resultsState.range;
var rangeBtns = ['day','week','month','year'].map(function(r) {
var label = {day:'Tag',week:'Woche',month:'Monat',year:'Jahr'}[r];
var cls = r === range ? 'btn' : 'btn btn-outline';
return '<button class="'+cls+'" style="font-size:.7rem;padding:.3rem .7rem" onclick="changeActivityRange(\''+r+'\')">'+label+'</button>';
}).join('');
// Toggle „nach Modul" vs „nach Schüler*in" (Backend liefert beides).
var groupBy = _resultsState.groupBy || 'module';
var groupBtns = [
{k:'module', l:'Nach Modul'},
{k:'student', l:'Nach Schüler*in'},
].map(function(g) {
var cls = g.k === groupBy ? 'btn' : 'btn btn-outline';
return '<button class="'+cls+'" style="font-size:.7rem;padding:.3rem .7rem" onclick="changeActivityGroupBy(\''+g.k+'\')">'+g.l+'</button>';
}).join('');
var headTitle = (groupBy === 'student')
? 'Fleißmessung — Durchgänge pro Schüler*in im Zeitverlauf'
: 'Fleißmessung — Durchgänge im Zeitverlauf';
var html = '<div class="card"><div class="card-h">' + headTitle + '</div>';
html += '<div style="display:flex;gap:.4rem;margin-bottom:.6rem;flex-wrap:wrap;align-items:center">';
html += '<div style="display:flex;gap:.3rem">'+rangeBtns+'</div>';
html += '<div style="width:1px;height:1.6rem;background:rgba(0,0,0,.12);margin:0 .2rem"></div>';
html += '<div style="display:flex;gap:.3rem">'+groupBtns+'</div>';
html += '</div>';
if (!a || !a.series || a.series.length === 0) {
html += '<p style="font-size:.72rem;color:#8a8a8a">Keine Aktivität in dieser Periode.</p>';
html += '</div>';
document.getElementById('res-activity').innerHTML = html;
return;
}
// Max-Stack-Höhe für Y-Skala
var maxStack = 0;
for (var i = 0; i < a.buckets.length; i++) {
var sum = a.series.reduce(function(acc, s) { return acc + (s.values[i] || 0); }, 0);
if (sum > maxStack) maxStack = sum;
}
if (maxStack < 1) maxStack = 1;
var W = 720, H = 240, padL = 36, padR = 14, padT = 14, padB = 30;
var chartW = W - padL - padR;
var chartH = H - padT - padB;
var barWidth = chartW / a.buckets.length * 0.78;
var step = chartW / a.buckets.length;
var svg = '<svg class="activity-svg" viewBox="0 0 '+W+' '+H+'" preserveAspectRatio="none">';
// Y-Grid + Labels (4 Schritte)
for (var g = 0; g <= 4; g++) {
var y = padT + chartH * (1 - g/4);
var v = Math.round(maxStack * g / 4);
svg += '<line x1="'+padL+'" y1="'+y+'" x2="'+(W-padR)+'" y2="'+y+'" stroke="rgba(0,0,0,0.06)"/>';
svg += '<text x="'+(padL-6)+'" y="'+(y+3)+'" font-size="9" text-anchor="end" fill="#8a8a8a">'+v+'</text>';
}
// Stacked Bars
a.buckets.forEach(function(label, idx) {
var x = padL + idx * step + (step - barWidth) / 2;
var yCursor = padT + chartH;
a.series.forEach(function(s) {
var v = s.values[idx] || 0;
if (!v) return;
var h = (v / maxStack) * chartH;
yCursor -= h;
var titleLabel = (s.icon ? s.icon + ' ' : '') + esc(s.name) + ': ' + v;
svg += '<rect x="'+x+'" y="'+yCursor+'" width="'+barWidth+'" height="'+h+'" fill="'+s.color+'"><title>'+titleLabel+'</title></rect>';
});
// X-Label
svg += '<text x="'+(x+barWidth/2)+'" y="'+(H-padB+14)+'" font-size="10" text-anchor="middle" fill="#4a4a4a">'+esc(label)+'</text>';
});
svg += '</svg>';
// Legende
var legend = '<div class="activity-legend">';
a.series.forEach(function(s) {
var total = s.values.reduce(function(x,y){return x+y;}, 0);
legend += '<span class="lg-item"><span class="lg-dot" style="background:'+s.color+'"></span>'+s.icon+' '+esc(s.name)+' <b>'+total+'</b></span>';
});
legend += '</div>';
html += '<div class="activity-chart">' + svg + '</div>' + legend + '</div>';
document.getElementById('res-activity').innerHTML = html;
}
async function changeActivityRange(r) {
_resultsState.range = r;
reloadActivity();
}
async function changeActivityGroupBy(g) {
_resultsState.groupBy = g;
reloadActivity();
}
async function reloadActivity() {
document.getElementById('res-activity').innerHTML = '<div class="card"><p style="padding:1rem;font-size:.7rem;color:#8a8a8a">Lade…</p></div>';
var g = _resultsState.groupBy || 'module';
var a = await api('results?class_id=' + currentClassId + '&view=activity&range=' + _resultsState.range + '&groupBy=' + g);
if (a.error) { document.getElementById('res-activity').innerHTML = '<p style="color:#c07a6b">'+esc(a.error)+'</p>'; return; }
_resultsState.activity = a;
renderActivity();
}
// ---------- Sektion 3: Modul-Cards (kompakt, 4 in einer Reihe) mit Drill-Down ----------
function renderModuleCards() {
var m = _resultsState.matrix;
if (!m) return;
var html = '<div class="card"><div class="card-h">Modul-Detail (Klick öffnet Schüler-Vergleich)</div>';
html += '<div class="result-mod-grid">';
m.modules.forEach(function(mod) {
// Aggregat über alle Schüler
var totalPlays = 0, totalStars = 0, withPlays = 0;
m.students.forEach(function(s) {
var c = m.cells[s.id]?.[mod.id];
if (c && c.plays > 0) { totalPlays += 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+'\')">';
html += '<div class="rm-img">';
html += imgSrc ? '<img src="'+imgSrc+'" alt="">' : '<span style="font-size:2.2rem">'+(mod.icon||'📚')+'</span>';
html += '</div>';
html += '<div class="rm-body">';
html += '<div class="rm-name">'+(mod.icon||'')+' '+esc(mod.name)+'</div>';
html += '<div class="rm-stats">⭐ '+avgStars+' · '+totalPlays+'×</div>';
html += '</div></div>';
});
html += '</div></div>';
document.getElementById('res-modules').innerHTML = html;
}
// Modul-Detail-Modal
async function openModuleDetail(moduleId) {
showModal('modal-mod-detail');
document.getElementById('mod-detail-body').innerHTML = '<p style="font-size:.7rem;color:#8a8a8a">Lade…</p>';
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; }
_modDetail = { moduleId: moduleId, data: d, sortKey: 'best_stars', sortDir: 'desc' };
renderModDetail();
}
var _modDetail = null;
function renderModDetail() {
if (!_modDetail) return;
var d = _modDetail.data;
var key = _modDetail.sortKey, dir = _modDetail.sortDir;
var stu = d.students.slice().sort(function(a, b) {
var ka, kb;
if (key === 'name') { ka = (a.displayName||'').toLowerCase(); kb = (b.displayName||'').toLowerCase(); }
else if (key === 'last') { ka = a.last_played || ''; kb = b.last_played || ''; }
else if (key === 'dur') { ka = a.total_duration_ms || 0; kb = b.total_duration_ms || 0; }
else if (key.indexOf('hl:') === 0) {
var hk = key.substring(3);
ka = (a.highlights && a.highlights[hk] !== undefined && a.highlights[hk] !== null) ? a.highlights[hk] : -Infinity;
kb = (b.highlights && b.highlights[hk] !== undefined && b.highlights[hk] !== null) ? b.highlights[hk] : -Infinity;
}
else { ka = a[key] || 0; kb = b[key] || 0; }
var c = ka < kb ? -1 : ka > kb ? 1 : 0;
return dir === 'asc' ? c : -c;
});
function arr(k) { return _modDetail.sortKey === k ? (dir === 'asc' ? '↑' : '↓') : '↕'; }
var totalH = Math.round((d.classStats.totalDurationMs || 0) / 3600000 * 10) / 10;
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 += '<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>';
var mfFrom = d.from || _modFilter.from || '', mfTo = d.to || _modFilter.to || '';
html += '<div style="display:inline-flex;align-items:center;gap:.35rem;font-size:.68rem;color:#6a6a6a;margin-top:.3rem">Zeitraum: '
+ '<input type="date" value="'+mfFrom+'" onchange="setModDate(\'from\',this.value)" style="font:inherit;padding:.12rem .35rem;border:1px solid #ccc;border-radius:5px">'
+ ' <input type="date" value="'+mfTo+'" onchange="setModDate(\'to\',this.value)" style="font:inherit;padding:.12rem .35rem;border:1px solid #ccc;border-radius:5px">'
+ '<button class="btn-sm" style="padding:.12rem .5rem" onclick="resetModDateModal()" title="Auf Schuljahresbeginn zurücksetzen">↺</button></div>';
html += '</div></div>';
var hl = d.highlights || [];
var champs = d.champions || {};
function fmt(h, v) {
if (v === null || v === undefined) return '—';
if (h.format === 'percent') return Math.round(v * 100) + '%';
if (typeof v === 'number') {
var n = Math.abs(v) >= 100 ? Math.round(v) : (Math.round(v * 10) / 10);
return n + (h.unit ? ' ' + h.unit : '');
}
return v + (h.unit ? ' ' + h.unit : '');
}
// Spalten-Spannweite je Kennzahl (für vergleichbare Balken)
var hlStats = {};
hl.forEach(function(h) {
var vals = d.students.map(function(s){ var v=(s.highlights||{})[h.key]; return (v===null||v===undefined)?null:+v; })
.filter(function(v){ return v!==null && !isNaN(v); });
hlStats[h.key] = vals.length ? { min: Math.min.apply(null, vals), max: Math.max.apply(null, vals) } : null;
});
// Ampelbalken: g = Güte 0..1 (1 = gut = grün). Grün immer positiv.
function ampBar(g) {
if (g === null) return '';
g = Math.max(0, Math.min(1, g));
var col = g>=.75 ? '#3f7a45' : g>=.55 ? '#7fa856' : g>=.4 ? '#e0a838' : g>=.25 ? '#e08a3a' : '#c65a49';
return '<span style="display:block;height:5px;border-radius:3px;background:#ece8dc;margin-top:3px;overflow:hidden">'
+ '<i style="display:block;height:100%;width:'+Math.round(g*100)+'%;background:'+col+'"></i></span>';
}
// Güte eines Werts in seiner Spalte, Richtung aus h.agg ('min' = weniger ist besser)
function hlGoodness(h, v) {
if (v === null || v === undefined || isNaN(+v)) return null;
var st = hlStats[h.key];
if (!st || st.max === st.min) return 0.5; // keine Spreizung → neutral
var frac = (+v - st.min) / (st.max - st.min);
return h.agg === 'min' ? (1 - frac) : frac;
}
// Engagement-Spalten (Durchgänge, Spielzeit, Zuletzt): mehr/aktueller = grün
var maxPlays = Math.max.apply(null, [1].concat(d.students.map(function(s){ return s.plays || 0; })));
var maxDur = Math.max.apply(null, [1].concat(d.students.map(function(s){ return s.total_duration_ms || 0; })));
var NOW = Date.now();
function recencyGood(lp){
if (!lp) return null;
var days = (NOW - new Date(lp.replace(' ','T')+'Z').getTime()) / 86400000;
return Math.max(0, Math.min(1, 1 - days/21)); // heute = 1, ab ~3 Wochen = 0
}
html += '<table class="result-matrix" style="font-size:.72rem">';
html += '<thead><tr>'
+ '<th onclick="sortModDetail(\'name\')">Schüler*in '+arr('name')+'</th>'
+ '<th onclick="sortModDetail(\'best_stars\')">Leistung '+arr('best_stars')+'</th>'
+ '<th onclick="sortModDetail(\'plays\')">Durchg. '+arr('plays')+'</th>';
hl.forEach(function(h) {
html += '<th onclick="sortModDetail(\'hl:'+h.key+'\')" title="'+esc(h.label)+(h.agg==='min'?' (weniger ist besser)':' (mehr ist besser)')+'">'+esc(h.label)+' '+arr('hl:'+h.key)+'</th>';
});
html += '<th onclick="sortModDetail(\'last\')">Zuletzt '+arr('last')+'</th>'
+ '<th onclick="sortModDetail(\'dur\')">Spielzeit '+arr('dur')+'</th>'
+ '<th></th>'
+ '</tr></thead><tbody>';
stu.forEach(function(s) {
var stars = s.best_stars || 0;
var starStr = _pctBar(Math.round(Math.max(0,Math.min(5,stars))/5*100));
var lastDate = s.last_played ? new Date(s.last_played.replace(' ','T')+'Z').toLocaleDateString('de-AT', {day:'2-digit',month:'2-digit'}) : '—';
var dur = s.total_duration_ms ? Math.round(s.total_duration_ms/60000)+' min' : '—';
var avatar = (typeof s.emoji === 'string' && s.emoji.indexOf('avatar:') === 0)
? '<img src="assets/img/avatars/thumbs/avatar-'+s.emoji.substring(7)+'.webp" alt="" style="width:1.2em;height:1.2em;border-radius:50%;object-fit:cover;vertical-align:middle">'
: s.emoji;
html += '<tr>'
+ '<td>'+avatar+' '+esc(s.displayName)+'</td>'
+ '<td style="color:#a37800">'+starStr+(s.plays>0?ampBar(stars/5):'')+'</td>'
+ '<td>'+s.plays+(s.plays>0?ampBar(s.plays/maxPlays):'')+'</td>';
hl.forEach(function(h) {
var v = (s.highlights || {})[h.key];
var isChamp = (champs[h.key] === s.id);
var text = fmt(h, v);
html += '<td'+(isChamp ? ' style="background:#fff5dc;font-weight:700"' : '')+'>'
+ (isChamp && v !== null && v !== undefined ? '🏆 ' : '')
+ text + ampBar(hlGoodness(h, v)) + '</td>';
});
html += '<td>'+lastDate+(s.plays>0?ampBar(recencyGood(s.last_played)):'')+'</td>'
+ '<td>'+dur+(s.total_duration_ms?ampBar(s.total_duration_ms/maxDur):'')+'</td>'
+ '<td>'+(s.plays > 0 ? '<button class="btn btn-outline" style="font-size:.6rem;padding:.2rem .5rem" onclick="openStudentRuns('+s.id+')">Verlauf</button>' : '')+'</td>'
+ '</tr>';
});
html += '</tbody></table>';
html += '<div id="mod-runs-box"></div>';
document.getElementById('mod-detail-body').innerHTML = html;
}
function sortModDetail(key) {
if (_modDetail.sortKey === key) _modDetail.sortDir = _modDetail.sortDir === 'asc' ? 'desc' : 'asc';
else { _modDetail.sortKey = key; _modDetail.sortDir = (key === 'name') ? 'asc' : 'desc'; }
renderModDetail();
}
async function openStudentRuns(studentId) {
var box = document.getElementById('mod-runs-box');
box.innerHTML = '<p style="font-size:.7rem;color:#8a8a8a;padding:.4rem">Lade Verlauf…</p>';
var r = await api('results?class_id=' + currentClassId + '&view=module&module_id=' + _modDetail.moduleId + '&student_id=' + studentId);
if (r.error) { box.innerHTML = '<p style="color:#c07a6b">'+esc(r.error)+'</p>'; return; }
var html = '<div style="margin-top:.6rem;border-top:1px solid rgba(0,0,0,.08);padding-top:.5rem"><strong style="font-size:.74rem">Verlauf — letzte '+r.runs.length+' Durchgänge</strong>';
if (!r.runs.length) { html += '<p style="font-size:.7rem;color:#8a8a8a">Keine Durchgänge</p></div>'; box.innerHTML = html; return; }
html += '<div class="run-list">';
// Sonnensystem: task_solved + set_complete in Sessions clustern (analog
// Schüler-Eigen-Verlauf). Eine Karte pro Session, orange = halbfertig,
// grün = abgeschlossen.
if (_modDetail.moduleId === 'sonnensystem') {
var sessions = groupSonnensystemSessionsTeacher(r.runs);
sessions.forEach(function(sess) {
html += renderSonnensystemSessionTeacher(sess);
});
} else if (_modDetail.moduleId === 'kofferdetektiv') {
// Kofferdetektiv: aggregierte Auswertung + Heatmap der gelösten Länder
html += renderKofferdetektivStudentAnalysis(r.runs);
} else if ({'eu-werkstatt':1,'energiemanager':1,'busfahrt':1,'fluggesellschaft':1,'heli':1}[_modDetail.moduleId]) {
// Module mit eigener, reicher Durchgangs-Karte
r.runs.forEach(function(run, i) {
html += renderRunDetailCard(_modDetail.moduleId, run, i);
});
} else {
// Alle übrigen: Liniengrafik über die wichtigsten Kennzahlen (statt JSON)
html += renderRunsLineChart(_modDetail.moduleId, r.runs);
}
html += '</div></div>';
box.innerHTML = html;
}
/* ────────────────────────────────────────────────────────────────────────
* Kofferdetektiv-spezifische Schüler:innen-Detail-Auswertung
* Aggregiert über alle runs: gelöste Länder, Hinweise pro Fall, Fehler-Trend
* ──────────────────────────────────────────────────────────────────────── */
function renderKofferdetektivStudentAnalysis(runs) {
if (!runs.length) return '<p style="font-size:.78rem;color:#8a8a8a">Noch keine Durchgänge.</p>';
// Aggregate sammeln
var totalCases = 0, totalSolved = 0, totalFirstTry = 0;
var totalWrong = 0, totalHints = 0, totalPoints = 0, timeouts = 0;
var countryMap = {}; // iso → {solved, attempts, hints, lastDate}
var perRun = []; // {date, level, cases_total, solved, score, wrong}
runs.forEach(function(run) {
var d = (run.results && run.results.data) || run.results || {};
if (!d.cases_total) return;
totalCases += +d.cases_total || 0;
totalSolved += +d.cases_solved || 0;
totalFirstTry += +d.correct_first_try || 0;
totalWrong += +d.wrong_guesses || 0;
totalHints += +d.hints_used_total || 0;
totalPoints += +d.points_total || 0;
timeouts += +d.timeouts || 0;
perRun.push({
date: run.submitted_at,
level: d.level,
total: +d.cases_total || 0,
solved: +d.cases_solved || 0,
wrong: +d.wrong_guesses || 0,
hints_avg: +d.hints_used_avg || 0,
airport: d.airport || '—',
});
(d.countries || []).forEach(function(c) {
var m = countryMap[c.iso2] = countryMap[c.iso2] || { iso: c.iso2, solved: 0, attempts: 0, hints: 0, wrong: 0 };
if (c.solved) m.solved++;
m.attempts++;
m.hints += (+c.hints || 0);
m.wrong += (+c.wrong || 0);
});
});
if (!totalCases) return '<p style="font-size:.78rem;color:#8a8a8a">Noch keine Durchgänge mit Ergebnis-Daten.</p>';
var solvePct = Math.round(totalSolved / totalCases * 100);
var firstTryPct = totalCases ? Math.round(totalFirstTry / totalCases * 100) : 0;
var avgHints = totalCases ? (totalHints / totalCases).toFixed(1) : '0';
// KPI-Karten
var html = ''
+ '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:.6rem;margin:.8rem 0">'
+ _kpiCard('🎯 Lösungs-Quote', solvePct + ' %', totalSolved + ' von ' + totalCases, solvePct >= 70 ? '#5a8a5e' : solvePct >= 40 ? '#c9913a' : '#c07a6b')
+ _kpiCard('1.-Versuch ✓', firstTryPct + ' %', totalFirstTry + ' von ' + totalCases, firstTryPct >= 50 ? '#5a8a5e' : firstTryPct >= 30 ? '#c9913a' : '#c07a6b')
+ _kpiCard('Ø Hinweise / Fall', avgHints, 'Effizienz im Spüren', avgHints <= 4 ? '#5a8a5e' : avgHints <= 7 ? '#c9913a' : '#c07a6b')
+ _kpiCard('Fehlversuche', String(totalWrong), totalWrong <= 5 ? 'wenige' : totalWrong <= 15 ? 'mittel' : 'viele', totalWrong <= 5 ? '#5a8a5e' : totalWrong <= 15 ? '#c9913a' : '#c07a6b')
+ _kpiCard('🏅 Punkte', String(totalPoints), runs.length + ' Durchgänge', '#3a6a8a')
+ _kpiCard('⏰ Zeitüberschr.', String(timeouts), timeouts === 0 ? 'immer in der Zeit' : 'manchmal zu spät', timeouts === 0 ? '#5a8a5e' : '#c9913a')
+ '</div>';
// Balken-Grafik: Lösungs-Quote pro Durchgang
html += '<h4 style="margin:1rem 0 .4rem;font-size:.84rem">📊 Lösungs-Quote pro Durchgang</h4>';
html += '<div style="display:flex;flex-direction:column;gap:.3rem">';
perRun.slice(0, 12).forEach(function(p) {
var pct = p.total ? Math.round(p.solved / p.total * 100) : 0;
var color = pct >= 70 ? '#5a8a5e' : pct >= 40 ? '#c9913a' : '#c07a6b';
var dt = p.date ? new Date(p.date.replace(' ','T')+'Z').toLocaleDateString('de-AT', {day:'2-digit',month:'2-digit',year:'2-digit'}) : '—';
html += '<div style="display:grid;grid-template-columns:8em 1fr 4em;gap:.4rem;align-items:center;font-size:.74rem">'
+ '<span style="color:#8a8a8a">' + esc(dt) + ' · St ' + (p.level || '?') + '</span>'
+ '<div style="background:#eee;height:14px;border-radius:3px;overflow:hidden;position:relative">'
+ '<div style="width:' + pct + '%;height:100%;background:' + color + '"></div>'
+ '<span style="position:absolute;left:6px;top:0;line-height:14px;font-size:.66rem;color:white;font-weight:700">' + p.solved + ' / ' + p.total + '</span>'
+ '</div>'
+ '<span style="color:' + color + ';font-weight:700;font-variant-numeric:tabular-nums">' + pct + ' %</span>'
+ '</div>';
});
html += '</div>';
// Länder-Heatmap (sortiert nach Häufigkeit / am häufigsten gelöst zuerst)
var countries = Object.values(countryMap).sort(function(a, b) { return b.solved - a.solved || b.attempts - a.attempts; });
if (countries.length) {
html += '<h4 style="margin:1.1rem 0 .4rem;font-size:.84rem">🗺️ Länder-Heatmap — ' + countries.length + ' Länder begegnet</h4>';
html += '<p style="font-size:.7rem;color:#8a8a8a;margin:0 0 .5rem">Grün = mehrfach gelöst · Gelb = einmal gelöst · Rot = nicht gelöst.</p>';
html += '<div style="display:flex;flex-wrap:wrap;gap:.25rem">';
countries.forEach(function(c) {
var bg, label;
if (c.solved === 0) { bg = '#f4d8d2'; label = '✗'; }
else if (c.solved >= c.attempts) { bg = c.solved > 1 ? '#5a8a5e' : '#a3c167'; label = c.solved > 1 ? c.solved + '×' : '✓'; }
else { bg = '#e8c547'; label = c.solved + '/' + c.attempts; }
var fg = (bg === '#5a8a5e' || bg === '#a3c167') ? 'white' : '#3a3a3a';
html += '<span style="display:inline-block;min-width:2.3em;text-align:center;padding:.3rem .4rem;background:' + bg + ';color:' + fg + ';border-radius:4px;font-size:.7rem;font-weight:600" title="' + c.iso.toUpperCase() + ' · ' + c.solved + '/' + c.attempts + ' gelöst, Ø ' + (c.hints / c.attempts || 0).toFixed(1) + ' Hinweise">'
+ c.iso.toUpperCase() + '<br><span style="font-size:.6rem;opacity:.85">' + label + '</span></span>';
});
html += '</div>';
}
// Reflexionen (falls vorhanden)
var allReflections = [];
runs.forEach(function(run) {
if (run.reflections && Array.isArray(run.reflections)) {
run.reflections.forEach(function(rf) {
if (rf && rf.answer) allReflections.push({ when: run.submitted_at, q: rf.question, a: rf.answer });
});
}
});
if (allReflections.length) {
html += '<h4 style="margin:1.1rem 0 .4rem;font-size:.84rem">💭 Reflexionen</h4>';
html += '<div style="display:flex;flex-direction:column;gap:.4rem">';
allReflections.forEach(function(rf) {
var dt = rf.when ? new Date(rf.when.replace(' ','T')+'Z').toLocaleDateString('de-AT', {day:'2-digit',month:'2-digit',year:'2-digit'}) : '';
html += '<div style="background:#fbfaf6;border-left:3px solid #c9913a;padding:.4rem .6rem;font-size:.78rem">'
+ '<div style="font-size:.62rem;color:#8a8a8a;margin-bottom:.15rem">' + esc(dt) + ' · ' + esc((rf.q||'').substring(0,80)) + '</div>'
+ esc(rf.a)
+ '</div>';
});
html += '</div>';
}
return html;
}
function _kpiCard(label, value, sub, color) {
return '<div style="background:#fbfaf6;border:1px solid rgba(0,0,0,.08);border-left:4px solid ' + color + ';border-radius:6px;padding:.55rem .65rem">'
+ '<div style="font-size:.6rem;letter-spacing:.12em;text-transform:uppercase;color:#8a8a8a;font-weight:700">' + label + '</div>'
+ '<div style="font-size:1.2rem;font-weight:800;color:' + color + ';font-variant-numeric:tabular-nums;line-height:1.1;margin:.15rem 0">' + value + '</div>'
+ '<div style="font-size:.68rem;color:#6a6a6a">' + sub + '</div>'
+ '</div>';
}
// Session-Clustering identisch zur Schüler-Ansicht — Trennung bei 1h-Gap
// oder wenn ein set_complete den Block abschließt.
function groupSonnensystemSessionsTeacher(runs) {
var SESSION_GAP_MS = 60 * 60 * 1000;
var sorted = runs.slice().sort(function(a,b){
return new Date(a.submitted_at.replace(' ','T')+'Z') - new Date(b.submitted_at.replace(' ','T')+'Z');
});
var sessions = [];
var current = null;
sorted.forEach(function(run) {
var d = (run.results && run.results.data) || run.results || {};
var ts = new Date(run.submitted_at.replace(' ','T')+'Z').getTime();
var ev = d.event || (run.stars > 0 ? 'set_complete' : null);
if (!current || (ts - current.endTs) > SESSION_GAP_MS) {
if (current) sessions.push(current);
current = { runs: [], complete: false, startTs: ts, endTs: ts, completeRun: null, total: d.total || 0 };
}
current.runs.push(run);
current.endTs = ts;
if (d.total) current.total = d.total;
if (ev === 'set_complete') {
current.complete = true;
current.completeRun = run;
sessions.push(current);
current = null;
}
});
if (current) sessions.push(current);
return sessions.reverse();
}
function renderSonnensystemSessionTeacher(sess) {
var startDate = new Date(sess.startTs);
var endDate = new Date(sess.endTs);
var dt = startDate.toLocaleString('de-AT', {day:'2-digit',month:'2-digit',year:'2-digit',hour:'2-digit',minute:'2-digit'});
var durSec = Math.round((sess.endTs - sess.startTs) / 1000);
var dur = _fmtSeconds(durSec);
var solvedTasks = new Set();
var firstTryTasks = 0;
var attemptsByTask = {};
var newAch = [];
sess.runs.forEach(function(run){
var d = (run.results && run.results.data) || run.results || {};
if (d.taskId) {
solvedTasks.add(d.taskId);
if (d.firstTry) firstTryTasks++;
}
if (d.attemptsByTask) {
Object.keys(d.attemptsByTask).forEach(function(k){
attemptsByTask[k] = Math.max(attemptsByTask[k] || 0, d.attemptsByTask[k] || 1);
});
}
if (Array.isArray(d.newAchievements)) newAch = newAch.concat(d.newAchievements);
});
var done = solvedTasks.size;
var total = sess.total || 0;
var stars = 0;
if (sess.completeRun) {
var crd = (sess.completeRun.results && sess.completeRun.results.data) || sess.completeRun.results || {};
if (crd.completed) done = crd.completed;
if (crd.total) total = crd.total;
stars = Math.max(0, Math.min(5, crd.stars || sess.completeRun.results.stars || 0));
}
var quote = total > 0 ? Math.round(done/total*100) : 0;
var starsHtml = sess.complete
? _pctBadge(quote)
: '<span style="font-size:.7rem;color:#c9913a;font-weight:700">⏸ läuft / abgebrochen</span>';
var statusEmoji = sess.complete ? '🪐' : '⏳';
var statusLabel = sess.complete ? 'Aufgaben-Runde abgeschlossen' : 'Teil-Durchlauf (nicht abgeschlossen)';
var quoteColor = sess.complete
? (quote >= 85 ? '#5a8a5e' : quote >= 50 ? '#c9913a' : '#c07a6b')
: '#c9913a';
var borderColor = sess.complete ? '#5a8a5e' : '#e8a247';
var bgGradient = sess.complete ? 'linear-gradient(90deg,#f0f7f0 0,#fdfdfb 80px)' : 'linear-gradient(90deg,#fff8ec 0,#fdfdfb 80px)';
var manyTries = [];
Object.keys(attemptsByTask).forEach(function(k){
var n = attemptsByTask[k] || 1;
if (n > 1) manyTries.push({id:k, n:n});
});
manyTries.sort(function(a,b){ return b.n - a.n; });
var solvedList = Array.from(solvedTasks);
var detailsHtml = '<details style="margin-top:.4rem;border-top:1px dashed rgba(0,0,0,.08);padding-top:.3rem"><summary style="cursor:pointer;color:#4a7c8a;font-size:.72rem;font-weight:600">Details anzeigen</summary>';
detailsHtml += '<div style="font-size:.72rem;line-height:1.5;padding:.4rem 0">';
detailsHtml += '<div><b>Zeitraum:</b> ' + dt + ' · Dauer ≈ ' + dur + '</div>';
if (firstTryTasks > 0) detailsHtml += '<div style="margin-top:.3rem">✓ <b>'+firstTryTasks+'</b> auf Anhieb richtig</div>';
if (solvedList.length) {
detailsHtml += '<div style="margin-top:.3rem">📋 Gelöste Aufgaben:</div>';
detailsHtml += '<ul style="margin:.15rem 0 0 1.1rem;padding:0">';
solvedList.forEach(function(tid){
var nTries = attemptsByTask[tid] || 1;
var trySuffix = nTries > 1 ? ' <span style="color:#c9913a">(' + nTries + ' Versuche)</span>' : '';
detailsHtml += '<li>' + esc(tid) + trySuffix + '</li>';
});
detailsHtml += '</ul>';
}
if (newAch.length) detailsHtml += '<div style="margin-top:.3rem">🎉 Neue Erfolge: <b>'+newAch.map(esc).join(', ')+'</b></div>';
if (!sess.complete) detailsHtml += '<div style="margin-top:.4rem;color:#c9913a;font-style:italic">Diese Runde wurde nicht bis zum Ende gespielt.</div>';
detailsHtml += '</div></details>';
return ''
+ '<div class="run-card" style="border-left:4px solid ' + borderColor + ';background:' + bgGradient + '">'
+ '<div class="run-head">'
+ '<div><strong>'+statusEmoji+' '+statusLabel+'</strong>'
+ '<div class="run-meta">'+dt+' · ≈ '+dur+'</div>'
+ '</div>'
+ '<div style="text-align:right">'+starsHtml+'<div style="font-size:.7rem;color:'+quoteColor+';font-weight:700;margin-top:.2rem">'+(total ? quote+'% gelöst' : '—')+'</div></div>'
+ '</div>'
+ '<div class="run-stats">'
+ '<span><b>'+done+'</b>/'+(total||'?')+' Aufgaben richtig</span>'
+ (newAch.length ? '<span>🎉 <b>'+newAch.length+'</b> Erfolge</span>' : '')
+ '</div>'
+ detailsHtml
+ '</div>';
}
/* Sim-spezifischer Pretty-Renderer für einen einzelnen Run.
Fallback (unbekannte Sim): raw JSON ausklappbar. */
/* ────────────────────────────────────────────────────────────────────────
* Verlaufs-Liniengrafik über die wichtigsten Kennzahlen einer Sim.
* Quelle = die Spalten-Definitionen (highlights: key/agg/unit/format) plus
* Sterne. Eine farbige Linie je Kennzahl, je Kennzahl auf ihre eigene
* Spannweite normiert (damit alle sichtbar), OBEN = besser (Richtung aus agg).
* Legende zeigt letzten Wert + Trendpfeil (grün besser / rot schlechter).
* ──────────────────────────────────────────────────────────────────────── */
function _hlGet(obj, path){
if (!obj) return null;
var parts = String(path).split('.'), v = obj;
for (var i = 0; i < parts.length; i++) { if (v == null) return null; v = v[parts[i]]; }
return v;
}
function renderRunsLineChart(simId, runs) {
var rs = runs.slice().filter(function(r){ return r.submitted_at; })
.sort(function(a,b){ return a.submitted_at < b.submitted_at ? -1 : a.submitted_at > b.submitted_at ? 1 : 0; });
function runData(run){ return (run.results && run.results.data) ? run.results.data : (run.results || {}); }
// Kennzahlen aus dem gemeinsamen Sim-Schema (deckt alle Chart-Module ab)
var schema = (typeof simPlotMetrics === 'function') ? simPlotMetrics(simId) : [];
var metrics = schema.map(function(f){
return { key:f.k, t:f.t, type:f.type, label:f.l, unit:f.u,
hi:(typeof metricHigherBetter === 'function' ? metricHigherBetter(f) : true) };
});
function rawVal(run, m){
var d = runData(run), v = d[m.key];
if (v === null || v === undefined || isNaN(+v)) return null;
if (m.type === 'ratio') { var t = d[m.t]; return (t > 0) ? (+v / t * 100) : null; }
return +v;
}
function fmtMetric(m, v){
if (v === null) return '';
if (m.type === 'stars' || m.type === 'starsSmall') return _pctBar(Math.round(Math.max(0,Math.min(5,v))/5*100));
if (m.type === 'pct' || m.type === 'pctInv' || m.type === 'ratio') return Math.round(v) + ' %';
if (m.type === 'temp') return (Math.round(v*10)/10).toLocaleString('de-AT') + ' °C';
var n = Math.abs(v) >= 100 ? Math.round(v) : Math.round(v * 10) / 10;
return n.toLocaleString('de-AT') + (m.unit ? ' ' + m.unit : '');
}
// Snapshot (grafische Kacheln) bei nur einem Durchgang
if (rs.length < 2) {
var only = runData(rs[0] || runs[0] || {});
return '<div style="margin-top:.5rem"><p style="font-size:.72rem;color:#8a8a8a;margin:.2rem 0 .5rem">Erst ein Durchgang die Liniengrafik erscheint ab dem zweiten. Aktueller Stand:</p>'
+ ((typeof renderSimMetrics === 'function') ? renderSimMetrics(simId, only) : '') + '</div>';
}
var COLORS = ['#3f7a45','#c65a49','#3a6a8a','#c9913a','#7a5a9a','#4a9a8a','#b0743a'];
var series = [];
metrics.forEach(function(m){
var vals = rs.map(function(r){ return rawVal(r, m); });
var present = vals.filter(function(v){ return v !== null; });
if (present.length < 2) return; // ohne ≥2 Punkte keine Linie
var mn = Math.min.apply(null, present), mx = Math.max.apply(null, present);
series.push({ m:m, color:COLORS[series.length % COLORS.length], vals:vals, mn:mn, mx:mx, hi:m.hi,
first: present[0], last: present[present.length-1] });
});
if (!series.length) return '<p style="font-size:.72rem;color:#8a8a8a;margin-top:.5rem">Keine auswertbaren Verlaufswerte.</p>';
var W = 700, H = 210, padL = 10, padR = 10, padT = 12, padB = 30, n = rs.length;
var plotW = W - padL - padR, plotH = H - padT - padB;
function X(i){ return padL + (n > 1 ? i/(n-1) : 0.5) * plotW; }
function Y(s, v){
if (v === null) return null;
var frac = (s.mx > s.mn) ? (v - s.mn)/(s.mx - s.mn) : 0.5; // 0..1 im eigenen Wertebereich
var good = s.hi ? frac : (1 - frac); // oben = besser
return padT + (1 - good) * plotH;
}
var svg = '<svg viewBox="0 0 '+W+' '+H+'" width="100%" preserveAspectRatio="xMidYMid meet" style="display:block;font-family:inherit">';
// horizontale Hilfslinien
[0,0.25,0.5,0.75,1].forEach(function(g){
var yy = padT + g*plotH;
svg += '<line x1="'+padL+'" y1="'+yy+'" x2="'+(W-padR)+'" y2="'+yy+'" stroke="#ece8dc" stroke-width="1"/>';
});
svg += '<text x="'+padL+'" y="'+(padT-3)+'" font-size="9" fill="#8a9a7a" font-weight="700">▲ besser</text>';
// x-Achsen-Datumslabels
rs.forEach(function(r, i){
var lbl = new Date(r.submitted_at.replace(' ','T')+'Z').toLocaleDateString('de-AT',{day:'2-digit',month:'2-digit'});
var show = (n <= 8) || (i === 0) || (i === n-1) || (i % Math.ceil(n/8) === 0);
if (show) svg += '<text x="'+X(i)+'" y="'+(H-8)+'" font-size="9" fill="#9a9a9a" text-anchor="middle">'+lbl+'</text>';
});
// Linien + Punkte je Kennzahl (Lücken bei null überspringen)
series.forEach(function(s){
var seg = [], pts = '';
s.vals.forEach(function(v, i){
var yy = Y(s, v);
if (yy === null) { if (seg.length) { svg += '<polyline points="'+seg.join(' ')+'" fill="none" stroke="'+s.color+'" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round"/>'; seg = []; } return; }
seg.push(X(i)+','+yy);
pts += '<circle cx="'+X(i)+'" cy="'+yy+'" r="3" fill="'+s.color+'"/>';
});
if (seg.length) svg += '<polyline points="'+seg.join(' ')+'" fill="none" stroke="'+s.color+'" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round"/>';
svg += pts;
});
svg += '</svg>';
// Legende: Farbe · Name · letzter Wert · Trendpfeil
var leg = '<div style="display:flex;flex-wrap:wrap;gap:.4rem .9rem;margin-top:.5rem">';
series.forEach(function(s){
var improved = s.hi ? (s.last > s.first) : (s.last < s.first);
var worsened = s.hi ? (s.last < s.first) : (s.last > s.first);
var trend = improved ? '<span style="color:#3f7a45">▲</span>' : worsened ? '<span style="color:#c65a49">▼</span>' : '<span style="color:#9a9a9a">▬</span>';
var dirHint = s.hi ? '' : ' <span style="color:#9a9a9a;font-size:.62rem">(weniger = besser)</span>';
leg += '<span style="display:inline-flex;align-items:center;gap:.3rem;font-size:.72rem">'
+ '<span style="width:12px;height:3px;border-radius:2px;background:'+s.color+';display:inline-block"></span>'
+ '<b>'+esc(s.m.label)+'</b> '+fmtMetric(s.m, s.last)+' '+trend+dirHint+'</span>';
});
leg += '</div>';
return '<div style="margin-top:.5rem;background:#fbfaf6;border:1px solid rgba(0,0,0,.06);border-radius:10px;padding:.6rem .7rem">'
+ '<div style="font-size:.72rem;color:#6a6a6a;margin-bottom:.3rem">Verlauf über '+n+' Durchgänge jede Linie auf ihren eigenen Wertebereich normiert</div>'
+ svg + leg + '</div>';
}
function renderRunDetailCard(simId, run, idx) {
var dt = run.submitted_at ? new Date(run.submitted_at.replace(' ','T')+'Z').toLocaleString('de-AT', {day:'2-digit',month:'2-digit',year:'2-digit',hour:'2-digit',minute:'2-digit'}) : '—';
var dur = run.duration_ms ? _fmtSeconds(Math.round(run.duration_ms/1000)) : '—';
var r = run.results || {};
// submitToBackend nested: { stars, durationMs, data:{ ... } }; je nach Sim auch flat
var d = r.data || r;
if (simId === 'eu-werkstatt') return renderEuWerkstattRun(dt, dur, r, d);
if (simId === 'energiemanager') return renderEnergiemanagerRun(dt, dur, r, d);
if (simId === 'sonnensystem') return renderSonnensystemRun(dt, dur, r, d);
if (simId === 'busfahrt') return renderBusfahrtRun(dt, dur, r, d);
if (simId === 'fluggesellschaft') return renderFluggesellschaftRun(dt, dur, r, d);
if (simId === 'heli') return renderHeliRun(dt, dur, r, d);
// Generisch
return ''
+ '<div class="run-card">'
+ '<div class="run-head"><strong>'+dt+'</strong><span class="run-meta">'+dur+'</span></div>'
+ '<details><summary>JSON anzeigen</summary><pre class="run-json">'+esc(JSON.stringify(r, null, 2))+'</pre></details>'
+ '</div>';
}
function renderSonnensystemRun(dt, dur, r, d) {
var stars = Math.max(0, Math.min(5, r.stars || (d.stars||0)));
var starsHtml = _pctBadge(Math.round(stars/5*100));
var done = d.completed || 0;
var total = d.total || 0;
var quote = d.scoreQuote != null ? d.scoreQuote : (total > 0 ? Math.round(done/total*100) : 0);
var quoteColor = quote >= 85 ? '#5a8a5e' : quote >= 50 ? '#c9913a' : '#c07a6b';
var event = d.event || 'set_complete';
var attemptsByTask = d.attemptsByTask || {};
var maxTries = 0; var manyTries = [];
Object.keys(attemptsByTask).forEach(function(k){
var n = attemptsByTask[k] || 1;
if (n > maxTries) maxTries = n;
if (n > 1) manyTries.push({id:k, n:n});
});
var triesHtml = manyTries.length
? '<details style="margin-top:.4rem"><summary style="cursor:pointer;color:#4a7c8a;font-size:.72rem">'+manyTries.length+' Aufgaben mit Mehrfachversuchen</summary>'
+ '<ul style="margin:.3rem 0 0 1rem;padding:0;font-size:.7rem">'
+ manyTries.map(function(x){ return '<li>'+esc(x.id)+': <b>'+x.n+'</b> Versuche</li>'; }).join('')
+ '</ul></details>'
: '';
return ''
+ '<div class="run-card">'
+ '<div class="run-head">'
+ '<div><strong>🪐 '+(event === 'set_complete' ? 'Set abgeschlossen' : 'Einzel-Lösung')+'</strong>'
+ '<div class="run-meta">'+dt+' · '+dur+'</div>'
+ '</div>'
+ '<div style="text-align:right">'+starsHtml+'<div style="font-size:.7rem;color:'+quoteColor+';font-weight:700;margin-top:.2rem">'+quote+'% gelöst</div></div>'
+ '</div>'
+ '<div class="run-stats">'
+ '<span><b>'+done+'</b>/'+total+' Aufgaben</span>'
+ (maxTries > 1 ? '<span>höchste Versuche: <b>'+maxTries+'</b></span>' : '')
+ ((d.newAchievements||[]).length ? '<span>🎉 <b>'+(d.newAchievements||[]).length+'</b> neue Erfolge</span>' : '')
+ '</div>'
+ triesHtml
+ '</div>';
}
function renderEnergiemanagerRun(dt, dur, r, d) {
// Eigener Fehlversuch: Blackout (kein Set-Abschluss).
if (d.event === 'blackout') {
var causeTxt = d.cause === 'ueberfrequenz'
? 'Überfrequenz — es wurde zu viel Strom erzeugt (pumpen fehlte).'
: 'Unterfrequenz — es wurde zu wenig Strom erzeugt (turbinieren fehlte).';
var freqTxt = (d.freq != null) ? String(d.freq).replace('.', ',') + ' Hz' : '—';
return ''
+ '<div class="run-card" style="border-left:4px solid #d8453a">'
+ '<div class="run-head">'
+ '<div><strong>🔌 Blackout · '+(d.setIcon||'⚡')+' '+esc(d.setTitle||d.setId||'?')+'</strong>'
+ '<div class="run-meta">'+dt+' · '+dur+'</div>'
+ '</div>'
+ '<div style="text-align:right;color:#d8453a;font-weight:700;font-size:.78rem">Netz<br>zusammengebrochen</div>'
+ '</div>'
+ '<div class="run-stats">'
+ '<span>Tag <b>'+(d.day||'?')+'</b>/'+(d.dayTotal||'?')+(d.dayTitle?' · '+esc(d.dayTitle):'')+'</span>'
+ '<span>Block <b>'+(d.block||'?')+'</b>/'+(d.blockTotal||'?')+'</span>'
+ '<span>Frequenz: <b>'+freqTxt+'</b></span>'
+ '</div>'
+ '<div style="margin-top:.4rem;font-size:.72rem;color:#c07a6b">'+causeTxt+'</div>'
+ '</div>';
}
var stars = Math.max(0, Math.min(5, r.stars || 0));
var starsHtml = _pctBadge(Math.round(stars/5*100));
var setIcon = d.setIcon || '⚡';
var setTitle = d.setTitle || d.setId || '?';
var blocksB = d.blocksBalanced || 0;
var blocksT = d.blocksTotal || 0;
var quote = blocksT > 0 ? Math.round(blocksB/blocksT*100) : 0;
var quoteColor = quote >= 87 ? '#5a8a5e' : quote >= 50 ? '#c9913a' : '#c07a6b';
// Tag-Tabelle
var perDay = Array.isArray(d.perDay) ? d.perDay : [];
var dayRows = perDay.map(function(day, i) {
var ok = day.balanced === day.blocks;
var icon = ok ? '🏆' : day.balanced >= 6 ? '✓' : day.balanced >= 4 ? '◐' : '⚠️';
var statColor = ok ? '#5a8a5e' : day.balanced >= 4 ? '#c9913a' : '#c07a6b';
var notes = [];
if (day.gap > 0.5) notes.push('<span style="color:#c07a6b">'+day.gap+' GWh Lücke</span>');
if (day.surplus > 0.5) notes.push('<span style="color:#c9913a">'+day.surplus+' GWh Überschuss</span>');
return '<tr><td style="width:2em;color:#8a8a8a">'+(i+1)+'.</td>'
+ '<td>'+icon+' '+esc(day.dayTitle||day.dayId||'?')+'</td>'
+ '<td style="color:'+statColor+';font-weight:700">'+day.balanced+'/'+day.blocks+'</td>'
+ '<td>'+(notes.join(' · ')||'<span style="color:#8a8a8a"></span>')+'</td></tr>';
}).join('');
return ''
+ '<div class="run-card">'
+ '<div class="run-head">'
+ '<div><strong>'+setIcon+' Set '+esc(setTitle)+'</strong>'
+ '<div class="run-meta">'+dt+' · '+dur+' · '+(d.daysPlayed||0)+' Tage'+((d.blackouts||0)>0 ? ' · <span style="color:#d8453a;font-weight:700">🔌 '+d.blackouts+' Blackout'+(d.blackouts===1?'':'s')+'</span>' : '')+'</div>'
+ '</div>'
+ '<div style="text-align:right">'+starsHtml+'<div style="font-size:.7rem;color:'+quoteColor+';font-weight:700;margin-top:.2rem">'+quote+'% ausgegl.</div></div>'
+ '</div>'
+ '<div class="run-stats">'
+ '<span><b>'+blocksB+'</b>/'+blocksT+' Blöcke ausgeglichen</span>'
+ '<span>Σ Lücke: <b>'+(d.totalGap||0)+' GWh</b></span>'
+ '<span>Σ Überschuss: <b>'+(d.totalSurplus||0)+' GWh</b></span>'
+ '</div>'
+ (dayRows
? '<table class="run-steps"><thead><tr><th>#</th><th>Tag</th><th>Balance</th><th>Anmerkung</th></tr></thead><tbody>'+dayRows+'</tbody></table>'
: '')
+ '</div>';
}
function renderEuWerkstattRun(dt, dur, r, d) {
var stars = Math.max(0, Math.min(5, r.stars || 0));
var starsHtml = _pctBadge(Math.round(stars/5*100));
var endTypeLabel = ({success:'🏆 Gewonnen', mixed:'⚖️ Teilweise', rejected:'❌ Verloren'})[d.endType] || '—';
var endTypeColor = ({success:'#5a8a5e', mixed:'#c9913a', rejected:'#c07a6b'})[d.endType] || '#8a8a8a';
var diffLabel = d.difficulty === 'expert' ? '🦉 Profi' : '🌱 Einfach';
var stepsTotal = d.stepsTotal || 0;
var attempts = d.attemptsByStep || [];
var detours = d.detoursByStep || [];
var reshuffles = d.reshufflesByStep || [];
// Schritt-für-Schritt-Tabelle
var stepRows = '';
for (var i = 0; i < stepsTotal; i++) {
var n = attempts[i] || 1;
var rs = reshuffles[i] || 0;
var clean = (n === 1 && !rs); // sauber = ein Zug, kein Herumprobieren
var stepDet = (detours[i] || []).map(function(x){
var ic = x.kind === 'muelleimer' ? '🗑️' : '🔁';
return '<span class="run-detour '+(x.kind === 'muelleimer'?'kind-muelleimer':'kind-soft')+'">'+ic+' '+esc(x.label||'')+'</span>';
}).join('');
if (rs > 0) stepDet += '<span class="run-detour kind-soft">🔄 '+rs+' Probierzug'+(rs===1?'':'e')+'</span>';
var tag = clean
? '<span style="color:#5a8a5e;font-weight:700">✓ auf Anhieb</span>'
: (n > 1
? '<span style="color:#c9913a;font-weight:700">'+n+'. Versuch</span>'
: '<span style="color:#c9913a;font-weight:700">🔄 nach Probieren</span>');
stepRows += '<tr><td style="width:2em;color:#8a8a8a">'+(i+1)+'.</td><td>'+tag+'</td><td>'+(stepDet||'—')+'</td></tr>';
}
return ''
+ '<div class="run-card">'
+ '<div class="run-head">'
+ '<div><strong>'+(d.scenarioIcon||'')+' '+esc(d.scenarioTitle||d.scenarioId||'?')+'</strong>'
+ '<div class="run-meta">'+dt+' · '+dur+' · '+diffLabel+'</div>'
+ '</div>'
+ '<div style="text-align:right">'+starsHtml+'<div style="font-size:.7rem;color:'+endTypeColor+';font-weight:700;margin-top:.2rem">'+endTypeLabel+'</div></div>'
+ '</div>'
+ '<div class="run-stats">'
+ '<span><b>'+(d.firstTryHits||0)+'</b>/'+stepsTotal+' auf Anhieb</span>'
+ '<span><b>'+(d.totalAttempts||0)+'</b> Versuche</span>'
+ ((d.totalReshuffles||0)>0 ? '<span>🔄 <b>'+d.totalReshuffles+'</b> Probierzüge</span>' : '')
+ '<span><b>'+(d.muelleimerCount||0)+'</b> Mülleimer</span>'
+ '</div>'
+ (stepRows
? '<table class="run-steps"><thead><tr><th>#</th><th>Status</th><th>Sackgassen</th></tr></thead><tbody>'+stepRows+'</tbody></table>'
: '')
+ '</div>';
}
function renderBusfahrtRun(dt, dur, r, d) {
var stars = Math.max(0, Math.min(3, r.stars || 0)); // Busfahrt: 03-Skala
var starsHtml = _pctBadge(Math.round(stars/3*100));
var lvl = (d.level || 'l1').toString().toUpperCase();
var budget = d.results && d.results.budget != null ? d.results.budget : (d.budget != null ? d.budget : 0);
var earnings = d.results && d.results.earnings != null ? d.results.earnings : (d.earnings != null ? d.earnings : 0);
var won = (d.results && d.results.wonOrders) || d.wonOrders || 0;
var total = (d.results && d.results.totalOrders) || d.totalOrders || 0;
var quizFt = (d.results && d.results.quizFirstTry) || d.quizFirstTry || 0;
var budgetColor = budget >= 8000 ? '#5a8a5e' : budget >= 5000 ? '#c9913a' : '#c07a6b';
return ''
+ '<div class="run-card">'
+ '<div class="run-head">'
+ '<div><strong>🚌 Bus-Tour '+lvl+'</strong>'
+ '<div class="run-meta">'+dt+' · '+dur+'</div>'
+ '</div>'
+ '<div style="text-align:right">'+starsHtml+'<div style="font-size:.78rem;color:'+budgetColor+';font-weight:700;margin-top:.2rem">'+budget.toLocaleString('de-AT')+' €</div></div>'
+ '</div>'
+ '<div class="run-stats">'
+ '<span><b>'+won+'</b>/'+total+' Aufträge ✓</span>'
+ '<span>Verdienst: <b>'+(earnings>=0?'+':'')+earnings.toLocaleString('de-AT')+' €</b></span>'
+ '<span>🧠 <b>'+quizFt+'</b>/'+total+' Quiz auf Anhieb</span>'
+ '</div>'
+ '</div>';
}
function renderHeliRun(dt, dur, r, d) {
// Heli liefert results direkt im d (siehe submitHeliMission in game.html)
var res = d.results || d;
var stars = Math.max(0, Math.min(5, r.stars || (d.stars || 0)));
var starsHtml = _pctBadge(Math.round(stars/5*100));
var failed = !!res.failed;
var statusIcon = failed ? '🧑‍✈️' : '🚁';
var statusLbl = failed ? 'Chefpilot übernahm' : 'Einsatz erfolgreich';
var statusCol = failed ? '#c07a6b' : '#5a8a5e';
var missionTitle = res.missionTitle || res.missionId || '?';
var region = res.region || '';
var heliName = res.heliName || '';
var planPct = res.planPct != null ? res.planPct : 0;
var planCorrect = res.planCorrect || 0;
var planTotal = res.planTotal || 0;
var planStars = res.planStars || 0;
var startScore = res.startScore || 0;
var landingScore = res.landingScore|| 0;
var diff = res.difficulty || '';
var diffIcon = diff === 'hard' ? '🔴' : diff === 'medium' ? '🟡' : diff === 'easy' ? '🟢' : '';
var takeoverNotes = [];
if (res.planTakeover) takeoverNotes.push('Routenplanung-Übernahme');
if (res.startTakeover) takeoverNotes.push('Start-Übernahme'+(res.startTakeoverReason?' ('+esc(res.startTakeoverReason)+')':''));
var failNote = failed && res.failReason ? '<div style="margin-top:.4rem;color:#c07a6b;font-style:italic">'+esc(res.failReason)+'</div>' : '';
return ''
+ '<div class="run-card" style="border-left:4px solid '+statusCol+'">'
+ '<div class="run-head">'
+ '<div><strong>'+statusIcon+' '+esc(missionTitle)+'</strong>'
+ '<div class="run-meta">'+dt+' · '+dur+(region?' · '+esc(region):'')+(diffIcon?' · '+diffIcon:'')+'</div>'
+ '</div>'
+ '<div style="text-align:right">'+starsHtml+'<div style="font-size:.7rem;color:'+statusCol+';font-weight:700;margin-top:.2rem">'+statusLbl+'</div></div>'
+ '</div>'
+ '<div class="run-stats">'
+ '<span>🗺️ Navigation: <b>'+planCorrect+'</b>/'+planTotal+' ('+planPct+'%)</span>'
+ '<span>🛫 Start: <b style="color:'+_pctColor((startScore||0)/5*100)+'">'+Math.round((startScore||0)/5*100)+' %</b></span>'
+ '<span>🛬 Landung: <b style="color:'+_pctColor((landingScore||0)/5*100)+'">'+Math.round((landingScore||0)/5*100)+' %</b></span>'
+ (heliName ? '<span>'+esc(heliName)+'</span>' : '')
+ '</div>'
+ (takeoverNotes.length ? '<div style="margin-top:.4rem;color:#c9913a;font-size:.78rem">⚠ '+takeoverNotes.join(' · ')+'</div>' : '')
+ failNote
+ '</div>';
}
function renderFluggesellschaftRun(dt, dur, r, d) {
var stars = Math.max(0, Math.min(3, r.stars || 0)); // Fluggesellschaft: 03-Skala
var starsHtml = _pctBadge(Math.round(stars/3*100));
var lvl = (d.level || 'l1').toString().toUpperCase();
var res = d.results || d;
var budget = res.budget != null ? res.budget : 0;
var earnings = res.earnings != null ? res.earnings : 0;
var co2t = ((res.co2Kg || 0) / 1000).toFixed(1);
var won = res.wonOrders || 0;
var total = res.totalOrders || 0;
var quizFt = res.quizFirstTry || 0;
var budgetColor = budget >= 14000 ? '#5a8a5e' : budget >= 8000 ? '#c9913a' : '#c07a6b';
return ''
+ '<div class="run-card">'
+ '<div class="run-head">'
+ '<div><strong>✈ Flug-Tour '+lvl+'</strong>'
+ '<div class="run-meta">'+dt+' · '+dur+'</div>'
+ '</div>'
+ '<div style="text-align:right">'+starsHtml+'<div style="font-size:.78rem;color:'+budgetColor+';font-weight:700;margin-top:.2rem">'+budget.toLocaleString('de-AT')+' €</div></div>'
+ '</div>'
+ '<div class="run-stats">'
+ '<span><b>'+won+'</b>/'+total+' Flüge ✓</span>'
+ '<span>Verdienst: <b>'+(earnings>=0?'+':'')+earnings.toLocaleString('de-AT')+' €</b></span>'
+ '<span>🌍 <b>'+co2t+'</b> t CO₂</span>'
+ '<span>🧠 <b>'+quizFt+'</b>/'+total+' Quiz auf Anhieb</span>'
+ '</div>'
+ '</div>';
}
// === LIZENZEN ===
function fmtLicDate(d) { if (!d) return '—'; var dt = new Date(String(d).replace(' ','T')); return isNaN(dt) ? '—' : dt.toLocaleDateString('de-AT'); }
async function loadLicenses() {
var r = await api('licenses?class_id=' + currentClassId);
if (r.error) r = {assigned:[], free:[]};
var html = '<div class="card"><div class="stat-row">';
html += '<div class="stat"><div class="n">'+(r.free?.length||0)+'</div><div class="l">Freie Lizenzen</div></div>';
html += '<div class="stat"><div class="n">'+(r.assigned?.length||0)+'</div><div class="l">Zugewiesen</div></div>';
html += '</div>';
html += '<button class="btn" onclick="showModal(\'modal-license\')" style="margin-bottom:.8rem">🔑 Lizenzen einlösen</button>';
html += '</div>';
// Ohne gültige Lizenz — direkt zuweisbar
var unlic = r.unlicensed || [];
html += '<div class="card"><div class="card-h">Ohne gültige Lizenz ('+unlic.length+')</div>';
if (unlic.length > 0) {
var freeN = r.free ? r.free.length : 0;
html += '<p style="font-size:.72rem;color:#7a2318;margin-bottom:.5rem">Diese Schüler:innen können noch keine Simulationen nutzen.'+(freeN < unlic.length ? ' <b>Nur '+freeN+' freie Lizenz(en) verfügbar.</b>' : '')+'</p>';
if (freeN > 0) html += '<button class="btn" style="margin-bottom:.6rem" onclick=\'assignAllUnlicensed('+JSON.stringify(unlic.map(function(s){return s.id;}))+')\'>Allen eine freie Lizenz zuweisen</button>';
html += '<table class="students"><tr><th>Schüler*in</th><th></th></tr>';
unlic.forEach(function(s) {
html += '<tr><td>'+esc(s.display_name||s.username)+'</td><td>'
+ (freeN>0 ? '<button class="btn-sm" onclick="assignLicenseToStudent('+s.id+')">Lizenz zuweisen</button>' : '<span style="color:#8a8a8a;font-size:.68rem">keine freie Lizenz</span>')
+ '</td></tr>';
});
html += '</table>';
} else {
html += '<p style="font-size:.72rem;color:#5a8a5e">Alle Schüler:innen haben eine gültige Lizenz. ✓</p>';
}
html += '</div>';
// Zugewiesene Lizenzen
html += '<div class="card"><div class="card-h">Zugewiesene Lizenzen</div>';
if (r.assigned && r.assigned.length > 0) {
html += '<table class="students"><tr><th>#</th><th>Schüler*in</th><th>Lizenzcode</th><th>Eingelöst am</th><th>Gültig bis</th></tr>';
r.assigned.forEach(function(l, i) {
html += '<tr><td>'+(i+1)+'</td><td>'+esc(l.student_display || l.student_name)+'</td>';
html += '<td style="font-family:\'Courier New\',monospace;font-size:.7rem;letter-spacing:.03em">'+l.code+'</td>';
html += '<td>'+fmtLicDate(l.redeemed_at)+'</td>';
html += '<td>'+fmtLicDate(l.valid_until)+'</td></tr>';
});
html += '</table>';
} else {
html += '<p style="font-size:.72rem;color:#8a8a8a">Noch keine Lizenzen zugewiesen.</p>';
}
html += '</div>';
// Freie Lizenzen
html += '<div class="card"><div class="card-h">Freie Lizenzen (nicht zugewiesen)</div>';
if (r.free && r.free.length > 0) {
html += '<div style="max-height:250px;overflow-y:auto"><table class="students"><tr><th>#</th><th>Lizenzcode</th><th>Eingelöst am</th><th>Gültig bis</th></tr>';
r.free.forEach(function(l, i) {
html += '<tr><td>'+(i+1)+'</td><td style="font-family:\'Courier New\',monospace;font-size:.7rem;letter-spacing:.03em">'+l.code+'</td>';
html += '<td>'+fmtLicDate(l.redeemed_at)+'</td>';
html += '<td>'+fmtLicDate(l.valid_until)+'</td></tr>';
});
html += '</table></div>';
} else {
html += '<p style="font-size:.72rem;color:#8a8a8a">Keine freien Lizenzen. Neue Codes über das Textfeld oben einlösen.</p>';
}
html += '</div>';
document.getElementById('tab-licenses').innerHTML = html;
checkLicenseWarning();
}
async function assignLicenseToStudent(studentId) {
var r = await api('licenses', {action:'auto_assign', studentId:studentId});
if (r.error) { alert(r.error); return; }
loadLicenses();
}
async function assignAllUnlicensed(ids) {
if (!ids || !ids.length) return;
if (!confirm(ids.length + ' Schüler:innen je eine freie Lizenz zuweisen?')) return;
var failed = null;
for (var i = 0; i < ids.length; i++) {
var r = await api('licenses', {action:'auto_assign', studentId:ids[i]});
if (r.error) { failed = r.error; break; }
}
if (failed) alert('Nicht alle konnten zugewiesen werden: ' + failed);
loadLicenses();
}
// === BULK SELECTION ===
function bulkUpdate() {
var checked = document.querySelectorAll('.bulk-cb:checked');
var bar = document.getElementById('bulk-bar');
if (checked.length > 0) {
bar.classList.remove('hidden');
bar.style.display = 'flex';
document.getElementById('bulk-count').textContent = checked.length;
} else {
bar.classList.add('hidden');
bar.style.display = 'none';
}
}
function bulkToggleAll(masterCb) {
document.querySelectorAll('.bulk-cb:not(:disabled)').forEach(function(cb) { cb.checked = masterCb.checked; });
bulkUpdate();
}
function bulkSelectNone() {
document.querySelectorAll('.bulk-cb').forEach(function(cb) { cb.checked = false; });
var master = document.querySelector('th input[type=checkbox]');
if (master) master.checked = false;
bulkUpdate();
}
function bulkSelectWithout() {
document.querySelectorAll('.bulk-cb:not(:disabled)').forEach(function(cb) { cb.checked = true; });
bulkUpdate();
}
async function bulkAssignLicense() {
var ids = [];
document.querySelectorAll('.bulk-cb:checked').forEach(function(cb) { ids.push(parseInt(cb.value)); });
if (ids.length === 0) return;
if (!confirm(ids.length + ' Schüler*innen eine Lizenz zuweisen?')) return;
var ok = 0, fail = 0;
for (var i = 0; i < ids.length; i++) {
var r = await api('licenses', {action:'auto_assign', studentId:ids[i]});
if (r.error) fail++; else ok++;
}
var msg = ok + ' Lizenzen zugewiesen.';
if (fail) msg += ' ' + fail + ' fehlgeschlagen (keine freien Lizenzen?).';
alert(msg);
loadStudents();
loadLicenses();
}
async function assignLicense(studentId) {
var r = await api('licenses', {action:'auto_assign', studentId:studentId});
if (r.error) { alert(r.error); return; }
loadStudents();
loadLicenses();
}
// Sicherer Datei-Import fuer Codes — mehrstufig:
// (1) Endung+MIME .txt/.csv, (2) Groessenlimit, (3) NUR als Text lesen,
// (4) Whitelist-Regex extrahiert ausschliesslich Code-Tokens (nichts wird
// interpretiert/gerendert/ausgefuehrt), (5) Server validiert jeden Code.
function extractCodes(text){
var m = String(text).toUpperCase().match(/\b[A-Z0-9]{4}(?:-[A-Z0-9]{4}){2,4}\b/g) || [];
var seen={}, out=[];
for (var i=0;i<m.length;i++){ var c=m[i]; if(!seen[c]){ seen[c]=1; out.push(c); } if(out.length>=5000) break; }
return out;
}
function ingestLicenseFile(file){
var info=document.getElementById('ml-fileinfo'); if(!file) return;
var name=(file.name||'').toLowerCase();
if(!/\.(txt|csv)$/.test(name) || (file.type && !/^text\//.test(file.type))){
info.innerHTML='<span style="color:#c07a6b">Nur .txt oder .csv erlaubt.</span>'; return; }
if(file.size > 256*1024){ info.innerHTML='<span style="color:#c07a6b">Datei zu groß (max 256 KB).</span>'; return; }
var reader=new FileReader();
reader.onload=function(){
var codes=extractCodes(reader.result);
if(!codes.length){ info.innerHTML='<span style="color:#c07a6b">Keine gültigen Codes gefunden.</span>'; return; }
document.getElementById('ml-codes').value=codes.join('\n');
info.innerHTML='<span style="color:#5a8a5e">'+codes.length+' Codes aus Datei übernommen.</span>';
};
reader.onerror=function(){ info.innerHTML='<span style="color:#c07a6b">Datei konnte nicht gelesen werden.</span>'; };
reader.readAsText(file);
}
function licenseFilePick(input){ if(input.files && input.files[0]) ingestLicenseFile(input.files[0]); input.value=''; }
function licenseDrop(ev){ ev.preventDefault(); if(ev.currentTarget) ev.currentTarget.style.background=''; var f=ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files[0]; if(f) ingestLicenseFile(f); }
async function redeemLicenses() {
var text = document.getElementById('ml-codes').value.trim();
if (!text) return;
var codes = text.split(/[\n,;]+/).map(function(c){return c.trim();}).filter(Boolean);
var r = codes.length === 1
? await api('licenses', {action:'redeem', code:codes[0]})
: await api('licenses', {action:'redeem_batch', codes:codes});
var el = document.getElementById('license-result');
if (r.error) { el.innerHTML = '<span style="color:#c07a6b">'+esc(r.error)+'</span>'; return; }
var msg = r.redeemed ? r.redeemed + ' Lizenzen eingelöst.' : 'Lizenz eingelöst!';
el.innerHTML = '<span style="color:#5a8a5e">'+msg+'</span>';
if (r.errors?.length) el.innerHTML += '<br><span style="color:#c07a6b">'+r.errors.join('<br>')+'</span>';
document.getElementById('ml-codes').value = '';
loadLicenses();
}
</script>
<script src="assets/js/version-tag.js" defer></script>
</body>
</html>