f6df2eb79f
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4833 lines
282 KiB
HTML
4833 lines
282 KiB
HTML
<!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)}
|
||
}
|
||
|
||
/* 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> Live<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('results')">Ergebnisse</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','results','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
|
||
: (_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();
|
||
return renderLive(); // 'aktuell'
|
||
}
|
||
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">📊 Vergleich</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>';
|
||
}
|
||
|
||
var LIVE_PRIMARY_FIELDS = {
|
||
'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>';
|
||
}},
|
||
{k:'dishOrigin', label:'Herkunft', align:'left', fmt:function(v){
|
||
return v ? esc(v) : '<span style="color:#8a8a8a">—</span>';
|
||
}},
|
||
{k:'ingSolved', label:'Zutaten', 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>';
|
||
}},
|
||
{k:'mistakesInDish', label:'Fehlklicks', 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:'__prog', label:'Fortschritt', align:'left', fmt:function(v, row){ return _liveProgressBar(row.module_id, row.state); }},
|
||
{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'},
|
||
],
|
||
// 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 →</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 · Mo–So · 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;
|
||
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 '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>';
|
||
var map = { good:['#5a8a5e','Gut'], mid:['#c9913a','OK'], bad:['#c07a6b','Kritisch'] };
|
||
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>';
|
||
}
|
||
|
||
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) {
|
||
var keySet = {};
|
||
grp.rows.forEach(function(r) {
|
||
var s = r.state || {};
|
||
Object.keys(s).forEach(function(k) { 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>';
|
||
|
||
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';
|
||
html += '<td style="text-align:'+align+';padding:.4rem .5rem;font-variant-numeric:tabular-nums">'+_liveFmt(s[f.k], f.unit, f, ls)+'</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
|
||
var LIVE_SIM_SHORT = {
|
||
heli: 'Heli', klima: 'Klima', 'klima-3d': 'Klima 3D',
|
||
busfahrt: 'Bus', fluggesellschaft: 'Flug',
|
||
sonnensystem: 'Sonne', 'eu-werkstatt': 'EU', energiemanager: 'Energie',
|
||
farmer: 'Farmer', fluss: 'Fluss', logistik: 'Log', stadt: 'Stadt',
|
||
regenwald: 'Regenw.', staustufen: 'Stau', erdbeben: 'Erdb.',
|
||
vulkan: 'Vulkan', wal: 'Wal',
|
||
};
|
||
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';
|
||
}
|
||
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>< 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) {
|
||
// 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);
|
||
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,
|
||
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))+' · Vergleich</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)">Phase</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.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 simShort = LIVE_SIM_SHORT[sess.simId] || sess.simIcon || sess.simId;
|
||
var lbl = simShort + (sess.lessonLabel ? ' '+sess.lessonLabel : '');
|
||
var color = _liveSuccessColor(sess.success);
|
||
var tip = (sess.simTitle||sess.simId) + (sess.lessonLabel?' · '+sess.lessonLabel:'')
|
||
+ ' · ' + _fmtHM(sess.startMs) + '–' + _fmtHM(endMs)
|
||
+ ' · ' + _liveFmtMin(realDurSec)
|
||
+ (sess.stars!=null?' · '+sess.stars+'/5★':'');
|
||
var inner = (pctW < 3) ? '' : (pctW < 8) ? simShort : lbl;
|
||
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:2px;height:24px;'
|
||
+ 'background:'+color+';color:#fff;border-radius:3px;'
|
||
+ 'padding:0 .35rem;font-size:.62rem;font-weight:700;line-height:24px;'
|
||
+ 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;'
|
||
+ 'box-shadow:0 1px 2px rgba(0,0,0,.1)">'
|
||
+ esc(inner) + '</div>';
|
||
// Nach Abschluss immer die Sterne (gold) am Balkenende zeigen
|
||
if (sess.stars != null) {
|
||
var _sc = Math.max(0, Math.min(5, sess.stars));
|
||
var _starTxt = '★'.repeat(_sc) + '<span style="color:#d9d2be">' + '★'.repeat(5-_sc) + '</span>';
|
||
var _rightEdge = pctR > 82;
|
||
html += '<span style="position:absolute;'
|
||
+ (_rightEdge ? 'right:'+(100-pctR)+'%;padding-right:4px;' : 'left:'+pctR+'%;padding-left:3px;')
|
||
+ 'top:0;height:28px;line-height:28px;font-size:.62rem;color:#e0a838;font-weight:700;'
|
||
+ 'letter-spacing:.5px;white-space:nowrap;pointer-events:none">'+_starTxt+'</span>';
|
||
}
|
||
});
|
||
html += '</div>';
|
||
});
|
||
html += '</div>';
|
||
|
||
html += '<div style="margin-top:.8rem;display:flex;gap:.6rem;flex-wrap:wrap;font-size:.65rem;color:#5a5a5a">';
|
||
[['good','★ ≥ 4 — gut'],['mid','★ 2-3 — mittel'],['bad','★ ≤ 1 — schwach'],['aborted','abgebrochen'],['running','läuft noch']].forEach(function(p){
|
||
html += '<span><span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:'+_liveSuccessColor(p[0])+';vertical-align:middle;margin-right:.25rem"></span>'+p[1]+'</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>';
|
||
}
|
||
|
||
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>';
|
||
// 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.
|
||
|
||
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('tab-results');
|
||
host.innerHTML = '<p style="font-size:.78rem;color:#8a8a8a;padding:1rem">Lade Ergebnisse…</p>';
|
||
|
||
// Drei parallele API-Calls: Fleiß (NEU oben) + Matrix + Activity (alt)
|
||
var [fleiss, matrix, activity] = await Promise.all([
|
||
api('results?class_id=' + currentClassId + '&view=fleiss&range=' + _resultsState.fleissRange + '&module_id=' + encodeURIComponent(_resultsState.fleissModule)),
|
||
api('results?class_id=' + currentClassId + '&view=matrix'),
|
||
api('results?class_id=' + currentClassId + '&view=activity&range=' + _resultsState.range + '&groupBy=' + (_resultsState.groupBy || 'module')),
|
||
]);
|
||
|
||
if (matrix.error) { host.innerHTML = '<p style="color:#c07a6b">'+esc(matrix.error)+'</p>'; return; }
|
||
if (activity.error) { host.innerHTML = '<p style="color:#c07a6b">'+esc(activity.error)+'</p>'; return; }
|
||
// fleiss-Error nicht fatal — view ist neu, alte Backends antworten ohne Daten
|
||
|
||
_resultsState.fleiss = (fleiss && !fleiss.error) ? fleiss : null;
|
||
_resultsState.matrix = matrix;
|
||
_resultsState.activity = activity;
|
||
renderResults();
|
||
}
|
||
|
||
function renderResults() {
|
||
var host = document.getElementById('tab-results');
|
||
host.innerHTML = ''
|
||
+ '<div id="res-fleiss"></div>'
|
||
+ '<div id="res-matrix"></div>'
|
||
+ '<div id="res-activity"></div>'
|
||
+ '<div id="res-modules"></div>';
|
||
renderFleiss();
|
||
renderMatrix();
|
||
renderActivity();
|
||
renderModuleCards();
|
||
}
|
||
|
||
// ---------- Sektion 0 (NEU oben): Fleiß pro Schüler:in ----------
|
||
// Layout: Matrix mit Zeilen=Schüler:innen, Spalten=Zeitbuckets (Mo–So
|
||
// 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> (<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>
|