Files
geograsim/App/teacher.html
T
Adminator ad8c6b7d7e Gesamt: "Jetzt live"-Streifen oben (laufende Spiele, blinkend)
In Ergebnisse/Gesamt fehlten laufende Sessions (Fleiss baut nur auf fertigen
Laeufen). Neuer blinkender Live-Streifen ganz oben aus live_sessions: Name +
Sim-Symbol + Laufzeit, Klick = zuschauen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 14:55:32 +02:00

5114 lines
300 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>
<!-- 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):</p>
<textarea id="ml-codes" rows="4" style="font-family:'Courier New',monospace" placeholder="ABCD-EFGH-JKLM-2026"></textarea>
<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; }
// "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;
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; loadLive(); };
window.shiftLiveDate= function(n){
var d = new Date(_liveDate + 'T12:00:00'); d.setDate(d.getDate()+n);
_liveDate = d.toISOString().slice(0,10); loadLive();
};
window.liveDateToday= function(){ _liveDate = new Date().toISOString().slice(0,10); loadLive(); };
function setLiveDate(dateStr) {
_liveDate = dateStr;
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) {
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;
if (goodWhen === 'low') {
c = pct <= 33 ? '#5a8a5e' : pct <= 66 ? '#c9913a' : '#c07a6b';
} else if (goodWhen === 'mid') {
c = '#7a7a7a';
} else {
c = pct >= 60 ? '#5a8a5e' : pct >= 30 ? '#c9913a' : '#c07a6b';
}
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(val)+'</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 === 'low') return pct <= 33 ? '#5a8a5e' : pct <= 66 ? '#c9913a' : '#c07a6b';
if (goodWhen === 'mid') return '#9a9a9a';
return pct >= 60 ? '#5a8a5e' : pct >= 30 ? '#c9913a' : '#c07a6b';
}
// 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>';
}
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:'tick', label:'Tick'},
{k:'year', label:'Jahr'},
{k:'budget', label:'Budget', unit:'Mio €'},
{k:'co2', label:'CO₂', unit:'ppm'},
{k:'temp', label:'Temp', unit:'°C'},
{k:'floodedPct', label:'Geflutet', fmt:function(v){ return _liveBar(v, 0, 100, 'low'); }},
{k:'population', label:'Bev.'},
{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 === 'plan') return '<span style="color:#8a8a8a">plant</span>'; return s.blockTotal ? (v+1)+' <span style="color:#8a8a8a">/ '+s.blockTotal+'</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:'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:'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 : '—'; }},
],
'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 = {plan:'🗺️ Routenplan', 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>';
}},
{k:'startScore', label:'🛫 Start', fmt:function(v){
var n = Math.max(0, Math.min(5, v||0));
var c = n>=4 ? '#5a8a5e' : n>=2 ? '#c9913a' : '#c07a6b';
return v==null ? '—' : '<span style="color:'+c+';font-weight:700">'+'★'.repeat(n)+'<span style="color:#ccc">'+'★'.repeat(5-n)+'</span></span>';
}},
{k:'landingScore', label:'🛬 Landung', fmt:function(v){
var n = Math.max(0, Math.min(5, v||0));
var c = n>=4 ? '#5a8a5e' : n>=2 ? '#c9913a' : '#c07a6b';
return v==null ? '—' : '<span style="color:'+c+';font-weight:700">'+'★'.repeat(n)+'<span style="color:#ccc">'+'★'.repeat(5-n)+'</span></span>';
}},
{k:'totalStars', label:'⭐ Gesamt', fmt:function(v){
var n = Math.max(0, Math.min(5, v||0));
var c = n>=4 ? '#5a8a5e' : n>=2 ? '#c9913a' : '#c07a6b';
return v==null ? '—' : '<span style="color:'+c+';font-weight:800;font-size:.82rem">'+'★'.repeat(n)+'<span style="color:#ccc">'+'★'.repeat(5-n)+'</span></span>';
}},
{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>';
return '<span style="color:#8a8a8a">…</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:'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 ? new Date(s.lastSeen).toLocaleTimeString('de-AT',{hour:'2-digit',minute:'2-digit'}) : '';
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 ? new Date(it.recordedAt).toLocaleString('de-AT', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) : '';
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">' + esc(it.emoji + ' ' + (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;
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;
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 ((s.floodedPct==null?100:s.floodedPct) < 20 && (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;
}
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>';
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 = {};
sessions.forEach(function(ls) {
if (!byModule[ls.module_id]) byModule[ls.module_id] = { meta: ls, rows: [] };
byModule[ls.module_id].rows.push(ls);
});
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>';
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 paused = !_liveIsActive(ls);
var dotColor = lastSeen < 8 ? '#5a8a5e' : lastSeen < 20 ? '#e8833a' : '#c07a6b';
var s = ls.state || {};
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>';
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>';
// 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) {
v = Math.max(0, Math.min(100, v || 0));
return v >= 80 ? '#1f7a3d' : v >= 65 ? '#7cb342' : v >= 50 ? '#f9c33b' : v >= 35 ? '#ef8b2c' : '#d8453a';
}
function _liveSuccessIcon(stars, success) {
if (success === 'aborted') return '✗';
if (success === 'running') return '⏳';
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))+' · Vergleich</div>'
+ '<p style="font-size:.78rem;color:#8a8a8a">Noch niemand arbeitet gerade an dieser Simulation. Sobald Kinder starten, erscheinen sie hier im Vergleich.</p></div>';
} else if (sim === 'klima') {
html += _renderKlimaCompareBody(simSessions);
} else {
html += _renderGenericCompareBody(simSessions, sim);
}
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;
}
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, dann auf volle
// Stundenränder runden und 10 min Polster. Wenn niemand aktiv war,
// zeigen wir 08 14 Uhr als Standard-Bereich.
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; }
// Auf volle 30-min-Schritte runden + 15 min Polster
var winStart = Math.floor((minMs - 15*60*1000) / (30*60*1000)) * (30*60*1000);
var winEnd = Math.ceil ((maxMs + 15*60*1000) / (30*60*1000)) * (30*60*1000);
if (winEnd - winStart < 60*60*1000) winEnd = winStart + 60*60*1000;
var winSpanMs = winEnd - winStart;
function _msToPct(ms) { return ((ms - winStart) / winSpanMs) * 100; }
function _fmtHM(ms) {
var d = new Date(ms);
return ('0'+d.getHours()).slice(-2) + ':' + ('0'+d.getMinutes()).slice(-2);
}
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>';
// Stunden-Marker an der Achse
var scaleMarks = [];
for (var t = winStart; t <= winEnd; t += 30*60*1000) {
var d = new Date(t);
var isHour = d.getMinutes() === 0;
scaleMarks.push({ ms: t, label: _fmtHM(t), isHour: isHour });
}
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">';
// 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);
var pctR = _msToPct(endMs);
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');
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 (Balken wächst ohnehin mit der Zeit).
var anim = inProgress ? 'animation:_tagblink 1.8s ease-in-out infinite;' : '';
var stateWord = inProgress ? 'läuft noch' : aborted ? 'abgebrochen' : 'fertig';
var tip = (sess.simTitle||sess.simId) + (sess.lessonLabel?' · '+sess.lessonLabel:'')
+ ' · ' + _fmtHM(sess.startMs) + '' + _fmtHM(endMs)
+ ' · ' + _liveFmtMin(realDurSec) + ' · ' + stateWord
+ (score!=null?' · Erfolg '+score+' %':'');
// Symbol immer sichtbar; ab etwas Breite zusätzlich der Sim-Name
var inner = (pctW < 2) ? '' : (pctW < 9 ? '<span style="font-size:.82rem">'+icon+'</span>'
: '<span style="font-size:.82rem">'+icon+'</span> <span style="font-weight:700">'+esc(simName)+'</span>');
var sessKey = s.studentId + ':' + sess.simId + ':' + (sess.lessonLabel||'_') + ':' + sess.startMs;
html += '<div onclick="openLiveSessionDetail(\''+sessKey+'\')" '
+ 'title="'+esc(tip)+'" '
+ 'style="position:absolute;left:'+pctL+'%;width:'+pctW+'%;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>';
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="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 = sess.studentAvatarSlug
? '<img src="assets/img/avatars/thumbs/avatar-'+sess.studentAvatarSlug+'.webp" alt="" style="width:2em;height:2em;border-radius:50%;object-fit:cover;vertical-align:middle">'
: '<span style="font-size:1.3rem">'+(sess.studentEmoji||'🧑‍🎓')+'</span>';
var startTime = new Date(sess.startMs).toLocaleString('de-AT',{hour:'2-digit',minute:'2-digit',day:'2-digit',month:'2-digit'});
var endTime = new Date(sess.endMs).toLocaleString('de-AT',{hour:'2-digit',minute:'2-digit'});
var dur = _liveFmtMin(sess.durationSec||0);
var stars = sess.stars;
var starsHtml = stars != null
? '<span style="color:#e8c547;font-size:1.1rem">'+'★'.repeat(stars)+'</span><span style="color:#ccc;font-size:1.1rem">'+'★'.repeat(5-stars)+'</span>'
: '<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', '★'.repeat(nested.startScore||0) + '☆'.repeat(5-(nested.startScore||0))]);
if (nested.landingScore != null)rows.push(['🛬 Landung', '★'.repeat(nested.landingScore||0) + '☆'.repeat(5-(nested.landingScore||0))]);
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?new Date(data.last_seen_ms).toLocaleTimeString('de-AT'):'?')+'</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);
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();
}
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:#3e9b63;--ivli:#7aa03a;--ivy:#dcae35;--ivo:#db8a33;--ivr:#cc4a37;'
+ '--ivns:#5b7fa6;--ivnm:#3f8f8f;--ivnw:#8f6f9c;--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:72px 1fr 28px 50px 46px 14px;gap:9px;padding:0 8px 3px;font-size:.52rem;font-weight:700;text-transform:uppercase;letter-spacing:.03em;color:var(--ivmut);border-bottom:1px solid var(--ivline)}'
+ '.iv-cols span:nth-child(2){justify-self:end}.iv-cols span:nth-child(3),.iv-cols span:nth-child(4){justify-self:center}.iv-cols span:nth-child(5){justify-self:end}'
+ '.iv-prow{display:grid;grid-template-columns:72px 1fr 28px 50px 46px 14px;gap:9px;align-items:center;width:100%;text-align:left;cursor:pointer;background:none;border:0;border-radius:8px;padding:7px 8px;font:inherit;color:inherit}'
+ '.iv-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:7px;justify-self:end}.iv-ebar{width:70px;height:9px;border-radius:5px;background:var(--ivtrack);overflow:hidden}.iv-ebar>i{display:block;height:100%;border-radius:5px}.iv-erf b{font-size:.78rem;font-weight:700;width:1.7em}'
+ '.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<38?'iv-r':v<50?'iv-o':v<62?'iv-y':v<75?'iv-li':'iv-g'; }
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"/>'};
var IV_META={support:['Fördern','zuletzt schwächere Läufe','--ivns'],more:['Fordern','zuletzt sicher, kaum gefordert','--ivnm'],
work:['Begleiten','zuletzt viel gearbeitet, wenig Ertrag','--ivnw'],good:['Bestätigen','zuletzt besser geworden','--ivngd'],idle:['Nicht aktiv','länger nicht mehr dran (oder noch nie)','--ivni']};
// 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.
function ivTriage(recErf, daysSince, recentPlays, rising){
if (daysSince == null || daysSince > 14) return 'idle'; // länger nicht dran / nie
if (recErf == null) return 'idle';
if (recErf < 48 && recentPlays >= 5) return 'work'; // zuletzt viel, wenig Ertrag
if (recErf < 48) return 'support'; // zuletzt schwach
if (recErf >= 65 && recentPlays <= 2) return 'more'; // zuletzt sicher, kaum gefordert
if (rising) return 'good'; // zuletzt besser geworden
return null; // läuft rund
}
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){
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.
var IV_WEEK_CAP=10800, 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; });
var groups={support:[],more:[],work:[],good:[],idle:[]}, calm=[], 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, rplays, rising);
var row={sid:sid, name:s.displayName, erf:recErf, status:s.status, secs:secs, days:days};
if(g) groups[g].push(row); else calm.push(s.displayName);
});
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
var need=groups.support.length+groups.more.length+groups.work.length+groups.good.length+groups.idle.length;
h+='<div class="iv-sec">Empfohlene Aktivität für dich.<span>'+need+' von '+studentsCount+'</span></div><div class="iv-grid">';
['support','more','work','good','idle'].forEach(function(key){
var rows=groups[key];
var m=IV_META[key];
h+='<div class="iv-card" style="--iva:var('+m[2]+')"><div class="iv-chead"><span class="iv-ic"><svg viewBox="0 0 24 24">'+IV_ICON[key]+'</svg></span><div class="iv-ct"><h4>'+m[0]+'</h4><p>'+m[1]+'</p></div><span class="iv-cn num">'+rows.length+'</span></div>';
if(rows.length){
h+='<div class="iv-cols"><span></span><span title="Ø der letzten 3 Läufe, 0 100">Erfolg</span><span title="arbeitet gerade? blinkt live">Aktiv</span><span title="aktive Zeit pro Woche">Verlauf</span><span title="Tage seit dem letzten Lauf">Zuletzt</span><span></span></div>';
rows.forEach(function(r){
h+='<button class="iv-prow" onclick="ivOpenStudent('+r.sid+')"><span class="iv-nm">'+esc(r.name)+'</span><span style="display:contents">'+ivErf(r.erf)+ivAkt(r.status)+ivSpark(r.secs,maxSec,IV_WEEK_CAP)+'</span>'+ivZuletzt(r.days)+'<svg class="iv-chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></svg></button>';
});
} else {
h+='<div class="iv-cempty">Aktuell niemand betroffen.</div>';
}
h+='</div>';
});
h+='</div>';
if(calm.length) h+='<div class="iv-calm"><span class="iv-dot"></span><span><b>Läuft rund — '+calm.length+'</b></span><span class="iv-names">'+calm.map(esc).join(' · ')+'</span></div>';
// 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 (typeof ggsStarsHtml==='function') ? ggsStarsHtml(n) : ('★'.repeat(Math.max(0,Math.min(5,Math.round(n)))) ); }
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 = (typeof ggsStarsHtml === 'function') ? ggsStarsHtml : function(n){ return '★'.repeat(Math.max(0,Math.min(5,n))); };
// 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] = 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),
]);
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);
// === 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 = {};
if (licData.assigned) licData.assigned.forEach(function(l) { assignedMap[l.student_id] = l.code; });
// Count without license
var withoutLicense = students.filter(function(s) { return !assignedMap[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 lic = assignedMap[s.id];
var licHtml = lic ? '<span style="font-family:monospace;font-size:.6rem;color:#5a8a5e">'+lic+'</span>' : '<span style="font-size:.6rem;color:#c07a6b">—</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()"'+(lic?' disabled title="Hat bereits 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, live] = 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')),
api('live?class_id=' + currentClassId),
]);
_resultsState.live = Array.isArray(live) ? live : [];
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-live"></div>'
+ '<div id="res-fleiss"></div>'
+ '<div id="res-matrix"></div>'
+ '<div id="res-activity"></div>'
+ '<div id="res-modules"></div>';
renderResultsLive();
renderFleiss();
renderMatrix();
renderActivity();
renderModuleCards();
}
// „Jetzt live"-Streifen ganz oben in Gesamt: laufende Spiele (aus live_sessions),
// als langsam blinkende Blöcke — sonst fehlen sie hier (Fleiß baut nur auf fertigen Läufen).
function renderResultsLive() {
var host = document.getElementById('res-live'); if (!host) return;
var live = (_resultsState.live || []).filter(function(ls){ return ls.last_seen_ms && (Date.now() - ls.last_seen_ms) < 90000; });
if (!live.length) { host.innerHTML = ''; return; }
var html = '<div class="card" style="border-color:#e0b3aa">'
+ '<div class="card-h" style="color:#b03a2a"><span style="display:inline-block;width:9px;height:9px;border-radius:50%;background:#c0392b;margin-right:.45rem;animation:_tagblink 1.4s ease-in-out infinite"></span>Jetzt live · '+live.length+'</div>'
+ '<div style="display:flex;flex-wrap:wrap;gap:.5rem">';
live.forEach(function(ls){
var el = ls.started_at_ms ? Math.max(0, Math.round((Date.now() - ls.started_at_ms)/60000)) : 0;
var av = (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.4em;height:1.4em;border-radius:50%;object-fit:cover">'
: '<span style="font-size:1.05rem">'+(ls.emoji || '🧑‍🎓')+'</span>';
html += '<button onclick="openLiveSpectator('+ls.student_id+',\''+ls.module_id+'\')" title="Live zuschauen" '
+ 'style="animation:_tagblink 1.9s ease-in-out infinite;cursor:pointer;background:#fdeae4;border:1px solid #eabfb4;border-radius:10px;padding:.4rem .7rem;display:inline-flex;align-items:center;gap:.4rem;font-size:.76rem">'
+ av + '<b>'+esc(ls.displayName)+'</b>'
+ '<span style="color:#8a5a52">'+esc((ls.moduleIcon||'📚')+' '+(ls.moduleName||ls.module_id))+' · '+el+' min</span></button>';
});
html += '</div></div>';
host.innerHTML = html;
}
// ---------- 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>';
});
html += '<div style="height:'+maxBarHeight+'px;display:flex;align-items:flex-end;justify-content:center" title="'+title+'">';
html += '<div style="width:65%;height:'+hPx+'px;display:flex;flex-direction:column-reverse;border-radius:2px;overflow:hidden;box-shadow:0 0 0 1px rgba(0,0,0,.05)">' + 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\')">Sterne '+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 = '★'.repeat(stars) + '<span style="opacity:.3">'+'★'.repeat(5-stars)+'</span>';
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
? '<span class="run-stars">'+'★'.repeat(stars)+'<span class="run-stars-empty">'+'★'.repeat(5-stars)+'</span></span>'
: '<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 Math.round(v) + '★';
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 = '<span class="run-stars">'+'★'.repeat(stars)+'<span class="run-stars-empty">'+'★'.repeat(5-stars)+'</span></span>';
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) {
var stars = Math.max(0, Math.min(5, r.stars || 0));
var starsHtml = '<span class="run-stars">'+'★'.repeat(stars)+'<span class="run-stars-empty">'+'★'.repeat(5-stars)+'</span></span>';
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</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 = '<span class="run-stars">'+'★'.repeat(stars)+'<span class="run-stars-empty">'+'★'.repeat(5-stars)+'</span></span>';
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 || [];
// Schritt-für-Schritt-Tabelle
var stepRows = '';
for (var i = 0; i < stepsTotal; i++) {
var n = attempts[i] || 1;
var firstTry = (n === 1);
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('');
var tag = firstTry
? '<span style="color:#5a8a5e;font-weight:700">✓ auf Anhieb</span>'
: '<span style="color:#c9913a;font-weight:700">'+n+'. Versuch</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>'
+ '<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(5, r.stars || 0));
var starsHtml = '<span class="run-stars">'+'★'.repeat(stars)+'<span class="run-stars-empty">'+'★'.repeat(5-stars)+'</span></span>';
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 = '<span class="run-stars">'+'★'.repeat(stars)+'<span class="run-stars-empty">'+'★'.repeat(5-stars)+'</span></span>';
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>'+'★'.repeat(startScore)+'</b></span>'
+ '<span>🛬 Landung: <b>'+'★'.repeat(landingScore)+'</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(5, r.stars || 0));
var starsHtml = '<span class="run-stars">'+'★'.repeat(stars)+'<span class="run-stars-empty">'+'★'.repeat(5-stars)+'</span></span>';
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 ===
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>';
// 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>Schüler*in</th><th>Lizenzcode</th><th>Schuljahr</th></tr>';
r.assigned.forEach(function(l) {
html += '<tr><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>'+l.school_year+'</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>Lizenzcode</th><th>Schuljahr</th></tr>';
r.free.forEach(function(l) {
html += '<tr><td style="font-family:\'Courier New\',monospace;font-size:.7rem;letter-spacing:.03em">'+l.code+'</td>';
html += '<td>'+l.school_year+'</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;
}
// === 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();
}
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>