Compare commits
123 Commits
a568f81b0a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3dba841830 | |||
| ff889865e1 | |||
| eea5688c76 | |||
| 50c72fa8d8 | |||
| 96a73e22b2 | |||
| 7691649f37 | |||
| bb4d3b4cb6 | |||
| b109ebb099 | |||
| 93c742b381 | |||
| ff91c489f7 | |||
| 085b0b6aa8 | |||
| 637c058507 | |||
| 5b6c25bcd9 | |||
| 77a3e2f16c | |||
| 3f29ad0533 | |||
| 248a46f829 | |||
| 2e9ad6a83f | |||
| 6009eabca1 | |||
| 41731254b1 | |||
| 1dffef8200 | |||
| 496fc59c7b | |||
| 8f89613eee | |||
| 6135dd2648 | |||
| aab9b35781 | |||
| 014eb418a6 | |||
| e901f5addb | |||
| abc8bf5149 | |||
| 54772180a2 | |||
| 4cbdb6bbb1 | |||
| b835475ef7 | |||
| ec746f250f | |||
| 6c2990aa5e | |||
| 7b1bdb8d1b | |||
| c913522a77 | |||
| 502f271f36 | |||
| 362aaba883 | |||
| 88e3c09e5c | |||
| 539f6b012e | |||
| 7e1a7c5228 | |||
| ce7bff3184 | |||
| 89d4d8587e | |||
| a4e57d7aa6 | |||
| 3bc0f418be | |||
| 0a6fa2172f | |||
| c79ef691ad | |||
| bf579310ed | |||
| 691387c4d8 | |||
| 62e903a5ec | |||
| 11c48817de | |||
| 8ec005f889 | |||
| 7de71b0e83 | |||
| b77f4ed912 | |||
| 868cdda276 | |||
| 9a1ee0fae1 | |||
| 1e1c1238bd | |||
| de104e915a | |||
| 193c6dcc8f | |||
| 74736a33aa | |||
| f7bf0f0901 | |||
| cc83493055 | |||
| abbc0580c5 | |||
| be7d9fce1a | |||
| da1e6e6e87 | |||
| bc468272ba | |||
| 4976582400 | |||
| bf5b026b46 | |||
| 77023ff7a3 | |||
| aaa40e9f1a | |||
| 0d9481cae2 | |||
| 0d765961af | |||
| 7986bb2eb6 | |||
| 6dfcf4d57f | |||
| 0532327e6a | |||
| 5d79fa3c3a | |||
| c9eb0e7764 | |||
| 3c28794012 | |||
| 5633d3f9f9 | |||
| e4b877de4b | |||
| 550a35eae8 | |||
| 47adf0497d | |||
| 48971b4588 | |||
| 2386756a20 | |||
| 0c4fb84606 | |||
| 2160a99896 | |||
| 8cc48b1bc6 | |||
| eb67cbc79f | |||
| 3c856ff5ce | |||
| 64a9a62157 | |||
| ad8c6b7d7e | |||
| 71226581fa | |||
| 540b27a2bd | |||
| 5635fa26b4 | |||
| 4e38b27c64 | |||
| 85ee58c56d | |||
| fecd8bb9c9 | |||
| 9c99359d9c | |||
| b32097b617 | |||
| 257b5e5099 | |||
| 532b5471ec | |||
| ff751f3a05 | |||
| 714abc026a | |||
| 8c316f22e8 | |||
| 8ffc43d7b2 | |||
| 9bc3ca7b26 | |||
| 13f14c26a8 | |||
| d7dd3f9de5 | |||
| 8977901e89 | |||
| f934a7f9e4 | |||
| 0c2225594b | |||
| 7f997bff84 | |||
| 423490260d | |||
| b6b6c6ccce | |||
| a68c58ac9e | |||
| c3e61f4da7 | |||
| b353f25b07 | |||
| 5fbbf506b5 | |||
| d4c7fd5009 | |||
| bf2a4cd0d1 | |||
| 4c0183f2e0 | |||
| 0c4064bb58 | |||
| 5d3cd1bdcf | |||
| 5b0505b140 | |||
| c950f954d1 |
@@ -0,0 +1,34 @@
|
|||||||
|
const { chromium } = require('@playwright/test');
|
||||||
|
(async () => {
|
||||||
|
const b = await chromium.launch(); const p = await b.newPage();
|
||||||
|
const errs=[]; p.on('pageerror',e=>errs.push(e.message)); p.on('console',m=>{if(m.type()==='error')errs.push(m.text());});
|
||||||
|
await p.route('**/api/admin', r => r.fulfill({ contentType:'application/json', body: JSON.stringify({authenticated:true}) }));
|
||||||
|
await p.goto('http://localhost/geograsim/App/admin-weltkueche.html');
|
||||||
|
await p.waitForTimeout(2000);
|
||||||
|
const r = await p.evaluate(() => {
|
||||||
|
const dishes = [...document.querySelectorAll('.dish')];
|
||||||
|
const names = dishes.map(d => d.querySelector('.nm')?.textContent);
|
||||||
|
function dishInfo(namePart){
|
||||||
|
const d = dishes.find(x => (x.querySelector('.nm')?.textContent||'').toLowerCase().includes(namePart));
|
||||||
|
if(!d) return null;
|
||||||
|
const rows = [...d.querySelectorAll('tbody tr')].map(tr => ({
|
||||||
|
ing: tr.querySelector('.ing-nm')?.textContent.replace(/\s+/g,' ').trim(),
|
||||||
|
ok: [...tr.querySelectorAll('.cty.ok')].map(c=>c.textContent.replace(/\s+/g,' ').trim()),
|
||||||
|
dist: [...tr.querySelectorAll('.cty.dist')].map(c=>c.title.split(' · ')[0]),
|
||||||
|
}));
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
const groestl = dishInfo('tiroler gröstl');
|
||||||
|
const kart = groestl && groestl.find(r => /Kartoffel/i.test(r.ing));
|
||||||
|
const eierRow = groestl && groestl.find(r => /Eier/i.test(r.ing));
|
||||||
|
return {
|
||||||
|
dishCount: dishes.length,
|
||||||
|
sampleNames: names.slice(0,6),
|
||||||
|
kartoffelOk: kart ? kart.ok : null,
|
||||||
|
eierOk: eierRow ? eierRow.ok : null,
|
||||||
|
warnCount: document.querySelectorAll('.warn').length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify({result:r, pageErrors:errs}, null, 2));
|
||||||
|
await b.close();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
const { chromium } = require('@playwright/test');
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const r = await page.request.get('https://geograsim.at/teacher.html');
|
||||||
|
const html = await r.text();
|
||||||
|
console.log('STATUS', r.status(), 'LEN', html.length);
|
||||||
|
console.log('has renderLiveGesamt:', html.includes('renderLiveGesamt'));
|
||||||
|
console.log('has _liveQualityBar:', html.includes('_liveQualityBar'));
|
||||||
|
console.log('has tab-bar:', html.includes('tab-bar'));
|
||||||
|
console.log('has Ergebnisse:', html.includes('Ergebnisse'));
|
||||||
|
console.log('has external script src (module?):', /<script[^>]+src=/i.test(html));
|
||||||
|
// show script tags
|
||||||
|
const scripts = [...html.matchAll(/<script[^>]*>/gi)].map(m=>m[0]);
|
||||||
|
console.log('SCRIPT TAGS:', JSON.stringify(scripts, null, 2));
|
||||||
|
// show first 600 chars to see redirect logic
|
||||||
|
console.log('HEAD:', html.slice(0, 1200));
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
const { chromium } = require('@playwright/test');
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const r = await page.request.get('https://geograsim.at/teacher.html');
|
||||||
|
const html = await r.text();
|
||||||
|
|
||||||
|
const idxLogin = html.indexOf('login.html');
|
||||||
|
console.log('--- context around first login.html reference ---');
|
||||||
|
console.log(html.slice(idxLogin - 400, idxLogin + 120));
|
||||||
|
|
||||||
|
for (const name of ['renderLiveGesamt', '_liveQualityBar']) {
|
||||||
|
const idx = html.indexOf('function ' + name);
|
||||||
|
console.log('\n--- "function ' + name + '" @' + idx + ' ---');
|
||||||
|
if (idx >= 0) console.log(html.slice(idx - 60, idx + 120));
|
||||||
|
}
|
||||||
|
|
||||||
|
const idxErg = html.indexOf('Ergebnisse');
|
||||||
|
console.log('\n--- context around Ergebnisse ---');
|
||||||
|
console.log(html.slice(idxErg - 220, idxErg + 80));
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
const { chromium } = require('@playwright/test');
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const r = await page.request.get('https://geograsim.at/teacher.html');
|
||||||
|
const html = await r.text();
|
||||||
|
|
||||||
|
// 1) Static tab-bar markup
|
||||||
|
const tb = html.indexOf('id="tab-bar"');
|
||||||
|
console.log('--- #tab-bar markup ---');
|
||||||
|
console.log(tb >= 0 ? html.slice(tb - 40, tb + 700) : 'tab-bar id NOT found');
|
||||||
|
|
||||||
|
// 2) Extract _liveQualityBar function body and run it in isolation
|
||||||
|
const start = html.indexOf('function _liveQualityBar');
|
||||||
|
// find matching braces
|
||||||
|
let i = html.indexOf('{', start), depth = 0, end = -1;
|
||||||
|
for (; i < html.length; i++) {
|
||||||
|
if (html[i] === '{') depth++;
|
||||||
|
else if (html[i] === '}') { depth--; if (depth === 0) { end = i + 1; break; } }
|
||||||
|
}
|
||||||
|
const fnSrc = html.slice(start, end);
|
||||||
|
const fn = new Function(fnSrc + '\nreturn _liveQualityBar;')();
|
||||||
|
const sample = fn(8, 2);
|
||||||
|
console.log('\n--- _liveQualityBar(8,2) OUTPUT ---');
|
||||||
|
console.log(sample);
|
||||||
|
console.log('\ncontains "80":', String(sample).includes('80'));
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
/**
|
||||||
|
* gen-display-preview.js
|
||||||
|
* ----------------------
|
||||||
|
* Lokales Admin-Werkzeug: erstellt Bildschirmfotos aller Simulationen
|
||||||
|
* (Onboarding/Start + Spielverlauf) in zwei Auflösungen (iPad + Full HD)
|
||||||
|
* und generiert daraus eine statische Galerie-Seite.
|
||||||
|
*
|
||||||
|
* Ausgabe: App/.LocalDeveloperTools/display-preview/
|
||||||
|
* shots/<id>__<vp>__<screen>.png
|
||||||
|
* display_preview.html
|
||||||
|
*
|
||||||
|
* Start: node gen-display-preview.js
|
||||||
|
*/
|
||||||
|
const { chromium } = require('@playwright/test');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// --- Pfade ---------------------------------------------------------------
|
||||||
|
const APP_DIR = 'C:/xampp/htdocs/geograsim/App';
|
||||||
|
const SIMS_DIR = path.join(APP_DIR, 'sims');
|
||||||
|
const OUT_DIR = path.join(APP_DIR, '.LocalDeveloperTools', 'display-preview');
|
||||||
|
const SHOTS_DIR = path.join(OUT_DIR, 'shots');
|
||||||
|
const BASE_URL = 'http://localhost/geograsim/App/sims';
|
||||||
|
|
||||||
|
// --- Viewports -----------------------------------------------------------
|
||||||
|
const VIEWPORTS = [
|
||||||
|
{ key: 'ipad', label: 'iPad', w: 1180, h: 820 },
|
||||||
|
{ key: 'fullhd', label: 'Full HD', w: 1920, h: 1080 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Freundliche Namen ---------------------------------------------------
|
||||||
|
const NAMES = {
|
||||||
|
weltkueche: '🍲 Welt-Küche',
|
||||||
|
fluss: '🌊 Flussmanagement',
|
||||||
|
heli: '🚁 Helikopter-Navigation',
|
||||||
|
energiemanager: '⚡ Energiemanager',
|
||||||
|
'eu-werkstatt': '🇪🇺 EU-Werkstatt',
|
||||||
|
farmer: '🌾 Farmer',
|
||||||
|
busfahrt: '🚌 Busfahrt',
|
||||||
|
sonnensystem: '🪐 Sonnensystem',
|
||||||
|
tourismustal: '🏔️ Tourismustal',
|
||||||
|
tourismusregion: '🗺️ Tourismusregion',
|
||||||
|
logistik: '🚚 Logistik',
|
||||||
|
fluggesellschaft:'✈️ Fluggesellschaft',
|
||||||
|
staustufen: '💧 Staustufen',
|
||||||
|
entscheidungstag:'📅 Entscheidungstag',
|
||||||
|
vulkan: '🌋 Vulkan',
|
||||||
|
wal: '🐋 Wal',
|
||||||
|
kofferdetektiv: '🧳 Kofferdetektiv',
|
||||||
|
stadt: '🏙️ Stadt',
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Sims finden ---------------------------------------------------------
|
||||||
|
function findSims() {
|
||||||
|
return fs.readdirSync(SIMS_DIR, { withFileTypes: true })
|
||||||
|
.filter(d => d.isDirectory() && fs.existsSync(path.join(SIMS_DIR, d.name, 'game.html')))
|
||||||
|
.map(d => d.name)
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- In-Page: sichtbaren Start-/Weiter-Button klicken --------------------
|
||||||
|
async function clickStartLike(page) {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
const isVisible = (el) => {
|
||||||
|
if (!el) return false;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
if (r.width < 4 || r.height < 4) return false;
|
||||||
|
if (r.bottom < 0 || r.right < 0) return false;
|
||||||
|
const st = getComputedStyle(el);
|
||||||
|
return st.visibility !== 'hidden' && st.display !== 'none' && st.opacity !== '0'
|
||||||
|
&& !el.disabled && !el.classList.contains('hidden');
|
||||||
|
};
|
||||||
|
// 1) Prioritäts-Selektoren (bekannte Start-/Primary-Buttons der Plattform)
|
||||||
|
const prio = [
|
||||||
|
'.ggs-qi-btn-primary', '[data-action="start"]',
|
||||||
|
'#btnStart', '#obs-start', '#phase-start',
|
||||||
|
'.sn-btn-primary', '.ggs-btn-primary',
|
||||||
|
'.era-card-cta', '.scene-btn',
|
||||||
|
'.map-btn', '.btn.primary', '.primary', '.next',
|
||||||
|
];
|
||||||
|
for (const sel of prio) {
|
||||||
|
const el = [...document.querySelectorAll(sel)].find(isVisible);
|
||||||
|
if (el) { el.click(); return sel + ' :: ' + (el.textContent || '').trim().slice(0, 24); }
|
||||||
|
}
|
||||||
|
// 2) Text-basiert — führende Emojis/Pfeile/Ziffern strippen, dann Anfang prüfen
|
||||||
|
const clean = (s) => (s || '').replace(/^[^\p{L}]+/u, '').trim();
|
||||||
|
const rx = /^(los geht|los!|los →|los\b|start|starten|jetzt starten|neue partie|partie starten|spielen|spiel starten|beginnen|anfangen|einsteigen|erntejahr starten|routenplanung starten|in dieser welt starten|szene|weiter|verstanden|fertig|karte wählen|auswählen)/i;
|
||||||
|
const cands = [...document.querySelectorAll('button, a.btn, a[role=button], .btn, [role=button], [onclick]')];
|
||||||
|
const el = cands.find(e => {
|
||||||
|
if (!isVisible(e)) return false;
|
||||||
|
const t = clean(e.textContent);
|
||||||
|
return t.length > 0 && t.length < 44 && rx.test(t);
|
||||||
|
});
|
||||||
|
if (el) { el.click(); return 'text :: ' + (el.textContent || '').trim().slice(0, 24); }
|
||||||
|
return null;
|
||||||
|
}).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ein Screenshot ------------------------------------------------------
|
||||||
|
async function shoot(page, file) {
|
||||||
|
try {
|
||||||
|
await page.screenshot({ path: file }); // Viewport-Ausschnitt (exakte Auflösung)
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.log(' ! Screenshot-Fehler:', e.message.slice(0, 80));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const sims = findSims();
|
||||||
|
// Optional: nur bestimmte Sims neu schießen (HTML wird immer komplett neu erzeugt).
|
||||||
|
const onlyArg = (process.argv.find(a => a.startsWith('--only=')) || '').slice(7);
|
||||||
|
const shootList = onlyArg ? onlyArg.split(',').map(s => s.trim()).filter(Boolean) : sims;
|
||||||
|
console.log('Gefundene Sims (' + sims.length + '):', sims.join(', '));
|
||||||
|
if (onlyArg) console.log('Neu geschossen werden nur:', shootList.join(', '));
|
||||||
|
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
// results[id][vp] = { start:bool, play:bool, play2:bool, errs:[] }
|
||||||
|
const results = {};
|
||||||
|
|
||||||
|
for (const vp of VIEWPORTS) {
|
||||||
|
console.log('\n===== Viewport ' + vp.label + ' (' + vp.w + '×' + vp.h + ') =====');
|
||||||
|
for (const id of shootList) {
|
||||||
|
results[id] = results[id] || {};
|
||||||
|
const r = { start: false, play: false, play2: false, errs: [] };
|
||||||
|
results[id][vp.key] = r;
|
||||||
|
|
||||||
|
const ctx = await browser.newContext({
|
||||||
|
viewport: { width: vp.w, height: vp.h },
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
});
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
page.on('pageerror', e => { if (r.errs.length < 4) r.errs.push('PE:' + e.message.slice(0, 60)); });
|
||||||
|
page.on('console', m => { if (m.type() === 'error' && r.errs.length < 4) r.errs.push('CON:' + m.text().slice(0, 60)); });
|
||||||
|
|
||||||
|
const url = BASE_URL + '/' + id + '/game.html';
|
||||||
|
try {
|
||||||
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||||
|
} catch (e) {
|
||||||
|
r.errs.push('GOTO:' + e.message.slice(0, 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Onboarding/Start
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
r.start = await shoot(page, path.join(SHOTS_DIR, id + '__' + vp.key + '__start.png'));
|
||||||
|
|
||||||
|
// 2) Spielverlauf: mehrere Runden klicken (Onboarding schließen -> Menü -> Spiel -> Tutorial weg)
|
||||||
|
let clicks = 0, last = null;
|
||||||
|
for (let round = 0; round < 4; round++) {
|
||||||
|
const clicked = await clickStartLike(page);
|
||||||
|
if (!clicked) break;
|
||||||
|
clicks++;
|
||||||
|
if (clicked === last) break; // gleiches Element erneut -> Abbruch
|
||||||
|
last = clicked;
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
}
|
||||||
|
if (clicks > 0) {
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
r.play = await shoot(page, path.join(SHOTS_DIR, id + '__' + vp.key + '__play.png'));
|
||||||
|
|
||||||
|
// 3) optionaler zweiter Play-Screen: noch ein Weiter/Start, falls vorhanden
|
||||||
|
const more = await clickStartLike(page);
|
||||||
|
if (more && more !== last) {
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
r.play2 = await shoot(page, path.join(SHOTS_DIR, id + '__' + vp.key + '__play2.png'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const flags = [r.start ? 'S' : '-', r.play ? 'P' : '-', r.play2 ? 'P2' : '--'].join(' ');
|
||||||
|
console.log(' ' + id.padEnd(18) + '[' + flags + ']'
|
||||||
|
+ (clicks ? ' clicks=' + clicks : ' (kein Start-Button)')
|
||||||
|
+ (r.errs.length ? ' ' + r.errs.slice(0, 2).join(' | ') : ''));
|
||||||
|
|
||||||
|
await ctx.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
// --- HTML generieren ---------------------------------------------------
|
||||||
|
const genDate = new Date().toLocaleString('de-AT', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
|
||||||
|
const screens = [
|
||||||
|
{ key: 'start', label: 'Onboarding / Start' },
|
||||||
|
{ key: 'play', label: 'Spielverlauf' },
|
||||||
|
{ key: 'play2', label: 'Spielverlauf (2)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function cell(id, vp, screenKey) {
|
||||||
|
const rel = 'shots/' + id + '__' + vp.key + '__' + screenKey + '.png';
|
||||||
|
const abs = path.join(SHOTS_DIR, id + '__' + vp.key + '__' + screenKey + '.png');
|
||||||
|
if (fs.existsSync(abs)) {
|
||||||
|
return '<a href="' + rel + '" target="_blank" class="shot">'
|
||||||
|
+ '<img loading="lazy" src="' + rel + '" alt="' + id + ' ' + vp.label + ' ' + screenKey + '">'
|
||||||
|
+ '<span class="cap">' + vp.label + ' · ' + vp.w + '×' + vp.h + '</span></a>';
|
||||||
|
}
|
||||||
|
return '<div class="missing">— kein Screenshot —<span class="cap">' + vp.label + '</span></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
let blocks = '';
|
||||||
|
for (const id of sims) {
|
||||||
|
const name = NAMES[id] || (id.charAt(0).toUpperCase() + id.slice(1));
|
||||||
|
// nur Screen-Zeilen zeigen, die mindestens ein Bild haben
|
||||||
|
const activeScreens = screens.filter(sc =>
|
||||||
|
VIEWPORTS.some(vp => fs.existsSync(path.join(SHOTS_DIR, id + '__' + vp.key + '__' + sc.key + '.png')))
|
||||||
|
);
|
||||||
|
const rows = (activeScreens.length ? activeScreens : [screens[0]]).map(sc =>
|
||||||
|
'<tr><th class="screen">' + sc.label + '</th>'
|
||||||
|
+ VIEWPORTS.map(vp => '<td>' + cell(id, vp, sc.key) + '</td>').join('')
|
||||||
|
+ '</tr>'
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
blocks += '\n<section class="sim" id="sim-' + id + '">'
|
||||||
|
+ '<h2>' + name + ' <span class="simid">' + id + '</span></h2>'
|
||||||
|
+ '<table><thead><tr><th class="screen"></th>'
|
||||||
|
+ VIEWPORTS.map(vp => '<th>' + vp.label + ' <span class="res">' + vp.w + '×' + vp.h + '</span></th>').join('')
|
||||||
|
+ '</tr></thead><tbody>' + rows + '</tbody></table></section>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const nav = sims.map(id =>
|
||||||
|
'<a href="#sim-' + id + '">' + (NAMES[id] || id) + '</a>'
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Display-Vorschau (lokal)</title>
|
||||||
|
<style>
|
||||||
|
:root { --bg:#f4f2ec; --card:#fff; --ink:#1f2b26; --accent:#1f4b37; --muted:#6b7a72; --line:rgba(0,0,0,.08); }
|
||||||
|
* { box-sizing:border-box; }
|
||||||
|
body { margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; background:var(--bg); color:var(--ink); }
|
||||||
|
header { background:var(--accent); color:#fff; padding:20px 28px; position:sticky; top:0; z-index:10; box-shadow:0 2px 10px rgba(0,0,0,.15); }
|
||||||
|
header h1 { margin:0; font-size:1.4rem; }
|
||||||
|
header .meta { font-size:.82rem; opacity:.85; margin-top:4px; }
|
||||||
|
nav.toc { display:flex; flex-wrap:wrap; gap:6px; padding:14px 28px; background:#e9e6dd; border-bottom:1px solid var(--line); }
|
||||||
|
nav.toc a { font-size:.82rem; text-decoration:none; color:var(--accent); background:#fff; border:1px solid var(--line); border-radius:999px; padding:4px 11px; }
|
||||||
|
nav.toc a:hover { background:var(--accent); color:#fff; }
|
||||||
|
main { padding:24px 28px 60px; max-width:1500px; margin:0 auto; }
|
||||||
|
section.sim { background:var(--card); border-radius:14px; padding:18px 20px 22px; margin-bottom:26px; box-shadow:0 4px 14px rgba(0,0,0,.06); scroll-margin-top:90px; }
|
||||||
|
section.sim h2 { margin:0 0 14px; font-size:1.25rem; color:var(--accent); display:flex; align-items:baseline; gap:10px; }
|
||||||
|
.simid { font-size:.72rem; font-weight:600; color:var(--muted); background:#eef0ec; padding:2px 8px; border-radius:6px; letter-spacing:.03em; }
|
||||||
|
table { width:100%; border-collapse:collapse; table-layout:fixed; }
|
||||||
|
thead th { text-align:left; font-size:.82rem; color:var(--muted); padding:0 0 8px; font-weight:700; }
|
||||||
|
thead th .res { font-weight:500; opacity:.7; }
|
||||||
|
th.screen { width:150px; vertical-align:top; font-size:.9rem; color:var(--ink); padding-top:8px; }
|
||||||
|
td { padding:6px 6px 14px; vertical-align:top; width:calc((100% - 150px)/2); }
|
||||||
|
a.shot { display:block; border:1px solid var(--line); border-radius:8px; overflow:hidden; background:#0d1512; text-decoration:none; }
|
||||||
|
a.shot img { display:block; width:100%; max-width:100%; height:auto; }
|
||||||
|
.cap { display:block; font-size:.72rem; color:var(--muted); padding:5px 8px; background:#fafafa; border-top:1px solid var(--line); }
|
||||||
|
a.shot .cap { color:#c8d4ce; background:#141f1a; border-top:none; }
|
||||||
|
.missing { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:6px; min-height:120px; border:1px dashed var(--line); border-radius:8px; color:var(--muted); font-size:.85rem; background:#faf9f5; }
|
||||||
|
.missing .cap { background:none; border:none; }
|
||||||
|
@media (max-width:820px){ th.screen{width:90px;} td{width:calc((100% - 90px)/2);} }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Display-Vorschau (lokal)</h1>
|
||||||
|
<div class="meta">${sims.length} Simulationen · je Onboarding/Start + Spielverlauf · iPad 1180×820 & Full HD 1920×1080 · generiert am ${genDate}</div>
|
||||||
|
</header>
|
||||||
|
<nav class="toc">${nav}</nav>
|
||||||
|
<main>${blocks}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
const outFile = path.join(OUT_DIR, 'display_preview.html');
|
||||||
|
fs.writeFileSync(outFile, html, 'utf8');
|
||||||
|
console.log('\nHTML geschrieben: ' + outFile);
|
||||||
|
|
||||||
|
// --- Zusammenfassung ---------------------------------------------------
|
||||||
|
console.log('\n===== ZUSAMMENFASSUNG =====');
|
||||||
|
let totalShots = 0, missingPlay = [];
|
||||||
|
for (const id of sims) {
|
||||||
|
const parts = [];
|
||||||
|
for (const vp of VIEWPORTS) {
|
||||||
|
const r = (results[id] && results[id][vp.key]) || {};
|
||||||
|
const n = (r.start ? 1 : 0) + (r.play ? 1 : 0) + (r.play2 ? 1 : 0);
|
||||||
|
totalShots += n;
|
||||||
|
parts.push(vp.label + ':' + n + '/3(' + (r.start ? 'S' : '-') + (r.play ? 'P' : '-') + (r.play2 ? '2' : '-') + ')');
|
||||||
|
if (!r.play) missingPlay.push(id + '/' + vp.key);
|
||||||
|
}
|
||||||
|
console.log(' ' + id.padEnd(18) + parts.join(' '));
|
||||||
|
}
|
||||||
|
console.log('\nScreenshots gesamt: ' + totalShots);
|
||||||
|
console.log('Fehlende Play-Screens: ' + (missingPlay.length ? missingPlay.join(', ') : 'keine'));
|
||||||
|
console.log('Seite: http://localhost/geograsim/App/.LocalDeveloperTools/display-preview/display_preview.html');
|
||||||
|
})();
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
const { chromium } = require('@playwright/test');
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const res = await page.goto('http://localhost/geograsim/App/modul-weltkueche');
|
||||||
|
await page.waitForTimeout(700);
|
||||||
|
const r = await page.evaluate(() => {
|
||||||
|
const sec = document.getElementById('lehrplan-bezug');
|
||||||
|
const komps = [...document.querySelectorAll('.md-kompetenz .md-kompetenz-title')].map(e=>e.textContent.trim());
|
||||||
|
const anchors = document.querySelectorAll('.md-anchor').length;
|
||||||
|
const countryChips = [...document.querySelectorAll('.md-lehrplan-filters .md-chip')].map(c=>c.textContent.trim());
|
||||||
|
const empty = document.body.textContent.includes('Noch keine Kompetenzen zugeordnet');
|
||||||
|
return { hasSection: !!sec, kompetenzTitles: komps, anchorCount: anchors, countryChips, showsEmpty: empty };
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify({ httpStatus: res.status(), result: r }, null, 2));
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
const { chromium } = require('@playwright/test');
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const res = await page.goto('https://geograsim.at/modul-weltkueche', { waitUntil: 'networkidle' });
|
||||||
|
await page.waitForTimeout(700);
|
||||||
|
const r = await page.evaluate(() => {
|
||||||
|
const sec = document.getElementById('lehrplan-bezug');
|
||||||
|
const komps = [...document.querySelectorAll('.md-kompetenz .md-kompetenz-title')].map(e=>e.textContent.trim());
|
||||||
|
const anchors = document.querySelectorAll('.md-anchor').length;
|
||||||
|
const countryChips = [...document.querySelectorAll('.md-lehrplan-filters .md-chip')].map(c=>c.textContent.trim());
|
||||||
|
const empty = document.body.textContent.includes('Noch keine Kompetenzen zugeordnet');
|
||||||
|
return { hasSection: !!sec, kompetenzTitles: komps, anchorCount: anchors, countryChips, showsEmpty: empty };
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify({ httpStatus: res.status(), result: r }, null, 2));
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
const { chromium } = require('@playwright/test');
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const errs=[]; page.on('pageerror',e=>errs.push(e.message));
|
||||||
|
await page.goto('http://localhost/geograsim/App/modul-weltkueche', { waitUntil:'domcontentloaded' });
|
||||||
|
await page.waitForTimeout(700);
|
||||||
|
|
||||||
|
async function snapshot() {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
const all = [...document.querySelectorAll('.md-anchor')];
|
||||||
|
const visible = all.filter(a => !a.classList.contains('hidden'));
|
||||||
|
const byType = t => visible.filter(a => a.dataset.anchorType===t).length;
|
||||||
|
const fachCountries = {};
|
||||||
|
visible.filter(a=>a.dataset.anchorType==='fach').forEach(a=>{ fachCountries[a.dataset.country]=(fachCountries[a.dataset.country]||0)+1; });
|
||||||
|
return { total: all.length, visible: visible.length, fachVisible: byType('fach'), fachCountries };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function clickChip(country) {
|
||||||
|
await page.evaluate((c) => {
|
||||||
|
const btn = [...document.querySelectorAll('.md-chip')].find(b => (b.dataset.country||'')===c);
|
||||||
|
if (btn) btn.click();
|
||||||
|
}, country);
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = await snapshot();
|
||||||
|
await clickChip(''); // Alle Länder
|
||||||
|
const alle = await snapshot();
|
||||||
|
await clickChip('DE'); // Deutschland
|
||||||
|
const de = await snapshot();
|
||||||
|
await clickChip('AT'); // Österreich
|
||||||
|
const at = await snapshot();
|
||||||
|
|
||||||
|
console.log(JSON.stringify({ start, alle, de, at, pageErrors: errs }, null, 2));
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
const { chromium } = require('@playwright/test');
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const errs=[]; page.on('pageerror',e=>errs.push(e.message));
|
||||||
|
await page.goto('https://geograsim.at/modul-weltkueche', { waitUntil:'domcontentloaded' });
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
async function snap(){ return await page.evaluate(()=>{ const all=[...document.querySelectorAll('.md-anchor')]; const vis=all.filter(a=>!a.classList.contains('hidden')); const fc={}; vis.filter(a=>a.dataset.anchorType==='fach').forEach(a=>fc[a.dataset.country]=(fc[a.dataset.country]||0)+1); return {total:all.length, visible:vis.length, fach:vis.filter(a=>a.dataset.anchorType==='fach').length, fachCountries:fc}; }); }
|
||||||
|
async function chip(c){ await page.evaluate(c=>{const b=[...document.querySelectorAll('.md-chip')].find(x=>(x.dataset.country||'')===c); if(b)b.click();},c); await page.waitForTimeout(200); }
|
||||||
|
await chip(''); const alle=await snap();
|
||||||
|
await chip('DE'); const de=await snap();
|
||||||
|
await chip('AT'); const at=await snap();
|
||||||
|
console.log(JSON.stringify({alle,de,at,pageErrors:errs},null,2));
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
const { chromium } = require('@playwright/test');
|
||||||
|
(async () => {
|
||||||
|
const b = await chromium.launch(); const p = await b.newPage();
|
||||||
|
await p.addInitScript(() => {
|
||||||
|
window.__GGS__ = { simId: 'test', baseUrl: '' };
|
||||||
|
window.fetch = () => Promise.resolve({ ok:true, json:()=>Promise.resolve({}) }); // Heartbeat stummschalten
|
||||||
|
});
|
||||||
|
await p.goto('about:blank');
|
||||||
|
await p.addScriptTag({ path: 'C:/xampp/htdocs/geograsim/App/assets/js/live-client.js' });
|
||||||
|
const read = () => p.evaluate(() => window.__GGS_LIVE__ ? window.__GGS_LIVE__.activeMs : null);
|
||||||
|
const set = (v) => p.evaluate((v) => window.__GGS_LIVE__.setPaused(v), v);
|
||||||
|
await p.waitForTimeout(300);
|
||||||
|
const a1 = await read();
|
||||||
|
await p.waitForTimeout(2500); const a2 = await read(); // sollte gestiegen sein
|
||||||
|
await set(true);
|
||||||
|
await p.waitForTimeout(2500); const a3 = await read(); // sollte eingefroren sein
|
||||||
|
await set(false);
|
||||||
|
await p.waitForTimeout(2500); const a4 = await read(); // sollte wieder steigen
|
||||||
|
const grewNormal = (a2 - a1) > 1500;
|
||||||
|
const frozen = (a3 - a2) < 300;
|
||||||
|
const resumed = (a4 - a3) > 1500;
|
||||||
|
console.log(JSON.stringify({ a1,a2,a3,a4, grewNormal, frozen, resumed,
|
||||||
|
verdict: (grewNormal && frozen && resumed) ? 'PASS' : 'FAIL' }, null, 2));
|
||||||
|
await b.close();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
const { chromium } = require('@playwright/test');
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const out = {};
|
||||||
|
|
||||||
|
// 1) modul-weltkueche: Lehrplan-Bezug jetzt vorhanden?
|
||||||
|
{
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.goto('https://geograsim.at/modul-weltkueche', { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
out.modulWeltkueche = await page.evaluate(() => ({
|
||||||
|
kompetenzTitles: [...document.querySelectorAll('.md-kompetenz-title')].map(e=>e.textContent.trim()),
|
||||||
|
anchorCount: document.querySelectorAll('.md-anchor').length,
|
||||||
|
showsEmpty: document.body.textContent.includes('Noch keine Kompetenzen zugeordnet'),
|
||||||
|
}));
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Weltküche-Sim: Artikel + Ukraine-Feedback
|
||||||
|
{
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const errs=[]; page.on('pageerror',e=>errs.push(e.message));
|
||||||
|
await page.goto('https://geograsim.at/sims/weltkueche/game.html', { waitUntil:'domcontentloaded' });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
out.weltkueche = await page.evaluate(async () => {
|
||||||
|
try {
|
||||||
|
if (!DISHES.length) await loadWeltkuecheData();
|
||||||
|
const dish = DISHES.find(d=>d.id==='tiroler-groestl'); current={dish};
|
||||||
|
buildTaskFor('kartoffel');
|
||||||
|
return {
|
||||||
|
kartoffelArt: ING.kartoffel.art,
|
||||||
|
badArticles: Object.entries(ING).filter(([k,v])=>!['der','die','das'].includes(v&&v.art)).map(([k])=>k),
|
||||||
|
wrongUA: explainChoice('kartoffel','ukraine',false).text,
|
||||||
|
};
|
||||||
|
} catch(e){ return { error:String(e) }; }
|
||||||
|
});
|
||||||
|
out.weltkueche.pageErrors = errs;
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) teacher.html: lädt + neue Funktionen da (direkter Link, den das Produkt nutzt)
|
||||||
|
{
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const errs=[]; page.on('pageerror',e=>errs.push(e.message));
|
||||||
|
// teacher.html leitet ohne Login auf login.html um — Navigation abfangen, sofort auswerten
|
||||||
|
await page.route('**/login.html', r=>r.abort());
|
||||||
|
await page.goto('https://geograsim.at/teacher.html', { waitUntil:'domcontentloaded' }).catch(()=>{});
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
out.teacher = await page.evaluate(() => ({
|
||||||
|
hasRenderGesamt: typeof renderLiveGesamt === 'function',
|
||||||
|
hasQualityBar: typeof _liveQualityBar === 'function',
|
||||||
|
qualitySample: (typeof _liveQualityBar==='function') ? _liveQualityBar(8,2) : null,
|
||||||
|
tabLabels: [...document.querySelectorAll('#tab-bar .tab')].map(t=>t.textContent.trim()),
|
||||||
|
})).catch(e=>({evalError:String(e)}));
|
||||||
|
out.teacher.pageErrors = errs;
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Tourismusregion passstrasse Karte
|
||||||
|
{
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const mc = await page.request.get('https://geograsim.at/sims/tourismusregion/js/maps-custom.json');
|
||||||
|
const j = await mc.json();
|
||||||
|
out.tourismus = {
|
||||||
|
keys: Object.keys(j),
|
||||||
|
passBergaufPoints: j.passstrasse?.routes?.bergauf?.length ?? null,
|
||||||
|
passSlots: (j.passstrasse?.slots||[]).length,
|
||||||
|
};
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(JSON.stringify(out, null, 2));
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
const { chromium } = require('@playwright/test');
|
||||||
|
(async () => {
|
||||||
|
const b = await chromium.launch(); const p = await b.newPage();
|
||||||
|
const errs=[]; p.on('pageerror',e=>errs.push(e.message)); p.on('console',m=>{if(m.type()==='error')errs.push(m.text());});
|
||||||
|
await p.route('**/login.html', r=>r.abort());
|
||||||
|
await p.goto('http://localhost/geograsim/App/teacher.html',{waitUntil:'domcontentloaded'}).catch(()=>{});
|
||||||
|
await p.waitForTimeout(1200);
|
||||||
|
const r = await p.evaluate(() => ({
|
||||||
|
hasSimWord: typeof simWord==='function',
|
||||||
|
hasSimCode: typeof simCode==='function',
|
||||||
|
logistikWord: typeof simWord==='function' ? simWord('logistik') : null,
|
||||||
|
logistikCode: typeof simCode==='function' ? simCode('logistik') : null,
|
||||||
|
unknownCode: typeof simCode==='function' ? simCode('irgendwas') : null,
|
||||||
|
logistikFields: (typeof LIVE_PRIMARY_FIELDS==='object' && LIVE_PRIMARY_FIELDS.logistik) ? LIVE_PRIMARY_FIELDS.logistik.map(f=>f.label) : null,
|
||||||
|
liveShortLogistik: typeof LIVE_SIM_SHORT==='object' ? LIVE_SIM_SHORT.logistik : null,
|
||||||
|
// Kürzel-Eindeutigkeit
|
||||||
|
codesUnique: (function(){ if(typeof SIM_LABELS!=='object') return null; var seen={},dup=[]; Object.keys(SIM_LABELS).forEach(k=>{var c=SIM_LABELS[k].c; if(seen[c])dup.push(c); seen[c]=1;}); return dup; })(),
|
||||||
|
}));
|
||||||
|
console.log(JSON.stringify({result:r, pageErrors:errs}, null, 2));
|
||||||
|
await b.close();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
const { chromium } = require('@playwright/test');
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', e => errors.push('PAGEERROR: '+e.message));
|
||||||
|
page.on('console', m => { if (m.type()==='error') errors.push(m.text()); });
|
||||||
|
const res = await page.goto('http://localhost/geograsim/App/teacher');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
const r = await page.evaluate(() => {
|
||||||
|
const tabs = [...document.querySelectorAll('#tab-bar .tab')].map(t => t.textContent.trim());
|
||||||
|
let toggle = null;
|
||||||
|
try { toggle = (typeof _liveToggleHtml === 'function') ? _liveToggleHtml() : null; } catch(e){ toggle = 'ERR:'+e.message; }
|
||||||
|
return {
|
||||||
|
tabLabels: tabs,
|
||||||
|
hasSetLiveMode: typeof setLiveMode === 'function',
|
||||||
|
hasRenderGesamt: typeof renderLiveGesamt === 'function',
|
||||||
|
hasQualityBar: typeof _liveQualityBar === 'function',
|
||||||
|
qualitySample: (typeof _liveQualityBar === 'function') ? _liveQualityBar(8,2) : null,
|
||||||
|
toggleHasGesamt: (typeof toggle === 'string') ? toggle.includes('Gesamt') : toggle,
|
||||||
|
toggleHasTag: (typeof toggle === 'string') ? toggle.includes('Tag') : toggle,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify({ httpStatus: res.status(), result: r, pageErrors: errors }, null, 2));
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
const { chromium } = require('@playwright/test');
|
||||||
|
(async () => {
|
||||||
|
const b = await chromium.launch(); const p = await b.newPage({ viewport:{width:1180,height:820} });
|
||||||
|
const errs=[]; p.on('pageerror',e=>errs.push(e.message)); p.on('console',m=>{if(m.type()==='error')errs.push(m.text());});
|
||||||
|
await p.goto('http://localhost/geograsim/App/sims/tourismusregion/game.html');
|
||||||
|
await p.waitForTimeout(1500);
|
||||||
|
await p.click('.map-btn'); // Route klicken → Spiel startet → Willkommenskarte
|
||||||
|
await p.waitForTimeout(2000);
|
||||||
|
const r = await p.evaluate(() => {
|
||||||
|
const card = document.getElementById('cardText');
|
||||||
|
const txt = card ? card.textContent : '';
|
||||||
|
return {
|
||||||
|
started: !!window.__ttState,
|
||||||
|
cardHasEnvText: /Umwelt ist deine Landschaft/.test(txt),
|
||||||
|
cardHasNaturschutz: /Naturschutzgebiet/.test(txt),
|
||||||
|
envKpi: document.getElementById('kEnv')?.textContent,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify({ result:r, pageErrors: errs.filter(e=>!/404|Failed to load resource/.test(e)) }, null, 2));
|
||||||
|
await b.close();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
const { chromium } = require('@playwright/test');
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', e => errors.push('PAGEERROR: '+e.message));
|
||||||
|
page.on('console', m => { if (m.type()==='error') errors.push(m.text()); });
|
||||||
|
|
||||||
|
// 1) maps-custom.json direkt prüfen
|
||||||
|
const mc = await page.request.get('http://localhost/geograsim/App/sims/tourismusregion/js/maps-custom.json');
|
||||||
|
const mcJson = await mc.json();
|
||||||
|
const ps = mcJson.passstrasse || null;
|
||||||
|
|
||||||
|
// 2) Hintergrundbild erreichbar?
|
||||||
|
const img = await page.request.get('http://localhost/geograsim/App/sims/tourismusregion/assets/maps/passstrasse.png');
|
||||||
|
|
||||||
|
// 3) Sim-Startscreen laden, Kartenliste prüfen
|
||||||
|
const res = await page.goto('http://localhost/geograsim/App/sims/tourismusregion/game.html');
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
const startInfo = await page.evaluate(() => {
|
||||||
|
const list = document.getElementById('mapList');
|
||||||
|
const labels = list ? [...list.querySelectorAll('*')].map(e=>e.textContent).filter(t=>t && t.length<40) : [];
|
||||||
|
const hasPass = !!(list && /Passstra/i.test(list.textContent||''));
|
||||||
|
return { mapListText: (list?list.textContent:'').replace(/\s+/g,' ').trim().slice(0,300), hasPass };
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
httpStatus: res.status(),
|
||||||
|
passInMapsCustom: !!ps,
|
||||||
|
routeKeys: ps ? Object.keys(ps.routes||{}) : null,
|
||||||
|
bergaufPoints: ps && ps.routes && ps.routes.bergauf ? ps.routes.bergauf.length : null,
|
||||||
|
slotCount: ps ? (ps.slots||[]).length : null,
|
||||||
|
bgImageStatus: img.status(),
|
||||||
|
startInfo,
|
||||||
|
pageErrors: errors
|
||||||
|
}, null, 2));
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
const { chromium } = require('@playwright/test');
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1180, height: 820 } }); // iPad Landscape
|
||||||
|
const errs=[]; page.on('pageerror',e=>errs.push(e.message)); page.on('console',m=>{if(m.type()==='error')errs.push(m.text());});
|
||||||
|
await page.goto('http://localhost/geograsim/App/sims/tourismusregion/game.html');
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
const before = await page.evaluate(() => ({
|
||||||
|
hasPlayerName: !!document.getElementById('playerName'),
|
||||||
|
hasClassCode: !!document.getElementById('classCode'),
|
||||||
|
hasBtnStart: !!document.getElementById('btnStart'),
|
||||||
|
hasBtnSave: !!document.getElementById('btnSave'),
|
||||||
|
mapBtnCount: document.querySelectorAll('.map-btn').length,
|
||||||
|
startOverlayHidden: document.getElementById('startOverlay').classList.contains('hidden'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ② Klick auf erste Route sollte SOFORT starten
|
||||||
|
await page.click('.map-btn');
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
const after = await page.evaluate(() => ({
|
||||||
|
startOverlayHidden: document.getElementById('startOverlay').classList.contains('hidden'),
|
||||||
|
gameStarted: !!window.__ttState,
|
||||||
|
mapId: window.__ttState && window.__ttState.map && window.__ttState.map.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'C:/Users/herr_/AppData/Local/Temp/claude/c--xampp-htdocs-geograsim/c02572ea-7616-4524-a974-dff0e5670d2c/scratchpad/tr-ipad.png' });
|
||||||
|
console.log(JSON.stringify({ before, after, pageErrors: errs }, null, 2));
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const { chromium } = require('@playwright/test');
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on('console', m => { if (m.type()==='error') errors.push(m.text()); });
|
||||||
|
page.on('pageerror', e => errors.push('PAGEERROR: '+e.message));
|
||||||
|
await page.goto('http://localhost/geograsim/App/sims/weltkueche/game.html');
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
const r = await page.evaluate(async () => {
|
||||||
|
try {
|
||||||
|
if (!DISHES.length) await loadWeltkuecheData();
|
||||||
|
const dish = DISHES.find(d => d.id === 'tiroler-groestl');
|
||||||
|
if (!dish) return { error: 'no tiroler-groestl dish' };
|
||||||
|
current = { dish };
|
||||||
|
const task = buildTaskFor('kartoffel');
|
||||||
|
const art = ING.kartoffel.art;
|
||||||
|
const correctAT = explainChoice('kartoffel', 'oesterreich', true).text;
|
||||||
|
const correctDE = explainChoice('kartoffel', 'deutschland', true).text;
|
||||||
|
const wrongUA = explainChoice('kartoffel', 'ukraine', false).text;
|
||||||
|
const bad = Object.entries(ING).filter(([k,v]) => !['der','die','das'].includes(v && v.art)).map(([k])=>k);
|
||||||
|
return { art, taskCorrect: task.correct, correctAT, correctDE, wrongUA, badArticles: bad, ingCount: Object.keys(ING).length };
|
||||||
|
} catch (e) { return { error: String(e), stack: e.stack }; }
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify({ result: r, consoleErrors: errors }, null, 2));
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
const { chromium } = require('@playwright/test');
|
||||||
|
(async () => {
|
||||||
|
const b = await chromium.launch(); const p = await b.newPage();
|
||||||
|
const errs=[]; p.on('pageerror',e=>errs.push(e.message)); p.on('console',m=>{if(m.type()==='error')errs.push(m.text());});
|
||||||
|
await p.goto('http://localhost/geograsim/App/sims/weltkueche/game.html');
|
||||||
|
await p.waitForTimeout(1500);
|
||||||
|
const r = await p.evaluate(async () => {
|
||||||
|
try {
|
||||||
|
if (!DISHES.length) await loadWeltkuecheData();
|
||||||
|
function taskFor(ingKey, heimat){
|
||||||
|
current = { dish: { id:'test-'+ingKey, heimat: heimat, ings:[ingKey], signature:{} } };
|
||||||
|
try { delete _taskCache[ingKey]; } catch(_){}
|
||||||
|
return buildTaskFor(ingKey);
|
||||||
|
}
|
||||||
|
const dEggsDE = _wkDist('oesterreich','deutschland');
|
||||||
|
const dEggsFR = _wkDist('oesterreich','frankreich');
|
||||||
|
const eier = taskFor('eier','oesterreich');
|
||||||
|
const kart = taskFor('kartoffel','oesterreich');
|
||||||
|
const milch = taskFor('milch','oesterreich');
|
||||||
|
// alle Zutaten müssen fehlerfrei bauen
|
||||||
|
let buildErr = null;
|
||||||
|
for (const k of Object.keys(ING)) {
|
||||||
|
current = { dish:{id:'x',heimat:'oesterreich',ings:[k],signature:{}} };
|
||||||
|
try { delete _taskCache[k]; buildTaskFor(k); } catch(e){ buildErr = k+': '+e.message; break; }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
distDE: Math.round(dEggsDE), distFR: Math.round(dEggsFR),
|
||||||
|
eierCorrect: eier.correct, eierChoices: eier.choices,
|
||||||
|
eierDistractors: eier.choices.length - eier.correct.length,
|
||||||
|
kartoffelCorrect: kart.correct,
|
||||||
|
milchCorrect: milch.correct,
|
||||||
|
buildErr, ingCount: Object.keys(ING).length,
|
||||||
|
};
|
||||||
|
} catch(e){ return { error:String(e), stack:e.stack }; }
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify({result:r, pageErrors:errs}, null, 2));
|
||||||
|
await b.close();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Display-Vorschau (lokal)</title>
|
||||||
|
<style>
|
||||||
|
:root { --bg:#f4f2ec; --card:#fff; --ink:#1f2b26; --accent:#1f4b37; --muted:#6b7a72; --line:rgba(0,0,0,.08); }
|
||||||
|
* { box-sizing:border-box; }
|
||||||
|
body { margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; background:var(--bg); color:var(--ink); }
|
||||||
|
header { background:var(--accent); color:#fff; padding:20px 28px; position:sticky; top:0; z-index:10; box-shadow:0 2px 10px rgba(0,0,0,.15); }
|
||||||
|
header h1 { margin:0; font-size:1.4rem; }
|
||||||
|
header .meta { font-size:.82rem; opacity:.85; margin-top:4px; }
|
||||||
|
nav.toc { display:flex; flex-wrap:wrap; gap:6px; padding:14px 28px; background:#e9e6dd; border-bottom:1px solid var(--line); }
|
||||||
|
nav.toc a { font-size:.82rem; text-decoration:none; color:var(--accent); background:#fff; border:1px solid var(--line); border-radius:999px; padding:4px 11px; }
|
||||||
|
nav.toc a:hover { background:var(--accent); color:#fff; }
|
||||||
|
main { padding:24px 28px 60px; max-width:1500px; margin:0 auto; }
|
||||||
|
section.sim { background:var(--card); border-radius:14px; padding:18px 20px 22px; margin-bottom:26px; box-shadow:0 4px 14px rgba(0,0,0,.06); scroll-margin-top:90px; }
|
||||||
|
section.sim h2 { margin:0 0 14px; font-size:1.25rem; color:var(--accent); display:flex; align-items:baseline; gap:10px; }
|
||||||
|
.simid { font-size:.72rem; font-weight:600; color:var(--muted); background:#eef0ec; padding:2px 8px; border-radius:6px; letter-spacing:.03em; }
|
||||||
|
table { width:100%; border-collapse:collapse; table-layout:fixed; }
|
||||||
|
thead th { text-align:left; font-size:.82rem; color:var(--muted); padding:0 0 8px; font-weight:700; }
|
||||||
|
thead th .res { font-weight:500; opacity:.7; }
|
||||||
|
th.screen { width:150px; vertical-align:top; font-size:.9rem; color:var(--ink); padding-top:8px; }
|
||||||
|
td { padding:6px 6px 14px; vertical-align:top; width:calc((100% - 150px)/2); }
|
||||||
|
a.shot { display:block; border:1px solid var(--line); border-radius:8px; overflow:hidden; background:#0d1512; text-decoration:none; }
|
||||||
|
a.shot img { display:block; width:100%; max-width:100%; height:auto; }
|
||||||
|
.cap { display:block; font-size:.72rem; color:var(--muted); padding:5px 8px; background:#fafafa; border-top:1px solid var(--line); }
|
||||||
|
a.shot .cap { color:#c8d4ce; background:#141f1a; border-top:none; }
|
||||||
|
.missing { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:6px; min-height:120px; border:1px dashed var(--line); border-radius:8px; color:var(--muted); font-size:.85rem; background:#faf9f5; }
|
||||||
|
.missing .cap { background:none; border:none; }
|
||||||
|
@media (max-width:820px){ th.screen{width:90px;} td{width:calc((100% - 90px)/2);} }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Display-Vorschau (lokal)</h1>
|
||||||
|
<div class="meta">16 Simulationen · je Onboarding/Start + Spielverlauf · iPad 1180×820 & Full HD 1920×1080 · generiert am 19.08.2026, 15:06</div>
|
||||||
|
</header>
|
||||||
|
<nav class="toc"><a href="#sim-busfahrt">🚌 Busfahrt</a><a href="#sim-energiemanager">⚡ Energiemanager</a><a href="#sim-entscheidungstag">📅 Entscheidungstag</a><a href="#sim-eu-werkstatt">🇪🇺 EU-Werkstatt</a><a href="#sim-farmer">🌾 Farmer</a><a href="#sim-fluggesellschaft">✈️ Fluggesellschaft</a><a href="#sim-fluss">🌊 Flussmanagement</a><a href="#sim-heli">🚁 Helikopter-Navigation</a><a href="#sim-logistik">🚚 Logistik</a><a href="#sim-sonnensystem">🪐 Sonnensystem</a><a href="#sim-staustufen">💧 Staustufen</a><a href="#sim-tourismusregion">🗺️ Tourismusregion</a><a href="#sim-tourismustal">🏔️ Tourismustal</a><a href="#sim-vulkan">🌋 Vulkan</a><a href="#sim-wal">🐋 Wal</a><a href="#sim-weltkueche">🍲 Welt-Küche</a></nav>
|
||||||
|
<main>
|
||||||
|
<section class="sim" id="sim-busfahrt"><h2>🚌 Busfahrt <span class="simid">busfahrt</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/busfahrt__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/busfahrt__ipad__start.png" alt="busfahrt iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/busfahrt__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/busfahrt__fullhd__start.png" alt="busfahrt Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
|
||||||
|
<tr><th class="screen">Spielverlauf</th><td><a href="shots/busfahrt__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/busfahrt__ipad__play.png" alt="busfahrt iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/busfahrt__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/busfahrt__fullhd__play.png" alt="busfahrt Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
|
||||||
|
<section class="sim" id="sim-energiemanager"><h2>⚡ Energiemanager <span class="simid">energiemanager</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/energiemanager__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/energiemanager__ipad__start.png" alt="energiemanager iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/energiemanager__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/energiemanager__fullhd__start.png" alt="energiemanager Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
|
||||||
|
<tr><th class="screen">Spielverlauf</th><td><a href="shots/energiemanager__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/energiemanager__ipad__play.png" alt="energiemanager iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/energiemanager__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/energiemanager__fullhd__play.png" alt="energiemanager Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
|
||||||
|
<tr><th class="screen">Spielverlauf (2)</th><td><a href="shots/energiemanager__ipad__play2.png" target="_blank" class="shot"><img loading="lazy" src="shots/energiemanager__ipad__play2.png" alt="energiemanager iPad play2"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/energiemanager__fullhd__play2.png" target="_blank" class="shot"><img loading="lazy" src="shots/energiemanager__fullhd__play2.png" alt="energiemanager Full HD play2"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
|
||||||
|
<section class="sim" id="sim-entscheidungstag"><h2>📅 Entscheidungstag <span class="simid">entscheidungstag</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/entscheidungstag__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/entscheidungstag__ipad__start.png" alt="entscheidungstag iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/entscheidungstag__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/entscheidungstag__fullhd__start.png" alt="entscheidungstag Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
|
||||||
|
<tr><th class="screen">Spielverlauf</th><td><a href="shots/entscheidungstag__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/entscheidungstag__ipad__play.png" alt="entscheidungstag iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/entscheidungstag__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/entscheidungstag__fullhd__play.png" alt="entscheidungstag Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
|
||||||
|
<section class="sim" id="sim-eu-werkstatt"><h2>🇪🇺 EU-Werkstatt <span class="simid">eu-werkstatt</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/eu-werkstatt__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/eu-werkstatt__ipad__start.png" alt="eu-werkstatt iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/eu-werkstatt__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/eu-werkstatt__fullhd__start.png" alt="eu-werkstatt Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
|
||||||
|
<tr><th class="screen">Spielverlauf</th><td><a href="shots/eu-werkstatt__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/eu-werkstatt__ipad__play.png" alt="eu-werkstatt iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/eu-werkstatt__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/eu-werkstatt__fullhd__play.png" alt="eu-werkstatt Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
|
||||||
|
<section class="sim" id="sim-farmer"><h2>🌾 Farmer <span class="simid">farmer</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/farmer__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/farmer__ipad__start.png" alt="farmer iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/farmer__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/farmer__fullhd__start.png" alt="farmer Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
|
||||||
|
<tr><th class="screen">Spielverlauf</th><td><a href="shots/farmer__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/farmer__ipad__play.png" alt="farmer iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/farmer__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/farmer__fullhd__play.png" alt="farmer Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
|
||||||
|
<section class="sim" id="sim-fluggesellschaft"><h2>✈️ Fluggesellschaft <span class="simid">fluggesellschaft</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/fluggesellschaft__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/fluggesellschaft__ipad__start.png" alt="fluggesellschaft iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/fluggesellschaft__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/fluggesellschaft__fullhd__start.png" alt="fluggesellschaft Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
|
||||||
|
<tr><th class="screen">Spielverlauf</th><td><a href="shots/fluggesellschaft__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/fluggesellschaft__ipad__play.png" alt="fluggesellschaft iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/fluggesellschaft__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/fluggesellschaft__fullhd__play.png" alt="fluggesellschaft Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
|
||||||
|
<section class="sim" id="sim-fluss"><h2>🌊 Flussmanagement <span class="simid">fluss</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/fluss__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/fluss__ipad__start.png" alt="fluss iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/fluss__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/fluss__fullhd__start.png" alt="fluss Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
|
||||||
|
<section class="sim" id="sim-heli"><h2>🚁 Helikopter-Navigation <span class="simid">heli</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/heli__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/heli__ipad__start.png" alt="heli iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/heli__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/heli__fullhd__start.png" alt="heli Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
|
||||||
|
<tr><th class="screen">Spielverlauf</th><td><a href="shots/heli__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/heli__ipad__play.png" alt="heli iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/heli__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/heli__fullhd__play.png" alt="heli Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
|
||||||
|
<section class="sim" id="sim-logistik"><h2>🚚 Logistik <span class="simid">logistik</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/logistik__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/logistik__ipad__start.png" alt="logistik iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/logistik__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/logistik__fullhd__start.png" alt="logistik Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
|
||||||
|
<tr><th class="screen">Spielverlauf</th><td><a href="shots/logistik__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/logistik__ipad__play.png" alt="logistik iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/logistik__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/logistik__fullhd__play.png" alt="logistik Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
|
||||||
|
<section class="sim" id="sim-sonnensystem"><h2>🪐 Sonnensystem <span class="simid">sonnensystem</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/sonnensystem__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/sonnensystem__ipad__start.png" alt="sonnensystem iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/sonnensystem__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/sonnensystem__fullhd__start.png" alt="sonnensystem Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
|
||||||
|
<tr><th class="screen">Spielverlauf</th><td><a href="shots/sonnensystem__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/sonnensystem__ipad__play.png" alt="sonnensystem iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/sonnensystem__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/sonnensystem__fullhd__play.png" alt="sonnensystem Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
|
||||||
|
<tr><th class="screen">Spielverlauf (2)</th><td><a href="shots/sonnensystem__ipad__play2.png" target="_blank" class="shot"><img loading="lazy" src="shots/sonnensystem__ipad__play2.png" alt="sonnensystem iPad play2"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/sonnensystem__fullhd__play2.png" target="_blank" class="shot"><img loading="lazy" src="shots/sonnensystem__fullhd__play2.png" alt="sonnensystem Full HD play2"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
|
||||||
|
<section class="sim" id="sim-staustufen"><h2>💧 Staustufen <span class="simid">staustufen</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/staustufen__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/staustufen__ipad__start.png" alt="staustufen iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/staustufen__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/staustufen__fullhd__start.png" alt="staustufen Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
|
||||||
|
<tr><th class="screen">Spielverlauf</th><td><a href="shots/staustufen__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/staustufen__ipad__play.png" alt="staustufen iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/staustufen__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/staustufen__fullhd__play.png" alt="staustufen Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
|
||||||
|
<section class="sim" id="sim-tourismusregion"><h2>🗺️ Tourismusregion <span class="simid">tourismusregion</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/tourismusregion__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/tourismusregion__ipad__start.png" alt="tourismusregion iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/tourismusregion__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/tourismusregion__fullhd__start.png" alt="tourismusregion Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
|
||||||
|
<tr><th class="screen">Spielverlauf</th><td><a href="shots/tourismusregion__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/tourismusregion__ipad__play.png" alt="tourismusregion iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/tourismusregion__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/tourismusregion__fullhd__play.png" alt="tourismusregion Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
|
||||||
|
<section class="sim" id="sim-tourismustal"><h2>🏔️ Tourismustal <span class="simid">tourismustal</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/tourismustal__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/tourismustal__ipad__start.png" alt="tourismustal iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/tourismustal__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/tourismustal__fullhd__start.png" alt="tourismustal Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
|
||||||
|
<tr><th class="screen">Spielverlauf</th><td><a href="shots/tourismustal__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/tourismustal__ipad__play.png" alt="tourismustal iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/tourismustal__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/tourismustal__fullhd__play.png" alt="tourismustal Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
|
||||||
|
<section class="sim" id="sim-vulkan"><h2>🌋 Vulkan <span class="simid">vulkan</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/vulkan__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/vulkan__ipad__start.png" alt="vulkan iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/vulkan__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/vulkan__fullhd__start.png" alt="vulkan Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
|
||||||
|
<tr><th class="screen">Spielverlauf</th><td><a href="shots/vulkan__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/vulkan__ipad__play.png" alt="vulkan iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/vulkan__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/vulkan__fullhd__play.png" alt="vulkan Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
|
||||||
|
<tr><th class="screen">Spielverlauf (2)</th><td><a href="shots/vulkan__ipad__play2.png" target="_blank" class="shot"><img loading="lazy" src="shots/vulkan__ipad__play2.png" alt="vulkan iPad play2"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/vulkan__fullhd__play2.png" target="_blank" class="shot"><img loading="lazy" src="shots/vulkan__fullhd__play2.png" alt="vulkan Full HD play2"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
|
||||||
|
<section class="sim" id="sim-wal"><h2>🐋 Wal <span class="simid">wal</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/wal__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/wal__ipad__start.png" alt="wal iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/wal__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/wal__fullhd__start.png" alt="wal Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
|
||||||
|
<tr><th class="screen">Spielverlauf</th><td><a href="shots/wal__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/wal__ipad__play.png" alt="wal iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/wal__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/wal__fullhd__play.png" alt="wal Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
|
||||||
|
<section class="sim" id="sim-weltkueche"><h2>🍲 Welt-Küche <span class="simid">weltkueche</span></h2><table><thead><tr><th class="screen"></th><th>iPad <span class="res">1180×820</span></th><th>Full HD <span class="res">1920×1080</span></th></tr></thead><tbody><tr><th class="screen">Onboarding / Start</th><td><a href="shots/weltkueche__ipad__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/weltkueche__ipad__start.png" alt="weltkueche iPad start"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/weltkueche__fullhd__start.png" target="_blank" class="shot"><img loading="lazy" src="shots/weltkueche__fullhd__start.png" alt="weltkueche Full HD start"><span class="cap">Full HD · 1920×1080</span></a></td></tr>
|
||||||
|
<tr><th class="screen">Spielverlauf</th><td><a href="shots/weltkueche__ipad__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/weltkueche__ipad__play.png" alt="weltkueche iPad play"><span class="cap">iPad · 1180×820</span></a></td><td><a href="shots/weltkueche__fullhd__play.png" target="_blank" class="shot"><img loading="lazy" src="shots/weltkueche__fullhd__play.png" alt="weltkueche Full HD play"><span class="cap">Full HD · 1920×1080</span></a></td></tr></tbody></table></section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
/**
|
||||||
|
* regen-gameplay-shots.cjs
|
||||||
|
* ------------------------
|
||||||
|
* Ersetzt die "Spielverlauf"-Screenshots (play / play2) der Display-Vorschau
|
||||||
|
* durch ECHTE Spielszenen: Auswahl-Screens (Welt/Karte/Szenario) werden
|
||||||
|
* betreten und das Onboarding wird zuverlässig weggeklickt (Skip-Buttons +
|
||||||
|
* mehrseitige "Weiter"-Tutorials, auch wenn der Button unter dem Fold liegt),
|
||||||
|
* bevor der Screenshot fällt.
|
||||||
|
*
|
||||||
|
* Ersetzt NUR die play/play2-Bilder in-place; die start-Bilder (Onboarding)
|
||||||
|
* und display_preview.html bleiben unangetastet, damit die Galerie
|
||||||
|
* unverändert funktioniert.
|
||||||
|
*
|
||||||
|
* Start (aus dem playwright-Ordner wegen node_modules):
|
||||||
|
* cd /c/xampp/htdocs/geograsim/.dontDeploy/playwright && \
|
||||||
|
* NODE_PATH="C:\\xampp\\htdocs\\geograsim\\.dontDeploy\\playwright\\node_modules" \
|
||||||
|
* node ../../App/.LocalDeveloperTools/display-preview/regen-gameplay-shots.cjs
|
||||||
|
*
|
||||||
|
* Optional: --only=sonnensystem,wal --vp=ipad
|
||||||
|
*/
|
||||||
|
const { chromium } = require('@playwright/test');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const SHOTS_DIR = 'C:/xampp/htdocs/geograsim/App/.LocalDeveloperTools/display-preview/shots';
|
||||||
|
const BASE_URL = 'http://localhost/geograsim/App/sims';
|
||||||
|
|
||||||
|
const VIEWPORTS = [
|
||||||
|
{ key: 'ipad', w: 1180, h: 820 },
|
||||||
|
{ key: 'fullhd', w: 1920, h: 1080 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sims mit "play"-Screenshot in der Galerie (fluss hat keinen play -> weggelassen).
|
||||||
|
const SIMS = [
|
||||||
|
{ id: 'busfahrt' },
|
||||||
|
{ id: 'energiemanager', play2: true },
|
||||||
|
{ id: 'entscheidungstag' },
|
||||||
|
{ id: 'eu-werkstatt' },
|
||||||
|
{ id: 'farmer' },
|
||||||
|
{ id: 'fluggesellschaft' },
|
||||||
|
{ id: 'heli' },
|
||||||
|
{ id: 'logistik' },
|
||||||
|
{ id: 'sonnensystem', play2: true },
|
||||||
|
{ id: 'staustufen' },
|
||||||
|
{ id: 'tourismusregion' },
|
||||||
|
{ id: 'tourismustal' },
|
||||||
|
{ id: 'vulkan', play2: true },
|
||||||
|
{ id: 'wal' },
|
||||||
|
{ id: 'weltkueche' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---- In-Page: EIN Onboarding-Schritt wegklicken -------------------------
|
||||||
|
async function skipStep(page) {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
// "loose": gerendert, aber darf auch unter dem Fold liegen (.click() geht trotzdem)
|
||||||
|
const rendered = (el) => {
|
||||||
|
if (!el) return false;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
if (r.width < 6 || r.height < 6) return false;
|
||||||
|
const s = getComputedStyle(el);
|
||||||
|
return s.visibility !== 'hidden' && s.display !== 'none' && +s.opacity > 0.05
|
||||||
|
&& !el.disabled && el.getAttribute('aria-hidden') !== 'true'
|
||||||
|
&& !el.classList.contains('hidden');
|
||||||
|
};
|
||||||
|
const clean = (s) => (s || '').replace(/\s+/g, ' ').trim();
|
||||||
|
const norm = (s) => clean(s).replace(/[^\p{L}\p{N} ]+/gu, '').trim().toLowerCase();
|
||||||
|
|
||||||
|
// Namensfelder füllen
|
||||||
|
document.querySelectorAll('input[type=text],input[type=search],input:not([type])').forEach((i) => {
|
||||||
|
if (rendered(i) && !i.value) {
|
||||||
|
i.value = 'Demo';
|
||||||
|
i.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
i.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1) Explizite Skip-Buttons (schließen das GANZE Tutorial auf einmal)
|
||||||
|
const skipSel = [
|
||||||
|
'#emOnbSkip', '#tutorial-skip', '#heliOnbSkip', '#snOnbSkip',
|
||||||
|
'[onclick*="skipTutorial"]', '[onclick*="closeTutorial"]',
|
||||||
|
'[onclick*="skipOnboarding"]', '[onclick*="skipHeliOnboarding"]',
|
||||||
|
];
|
||||||
|
for (const sel of skipSel) {
|
||||||
|
const el = [...document.querySelectorAll(sel)].find(rendered);
|
||||||
|
if (el) { el.click(); return 'skip:' + sel; }
|
||||||
|
}
|
||||||
|
// Skip per Text
|
||||||
|
{
|
||||||
|
const el = [...document.querySelectorAll('button,a,[role=button]')].find(
|
||||||
|
(e) => rendered(e) && /^(uberspringen|tutorial uberspringen|einfuhrung uberspringen|nicht mehr anzeigen)$/.test(norm(e.textContent))
|
||||||
|
);
|
||||||
|
if (el) { el.click(); return 'skiptext'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) "Weiter"/"Los geht's" NUR innerhalb eines sichtbaren Overlays
|
||||||
|
// (verhindert das Anklicken von Gameplay-Buttons wie Vulkan "Weiter →")
|
||||||
|
const overSel = '.ggs-modal-overlay,.ggs-overlay,.ggs-overlay-card,.sn-overlay,'
|
||||||
|
+ '.overlay,.overlay-card,.splash-overlay,.lg-splash-overlay,.modal,'
|
||||||
|
+ '.st-hint-overlay,.st-hint-modal,#vulk-overlay,#vulk-modal,.ggs-qi-back,'
|
||||||
|
+ '[id*="Onb"],[id*="onboard"],[id*="overlay" i],[id*="modal" i],'
|
||||||
|
+ '[class*="onb"],[class*="welcome"],[class*="tutorial"],[class*="intro"],'
|
||||||
|
+ '[class*="willkommen"],[class*="overlay" i],[class*="ggs-qi"]';
|
||||||
|
const overlays = [...document.querySelectorAll(overSel)].filter((o) => {
|
||||||
|
if (!rendered(o)) return false;
|
||||||
|
const r = o.getBoundingClientRect();
|
||||||
|
return r.width > 240 && r.height > 150;
|
||||||
|
});
|
||||||
|
const proceedRx = /^(weiter|weiter gehts|los gehts|los geht s|auf gehts|verstanden|alles klar|habe verstanden|fertig|los arbeiten|jetzt starten|jetzt loslegen|jetzt spielen|spiel starten|neue partie|partie starten|los|okay|ok|schliessen|start|starten|beginnen)$/;
|
||||||
|
for (const ov of overlays) {
|
||||||
|
const btns = [...ov.querySelectorAll('button,a,[role=button],.btn')].filter(rendered);
|
||||||
|
let el = btns.find((b) => proceedRx.test(norm(b.textContent)));
|
||||||
|
if (!el) el = btns.find((b) => /^(x|close)$/.test(norm(b.textContent)) || /(^|\s)(close|ggs-modal-close)(\s|$)/i.test(b.className));
|
||||||
|
if (el) { el.click(); return 'proceed:' + norm(el.textContent).slice(0, 24); }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Noch ein Onboarding-Overlay sichtbar? (Heuristik für den Report)
|
||||||
|
async function onboardingStillVisible(page) {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
const rx = /(willkommen|wusstest du|anleitung|was lerne ich|so funktioniert|einf(ü|u)hrung|beobachtungs-?aufgaben|dein kreislauf|uberspringen|überspringen|vulkanologie|observatorium|du erkundest die planeten|was tust du)/i;
|
||||||
|
const els = [...document.querySelectorAll('.overlay,.ggs-overlay,.ggs-modal-overlay,.sn-overlay,.st-hint-overlay,#vulk-overlay,[class*="onb"],[class*="tutorial"],[class*="intro"],[class*="welcome"],[class*="overlay" i]')];
|
||||||
|
return els.some((el) => {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
if (r.width < 240 || r.height < 150) return false;
|
||||||
|
const s = getComputedStyle(el);
|
||||||
|
if (s.visibility === 'hidden' || s.display === 'none' || +s.opacity < 0.05 || el.classList.contains('hidden')) return false;
|
||||||
|
return rx.test((el.textContent || '').slice(0, 500));
|
||||||
|
});
|
||||||
|
}).catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickSel(page, sel) {
|
||||||
|
return await page.evaluate((sel) => {
|
||||||
|
const el = [...document.querySelectorAll(sel)].find((e) => {
|
||||||
|
const r = e.getBoundingClientRect();
|
||||||
|
const s = getComputedStyle(e);
|
||||||
|
return r.width > 4 && r.height > 4 && s.visibility !== 'hidden' && s.display !== 'none' && !e.classList.contains('hidden');
|
||||||
|
});
|
||||||
|
if (el) { el.click(); return true; }
|
||||||
|
return false;
|
||||||
|
}, sel).catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickByText(page, source) {
|
||||||
|
return await page.evaluate((src) => {
|
||||||
|
const rx = new RegExp(src, 'i');
|
||||||
|
const norm = (s) => (s || '').replace(/\s+/g, ' ').trim().replace(/[^\p{L}\p{N} ]+/gu, '').trim();
|
||||||
|
const el = [...document.querySelectorAll('button,a,[role=button],.btn')].find((e) => {
|
||||||
|
const r = e.getBoundingClientRect();
|
||||||
|
const s = getComputedStyle(e);
|
||||||
|
if (r.width < 6 || r.height < 6 || s.visibility === 'hidden' || s.display === 'none' || e.classList.contains('hidden')) return false;
|
||||||
|
return rx.test(norm(e.textContent));
|
||||||
|
});
|
||||||
|
if (el) { el.click(); return true; }
|
||||||
|
return false;
|
||||||
|
}, source).catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function skipLoop(page, rounds = 12) {
|
||||||
|
const seq = [];
|
||||||
|
for (let i = 0; i < rounds; i++) {
|
||||||
|
const r = await skipStep(page);
|
||||||
|
if (!r) break;
|
||||||
|
seq.push(r);
|
||||||
|
await page.waitForTimeout(420);
|
||||||
|
}
|
||||||
|
return seq;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- pro-Sim: Auswahl-Screen betreten, dann Tutorial wegklicken ---------
|
||||||
|
const ENTRY = {
|
||||||
|
// heli: Flugkarte braucht PHP-injizierte Waypoints (WP), die beim direkten
|
||||||
|
// Aufruf von game.html fehlen (-> "WP is not defined"). Daher bleibt der
|
||||||
|
// stabile Gameplay-Screen die Einsatzzentrale (Missionsauswahl nach Onboarding).
|
||||||
|
'wal': async (page) => {
|
||||||
|
await clickSel(page, '.era-card-cta'); // "In dieser Welt starten →"
|
||||||
|
await page.waitForTimeout(1800);
|
||||||
|
await skipLoop(page);
|
||||||
|
},
|
||||||
|
'tourismusregion': async (page) => {
|
||||||
|
await clickSel(page, '#mapList .map-btn, .map-btn'); // Wegkarte wählen
|
||||||
|
await page.waitForTimeout(3000); // 3D-Szene
|
||||||
|
await skipLoop(page); // "Willkommen …" weg
|
||||||
|
},
|
||||||
|
'tourismustal': async (page) => {
|
||||||
|
await clickSel(page, '[data-scene]'); // Szene wählen
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
await skipLoop(page); // "Die Gäste …" weg
|
||||||
|
},
|
||||||
|
'eu-werkstatt': async (page) => {
|
||||||
|
await clickSel(page, '.scenario-card'); // Szenario betreten
|
||||||
|
await page.waitForTimeout(1600);
|
||||||
|
await skipLoop(page, 8); // Konzept-Intro weg
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
await clickByText(page, 'los gehts|los geht|^weiter|verstanden|alles klar');
|
||||||
|
await page.waitForTimeout(700);
|
||||||
|
await skipLoop(page, 4);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nach dem Wegklicken des Onboardings noch aufräumen, um die echte Szene zu zeigen.
|
||||||
|
const POST = {
|
||||||
|
'vulkan': async (page) => {
|
||||||
|
// "Los geht's" öffnet direkt das erste Aufgaben-Modal (#vulk-overlay).
|
||||||
|
// Schließen -> darunter liegt die 3D-Vulkanszene + Steuerpanel (Slider).
|
||||||
|
await page.evaluate(() => { const o = document.getElementById('vulk-overlay'); if (o) o.classList.remove('show'); });
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLAY2 = {
|
||||||
|
'vulkan': async (page) => { await clickSel(page, '#erupt-btn'); await page.waitForTimeout(3200); },
|
||||||
|
'sonnensystem': async (page) => { await clickSel(page, '#obs-open-btn'); await page.waitForTimeout(1800); await skipLoop(page, 4); await page.waitForTimeout(1200); },
|
||||||
|
'energiemanager': async (page) => { await clickSel(page, '#btnRunDay'); await page.waitForTimeout(3200); },
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const onlyArg = (process.argv.find((a) => a.startsWith('--only=')) || '').slice(7);
|
||||||
|
const vpArg = (process.argv.find((a) => a.startsWith('--vp=')) || '').slice(5);
|
||||||
|
const list = onlyArg ? SIMS.filter((s) => onlyArg.split(',').includes(s.id)) : SIMS;
|
||||||
|
const vps = vpArg ? VIEWPORTS.filter((v) => v.key === vpArg) : VIEWPORTS;
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
args: ['--use-gl=swiftshader', '--ignore-gpu-blocklist', '--enable-unsafe-swiftshader'],
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const vp of vps) {
|
||||||
|
console.log(`\n===== ${vp.key} (${vp.w}x${vp.h}) =====`);
|
||||||
|
for (const sim of list) {
|
||||||
|
const ctx = await browser.newContext({ viewport: { width: vp.w, height: vp.h }, deviceScaleFactor: 1 });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
const errs = [];
|
||||||
|
page.on('pageerror', (e) => { if (errs.length < 3) errs.push('PE:' + e.message.slice(0, 50)); });
|
||||||
|
|
||||||
|
const url = `${BASE_URL}/${sim.id}/game.html`;
|
||||||
|
try {
|
||||||
|
await page.goto(url, { waitUntil: 'load', timeout: 40000 });
|
||||||
|
} catch (e) { errs.push('GOTO:' + e.message.slice(0, 40)); }
|
||||||
|
await page.waitForTimeout(3200); // 3D / CDN
|
||||||
|
|
||||||
|
let seq = await skipLoop(page, 14); // sofortiges Onboarding
|
||||||
|
if (ENTRY[sim.id]) await ENTRY[sim.id](page); // Auswahl-Screen + Tutorial
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
// Zweiter Durchlauf: 3D-Sims (vulkan/sonnensystem) laden ihr Onboarding
|
||||||
|
// asynchron erst NACH dem ersten Skip -> hier erneut wegklicken.
|
||||||
|
seq = seq.concat(await skipLoop(page, 10));
|
||||||
|
if (POST[sim.id]) await POST[sim.id](page);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
const stuck = await onboardingStillVisible(page);
|
||||||
|
let ok = false;
|
||||||
|
try {
|
||||||
|
await page.screenshot({ path: path.join(SHOTS_DIR, `${sim.id}__${vp.key}__play.png`) });
|
||||||
|
ok = true;
|
||||||
|
} catch (e) { errs.push('SHOT:' + e.message.slice(0, 40)); }
|
||||||
|
|
||||||
|
let ok2 = null;
|
||||||
|
if (sim.play2 && PLAY2[sim.id]) {
|
||||||
|
try {
|
||||||
|
await PLAY2[sim.id](page);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
await page.screenshot({ path: path.join(SHOTS_DIR, `${sim.id}__${vp.key}__play2.png`) });
|
||||||
|
ok2 = true;
|
||||||
|
} catch (e) { ok2 = false; errs.push('SHOT2:' + e.message.slice(0, 40)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
' ' + sim.id.padEnd(18)
|
||||||
|
+ (ok ? 'play OK' : 'play FAIL')
|
||||||
|
+ (ok2 === null ? '' : ok2 ? ' · play2 OK' : ' · play2 FAIL')
|
||||||
|
+ (stuck ? ' [!! ONBOARDING NOCH SICHTBAR]' : '')
|
||||||
|
+ ' clicks=' + seq.length + (seq.length ? ' (' + seq.slice(0, 6).join(',') + ')' : '')
|
||||||
|
+ (errs.length ? ' ' + errs.slice(0, 2).join(' | ') : '')
|
||||||
|
);
|
||||||
|
|
||||||
|
await ctx.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await browser.close();
|
||||||
|
console.log('\nFertig. Galerie: http://localhost/geograsim/App/.LocalDeveloperTools/display-preview/display_preview.html');
|
||||||
|
})();
|
||||||
|
After Width: | Height: | Size: 369 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 492 KiB |
|
After Width: | Height: | Size: 316 KiB |
|
After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 721 KiB |
|
After Width: | Height: | Size: 505 KiB |
|
After Width: | Height: | Size: 316 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 454 KiB |
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 277 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 714 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 444 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 485 KiB |
|
After Width: | Height: | Size: 595 KiB |
|
After Width: | Height: | Size: 298 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 413 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 391 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 601 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 500 KiB |
|
After Width: | Height: | Size: 79 KiB |
@@ -0,0 +1,272 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>GeoGraSim — Test-Checkliste (19.08.2026)</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#f4f2ec; --card:#fff; --ink:#242018; --muted:#7a7266; --line:rgba(0,0,0,.08);
|
||||||
|
--accent:#4a7c8a; --accent-d:#356070; --ok:#4a8a5a; --warn:#c58a2a; --sim:#7a5ca8;
|
||||||
|
}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:'Inter',system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--ink);line-height:1.5;padding-bottom:4rem}
|
||||||
|
header{position:sticky;top:0;z-index:10;background:#fff;border-bottom:1px solid var(--line);
|
||||||
|
padding:.9rem 1.2rem;display:flex;align-items:center;gap:1rem;flex-wrap:wrap;box-shadow:0 1px 6px rgba(0,0,0,.04)}
|
||||||
|
header h1{font-size:1.15rem;font-weight:900;color:var(--accent-d);letter-spacing:-.02em}
|
||||||
|
header .sub{font-size:.72rem;color:var(--muted)}
|
||||||
|
.progress{margin-left:auto;display:flex;align-items:center;gap:.6rem;font-size:.8rem;font-weight:700;color:var(--accent-d)}
|
||||||
|
.bar{width:160px;height:9px;border-radius:99px;background:#e4e0d6;overflow:hidden}
|
||||||
|
.bar > div{height:100%;background:var(--ok);width:0;transition:width .3s}
|
||||||
|
.reset{font-size:.66rem;color:var(--muted);border:1px solid var(--line);background:#fff;border-radius:6px;padding:.25rem .5rem;cursor:pointer}
|
||||||
|
.wrap{max-width:920px;margin:1.4rem auto;padding:0 1.1rem}
|
||||||
|
.intro{font-size:.86rem;color:var(--muted);margin-bottom:1.4rem;background:#fff;border:1px solid var(--line);border-radius:10px;padding:.8rem 1rem}
|
||||||
|
.intro code{background:#eee9df;padding:.05rem .35rem;border-radius:4px;font-size:.85em}
|
||||||
|
section{margin-bottom:1.8rem}
|
||||||
|
.sec-h{font-size:.7rem;font-weight:800;text-transform:uppercase;letter-spacing:.1em;color:var(--accent);
|
||||||
|
margin:0 0 .6rem .2rem;display:flex;align-items:center;gap:.5rem}
|
||||||
|
.sec-h .cnt{font-size:.62rem;color:var(--muted);font-weight:600;letter-spacing:.02em}
|
||||||
|
.item{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:.85rem 1rem;margin-bottom:.7rem;
|
||||||
|
box-shadow:0 1px 3px rgba(0,0,0,.03);transition:opacity .2s}
|
||||||
|
.item.done{opacity:.55}
|
||||||
|
.item-top{display:flex;gap:.7rem;align-items:flex-start}
|
||||||
|
.item-top input[type=checkbox]{width:22px;height:22px;margin-top:.1rem;accent-color:var(--ok);cursor:pointer;flex-shrink:0}
|
||||||
|
.item-title{font-weight:800;font-size:.98rem}
|
||||||
|
.item.done .item-title{text-decoration:line-through;text-decoration-color:rgba(0,0,0,.3)}
|
||||||
|
.tag{display:inline-block;font-size:.58rem;font-weight:800;text-transform:uppercase;letter-spacing:.05em;
|
||||||
|
padding:.12rem .5rem;border-radius:99px;vertical-align:middle;margin-left:.5rem}
|
||||||
|
.tag.live{background:#dceadd;color:#2f6b3a}
|
||||||
|
.tag.pending{background:#fdeecd;color:#8a6510}
|
||||||
|
.body{padding-left:calc(22px + .7rem);margin-top:.4rem;font-size:.86rem;color:#4a463d}
|
||||||
|
.body .row{margin:.28rem 0}
|
||||||
|
.body b{color:var(--ink)}
|
||||||
|
.k{display:inline-block;min-width:74px;font-weight:700;color:var(--muted);font-size:.72rem;text-transform:uppercase;letter-spacing:.03em}
|
||||||
|
a.url{color:var(--accent-d);font-family:ui-monospace,monospace;font-size:.82em;text-decoration:none;border-bottom:1px dotted var(--accent)}
|
||||||
|
a.url:hover{background:#eaf2f4}
|
||||||
|
.exp{background:#f0f6f1;border-left:3px solid var(--ok);border-radius:0 6px 6px 0;padding:.35rem .6rem;margin-top:.35rem;font-size:.82rem;color:#33613f}
|
||||||
|
.note{width:100%;margin-top:.5rem;border:1px solid var(--line);border-radius:7px;padding:.4rem .55rem;font-family:inherit;
|
||||||
|
font-size:.82rem;resize:vertical;min-height:0;height:2rem;background:#fcfbf8;color:var(--ink)}
|
||||||
|
.note::placeholder{color:#b3ac9e}
|
||||||
|
footer{text-align:center;color:var(--muted);font-size:.72rem;margin:2rem 0}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>🧪 GeoGraSim — Test-Checkliste</h1>
|
||||||
|
<div class="sub">Alle Erledigungen seit den Weltküche-Lebensmitteln · Stand 19.08.2026 · lokal testen</div>
|
||||||
|
</div>
|
||||||
|
<div class="progress">
|
||||||
|
<span id="pcount">0 / 0</span>
|
||||||
|
<div class="bar"><div id="pbar"></div></div>
|
||||||
|
<button class="reset" onclick="if(confirm('Alle Haken + Notizen zurücksetzen?')){localStorage.clear();location.reload();}">reset</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="intro">
|
||||||
|
Basis-URL lokal: <code>http://localhost/geograsim/App/</code> · <b>Haken & Notizen</b> werden im Browser gespeichert.
|
||||||
|
<span class="tag live">live</span> = auf Prod, <span class="tag pending">bereit</span> = committed, kommt mit dem nächsten Deploy.
|
||||||
|
Für Admin-Seiten zuerst über <a class="url" href="http://localhost/geograsim/App/admin.html" target="_blank">admin.html</a> anmelden (Username <b>Thomas</b> → PIN ist lokal vorausgefüllt).
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================= WELTKÜCHE (SIM) ================= -->
|
||||||
|
<section data-sec="Weltküche – Simulation"></section>
|
||||||
|
|
||||||
|
<!-- ================= WELTKÜCHE (LEHRPLAN/ADMIN) ================= -->
|
||||||
|
<section data-sec="Weltküche – Lehrplan & Admin"></section>
|
||||||
|
|
||||||
|
<!-- ================= LEHRER-COCKPIT ================= -->
|
||||||
|
<section data-sec="Lehrer-Cockpit (teacher.html)"></section>
|
||||||
|
|
||||||
|
<!-- ================= TOURISMUSREGION ================= -->
|
||||||
|
<section data-sec="Tourismusregion"></section>
|
||||||
|
|
||||||
|
<!-- ================= LOGISTIK & PLATTFORM ================= -->
|
||||||
|
<section data-sec="Logistik & Plattform"></section>
|
||||||
|
|
||||||
|
<!-- ================= ADMIN & AUTH ================= -->
|
||||||
|
<section data-sec="Admin & Auth-Fundament"></section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>GeoGraSim · Test-Checkliste · lokal generiert 19.08.2026</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Alle Test-Items als Daten — daraus wird die Liste gebaut.
|
||||||
|
const BASE = 'http://localhost/geograsim/App/';
|
||||||
|
function u(path){ return BASE + path; }
|
||||||
|
const ITEMS = {
|
||||||
|
'Weltküche – Simulation': [
|
||||||
|
{ id:'wk-artikel', t:'Korrekte Artikel für alle 70 Zutaten', tag:'live',
|
||||||
|
w:'der/die/das je Zutat („die Kartoffel", „der Pfeffer", „das Olivenöl") statt Platzhalter.',
|
||||||
|
url:'sims/weltkueche/game.html', steps:'Ein Gericht spielen, Zutat lösen/falsch klicken – im Feedback-Satz steht der richtige Artikel.',
|
||||||
|
exp:'Nie mehr „der/die/das Kartoffel" – immer „die Kartoffel" usw.' },
|
||||||
|
{ id:'wk-ukraine', t:'Verdrehter Herkunfts-Satz korrigiert (Ukraine-Fall)', tag:'live',
|
||||||
|
w:'Bei fernem Großproduzenten (z. B. Ukraine-Kartoffel im AT-Gericht) sachlich korrektes Feedback.',
|
||||||
|
url:'sims/weltkueche/game.html', steps:'Tiroler Gröstl → Kartoffel → ein fernes Land wie Ukraine anklicken.',
|
||||||
|
exp:'„…in unseren Handel kommt davon kaum etwas… nimmt man die Kartoffel aus Österreich oder einem Nachbarland." (nicht mehr „…stammt die Kartoffel von dort").' },
|
||||||
|
{ id:'wk-heimat', t:'Heimat-Banner neu formuliert', tag:'live',
|
||||||
|
w:'Erklärt „bevorzugt aus AT, aber AT deckt den Bedarf nicht allein".',
|
||||||
|
url:'sims/weltkueche/game.html', steps:'AT-Gericht, eine Heimat-Zutat lösen → grünes Banner lesen.',
|
||||||
|
exp:'„Zutaten werden zwar bevorzugt aus Österreich verwendet, aber Österreich allein deckt den Bedarf nicht ab – darum zählt auch ein Nachbarland."' },
|
||||||
|
{ id:'wk-gravitation', t:'Herkunft per Handels-Gravitation (Nähe × Menge)', tag:'pending',
|
||||||
|
w:'Zweit-Lieferant = nächster großer Nachbar, nicht ferner Großproduzent.',
|
||||||
|
url:'sims/weltkueche/game.html', steps:'Ein AT-Gericht mit Eiern/Kartoffel spielen – richtige Länder prüfen.',
|
||||||
|
exp:'AT-Eier → Österreich + Deutschland (NICHT Frankreich), obwohl FR mehr Eier erzeugt.' },
|
||||||
|
{ id:'wk-punkte', t:'Punktefix: „alle Falschen zuerst = 0"', tag:'pending',
|
||||||
|
w:'Strafe pro Fehlklick = 100 ÷ Anzahl Distraktoren.',
|
||||||
|
url:'sims/weltkueche/game.html', steps:'Bei einer Zutat ALLE falschen Länder zuerst anklicken, dann die richtigen.',
|
||||||
|
exp:'Ergebnis 0 Punkte. Banner zeigt die tatsächliche Strafe pro Fehlklick.' },
|
||||||
|
{ id:'wk-phase', t:'Live-State zeigt echten Fortschritt statt „forschen"', tag:'live',
|
||||||
|
w:'GGS_LIVE_STATE-Phase = „Gericht X · Zutat Y/Z".',
|
||||||
|
url:'teacher.html', steps:'Als Lehrer im Cockpit „Aktuell" ein Weltküche-Kind ansehen (oder Live-State im Sim prüfen).',
|
||||||
|
exp:'Phase/Info zeigt „Gericht 2 · Zutat 3/5" – nicht dauerhaft „forschen".' },
|
||||||
|
{ id:'wk-submit', t:'Backend-Submit + Persistenz geschaffter Gerichte', tag:'live',
|
||||||
|
w:'Pro fertigem Gericht ein assessments-Eintrag; geschaffte Gerichte bleiben nach Reload.',
|
||||||
|
url:'sims/weltkueche/game.html', steps:'Ein Gericht abschließen, Seite neu laden.',
|
||||||
|
exp:'Bereits geschaffte Gerichte bleiben erkennbar; im Lehrer-Cockpit taucht das Ergebnis auf.' },
|
||||||
|
],
|
||||||
|
'Weltküche – Lehrplan & Admin': [
|
||||||
|
{ id:'wk-lehrplan', t:'Lehrplan-Bezug auf der Weltküche-Modulseite', tag:'live',
|
||||||
|
w:'Kompetenz „Welternährung, Konsum und globale Disparitäten" + Anker.',
|
||||||
|
url:'modul-weltkueche', steps:'Seite öffnen, zu „Lehrplan-Bezug" scrollen.',
|
||||||
|
exp:'Kompetenzen + Anker sichtbar (nicht mehr „Noch keine Kompetenzen zugeordnet").' },
|
||||||
|
{ id:'wk-filter', t:'Länder-Filter im Lehrplan-Bezug wirkt', tag:'live',
|
||||||
|
w:'Filter-Chips (Alle/AT/DE/CH/FL) blenden Fach-Anker wirklich um.',
|
||||||
|
url:'modul-weltkueche', steps:'Bei „Lehrplan-Bezug" auf Deutschland, dann Österreich klicken.',
|
||||||
|
exp:'Sichtbare Fach-Anker ändern sich je Land (vorher passierte nichts).' },
|
||||||
|
{ id:'wk-admin', t:'Admin-QA: Weltküche-Herkunftsübersicht (NEU)', tag:'pending',
|
||||||
|
w:'Pro Gericht × Zutat richtige Länder + Distraktoren; „nur Auffällige"-Filter.',
|
||||||
|
url:'admin-weltkueche.html', steps:'Als Admin einloggen, Seite öffnen. Suche/Kategorie testen, „nur Auffällige" aktivieren.',
|
||||||
|
exp:'30 Gerichte; Kartoffel/Eier → 🟢 AT+DE. „Nur Auffällige" zeigt ~5 Kandidaten zum Prüfen.' },
|
||||||
|
],
|
||||||
|
'Lehrer-Cockpit (teacher.html)': [
|
||||||
|
{ id:'tc-menu', t:'Menü „Live" → „Ergebnisse" mit Tag · Aktuell · Vergleich · Gesamt', tag:'pending',
|
||||||
|
w:'Umbenannt + 4. Unteransicht „Gesamt" (alte Ergebnis-Ansicht).',
|
||||||
|
url:'teacher.html', steps:'Als Lehrer einloggen, Klasse wählen, Tab „Ergebnisse".',
|
||||||
|
exp:'Vier Unteransichten; „Gesamt" zeigt die frühere Ergebnis-/Auswertungsansicht.' },
|
||||||
|
{ id:'tc-quali', t:'Qualitäts-Kennzahl (richtig ↔ falsch als Balken)', tag:'pending',
|
||||||
|
w:'Grafischer Balken (grün/rot) je Kind in Aktuell + Vergleich.',
|
||||||
|
url:'teacher.html', steps:'Ergebnisse → Aktuell/Vergleich bei aktivem Weltküche-Kind.',
|
||||||
|
exp:'Zweifarbiger Balken + %-Wert („blödeln vs. gut" sichtbar).' },
|
||||||
|
{ id:'tc-labels', t:'Klartext-Labels statt Jargon', tag:'pending',
|
||||||
|
w:'„Stark/Solide/Viele Fehler" statt „Kritisch/OK"; „Gerade dabei" statt „Phase".',
|
||||||
|
url:'teacher.html', steps:'Aktuell/Vergleich ansehen.',
|
||||||
|
exp:'Keine „Kritisch"-Bezeichnung mehr.' },
|
||||||
|
{ id:'tc-simlabels', t:'Sim-Bezeichner: Wort + 3-Buchstaben-Kürzel', tag:'pending',
|
||||||
|
w:'Kein „Log L1" mehr — enger Balken zeigt Kürzel (LOG), breiter das Wort.',
|
||||||
|
url:'teacher.html', steps:'Ergebnisse → Tag, Session-Balken ansehen.',
|
||||||
|
exp:'Balken tragen LOG/Logistik statt „Log L1".' },
|
||||||
|
{ id:'tc-logistik', t:'Logistik hat kuratierte Spalten (statt Roh-Dump)', tag:'pending',
|
||||||
|
w:'Stufe · Budget · Geliefert · Unterwegs · Offen · Verspätet · Fortschritt.',
|
||||||
|
url:'teacher.html', steps:'Ergebnisse → Aktuell mit aktivem Logistik-Kind.',
|
||||||
|
exp:'Sinnvolle Spalten – keine simId/__activeMs/timestamp-Spalten mehr.' },
|
||||||
|
{ id:'tc-streifen', t:'Aktivitätsstreifen: Farbe je Sim, Höhe=Erfolg, Rahmen=Zustand', tag:'pending',
|
||||||
|
w:'Fertig = geschlossener Rahmen · läuft = rechts offen · Abbruch = gestrichelt + ✖.',
|
||||||
|
url:'teacher.html', steps:'Ergebnisse → Tag, mit Sessions eines Tages.',
|
||||||
|
exp:'Balken je Sim gefärbt, höher = besser; Rahmen/✖ zeigen den Zustand; Legende erklärt es.' },
|
||||||
|
{ id:'tc-assess', t:'assessment-Skip-Fix (keine leeren Ping-Zeilen mehr)', tag:'pending',
|
||||||
|
w:'„started/running"-Pings landen nicht mehr als leere Zeilen in Ergebnisse/Tag.',
|
||||||
|
url:'teacher.html', steps:'Nach etwas Spielzeit Ergebnisse/Tag ansehen.',
|
||||||
|
exp:'Nur echte Abschlüsse als Balken – kein leeres Rauschen.' },
|
||||||
|
],
|
||||||
|
'Tourismusregion': [
|
||||||
|
{ id:'tr-start', t:'Startseite: keine Name/Klasscode-Felder, Klick auf Route startet', tag:'live',
|
||||||
|
w:'Felder entfernt; Klick auf eine Wegkarte startet sofort (kein „Partie starten").',
|
||||||
|
url:'sims/tourismusregion/game.html', steps:'Sim öffnen, eine Wegkarte anklicken.',
|
||||||
|
exp:'Keine Eingabefelder; Partie startet direkt bei Kartenklick.' },
|
||||||
|
{ id:'tr-ipad', t:'iPad-Responsive (Panels decken die Karte nicht mehr zu)', tag:'live',
|
||||||
|
w:'Panels mit Höhen-Deckel + eigene iPad-Landscape-Regeln. AM IPAD TESTEN!',
|
||||||
|
url:'sims/tourismusregion/game.html', steps:'Am echten iPad (Landscape) öffnen und ein Gebäude antippen.',
|
||||||
|
exp:'KPIs/Baumenü/Info-Panel überdecken die Karte nicht; Info-Panel scrollt statt zu überziehen.' },
|
||||||
|
{ id:'tr-lift', t:'Seilbahn hat eine Talstation (nicht mehr „transparent")', tag:'live',
|
||||||
|
w:'Talstation mit Dach + Umlenkrad unten am Lift.',
|
||||||
|
url:'sims/tourismusregion/game.html', steps:'Seilbahn bauen (Berg erschließen), auf die Talstation schauen.',
|
||||||
|
exp:'Solider Baukörper unten – kein durchsichtiger Lift-Anfang.' },
|
||||||
|
{ id:'tr-save', t:'Abschluss: kein „Ergebnis speichern"-Button (Auto-Save)', tag:'live',
|
||||||
|
w:'Automatisches Speichern am Partie-Ende; Button entfernt.',
|
||||||
|
url:'sims/tourismusregion/game.html', steps:'Eine Partie zu Ende bringen.',
|
||||||
|
exp:'Hinweis „automatisch gespeichert", kein Speichern-Button.' },
|
||||||
|
{ id:'tr-pass', t:'Neue Karte „Passstraße"', tag:'live',
|
||||||
|
w:'Route bergauf (136 Punkte) + 41 Bauslots + Hintergrundbild.',
|
||||||
|
url:'sims/tourismusregion/game.html', steps:'Im Startbildschirm „Passstraße" wählen.',
|
||||||
|
exp:'Karte lädt, Route + Bauslots passen aufs Bild (optisch prüfen).' },
|
||||||
|
{ id:'tr-balance', t:'Balance: Umwelt beißt jetzt (Übertourismus)', tag:'pending',
|
||||||
|
w:'Zu viel Bauen + Verkehr senken die Umwelt spürbar; Umwelt-Malus wirkt.',
|
||||||
|
url:'sims/tourismusregion/game.html', steps:'Viel/dicht bauen und laufen lassen; Umwelt-KPI beobachten.',
|
||||||
|
exp:'Umwelt fällt deutlich (Richtung <45), umweltbewusste Gäste werden unzufriedener.' },
|
||||||
|
{ id:'tr-erklaer', t:'Schüler-Erklärung: Umwelt & Naturschutzgebiet', tag:'pending',
|
||||||
|
w:'Willkommenskarte + Warnkarte bei Umwelt<50 + klarere Naturschutz-Beschreibung.',
|
||||||
|
url:'sims/tourismusregion/game.html', steps:'Neu starten → Willkommenskarte lesen; Umwelt absacken lassen → Warnkarte.',
|
||||||
|
exp:'Karte erklärt Umwelt-Mechanik + Naturschutzgebiet; Warnkarte erscheint bei <50.' },
|
||||||
|
],
|
||||||
|
'Logistik & Plattform': [
|
||||||
|
{ id:'lg-pause', t:'Logistik-Pause friert die Aktivzeit ein', tag:'pending',
|
||||||
|
w:'Bei Pause zählt die „Seit"-Zeit im Cockpit nicht weiter.',
|
||||||
|
url:'sims/logistik/game.html', steps:'Logistik spielen, ⏸ drücken, im Lehrer-Cockpit die Aktivzeit beobachten.',
|
||||||
|
exp:'Aktivzeit steht still, solange pausiert; läuft nach dem Fortsetzen weiter.' },
|
||||||
|
{ id:'pf-scripts', t:'Wrapper-Bug behoben (/teacher lud kein Script mehr)', tag:'live',
|
||||||
|
w:'Favicon wurde ins letzte </head> injiziert – zerschoss ein JS-Popup-Script.',
|
||||||
|
url:'teacher', steps:'Die /teacher-Route (Wrapper) öffnen.',
|
||||||
|
exp:'Seite lädt sauber, keine „Invalid token"-Konsolenfehler. (teacher.html direkt lief immer.)' },
|
||||||
|
],
|
||||||
|
'Admin & Auth-Fundament': [
|
||||||
|
{ id:'ad-login', t:'Lokaler Admin-Login ohne Mailversand (Dev-PIN)', tag:'pending',
|
||||||
|
w:'PIN wird lokal vorausgefüllt; Mailer-Fehler sperrt nicht mehr aus. Auf Prod bleibt 2FA-Mail Pflicht.',
|
||||||
|
url:'admin.html', steps:'Username Thomas + Passwort → „Anmelden".',
|
||||||
|
exp:'PIN-Feld ist vorausgefüllt („Dev: … vorausgefüllt"), „Bestätigen" → eingeloggt.' },
|
||||||
|
{ id:'auth-found', t:'Auth-Fundament JWT + Redis (schlafend, non-breaking)', tag:'pending',
|
||||||
|
w:'firebase/php-jwt + predis + Jwt/SessionStore + Session-Kanalisierung. AUTH_BACKEND=session.',
|
||||||
|
url:'', steps:'Kein direkter Test nötig — nur sicherstellen, dass Login/Admin normal funktionieren.',
|
||||||
|
exp:'Alles läuft wie bisher (das Fundament ist inaktiv, bis der JWT-Cutover kommt).' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const listWrap = document.querySelector('.wrap');
|
||||||
|
document.querySelectorAll('section[data-sec]').forEach(sec => {
|
||||||
|
const name = sec.getAttribute('data-sec');
|
||||||
|
const items = ITEMS[name] || [];
|
||||||
|
let html = '<div class="sec-h">' + name + ' <span class="cnt">' + items.length + ' Punkte</span></div>';
|
||||||
|
items.forEach(it => {
|
||||||
|
const urlHtml = it.url ? '<a class="url" href="' + u(it.url) + '" target="_blank" rel="noopener">' + u(it.url) + '</a>' : '<span style="color:#b3ac9e">—</span>';
|
||||||
|
html += ''
|
||||||
|
+ '<div class="item" data-id="' + it.id + '">'
|
||||||
|
+ '<div class="item-top">'
|
||||||
|
+ '<input type="checkbox" data-check="' + it.id + '">'
|
||||||
|
+ '<div style="flex:1"><span class="item-title">' + it.t + '</span>'
|
||||||
|
+ '<span class="tag ' + it.tag + '">' + (it.tag==='live'?'live':'bereit') + '</span></div>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="body">'
|
||||||
|
+ '<div class="row"><span class="k">Was</span> ' + it.w + '</div>'
|
||||||
|
+ '<div class="row"><span class="k">Öffnen</span> ' + urlHtml + '</div>'
|
||||||
|
+ '<div class="row"><span class="k">Testen</span> ' + it.steps + '</div>'
|
||||||
|
+ '<div class="exp">✓ Erwartet: ' + it.exp + '</div>'
|
||||||
|
+ '<textarea class="note" data-note="' + it.id + '" placeholder="Notiz / gefundene Abweichung…"></textarea>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '</div>';
|
||||||
|
});
|
||||||
|
sec.innerHTML = html;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Persistenz + Fortschritt
|
||||||
|
const checks = document.querySelectorAll('input[data-check]');
|
||||||
|
function refresh(){
|
||||||
|
let done = 0;
|
||||||
|
checks.forEach(c => { if(c.checked){ done++; c.closest('.item').classList.add('done'); } else c.closest('.item').classList.remove('done'); });
|
||||||
|
document.getElementById('pcount').textContent = done + ' / ' + checks.length;
|
||||||
|
document.getElementById('pbar').style.width = (checks.length? (done/checks.length*100):0) + '%';
|
||||||
|
}
|
||||||
|
checks.forEach(c => {
|
||||||
|
const key = 'chk_' + c.getAttribute('data-check');
|
||||||
|
c.checked = localStorage.getItem(key) === '1';
|
||||||
|
c.addEventListener('change', () => { localStorage.setItem(key, c.checked?'1':'0'); refresh(); });
|
||||||
|
});
|
||||||
|
document.querySelectorAll('textarea[data-note]').forEach(t => {
|
||||||
|
const key = 'note_' + t.getAttribute('data-note');
|
||||||
|
t.value = localStorage.getItem(key) || '';
|
||||||
|
t.addEventListener('input', () => localStorage.setItem(key, t.value));
|
||||||
|
});
|
||||||
|
refresh();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- Lizenz-Vertrieb — Nachtrag 2 (2026-08-26)
|
||||||
|
-- * bestellte vs. gelieferte Menge (Zugabe +10 %)
|
||||||
|
-- Additiv & non-breaking. (Mail-Config: siehe Nachtrag 3, app_settings.)
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
ALTER TABLE license_batches
|
||||||
|
ADD COLUMN qty_ordered INT UNSIGNED NULL AFTER qty; -- bestellt (verrechnet); qty = geliefert inkl. Zugabe
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- Lizenz-Vertrieb — Nachtrag 3 (2026-08-26): portable App-Settings
|
||||||
|
-- Eigene Key/Value-Tabelle fuer die Mail-Konfiguration (Provider/Absender/URL).
|
||||||
|
-- Ersetzt die Nutzung der lokalen Orphan-Tabelle platform_config_v2 (existiert
|
||||||
|
-- auf v3/Prod nicht). Additiv & non-breaking. MariaDB + MySQL 8 kompatibel.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
|
skey VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||||
|
sval TEXT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
updated_by INT UNSIGNED NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO app_settings (skey, sval) VALUES
|
||||||
|
('mail_provider', 'server'),
|
||||||
|
('mail_from_email', 'support@geograsim.at'),
|
||||||
|
('mail_from_name', 'GeoGraSim'),
|
||||||
|
('mail_public_url', 'https://geograsim.at');
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- Lizenz-Vergabe / Vertrieb (2026-08-26)
|
||||||
|
-- Additiv & non-breaking: bestehende `licenses`-Zeilen (batch_id NULL) und
|
||||||
|
-- die Flows generate/redeem/assign bleiben unveraendert funktionsfaehig.
|
||||||
|
-- Neu: Kunden, Chargen (Bestellungen), Mail-Archiv, Storno, Verlaengerung,
|
||||||
|
-- Brute-Force-Schutz + Audit-Log, laengere Codes, Charge-Einloeselink.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- 1) Kunden (Besteller) ------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS license_customers (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
customer_no VARCHAR(40) NULL, -- INTERNE Kundennummer (optional)
|
||||||
|
email VARCHAR(190) NULL,
|
||||||
|
name VARCHAR(190) NULL, -- Besteller / Verlag / Schule
|
||||||
|
note VARCHAR(255) NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_customer_no (customer_no),
|
||||||
|
KEY idx_email (email),
|
||||||
|
KEY idx_name (name)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 2) Chargen (Bestellungen) -------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS license_batches (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
customer_id INT UNSIGNED NULL,
|
||||||
|
order_no VARCHAR(120) NULL, -- EXTERNE Bestellnummer (Marktplatz/Ministerium), Freitext -> geht in die Mail
|
||||||
|
sales_type VARCHAR(30) NOT NULL DEFAULT 'privat', -- marktplatz | direktvertrieb | privat (spaeter erweiterbar)
|
||||||
|
qty INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
default_valid_from DATE NULL,
|
||||||
|
default_valid_until DATE NULL,
|
||||||
|
note VARCHAR(255) NULL,
|
||||||
|
redeem_token CHAR(40) NULL, -- Sammel-Einloeselink der ganzen Charge
|
||||||
|
created_by INT UNSIGNED NULL, -- admin_users.id
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
canceled_at TIMESTAMP NULL,
|
||||||
|
canceled_reason VARCHAR(255) NULL,
|
||||||
|
UNIQUE KEY uq_redeem_token (redeem_token),
|
||||||
|
KEY idx_customer (customer_id),
|
||||||
|
KEY idx_order (order_no),
|
||||||
|
KEY idx_type (sales_type),
|
||||||
|
KEY idx_created (created_at),
|
||||||
|
CONSTRAINT fk_batch_customer FOREIGN KEY (customer_id) REFERENCES license_customers(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 3) licenses erweitern ------------------------------------------------
|
||||||
|
-- code auf 32 Zeichen verbreitern (laengere Codes), Vertriebsfelder ergaenzen.
|
||||||
|
ALTER TABLE licenses
|
||||||
|
MODIFY COLUMN code VARCHAR(32) NOT NULL,
|
||||||
|
ADD COLUMN batch_id INT UNSIGNED NULL AFTER school_year,
|
||||||
|
ADD COLUMN issued_at TIMESTAMP NULL AFTER redeemed_at,
|
||||||
|
ADD COLUMN valid_from DATE NULL AFTER issued_at,
|
||||||
|
ADD COLUMN valid_until DATE NULL AFTER valid_from,
|
||||||
|
ADD COLUMN canceled_at TIMESTAMP NULL AFTER valid_until,
|
||||||
|
ADD COLUMN canceled_reason VARCHAR(255) NULL AFTER canceled_at,
|
||||||
|
ADD KEY idx_batch (batch_id),
|
||||||
|
ADD KEY idx_valid_until (valid_until),
|
||||||
|
ADD KEY idx_issued (issued_at),
|
||||||
|
ADD CONSTRAINT fk_license_batch FOREIGN KEY (batch_id) REFERENCES license_batches(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- 4) Mail-Archiv / Outbox (unabhaengig vom IMAP-Postausgang) -----------
|
||||||
|
CREATE TABLE IF NOT EXISTS license_mails (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
batch_id INT UNSIGNED NULL,
|
||||||
|
to_email VARCHAR(190) NOT NULL,
|
||||||
|
subject VARCHAR(255) NOT NULL,
|
||||||
|
body_text MEDIUMTEXT NULL, -- gerenderter Klartext (kopierbar + Archiv)
|
||||||
|
body_html MEDIUMTEXT NULL,
|
||||||
|
provider VARCHAR(20) NOT NULL DEFAULT 'server', -- server | ses
|
||||||
|
status VARCHAR(12) NOT NULL DEFAULT 'queued', -- queued | sent | failed
|
||||||
|
error VARCHAR(255) NULL,
|
||||||
|
created_by INT UNSIGNED NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
sent_at TIMESTAMP NULL,
|
||||||
|
KEY idx_batch (batch_id),
|
||||||
|
KEY idx_status (status),
|
||||||
|
CONSTRAINT fk_mail_batch FOREIGN KEY (batch_id) REFERENCES license_batches(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 5) Brute-Force-Schutz + Audit beim Einloesen -------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS license_redeem_attempts (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
ip VARBINARY(16) NULL, -- INET6_ATON(...)
|
||||||
|
teacher_id INT UNSIGNED NULL,
|
||||||
|
code_tried VARCHAR(32) NULL,
|
||||||
|
ok TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_ip_time (ip, created_at),
|
||||||
|
KEY idx_teacher_time (teacher_id, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 6) Audit-Log fuer Chargen/Codes -------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS license_events (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
batch_id INT UNSIGNED NULL,
|
||||||
|
license_id INT UNSIGNED NULL,
|
||||||
|
event VARCHAR(30) NOT NULL, -- issued|extended|canceled|mailed|redeemed
|
||||||
|
detail VARCHAR(255) NULL,
|
||||||
|
admin_id INT UNSIGNED NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_batch (batch_id),
|
||||||
|
KEY idx_license (license_id),
|
||||||
|
KEY idx_event (event)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- Fehlende Heli-Wegpunkte in geo_waypoints
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- Gefunden 2026-08-23: 5 Missions-Wegpunkte fehlten in der DB.
|
||||||
|
-- Folge: Missionen hingen bei der Routenplanung nach dem vorletzten
|
||||||
|
-- Wegpunkt (Route zeigte auf undefined). Betroffen u.a.:
|
||||||
|
-- M5 "Seerettung Bregenz" -> bodensee_bregenz
|
||||||
|
-- M7 (Kufstein) -> kaiser_kufstein
|
||||||
|
-- Weitere: felswand_raetikon, schlucht_mellau, maria_saal.
|
||||||
|
--
|
||||||
|
-- Auf v3 (geograsim_v3) bereits eingespielt.
|
||||||
|
-- Die LIVE-DB `geograsim` ist ein Klon und hat DIESELBE Luecke ->
|
||||||
|
-- vor/mit dem naechsten Heli-Prod-Deploy hier ausfuehren:
|
||||||
|
--
|
||||||
|
-- docker exec -i webstack-mysql mysql -u root -p'...' geograsim < heli_waypoints_fix_PROD.sql
|
||||||
|
-- ============================================================
|
||||||
|
INSERT IGNORE INTO geo_waypoints (wp_key,name,lat,lon,region,wp_type) VALUES
|
||||||
|
('bodensee_bregenz','Schwimmer Bodensee bei Bregenz',47.51000,9.72000,'vorarlberg','target'),
|
||||||
|
('felswand_raetikon','Kletterunfall Raetikon-Felswand',47.10000,9.70000,'vorarlberg','target'),
|
||||||
|
('kaiser_kufstein','Wanderunfall Kaisergebirge',47.56000,12.25000,'tirol','target'),
|
||||||
|
('schlucht_mellau','Canyoning-Unfall Schlucht Mellau',47.34000,9.86000,'vorarlberg','target'),
|
||||||
|
('maria_saal','Maria Saal',46.67972,14.34361,'kaernten','city');
|
||||||
|
|
||||||
|
-- M10 "Verkehrsunfall A2 Baden": DB-Wert lag falsch (16.2445 = zwischen Stadt und
|
||||||
|
-- Autobahn). Zurueck auf die A2-Linie (Original-Snapshot-Wert, oestlich von Baden).
|
||||||
|
UPDATE geo_waypoints SET lat=47.99200, lon=16.28550, name='Unfall A2 Baden' WHERE wp_key='verkehr_a2_baden';
|
||||||
|
|
||||||
|
-- M17 "LKW-Unfall A10 Tauern": Zielpunkt lag ~7 km noerdlich im Gebirge (47.265/13.477)
|
||||||
|
-- statt am Tauerntunnel-Nordportal (Flachauwinkl-Seite).
|
||||||
|
UPDATE geo_waypoints SET lat=47.22500, lon=13.42400, name='Unfall A10 Tauerntunnel (Nordportal)' WHERE wp_key='verkehr_a10_tauern';
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Weitere v3-DB-Aenderungen fuer den Prod-Cutover
|
||||||
|
-- ============================================================
|
||||||
|
-- "Stadt & Raumplanung" vorlaeufig in Vorbereitung (nicht spielbar, coming_soon).
|
||||||
|
UPDATE module_info SET status='geplant' WHERE module_id='stadt';
|
||||||
@@ -0,0 +1,825 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>GeoGraSim — Lehrer View Parameter-Überblick Schülerleistung</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;min-height:100vh;color:#2a2a2a}
|
||||||
|
.topbar{background:#fff;padding:.6rem 1.2rem;display:flex;align-items:center;gap:.8rem;border-bottom:1px solid rgba(0,0,0,.06);position:sticky;top:0;z-index:20;flex-wrap:wrap}
|
||||||
|
.topbar h1{font-size:.95rem;font-weight:800;color:#4a7c8a;flex:1;display:flex;align-items:center;gap:.5rem;min-width:220px}
|
||||||
|
.topbar h1 img{height:26px}
|
||||||
|
.btn-sm{padding:.3rem .6rem;border:1px solid rgba(0,0,0,.08);border-radius:6px;background:#fff;font-size:.68rem;font-weight:600;cursor:pointer;color:#4a4a4a;text-decoration:none;white-space:nowrap}
|
||||||
|
.btn-sm:hover{background:#dae8ec;color:#4a7c8a}
|
||||||
|
.wrap{max-width:1240px;margin:1rem auto 4rem;padding:0 1rem}
|
||||||
|
.intro{background:#fff;border-radius:12px;padding:1.1rem 1.3rem;box-shadow:0 1px 4px rgba(0,0,0,.04);margin-bottom:1.2rem}
|
||||||
|
.intro h2{font-size:1.05rem;color:#1f4b37;font-weight:800;margin-bottom:.4rem}
|
||||||
|
.intro p{font-size:.82rem;line-height:1.5;color:#4a4a4a;margin-bottom:.5rem}
|
||||||
|
.intro .wiring{display:flex;gap:.5rem;flex-wrap:wrap;margin:.6rem 0 .2rem}
|
||||||
|
.wire-chip{background:#eef3f5;border:1px solid #d5e2e7;border-radius:8px;padding:.35rem .7rem;font-size:.72rem;color:#3a5a66;font-weight:600}
|
||||||
|
.wire-chip b{color:#1f4b37}
|
||||||
|
.legend{display:flex;gap:1.2rem;flex-wrap:wrap;margin-top:.7rem;padding-top:.7rem;border-top:1px solid rgba(0,0,0,.06);font-size:.76rem;color:#4a4a4a}
|
||||||
|
.legend span{display:inline-flex;align-items:center;gap:.4rem}
|
||||||
|
.sw{width:16px;height:16px;border-radius:4px;display:inline-block;border:1px solid rgba(0,0,0,.12)}
|
||||||
|
.toc{display:flex;flex-wrap:wrap;gap:.4rem;margin-bottom:1.4rem}
|
||||||
|
.toc a{font-size:.72rem;text-decoration:none;background:#fff;border:1px solid rgba(0,0,0,.08);border-radius:20px;padding:.3rem .7rem;color:#3a5a66;font-weight:600}
|
||||||
|
.toc a:hover{background:#dae8ec}
|
||||||
|
.sim{background:#fff;border-radius:12px;box-shadow:0 1px 4px rgba(0,0,0,.04);margin-bottom:1.6rem;overflow:hidden;scroll-margin-top:70px}
|
||||||
|
.sim-head{display:flex;align-items:baseline;gap:.6rem;padding:.9rem 1.1rem .5rem;flex-wrap:wrap}
|
||||||
|
.sim-head .emoji{font-size:1.5rem;line-height:1}
|
||||||
|
.sim-head h3{font-size:1.1rem;font-weight:800;color:#1f4b37}
|
||||||
|
.sim-head .sid{font-size:.68rem;color:#8a8a8a;font-family:'Courier New',monospace}
|
||||||
|
.sim-head .count{margin-left:auto;font-size:.7rem;color:#5a8a5e;font-weight:700}
|
||||||
|
.tbl-scroll{overflow-x:auto;padding:0 1.1rem}
|
||||||
|
table.live{width:100%;border-collapse:collapse;font-size:.74rem}
|
||||||
|
table.live th{padding:.4rem .5rem;white-space:nowrap;vertical-align:bottom;border-bottom:2px solid rgba(0,0,0,.08)}
|
||||||
|
table.live th .key{display:block;font-size:.58rem;color:#9a9a9a;font-weight:500;font-family:'Courier New',monospace;margin-top:2px}
|
||||||
|
table.live th.green{background:#d9eddc}
|
||||||
|
table.live th.grey{background:#f7f6f3}
|
||||||
|
table.live td{padding:.4rem .5rem;font-variant-numeric:tabular-nums;vertical-align:top;border-bottom:1px solid rgba(0,0,0,.04)}
|
||||||
|
table.live td.green{background:#eef7f0}
|
||||||
|
.tag{display:inline-block;font-size:.55rem;font-weight:700;border-radius:4px;padding:1px 5px;margin-top:3px;letter-spacing:.03em}
|
||||||
|
.tag.g{background:#5a8a5e;color:#fff}
|
||||||
|
.tag.l{background:#e3e0d8;color:#7a7568}
|
||||||
|
.note{margin:.6rem 1.1rem 1rem;background:#faf7f0;border:1px solid #ece3cf;border-left:4px solid #d8b04a;border-radius:8px;padding:.6rem .85rem;font-size:.76rem;color:#6a5a2a}
|
||||||
|
.note b{color:#8a6a1a}
|
||||||
|
.note ul{margin:.35rem 0 0 1.1rem;padding:0}
|
||||||
|
.note li{margin:.15rem 0;line-height:1.4}
|
||||||
|
.note code{background:#efe8d5;border-radius:3px;padding:0 4px;font-size:.72rem;color:#7a5a1a}
|
||||||
|
.allgreen{margin:.6rem 1.1rem 1rem;background:#f0f7f1;border:1px solid #cfe6d2;border-left:4px solid #5a8a5e;border-radius:8px;padding:.55rem .85rem;font-size:.76rem;color:#3a6b3e}
|
||||||
|
.scorebox{margin:.2rem 1.1rem 1rem;background:#eef3f7;border:1px solid #d3e0e8;border-left:4px solid #4a7c8a;border-radius:8px;padding:.7rem .9rem;font-size:.78rem;color:#2f4a58}
|
||||||
|
.scorebox h4{font-size:.8rem;color:#2f5866;margin-bottom:.45rem;display:flex;align-items:center;gap:.4rem}
|
||||||
|
.scorebox .srow{display:grid;grid-template-columns:78px 1fr;gap:.5rem;margin:.28rem 0;align-items:start}
|
||||||
|
.scorebox .srow .lab{font-weight:700;color:#3a6072}
|
||||||
|
.scorebox code{background:#dde8ee;border-radius:3px;padding:0 4px;font-size:.72rem;color:#294a58;font-family:'Courier New',monospace}
|
||||||
|
.scorebox .flag{display:block;margin-top:.5rem;font-size:.73rem;color:#8a3a2a;background:#fbeee9;border:1px solid #ecc9bd;border-radius:6px;padding:.35rem .55rem}
|
||||||
|
.scorebox .flag b{color:#a5341f}
|
||||||
|
.gbox{background:#fff;border-radius:12px;box-shadow:0 1px 4px rgba(0,0,0,.04);padding:1.1rem 1.3rem;margin-bottom:1.2rem;border-left:4px solid #4a7c8a}
|
||||||
|
.gbox h2{font-size:1rem;color:#1f4b37;font-weight:800;margin-bottom:.5rem}
|
||||||
|
.gbox p{font-size:.82rem;line-height:1.5;color:#4a4a4a;margin-bottom:.5rem}
|
||||||
|
.gbox code{background:#eef3f5;border-radius:3px;padding:0 4px;font-size:.75rem;color:#294a58;font-family:'Courier New',monospace}
|
||||||
|
.gbox .rec{background:#f0f7f1;border:1px solid #cfe6d2;border-radius:8px;padding:.6rem .8rem;font-size:.8rem;color:#2f5e39;margin-top:.5rem}
|
||||||
|
.foot{font-size:.72rem;color:#8a8a8a;text-align:center;margin-top:2rem}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar">
|
||||||
|
<h1><img src="assets/img/geograsim_t.svg" alt="GeoGraSim"> Lehrer View · Parameter-Überblick Schülerleistung</h1>
|
||||||
|
<a class="btn-sm" href="admin-sitemap.html">← Admin-Übersicht</a>
|
||||||
|
<a class="btn-sm" href="admin-modules.html">Module</a>
|
||||||
|
<a class="btn-sm" href="teacher.html">Lehreransicht</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="intro">
|
||||||
|
<h2>Wozu diese Seite?</h2>
|
||||||
|
<p>Referenz für die <b>Verkabelung</b> jeder Simulation. Pro Sim siehst du die Tabelle aus dem Lehrer-Menüpunkt
|
||||||
|
<b>Ergebnisse / Aktuell</b> — dort erscheint jede Schüler:in mit den relevanten Leistungsdaten der laufenden Sim.
|
||||||
|
Die Tabellen hier sind <b>1:1 dieselbe Render-Logik</b> (<code>LIVE_PRIMARY_FIELDS</code> + Formatter aus <code>teacher.html</code>), nur mit <b>Testdaten</b>, damit du sie vergleichen kannst.</p>
|
||||||
|
<div class="wiring">
|
||||||
|
<span class="wire-chip"><b>Spiel</b> → <code>window.GGS_LIVE_STATE()</code> liefert die State-Keys</span>
|
||||||
|
<span class="wire-chip"><b>Lehrer-Backend</b> → Spalte + Formatter je Key</span>
|
||||||
|
<span class="wire-chip"><b>Schülerinfo</b> → Name · Avatar · Spielzeit</span>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:.78rem;margin-top:.5rem">Unter jedem Spaltentitel steht der <b>State-Key</b> (der Parameter, den das Spiel senden muss). So ist ablesbar, welcher Wert aus dem Spiel welche Spalte speist.</p>
|
||||||
|
<div class="legend">
|
||||||
|
<span><span class="sw" style="background:#d9eddc"></span> <b>Grün = nach Abschluss noch relevant</b> — Ergebnis, Fortschritt (auch bei Abbruch) oder Identität der Aufgabe. Sollte in die Ergebnis-/Verlaufsansicht übernommen werden.</span>
|
||||||
|
<span><span class="sw" style="background:#f7f6f3"></span> <b>Grau = nur live</b> — momentaner Zustand, der sich laufend ändert oder zurücksetzt. Nach Abschluss ohne Aussagekraft.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="gbox">
|
||||||
|
<h2>So entstehen Prozent & Sterne — und warum das heute uneinheitlich ist</h2>
|
||||||
|
<p><b>Der Zeilen-Prozentwert</b> in „Ergebnisse/Aktuell" entsteht in <code>live.php · extractScore</code>: liefert die Sim einen eigenen <b>0–100-Score</b>, wird der genommen; sonst aus den Sternen: <code>% = Sterne / 5 × 100</code>.</p>
|
||||||
|
<p><b>Die Sterne</b> berechnet jede App am Ende selbst — aus unterschiedlichen Kennzahlen und Schwellen (je Sim unten dokumentiert). Deshalb bedeuten 4★ heute nicht überall dasselbe.</p>
|
||||||
|
<p><b>Uneinheitlichkeiten:</b> Busfahrt · Fluggesellschaft · Kofferdetektiv liefern nur <b>0–3 Sterne</b> statt 0–5. Busfahrt/Fluggesellschaft senden als <code>score</code> rohe Punkte (nicht 0–100 → Zeilen-% verzerrt). Weltküche wertet <b>pro Gericht</b> statt pro Durchgang. <b>Logistik überträgt weder Sterne noch Score</b>. Ein echter 0–100-Live-Score existiert nur bei Klima, Fluss, Heli, Kofferdetektiv, Tourismusregion (abgeleitet Tourismustal).</p>
|
||||||
|
<div class="rec"><b>Beschlossen:</b> <b>Sterne entfallen.</b> Jede App liefert genau <b>eine Funktion → Leistungs-% (0–100)</b> als Wahrheit, überall gezeigt als Prozent + Balken in der 5-stufigen Farbskala. Auch Fördern / Begleiten / Läuft gut / Fordern leiten sich aus diesem % ab.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="preview"></div>
|
||||||
|
<div class="toc" id="toc"></div>
|
||||||
|
<div id="content"></div>
|
||||||
|
<p class="foot">Alle Werte sind Beispieldaten zur Ansicht. Render-Logik gespiegelt aus <code>teacher.html · renderLive()</code>. Grün/Grau ist Atlas' Vorschlag — bitte prüfen & korrigieren.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* ============================================================
|
||||||
|
1) Formatter & Helfer — VERBATIM aus teacher.html gespiegelt,
|
||||||
|
damit die Zellen exakt wie in der echten Ansicht rendern.
|
||||||
|
============================================================ */
|
||||||
|
function esc(s){ var d=document.createElement('div'); d.textContent=s||''; return d.innerHTML; }
|
||||||
|
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; }
|
||||||
|
function _fmtSince(sec){ return sec<60 ? sec+' s' : Math.floor(sec/60)+' min'; }
|
||||||
|
|
||||||
|
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 _liveBar(val, min, max, goodWhen, dec){
|
||||||
|
if (val==null || isNaN(val)) return '<span style="color:#9a9a9a">—</span>';
|
||||||
|
var span=(max-min)||1;
|
||||||
|
var pct=Math.max(0,Math.min(100,((val-min)/span)*100));
|
||||||
|
var c;
|
||||||
|
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'; }
|
||||||
|
var disp=(dec!=null)?Number(val).toFixed(dec):Math.round(val);
|
||||||
|
return '<span style="display:inline-flex;align-items:center;gap:.35rem;font-variant-numeric:tabular-nums">'
|
||||||
|
+ '<span aria-hidden="true" style="display:inline-block;width:42px;height:7px;border-radius:99px;background:rgba(0,0,0,.08);overflow:hidden">'
|
||||||
|
+ '<span style="display:block;height:100%;width:'+pct.toFixed(0)+'%;background:'+c+'"></span></span>'
|
||||||
|
+ '<span style="color:'+c+';font-weight:700;font-size:.8rem;min-width:1.6em;text-align:right">'+disp+'</span></span>';
|
||||||
|
}
|
||||||
|
function _liveBarColor(pct, goodWhen){
|
||||||
|
pct=Math.max(0,Math.min(100,pct||0));
|
||||||
|
if (goodWhen==='low') return pct<=33?'#5a8a5e':pct<=66?'#c9913a':'#c07a6b';
|
||||||
|
if (goodWhen==='mid') return '#9a9a9a';
|
||||||
|
return pct>=60?'#5a8a5e':pct>=30?'#c9913a':'#c07a6b';
|
||||||
|
}
|
||||||
|
function _liveMiniBar(pct, color){
|
||||||
|
pct=Math.max(0,Math.min(100,pct||0));
|
||||||
|
return '<div style="margin-top:3px;height:3px;border-radius:99px;background:rgba(0,0,0,.06);overflow:hidden">'
|
||||||
|
+ '<div style="height:100%;width:'+pct.toFixed(0)+'%;background:'+color+'"></div></div>';
|
||||||
|
}
|
||||||
|
function _liveNeedsHelp(simId, s){
|
||||||
|
if (!s || typeof s!=='object') return false;
|
||||||
|
switch (simId){
|
||||||
|
case 'heli': return (s.planAttempts||0) >= 10;
|
||||||
|
case 'klima': case 'klima-3d': return (s.budget||0) < 0;
|
||||||
|
case 'weltkueche': return (s.mistakesInDish||0) >= 12;
|
||||||
|
case 'farmer': return (s.cash==null?1000:s.cash) < 0 || ((s.attempts||0) >= 6 && s.hit_rate != null && s.hit_rate < 30);
|
||||||
|
case 'logistik': return (s.balance==null?1000:s.balance) < 0;
|
||||||
|
case 'eu-werkstatt': return (s.attemptsThisStep||0) >= 6 || (s.reshufflesThisStep||0) >= 5;
|
||||||
|
case 'energiemanager': return (s.blackouts||0) >= 2 || (s.phase==='done' && (s.balanced||0) < 4);
|
||||||
|
case 'tourismustal': return (s.money==null?1:s.money) < 0 || (s.lossStreak||0) >= 8;
|
||||||
|
case 'tourismusregion': return (s.budget==null?1:s.budget) < -400 || (s.env==null?100:s.env) < 30;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function _liveMood(simId, s){
|
||||||
|
if (!s) return null;
|
||||||
|
if (s.health) return ({good:'good',ok:'good',warning:'mid',bad:'bad',critical:'bad'})[s.health] || 'mid';
|
||||||
|
if (_liveNeedsHelp(simId, s)) return 'bad';
|
||||||
|
switch (simId){
|
||||||
|
case 'weltkueche': return (s.mistakesInDish||0)<=2?'good':(s.mistakesInDish>=8?'bad':'mid');
|
||||||
|
case 'farmer': return (s.hit_rate==null)?'mid':(s.hit_rate>=60?'good':s.hit_rate>=30?'mid':'bad');
|
||||||
|
case 'klima': case 'klima-3d': return (typeof s.score==='number')?(s.score>=65?'good':s.score>=40?'mid':'bad'):(((s.floodedPct==null?100:s.floodedPct)<25 && (s.budget||0)>=0)?'good':'mid');
|
||||||
|
case 'busfahrt': case 'fluggesellschaft': return (s.budget==null?0:s.budget)>=5000?'good':(s.budget<0?'bad':'mid');
|
||||||
|
case 'tourismustal': return (s.money==null?0:s.money)>=4000?'good':(s.money<0?'bad':'mid');
|
||||||
|
case 'heli': return (s.totalStars||0)>=4?'good':'mid';
|
||||||
|
case 'fluss': case 'kofferdetektiv': case 'sonnensystem': return s.score!=null?(s.score>=70?'good':s.score>=40?'mid':'bad'):'mid';
|
||||||
|
}
|
||||||
|
return 'mid';
|
||||||
|
}
|
||||||
|
function _liveMoodBadge(simId, s){
|
||||||
|
var m=_liveMood(simId,s);
|
||||||
|
if (!m) return '<span style="color:#8a8a8a">—</span>';
|
||||||
|
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>';
|
||||||
|
}
|
||||||
|
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 };
|
||||||
|
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), 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>';
|
||||||
|
}
|
||||||
|
function _liveQualityFromState(simId, s){
|
||||||
|
var cw=_liveClicks(simId,s);
|
||||||
|
return cw ? _liveQualityBar(cw.correct,cw.wrong) : '<span style="color:#8a8a8a">—</span>';
|
||||||
|
}
|
||||||
|
function _liveFmt(v, unit, fieldDef, row){
|
||||||
|
if (fieldDef && typeof fieldDef.fmt==='function'){
|
||||||
|
try { var f=fieldDef.fmt(v,row); return (f===undefined||f===null||f==='')?'—':f; } catch(_){}
|
||||||
|
}
|
||||||
|
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>':'');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
2) Spaltenschema — VERBATIM aus teacher.html (LIVE_PRIMARY_FIELDS)
|
||||||
|
============================================================ */
|
||||||
|
var LIVE_PRIMARY_FIELDS = {
|
||||||
|
farmer: [
|
||||||
|
{k:'year', label:'Jahr', bar:{kind:'abs',max:10,good:'mid'}, fmt:function(v){ return (v||0) + '<span style="color:#8a8a8a"> / 10</span>'; }},
|
||||||
|
{k:'market_de', label:'Markt', align:'left', fmt:function(v){ return v ? '🌍 '+esc(v) : '<span style="color:#8a8a8a">—</span>'; }},
|
||||||
|
{k:'cash', label:'Konto', bar:{kind:'rel',good:'signed'}, fmt:function(v){ if (v==null) return '<span style="color:#9a9a9a">—</span>'; var color=v<0?'#c0392b':'#5a8a5e'; return '<span style="color:'+color+';font-weight:700">'+Number(v).toLocaleString('de-AT')+' €</span>'; }},
|
||||||
|
{k:'hit_rate', label:'Trefferquote', unit:'%', bar:{kind:'abs',max:100,good:'high'}, fmt:function(v){ return (v!=null? v : 0); }},
|
||||||
|
{k:'crops_used', label:'Sorten', bar:{kind:'abs',max:10,good:'high'}, fmt:function(v){ return (v||0) + '<span style="color:#8a8a8a"> / 10</span>'; }},
|
||||||
|
{k:'countries', label:'Länder', bar:{kind:'rel',good:'high'}, fmt:function(v){ return (v!=null? v : 0); }},
|
||||||
|
{k:'fields', label:'Felder', bar:{kind:'rel',good:'mid'}, fmt:function(v){ return (v!=null? v : 0); }},
|
||||||
|
{k:'attempts', label:'Versuche',bar:{kind:'rel',good:'mid'}, fmt:function(v){ return (v!=null? v : 0); }},
|
||||||
|
{k:'co2_kg', label:'CO₂', unit:'kg', bar:{kind:'rel',good:'low'}, fmt:function(v){ if (v==null) return '<span style="color:#9a9a9a">—</span>'; return '<span style="font-variant-numeric:tabular-nums">'+Number(Math.round(v)).toLocaleString('de-AT')+'</span>'; }},
|
||||||
|
{k:'diseases', label:'Krankheiten', align:'center', bar:{kind:'abs',max:5,good:'low'}, fmt:function(v){ if (v==null) return '<span style="color:#9a9a9a">—</span>'; var color=(v>0)?'#c07a6b':'#8a8a8a'; return '<span style="color:'+color+';font-weight:'+(v>0?'700':'400')+'">'+v+'</span>'; }},
|
||||||
|
],
|
||||||
|
'tourismusregion': [
|
||||||
|
{k:'mapId', label:'Karte', align:'left', fmt:function(v){ return {talschleife:'🏞 Talschleife', seerunde:'🚣 Seerunde', serpentinen:'⛰ Serpentinen', kreuzung:'✚ Kreuzung', passstrasse:'🛣 Passstraße'}[v] || v || '—'; }},
|
||||||
|
{k:'wave', label:'Welle', fmt:function(v, row){ var s=row.state||{}; if (v===undefined) return '—'; var status=s.waveStatus==='pause'?' <span style="color:#8a8a8a">⏸ Baupause</span>':''; return v+'<span style="color:#8a8a8a"> / '+(s.waveTotal||8)+'</span>'+status; }},
|
||||||
|
{k:'season', label:'Saison', align:'center'},
|
||||||
|
{k:'budget', label:'Budget', fmt:function(v){ if (v===undefined) return '—'; var color=v<0?'#c0392b':'#5a8a5e'; return '<span style="color:'+color+';font-weight:700">'+Number(v).toLocaleString('de-AT')+' €</span>'; }},
|
||||||
|
{k:'revenue', label:'Wertsch.', unit:'€'},
|
||||||
|
{k:'avgStars', label:'Ø ★', fmt:function(v){ if (v===undefined) return '—'; var color=v>=3.5?'#5a8a5e':v>=2.5?'#c9913a':'#c07a6b'; return '<span style="color:'+color+';font-weight:700">'+v.toFixed(2)+'</span>'; }},
|
||||||
|
{k:'env', label:'Umwelt', fmt:function(v){ return _liveBar(v,0,100,'high'); }},
|
||||||
|
{k:'visitors', label:'Gäste'},
|
||||||
|
{k:'goalsDone', label:'Ziele', fmt:function(v, row){ var s=row.state||{}; if (v===undefined||!s.goalsTotal) return '—'; var color=v===s.goalsTotal?'#5a8a5e':v>=2?'#c9913a':'#8a8a8a'; return '<span style="color:'+color+';font-weight:700">'+v+'/'+s.goalsTotal+'</span>'; }},
|
||||||
|
],
|
||||||
|
'tourismustal': [
|
||||||
|
{k:'scene', label:'Szene', align:'left', fmt:function(v){ return {pionierzeit:'⛏️ 1975', boom:'📈 Boom 2000', klimawandel:'🌡️ 2035'}[v] || v || '—'; }},
|
||||||
|
{k:'season', label:'Saison', align:'center'},
|
||||||
|
{k:'year', label:'Jahr'},
|
||||||
|
{k:'money', label:'Konto', fmt:function(v){ if (v===undefined) return '—'; var color=v<0?'#c0392b':v<4000?'#c9913a':'#5a8a5e'; return '<span style="color:'+color+';font-weight:700">'+Number(v).toLocaleString('de-AT')+' €</span>'; }},
|
||||||
|
{k:'guestsToday', label:'Gäste'},
|
||||||
|
{k:'satisfaction', label:'Zufr.', fmt:function(v){ return _liveBar(v,0,100,'high'); }},
|
||||||
|
{k:'nature', label:'Natur', fmt:function(v){ return _liveBar(v,0,100,'high'); }},
|
||||||
|
{k:'goalsDone', label:'Ziele', fmt:function(v, row){ var s=row.state||{}; if (v===undefined||!s.goalsTotal) return '—'; var color=v===s.goalsTotal?'#5a8a5e':v>=2?'#c9913a':'#8a8a8a'; return '<span style="color:'+color+';font-weight:700">'+v+'/'+s.goalsTotal+'</span>'; }},
|
||||||
|
{k:'buildings', label:'Geb.'},
|
||||||
|
{k:'bottleneck', label:'Engpass', align:'left', fmt:function(v){ return v ? '<span style="color:#c07a6b">⚠️ '+v+'</span>' : '<span style="color:#8a8a8a">—</span>'; }},
|
||||||
|
],
|
||||||
|
klima: [
|
||||||
|
{k:'level', label:'Stufe'},
|
||||||
|
{k:'year', label:'Jahr'},
|
||||||
|
{k:'co2', label:'CO₂', unit:'ppm', fmt:function(v){ return _liveBar(v,280,700,'low'); }},
|
||||||
|
{k:'temp', label:'Temp', unit:'°C', fmt:function(v){ return _liveBar(v,14,20,'low',1); }},
|
||||||
|
{k:'floodedPct', label:'Geflutet', unit:'%', fmt:function(v){ if (v==null||isNaN(v)) return '<span style="color:#9a9a9a">—</span>'; var c=v<25?'#5a8a5e':v<=40?'#c9913a':'#c07a6b'; var pct=Math.max(0,Math.min(100,v)); return '<span style="display:inline-flex;align-items:center;gap:.35rem;font-variant-numeric:tabular-nums"><span aria-hidden="true" style="display:inline-block;width:42px;height:7px;border-radius:99px;background:rgba(0,0,0,.08);overflow:hidden"><span style="display:block;height:100%;width:'+pct.toFixed(0)+'%;background:'+c+'"></span></span><span style="color:'+c+';font-weight:700;font-size:.8rem;min-width:1.6em;text-align:right">'+Math.round(v)+'</span></span>'; }},
|
||||||
|
{k:'budget', label:'Budget', unit:'Mio €', fmt:function(v){ return _liveBar(v,-500,3000,'high'); }},
|
||||||
|
{k:'population', label:'Bev.', fmt:function(v, row){ var sp=(((row&&row.state)||{}).level>=3)?7000:6000; return _liveBar(v,sp*0.3,sp*1.6,'high'); }},
|
||||||
|
{k:'measures', label:'Maßn.'},
|
||||||
|
{k:'speed', label:'Speed', unit:'×'},
|
||||||
|
],
|
||||||
|
'sonnensystem': [
|
||||||
|
{k:'health', label:'Status', align:'center', fmt:function(v){ return LIVE_VALUE_LABELS.health[v] || '—'; }},
|
||||||
|
{k:'screen', label:'Modus', fmt:function(v){ return v==='play'?'📚 Aufgabe':'🚀 Erkunden'; }},
|
||||||
|
{k:'taskTitle', label:'Aufgabe', align:'left', fmt:function(v, row){ var s=row.state||{}; if (s.screen!=='play'||!v) return '<span style="color:#8a8a8a">—</span>'; return v+' <span style="color:#8a8a8a;font-weight:400">('+s.taskIndex+'/'+s.taskTotal+')</span>'; }},
|
||||||
|
{k:'completed', label:'Gelöst', fmt:function(v, row){ var s=row.state||{}; return s.taskTotal ? v+' <span style="color:#8a8a8a">/ '+s.taskTotal+'</span>' : '—'; }},
|
||||||
|
{k:'progressPct', label:'Quote', fmt:function(v){ return _liveBar(v,0,100,'high'); }},
|
||||||
|
{k:'simDate', label:'Sim-Datum', align:'center'},
|
||||||
|
{k:'achievementsUnlocked', label:'Erfolge', fmt:function(v, row){ var s=row.state||{}; if (v===undefined) return '—'; return v+' <span style="color:#8a8a8a">/ '+(s.achievementsTotal||6)+'</span>'; }},
|
||||||
|
],
|
||||||
|
'energiemanager': [
|
||||||
|
{k:'health', label:'Status', align:'center', fmt:function(v){ return LIVE_VALUE_LABELS.health[v] || '—'; }},
|
||||||
|
{k:'setTitle', label:'Set', align:'left', fmt:function(v, row){ var s=row.state||{}; var icon=s.setIcon?'<span style="margin-right:.25rem">'+s.setIcon+'</span>':''; var idxBadge=(s.setIndex&&s.setTotal)?' <span style="color:#8a8a8a;font-weight:400">'+s.setIndex+'/'+s.setTotal+'</span>':''; return v ? icon+v+idxBadge : '—'; }},
|
||||||
|
{k:'day', label:'Tag', fmt:function(v, row){ var s=row.state||{}; return s.dayTotal ? v+' <span style="color:#8a8a8a">/ '+s.dayTotal+'</span>' : '—'; }},
|
||||||
|
{k:'block', label:'Block', fmt:function(v, row){ var s=row.state||{}; if (s.phase==='blackout') return '<span style="color:#d8453a;font-weight:700">🔌 neu planen</span>'; if (s.phase==='plan') return '<span style="color:#8a8a8a">plant</span>'; return s.blockTotal ? (v+1)+' <span style="color:#8a8a8a">/ '+s.blockTotal+'</span>' : '—'; }},
|
||||||
|
{k:'blackouts', label:'🔌', align:'center', fmt:function(v){ if (!v) return '<span style="color:#8a8a8a">–</span>'; var c=v>=2?'#d8453a':'#c9913a'; return '<span style="color:'+c+';font-weight:700">'+v+'</span>'; }},
|
||||||
|
{k:'balanced', label:'Tag ausg.', fmt:function(v, row){ var s=row.state||{}; if (s.phase!=='done') return '<span style="color:#8a8a8a">—</span>'; var color=v===s.blockTotal?'#5a8a5e':v>=4?'#c9913a':'#c07a6b'; return '<span style="color:'+color+';font-weight:700">'+v+'/'+s.blockTotal+'</span>'; }},
|
||||||
|
{k:'setBlocksBalanced', label:'Set ausg.', fmt:function(v, row){ var s=row.state||{}; var max=(s.setTotal&&s.dayTotal&&s.blockTotal)?s.dayTotal*s.blockTotal:0; if (!max) return '—'; var color=v>=max*0.87?'#5a8a5e':v>=max*0.5?'#c9913a':'#c07a6b'; return '<span style="color:'+color+';font-weight:700">'+v+'/'+max+'</span>'; }},
|
||||||
|
{k:'upperLake', label:'⬆ See', unit:'GWh'},
|
||||||
|
{k:'lowerLake', label:'⬇ See', unit:'GWh'},
|
||||||
|
],
|
||||||
|
'eu-werkstatt': [
|
||||||
|
{k:'health', label:'Status', align:'center', fmt:function(v){ return LIVE_VALUE_LABELS.health[v] || '—'; }},
|
||||||
|
{k:'scenarioTitle', label:'Szenario', align:'left', fmt:function(v, row){ var s=row.state||{}; var icon=s.scenarioIcon?'<span style="margin-right:.25rem">'+s.scenarioIcon+'</span>':''; return v ? icon+v : '<span style="color:#8a8a8a">Szenario-Auswahl</span>'; }},
|
||||||
|
{k:'step', label:'Schritt', fmt:function(v, row){ var s=row.state||{}; if (!s.stepsTotal) return '—'; return v+' <span style="color:#8a8a8a">/ '+s.stepsTotal+'</span>'; }},
|
||||||
|
{k:'difficulty', label:'Modus', fmt:function(v){ return LIVE_VALUE_LABELS.difficulty[v] || '—'; }},
|
||||||
|
{k:'firstTryHits', label:'Treffer'},
|
||||||
|
{k:'muelleimerCount', label:'Mülleimer', fmt:function(v){ if (v===undefined||v===null) return '—'; var color=v===0?'#5a8a5e':v>=3?'#c07a6b':'#c9913a'; return '<span style="color:'+color+';font-weight:700">'+v+'</span>'; }},
|
||||||
|
{k:'reshuffles', label:'🔄 Probier', align:'center', fmt:function(v){ if (!v) return '<span style="color:#8a8a8a">–</span>'; var color=v>=5?'#c07a6b':v>=2?'#c9913a':'#5a8a5e'; return '<span style="color:'+color+';font-weight:700">'+v+'</span>'; }},
|
||||||
|
{k:'secondsElapsed', label:'Dauer', fmt:function(v){ return v ? _fmtSeconds(v) : '—'; }},
|
||||||
|
{k:'achievementsUnlocked', label:'Erfolge', fmt:function(v, row){ var s=row.state||{}; if (v===undefined||v===null) return '—'; return v+' <span style="color:#8a8a8a">/ '+(s.achievementsTotal||6)+'</span>'; }},
|
||||||
|
],
|
||||||
|
'weltkueche': [
|
||||||
|
{k:'dishName', label:'Gericht', align:'left', fmt:function(v){ return v ? '<b>'+esc(v)+'</b>' : '<span style="color:#8a8a8a">Auswahl</span>'; }},
|
||||||
|
{k:'completed', label:'Gerichte', align:'center', fmt:function(v, row){ var s=row.state||{}; var t=s.completedTotal||0; if (!t) return '<span style="color:#8a8a8a">—</span>'; var done=v||0; var c=done>=t?'#5a8a5e':'#3d5165'; return '<b style="color:'+c+'">'+done+'</b><span style="color:#8a8a8a"> / '+t+'</span>'; }},
|
||||||
|
{k:'ingSolved', label:'Zutaten akt.', fmt:function(v, row){ var s=row.state||{}; var t=s.ingTotal||0; if (!s.dishName||!t) return '<span style="color:#8a8a8a">—</span>'; var done=v||0; var pct=done/t; var c=pct>=1?'#5a8a5e':pct>=.5?'#c9913a':'#8a7a4a'; return '<span style="color:'+c+';font-weight:700">'+done+' <span style="color:#8a8a8a;font-weight:400">/ '+t+'</span></span>'; }},
|
||||||
|
{k:'__quality', label:'Qualität', align:'left', fmt:function(v, row){ return _liveQualityFromState(row.module_id, row.state); }},
|
||||||
|
{k:'mistakesInDish', label:'Fehler (akt.)', align:'center', fmt:function(v){ var n=v||0; var c=n>=12?'#c07a6b':n>=5?'#c9913a':'#5a8a5e'; return '<span style="color:'+c+';font-weight:700">'+n+'</span>'; }},
|
||||||
|
{k:'__lage', label:'Lage', align:'center', fmt:function(v, row){ return _liveMoodBadge(row.module_id, row.state); }},
|
||||||
|
],
|
||||||
|
'busfahrt': [
|
||||||
|
{k:'levelId', label:'Stufe', align:'center'},
|
||||||
|
{k:'orderIndex', label:'Auftrag', fmt:function(v, row){ var s=row.state||{}; return s.totalOrders ? (v+1)+' / '+s.totalOrders : '—'; }},
|
||||||
|
{k:'budget', label:'Budget', unit:'€', fmt:function(v){ if (v==null) return '—'; var c=v>=5000?'#5a8a5e':v>=0?'#c9913a':'#c07a6b'; return '<span style="color:'+c+';font-weight:700">'+v.toLocaleString('de-AT')+' €</span>'; }},
|
||||||
|
{k:'wonOrders', label:'Aufträge ✓'},
|
||||||
|
{k:'lastCityTitle', label:'Letzte Stadt', align:'left'},
|
||||||
|
{k:'lastMargin', label:'Letzte Marge', unit:'€'},
|
||||||
|
{k:'quizFirstTry', label:'🧠 1.-Versuch', fmt:function(v, row){ var s=row.state||{}; var t=s.orderIndex||0; return t ? v+' / '+t : '—'; }},
|
||||||
|
],
|
||||||
|
'fluggesellschaft': [
|
||||||
|
{k:'levelId', label:'Stufe', align:'center'},
|
||||||
|
{k:'orderIndex', label:'Flug', fmt:function(v, row){ var s=row.state||{}; return s.totalOrders ? (v+1)+' / '+s.totalOrders : '—'; }},
|
||||||
|
{k:'budget', label:'Budget', unit:'€', fmt:function(v){ if (v==null) return '—'; var c=v>=8000?'#5a8a5e':v>=0?'#c9913a':'#c07a6b'; return '<span style="color:'+c+';font-weight:700">'+v.toLocaleString('de-AT')+' €</span>'; }},
|
||||||
|
{k:'wonOrders', label:'Flüge ✓'},
|
||||||
|
{k:'co2Kg', label:'🌍 CO₂', fmt:function(v){ return v==null ? '—' : (v/1000).toFixed(1)+' t'; }},
|
||||||
|
{k:'lastCityTitle', label:'Letztes Ziel', align:'left'},
|
||||||
|
{k:'lastTzDiff', label:'Zeitzone', fmt:function(v){ if (v==null) return '—'; return (v>0?'+':'')+v+' h'; }},
|
||||||
|
{k:'quizFirstTry', label:'🧠 1.-Versuch', fmt:function(v, row){ var s=row.state||{}; var t=s.orderIndex||0; return t ? v+' / '+t : '—'; }},
|
||||||
|
],
|
||||||
|
'heli': [
|
||||||
|
{k:'phase', label:'Phase', align:'center', fmt:function(v){ var m={mission:'📋 Auftragswahl', plan:'🗺️ Routenplan', briefing:'📋 Briefing', start:'🛫 Start', flight:'✈️ Flug', approach:'🎯 Anflug', landing:'🛬 Landung', results:'🏁 Ende'}; return m[v] || (v ? v : '—'); }},
|
||||||
|
{k:'missionId', label:'Auftrag', align:'left', fmt:function(v, row){ var s=row.state||{}; var title=s.missionTitle||''; return v ? '<b>'+v+'</b>'+(title?' <span style="color:#8a8a8a;font-weight:400">'+title+'</span>':'') : '—'; }},
|
||||||
|
{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>'; }},
|
||||||
|
{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>'; if (s.phase==='mission'||!s.phase){ if (s.lastResult) return s.lastFailed ? '<span style="color:#c07a6b;font-weight:600" title="Werte = letzter Einsatz">⚠ letzter Einsatz</span>' : '<span style="color:#5a8a5e;font-weight:600" title="Werte = letzter Einsatz">✓ letzter Einsatz</span>'; return '<span style="color:#8a8a8a">wählt Auftrag</span>'; } return '<span style="color:#4a7c8a;font-weight:600">🟢 unterwegs</span>'; }},
|
||||||
|
],
|
||||||
|
'fluss': [
|
||||||
|
{k:'health', label:'Status', align:'center', fmt:function(v){ return LIVE_VALUE_LABELS.health[v] || '—'; }},
|
||||||
|
{k:'levelId', label:'Stufe', align:'center'},
|
||||||
|
{k:'year', label:'Jahr', fmt:function(v, row){ var s=row.state||{}; var max=100; return s.progressPct!=null ? (v||0)+' <span style="color:#8a8a8a">/ '+max+'</span>' : (v||'—'); }},
|
||||||
|
{k:'score', label:'🏅 Score', fmt:function(v){ return _liveBar(v,0,100,'high'); }},
|
||||||
|
{k:'progressPct', label:'Fortschritt', fmt:function(v){ return _liveBar(v,0,100,'mid'); }},
|
||||||
|
{k:'budget', label:'Budget', unit:'Mio €', fmt:function(v){ if (v==null) return '—'; var c=v>=20?'#5a8a5e':v>=0?'#c9913a':'#c07a6b'; return '<span style="color:'+c+';font-weight:700">'+v+'</span>'; }},
|
||||||
|
{k:'biodiv', label:'🌳 Biodiv.', fmt:function(v){ return _liveBar(v,0,100,'high'); }},
|
||||||
|
{k:'economy', label:'💰 Wirtsch.', fmt:function(v){ return _liveBar(v,0,100,'high'); }},
|
||||||
|
{k:'flood', label:'🛡️ Hochw.-Schutz', fmt:function(v){ return _liveBar(v,0,100,'high'); }},
|
||||||
|
{k:'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){ var s=row.state||{}; if (!v) return '—'; var iso=String(v).toUpperCase(); if (s.case_country) return '<span style="font-weight:600;color:#5a8a5e">'+esc(s.case_country)+'</span>'; return '<span style="font-weight:600;color:#b04a3e" title="Antwort verdeckt — Schüler:in sucht noch">'+iso+' (suchend)</span>'; }},
|
||||||
|
{k:'hints_revealed', label:'Hinweise', align:'center'},
|
||||||
|
{k:'wrong_guesses_total', label:'❌ Fehler', fmt:function(v){ return _liveBar(v,0,10,'low'); }},
|
||||||
|
{k:'time_left_s', label:'⏱ Rest', align:'center', fmt:function(v){ if (v==null) return '<span style="color:#8a8a8a">∞</span>'; var c=v<=30?'#c07a6b':v<=120?'#c9913a':'#5a8a5e'; return '<span style="color:'+c+';font-weight:700">'+_fmtSeconds(v)+'</span>'; }},
|
||||||
|
{k:'airport', label:'✈ Flughafen', align:'left'},
|
||||||
|
],
|
||||||
|
'logistik': [
|
||||||
|
{k:'levelId', label:'Stufe', align:'center'},
|
||||||
|
{k:'balance', label:'Budget', fmt:function(v){ if (v==null) return '—'; var c=v>=5000?'#5a8a5e':v>=0?'#c9913a':'#c07a6b'; return '<span style="color:'+c+';font-weight:700">'+Number(v).toLocaleString('de-AT')+' €</span>'; }},
|
||||||
|
{k:'ordersDelivered', label:'Geliefert', align:'center', fmt:function(v, row){ var s=row.state||{}; var t=s.totalOrders||0; var done=v||0; var c=done>0?'#5a8a5e':'#8a8a8a'; return '<b style="color:'+c+'">'+done+'</b>'+(t?'<span style="color:#8a8a8a"> / '+t+'</span>':''); }},
|
||||||
|
{k:'ordersInFlight', label:'Unterwegs', align:'center'},
|
||||||
|
{k:'ordersOpen', label:'Offen', align:'center'},
|
||||||
|
{k:'ordersLate', label:'Verspätet', align:'center', fmt:function(v){ var n=v||0; return n>0 ? '<span style="color:#c07a6b;font-weight:700">'+n+'</span>' : '<span style="color:#5a8a5e">0</span>'; }},
|
||||||
|
{k:'progressPct', label:'Fortschritt', fmt:function(v){ return _liveBar(v,0,100,'high'); }},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
3) Reihenfolge, Titel/Icons + Grün-Bewertung je Sim
|
||||||
|
GREEN = Keys, die NACH Abschluss noch relevant sind
|
||||||
|
(Ergebnis · Fortschritt/Abbruch · Aufgaben-Identität).
|
||||||
|
Alles andere = nur live.
|
||||||
|
============================================================ */
|
||||||
|
var SIM_META = [
|
||||||
|
{id:'farmer', icon:'🌾', title:'Farmer'},
|
||||||
|
{id:'klima', icon:'🌍', title:'Klimawächter'},
|
||||||
|
{id:'fluss', icon:'🌊', title:'Flussmanagement'},
|
||||||
|
{id:'energiemanager', icon:'⚡', title:'Energiemanager'},
|
||||||
|
{id:'eu-werkstatt', icon:'🇪🇺', title:'EU-Werkstatt'},
|
||||||
|
{id:'weltkueche', icon:'🍽️', title:'Weltküche'},
|
||||||
|
{id:'busfahrt', icon:'🚌', title:'Busfahrt'},
|
||||||
|
{id:'fluggesellschaft', icon:'✈️', title:'Fluggesellschaft'},
|
||||||
|
{id:'heli', icon:'🚁', title:'Hubschrauber-Rettung'},
|
||||||
|
{id:'kofferdetektiv', icon:'🧳', title:'Kofferdetektiv'},
|
||||||
|
{id:'sonnensystem', icon:'🪐', title:'Sonnensystem'},
|
||||||
|
{id:'tourismustal', icon:'🏔️', title:'Tourismustal'},
|
||||||
|
{id:'tourismusregion', icon:'🗺️', title:'Tourismusregion'},
|
||||||
|
{id:'logistik', icon:'📦', title:'Logistik'},
|
||||||
|
];
|
||||||
|
|
||||||
|
var GREEN = {
|
||||||
|
farmer: ['year','cash','hit_rate','crops_used','countries','fields','attempts','co2_kg','diseases'],
|
||||||
|
klima: ['level','year','co2','temp','floodedPct','budget','population','measures'],
|
||||||
|
fluss: ['levelId','year','score','progressPct','budget','biodiv','economy','flood','settlementsLost'],
|
||||||
|
energiemanager: ['setTitle','day','blackouts','balanced','setBlocksBalanced'],
|
||||||
|
'eu-werkstatt': ['scenarioTitle','step','difficulty','firstTryHits','muelleimerCount','reshuffles','secondsElapsed','achievementsUnlocked'],
|
||||||
|
weltkueche: ['completed','__quality'],
|
||||||
|
busfahrt: ['levelId','orderIndex','budget','wonOrders','quizFirstTry'],
|
||||||
|
fluggesellschaft: ['levelId','orderIndex','budget','wonOrders','co2Kg','quizFirstTry'],
|
||||||
|
heli: ['missionId','planIndex','planCorrect','planAttempts','startScore','landingScore','totalStars','failed'],
|
||||||
|
kofferdetektiv: ['progressPct','score','cases_solved','hints_revealed','wrong_guesses_total'],
|
||||||
|
sonnensystem: ['completed','progressPct','achievementsUnlocked'],
|
||||||
|
tourismustal: ['scene','year','money','guestsToday','satisfaction','nature','goalsDone','buildings'],
|
||||||
|
tourismusregion: ['mapId','wave','budget','revenue','avgStars','env','goalsDone'],
|
||||||
|
logistik: ['levelId','balance','ordersDelivered','ordersLate','progressPct'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Kurzbegründung, warum ein Grau-Key nur live relevant ist.
|
||||||
|
var GREY_REASON = {
|
||||||
|
market_de:'aktueller Handelsmarkt — wechselt ständig',
|
||||||
|
season:'momentane Jahreszeit im Spiel',
|
||||||
|
visitors:'Gäste im aktuellen Moment',
|
||||||
|
guestsToday:'Gäste nur am heutigen Sim-Tag',
|
||||||
|
bottleneck:'aktueller Engpass — ändert sich laufend',
|
||||||
|
speed:'Simulationstempo (Bedien-Einstellung, keine Leistung)',
|
||||||
|
health:'Live-Ampel des Moments — das Ergebnis steckt in Score/Sternen',
|
||||||
|
screen:'was gerade offen ist (Aufgabe/Erkunden)',
|
||||||
|
taskTitle:'aktuell offene Aufgabe',
|
||||||
|
simDate:'gerade beobachtetes Datum',
|
||||||
|
block:'aktueller Zeitblock im Tag',
|
||||||
|
upperLake:'momentaner Speicherstand (oberer See)',
|
||||||
|
lowerLake:'momentaner Speicherstand (unterer See)',
|
||||||
|
dishName:'aktuell gekochtes Gericht',
|
||||||
|
ingSolved:'Zutaten im aktuellen Gericht (setzt sich je Gericht zurück)',
|
||||||
|
mistakesInDish:'Fehler nur im aktuellen Gericht (setzt sich zurück)',
|
||||||
|
__lage:'Live-Stimmung — Ergebnis steckt in Qualität/Gerichte',
|
||||||
|
lastCityTitle:'zuletzt angefahrenes Ziel',
|
||||||
|
lastMargin:'Marge des letzten Auftrags',
|
||||||
|
lastTzDiff:'Zeitzonendifferenz des letzten Ziels',
|
||||||
|
phase:'aktueller Spielabschnitt',
|
||||||
|
case_iso2:'aktuelles Zielland — bewusst verdeckt, nur live',
|
||||||
|
time_left_s:'Restzeit im laufenden Fall',
|
||||||
|
airport:'aktueller Standort',
|
||||||
|
ordersInFlight:'Aufträge gerade unterwegs',
|
||||||
|
ordersOpen:'offene Aufträge im Moment',
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
3b) Bewertung je Sim — wie Sterne UND Prozent zustandekommen.
|
||||||
|
Quelle: Sterne-Audit der Game-Files (Formeln/Schwellen 1:1).
|
||||||
|
============================================================ */
|
||||||
|
var SCORING = {
|
||||||
|
farmer: {
|
||||||
|
stars:'<b>0–5</b> aus <code>totalScore</code> (0–100, gewichtet: Konto 25% · Trefferquote 20% · CO₂ 15% · Krankheiten 10% · Sorten 10% · Länder 10% · Klimazonen 10%). Schwellen: <code>≥85→5 · ≥70→4 · ≥50→3 · ≥30→2 · sonst 1</code>.',
|
||||||
|
pct:'Kein 0–100-Live-Score → Zeilen-% = <code>Sterne/5×100</code> (im Ergebnis liegt zusätzlich <code>data.score</code>).',
|
||||||
|
},
|
||||||
|
klima: {
|
||||||
|
stars:'<b>0–5</b>: Basis <code>2★</code> bei Sieg, dann <code>+1</code> je Temp<17° · Temp<16,3° · Budget>200 · (Flut<20% & Bev.>90% Start). Deckel: Temp≥17°→max 3★, ≥18,5°→max 1★.',
|
||||||
|
pct:'Eigener 0–100-Score = <code>TempScore(0–35)+FlutScore(0–25)+BudgetScore(0–20)+BevScore(0–20)</code>. Live-Score identisch → Zeilen-% = dieser Score.',
|
||||||
|
},
|
||||||
|
fluss: {
|
||||||
|
stars:'<b>0–5</b> aus Score: <code>≥85→5 · ≥70→4 · ≥55→3 · ≥35→2 · sonst 1</code>.',
|
||||||
|
pct:'Score 0–100 = <code>Ø(Biodiv, Wirtschaft, Hochwasserschutz) − 5×Siedlungsverluste</code>. Live-Score vorhanden → Zeilen-% = Score.',
|
||||||
|
},
|
||||||
|
energiemanager: {
|
||||||
|
stars:'<b>0–5</b> aus <code>Quote = ausgeglichene Blöcke / gesamt</code>: <code>≥1,0→5 · ≥0,87→4 · ≥0,66→3 · ≥0,50→2 · sonst 1</code>. Blackout-Deckel: 1×→max 3★, ≥2×→max 2★.',
|
||||||
|
pct:'Kein 0–100-Live-Score → Zeilen-% = <code>Sterne/5×100</code>.',
|
||||||
|
},
|
||||||
|
'eu-werkstatt': {
|
||||||
|
stars:'<b>0–5</b> aus <code>firstTryRate = saubere Erstversuche / Schritte</code>: <code>≥1,0→5 · ≥0,85→4 · ≥0,65→3 · ≥0,40→2 · sonst 1</code>.',
|
||||||
|
pct:'Kein 0–100-Live-Score → Zeilen-% = <code>Sterne/5×100</code>.',
|
||||||
|
},
|
||||||
|
weltkueche: {
|
||||||
|
stars:'<b>0–5 pro Gericht</b> aus Fehlern: <code>0→5 · ≤1→4 · ≤3→3 · ≤6→2 · sonst 1</code>.',
|
||||||
|
pct:'Live-<code>score</code> ist eine <b>rohe Punktezahl</b> (nicht 0–100) → Zeilen-% aktuell unklar.',
|
||||||
|
flags:['Wird <b>pro Gericht</b> gesendet, nicht pro Durchgang.','„Fehler in %" (Fehler ÷ Aktionen) gehört genau hierher — vergleichbar statt roher, gericht-abhängiger Zahl.'],
|
||||||
|
},
|
||||||
|
busfahrt: {
|
||||||
|
stars:'<b>nur 0–3</b> aus <code>Ratio = Topo-Punkte / (Aufträge×3)</code>: <code>≥0,85→3 · ≥0,55→2 · ≥0,30→1 · sonst 0</code>.',
|
||||||
|
pct:'Gesendeter <code>score</code> = rohe Topo-Punkte (nicht 0–100).',
|
||||||
|
flags:['0–3 statt 0–5 Sterne.','score ist keine 0–100-Zahl → Zeilen-% verzerrt.'],
|
||||||
|
},
|
||||||
|
fluggesellschaft: {
|
||||||
|
stars:'<b>nur 0–3</b> aus <code>Ratio = Topo-Punkte / (Flüge×3)</code>: <code>≥0,85→3 · ≥0,55→2 · ≥0,30→1 · sonst 0</code>.',
|
||||||
|
pct:'Gesendeter <code>score</code> = rohe Topo-Punkte (nicht 0–100).',
|
||||||
|
flags:['0–3 statt 0–5 Sterne.','score ist keine 0–100-Zahl → Zeilen-% verzerrt.'],
|
||||||
|
},
|
||||||
|
heli: {
|
||||||
|
stars:'<b>0–5</b> = <code>round((PlanSterne + Start + Landung) / 3)</code>. PlanSterne aus Timing (Ø≥1,6→5 …) bzw. Klickgenauigkeit; Chefpilot-Übernahme → 0.',
|
||||||
|
pct:'Live-Score 0–100 = <code>Sterne/5×100</code> (submit <code>score = Sterne×20</code>) → Zeilen-% = das.',
|
||||||
|
},
|
||||||
|
kofferdetektiv: {
|
||||||
|
stars:'<b>nur 0–3</b> aus <code>q = score100/100</code>: <code>≥0,7→3 · ≥0,45→2 · ≥0,2→1 · sonst 0</code>.',
|
||||||
|
pct:'<code>score100 = Punkte / (Fälle×100) × 100</code> (je Fall 100 P, −7/Hinweis, −15/Fehltipp, −20/Timeout). Live-Score = score100 → Zeilen-% = das.',
|
||||||
|
flags:['0–3 statt 0–5 Sterne.'],
|
||||||
|
},
|
||||||
|
sonnensystem: {
|
||||||
|
stars:'<b>1–5</b> aus <code>Quote = Erstversuch-richtig / gesamt</code>: <code>≥0,85→5 · ≥0,68→4 · ≥0,50→3 · ≥0,30→2 · sonst 1</code> (Minimum 1, nie 0).',
|
||||||
|
pct:'Kein 0–100-Live-Score (nur <code>levelWon</code>+Fortschritt; <code>scoreQuote</code> im data) → Zeilen-% = <code>Sterne/5×100</code>.',
|
||||||
|
},
|
||||||
|
tourismustal: {
|
||||||
|
stars:'<b>0–5</b> = <code>min(5, erreichte Ziele + (Sieg?1:0))</code>.',
|
||||||
|
pct:'Score 0–100 = <code>erreichte Ziele / gesamt × 100</code>; kein Live-0–100 → Zeilen-% = data.score bzw. Sterne/5×100.',
|
||||||
|
},
|
||||||
|
tourismusregion: {
|
||||||
|
stars:'<b>0–5</b> = <code>round(Ø Besucher-Bewertung)</code> (avgStars 0–5).',
|
||||||
|
pct:'Score 0–100 = gewichtet: <code>Bewertung 35% · Wertschöpfung 25% · Nachhaltigkeit 20% · Lenkung 10% · Finanzen 10%</code>. Live <code>avgStars</code> → Zeilen-% = Score.',
|
||||||
|
},
|
||||||
|
logistik: {
|
||||||
|
stars:'<b>0–5</b> aus Endkontostand: <code>≤0→0 · ≥16000→5 · ≥13000→4 · ≥10500→3 · ≥8500→2 · sonst 1</code>.',
|
||||||
|
pct:'Kein Score/Stern wird ans Backend gesendet.',
|
||||||
|
flags:['Sterne & Score werden <b>NICHT übertragen</b> — im Lehrer-Cockpit erscheint für Logistik kein Stern/Prozent. Eigene Endpoints <code>/api/assessment</code> + <code>/api/logistik-sessions</code>.'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
3c) VORSCHLAG — einheitliches Leistungs-% (0–100) je Sim,
|
||||||
|
5-stufige Farbskala, %+Balken-Komponente, Triage (%×Fleiß).
|
||||||
|
============================================================ */
|
||||||
|
var PROPOSED_PCT = {
|
||||||
|
farmer:'gewichteter 0–100-Score (wie heute intern berechnet) — künftig auch LIVE senden.',
|
||||||
|
klima:'vorhandener 0–100-Score (Temp+Flut+Budget+Bev.) übernehmen.',
|
||||||
|
fluss:'vorhandener Score Ø(Biodiv,Wirtschaft,Schutz) − 5×Verluste übernehmen.',
|
||||||
|
energiemanager:'ausgeglichene Blöcke / (Tage×8) ×100 − 15 je Blackout.',
|
||||||
|
'eu-werkstatt':'saubere Erstversuche / Schritte ×100 − 5 je Mülleimer.',
|
||||||
|
weltkueche:'richtige / (richtige+falsche) ×100 (= 100 − Fehlerquote).',
|
||||||
|
busfahrt:'Topo-Punkte / (Aufträge×3) ×100.',
|
||||||
|
fluggesellschaft:'Topo-Punkte / (Flüge×3) ×100.',
|
||||||
|
heli:'kontinuierlich aus PlanPct + Start + Landung (0–100), nicht gerundete Sterne.',
|
||||||
|
kofferdetektiv:'vorhandener score100 (0–100) übernehmen.',
|
||||||
|
sonnensystem:'Erstversuch-Quote ×100.',
|
||||||
|
tourismustal:'Ziele/gesamt ×100, gewichtet mit Zufriedenheit/Natur.',
|
||||||
|
tourismusregion:'vorhandener gewichteter 0–100-Score übernehmen.',
|
||||||
|
logistik:'Kontostand→% (0 €→0, Zielbudget→100) — UND ans Backend senden.',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 5-stufige Farbskala (= _tagTraffic).
|
||||||
|
// ≥90 Dunkelgrün · ≥80 Hellgrün · ≥60 Gelb · ≥50 Orange · <50 Rot
|
||||||
|
function pctColor(v){ v=Math.max(0,Math.min(100,v||0)); return v>=90?'#1f7a3d':v>=80?'#7cb342':v>=60?'#f9c33b':v>=50?'#ef8b2c':'#d8453a'; }
|
||||||
|
// Standard-Anzeige überall: Prozent in Ampelfarbe + schmaler Balken darunter (kompakt).
|
||||||
|
function pctBar(pct){
|
||||||
|
if (pct==null) return '<span style="color:#9a9a9a">—</span>';
|
||||||
|
var c=pctColor(pct);
|
||||||
|
return '<div style="width:40px">'
|
||||||
|
+ '<div style="font-weight:800;color:'+c+';font-size:.78rem;font-variant-numeric:tabular-nums;line-height:1">'+pct+'<span style="font-size:.6rem"> %</span></div>'
|
||||||
|
+ '<div style="margin-top:2px;height:4px;border-radius:99px;background:rgba(0,0,0,.07);overflow:hidden"><div style="height:100%;width:'+pct+'%;background:'+c+'"></div></div>'
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
// Triage aus Leistungs-% × Aktivität — Grenzen = Farbgrenzen, damit Feld ↔ Farbe stimmen.
|
||||||
|
// <50 Rot→Fördern · 50–79 Orange+Gelb→Begleiten · 80–89 Hellgrün→Läuft gut · ≥90 Dunkelgrün→Fordern.
|
||||||
|
function triage(pct, active){
|
||||||
|
if (pct==null || !active) return {lab:'inaktiv', col:'#8a8a8a', bg:'#efeee8'};
|
||||||
|
if (pct<50) return {lab:'Fördern', col:'#b04a3e', bg:'#f7e2dc'};
|
||||||
|
if (pct<80) return {lab:'Begleiten', col:'#9a6a12', bg:'#f7edd6'};
|
||||||
|
if (pct<90) return {lab:'Läuft gut', col:'#3a6b3e', bg:'#dceadd'};
|
||||||
|
return {lab:'Fordern', col:'#155e2a', bg:'#c8e6cd'};
|
||||||
|
}
|
||||||
|
function triageChip(pct, active){ var t=triage(pct,active); return '<span style="display:inline-block;font-size:.62rem;font-weight:700;color:'+t.col+';background:'+t.bg+';padding:1px 7px;border-radius:10px">'+t.lab+'</span>'; }
|
||||||
|
|
||||||
|
// Vorschlags-Leistungs-% auf die Testdaten gerechnet (Vorschau).
|
||||||
|
function _cl(v,a,b){ return Math.max(a,Math.min(b,v)); }
|
||||||
|
var PERF = {
|
||||||
|
klima:function(s){ var sp=(s.level>=3)?7000:6000; return Math.round(_cl(35-(s.temp-15.5)*20,0,35)+Math.max(0,25*(1-(s.floodedPct||0)/50))+_cl((s.budget||0)/10,0,20)+_cl((s.population||0)/sp*20,0,20)); },
|
||||||
|
fluss:function(s){ return _cl(Math.round((s.biodiv+s.economy+s.flood)/3)-(s.settlementsLost||0)*5,0,100); },
|
||||||
|
kofferdetektiv:function(s){ return s.score!=null?s.score:null; },
|
||||||
|
tourismusregion:function(s){ return Math.round(0.6*((s.avgStars||0)/5*100)+0.4*(s.env||0)); },
|
||||||
|
tourismustal:function(s){ if(!s.goalsTotal) return null; return Math.round(0.5*(s.goalsDone/s.goalsTotal*100)+0.3*(s.satisfaction||0)+0.2*(s.nature||0)); },
|
||||||
|
heli:function(s){ if(s.totalStars!=null) return Math.round(s.totalStars/5*100); if(s.planTotal) return Math.round((s.planCorrect||0)/s.planTotal*100); return null; },
|
||||||
|
farmer:function(s){ var money=_cl(50+(s.cash||0)/300,0,100),co2=_cl(100-(s.co2_kg||0)/80,0,100),dis=_cl(100-(s.diseases||0)*20,0,100),crop=_cl((s.crops_used||0)/10*100,0,100),country=_cl((s.countries||0)/6*100,0,100); return Math.round(0.25*money+0.20*(s.hit_rate||0)+0.15*co2+0.10*dis+0.10*crop+0.10*country+0.10*70); },
|
||||||
|
energiemanager:function(s){ var d=(s.day||1)*(s.blockTotal||8); return _cl(Math.round((s.setBlocksBalanced||0)/d*100)-15*(s.blackouts||0),0,100); },
|
||||||
|
'eu-werkstatt':function(s){ if(!s.stepsTotal) return null; return _cl(Math.round((s.firstTryHits||0)/s.stepsTotal*100)-5*(s.muelleimerCount||0),0,100); },
|
||||||
|
weltkueche:function(s){ var t=(s.correctClicks||0)+(s.wrongClicks||0); return t?Math.round((s.correctClicks||0)/t*100):null; },
|
||||||
|
busfahrt:function(s){ return s.orderIndex?Math.round((s.quizFirstTry||0)/s.orderIndex*100):null; },
|
||||||
|
fluggesellschaft:function(s){ return s.orderIndex?Math.round((s.quizFirstTry||0)/s.orderIndex*100):null; },
|
||||||
|
sonnensystem:function(s){ return s.taskTotal?Math.round((s.completed||0)/s.taskTotal*100):null; },
|
||||||
|
logistik:function(s){ return s.balance==null?null:_cl(Math.round(s.balance/16000*100),0,100); },
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
4) Testdaten je Sim (3 Schüler:innen: stark · schwach · mittel)
|
||||||
|
============================================================ */
|
||||||
|
var NOW = Date.now();
|
||||||
|
function R(name, emoji, state, o){
|
||||||
|
o = o || {};
|
||||||
|
return {
|
||||||
|
displayName:name, emoji:emoji, module_id:state.__mod, state:state,
|
||||||
|
started_at_ms: NOW - (o.sinceMin||10)*60000,
|
||||||
|
last_seen_ms: NOW - (o.seenSec!=null?o.seenSec:4)*1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// missionStartedAt in ms setzen (für die „Seit"-Zeile)
|
||||||
|
function withMission(state, missMin){ if (missMin!=null) state.missionStartedAt = NOW - missMin*60000; return state; }
|
||||||
|
|
||||||
|
var TEST = {
|
||||||
|
farmer: [
|
||||||
|
R('Anna','🦊', {__mod:'farmer', year:8, market_de:'Ägypten', cash:14200, hit_rate:78, crops_used:7, countries:5, fields:9, attempts:12, co2_kg:3400, diseases:1}, {sinceMin:24, seenSec:3}),
|
||||||
|
R('Ben','🐼', {__mod:'farmer', year:4, market_de:'Indien', cash:-800, hit_rate:22, crops_used:3, countries:2, fields:5, attempts:14, co2_kg:5200, diseases:3}, {sinceMin:16, seenSec:6}),
|
||||||
|
R('Clara','🦉',{__mod:'farmer', year:6, market_de:'Brasilien', cash:5100, hit_rate:54, crops_used:5, countries:4, fields:7, attempts:9, co2_kg:4100, diseases:2}, {sinceMin:19, seenSec:140}),
|
||||||
|
],
|
||||||
|
klima: [
|
||||||
|
R('Anna','🦊', {__mod:'klima', level:2, year:2068, co2:520, temp:16.8, floodedPct:18, budget:1200, population:6400, measures:11, speed:2}, {sinceMin:14}),
|
||||||
|
R('Ben','🐼', {__mod:'klima', level:1, year:2041, co2:610, temp:17.9, floodedPct:47, budget:-220, population:5200, measures:5, speed:1}, {sinceMin:9, seenSec:5}),
|
||||||
|
R('Clara','🦉',{__mod:'klima', level:3, year:2085, co2:430, temp:15.9, floodedPct:31, budget:2600, population:8100, measures:16, speed:3}, {sinceMin:22, seenSec:130}),
|
||||||
|
],
|
||||||
|
fluss: [
|
||||||
|
R('Anna','🦊', {__mod:'fluss', health:'good', levelId:'L2', year:64, progressPct:64, score:78, budget:32, biodiv:72, economy:66, flood:81, settlementsLost:0}, {sinceMin:17}),
|
||||||
|
R('Ben','🐼', {__mod:'fluss', health:'struggling', levelId:'L1', year:20, progressPct:20, score:34, budget:-4, biodiv:41, economy:38, flood:52, settlementsLost:3}, {sinceMin:8, seenSec:7}),
|
||||||
|
R('Clara','🦉',{__mod:'fluss', health:'ok', levelId:'L2', year:100, progressPct:100, score:88, budget:18, biodiv:80, economy:74, flood:90, settlementsLost:1}, {sinceMin:26, seenSec:150}),
|
||||||
|
],
|
||||||
|
energiemanager: [
|
||||||
|
R('Anna','🦊', {__mod:'energiemanager', health:'good', setTitle:'Frühling', setIcon:'🌱', setIndex:1, setTotal:6, day:3, dayTotal:3, phase:'done', block:7, blockTotal:8, blackouts:0, balanced:8, setBlocksBalanced:21, upperLake:32, lowerLake:28}, {sinceMin:15}),
|
||||||
|
R('Ben','🐼', {__mod:'energiemanager', health:'struggling', setTitle:'Sommer', setIcon:'🌞', setIndex:2, setTotal:6, day:2, dayTotal:3, phase:'blackout', block:4, blockTotal:8, blackouts:2, balanced:0, setBlocksBalanced:9, upperLake:5, lowerLake:55}, {sinceMin:11, seenSec:6}),
|
||||||
|
R('Clara','🦉',{__mod:'energiemanager', health:'ok', setTitle:'Herbst', setIcon:'🍂', setIndex:3, setTotal:6, day:1, dayTotal:3, phase:'done', block:5, blockTotal:8, blackouts:1, balanced:6, setBlocksBalanced:6, upperLake:40, lowerLake:20}, {sinceMin:20, seenSec:135}),
|
||||||
|
],
|
||||||
|
'eu-werkstatt': [
|
||||||
|
R('Anna','🦊', {__mod:'eu-werkstatt', health:'good', scenarioTitle:'Käfige abschaffen', scenarioIcon:'🐔', step:6, stepsTotal:6, difficulty:'easy', firstTryHits:6, muelleimerCount:0, reshuffles:0, secondsElapsed:214, achievementsUnlocked:4, achievementsTotal:6}, {sinceMin:6}),
|
||||||
|
R('Ben','🐼', {__mod:'eu-werkstatt', health:'struggling', scenarioTitle:'Käfige abschaffen', scenarioIcon:'🐔', step:2, stepsTotal:6, difficulty:'easy', firstTryHits:1, muelleimerCount:3, reshuffles:2, secondsElapsed:150, achievementsUnlocked:1, achievementsTotal:6, attemptsThisStep:6}, {sinceMin:5, seenSec:6}),
|
||||||
|
R('Clara','🦉',{__mod:'eu-werkstatt', health:'ok', scenarioTitle:'Ein Kabel für alle', scenarioIcon:'🔌', step:3, stepsTotal:5, difficulty:'expert', firstTryHits:2, muelleimerCount:1, reshuffles:6, secondsElapsed:330, achievementsUnlocked:2, achievementsTotal:6}, {sinceMin:9, seenSec:150}),
|
||||||
|
],
|
||||||
|
weltkueche: [
|
||||||
|
R('Anna','🦊', {__mod:'weltkueche', dishName:'Tiroler Gröstl', completed:4, completedTotal:6, ingSolved:5, ingTotal:5, correctClicks:38, wrongClicks:4, mistakesInDish:1}, {sinceMin:13}),
|
||||||
|
R('Ben','🐼', {__mod:'weltkueche', dishName:'Moussaka', completed:1, completedTotal:6, ingSolved:2, ingTotal:6, correctClicks:12, wrongClicks:9, mistakesInDish:7}, {sinceMin:7, seenSec:5}),
|
||||||
|
R('Clara','🦉',{__mod:'weltkueche', dishName:'Sushi', completed:2, completedTotal:6, ingSolved:6, ingTotal:6, correctClicks:22, wrongClicks:14, mistakesInDish:12}, {sinceMin:10, seenSec:145}),
|
||||||
|
],
|
||||||
|
busfahrt: [
|
||||||
|
R('Anna','🦊', {__mod:'busfahrt', levelId:'L2', orderIndex:8, totalOrders:12, budget:9200, wonOrders:8, lastCityTitle:'Salzburg', lastMargin:420, quizFirstTry:6}, {sinceMin:18}),
|
||||||
|
R('Ben','🐼', {__mod:'busfahrt', levelId:'L1', orderIndex:3, totalOrders:10, budget:-300, wonOrders:2, lastCityTitle:'Graz', lastMargin:-120, quizFirstTry:1}, {sinceMin:9, seenSec:6}),
|
||||||
|
R('Clara','🦉',{__mod:'busfahrt', levelId:'L2', orderIndex:5, totalOrders:12, budget:5400, wonOrders:5, lastCityTitle:'Wien', lastMargin:260, quizFirstTry:4}, {sinceMin:14, seenSec:150}),
|
||||||
|
],
|
||||||
|
fluggesellschaft: [
|
||||||
|
R('Anna','🦊', {__mod:'fluggesellschaft', levelId:'L4', orderIndex:6, totalOrders:9, budget:14200, wonOrders:6, co2Kg:8400, lastCityTitle:'Tokio', lastTzDiff:8, quizFirstTry:5}, {sinceMin:21}),
|
||||||
|
R('Ben','🐼', {__mod:'fluggesellschaft', levelId:'L4', orderIndex:2, totalOrders:9, budget:-500, wonOrders:1, co2Kg:3200, lastCityTitle:'Kairo', lastTzDiff:1, quizFirstTry:1}, {sinceMin:8, seenSec:5}),
|
||||||
|
R('Clara','🦉',{__mod:'fluggesellschaft', levelId:'L4', orderIndex:4, totalOrders:9, budget:7600, wonOrders:4, co2Kg:5600, lastCityTitle:'New York', lastTzDiff:-6, quizFirstTry:3}, {sinceMin:12, seenSec:150}),
|
||||||
|
],
|
||||||
|
heli: [
|
||||||
|
R('Anna','🦊', withMission({__mod:'heli', phase:'results', missionId:'M19', missionTitle:'Seerettung', planIndex:6, planTotal:6, planCorrect:6, planAttempts:1, startScore:5, landingScore:4, totalStars:5, failed:false}, 5), {sinceMin:12}),
|
||||||
|
R('Ben','🐼', withMission({__mod:'heli', phase:'flight', missionId:'M7', missionTitle:'Kufstein', planIndex:3, planTotal:5, planCorrect:2, planAttempts:11, startScore:3, landingScore:null, totalStars:null, failed:false}, 3), {sinceMin:8, seenSec:6}),
|
||||||
|
R('Clara','🦉',withMission({__mod:'heli', phase:'mission', missionId:'M5', missionTitle:'Bregenz', planIndex:0, planTotal:5, planCorrect:0, planAttempts:0, startScore:null, landingScore:null, totalStars:null, failed:false, lastResult:true, lastFailed:false}, 1), {sinceMin:14, seenSec:150}),
|
||||||
|
],
|
||||||
|
kofferdetektiv: [
|
||||||
|
R('Anna','🦊', {__mod:'kofferdetektiv', health:'good', phase:'suche', progressPct:60, score:82, cases_solved:6, cases_total:10, case_iso2:'IT', hints_revealed:1, wrong_guesses_total:2, time_left_s:180, airport:'Rom-Fiumicino'}, {sinceMin:16}),
|
||||||
|
R('Ben','🐼', {__mod:'kofferdetektiv', health:'ok', phase:'suche', progressPct:30, score:55, cases_solved:3, cases_total:10, case_iso2:'BR', hints_revealed:4, wrong_guesses_total:7, time_left_s:45, airport:'São Paulo-Guarulhos'}, {sinceMin:11, seenSec:6}),
|
||||||
|
R('Clara','🦉',{__mod:'kofferdetektiv', health:'struggling', phase:'briefing', progressPct:10, score:28, cases_solved:1, cases_total:10, case_iso2:'JP', hints_revealed:6, wrong_guesses_total:9, time_left_s:20, airport:'Tokio-Narita'}, {sinceMin:6, seenSec:150}),
|
||||||
|
],
|
||||||
|
sonnensystem: [
|
||||||
|
R('Anna','🦊', {__mod:'sonnensystem', health:'good', screen:'play', taskTitle:'Mondphasen', taskIndex:8, taskTotal:20, completed:8, progressPct:40, simDate:'14.03.', achievementsUnlocked:3, achievementsTotal:8}, {sinceMin:13}),
|
||||||
|
R('Ben','🐼', {__mod:'sonnensystem', health:'ok', screen:'explore', taskTitle:'', taskIndex:0, taskTotal:20, completed:14, progressPct:70, simDate:'22.09.', achievementsUnlocked:5, achievementsTotal:8}, {sinceMin:19, seenSec:5}),
|
||||||
|
R('Clara','🦉',{__mod:'sonnensystem', health:'struggling', screen:'play', taskTitle:'Sonnenfinsternis', taskIndex:3, taskTotal:20, completed:2, progressPct:10, simDate:'02.07.', achievementsUnlocked:1, achievementsTotal:8}, {sinceMin:7, seenSec:150}),
|
||||||
|
],
|
||||||
|
tourismustal: [
|
||||||
|
R('Anna','🦊', {__mod:'tourismustal', scene:'boom', season:'Sommer', year:2004, money:8200, guestsToday:180, satisfaction:74, nature:52, goalsDone:2, goalsTotal:3, buildings:6, bottleneck:''}, {sinceMin:20}),
|
||||||
|
R('Ben','🐼', {__mod:'tourismustal', scene:'klimawandel', season:'Winter', year:2037, money:-600, guestsToday:60, satisfaction:38, nature:41, goalsDone:1, goalsTotal:3, buildings:9, bottleneck:'Schnee'}, {sinceMin:10, seenSec:6}),
|
||||||
|
R('Clara','🦉',{__mod:'tourismustal', scene:'pionierzeit', season:'Sommer', year:1978, money:4300, guestsToday:120, satisfaction:63, nature:78, goalsDone:1, goalsTotal:3, buildings:3, bottleneck:''}, {sinceMin:15, seenSec:150}),
|
||||||
|
],
|
||||||
|
tourismusregion: [
|
||||||
|
R('Anna','🦊', {__mod:'tourismusregion', mapId:'talschleife', wave:8, waveTotal:8, budget:22000, revenue:180000, avgStars:3.8, env:72, visitors:540, goalsDone:3, goalsTotal:3}, {sinceMin:23}),
|
||||||
|
R('Ben','🐼', {__mod:'tourismusregion', mapId:'seerunde', wave:3, waveTotal:8, waveStatus:'pause', budget:-400, revenue:42000, avgStars:2.1, env:34, visitors:210, goalsDone:1, goalsTotal:3}, {sinceMin:9, seenSec:6}),
|
||||||
|
R('Clara','🦉',{__mod:'tourismusregion', mapId:'serpentinen', wave:5, waveTotal:8, budget:8600, revenue:96000, avgStars:2.9, env:58, visitors:330, goalsDone:2, goalsTotal:3}, {sinceMin:16, seenSec:150}),
|
||||||
|
],
|
||||||
|
logistik: [
|
||||||
|
R('Anna','🦊', {__mod:'logistik', levelId:'L1', balance:9200, ordersDelivered:8, totalOrders:12, ordersInFlight:2, ordersOpen:2, ordersLate:0, progressPct:66}, {sinceMin:18}),
|
||||||
|
R('Ben','🐼', {__mod:'logistik', levelId:'L2', balance:-300, ordersDelivered:3, totalOrders:14, ordersInFlight:3, ordersOpen:8, ordersLate:2, progressPct:21}, {sinceMin:9, seenSec:6}),
|
||||||
|
R('Clara','🦉',{__mod:'logistik', levelId:'L1', balance:5400, ordersDelivered:6, totalOrders:12, ordersInFlight:1, ordersOpen:5, ordersLate:1, progressPct:50}, {sinceMin:13, seenSec:150}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
5) Rendern — Tabelle wie renderLive(), plus Grün/Grau + Keys
|
||||||
|
============================================================ */
|
||||||
|
var _LIVE_PAUSE_SEC = 90;
|
||||||
|
function _liveIsActive(ls){ return ls.last_seen_ms && (NOW - ls.last_seen_ms) <= _LIVE_PAUSE_SEC*1000; }
|
||||||
|
|
||||||
|
function renderScoring(id){
|
||||||
|
var sc = SCORING[id];
|
||||||
|
if (!sc) return '';
|
||||||
|
var h = '<div class="scorebox"><h4>🎯 So entsteht die Bewertung</h4>';
|
||||||
|
h += '<div class="srow"><span class="lab">Sterne</span><span>'+sc.stars+'</span></div>';
|
||||||
|
h += '<div class="srow"><span class="lab">Prozent</span><span>'+sc.pct+'</span></div>';
|
||||||
|
if (PROPOSED_PCT[id]) h += '<div class="srow" style="background:#eef7f0;border-radius:6px;padding:.25rem .4rem;margin-top:.35rem"><span class="lab" style="color:#3a6b3e">Vorschlag %</span><span style="color:#2f5e39">'+PROPOSED_PCT[id]+'</span></div>';
|
||||||
|
(sc.flags||[]).forEach(function(f){ h += '<div class="flag">⚠ '+f+'</div>'; });
|
||||||
|
h += '</div>';
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSim(meta){
|
||||||
|
var fields = LIVE_PRIMARY_FIELDS[meta.id] || [];
|
||||||
|
var rows = TEST[meta.id] || [];
|
||||||
|
var greenSet = {}; (GREEN[meta.id]||[]).forEach(function(k){ greenSet[k]=true; });
|
||||||
|
var greyKeys = fields.filter(function(f){ return !greenSet[f.k]; }).map(function(f){ return f.k; });
|
||||||
|
var greenCount = fields.length - greyKeys.length;
|
||||||
|
|
||||||
|
// Spalten-Maxima für relative Mini-Balken
|
||||||
|
var colMax = {};
|
||||||
|
fields.forEach(function(f){ var mx=0; rows.forEach(function(r){ var val=(r.state||{})[f.k]; if (typeof val==='number' && !isNaN(val)) mx=Math.max(mx,Math.abs(val)); }); colMax[f.k]=mx; });
|
||||||
|
|
||||||
|
var h = '<div class="sim" id="sim-'+meta.id+'">';
|
||||||
|
h += '<div class="sim-head"><span class="emoji">'+meta.icon+'</span><h3>'+esc(meta.title)+'</h3>'
|
||||||
|
+ '<span class="sid">'+meta.id+'</span>'
|
||||||
|
+ '<span class="count">'+greenCount+' grün · '+greyKeys.length+' nur live</span></div>';
|
||||||
|
|
||||||
|
h += '<div class="tbl-scroll"><table class="live"><thead><tr>';
|
||||||
|
h += '<th style="text-align:left">Schüler*in<span class="key">displayName</span></th>';
|
||||||
|
h += '<th style="text-align:left;color:#8a8a8a">Seit<span class="key">missionStartedAt / started_at</span></th>';
|
||||||
|
h += '<th style="text-align:left;background:#e7f4ea;border-bottom-color:#bcd9c2">🎯 Leistung<span class="key">leistung_pct (Vorschlag)</span><span class="tag g">EIN Wert</span></th>';
|
||||||
|
fields.forEach(function(f){
|
||||||
|
var align=f.align||'right';
|
||||||
|
var isG=!!greenSet[f.k];
|
||||||
|
h += '<th class="'+(isG?'green':'grey')+'" style="text-align:'+align+'">'
|
||||||
|
+ esc(f.label)+(f.unit?' <span style="color:#8a8a8a;font-weight:400;font-size:.66rem">'+f.unit+'</span>':'')
|
||||||
|
+ '<span class="key">'+esc(f.k)+'</span>'
|
||||||
|
+ '<span class="tag '+(isG?'g':'l')+'">'+(isG?'nach Abschluss':'nur live')+'</span>'
|
||||||
|
+ '</th>';
|
||||||
|
});
|
||||||
|
h += '<th style="width:1.2em"></th></tr></thead><tbody>';
|
||||||
|
|
||||||
|
rows.forEach(function(ls){
|
||||||
|
var s = ls.state||{};
|
||||||
|
var avatarHtml = '<span style="font-size:1.1rem">'+(ls.emoji||'🧑🎓')+'</span>';
|
||||||
|
var sinceSimBase = ls.started_at_ms||0;
|
||||||
|
var sinceMissionBase = (s.missionStartedAt)||(s.levelStartedAt)||(s.scenarioStartedAt)||0;
|
||||||
|
var sinceSim = sinceSimBase ? Math.max(0,Math.round((NOW-sinceSimBase)/1000)) : 0;
|
||||||
|
var sinceMission = sinceMissionBase ? Math.max(0,Math.round((NOW-sinceMissionBase)/1000)) : 0;
|
||||||
|
var simStr = sinceSimBase ? _fmtSince(sinceSim) : '—';
|
||||||
|
var missionStr = sinceMissionBase ? _fmtSince(sinceMission) : '—';
|
||||||
|
var lastSeen = ls.last_seen_ms ? Math.max(0,Math.round((NOW-ls.last_seen_ms)/1000)) : 0;
|
||||||
|
var paused = !_liveIsActive(ls) || !!(s.__idlePaused||s.__paused);
|
||||||
|
var dotColor = lastSeen<8?'#5a8a5e':lastSeen<20?'#e8833a':'#c07a6b';
|
||||||
|
var help = !paused && _liveNeedsHelp(ls.module_id, s);
|
||||||
|
var restBg = help ? '#fdf3ee' : (paused ? '#f6f5f2' : '');
|
||||||
|
var accent = help ? 'inset 3px 0 0 #c0574a' : (paused ? 'inset 3px 0 0 #cfcabb' : 'none');
|
||||||
|
|
||||||
|
h += '<tr style="background:'+restBg+';'+(paused?'color:#9a9a9a;':'')+'box-shadow:'+accent+'">';
|
||||||
|
h += '<td style="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">⏸</span>':''))+avatarHtml+'<strong>'+esc(ls.displayName)+'</strong></span></td>';
|
||||||
|
h += '<td style="white-space:nowrap;line-height:1.15"><div style="font-size:.78rem;color:#3a3a3a;font-weight:600">'+missionStr+'</div><div style="font-size:.62rem;color:#8a8a8a">'+simStr+' ges.</div></td>';
|
||||||
|
var perfPct = (PERF[meta.id]) ? PERF[meta.id](s) : null;
|
||||||
|
h += '<td style="background:#f2faf4;vertical-align:top">'+pctBar(perfPct)+'<div style="margin-top:3px">'+triageChip(perfPct, perfPct!=null)+'</div></td>';
|
||||||
|
fields.forEach(function(f){
|
||||||
|
var align=f.align||'right';
|
||||||
|
var isG=!!greenSet[f.k];
|
||||||
|
var cell=_liveFmt(s[f.k], f.unit, f, ls);
|
||||||
|
if (f.bar){
|
||||||
|
var raw=s[f.k];
|
||||||
|
if (typeof raw==='number' && !isNaN(raw)){
|
||||||
|
var pct=0, bcol;
|
||||||
|
if (f.bar.kind==='abs'){ pct=(raw/(f.bar.max||100))*100; bcol=_liveBarColor(pct,f.bar.good); }
|
||||||
|
else { var mx=colMax[f.k]||0; pct=mx>0?(Math.abs(raw)/mx)*100:0; bcol=(f.bar.good==='signed')?(raw<0?'#c0392b':'#5a8a5e'):_liveBarColor(pct,f.bar.good); }
|
||||||
|
cell='<div style="line-height:1.1">'+cell+'</div>'+_liveMiniBar(pct,bcol);
|
||||||
|
}
|
||||||
|
} else if (typeof s[f.k]==='number' && !isNaN(s[f.k]) && (colMax[f.k]||0)>0 && cell.indexOf('border-radius:99px')<0){
|
||||||
|
var apct=(Math.abs(s[f.k])/colMax[f.k])*100;
|
||||||
|
cell='<div style="line-height:1.1">'+cell+'</div>'+_liveMiniBar(apct,'#9aa7b0');
|
||||||
|
}
|
||||||
|
h += '<td class="'+(isG?'green':'')+'" style="text-align:'+align+'">'+cell+'</td>';
|
||||||
|
});
|
||||||
|
h += '<td style="text-align:center">'+(paused
|
||||||
|
? '<span style="font-size:.6rem;color:#9a9a9a;white-space:nowrap">⏸ '+_fmtSince(lastSeen)+'</span>'
|
||||||
|
: '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:'+dotColor+'"></span>')+'</td>';
|
||||||
|
h += '</tr>';
|
||||||
|
});
|
||||||
|
h += '</tbody></table></div>';
|
||||||
|
|
||||||
|
// Bewertung: wie Sterne + Prozent entstehen
|
||||||
|
h += renderScoring(meta.id);
|
||||||
|
|
||||||
|
// Nach-Abschluss-Hinweis
|
||||||
|
if (greyKeys.length){
|
||||||
|
h += '<div class="note"><b>Nach Abschluss NICHT relevant (nur live):</b><ul>';
|
||||||
|
greyKeys.forEach(function(k){
|
||||||
|
var lbl=(fields.filter(function(f){return f.k===k;})[0]||{}).label||k;
|
||||||
|
h += '<li><code>'+esc(k)+'</code> · '+esc(lbl)+' — '+esc(GREY_REASON[k]||'momentaner Zustand, ändert sich laufend')+'</li>';
|
||||||
|
});
|
||||||
|
h += '</ul></div>';
|
||||||
|
} else {
|
||||||
|
h += '<div class="allgreen">✓ Alle Spalten dieser Sim bleiben auch nach Abschluss relevant.</div>';
|
||||||
|
}
|
||||||
|
h += '</div>';
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vorschau-Karte: neues Bewertungssystem (Skala · %+Balken · Triage)
|
||||||
|
function renderPreviewCard(){
|
||||||
|
// EINE Skala. Jede Zeile: Farbe · Bereich · Label · Beispiel-Balken.
|
||||||
|
var scale=[
|
||||||
|
{c:'#1f7a3d', r:'≥ 90 %', l:'sehr gut', v:90},
|
||||||
|
{c:'#7cb342', r:'80–89 %', l:'gut', v:80},
|
||||||
|
{c:'#f9c33b', r:'60–79 %', l:'solide', v:60},
|
||||||
|
{c:'#ef8b2c', r:'50–59 %', l:'wackelig', v:50},
|
||||||
|
{c:'#d8453a', r:'< 50 %', l:'kritisch', v:40},
|
||||||
|
];
|
||||||
|
var h='<div class="gbox" style="border-left-color:#5a8a5e">';
|
||||||
|
h+='<h2>🎯 Bewertung — EINE Skala</h2>';
|
||||||
|
h+='<p><b>Standard überall im Programm:</b> ein Leistungs-% (0–100) in der Ampelfarbe + schmaler Balken. Sterne fallen weg. Es gibt nur <b>diese eine</b> Skala mit 5 Stufen:</p>';
|
||||||
|
h+='<div style="display:grid;grid-template-columns:24px auto 1fr 48px;gap:.4rem .9rem;align-items:center;max-width:460px;margin:.5rem 0 1rem;font-size:.82rem">';
|
||||||
|
scale.forEach(function(b){
|
||||||
|
h+='<span style="width:20px;height:20px;border-radius:5px;background:'+b.c+'"></span>';
|
||||||
|
h+='<b style="font-variant-numeric:tabular-nums">'+b.r+'</b>';
|
||||||
|
h+='<span style="color:#5a5a5a">'+b.l+'</span>';
|
||||||
|
h+='<span>'+pctBar(b.v)+'</span>';
|
||||||
|
});
|
||||||
|
h+='</div>';
|
||||||
|
h+='<p><b>Triage</b> — dieselbe Skala, zu Handlungs-Feldern zusammengefasst (Grenzen = Farbgrenzen 50 / 80 / 90):</p>';
|
||||||
|
h+='<div style="display:flex;gap:1.3rem;flex-wrap:wrap;align-items:center;margin:.3rem 0 .2rem;font-size:.78rem">'
|
||||||
|
+'<span>'+triageChip(30,true)+' <b>< 50 %</b> <span style="color:#8a8a8a">(rot)</span></span>'
|
||||||
|
+'<span>'+triageChip(65,true)+' <b>50–79 %</b> <span style="color:#8a8a8a">(orange + gelb)</span></span>'
|
||||||
|
+'<span>'+triageChip(85,true)+' <b>80–89 %</b> <span style="color:#8a8a8a">(hellgrün)</span></span>'
|
||||||
|
+'<span>'+triageChip(95,true)+' <b>≥ 90 %</b> <span style="color:#8a8a8a">(dunkelgrün)</span></span>'
|
||||||
|
+'<span>'+triageChip(null,false)+' <span style="color:#8a8a8a">nicht aktiv</span></span>'
|
||||||
|
+'</div>';
|
||||||
|
h+='<p style="font-size:.72rem;color:#8a8a8a;margin-top:.8rem">Unten hat jede Tabelle die Spalte <b>🎯 Leistung</b> — die Vorschlags-Formel je Sim, auf die Testdaten gerechnet. Bei Busfahrt · Fluggesellschaft · Sonnensystem ist der Wert eine Näherung (Topo-/Erstversuch-Rohwerte fehlen in den Testdaten).</p>';
|
||||||
|
h+='</div>';
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOC + Inhalt aufbauen
|
||||||
|
var toc = SIM_META.map(function(m){ return '<a href="#sim-'+m.id+'">'+m.icon+' '+esc(m.title)+'</a>'; }).join('');
|
||||||
|
document.getElementById('preview').innerHTML = renderPreviewCard();
|
||||||
|
document.getElementById('toc').innerHTML = toc;
|
||||||
|
document.getElementById('content').innerHTML = SIM_META.map(renderSim).join('');
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -35,6 +35,58 @@
|
|||||||
.toolbar{display:flex;gap:.4rem;margin-bottom:.8rem;flex-wrap:wrap}
|
.toolbar{display:flex;gap:.4rem;margin-bottom:.8rem;flex-wrap:wrap}
|
||||||
.msg{font-size:.72rem;padding:.4rem .6rem;border-radius:6px;margin-top:.5rem}
|
.msg{font-size:.72rem;padding:.4rem .6rem;border-radius:6px;margin-top:.5rem}
|
||||||
.msg.ok{background:#dceadd;color:#5a8a5e}
|
.msg.ok{background:#dceadd;color:#5a8a5e}
|
||||||
|
/* ---- Datenmodell-Sektion ---- */
|
||||||
|
.dm-lead{font-size:.74rem;color:#4a4a4a;line-height:1.55;margin-bottom:.6rem}
|
||||||
|
.dm-sub{font-size:.6rem;font-weight:800;color:#4a7c8a;text-transform:uppercase;letter-spacing:.05em;margin:1.2rem 0 .5rem}
|
||||||
|
.dm-note{font-size:.66rem;color:#8a6a2a;background:#fdf6e3;border:1px solid #efe0b8;border-radius:6px;padding:.45rem .6rem;margin-top:.5rem;line-height:1.5}
|
||||||
|
.dm-flow{display:flex;gap:.4rem;align-items:stretch;flex-wrap:wrap}
|
||||||
|
.dm-step{flex:1;min-width:190px;background:#f7f6f3;border-radius:8px;border-left:4px solid #c4a97a;padding:.65rem .75rem}
|
||||||
|
.dm-step.s-pool{border-left-color:#b8b2a6}
|
||||||
|
.dm-step.s-redeem{border-left-color:#4a7c8a}
|
||||||
|
.dm-step.s-assign{border-left-color:#5a8a5e}
|
||||||
|
.dm-step .s-top{display:flex;align-items:center;gap:.4rem;margin-bottom:.35rem}
|
||||||
|
.dm-step .s-n{width:18px;height:18px;border-radius:50%;background:#4a7c8a;color:#fff;font-size:.62rem;font-weight:800;display:flex;align-items:center;justify-content:center;flex:none}
|
||||||
|
.dm-step.s-pool .s-n{background:#b8b2a6}
|
||||||
|
.dm-step.s-assign .s-n{background:#5a8a5e}
|
||||||
|
.dm-step .s-t{font-size:.74rem;font-weight:800;color:#2f3b3f}
|
||||||
|
.dm-step .s-who{font-size:.6rem;font-weight:700;color:#8a8a8a;text-transform:uppercase;letter-spacing:.03em}
|
||||||
|
.dm-step .s-d{font-size:.68rem;color:#4a4a4a;line-height:1.45;margin-top:.15rem}
|
||||||
|
.dm-step code{font-family:'Courier New',monospace;font-size:.66rem;background:rgba(0,0,0,.05);padding:.05rem .3rem;border-radius:4px;color:#c85c4a}
|
||||||
|
.dm-arrow{display:flex;align-items:center;justify-content:center;color:#c4a97a;font-size:1.4rem;font-weight:900;padding:0 .1rem}
|
||||||
|
.dm-er{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap;font-size:.68rem}
|
||||||
|
.dm-ent{background:#fff;border:1.5px solid #4a7c8a;color:#4a7c8a;border-radius:6px;padding:.3rem .55rem;font-weight:800;font-family:'Courier New',monospace}
|
||||||
|
.dm-ent.e-lic{background:#4a7c8a;color:#fff}
|
||||||
|
.dm-rel{color:#8a8a8a;display:flex;flex-direction:column;align-items:center;line-height:1;font-family:'Courier New',monospace;font-size:.58rem}
|
||||||
|
.dm-rel b{color:#c4a97a;font-size:1.1rem}
|
||||||
|
.dm-derive{background:#f7f6f3;border-radius:8px;padding:.7rem .8rem;font-size:.7rem;line-height:1.7}
|
||||||
|
.dm-derive .d-code{font-family:'Courier New',monospace;font-weight:800;color:#4a7c8a;letter-spacing:.04em}
|
||||||
|
.dm-derive .d-hi{background:#e8c547;color:#4a4a4a;padding:.02rem .28rem;border-radius:4px;font-weight:800}
|
||||||
|
td.mono{font-family:'Courier New',monospace;font-size:.68rem;color:#c85c4a}
|
||||||
|
/* ---- Vertrieb / Ausgabe ---- */
|
||||||
|
.frm{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:.6rem}
|
||||||
|
.frm label{font-size:.6rem;font-weight:700;color:#6a6a6a;text-transform:uppercase;letter-spacing:.03em;display:block;margin-bottom:.2rem}
|
||||||
|
.frm input,.frm select,.frm textarea{width:100%;padding:.4rem .55rem;border:1.5px solid rgba(0,0,0,.12);border-radius:6px;font-size:.78rem;font-family:inherit;background:#fff}
|
||||||
|
.frm textarea{resize:vertical;line-height:1.5}
|
||||||
|
.hint-s{font-size:.62rem;color:#8a8a8a;margin-top:.2rem}
|
||||||
|
.badge{display:inline-block;padding:.1rem .45rem;border-radius:20px;font-size:.58rem;font-weight:800;letter-spacing:.02em;color:#fff}
|
||||||
|
.b-issued{background:#4a7c8a}.b-redeemed{background:#5a8a5e}.b-assigned{background:#3f7a45}
|
||||||
|
.b-canceled{background:#b0453a}.b-expired{background:#c98a2a}.b-free{background:#9a9a9a}
|
||||||
|
.kpi{display:inline-flex;flex-direction:column;align-items:center;min-width:52px}
|
||||||
|
.kpi b{font-size:.9rem;font-weight:900;color:#3a4a4f}.kpi span{font-size:.52rem;color:#8a8a8a;text-transform:uppercase}
|
||||||
|
.out{background:#f2f8f9;border:1px solid #cfe3e8;border-radius:8px;padding:.8rem;margin-top:.8rem}
|
||||||
|
.out h4{font-size:.66rem;color:#2f6373;margin-bottom:.4rem;text-transform:uppercase;letter-spacing:.04em}
|
||||||
|
.row-act{display:flex;gap:.35rem;flex-wrap:wrap;align-items:center}
|
||||||
|
.lnk{font-family:'Courier New',monospace;font-size:.66rem;background:#fff;border:1px solid rgba(0,0,0,.12);border-radius:6px;padding:.35rem .5rem;flex:1;min-width:180px;color:#3a4a4f}
|
||||||
|
.ovl{position:fixed;inset:0;background:rgba(20,30,35,.55);display:none;align-items:flex-start;justify-content:center;z-index:100;overflow-y:auto;padding:2rem 1rem}
|
||||||
|
.ovl.open{display:flex}
|
||||||
|
.modal{background:#fff;border-radius:12px;max-width:820px;width:100%;padding:1.1rem;box-shadow:0 12px 40px rgba(0,0,0,.25)}
|
||||||
|
.modal-h{display:flex;align-items:center;gap:.6rem;margin-bottom:.6rem}
|
||||||
|
.modal-h h3{font-size:.9rem;font-weight:800;color:#2f3b3f;flex:1}
|
||||||
|
.x{cursor:pointer;font-size:1.1rem;color:#8a8a8a;background:none;border:none;padding:.2rem .5rem}
|
||||||
|
.mini{font-size:.66rem;color:#8a8a8a}
|
||||||
|
.chip-copy{cursor:pointer}
|
||||||
|
details.batchdet{border:1px solid rgba(0,0,0,.06);border-radius:8px;margin-bottom:.4rem;padding:.2rem .5rem}
|
||||||
|
details.batchdet summary{cursor:pointer;font-size:.72rem;font-weight:600;padding:.3rem 0}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -45,6 +97,7 @@
|
|||||||
<a href="admin-styleguide.html" class="btn-sm">🎨 Styleguide</a>
|
<a href="admin-styleguide.html" class="btn-sm">🎨 Styleguide</a>
|
||||||
<a href="admin-accounts.html" class="btn-sm">👤 Admins</a>
|
<a href="admin-accounts.html" class="btn-sm">👤 Admins</a>
|
||||||
<a href="admin-weltkueche.html" class="btn-sm">🍽️ Weltküche-Herkunft</a>
|
<a href="admin-weltkueche.html" class="btn-sm">🍽️ Weltküche-Herkunft</a>
|
||||||
|
<a href="admin-todo.html" class="btn-sm">📋 ToDo / Review</a>
|
||||||
<a href="admin.html" class="btn-sm">Logout</a>
|
<a href="admin.html" class="btn-sm">Logout</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -57,6 +110,7 @@
|
|||||||
<a class="btn btn-outline" href="stadt-editor">🏙️ Stadt-Raumplanung-Editor</a>
|
<a class="btn btn-outline" href="stadt-editor">🏙️ Stadt-Raumplanung-Editor</a>
|
||||||
<a class="btn btn-outline" href="foto-upload">📷 Schulbuch-Bildupload</a>
|
<a class="btn btn-outline" href="foto-upload">📷 Schulbuch-Bildupload</a>
|
||||||
<a class="btn btn-outline" href="admin-weltkueche.html">🍽️ Weltküche-Herkunftsübersicht (QA)</a>
|
<a class="btn btn-outline" href="admin-weltkueche.html">🍽️ Weltküche-Herkunftsübersicht (QA)</a>
|
||||||
|
<a class="btn btn-outline" href="admin-todo.html">📋 ToDo / Review-Liste</a>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:.66rem;color:#8a8a8a;margin-top:.55rem">Alle nur mit Admin-Login erreichbar. Schulbuch-Upload zusätzlich per PIN (0815). Heli-Audio-Werkzeuge liegen nur lokal (<code>.LocalDeveloperTools</code>) und sind nicht auf dem Server.</div>
|
<div style="font-size:.66rem;color:#8a8a8a;margin-top:.55rem">Alle nur mit Admin-Login erreichbar. Schulbuch-Upload zusätzlich per PIN (0815). Heli-Audio-Werkzeuge liegen nur lokal (<code>.LocalDeveloperTools</code>) und sind nicht auf dem Server.</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,6 +123,111 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ Vertrieb: Charge ausgeben ============ -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-h">📦 Lizenzen ausgeben (Vertrieb)</div>
|
||||||
|
<div class="frm">
|
||||||
|
<div>
|
||||||
|
<label>Anzahl (bestellt)</label>
|
||||||
|
<input type="number" id="iss-count" value="12" min="1" max="5000" oninput="updatePreview()">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Typ</label>
|
||||||
|
<select id="iss-type">
|
||||||
|
<option value="marktplatz">Marktplatz Lernapps</option>
|
||||||
|
<option value="direktvertrieb">Direktvertrieb Schule</option>
|
||||||
|
<option value="privat" selected>Privat</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Externe Bestellnummer</label>
|
||||||
|
<input type="text" id="iss-order" placeholder="z. B. Marktplatz/Ministerium" list="order-hints">
|
||||||
|
<div class="hint-s">Geht in die Mail (für die Rechnung des Kunden).</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Kunde — Name / Verlag / Schule</label>
|
||||||
|
<input type="text" id="iss-name" placeholder="z. B. Verlag XY · VS Musterstadt">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>E-Mail</label>
|
||||||
|
<input type="email" id="iss-email" placeholder="kunde@schule.at">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Gültig von</label>
|
||||||
|
<input type="date" id="iss-from">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Gültig bis</label>
|
||||||
|
<input type="date" id="iss-until">
|
||||||
|
<div class="hint-s">Standard: 1. Okt. Folgejahr (merkt sich die letzte Wahl).</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<datalist id="order-hints"></datalist>
|
||||||
|
<div style="margin-top:.6rem;display:flex;align-items:center;gap:.7rem;flex-wrap:wrap">
|
||||||
|
<label style="display:flex;align-items:center;gap:.4rem;font-size:.75rem;font-weight:600;color:#4a4a4a">
|
||||||
|
<input type="checkbox" id="iss-bonus" checked onchange="updatePreview()"> +10 % Gratis-Zugabe (aufgerundet)
|
||||||
|
</label>
|
||||||
|
<span id="iss-preview" class="mini"></span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:.7rem" class="row-act">
|
||||||
|
<button class="btn" onclick="issueBatch()">Codes erzeugen & ausgeben</button>
|
||||||
|
<span id="iss-msg" class="mini"></span>
|
||||||
|
</div>
|
||||||
|
<div id="iss-result"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ Bestellungen / Chargen-Übersicht ============ -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-h">🧾 Bestellungen & Gültigkeit</div>
|
||||||
|
<div class="filter-bar">
|
||||||
|
<select id="bf-type" onchange="loadBatches()">
|
||||||
|
<option value="">Alle Typen</option>
|
||||||
|
<option value="marktplatz">Marktplatz Lernapps</option>
|
||||||
|
<option value="direktvertrieb">Direktvertrieb Schule</option>
|
||||||
|
<option value="privat">Privat</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" id="bf-customer" placeholder="Kunde (E-Mail/Name/Nr.)" oninput="debouncedBatches()">
|
||||||
|
<input type="text" id="bf-order" placeholder="Bestellnummer" oninput="debouncedBatches()">
|
||||||
|
<button class="btn btn-outline" onclick="loadBatches()">Aktualisieren</button>
|
||||||
|
</div>
|
||||||
|
<div id="batch-table" style="overflow-x:auto"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ Mail-Einstellungen ============ -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-h">✉️ Mail-Einstellungen</div>
|
||||||
|
<div class="frm">
|
||||||
|
<div>
|
||||||
|
<label>Versand-Treiber</label>
|
||||||
|
<select id="mc-provider">
|
||||||
|
<option value="server">Eigener Server (SMTP)</option>
|
||||||
|
<option value="aws">Amazon SES (später)</option>
|
||||||
|
</select>
|
||||||
|
<div class="hint-s">AWS wird aktiv, sobald konfiguriert.</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Absender-Name</label>
|
||||||
|
<input type="text" id="mc-fromname" placeholder="GeoGraSim">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Absender-Adresse</label>
|
||||||
|
<input type="email" id="mc-fromemail" placeholder="support@geograsim.at">
|
||||||
|
<div class="hint-s">SMTP-Zugang bleibt geheim in der .env.</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Öffentliche Basis-URL</label>
|
||||||
|
<input type="text" id="mc-publicurl" placeholder="https://geograsim.at">
|
||||||
|
<div class="hint-s">Für Links in Mails (nicht v3/localhost).</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row-act" style="margin-top:.6rem">
|
||||||
|
<button class="btn" onclick="saveMailConfig()">Speichern</button>
|
||||||
|
<input type="email" id="mc-testto" placeholder="Test an…" style="padding:.4rem .55rem;border:1.5px solid rgba(0,0,0,.12);border-radius:6px;font-size:.78rem">
|
||||||
|
<button class="btn btn-outline" onclick="testMail()">Test senden</button>
|
||||||
|
<span id="mc-msg" class="mini"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-h">Neue Lizenzen generieren</div>
|
<div class="card-h">Neue Lizenzen generieren</div>
|
||||||
<div style="display:flex;gap:.5rem;align-items:flex-end;flex-wrap:wrap">
|
<div style="display:flex;gap:.5rem;align-items:flex-end;flex-wrap:wrap">
|
||||||
@@ -102,6 +261,84 @@
|
|||||||
<button class="btn btn-outline" id="btn-next" onclick="nextPage()">Weiter →</button>
|
<button class="btn btn-outline" id="btn-next" onclick="nextPage()">Weiter →</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ Datenmodell-Referenz ============ -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-h">📐 Datenmodell — Lizenznummern</div>
|
||||||
|
<p class="dm-lead">Eine Lizenz = <b>ein Sitzplatz für eine:n Schüler:in in einem Schuljahr</b>. Der Admin erzeugt einen Vorrat an Codes, eine Lehrperson <b>löst</b> Codes ein (sie werden ihrem Konto zugeordnet) und <b>weist</b> sie dann einzelnen Schüler:innen zu. Alles steht in <b>einer</b> Tabelle <span class="code">licenses</span> — die drei Zustände ergeben sich allein aus zwei Feldern: <span class="code">teacher_id</span> und <span class="code">student_id</span>.</p>
|
||||||
|
|
||||||
|
<div class="dm-sub">Tabelle „licenses"</div>
|
||||||
|
<table>
|
||||||
|
<tr><th>Spalte</th><th>Typ</th><th>Bedeutung</th></tr>
|
||||||
|
<tr><td class="mono">id</td><td>INT · PK</td><td>Interne ID</td></tr>
|
||||||
|
<tr><td class="mono">code</td><td>VARCHAR · UNIQUE</td><td>Der Lizenzcode, Format <span class="code">XXXX-XXXX-XXXX-JAHR</span></td></tr>
|
||||||
|
<tr><td class="mono">school_year</td><td>VARCHAR</td><td>Schuljahr, z. B. „26/27" — aus dem Jahr abgeleitet</td></tr>
|
||||||
|
<tr><td class="mono">teacher_id</td><td>FK → teachers</td><td>Einlösende Lehrperson · <b>NULL = noch im Pool</b></td></tr>
|
||||||
|
<tr><td class="mono">student_id</td><td>FK → students</td><td>Platz-Inhaber:in · <b>NULL = noch frei</b></td></tr>
|
||||||
|
<tr><td class="mono">redeemed_at</td><td>TIMESTAMP · NULL</td><td>Zeitpunkt des Einlösens durch die Lehrperson</td></tr>
|
||||||
|
<tr><td class="mono">created_at</td><td>TIMESTAMP</td><td>Erstellung (Admin-Generierung)</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="dm-sub">Lebenszyklus & Einlösen</div>
|
||||||
|
<div class="dm-flow">
|
||||||
|
<div class="dm-step s-pool">
|
||||||
|
<div class="s-top"><span class="s-n">1</span><span class="s-t">Generiert</span></div>
|
||||||
|
<div class="s-who">Admin</div>
|
||||||
|
<div class="s-d"><code>teacher_id</code> = NULL · <code>student_id</code> = NULL. Frisch erzeugter Code im Pool.<br>Aktion: <code>generate</code></div>
|
||||||
|
</div>
|
||||||
|
<div class="dm-arrow">→</div>
|
||||||
|
<div class="dm-step s-redeem">
|
||||||
|
<div class="s-top"><span class="s-n">2</span><span class="s-t">Eingelöst</span></div>
|
||||||
|
<div class="s-who">Lehrperson</div>
|
||||||
|
<div class="s-d"><code>teacher_id</code> gesetzt · <code>redeemed_at</code> = jetzt · <code>student_id</code> noch NULL. Code gehört nun der Lehrperson.<br>Aktion: <code>redeem</code> / <code>redeem_batch</code></div>
|
||||||
|
</div>
|
||||||
|
<div class="dm-arrow">→</div>
|
||||||
|
<div class="dm-step s-assign">
|
||||||
|
<div class="s-top"><span class="s-n">3</span><span class="s-t">Zugewiesen</span></div>
|
||||||
|
<div class="s-who">Lehrperson</div>
|
||||||
|
<div class="s-d"><code>student_id</code> gesetzt. Der Platz ist von einer konkreten Person belegt.<br>Aktion: <code>assign</code> / <code>auto_assign</code></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dm-note">⚠️ In der Statistik oben zählt <b>„Frei"</b> = <span class="code">student_id IS NULL</span> — also auch schon <b>eingelöste</b>, aber noch nicht zugewiesene Codes. <b>„Zugewiesen"</b> = <span class="code">student_id</span> gesetzt. Ob ein freier Code schon einer Lehrperson gehört, sieht man an <span class="code">teacher_id</span> / <span class="code">redeemed_at</span>.</div>
|
||||||
|
|
||||||
|
<div class="dm-sub">Beziehungen</div>
|
||||||
|
<div class="dm-er" style="margin-bottom:.5rem">
|
||||||
|
<span class="dm-ent e-lic">licenses</span>
|
||||||
|
<span class="dm-rel"><b>→</b>teacher_id</span>
|
||||||
|
<span class="dm-ent">teachers</span>
|
||||||
|
</div>
|
||||||
|
<div class="dm-er">
|
||||||
|
<span class="dm-ent e-lic">licenses</span>
|
||||||
|
<span class="dm-rel"><b>→</b>student_id</span>
|
||||||
|
<span class="dm-ent">students</span>
|
||||||
|
<span class="dm-rel"><b>→</b>class_id</span>
|
||||||
|
<span class="dm-ent">classes</span>
|
||||||
|
<span class="dm-rel"><b>→</b>teacher_id</span>
|
||||||
|
<span class="dm-ent">teachers</span>
|
||||||
|
</div>
|
||||||
|
<p class="dm-lead" style="margin-top:.5rem"><span class="code">licenses.teacher_id</span> merkt sich die <b>einlösende</b> Lehrperson, <span class="code">licenses.student_id</span> den <b>Platz-Inhaber</b>. Über <span class="code">students.class_id → classes.teacher_id</span> hängt jede:r Schüler:in ohnehin an einer Klasse einer Lehrperson — der direkte <span class="code">teacher_id</span>-Eintrag macht aber auch <b>eingelöste, noch nicht zugewiesene</b> Codes eindeutig einer Lehrperson zuordenbar.</p>
|
||||||
|
|
||||||
|
<div class="dm-sub">Jahr → Schuljahr → Code</div>
|
||||||
|
<div class="dm-derive">
|
||||||
|
Eingabe beim Generieren: <b>Jahr</b> = <span class="d-code">2026</span> (Kalenderjahr, in dem das Schuljahr <b>beginnt</b>).<br>
|
||||||
|
→ <b>school_year</b> = „letzte 2 Ziffern" <span class="d-code">26</span> / <span class="d-code">26+1</span> = <span class="d-hi">26/27</span><br>
|
||||||
|
→ <b>Code-Suffix</b> = das volle Jahr: <span class="d-code">-2026</span><br>
|
||||||
|
→ <b>Zeichen-Alphabet</b> (ohne Verwechsler): <span class="code">A–Z ohne I,O</span> + <span class="code">2–9</span> (kein 0, kein 1)<br>
|
||||||
|
→ 3 Blöcke à 4 Zeichen + Jahr ⟶ Beispiel: <span class="d-code">K7QF-3M9P-RT2W-2026</span> · Schuljahr <span class="d-hi">26/27</span>
|
||||||
|
</div>
|
||||||
|
<div class="dm-note" style="background:#eef4f6;border-color:#cfe0e6;color:#3a5b64">Das <b>Jahr</b> steckt fest im Code (Suffix) und bestimmt <b>school_year</b>. Für ein neues Schuljahr wird ein neuer Vorrat generiert; Filter/Export laufen über <span class="code">school_year</span>.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detail-Modal einer Bestellung -->
|
||||||
|
<div class="ovl" id="modal-ovl">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-h">
|
||||||
|
<h3 id="m-title">Bestellung</h3>
|
||||||
|
<button class="x" onclick="closeModal()">✕</button>
|
||||||
|
</div>
|
||||||
|
<div id="m-body"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -172,12 +409,281 @@ function exportFree() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════ Vertrieb: Ausgeben ═══════════════
|
||||||
|
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g, function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];}); }
|
||||||
|
function today(){ return new Date().toISOString().slice(0,10); }
|
||||||
|
function fmtDate(d){ if(!d) return '—'; var p=String(d).slice(0,10).split('-'); return p.length===3 ? p[2]+'.'+p[1]+'.'+p[0] : d; }
|
||||||
|
function absUrl(path){ return location.origin + BASE + path; }
|
||||||
|
var lastIssue = null;
|
||||||
|
|
||||||
|
async function prefillDefaults(){
|
||||||
|
var r = await api('licenses?admin=1&sales_default=1');
|
||||||
|
if (r && r.valid_from) {
|
||||||
|
if(!document.getElementById('iss-from').value) document.getElementById('iss-from').value = r.valid_from;
|
||||||
|
if(!document.getElementById('iss-until').value) document.getElementById('iss-until').value = r.valid_until;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var custTimer=null;
|
||||||
|
function custSuggest(v){
|
||||||
|
if(custTimer) clearTimeout(custTimer);
|
||||||
|
if(!v || v.length<2) return;
|
||||||
|
custTimer = setTimeout(async function(){
|
||||||
|
var r = await api('licenses?admin=1&customers='+encodeURIComponent(v));
|
||||||
|
if(!r || !r.customers) return;
|
||||||
|
var dl = document.getElementById('cust-hints');
|
||||||
|
dl.innerHTML = r.customers.map(function(c){ return '<option value="'+esc(c.email||'')+'">'+esc((c.name||'')+(c.customer_no?' · '+c.customer_no:''))+'</option>'; }).join('');
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function issueBatch(){
|
||||||
|
var count = parseInt(document.getElementById('iss-count').value)||0;
|
||||||
|
var type = document.getElementById('iss-type').value;
|
||||||
|
var order = document.getElementById('iss-order').value.trim();
|
||||||
|
var email = document.getElementById('iss-email').value.trim();
|
||||||
|
var name = document.getElementById('iss-name').value.trim();
|
||||||
|
var vf = document.getElementById('iss-from').value;
|
||||||
|
var vu = document.getElementById('iss-until').value;
|
||||||
|
var bonus = document.getElementById('iss-bonus').checked;
|
||||||
|
var msg = document.getElementById('iss-msg');
|
||||||
|
if(count<1||count>5000){ msg.textContent='Anzahl 1–5000.'; return; }
|
||||||
|
var delivered = calcDelivered(count, bonus);
|
||||||
|
var who = name||email||'(kein Kunde)';
|
||||||
|
if(count>=100 && !confirm(count+' bestellt → '+delivered+' Codes im Paket für „'+who+'"'+(order?' (Best.-Nr. '+order+')':'')+', gültig bis '+fmtDate(vu)+', Typ '+type+'.\n\nWirklich erzeugen?')) return;
|
||||||
|
msg.textContent = 'Erzeuge …';
|
||||||
|
var r = await api('licenses', {action:'issue_batch', count:count, bonus:bonus, sales_type:type, order_no:order,
|
||||||
|
customer:{email:email, name:name}, valid_from:vf, valid_until:vu});
|
||||||
|
if(r.error){ msg.innerHTML='<span style="color:#c07a6b">'+esc(r.error)+'</span>'; return; }
|
||||||
|
msg.innerHTML='<span style="color:#5a8a5e">✓ '+r.count+' Codes erzeugt (Bestellung #'+r.internal_no+', bestellt '+r.ordered+').</span>';
|
||||||
|
lastIssue = {batch_id:r.batch_id, internal_no:r.internal_no, ordered:r.ordered, codes:r.codes, count:r.count, valid_until:r.valid_until, token:r.redeem_token, order_no:order, email:email};
|
||||||
|
renderIssueResult();
|
||||||
|
loadBatches();
|
||||||
|
loadLicenses();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIssueResult(){
|
||||||
|
var m = lastIssue; if(!m) return;
|
||||||
|
var codesText = m.codes.join('\n');
|
||||||
|
var mailText = buildMailText(m);
|
||||||
|
var h = '<div class="out">'
|
||||||
|
+ '<div class="mini" style="margin-bottom:.5rem">Bestellung <b>#'+m.internal_no+'</b> · bestellt <b>'+m.ordered+'</b> · geliefert <b>'+m.count+'</b>'+(m.count>m.ordered?' <span style="color:#5a8a5e">(+'+(m.count-m.ordered)+' gratis)</span>':'')+' · gültig bis '+fmtDate(m.valid_until)+'</div>'
|
||||||
|
+ '<h4>1 · Codes (' + m.count + ') — kopieren</h4>'
|
||||||
|
+ '<textarea id="codes-ta" rows="'+Math.min(10,Math.max(3,m.codes.length))+'" readonly>'+esc(codesText)+'</textarea>'
|
||||||
|
+ '<div class="row-act" style="margin-top:.4rem">'
|
||||||
|
+ '<button class="btn btn-outline" onclick="copyEl(\'codes-ta\')">📋 Kopieren</button>'
|
||||||
|
+ '<button class="btn btn-outline" onclick="downloadCodes(\'txt\')">⬇ .txt</button>'
|
||||||
|
+ '<button class="btn btn-outline" onclick="downloadCodes(\'csv\')">⬇ .csv</button>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<h4 style="margin-top:.9rem">2 · Per Mail senden</h4>'
|
||||||
|
+ '<div class="frm" style="grid-template-columns:1fr 2fr">'
|
||||||
|
+ '<div><label>An</label><input type="email" id="mail-to" value="'+esc(m.email||'')+'"></div>'
|
||||||
|
+ '<div><label>Betreff</label><input type="text" id="mail-subj" value="Ihre GeoGraSim-Lizenzcodes'+(m.order_no?' — Best.-Nr. '+esc(m.order_no):'')+'"></div>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<label style="margin-top:.5rem;display:block">Mailtext (editierbar)</label>'
|
||||||
|
+ '<textarea id="mail-body" rows="10">'+esc(mailText)+'</textarea>'
|
||||||
|
+ '<div class="hint-s">'+(m.count>40?'Große Charge: die Codes gehen automatisch als .txt- und .csv-Anhang mit.':'Die Codes stehen im Text.')+' Der Text ist auch für den Selbstversand kopierbar.</div>'
|
||||||
|
+ '<div class="row-act" style="margin-top:.4rem"><button class="btn" onclick="sendMail('+m.batch_id+')">✉ Absenden</button><span id="mail-msg" class="mini"></span></div>'
|
||||||
|
+ '</div>';
|
||||||
|
document.getElementById('iss-result').innerHTML = h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMailText(m){
|
||||||
|
var codesPart = (m.count>40) ? 'Die '+m.count+' Codes finden Sie in der angehängten Datei „lizenzcodes.txt".' : m.codes.join('\n');
|
||||||
|
var order = m.order_no ? ('\nBestellnummer: '+m.order_no) : '';
|
||||||
|
return 'Guten Tag,\n\nvielen Dank für Ihre Bestellung. Anbei Ihre '+m.count+' GeoGraSim-Lizenzcodes, gültig bis '+fmtDate(m.valid_until)+'.'+order+'\n\n'
|
||||||
|
+ codesPart + '\n\n'
|
||||||
|
+ 'Einlösen: Als Lehrperson unter '+PUBLIC_URL+' anmelden und die Codes unter „Lizenzen" eingeben.\n\n'
|
||||||
|
+ 'Freundliche Grüße\nGeoGraSim';
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyEl(id){ var el=document.getElementById(id); el.select(); navigator.clipboard.writeText(el.value).catch(function(){document.execCommand('copy');}); }
|
||||||
|
|
||||||
|
// Nur die Codes (eine pro Zeile), KEINE Kopfzeile — direkt per Copy-Paste einlösbar.
|
||||||
|
function downloadCodes(fmt){
|
||||||
|
var m = lastIssue; if(!m) return;
|
||||||
|
var content = m.codes.join('\r\n')+'\r\n';
|
||||||
|
var mime = fmt==='csv' ? 'text/csv' : 'text/plain';
|
||||||
|
var ext = m.order_no ? m.order_no.replace(/[^a-zA-Z0-9_\-]/g,'_') : (new Date().toISOString().slice(0,16).replace(/[:T]/g,'-'));
|
||||||
|
var fname = ext+'_geograsim_'+(m.internal_no||'')+'.'+fmt;
|
||||||
|
var a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([content],{type:mime})); a.download=fname; a.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcDelivered(ordered, bonus){ return bonus ? Math.ceil(ordered*1.1) : ordered; }
|
||||||
|
function updatePreview(){
|
||||||
|
var o=parseInt(document.getElementById('iss-count').value)||0;
|
||||||
|
var b=document.getElementById('iss-bonus').checked;
|
||||||
|
var el=document.getElementById('iss-preview'); if(!el) return;
|
||||||
|
if(o<1){ el.textContent=''; return; }
|
||||||
|
var d=calcDelivered(o,b);
|
||||||
|
el.innerHTML = b ? ('→ im Paket <b>'+d+'</b> Codes (+'+(d-o)+' gratis)') : ('→ <b>'+d+'</b> Codes (keine Zugabe)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mail-Einstellungen ──
|
||||||
|
var PUBLIC_URL='https://geograsim.at';
|
||||||
|
async function loadMailConfig(){
|
||||||
|
var r=await api('licenses?admin=1&mail_config=1');
|
||||||
|
if(!r || r.error) return;
|
||||||
|
PUBLIC_URL=r.public_url||PUBLIC_URL;
|
||||||
|
document.getElementById('mc-provider').value=r.provider||'server';
|
||||||
|
document.getElementById('mc-fromname').value=r.from_name||'';
|
||||||
|
document.getElementById('mc-fromemail').value=r.from_email||'';
|
||||||
|
document.getElementById('mc-publicurl').value=r.public_url||'';
|
||||||
|
}
|
||||||
|
async function saveMailConfig(){
|
||||||
|
var mm=document.getElementById('mc-msg'); mm.textContent='Speichere …';
|
||||||
|
var r=await api('licenses',{action:'set_mail_config',
|
||||||
|
provider:document.getElementById('mc-provider').value,
|
||||||
|
from_name:document.getElementById('mc-fromname').value.trim(),
|
||||||
|
from_email:document.getElementById('mc-fromemail').value.trim(),
|
||||||
|
public_url:document.getElementById('mc-publicurl').value.trim()});
|
||||||
|
if(r.error){ mm.innerHTML='<span style="color:#c07a6b">'+esc(r.error)+'</span>'; return; }
|
||||||
|
mm.innerHTML='<span style="color:#5a8a5e">✓ Gespeichert.</span>'; loadMailConfig();
|
||||||
|
}
|
||||||
|
async function testMail(){
|
||||||
|
var to=document.getElementById('mc-testto').value.trim();
|
||||||
|
var mm=document.getElementById('mc-msg');
|
||||||
|
if(!to){ mm.innerHTML='<span style="color:#c07a6b">Test-Adresse fehlt.</span>'; return; }
|
||||||
|
mm.textContent='Sende Test …';
|
||||||
|
var r=await api('licenses',{action:'test_mail', to_email:to});
|
||||||
|
if(r.error){ mm.innerHTML='<span style="color:#c07a6b">'+esc(r.error)+'</span>'; return; }
|
||||||
|
mm.innerHTML='<span style="color:#5a8a5e">✓ Test gesendet.</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMail(batchId){
|
||||||
|
var to=document.getElementById('mail-to').value.trim();
|
||||||
|
var subj=document.getElementById('mail-subj').value.trim();
|
||||||
|
var body=document.getElementById('mail-body').value;
|
||||||
|
var mm=document.getElementById('mail-msg');
|
||||||
|
if(!to){ mm.innerHTML='<span style="color:#c07a6b">Empfänger fehlt.</span>'; return; }
|
||||||
|
mm.textContent='Sende …';
|
||||||
|
var r=await api('licenses',{action:'send_batch_mail', batch_id:batchId, to_email:to, subject:subj, body_text:body});
|
||||||
|
if(r.error){ mm.innerHTML='<span style="color:#c07a6b">'+esc(r.error)+'</span>'; return; }
|
||||||
|
mm.innerHTML='<span style="color:#5a8a5e">✓ Gesendet & archiviert.</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════ Bestellungen / Übersicht ═══════════════
|
||||||
|
var batchTimer=null;
|
||||||
|
function debouncedBatches(){ if(batchTimer)clearTimeout(batchTimer); batchTimer=setTimeout(loadBatches,300); }
|
||||||
|
|
||||||
|
async function loadBatches(){
|
||||||
|
var qs = 'licenses?admin=1&view=batches';
|
||||||
|
var t=document.getElementById('bf-type').value; if(t) qs+='&type='+encodeURIComponent(t);
|
||||||
|
var c=document.getElementById('bf-customer').value.trim(); if(c) qs+='&customer='+encodeURIComponent(c);
|
||||||
|
var o=document.getElementById('bf-order').value.trim(); if(o) qs+='&order_no='+encodeURIComponent(o);
|
||||||
|
var r=await api(qs);
|
||||||
|
if(!r || !r.batches){ document.getElementById('batch-table').innerHTML='<div class="mini">—</div>'; return; }
|
||||||
|
var typeLabel={marktplatz:'Marktplatz',direktvertrieb:'Direktvertrieb',privat:'Privat'};
|
||||||
|
var html='<table><tr><th>#</th><th>Bestellnr.</th><th>Kunde</th><th>Typ</th><th>Erstellt</th><th>Gültig bis</th>'
|
||||||
|
+ '<th>Ausgeg.</th><th>Storno</th><th>Netto</th><th>Eingelöst</th><th>Abgel.</th><th></th></tr>';
|
||||||
|
r.batches.forEach(function(b){
|
||||||
|
var net=(b.n_total|0)-(b.n_canceled|0);
|
||||||
|
var cust=esc(b.customer_name||b.customer_email||b.customer_no||'—');
|
||||||
|
var canceled=b.canceled_at?' <span class="badge b-canceled">storniert</span>':'';
|
||||||
|
html+='<tr>'
|
||||||
|
+'<td>'+b.id+'</td>'
|
||||||
|
+'<td class="mono">'+esc(b.order_no||'—')+'</td>'
|
||||||
|
+'<td>'+cust+canceled+'</td>'
|
||||||
|
+'<td>'+esc(typeLabel[b.sales_type]||b.sales_type)+'</td>'
|
||||||
|
+'<td>'+fmtDate(b.created_at)+'</td>'
|
||||||
|
+'<td>'+fmtDate(b.default_valid_until)+'</td>'
|
||||||
|
+'<td>'+(b.n_total|0)+'</td>'
|
||||||
|
+'<td>'+((b.n_canceled|0)?'<b style="color:#b0453a">'+b.n_canceled+'</b>':'0')+'</td>'
|
||||||
|
+'<td><b>'+net+'</b></td>'
|
||||||
|
+'<td>'+(b.n_redeemed|0)+'</td>'
|
||||||
|
+'<td>'+((b.n_expired|0)?'<b style="color:#c98a2a">'+b.n_expired+'</b>':'0')+'</td>'
|
||||||
|
+'<td><button class="btn-sm" onclick="openBatch('+b.id+')">Details</button></td>'
|
||||||
|
+'</tr>';
|
||||||
|
});
|
||||||
|
html+='</table>';
|
||||||
|
document.getElementById('batch-table').innerHTML=html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function codeStatus(l){
|
||||||
|
if(l.canceled_at) return '<span class="badge b-canceled">storniert</span>';
|
||||||
|
var base = l.student_id ? '<span class="badge b-assigned">zugewiesen</span>'
|
||||||
|
: l.redeemed_at ? '<span class="badge b-redeemed">eingelöst</span>'
|
||||||
|
: l.issued_at ? '<span class="badge b-issued">vergeben</span>'
|
||||||
|
: '<span class="badge b-free">frei</span>';
|
||||||
|
var exp = l.valid_until && String(l.valid_until).slice(0,10) < today();
|
||||||
|
return base + (exp?' <span class="badge b-expired">abgelaufen</span>':'');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openBatch(id){
|
||||||
|
var r=await api('licenses?admin=1&view=batch_detail&batch_id='+id);
|
||||||
|
if(r.error){ alert(r.error); return; }
|
||||||
|
var b=r.batch;
|
||||||
|
var cust=esc(b.customer_name||'')+(b.customer_email?' · '+esc(b.customer_email):'')+(b.customer_no?' · Nr. '+esc(b.customer_no):'');
|
||||||
|
var h='<div class="mini" style="margin-bottom:.6rem">'
|
||||||
|
+ 'Typ <b>'+esc(b.sales_type)+'</b> · Bestellnr. <b>'+esc(b.order_no||'—')+'</b> · Kunde '+(cust||'—')
|
||||||
|
+ ' · gültig '+fmtDate(b.default_valid_from)+'–'+fmtDate(b.default_valid_until)
|
||||||
|
+ (b.canceled_at?' · <span class="badge b-canceled">Charge storniert</span>':'')+'</div>';
|
||||||
|
h+='<div class="row-act" style="margin-bottom:.6rem">'
|
||||||
|
+ '<button class="btn-sm" onclick="extendBatch('+b.id+',\''+esc(b.default_valid_until||'')+'\')">📅 Charge verlängern</button>'
|
||||||
|
+ '<button class="btn-sm" onclick="cancelBatch('+b.id+')">✖ Charge stornieren</button></div>';
|
||||||
|
h+='<div style="max-height:320px;overflow:auto"><table><tr><th>Code</th><th>Status</th><th>Gültig bis</th><th>Lehrperson</th><th></th></tr>';
|
||||||
|
r.codes.forEach(function(l){
|
||||||
|
h+='<tr><td class="code">'+esc(l.code)+'</td><td>'+codeStatus(l)+'</td><td>'+fmtDate(l.valid_until)+'</td>'
|
||||||
|
+'<td class="mini">'+esc(l.teacher_name||'—')+'</td>'
|
||||||
|
+'<td class="row-act">'
|
||||||
|
+ (l.canceled_at?'':'<button class="btn-sm" onclick="extendLicense('+l.id+',\''+esc(l.valid_until||'')+'\')">📅</button>'
|
||||||
|
+ '<button class="btn-sm" onclick="cancelLicense('+l.id+')">✖</button>')
|
||||||
|
+'</td></tr>';
|
||||||
|
});
|
||||||
|
h+='</table></div>';
|
||||||
|
if(r.mails && r.mails.length){
|
||||||
|
h+='<div class="dm-sub">Mail-Verlauf</div><table>';
|
||||||
|
r.mails.forEach(function(mm){ h+='<tr><td class="mini">'+fmtDate(mm.created_at)+'</td><td class="mini">'+esc(mm.to_email)+'</td><td><span class="badge '+(mm.status==='sent'?'b-redeemed':mm.status==='failed'?'b-canceled':'b-free')+'">'+esc(mm.status)+'</span></td><td class="mini">'+esc(mm.error||'')+'</td></tr>'; });
|
||||||
|
h+='</table>';
|
||||||
|
}
|
||||||
|
if(r.events && r.events.length){
|
||||||
|
h+='<div class="dm-sub">Verlauf</div><div class="mini">';
|
||||||
|
r.events.forEach(function(e){ h+=fmtDate(e.created_at)+' · <b>'+esc(e.event)+'</b> '+esc(e.detail||'')+'<br>'; });
|
||||||
|
h+='</div>';
|
||||||
|
}
|
||||||
|
document.getElementById('m-title').textContent='Bestellung #'+b.id;
|
||||||
|
document.getElementById('m-body').innerHTML=h;
|
||||||
|
document.getElementById('modal-ovl').classList.add('open');
|
||||||
|
}
|
||||||
|
function closeModal(){ document.getElementById('modal-ovl').classList.remove('open'); }
|
||||||
|
function copyEl2(btn){ var inp=btn.previousElementSibling; inp.select(); navigator.clipboard.writeText(inp.value).catch(function(){document.execCommand('copy');}); }
|
||||||
|
|
||||||
|
async function cancelBatch(id){
|
||||||
|
var reason=prompt('Ganze Charge stornieren? Grund (optional):'); if(reason===null) return;
|
||||||
|
var r=await api('licenses',{action:'cancel_batch', batch_id:id, reason:reason});
|
||||||
|
if(r.error){ alert(r.error); return; }
|
||||||
|
openBatch(id); loadBatches();
|
||||||
|
}
|
||||||
|
async function cancelLicense(id){
|
||||||
|
var reason=prompt('Diesen Code stornieren? Grund (optional):'); if(reason===null) return;
|
||||||
|
var r=await api('licenses',{action:'cancel_license', license_id:id, reason:reason});
|
||||||
|
if(r.error){ alert(r.error); return; }
|
||||||
|
reopenCurrent(); loadBatches();
|
||||||
|
}
|
||||||
|
async function extendBatch(id,cur){
|
||||||
|
var vu=prompt('Charge verlängern — neues „Gültig bis" (JJJJ-MM-TT):', cur||''); if(!vu) return;
|
||||||
|
var r=await api('licenses',{action:'extend_batch', batch_id:id, valid_until:vu});
|
||||||
|
if(r.error){ alert(r.error); return; }
|
||||||
|
openBatch(id); loadBatches();
|
||||||
|
}
|
||||||
|
async function extendLicense(id,cur){
|
||||||
|
var vu=prompt('Code verlängern — neues „Gültig bis" (JJJJ-MM-TT):', cur||''); if(!vu) return;
|
||||||
|
var r=await api('licenses',{action:'extend_license', license_id:id, valid_until:vu});
|
||||||
|
if(r.error){ alert(r.error); return; }
|
||||||
|
reopenCurrent(); loadBatches();
|
||||||
|
}
|
||||||
|
var _curBatch=null;
|
||||||
|
function reopenCurrent(){ var t=document.getElementById('m-title').textContent.match(/#(\d+)/); if(t) openBatch(parseInt(t[1])); }
|
||||||
|
|
||||||
// Admin auth check (2FA required)
|
// Admin auth check (2FA required)
|
||||||
fetch(BASE + '/api/admin', {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin', body:JSON.stringify({action:'status'})})
|
fetch(BASE + '/api/admin', {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin', body:JSON.stringify({action:'status'})})
|
||||||
.then(function(r){return r.json()})
|
.then(function(r){return r.json()})
|
||||||
.then(function(r) {
|
.then(function(r) {
|
||||||
if (!r.authenticated) { location.href = 'admin.html'; return; }
|
if (!r.authenticated) { location.href = 'admin.html'; return; }
|
||||||
loadLicenses();
|
loadLicenses();
|
||||||
|
prefillDefaults();
|
||||||
|
loadBatches();
|
||||||
|
loadMailConfig();
|
||||||
|
updatePreview();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -90,6 +90,7 @@
|
|||||||
<a href="admin-levels.html" class="btn-sm">Level</a>
|
<a href="admin-levels.html" class="btn-sm">Level</a>
|
||||||
<a href="admin-licenses.html" class="btn-sm">Lizenzen</a>
|
<a href="admin-licenses.html" class="btn-sm">Lizenzen</a>
|
||||||
<a href="admin-accounts.html" class="btn-sm">👤 Admins</a>
|
<a href="admin-accounts.html" class="btn-sm">👤 Admins</a>
|
||||||
|
<a href="admin-lehrer-view.html" class="btn-sm">📊 Lehrer-View Parameter</a>
|
||||||
<a href="index.html" class="btn-sm">Startseite</a>
|
<a href="index.html" class="btn-sm">Startseite</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -249,6 +250,7 @@
|
|||||||
<div class="pageitem"><a href="admin-modules.html">Module</a><span class="url">/admin-modules.html</span></div>
|
<div class="pageitem"><a href="admin-modules.html">Module</a><span class="url">/admin-modules.html</span></div>
|
||||||
<div class="pageitem"><a href="admin-levels.html">Level / Waypoints</a><span class="url">/admin-levels.html</span></div>
|
<div class="pageitem"><a href="admin-levels.html">Level / Waypoints</a><span class="url">/admin-levels.html</span></div>
|
||||||
<div class="pageitem"><a href="admin-styleguide.html">Styleguide</a><span class="url">/admin-styleguide.html</span></div>
|
<div class="pageitem"><a href="admin-styleguide.html">Styleguide</a><span class="url">/admin-styleguide.html</span></div>
|
||||||
|
<div class="pageitem"><a href="admin-lehrer-view.html">Lehrer-View Parameter-Überblick</a><span class="url">/admin-lehrer-view.html</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -64,6 +64,8 @@
|
|||||||
<a href="#bildstil">🎨 Bildstil</a>
|
<a href="#bildstil">🎨 Bildstil</a>
|
||||||
<a href="#farben-bild">🌿 Bild-Farbpalette</a>
|
<a href="#farben-bild">🌿 Bild-Farbpalette</a>
|
||||||
<a href="#farben-ui">🧩 UI-Farbtokens</a>
|
<a href="#farben-ui">🧩 UI-Farbtokens</a>
|
||||||
|
<a href="#leistung">📊 Leistungs-Skala</a>
|
||||||
|
<a href="#kopfzeile">🎩 Kopfzeile</a>
|
||||||
<a href="#layout">📐 Layout & Spacing</a>
|
<a href="#layout">📐 Layout & Spacing</a>
|
||||||
<a href="#typo">🔤 Typografie</a>
|
<a href="#typo">🔤 Typografie</a>
|
||||||
<a href="#bilder">🖼️ Bildformate</a>
|
<a href="#bilder">🖼️ Bildformate</a>
|
||||||
@@ -195,6 +197,161 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================== -->
|
||||||
|
<div class="card" id="leistung">
|
||||||
|
<div class="card-h">Leistungs-Skala — 5 Stufen (Standard für ALLE Sims)</div>
|
||||||
|
<p class="lead">EINE Skala für Schülerleistung überall im Programm: Prozentwert (0–100) + dünner Balken in der Ampelfarbe. <strong>Sterne entfallen.</strong> Die Grenzen <strong>50 / 80 / 90</strong> sind zugleich die Triage-Grenzen (ab 90 % „Fordern", ab 80 % „Läuft gut", ab 50 % „Begleiten", darunter „Fördern"), damit Balkenfarbe und Feld immer übereinstimmen. Quelle in Code: <code>pctColor()</code> = <code>_tagTraffic()</code>.</p>
|
||||||
|
<div style="display:grid;grid-template-columns:26px auto 1fr 90px;gap:.55rem 1rem;align-items:center;max-width:520px;margin:.7rem 0 1.3rem;font-size:.85rem">
|
||||||
|
<span style="width:22px;height:22px;border-radius:6px;background:#1f7a3d"></span><b>≥ 90 %</b><span style="color:#5a5a5a">sehr gut <code style="font-size:.72rem">#1f7a3d</code></span><div><div style="font-weight:800;color:#1f7a3d;font-size:.82rem">90 %</div><div style="margin-top:2px;height:5px;border-radius:99px;background:rgba(0,0,0,.07);overflow:hidden"><div style="height:100%;width:90%;background:#1f7a3d"></div></div></div>
|
||||||
|
<span style="width:22px;height:22px;border-radius:6px;background:#7cb342"></span><b>80–89 %</b><span style="color:#5a5a5a">gut <code style="font-size:.72rem">#7cb342</code></span><div><div style="font-weight:800;color:#7cb342;font-size:.82rem">80 %</div><div style="margin-top:2px;height:5px;border-radius:99px;background:rgba(0,0,0,.07);overflow:hidden"><div style="height:100%;width:80%;background:#7cb342"></div></div></div>
|
||||||
|
<span style="width:22px;height:22px;border-radius:6px;background:#f9c33b"></span><b>60–79 %</b><span style="color:#5a5a5a">solide <code style="font-size:.72rem">#f9c33b</code></span><div><div style="font-weight:800;color:#f9c33b;font-size:.82rem">60 %</div><div style="margin-top:2px;height:5px;border-radius:99px;background:rgba(0,0,0,.07);overflow:hidden"><div style="height:100%;width:60%;background:#f9c33b"></div></div></div>
|
||||||
|
<span style="width:22px;height:22px;border-radius:6px;background:#ef8b2c"></span><b>50–59 %</b><span style="color:#5a5a5a">wackelig <code style="font-size:.72rem">#ef8b2c</code></span><div><div style="font-weight:800;color:#ef8b2c;font-size:.82rem">50 %</div><div style="margin-top:2px;height:5px;border-radius:99px;background:rgba(0,0,0,.07);overflow:hidden"><div style="height:100%;width:50%;background:#ef8b2c"></div></div></div>
|
||||||
|
<span style="width:22px;height:22px;border-radius:6px;background:#d8453a"></span><b>< 50 %</b><span style="color:#5a5a5a">kritisch <code style="font-size:.72rem">#d8453a</code></span><div><div style="font-weight:800;color:#d8453a;font-size:.82rem">40 %</div><div style="margin-top:2px;height:5px;border-radius:99px;background:rgba(0,0,0,.07);overflow:hidden"><div style="height:100%;width:40%;background:#d8453a"></div></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Triage-Felder (aus % × Fleiß)</h3>
|
||||||
|
<p><strong>< 50 % → Fördern</strong> (Hilfe) · <strong>50–79 % → Begleiten</strong> · <strong>80–89 % → Läuft gut</strong> · <strong>≥ 90 % → Fordern</strong> (mehr herausfordern) — jeweils nur, wenn aktiv gearbeitet wurde, sonst „inaktiv".</p>
|
||||||
|
<div style="display:flex;gap:.6rem;flex-wrap:wrap;margin:.4rem 0 1rem">
|
||||||
|
<span style="font-size:.8rem;font-weight:700;color:#b04a3e;background:#f7e2dc;padding:.25rem .7rem;border-radius:12px">Fördern</span>
|
||||||
|
<span style="font-size:.8rem;font-weight:700;color:#9a6a12;background:#f7edd6;padding:.25rem .7rem;border-radius:12px">Begleiten</span>
|
||||||
|
<span style="font-size:.8rem;font-weight:700;color:#3a6b3e;background:#dceadd;padding:.25rem .7rem;border-radius:12px">Läuft gut</span>
|
||||||
|
<span style="font-size:.8rem;font-weight:700;color:#155e2a;background:#c8e6cd;padding:.25rem .7rem;border-radius:12px">Fordern</span>
|
||||||
|
<span style="font-size:.8rem;font-weight:700;color:#8a8a8a;background:#efeee8;padding:.25rem .7rem;border-radius:12px">inaktiv</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>CSS-Variablen</h3>
|
||||||
|
<div style="background:#1f2733;border-radius:8px;padding:1rem;overflow:auto"><pre style="margin:0;color:#e8eef4;font-size:.78rem;font-family:'Courier New',monospace">--ggs-perf-5: #1f7a3d; /* ≥ 90 % sehr gut */
|
||||||
|
--ggs-perf-4: #7cb342; /* 80–89 % gut */
|
||||||
|
--ggs-perf-3: #f9c33b; /* 60–79 % solide */
|
||||||
|
--ggs-perf-2: #ef8b2c; /* 50–59 % wackelig */
|
||||||
|
--ggs-perf-1: #d8453a; /* < 50 % kritisch */
|
||||||
|
/* Triage */
|
||||||
|
--ggs-triage-foerdern: #b04a3e;
|
||||||
|
--ggs-triage-begleiten: #9a6a12;
|
||||||
|
--ggs-triage-gut: #3a6b3e;
|
||||||
|
--ggs-triage-inaktiv: #8a8a8a;</pre></div>
|
||||||
|
<p style="font-size:.78rem;color:#8a8a8a;margin-top:.6rem">Vorschau der Zuordnung je Sim: <a href="admin-lehrer-view.html">Lehrer-View Parameter-Überblick</a>.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================== -->
|
||||||
|
<div class="card" id="kopfzeile">
|
||||||
|
<div class="card-h">Kopfzeile — Entwurf B (schwebend)</div>
|
||||||
|
<p class="lead">Standard-Kopfzeile für ALLE Sims: <strong>Kreis-Logo · Titel · Level</strong> als eine schwebende Pille — Home und rechts die Steuerung: <strong>Tempo</strong> (nur Echtzeit-Sims) · Musik · Ton · Neustart · Hilfe · Glossar (<code>target="_blank"</code>). <strong>Eigene Icons im App-Stil</strong> statt Emojis, alle Elemente auf <strong>gleicher Höhe</strong>. Lehrplan entfällt. Musik überall (Klima-Track als Platzhalter).</p>
|
||||||
|
<style>
|
||||||
|
.khz-scene{position:relative;border-radius:12px;overflow:hidden;background:linear-gradient(135deg,#a7cbe0 0%,#8bb98d 55%,#e6dca6 100%);border:1px solid #cfd6cf}
|
||||||
|
.khz-scene .khz-sky{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:rgba(255,255,255,.5);font-size:1.5rem;font-weight:800;letter-spacing:.08em;pointer-events:none}
|
||||||
|
.khz-scene .khz-content{position:absolute;left:14px;bottom:12px;display:flex;gap:8px;pointer-events:none}
|
||||||
|
.khz-scene .khz-content i{width:70px;height:44px;border-radius:8px;background:rgba(255,255,255,.35);display:block}
|
||||||
|
/* Alle Header-Elemente 38px hoch, vertikal zentriert (Logo·Titel·Level = eine Pille) */
|
||||||
|
.ghdr{position:absolute;left:0;right:0;top:0;display:flex;align-items:center;gap:.5rem;padding:.55rem .7rem;z-index:3}
|
||||||
|
.ghdr .sp{flex:1}
|
||||||
|
.ghdr .grp{display:flex;align-items:center;gap:.5rem;height:38px;padding:0 .8rem 0 .3rem;background:rgba(255,255,255,.96);border-radius:19px;box-shadow:0 2px 9px rgba(0,0,0,.2)}
|
||||||
|
.ghdr .grp .logo{width:30px;height:30px;border-radius:50%;background:#1f4e5a;flex:none;overflow:hidden;display:flex;align-items:center;justify-content:center}
|
||||||
|
.ghdr .grp .logo img{width:100%;height:100%;object-fit:cover}
|
||||||
|
.ghdr .grp .title{font-weight:800;color:#1f4e5a;font-size:.92rem;white-space:nowrap;line-height:1}
|
||||||
|
.ghdr .grp .ctx{font-size:.6rem;font-weight:800;color:#fff;background:#5a8a5e;border-radius:9px;padding:.2rem .5rem;line-height:1;white-space:nowrap}
|
||||||
|
.ghdr .btn{width:38px;height:38px;border-radius:12px;border:none;background:rgba(255,255,255,.96);box-shadow:0 2px 9px rgba(0,0,0,.2);display:flex;align-items:center;justify-content:center;cursor:pointer;color:#1f4e5a;padding:0}
|
||||||
|
.ghdr .btn .ic{width:20px;height:20px}
|
||||||
|
.khz-variant{margin:1rem 0}
|
||||||
|
.khz-frame-lab{font-size:.66rem;font-weight:700;color:#8a8a8a;text-transform:uppercase;letter-spacing:.04em;margin:.9rem 0 .3rem}
|
||||||
|
.khz-ipad{height:150px}
|
||||||
|
.khz-fhd{height:120px}
|
||||||
|
.khz-scroll{overflow-x:auto}
|
||||||
|
/* Aufgeklappte Steuerung */
|
||||||
|
.khz-pop{display:inline-flex;align-items:center;gap:.25rem;height:38px;padding:0 .3rem;background:rgba(255,255,255,.7);backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px);border-radius:12px;box-shadow:0 2px 9px rgba(0,0,0,.14);border:1px solid rgba(255,255,255,.5);vertical-align:middle}
|
||||||
|
.khz-pop .ic{width:16px;height:16px;color:#1f4e5a}
|
||||||
|
.khz-seg{height:30px;min-width:36px;padding:0 .6rem;border:none;background:transparent;border-radius:8px;font-weight:800;font-size:.82rem;color:#4a4a4a;cursor:pointer;display:flex;align-items:center;justify-content:center}
|
||||||
|
.khz-seg.on{background:#4a7c8a;color:#fff}
|
||||||
|
.khz-seg.on .ic{color:#fff}
|
||||||
|
.khz-music{gap:.5rem;padding:0 .55rem}
|
||||||
|
.khz-select{height:30px;border:1px solid rgba(0,0,0,.12);border-radius:8px;padding:0 .55rem;font-size:.78rem;color:#1f4e5a;font-weight:700;background:#f7f6f3;display:inline-flex;align-items:center;gap:.35rem;white-space:nowrap}
|
||||||
|
.khz-range{width:96px;height:6px;border-radius:99px;background:linear-gradient(90deg,#4a7c8a 0 62%,#dfe6e9 62% 100%);position:relative}
|
||||||
|
.khz-range::after{content:'';position:absolute;left:62%;top:50%;transform:translate(-50%,-50%);width:14px;height:14px;border-radius:50%;background:#4a7c8a;box-shadow:0 1px 3px rgba(0,0,0,.3)}
|
||||||
|
/* Tempo-Button zeigt IMMER den aktuellen Stand */
|
||||||
|
.khz-spd{width:38px;height:38px;border-radius:12px;border:none;background:#fff;box-shadow:0 2px 9px rgba(0,0,0,.2);display:inline-flex;flex-direction:column;align-items:center;justify-content:center;color:#1f4e5a;font-weight:800;line-height:1;cursor:pointer;vertical-align:middle}
|
||||||
|
.khz-spd b{font-size:.82rem}
|
||||||
|
.khz-spd small{font-size:.5rem;font-weight:700;margin-top:1px;color:#4a7c8a;letter-spacing:.02em}
|
||||||
|
.khz-spd.act{background:#4a7c8a;color:#fff}
|
||||||
|
.khz-spd.act small{color:#dbe9ee}
|
||||||
|
.khz-spd .ic{width:18px;height:18px;color:inherit}
|
||||||
|
</style>
|
||||||
|
<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs>
|
||||||
|
<symbol id="ic-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11.4 12 4l8 7.4"/><path d="M6 10.3V19a1 1 0 0 0 1 1h3v-5h4v5h3a1 1 0 0 0 1-1v-8.7"/></symbol>
|
||||||
|
<symbol id="ic-tempo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4.6 16.5a7.5 7.5 0 0 1 14.8 0"/><path d="M12 16 15.2 12"/><circle cx="12" cy="16.3" r="1.3" fill="currentColor" stroke="none"/></symbol>
|
||||||
|
<symbol id="ic-music" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 17V6.4l9-1.8V15"/><circle cx="6.5" cy="17" r="2.4"/><circle cx="15.5" cy="15" r="2.4"/></symbol>
|
||||||
|
<symbol id="ic-sound" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9.5h3.2L11 6v12l-3.8-3.5H4z"/><path d="M14.6 9.6a4 4 0 0 1 0 4.8"/><path d="M17 7.4a7 7 0 0 1 0 9.2"/></symbol>
|
||||||
|
<symbol id="ic-restart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12a7 7 0 1 1-2-4.9"/><path d="M19 4.5V9h-4.5"/></symbol>
|
||||||
|
<symbol id="ic-help" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8.4"/><path d="M9.7 9.3a2.4 2.4 0 0 1 4.6.8c0 1.6-2.3 1.8-2.3 3.2"/><circle cx="12" cy="16.4" r=".7" fill="currentColor" stroke="none"/></symbol>
|
||||||
|
<symbol id="ic-glossar" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 6.6C10 5.2 7 5.2 5 6.2v11c2-1 5-1 7 .4"/><path d="M12 6.6c2-1.4 5-1.4 7-.4v11c-2-1-5-1-7 .4"/><path d="M12 6.6v11.4"/></symbol>
|
||||||
|
<symbol id="ic-pause" viewBox="0 0 24 24"><rect x="6" y="5" width="4" height="14" rx="1.2" fill="currentColor"/><rect x="14" y="5" width="4" height="14" rx="1.2" fill="currentColor"/></symbol>
|
||||||
|
<symbol id="ic-caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M7 10l5 5 5-5"/></symbol>
|
||||||
|
</defs></svg>
|
||||||
|
|
||||||
|
<p style="font-size:.78rem;color:#4a4a4a;margin:.2rem 0 .3rem">Die Stufen hinter der Zeile deuten den Sim-Inhalt an — so sieht man, wie wenig die schwebende Zeile verdeckt. Logo, Titel und Level sitzen in <b>einer</b> Pille auf gleicher Höhe wie die Buttons.</p>
|
||||||
|
|
||||||
|
<div class="khz-variant">
|
||||||
|
<div class="khz-frame-lab">📱 iPad-Landscape (~1024 px)</div>
|
||||||
|
<div class="khz-scene khz-ipad" style="max-width:1024px">
|
||||||
|
<div class="khz-sky">SIM-INHALT</div><div class="khz-content"><i></i><i></i><i></i></div>
|
||||||
|
<div class="ghdr">
|
||||||
|
<div class="grp"><span class="logo"><img src="assets/img/bildLogo.png" alt=""></span><span class="title">Klimawächter</span><span class="ctx">Level 2</span></div>
|
||||||
|
<button class="btn" title="Startseite"><svg class="ic"><use href="#ic-home"/></svg></button>
|
||||||
|
<span class="sp"></span>
|
||||||
|
<button class="btn" title="Tempo"><svg class="ic"><use href="#ic-tempo"/></svg></button>
|
||||||
|
<button class="btn" title="Musik"><svg class="ic"><use href="#ic-music"/></svg></button>
|
||||||
|
<button class="btn" title="Ton"><svg class="ic"><use href="#ic-sound"/></svg></button>
|
||||||
|
<button class="btn" title="Neustart"><svg class="ic"><use href="#ic-restart"/></svg></button>
|
||||||
|
<button class="btn" title="Hilfe"><svg class="ic"><use href="#ic-help"/></svg></button>
|
||||||
|
<button class="btn" title="Glossar (neuer Tab)"><svg class="ic"><use href="#ic-glossar"/></svg></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="khz-frame-lab">🖥️ Full-HD PC (1920 px — mehr Platz zwischen den Elementen)</div>
|
||||||
|
<div class="khz-scroll"><div class="khz-scene khz-fhd" style="width:1600px">
|
||||||
|
<div class="khz-sky">SIM-INHALT</div><div class="khz-content"><i></i><i></i><i></i><i></i><i></i></div>
|
||||||
|
<div class="ghdr">
|
||||||
|
<div class="grp"><span class="logo"><img src="assets/img/bildLogo.png" alt=""></span><span class="title">Klimawächter</span><span class="ctx">Level 2</span></div>
|
||||||
|
<button class="btn" title="Startseite"><svg class="ic"><use href="#ic-home"/></svg></button>
|
||||||
|
<span class="sp"></span>
|
||||||
|
<button class="btn" title="Tempo"><svg class="ic"><use href="#ic-tempo"/></svg></button>
|
||||||
|
<button class="btn" title="Musik"><svg class="ic"><use href="#ic-music"/></svg></button>
|
||||||
|
<button class="btn" title="Ton"><svg class="ic"><use href="#ic-sound"/></svg></button>
|
||||||
|
<button class="btn" title="Neustart"><svg class="ic"><use href="#ic-restart"/></svg></button>
|
||||||
|
<button class="btn" title="Hilfe"><svg class="ic"><use href="#ic-help"/></svg></button>
|
||||||
|
<button class="btn" title="Glossar (neuer Tab)"><svg class="ic"><use href="#ic-glossar"/></svg></button>
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="khz-frame-lab">Aufgeklappt · Tempo — öffnet LINKS vom Button, bleibt offen (nur Echtzeit-Sims) · 30 % transparent</div>
|
||||||
|
<div class="khz-pop">
|
||||||
|
<button class="khz-seg" title="Pause"><svg class="ic"><use href="#ic-pause"/></svg></button>
|
||||||
|
<button class="khz-seg on">1×</button>
|
||||||
|
<button class="khz-seg">2×</button>
|
||||||
|
<button class="khz-seg">4×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="khz-frame-lab">Tempo-Button im Header — zeigt IMMER den aktuellen Stand</div>
|
||||||
|
<div style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">
|
||||||
|
<button class="khz-spd" title="Normal"><b>1×</b></button>
|
||||||
|
<button class="khz-spd" title="Doppelt"><b>2×</b></button>
|
||||||
|
<button class="khz-spd act" title="Logistik: Auto-Tempo"><b>8×</b><small>Auto</small></button>
|
||||||
|
<button class="khz-spd" title="Pausiert"><svg class="ic"><use href="#ic-pause"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:.76rem;color:#6a6a6a;margin:.5rem 0 0">Klick öffnet das Tempo-Panel und lässt es offen bis zum erneuten Klick (Toggle) — genauso beim Musik-Player. Der Button selbst zeigt jederzeit den Speed: <b>1×</b>, <b>2×</b>, <b>8× Auto</b> (z. B. Logistik) oder <b>⏸ Pause</b>. So bleibt der Zustand sichtbar, ohne dass das Panel offen bleiben muss.</p>
|
||||||
|
|
||||||
|
<div class="khz-frame-lab">Aufgeklappt · Musikplayer — klappt nach UNTEN auf, schließt nach Song-Auswahl · 30 % transparent</div>
|
||||||
|
<div class="khz-pop khz-music">
|
||||||
|
<button class="khz-seg on" title="Pause"><svg class="ic"><use href="#ic-pause"/></svg></button>
|
||||||
|
<span class="khz-select"><svg class="ic"><use href="#ic-music"/></svg> Ruhige Klänge <svg class="ic"><use href="#ic-caret"/></svg></span>
|
||||||
|
<svg class="ic" style="width:18px;height:18px;color:#1f4e5a"><use href="#ic-sound"/></svg>
|
||||||
|
<span class="khz-range"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background:#f0f7f1;border:1px solid #cfe6d2;border-radius:8px;padding:.7rem .9rem;font-size:.8rem;color:#2f5e39;margin-top:1rem">
|
||||||
|
<b>Aufbau:</b> links die Pille Kreis-Logo · Titel · Level (gleiche Höhe) + Home — rechts Tempo (nur Echtzeit-Sims: Klima, Fluss, Tourismus, Logistik) · Musik · Ton · Neustart · Hilfe · Glossar (neuer Tab). Eigene SVG-Icons im App-Stil. <b>Untermenüs:</b> Tempo öffnet <b>links</b> vom Button und bleibt offen (Zustand steht ohnehin im Button); Musik klappt <b>nach unten</b> auf und schließt nach der Song-Auswahl; beide 30 % transparent hinterlegt als Untermenü-Signal, Toggle per Klick. Sim-eigene KPIs (Budget, Jahr …) bleiben in einer eigenen Unterzeile, nicht in der Markenzeile.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ============================================================== -->
|
<!-- ============================================================== -->
|
||||||
<div class="card" id="layout">
|
<div class="card" id="layout">
|
||||||
<div class="card-h">Layout & Spacing</div>
|
<div class="card-h">Layout & Spacing</div>
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex,nofollow">
|
||||||
|
<title>GeoGraSim — ToDo / Review (Admin)</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',system-ui,sans-serif;background:#f7f6f3;min-height:100vh;color:#2a2a2a;-webkit-text-size-adjust:100%}
|
||||||
|
.topbar{background:#fff;padding:.6rem .9rem;display:flex;align-items:center;gap:.6rem;border-bottom:1px solid rgba(0,0,0,.06);position:sticky;top:0;z-index:20;flex-wrap:wrap}
|
||||||
|
.topbar h1{font-size:.9rem;font-weight:800;color:#4a7c8a;flex:1;display:flex;align-items:center;gap:.45rem;min-width:0}
|
||||||
|
.topbar h1 img{height:26px}
|
||||||
|
.btn-sm{padding:.35rem .6rem;border:1px solid rgba(0,0,0,.08);border-radius:6px;background:#fff;font-size:.7rem;font-weight:600;cursor:pointer;color:#4a4a4a;text-decoration:none;white-space:nowrap}
|
||||||
|
.btn-sm:hover{background:#dae8ec;color:#4a7c8a}
|
||||||
|
.btn-sm.primary{background:#4a7c8a;color:#fff;border-color:#4a7c8a}
|
||||||
|
.wrap{max-width:820px;margin:0 auto;padding:.9rem}
|
||||||
|
.lead{font-size:.8rem;color:#6a6a6a;margin-bottom:.7rem;line-height:1.5}
|
||||||
|
.lead b{color:#3a3a3a}
|
||||||
|
.bar{height:8px;border-radius:99px;background:#e6e3dc;overflow:hidden;margin:.5rem 0 1rem}
|
||||||
|
.bar>i{display:block;height:100%;background:linear-gradient(90deg,#4a7c8a,#6aa );background:#4a7c8a;width:0;transition:width .3s}
|
||||||
|
.toolbar{display:flex;gap:.4rem;flex-wrap:wrap;margin-bottom:1rem}
|
||||||
|
.sec{margin-bottom:1.4rem}
|
||||||
|
.sec-h{font-size:.7rem;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:#8a8a8a;margin:0 .2rem .5rem;display:flex;align-items:center;gap:.5rem}
|
||||||
|
.sec-h .pill{font-size:.6rem;padding:.12rem .5rem;border-radius:99px;font-weight:800;letter-spacing:.02em}
|
||||||
|
.p1 .pill{background:#f6dcd6;color:#a33f28}
|
||||||
|
.p2 .pill{background:#fbeccd;color:#8a6510}
|
||||||
|
.p3 .pill{background:#dceadd;color:#2f6b3a}
|
||||||
|
.p4 .pill{background:#e2e8ee;color:#42607a}
|
||||||
|
.item{background:#fff;border-radius:11px;box-shadow:0 1px 3px rgba(0,0,0,.04);border:1px solid rgba(0,0,0,.05);padding:.7rem .8rem;margin-bottom:.6rem;border-left:4px solid #ccc}
|
||||||
|
.p1 .item{border-left-color:#c9503a}.p2 .item{border-left-color:#e0a020}.p3 .item{border-left-color:#4e9c5e}.p4 .item{border-left-color:#6a8db0}
|
||||||
|
.item.done{opacity:.55}
|
||||||
|
.item-top{display:flex;gap:.6rem;align-items:flex-start}
|
||||||
|
.chk{appearance:none;-webkit-appearance:none;flex:0 0 auto;width:22px;height:22px;border:2px solid #bcbcbc;border-radius:6px;cursor:pointer;position:relative;margin-top:.05rem;background:#fff}
|
||||||
|
.chk:checked{background:#4a7c8a;border-color:#4a7c8a}
|
||||||
|
.chk:checked::after{content:"✓";position:absolute;inset:0;color:#fff;font-size:.85rem;font-weight:800;display:flex;align-items:center;justify-content:center}
|
||||||
|
.item .ttl{font-size:.88rem;font-weight:700;color:#2a2a2a;line-height:1.3}
|
||||||
|
.item.done .ttl{text-decoration:line-through}
|
||||||
|
.item .desc{font-size:.76rem;color:#6a6a6a;line-height:1.5;margin-top:.25rem}
|
||||||
|
.item .desc code{background:#f0efe9;padding:.05rem .3rem;border-radius:4px;font-size:.72rem}
|
||||||
|
.note{width:100%;margin-top:.5rem;border:1.5px solid rgba(0,0,0,.1);border-radius:7px;padding:.45rem .55rem;font-family:inherit;font-size:.8rem;resize:vertical;min-height:2.2rem;color:#2a2a2a;background:#fcfcfa}
|
||||||
|
.note:focus{outline:none;border-color:#4a7c8a;background:#fff}
|
||||||
|
.note.has{border-color:#e0a020;background:#fffdf6}
|
||||||
|
#status{padding:2rem;text-align:center;color:#8a8a8a}
|
||||||
|
dialog{border:none;border-radius:12px;padding:0;max-width:640px;width:92%;box-shadow:0 20px 60px rgba(0,0,0,.3)}
|
||||||
|
dialog::backdrop{background:rgba(0,0,0,.45)}
|
||||||
|
.dlg-h{padding:.8rem 1rem;font-weight:800;color:#4a7c8a;border-bottom:1px solid rgba(0,0,0,.07);display:flex;align-items:center;justify-content:space-between}
|
||||||
|
.dlg-b{padding:1rem;max-height:60vh;overflow:auto}
|
||||||
|
.dlg-b textarea{width:100%;min-height:40vh;border:1px solid rgba(0,0,0,.12);border-radius:8px;padding:.6rem;font-family:ui-monospace,monospace;font-size:.75rem}
|
||||||
|
.foot{font-size:.68rem;color:#a0a0a0;text-align:center;margin:1.5rem 0 2rem}
|
||||||
|
@media(max-width:520px){ .wrap{padding:.7rem} .topbar h1{font-size:.82rem} }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar">
|
||||||
|
<h1><img src="assets/img/geograsim_t.svg" alt="GeoGraSim"> 📋 ToDo / Review</h1>
|
||||||
|
<button class="btn-sm primary" onclick="showNotes()">📝 Notizen</button>
|
||||||
|
<a href="admin-licenses.html" class="btn-sm">← Admin</a>
|
||||||
|
<a href="admin.html" class="btn-sm">Logout</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status">Prüfe Zugang…</div>
|
||||||
|
|
||||||
|
<div class="wrap" id="app" style="display:none">
|
||||||
|
<p class="lead">Lebende ToDo-/Review-Liste. Häkchen = <b>erledigt/gesichtet</b>, Notizfeld je Punkt für deine Review-Gedanken. Alles wird <b>lokal in diesem Browser</b> gespeichert (kein Server) — ideal für unterwegs. Mit <b>📝 Notizen</b> holst du dir alle Notizen als Text zum Kopieren.</p>
|
||||||
|
<div class="bar"><i id="prog"></i></div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="btn-sm" onclick="filterDone()"><span id="fd">Erledigte ausblenden</span></button>
|
||||||
|
<button class="btn-sm" onclick="resetAll()">↺ Häkchen zurücksetzen</button>
|
||||||
|
</div>
|
||||||
|
<div id="list"></div>
|
||||||
|
<div class="foot">Stand: 2026-08-20 · gepflegt von Atlas · nur Admin</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog id="notesDlg">
|
||||||
|
<div class="dlg-h"><span>📝 Alle Review-Notizen</span><button class="btn-sm" onclick="document.getElementById('notesDlg').close()">✕</button></div>
|
||||||
|
<div class="dlg-b">
|
||||||
|
<textarea id="notesOut" readonly></textarea>
|
||||||
|
<div style="margin-top:.6rem;display:flex;gap:.4rem"><button class="btn-sm primary" onclick="copyNotes()">In Zwischenablage</button></div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ---- Auth-Gate (wie andere Admin-Seiten) ----
|
||||||
|
var BASE = location.pathname.replace(/\/admin-todo\.html$/,'').replace(/\/$/,'');
|
||||||
|
fetch(BASE + '/api/admin', {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin', body:JSON.stringify({action:'status'})})
|
||||||
|
.then(function(r){ return r.json(); })
|
||||||
|
.then(function(r){
|
||||||
|
if (!r || !r.authenticated) { location.href = 'admin.html'; return; }
|
||||||
|
document.getElementById('status').style.display='none';
|
||||||
|
document.getElementById('app').style.display='';
|
||||||
|
render();
|
||||||
|
})
|
||||||
|
.catch(function(){ location.href = 'admin.html'; });
|
||||||
|
|
||||||
|
// ---- ToDo-Daten (id STABIL halten, damit Notizen erhalten bleiben) ----
|
||||||
|
var TODO = [
|
||||||
|
{ sec:'Auth-Cutover: v3 → Produktion', prio:1, items:[
|
||||||
|
{id:'csrf', t:'CSRF-Token für POST-Endpoints', d:'Aktuell nur <code>SameSite=Lax</code>. Vor jedem Prod-Cutover des JWT-Backends CSRF-Token ergänzen (Double-Submit oder synchronizer).'},
|
||||||
|
{id:'cutover-dual', t:'Doppelgleisiger Cutover (alt + neu parallel)', d:'Übergangszeit altes <code>$_SESSION</code>/Cookie UND neues JWT akzeptieren, Migration beim nächsten Login. 9 echte Lehrer + Schüler mit 30-Tage-Cookies dürfen NICHT rausfliegen.'},
|
||||||
|
{id:'admin-pending-redis', t:'admin_pending (2FA-Zwischenstand) → Redis', d:'Liegt im jwt-Modus noch in nativer $_SESSION. Für echtes k8s (Multi-Pod) nach Redis. Auf Single-Container-v3 aktuell ok.'},
|
||||||
|
{id:'reporting-fk', t:'Reporting auf student_id-FK ziehen', d:'Voraussetzung, dass Schüler ganz ohne DB-Session-Zeile auskommen (assessments.session_id → student_id).'},
|
||||||
|
{id:'prod-redis', t:'Prod-Redis + JWT_SECRET, Cutover nachts', d:'Eigener Prod-Redis-Container + JWT_SECRET/REDIS_URL in Prod-.env.production. Umstellung nachts, wenn niemand online ist.'},
|
||||||
|
]},
|
||||||
|
{ sec:'Offener Prod-Deploy (Feature-Bündel)', prio:2, items:[
|
||||||
|
{id:'deploy-batch', t:'Bündel db4b75c5..HEAD auf Prod', d:'Non-breaking (AUTH bleibt <code>session</code>). <b>Enthält den kritischen PHPMailer-vendor-Fix</b> — ohne den bricht Prod-Mailversand. vendor/ auch ins Top-Level spiegeln.'},
|
||||||
|
{id:'deploy-content', t:'Inhalt des Bündels prüfen', d:'Lehrer-Aktivitätsstreifen · Tourismusregion-Balance+UI · Weltküche-Gravitation+Artikel+Sätze · Logistik-Pause-Fix · assessment-Skip-Fix · admin-weltkueche QA · Passstraße-Karte · Lehrplan-Prod-Mapping.'},
|
||||||
|
]},
|
||||||
|
{ sec:'v3-Härtung & Infrastruktur', prio:3, items:[
|
||||||
|
{id:'v3-noindex', t:'v3 schützen (öffentlich mit Echt-Daten)', d:'noindex ist gesetzt; überlegen: Basic-Auth/IP-Schutz vor v3, da geklonte Echt-DB.'},
|
||||||
|
{id:'secret-scrub', t:'Secret-Scrub vor Bitbucket/Bruder-Deploy', d:'Prod-DB-Passwort im Klartext in getrackten Deploy-Skripten; Voll-DB-Dumps getrackt (DSGVO). → Secrets in .env, Dumps untracken, Passwort rotieren.'},
|
||||||
|
{id:'bitbucket', t:'App-Ordner → Bitbucket für neues Prod-System', d:'Bruder baut Deployment-Umgebung; wir pullen/pushen. Zwei Origins + eigene .gitignore-Regeln.'},
|
||||||
|
]},
|
||||||
|
{ sec:'Feinschliff-Backlog (Features)', prio:4, items:[
|
||||||
|
{id:'tr-stars', t:'Tourismusregion: Sterne-Kurve/Eskalation', d:'Umwelt beißt jetzt (Pass 1). Sterne-Kurve + eskalierende Ansprüche über Wellen tunen — braucht deinen Playtest (Feeling headless nicht messbar).'},
|
||||||
|
{id:'wk-gesamt', t:'Weltküche-Gesamt-Kachel (teacher.html)', d:'Aggregat-Kachel in der Lehreransicht.'},
|
||||||
|
{id:'lehrplan-switch', t:'Lehrplan-Länderschalter (D-A-CH)', d:'Umschalter AT/DE/CH/LI in der Lehrplan-/Modul-Ansicht.'},
|
||||||
|
{id:'preview-rest', t:'display_preview: klima/kofferdetektiv/stadt', d:'Fehlen noch in der Screenshot-Galerie (andere Einstiegspfade).'},
|
||||||
|
{id:'strip-v2', t:'Aktivitätsstreifen v2: „läuft noch"', d:'Aktive live_sessions einblenden (rechts-offener Balken für laufende Runden).'},
|
||||||
|
]},
|
||||||
|
{ sec:'Ältere offene Entscheidungen', prio:4, items:[
|
||||||
|
{id:'klassen-exkl', t:'Klassenaufgaben: Exklusiv-Sperre', d:'Vertagt — nur eine Sim gleichzeitig freigeben?'},
|
||||||
|
{id:'klima-save', t:'Klima Save-Cleanup nach Game-Over', d:'Konzept steht, dein OK für Implementierung offen.'},
|
||||||
|
{id:'balance-rest', t:'Balancing sonnensystem / energiemanager', d:'Aus dem großen Review offen.'},
|
||||||
|
]},
|
||||||
|
];
|
||||||
|
|
||||||
|
var KEY='ggs_todo_review_v1';
|
||||||
|
function load(){ try{ return JSON.parse(localStorage.getItem(KEY)||'{}'); }catch(e){ return {}; } }
|
||||||
|
function save(s){ localStorage.setItem(KEY, JSON.stringify(s)); }
|
||||||
|
var state = load();
|
||||||
|
var hideDone = false;
|
||||||
|
|
||||||
|
function render(){
|
||||||
|
var host = document.getElementById('list');
|
||||||
|
host.innerHTML = '';
|
||||||
|
var total=0, done=0;
|
||||||
|
TODO.forEach(function(g){
|
||||||
|
var sec = document.createElement('div');
|
||||||
|
sec.className = 'sec p'+g.prio;
|
||||||
|
var pill = g.prio===1?'Priorität 1':g.prio===2?'Priorität 2':g.prio===3?'Priorität 3':'Backlog';
|
||||||
|
sec.innerHTML = '<div class="sec-h"><span class="pill">'+pill+'</span> '+g.sec+'</div>';
|
||||||
|
g.items.forEach(function(it){
|
||||||
|
total++;
|
||||||
|
var st = state[it.id] || {};
|
||||||
|
if (st.done) done++;
|
||||||
|
if (hideDone && st.done) return;
|
||||||
|
var el = document.createElement('div');
|
||||||
|
el.className = 'item' + (st.done?' done':'');
|
||||||
|
var note = (st.note||'');
|
||||||
|
el.innerHTML =
|
||||||
|
'<div class="item-top">'+
|
||||||
|
'<input type="checkbox" class="chk" '+(st.done?'checked':'')+' data-id="'+it.id+'">'+
|
||||||
|
'<div style="flex:1;min-width:0"><div class="ttl">'+it.t+'</div><div class="desc">'+it.d+'</div></div>'+
|
||||||
|
'</div>'+
|
||||||
|
'<textarea class="note'+(note?' has':'')+'" data-id="'+it.id+'" placeholder="Notiz für Review…">'+note.replace(/</g,'<')+'</textarea>';
|
||||||
|
sec.appendChild(el);
|
||||||
|
});
|
||||||
|
host.appendChild(sec);
|
||||||
|
});
|
||||||
|
document.getElementById('prog').style.width = total? Math.round(done/total*100)+'%' : '0';
|
||||||
|
host.querySelectorAll('.chk').forEach(function(c){ c.addEventListener('change', function(){ var id=c.dataset.id; state[id]=state[id]||{}; state[id].done=c.checked; save(state); render(); }); });
|
||||||
|
host.querySelectorAll('.note').forEach(function(n){ n.addEventListener('input', function(){ var id=n.dataset.id; state[id]=state[id]||{}; state[id].note=n.value; save(state); n.classList.toggle('has', !!n.value); }); });
|
||||||
|
}
|
||||||
|
function filterDone(){ hideDone=!hideDone; document.getElementById('fd').textContent = hideDone?'Erledigte einblenden':'Erledigte ausblenden'; render(); }
|
||||||
|
function resetAll(){ if(!confirm('Alle Häkchen zurücksetzen? (Notizen bleiben)')) return; Object.keys(state).forEach(function(k){ if(state[k]) state[k].done=false; }); save(state); render(); }
|
||||||
|
function showNotes(){
|
||||||
|
var out=[];
|
||||||
|
TODO.forEach(function(g){ g.items.forEach(function(it){ var st=state[it.id]||{}; if(st.note){ out.push('• '+it.t+(st.done?' [erledigt]':'')+'\n '+st.note); } }); });
|
||||||
|
document.getElementById('notesOut').value = out.length? out.join('\n\n') : '(noch keine Notizen)';
|
||||||
|
document.getElementById('notesDlg').showModal();
|
||||||
|
}
|
||||||
|
function copyNotes(){ var t=document.getElementById('notesOut'); t.select(); try{ document.execCommand('copy'); }catch(e){} if(navigator.clipboard){ navigator.clipboard.writeText(t.value); } }
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -65,7 +65,42 @@
|
|||||||
<span class="chip" data-cat="europa" onclick="setCat(this,'europa')">Europa</span>
|
<span class="chip" data-cat="europa" onclick="setCat(this,'europa')">Europa</span>
|
||||||
<span class="chip" data-cat="exotisch" onclick="setCat(this,'exotisch')">Exotisch</span>
|
<span class="chip" data-cat="exotisch" onclick="setCat(this,'exotisch')">Exotisch</span>
|
||||||
<label style="font-size:.72rem;color:#6a6a6a;margin-left:.5rem"><input type="checkbox" id="onlyWarn" onchange="render()"> nur Auffällige ⚠</label>
|
<label style="font-size:.72rem;color:#6a6a6a;margin-left:.5rem"><input type="checkbox" id="onlyWarn" onchange="render()"> nur Auffällige ⚠</label>
|
||||||
|
<label style="font-size:.72rem;color:#6a6a6a;margin-left:.5rem">🛒 Land des Users:
|
||||||
|
<select id="userCty" onchange="setUser(this.value)" style="font-size:.72rem;padding:.15rem .3rem">
|
||||||
|
<option value="oesterreich">🇦🇹 Österreich</option>
|
||||||
|
<option value="deutschland">🇩🇪 Deutschland</option>
|
||||||
|
<option value="schweiz">🇨🇭 Schweiz</option>
|
||||||
|
</select></label>
|
||||||
</div>
|
</div>
|
||||||
|
<details class="maengel" open style="margin:1rem 0;border:1px solid #d9b38c;border-radius:8px;background:#fff9f0;overflow:hidden">
|
||||||
|
<summary style="cursor:pointer;padding:.7rem 1rem;font-weight:700;font-size:.95rem;color:#8a5a2b;background:#fdf2e2">🔧 Mängelliste — Zutaten vs. Rezept <span style="font-weight:400;color:#9a7a5a">(✅ komplett abgearbeitet 2026-08-22)</span></summary>
|
||||||
|
<div style="padding:.5rem 1rem 1rem">
|
||||||
|
<p style="font-size:.82rem;color:#2e6b34;margin:.3rem 0 .8rem;background:#e9f5ea;border-left:3px solid #4a7c4e;padding:.4rem .6rem"><b>✅ Erledigt (14 Fixes, Commit siehe git):</b> die Tabelle unten ist abgearbeitet — Zutaten raus/ersetzt, Guanciale neu für Carbonara. <b>Offen bleiben nur</b> die mediterranen Kräuter + Marillenknödel-Teig (weiter unten, deine Entscheidung).</p>
|
||||||
|
<p style="font-size:.8rem;color:#7a6a55;margin:.3rem 0 .8rem">Zutaten, die im <b>Rezepttext nicht vorkamen</b> oder <b>falsch</b> waren. „Vorschlag" = was stattdessen stimmt (umgesetzt).</p>
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:.82rem">
|
||||||
|
<thead><tr style="text-align:left;color:#8a5a2b;border-bottom:2px solid #e6cba3">
|
||||||
|
<th style="padding:.35rem .5rem">Gericht</th><th style="padding:.35rem .5rem">Zutat</th><th style="padding:.35rem .5rem">Befund</th><th style="padding:.35rem .5rem">Vorschlag</th></tr></thead>
|
||||||
|
<tbody style="color:#4a4038">
|
||||||
|
<tr style="border-bottom:1px solid #f0e2cc"><td style="padding:.35rem .5rem">Apfelstrudel</td><td style="padding:.35rem .5rem"><b>zitrone, essig</b></td><td style="padding:.35rem .5rem">kommen im Rezept nicht vor</td><td style="padding:.35rem .5rem">beide raus</td></tr>
|
||||||
|
<tr style="border-bottom:1px solid #f0e2cc"><td style="padding:.35rem .5rem">Rheinischer Sauerbraten</td><td style="padding:.35rem .5rem"><b>mehl</b></td><td style="padding:.35rem .5rem">Sauce bindet mit Lebkuchen, kein Mehl</td><td style="padding:.35rem .5rem">raus</td></tr>
|
||||||
|
<tr style="border-bottom:1px solid #f0e2cc"><td style="padding:.35rem .5rem">Schwarzwälder Kirschtorte</td><td style="padding:.35rem .5rem"><b>milch</b></td><td style="padding:.35rem .5rem">Rezept nutzt Schlagobers, keine Milch</td><td style="padding:.35rem .5rem">→ sahne (Pantry)</td></tr>
|
||||||
|
<tr style="border-bottom:1px solid #f0e2cc"><td style="padding:.35rem .5rem">Zürcher Geschnetzeltes</td><td style="padding:.35rem .5rem"><b>milch</b></td><td style="padding:.35rem .5rem">Rezept nutzt Rahm</td><td style="padding:.35rem .5rem">→ sahne (Pantry)</td></tr>
|
||||||
|
<tr style="border-bottom:1px solid #f0e2cc"><td style="padding:.35rem .5rem">Quiche Lorraine</td><td style="padding:.35rem .5rem"><b>milch</b></td><td style="padding:.35rem .5rem">Rezept nutzt Sahne + Crème fraîche</td><td style="padding:.35rem .5rem">→ sahne (Pantry)</td></tr>
|
||||||
|
<tr style="border-bottom:1px solid #f0e2cc"><td style="padding:.35rem .5rem">Pierogi Ruskie</td><td style="padding:.35rem .5rem"><b>milch</b></td><td style="padding:.35rem .5rem">Teig mit Wasser, keine Milch</td><td style="padding:.35rem .5rem">raus</td></tr>
|
||||||
|
<tr style="border-bottom:1px solid #f0e2cc"><td style="padding:.35rem .5rem">Pizza Margherita</td><td style="padding:.35rem .5rem"><b>hartweizen</b></td><td style="padding:.35rem .5rem">Pizza nutzt Tipo-00 (Weichweizen), kein Durum</td><td style="padding:.35rem .5rem">→ weiches Weizenmehl</td></tr>
|
||||||
|
<tr style="border-bottom:1px solid #f0e2cc"><td style="padding:.35rem .5rem">Spaghetti Carbonara</td><td style="padding:.35rem .5rem"><b>speck</b></td><td style="padding:.35rem .5rem">Rezept sagt ausdrücklich Guanciale, NICHT Speck</td><td style="padding:.35rem .5rem">→ guanciale</td></tr>
|
||||||
|
<tr style="border-bottom:1px solid #f0e2cc"><td style="padding:.35rem .5rem">Ungarisches Gulasch</td><td style="padding:.35rem .5rem"><b>tomaten</b></td><td style="padding:.35rem .5rem">Gulyás ist paprika-basiert, keine Tomaten im Rezept</td><td style="padding:.35rem .5rem">raus</td></tr>
|
||||||
|
<tr style="border-bottom:1px solid #f0e2cc"><td style="padding:.35rem .5rem">Ungarisches Gulasch</td><td style="padding:.35rem .5rem"><b>butter</b></td><td style="padding:.35rem .5rem">Rezept nutzt Schweineschmalz</td><td style="padding:.35rem .5rem">→ öl/schmalz</td></tr>
|
||||||
|
<tr style="border-bottom:1px solid #f0e2cc"><td style="padding:.35rem .5rem">Gemüse-Curry</td><td style="padding:.35rem .5rem"><b>olivenoel</b></td><td style="padding:.35rem .5rem">Curry nutzt neutrales Öl, kein Olivenöl</td><td style="padding:.35rem .5rem">→ oel (neutral)</td></tr>
|
||||||
|
<tr style="border-bottom:1px solid #f0e2cc"><td style="padding:.35rem .5rem">Pad Thai</td><td style="padding:.35rem .5rem"><b>olivenoel</b></td><td style="padding:.35rem .5rem">Wok-Gericht, neutrales/Erdnussöl</td><td style="padding:.35rem .5rem">→ oel (neutral)</td></tr>
|
||||||
|
<tr style="border-bottom:1px solid #f0e2cc"><td style="padding:.35rem .5rem">Guacamole</td><td style="padding:.35rem .5rem"><b>olivenoel</b></td><td style="padding:.35rem .5rem">Guacamole hat kein Öl im Rezept</td><td style="padding:.35rem .5rem">raus</td></tr>
|
||||||
|
<tr style="border-bottom:1px solid #f0e2cc"><td style="padding:.35rem .5rem">Tiroler Gröstl</td><td style="padding:.35rem .5rem"><b>kuemmel</b></td><td style="padding:.35rem .5rem">kommt im Rezept nicht vor (nur Majoran)</td><td style="padding:.35rem .5rem">raus oder ins Rezept</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p style="font-size:.82rem;color:#2e6b34;margin:.9rem 0 .3rem;background:#e9f5ea;border-left:3px solid #4a7c4e;padding:.4rem .6rem"><b>✅ Entschieden:</b> <b>Mediterrane Kräuter bleiben</b> (Thomas: lassen) bei Zürcher, Pierogi, Gulasch, Moussaka, Falafel. <b>Marillenknödel</b> → Rezept auf reinen Topfenteig umgestellt (Erdäpfel raus).</p>
|
||||||
|
<p style="font-size:.8rem;color:#5a5048;margin:.2rem 0 .3rem"><b>📝 Rein kosmetisch (offen gelassen):</b> Steirisches Wurzelfleisch — Pfeffer nur implizit · Tiroler Gröstl / Brezel — Butter vs. Butterschmalz/Frischkäse</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
<div id="status">Lade Daten…</div>
|
<div id="status">Lade Daten…</div>
|
||||||
<div id="list"></div>
|
<div id="list"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,6 +109,8 @@ var BASE = location.pathname.replace(/\/admin-weltkueche\.html$/, '');
|
|||||||
var D = 'sims/weltkueche/assets/data/';
|
var D = 'sims/weltkueche/assets/data/';
|
||||||
var C = {}, PM = {}, DISHES = [];
|
var C = {}, PM = {}, DISHES = [];
|
||||||
var _cat = '';
|
var _cat = '';
|
||||||
|
var WK_USER = 'oesterreich'; // Teil B: Land des Users (Consumer-Anker) — via Umschalter
|
||||||
|
function setUser(v){ WK_USER = v || 'oesterreich'; render(); }
|
||||||
|
|
||||||
// ---- Spiel-Logik gespiegelt (siehe game.html buildTaskFor) ----
|
// ---- Spiel-Logik gespiegelt (siehe game.html buildTaskFor) ----
|
||||||
function mrank(m){ return m === 'eu' ? 0 : (m === 'world-far' || m === 'local') ? 2 : 1; }
|
function mrank(m){ return m === 'eu' ? 0 : (m === 'world-far' || m === 'local') ? 2 : 1; }
|
||||||
@@ -87,16 +124,37 @@ function wkDist(a, b){
|
|||||||
}
|
}
|
||||||
// Klassifiziert alle Produzenten einer Zutat für ein Gericht.
|
// Klassifiziert alle Produzenten einer Zutat für ein Gericht.
|
||||||
// Gibt { correct:[keys], distractors:[keys], branch } zurück (Pools, ohne Zufalls-Ziehung).
|
// Gibt { correct:[keys], distractors:[keys], branch } zurück (Pools, ohne Zufalls-Ziehung).
|
||||||
function classify(ingKey, heimat){
|
function classify(ingKey, heimat, isSig){
|
||||||
var ing = PM[ingKey]; if (!ing) return null;
|
var ing = PM[ingKey]; if (!ing) return null;
|
||||||
var producers = ing.producers || {};
|
var producers = ing.producers || {};
|
||||||
var keys = Object.keys(producers).filter(function(c){ return C[c]; });
|
var keys = Object.keys(producers).filter(function(c){ return C[c]; });
|
||||||
|
// ── FEST VERDRAHTETE HERKUNFT: feste Länderliste + feste Distraktoren.
|
||||||
|
if (ing.correct && ing.correct.length) {
|
||||||
|
var fc = ing.correct.filter(function(c){ return C[c]; });
|
||||||
|
var fd = (ing.distractors||[]).filter(function(c){ return C[c] && fc.indexOf(c)<0; });
|
||||||
|
return { correct: fc, distractors: fd, branch:'fest', grav:function(){return 0;} };
|
||||||
|
}
|
||||||
|
// ── CONSUMER-REGEL (Teil B): Grundzutat, im Land des Users heimisch → kommt aus
|
||||||
|
// dem Land des Users (unabhängig vom Gericht-Land). Signaturzutaten ausgenommen.
|
||||||
|
if (!isSig && ing.heimisch && ing.heimisch.indexOf(WK_USER) >= 0 && C[WK_USER]) {
|
||||||
|
var cmarket = (ing.market||[]).filter(function(c){ return C[c] && c !== WK_USER; });
|
||||||
|
var ccorrect = [WK_USER].concat(cmarket).slice(0,3);
|
||||||
|
if (ccorrect.length < 2){ for (var hi=0; hi<(ing.heimisch||[]).length; hi++){ var hh=ing.heimisch[hi]; if(C[hh]&&ccorrect.indexOf(hh)<0){ccorrect.push(hh);break;} } }
|
||||||
|
var cdistr;
|
||||||
|
if (ing.distractors && ing.distractors.length){ cdistr = ing.distractors.filter(function(c){ return C[c] && ccorrect.indexOf(c)<0; }); }
|
||||||
|
else { var cfar = keys.filter(function(c){ var mm=producers[c].market; return (mm==='world-far'||mm==='local') && ccorrect.indexOf(c)<0; }).sort(function(a,b){ return (producers[b].t||0)-(producers[a].t||0); });
|
||||||
|
var cclim = (ing.climate_possible||[]).filter(function(c){ return C[c] && ccorrect.indexOf(c)<0; }); cdistr = cfar.concat(cclim); }
|
||||||
|
return { correct: ccorrect, distractors: cdistr, branch:'consumer', grav:function(){return 0;} };
|
||||||
|
}
|
||||||
// Heimat-Zweig: Heimat erzeugt die Zutat selbst
|
// Heimat-Zweig: Heimat erzeugt die Zutat selbst
|
||||||
if (heimat && C[heimat] && producers[heimat]) {
|
if (heimat && C[heimat] && producers[heimat]) {
|
||||||
var grav = function(c){ return (producers[c].t || 0) / wkDist(heimat, c); };
|
var grav = function(c){ return (producers[c].t || 0) / wkDist(heimat, c); };
|
||||||
var realistic = keys.filter(function(c){ return c !== heimat && mrank(producers[c].market) < 2; })
|
var realistic = keys.filter(function(c){ return c !== heimat && mrank(producers[c].market) < 2; })
|
||||||
.sort(function(a,b){ return (mrank(producers[a].market)-mrank(producers[b].market)) || (grav(b)-grav(a)); });
|
.sort(function(a,b){ return (mrank(producers[a].market)-mrank(producers[b].market)) || (grav(b)-grav(a)); });
|
||||||
var correct = [heimat]; if (realistic[0]) correct.push(realistic[0]);
|
// Kuratierte Zweitlieferanten (correct_for) übersteuern die Gravitation — wie im Spiel.
|
||||||
|
var curated = ((ing.correct_for && ing.correct_for[heimat]) || []).filter(function(c){ return C[c] && c !== heimat; });
|
||||||
|
var correct = curated.length ? [heimat].concat(curated) : [heimat];
|
||||||
|
if (!curated.length && realistic[0]) correct.push(realistic[0]);
|
||||||
if (correct.length < 2){ var any = keys.filter(function(c){ return c!==heimat && correct.indexOf(c)<0; })
|
if (correct.length < 2){ var any = keys.filter(function(c){ return c!==heimat && correct.indexOf(c)<0; })
|
||||||
.sort(function(a,b){ return (producers[b].t||0)-(producers[a].t||0); }); if(any[0]) correct.push(any[0]); }
|
.sort(function(a,b){ return (producers[b].t||0)-(producers[a].t||0); }); if(any[0]) correct.push(any[0]); }
|
||||||
var farP = keys.filter(function(c){ return correct.indexOf(c)<0 && mrank(producers[c].market)===2; });
|
var farP = keys.filter(function(c){ return correct.indexOf(c)<0 && mrank(producers[c].market)===2; });
|
||||||
@@ -140,7 +198,7 @@ function render(){
|
|||||||
var rows = ''; var dishHasWarn = false;
|
var rows = ''; var dishHasWarn = false;
|
||||||
ings.forEach(function(k){
|
ings.forEach(function(k){
|
||||||
if (q && (d.name||'').toLowerCase().indexOf(q)<0 && (PM[k].nm||k).toLowerCase().indexOf(q)<0) return;
|
if (q && (d.name||'').toLowerCase().indexOf(q)<0 && (PM[k].nm||k).toLowerCase().indexOf(q)<0) return;
|
||||||
var cl = classify(k, heimat); if(!cl) return;
|
var cl = classify(k, heimat, !!(d.signature && d.signature[k])); if(!cl) return;
|
||||||
// Auffällig NUR im Heimat-Zweig: die Zutat ist lokal erzeugbar (Heimat baut
|
// Auffällig NUR im Heimat-Zweig: die Zutat ist lokal erzeugbar (Heimat baut
|
||||||
// sie an), trotzdem ist ein „richtiges" Land > 1500 km weg → echter Ausreißer.
|
// sie an), trotzdem ist ein „richtiges" Land > 1500 km weg → echter Ausreißer.
|
||||||
// Welt-Zutaten (Pfeffer aus Vietnam etc.) sind zu Recht fern → keine Warnung.
|
// Welt-Zutaten (Pfeffer aus Vietnam etc.) sind zu Recht fern → keine Warnung.
|
||||||
@@ -152,7 +210,7 @@ function render(){
|
|||||||
if (onlyWarn && !warn) return;
|
if (onlyWarn && !warn) return;
|
||||||
var okChips = cl.correct.map(function(c){ return ctyChip(c,'ok',k,heimat,true); }).join('');
|
var okChips = cl.correct.map(function(c){ return ctyChip(c,'ok',k,heimat,true); }).join('');
|
||||||
var distChips = cl.distractors.slice(0,6).map(function(c){ return ctyChip(c,'dist',k,heimat,false); }).join('');
|
var distChips = cl.distractors.slice(0,6).map(function(c){ return ctyChip(c,'dist',k,heimat,false); }).join('');
|
||||||
rows += '<tr><td class="ing-nm">'+(PM[k].ic||'')+' '+(PM[k].nm||k)+'<small>'+(cl.branch==='heimat'?'Heimat-Regel':'Welt-Zutat')+'</small></td>'
|
rows += '<tr><td class="ing-nm">'+(PM[k].ic||'')+' '+(PM[k].nm||k)+'<small>'+(cl.branch==='fest'?'📌 Feste Herkunft':(cl.branch==='consumer'?'🛒 Consumer-Regel (User-Land)':(cl.branch==='heimat'?'Heimat-Regel':'Welt-Zutat')))+'</small></td>'
|
||||||
+ '<td><div class="colhead">richtig</div>'+(okChips||'—')+warn+'</td>'
|
+ '<td><div class="colhead">richtig</div>'+(okChips||'—')+warn+'</td>'
|
||||||
+ '<td><div class="colhead">Distraktoren (Pool)</div>'+(distChips||'—')+'</td></tr>';
|
+ '<td><div class="colhead">Distraktoren (Pool)</div>'+(distChips||'—')+'</td></tr>';
|
||||||
});
|
});
|
||||||
@@ -174,10 +232,11 @@ fetch(BASE + '/api/admin', {method:'POST', headers:{'Content-Type':'application/
|
|||||||
.then(function(r){return r.json();})
|
.then(function(r){return r.json();})
|
||||||
.then(function(r){
|
.then(function(r){
|
||||||
if (!r.authenticated){ location.href = 'admin.html'; return; }
|
if (!r.authenticated){ location.href = 'admin.html'; return; }
|
||||||
|
var _bust = '?v=' + Date.now(); // Cache-Busting: Admin-QA zeigt IMMER die frischen Daten, nie eine gecachte JSON
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
fetch(D+'countries-full.json').then(function(r){return r.json();}),
|
fetch(D+'countries-full.json'+_bust, {cache:'no-store'}).then(function(r){return r.json();}),
|
||||||
fetch(D+'production-matrix.json').then(function(r){return r.json();}),
|
fetch(D+'production-matrix.json'+_bust, {cache:'no-store'}).then(function(r){return r.json();}),
|
||||||
fetch(D+'dishes.json').then(function(r){return r.json();}),
|
fetch(D+'dishes.json'+_bust, {cache:'no-store'}).then(function(r){return r.json();}),
|
||||||
]).then(function(a){
|
]).then(function(a){
|
||||||
C = a[0]; PM = a[1];
|
C = a[0]; PM = a[1];
|
||||||
DISHES = Object.keys(a[2]).filter(function(k){return k[0]!=='_';}).map(function(id){ return Object.assign({id:id}, a[2][id]); });
|
DISHES = Object.keys(a[2]).filter(function(k){return k[0]!=='_';}).map(function(id){ return Object.assign({id:id}, a[2][id]); });
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
/* ============================================================
|
||||||
|
GGS-HEADER — Einheitliche Sim-Kopfzeile (Entwurf B, „schwebend")
|
||||||
|
------------------------------------------------------------
|
||||||
|
Wird von ggs-header.js aktiviert (Klasse .ggs-header--float auf
|
||||||
|
<header class="ggs-header">). Überführt das vorhandene
|
||||||
|
Design-System-Markup in die schwebende Pillen-Optik:
|
||||||
|
Kreis-Logo · Titel · Level = EINE Pille, rechts schwebende
|
||||||
|
Icon-Buttons, Tempo-/Musik-Panels als Untermenü (30 % transparent).
|
||||||
|
Styling ist auf .ggs-header--float gescoped → betrifft nur
|
||||||
|
Sims, die die Komponente laden.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* Werte 1:1 aus admin-styleguide.html (Entwurf B, von Thomas bestätigt) */
|
||||||
|
.ggs-header--float {
|
||||||
|
display: flex !important; /* selbsttragend, auch ohne design-system.css */
|
||||||
|
align-items: center;
|
||||||
|
background: transparent !important;
|
||||||
|
border-bottom: none !important;
|
||||||
|
height: auto !important;
|
||||||
|
min-height: 50px;
|
||||||
|
gap: .5rem !important;
|
||||||
|
padding: .55rem .7rem !important;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1000;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
/* Falls die Sim-Leiste ein Panel-Styling mitbringt (blur/shadow/radius):
|
||||||
|
strippen, damit nur die einzelnen schwebenden Pillen sichtbar sind. */
|
||||||
|
backdrop-filter: none !important;
|
||||||
|
-webkit-backdrop-filter: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overlay-Modus: Zeile schwebt über Vollbild-Canvas (Heli, Tourismus …).
|
||||||
|
Der Sim-Canvas muss dann bis top:0 laufen; Sim-eigene Top-HUDs ggf.
|
||||||
|
nach unten schieben (siehe Kofferdetektiv). */
|
||||||
|
.ggs-header--float.ggs-header--overlay {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* WICHTIG bei Vollbild-Karten/Canvas: die schwebende Zeile darf die
|
||||||
|
Karte darunter nicht blockieren. Der Container ist klick-durchlässig,
|
||||||
|
nur die tatsächlichen Bedienelemente fangen Klicks. */
|
||||||
|
.ggs-header--float { pointer-events: none; }
|
||||||
|
.ggs-header--float .ggs-hpille,
|
||||||
|
.ggs-header--float .ggs-btn-ghost,
|
||||||
|
.ggs-header--float .ggs-header-actions > button,
|
||||||
|
.ggs-header--float .ggs-header-actions > a,
|
||||||
|
.ggs-header--float .ggs-tempo,
|
||||||
|
.ggs-header--float .ggs-music-wrap { pointer-events: auto; }
|
||||||
|
|
||||||
|
/* --- Marken-Pille: Kreis-Logo · Titel · Level ---------------- */
|
||||||
|
.ggs-header--float .ggs-hpille {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 .8rem 0 .3rem;
|
||||||
|
background: rgba(255, 255, 255, .96);
|
||||||
|
border-radius: 19px;
|
||||||
|
box-shadow: 0 2px 9px rgba(0, 0, 0, .2);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.ggs-header--float .ggs-header-logo {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
min-width: 30px;
|
||||||
|
min-height: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #1f4e5a;
|
||||||
|
flex: none;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.ggs-header--float .ggs-header-logo-icon {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
object-fit: cover;
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
.ggs-header--float .ggs-header-logo-text { display: none !important; }
|
||||||
|
.ggs-header--float .ggs-header-separator { display: none !important; }
|
||||||
|
.ggs-header--float .ggs-header-module {
|
||||||
|
font-weight: 800;
|
||||||
|
color: #1f4e5a;
|
||||||
|
font-size: .92rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.ggs-header--float .ggs-header-badge {
|
||||||
|
font-size: .6rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #fff;
|
||||||
|
background: #5a8a5e;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: .2rem .5rem;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* Live-Tick-Punkt (klima) darf in der Pille mitlaufen */
|
||||||
|
.ggs-header--float .ggs-hpille .kw-tick-dot { flex: none; }
|
||||||
|
|
||||||
|
/* --- Schwebende Icon-Buttons (rechts + Home links) ----------- */
|
||||||
|
.ggs-header--float .ggs-btn-ghost,
|
||||||
|
.ggs-header--float .ggs-spd,
|
||||||
|
.ggs-header--float .ggs-mtrigger {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(255, 255, 255, .96);
|
||||||
|
box-shadow: 0 2px 9px rgba(0, 0, 0, .2);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #1f4e5a;
|
||||||
|
padding: 0;
|
||||||
|
flex: none;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background .15s, transform .1s;
|
||||||
|
}
|
||||||
|
.ggs-header--float .ggs-btn-ghost:hover,
|
||||||
|
.ggs-header--float .ggs-spd:hover,
|
||||||
|
.ggs-header--float .ggs-mtrigger:hover {
|
||||||
|
background: #fff;
|
||||||
|
color: #16323a;
|
||||||
|
border-radius: 12px;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.ggs-header--float .ggs-btn-ghost .ic,
|
||||||
|
.ggs-header--float .ggs-mtrigger .ic {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
.ggs-header--float .ggs-header-spacer { flex: 1; }
|
||||||
|
.ggs-header--float .ggs-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
/* Bespoke-Sims: Buttons/Links in den Aktionen ohne .ggs-btn-ghost-Klasse
|
||||||
|
bekommen trotzdem die schwebende Icon-Button-Optik. */
|
||||||
|
.ggs-header--float .ggs-header-actions > button,
|
||||||
|
.ggs-header--float .ggs-header-actions > a {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(255, 255, 255, .96);
|
||||||
|
box-shadow: 0 2px 9px rgba(0, 0, 0, .2);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #1f4e5a;
|
||||||
|
padding: 0;
|
||||||
|
flex: none;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1;
|
||||||
|
transition: background .15s, transform .1s;
|
||||||
|
}
|
||||||
|
.ggs-header--float .ggs-header-actions > button:hover,
|
||||||
|
.ggs-header--float .ggs-header-actions > a:hover {
|
||||||
|
background: #fff;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.ggs-header--float .ggs-header-actions > button .ic,
|
||||||
|
.ggs-header--float .ggs-header-actions > a .ic {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
/* Zwei-Zustands-Icon: .ic-state-on normal, .ic-state-off wenn Button .active.
|
||||||
|
(z. B. Animationen an → reduziert = durchgestrichen) */
|
||||||
|
.ggs-header--float button .ic-state-off { display: none; }
|
||||||
|
.ggs-header--float button.active .ic-state-on { display: none; }
|
||||||
|
.ggs-header--float button.active .ic-state-off { display: inline-block; }
|
||||||
|
|
||||||
|
/* --- Tempo-Zustandsbutton (zeigt IMMER den aktuellen Speed) --- */
|
||||||
|
.ggs-header--float .ggs-tempo { position: relative; display: inline-flex; }
|
||||||
|
.ggs-header--float .ggs-spd {
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.ggs-header--float .ggs-spd b { font-size: .82rem; }
|
||||||
|
.ggs-header--float .ggs-spd small {
|
||||||
|
font-size: .5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 1px;
|
||||||
|
color: #4a7c8a;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
.ggs-header--float .ggs-spd .ic { width: 18px; height: 18px; color: inherit; }
|
||||||
|
.ggs-header--float .ggs-spd.act { background: #4a7c8a; color: #fff; }
|
||||||
|
.ggs-header--float .ggs-spd.act small { color: #dbe9ee; }
|
||||||
|
|
||||||
|
/* --- Untermenü-Panels (Tempo links, Musik unten) ------------ */
|
||||||
|
.ggs-header--float .ggs-pop {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: .25rem;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 .3rem;
|
||||||
|
background: rgba(255, 255, 255, .7);
|
||||||
|
-webkit-backdrop-filter: blur(2px);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 9px rgba(0, 0, 0, .14);
|
||||||
|
border: 1px solid rgba(255, 255, 255, .5);
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1001;
|
||||||
|
}
|
||||||
|
.ggs-header--float .ggs-tempo.open .ggs-pop--tempo,
|
||||||
|
.ggs-header--float .ggs-music-wrap.open .ggs-pop--music { display: inline-flex; }
|
||||||
|
/* Tempo: öffnet LINKS vom Button, bleibt offen */
|
||||||
|
.ggs-header--float .ggs-pop--tempo {
|
||||||
|
right: calc(100% + 6px);
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
/* Musik: klappt nach UNTEN, schließt nach Song-Auswahl */
|
||||||
|
.ggs-header--float .ggs-music-wrap { position: relative; display: inline-flex; }
|
||||||
|
.ggs-header--float .ggs-pop--music {
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
gap: .5rem;
|
||||||
|
padding: 0 .55rem;
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Tempo-Segmente im Panel (nutzt bestehende .ggs-speed) --- */
|
||||||
|
.ggs-header--float .ggs-pop--tempo .ggs-speed {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: .25rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.ggs-header--float .ggs-pop--tempo .ggs-speed-btn {
|
||||||
|
height: 30px;
|
||||||
|
min-width: 36px;
|
||||||
|
padding: 0 .6rem;
|
||||||
|
border: none !important;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: .82rem;
|
||||||
|
color: #4a4a4a;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.ggs-header--float .ggs-pop--tempo .ggs-speed-btn:hover { background: rgba(74, 124, 138, .15); color: #1f4e5a; }
|
||||||
|
.ggs-header--float .ggs-pop--tempo .ggs-speed-btn.active { background: #4a7c8a; color: #fff; }
|
||||||
|
|
||||||
|
/* --- Musik-Widget im Panel (bestehendes #ggs-music) --------- */
|
||||||
|
.ggs-header--float .ggs-pop--music .ggs-music {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.ggs-header--float .ggs-pop--music .ggs-music[hidden] { display: inline-flex !important; }
|
||||||
|
/* Musik-Bedienelemente selbst gestalten (auch für Sims ohne design-system.css) */
|
||||||
|
.ggs-header--float .ggs-pop--music .ggs-music-sel {
|
||||||
|
height: 30px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, .12);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 .5rem;
|
||||||
|
font-size: .78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f4e5a;
|
||||||
|
background: #f7f6f3;
|
||||||
|
max-width: 130px;
|
||||||
|
}
|
||||||
|
.ggs-header--float .ggs-pop--music .ggs-music-btn {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #4a7c8a;
|
||||||
|
color: #fff;
|
||||||
|
font-size: .8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.ggs-header--float .ggs-pop--music .ggs-music-btn.playing { background: #5a8a5e; }
|
||||||
|
.ggs-header--float .ggs-pop--music .ggs-music-vol {
|
||||||
|
width: 84px;
|
||||||
|
accent-color: #4a7c8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Schmaler auf iPad-Landscape (Platz sparen) */
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.ggs-header--float .ggs-header-actions { gap: .4rem; }
|
||||||
|
}
|
||||||