/* ===================================================================== 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 };