/* ===================================================================== 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. */ /* DURCH DIE AUGEN DER ANGESEHENEN PERSON (11.09.2026). Filipe: "oben bei meine sicht soll ich auch die sicht von allen jeden moment sehen koennen und das genau genau so wie sie es sehen alles." Nur LESEN wird umgestellt, nie ein Recht: `req.person` bleibt ueberall dort stehen, wo geschrieben oder geprueft wird. Und weil DogFather ohnehin alles sehen darf, kann diese Umstellung nichts oeffnen -- sie kann nur WENIGER zeigen, naemlich genau das, was die andere Person sieht. Und weil die Fruehwarnung ausdruecklich NICHT fuer die betroffene Person gedacht ist, muss auch dieser Riegel der Sicht folgen: In der Sicht auf einen Creator gehoert dort `gilt_nicht` zu stehen, nicht DogFathers eigene Liste. */ const zeigt = req.sicht || req.person; if (zeigt.rolle === "creator") return res.json({ creator: [], gilt_nicht: true }); const ids = sichtbareCreatorIds(zeigt); 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 };