/* ===================================================================== leistung.js — die Zahlen über das Geschäft. Stufe 1 des Plans vom 31.08.2026. Der Server rechnet (siehe workspace-leistung.js), diese Datei zeigt und nimmt entgegen. DREI DINGE, die jede Zahl hier mitbringt -- sonst fliegt sie raus: 1. den WERT 2. den VERGLEICH zur Vorwoche ("+23 %") 3. den VERLAUF als kleine Linie Eine Zahl ohne diese drei ist eine Eitelkeitszahl. Sie sieht nach Auskunft aus, und man kann nichts entscheiden. ===================================================================== */ (() => { 'use strict'; const $ = (id) => document.getElementById(id); const el = (tag, klasse, text) => { const k = document.createElement(tag); if (klasse) k.className = klasse; if (text !== undefined) k.textContent = text; return k; }; const LEITUNG = new Set(['admin', 'manager']); let ich = null; let creator = []; let offenerCreator = 0; let stand = null; let importText = ''; let importSpalten = null; async function hole(weg, einst) { const a = await fetch(weg, { credentials: 'same-origin', ...einst }); if (a.status === 401) { location.assign('/workspace/'); throw new Error('weg'); } return a; } const melde = (t) => { $('fehler').textContent = t || ''; }; /* ---------- Zahlen lesbar machen ---------------------------------------- */ /** 1240 → "1.240". Tausenderpunkte, damit man vierstellige Zahlen * auf einen Blick erfasst. */ const zahl = (n) => (n === null || n === undefined ? '–' : Number(n).toLocaleString('de-DE')); /** Sekunden → "3:30 Min". Eine Verweildauer von 210 Sekunden liest * niemand als dreieinhalb Minuten, wenn sie als "210" dasteht. */ function dauer(s) { if (s === null || s === undefined) return '–'; const m = Math.floor(s / 60); const rest = Math.round(s % 60); return m ? `${m}:${String(rest).padStart(2, '0')} Min` : `${rest} Sek`; } /** Minuten → "1 Std 35". */ function stunden(min) { if (min === null || min === undefined) return '–'; const h = Math.floor(min / 60); const m = min % 60; return h ? `${h} Std ${m}` : `${m} Min`; } /* ---------- Die Wochenkarten -------------------------------------------- */ const FELDER = [ /* Die Verweildauer steht VORN. Sie ist die Leitzahl (Recherche 06.09.2026), nicht eine von acht. */ { key: 'verweildauer_s', name: 'Ø Verweildauer', form: dauer, leit: true }, { key: 'diamanten', name: 'Diamanten', form: zahl }, { key: 'gueltige_tage', name: 'LIVE-Tage', form: (n) => `${n} von 7` }, { key: 'dauer_min', name: 'LIVE-Zeit', form: stunden }, { key: 'zuschauer_avg', name: 'Ø Zuschauer', form: zahl }, { key: 'schenker', name: 'Schenker', form: zahl }, { key: 'follower_neu', name: 'Neue Follower', form: zahl }, ]; function wochenKartenBauen() { const ziel = $('zahlkarten'); ziel.textContent = ''; if (!stand) return; for (const f of FELDER) { const w = stand.woche[f.key]; if (!w) continue; const karte = el('article', 'zahlkarte'); if (f.leit) karte.dataset.leit = 'ja'; karte.append(el('p', 'zahlkarte__name', f.name)); karte.append(el('p', 'zahlkarte__wert', f.form(w.wert))); /* DER VERGLEICH. Ohne ihn ist die Zahl eine Behauptung. */ const v = el('p', 'zahlkarte__wandel'); if (w.wandel?.prozent !== undefined) { const p = w.wandel.prozent; v.dataset.richtung = p > 0 ? 'hoch' : p < 0 ? 'runter' : 'gleich'; v.textContent = `${p > 0 ? '+' : ''}${p} % gegenüber der Vorwoche`; } else if (w.wandel?.neu) { /* "Von null auf 500" -- keine Prozent, sondern die Auskunft, die dahintersteckt. */ v.dataset.richtung = 'hoch'; v.textContent = 'neu – vorher nichts'; } else { v.dataset.richtung = 'gleich'; v.textContent = w.vorher === null ? 'kein Vergleich' : 'wie in der Vorwoche'; } karte.append(v); /* DER VERLAUF als kleine Linie. Acht Wochen -- ohne Richtung ist ein Wert nur ein Punkt. */ const linie = verlaufsLinie(stand.verlauf, f.key); if (linie) karte.append(linie); ziel.append(karte); } } /** Eine kleine Linie aus den letzten acht Wochen. * * Selbst gezeichnet als SVG-Pfad, kein Diagrammpaket: Für acht * Punkte eine Bibliothek zu laden wäre in einem Haus mit drei * Abhängigkeiten die vierte -- und sie brächte Farben, Achsen und * Beschriftungen mit, die hier alle stören würden. */ function verlaufsLinie(verlauf, key) { if (!Array.isArray(verlauf) || verlauf.length < 3) return null; /* == null FAENGT AUCH undefined -- und genau darauf kam es an. Der Server liefert im Verlauf nur drei der sieben Groessen (Diamanten, LIVE-Tage, Verweildauer); fuer alle anderen ist w[key] undefined. Mit `=== null` rutschte das durch, wurde zu Number(undefined) = NaN, und die Linie bekam Punkte wie "NaN,NaN" -- der Browser zeichnet dann gar nichts und sagt nichts. Eine leere Stelle in der Karte, ohne Fehlermeldung. */ const werte = verlauf.map((w) => { const r = w?.[key]; if (r === null || r === undefined) return null; const n = Number(r); return Number.isFinite(n) ? n : null; }); const echte = werte.filter((w) => w !== null); if (echte.length < 3) return null; const max = Math.max(...echte); const min = Math.min(...echte); const spanne = max - min || 1; const B = 100, H = 26; const punkte = werte.map((w, i) => { if (w === null) return null; const x = (i / (werte.length - 1)) * B; /* Von unten nach oben: In SVG wächst y nach unten. */ const y = H - ((w - min) / spanne) * (H - 4) - 2; return `${x.toFixed(1)},${y.toFixed(1)}`; }).filter(Boolean); const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('class', 'zahlkarte__linie'); svg.setAttribute('viewBox', `0 0 ${B} ${H}`); svg.setAttribute('preserveAspectRatio', 'none'); svg.setAttribute('aria-hidden', 'true'); svg.innerHTML = ``; /* Der letzte Punkt bekommt einen Kreis -- er ist das Jetzt. */ const letzter = punkte[punkte.length - 1].split(','); svg.innerHTML += ``; return svg; } /* ---------- Ziele als Fortschritt --------------------------------------- */ function zieleBauen() { const ziel = $('zielbalken'); ziel.textContent = ''; const z = stand?.ziele; $('ziele-aendern').hidden = !darfEintragen(); if (!z || (!z.diamanten && !z.tage && !z.verweildauer)) { ziel.append(el('p', 'leistung__leer', darfEintragen() ? 'Noch keine Ziele gesetzt. Aus „mehr Reichweite" wird hier eine prüfbare Größe.' : 'Noch keine Ziele gesetzt.')); return; } for (const [key, name, form] of [ ['diamanten', 'Diamanten', zahl], ['tage', 'LIVE-Tage', (n) => String(n)], ['verweildauer', 'Ø Verweildauer', dauer], ]) { const t = z[key]; if (!t) continue; const zeile = el('div', 'zielbalken__zeile'); const kopf = el('div', 'zielbalken__kopf'); kopf.append(el('span', 'zielbalken__name', name)); kopf.append(el('span', 'zielbalken__zahl', `${form(t.ist)} von ${form(t.ziel)}`)); zeile.append(kopf); const spur = el('div', 'zielbalken__spur'); const fuell = el('div', 'zielbalken__fuell'); /* Über 100 % wird nicht breiter -- der Balken bliebe sonst aus dem Kasten hängen. Die Zahl daneben sagt es trotzdem. */ fuell.style.width = `${Math.min(100, Math.max(0, t.anteil))}%`; if (t.anteil >= 100) fuell.dataset.voll = 'ja'; spur.append(fuell); zeile.append(spur); zeile.append(el('p', 'zielbalken__anteil', t.anteil >= 100 ? `Ziel erreicht (${t.anteil} %)` : `${t.anteil} %`)); ziel.append(zeile); } } const darfEintragen = () => LEITUNG.has(ich?.rolle) || ich?.rolle === 'scout'; /* ---------- Die Tageszeilen --------------------------------------------- */ async function tageLaden() { let tage = []; try { const a = await hole(`/workspace/api/leistung/${offenerCreator}/tage?tage=21`); if (a.ok) tage = (await a.json()).tage || []; } catch { return; } const ziel = $('tagetabelle'); ziel.textContent = ''; const nach = new Map(tage.map((t) => [t.tag, t])); /* DIE LETZTEN 14 TAGE, auch die ohne Eintrag. Nur die vorhandenen zu zeigen hieße, dass eine Lücke unsichtbar ist -- und genau die Lücke ist die Auskunft: Da war nichts, oder es wurde nicht erfasst. */ for (let i = 0; i < 14; i++) { const d = new Date(); d.setDate(d.getDate() - i); const p = (n) => String(n).padStart(2, '0'); const tag = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; const z = nach.get(tag); const zeile = el('div', 'tagzeile'); if (!z) zeile.dataset.leer = 'ja'; if (z?.quelle === 'import') zeile.dataset.quelle = 'import'; const datum = el('div', 'tagzeile__datum'); datum.append(el('span', 'tagzeile__wochentag', d.toLocaleDateString('de-DE', { weekday: 'short' }))); datum.append(el('span', 'tagzeile__zahl', d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }))); zeile.append(datum); const werte = el('div', 'tagzeile__werte'); if (z) { werte.append(wert('Diamanten', zahl(z.diamanten))); werte.append(wert('LIVE', stunden(z.dauer_min))); werte.append(wert('Verweil', dauer(z.verweildauer_s))); werte.append(wert('Ø Zusch.', zahl(z.zuschauer_avg))); } else { werte.append(el('span', 'tagzeile__leer', 'nichts erfasst')); } zeile.append(werte); if (z?.notiz) { const n = el('p', 'tagzeile__notiz', z.notiz); zeile.append(n); } if (darfEintragen()) { const knopf = el('button', 'tagzeile__knopf', z ? 'ändern' : 'eintragen'); knopf.type = 'button'; knopf.setAttribute('aria-label', `${tag} ${z ? 'ändern' : 'eintragen'}`); knopf.addEventListener('click', () => tagOeffnen(tag, z)); zeile.append(knopf); } ziel.append(zeile); } } function wert(name, text) { const s = el('span', 'tagzeile__wert'); s.append(el('span', 'tagzeile__wertname', name)); s.append(el('span', 'tagzeile__wertzahl', text)); return s; } /* ---------- Eintragen ---------------------------------------------------- */ function tagOeffnen(tag, z) { const d = $('tag-dialog'); d.dataset.tag = tag; const datum = new Date(tag + 'T12:00:00'); $('tag-titel').textContent = datum.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' }); $('tag-unter').textContent = z?.quelle === 'import' ? 'Diese Zeile kam aus einer Datei. Änderungen von Hand bleiben erhalten, bis erneut eingelesen wird.' : 'Leer lassen heißt: nicht gemessen. Eine Null ist etwas anderes.'; $('f-diamanten').value = z?.diamanten ?? ''; $('f-dauer').value = z?.dauer_min ?? ''; $('f-verweildauer').value = z?.verweildauer_s ?? ''; $('f-zavg').value = z?.zuschauer_avg ?? ''; $('f-zmax').value = z?.zuschauer_max ?? ''; $('f-schenker').value = z?.schenker ?? ''; $('f-follower').value = z?.follower_neu ?? ''; $('f-notiz').value = z?.notiz ?? ''; $('tag-fehler').textContent = ''; $('tag-loeschen').hidden = !z; d.showModal(); $('f-diamanten').focus(); } async function tagSpeichern(e) { e.preventDefault(); const d = $('tag-dialog'); const tag = d.dataset.tag; const n = (id) => { const v = $(id).value.trim(); return v === '' ? null : Number(v); }; const dauerMin = n('f-dauer'); try { const a = await hole(`/workspace/api/leistung/${offenerCreator}/${tag}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diamanten: n('f-diamanten'), dauer_min: dauerMin, /* Ein gültiger Tag ist ein Tag mit LIVE-Zeit -- daraus geschlossen, statt ein weiteres Kästchen zu verlangen. */ gueltiger_tag: dauerMin > 0, verweildauer_s: n('f-verweildauer'), zuschauer_avg: n('f-zavg'), zuschauer_max: n('f-zmax'), schenker: n('f-schenker'), follower_neu: n('f-follower'), notiz: $('f-notiz').value, }), }); if (!a.ok) { $('tag-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.'; return; } d.close(); await allesLaden(); } catch { /* umgeleitet */ } } async function tagLoeschen() { const d = $('tag-dialog'); if (!window.confirm('Diese Zeile wirklich löschen? Die Zahlen des Tages sind dann weg.')) return; try { const a = await hole(`/workspace/api/leistung/${offenerCreator}/${d.dataset.tag}`, { method: 'DELETE' }); if (!a.ok) { $('tag-fehler').textContent = 'Ging nicht.'; return; } d.close(); await allesLaden(); } catch { /* umgeleitet */ } } /* ---------- Ziele ------------------------------------------------------- */ function zieleOeffnen() { const z = stand?.ziele; $('z-diamanten').value = z?.diamanten?.ziel ?? ''; $('z-tage').value = z?.tage?.ziel ?? ''; $('z-verweildauer').value = z?.verweildauer?.ziel ?? ''; $('ziele-fehler').textContent = ''; $('ziele-dialog').showModal(); } async function zieleSpeichern(e) { e.preventDefault(); const n = (id) => { const v = $(id).value.trim(); return v === '' ? null : Number(v); }; try { const a = await hole(`/workspace/api/leistung/${offenerCreator}/ziele`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diamanten_woche: n('z-diamanten'), tage_woche: n('z-tage'), verweildauer_s: n('z-verweildauer'), }), }); if (!a.ok) { $('ziele-fehler').textContent = 'Ging nicht.'; return; } $('ziele-dialog').close(); await allesLaden(); } catch { /* umgeleitet */ } } /* ---------- Import ------------------------------------------------------ */ async function dateiGewaehlt(e) { const datei = e.target.files?.[0]; if (!datei) return; $('import-fehler').textContent = ''; $('vorschau').hidden = true; $('import-los').disabled = true; try { importText = await datei.text(); } catch { $('import-fehler').textContent = 'Die Datei ließ sich nicht lesen.'; return; } /* ERST DIE VORSCHAU. Ein Import, der sofort schreibt, ist bei einer falsch zugeordneten Spalte nicht mehr zurückzuholen. */ try { const a = await hole(`/workspace/api/leistung/${offenerCreator}/import/vorschau`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: importText }), }); const v = await a.json(); if (!a.ok) { $('import-fehler').textContent = v.fehler || 'Die Datei konnte nicht gelesen werden.'; return; } importSpalten = v.spalten; vorschauZeigen(v); } catch { /* umgeleitet */ } } function vorschauZeigen(v) { const ziel = $('vorschau'); ziel.textContent = ''; ziel.hidden = false; /* Die ZAHLEN stehen vorn: Wer übernimmt, will wissen "wie viele", nicht "welche". */ const zeile = el('div', 'import-zahlen'); zeile.append(importZahl(v.neu, 'neu')); zeile.append(importZahl(v.ersetzt, v.ersetzt === 1 ? 'wird ersetzt' : 'werden ersetzt')); if (v.fehlerhaft) zeile.append(importZahl(v.fehlerhaft, 'unlesbar', true)); ziel.append(zeile); /* Welche Spalten erkannt wurden -- damit man merkt, wenn etwas fehlt. Eine stillschweigend nicht erkannte Spalte wäre eine Zahl, die einfach nie ankommt. */ const erkannt = Object.keys(importSpalten || {}).filter((k) => k !== 'tag'); const namen = { diamanten: 'Diamanten', dauer_min: 'LIVE-Dauer', zuschauer_avg: 'Ø Zuschauer', zuschauer_max: 'Spitze', verweildauer_s: 'Verweildauer', schenker: 'Schenker', follower_neu: 'Follower', }; ziel.append(el('p', 'import-spalten', erkannt.length ? 'Erkannt: ' + erkannt.map((k) => namen[k] || k).join(' · ') : 'Außer dem Datum wurde keine Spalte erkannt – die Zahlen bleiben leer.')); if (v.probleme?.length) { const liste = el('ul', 'import-probleme'); for (const p of v.probleme) { liste.append(el('li', null, `Zeile ${p.zeile}: ${p.grund}`)); } ziel.append(liste); } $('import-los').disabled = !(v.neu + v.ersetzt); } function importZahl(n, name, warn = false) { const k = el('div', 'import-zahl'); if (warn) k.dataset.warn = 'ja'; k.append(el('span', 'import-zahl__wert', String(n))); k.append(el('span', 'import-zahl__name', name)); return k; } async function importUebernehmen(e) { e.preventDefault(); if (!importText) return; const knopf = $('import-los'); knopf.disabled = true; try { const a = await hole(`/workspace/api/leistung/${offenerCreator}/import`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: importText, spalten: importSpalten }), }); const r = await a.json(); if (!a.ok) { $('import-fehler').textContent = r.fehler || 'Ging nicht.'; return; } $('import-dialog').close(); $('datei').value = ''; importText = ''; await allesLaden(); melde(`${r.geschrieben} Tage übernommen.`); setTimeout(() => melde(''), 5000); } catch { /* umgeleitet */ } finally { knopf.disabled = false; } } /* ---------- Laden ------------------------------------------------------- */ async function allesLaden() { if (!offenerCreator) return; try { const a = await hole(`/workspace/api/leistung/${offenerCreator}`); if (!a.ok) { melde('Die Zahlen konnten nicht geladen werden.'); return; } stand = await a.json(); } catch { return; } $('woche').hidden = false; $('ziele-block').hidden = false; $('tage-block').hidden = false; /* WIE VOLLSTÄNDIG IST DIE WOCHE? Eine Zahl aus zwei von sieben Tagen ist etwas anderes als eine aus sieben -- ohne diesen Hinweis hält man eine halbe Woche für einen Einbruch. */ const e = stand.erfassung; $('erfassung').textContent = e.tage >= 7 ? 'alle 7 Tage erfasst' : `${e.tage} von 7 Tagen erfasst`; $('erfassung').dataset.knapp = e.tage < 4 ? 'ja' : ''; wochenKartenBauen(); zieleBauen(); await tageLaden(); } /* ---------- Start -------------------------------------------------------- */ $('tag-form').addEventListener('submit', tagSpeichern); $('tag-zu').addEventListener('click', () => $('tag-dialog').close()); $('tag-loeschen').addEventListener('click', tagLoeschen); $('ziele-aendern').addEventListener('click', zieleOeffnen); $('ziele-form').addEventListener('submit', zieleSpeichern); $('ziele-zu').addEventListener('click', () => $('ziele-dialog').close()); $('import-oeffnen').addEventListener('click', () => { $('vorschau').hidden = true; $('import-los').disabled = true; $('import-fehler').textContent = ''; $('import-dialog').showModal(); }); $('import-zu').addEventListener('click', () => $('import-dialog').close()); $('datei').addEventListener('change', dateiGewaehlt); $('import-form').addEventListener('submit', importUebernehmen); (async () => { try { const a = await hole('/workspace/api/ich'); if (!a.ok) { location.assign('/workspace/'); return; } ich = await a.json(); } catch { return; } window.werZeigen?.(ich); /* Ein Creator sieht nur sich selbst -- ohne Auswahlfeld, das nur einen Eintrag hätte. */ if (ich.rolle === 'creator') { offenerCreator = ich.id; await allesLaden(); return; } try { const { creator: liste } = await (await hole('/workspace/api/leistung')).json(); creator = liste || []; } catch { return; } if (!creator.length) { melde('Dir ist noch kein Creator zugeordnet.'); return; } $('creator-wahl').hidden = false; for (const c of creator) { const o = document.createElement('option'); o.value = String(c.id); /* Die wichtigste Zahl steht gleich in der Auswahl -- so sieht man beim Umschalten, wo etwas los ist. */ o.textContent = c.tage_erfasst ? `${c.name} — ${zahl(c.diamanten)} Diamanten` : `${c.name} — noch keine Zahlen`; $('creator').append(o); } /* Vom Dashboard kommend: direkt zur richtigen Person. */ const wunsch = Number(new URLSearchParams(location.search).get('creator')); offenerCreator = creator.some((c) => c.id === wunsch) ? wunsch : creator[0].id; $('creator').value = String(offenerCreator); window.personenwahl?.('creator'); $('creator').addEventListener('change', async () => { offenerCreator = Number($('creator').value); history.replaceState(null, '', `leistung.html?creator=${offenerCreator}`); await allesLaden(); }); await allesLaden(); })(); })();