Files
dogfather-universe/workspace/assets/js/leistung.js
T
DogFatherGitandClaude Opus 5 027c372afd Die TikTok-Datei des Creators -- ohne Entwicklerkonto
Filipe: "tiktok entwicklerkonto hab ich nicht und werd ich nicht
bekommen."

Damit sind zwei der drei Wege aus dem Plan vom 10.09. erledigt: Login
Kit und die Datenportabilitaets-API haengen beide an einer App auf
developers.tiktok.com. Sie sind nicht verschoben, sie sind weg.

DER DRITTE BRAUCHT KEINES. Jeder TikTok-Nutzer kann seine eigenen Daten
selbst herunterladen (Profil -> Einstellungen -> Konto -> Deine Daten
herunterladen, Format JSON). Die Datei enthaelt laut TikToks eigener
Datentypen-Liste einen Abschnitt "TikTok LIVE" mit dem Go-LIVE-Verlauf.
Der Creator gibt sie her, mit eigenen Haenden -- legitimer wird es
nicht, und es ist jetzt der einzige verbliebene Weg an TikTok-Daten.

ICH KENNE DAS FORMAT NICHT -- UND BAUE TROTZDEM.
TikTok dokumentiert, DASS es den Verlauf gibt, nicht wie die Schluessel
heissen. Der naheliegende Weg waere, Namen zu raten. Das waere die
schlechteste Loesung: Er fiele bei der ersten echten Datei auseinander,
und zwar STILL -- "0 Eintraege gefunden" sieht aus wie "war nicht live".

Deshalb wird gesucht statt geraten. Der Leser geht die Datei durch,
sammelt ALLE Listen, deren Eintraege ein Datum tragen (gemessen am
INHALT, nicht am Feldnamen), schlaegt eine Zuordnung vor und legt sie
zur Auswahl vor -- mit Anzahl, Zeitraum, Beispielzeile und dem, was
dabei herauskaeme. Bestaetigt wird von Hand. Damit ist der Leser
unabhaengig davon, wie die Felder heissen.

DIE EINHEIT IST DIE FALLE. "83" kann Sekunden, Minuten oder Stunden
sein. Geraten wird NICHT aus der Zahl, sondern aus dem Schluesselnamen
-- und wo der schweigt, aus der Form ("01:23:45" ist eindeutig).
Schweigen beide, kommt null zurueck. Eine Dauer, die um Faktor 60
danebenliegt, sieht richtig aus und ist es nicht.

MEHRERE LIVES AN EINEM TAG SIND EIN TAG: Dauer und Diamanten addiert,
bei den Zuschauern gewinnt die hoehere Spitze -- ein Durchschnitt aus
zwei Streams waere eine Zahl, die es nie gegeben hat.

DIE DATEI UEBERSCHREIBT NICHTS. Anders als der Backstage-Import, der
die Wahrheit der Agentur bringt, ist diese Datei die ZWEITE Quelle. Wo
schon eine Zahl steht -- von Hand oder aus Backstage --, bleibt sie
stehen (COALESCE statt REPLACE). Sonst wuerde ein Dateiupload
stillschweigend die offiziellen Zahlen ersetzen, und niemand wuesste
hinterher, welche gilt.

UND SIE WIRD NICHT GESPEICHERT. Gelesen, ausgewertet, verworfen. In
derselben Datei stehen Direktnachrichten, Such- und Ansehverlauf; die
haben auf diesem Server nichts zu suchen. "Income and Wallet" bleibt
ebenfalls liegen: Diamanten sind Leistung, Auszahlungen sind Gehalt.

GEPRUEFT OHNE ECHTE DATEI -- MIT DREI ERFUNDENEN (47 Pruefungen):
  A  englisch, flach, Dauer in Sekunden
  B  deutsch, verschachtelt, Dauer als "01:23:45"
  C  Sekundenstempel, Dauer in Minuten, andere Namen
  +  eine Datei ganz ohne Verlauf -> muss abgelehnt werden

Eine Pruefung gegen EINE ausgedachte Form wuerde nur beweisen, dass
mein Leser meine eigene Erfindung liest. Drei verschiedene zeigen, dass
er das Prinzip kann und nicht eine Form.

EIN EIGENER FEHLER, von der Pruefung gefunden: Der Nachrichtenverlauf
in Form A hatte nur EINEN Eintrag und fiel damit unter die
Mindestgroesse von zwei. Die Pruefung "der LIVE-Verlauf steht oben"
hatte danach nur einen Kandidaten und bewies gar nichts -- eine
Rangfolge laesst sich nur an mindestens zwei Dingen zeigen. Jetzt sind
es drei Nachrichten, und die Rangfolge ist wirklich gemessen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-11 12:01:49 +02:00

837 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
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();
/* 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; hier geht es
darum, niemandem einen Knopf hinzustellen, der eine Absage
bringt. */
$('netz-oeffnen').hidden = !LEITUNG.has(ich?.rolle);
/* Die Datei gehoert EINEM Creator -- deshalb sichtbar fuer alle,
die fuer ihn eintragen duerfen, also auch fuer seinen Scout. */
$('tt-oeffnen').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);
}
/* 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 = (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);
/* =====================================================================
DIE TIKTOK-DATEI DES CREATORS
Das Format ist nicht dokumentiert. Deshalb wird hier nichts
geraten: Der Server durchsucht die Datei nach Listen mit Datum und
schickt sie zur Auswahl zurück -- mit Anzahl, Zeitraum und dem,
was dabei herauskäme. Bestätigt wird von Hand.
===================================================================== */
let ttInhalt = null; /* die gelesene Datei -- bleibt im Browser */
let ttWahl = null; /* der bestätigte Kandidat */
async function ttDateiGewaehlt(ereignis) {
const datei = ereignis.target.files?.[0];
$('tt-fehler').textContent = '';
$('tt-vorschau').hidden = true;
$('tt-los').disabled = true;
ttInhalt = null; ttWahl = null;
if (!datei) return;
/* Ein Vollarchiv kann zweistellige Megabyte haben. Das Lesen
passiert im Browser; auf den Server geht es erst danach -- und
nur einmal, nicht bei jedem Klick. */
$('tt-fehler').textContent = 'Datei wird gelesen …';
let text;
try { text = await datei.text(); } catch { $('tt-fehler').textContent = 'Die Datei ließ sich nicht lesen.'; return; }
try { ttInhalt = JSON.parse(text); } catch {
$('tt-fehler').textContent = 'Das ist keine JSON-Datei. Beim Herunterladen bei TikTok '
+ 'muss als Format JSON gewählt werden, nicht TXT.';
return;
}
$('tt-fehler').textContent = 'Datei wird durchsucht …';
const a = await hole(`/workspace/api/leistung/${offenerCreator}/tiktok-datei/vorschau`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ inhalt: ttInhalt }),
}).catch(() => null);
if (!a) { $('tt-fehler').textContent = 'Die Datei konnte nicht geprüft werden.'; return; }
const d = await a.json().catch(() => ({}));
if (!a.ok) { $('tt-fehler').textContent = d.fehler || 'Die Datei wurde nicht verstanden.'; return; }
$('tt-fehler').textContent = '';
const v = $('tt-vorschau');
v.textContent = ''; v.hidden = false;
const zeile = (text2, art) => {
const p = el('p', 'import-vorschau__zeile');
if (art) p.dataset.art = art;
p.textContent = text2;
v.append(p);
return p;
};
zeile(`${d.kandidaten.length} Liste(n) mit Datum gefunden. Welche ist der LIVE-Verlauf?`);
/* Die Auswahl als echte Radioknöpfe: Es ist eine Entscheidung, und
eine Entscheidung gehört nicht in ein Aufklappfeld, in dem man
die erste Zeile für gesetzt hält. */
d.kandidaten.forEach((k, i) => {
const box = el('label', 'tt-wahl');
const r = document.createElement('input');
r.type = 'radio'; r.name = 'tt-kandidat'; r.value = String(i);
if (i === 0) { r.checked = true; ttWahl = k; }
r.addEventListener('change', () => { ttWahl = k; $('tt-los').disabled = false; });
const t = el('span', 'tt-wahl__text');
t.append(el('strong', null, k.pfad || '(oberste Ebene)'));
t.append(el('span', 'tt-wahl__zahl',
` ${k.anzahl} Einträge · ${k.tage_gesamt} Tage`
+ (k.von ? ` · ${k.von} bis ${k.bis}` : '')));
const felder = ['tag', 'dauer', 'diamanten', 'zuschauer']
.filter((f) => k.vorschlag[f])
.map((f) => `${f}: ${k.vorschlag[f]}`).join(' · ');
t.append(el('span', 'tt-wahl__felder', felder || 'nur ein Datum erkannt'));
box.append(r, t);
v.append(box);
});
if (ttWahl) $('tt-los').disabled = false;
}
async function ttUebernehmen(ereignis) {
ereignis.preventDefault();
if (!ttInhalt || !ttWahl) return;
$('tt-los').disabled = true;
const a = await hole(`/workspace/api/leistung/${offenerCreator}/tiktok-datei`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ inhalt: ttInhalt, pfad: ttWahl.pfad, zuordnung: ttWahl.vorschlag }),
}).catch(() => null);
if (!a) { $('tt-los').disabled = false; return; }
const d = await a.json().catch(() => ({}));
if (!a.ok) {
$('tt-fehler').textContent = d.fehler === 'keine_brauchbaren_tage'
? 'In dieser Liste stehen keine Tage mit Zahlen.'
: (d.fehler || 'Übernehmen ging nicht.');
$('tt-los').disabled = false;
return;
}
/* Die Datei loslassen, sobald sie nicht mehr gebraucht wird. */
ttInhalt = null; ttWahl = null;
$('tt-dialog').close();
melde(`${d.geschrieben} Tage neu, ${d.ergaenzt} ergänzt`);
setTimeout(() => melde(''), 6000);
await laden();
}
/* =====================================================================
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 = 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'));
/* 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)
.filter(([k]) => !['creator_id', 'name', 'tag'].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 = d.fehler || 'Übernehmen ging nicht.';
$('netz-los').disabled = false;
return;
}
$('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());
$('tt-oeffnen').addEventListener('click', () => {
$('tt-vorschau').hidden = true; $('tt-los').disabled = true;
$('tt-fehler').textContent = ''; $('tt-datei').value = '';
$('tt-dialog').showModal();
});
$('tt-zu').addEventListener('click', () => $('tt-dialog').close());
$('tt-datei').addEventListener('change', ttDateiGewaehlt);
$('tt-form').addEventListener('submit', ttUebernehmen);
/* 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 { 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();
})();
})();