Files
dogfather-universe/server/workspace-fruehwarnung.js
T
DogFatherGitandClaude Opus 5 c8a4e7628c Stufe 1+2: Zahlen ueber das Geschaeft, und eine Fruehwarnung
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]>
2026-09-06 18:12:16 +02:00

285 lines
10 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.
/* =====================================================================
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 };