Lehrer-Observability + Weltküche-Inhalt + Wrapper-Fix + Passstraße

teacher.html: Menü „Live" → „Ergebnisse" mit Unteransichten
Tag·Aktuell·Vergleich·Gesamt (alte Ergebnisse-Ansicht ist jetzt „Gesamt").
Neue Kennzahl Qualität (grafischer richtig/falsch-Balken) in Aktuell +
Vergleich + Weltküche-Zeile, Gerichte-Zähler (Fleiß), Labels entschärft
(„Kritisch" → Stark/Solide/Viele Fehler).

Weltküche: korrekte Artikel (der/die/das) für alle 70 Zutaten in
production-matrix.json; verdrehten Herkunfts-Satz + Heimat-Banner neu und
marktbewusst (Ukraine-Distraktor ehrlich erklärt), „man"-Konstruktionen
für Singular/Plural.

_scripts.php: renderPage() injizierte Favicon per strrpos ins letzte
</head> — das lag in teacher.htmls printBatchTickets-JS-String → SyntaxError
zerschoss das ganze Inline-Script auf /teacher. Fix: strpos (erstes </head>).

tourismusregion: neue Karte passstrasse in maps-custom.json (Route bergauf
136 Punkte, 41 Bauslots) aus Editor-Export.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 13:32:24 +02:00
parent 4bfb6539cb
commit 5655af81bf
6 changed files with 7038 additions and 5808 deletions
@@ -0,0 +1,46 @@
-- ============================================================
-- Weltküche: Lehrplan-Bezug erscheint nicht — Reparatur
-- Stand: 2026-08-18 · Atlas
-- ------------------------------------------------------------
-- Ursache: Die phase3-Migration (2026-06-08) hat die Modul-
-- Zuordnung über die Session-Variable @welternaehrung_id gesetzt:
-- INSERT IGNORE INTO kompetenz_modules VALUES (@welternaehrung_id,'weltkueche',100,'primaer');
-- War @welternaehrung_id beim Prod-Import NULL (anderer Connection-
-- Kontext), wurde KEINE weltkueche-Zeile angelegt. Der INNER JOIN in
-- modul_detail.php liefert dann nichts → „Noch keine Kompetenzen".
--
-- Die Kompetenz 'welternaehrung-konsum-disparitaeten' und ihre ~118
-- Anker existieren bereits — es fehlt nur die Verknüpfung.
--
-- Diese Reparatur ist idempotent und referenziert die Kompetenz per
-- key_slug (NICHT per Session-Variable / harter ID). Sekundär-Bezüge
-- ebenfalls per key_slug, damit sie environment-unabhängig greifen.
-- ============================================================
-- Primär: Welternährung, Konsum und globale Disparitäten
INSERT INTO kompetenz_modules (kompetenz_id, module_id, level, strength)
SELECT id, 'weltkueche', 100, 'primaer'
FROM kompetenzen WHERE key_slug = 'welternaehrung-konsum-disparitaeten'
ON DUPLICATE KEY UPDATE strength = VALUES(strength), level = VALUES(level);
-- Sekundär: Klimawandel (Klimazonen/Anbaugrenzen, Ökobilanz von Lebensmitteln)
INSERT INTO kompetenz_modules (kompetenz_id, module_id, level, strength)
SELECT id, 'weltkueche', 50, 'sekundaer'
FROM kompetenzen WHERE key_slug = 'klimawandel'
ON DUPLICATE KEY UPDATE strength = VALUES(strength);
-- Sekundär: Systemisches Denken (Welthandel, Lieferketten, Konsumfolgen)
INSERT INTO kompetenz_modules (kompetenz_id, module_id, level, strength)
SELECT id, 'weltkueche', 50, 'sekundaer'
FROM kompetenzen WHERE key_slug = 'systemisches-denken'
ON DUPLICATE KEY UPDATE strength = VALUES(strength);
-- Kontrolle:
-- SELECT k.key_slug, km.strength, km.level
-- FROM kompetenz_modules km JOIN kompetenzen k ON k.id = km.kompetenz_id
-- WHERE km.module_id = 'weltkueche' ORDER BY km.strength;
-- -> erwartet: primaer welternaehrung-konsum-disparitaeten (+ 2 sekundaer)
--
-- SELECT COUNT(*) FROM lehrplan_anchors
-- WHERE kompetenz_id = (SELECT id FROM kompetenzen WHERE key_slug='welternaehrung-konsum-disparitaeten');
-- -> erwartet: > 0 (Anker existieren bereits aus phase3)
+8 -2
View File
@@ -103,8 +103,14 @@ HTML;
$html = preg_replace('/<link[^>]*rel=["\']icon["\'][^>]*>\s*/', '', $html);
}
// Favicon vor LETZTEM </head> einfügen (nicht in JS-Strings injizieren)
$headPos = strrpos($html, '</head>');
// Favicon vor dem ERSTEN </head> einfügen (= echter Dokumentkopf).
// WICHTIG: strpos (erstes), NICHT strrpos (letztes). Seiten wie teacher.html
// enthalten in Body-Skripten ein w.document.write('<html><head>…</head>…')
// (z.B. Ticket-Druck-Popup). Dessen </head> ist das LETZTE der Datei — strrpos
// hätte den mehrzeiligen Favicon-Block mitten in den einfach-gequoteten
// JS-String injiziert → „Invalid or unexpected token" → gesamtes Inline-Script
// tot. Das echte Kopf-</head> ist immer das erste.
$headPos = strpos($html, '</head>');
if ($headPos !== false) {
$html = substr($html, 0, $headPos) . $favicon . "\n" . substr($html, $headPos);
}
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
{
"pfeffer": {
"nm": "Schwarzer Pfeffer",
"art": "der",
"ic": "🧂",
"producers": {
"vietnam": {
@@ -49,6 +50,7 @@
},
"kakao": {
"nm": "Kakao",
"art": "der",
"ic": "🍫",
"producers": {
"elfenbein": {
@@ -105,6 +107,7 @@
},
"vanille": {
"nm": "Vanille",
"art": "die",
"ic": "🌼",
"producers": {
"madagaskar": {
@@ -136,6 +139,7 @@
},
"kurkuma": {
"nm": "Kurkuma",
"art": "die",
"ic": "🟡",
"producers": {
"indien": {
@@ -184,6 +188,7 @@
},
"ingwer": {
"nm": "Ingwer",
"art": "der",
"ic": "🫚",
"producers": {
"indien": {
@@ -235,6 +240,7 @@
},
"zimt": {
"nm": "Zimt",
"art": "der",
"ic": "🪵",
"producers": {
"indonesien": {
@@ -270,6 +276,7 @@
},
"kardamom": {
"nm": "Kardamom",
"art": "der",
"ic": "🫛",
"producers": {
"guatemala": {
@@ -305,6 +312,7 @@
},
"sesam": {
"nm": "Sesam",
"art": "der",
"ic": "⚪",
"producers": {
"sudan": {
@@ -356,6 +364,7 @@
},
"chili": {
"nm": "Chili",
"art": "die",
"ic": "🌶️",
"producers": {
"indien": {
@@ -403,6 +412,7 @@
},
"kumin": {
"nm": "Kreuzkümmel (Cumin)",
"art": "der",
"ic": "🌿",
"producers": {
"indien": {
@@ -437,6 +447,7 @@
},
"koriander": {
"nm": "Koriander (Samen)",
"art": "der",
"ic": "🌿",
"producers": {
"indien": {
@@ -481,6 +492,7 @@
},
"safran": {
"nm": "Safran",
"art": "der",
"ic": "🌸",
"producers": {
"iran": {
@@ -519,6 +531,7 @@
},
"muskat": {
"nm": "Muskat",
"art": "der",
"ic": "🌰",
"producers": {
"indonesien": {
@@ -548,6 +561,7 @@
},
"nelken": {
"nm": "Gewürznelken",
"art": "die",
"ic": "🌾",
"producers": {
"indonesien": {
@@ -577,6 +591,7 @@
},
"paprika": {
"nm": "Paprika-Pulver",
"art": "das",
"ic": "🟥",
"producers": {
"china": {
@@ -626,6 +641,7 @@
},
"kuemmel": {
"nm": "Kümmel",
"art": "der",
"ic": "🌿",
"producers": {
"finnland": {
@@ -668,6 +684,7 @@
},
"majoran": {
"nm": "Majoran",
"art": "der",
"ic": "🌿",
"producers": {
"aegypten": {
@@ -704,6 +721,7 @@
},
"kraeuter": {
"nm": "Mediterrane Kräuter",
"art": "die",
"ic": "🌿",
"producers": {
"marokko": {
@@ -759,6 +777,7 @@
},
"banane": {
"nm": "Banane",
"art": "die",
"ic": "🍌",
"producers": {
"indien": {
@@ -832,6 +851,7 @@
},
"avocado": {
"nm": "Avocado",
"art": "die",
"ic": "🥑",
"producers": {
"mexiko": {
@@ -894,6 +914,7 @@
},
"limette": {
"nm": "Limette",
"art": "die",
"ic": "🟢",
"producers": {
"mexiko": {
@@ -953,6 +974,7 @@
},
"orange": {
"nm": "Orange / Orangensaft",
"art": "die",
"ic": "🍊",
"producers": {
"brasilien": {
@@ -1029,6 +1051,7 @@
},
"zitrone": {
"nm": "Zitrone",
"art": "die",
"ic": "🍋",
"producers": {
"indien": {
@@ -1102,6 +1125,7 @@
},
"kokos": {
"nm": "Kokosnuss / Kokosmilch",
"art": "die",
"ic": "🥥",
"producers": {
"indonesien": {
@@ -1153,6 +1177,7 @@
},
"tamarinde": {
"nm": "Tamarinde",
"art": "die",
"ic": "🟤",
"producers": {
"indien": {
@@ -1187,6 +1212,7 @@
},
"kaffee": {
"nm": "Kaffee",
"art": "der",
"ic": "☕",
"producers": {
"brasilien": {
@@ -1244,6 +1270,7 @@
},
"wein": {
"nm": "Wein (Reben)",
"art": "der",
"ic": "🍷",
"producers": {
"italien": {
@@ -1322,6 +1349,7 @@
},
"reis": {
"nm": "Reis",
"art": "der",
"ic": "🍚",
"producers": {
"china": {
@@ -1420,6 +1448,7 @@
},
"reisnudeln": {
"nm": "Reisnudeln",
"art": "die",
"ic": "🍜",
"producers": {
"china": {
@@ -1461,6 +1490,7 @@
},
"hartweizen": {
"nm": "Hartweizen (Durum)",
"art": "der",
"ic": "🌾",
"producers": {
"kanada": {
@@ -1527,6 +1557,7 @@
},
"mehl": {
"nm": "Mehl (Weichweizen)",
"art": "das",
"ic": "🌾",
"producers": {
"china": {
@@ -1615,6 +1646,7 @@
},
"mais": {
"nm": "Mais (Tortilla, Polenta)",
"art": "der",
"ic": "🌽",
"producers": {
"usa": {
@@ -1711,6 +1743,7 @@
},
"mandeln": {
"nm": "Mandeln",
"art": "die",
"ic": "🌰",
"producers": {
"usa": {
@@ -1771,6 +1804,7 @@
},
"erdnuss": {
"nm": "Erdnüsse",
"art": "die",
"ic": "🥜",
"producers": {
"china": {
@@ -1846,6 +1880,7 @@
},
"kichererbsen": {
"nm": "Kichererbsen",
"art": "die",
"ic": "🟤",
"producers": {
"indien": {
@@ -1911,6 +1946,7 @@
},
"soja": {
"nm": "Sojabohnen",
"art": "die",
"ic": "🫘",
"producers": {
"brasilien": {
@@ -1990,6 +2026,7 @@
},
"soja-sauce": {
"nm": "Sojasauce",
"art": "die",
"ic": "🥢",
"producers": {
"china": {
@@ -2028,6 +2065,7 @@
},
"rohrzucker": {
"nm": "Rohrzucker",
"art": "der",
"ic": "🧊",
"producers": {
"brasilien": {
@@ -2100,6 +2138,7 @@
},
"apfel": {
"nm": "Apfel",
"art": "der",
"ic": "🍎",
"producers": {
"china": {
@@ -2223,6 +2262,7 @@
},
"marille": {
"nm": "Marille (Aprikose)",
"art": "die",
"ic": "🍑",
"producers": {
"tuerkei": {
@@ -2296,6 +2336,7 @@
},
"kirschen": {
"nm": "Kirschen",
"art": "die",
"ic": "🍒",
"producers": {
"tuerkei": {
@@ -2374,6 +2415,7 @@
},
"lebkuchen": {
"nm": "Lebkuchen-Gewürz",
"art": "das",
"ic": "🍪",
"producers": {
"indonesien": {
@@ -2409,6 +2451,7 @@
},
"rosinen": {
"nm": "Rosinen (getr. Trauben)",
"art": "die",
"ic": "🍇",
"producers": {
"tuerkei": {
@@ -2463,6 +2506,7 @@
},
"olivenoel": {
"nm": "Olivenöl",
"art": "das",
"ic": "🫒",
"producers": {
"spanien": {
@@ -2522,6 +2566,7 @@
},
"milch": {
"nm": "Milch / Sahne",
"art": "die",
"ic": "🥛",
"producers": {
"indien": {
@@ -2658,6 +2703,7 @@
},
"butter": {
"nm": "Butter",
"art": "die",
"ic": "🧈",
"producers": {
"indien": {
@@ -2749,6 +2795,7 @@
},
"feta": {
"nm": "Feta (Käse)",
"art": "der",
"ic": "🧀",
"producers": {
"griechenland": {
@@ -2781,6 +2828,7 @@
},
"mozzarella": {
"nm": "Mozzarella",
"art": "der",
"ic": "🧀",
"producers": {
"italien": {
@@ -2827,6 +2875,7 @@
},
"pecorino": {
"nm": "Pecorino (Käse)",
"art": "der",
"ic": "🧀",
"producers": {
"italien": {
@@ -2857,6 +2906,7 @@
},
"topfen": {
"nm": "Topfen / Quark",
"art": "der",
"ic": "🥛",
"producers": {
"deutschland": {
@@ -2911,6 +2961,7 @@
},
"schmand": {
"nm": "Schmand / Saure Sahne",
"art": "der",
"ic": "🥛",
"producers": {
"deutschland": {
@@ -2958,6 +3009,7 @@
},
"fleisch": {
"nm": "Fleisch (Kalb/Schwein/Rind, regional)",
"art": "das",
"ic": "🥩",
"producers": {
"oesterreich": {
@@ -3032,6 +3084,7 @@
},
"lamm": {
"nm": "Lammfleisch",
"art": "das",
"ic": "🐑",
"producers": {
"tuerkei": {
@@ -3123,6 +3176,7 @@
},
"huhn-spanien": {
"nm": "Hähnchen / Huhn (Paella)",
"art": "das",
"ic": "🐔",
"producers": {
"spanien": {
@@ -3166,6 +3220,7 @@
},
"rind-ungarn": {
"nm": "Rind (für Gulasch)",
"art": "das",
"ic": "🥩",
"producers": {
"ungarn": {
@@ -3215,6 +3270,7 @@
},
"speck": {
"nm": "Speck (Pancetta/Tiroler)",
"art": "der",
"ic": "🥓",
"producers": {
"italien": {
@@ -3268,6 +3324,7 @@
},
"speck-lorraine": {
"nm": "Lardons (für Quiche)",
"art": "die",
"ic": "🥓",
"producers": {
"frankreich": {
@@ -3299,6 +3356,7 @@
},
"eier": {
"nm": "Eier",
"art": "die",
"ic": "🥚",
"producers": {
"china": {
@@ -3397,6 +3455,7 @@
},
"fischsauce": {
"nm": "Fischsauce",
"art": "die",
"ic": "🐟",
"producers": {
"vietnam": {
@@ -3453,6 +3512,7 @@
},
"nori": {
"nm": "Nori (Seetang)",
"art": "der",
"ic": "🟢",
"producers": {
"china": {
@@ -3500,6 +3560,7 @@
},
"wasabi": {
"nm": "Wasabi (echt)",
"art": "der",
"ic": "🟢",
"producers": {
"japan": {
@@ -3541,6 +3602,7 @@
},
"tomaten": {
"nm": "Tomaten",
"art": "die",
"ic": "🍅",
"producers": {
"china": {
@@ -3632,6 +3694,7 @@
},
"gemuese": {
"nm": "Sommer-Gemüse (Mittelmeer)",
"art": "das",
"ic": "🥬",
"producers": {
"spanien": {
@@ -3681,6 +3744,7 @@
},
"wurzelgemuese": {
"nm": "Wurzelgemüse (Möhre, Sellerie, Pastinake)",
"art": "das",
"ic": "🥕",
"producers": {
"china": {
@@ -3749,6 +3813,7 @@
},
"kartoffel": {
"nm": "Kartoffel",
"art": "die",
"ic": "🥔",
"producers": {
"china": {
@@ -3885,6 +3950,7 @@
},
"zwiebel": {
"nm": "Zwiebel",
"art": "die",
"ic": "🧅",
"producers": {
"china": {
@@ -3978,6 +4044,7 @@
},
"knoblauch": {
"nm": "Knoblauch",
"art": "der",
"ic": "🧄",
"producers": {
"china": {
@@ -4056,6 +4123,7 @@
},
"champignons": {
"nm": "Champignons",
"art": "die",
"ic": "🍄",
"producers": {
"china": {
@@ -4140,6 +4208,7 @@
},
"hefe": {
"nm": "Hefe",
"art": "die",
"ic": "🫧",
"producers": {
"frankreich": {
@@ -4190,6 +4259,7 @@
},
"essig": {
"nm": "Essig (Wein- / Apfelessig)",
"art": "der",
"ic": "🧪",
"producers": {
"italien": {
@@ -4241,4 +4311,4 @@
],
"fact": "Aceto Balsamico Tradizionale di Modena reift mindestens 12 Jahre in Holzfässern — eine kleine Flasche kann über 100 € kosten."
}
}
}
+26 -7
View File
@@ -521,7 +521,7 @@ async function loadWeltkuecheData() {
// ING-Map aus production-matrix bauen
Object.entries(prodMatrix).forEach(([k, v]) => {
ING[k] = {
nm: v.nm, ic: v.ic,
nm: v.nm, ic: v.ic, art: v.art || 'die',
producers: v.producers || {},
climate_possible: v.climate_possible || [],
fact: v.fact || '',
@@ -1021,6 +1021,16 @@ function impossibleCountries(corr, possible) {
return out;
}
// Zutat mit korrektem Artikel: „die Kartoffel", „der Pfeffer", „das Olivenöl".
// Plural-Zutaten tragen art='die' — die Sätze nutzen bewusst „man"-Konstruktionen
// (nimmt man / holt man …), damit Singular UND Plural grammatisch passen.
function ingWas(ing, sTask, ingKey) {
const label = sTask && sTask.label;
if (label) return label;
const a = (ing && ing.art) || 'die';
return a + ' ' + (ing ? ing.nm : '');
}
function buildTaskFor(ingKey) {
if (_taskCache[ingKey]) return _taskCache[ingKey];
const ing = ING[ingKey];
@@ -1358,12 +1368,17 @@ function explainChoice(ingKey, key, isCorrect){
const sTask = _taskCache[ingKey];
if (sTask && sTask.signature) {
const heimatN = C[sTask.heimat] ? C[sTask.heimat].n : '';
const was = sTask.label || ('der/die/das ' + ing.nm);
const was = ingWas(ing, sTask, ingKey);
if (isCorrect) {
return {cls:'ok', badge:'✓', text:`Dieses Gericht kommt aus <b>${heimatN}</b> ${was} ist hier heimisch und wird vor Ort erzeugt.`};
if (key === sTask.heimat)
return {cls:'ok', badge:'✓', text:`Richtig für ein Gericht aus <b>${heimatN}</b> nimmt man ${was} aus ${heimatN} selbst.`};
return {cls:'ok', badge:'✓', text:`Auch richtig ${heimatN} allein deckt den Bedarf nicht, darum ist ${C[key].n} als Nachbarland ein realistischer Zweit-Lieferant.`};
}
if (info) {
return {cls:'mid', badge:'⚠️', text:`${C[key].n} erzeugt zwar viel ${ing.nm}${amount?' ('+amount+')':''} aber in einem Gericht aus ${heimatN} stammt ${was} von dort.`};
const m = info.market || 'global';
if (m === 'world-far' || m === 'local')
return {cls:'mid', badge:'🌐', text:`${C[key].n} erzeugt zwar riesige Mengen ${ing.nm}${amount?' ('+amount+')':''} in unseren Handel kommt davon aber kaum etwas. Für ein Gericht aus ${heimatN} nimmt man ${was} aus ${heimatN} oder einem Nachbarland.`};
return {cls:'mid', badge:'⚠️', text:`${C[key].n} ist auch ein echter Erzeuger${amount?' ('+amount+')':''} aber für ein Gericht aus ${heimatN} nimmt man ${was} lieber aus ${heimatN} selbst oder dem nächsten Nachbarland.`};
}
// Nicht-Produzent-Fehlklick → normale „unmöglich"-Logik unten
}
@@ -1471,7 +1486,7 @@ function showSolved(ing,pts,bonus){
const factLine = ing.fact ? `<div class="more-line"><b>Wusstest du?</b> ${esc(ing.fact)}</div>` : '';
// Heimat-Regel: erklären, warum die Region die Antwort ist (nicht der größte Produzent)
const sigLine = (task.signature && C[task.heimat])
? `<div class="more-line" style="background:#eef5ec;border-left:3px solid #4a7c4e">🍽️ Das ist ein Gericht aus <b>${C[task.heimat].n}</b>. ${task.label ? ('<b>'+task.label+'</b>') : ('Der/die/das '+esc(ing.nm))} kommt von hier nur Zutaten, die bei uns gar nicht wachsen, reisen von weit her.</div>`
? `<div class="more-line" style="background:#eef5ec;border-left:3px solid #4a7c4e">🍽️ Das ist ein Gericht aus <b>${C[task.heimat].n}</b>. Zutaten werden zwar bevorzugt aus ${C[task.heimat].n} verwendet, aber ${C[task.heimat].n} allein deckt den Bedarf nicht ab darum zählt auch ein Nachbarland als richtig. Nur Zutaten, die bei uns gar nicht wachsen, reisen von weit her.</div>`
: '';
// Caveats für world-far/off-season Korrekt-Picks (pro Land separat)
const caveats = task.correct.map(k => {
@@ -1521,8 +1536,12 @@ function showWrong(key, kind, ingKey){
const info = ing.producers[key];
const amount = info && info.t ? fmtTonnes(info.t) : 'verbreitet';
const heimatN = C[sTask.heimat] ? C[sTask.heimat].n : '';
const was = sTask.label || ('der/die/das ' + ing.nm);
card.innerHTML = `<div class="rc-head">⚠️ ${c.f} ${c.n} erzeugt zwar viel ${ing.nm} (${amount}) aber dieses Gericht kommt aus ${heimatN}, und ${was} stammt von dort.</div>`+
const was = ingWas(ing, sTask, ingKey);
const m = info.market || 'global';
const head = (m === 'world-far' || m === 'local')
? `⚠️ ${c.f} ${c.n} erzeugt zwar riesige Mengen ${ing.nm} (${amount}) aber davon kommt bei uns fast nichts in den Handel. Für dieses Gericht aus ${heimatN} nimmt man ${was} aus ${heimatN} oder einem Nachbarland.`
: `⚠️ ${c.f} ${c.n} ist auch ein echter Erzeuger (${amount}) aber für dieses Gericht aus ${heimatN} nimmt man ${was} lieber aus ${heimatN} selbst oder dem nächsten Nachbarland.`;
card.innerHTML = `<div class="rc-head">${head}</div>`+
`<div class="note">${c.info}<br><br>🔎 Tipp: Achte auf den Namen des Gerichts er verrät oft die Herkunft.</div>`;
return;
}
+82 -14
View File
@@ -211,10 +211,9 @@
<!-- 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" id="tab-btn-live" onclick="switchTab('live')"><span class="live-dot"></span> Ergebnisse<span class="live-count" id="live-count"></span></div>
<div class="tab" onclick="switchTab('modules')">Modulfreigabe</div>
<div class="tab" onclick="switchTab('students')">Klasse</div>
<div class="tab" onclick="switchTab('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>
@@ -480,7 +479,7 @@ api('auth', {action:'status'}).then(function(r) {
// === TABS ===
function switchTab(tab) {
currentTab = tab;
var tabs = ['overview','live','modules','students','results','licenses','classes'];
var tabs = ['overview','live','modules','students','licenses','classes'];
document.querySelectorAll('#tab-bar .tab').forEach(function(t, i) { t.classList.toggle('on', tabs[i] === tab); });
tabs.forEach(function(t) { document.getElementById('tab-' + t).classList.toggle('hidden', t !== tab); });
// Live-Tab darf seinen Polling-Timer beim Verlassen aufräumen
@@ -523,14 +522,27 @@ async function loadLive() {
if (_liveTimer) clearInterval(_liveTimer);
// Auto-Refresh: aktuell alle 4s, übersicht/vergleich nur wenn heute
var refreshMs = (_liveMode === 'aktuell') ? 4000
: (_liveMode === 'gesamt') ? 0
: (_liveDate === new Date().toISOString().slice(0,10)) ? 60000 : 0;
if (refreshMs) _liveTimer = setInterval(_renderLiveTab, refreshMs);
}
async function _renderLiveTab() {
if (_liveMode === 'uebersicht') return renderLiveSummary();
if (_liveMode === 'vergleich') return renderLiveCompare();
if (_liveMode === 'gesamt') return renderLiveGesamt();
return renderLive(); // 'aktuell'
}
// „Gesamt" = die klassische Ergebnis-/Auswertungsansicht (früher eigener Tab),
// jetzt als 4. Unteransicht in „Ergebnisse". Rendert die results.php-Auswertung
// in einen eingebetteten Container innerhalb von #tab-live.
async function renderLiveGesamt() {
var host = document.getElementById('tab-live');
if (!host) return;
host.innerHTML = _liveToggleHtml()
+ '<div id="results-embed"><p style="font-size:.78rem;color:#8a8a8a;padding:1rem">Lade Gesamt-Auswertung…</p></div>';
_resultsHost = 'results-embed';
await loadResults();
}
window.setCompareSim = function(simId){ _liveCompareSim = simId; loadLive(); };
function setLiveMode(mode) {
console.log('[live] setLiveMode('+mode+')');
@@ -563,6 +575,7 @@ function _liveToggleHtml() {
+ ' <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>'
+ ' <button class="btn-sm" onclick="setLiveMode(\'gesamt\')" style="padding:.35rem .8rem;border:0;border-left:1.5px solid rgba(0,0,0,.08);background:'+(_liveMode==='gesamt'?'#4a7c8a':'transparent')+';color:'+(_liveMode==='gesamt'?'#fff':'#3d5165')+';font-weight:700;font-size:.74rem">📚 Gesamt</button>'
+ '</div>'
+ (_liveMode==='uebersicht' ?
'<div style="display:inline-flex;gap:.3rem;align-items:center;margin-left:.6rem">'
@@ -749,21 +762,26 @@ var LIVE_PRIMARY_FIELDS = {
{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>';
// Fleiß in dieser Sitzung: fertige Gerichte / gesamt
{k:'completed', label:'Gerichte', align:'center', fmt:function(v, row){
var s = row.state||{}; var t = s.completedTotal||0;
if (!t) return '<span style="color:#8a8a8a"></span>';
var done = v||0; var c = done>=t ? '#5a8a5e' : '#3d5165';
return '<b style="color:'+c+'">'+done+'</b><span style="color:#8a8a8a"> / '+t+'</span>';
}},
{k:'ingSolved', label:'Zutaten', fmt:function(v, row){
{k:'ingSolved', label:'Zutaten akt.', fmt:function(v, row){
var s = row.state||{}; var t = s.ingTotal||0;
if (!s.dishName || !t) return '<span style="color:#8a8a8a"></span>';
var done = v||0; var pct = done/t; var c = pct>=1 ? '#5a8a5e' : pct>=.5 ? '#c9913a' : '#8a7a4a';
return '<span style="color:'+c+';font-weight:700">'+done+' <span style="color:#8a8a8a;font-weight:400">/ '+t+'</span></span>';
}},
{k:'mistakesInDish', label:'Fehlklicks', align:'center', fmt:function(v){
// Qualität = grafisches Verhältnis richtige ↔ falsche Klicks (blödeln vs. gut)
{k:'__quality', label:'Qualität', align:'left', fmt:function(v, row){ return _liveQualityFromState(row.module_id, row.state); }},
{k:'mistakesInDish', label:'Fehler (akt.)', align:'center', fmt:function(v){
var n = v||0; var c = n>=12 ? '#c07a6b' : n>=5 ? '#c9913a' : '#5a8a5e';
return '<span style="color:'+c+';font-weight:700">'+n+'</span>';
}},
{k:'__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); }},
{k:'__lage', label:'Lage', align:'center', fmt:function(v, row){ return _liveMoodBadge(row.module_id, row.state); }},
],
'busfahrt': [
{k:'levelId', label:'Stufe', align:'center'},
@@ -1230,13 +1248,53 @@ function _liveProgressBar(simId, s) {
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'] };
// Klartext statt Jargon („Kritisch"): stark / solide / viele Fehler.
var map = { good:['#5a8a5e','Stark'], mid:['#c9913a','Solide'], bad:['#c07a6b','Viele Fehler'] };
var x = map[m] || map.mid;
return '<span style="display:inline-flex;align-items:center;gap:.3rem">'
+ '<span style="width:8px;height:8px;border-radius:50%;background:'+x[0]+';display:inline-block"></span>'
+ '<span style="font-size:.66rem;color:'+x[0]+';font-weight:700">'+x[1]+'</span></span>';
}
// KENNZAHL QUALITÄT — grafisches Verhältnis richtige ↔ falsche Klicks.
// Zweifarbiger Balken (grün = richtig-Anteil, rot = Fehler-Anteil) + %-Wert.
// Funktioniert live (GGS_LIVE_STATE.correctClicks/wrongClicks) UND aus
// gespeicherten assessment.data-Feldern (gleiche Keys).
function _liveClicks(simId, s) {
if (!s) return null;
if (typeof s.correctClicks === 'number' || typeof s.wrongClicks === 'number')
return { correct: s.correctClicks || 0, wrong: s.wrongClicks || 0 };
// Fallbacks für Sims, die (noch) keine correct/wrongClicks liefern:
switch (simId) {
case 'heli':
if (s.planTotal) return { correct: s.planCorrect || 0, wrong: Math.max(0, s.planTotal - (s.planCorrect || 0)) };
break;
}
return null;
}
function _liveQualityBar(correct, wrong) {
correct = Math.max(0, correct || 0);
wrong = Math.max(0, wrong || 0);
var tot = correct + wrong;
if (!tot) return '<span style="color:#8a8a8a"></span>';
var gp = Math.round(correct / tot * 100);
var rp = 100 - gp;
var c = gp >= 80 ? '#5a8a5e' : gp >= 55 ? '#c9913a' : '#c07a6b';
return '<span title="'+correct+' richtig · '+wrong+' Fehler ('+gp+' % richtig)" '
+ 'style="display:inline-flex;align-items:center;gap:.35rem;font-variant-numeric:tabular-nums">'
+ '<span aria-hidden="true" style="display:inline-flex;width:56px;height:9px;border-radius:99px;overflow:hidden;background:#ece8dc">'
+ '<span style="width:'+gp+'%;background:#5a8a5e"></span>'
+ '<span style="width:'+rp+'%;background:#d98b7a"></span>'
+ '</span>'
+ '<span style="font-size:.72rem;font-weight:700;color:'+c+';min-width:2.3em;text-align:right">'+gp+' %</span>'
+ '</span>';
}
// Bequemer Wrapper aus einem Live-State (für Vergleich/Aktuell-Tabellen).
function _liveQualityFromState(simId, s) {
var cw = _liveClicks(simId, s);
return cw ? _liveQualityBar(cw.correct, cw.wrong) : '<span style="color:#8a8a8a"></span>';
}
var _liveReflCache = '', _liveReflAt = 0;
// Aktiv = Lebenszeichen in den letzten 90 s. Ältere Sessions bleiben sichtbar,
// aber als „pausiert" (Lernende:r macht Pause, kann später weitermachen).
@@ -1735,6 +1793,11 @@ function _renderGenericCompareBody(rows, simId) {
return 0;
}
function qualityScore(s) {
// Sims mit echten richtig/falsch-Klicks (z. B. Weltküche): Anteil richtig
if (typeof s.correctClicks === 'number' || typeof s.wrongClicks === 'number') {
var t = (s.correctClicks||0) + (s.wrongClicks||0);
return t ? (s.correctClicks||0) / t * 100 : null;
}
// Heli: planCorrect / planTotal
if (typeof s.planCorrect === 'number' && s.planTotal) return Math.min(100, s.planCorrect / s.planTotal * 100);
// Busfahrt: quizFirstTry / orderIndex
@@ -1750,6 +1813,8 @@ function _renderGenericCompareBody(rows, simId) {
displayName: _liveCleanName(ls.displayName, idx+1),
phase: s.phase || '',
progress: p, quality: q,
// grafischer richtig/falsch-Balken, wenn Klick-Daten vorhanden
qualityHtml: _liveClicks(ls.module_id, s) ? _liveQualityFromState(ls.module_id, s) : null,
stars: (typeof s.totalStars === 'number') ? s.totalStars : null,
};
});
@@ -1764,7 +1829,7 @@ function _renderGenericCompareBody(rows, simId) {
+ '<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>'
+ '<th style="text-align:right;padding:.45rem .5rem;font-size:.7rem;text-transform:uppercase;color:#6a6a6a;border-bottom:2px solid rgba(0,0,0,.08)">Gerade dabei</th>'
+ '</tr></thead><tbody>';
data.forEach(function(r, i){
var bg = (Math.floor(i/5) % 2 === 1) ? 'background:rgba(0,0,0,.02)' : '';
@@ -1775,7 +1840,7 @@ function _renderGenericCompareBody(rows, simId) {
+ '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="padding:.4rem .5rem">'+(r.qualityHtml ? r.qualityHtml : (r.quality!=null ? _klimaBar(r.quality, Math.round(r.quality)+'%') : '<span style="color:#aaa"></span>'))+'</td>';
html += '<td style="text-align:right;padding:.4rem .5rem;color:#6a6a6a;font-size:.72rem">'+esc(r.phase||'—')+'</td>';
html += '</tr>';
});
@@ -3487,6 +3552,9 @@ async function toggleEasyLanguage(studentId, enabled) {
// Performance: zwei parallele API-Calls pro Tab-Wechsel, clientseitige Sortierung,
// Modul-Detail lazy bei Klick.
// Ziel-Container der Gesamt-Auswertung. Default #tab-results (Legacy), im
// neuen Menü rendert die „Gesamt"-Unteransicht in #results-embed (in #tab-live).
var _resultsHost = 'tab-results';
var _resultsState = {
matrix: null, // {students, modules, cells, totals}
activity: null, // {range, buckets, series}
@@ -3500,7 +3568,7 @@ var _resultsState = {
};
async function loadResults() {
var host = document.getElementById('tab-results');
var host = document.getElementById(_resultsHost) || document.getElementById('tab-results');
host.innerHTML = '<p style="font-size:.78rem;color:#8a8a8a;padding:1rem">Lade Ergebnisse…</p>';
// Drei parallele API-Calls: Fleiß (NEU oben) + Matrix + Activity (alt)
@@ -3521,7 +3589,7 @@ async function loadResults() {
}
function renderResults() {
var host = document.getElementById('tab-results');
var host = document.getElementById(_resultsHost) || document.getElementById('tab-results');
host.innerHTML = ''
+ '<div id="res-fleiss"></div>'
+ '<div id="res-matrix"></div>'