diff --git a/server/index.js b/server/index.js index ad39de36..080f1e4a 100644 --- a/server/index.js +++ b/server/index.js @@ -39,6 +39,8 @@ import { ampelRouter } from "./workspace-ampel.js"; import { checklisteRouter } from "./workspace-checkliste.js"; import { pushRouter, pushStarten } from "./workspace-push.js"; import { chatRouter } from "./workspace-chat.js"; +import { leistungRouter } from "./workspace-leistung.js"; +import { fruehwarnungRouter } from "./workspace-fruehwarnung.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SITE_DIR = join(__dirname, ".."); @@ -179,6 +181,11 @@ app.use(pushRouter); alle Chat-Wege still 404 und niemand sieht warum. Der zweite Fall ist der unangenehmere: Die Seite laedt, der Chat bleibt leer. */ app.use(chatRouter); +/* LEISTUNG und FRUEHWARNUNG (06.09.2026, Stufe 1+2 des Plans). Die + Fruehwarnung liest aus `leistung` -- ohne Zahlen hat sie nichts zu + melden, deshalb stehen sie beieinander. */ +app.use(leistungRouter); +app.use(fruehwarnungRouter); app.use(vorlagenRouter); app.use(schulungRouter); app.use(ampelRouter); diff --git a/server/pruef-leistung.mjs b/server/pruef-leistung.mjs new file mode 100644 index 00000000..2292b3e0 --- /dev/null +++ b/server/pruef-leistung.mjs @@ -0,0 +1,455 @@ +/* LEISTUNGSZAHLEN UND FRÜHWARNUNG. + + Stufe 1 und 2 des Plans vom 31.08.2026 -- das Fundament, auf dem + jede Betreuungsentscheidung stehen soll. + + WARUM HIER BESONDERS GENAU GEPRÜFT WIRD: Eine falsche Zahl ist + schlimmer als eine fehlende. Eine fehlende sieht man; eine falsche + sieht aus wie eine Auskunft und führt zu einer Entscheidung. Wenn + das Dashboard "−40 %" sagt, ruft jemand an. + + DIE FALLEN, die diese Prüfung stellt: + + 1. RECHNEN. Durchschnitte über Tage ohne Wert, Prozent von null, + Wochengrenzen. Jede einzeln nachgerechnet. + 2. IMPORT ZWEIMAL. Der häufigste Fehler bei Importen -- dieselbe + Datei doppelt eingelesen darf nichts verdoppeln. + 3. ZAHLENFORMATE. "1.234,5" (deutsch) und "1,234.5" (englisch) + bedeuten dasselbe. Wer das verwechselt, liest 1234,5 als 1,2345. + 4. RECHTE. Ein Creator trägt seine Zahlen nicht selbst ein, und + niemand sieht fremde. + 5. DIE FRÜHWARNUNG SCHLÄGT NICHT ZU FRÜH AN. Ein System, das + jede Woche Alarm gibt, wird jede Woche weniger gelesen. + + GEGENPROBEN durchgehend. */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-leistung-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +const { portMussFreiSein } = await import("./helfer-port.mjs"); +await portMussFreiSein(4333, "die Leistungspruefung"); + +process.env.PORT = "4333"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 1000)); +const BASIS = "http://127.0.0.1:4333"; + +let fehler = 0; +const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +/* ---- Bestand ------------------------------------------------------------ */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); +for (let i = 0; i < 60; i++) { + try { + const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idPat = anlegen("Patrick", "scout", "CODE-SCOU-0001"); +const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001"); +const idNora = anlegen("Nora", "creator", "CODE-CREA-0002"); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idPat, jetzt); + +/* ORTSZEIT -- nie toISOString(). Zwischen Mitternacht und zwei Uhr + läge das Datum sonst einen Tag daneben, und "diese Woche" wäre + verschoben. */ +const tag = (v) => { + const t = new Date(); t.setDate(t.getDate() + v); + const p = (n) => String(n).padStart(2, "0"); + return `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())}`; +}; + +async function anmelden(rolle, code) { + const a = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle, code }) }); + const keks = (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; "); + return { ok: a.ok, keks }; +} +const rufe = (keks, weg, koerper, methode = "GET") => + fetch(BASIS + weg, { + method: methode, + headers: { "Content-Type": "application/json", Cookie: keks }, + body: koerper === undefined ? undefined : JSON.stringify(koerper), + }); +const holen = async (keks, weg) => (await rufe(keks, weg)).json(); + +const dogi = await anmelden("admin", "CODE-DOGI-0001"); +const pat = await anmelden("scout", "CODE-SCOU-0001"); +const luna = await anmelden("creator", "CODE-CREA-0001"); +const nora = await anmelden("creator", "CODE-CREA-0002"); +ok([dogi, pat, luna, nora].every((x) => x.ok && x.keks), "alle vier Rollen angemeldet"); + +/* ======================================================================= + 1. Eintragen und wiederfinden + ======================================================================= */ +console.log("\n=== Einen Tag eintragen ==="); +{ + const a = await rufe(dogi.keks, `/workspace/api/leistung/${idLuna}/${tag(-1)}`, { + diamanten: 1200, dauer_min: 95, gueltiger_tag: true, + zuschauer_avg: 48, zuschauer_max: 130, verweildauer_s: 210, + schenker: 14, follower_neu: 23, notiz: "PK gegen Mia", + }, "PUT"); + ok(a.ok, `eingetragen (${a.status})`); + + const { tage } = await holen(dogi.keks, `/workspace/api/leistung/${idLuna}/tage`); + const z = tage?.[0]; + ok(z?.diamanten === 1200 && z?.verweildauer_s === 210 && z?.notiz === "PK gegen Mia", + `alles gespeichert (${z?.diamanten} Diamanten, ${z?.verweildauer_s}s, "${z?.notiz}")`); + ok(z?.quelle === "hand", `Quelle vermerkt: ${z?.quelle}`); + + /* DERSELBE TAG NOCHMAL überschreibt, statt zu verdoppeln. */ + await rufe(dogi.keks, `/workspace/api/leistung/${idLuna}/${tag(-1)}`, + { diamanten: 1500, gueltiger_tag: true }, "PUT"); + const p = new DatabaseSync(process.env.WORKSPACE_DB); + const n = p.prepare("SELECT COUNT(*) AS n FROM leistung WHERE creator_id = ? AND tag = ?") + .get(idLuna, tag(-1)).n; + const wert = p.prepare("SELECT diamanten FROM leistung WHERE creator_id = ? AND tag = ?") + .get(idLuna, tag(-1)).diamanten; + p.close(); + ok(n === 1 && wert === 1500, `zweimal eingetragen: ${n} Zeile, Wert ${wert} (überschrieben)`); + + /* KEINE ZUKUNFT. Ein Tippfehler im Jahr legt sonst eine Zeile an, + die nie wieder auffällt -- sie taucht in keiner Auswertung auf. */ + const z2 = await rufe(dogi.keks, `/workspace/api/leistung/${idLuna}/${tag(3)}`, + { diamanten: 999 }, "PUT"); + ok(z2.status === 400, `ein Tag in der Zukunft: abgelehnt (${z2.status})`); +} + +/* ======================================================================= + 2. Das Rechnen — hier wird jede Zahl nachgerechnet + ======================================================================= */ +console.log("\n=== Die Rechnung stimmt ==="); +{ + const p = new DatabaseSync(process.env.WORKSPACE_DB); + p.prepare("DELETE FROM leistung WHERE creator_id = ?").run(idLuna); + /* Diese Woche: 3 Tage mit Werten. Ein vierter Tag OHNE Zuschauerzahl + -- er darf den Durchschnitt nicht verwässern. */ + const setz = p.prepare(`INSERT INTO leistung (creator_id, tag, diamanten, dauer_min, + gueltiger_tag, zuschauer_avg, verweildauer_s, erfasst, quelle) + VALUES (?,?,?,?,?,?,?,?, 'hand')`); + setz.run(idLuna, tag(-1), 100, 60, 1, 40, 200, jetzt); + setz.run(idLuna, tag(-2), 200, 60, 1, 60, 300, jetzt); + setz.run(idLuna, tag(-3), 300, 60, 1, 50, 250, jetzt); + setz.run(idLuna, tag(-4), 0, 0, 0, null, null, jetzt); // Ruhetag + /* Vorwoche: zusammen 300 -- damit der Vergleich +100 % ergibt. */ + setz.run(idLuna, tag(-8), 300, 60, 1, 30, 100, jetzt); + p.close(); + + const z = await holen(dogi.keks, `/workspace/api/leistung/${idLuna}`); + + ok(z.woche.diamanten.wert === 600, + `Diamanten diese Woche: ${z.woche.diamanten.wert} (100+200+300+0 = 600)`); + ok(z.woche.diamanten.vorher === 300, + `Vorwoche: ${z.woche.diamanten.vorher}`); + ok(z.woche.diamanten.wandel?.prozent === 100, + `Veränderung: +${z.woche.diamanten.wandel?.prozent} % (300 → 600)`); + + /* DER DURCHSCHNITT ZÄHLT NUR TAGE MIT WERT. (40+60+50)/3 = 50. + Würde der Ruhetag als 0 mitgerechnet, käme 37,5 heraus -- und jede + Zahl würde kleingeredet, sobald jemand Urlaub hat. */ + ok(z.woche.zuschauer_avg.wert === 50, + `Ø Zuschauer: ${z.woche.zuschauer_avg.wert} — der Ruhetag ohne Wert zählt nicht mit`); + + /* Dasselbe für die Leitzahl. (200+300+250)/3 = 250 */ + ok(z.woche.verweildauer_s.wert === 250, + `Ø Verweildauer: ${z.woche.verweildauer_s.wert}s`); + + ok(z.woche.gueltige_tage.wert === 3, + `gültige Tage: ${z.woche.gueltige_tage.wert} (der Ruhetag zählt nicht)`); + + /* Erfassungsgrad -- eine Zahl aus zwei von sieben Tagen ist etwas + anderes als eine aus sieben. */ + ok(z.erfassung.tage === 4, `${z.erfassung.tage} von 7 Tagen erfasst`); + + /* VERLAUF für die kleine Linie. */ + ok(Array.isArray(z.verlauf) && z.verlauf.length === 8, + `Verlauf über ${z.verlauf?.length} Wochen`); + ok(z.verlauf.at(-1).diamanten === 600, + `die letzte Woche im Verlauf stimmt mit der Wochenzahl überein`); +} + +/* ======================================================================= + 3. Prozent von null — die Falle + ======================================================================= */ +console.log("\n=== Von null aus gibt es keine Prozent ==="); +{ + const p = new DatabaseSync(process.env.WORKSPACE_DB); + p.prepare("DELETE FROM leistung WHERE creator_id = ?").run(idNora); + p.prepare(`INSERT INTO leistung (creator_id, tag, diamanten, gueltiger_tag, erfasst, quelle) + VALUES (?,?,?,1,?, 'hand')`).run(idNora, tag(-1), 500, jetzt); + p.close(); + + const z = await holen(dogi.keks, `/workspace/api/leistung/${idNora}`); + /* Vorwoche war 0. "+unendlich Prozent" wäre Unsinn auf dem + Bildschirm -- stattdessen die Auskunft "vorher nichts". */ + ok(z.woche.diamanten.wandel?.neu === true && z.woche.diamanten.wandel?.prozent === undefined, + `von 0 auf 500: als "neu" gemeldet, nicht als Prozentwert`); + + /* Und wenn beide null sind, gibt es gar keine Meldung. */ + ok(z.woche.schenker.wandel === null, + `0 → 0 ergibt keine Veränderungsmeldung`); +} + +/* ======================================================================= + 4. Ziele als Fortschritt + ======================================================================= */ +console.log("\n=== Ziele ==="); +{ + await rufe(dogi.keks, `/workspace/api/leistung/${idLuna}/ziele`, + { diamanten_woche: 1200, tage_woche: 4, verweildauer_s: 300 }, "PUT"); + const z = await holen(dogi.keks, `/workspace/api/leistung/${idLuna}`); + ok(z.ziele?.diamanten?.anteil === 50, + `600 von 1200 Diamanten = ${z.ziele?.diamanten?.anteil} %`); + ok(z.ziele?.tage?.anteil === 75, + `3 von 4 Tagen = ${z.ziele?.tage?.anteil} %`); + /* Aus "mehr Reichweite" wird eine prüfbare Größe. */ + ok(z.ziele?.verweildauer?.ist === 250 && z.ziele?.verweildauer?.ziel === 300, + `Verweildauer ${z.ziele?.verweildauer?.ist}s von ${z.ziele?.verweildauer?.ziel}s`); +} + +/* ======================================================================= + 5. Rechte + ======================================================================= */ +console.log("\n=== Wer darf was ==="); +{ + /* Ein Creator trägt seine Zahlen NICHT selbst ein. Nicht aus + Misstrauen: Die Zahlen kommen aus dem offiziellen Export. */ + const a = await rufe(luna.keks, `/workspace/api/leistung/${idLuna}/${tag(-1)}`, + { diamanten: 99999 }, "PUT"); + ok(a.status === 403, `Creator trägt eigene Zahlen ein: abgelehnt (${a.status})`); + + /* Aber SEHEN darf sie ihre eigenen. */ + const b = await rufe(luna.keks, `/workspace/api/leistung/${idLuna}`); + ok(b.ok, `ihre eigenen Zahlen sehen: geht (${b.status})`); + + /* Fremde nicht -- und zwar 404, nicht 403. */ + const c = await rufe(luna.keks, `/workspace/api/leistung/${idNora}`); + ok(c.status === 404, `fremde Zahlen: ${c.status} (404 — die Nummer verrät nichts)`); + + /* Der Scout darf bei SEINEM Creator eintragen. */ + const e = await rufe(pat.keks, `/workspace/api/leistung/${idLuna}/${tag(-5)}`, + { diamanten: 50, gueltiger_tag: true }, "PUT"); + ok(e.ok, `Scout bei seinem Creator: geht (${e.status})`); + + /* Bei einem fremden nicht. */ + const f = await rufe(pat.keks, `/workspace/api/leistung/${idNora}/${tag(-1)}`, + { diamanten: 50 }, "PUT"); + ok(f.status === 404, `Scout bei fremdem Creator: ${f.status}`); +} + +/* ======================================================================= + 6. Der CSV-Import + ======================================================================= */ +console.log("\n=== Import aus dem Export ==="); +{ + const p = new DatabaseSync(process.env.WORKSPACE_DB); + p.prepare("DELETE FROM leistung WHERE creator_id = ?").run(idNora); + p.close(); + + /* Deutsches Format: Semikolon, Punkt als Tausendertrenner. */ + const csv = [ + "Datum;Diamanten;LIVE-Dauer (Min);Ø Zuschauer;Spitze Zuschauer;Verweildauer;Schenker;Neue Follower", + `${tag(-3)};1.250;95;48;130;210;14;23`, + `${tag(-2)};2.100;120;62;180;245;19;31`, + `${tag(-1)};890;45;30;70;180;8;12`, + ].join("\n"); + + /* ERST DIE VORSCHAU -- sie schreibt nichts. */ + const v = await (await rufe(dogi.keks, `/workspace/api/leistung/${idNora}/import/vorschau`, + { text: csv }, "POST")).json(); + ok(v.neu === 3 && v.ersetzt === 0 && v.fehlerhaft === 0, + `Vorschau: ${v.neu} neu, ${v.ersetzt} ersetzt, ${v.fehlerhaft} fehlerhaft`); + + /* Die Spalten wurden erkannt. */ + ok(v.spalten?.diamanten === 1 && v.spalten?.verweildauer_s === 5, + `Spalten erkannt: Diamanten=${v.spalten?.diamanten}, Verweildauer=${v.spalten?.verweildauer_s}`); + + const p2 = new DatabaseSync(process.env.WORKSPACE_DB); + const vorher = p2.prepare("SELECT COUNT(*) AS n FROM leistung WHERE creator_id = ?").get(idNora).n; + p2.close(); + ok(vorher === 0, `die Vorschau hat NICHTS geschrieben (${vorher} Zeilen)`); + + /* Jetzt übernehmen. */ + const u = await (await rufe(dogi.keks, `/workspace/api/leistung/${idNora}/import`, + { text: csv }, "POST")).json(); + ok(u.geschrieben === 3, `übernommen: ${u.geschrieben} Tage`); + + /* DAS DEUTSCHE ZAHLENFORMAT: "1.250" sind 1250, nicht 1,25. */ + const p3 = new DatabaseSync(process.env.WORKSPACE_DB); + const z = p3.prepare("SELECT * FROM leistung WHERE creator_id = ? AND tag = ?") + .get(idNora, tag(-3)); + ok(z?.diamanten === 1250, + `"1.250" wurde als ${z?.diamanten} gelesen (nicht als 1,25)`); + ok(z?.quelle === "import", `Quelle: ${z?.quelle}`); + ok(z?.gueltiger_tag === 1, + `gültiger Tag aus der Dauer geschlossen (95 Min > 0)`); + p3.close(); + + /* ZWEIMAL EINLESEN — der häufigste Fehler bei Importen. */ + const u2 = await (await rufe(dogi.keks, `/workspace/api/leistung/${idNora}/import`, + { text: csv }, "POST")).json(); + const p4 = new DatabaseSync(process.env.WORKSPACE_DB); + const n = p4.prepare("SELECT COUNT(*) AS n FROM leistung WHERE creator_id = ?").get(idNora).n; + p4.close(); + ok(n === 3, + `dieselbe Datei zweimal: ${n} Zeilen (nicht 6) — überschrieben statt verdoppelt`); + + /* EINE VON HAND GESCHRIEBENE NOTIZ ÜBERLEBT den Import. Sie steht + in keinem Export -- würde er sie leeren, wäre die Erklärung für + einen Einbruch weg. */ + await rufe(dogi.keks, `/workspace/api/leistung/${idNora}/${tag(-1)}`, + { diamanten: 890, notiz: "war krank" }, "PUT"); + await rufe(dogi.keks, `/workspace/api/leistung/${idNora}/import`, { text: csv }, "POST"); + const p5 = new DatabaseSync(process.env.WORKSPACE_DB); + const mitNotiz = p5.prepare("SELECT notiz FROM leistung WHERE creator_id = ? AND tag = ?") + .get(idNora, tag(-1)); + p5.close(); + ok(mitNotiz?.notiz === "war krank", + `die Notiz "${mitNotiz?.notiz}" hat den Import überlebt`); + + /* Englisches Format muss auch gehen. */ + const csvEn = [ + "Date,Diamonds,Duration,Avg Viewers", + `${tag(-5)},"1,234",60,45`, + ].join("\n"); + const v2 = await (await rufe(dogi.keks, `/workspace/api/leistung/${idNora}/import/vorschau`, + { text: csvEn }, "POST")).json(); + ok(v2.neu === 1 && v2.beispiele?.[0]?.diamanten === 1234, + `englisches Format: "1,234" wurde als ${v2.beispiele?.[0]?.diamanten} gelesen`); + + /* Kaputte Zeilen werden gemeldet, nicht verschluckt. */ + const csvKaputt = [ + "Datum;Diamanten", + `${tag(-1)};100`, + "keindatum;200", + `${tag(20)};300`, + ].join("\n"); + const v3 = await (await rufe(dogi.keks, `/workspace/api/leistung/${idNora}/import/vorschau`, + { text: csvKaputt }, "POST")).json(); + ok(v3.fehlerhaft === 2 && v3.probleme?.length === 2, + `2 kaputte Zeilen gemeldet: ${v3.probleme?.map((p) => p.grund).join(" · ")}`); +} + +/* ======================================================================= + 7. Die Frühwarnung + ======================================================================= */ +console.log("\n=== Frühwarnung ==="); +{ + const p = new DatabaseSync(process.env.WORKSPACE_DB); + p.prepare("DELETE FROM leistung").run(); + + /* Luna: alles in Ordnung -- gleichbleibende Zahlen, frisch + angemeldet, gerade ein Gespräch gehabt. */ + const setz = p.prepare(`INSERT INTO leistung (creator_id, tag, diamanten, dauer_min, + gueltiger_tag, erfasst, quelle) VALUES (?,?,?,?,?,?, 'hand')`); + for (let w = 0; w < 3; w++) { + for (let t = 0; t < 4; t++) setz.run(idLuna, tag(-(w * 7 + t + 1)), 300, 60, 1, jetzt); + } + p.prepare("UPDATE personen SET letzter_login = ? WHERE id = ?").run(jetzt, idLuna); + p.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, erstellt, erstellt_von) + VALUES ('Call','call',?,30,?,?,?)`).run(tag(-2) + "T10:00", idLuna, jetzt, idDogi); + + /* Nora: fallende Zahlen über drei Wochen, LIVE-Tage brechen weg, + lange nicht angemeldet, nie ein Gespräch. */ + for (let t = 0; t < 5; t++) setz.run(idNora, tag(-(14 + t + 1)), 400, 60, 1, jetzt); + for (let t = 0; t < 3; t++) setz.run(idNora, tag(-(7 + t + 1)), 200, 60, 1, jetzt); + setz.run(idNora, tag(-1), 50, 30, 1, jetzt); + p.prepare("UPDATE personen SET letzter_login = ? WHERE id = ?") + .run(new Date(Date.now() - 40 * 86400000).toISOString(), idNora); + p.close(); + + const f = await holen(dogi.keks, "/workspace/api/fruehwarnung"); + const nora = f.creator?.find((c) => c.name === "Nora"); + const lunaW = f.creator?.find((c) => c.name === "Luna"); + + ok(!!nora, `Nora hat Befunde (${nora?.signale?.length || 0} Signale)`); + ok(!lunaW, `Luna hat KEINE — ein System, das jede Woche Alarm gibt, wird nicht gelesen`); + + const arten = (nora?.signale || []).map((s) => s.art); + ok(arten.includes("zahlen_fallen"), `Signal "Zahlen fallen": ${arten.includes("zahlen_fallen")}`); + ok(arten.includes("tage_weg"), `Signal "LIVE-Tage brechen weg"`); + ok(arten.includes("nicht_angemeldet"), `Signal "lange nicht angemeldet"`); + ok(arten.includes("nie_gesprochen"), `Signal "nie ein Gespräch"`); + + /* JEDES SIGNAL IST EIN SATZ, keine Zahl. Das ist die Kernentscheidung + gegen einen Score. */ + const ersterSatz = nora?.signale?.[0]?.text || ""; + ok(/\D/.test(ersterSatz) && ersterSatz.length > 20, + `die Signale sind Sätze, keine Punktzahl: "${ersterSatz}"`); + + /* KEIN SCORE nach außen. */ + ok(nora && nora._rang === undefined && nora.punkte === undefined && nora.score === undefined, + `keine Gesamtnote in der Antwort — bewusst`); + + /* Ein Satz für die Startseite. */ + ok(/Nora/.test(f.satz || ""), `Satz für die Startseite: "${f.satz}"`); + + /* EIN CREATOR SIEHT DIE FRÜHWARNUNG NICHT. Das ist eine + Arbeitsgrundlage für die Betreuung, keine Mitteilung an die + betroffene Person. */ + const beiLuna = await holen(luna.keks, "/workspace/api/fruehwarnung"); + ok(beiLuna.gilt_nicht === true && (beiLuna.creator || []).length === 0, + `ein Creator bekommt keine Frühwarnung zu sehen`); + + /* Und ein Scout sieht nur seine eigenen. */ + const beiPat = await holen(pat.keks, "/workspace/api/fruehwarnung"); + ok(!(beiPat.creator || []).some((c) => c.name === "Nora"), + `der Scout sieht nur seine Betreuten (${(beiPat.creator || []).map((c) => c.name).join(", ") || "keine"})`); +} + +/* ======================================================================= + GEGENPROBE + ======================================================================= */ +console.log("\n=== Gegenprobe ==="); +{ + /* Schlägt die Frühwarnung an, wenn wirklich nichts ist? Sie darf + nicht -- sonst wäre "Nora hat Befunde" oben wertlos. */ + const p = new DatabaseSync(process.env.WORKSPACE_DB); + p.prepare("DELETE FROM leistung").run(); + p.prepare("UPDATE personen SET letzter_login = ? WHERE rolle = 'creator'").run(jetzt); + p.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, erstellt, erstellt_von) + VALUES ('Call','call',?,30,?,?,?)`).run(tag(-1) + "T10:00", idNora, jetzt, idDogi); + p.close(); + + const f = await holen(dogi.keks, "/workspace/api/fruehwarnung"); + ok((f.creator || []).length === 0 && f.satz === null, + `ohne Auffälligkeiten meldet sie nichts (${(f.creator || []).length} Befunde)`); + + /* Und ohne Anmeldung gar nichts. */ + for (const weg of ["/workspace/api/leistung", "/workspace/api/fruehwarnung"]) { + const a = await fetch(BASIS + weg); + ok(a.status === 401, `${weg} ohne Anmeldung: ${a.status}`); + } +} + +d.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } + +console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`); +/* Der Server läuft weiter -- siehe pruef-chat-optik. */ +process.exit(fehler ? 1 : 0); diff --git a/server/workspace-fruehwarnung.js b/server/workspace-fruehwarnung.js new file mode 100644 index 00000000..b8615586 --- /dev/null +++ b/server/workspace-fruehwarnung.js @@ -0,0 +1,284 @@ +/* ===================================================================== + workspace-fruehwarnung.js — bevor jemand geht. + + Aus dem Plan vom 31.08.2026: *"Das ist der Teil, den kaum ein System + hat, und der am meisten bringt."* + + Die Recherche ist an dieser Stelle eindeutig: Abwanderung entsteht + fast nie durch schlechte Arbeit, sondern durch GEBROCHENE ERWARTUNGEN + und ABREISSENDE KOMMUNIKATION. Und sie kündigt sich an -- wochenlang, + in Daten, die längst da sind. + + Agenturen mit hoher BINDUNG schlagen Agenturen mit hoher ANWERBUNG + auf jeder langfristigen Kennzahl. Zielgröße guter Häuser: ~85 %. + + --------------------------------------------------------------------- + BEWUSST KEIN SCORE — das ist die wichtigste Entscheidung hier + + Eine Note von 1 bis 100 wirkt objektiv und ist es nicht. Sie verführt + dazu, Menschen nach einer Zahl zu behandeln: Wer 42 hat, bekommt + weniger Aufmerksamkeit als wer 78 hat -- ohne dass jemand je geprüft + hätte, woraus die Zahlen entstanden sind. + + Stattdessen: BENANNTE SIGNALE. Jedes einzeln nachvollziehbar, jedes + einzeln wegdrückbar. Die Anzeige sagt nicht "Luna: 42 Punkte", + sondern "Bei Luna sind die LIVE-Tage von 5 auf 1 gefallen". + + Der Unterschied ist nicht kosmetisch: Am Score kann man nichts tun, + am Signal schon. + + --------------------------------------------------------------------- + UND KEINE ALARME + + Die Anzeige ist ein ruhiger Satz, keine rote Wand. Ein System, das + jede Woche Alarm schlägt, wird jede Woche weniger gelesen. Wenn + nichts ist, steht hier nichts. + ===================================================================== */ + +import express from "express"; +import { + db, sitzungLesen, sichtbareCreatorIds, betreuteIds, istLeitung, +} from "./workspace.js"; + +export const fruehwarnungRouter = express.Router(); + +const TAG_MS = 86400000; +function tagText(d) { + const p = (n) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; +} +const tagVor = (n) => tagText(new Date(Date.now() - n * TAG_MS)); + +function angemeldet(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + req.person = person; + next(); +} +fruehwarnungRouter.use("/workspace/api/fruehwarnung", angemeldet); + +/* ===================================================================== + Die Signale + ===================================================================== + + Jedes ist eine eigene Funktion mit derselben Form: Sie bekommt einen + Creator und gibt entweder null zurück (nichts los) oder einen Befund + mit Text und Beleg. + + WARUM JEDES EINZELN: Man kann sie einzeln abschalten, einzeln prüfen + und einzeln erklären. Ein Signal, das man nicht erklären kann, gehört + nicht in eine Betreuungsentscheidung. */ + +/** Fallen die Zahlen über mehrere Wochen? + * + * ZWEI Wochen Rückgang, nicht eine. Eine einzelne schlechte Woche hat + * jeder -- Urlaub, Krankheit, eine Prüfung. Erst die Wiederholung ist + * ein Muster. Wer bei der ersten Woche anschlägt, meldet Rauschen. */ +function zahlenFallen(creatorId) { + const woche = (n) => db().prepare(` + SELECT COALESCE(SUM(diamanten), 0) AS d, COUNT(*) AS tage + FROM leistung WHERE creator_id = ? AND tag >= ? AND tag <= ?`) + .get(creatorId, tagVor(n * 7 + 6), tagVor(n * 7)); + + const jetzt = woche(0), vor1 = woche(1), vor2 = woche(2); + /* Ohne Vergleichsdaten kein Urteil. Ein neuer Creator ohne Historie + ist kein Fall für die Frühwarnung. */ + if (!vor2.tage || !vor1.tage) return null; + if (!vor2.d || !vor1.d) return null; + + const fallend = jetzt.d < vor1.d && vor1.d < vor2.d; + if (!fallend) return null; + + const anteil = Math.round(((vor2.d - jetzt.d) / vor2.d) * 100); + /* Unter 20 % ist Schwankung, kein Einbruch. */ + if (anteil < 20) return null; + + return { + art: "zahlen_fallen", + text: `Die Diamanten fallen die dritte Woche in Folge (−${anteil} % gegenüber vor zwei Wochen).`, + beleg: `${vor2.d} → ${vor1.d} → ${jetzt.d}`, + gewicht: 3, + }; +} + +/** Brechen die LIVE-Tage weg? + * + * Das ist das härtere Signal: Wer nicht mehr sendet, hört auf -- + * Diamanten schwanken, Anwesenheit nicht. */ +function tageBrechenWeg(creatorId) { + const tage = (n) => db().prepare(` + SELECT COALESCE(SUM(gueltiger_tag), 0) AS t, COUNT(*) AS erfasst + FROM leistung WHERE creator_id = ? AND tag >= ? AND tag <= ?`) + .get(creatorId, tagVor(n * 7 + 6), tagVor(n * 7)); + + const jetzt = tage(0), vorher = tage(1); + if (!vorher.erfasst || vorher.t < 2) return null; + if (jetzt.t >= vorher.t) return null; + + const weg = vorher.t - jetzt.t; + if (weg < 2) return null; + + return { + art: "tage_weg", + text: jetzt.t === 0 + ? `Diese Woche kein einziger LIVE-Tag (vorher ${vorher.t}).` + : `Die LIVE-Tage sind von ${vorher.t} auf ${jetzt.t} gefallen.`, + beleg: `${vorher.t} → ${jetzt.t}`, + /* Auf null zu fallen wiegt schwerer als eine Halbierung. */ + gewicht: jetzt.t === 0 ? 5 : 3, + }; +} + +/** Seit wann nicht mehr angemeldet? */ +function langeNichtDa(creatorId) { + const p = db().prepare("SELECT letzter_login FROM personen WHERE id = ?").get(creatorId); + if (!p?.letzter_login) return null; + const tage = Math.floor((Date.now() - Date.parse(p.letzter_login)) / TAG_MS); + if (!Number.isFinite(tage) || tage < 14) return null; + return { + art: "nicht_angemeldet", + text: `Seit ${tage} Tagen nicht mehr im Workspace gewesen.`, + beleg: p.letzter_login.slice(0, 10), + gewicht: tage >= 30 ? 4 : 2, + }; +} + +/** Seit wann kein Gespräch? + * + * DAS IST DAS SIGNAL ÜBER UNS, nicht über den Creator. Wenn seit + * sechs Wochen niemand mit ihm gesprochen hat, ist das unser + * Versäumnis -- und laut Recherche der häufigste Grund, warum + * jemand geht. */ +function keinGespraech(creatorId) { + const z = db().prepare(` + SELECT MAX(beginn) AS letzter FROM termine + WHERE (creator_id = ? OR teilnehmer_id = ?) + AND art IN ('call','review') AND beginn <= ?`) + .get(creatorId, creatorId, new Date().toISOString()); + if (!z?.letzter) { + return { + art: "nie_gesprochen", + text: "Mit dieser Person hat noch nie ein Gespräch stattgefunden.", + beleg: null, + gewicht: 4, + }; + } + const tage = Math.floor((Date.now() - Date.parse(z.letzter)) / TAG_MS); + if (!Number.isFinite(tage) || tage < 21) return null; + return { + art: "kein_gespraech", + text: `Seit ${tage} Tagen kein Gespräch mehr — das liegt an uns, nicht an ihr.`, + beleg: String(z.letzter).slice(0, 10), + gewicht: tage >= 42 ? 4 : 2, + }; +} + +/** Stauen sich unerledigte Aufgaben? */ +function aufgabenStauen(creatorId) { + const z = db().prepare(` + SELECT COUNT(*) AS n FROM aufgaben + WHERE creator_id = ? AND status NOT IN ('erledigt','abgebrochen') + AND frist IS NOT NULL AND frist < ?`).get(creatorId, tagVor(0)); + if (!z?.n || z.n < 3) return null; + return { + art: "aufgaben_stau", + text: `${z.n} Aufgaben sind überfällig.`, + beleg: null, + gewicht: z.n >= 6 ? 3 : 2, + }; +} + +/** Hängt der Start-Check? */ +function startcheckHaengt(creatorId) { + const z = db().prepare(` + SELECT COUNT(*) AS geprueft, MAX(geaendert) AS zuletzt + FROM startcheck WHERE creator_id = ?`).get(creatorId); + if (!z || !z.geprueft) return null; // gar nicht begonnen: anderes Thema + const offen = db().prepare(` + SELECT COUNT(*) AS n FROM startcheck + WHERE creator_id = ? AND bewertung = 'handlung'`).get(creatorId).n; + if (!offen) return null; + const tage = z.zuletzt ? Math.floor((Date.now() - Date.parse(z.zuletzt)) / TAG_MS) : 999; + if (tage < 21) return null; + return { + art: "startcheck_haengt", + text: `${offen} Punkte im Start-Check brauchen Handlung — seit ${tage} Tagen unverändert.`, + beleg: null, + gewicht: 2, + }; +} + +const SIGNALE = [ + zahlenFallen, tageBrechenWeg, langeNichtDa, + keinGespraech, aufgabenStauen, startcheckHaengt, +]; + +/* ===================================================================== + Die Auskunft + ===================================================================== */ + +/** Alle Befunde zu einem Creator. */ +function befunde(creatorId) { + const raus = []; + for (const pruefen of SIGNALE) { + try { + const b = pruefen(creatorId); + if (b) raus.push(b); + } catch (fehler) { + /* Ein Signal, das stolpert, darf die anderen nicht mitnehmen -- + sonst verschwindet die ganze Frühwarnung wegen einer Spalte. */ + console.error("[fruehwarnung] Signal:", pruefen.name, fehler?.message); + } + } + return raus.sort((a, b) => b.gewicht - a.gewicht); +} + +fruehwarnungRouter.get("/workspace/api/fruehwarnung", (req, res) => { + try { + /* Nur wer betreut, bekommt die Frühwarnung. Ein Creator soll nicht + lesen, dass das System ihn für abwanderungsgefährdet hält -- das + ist eine Arbeitsgrundlage für die Betreuung, keine Mitteilung an + die betroffene Person. */ + if (req.person.rolle === "creator") return res.json({ creator: [], gilt_nicht: true }); + + const ids = sichtbareCreatorIds(req.person); + const wo = ids === null ? "rolle = 'creator' AND aktiv = 1" + : ids.length ? `id IN (${ids.map(() => "?").join(",")}) AND aktiv = 1` : "0=1"; + const leute = db().prepare(`SELECT id, name FROM personen WHERE ${wo} ORDER BY name`) + .all(...(ids === null ? [] : ids)); + + const mitBefund = []; + for (const c of leute) { + const b = befunde(c.id); + if (b.length) { + mitBefund.push({ + id: c.id, name: c.name, + signale: b, + /* Das Gewicht dient NUR der Reihenfolge, nicht der Anzeige. + Es steht bewusst nicht als Zahl auf dem Bildschirm -- + sonst wäre es doch wieder ein Score. */ + _rang: b.reduce((s, x) => s + x.gewicht, 0), + }); + } + } + mitBefund.sort((a, b) => b._rang - a._rang); + for (const m of mitBefund) delete m._rang; + + res.json({ + creator: mitBefund, + /* Ein Satz statt einer Liste, wenn man nur wissen will, ob etwas + ist. Genau so steht er später auf der Startseite. */ + satz: mitBefund.length === 0 + ? null + : mitBefund.length === 1 + ? `Bei ${mitBefund[0].name} würde ich diese Woche nachfassen.` + : `Bei ${mitBefund.length} Creators würde ich diese Woche nachfassen.`, + }); + } catch (fehler) { + console.error("[fruehwarnung] Lesen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* Für andere Module (Startseite, Dashboard). */ +export { befunde }; diff --git a/server/workspace-leistung.js b/server/workspace-leistung.js new file mode 100644 index 00000000..5e917a04 --- /dev/null +++ b/server/workspace-leistung.js @@ -0,0 +1,672 @@ +/* ===================================================================== + workspace-leistung.js — die Zahlen über das Geschäft. + + Der große Befund aus dem Plan vom 31.08.2026: Der Workspace + organisiert ARBEIT hervorragend -- Aufgaben, Termine, Protokolle, + Bereiche, Pipeline. Über das GESCHÄFT wusste er nichts. Keine + Diamanten, keine LIVE-Tage, keine Verweildauer. + + Damit hing die ganze Betreuung in der Luft: + * Der Start-Check bewertet, aber misst nicht. + * Der Report fragt "was hat funktioniert?" -- ohne Beleg. + * Das 90-Tage-Ziel ist ein Satz, kein Fortschritt. + * "Creator im Fokus" zeigt überfällige Aufgaben, aber nicht, + WESSEN ZAHLEN einbrechen. + + --------------------------------------------------------------------- + DIE DREI REGELN GUTER KENNZAHLEN (Recherche 31.08./06.09.2026) + + Jede Zahl, die dieses Modul ausliefert, bringt mit: + + 1. VERGLEICH gegen die Vorperiode oder gegen das Ziel + 2. VERLAUF die letzten Wochen als Reihe, damit man Richtung sieht + 3. EINORDNUNG "+23 % gegenüber der Vorwoche" statt nur "1.240" + + Eine Zahl ohne diese drei ist eine Eitelkeitszahl. Sie sieht nach + Auskunft aus und ist keine -- man kann nichts entscheiden. + + --------------------------------------------------------------------- + DIE LEITZAHL IST DIE VERWEILDAUER + + Neu aus der Recherche vom 06.09.2026: 2026 hängt der Algorithmus + alles an der Completion Rate. Und sie gehört als BETRIEBSkennzahl + behandelt, nicht als kreative -- sie soll die nächste Runde steuern, + nicht die letzte erklären. Deshalb steht sie überall vorn. + + --------------------------------------------------------------------- + WAS DIESES MODUL NICHT TUT + + Es holt nichts von TikTok. Es gibt keine offene Agentur-Schnittstelle; + Fremd-APIs kosten Geld, verstoßen gegen die Nutzungsbedingungen und + brechen ohne Vorwarnung. Eingelesen wird der OFFIZIELLE Export -- + und von Hand getippt, was fehlt. + ===================================================================== */ + +import express from "express"; +import { + db, protokolliere, echteIp, sitzungLesen, istLeitung, + sichtbareCreatorIds, ROLLEN_SORTIERUNG, +} from "./workspace.js"; + +export const leistungRouter = express.Router(); + +const jetzt = () => new Date().toISOString(); +const NOTIZ_MAX = 500; + +/* ---------- Schranke ---------------------------------------------------- */ + +function angemeldet(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + req.person = person; + next(); +} + +function gleicheHerkunft(req, res, next) { + const herkunft = req.get("origin"); + if (!herkunft) return next(); + let erlaubt; + try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; } + if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" }); + next(); +} + +leistungRouter.use("/workspace/api/leistung", angemeldet); + +/* ---------- Wer darf was ------------------------------------------------ */ + +/** Wessen Zahlen darf ich SEHEN? Dieselbe Regel wie überall sonst -- + * eine eigene Sichtbarkeit für Zahlen wäre eine zweite Wahrheit, und + * zwei Wahrheiten laufen auseinander. */ +function darfSehen(person, creatorId) { + const ids = sichtbareCreatorIds(person); + if (ids === null) return true; // DogFather + return ids.includes(Number(creatorId)); +} + +/** Wer darf Zahlen EINTRAGEN? Leitung und Scouts -- nicht der Creator + * selbst. Nicht aus Misstrauen: Die Zahlen kommen aus dem offiziellen + * Export, und wer sie selbst tippt, hat den Export nicht. Eine Zahl, + * die man sich selbst gibt, ist keine Messung. */ +function darfEintragen(person) { + return istLeitung(person) || person.rolle === "scout"; +} + +/* ---------- Rechnen ----------------------------------------------------- */ + +const TAG_MS = 86400000; + +/** Ortszeit-Datum als "JJJJ-MM-TT". Nie toISOString(): Das ist UTC und + * liegt zwischen Mitternacht und zwei Uhr einen Tag daneben. */ +function tagText(d) { + const p = (n) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; +} +const tagVor = (n) => tagText(new Date(Date.now() - n * TAG_MS)); + +/** Summe und Durchschnitt über einen Zeitraum. + * + * DURCHSCHNITTE ZÄHLEN NUR TAGE MIT WERT. Ein Tag ohne LIVE ist kein + * Tag mit 0 Zuschauern -- er ist ein Tag ohne Messung. Wer ihn + * mitmittelt, redet jede Zahl klein, sobald jemand Urlaub hat. */ +function zeitraum(creatorId, von, bis) { + const z = db().prepare(` + SELECT + COUNT(*) AS tage_erfasst, + COALESCE(SUM(diamanten), 0) AS diamanten, + COALESCE(SUM(dauer_min), 0) AS dauer_min, + COALESCE(SUM(gueltiger_tag), 0) AS gueltige_tage, + COALESCE(SUM(follower_neu), 0) AS follower_neu, + COALESCE(SUM(schenker), 0) AS schenker, + AVG(CASE WHEN zuschauer_avg IS NOT NULL THEN zuschauer_avg END) AS zuschauer_avg, + MAX(zuschauer_max) AS zuschauer_max, + AVG(CASE WHEN verweildauer_s IS NOT NULL THEN verweildauer_s END) AS verweildauer_s + FROM leistung WHERE creator_id = ? AND tag >= ? AND tag <= ?`) + .get(creatorId, von, bis); + + return { + tage_erfasst: z.tage_erfasst, + diamanten: z.diamanten, + dauer_min: z.dauer_min, + gueltige_tage: z.gueltige_tage, + follower_neu: z.follower_neu, + schenker: z.schenker, + zuschauer_avg: z.zuschauer_avg === null ? null : Math.round(z.zuschauer_avg), + zuschauer_max: z.zuschauer_max, + verweildauer_s: z.verweildauer_s === null ? null : Math.round(z.verweildauer_s), + }; +} + +/** Die Veränderung gegenüber der Vorperiode, in Prozent. + * + * VON NULL AUS GIBT ES KEINE PROZENT. 0 → 50 sind nicht "unendlich + * Prozent besser", sondern schlicht "vorher nichts". Wer hier eine + * Zahl erzwingt, bekommt Unsinn wie "+∞ %" auf den Bildschirm. */ +function wandel(jetztWert, vorherWert) { + if (jetztWert === null || vorherWert === null) return null; + if (!vorherWert) return jetztWert ? { neu: true } : null; + return { prozent: Math.round(((jetztWert - vorherWert) / vorherWert) * 100) }; +} + +/** Der Verlauf: eine Zahl je Woche, die letzten N Wochen. + * Für die kleine Linie neben der Zahl -- ohne Richtung ist ein Wert + * nur ein Punkt. */ +function verlauf(creatorId, wochen = 8) { + const reihen = []; + for (let i = wochen - 1; i >= 0; i--) { + const bis = tagVor(i * 7); + const von = tagVor(i * 7 + 6); + const z = zeitraum(creatorId, von, bis); + reihen.push({ + von, bis, + diamanten: z.diamanten, + gueltige_tage: z.gueltige_tage, + verweildauer_s: z.verweildauer_s, + }); + } + return reihen; +} + +/* ===================================================================== + Die Schnittstellen + ===================================================================== */ + +/** Die Zahlen eines Creators: diese Woche, Vorwoche, Ziel, Verlauf. */ +leistungRouter.get("/workspace/api/leistung/:creatorId", (req, res) => { + try { + const creatorId = Number(req.params.creatorId); + if (!Number.isInteger(creatorId)) return res.status(400).json({ fehler: "ungueltig" }); + /* Was man nicht sehen darf, existiert nicht -- 404, nicht 403. */ + if (!darfSehen(req.person, creatorId)) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const dieseWoche = zeitraum(creatorId, tagVor(6), tagVor(0)); + const vorwoche = zeitraum(creatorId, tagVor(13), tagVor(7)); + const ziele = db().prepare("SELECT * FROM leistung_ziele WHERE creator_id = ?").get(creatorId); + + /* JEDE ZAHL MIT VERGLEICH UND EINORDNUNG -- die drei Regeln aus + der Recherche. Eine nackte Zahl fliegt raus. */ + const mitVergleich = {}; + for (const feld of ["diamanten", "gueltige_tage", "dauer_min", "zuschauer_avg", + "verweildauer_s", "schenker", "follower_neu"]) { + mitVergleich[feld] = { + wert: dieseWoche[feld], + vorher: vorwoche[feld], + wandel: wandel(dieseWoche[feld], vorwoche[feld]), + }; + } + + /* Das Ziel als Fortschritt, nicht als Satz. */ + const zielstand = ziele ? { + diamanten: ziele.diamanten_woche + ? { ziel: ziele.diamanten_woche, ist: dieseWoche.diamanten, + anteil: Math.round((dieseWoche.diamanten / ziele.diamanten_woche) * 100) } : null, + tage: ziele.tage_woche + ? { ziel: ziele.tage_woche, ist: dieseWoche.gueltige_tage, + anteil: Math.round((dieseWoche.gueltige_tage / ziele.tage_woche) * 100) } : null, + verweildauer: ziele.verweildauer_s && dieseWoche.verweildauer_s + ? { ziel: ziele.verweildauer_s, ist: dieseWoche.verweildauer_s, + anteil: Math.round((dieseWoche.verweildauer_s / ziele.verweildauer_s) * 100) } : null, + } : null; + + res.json({ + creator_id: creatorId, + woche: mitVergleich, + roh: { woche: dieseWoche, vorwoche }, + ziele: zielstand, + verlauf: verlauf(creatorId), + /* Wie vollständig ist die Woche überhaupt? Eine Zahl aus zwei von + sieben Tagen ist etwas anderes als eine aus sieben. Ohne das + hält man eine halbe Woche für einen Einbruch. */ + erfassung: { tage: dieseWoche.tage_erfasst, von: 7 }, + }); + } catch (fehler) { + console.error("[leistung] Lesen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Die Tageszeilen eines Creators -- für die Schnelleingabe. */ +leistungRouter.get("/workspace/api/leistung/:creatorId/tage", (req, res) => { + try { + const creatorId = Number(req.params.creatorId); + if (!Number.isInteger(creatorId)) return res.status(400).json({ fehler: "ungueltig" }); + if (!darfSehen(req.person, creatorId)) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const tage = Math.min(Math.max(Number(req.query.tage) || 14, 1), 120); + const reihen = db().prepare(` + SELECT * FROM leistung WHERE creator_id = ? AND tag >= ? + ORDER BY tag DESC`).all(creatorId, tagVor(tage - 1)); + res.json({ tage: reihen }); + } catch (fehler) { + console.error("[leistung] Tage:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ZIELE STEHEN VOR DER TAGES-ROUTE -- und das ist kein Stilfrage. + + Express nimmt die ERSTE Route, die passt. `:tag` passt auf jeden + Text, also auch auf "ziele": Ein PUT auf .../ziele landete in der + Tages-Route, scheiterte dort am Datumsmuster und gab 400 zurueck. + Die Ziele liessen sich nicht setzen, und die Meldung sagte + "ungueltig" -- was auf die Zahlen im Koerper hindeutet, nicht auf + den Weg. + + Gefunden von pruef-leistung ("600 von 1200 Diamanten = undefined %"). + Ohne die Pruefung waere es erst aufgefallen, wenn jemand ein Ziel + setzen wollte. */ +/** Ziele setzen. */ +leistungRouter.put("/workspace/api/leistung/:creatorId/ziele", gleicheHerkunft, + express.json({ limit: "4kb" }), (req, res) => { + try { + const creatorId = Number(req.params.creatorId); + if (!Number.isInteger(creatorId)) return res.status(400).json({ fehler: "ungueltig" }); + if (!darfEintragen(req.person)) return res.status(403).json({ fehler: "nicht_erlaubt" }); + if (!darfSehen(req.person, creatorId)) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const z = (n) => { + const w = Number(req.body?.[n]); + return Number.isFinite(w) && w > 0 ? Math.round(w) : null; + }; + db().prepare(` + INSERT INTO leistung_ziele (creator_id, diamanten_woche, tage_woche, verweildauer_s, gesetzt, gesetzt_von) + VALUES (?,?,?,?,?,?) + ON CONFLICT(creator_id) DO UPDATE SET + diamanten_woche = excluded.diamanten_woche, + tage_woche = excluded.tage_woche, + verweildauer_s = excluded.verweildauer_s, + gesetzt = excluded.gesetzt, gesetzt_von = excluded.gesetzt_von`) + .run(creatorId, z("diamanten_woche"), z("tage_woche"), z("verweildauer_s"), + jetzt(), req.person.id); + res.json({ ok: true }); + } catch (fehler) { + console.error("[leistung] Ziele:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + + +/** Einen Tag eintragen oder ändern. + * + * INSERT OR REPLACE auf (creator_id, tag): Derselbe Tag zweimal + * eingetragen überschreibt, statt zu verdoppeln. Verdoppelte Zahlen + * sind schlimmer als fehlende -- sie sehen richtig aus. */ +leistungRouter.put("/workspace/api/leistung/:creatorId/:tag", gleicheHerkunft, + express.json({ limit: "8kb" }), (req, res) => { + try { + const creatorId = Number(req.params.creatorId); + const tag = String(req.params.tag); + if (!Number.isInteger(creatorId) || !/^\d{4}-\d{2}-\d{2}$/.test(tag)) { + return res.status(400).json({ fehler: "ungueltig" }); + } + if (!darfEintragen(req.person)) { + return res.status(403).json({ fehler: "Zahlen tragen DogFather, Manager und Scouts ein." }); + } + if (!darfSehen(req.person, creatorId)) return res.status(404).json({ fehler: "nicht_gefunden" }); + + /* KEINE ZAHLEN AUS DER ZUKUNFT. Ein Tippfehler im Jahr (2027 + statt 2026) legt sonst eine Zeile an, die nie wieder auffällt + -- sie taucht in keiner Auswertung auf, weil alle nur + zurückschauen. */ + if (tag > tagVor(0)) { + return res.status(400).json({ fehler: "Für einen Tag in der Zukunft gibt es noch keine Zahlen." }); + } + + const z = (name) => { + const w = req.body?.[name]; + if (w === null || w === undefined || w === "") return null; + const n = Number(w); + /* Negative Werte gibt es hier nicht -- weder negative Diamanten + noch negative Minuten. Ein Minus ist immer ein Tippfehler. */ + return Number.isFinite(n) && n >= 0 ? Math.round(n) : null; + }; + + const notiz = String(req.body?.notiz ?? "").trim().slice(0, NOTIZ_MAX) || null; + + db().prepare(` + INSERT OR REPLACE INTO leistung + (creator_id, tag, diamanten, dauer_min, gueltiger_tag, zuschauer_avg, + zuschauer_max, verweildauer_s, schenker, follower_neu, notiz, + erfasst, erfasst_von, quelle) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run( + creatorId, tag, z("diamanten"), z("dauer_min"), + req.body?.gueltiger_tag ? 1 : 0, + z("zuschauer_avg"), z("zuschauer_max"), z("verweildauer_s"), + z("schenker"), z("follower_neu"), notiz, + jetzt(), req.person.id, "hand"); + + protokolliere("leistung_erfasst", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `Creator #${creatorId} ${tag}`, + }); + res.json({ ok: true }); + } catch (fehler) { + console.error("[leistung] Eintragen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/** Einen Tag löschen -- für den Fall, dass eine Zeile falsch angelegt + * wurde. Kein "weg_am" wie beim Chat: Eine Messung, die es nicht gab, + * soll auch nicht als leere Zeile herumstehen und Durchschnitte + * verwässern. */ +leistungRouter.delete("/workspace/api/leistung/:creatorId/:tag", gleicheHerkunft, (req, res) => { + try { + const creatorId = Number(req.params.creatorId); + const tag = String(req.params.tag); + if (!Number.isInteger(creatorId) || !/^\d{4}-\d{2}-\d{2}$/.test(tag)) { + return res.status(400).json({ fehler: "ungueltig" }); + } + if (!darfEintragen(req.person)) return res.status(403).json({ fehler: "nicht_erlaubt" }); + if (!darfSehen(req.person, creatorId)) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const e = db().prepare("DELETE FROM leistung WHERE creator_id = ? AND tag = ?") + .run(creatorId, tag); + protokolliere("leistung_geloescht", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `Creator #${creatorId} ${tag}`, + }); + res.json({ ok: true, entfernt: Number(e.changes) }); + } catch (fehler) { + console.error("[leistung] Löschen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Überblick über ALLE sichtbaren Creator -- für das Dashboard. + * Eine Abfrage statt einer je Creator. */ +leistungRouter.get("/workspace/api/leistung", (req, res) => { + try { + const ids = sichtbareCreatorIds(req.person); + const wo = ids === null ? "rolle = 'creator' AND aktiv = 1" + : ids.length ? `id IN (${ids.map(() => "?").join(",")}) AND aktiv = 1` + : "0=1"; + const creator = db().prepare( + `SELECT id, name FROM personen WHERE ${wo} ORDER BY name`) + .all(...(ids === null ? [] : ids)); + + res.json({ + creator: creator.map((c) => { + const w = zeitraum(c.id, tagVor(6), tagVor(0)); + const v = zeitraum(c.id, tagVor(13), tagVor(7)); + return { + id: c.id, name: c.name, + diamanten: w.diamanten, + gueltige_tage: w.gueltige_tage, + verweildauer_s: w.verweildauer_s, + wandel_diamanten: wandel(w.diamanten, v.diamanten), + tage_erfasst: w.tage_erfasst, + }; + }), + }); + } catch (fehler) { + console.error("[leistung] Überblick:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ===================================================================== + CSV-Import aus dem offiziellen Export + ===================================================================== + + ZWEIMAL EINLESEN DARF KEINEN SCHADEN ANRICHTEN. Das ist der + häufigste Fehler bei Importen und der Grund für den + Primärschlüssel (creator_id, tag): Dieselbe Datei ein zweites Mal + überschreibt, statt zu verdoppeln. + + VORSCHAU VOR DEM ÜBERNEHMEN: Erst zeigen, was passieren wird, dann + machen. Ein Import, der sofort schreibt, ist bei einer falsch + zugeordneten Spalte nicht mehr zurückzuholen. */ + +/** Zerlegt CSV -- auch mit Anführungszeichen und Semikolon. + * + * Kein fremdes Paket: Die Formate, die hier vorkommen, sind + * überschaubar, und eine Abhängigkeit für dreißig Zeilen wäre in + * einem Haus mit drei Abhängigkeiten die vierte. */ +export function csvZerlegen(text) { + const zeilen = []; + let feld = ""; + let zeile = []; + let inAnfuehrung = false; + const roh = String(text).replace(/^/, ""); // BOM weg + + /* Trennzeichen erkennen: Deutsche Exporte nutzen oft Semikolon, + englische Komma. Geraten wird an der ERSTEN Zeile -- dort, wo die + Überschriften stehen. */ + const kopf = roh.split(/\r?\n/)[0] || ""; + const trenner = (kopf.match(/;/g) || []).length > (kopf.match(/,/g) || []).length ? ";" : ","; + + for (let i = 0; i < roh.length; i++) { + const z = roh[i]; + if (inAnfuehrung) { + if (z === '"' && roh[i + 1] === '"') { feld += '"'; i++; } + else if (z === '"') inAnfuehrung = false; + else feld += z; + } else if (z === '"') inAnfuehrung = true; + else if (z === trenner) { zeile.push(feld); feld = ""; } + else if (z === "\n") { zeile.push(feld); zeilen.push(zeile); zeile = []; feld = ""; } + else if (z !== "\r") feld += z; + } + if (feld !== "" || zeile.length) { zeile.push(feld); zeilen.push(zeile); } + return zeilen.filter((z) => z.some((f) => String(f).trim() !== "")); +} + +/** Macht aus einem Textwert eine Zahl. + * + * Deutsche Exporte schreiben "1.234,5", englische "1,234.5". Beide + * müssen gehen -- und "1.234" darf nicht als 1,234 gelesen werden. + * Regel: Das LETZTE Trennzeichen entscheidet, ob es ein Komma- + * oder Punktformat ist. */ +export function zahlLesen(roh) { + const t = String(roh ?? "").trim(); + if (!t) return null; + const nurZiffern = t.replace(/[^\d.,-]/g, ""); + if (!nurZiffern) return null; + + const letztesKomma = nurZiffern.lastIndexOf(","); + const letzterPunkt = nurZiffern.lastIndexOf("."); + let sauber; + + if (letztesKomma >= 0 && letzterPunkt >= 0) { + /* BEIDE kommen vor -- dann ist das LETZTE das Dezimalzeichen und + das andere der Tausendertrenner. "1.234,5" und "1,234.5" sind + damit eindeutig. */ + sauber = letztesKomma > letzterPunkt + ? nurZiffern.replace(/\./g, "").replace(",", ".") + : nurZiffern.replace(/,/g, ""); + } else if (letztesKomma >= 0 || letzterPunkt >= 0) { + /* NUR EINES kommt vor -- und das ist die Falle. + "1.250" ist deutsch Tausend-zweihundertfünfzig, englisch aber + eins-komma-zwofünfnull. "1,234" genauso, nur andersherum. Die + Regel "das letzte Trennzeichen ist das Dezimalzeichen" liefert + hier für BEIDE Fälle das Falsche: Sie las "1.250" als 1,25 und + "1,234" als 1,234 -- Faktor tausend daneben, und die Zahl sieht + trotzdem plausibel aus. + + Entscheidend ist stattdessen, WIE VIELE Ziffern dahinter + stehen: Genau drei heißt Tausendertrenner (so gruppiert man + überall), alles andere heißt Dezimalstelle. + + "1.250" -> 3 Ziffern -> 1250 + "1,234" -> 3 Ziffern -> 1234 + "210,5" -> 1 Ziffer -> 210.5 + "12.75" -> 2 Ziffern -> 12.75 + + Bleibt EIN unauflösbarer Fall: "1.500" könnte auch eins Komma + fünf sein. Bei Diamanten, Minuten und Zuschauern ist die + Tausenderdeutung die richtige -- Nachkommastellen kommen dort + nicht vor. Deshalb wird hier so entschieden und nicht anders. */ + const trenner = letztesKomma >= 0 ? "," : "."; + const stelle = letztesKomma >= 0 ? letztesKomma : letzterPunkt; + const dahinter = nurZiffern.length - stelle - 1; + sauber = dahinter === 3 + ? nurZiffern.split(trenner).join("") + : nurZiffern.replace(trenner, "."); + } else { + sauber = nurZiffern; + } + + const n = Number(sauber); + return Number.isFinite(n) ? n : null; +} + +/** Macht aus einem Textwert ein Datum "JJJJ-MM-TT". + * Erkennt 2026-09-06, 06.09.2026 und 09/06/2026. */ +export function datumLesen(roh) { + const t = String(roh ?? "").trim(); + let m; + if ((m = /^(\d{4})-(\d{2})-(\d{2})/.exec(t))) return `${m[1]}-${m[2]}-${m[3]}`; + if ((m = /^(\d{1,2})\.(\d{1,2})\.(\d{4})/.exec(t))) { + return `${m[3]}-${String(m[2]).padStart(2, "0")}-${String(m[1]).padStart(2, "0")}`; + } + if ((m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})/.exec(t))) { + /* Amerikanisch: Monat zuerst. */ + return `${m[3]}-${String(m[1]).padStart(2, "0")}-${String(m[2]).padStart(2, "0")}`; + } + return null; +} + +/** Rät die Spaltenzuordnung aus den Überschriften. + * Nur ein Vorschlag -- bestätigt wird er in der Vorschau. */ +export function spaltenRaten(kopf) { + const muster = { + tag: /datum|date|tag|day/i, + diamanten: /diamant|diamond/i, + dauer_min: /dauer|duration|minut|zeit.*live|live.*zeit/i, + zuschauer_avg: /(durchschnitt|avg|average|⌀|ø).*(zuschauer|viewer)|(zuschauer|viewer).*(durchschnitt|avg)/i, + zuschauer_max: /(spitze|peak|max).*(zuschauer|viewer)|(zuschauer|viewer).*(spitze|peak|max)/i, + verweildauer_s: /verweil|watch.*time|retention|zuschauzeit/i, + schenker: /schenker|gifter|sender/i, + follower_neu: /follower|abonnent/i, + }; + const zu = {}; + kopf.forEach((name, i) => { + for (const [feld, m] of Object.entries(muster)) { + if (zu[feld] === undefined && m.test(String(name))) zu[feld] = i; + } + }); + return zu; +} + +/** Vorschau: Was WÜRDE passieren? Schreibt nichts. */ +leistungRouter.post("/workspace/api/leistung/:creatorId/import/vorschau", gleicheHerkunft, + express.json({ limit: "2mb" }), (req, res) => { + try { + const creatorId = Number(req.params.creatorId); + if (!Number.isInteger(creatorId)) return res.status(400).json({ fehler: "ungueltig" }); + if (!darfEintragen(req.person)) return res.status(403).json({ fehler: "nicht_erlaubt" }); + if (!darfSehen(req.person, creatorId)) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const zeilen = csvZerlegen(String(req.body?.text || "")); + if (zeilen.length < 2) { + return res.status(400).json({ fehler: "Die Datei hat keine Datenzeilen." }); + } + const kopf = zeilen[0]; + const zu = req.body?.spalten && typeof req.body.spalten === "object" + ? req.body.spalten : spaltenRaten(kopf); + + if (zu.tag === undefined) { + return res.status(400).json({ + fehler: "Ich finde keine Datumsspalte. Bitte die Spalte zuordnen.", + kopf, spalten: zu, + }); + } + + const neu = []; + const ersetzt = []; + const fehlerhaft = []; + for (let i = 1; i < zeilen.length; i++) { + const z = zeilen[i]; + const tag = datumLesen(z[zu.tag]); + if (!tag) { fehlerhaft.push({ zeile: i + 1, grund: `Datum unlesbar: "${z[zu.tag]}"` }); continue; } + if (tag > tagVor(0)) { fehlerhaft.push({ zeile: i + 1, grund: `${tag} liegt in der Zukunft` }); continue; } + + const werte = { tag }; + for (const [feld, spalte] of Object.entries(zu)) { + if (feld === "tag" || spalte === undefined) continue; + werte[feld] = zahlLesen(z[spalte]); + } + const vorhanden = db().prepare( + "SELECT 1 FROM leistung WHERE creator_id = ? AND tag = ?").get(creatorId, tag); + (vorhanden ? ersetzt : neu).push(werte); + } + + res.json({ + kopf, spalten: zu, + /* Die ZAHLEN stehen vorn, die Beispiele dahinter: Wer + übernimmt, will wissen "wie viele", nicht "welche". */ + neu: neu.length, ersetzt: ersetzt.length, fehlerhaft: fehlerhaft.length, + beispiele: neu.slice(0, 5), + ersetzt_beispiele: ersetzt.slice(0, 5), + probleme: fehlerhaft.slice(0, 10), + }); + } catch (fehler) { + console.error("[leistung] Vorschau:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/** Übernehmen. Erst jetzt wird geschrieben. */ +leistungRouter.post("/workspace/api/leistung/:creatorId/import", gleicheHerkunft, + express.json({ limit: "2mb" }), (req, res) => { + try { + const creatorId = Number(req.params.creatorId); + if (!Number.isInteger(creatorId)) return res.status(400).json({ fehler: "ungueltig" }); + if (!darfEintragen(req.person)) return res.status(403).json({ fehler: "nicht_erlaubt" }); + if (!darfSehen(req.person, creatorId)) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const zeilen = csvZerlegen(String(req.body?.text || "")); + if (zeilen.length < 2) return res.status(400).json({ fehler: "keine_daten" }); + const zu = req.body?.spalten && typeof req.body.spalten === "object" + ? req.body.spalten : spaltenRaten(zeilen[0]); + if (zu.tag === undefined) return res.status(400).json({ fehler: "keine_datumsspalte" }); + + const d = db(); + const schreiben = d.prepare(` + INSERT OR REPLACE INTO leistung + (creator_id, tag, diamanten, dauer_min, gueltiger_tag, zuschauer_avg, + zuschauer_max, verweildauer_s, schenker, follower_neu, notiz, + erfasst, erfasst_von, quelle) + VALUES (?,?,?,?,?,?,?,?,?,?, + /* Eine vorhandene Notiz NICHT wegwerfen: Sie ist von Hand + geschrieben und steht in keinem Export. */ + (SELECT notiz FROM leistung WHERE creator_id = ? AND tag = ?), + ?,?, 'import')`); + + let geschrieben = 0, uebersprungen = 0; + d.exec("BEGIN"); + try { + for (let i = 1; i < zeilen.length; i++) { + const z = zeilen[i]; + const tag = datumLesen(z[zu.tag]); + if (!tag || tag > tagVor(0)) { uebersprungen++; continue; } + const w = (feld) => (zu[feld] === undefined ? null : zahlLesen(z[zu[feld]])); + const dauer = w("dauer_min"); + schreiben.run( + creatorId, tag, w("diamanten"), dauer, + /* Ein gültiger Tag ist ein Tag mit LIVE-Zeit. Steht im + Export nichts, wird aus der Dauer geschlossen -- eine + eigene Spalte dafür gibt es meist nicht. */ + dauer && dauer > 0 ? 1 : 0, + w("zuschauer_avg"), w("zuschauer_max"), w("verweildauer_s"), + w("schenker"), w("follower_neu"), + creatorId, tag, + jetzt(), req.person.id); + geschrieben++; + } + d.exec("COMMIT"); + } catch (f) { + try { d.exec("ROLLBACK"); } catch { /* schon zurück */ } + throw f; + } + + protokolliere("leistung_import", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `Creator #${creatorId}: ${geschrieben} Tage`, + }); + res.json({ ok: true, geschrieben, uebersprungen }); + } catch (fehler) { + console.error("[leistung] Import:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); diff --git a/server/workspace.js b/server/workspace.js index ec3d5501..256d3dec 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -975,6 +975,90 @@ export function db() { ); CREATE INDEX IF NOT EXISTS idx_wissen_kat ON wissen (kategorie); + /* ================================================================= + LEISTUNG — die Zahlen, an denen die Betreuung haengt (06.09.2026) + + DER GROSSE BEFUND aus dem Plan vom 31.08.2026: Der Workspace + organisiert ARBEIT sehr gut, aber es gibt keine einzige Zahl + ueber das GESCHAEFT. Damit haengt die ganze Betreuung in der + Luft -- der Start-Check bewertet ohne zu messen, der Report + fragt "was hat funktioniert" ohne Beleg, das 90-Tage-Ziel ist + ein Satz statt eines Fortschritts. + + EINE ZEILE JE CREATOR UND TAG. Tag und nicht Woche: Feineres + laesst sich immer zusammenfassen, Groeberes nie aufteilen. Wer + mit Wochenwerten anfaengt, kann spaeter nie mehr sagen, ob der + Einbruch am Dienstag oder am Wochenende lag. + + WELCHE FELDER -- und warum genau diese (Recherche 31.08. und + 06.09.2026, TikTok LIVE Backstage): + + diamanten die Zahl, an der TikTok und die Agentur den + Erfolg messen + dauer_min Grundlage fuer "gueltige Tage"/"aktive Stunden" + gueltiger_tag der Zaehler, an dem Ranghochstufungen haengen + zuschauer_avg sagt, ob es TRAEGT + zuschauer_max sagt, was MOEGLICH waere + verweildauer_s DIE LEITZAHL (Recherche 06.09.2026): 2026 + haengt der Algorithmus alles daran, und sie + gehoert als Betriebskennzahl behandelt -- + sie soll die naechste Runde steuern, nicht + die letzte erklaeren + schenker wie BREIT die Unterstuetzung steht, nicht nur + wie hoch. Ein Grossspender ist ein Risiko, + zwanzig kleine sind ein Fundament + follower_neu waechst der Kanal oder dreht er sich im Kreis + notiz ein Satz Kontext ("PK gegen X", "krank") + + WARUM DIE NOTIZ DAZUGEHOERT: Ohne sie sieht man in drei Monaten + einen Einbruch und weiss nicht mehr, dass die Person Grippe + hatte. Zahlen ohne Umstaende fuehren in die Irre. + + PRIMAERSCHLUESSEL (creator_id, tag): Derselbe Creator am + selben Tag kann nur EINE Zeile haben. Das ist die halbe Miete + beim Import -- wer eine Datei zweimal einliest, ueberschreibt + dann, statt zu verdoppeln. Verdoppelte Zahlen sind schlimmer + als fehlende: Sie sehen richtig aus. */ + CREATE TABLE IF NOT EXISTS leistung ( + creator_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE, + tag TEXT NOT NULL, /* JJJJ-MM-TT, Ortszeit */ + diamanten INTEGER, + dauer_min INTEGER, + gueltiger_tag INTEGER NOT NULL DEFAULT 0, + zuschauer_avg INTEGER, + zuschauer_max INTEGER, + verweildauer_s INTEGER, /* Sekunden, die Leitzahl */ + schenker INTEGER, + follower_neu INTEGER, + notiz TEXT, + erfasst TEXT NOT NULL, + erfasst_von INTEGER REFERENCES personen(id) ON DELETE SET NULL, + /* Woher der Wert kommt. Wichtig, wenn eine Zahl seltsam + aussieht: von Hand getippt oder aus dem Export? */ + quelle TEXT NOT NULL DEFAULT 'hand' + CHECK (quelle IN ('hand','import')), + PRIMARY KEY (creator_id, tag) + ); + CREATE INDEX IF NOT EXISTS idx_leistung_tag ON leistung (tag DESC); + + /* ZIELE je Creator. Aus dem 90-Tage-Plan, den es im Profil schon + als FREITEXT gibt -- daraus wird hier eine pruefbare Groesse. + "Mehr Reichweite" ist kein Ziel, "4 LIVE-Tage pro Woche" ist + eines. + + Eine Zeile je Creator, nicht je Ziel und Zeitraum: Ein Ziel, + das sich woechentlich aendert, ist keines. Wer es anpasst, + ueberschreibt -- der Verlauf steht ohnehin in der Tabelle + "leistung". */ + CREATE TABLE IF NOT EXISTS leistung_ziele ( + creator_id INTEGER PRIMARY KEY REFERENCES personen(id) ON DELETE CASCADE, + diamanten_woche INTEGER, + tage_woche INTEGER, + verweildauer_s INTEGER, + gesetzt TEXT NOT NULL, + gesetzt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL + ); + /* Start-Check / Erstanalyse (Konzept, Seite 5). Eine Zeile je geprueftem Punkt -- ungeprueft heisst: gar keine Zeile. So sieht man am Bestand sofort, wie weit die Analyse ist, ohne leere