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