Der grosse Befund aus dem Plan vom 31.08.2026: Der Workspace organisiert ARBEIT hervorragend, wusste ueber das GESCHAEFT aber nichts. Keine Diamanten, keine LIVE-Tage, keine Verweildauer. Damit hing die ganze Betreuung in der Luft -- der Start-Check bewertete ohne zu messen, der Report fragte "was hat funktioniert" ohne Beleg, das 90-Tage-Ziel war ein Satz statt eines Fortschritts. STUFE 1 -- LEISTUNG Eine Zeile je Creator und TAG (nicht Woche: Feineres laesst sich immer zusammenfassen, Groeberes nie aufteilen). Diamanten, LIVE-Dauer, gueltiger Tag, Zuschauer im Schnitt und in der Spitze, Verweildauer, Schenker, neue Follower -- und eine NOTIZ. Ohne sie sieht man in drei Monaten einen Einbruch und weiss nicht mehr, dass die Person Grippe hatte. DIE VERWEILDAUER IST DIE LEITZAHL (Recherche 06.09.2026): 2026 haengt der Algorithmus alles an der Completion Rate, und sie gehoert als BETRIEBSkennzahl behandelt -- sie soll die naechste Runde steuern, nicht die letzte erklaeren. Jede Zahl kommt mit Vergleich, Verlauf und Einordnung. Eine Zahl ohne diese drei ist eine Eitelkeitszahl: Sie sieht nach Auskunft aus und ist keine, weil man nichts entscheiden kann. Zwei Wege hinein: Schnelleingabe und CSV-Import aus dem offiziellen Export, mit Vorschau vor dem Uebernehmen. STUFE 2 -- FRUEHWARNUNG Sechs benannte Signale (Zahlen fallen, LIVE-Tage brechen weg, lange nicht angemeldet, kein Gespraech, Aufgabenstau, Start-Check haengt) und daraus EIN ruhiger Satz: "Bei Nora wuerde ich diese Woche nachfassen." BEWUSST KEIN SCORE. Eine Note von 1 bis 100 wirkt objektiv und ist es nicht; sie verfuehrt dazu, Menschen nach einer Zahl zu behandeln. Am Score kann man nichts tun, am Signal schon. Deshalb Saetze statt Punkte, und jedes Signal einzeln nachvollziehbar. Ein CREATOR sieht die Fruehwarnung nicht: Das ist eine Arbeitsgrundlage fuer die Betreuung, keine Mitteilung an die betroffene Person. ZWEI ECHTE FEHLER, VON DER PRUEFUNG GEFUNDEN 1. ZAHLENFORMAT, Faktor tausend daneben. "1.250" wurde als 1,25 gelesen und "1,234" als 1,234. Die Regel "das letzte Trennzeichen ist das Dezimalzeichen" liefert bei nur EINEM Trennzeichen fuer beide Faelle das Falsche -- und die Zahl sieht danach trotzdem plausibel aus. Jetzt entscheidet, wie viele Ziffern dahinter stehen: genau drei heisst Tausendertrenner. 2. DIE ZIELE-ROUTE WURDE VERSCHLUCKT. Express nimmt die erste passende Route, und `:tag` passt auch auf "ziele" -- ein PUT auf .../ziele landete in der Tages-Route und scheiterte am Datumsmuster. Die Meldung sagte "ungueltig", was auf die Zahlen deutet, nicht auf den Weg. Reihenfolge getauscht. Beides waere still gewesen: falsche Zahlen sehen richtig aus, und ein Ziel, das sich nicht setzen laesst, probiert man zweimal und laesst es dann. pruef-leistung.mjs: 47 Punkte, darunter jede Rechnung einzeln nachgerechnet (Durchschnitte ohne Tage ohne Wert, Prozent von null, Wochengrenzen), Import zweimal eingelesen, deutsche und englische Zahlenformate, Rechte, und die Gegenprobe, dass die Fruehwarnung bei Unauffaelligkeit SCHWEIGT. Co-Authored-By: Claude Opus 5 <[email protected]>
456 lines
21 KiB
JavaScript
456 lines
21 KiB
JavaScript
/* 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);
|