Files
geograsim/App/schueler.html
T
Adminator ff889865e1 Lizenz-Gate: Schueler ohne Lizenz gesperrt + Lehrer-Cockpit-Warnung
Teil 1 — Schueler-Lizenz-Gate (gilt fuer alle Klassen-Schueler ohne gueltige
Lizenz = zugewiesen, nicht storniert, nicht abgelaufen):
- Server-Gate zentral im Front-Controller (index.php + php/lib/license_gate.php):
  eingeloggte Klassen-Schueler ohne Lizenz sehen bei JEDER Sim-Route statt der
  Simulation eine Hinweis-Seite "Deine Lehrperson muss dir noch eine gueltige
  Lizenz zuweisen". Schnell-Ausstieg fuer Nicht-Schueler/Nicht-Sim (kein Cookie
  -> keine DB-Abfrage). Autodidakt ohne Klasse: nicht lizenzpflichtig.
- modules.php?student=1 liefert 'licensed'; Schueler-Cockpit (schueler.html)
  zeigt oben die Meldung und sperrt die Start-Buttons.

Teil 2 — Lehrer-Cockpit:
- Rote Warnleiste ganz oben, wenn Schueler:innen ohne gueltige Lizenz in der
  Klasse sind (+ Link "Jetzt zuweisen"). licenses?class_id=X liefert dazu die
  Liste 'unlicensed'.
- Lizenzen-Lasche: neue Sektion "Ohne gueltige Lizenz" mit Einzel- und
  Sammel-Zuweisung (auto_assign) — sonst fuehrt die Warnung ins Leere.

Kunden-Vereinfachung (Feedback): keine Auto-Erkennung/Dedup mehr (Ansprech-
partner koennen wechseln). Kunde = Freitext "Name/Verlag/Schule" + Mailadresse,
je Bestellung gespeichert. Editierbares Kundennummer-Feld + Autocomplete entfernt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-26 16:15:16 +02:00

899 lines
50 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoGraSim — Meine Simulationen</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{background:#fff;padding:.55rem 1.2rem;display:flex;align-items:center;gap:.7rem;border-bottom:1px solid rgba(0,0,0,.06);position:sticky;top:0;z-index:10}
.topbar .logo{height:28px}
.topbar .spacer{flex:1}
.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}
.topbar .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}
.topbar .btn-sm:hover{background:#dae8ec;color:#4a7c8a}
.wrap{max-width:900px;margin:0 auto;padding:1.2rem 1rem}
/* Tabs */
.tabs{display:flex;gap:0;margin-bottom:1.2rem;border-radius:10px;overflow:hidden;border:1.5px solid rgba(0,0,0,.06);background:#fff}
.tab{flex:1;padding:.6rem;text-align:center;font-size:.78rem;font-weight:700;cursor:pointer;color:#8a8a8a;transition:all .15s;border:none;background:transparent;text-decoration:none}
.tab.on{background:#4a7c8a;color:#fff}
/* Module grid */
.mod-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1rem}
.mod-card{background:#fff;border-radius:12px;overflow:hidden;border:1.5px solid rgba(0,0,0,.05);transition:all .25s;display:flex;flex-direction:column}
.mod-card.spielbar{cursor:default}
.mod-card.locked{opacity:.55}
.mod-card.coming{opacity:.7}
.mod-card.coming .mod-img{filter:grayscale(.5)}
.coming-icon{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:1rem;background:rgba(255,255,255,.92);border-radius:8px;padding:.3rem .55rem;font-weight:700;color:#8a6a3a;white-space:nowrap}
.coming-divider{margin:1.4rem 0 .8rem;font-size:.74rem;font-weight:700;color:#8a8a8a;text-transform:uppercase;letter-spacing:.06em;padding:.4rem .2rem;border-top:1px solid rgba(0,0,0,.06)}
.mod-img{height:210px;background:linear-gradient(135deg,#dae8ec,#c8dce2);display:flex;align-items:center;justify-content:center;font-size:2.5rem;position:relative;overflow:hidden}
.mod-img img{width:100%;height:100%;object-fit:cover;transition:transform .35s}
.mod-card.spielbar:hover .mod-img img{transform:scale(1.04)}
.mod-body{padding:.7rem .8rem .9rem;flex:1;display:flex;flex-direction:column}
.mod-body h3{font-size:.92rem;font-weight:700;margin-bottom:.25rem;line-height:1.2}
.mod-body p{font-size:.72rem;color:#4a4a4a;margin-bottom:.6rem;line-height:1.4;flex:1}
.mod-actions{display:flex;flex-wrap:wrap;gap:.35rem;margin-top:auto}
.mod-btn{display:inline-flex;align-items:center;gap:.25rem;padding:.42rem .7rem;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 .15s;min-height:32px;white-space:nowrap}
.mod-btn:hover{background:#fff;border-color:rgba(74,124,138,.4);color:#1a1a1a}
.mod-btn.primary{background:#4a7c8a;color:#fff;border-color:#4a7c8a;flex:1;justify-content:center}
.mod-btn.primary:hover{background:#3d6671;color:#fff}
.lock-icon{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:1.5rem;background:rgba(255,255,255,.9);border-radius:8px;padding:.3rem .6rem}
/* Erfolge */
.badge-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:.6rem}
.badge{background:#fff;border-radius:10px;padding:.8rem;text-align:center;border:1.5px solid rgba(0,0,0,.04)}
.badge .icon{font-size:2rem;margin-bottom:.3rem}
.badge .icon img{width:48px;height:48px;object-fit:contain;display:block;margin:0 auto}
.badge.locked-b .icon img{filter:grayscale(.9)}
.badge .name{font-size:.72rem;font-weight:700}
.badge .desc{font-size:.6rem;color:#8a8a8a}
.badge.earned{border-color:#4a7c8a;background:linear-gradient(135deg,#f0f8fa,#fff)}
.badge.locked-b{opacity:.4}
.badge .desc{min-height:1.6em}
.sim-coll{margin-bottom:1.1rem}
.sim-coll-head{display:flex;align-items:center;gap:.5rem;margin:.5rem 0 .45rem;font-weight:700;font-size:.82rem}
.sim-coll-head .sc-ic{font-size:1.3rem}
.sim-coll-head .sc-t{flex:1}
.sim-coll-head .sc-stars{color:#e0a838;letter-spacing:1px;font-size:.82rem}
.sim-coll-head .sc-count{font-size:.68rem;color:#8a8a8a;background:#f0eeea;border-radius:99px;padding:.12rem .55rem;font-weight:700}
/* Erfolgs-Liste pro Modul */
.ach-summary{display:grid;grid-template-columns:repeat(3,1fr);gap:.6rem;margin-bottom:1.2rem}
.ach-summary .card{background:#fff;border-radius:10px;padding:.9rem .8rem;text-align:center;border:1.5px solid rgba(0,0,0,.05)}
.ach-summary .n{font-size:1.6rem;font-weight:900;color:#4a7c8a;line-height:1}
.ach-summary .l{font-size:.62rem;color:#8a8a8a;margin-top:.25rem;text-transform:uppercase;letter-spacing:.04em}
.ach-modules{display:flex;flex-direction:column;gap:.5rem;margin-bottom:1.4rem}
.ach-mod{background:#fff;border-radius:10px;padding:.7rem .9rem;display:flex;align-items:center;gap:.7rem;border:1.5px solid rgba(0,0,0,.05)}
.ach-mod .ic{font-size:1.5rem;width:36px;text-align:center;flex-shrink:0}
.ach-mod .info{flex:1;min-width:0}
.ach-mod .info h4{font-size:.85rem;font-weight:700;margin:0 0 .15rem 0}
.ach-mod .info .meta{font-size:.65rem;color:#8a8a8a}
.ach-mod .stats{display:flex;gap:.5rem;flex-shrink:0}
.ach-mod .stat-pill{background:#f0eeea;color:#4a4a4a;padding:.2rem .55rem;border-radius:6px;font-size:.62rem;font-weight:700}
.ach-mod .stat-pill.stars{background:#fff4d6;color:#a37800}
.ach-mod .stat-pill.lvl{background:#dae8ec;color:#4a7c8a}
.ach-empty{background:#fff;border-radius:10px;padding:1.5rem 1rem;text-align:center;border:1.5px dashed rgba(0,0,0,.1);color:#8a8a8a;font-size:.78rem}
/* Profil-Karte */
.profile-card{background:#fff;border-radius:12px;padding:1.5rem;text-align:center;border:1.5px solid rgba(0,0,0,.04)}
.profile-avatar{font-size:3.5rem;cursor:pointer;transition:transform .15s}
.profile-avatar:hover{transform:scale(1.15)}
.profile-name{font-size:1.1rem;font-weight:800;margin-top:.3rem}
.profile-class{font-size:.75rem;color:#8a8a8a}
.profile-stats{display:flex;justify-content:center;gap:1.5rem;margin-top:.8rem}
.profile-stat .n{font-size:1.3rem;font-weight:900;color:#4a7c8a}
.profile-stat .l{font-size:.6rem;color:#8a8a8a}
.hidden{display:none}
@media(max-width:550px){.mod-grid{grid-template-columns:1fr}.badge-grid{grid-template-columns:repeat(2,1fr)}}
/* Gemeinsames Sim-Kennzahlen-Schema (renderSimMetrics aus sim-metrics.js) */
.ivm-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:7px;margin-top:.5rem}
.ivm{background:#f4f1e8;border-radius:8px;padding:6px 9px;min-width:0}
.ivm-l{display:block;font-size:.58rem;color:#8a8a8a;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:#8a8a8a}.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:#ddd}
.ivm-bar{display:block;height:7px;border-radius:4px;background:#ece8dc;overflow:hidden;margin-top:3px}.ivm-bar>i{display:block;height:100%;border-radius:4px;background:currentColor}
.ivm-g{color:#4a7c4e}.ivm-li{color:#7fa856}.ivm-y{color:#e0a838}.ivm-o{color:#e08a3a}.ivm-r{color:#c0574a}
</style>
<script src="assets/js/badges.js"></script>
<script src="assets/js/sim-metrics.js"></script>
</head>
<body>
<div class="topbar">
<a href="schueler.html" aria-label="Cockpit"><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"><span class="emoji">🧑‍🎓</span><span id="user-name"></span></a>
<button class="btn-sm" onclick="logout()">Abmelden</button>
</div>
<div class="wrap">
<!-- Tabs -->
<div class="tabs">
<div class="tab on" data-tab="modules" onclick="switchTab('modules')">Module</div>
<div class="tab" data-tab="achievements" onclick="switchTab('achievements')">Erfolge</div>
<a class="tab" href="buch">Schulbuch</a>
<div class="tab" data-tab="profile" onclick="switchTab('profile')">Profil</div>
</div>
<div id="tab-modules"></div>
<div id="tab-achievements" class="hidden"></div>
<div id="tab-profile" class="hidden"></div>
</div>
<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 profile = null;
var modules = [];
// Module kommen aus /api/modules?student=1 (inkl. play_url, icon, name, mode,
// card_image, desc). Bild-Pfad ist ein Dateiname wie "card-farmer.webp" und wird
// gegen "assets/img/" relativ zur App-Basis aufgeloest.
var BADGES = [
{id:'first_sim', icon:'🎯', name:'Erste Simulation', desc:'Eine Simulation abgeschlossen'},
{id:'three_sims', icon:'🏅', name:'Dreifach', desc:'Drei verschiedene Simulationen gespielt'},
{id:'all_sims', icon:'🏆', name:'Komplett', desc:'Alle Module abgeschlossen'},
{id:'high_score', icon:'⭐', name:'Spitzenscore', desc:'Score über 80 in einer Simulation'},
{id:'speed_run', icon:'⚡', name:'Blitzschnell', desc:'Eine Simulation in unter 10 Minuten'},
{id:'explorer', icon:'🧭', name:'Entdecker', desc:'Alle Maßnahmen in einer Simulation ausprobiert'},
{id:'climate_hero', icon:'🌍', name:'Klimaheld', desc:'Temperaturanstieg unter 1.5°C gehalten'},
{id:'city_planner', icon:'🏙️', name:'Stadtplaner', desc:'Stadt mit über 90% Zufriedenheit gebaut'},
];
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'};
var r = await fetch(BASE + '/api/' + endpoint, opts);
return await r.json();
}
function logout() { api('auth', {action:'logout'}).then(function() { location.href = 'index.html'; }); }
function switchTab(tab) {
document.querySelectorAll('.tab[data-tab]').forEach(function(t) {
t.classList.toggle('on', t.getAttribute('data-tab') === tab);
});
document.getElementById('tab-modules').classList.toggle('hidden', tab !== 'modules');
document.getElementById('tab-achievements').classList.toggle('hidden', tab !== 'achievements');
document.getElementById('tab-profile').classList.toggle('hidden', tab !== 'profile');
}
async function init() {
var status = await api('auth', {action:'status'});
if (status.role !== 'student') { location.href = 'login.html'; return; }
document.getElementById('user-name').textContent = status.session?.display_name || 'Schüler';
// Load profile
var pr = await api('profile');
profile = pr.profile;
if (profile?.emoji_avatar) {
var v = profile.emoji_avatar;
var pillEmoji = document.querySelector('#user-pill .emoji');
if (typeof v === 'string' && v.indexOf('avatar:') === 0) {
var slug = v.substring(7);
pillEmoji.innerHTML = '<img src="assets/img/avatars/thumbs/avatar-' + slug + '.webp" alt="">';
} else {
pillEmoji.textContent = v;
}
}
await renderModules();
renderAchievements();
renderProfile();
// Hash-Routing: schueler.html#achievements / #profile → entsprechenden Tab aktiv.
// Profil-Seite verlinkt mit #achievements zurück, damit „Meine Erfolge" direkt
// landet. Default ohne Hash bleibt „modules".
var validTabs = {modules:1, achievements:1, profile:1};
var initialTab = (location.hash || '').replace('#','');
if (validTabs[initialTab]) switchTab(initialTab);
window.addEventListener('hashchange', function() {
var t = (location.hash || '').replace('#','');
if (validTabs[t]) switchTab(t);
});
}
function buildModuleUrl(mod) {
// Bevorzugt play_url aus module_info; Fallback: module_id
var base = mod.play_url || mod.id;
// Relativ zum aktuellen Front-Controller-Pfad — Apache-Rewrite macht den Rest
var url = base.indexOf('/') === 0 ? base : base;
if (mod.mode === 'teacher_started' && mod.forcedLevel) {
url += (url.indexOf('?') === -1 ? '?' : '&') + 'level=' + mod.forcedLevel;
}
return url;
}
// Module mit Schulbuch-Kapitel (pages/buch-<id>.php). klima-3d teilt sich das Klima-Kapitel.
var BUCH_KAP = {tourismustal:1,tourismusregion:1,kofferdetektiv:1,busfahrt:1,logistik:1,
weltkueche:1,farmer:1,klima:1,'klima-3d':'klima',fluss:1,energiemanager:1,heli:1,
sonnensystem:1,'eu-werkstatt':1};
function buchKapitel(id){ var t = BUCH_KAP[id]; if (!t) return null; return 'buch-' + (t === 1 ? id : t); }
async function renderModules() {
var html = '';
var modList = [];
try {
modList = await api('modules?student=1');
if (modList.error) modList = [];
} catch(e) {}
// Lizenz-Gate: ohne gültige Lizenz sind die Sims nicht startbar (Hinweis oben).
var licensed = !modList.length || modList[0].licensed !== false;
// Lehrer-gesteuerte Auftraege zuerst — erledigte Auftraege fallen automatisch
// in die regulaere Modul-Liste, damit der Auftrag aus dem Cockpit verschwindet,
// das Modul aber spielbar bleibt.
var teacherStarted = modList.filter(function(m) { return m.mode === 'teacher_started' && !m.assignmentCompleted; });
if (teacherStarted.length > 0) {
html += '<div style="margin-bottom:1.3rem">';
html += '<p style="font-size:.84rem;font-weight:800;color:#e8833a;margin-bottom:.55rem;display:flex;align-items:center;gap:.4rem">📋 Aufträge von deiner Lehrperson</p>';
html += '<div class="mod-grid">';
teacherStarted.forEach(function(m) { html += renderAuftragCard(m); });
html += '</div></div>';
}
// Auftrags-Card im Marken-Stil: Sim-Bild + Orange-Akzent, klar als Auftrag erkennbar.
function renderAuftragCard(m) {
var cardImg = m.card_image ? 'assets/img/' + m.card_image : null;
var imgHtml = cardImg ? '<img src="'+cardImg+'" alt="">' : '<span style="font-size:3rem">'+(m.icon||'📚')+'</span>';
var url = buildModuleUrl(m);
var badge = '<span style="position:absolute;top:8px;left:8px;background:#e8833a;color:#fff;font-size:.66rem;font-weight:800;padding:3px 11px;border-radius:9999px;letter-spacing:.04em;box-shadow:0 2px 5px rgba(0,0,0,.22);z-index:5">📋 Auftrag</span>';
var meta = [];
if (m.dueDate) meta.push('⏰ bis ' + new Date(m.dueDate).toLocaleDateString('de-AT',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}));
if (m.quizEnabled) meta.push('📝 mit kurzem Quiz');
var s = '<div class="mod-card spielbar" style="border-color:#e8833a;border-width:2px">';
s += '<div class="mod-img" style="position:relative">'+imgHtml+badge+'</div>';
s += '<div class="mod-body">';
s += '<h3 style="color:#c25e1e">'+(m.icon||'📚')+' '+m.name+(m.forcedLevel ? ' <span style="font-weight:600;color:#9a9a9a;font-size:.82rem">· Stufe '+m.forcedLevel+'</span>' : '')+'</h3>';
s += '<p>Auftrag von deiner Lehrperson'+(meta.length ? '<br><span style="color:#8a8a8a">'+meta.join(' · ')+'</span>' : '')+'</p>';
s += '<div class="mod-actions">';
s += licensed ? '<a href="'+url+'" class="mod-btn primary" style="background:#e8833a;border-color:#e8833a">▶ Jetzt starten</a>' : '<span class="mod-btn primary" style="background:#c9a99a;border-color:#c9a99a;opacity:.7;pointer-events:none">🔑 Lizenz nötig</span>';
s += '</div></div></div>';
return s;
}
// Regulaere Module: spielbar zuerst, "in Kürze" am Ende.
function renderModCard(m) {
var isComing = m.mode === 'coming_soon';
var isLocked = m.mode === 'locked';
var isSpielbar = !isComing && !isLocked;
var cardImg = m.card_image ? 'assets/img/' + m.card_image : null;
var imgHtml = cardImg ? '<img src="'+cardImg+'" alt="">' : '<span style="font-size:3rem">'+(m.icon||'📚')+'</span>';
var url = buildModuleUrl(m);
var moduleId = m.id;
var infoHref = 'modul-' + moduleId;
var glossarHref = 'glossar?module=' + moduleId;
var buchHref = buchKapitel(moduleId);
var cls = isComing ? 'coming' : (isLocked ? 'locked' : 'spielbar');
var s = '<div class="mod-card '+cls+'">';
var overlay = isComing ? '<div class="coming-icon">🚧 In Kürze</div>'
: isLocked ? '<div class="lock-icon">🔒</div>'
: '';
// NEU-Pill: zeigt sich wenn available_from in den letzten 30 Tagen ODER in den nächsten 14 Tagen liegt
var newPill = '';
if (isSpielbar && m.available_from) {
var diffDays = (Date.now() - new Date(m.available_from).getTime()) / 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>';
}
}
s += '<div class="mod-img" style="position:relative">'+imgHtml+overlay+newPill+'</div>';
s += '<div class="mod-body">';
s += '<h3>'+(m.icon||'📚')+' '+m.name+'</h3>';
s += '<p>'+(m.desc||'')+'</p>';
s += '<div class="mod-actions">';
if (isSpielbar) s += licensed ? '<a href="'+url+'" class="mod-btn primary">▶ Starten</a>' : '<span class="mod-btn primary" style="opacity:.5;pointer-events:none" title="Deine Lehrperson muss dir eine Lizenz zuweisen">🔑 Lizenz nötig</span>';
s += '<a href="'+infoHref+'" class="mod-btn" title="Mehr Infos">📖 Infos</a>';
if (!isComing) s += '<a href="'+glossarHref+'" class="mod-btn" title="Glossar zu diesem Modul">📚 Glossar</a>';
if (buchHref) s += '<a href="'+buchHref+'" class="mod-btn" title="Schulbuch-Kapitel zu diesem Modul">📘 Schulbuch</a>';
s += '</div>';
s += '</div></div>';
return s;
}
// Regulaere Liste enthaelt alles ausser noch offene Lehrer-Auftraege.
// Erledigte Auftraege erscheinen hier als spielbar, damit der Schueler sie
// freiwillig wiederholen kann.
// Schueler-Sicht: coming_soon-Module werden NICHT angezeigt.
// Gesperrte (von Lehrperson noch nicht freigegebene) Module kommen unter
// einen eigenen Abschnitt "Kann durch die Lehrperson freigegeben werden".
var regular = modList.filter(function(m) { return (m.mode !== 'teacher_started' || m.assignmentCompleted) && m.mode !== 'coming_soon'; });
var spielbar = regular.filter(function(m) { return m.mode !== 'locked'; });
var lockedByTeacher = regular.filter(function(m) { return m.mode === 'locked'; });
html += '<div class="mod-grid">';
spielbar.forEach(function(m) { html += renderModCard(m); });
html += '</div>';
if (lockedByTeacher.length > 0) {
html += '<div class="coming-divider">🔒 Deine Lehrperson kann weitere Simulationen freischalten</div>';
html += '<div class="mod-grid">';
lockedByTeacher.forEach(function(m) { html += renderModCard(m); });
html += '</div>';
}
if (!licensed) {
html = '<div style="background:#fdece9;border:1px solid #f3c9c1;border-left:5px solid #c0392b;border-radius:12px;padding:1rem 1.1rem;margin-bottom:1.2rem;color:#7a2318">'
+ '<div style="font-size:1.4rem;margin-bottom:.15rem">🔑</div>'
+ '<div style="font-weight:800;font-size:.98rem;margin-bottom:.25rem">Noch keine Lizenz</div>'
+ '<div style="font-size:.84rem;line-height:1.55">Deine Lehrperson muss dir noch eine gültige Lizenz zuweisen. Sobald das erledigt ist, kannst du die Simulationen starten.</div>'
+ '</div>' + html;
}
document.getElementById('tab-modules').innerHTML = html;
}
// Modul-Beschreibungen kommen jetzt aus module_info.short_desc via API.
async function renderAchievements() {
var allProgress = [];
try {
var resp = await api('progress?student_id=0');
if (Array.isArray(resp)) allProgress = resp;
} catch(e) {}
var totalPlays = 0, totalXP = 0, bestStarsOverall = 0;
allProgress.forEach(function(p) {
totalPlays += parseInt(p.plays || 0);
totalXP += parseInt(p.xp || 0);
bestStarsOverall = Math.max(bestStarsOverall, parseInt(p.best_stars || 0));
});
var simCount = allProgress.length;
var earned = [];
if (totalPlays >= 1) earned.push('first_sim');
if (simCount >= 3) earned.push('three_sims');
if (simCount >= 8) earned.push('all_sims');
if (bestStarsOverall >= 4) earned.push('high_score');
if (totalXP >= 100) earned.push('speed_run');
var html = '';
// Top-Kennzahlen
html += '<div class="ach-summary">';
html += '<div class="card"><div class="n">'+simCount+'</div><div class="l">Module begonnen</div></div>';
html += '<div class="card"><div class="n">'+totalPlays+'</div><div class="l">Durchgänge</div></div>';
html += '<div class="card"><div class="n">'+totalXP+'</div><div class="l">XP gesamt</div></div>';
html += '</div>';
// Modul-Erfolge-Liste
html += '<p style="font-size:.85rem;font-weight:700;color:#4a7c8a;margin-bottom:.5rem">Was du in den Modulen geschafft hast</p>';
if (allProgress.length === 0) {
html += '<div class="ach-empty">Noch keine Module gespielt. Geh auf <strong>Module</strong> und starte einen Durchgang.</div>';
} else {
html += '<div class="ach-modules">';
allProgress.forEach(function(p) {
var stars = '★'.repeat(p.best_stars || 0) + '☆'.repeat(Math.max(0, 5 - (p.best_stars || 0)));
var last = p.last_played_at ? new Date(p.last_played_at.replace(' ','T')+'Z').toLocaleDateString('de-AT', {day:'2-digit',month:'2-digit',year:'2-digit'}) : '—';
// Klick → Detail-Modal mit allen Runs dieser Sim
html += '<div class="ach-mod" style="cursor:pointer" onclick="openOwnRuns(\''+(p.sim_id||'').replace(/[^a-z0-9_-]/gi,'')+'\',\''+(p.title||p.sim_id||'').replace(/[\'"<>]/g,'')+'\',\''+(p.icon||'📚').replace(/[\'"<>]/g,'')+'\')" title="Klicken für alle Durchgänge">';
html += '<div class="ic">'+(p.icon || '📚')+'</div>';
html += '<div class="info"><h4>'+(p.title || p.sim_id)+' <span style="color:#8a8a8a;font-size:.7rem;font-weight:500">▸ Details</span></h4>';
html += '<div class="meta">'+p.plays+'× gespielt · '+p.xp+' XP · zuletzt '+last+'</div></div>';
html += '<div class="stats">';
html += '<span class="stat-pill stars" title="Beste Sterne">'+stars+'</span>';
html += '<span class="stat-pill lvl">Lvl '+(p.level || 1)+'</span>';
html += '</div></div>';
});
html += '</div>';
}
// === Abzeichen-Sammlung pro Simulation (gesperrte = sichtbare Ziele) ===
if (typeof GGS_SIMS !== 'undefined') {
var progBySim = {};
allProgress.forEach(function(p){ progBySim[p.sim_id] = p; });
html += '<p style="font-size:.85rem;font-weight:700;color:#4a7c8a;margin:1.3rem 0 .3rem">Abzeichen-Sammlung</p>';
html += '<p style="font-size:.68rem;color:#8a8a8a;margin:0 0 .7rem">Jede Simulation hat fünf Abzeichen. Blasse sind noch nicht geschafft — das sind deine Ziele.</p>';
GGS_SIMS.forEach(function(sim){
var p = progBySim[sim.id] || {plays:0, best_stars:0, level:1};
var list = ggsSimBadges(sim.id);
var got = ggsBadgeCount(p, sim.id);
var st = (p.best_stars||0);
var stars = '★'.repeat(st) + '☆'.repeat(Math.max(0, 5-st));
html += '<div class="sim-coll"><div class="sim-coll-head">'
+ '<span class="sc-ic">'+sim.icon+'</span><span class="sc-t">'+sim.title+'</span>'
+ '<span class="sc-stars" title="Beste Sterne">'+stars+'</span>'
+ '<span class="sc-count">'+got+' / '+list.length+'</span></div>';
html += '<div class="badge-grid">';
list.forEach(function(b){
var e = b.cond(p);
html += '<div class="badge '+(e?'earned':'locked-b')+(b.thematic?' thematic':'')+'" title="'+(e?'Geschafft!':'Ziel: '+b.desc)+'">'
+ '<div class="icon"><img src="'+b.icon+'" alt="" loading="lazy"></div>'
+ '<div class="name">'+b.name+'</div>'
+ '<div class="desc">'+b.desc+'</div></div>';
});
html += '</div></div>';
});
}
document.getElementById('tab-achievements').innerHTML = html;
}
async function renderProfile() {
if (!profile) {
document.getElementById('tab-profile').innerHTML = '<p style="color:#8a8a8a">Profil wird geladen…</p>';
return;
}
// Lade Fortschritt
var totalPlays = 0, totalXP = 0, maxLevel = 1;
try {
var prog = await api('progress?student_id=0');
if (Array.isArray(prog)) prog.forEach(function(p) {
totalPlays += parseInt(p.plays||0);
totalXP += parseInt(p.xp||0);
maxLevel = Math.max(maxLevel, parseInt(p.level||1));
});
} catch(e) {}
var html = '<div class="profile-card">';
var pAv = profile.emoji_avatar || '🧑‍🎓';
var pAvHtml = (typeof pAv === 'string' && pAv.indexOf('avatar:') === 0)
? '<img src="assets/img/avatars/thumbs/avatar-' + pAv.substring(7) + '.webp" alt="" style="width:100%;height:100%;border-radius:50%;object-fit:cover">'
: pAv;
html += '<div class="profile-avatar" onclick="location.href=\'profil.html\'" style="width:80px;height:80px;border-radius:50%;background:#dae8ec;display:flex;align-items:center;justify-content:center;margin:0 auto;overflow:hidden">'+pAvHtml+'</div>';
html += '<div class="profile-name">'+(profile.display_name || profile.username)+'</div>';
html += '<div class="profile-class">Klasse '+(profile.class_name || '—')+'</div>';
html += '<div class="profile-stats">';
html += '<div class="profile-stat"><div class="n">'+totalPlays+'</div><div class="l">Simulationen</div></div>';
html += '<div class="profile-stat"><div class="n">'+totalXP+'</div><div class="l">XP</div></div>';
html += '<div class="profile-stat"><div class="n">Level '+maxLevel+'</div><div class="l">Höchstes Level</div></div>';
html += '</div></div>';
html += '<div style="text-align:center;margin-top:1rem"><a href="profil.html" class="tab" style="display:inline-block;padding:.5rem 1.2rem;background:#4a7c8a;color:#fff;border-radius:8px;text-decoration:none;font-size:.78rem;font-weight:700">Profil bearbeiten</a></div>';
document.getElementById('tab-profile').innerHTML = html;
}
// ===== Detail-Modal: alle Durchgänge einer Sim ansehen =====
async function openOwnRuns(simId, title, icon) {
if (!simId) return;
showOwnRunsModal();
document.getElementById('ownruns-title').textContent = (icon || '📚') + ' ' + (title || simId);
document.getElementById('ownruns-body').innerHTML = '<p style="color:#8a8a8a;font-size:.78rem">Lade Verlauf…</p>';
var r = await api('progress?sim_id=' + encodeURIComponent(simId) + '&runs=1');
if (r.error) { document.getElementById('ownruns-body').innerHTML = '<p style="color:#c07a6b">'+r.error+'</p>'; return; }
var runs = r.runs || [];
// Sonnensystem submittet zwei Events: task_solved (pro Einzel-Aufgabe,
// als Live-Heartbeat fürs Lehrer-Cockpit) + set_complete (Tour-Abschluss).
// Für die Schüler-Eigen-Verlauf-Ansicht gruppieren wir das zu Sessions:
// - Eine Session = task_solved-Einträge + (optional) ein set_complete
// - Trennkriterium: zeitliche Lücke > 1 h ODER ein set_complete schließt ab
// - Vollständige Sessions = grün, abgebrochene = orange (halbfertig)
if (simId === 'sonnensystem') {
var html = '<div class="ownruns-list">';
var sessions = groupSonnensystemSessions(runs);
if (!sessions.length) {
document.getElementById('ownruns-body').innerHTML = '<p style="font-size:.78rem;color:#8a8a8a">Noch keine Aufgaben gelöst.</p>';
return;
}
sessions.forEach(function(sess) { html += renderSonnensystemSession(sess); });
html += '</div>';
document.getElementById('ownruns-body').innerHTML = html;
return;
}
if (!runs.length) {
document.getElementById('ownruns-body').innerHTML = '<p style="font-size:.78rem;color:#8a8a8a">Noch keine abgeschlossenen Durchgänge gespeichert.</p>';
return;
}
var html = '<div class="ownruns-list">';
runs.forEach(function(run) {
html += renderOwnRunCard(simId, run);
});
html += '</div>';
document.getElementById('ownruns-body').innerHTML = html;
}
function showOwnRunsModal() {
document.getElementById('modal-ownruns').classList.add('open');
}
function closeOwnRunsModal() {
document.getElementById('modal-ownruns').classList.remove('open');
}
function fmtSecondsSh(s) {
s = Math.max(0, Math.round(s||0));
var mm = Math.floor(s/60), ss = s%60;
return mm + ':' + (ss<10?'0':'') + ss;
}
function escSh(s) { return String(s==null?'':s).replace(/[&<>"]/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]; }); }
function renderOwnRunCard(simId, run) {
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 ? fmtSecondsSh(Math.round(run.duration_ms/1000)) : '—';
var r = run.results || {};
var d = r.data || r;
if (simId === 'eu-werkstatt') return renderEuWerkstattOwnRun(dt, dur, r, d);
if (simId === 'energiemanager') return renderEnergiemanagerOwnRun(dt, dur, r, d);
if (simId === 'sonnensystem') return renderSonnensystemOwnRun(dt, dur, r, d);
if (simId === 'busfahrt') return renderBusfahrtOwnRun(dt, dur, r, d);
if (simId === 'fluggesellschaft') return renderFluggesellschaftOwnRun(dt, dur, r, d);
if (simId === 'heli') return renderHeliOwnRun(dt, dur, r, d);
// Generisch: gemeinsames Kennzahlen-Schema (Sterne + grafische Kacheln) —
// deckt klima, klima-3d, farmer, fluss, logistik, weltkueche, kofferdetektiv,
// tourismustal, tourismusregion ab (vorher nur Datum + Dauer).
var st = (typeof d.stars === 'number') ? d.stars : (typeof d.sterneGesamt === 'number' ? d.sterneGesamt : null);
var starsHtml = '';
if (st != null) { var n = Math.max(0, Math.min(5, Math.round(st)));
starsHtml = '<span class="ownruns-stars">'+'★'.repeat(n)+'<span class="ownruns-stars-empty">'+'★'.repeat(5-n)+'</span></span>'; }
var metrics = (typeof renderSimMetrics === 'function') ? renderSimMetrics(simId, d) : '';
return '<div class="ownruns-card"><div class="ownruns-head"><strong>'+dt+'</strong>'
+ '<span class="ownruns-meta" style="margin-left:auto">'+dur+(starsHtml?' · '+starsHtml:'')+'</span></div>'
+ metrics + '</div>';
}
function renderBusfahrtOwnRun(dt, dur, r, d) {
var stars = Math.max(0, Math.min(5, r.stars || 0));
var starsHtml = '<span class="ownruns-stars">'+'★'.repeat(stars)+'<span class="ownruns-stars-empty">'+'★'.repeat(5-stars)+'</span></span>';
var res = d.results || d;
var budget = res.budget || 0;
var won = res.wonOrders || 0;
var total = res.totalOrders || 0;
var quizFt = res.quizFirstTry || 0;
return ''
+ '<div class="ownruns-card">'
+ '<div class="ownruns-head">'
+ '<div><strong>🚌 Bus-Tour '+((d.level||'l1').toUpperCase())+'</strong><div class="ownruns-meta">'+dt+' · '+dur+'</div></div>'
+ '<div style="text-align:right">'+starsHtml+'<div style="font-size:.7rem;color:#5a8a5e;font-weight:700;margin-top:.2rem">'+budget.toLocaleString('de-AT')+' €</div></div>'
+ '</div>'
+ '<div class="ownruns-stats"><span><b>'+won+'</b>/'+total+' Aufträge</span><span>🧠 <b>'+quizFt+'</b>/'+total+' auf Anhieb</span></div>'
+ '</div>';
}
function renderHeliOwnRun(dt, dur, r, d) {
var res = d.results || d;
// Sterne-Fallback-Reihenfolge: top-level stars (neuer submitHeliMission)
// → res.totalStars (geschachtelt) → res.sterneGesamt (alter assessmentSend)
// → 0. So bekommen auch ältere Run-Formate korrekte Sterne.
var stars = Math.max(0, Math.min(5, r.stars || d.stars || res.totalStars || res.sterneGesamt || 0));
var starsHtml = '<span class="ownruns-stars">'+'★'.repeat(stars)+'<span class="ownruns-stars-empty">'+'★'.repeat(5-stars)+'</span></span>';
var failed = !!res.failed;
var icon = failed ? '🧑‍✈️' : '🚁';
var status = failed ? 'Chefpilot übernahm' : 'Erfolgreich';
var statusCol = failed ? '#c07a6b' : '#5a8a5e';
var title = res.missionTitle || res.missionId || 'Heli-Einsatz';
var planCorrect = res.planCorrect || 0;
var planTotal = res.planTotal || 0;
var planPct = res.planPct != null ? res.planPct : 0;
return ''
+ '<div class="ownruns-card">'
+ '<div class="ownruns-head">'
+ '<div><strong>'+icon+' '+escSh(title)+'</strong><div class="ownruns-meta">'+dt+' · '+dur+'</div></div>'
+ '<div style="text-align:right">'+starsHtml+'<div style="font-size:.7rem;color:'+statusCol+';font-weight:700;margin-top:.2rem">'+status+'</div></div>'
+ '</div>'
+ '<div class="ownruns-stats"><span>🗺️ <b>'+planCorrect+'</b>/'+planTotal+' Navigation ('+planPct+'%)</span><span>🛫 <b>'+'★'.repeat(res.startScore||0)+'</b> Start</span><span>🛬 <b>'+'★'.repeat(res.landingScore||0)+'</b> Landung</span></div>'
+ '</div>';
}
function renderFluggesellschaftOwnRun(dt, dur, r, d) {
var stars = Math.max(0, Math.min(5, r.stars || 0));
var starsHtml = '<span class="ownruns-stars">'+'★'.repeat(stars)+'<span class="ownruns-stars-empty">'+'★'.repeat(5-stars)+'</span></span>';
var res = d.results || d;
var budget = res.budget || 0;
var won = res.wonOrders || 0;
var total = res.totalOrders || 0;
var co2t = ((res.co2Kg || 0) / 1000).toFixed(1);
return ''
+ '<div class="ownruns-card">'
+ '<div class="ownruns-head">'
+ '<div><strong>✈ Flug-Tour '+((d.level||'l1').toUpperCase())+'</strong><div class="ownruns-meta">'+dt+' · '+dur+'</div></div>'
+ '<div style="text-align:right">'+starsHtml+'<div style="font-size:.7rem;color:#5a8a5e;font-weight:700;margin-top:.2rem">'+budget.toLocaleString('de-AT')+' €</div></div>'
+ '</div>'
+ '<div class="ownruns-stats"><span><b>'+won+'</b>/'+total+' Flüge</span><span>🌍 <b>'+co2t+'</b> t CO₂</span></div>'
+ '</div>';
}
// Sonnensystem: task_solved-Einträge + ggf. set_complete zu Sessions clustern.
// Eine Session endet entweder mit set_complete (= fertig) oder wenn die nächste
// Aufgabe > 1 h später kommt (= abgebrochen).
function groupSonnensystemSessions(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(); // neueste zuerst
}
function renderSonnensystemSession(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 sameDay = startDate.toDateString() === endDate.toDateString();
var durSec = Math.round((sess.endTs - sess.startTs) / 1000);
var dur = fmtSecondsSh(durSec);
// Unique gelöste Aufgaben in dieser Session
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);
});
// Falls set_complete vorliegt, dessen completed/total bevorzugen
var done = solvedTasks.size;
var total = sess.total || 0;
if (sess.completeRun) {
var cd = (sess.completeRun.results && sess.completeRun.results.data) || sess.completeRun.results || {};
if (cd.completed) done = cd.completed;
if (cd.total) total = cd.total;
}
var quote = total > 0 ? Math.round(done/total*100) : 0;
// Sterne nur bei abgeschlossenen Sessions
var stars = 0;
if (sess.completeRun) {
var crd = (sess.completeRun.results && sess.completeRun.results.data) || sess.completeRun.results || {};
stars = Math.max(0, Math.min(5, crd.stars || sess.completeRun.results.stars || 0));
}
var starsHtml = sess.complete
? '<span class="ownruns-stars">'+'★'.repeat(stars)+'<span class="ownruns-stars-empty">'+'★'.repeat(5-stars)+'</span></span>'
: '<span style="font-size:.7rem;color:#c9913a;font-weight:700">⏸ läuft / abgebrochen</span>';
var statusClass = sess.complete ? 'ownruns-card--complete' : 'ownruns-card--partial';
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';
// Aufklappbare Details: welche Aufgaben mit mehreren Versuchen + welche Aufgaben gelöst
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 class="ownruns-details"><summary>Details anzeigen</summary>';
detailsHtml += '<div style="font-size:.72rem;line-height:1.5;padding:.4rem 0">';
detailsHtml += '<div><b>Zeitraum:</b> ' + dt + (sameDay ? '' : ' bis ' + endDate.toLocaleString('de-AT', {day:'2-digit',month:'2-digit',year:'2-digit',hour:'2-digit',minute:'2-digit'})) + ' · 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>' + escSh(tid) + trySuffix + '</li>';
});
detailsHtml += '</ul>';
}
if (manyTries.length && !solvedList.length) {
detailsHtml += '<div style="margin-top:.3rem">🔁 <b>'+manyTries.length+'</b> Aufgaben mit Mehrfachversuchen:</div>';
detailsHtml += '<ul style="margin:.2rem 0 0 1.1rem;padding:0">';
manyTries.forEach(function(x){ detailsHtml += '<li>'+escSh(x.id)+': <b>'+x.n+'</b> Versuche</li>'; });
detailsHtml += '</ul>';
}
if (newAch.length) {
detailsHtml += '<div style="margin-top:.3rem">🎉 Neue Erfolge: <b>'+newAch.map(escSh).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. Du kannst sie über das Modul-Cockpit fortsetzen oder neu starten.</div>';
}
detailsHtml += '</div></details>';
return ''
+ '<div class="ownruns-card '+statusClass+'">'
+ '<div class="ownruns-head">'
+ '<div><strong>'+statusEmoji+' '+statusLabel+'</strong><div class="ownruns-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="ownruns-stats">'
+ '<span><b>'+done+'</b>/'+(total||'?')+' Aufgaben richtig</span>'
+ (newAch.length ? '<span>🎉 <b>'+newAch.length+'</b> Erfolge</span>' : '')
+ '</div>'
+ detailsHtml
+ '</div>';
}
function renderSonnensystemOwnRun(dt, dur, r, d) {
var stars = Math.max(0, Math.min(5, r.stars || (d.stars||0)));
var starsHtml = '<span class="ownruns-stars">'+'★'.repeat(stars)+'<span class="ownruns-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';
// Aufklappbare Details: welche Aufgaben mit mehreren Versuchen + Achievements.
var attemptsByTask = d.attemptsByTask || {};
var manyTries = [];
var firstTryCount = 0;
Object.keys(attemptsByTask).forEach(function(k){
var n = attemptsByTask[k] || 1;
if (n > 1) manyTries.push({id:k, n:n});
else firstTryCount++;
});
manyTries.sort(function(a,b){ return b.n - a.n; });
var newAch = d.newAchievements || [];
var detailsHtml = '';
if (event === 'set_complete' && (manyTries.length || newAch.length || firstTryCount)) {
detailsHtml = '<details class="ownruns-details"><summary>Details anzeigen</summary>';
detailsHtml += '<div style="font-size:.72rem;line-height:1.5;padding:.4rem 0">';
if (firstTryCount > 0) {
detailsHtml += '<div>✓ <b>'+firstTryCount+'</b> auf Anhieb richtig</div>';
}
if (manyTries.length) {
detailsHtml += '<div style="margin-top:.3rem">🔁 <b>'+manyTries.length+'</b> Aufgaben mit Mehrfachversuchen:</div>';
detailsHtml += '<ul style="margin:.2rem 0 0 1.1rem;padding:0">';
manyTries.forEach(function(x){
detailsHtml += '<li>'+escSh(x.id)+': <b>'+x.n+'</b> Versuche</li>';
});
detailsHtml += '</ul>';
}
if (newAch.length) {
detailsHtml += '<div style="margin-top:.3rem">🎉 Neue Erfolge: <b>'+newAch.map(escSh).join(', ')+'</b></div>';
}
detailsHtml += '</div></details>';
}
return ''
+ '<div class="ownruns-card">'
+ '<div class="ownruns-head">'
+ '<div><strong>🪐 '+(event === 'set_complete' ? 'Aufgaben-Runde' : 'Einzel-Lösung')+'</strong><div class="ownruns-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="ownruns-stats">'
+ '<span><b>'+done+'</b>/'+total+' Aufgaben richtig</span>'
+ (newAch.length ? '<span>🎉 <b>'+newAch.length+'</b> Erfolge</span>' : '')
+ '</div>'
+ detailsHtml
+ '</div>';
}
function renderEnergiemanagerOwnRun(dt, dur, r, d) {
var stars = Math.max(0, Math.min(5, r.stars || 0));
var starsHtml = '<span class="ownruns-stars">'+'★'.repeat(stars)+'<span class="ownruns-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';
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 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+' '+escSh(day.dayTitle||'?')+'</td><td><b>'+day.balanced+'/'+day.blocks+'</b></td><td>'+(notes.join(' · ')||'')+'</td></tr>';
}).join('');
return ''
+ '<div class="ownruns-card">'
+ '<div class="ownruns-head">'
+ '<div><strong>'+setIcon+' Set '+escSh(setTitle)+'</strong><div class="ownruns-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+'% ausgegl.</div></div>'
+ '</div>'
+ '<div class="ownruns-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="ownruns-steps"><thead><tr><th>#</th><th>Tag</th><th>Balance</th><th>Anmerkung</th></tr></thead><tbody>'+dayRows+'</tbody></table>'
: '')
+ '</div>';
}
function renderEuWerkstattOwnRun(dt, dur, r, d) {
var stars = Math.max(0, Math.min(5, r.stars || 0));
var starsHtml = '<span class="ownruns-stars">'+'★'.repeat(stars)+'<span class="ownruns-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 || [];
var stepRows = '';
for (var i = 0; i < stepsTotal; i++) {
var n = attempts[i] || 1;
var firstTry = (n === 1);
var det = (detours[i] || []).map(function(x){
var ic = x.kind === 'muelleimer' ? '🗑️' : '🔁';
return '<span class="ownruns-detour '+(x.kind === 'muelleimer'?'kind-m':'kind-s')+'">'+ic+' '+escSh(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>'+(det||'—')+'</td></tr>';
}
return ''
+ '<div class="ownruns-card">'
+ '<div class="ownruns-head">'
+ '<div><strong>'+(d.scenarioIcon||'')+' '+escSh(d.scenarioTitle||d.scenarioId||'?')+'</strong>'
+ '<div class="ownruns-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="ownruns-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="ownruns-steps"><thead><tr><th>#</th><th>Status</th><th>Sackgassen</th></tr></thead><tbody>'+stepRows+'</tbody></table>'
: '')
+ '</div>';
}
init();
</script>
<style>
/* Detail-Modal "alle Durchgänge" */
#modal-ownruns{position:fixed;inset:0;background:rgba(20,32,26,.55);display:none;align-items:center;justify-content:center;z-index:9000;padding:1rem}
#modal-ownruns.open{display:flex}
.ownruns-card-wrap{background:#fff;border-radius:14px;max-width:720px;width:100%;max-height:90vh;overflow-y:auto;padding:1rem 1.2rem;box-shadow:0 8px 30px rgba(0,0,0,.25)}
.ownruns-head-bar{display:flex;justify-content:space-between;align-items:center;margin-bottom:.6rem;padding-bottom:.4rem;border-bottom:1px solid rgba(0,0,0,.07)}
.ownruns-list{display:flex;flex-direction:column;gap:.6rem}
.ownruns-card{background:#fdfdfb;border:1px solid rgba(0,0,0,.08);border-radius:10px;padding:.65rem .85rem;border-left:4px solid rgba(0,0,0,.08)}
.ownruns-card--complete{border-left-color:#5a8a5e;background:linear-gradient(90deg,#f0f7f0 0,#fdfdfb 80px)}
.ownruns-card--partial{border-left-color:#e8a247;background:linear-gradient(90deg,#fff8ec 0,#fdfdfb 80px)}
.ownruns-head{display:flex;justify-content:space-between;align-items:flex-start;gap:.6rem;margin-bottom:.5rem}
.ownruns-meta{font-size:.7rem;color:#8a8a8a;margin-top:.15rem}
.ownruns-stars{font-size:1.05rem;color:#e8c547;letter-spacing:.05em}
.ownruns-stars-empty{opacity:.25}
.ownruns-stats{display:flex;gap:1rem;font-size:.74rem;color:#8a8a8a;margin-bottom:.5rem;flex-wrap:wrap}
.ownruns-stats b{color:#1f4b37;font-weight:700}
.ownruns-details{margin-top:.4rem;border-top:1px dashed rgba(0,0,0,.08);padding-top:.3rem}
.ownruns-details summary{cursor:pointer;color:#4a7c8a;font-size:.72rem;font-weight:600;padding:.2rem 0}
.ownruns-details summary:hover{color:#3a6470}
.ownruns-details ul{color:#5a5a5a}
.ownruns-steps{width:100%;border-collapse:collapse;font-size:.74rem;margin-top:.3rem}
.ownruns-steps th{text-align:left;padding:.3rem .4rem;background:#f7f6f3;font-size:.66rem;color:#8a8a8a;font-weight:600}
.ownruns-steps td{padding:.35rem .4rem;border-bottom:1px solid rgba(0,0,0,.04);vertical-align:top}
.ownruns-detour{display:inline-block;padding:2px 7px;border-radius:7px;font-size:.66rem;font-weight:700;margin-right:.3rem;margin-bottom:.15rem}
.ownruns-detour.kind-m{background:#b04a3e;color:#fff}
.ownruns-detour.kind-s{background:#fff3cf;color:#1f4b37;border:1px solid #e8c547}
.ownruns-close{background:transparent;border:1.5px solid #4a7c8a;color:#4a7c8a;border-radius:7px;padding:.35rem .8rem;cursor:pointer;font-weight:700;font-size:.78rem}
</style>
<div id="modal-ownruns" onclick="if(event.target.id==='modal-ownruns')closeOwnRunsModal()">
<div class="ownruns-card-wrap">
<div class="ownruns-head-bar">
<h3 id="ownruns-title" style="margin:0;font-size:1rem;color:#1f4b37">Verlauf</h3>
<button class="ownruns-close" onclick="closeOwnRunsModal()">Schließen</button>
</div>
<div id="ownruns-body"></div>
</div>
</div>
<script src="assets/js/version-tag.js" defer></script>
</body>
</html>