/* =====================================================================
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();
})();
})();