Deutsch oeffnet mit „ und schliesst mit “. An 199 Stellen, die ein
Mensch liest, stand als Schlusszeichen ein GERADES " -- „Nächstes"
statt „Nächstes“. Auf sechzehn oeffentlichen Seiten, in vierzig
Dateien des Workspace und in zwoelf Servermodulen, die Texte
verschicken.
Auf dem Bildschirm sieht man den Unterschied sofort. Beim Schreiben
nicht: Das gerade " liegt auf der Tastatur, die anderen nicht.
WARUM EIN ERSTER ANLAUF ZURUECKGENOMMEN WURDE
Ein gerades " ist an vielen Stellen SYNTAX und kein Schriftzeichen --
Grenze einer Zeichenkette, Grenze eines HTML-Attributs, Zeichen in
einem regulaeren Ausdruck. Wer stumpf ersetzt, macht aus
„<a href="https://… ein kaputtes Attribut
/^["'„»\s]+|["'“«.\s]+$/ einen kaputten Ausdruck
"… nichts „mal " + "eben …" eine kaputte Zeichenkette
DIE UNTERSCHEIDUNG LAEUFT AN MERKMALEN, NICHT AN EINER LISTE
< > = dazwischen -> HTML-Marke oder Attribut
endet auf Leerzeichen -> die Zeichenkette hoert hier auf, der
Satz geht in der naechsten Zeile weiter.
Ein deutsches Schlusszeichen steht NIE
hinter einem Leerzeichen.
Rueckstrich mittendrin -> regulaerer Ausdruck
${ ohne } -> mitten in einem Ausdruck
Eine Liste erlaubter Ausnahmen waere die naechste, die niemand
pflegt. Zwoelf Stellen bleiben dadurch stehen, alle zwoelf einzeln
angesehen und alle zwoelf zu Recht -- dort steht das richtige
Schlusszeichen ohnehin weiter unten im Satz.
Fuenf davon waren allerdings ECHTE Fehler HINTER dem Link
(`…>HasiDog</a>".`) -- die erste Regel hatte nur das Attribut
gesehen, nicht den Satz danach. Gezielt nachgezogen.
Ein maskiertes `\"` in workspace-vorlagen.js (28 Hooks) wird zu “ --
ohne Rueckstrich, denn “ begrenzt nichts.
KOMMENTARE BLEIBEN, WIE SIE SIND. Dort liest es niemand ausser mir;
eine Wache, die auch Kosmetik anmahnt, wird weggeklickt. Beim ersten
Messen fielen ausserdem acht Stellen aus buehne.html faelschlich an,
weil `/* */` in HTML (in <style> und <script>) nicht ausgeblendet
war -- jetzt schon.
DIE WACHE DAZU
pruef-struktur prueft es ab sofort mit derselben Regel: 322 Dateien
mit sichtbarem Text, 0 Funde, und die zwoelf bewussten Ausnahmen
werden GEZAEHLT und genannt (erlaubt: 12). Eine Ausnahme, die niemand
sieht, waechst -- und irgendwann steht der echte Fall darin.
GEPRUEFT
pruef-struktur 59 -> 68 Pruefungen, 0 Fehler
node --check auf allen geaenderten JS-Dateien
nachgemessen: 199 geaendert, 12 mit Grund stehen geblieben
gruen geblieben: bewerbung-aufgaben 163, nachwuchs 262,
reaktion 421, support 63, content 45, terminregel 35, treff 85
Und nachgesehen, ob eine Pruefung noch die alte Schreibweise
ERWARTET: 13 Fundstellen, alle dreizehn nur Text in ihrer eigenen
Ausgabe, keine einzige ein Vergleich mit dem Seitentext.
GEGENPROBE: In reaktion.html ein Schlusszeichen zurueckgedreht ->
„workspace/reaktion.html:546 „Nächstes"", mit Datei und Zeile. Und
die Erkennung einzeln gegen HTML-Attribut, fortgesetzte
Zeichenkette, regulaeren Ausdruck und eingesetzten Wert geprueft.
Stempel gesetzt: workspace 670 Verweise in 45 Dateien, oeffentlich
554 in 48 Seiten.
Co-Authored-By: Claude Opus 5 <[email protected]>
1131 lines
46 KiB
JavaScript
1131 lines
46 KiB
JavaScript
/* =====================================================================
|
||
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;
|
||
};
|
||
|
||
/* Die Leitungsliste steht in bereiche.js -- eine Antwort statt neun.
|
||
Sie stand hier frueher selbst, ohne 'spicy', und genau das war der
|
||
Fehler, den Filipe am 07.09.2026 gefunden hat. */
|
||
const LEITUNG = window.Bereiche.LEITUNG;
|
||
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 = `<polyline points="${punkte.join(' ')}" />`;
|
||
/* Der letzte Punkt bekommt einen Kreis -- er ist das Jetzt. */
|
||
const letzter = punkte[punkte.length - 1].split(',');
|
||
svg.innerHTML += `<circle cx="${letzter[0]}" cy="${letzter[1]}" r="2.2" />`;
|
||
return svg;
|
||
}
|
||
|
||
/* ---------- Ziele als Fortschritt --------------------------------------- */
|
||
|
||
function zieleBauen() {
|
||
const ziel = $('zielbalken');
|
||
ziel.textContent = '';
|
||
const z = stand?.ziele;
|
||
$('ziele-aendern').hidden = !darfEintragen();
|
||
|
||
/* ALLE EINTRAG-KNOEPFE AN EINER STELLE (11.09.2026).
|
||
Seit heute oeffnet sich diese Seite fuer jede Rolle -- Filipe:
|
||
"jetzt soll jede rolle diese kachel sehen ... aber die creator
|
||
kriegen dan quasi nur ihre daten zu sehen."
|
||
|
||
GEMESSEN, NICHT ANGENOMMEN: Beim Umbau fiel auf, dass
|
||
'import-oeffnen' als einziger Knopf NIE eine Rechteregel hatte.
|
||
Das war folgenlos, solange nur DogFather hereinkam. Ab heute
|
||
haette ein Creator dort "Aus Datei einlesen" gesehen und eine
|
||
Absage bekommen. Genau deshalb steht die Liste jetzt hier: Wer
|
||
einen Knopf hinzufuegt, sieht die Regel daneben und kann sie
|
||
nicht vergessen. */
|
||
const KNOEPFE = [
|
||
/* Eigene Datei mit Tageszahlen -- wer eintragen darf, darf das. */
|
||
['import-oeffnen', darfEintragen()],
|
||
/* Der Backstage-Weg gilt fuer ALLE Creator auf einmal -- deshalb
|
||
nur fuer die Leitung, nicht fuer einen Scout, der ohnehin nur
|
||
seine eigenen sieht. Der Server prueft dasselbe (netzDarf);
|
||
hier geht es darum, niemandem einen Knopf hinzustellen, der
|
||
eine Absage bringt. */
|
||
['netz-oeffnen', LEITUNG.has(ich?.rolle)],
|
||
];
|
||
for (const [knopf, darf] of KNOEPFE) $(knopf).hidden = !darf;
|
||
|
||
/* Jeder Erklaersatz haengt an SEINEM Knopf, nicht am Block. Ein
|
||
Scout, der die Backstage-Tabelle nicht hat, soll sie auch nicht
|
||
erklaert bekommen -- das waere keine Hilfe, sondern eine Absage,
|
||
die er nicht gesucht hat. Und ein Creator sieht keinen Knopf,
|
||
also auch keinen Satz.
|
||
|
||
Die Liste hat seit dem 11.09.2026 nur noch einen Eintrag (der
|
||
Weg ueber die TikTok-Datei des Creators ist entfernt). Sie
|
||
bleibt trotzdem eine Liste: Der naechste Weg haengt sich dann
|
||
daneben, statt dass jemand die Regel ein zweites Mal
|
||
aufschreibt. */
|
||
for (const [satz, knopf] of [['quelle-netz', 'netz-oeffnen']]) {
|
||
$(satz).hidden = $(knopf).hidden;
|
||
}
|
||
$('quellen-hinweis').hidden = $('quelle-netz').hidden;
|
||
|
||
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);
|
||
}
|
||
|
||
/* ZUGEKLAPPT AUF SIEBEN TAGE (07.09.2026, Wunsch Filipe).
|
||
|
||
Vierzehn Zeilen sind zu viel fuers Hinsehen und zu wenig fuers
|
||
Nachschlagen -- man scrollt an ihnen vorbei, statt sie zu lesen.
|
||
Sieben ist die Woche: der kuerzeste Zeitraum, ueber den man
|
||
ueberhaupt etwas sagen kann, und genau der, mit dem der
|
||
Wochenbericht rechnet. Die restlichen sieben Tage sind einen
|
||
Klick entfernt und verschwinden nicht.
|
||
|
||
Die Sieben steht nicht hier, sondern als eigene Regel im CSS
|
||
(`[data-klapp="zu7"]`) -- der Schalter kennt beide Stufen und
|
||
benennt sie im Knopftext, damit niemand raten muss, wie viel
|
||
gerade verborgen ist. */
|
||
window.klappe?.(ziel, document.getElementById('tage-schalter'),
|
||
document.getElementById('tage-schalter-text'), 7);
|
||
}
|
||
|
||
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 = sagWas((await a.json().catch(() => ({}))).fehler, 'Ging nicht.');
|
||
return;
|
||
}
|
||
/* Gespeichert -- die Verwurfwache darf hier nicht fragen. */
|
||
d.vergessen?.();
|
||
d.close();
|
||
await allesLaden();
|
||
} catch { /* umgeleitet */ }
|
||
}
|
||
|
||
async function tagLoeschen() {
|
||
const d = $('tag-dialog');
|
||
if (!await frageNach({
|
||
titel: 'Zahlen dieses Tages löschen?',
|
||
was: 'Die eingetragenen Zahlen für diesen Tag werden entfernt.',
|
||
bleibt: 'Alle anderen Tage bleiben unverändert. Du kannst den Tag '
|
||
+ 'jederzeit neu eintragen.',
|
||
endgueltig: true, ja: 'Zeile löschen',
|
||
})) return;
|
||
try {
|
||
const a = await hole(`/workspace/api/leistung/${offenerCreator}/${d.dataset.tag}`,
|
||
{ method: 'DELETE' });
|
||
if (!a.ok) { $('tag-fehler').textContent = 'Ging nicht.'; return; }
|
||
/* Gespeichert -- die Verwurfwache darf hier nicht fragen. */
|
||
d.vergessen?.();
|
||
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; }
|
||
/* Gespeichert -- die Verwurfwache darf hier nicht fragen. */
|
||
$('ziele-dialog').vergessen?.();
|
||
$('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;
|
||
|
||
/* WAS IST DAS UEBERHAUPT FUER EINE DATEI? (11.09.2026)
|
||
|
||
Filipe: "meine excel datei kann ich nicht auswaehlen wieso??"
|
||
|
||
Bis heute stand im Auswahlfeld nur `.csv` -- eine .xlsx war
|
||
ausgegraut, ohne ein Wort dazu. Eine Sackgasse ohne Wegweiser
|
||
ist schlimmer als eine Absage mit Begruendung: Man sucht den
|
||
Fehler bei sich.
|
||
|
||
Jetzt laesst sich jede Datei waehlen, und WAS sie ist, sagt der
|
||
INHALT -- nicht die Endung. Eine umbenannte Datei ist keine
|
||
andere Datei. Eine .xlsx ist in Wahrheit ein ZIP-Archiv und
|
||
beginnt mit "PK"; die alte .xls beginnt mit dem
|
||
OLE-Kennzeichen D0 CF 11 E0.
|
||
|
||
WARUM WIR SIE NICHT EINFACH LESEN: Der Leser dahinter versteht
|
||
Text (Komma, Semikolon, Tabulator). Ein ZIP-Archiv als Text
|
||
gelesen ergibt Zeichensalat -- und der schlimmste Ausgang waere
|
||
nicht "geht nicht", sondern eine Vorschau mit Zahlen, die
|
||
irgendwo aus dem Dateikopf stammen. */
|
||
/* WAS IST DAS UEBERHAUPT FUER EINE DATEI? (11.09.2026, erweitert 14.09.2026)
|
||
|
||
Filipe zuerst: "meine excel datei kann ich nicht auswaehlen
|
||
wieso??" -- dann: "mach doch bitte so dass man alle dateien hoch
|
||
laden könne auch excel dateien."
|
||
|
||
Erkannt wird am INHALT, nicht an der Endung: Eine umbenannte
|
||
Datei ist keine andere Datei. Eine .xlsx ist ein ZIP-Archiv und
|
||
beginnt mit "PK", die alte .xls mit dem OLE-Kennzeichen
|
||
D0 CF 11 E0.
|
||
|
||
Seit heute wird die .xlsx nicht mehr abgewiesen, sondern
|
||
GELESEN: Der Server macht daraus eine TAB-getrennte Tabelle --
|
||
genau die Form, die auch beim Einfuegen aus dem Browser
|
||
entsteht. Danach laeuft alles durch dieselbe Vorschau wie
|
||
bisher. */
|
||
const anfang = new Uint8Array(await datei.slice(0, 8).arrayBuffer());
|
||
const beginnt = (...b) => b.every((x, i) => anfang[i] === x);
|
||
let ausExcel = null;
|
||
|
||
if (beginnt(0x50, 0x4b)) {
|
||
$('import-fehler').textContent = 'Excel-Datei wird gelesen …';
|
||
try {
|
||
const a = await hole('/workspace/api/leistung/tabelle-lesen', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/octet-stream' },
|
||
body: await datei.arrayBuffer(),
|
||
});
|
||
const d = await a.json().catch(() => ({}));
|
||
if (!a.ok) {
|
||
$('import-fehler').textContent = sagWas(d.fehler, 'Die Excel-Datei ließ sich nicht lesen.');
|
||
$('datei').value = '';
|
||
return;
|
||
}
|
||
importText = d.text;
|
||
ausExcel = d;
|
||
$('import-fehler').textContent = '';
|
||
} catch {
|
||
$('import-fehler').textContent = 'Die Excel-Datei konnte nicht gelesen werden.';
|
||
$('datei').value = '';
|
||
return;
|
||
}
|
||
} else if (beginnt(0xd0, 0xcf, 0x11, 0xe0)) {
|
||
/* Die ALTE .xls ist kein ZIP, sondern ein ganz anderes Format
|
||
(BIFF, vor 2007). Sie zu lesen waere ein zweiter Leser fuer
|
||
einen Fall, den Backstage nicht erzeugt. */
|
||
$('import-fehler').innerHTML =
|
||
'Das ist eine <strong>alte Excel-Datei (.xls)</strong>. Speichere sie in Excel '
|
||
+ 'einmal als <em>.xlsx</em> oder als <em>CSV UTF-8</em> – beides geht dann durch.';
|
||
$('datei').value = '';
|
||
return;
|
||
}
|
||
|
||
if (!ausExcel) {
|
||
try {
|
||
importText = await datei.text();
|
||
} catch {
|
||
$('import-fehler').textContent = 'Die Datei ließ sich nicht lesen.';
|
||
return;
|
||
}
|
||
}
|
||
|
||
/* Eine leere Datei ist kein Lesefehler, sieht aber wie einer aus. */
|
||
if (!importText.trim()) {
|
||
$('import-fehler').textContent = 'Die Datei ist leer.';
|
||
$('datei').value = '';
|
||
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 = sagWas(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 = sagWas(r.fehler, 'Ging nicht.'); return; }
|
||
/* Gespeichert -- die Verwurfwache darf hier nicht fragen. */
|
||
$('import-dialog').vergessen?.();
|
||
$('import-dialog').close();
|
||
$('datei').value = '';
|
||
importText = '';
|
||
await allesLaden();
|
||
/* WAS GENAU ANGEKOMMEN IST -- Tage und Zeitraeume getrennt.
|
||
"2 Tage uebernommen" waere bei einer Backstage-Ausgabe ueber
|
||
zwei Wochen schlicht falsch; man wuerde sie in der Tagesliste
|
||
suchen und nicht finden. */
|
||
const teile = [];
|
||
if (r.geschrieben) teile.push(`${r.geschrieben} Tag${r.geschrieben === 1 ? '' : 'e'}`);
|
||
if (r.zeitraeume) teile.push(`${r.zeitraeume} Zeitraum${r.zeitraeume === 1 ? '' : 'e'}`);
|
||
if (r.felder) teile.push(`${r.felder} Werte aus der Datei`);
|
||
melde(teile.length ? `${teile.join(' und ')} übernommen.` : 'Nichts zu übernehmen.');
|
||
setTimeout(() => melde(''), 5000);
|
||
} catch { /* umgeleitet */ } finally { knopf.disabled = false; }
|
||
}
|
||
|
||
/* ===================================================================
|
||
ZAHLEN FÜR EINEN ZEITRAUM (14.09.2026)
|
||
|
||
Filipe: "ich will dass das auch so geht, mach dass es
|
||
funktioniert ... sieh zu dass die excel auch so durch geht."
|
||
|
||
Backstage gibt eine Ausgabe für einen ZEITRAUM heraus -- alle
|
||
Zahlen aufsummiert, eine Zeile je Creator. Auf einen Tag
|
||
geschrieben wären das dreizehn Tage auf einmal: eine Zahl, die
|
||
richtig aussieht und falsch ist. Also stehen sie hier als das, was
|
||
sie sind.
|
||
|
||
DER BLOCK VERSCHWINDET, WENN ES NICHTS GIBT. Eine leere
|
||
Überschrift ist keine Ordnung, sondern eine Lücke.
|
||
=================================================================== */
|
||
function spannenZeichnen() {
|
||
const liste = stand?.zeitraeume || [];
|
||
$('spannen-block').hidden = liste.length === 0;
|
||
const ziel = $('spannen');
|
||
ziel.textContent = '';
|
||
if (!liste.length) return;
|
||
|
||
for (const z of liste) {
|
||
const karte = el('article', 'spanne');
|
||
|
||
/* ---- Kopf: die Spanne gross, die Dauer als Marke ------------- */
|
||
const kopf = el('header', 'spanne__kopf');
|
||
const spanne = el('div', 'spanne__spanne');
|
||
spanne.append(el('span', 'spanne__von', kurzTag(z.von)));
|
||
spanne.append(el('span', 'spanne__strich', '–'));
|
||
spanne.append(el('span', 'spanne__bis', kurzTag(z.bis)));
|
||
kopf.append(spanne);
|
||
/* DIE ZAHL DER TAGE IST DIE WICHTIGSTE EINORDNUNG: "13.210
|
||
Diamanten" ueber drei Tage ist etwas anderes als ueber
|
||
dreizehn. Sie steht deshalb neben der Spanne und nicht
|
||
irgendwo unten. */
|
||
kopf.append(el('span', 'spanne__tage', `${z.tage} Tage`));
|
||
karte.append(kopf);
|
||
|
||
/* ---- Die Hauptzahl -------------------------------------------- */
|
||
if (z.diamanten !== null && z.diamanten !== undefined) {
|
||
const haupt = el('div', 'spanne__haupt');
|
||
haupt.append(el('span', 'spanne__haupt-zahl', zahl(z.diamanten)));
|
||
haupt.append(el('span', 'spanne__haupt-name', 'Diamanten'));
|
||
if (z.tage > 0) {
|
||
/* DIE EINZIGE UMRECHNUNG, DIE EHRLICH IST -- und sie steht
|
||
als Durchschnitt da, nicht als Tageswert. Gespeichert wird
|
||
sie nirgends. */
|
||
haupt.append(el('span', 'spanne__schnitt',
|
||
`Ø ${zahl(Math.round(z.diamanten / z.tage))} pro Tag`));
|
||
}
|
||
karte.append(haupt);
|
||
}
|
||
|
||
/* ---- Wie viele Tage davon wirklich live waren (16.09.2026) -----
|
||
Backstage liefert "Gültige LIVE-Gehen-Tage" mit. Die Zahl stand
|
||
bisher als eine von fünf gleich aussehenden unten im Raster --
|
||
dabei ist sie die EINZIGE, die sagt, wie die Diamantenzahl
|
||
zustande kam: 13.210 an elf Tagen ist etwas anderes als
|
||
13.210 an dreien.
|
||
|
||
EIN STREIFEN STATT EINES BALKENS. Er zeigt einen Anteil, keinen
|
||
Fortschritt -- deshalb keine Bewegung, kein Füllen, kein Ziel.
|
||
|
||
GERECHNET WIRD NUR, WENN BEIDE ZAHLEN DA SIND. Ohne die Zahl
|
||
der Tage wäre der Anteil eine Division durch nichts, und ein
|
||
Streifen ohne Bezug zeigt eine Genauigkeit, die es nicht gibt. */
|
||
if (z.gueltige_tage !== null && z.gueltige_tage !== undefined && z.tage > 0) {
|
||
/* NICHT GERUNDET. 11 von 13 sind 84,615 %; auf 85 gerundet
|
||
ragte die Füllung vier Pixel in den zwölften Tag hinein --
|
||
auf einem in Tage geteilten Streifen sieht das aus wie ein
|
||
angefangener Tag, den es nicht gibt. */
|
||
const anteil = Math.max(0, Math.min(100, (z.gueltige_tage / z.tage) * 100));
|
||
const lauf = el('div', 'spanne__lauf');
|
||
const bahn = el('div', 'spanne__lauf-bahn');
|
||
const teil = el('div', 'spanne__lauf-teil');
|
||
teil.style.width = anteil + '%';
|
||
/* Die Zahl der Tage geht als Wert an den Stil -- er teilt den
|
||
Streifen daraufhin in genau so viele Kästchen. Ab 32 Tagen
|
||
wäre jede Zelle schmaler als ihre Fuge, dann bleibt es bei
|
||
der glatten Bahn. Die Grenze steht im Stil erklärt. */
|
||
bahn.style.setProperty('--tage', String(z.tage));
|
||
if (z.tage <= 31) bahn.dataset.teilen = 'ja';
|
||
bahn.append(teil);
|
||
lauf.append(bahn);
|
||
lauf.append(el('span', 'spanne__lauf-text',
|
||
`${z.gueltige_tage} von ${z.tage} Tagen live`));
|
||
karte.append(lauf);
|
||
}
|
||
|
||
/* ---- Die uebrigen Werte --------------------------------------- */
|
||
const werte = el('div', 'spanne__werte');
|
||
const zeig = (name, wert, form) => {
|
||
if (wert === null || wert === undefined) return;
|
||
const w = el('div', 'spanne__wert');
|
||
w.append(el('span', 'spanne__name', name));
|
||
w.append(el('span', 'spanne__zahl', form(wert)));
|
||
werte.append(w);
|
||
};
|
||
zeig('LIVE-Zeit', z.dauer_min, stunden);
|
||
/* "Gültige Tage" steht NICHT mehr hier -- es steht als Streifen
|
||
darüber. Zweimal dieselbe Zahl wäre zweimal dieselbe Auskunft
|
||
mit zwei verschiedenen Rängen. */
|
||
zeig('Ø Zuschauer', z.zuschauer_avg, zahl);
|
||
zeig('Spitze', z.zuschauer_max, zahl);
|
||
zeig('Neue Follower', z.follower_neu, zahl);
|
||
if (werte.children.length) karte.append(werte);
|
||
|
||
/* ---- UND ALLES ANDERE, WAS IN DER DATEI STAND (16.09.2026) ----
|
||
Filipe: "ich will dass alles von der excel datei genommen wird.
|
||
... wenn ich dir runter lade soll alles notiert und angezeigt
|
||
werden."
|
||
|
||
Seine Ausgabe hat 41 Spalten; oben stehen die sechs, die das
|
||
System deutet. Der Rest -- letzter Monat, fünf Prozentwerte,
|
||
Matches, Multi-Gast-LIVEs, Fanclub, Graduierungs- und
|
||
Stufenstatus -- steht hier, WÖRTLICH wie in der Datei.
|
||
|
||
IN DER REIHENFOLGE DER DATEI, nicht in einer eigenen: Backstage
|
||
gruppiert seine Spalten schon sinnvoll, und jede Ordnung, die
|
||
ich erfinde, ist beim nächsten Backstage-Update falsch.
|
||
|
||
ZUGEKLAPPT, ABER MIT DER ZAHL IM DECKEL. Achtunddreißig Zeilen
|
||
offen unter jedem Zeitraum wären die Karte nicht mehr lesbar --
|
||
und genau die Lesbarkeit war gestern sein Thema. Im Deckel
|
||
steht, wie viele es sind; damit sieht man ohne Öffnen, dass
|
||
nichts fehlt. */
|
||
if (Array.isArray(z.felder) && z.felder.length) {
|
||
const alles = el('details', 'spanne__alles');
|
||
alles.append(el('summary', 'spanne__alles-kopf',
|
||
`Alle ${z.felder.length} Werte aus Backstage`));
|
||
const liste = el('dl', 'spanne__alles-liste');
|
||
for (const f of z.felder) {
|
||
liste.append(el('dt', 'spanne__alles-name', f.name));
|
||
liste.append(el('dd', 'spanne__alles-wert', f.text));
|
||
}
|
||
alles.append(liste);
|
||
karte.append(alles);
|
||
}
|
||
|
||
ziel.append(karte);
|
||
}
|
||
}
|
||
|
||
/** "2026-09-01" -> "01.09." -- in einer Spanne zählt der Tag, nicht
|
||
* das Jahr; beide Enden im selben Jahr wären doppelt genannt. */
|
||
function kurzTag(iso) {
|
||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso || ''));
|
||
return m ? `${m[3]}.${m[2]}.` : String(iso || '');
|
||
}
|
||
|
||
/* ---------- 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;
|
||
spannenZeichnen();
|
||
|
||
/* 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' : '';
|
||
|
||
/* KEIN TAG ERFASST -> DIE ANTWORT STATT SIEBEN NULLEN (14.09.2026).
|
||
|
||
Filipe: "wieso werden die zahlen oben nicht ausgefüllt von der
|
||
excel datei?"
|
||
|
||
Die Frage entsteht an DIESER Stelle, also steht die Antwort hier.
|
||
Sieben Karten mit "0" und "wie in der Vorwoche" sehen aus wie ein
|
||
Fehler -- dabei ist einfach kein einziger Tag erfasst, weil die
|
||
Backstage-Ausgabe einen ZEITRAUM enthielt.
|
||
|
||
Und sie sagt nicht nur WARUM, sondern auch, was zu tun ist:
|
||
in Backstage den Zeitraum auf einen Tag stellen. Ein Hinweis ohne
|
||
Ausweg ist eine Sackgasse mit Erklärung. */
|
||
const spannen = stand?.zeitraeume || [];
|
||
const leer = $('woche-leer');
|
||
if (e.tage === 0) {
|
||
leer.textContent = '';
|
||
leer.append(el('p', 'woche-leer__satz',
|
||
'Für diese Woche ist noch kein einziger Tag erfasst.'));
|
||
if (spannen.length) {
|
||
const s0 = spannen[0];
|
||
leer.append(el('p', 'woche-leer__grund',
|
||
`Deine Backstage-Ausgabe deckt ${kurzTag(s0.von)} – ${kurzTag(s0.bis)} ab `
|
||
+ `(${s0.tage} Tage auf einmal). Aus einer Summe über ${s0.tage} Tage lässt `
|
||
+ 'sich nicht ablesen, wie ein einzelner Tag lief – deshalb steht sie unten '
|
||
+ 'als Zeitraum und nicht hier oben.'));
|
||
leer.append(el('p', 'woche-leer__weg',
|
||
'Wenn du die Wochenzahlen füllen willst: In Backstage den Zeitraum auf '
|
||
+ 'EINEN Tag stellen, herunterladen, hier einlesen – je Tag einmal.'));
|
||
} else {
|
||
leer.append(el('p', 'woche-leer__grund',
|
||
'Trag einen Tag von Hand ein oder lies eine Datei mit Tageszeilen ein.'));
|
||
}
|
||
leer.hidden = false;
|
||
$('zahlkarten').hidden = true;
|
||
} else {
|
||
leer.hidden = true;
|
||
$('zahlkarten').hidden = false;
|
||
}
|
||
|
||
wochenKartenBauen();
|
||
zieleBauen();
|
||
await tageLaden();
|
||
}
|
||
|
||
/* ---------- Start -------------------------------------------------------- */
|
||
|
||
$('tag-form').addEventListener('submit', tagSpeichern);
|
||
$('tag-zu').addEventListener('click', () => $('tag-dialog').close());
|
||
$('tag-loeschen').addEventListener('click', tagLoeschen);
|
||
/* =====================================================================
|
||
DIE BACKSTAGE-TABELLE — Vorschau und Übernahme
|
||
===================================================================== */
|
||
async function netzVorschau() {
|
||
const text = $('netz-text').value.trim();
|
||
$('netz-fehler').textContent = '';
|
||
if (!text) { $('netz-vorschau').hidden = true; $('netz-los').disabled = true; return; }
|
||
|
||
const a = await hole('/workspace/api/leistung/netzwerk-import/vorschau', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ text, tag: $('netz-tag').value || null }),
|
||
}).catch(() => null);
|
||
if (!a) return;
|
||
const d = await a.json().catch(() => ({}));
|
||
|
||
if (!a.ok) {
|
||
$('netz-vorschau').hidden = true;
|
||
$('netz-los').disabled = true;
|
||
$('netz-fehler').textContent = sagWas(d.fehler, 'Die Tabelle konnte nicht gelesen werden.');
|
||
/* Fehlt nur der Tag, ist das kein Fehler, sondern eine Frage --
|
||
also wird das Feld hervorgehoben statt nur gemeckert. */
|
||
if (d.braucht_tag) $('netz-tag').focus();
|
||
return;
|
||
}
|
||
|
||
const v = $('netz-vorschau');
|
||
v.textContent = '';
|
||
v.hidden = false;
|
||
|
||
const zeile = (text2, art) => {
|
||
const p = document.createElement('p');
|
||
p.className = 'import-vorschau__zeile';
|
||
if (art) p.dataset.art = art;
|
||
p.textContent = text2;
|
||
v.append(p);
|
||
};
|
||
|
||
zeile(`${d.zugeordnet} von ${d.zugeordnet + d.unbekannt.length} Zeilen zugeordnet`
|
||
+ ` · ${d.neu} neu, ${d.ersetzt} werden ersetzt`, d.zugeordnet ? 'gut' : 'warn');
|
||
zeile(`Erkannte Spalten: ${Object.keys(d.spalten).join(', ') || 'keine'}`
|
||
+ (d.aus_tabelle ? ' · Datum aus der Tabelle' : ' · Datum aus dem Feld oben'));
|
||
|
||
/* WAS MIT DEM REST PASSIERT -- und das ist seit dem 16.09. die
|
||
Zeile, auf die es Filipe ankommt: "ich will dass alles von der
|
||
excel datei genommen wird."
|
||
|
||
Die Zeile darueber nennt nur die ACHT Spalten, die das System
|
||
deutet. Seine echte Ausgabe hat 41. Ohne diese Zeile sieht es
|
||
aus, als fielen 33 davon weg -- sie werden aber vollstaendig
|
||
mitgeschrieben und stehen danach auf der Karte. */
|
||
if (d.spalten_gesamt) {
|
||
zeile(`Alle ${d.spalten_gesamt} Spalten der Datei werden übernommen`
|
||
+ (d.felder ? ` · ${d.felder} davon tragen einen Wert` : '')
|
||
+ ' · sie stehen danach unter „Alle Werte aus Backstage“', 'gut');
|
||
}
|
||
|
||
/* DIE UNBEKANNTEN SIND DER WICHTIGE TEIL. Wer sie übersieht, hat
|
||
stillschweigend Leute ausgelassen -- und merkt es erst, wenn die
|
||
Zahlen nicht stimmen. Deshalb stehen sie mit Namen da und nicht
|
||
als Zahl. */
|
||
if (d.unbekannt.length) {
|
||
zeile(`Nicht zugeordnet (${d.unbekannt.length}): ${d.unbekannt.slice(0, 8).join(', ')}`
|
||
+ (d.unbekannt.length > 8 ? ' …' : ''), 'warn');
|
||
zeile('Diese Namen stehen bei keinem Creator im Feld „TikTok“ im Steckbrief. '
|
||
+ 'Dort eintragen – danach werden sie beim nächsten Mal gefunden.', 'still');
|
||
}
|
||
if (d.probleme_gesamt) {
|
||
zeile(`${d.probleme_gesamt} Zeile(n) übersprungen:`, 'warn');
|
||
for (const p of d.probleme) zeile(`Zeile ${p.zeile}: ${p.grund}`, 'still');
|
||
}
|
||
for (const b of d.beispiele) {
|
||
const zahlen = Object.entries(b)
|
||
/* `felder` ist eine LISTE und gehoert nicht in diese Zeile --
|
||
ohne diesen Ausschluss stand hier woertlich
|
||
"felder [object Object],[object Object]". */
|
||
.filter(([k]) => !['creator_id', 'name', 'tag', 'felder'].includes(k))
|
||
.filter(([, w]) => w !== null && w !== undefined)
|
||
.map(([k, w]) => `${k} ${w}`).join(' · ');
|
||
zeile(`${b.name} · ${b.tag} · ${zahlen}`, 'still');
|
||
}
|
||
|
||
$('netz-los').disabled = d.zugeordnet === 0;
|
||
}
|
||
|
||
async function netzUebernehmen(ereignis) {
|
||
ereignis.preventDefault();
|
||
$('netz-los').disabled = true;
|
||
const a = await hole('/workspace/api/leistung/netzwerk-import', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
text: $('netz-text').value, tag: $('netz-tag').value || null,
|
||
}),
|
||
}).catch(() => null);
|
||
if (!a) { $('netz-los').disabled = false; return; }
|
||
const d = await a.json().catch(() => ({}));
|
||
if (!a.ok) {
|
||
$('netz-fehler').textContent = sagWas(d.fehler, 'Übernehmen ging nicht.');
|
||
$('netz-los').disabled = false;
|
||
return;
|
||
}
|
||
/* Gespeichert -- die Verwurfwache darf hier nicht fragen. */
|
||
$('netz-dialog').vergessen?.();
|
||
$('netz-dialog').close();
|
||
melde(`${d.geschrieben} Zeile(n) übernommen`
|
||
+ (d.unbekannt?.length ? ` · ${d.unbekannt.length} nicht zugeordnet` : ''));
|
||
await laden();
|
||
}
|
||
|
||
$('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);
|
||
|
||
/* ---------- Die Backstage-Tabelle ------------------------------------ */
|
||
$('netz-oeffnen').addEventListener('click', () => {
|
||
$('netz-vorschau').hidden = true;
|
||
$('netz-los').disabled = true;
|
||
$('netz-fehler').textContent = '';
|
||
$('netz-text').value = '';
|
||
$('netz-dialog').showModal();
|
||
$('netz-text').focus();
|
||
});
|
||
$('netz-zu').addEventListener('click', () => $('netz-dialog').close());
|
||
|
||
/* DIE BACKSTAGE-AUSGABE ALS DATEI (14.09.2026).
|
||
|
||
Sie enthaelt ALLE Creator auf einmal -- deshalb hier und nicht im
|
||
Einzel-Import daneben. Der Weg ist bewusst derselbe wie beim
|
||
Einfuegen: Die Datei wird zu Text, der Text landet im Feld, und ab
|
||
da laeuft die vorhandene Vorschau. Ein eigener Ablauf fuer Dateien
|
||
waere eine zweite Fassung derselben Regeln. */
|
||
$('netz-datei').addEventListener('change', async (e) => {
|
||
const datei = e.target.files?.[0];
|
||
if (!datei) return;
|
||
$('netz-fehler').textContent = 'Datei wird gelesen …';
|
||
try {
|
||
const roh = new Uint8Array(await datei.slice(0, 8).arrayBuffer());
|
||
let text = null;
|
||
let ausExcel = null;
|
||
if (roh[0] === 0x50 && roh[1] === 0x4b) {
|
||
const a = await hole('/workspace/api/leistung/tabelle-lesen', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/octet-stream' },
|
||
body: await datei.arrayBuffer(),
|
||
});
|
||
const d = await a.json().catch(() => ({}));
|
||
if (!a.ok) { $('netz-fehler').textContent = sagWas(d.fehler, 'Ging nicht.'); return; }
|
||
text = d.text;
|
||
ausExcel = d;
|
||
} else if (roh[0] === 0xd0 && roh[1] === 0xcf) {
|
||
$('netz-fehler').textContent = 'Das ist eine alte Excel-Datei (.xls). '
|
||
+ 'Speichere sie einmal als .xlsx oder als CSV.';
|
||
return;
|
||
} else {
|
||
text = await datei.text();
|
||
}
|
||
if (!text || !text.trim()) { $('netz-fehler').textContent = 'Die Datei ist leer.'; return; }
|
||
$('netz-text').value = text;
|
||
$('netz-fehler').textContent = '';
|
||
/* WAS ICH MIT DER DATEI GEMACHT HABE, STEHT DA. Eine Tabelle, die
|
||
stillschweigend anders anfaengt als die Datei (Backstage setzt
|
||
bei einer Ausgabe eine Titelzeile darueber), ist die Sorte
|
||
Hilfsbereitschaft, die spaeter niemand nachvollzieht. */
|
||
if (ausExcel) {
|
||
melde(`Excel gelesen: Blatt „${ausExcel.blatt}“, ${ausExcel.zeilen} Zeilen, `
|
||
+ `${ausExcel.spalten} Spalten`
|
||
+ (ausExcel.uebersprungen ? ` (${ausExcel.uebersprungen} Titelzeile übersprungen)` : ''));
|
||
setTimeout(() => melde(''), 7000);
|
||
}
|
||
await netzVorschau();
|
||
} catch {
|
||
$('netz-fehler').textContent = 'Die Datei konnte nicht gelesen werden.';
|
||
}
|
||
});
|
||
/* Beim EINFÜGEN sofort nachsehen -- nicht erst auf einen Knopf warten.
|
||
Wer eine Tabelle einfügt, will wissen, ob sie erkannt wurde; ein
|
||
zweiter Klick dazwischen ist nur eine Gelegenheit, es nicht zu tun. */
|
||
$('netz-text').addEventListener('paste', () => setTimeout(netzVorschau, 60));
|
||
$('netz-text').addEventListener('change', netzVorschau);
|
||
$('netz-tag').addEventListener('change', netzVorschau);
|
||
$('netz-form').addEventListener('submit', netzUebernehmen);
|
||
|
||
(async () => {
|
||
try {
|
||
const a = await hole('/workspace/api/ich');
|
||
if (!a.ok) { location.assign('/workspace/'); return; }
|
||
ich = await a.json();
|
||
} catch {
|
||
/* NICHT MEHR STUMM (20.09.2026). Hier stand `catch { return; }`
|
||
-- und damit blieb die Seite bei jedem Netzaussetzer fuer
|
||
immer auf "wird geladen …" stehen. Gemessen auf 13 von 21
|
||
Seiten. `ladefehler()` sagt, was los ist, und bietet einen
|
||
Knopf an, der es noch einmal versucht. */
|
||
window.ladefehler?.();
|
||
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();
|
||
})();
|
||
})();
|