Files
dogfather-universe/server/workspace-manager-ziele.js
T
DogFatherGitandClaude Opus 5 72e51c57b6 Den Monatswechsel durchgespielt -- und zwei stille Fehler gefunden
Der zentrale Weg dieser Kachel war nie gelaufen: „Am 1. jedes Monats
um 00:00 Uhr starten alle Zaehler automatisch bei 0." Der erste
Oktober lag vor der Auslieferung, der erste November liegt dahinter.
Er laeuft ohne Zutun und betrifft alle gleichzeitig -- waere er
falsch, waere er fuer alle auf einmal falsch, und niemand wuesste
warum.

Dieselbe Lage gab es am 01.09.2026 schon einmal in RunOne (der Umbau
zur Hashkette war seit Tagen live und nie gelaufen). Die Lehre stand
danach in der Projektnotiz, und sie gilt hier woertlich: Was sich
nicht zuruecknehmen laesst, wird vorher auf einer Kopie durchgespielt.

`pruef-monatswechsel.mjs` verstellt dafuer die Uhr -- eine Huelle um
`Date`, ganz oben, vor jedem Import. Damit laeuft der ECHTE Code
durch zwei echte Monatswechsel (20. Oktober -> 1. November, 00:05 ->
3. Dezember), ohne dass eine Zeile dafuer umgebaut werden muesste.
41 Pruefungen, 0 Fehler: Zaehler bei 0, keine Warnung am ersten Tag,
die neuen Zielzahlen greifen, die alten Eintraege stehen unveraendert
da, der Oktober ist zu, der Verlauf misst ihn am OKTOBER-Ziel, und
„Neuer Monat" geht an alle drei -- auch an den, der den Oktober voll
hatte.

WAS DABEI AUFGEFALLEN IST -- zwei Fehler, beide stumm

1. DIE SUCHE NACH DEM ROLLENWECHSEL MASS DIE FALSCHE PERSON.
   `WHERE person_id = ?` im Protokoll findet den, der die Aenderung
   GEMACHT hat -- also DogFather --, nicht den, dessen Rolle sich
   geaendert hat (workspace-personen.js schreibt die betroffene
   Nummer ins `detail`). Fuer den Betroffenen fand die Abfrage
   deshalb nie etwas; bei DogFather schob jede fremde
   Rollenaenderung SEINEN Pflichtbeginn. In der echten Datenbank
   stand bei ihm „grund: rollenwechsel" -- ein plausibler Wert aus
   der falschen Zeile.

   NACHGESEHEN, OB ES SCHADET: Alle sechs Zeilen stehen auf 2026-10,
   und das ist ohnehin der frueheste moegliche Monat. Der Fehler
   hatte noch keine Wirkung -- er haette sie beim naechsten
   Rollenwechsel bekommen.

2. `node:sqlite` BINDET EINE ZAHL ALS REAL.
   Der erste Versuch der Reparatur lautete
   `detail LIKE ('#' || ? || ' %')` und traf NIE -- ohne Fehler, ohne
   Warnung, immer leer. Nachgemessen:

       SELECT ('#' || ? || ' %')  mit der Zahl 42  ->  '#42.0 %'

   Gesucht wurde „#42.0 ", gespeichert ist „#42 ". Mit derselben Zahl
   als Zeichenkette stimmt es sofort. Das Muster wird jetzt in
   JavaScript gebaut; im uebrigen Haus kommt dieselbe Verkettung mit
   einer Zahl nicht vor (nachgesehen).

   Gefunden hat das nicht das Lesen, sondern eine Pruefung, die den
   ECHTEN Weg benutzt (die Route der Personenverwaltung) statt den
   Protokolltext selbst zu schreiben. Haette sie ihn selbst
   geschrieben, haette sie ihre eigene Annahme geprueft und waere
   gruen gewesen.

AUSSERDEM BERICHTIGT

Der Pflichtbeginn wurde EINMAL gesetzt und nie wieder angesehen
(`INSERT OR IGNORE`). Wer die Rolle verliert und spaeter zurueck-
bekommt, haette damit keinen Schonmonat mehr bekommen, obwohl die
Vorlage ihn zusichert. Jetzt wird neu gerechnet, wenn seit der
Festlegung ein Rollenwechsel dazugekommen ist -- und nur dann;
geprueft wird ausdruecklich, dass ein zweiter Abruf nichts bewegt.

GEPRUEFT: 216 + 41 = 257 Pruefungen, 0 Fehler

Mit drei Gegenproben an der neuen Stelle: DogFathers Pflichtbeginn
bewegt sich NICHT, wenn er die Rolle eines anderen aendert; ein
unbeteiligter Scout behaelt seinen Wert; und ein zweiter Abruf
rechnet nichts neu. Ohne die erste waere der Fehler von oben
unentdeckt geblieben, ohne die zweite hiesse „einer hat sich
geaendert" vielleicht „alle".

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 18:02:28 +02:00

1482 lines
60 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
/* =====================================================================
MANAGER-ZIELE — vier feste Monatsaufgaben (02.10.2026)
=====================================================================
Vorlage: „Prompt · Kachel Agentur-Aufgaben". Vier Pflichten je Monat,
Fortschritt, Ampel, Warnungen, Monatsschnitt, Team-Übersicht.
---------------------------------------------------------------------
WAS HIER ANDERS IST ALS IN DER VORLAGE — und warum
1. DIE KACHEL HEISST „Manager-Ziele", nicht „Agentur-Aufgaben".
Es gibt im Haus bereits eine Kachel „Agentur" und eine Kachel
„Aufgaben". Eine dritte mit beiden Wörtern im Namen wäre auf
einem Handy nicht mehr auseinanderzuhalten. Entschieden von
Filipe am 02.10.2026.
2. DIE ZIELZAHLEN GELTEN JE ROLLE und sind von DogFather UND Spicy
Media änderbar („dogfather und spicy rolle sollen aber
gleichzeitig es immer ändern können für alle"). Ein Scout muss
nicht dieselbe Zahl schaffen wie DogFather.
DASSELBE GILT SEIT DEM 02.10.2026 FÜRS KORRIGIEREN. Die Vorlage
kannte dort nur DogFather; auf die Rückfrage: „ja spicy und
dogfather". Beides liegt jetzt auf derselben Menge
(`siehtAlles`) — wer alle Zahlen sieht und die Ziele setzt, muss
auch einen Zahlendreher gerade rücken können.
3. DIE PIPELINE IST ANGEBUNDEN, in beide Richtungen. Die Vorlage
kennt nur die Handeingabe. Das Haus hat aber ein vollständiges
Scout-CRM (`leads`), und zwei Listen derselben Rekrutierungen
laufen auseinander. Also: Vorschläge aus der Pipeline, Übernahme
mit einem Klick, Verbindung bleibt gespeichert — aber NICHTS
zählt von selbst. Gezählt wird nur, was ein Mensch bestätigt hat.
---------------------------------------------------------------------
KEIN ZWEITER ZÄHLER FÜR DIE KACHELWAND
Die Zahl auf einer Kachel kommt im Haus immer aus den Hinweisen
(`workspace-hinweise.js`), nie aus einer eigenen Rechnung. Dieses
Modul liefert dafür `offeneHinweise()` und rechnet nicht ein
zweites Mal — sonst stünden irgendwann zwei verschiedene Wahrheiten
übereinander auf einem Bildschirm.
---------------------------------------------------------------------
KEIN IMPORT AUS workspace-push.js
Die Erinnerungen entstehen hier als LISTE (`zielRufe`), verschickt
werden sie im Takt von workspace-push.js. Andersherum wäre es ein
Importkreis: push müsste dieses Modul kennen und dieses Modul push.
Über einen Kreis kommen Konstanten als `undefined` an, ohne dass
irgendwo ein Fehler erscheint.
===================================================================== */
import express from "express";
import {
db, sitzungLesen, protokolliere, echteIp, siehtAlles, ROLLEN_NAME,
} from "./workspace.js";
import {
AUFGABEN, AUFGABEN_SCHLUESSEL, PFLICHT_ROLLEN, ERSTER_MONAT,
FREIGABE_SEKUNDEN,
} from "./manager-ziele-tabellen.js";
export const managerZieleRouter = express.Router();
const BASIS = "/workspace/api/manager-ziele";
const jetzt = () => new Date().toISOString();
const NAME_MAX = 120;
const LINK_MAX = 500;
const NOTIZ_MAX = 1000;
const AUFGABE_VON = new Map(AUFGABEN.map((a) => [a.schluessel, a]));
/* =====================================================================
ZEIT — immer Europa/Berlin, nie die Uhr des Servers
=====================================================================
Die Vorlage nennt Europe/Luxembourg. Luxemburg und Berlin haben
dieselbe Zeit und dieselben Umstellungstage (beide MEZ/MESZ nach
EU-Regel), es gibt also keinen Unterschied im Verhalten — und das
ganze übrige Haus rechnet in Europe/Berlin. Zwei Zeitrechnungen in
einem Haus wären die Stelle, an der es später auseinanderläuft.
`getMonth()` oder `new Date().getDate()` kommen hier NICHT vor: Sie
lesen die Zeitzone des Servers, und die ist auf einem gemieteten
Rechner nicht verlässlich deutsch. Ein Monatswechsel, der zwei
Stunden zu früh passiert, verschiebt jede Zählung.
===================================================================== */
const TAG_BERLIN = new Intl.DateTimeFormat("sv-SE", {
timeZone: "Europe/Berlin", year: "numeric", month: "2-digit", day: "2-digit",
});
/** Der heutige Tag in Berlin als 'JJJJ-MM-TT'. */
export function tagBerlin(d = new Date()) {
return TAG_BERLIN.format(d);
}
/** Der laufende Monat als 'JJJJ-MM'. */
export function monatJetzt(d = new Date()) {
return tagBerlin(d).slice(0, 7);
}
/** Wie viele Tage dieser Monat hat. `Date.UTC(j, m, 0)` ist der letzte
* Tag des Vormonats von m+1 — also der letzte des gesuchten. */
export function letzterTagVon(monat) {
const [j, m] = String(monat).split("-").map(Number);
if (!j || !m) return 30;
return new Date(Date.UTC(j, m, 0)).getUTCDate();
}
/** Monat um `n` verschieben. Negativ heißt zurück. */
export function monatPlus(monat, n) {
const [j, m] = String(monat).split("-").map(Number);
const d = new Date(Date.UTC(j, (m - 1) + n, 1));
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
}
const istMonat = (w) => /^\d{4}-(0[1-9]|1[0-2])$/.test(String(w || ""));
const istDatum = (w) => /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/.test(String(w || ""));
/* =====================================================================
DIE AMPEL
===================================================================== */
/** Die Stufe einer einzelnen Aufgabe.
*
* Reihenfolge der Prüfungen ist die Dringlichkeit: erreicht schlägt
* alles, danach gewinnt Rot vor Orange. Stünde die 16er-Regel zuerst,
* bliebe eine Aufgabe am 28. orange — die schwächere von zwei
* zutreffenden Warnungen.
*
* `ziel = 0` heißt „für diese Rolle nicht gefordert". Das ist kein
* Sonderfall, sondern eine gültige Einstellung: DogFather kann für
* sich selbst z. B. 0 Creator setzen. Eine solche Zeile warnt nie und
* zählt nicht in den Gesamtfortschritt. */
export function stufeFuer(zahl, ziel, tag, letzter) {
if (!(ziel > 0)) return "aus";
if (zahl >= ziel) return "gruen";
const bisEnde = letzter - tag;
if (bisEnde <= 7) return "rot";
if (tag >= 16) return "orange";
return "neutral";
}
/** Der Satz zur Stufe. FARBE NIE ALLEIN — Hausregel: zu jeder Farbe
* gehört ein Text und ein Zeichen, sonst ist sie für einen Teil der
* Menschen gar nicht da. */
export function stufenText(stufe, bisEnde) {
if (stufe === "gruen") return "Ziel erreicht";
if (stufe === "orange") return "Sollte bis Monatsmitte erledigt sein";
if (stufe === "rot") {
if (bisEnde <= 0) return "Dringend – heute ist der letzte Tag";
if (bisEnde === 1) return "Dringend – noch 1 Tag";
return `Dringend – noch ${bisEnde} Tage`;
}
if (stufe === "aus") return "Für deine Rolle nicht gefordert";
return "";
}
/** Wer fremde Eintraege korrigieren und abgeschlossene Monate oeffnen
* darf.
*
* BEIDE ROLLEN, NICHT NUR DOGFATHER (02.10.2026). Die Vorlage sagte
* „Ausnahme: DogFather"; Filipe auf die Rueckfrage: „ja spicy und
* dogfather". Damit gilt hier dieselbe Menge wie bei den Zielzahlen
* und bei der Team-Uebersicht -- und das ist der eigentliche Grund,
* es so zu machen: Wer die Zahlen aller sieht und die Ziele setzt,
* muss auch einen Zahlendreher gerade ruecken koennen. Zwei
* verschiedene Grenzen fuer „darf alles sehen" und „darf etwas
* richtigstellen" waeren die Stelle, an der sich spaeter niemand
* mehr erklaeren kann, warum ein Knopf fehlt.
*
* ES IST `siehtAlles` UND KEINE EIGENE LISTE. Eine zweite Aufzaehlung
* derselben zwei Rollen waere die, die beim naechsten Umbau
* auseinanderlaeuft.
*
* UND ES BLEIBT NACHVOLLZIEHBAR: Jede Korrektur an einem fremden
* Eintrag und jede Aenderung an einem abgeschlossenen Monat steht
* mit Name und Zeit im Protokoll -- unveraendert, auch jetzt, wo es
* zwei Menschen tun koennen statt einem. */
const darfKorrigieren = (person) => siehtAlles(person);
const RANG = { rot: 0, orange: 1, neutral: 2, gruen: 3, aus: 4 };
/** Die schlechteste Stufe einer Liste — das ist die Farbe der Kachel. */
export function schlechteste(stufen) {
let beste = "aus";
for (const s of stufen) if (RANG[s] < RANG[beste]) beste = s;
return beste;
}
/* =====================================================================
DER LAUFENDE MONAT UND DIE EINGEFRORENEN ZIELE
===================================================================== */
/** Sagt der Datenbank, welcher Monat läuft — die Trigger lesen das.
*
* Wird vor jedem schreibenden Zugriff gesetzt und nicht einmal beim
* Start: Ein Server, der über den Monatswechsel durchläuft, hätte
* sonst einen Monat lang den falschen Wert stehen, und die Sperre
* griffe am 1. für den gerade begonnenen Monat. */
function lageSetzen(monat = monatJetzt()) {
db().prepare("UPDATE mz_lage SET monat = ? WHERE eins = 1").run(monat);
return monat;
}
/** Das geltende Ziel für (Monat, Rolle, Aufgabe), ohne zu schreiben.
*
* Gesucht wird die jüngste Festlegung, die NICHT NACH diesem Monat
* liegt. Dadurch gilt eine Änderung ab dem Monat, für den sie
* eingetragen wurde, und kein Monat davor ändert sich. Findet sich
* gar nichts, gilt die Vorgabe aus der Vorlage (3/2/2/1). */
function zielGeltend(monat, rolle, aufgabe) {
const z = db().prepare(
`SELECT ziel FROM mz_ziel
WHERE rolle = ? AND aufgabe = ? AND monat <= ?
ORDER BY monat DESC LIMIT 1`).get(rolle, aufgabe, monat);
if (z) return z.ziel;
return AUFGABE_VON.get(aufgabe)?.vorgabe ?? 0;
}
/** Schreibt die Ziele eines Monats fest, falls noch nicht geschehen.
*
* DAS IST DER KERN DER REGEL „Änderungen gelten ab dem nächsten
* Monat": Sobald ein Monat einmal angefasst wurde, stehen seine
* Zahlen. Eine spätere Änderung trägt sich für einen SPÄTEREN Monat
* ein und kann diesen hier nicht mehr erreichen.
*
* `INSERT OR IGNORE` und nicht `REPLACE` — genau deshalb. */
function monatSichern(monat) {
if (!istMonat(monat) || monat < ERSTER_MONAT) return;
const d = db();
const schon = d.prepare("SELECT COUNT(*) n FROM mz_ziel WHERE monat = ?").get(monat)?.n ?? 0;
if (schon >= PFLICHT_ROLLEN.length * AUFGABEN.length) return;
const einfuegen = d.prepare(
`INSERT OR IGNORE INTO mz_ziel (monat, rolle, aufgabe, ziel, gesetzt_von, gesetzt_am)
VALUES (?,?,?,?,NULL,?)`);
const am = jetzt();
for (const rolle of PFLICHT_ROLLEN) {
for (const a of AUFGABEN) {
einfuegen.run(monat, rolle, a.schluessel, zielGeltend(monat, rolle, a.schluessel), am);
}
}
}
/** Das Ziel, wie es für diesen Monat FESTSTEHT. */
function zielFuer(monat, rolle, aufgabe) {
const z = db().prepare(
"SELECT ziel FROM mz_ziel WHERE monat = ? AND rolle = ? AND aufgabe = ?")
.get(monat, rolle, aufgabe);
return z ? z.ziel : zielGeltend(monat, rolle, aufgabe);
}
/* =====================================================================
AB WANN JEMAND PFLICHTIG IST
=====================================================================
Vorlage: „Wer erst im Laufe des Monats eine der Rollen bekommt,
erhält in diesem Monat keine Warnungen; die Pflicht beginnt im
Folgemonat."
„IM LAUFE DES MONATS" HEISST AB DEM 2. Wer am 1. dazukommt, hat den
vollen Monat vor sich; ihm die Pflicht zu erlassen wäre ein
geschenkter Monat ohne Grund. Diese eine Zeile ist der ganze
Unterschied, und sie steht hier, damit man sie findet.
GEMESSEN WIRD EINMAL. Der Rollenwechsel steht im Protokoll, und ein
Protokoll ist ein Log — es darf gekürzt werden. Würde die Pflicht
bei jeder Abfrage daraus gerechnet, verschöbe ein Aufräumlauf
rückwirkend, seit wann jemand verantwortlich ist.
===================================================================== */
/** Wann hat sich DIE ROLLE DIESER PERSON zuletzt geändert?
*
* BERICHTIGT AM 02.10.2026 -- und das war ein echter Fehler, kein
* Schönheitsfleck.
*
* Hier stand `WHERE person_id = ?`. Im Protokoll steht unter
* `person_id` aber der, der die Änderung GEMACHT hat (also
* DogFather), nicht der, dessen Rolle sich geändert hat -- siehe
* workspace-personen.js: `protokolliere("rolle_geaendert",
* { personId: req.person.id, … detail: "#<id> <name>: alt -> neu" })`.
*
* Die Abfrage hat damit zwei Dinge falsch gemacht, und beide sahen
* richtig aus:
*
* 1. Für den BETROFFENEN fand sie nie etwas -- sein Pflichtbeginn
* wurde aus dem Anlegedatum gerechnet, als hätte er die Rolle
* seit jeher.
* 2. Für DogFather fand sie JEDE Rollenänderung, die er an anderen
* vorgenommen hat, und schob SEINEN Pflichtbeginn davon. In der
* echten Datenbank stand deshalb bei ihm „grund: rollenwechsel"
* -- ein plausibler Wert, der aus der falschen Zeile kam.
*
* Gefunden hat es nicht die Prüfung, sondern ein Blick in die Quelle
* beim Nachlesen, wie das Protokoll geschrieben wird. Das ist genau
* die Sorte Fehler, vor der die Hausnotiz warnt: Eine Prüfung, die
* grün ist, weil sie das Falsche misst.
*
* JETZT ÜBER DAS `detail`, denn dort steht die betroffene Nummer.
* Das ist eine Textsuche in einem Protokolltext und damit die
* schwächste Stelle hier -- deshalb wird das Ergebnis nicht jedes
* Mal neu gerechnet, sondern in `mz_start` festgehalten. */
function rollenWechselAm(personId) {
/* DAS MUSTER WIRD HIER GEBAUT UND NICHT IN SQL ZUSAMMENGESETZT.
Hier stand `detail LIKE ('#' || ? || ' %')`, und das hat NIE
getroffen -- ohne Fehler, ohne Warnung, einfach immer leer.
Nachgemessen in einer leeren Datenbank:
SELECT ('#' || ? || ' %') mit der Zahl 42 -> '#42.0 %'
`node:sqlite` bindet eine JavaScript-Zahl als REAL, und SQLite
macht aus 42.0 beim Verketten den Text „42.0". Gesucht wurde
also nach „#42.0 ", gespeichert ist „#42 ". Mit derselben Zahl
als Zeichenkette (`"42"`) stimmt es sofort.
Das ist genau die Sorte Fehler, vor der die Hausnotiz warnt: Die
Abfrage lief, sie war fehlerfrei, und sie war wertlos. Gefunden
hat sie nicht das Lesen, sondern eine Pruefung, die den ECHTEN
Weg benutzt hat statt den Protokolltext selbst zu schreiben.
`Number.isInteger` davor ist kein Schmuck: Eine Zeichenkette mit
`%` darin waere sonst ein Platzhalter im Muster. */
if (!Number.isInteger(Number(personId))) return null;
try {
return db().prepare(
`SELECT zeitpunkt FROM protokoll
WHERE aktion = 'rolle_geaendert' AND detail LIKE ?
ORDER BY zeitpunkt DESC LIMIT 1`)
.get(`#${Number(personId)} %`)?.zeitpunkt || null;
} catch (fehler) {
console.error("[manager-ziele] Rollenwechsel suchen:", fehler?.message);
return null;
}
}
function startMonatFuer(personId) {
const d = db();
const da = d.prepare(
"SELECT erster_monat, gesetzt_am FROM mz_start WHERE person_id = ?").get(personId);
const wechsel = rollenWechselAm(personId);
/* NEU GERECHNET WIRD NUR, WENN SICH SEITHER ETWAS GEÄNDERT HAT.
Vorher stand hier `if (da) return` -- einmal gesetzt, für immer.
Damit bekam jemand, der die Rolle verliert und später
zurückbekommt, KEINEN Schonmonat mehr, obwohl die Vorlage ihm
genau den zusichert („Wer erst im Laufe des Monats eine der
Rollen bekommt …"). Es wäre erst in Monaten aufgefallen, und
dann als „der wird zu Unrecht gemahnt". */
if (da && !(wechsel && wechsel > da.gesetzt_am)) return da.erster_monat;
const p = d.prepare("SELECT erstellt FROM personen WHERE id = ?").get(personId);
if (!p) return da?.erster_monat || ERSTER_MONAT;
/* Der jüngste Rollenwechsel schlägt das Anlegedatum: Wer seit einem
Jahr Creator war und gestern Scout wurde, ist nicht seit einem
Jahr pflichtig.
JEDER Rollenwechsel zählt, auch der von Scout zu Manager. Das ist
eine Entscheidung und keine Nachlässigkeit: Seit die Zielzahlen
je Rolle gelten, bekommt man mit einer neuen Rolle wirklich neue
Pflichten -- und Rollen ändert ohnehin nur DogFather, hier ist
also nichts zu holen. */
const quelle = wechsel || p.erstellt;
let grund = wechsel ? "rollenwechsel" : "angelegt";
let monat = ERSTER_MONAT;
try {
const tag = tagBerlin(new Date(quelle));
monat = tag.slice(8) === "01" ? tag.slice(0, 7) : monatPlus(tag.slice(0, 7), 1);
} catch {
grund = "unlesbar";
}
if (monat < ERSTER_MONAT) { monat = ERSTER_MONAT; grund += "+erster_monat"; }
d.prepare(`INSERT INTO mz_start (person_id, erster_monat, gesetzt_am, grund)
VALUES (?,?,?,?)
ON CONFLICT(person_id) DO UPDATE SET
erster_monat = excluded.erster_monat,
gesetzt_am = excluded.gesetzt_am,
grund = excluded.grund`)
.run(personId, monat, jetzt(), grund);
return monat;
}
/** Gilt die Pflicht für diese Person in diesem Monat schon? */
function pflichtigIn(person, monat) {
if (!person || !PFLICHT_ROLLEN.includes(person.rolle)) return false;
if (monat < ERSTER_MONAT) return false;
return startMonatFuer(person.id) <= monat;
}
/* =====================================================================
DER STAND
===================================================================== */
/** Zählt die Einträge einer Person je Aufgabe in einem Monat. */
function zaehlen(personId, monat) {
const zeilen = db().prepare(
"SELECT aufgabe, COUNT(*) n FROM mz_eintrag WHERE person_id = ? AND monat = ? GROUP BY aufgabe")
.all(personId, monat);
const raus = Object.create(null);
for (const s of AUFGABEN_SCHLUESSEL) raus[s] = 0;
for (const z of zeilen) raus[z.aufgabe] = z.n;
return raus;
}
/** Der vollständige Stand einer Person in einem Monat.
*
* EINE Rechnung für alles: Kachel, Seite, Erinnerung, Verlauf und
* Team-Übersicht fragen diese Funktion. Vier eigene Rechnungen wären
* vier Gelegenheiten, dass die Zahlen auseinandergehen. */
export function standFuer(person, monat = monatJetzt(), heute = tagBerlin()) {
const letzter = letzterTagVon(monat);
/* Für einen vergangenen Monat ist der „heutige" Tag der letzte:
Sonst stünde ein abgeschlossener Monat auf „neutral, noch Zeit". */
const imMonat = heute.slice(0, 7) === monat;
const tag = imMonat ? Number(heute.slice(8, 10)) : (monat < heute.slice(0, 7) ? letzter : 1);
const bisEnde = letzter - tag;
const zahl = zaehlen(person.id, monat);
const pflichtig = pflichtigIn(person, monat);
const aufgaben = AUFGABEN.map((a) => {
const ziel = zielFuer(monat, person.rolle, a.schluessel);
const n = zahl[a.schluessel] || 0;
/* OHNE PFLICHT KEINE WARNUNG — aber die Zeile bleibt sichtbar und
zählbar. Wer im ersten Monat schon etwas einträgt, soll es
sehen; er soll nur nicht dafür gemahnt werden. */
const stufe = pflichtig ? stufeFuer(n, ziel, tag, letzter)
: (ziel > 0 && n >= ziel ? "gruen" : "neutral");
/* WAS DIESE PERSON ZULETZT GEWAEHLT HAT -- abgeleitet aus ihrem
letzten Eintrag, nicht in einer Einstellung gespeichert.
Zweck: Der Schnell-Eintrag kann die Art vorbelegen, und damit
faellt bei „Werbung" und „Schulung" je ein Handgriff weg. Eine
eigene Spalte „Lieblingsart" waere ein zweiter Bestand, der
pflegebeduerftig ist und veralten kann; der letzte Eintrag
veraltet nie, er ist der letzte Eintrag. */
const zuletzt = a.arten
? db().prepare(`SELECT art FROM mz_eintrag
WHERE person_id = ? AND aufgabe = ? AND art IS NOT NULL
ORDER BY id DESC LIMIT 1`).get(person.id, a.schluessel)?.art
: null;
return {
schluessel: a.schluessel,
name: a.name,
kurz: a.kurz,
hinweis: a.hinweis,
feld: a.feld,
arten: a.arten || null,
letzteArt: zuletzt || a.arten?.[0]?.wert || null,
einheit: a.einheit,
zahl: n,
ziel,
fehlt: Math.max(0, ziel - n),
stufe,
text: stufenText(stufe, bisEnde),
};
});
const zielSumme = aufgaben.reduce((s, a) => s + a.ziel, 0);
const erledigt = aufgaben.reduce((s, a) => s + Math.min(a.zahl, a.ziel), 0);
const offene = aufgaben.filter((a) => a.ziel > 0 && a.zahl < a.ziel);
return {
monat, heute, tag, letzter, bisEnde, pflichtig,
aufgaben,
gesamt: {
erledigt, ziel: zielSumme,
anteil: zielSumme ? Math.round((erledigt / zielSumme) * 100) : 100,
offen: offene.length,
},
stufe: schlechteste(aufgaben.map((a) => a.stufe)),
};
}
/* =====================================================================
DIE SCHRANKE
===================================================================== */
function angemeldet(req, res, next) {
const person = sitzungLesen(req);
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
/* ZWEI GETRENNTE HÄUSER (Regel vom 24.09.2026). Auf der Adresse von
Team Dogi gibt es diese Kachel nicht — auch nicht für DogFather,
der in beiden Häusern arbeitet. 404 und nicht 403: Was es hier
nicht gibt, soll man auch nicht erahnen können. */
if (person.haus === "crew") return res.status(404).json({ fehler: "nicht_hier" });
/* Creator haben die Kachel nicht. 404 aus demselben Grund wie im
Scout-CRM: Sie sollen nicht einmal erfahren, dass es diesen
Bereich gibt. Die Prüfung ist SERVERSEITIG — die Rechtetafel
sperrt die Seite, diese Zeile die Schnittstelle. Nur eines von
beiden wäre eine Tür, die man bloß kennen muss. */
if (!PFLICHT_ROLLEN.includes(person.rolle)) {
return res.status(404).json({ fehler: "nicht_zustaendig" });
}
req.person = person;
lageSetzen();
monatSichern(monatJetzt());
next();
}
managerZieleRouter.use(BASIS, angemeldet);
function nurLeitung(req, res, next) {
if (!siehtAlles(req.person)) return res.status(403).json({ fehler: "nicht_erlaubt" });
next();
}
/* =====================================================================
PRÜFUNGEN AN EINEM EINTRAG
===================================================================== */
const sauber = (w, max) => {
const t = String(w ?? "").trim().replace(/\s+/g, " ");
return t.slice(0, max);
};
/** Der Name ohne Zierrat — für den Dublettenschutz.
* „@LunaTanzt", „luna tanzt" und „LUNATANZT" sind derselbe Mensch. */
const nameSchluessel = (w) =>
String(w || "").toLowerCase().replace(/^@+/, "").replace(/[\s._-]+/g, "");
/** Der Link ohne Zierrat. Groß/klein, abschließender Schrägstrich und
* Verfolgungsparameter (`?is_from_webapp=…`) machen aus demselben
* Video sonst zwei. */
function linkSchluessel(w) {
try {
const u = new URL(String(w));
return (u.host.replace(/^www\./, "") + u.pathname.replace(/\/+$/, "")).toLowerCase();
} catch {
return String(w || "").trim().toLowerCase();
}
}
/** Ist das eine brauchbare Internetadresse?
*
* Filipe am 02.10.2026: jede gültige Internetadresse, nicht nur
* TikTok. Geprüft wird deshalb nur, dass es wirklich eine Adresse mit
* http(s) und einem Punkt im Namen ist — „tiktok" allein oder
* „javascript:…" sind keine. */
function linkOk(w) {
try {
const u = new URL(String(w).trim());
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
return u.hostname.includes(".") && u.hostname.length >= 4;
} catch {
return false;
}
}
/** Prüft und normalisiert, was aus dem Formular kommt.
* Gibt `{ fehler }` oder `{ werte }` zurück — nie beides. */
function eintragPruefen(rumpf, monat, heute) {
const aufgabe = String(rumpf?.aufgabe || "");
const a = AUFGABE_VON.get(aufgabe);
if (!a) return { fehler: "aufgabe_unbekannt" };
const datum = String(rumpf?.datum || "").trim();
if (!istDatum(datum)) return { fehler: "datum_fehlt" };
/* KEIN DATUM IN DER ZUKUNFT und keines außerhalb des Monats
(Vorlage, Punkt 09). Beide Prüfungen gegen BERLINER Tage, nicht
gegen die Uhr des Servers. */
if (datum > heute) return { fehler: "datum_zukunft" };
if (datum.slice(0, 7) !== monat) return { fehler: "datum_fremder_monat" };
const werte = {
aufgabe, datum,
name: null, name_klein: null,
link: null, link_klein: null,
art: null,
notiz: sauber(rumpf?.notiz, NOTIZ_MAX) || null,
lead_id: null,
};
if (a.feld === "name") {
const name = sauber(rumpf?.name, NAME_MAX);
if (name.length < 2) return { fehler: "name_fehlt" };
werte.name = name;
werte.name_klein = nameSchluessel(name);
if (!werte.name_klein) return { fehler: "name_fehlt" };
}
if (a.feld === "link") {
const link = String(rumpf?.link || "").trim().slice(0, LINK_MAX);
if (!linkOk(link)) return { fehler: "link_ungueltig" };
werte.link = link;
werte.link_klein = linkSchluessel(link);
}
if (a.arten) {
const art = String(rumpf?.art || "");
if (!a.arten.some((x) => x.wert === art)) return { fehler: "art_fehlt" };
werte.art = art;
}
const lead = Number(rumpf?.lead_id);
if (Number.isInteger(lead) && lead > 0) werte.lead_id = lead;
return { werte };
}
/** Sucht Dubletten im selben Monat.
*
* ZWEI VERSCHIEDENE ANTWORTEN, und der Unterschied ist wichtig:
*
* EIGENE Dublette -> wird ABGELEHNT. Denselben Creator zweimal
* selbst einzutragen ist doppelt gezählt, und
* eine Zahl, die man sich selbst verdoppeln
* kann, ist keine Zahl.
* FREMDE Dublette -> wird ANGENOMMEN und vermerkt. Zwei Menschen
* können denselben Creator angesprochen haben;
* das ist eine Sache zum Klären, kein Grund,
* jemandem die Eingabe zu verweigern. DogFather
* sieht es in der Übersicht. */
function dublette(personId, monat, werte, ausser = 0) {
const spalte = werte.name_klein ? "name_klein" : (werte.link_klein ? "link_klein" : null);
if (!spalte) return { eigen: false, fremd: null };
const wert = werte.name_klein || werte.link_klein;
const eigen = db().prepare(
`SELECT id FROM mz_eintrag
WHERE person_id = ? AND monat = ? AND aufgabe = ? AND ${spalte} = ? AND id <> ?`)
.get(personId, monat, werte.aufgabe, wert, ausser);
if (eigen) return { eigen: true, fremd: null };
const fremd = db().prepare(
`SELECT e.person_id, p.name FROM mz_eintrag e
LEFT JOIN personen p ON p.id = e.person_id
WHERE e.person_id <> ? AND e.monat = ? AND e.aufgabe = ? AND e.${spalte} = ?
LIMIT 1`).get(personId, monat, werte.aufgabe, wert);
return { eigen: false, fremd: fremd || null };
}
/* =====================================================================
DIE FREIGABE FÜR ABGESCHLOSSENE MONATE
=====================================================================
Nur die Leitung (DogFather und Spicy Media), nur für eine einzelne
Handlung, nur zwei Minuten. Die Vorlage sagt „Vergangene Monate
sind gesperrt und nicht mehr bearbeitbar (Ausnahme: DogFather)";
seit dem 02.10.2026 gilt sie für beide — und der Trigger in der
Datenbank kann keine Sitzung lesen. Also wird die Ausnahme ein
sichtbarer Vorgang: Schloss auf, Handlung, Schloss zu, protokolliert.
`finally` und nicht am Ende des Blocks: Wirft die Handlung, bliebe
das Schloss sonst offen. Eine Sicherung, die bei einem Fehler
aufgeht, ist keine. */
function mitFreigabe(fn) {
const d = db();
const bis = new Date(Date.now() + FREIGABE_SEKUNDEN * 1000).toISOString()
.replace("T", " ").slice(0, 19);
d.prepare("UPDATE mz_freigabe SET offen = 1, bis = ? WHERE eins = 1").run(bis);
try {
return fn();
} finally {
d.prepare("UPDATE mz_freigabe SET offen = 0, bis = NULL WHERE eins = 1").run();
}
}
/* =====================================================================
DIE WEGE
===================================================================== */
/** Der eigene Stand (oder der einer anderen Person, für die Leitung). */
managerZieleRouter.get(`${BASIS}/stand`, (req, res) => {
try {
const monat = istMonat(req.query.monat) ? req.query.monat : monatJetzt();
if (monat < ERSTER_MONAT) {
return res.json({ vorErstemMonat: true, erster: ERSTER_MONAT, monat });
}
monatSichern(monat);
let person = req.person;
if (req.query.person && siehtAlles(req.person)) {
const p = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?")
.get(Number(req.query.person));
if (!p) return res.status(404).json({ fehler: "unbekannt" });
person = p;
}
const stand = standFuer(person, monat);
res.json({
...stand,
person: { id: person.id, name: person.name, rolle: person.rolle,
rollenName: ROLLEN_NAME[person.rolle] || person.rolle },
eigen: person.id === req.person.id,
darfZiele: siehtAlles(req.person),
darfKorrigieren: darfKorrigieren(req.person),
erster: ERSTER_MONAT,
jetztMonat: monatJetzt(),
});
} catch (fehler) {
console.error("[manager-ziele] Stand:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Die Einträge einer Aufgabe in einem Monat. */
managerZieleRouter.get(`${BASIS}/eintraege`, (req, res) => {
try {
const monat = istMonat(req.query.monat) ? req.query.monat : monatJetzt();
const aufgabe = String(req.query.aufgabe || "");
if (!AUFGABE_VON.has(aufgabe)) return res.status(400).json({ fehler: "aufgabe_unbekannt" });
let personId = req.person.id;
if (req.query.person && siehtAlles(req.person)) personId = Number(req.query.person);
const zeilen = db().prepare(
`SELECT e.id, e.datum, e.name, e.link, e.art, e.notiz, e.lead_id,
e.erstellt, e.geaendert, e.doppelt_bei,
p.name AS doppelt_name, l.status AS lead_status, l.name AS lead_name
FROM mz_eintrag e
LEFT JOIN personen p ON p.id = e.doppelt_bei
LEFT JOIN leads l ON l.id = e.lead_id
WHERE e.person_id = ? AND e.monat = ? AND e.aufgabe = ?
ORDER BY e.datum DESC, e.id DESC`).all(personId, monat, aufgabe);
res.json({
monat, aufgabe, eintraege: zeilen,
/* Ob die Liste bearbeitbar ist, entscheidet der Server und nicht
der Browser — sonst wäre der graue Knopf die ganze Sicherung.
ZWEI BEDINGUNGEN, UND DIE ERSTE HATTE GEFEHLT: Es muss die
eigene Liste sein (oder man ist DogFather), UND der Monat
muss offen sein (oder man ist DogFather). Ohne die erste sah
Spicy Media an einer fremden Liste „Bearbeiten" und
„Löschen" stehen, und der Server antwortete mit 403 — ein
Knopf, der nichts tut, ist schlimmer als kein Knopf. */
bearbeitbar: (personId === req.person.id || darfKorrigieren(req.person))
&& (monat === monatJetzt() || darfKorrigieren(req.person)),
});
} catch (fehler) {
console.error("[manager-ziele] Eintraege:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Legt EINEN Eintrag an und gibt zurueck, was daraus wurde.
*
* HERAUSGEZOGEN (02.10.2026), weil es den Weg jetzt zweimal gibt:
* einzeln (Dialog) und als Liste (Schnell-Eintrag). Zwei Fassungen
* derselben Pruefungen waeren zwei Fassungen, von denen eine beim
* naechsten Nachschaerfen zurueckbleibt -- und gerade hier geht es
* um Dublettenschutz und Datumsgrenzen, also genau um das, was
* niemand doppelt pflegen sollte. */
function eintragAnlegen(person, rumpf, monat, heute) {
const gepruefte = eintragPruefen(rumpf, monat, heute);
if (gepruefte.fehler) return { fehler: gepruefte.fehler };
const w = gepruefte.werte;
const doppel = dublette(person.id, monat, w);
if (doppel.eigen) {
return {
fehler: "schon_eingetragen",
text: w.name
? `„${w.name}" steht diesen Monat schon in deiner Liste.`
: "Dieses Video steht diesen Monat schon in deiner Liste.",
};
}
/* Der Lead wird nur uebernommen, wenn er wirklich existiert und zu
dieser Person gehoert. Eine Nummer in der Adresse ist kein
Besitznachweis -- dieselbe Luecke, die am 03.09.2026 bei
`darfCreator` steckte. */
if (w.lead_id) {
const l = db().prepare("SELECT id, scout_id, erstellt_von FROM leads WHERE id = ?")
.get(w.lead_id);
const meiner = l && (l.scout_id === person.id || l.erstellt_von === person.id);
if (!l || (!meiner && !siehtAlles(person))) w.lead_id = null;
}
const info = db().prepare(
`INSERT INTO mz_eintrag
(person_id, von_name, von_rolle, aufgabe, monat, datum, name, name_klein,
link, link_klein, art, notiz, lead_id, doppelt_bei, erstellt)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(
person.id, person.name || null, person.rolle || null,
w.aufgabe, monat, w.datum, w.name, w.name_klein,
w.link, w.link_klein, w.art, w.notiz, w.lead_id,
doppel.fremd?.person_id ?? null, jetzt());
/* ZWEIMAL AM SELBEN TAG -- ein Hinweis, keine Sperre (02.10.2026).
Seit es den Ein-Klick-Weg gibt, ist ein Doppeltipper die
wahrscheinlichste Fehleingabe: Der Knopf „Heute eingetragen"
braucht keine Angabe, also merkt man nichts davon. Zwei Meetings
an einem Tag sind aber moeglich -- es zu VERBIETEN waere falsch.
Also gesagt, was dasteht, und das Zuruecknehmen liegt ohnehin
daneben. Nur fuer die Aufgaben OHNE eigenes Erkennungsmerkmal;
Creator und Link haben ihren Dublettenschutz schon. */
let tageswarnung = null;
if (!w.name_klein && !w.link_klein) {
const schonHeute = db().prepare(
`SELECT COUNT(*) n FROM mz_eintrag
WHERE person_id = ? AND monat = ? AND aufgabe = ? AND datum = ?`)
.get(person.id, monat, w.aufgabe, w.datum)?.n ?? 0;
if (schonHeute > 1) {
tageswarnung = {
art: "zweimal_am_tag",
text: `Achtung: Für diesen Tag stehen jetzt ${schonHeute}. War das Absicht?`,
};
}
}
return {
id: Number(info.lastInsertRowid),
was: w.name || w.link || null,
warnung: doppel.fremd
? { art: "auch_bei", name: doppel.fremd.name,
text: `Hinweis: ${doppel.fremd.name || "jemand anderes"} hat das diesen Monat auch eingetragen.` }
: tageswarnung,
};
}
/* =====================================================================
DER SCHNELL-EINTRAG (02.10.2026)
=====================================================================
Filipe: „will dass es viel einfacher ist. am besten so wenig wie
moeglich zu tippen. fertige sachen, bereit um ab zu gehen."
GEZAEHLT, WAS EIN EINTRAG VORHER KOSTETE: Knopf „+ Eintragen",
Dialog, Knopf „Speichern" -- zwei Klicks und ein Fenster fuer die
Aussage „ich war heute im Meeting". Bei vier Aufgaben im Monat
sind das acht Klicks und acht Fenster fuer etwas, das keine
einzige Angabe braucht.
DIESER WEG NIMMT EINE LISTE. Damit ist „drei Creator auf einmal"
ein Vorgang statt drei, und das ist der eigentliche Gewinn: Wer
drei Namen aus Discord kopiert, hat den Monat in einem Zug
erledigt.
JEDER EINTRAG WIRD EINZELN GEPRUEFT und einzeln beantwortet. Ein
Abbruch der ganzen Liste, weil ein Name schon dasteht, waere die
bequeme und die falsche Loesung: Dann weiss man nicht, welcher der
drei das Problem war, und tippt alles noch einmal.
===================================================================== */
managerZieleRouter.post(`${BASIS}/eintraege`, (req, res) => {
try {
const heute = tagBerlin();
const monat = monatJetzt();
monatSichern(monat);
const vorlage = req.body || {};
const liste = [];
const namen = Array.isArray(vorlage.namen) ? vorlage.namen : null;
const links = Array.isArray(vorlage.links) ? vorlage.links : null;
/* HOECHSTENS ZWANZIG AUF EINMAL. Nicht aus Angst vor Last --
zwanzig Zeilen sind nichts --, sondern weil ein versehentlich
eingefuegter Textblock sonst dreihundert Eintraege erzeugt,
die jemand einzeln wieder loeschen muss. */
const GRENZE = 20;
if (namen) {
for (const n of namen.slice(0, GRENZE)) liste.push({ ...vorlage, name: n });
} else if (links) {
for (const l of links.slice(0, GRENZE)) liste.push({ ...vorlage, link: l });
} else {
liste.push({ ...vorlage });
}
if (!liste.length) return res.status(400).json({ fehler: "nichts_angegeben" });
const angelegt = [], abgelehnt = [], warnungen = [];
for (const einzeln of liste) {
const e = eintragAnlegen(req.person, einzeln, monat, heute);
if (e.fehler) abgelehnt.push({ was: einzeln.name || einzeln.link || null, ...e });
else {
angelegt.push({ id: e.id, was: e.was });
if (e.warnung) warnungen.push(e.warnung);
}
}
/* 201 nur, wenn wirklich etwas entstanden ist. Ein 201 ueber eine
leere Liste waere eine Zusage, die nicht stimmt. */
res.status(angelegt.length ? 201 : 409).json({
angelegt, abgelehnt, warnungen,
zuviel: (namen || links || []).length > GRENZE
? `Nur die ersten ${GRENZE} wurden übernommen.` : null,
stand: standFuer(req.person, monat),
});
} catch (fehler) {
if (String(fehler?.message || "").includes("monat_gesperrt")) {
return res.status(409).json({ fehler: "monat_gesperrt" });
}
console.error("[manager-ziele] Schnell-Eintrag:", fehler?.message);
res.status(503).json({ fehler: "nicht_gespeichert" });
}
});
/** Einen Eintrag anlegen. */
managerZieleRouter.post(`${BASIS}/eintrag`, (req, res) => {
try {
const heute = tagBerlin();
const monat = monatJetzt();
monatSichern(monat);
const e = eintragAnlegen(req.person, req.body, monat, heute);
if (e.fehler) {
const code = e.fehler === "schon_eingetragen" ? 409 : 400;
return res.status(code).json({ fehler: e.fehler, text: e.text });
}
res.status(201).json({
id: e.id,
stand: standFuer(req.person, monat),
warnung: e.warnung,
});
} catch (fehler) {
if (String(fehler?.message || "").includes("monat_gesperrt")) {
return res.status(409).json({ fehler: "monat_gesperrt" });
}
console.error("[manager-ziele] Anlegen:", fehler?.message);
res.status(503).json({ fehler: "nicht_gespeichert" });
}
});
/** Einen Eintrag ändern. */
managerZieleRouter.patch(`${BASIS}/eintrag/:id`, (req, res) => {
try {
const id = Number(req.params.id);
const alt = db().prepare("SELECT * FROM mz_eintrag WHERE id = ?").get(id);
if (!alt) return res.status(404).json({ fehler: "unbekannt" });
const fremd = alt.person_id !== req.person.id;
const darfEr = darfKorrigieren(req.person);
if (fremd && !darfEr) return res.status(403).json({ fehler: "nicht_deins" });
const heute = tagBerlin();
const gepruefte = eintragPruefen({ ...req.body, aufgabe: alt.aufgabe }, alt.monat, heute);
if (gepruefte.fehler) return res.status(400).json({ fehler: gepruefte.fehler });
const w = gepruefte.werte;
const doppel = dublette(alt.person_id, alt.monat, w, id);
if (doppel.eigen) return res.status(409).json({ fehler: "schon_eingetragen" });
const schreiben = () => db().prepare(
`UPDATE mz_eintrag
SET datum = ?, name = ?, name_klein = ?, link = ?, link_klein = ?,
art = ?, notiz = ?, geaendert = ?, geaendert_von = ?
WHERE id = ?`).run(
w.datum, w.name, w.name_klein, w.link, w.link_klein, w.art, w.notiz,
jetzt(), req.person.id, id);
const altMonat = alt.monat !== monatJetzt();
if (altMonat && !darfEr) return res.status(409).json({ fehler: "monat_gesperrt" });
if (altMonat) mitFreigabe(schreiben); else schreiben();
if (fremd || altMonat) {
protokolliere("manager_ziele_korrigiert", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `Eintrag ${id} (${alt.aufgabe}, ${alt.monat}) von Person ${alt.person_id}`,
});
}
const wem = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?")
.get(alt.person_id) || req.person;
res.json({ ok: true, stand: standFuer(wem, alt.monat) });
} catch (fehler) {
if (String(fehler?.message || "").includes("monat_gesperrt")) {
return res.status(409).json({ fehler: "monat_gesperrt" });
}
console.error("[manager-ziele] Aendern:", fehler?.message);
res.status(503).json({ fehler: "nicht_gespeichert" });
}
});
/** Einen Eintrag löschen. */
managerZieleRouter.delete(`${BASIS}/eintrag/:id`, (req, res) => {
try {
const id = Number(req.params.id);
const alt = db().prepare("SELECT * FROM mz_eintrag WHERE id = ?").get(id);
if (!alt) return res.status(404).json({ fehler: "unbekannt" });
const fremd = alt.person_id !== req.person.id;
const darfEr = darfKorrigieren(req.person);
if (fremd && !darfEr) return res.status(403).json({ fehler: "nicht_deins" });
const altMonat = alt.monat !== monatJetzt();
if (altMonat && !darfEr) return res.status(409).json({ fehler: "monat_gesperrt" });
const loeschen = () => db().prepare("DELETE FROM mz_eintrag WHERE id = ?").run(id);
if (altMonat) mitFreigabe(loeschen); else loeschen();
if (fremd || altMonat) {
protokolliere("manager_ziele_geloescht", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `Eintrag ${id} (${alt.aufgabe}, ${alt.monat}) von Person ${alt.person_id}`,
});
}
const wem = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?")
.get(alt.person_id) || req.person;
res.json({ ok: true, stand: standFuer(wem, alt.monat) });
} catch (fehler) {
if (String(fehler?.message || "").includes("monat_gesperrt")) {
return res.status(409).json({ fehler: "monat_gesperrt" });
}
console.error("[manager-ziele] Loeschen:", fehler?.message);
res.status(503).json({ fehler: "nicht_geloescht" });
}
});
/* =====================================================================
DIE BRÜCKE ZUR SCOUT-PIPELINE
===================================================================== */
/** Übergebene Leads dieses Monats, die noch nicht eingetragen sind.
*
* KEIN AUTOMATISMUS. Die Liste schlägt vor, sie zählt nicht. Wer
* einen Lead übernimmt, bestätigt damit die Rekrutierung — und erst
* dann steht sie in der Zahl. Ein Zähler, der sich von selbst füllt,
* ist ein Zähler, dem niemand mehr glaubt. */
managerZieleRouter.get(`${BASIS}/vorschlaege`, (req, res) => {
try {
const monat = monatJetzt();
const zeilen = db().prepare(
`SELECT l.id, l.name, l.handle, l.plattform, l.uebergeben_am
FROM leads l
WHERE l.status = 'uebergeben'
AND l.uebergeben_am IS NOT NULL
AND substr(l.uebergeben_am, 1, 7) = ?
AND (l.scout_id = ? OR l.erstellt_von = ?)
AND NOT EXISTS (SELECT 1 FROM mz_eintrag e
WHERE e.lead_id = l.id AND e.monat = ?)
ORDER BY l.uebergeben_am DESC
LIMIT 25`).all(monat, req.person.id, req.person.id, monat);
res.json({ monat, vorschlaege: zeilen });
} catch (fehler) {
console.error("[manager-ziele] Vorschlaege:", fehler?.message);
res.json({ monat: monatJetzt(), vorschlaege: [] });
}
});
/** Namensvorschläge beim Tippen — aus der eigenen Pipeline. */
managerZieleRouter.get(`${BASIS}/namen`, (req, res) => {
try {
const q = nameSchluessel(req.query.q);
if (q.length < 2) return res.json({ treffer: [] });
const zeilen = db().prepare(
`SELECT id, name, handle, status FROM leads
WHERE (scout_id = ? OR erstellt_von = ?)
AND (replace(replace(lower(name), ' ', ''), '@', '') LIKE ?
OR replace(replace(lower(COALESCE(handle,'')), ' ', ''), '@', '') LIKE ?)
ORDER BY erstellt DESC LIMIT 8`)
.all(req.person.id, req.person.id, `%${q}%`, `%${q}%`);
res.json({ treffer: zeilen });
} catch (fehler) {
console.error("[manager-ziele] Namen:", fehler?.message);
res.json({ treffer: [] });
}
});
/* =====================================================================
DER VERLAUF
===================================================================== */
managerZieleRouter.get(`${BASIS}/verlauf`, (req, res) => {
try {
let person = req.person;
if (req.query.person && siehtAlles(req.person)) {
const p = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?")
.get(Number(req.query.person));
if (!p) return res.status(404).json({ fehler: "unbekannt" });
person = p;
}
const jetztM = monatJetzt();
const start = startMonatFuer(person.id);
const von = start > ERSTER_MONAT ? start : ERSTER_MONAT;
const monate = [];
let m = monatPlus(jetztM, -1);
/* Höchstens 24 Monate zurück — ein Verlauf, der endlos scrollt,
wird nicht gelesen. Die Daten bleiben trotzdem alle da. */
for (let i = 0; i < 24 && m >= von; i++, m = monatPlus(m, -1)) {
const s = standFuer(person, m);
monate.push({
monat: m,
gesamt: s.gesamt,
aufgaben: s.aufgaben.map((a) => ({
schluessel: a.schluessel, kurz: a.kurz,
zahl: a.zahl, ziel: a.ziel,
erreicht: a.ziel === 0 ? null : a.zahl >= a.ziel,
})),
});
}
res.json({ monate, von, person: { id: person.id, name: person.name } });
} catch (fehler) {
console.error("[manager-ziele] Verlauf:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* =====================================================================
DIE TEAM-ÜBERSICHT — nur DogFather und Spicy Media
===================================================================== */
/** Alle pflichtigen Personen mit ihrem Stand in einem Monat. */
function teamStand(monat) {
const platzhalter = PFLICHT_ROLLEN.map(() => "?").join(",");
/* WER IN DIESER LISTE STEHT -- und warum es nicht nur die aktiven
vier Rollen sind.
Aus der Vorlage: „Wer die Rolle verliert, sieht die Kachel nicht
mehr; die Daten bleiben fuer den DogFather erhalten." Eine
Abfrage nur auf `aktiv = 1 AND rolle IN (...)` haette genau das
nicht geleistet: Wer im Oktober drei Creator gebracht hat und im
November Creator wird, waere aus dem Oktober verschwunden -- aus
einem abgeschlossenen Monat, den niemand mehr aendern darf.
Deshalb zweiteilig: die heute Pflichtigen UND jeder, der in
diesem Monat etwas eingetragen hat. Und fuer die zweite Gruppe
zaehlt die Rolle VON DAMALS (`von_rolle` am Eintrag), nicht die
von heute -- sonst wuerde ein abgeschlossener Monat rueckwirkend
an den Zielzahlen einer anderen Rolle gemessen. */
const leute = db().prepare(
`SELECT p.id, p.name, p.rolle, p.aktiv,
(SELECT e.von_rolle FROM mz_eintrag e
WHERE e.person_id = p.id AND e.monat = ? AND e.von_rolle IS NOT NULL
LIMIT 1) AS rolle_damals
FROM personen p
WHERE (p.aktiv = 1 AND p.rolle IN (${platzhalter}))
OR EXISTS (SELECT 1 FROM mz_eintrag e
WHERE e.person_id = p.id AND e.monat = ?)
ORDER BY p.rolle, p.name COLLATE NOCASE`)
.all(monat, ...PFLICHT_ROLLEN, monat);
const reihen = leute.map((p) => {
const heutePflichtig = p.aktiv === 1 && PFLICHT_ROLLEN.includes(p.rolle);
const rolle = heutePflichtig ? p.rolle : (p.rolle_damals || p.rolle);
const s = standFuer({ id: p.id, name: p.name, rolle }, monat);
return {
id: p.id, name: p.name, rolle,
rollenName: ROLLEN_NAME[rolle] || rolle,
/* „Nicht mehr dabei" ist etwas anderes als „neu und noch ohne
Pflicht". Beides getrennt zu nennen erspart die Rueckfrage,
warum jemand mit 0/3 nicht rot ist. */
ehemalig: !heutePflichtig,
pflichtig: s.pflichtig,
stufe: s.stufe,
gesamt: s.gesamt,
aufgaben: s.aufgaben.map((a) => ({
schluessel: a.schluessel, zahl: a.zahl, ziel: a.ziel, stufe: a.stufe,
})),
};
});
/* EINE GELOESCHTE PERSON hat keine Zeile in `personen` mehr, ihre
Eintraege aber schon (person_id ist dann NULL). Sie als eigene,
zusammengefasste Zeile zu zeigen ist ehrlicher, als die Zahlen
verschwinden zu lassen -- im Monatsverlauf fehlten sonst
Eintraege, die es wirklich gab. */
const verwaist = db().prepare(
`SELECT COALESCE(von_name, 'Gelöschter Zugang') AS name,
COALESCE(von_rolle, 'scout') AS rolle,
aufgabe, COUNT(*) AS n
FROM mz_eintrag
WHERE person_id IS NULL AND monat = ?
GROUP BY name, rolle, aufgabe`).all(monat);
if (verwaist.length) {
const nachName = new Map();
for (const v of verwaist) {
const schluessel = `${v.name}|${v.rolle}`;
if (!nachName.has(schluessel)) nachName.set(schluessel, {});
nachName.get(schluessel)[v.aufgabe] = v.n;
}
for (const [schluessel, zahlen] of nachName) {
const [name, rolle] = schluessel.split("|");
const aufgaben = AUFGABEN.map((a) => {
const ziel = zielFuer(monat, rolle, a.schluessel);
const zahl = zahlen[a.schluessel] || 0;
return { schluessel: a.schluessel, zahl, ziel, stufe: zahl >= ziel && ziel > 0 ? "gruen" : "aus" };
});
reihen.push({
id: null, name, rolle, rollenName: ROLLEN_NAME[rolle] || rolle,
ehemalig: true, pflichtig: false, stufe: "aus",
gesamt: {
erledigt: aufgaben.reduce((x, a) => x + Math.min(a.zahl, a.ziel), 0),
ziel: aufgaben.reduce((x, a) => x + a.ziel, 0),
anteil: 100, offen: 0,
},
aufgaben,
});
}
}
return reihen;
}
managerZieleRouter.get(`${BASIS}/team`, nurLeitung, (req, res) => {
try {
const monat = istMonat(req.query.monat) ? req.query.monat : monatJetzt();
if (monat < ERSTER_MONAT) return res.json({ monat, leute: [], erster: ERSTER_MONAT });
monatSichern(monat);
res.json({
monat, erster: ERSTER_MONAT, jetztMonat: monatJetzt(),
leute: teamStand(monat),
aufgaben: AUFGABEN.map((a) => ({ schluessel: a.schluessel, kurz: a.kurz, name: a.name })),
});
} catch (fehler) {
console.error("[manager-ziele] Team:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Der Monat als CSV.
*
* SEMIKOLON UND BOM, und beides aus demselben Grund: Ein deutsches
* Excel trennt an Semikolons und erkennt UTF-8 nur mit Vorzeichen.
* Eine Datei mit Komma und ohne BOM öffnet sich als eine einzige
* Spalte voller „ü" — technisch richtig und praktisch unbrauchbar. */
managerZieleRouter.get(`${BASIS}/export.csv`, nurLeitung, (req, res) => {
try {
const monat = istMonat(req.query.monat) ? req.query.monat : monatJetzt();
monatSichern(monat);
const leute = teamStand(monat);
const feld = (w) => {
const t = String(w ?? "");
return /[";\n\r]/.test(t) ? `"${t.replace(/"/g, '""')}"` : t;
};
const kopf = ["Name", "Rolle", "Stand",
...AUFGABEN.flatMap((a) => [`${a.kurz} erreicht`, `${a.kurz} Ziel`]),
"Gesamt erledigt", "Gesamt Ziel", "Ampel"];
const zeilen = [kopf.map(feld).join(";")];
for (const p of leute) {
/* DREI STAENDE, NICHT ZWEI. „nein" hiesse sonst dasselbe fuer
jemanden, der gerade erst dazugekommen ist, und fuer jemanden,
der nicht mehr dabei ist -- und wer die Liste spaeter liest,
kann die beiden nicht mehr auseinanderhalten. */
const stand = p.ehemalig ? "nicht mehr dabei"
: (p.pflichtig ? "pflichtig" : "neu, noch ohne Pflicht");
zeilen.push([
p.name, p.rollenName, stand,
...p.aufgaben.flatMap((a) => [a.zahl, a.ziel]),
p.gesamt.erledigt, p.gesamt.ziel, p.stufe,
].map(feld).join(";"));
}
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader("Content-Disposition",
`attachment; filename="manager-ziele-${monat}.csv"`);
res.send("" + zeilen.join("\r\n") + "\r\n");
} catch (fehler) {
console.error("[manager-ziele] Export:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* =====================================================================
DIE ZIELZAHLEN
===================================================================== */
managerZieleRouter.get(`${BASIS}/ziele`, nurLeitung, (req, res) => {
try {
const jetztM = monatJetzt();
const naechster = monatPlus(jetztM, 1);
monatSichern(jetztM);
const bauen = (monat) => PFLICHT_ROLLEN.map((rolle) => ({
rolle, rollenName: ROLLEN_NAME[rolle] || rolle,
ziele: Object.fromEntries(AUFGABEN.map((a) =>
[a.schluessel, monat === jetztM
? zielFuer(monat, rolle, a.schluessel)
: zielGeltend(monat, rolle, a.schluessel)])),
}));
res.json({
jetztMonat: jetztM, naechsterMonat: naechster,
aufgaben: AUFGABEN.map((a) => ({
schluessel: a.schluessel, name: a.name, kurz: a.kurz, vorgabe: a.vorgabe,
})),
jetzt: bauen(jetztM),
naechster: bauen(naechster),
});
} catch (fehler) {
console.error("[manager-ziele] Ziele lesen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Ziele setzen — IMMER für den nächsten Monat.
*
* Aus der Vorlage: „Änderungen gelten ab dem nächsten Monat." Das ist
* hier keine Anzeige, sondern der Weg selbst: Der laufende Monat ist
* bereits festgeschrieben, geschrieben wird in den nächsten. Es gibt
* gar keinen Aufruf, mit dem man den laufenden Monat umschreiben
* könnte — eine Regel, die man nicht umgehen kann, braucht keine
* Disziplin. */
managerZieleRouter.put(`${BASIS}/ziele`, nurLeitung, (req, res) => {
try {
const naechster = monatPlus(monatJetzt(), 1);
const eingabe = req.body?.ziele;
if (!eingabe || typeof eingabe !== "object") {
return res.status(400).json({ fehler: "nichts_angegeben" });
}
const setzen = db().prepare(
`INSERT INTO mz_ziel (monat, rolle, aufgabe, ziel, gesetzt_von, gesetzt_am)
VALUES (?,?,?,?,?,?)
ON CONFLICT(monat, rolle, aufgabe)
DO UPDATE SET ziel = excluded.ziel, gesetzt_von = excluded.gesetzt_von,
gesetzt_am = excluded.gesetzt_am`);
const am = jetzt();
let gesetzt = 0;
for (const rolle of PFLICHT_ROLLEN) {
const je = eingabe[rolle];
if (!je || typeof je !== "object") continue;
for (const a of AUFGABEN) {
if (!(a.schluessel in je)) continue;
const wert = Number(je[a.schluessel]);
if (!Number.isInteger(wert) || wert < 0 || wert > 99) {
return res.status(400).json({ fehler: "ziel_ungueltig", rolle, aufgabe: a.schluessel });
}
setzen.run(naechster, rolle, a.schluessel, wert, req.person.id, am);
gesetzt++;
}
}
if (!gesetzt) return res.status(400).json({ fehler: "nichts_angegeben" });
protokolliere("manager_ziele_gesetzt", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `${gesetzt} Zielzahlen für ${naechster}`,
});
res.json({ ok: true, monat: naechster, gesetzt });
} catch (fehler) {
console.error("[manager-ziele] Ziele setzen:", fehler?.message);
res.status(503).json({ fehler: "nicht_gespeichert" });
}
});
/* =====================================================================
DIE HINWEISE FÜR DIE STARTSEITE
=====================================================================
Liefert, was `workspace-hinweise.js` braucht — und zwar aus
derselben Rechnung wie die Seite selbst (`standFuer`). Die Zahl auf
der Kachel ist damit kein zweiter Zähler, sondern dieselbe Auskunft
an einer anderen Stelle.
===================================================================== */
export function offeneHinweise(person) {
try {
if (!person || person.haus === "crew") return [];
if (!PFLICHT_ROLLEN.includes(person.rolle)) return [];
const monat = monatJetzt();
if (monat < ERSTER_MONAT) return [];
lageSetzen(monat);
monatSichern(monat);
const stand = standFuer(person, monat);
if (!stand.pflichtig || !stand.gesamt.offen) return [];
/* Zwei Hinweise, nicht einer: Eine Warnung steht im Haus immer
oben und ist etwas anderes als „liegt noch an". Wer am 3. des
Monats dieselbe rote Zeile sieht wie am 29., liest sie beim
zweiten Mal nicht mehr. */
const dringend = stand.aufgaben.filter((a) => a.stufe === "rot").length;
const offen = stand.gesamt.offen - dringend;
const raus = [];
if (dringend) {
raus.push({ art: "mz_dringend", stufe: "warnung",
text: "dringend", ziel: "manager-ziele.html", anzahl: dringend });
}
if (offen > 0) {
raus.push({ art: "mz_offen", stufe: "offen",
text: "offen", ziel: "manager-ziele.html", anzahl: offen });
}
return raus;
} catch (fehler) {
console.error("[manager-ziele] Hinweise:", fehler?.message);
return [];
}
}
/* =====================================================================
DIE ERINNERUNGEN
=====================================================================
Diese Funktion VERSCHICKT NICHTS. Sie sagt nur, was heute anstünde;
verschickt wird im Takt von workspace-push.js (siehe Kopf dieser
Datei: kein Importkreis).
DER ANLASS-PLAN kommt wörtlich aus der Vorlage. Dass pro Tag
höchstens EINE Meldung herausgeht, steckt im Merkmal: Es enthält
den Tag, und `benachrichtige` schickt ein Merkmal nie zweimal.
Fallen zwei Anlässe auf denselben Tag (etwa der 1. eines Monats,
der zugleich drei Tage vor Monatsende läge — es gibt keinen solchen
Monat, aber die Rechnung soll auch dann stimmen), gewinnt der
dringendere, und der andere entfällt.
===================================================================== */
export function zielRufe(d = new Date()) {
try {
const heute = tagBerlin(d);
const monat = heute.slice(0, 7);
if (monat < ERSTER_MONAT) return [];
lageSetzen(monat);
monatSichern(monat);
const tag = Number(heute.slice(8, 10));
const letzter = letzterTagVon(monat);
const bisEnde = letzter - tag;
const leute = db().prepare(
`SELECT id, name, rolle FROM personen
WHERE aktiv = 1 AND rolle IN (${PFLICHT_ROLLEN.map(() => "?").join(",")})`)
.all(...PFLICHT_ROLLEN);
const rufe = [];
for (const p of leute) {
const stand = standFuer(p, monat, heute);
if (!stand.pflichtig) continue;
/* ---- Alles geschafft: einmal gratulieren, dann Ruhe ---------- */
if (!stand.gesamt.offen) {
if (stand.gesamt.ziel > 0) {
rufe.push({
personId: p.id, art: "manager_ziele",
titel: "Alles geschafft 🎉",
text: `Du hast diesen Monat alle ${stand.gesamt.ziel} Ziele erreicht. Danke!`,
/* ABSOLUT, nicht relativ: Push-Ziele gehen durch
`zielFuer()` und werden mit dem Pfad verglichen. Die
Hinweise auf der Startseite setzen das "/workspace/"
dagegen selbst davor -- deshalb steht es dort nicht. */
ziel: "/workspace/manager-ziele.html",
merkmal: `mz:${monat}:fertig`,
});
}
continue;
}
/* ---- Was genau fehlt, als Satz ------------------------------- */
const fehlt = stand.aufgaben.filter((a) => a.ziel > 0 && a.zahl < a.ziel)
.map((a) => {
const n = a.ziel - a.zahl;
return `${n} ${n === 1 ? a.einheit[0] : a.einheit[1]}`;
});
const liste = fehlt.length === 1 ? fehlt[0]
: fehlt.slice(0, -1).join(", ") + " und " + fehlt[fehlt.length - 1];
let titel = null;
if (bisEnde <= 2) {
titel = bisEnde <= 0 ? "Letzter Tag des Monats" : `Noch ${bisEnde} Tag${bisEnde === 1 ? "" : "e"}`;
} else if (bisEnde === 7) {
titel = "Dringend – noch 7 Tage";
} else if (tag === 16) {
titel = "Monatsmitte ist vorbei";
} else if (tag === 15) {
titel = "Heute ist Stichtag Monatsmitte";
} else if (tag === 10) {
titel = "Kurze Erinnerung";
} else if (tag === 1) {
titel = "Neuer Monat";
}
if (!titel) continue;
const text = tag === 1
? "Deine Aufgaben starten bei 0. Viel Erfolg!"
: `Dir fehlt noch ${liste}.`;
rufe.push({
personId: p.id, art: "manager_ziele", titel, text,
ziel: "/workspace/manager-ziele.html",
/* DER TAG STEHT IM MERKMAL, nicht der Anlass: So kann pro Tag
nur eine einzige Meldung herausgehen — genau das verlangt
die Vorlage. */
merkmal: `mz:${heute}`,
zahl: stand.gesamt.offen,
});
}
return rufe;
} catch (fehler) {
console.error("[manager-ziele] Rufe:", fehler?.message);
return [];
}
}