diff --git a/server/index.js b/server/index.js
index 99060e90..abbbbbc9 100644
--- a/server/index.js
+++ b/server/index.js
@@ -71,6 +71,7 @@ import { anrufRouter } from "./workspace-anruf.js";
import { videoRouter, videoWacheStarten } from "./workspace-video.js";
import { bewerbungRouter } from "./workspace-bewerbung.js";
import { leistungRouter } from "./workspace-leistung.js";
+import { managerZieleRouter } from "./workspace-manager-ziele.js";
import { fruehwarnungRouter } from "./workspace-fruehwarnung.js";
import { icsRouter } from "./workspace-ics.js";
@@ -349,6 +350,12 @@ app.use(unterstuetzungRouter);
app.use(bewerbungRouter);
app.use(reportRouter);
app.use(scoutRouter);
+/* Manager-Ziele (02.10.2026): die vier festen Monatsaufgaben. Steht
+ hinter dem Scout-CRM, weil es dessen Pipeline mitbenutzt -- die
+ Reihenfolge der Router entscheidet hier zwar nichts (die Wege
+ ueberschneiden sich nicht), aber wer suchen muss, sucht sie
+ nebeneinander. */
+app.use(managerZieleRouter);
app.use(callRouter);
app.use(hinweisRouter);
app.use(zentraleRouter);
diff --git a/server/manager-ziele-tabellen.js b/server/manager-ziele-tabellen.js
new file mode 100644
index 00000000..8a7a94ef
--- /dev/null
+++ b/server/manager-ziele-tabellen.js
@@ -0,0 +1,320 @@
+/* =====================================================================
+ MANAGER-ZIELE — die Tabellen (02.10.2026)
+ =====================================================================
+
+ Vier feste Monatsaufgaben für Scouts, Manager, DogFather und Spicy
+ Media: Creator rekrutieren, Manager-Meeting, Schulung oder Community
+ Talk, Werbung auf TikTok. Jede mit einer Zielzahl, einer Ampel und
+ einem Monatsschnitt.
+
+ WARUM EINE EIGENE DATEI: Die Tabellen hängen an `personen` (CASCADE)
+ und an `leads`. Beide entstehen im Bauplan von workspace.js, und
+ workspace-manager-ziele.js importiert AUS workspace.js — stünden die
+ Tabellen dort, wäre das ein Importkreis. Über einen Kreis kommen
+ Konstanten als `undefined` an, ohne dass irgendwo ein Fehler
+ erscheint; hilfe-tabellen.js, support-tabellen.js und
+ notiz-tabellen.js stehen aus demselben Grund getrennt.
+
+ ---------------------------------------------------------------------
+ DREI ENTSCHEIDUNGEN, DIE MAN SPÄTER NICHT MEHR BILLIG ÄNDERN KANN
+
+ 1. DAS ZIEL WIRD PRO MONAT EINGEFROREN, nicht als eine laufende
+ Einstellung geführt.
+
+ Filipe wollte die Zielzahlen änderbar haben, wirksam ab dem
+ nächsten Monat. Läge nur EIN aktueller Wert in `einstellungen`,
+ dann schriebe jede spätere Änderung rückwirkend den gesamten
+ Verlauf um: Ein Monat, der mit 2/2 als „erreicht" abgeschlossen
+ war, stünde nach einer Erhöhung auf 4 plötzlich als „nicht
+ erreicht" da — ohne dass jemand etwas getan hätte. Ein Verlauf,
+ der sich rückwirkend ändert, ist kein Verlauf.
+
+ Deshalb: `mz_ziel` hat den Monat im Schlüssel. Beim ersten
+ Zugriff auf einen Monat wird er festgeschrieben (siehe
+ `monatSichern` im Fachmodul), und ab da steht er fest.
+
+ 2. DIE SPERRE VERGANGENER MONATE SITZT IN DER DATENBANK, nicht nur
+ im Code.
+
+ Dieselbe Überlegung wie bei workspace-schulung.js: „Ein Schutz,
+ der nur im Code steht, ist beim nächsten neuen Weg zur Datenbank
+ wieder weg." Drei Trigger lehnen jede Änderung an einem
+ abgeschlossenen Monat ab.
+
+ DIE AUSNAHME FÜR DOGFATHER LÄSST SICH IN SQLITE NICHT ÜBER DIE
+ SITZUNG ABFRAGEN — es gibt keine Sitzungsvariablen. Deshalb
+ `mz_freigabe`: eine einzige Zeile, die seine Route INNERHALB
+ derselben Transaktion auf 1 setzt und danach wieder auf 0. Das
+ ist ehrlicher als ein Trigger, den man mit einem Kommentar
+ „gilt nicht für den Chef" einfach weglässt — die Ausnahme ist
+ damit ein sichtbarer, zählbarer Vorgang und kein Loch.
+
+ WARUM DIE FREIGABE NICHT VERGESSEN WERDEN KANN: Sie wird im
+ `finally` einer synchronen Transaktion zurückgesetzt, und
+ `mz_freigabe.bis` trägt zusätzlich einen Zeitpunkt. Was älter
+ als zwei Minuten ist, gilt als nicht freigegeben — ein
+ vergessenes Schloss wäre sonst dasselbe wie gar keines
+ (dieselbe Erfahrung wie beim Arbeitsschloss in der Buchhaltung).
+
+ 3. DER AKTUELLE MONAT STEHT IN EINER TABELLE, nicht in `strftime`.
+
+ Ein Trigger müsste sonst `strftime('%Y-%m','now','localtime')`
+ rechnen — und `localtime` ist die Zeitzone des SERVERS. Steht der
+ auf UTC, wäre am 1. eines Monats zwischen 00:00 und 02:00 Uhr
+ deutscher Zeit noch der Vormonat „aktuell", und die Sperre
+ griffe eine Stunde zu früh oder zu spät. `mz_lage` hält den
+ Monat, den die Anwendung selbst in Europa/Berlin ausrechnet.
+ ===================================================================== */
+
+/** Die vier Aufgaben. Reihenfolge = Anzeigereihenfolge.
+ *
+ * `feld` sagt, welche Zusatzangabe die Aufgabe braucht — daraus
+ * entstehen Formular, Prüfung und Dublettenschutz, damit die Liste
+ * nicht an vier Stellen getrennt gepflegt werden muss. Eine Liste,
+ * die niemand pflegt, kann nicht veralten. */
+export const AUFGABEN = [
+ {
+ schluessel: "creator",
+ name: "Creator rekrutieren",
+ kurz: "Creator",
+ einheit: ["Creator", "Creator"],
+ vorgabe: 3,
+ feld: "name",
+ hinweis: "TikTok-Name des Creators",
+ },
+ {
+ schluessel: "meeting",
+ name: "Manager Meeting über Discord",
+ kurz: "Meeting",
+ einheit: ["Manager Meeting", "Manager Meetings"],
+ vorgabe: 2,
+ feld: null,
+ hinweis: "Nur Datum und auf Wunsch eine Notiz",
+ },
+ {
+ schluessel: "schulung",
+ name: "Schulung oder Community Talk",
+ kurz: "Schulung",
+ einheit: ["Schulung oder Talk", "Schulungen oder Talks"],
+ vorgabe: 2,
+ feld: "art",
+ arten: [
+ { wert: "schulung", name: "Schulung" },
+ { wert: "talk", name: "Community Talk" },
+ ],
+ hinweis: "Über Discord",
+ },
+ {
+ schluessel: "werbung",
+ name: "Werbung auf TikTok hochladen",
+ kurz: "Werbung",
+ einheit: ["Werbe-Video", "Werbe-Videos"],
+ vorgabe: 1,
+ feld: "link",
+ arten: [
+ { wert: "agentur", name: "Für die Agentur" },
+ { wert: "eigen", name: "Eigenwerbung" },
+ { wert: "beides", name: "Beides" },
+ ],
+ hinweis: "Link zum Video",
+ },
+];
+
+export const AUFGABEN_SCHLUESSEL = AUFGABEN.map((a) => a.schluessel);
+
+/** Wer die vier Aufgaben erfüllen muss.
+ *
+ * Filipe am 02.10.2026 auf die Frage, wer eigene Zähler hat: „Alle
+ * vier Rollen." Creator stehen bewusst NICHT dabei — sie bekommen
+ * weder die Kachel noch eine Erinnerung. Modis und die Community
+ * kommen hier gar nicht vor: Sie gehören zum Haus Team Dogi, und die
+ * beiden Häuser sind seit dem 24.09.2026 getrennt. */
+export const PFLICHT_ROLLEN = ["spicy", "admin", "manager", "scout"];
+
+/** Wer die Zielzahlen ändern darf.
+ *
+ * Filipe: „dogfather und spicy rolle sollen aber gleichzeitig es
+ * immer ändern können für alle." Das ist dieselbe Menge wie
+ * `siehtAlles()` in workspace.js — sie wird dort abgefragt und nicht
+ * hier abgeschrieben, damit es nicht zwei Mengen gibt, die
+ * auseinanderlaufen. Diese Zeile steht nur als Erinnerung daran, dass
+ * es eine bewusste Entscheidung war und keine Nebenwirkung. */
+
+/** Der erste Monat, den es überhaupt gibt.
+ *
+ * Aus der Vorlage: „Der erste gültige Monat ist Oktober 2026; davor
+ * gibt es keine Daten und keine Warnungen." Steht hier und nicht
+ * dreimal im Fachmodul. */
+export const ERSTER_MONAT = "2026-10";
+
+/** Wie lange eine Freigabe für einen abgeschlossenen Monat gilt.
+ *
+ * Zwei Minuten. Lang genug für jede Korrektur, kurz genug, dass ein
+ * vergessenes Zurücksetzen keine dauerhaft offene Tür ist. */
+export const FREIGABE_SEKUNDEN = 120;
+
+export function managerZieleTabellen(d) {
+ d.exec(`
+ /* ---------- Ein Eintrag --------------------------------------- */
+ CREATE TABLE IF NOT EXISTS mz_eintrag (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ /* CASCADE und nicht SET NULL: Ein Eintrag ohne Person ist in
+ dieser Tabelle sinnlos — gezählt wird je Person. Was bleiben
+ soll, wenn jemand geht, ist die Pipeline und das Protokoll,
+ nicht sein Monatszähler. */
+ person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
+ aufgabe TEXT NOT NULL
+ CHECK (aufgabe IN ('creator','meeting','schulung','werbung')),
+ /* 'JJJJ-MM'. Der Monatsschnitt entsteht über diese Spalte und
+ NICHT über das Löschen alter Zeilen -- aus der Vorlage:
+ „Löse das über die Zuordnung der Einträge zu Monat/Jahr,
+ nicht über das Löschen von Daten." */
+ monat TEXT NOT NULL CHECK (length(monat) = 7),
+ datum TEXT NOT NULL CHECK (length(datum) = 10),
+ /* Der TikTok-Name bei 'creator'. Zusätzlich normalisiert in
+ name_klein, damit der Dublettenschutz nicht an einem
+ Großbuchstaben oder einem führenden @ scheitert. Zwei Spalten
+ statt einer Rechnung beim Suchen: So kann ein Index darauf
+ liegen. */
+ name TEXT,
+ name_klein TEXT,
+ /* Der Link bei 'werbung', ebenfalls mit normalisierter Fassung.
+ Filipe am 02.10.2026: jede gültige Internetadresse, nicht nur
+ TikTok. */
+ link TEXT,
+ link_klein TEXT,
+ /* schulung|talk bzw. agentur|eigen|beides */
+ art TEXT,
+ notiz TEXT,
+ /* DIE BRÜCKE ZUR SCOUT-PIPELINE. Darf leer sein: Nicht jede
+ Rekrutierung läuft über einen Lead, und ein Pflichtfeld hier
+ würde den Weg von Hand unmöglich machen. SET NULL, weil ein
+ gelöschter Lead den Monatseintrag nicht entwerten darf — die
+ Rekrutierung hat ja stattgefunden. */
+ lead_id INTEGER REFERENCES leads(id) ON DELETE SET NULL,
+ /* Ob jemand anders denselben Creator bzw. denselben Link in
+ diesem Monat schon eingetragen hatte. Kein Grund abzulehnen
+ (zwei Menschen können denselben Creator ansprechen), aber
+ DogFather soll es in der Übersicht sehen können. */
+ doppelt_bei INTEGER REFERENCES personen(id) ON DELETE SET NULL,
+ erstellt TEXT NOT NULL,
+ geaendert TEXT,
+ geaendert_von INTEGER REFERENCES personen(id) ON DELETE SET NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_mz_person_monat
+ ON mz_eintrag (person_id, monat, aufgabe);
+ CREATE INDEX IF NOT EXISTS idx_mz_monat ON mz_eintrag (monat);
+ CREATE INDEX IF NOT EXISTS idx_mz_name ON mz_eintrag (monat, name_klein);
+ CREATE INDEX IF NOT EXISTS idx_mz_link ON mz_eintrag (monat, link_klein);
+ CREATE INDEX IF NOT EXISTS idx_mz_lead ON mz_eintrag (lead_id);
+
+ /* ---------- Das Ziel, eingefroren je Monat und Rolle ----------- */
+ CREATE TABLE IF NOT EXISTS mz_ziel (
+ monat TEXT NOT NULL CHECK (length(monat) = 7),
+ rolle TEXT NOT NULL,
+ aufgabe TEXT NOT NULL
+ CHECK (aufgabe IN ('creator','meeting','schulung','werbung')),
+ /* 0 heißt ausdrücklich „für diese Rolle nicht gefordert" und
+ nicht „vergessen". Eine Zeile mit 0 erzeugt keine Warnung und
+ zählt nicht in den Gesamtfortschritt. */
+ ziel INTEGER NOT NULL CHECK (ziel >= 0 AND ziel <= 99),
+ gesetzt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
+ gesetzt_am TEXT NOT NULL,
+ PRIMARY KEY (monat, rolle, aufgabe)
+ );
+
+ /* ---------- Ab wann jemand pflichtig ist ----------------------- */
+ /* Aus der Vorlage: „Wer erst im Laufe des Monats eine der Rollen
+ bekommt, erhält in diesem Monat keine Warnungen; die Pflicht
+ beginnt im Folgemonat."
+
+ EINMAL GEMESSEN UND GESPEICHERT statt bei jeder Abfrage aus dem
+ Protokoll gerechnet. Das Protokoll ist ein Log: Es wird
+ irgendwann aufgeräumt, und dann änderte sich rückwirkend, ab
+ wann jemand pflichtig war. Ein Fundament darf nicht aus etwas
+ bestehen, das ein Aufräumlauf kürzen darf. */
+ CREATE TABLE IF NOT EXISTS mz_start (
+ person_id INTEGER PRIMARY KEY REFERENCES personen(id) ON DELETE CASCADE,
+ erster_monat TEXT NOT NULL CHECK (length(erster_monat) = 7),
+ gesetzt_am TEXT NOT NULL,
+ /* Woraus die Zahl entstanden ist — damit man es später
+ nachvollziehen kann, ohne das Protokoll zu durchsuchen. */
+ grund TEXT
+ );
+
+ /* ---------- Welcher Monat gerade läuft ------------------------- */
+ CREATE TABLE IF NOT EXISTS mz_lage (
+ eins INTEGER PRIMARY KEY CHECK (eins = 1),
+ monat TEXT NOT NULL
+ );
+ INSERT OR IGNORE INTO mz_lage (eins, monat) VALUES (1, '0000-00');
+
+ /* ---------- Die Freigabe für abgeschlossene Monate ------------- */
+ CREATE TABLE IF NOT EXISTS mz_freigabe (
+ eins INTEGER PRIMARY KEY CHECK (eins = 1),
+ offen INTEGER NOT NULL DEFAULT 0 CHECK (offen IN (0, 1)),
+ bis TEXT
+ );
+ INSERT OR IGNORE INTO mz_freigabe (eins, offen, bis) VALUES (1, 0, NULL);
+ `);
+
+ /* ---------- Die Sperre ------------------------------------------
+ Drei Trigger, einer je Richtung. Sie stehen hier und nicht in
+ einer Umstellung: Sie gehören zur Tabelle wie ihre Spalten.
+
+ DIE BEDINGUNG IST DREITEILIG und jeder Teil hat einen Grund:
+ * der Monat des Eintrags liegt vor dem laufenden
+ * die Freigabe steht nicht auf 1
+ * ODER die Freigabe ist abgelaufen (`bis` liegt in der
+ Vergangenheit) -- eine vergessene Freigabe ist damit nach
+ zwei Minuten von selbst wieder zu.
+
+ `datetime('now')` ist hier unbedenklich, obwohl die Zeitzone des
+ Servers unbekannt ist: Verglichen wird ein UTC-Zeitstempel mit
+ einem UTC-Zeitstempel (die Anwendung schreibt `toISOString()`),
+ und beide wandern gemeinsam. Beim MONAT wäre genau das nicht so --
+ deshalb steht er in `mz_lage`.
+ --------------------------------------------------------------- */
+ d.exec(`
+ CREATE TRIGGER IF NOT EXISTS mz_kein_alter_insert
+ BEFORE INSERT ON mz_eintrag
+ WHEN NEW.monat <> (SELECT monat FROM mz_lage WHERE eins = 1)
+ AND NOT (SELECT offen FROM mz_freigabe WHERE eins = 1)
+ BEGIN
+ SELECT RAISE(ABORT, 'monat_gesperrt');
+ END;
+
+ CREATE TRIGGER IF NOT EXISTS mz_kein_alter_update
+ BEFORE UPDATE ON mz_eintrag
+ WHEN OLD.monat <> (SELECT monat FROM mz_lage WHERE eins = 1)
+ AND NOT ((SELECT offen FROM mz_freigabe WHERE eins = 1)
+ AND (SELECT bis FROM mz_freigabe WHERE eins = 1) > datetime('now'))
+ BEGIN
+ SELECT RAISE(ABORT, 'monat_gesperrt');
+ END;
+
+ CREATE TRIGGER IF NOT EXISTS mz_kein_alter_delete
+ BEFORE DELETE ON mz_eintrag
+ WHEN OLD.monat <> (SELECT monat FROM mz_lage WHERE eins = 1)
+ AND NOT ((SELECT offen FROM mz_freigabe WHERE eins = 1)
+ AND (SELECT bis FROM mz_freigabe WHERE eins = 1) > datetime('now'))
+ BEGIN
+ SELECT RAISE(ABORT, 'monat_gesperrt');
+ END;
+ `);
+
+ /* ---------- Nachrüstung für Bestände ohne die neuen Spalten -------
+ ABGELEITET, NICHT ABGESCHRIEBEN. Am 11.09.2026 hat im Creator
+ Workspace eine von Hand gepflegte Spaltenliste beim Tabellenumbau
+ drei Spalten mit Inhalt verloren -- ohne Fehlermeldung, bei
+ unveränderter Zeilenzahl. Deshalb wird hier gefragt, was DA ist,
+ statt eine Liste zu pflegen. */
+ const da = new Set(d.prepare("PRAGMA table_info(mz_eintrag)").all().map((s) => s.name));
+ const fehlt = [
+ ["name_klein", "TEXT"], ["link_klein", "TEXT"],
+ ["doppelt_bei", "INTEGER REFERENCES personen(id) ON DELETE SET NULL"],
+ ].filter(([spalte]) => !da.has(spalte));
+ for (const [spalte, typ] of fehlt) {
+ d.exec(`ALTER TABLE mz_eintrag ADD COLUMN ${spalte} ${typ}`);
+ }
+}
diff --git a/server/mess-manager-ziele.mjs b/server/mess-manager-ziele.mjs
new file mode 100644
index 00000000..e566cdca
--- /dev/null
+++ b/server/mess-manager-ziele.mjs
@@ -0,0 +1,224 @@
+/* =====================================================================
+ WIE DIE MANAGER-ZIELE AUSSEHEN (02.10.2026)
+
+ SIE PRUEFT NICHTS und meldet keinen Fehlschlag -- sie macht Bilder,
+ auf denen man nachsehen kann, und misst drei Dinge, die man auf
+ einem Bild schlecht sieht: waagerechtes Schieben, Beruehrziele unter
+ 44 px und Ueberdeckungen.
+
+ Deshalb `mess-` und nicht `pruef-`.
+
+ ZWEI BREITEN, und die kleinere ist die wichtigere: 412 px ist die
+ haeufigste Android-Breite. Am 06.09.2026 hat genau diese Breite im
+ Creator Workspace eine Ueberdeckung aufgedeckt, bei der ein Knopf
+ ueber einem anderen lag -- auf dem Computer war nichts zu sehen.
+
+ EIGENE WEGWERF-DATENBANK in einem mktemp-Ordner. In einem laufenden
+ System waeren das Testdaten, und die sind verboten.
+
+ Aufrufen mit: node server/mess-manager-ziele.mjs
+ ===================================================================== */
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { request as httpAnfrage } from "node:http";
+
+import { eigenerPort } from "./helfer-port.mjs";
+const PORT = await eigenerPort(import.meta, "mess-manager-ziele");
+const ordner = mkdtempSync(join(tmpdir(), "ws-mz-bild-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+process.env.PORT = String(PORT);
+process.env.SITE_ACCESS_SECRET = "lokaler-test";
+process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
+
+const express = (await import("express")).default;
+const ec = express.response.cookie;
+express.response.cookie = function (n, w, o) {
+ return ec.call(this, n, w, { ...(o || {}), secure: false });
+};
+import { notbremse } from "./helfer-notbremse.mjs";
+await import("./index.js");
+notbremse(240_000, "mess-manager-ziele");
+await new Promise((r) => setTimeout(r, 900));
+process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
+process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
+
+/* ---------- Leute und ein paar echte Eintraege ----------------------- */
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const jetzt = new Date().toISOString();
+const alt = new Date(Date.now() - 365 * 86400_000).toISOString();
+const sch = randomBytes(32).toString("hex");
+d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
+ .run("code_kennung_schluessel", sch, jetzt);
+const anlegen = (name, rolle, code) => {
+ const s = randomBytes(16).toString("hex");
+ const h = scryptSync(code, s, 64,
+ { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
+ return d.prepare("INSERT INTO personen"
+ + " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ + " VALUES (?,?,?,?,?,?,1,?) RETURNING id")
+ .get(name, rolle, h, s, 32768,
+ createHmac("sha256", sch).update(code).digest("hex"), alt).id;
+};
+anlegen("Filipe", "admin", "CODE-DOGI-0001");
+const idScout = anlegen("Kevin", "scout", "CODE-KEVIN-0001");
+anlegen("Patrick", "manager", "CODE-PAT-000001");
+anlegen("Sandra", "scout", "CODE-SAND-00001");
+anlegen("Spicy Media", "spicy", "CODE-SPICY-0001");
+
+const { monatJetzt, tagBerlin } = await import("./workspace-manager-ziele.js");
+const MONAT = monatJetzt();
+const HEUTE = tagBerlin();
+
+/* DER LAUFENDE MONAT MUSS DER DATENBANK GESAGT WERDEN, bevor hier von
+ Hand eingetragen wird. `mz_lage` steht nach dem Anlegen auf
+ '0000-00', und die Trigger lehnen dann JEDEN Eintrag ab -- auch den
+ fuer den aktuellen Monat. Das ist richtig so (zu, solange niemand
+ gesagt hat, welcher Monat laeuft) und hat diesen Lauf beim ersten
+ Mal abgebrochen. Im Betrieb setzt es jede Anfrage selbst. */
+d.prepare("UPDATE mz_lage SET monat = ? WHERE eins = 1").run(MONAT);
+
+/* EIN GEMISCHTER STAND -- genau darum geht es beim Ansehen: eine
+ erfuellte Zeile, eine halbe, eine leere. Stuende alles auf null,
+ sieht man die Ampel gar nicht. */
+const setz = d.prepare(`INSERT INTO mz_eintrag
+ (person_id, aufgabe, monat, datum, name, name_klein, link, link_klein, art, notiz, erstellt)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)`);
+setz.run(idScout, "creator", MONAT, HEUTE, "@lunatanzt", "lunatanzt", null, null, null,
+ "über Discord gefunden, sehr aktiv", jetzt);
+setz.run(idScout, "creator", MONAT, HEUTE, "@marakocht", "marakocht", null, null, null, null, jetzt);
+setz.run(idScout, "meeting", MONAT, HEUTE, null, null, null, null, null, "Wochenrunde", jetzt);
+setz.run(idScout, "werbung", MONAT, HEUTE, null, null,
+ "https://www.tiktok.com/@dogfather/video/7312", "tiktok.com/@dogfather/video/7312",
+ "agentur", null, jetzt);
+/* Ein uebergebener Lead, der noch nicht eingetragen ist -- damit der
+ Vorschlagskasten auf dem Bild zu sehen ist. */
+d.prepare(`INSERT INTO leads (name, handle, status, scout_id, erstellt, erstellt_von, uebergeben_am)
+ VALUES (?,?,?,?,?,?,?)`)
+ .run("Nina B.", "@ninab", "uebergeben", idScout, jetzt, idScout, `${HEUTE}T09:00:00Z`);
+d.close();
+
+/* ---------- Anmelden -------------------------------------------------- */
+const anfrage = (pfad, art, koerper) => new Promise((fertig, schief) => {
+ const a = httpAnfrage({
+ host: "127.0.0.1", port: PORT, path: pfad, method: art,
+ headers: { "Content-Type": "application/json", Origin: `http://127.0.0.1:${PORT}` },
+ }, (r) => {
+ let t = ""; r.on("data", (x) => { t += x; });
+ r.on("end", () => fertig({ code: r.statusCode, kopf: r.headers, text: t }));
+ });
+ a.on("error", schief);
+ if (koerper) a.write(JSON.stringify(koerper));
+ a.end();
+});
+
+async function keksFuer(rolle, code) {
+ const an = await anfrage("/workspace/api/anmelden", "POST", { rolle, code });
+ if (an.code !== 200) { console.log(`ABBRUCH: Anmeldung ${rolle} ${an.code}`); process.exit(2); }
+ return (an.kopf["set-cookie"] || []).map((x) => x.split(";")[0]).join("; ");
+}
+const keksScout = await keksFuer("scout", "CODE-KEVIN-0001");
+const keksDogi = await keksFuer("admin", "CODE-DOGI-0001");
+
+/* ---------- Bilder ---------------------------------------------------- */
+const pw = await import(
+ "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+const browser = await pw.chromium.launch();
+
+/** Was man auf einem Bild schlecht sieht. */
+const nachmessen = () => ({
+ /* Waagerechtes Schieben ist im Haus ausdruecklich unerwuenscht.
+ Zwei Pixel Toleranz fuer Rundungen. */
+ schiebtQuer: document.documentElement.scrollWidth > window.innerWidth + 2,
+ breite: window.innerWidth,
+ /* Jedes Beruehrziel. 44 px ist die Hausgroesse (`--feld-ziel`). */
+ zuKlein: [...document.querySelectorAll("button, a[href], input, select")]
+ .filter((e) => {
+ const k = e.getBoundingClientRect();
+ return k.width > 0 && k.height > 0 && k.height < 40;
+ })
+ .map((e) => `${e.tagName.toLowerCase()}.${e.className || "-"}: ${Math.round(e.getBoundingClientRect().height)}px`),
+ /* Ueberdeckungen zwischen den Knoepfen der Kopfzeile einer Aufgabe. */
+ zeilen: [...document.querySelectorAll(".mz-zeile")].map((z) => ({
+ aufgabe: z.dataset.aufgabe,
+ stufe: z.dataset.stufe,
+ zahl: z.querySelector(".mz-zeile__zahl")?.textContent,
+ satz: z.querySelector(".mz-zeile__lage")?.textContent,
+ })),
+ ring: document.getElementById("ring-zahl")?.textContent
+ + " " + document.getElementById("ring-von")?.textContent,
+ kopf: document.getElementById("kopf-stand")?.textContent,
+ rest: document.getElementById("kopf-rest")?.textContent,
+ vorschlaege: document.querySelectorAll(".mz-vorschlag__knopf").length,
+});
+
+async function bild(name, keks, breite, hoehe, vorher) {
+ const kontext = await browser.newContext({
+ viewport: { width: breite, height: hoehe }, hasTouch: breite <= 860,
+ });
+ await kontext.addCookies(keks.split("; ").map((k) => {
+ const [n, ...r] = k.split("=");
+ return { name: n, value: r.join("="), domain: "127.0.0.1", path: "/" };
+ }));
+ const seite = await kontext.newPage();
+ const konsole = [];
+ seite.on("console", (e) => {
+ if (e.type() === "error" && !/favicon|net::ERR/i.test(e.text())) konsole.push(e.text().slice(0, 160));
+ });
+ seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message));
+
+ await seite.goto(`http://127.0.0.1:${PORT}/workspace/manager-ziele.html`,
+ { waitUntil: "networkidle" });
+ await seite.waitForTimeout(1200);
+ if (vorher) await vorher(seite);
+ await seite.waitForTimeout(500);
+
+ const blick = await seite.evaluate(nachmessen);
+ await seite.screenshot({ path: `bild-manager-ziele-${name}.png`, fullPage: true });
+
+ console.log(`\n--- ${name} (${breite}px) ---`);
+ console.log(` Ring: ${blick.ring}`);
+ console.log(` Kopf: ${blick.kopf}`);
+ console.log(` Rest: ${blick.rest}`);
+ for (const z of blick.zeilen) {
+ console.log(` ${String(z.aufgabe).padEnd(9)} ${String(z.zahl).padEnd(6)} ${String(z.stufe).padEnd(8)} ${z.satz}`);
+ }
+ console.log(` Vorschlaege: ${blick.vorschlaege}`);
+ console.log(` Quer schieben: ${blick.schiebtQuer ? "JA — Befund!" : "nein"}`);
+ console.log(` Zu kleine Ziele: ${blick.zuKlein.length ? blick.zuKlein.join(", ") : "keine"}`);
+ if (konsole.length) console.log(` KONSOLE: ${konsole.join(" | ")}`);
+ await kontext.close();
+ return blick;
+}
+
+/* Die vier Aufgabenzeilen einmal aufklappen, damit die Eintraege und
+ der Vorschlagskasten auf dem Bild stehen. */
+const aufklappen = async (seite) => {
+ for (const kopf of await seite.$$(".mz-zeile__kopf")) {
+ await kopf.click();
+ await seite.waitForTimeout(250);
+ }
+};
+
+const handy = await bild("scout-handy", keksScout, 412, 915, aufklappen);
+const computer = await bild("scout-computer", keksScout, 1280, 1100, aufklappen);
+await bild("dogfather-team", keksDogi, 1280, 1200, null);
+await bild("dogfather-handy", keksDogi, 412, 915, null);
+
+/* Und das Formular, denn dort wird wirklich getippt. */
+await bild("formular-handy", keksScout, 412, 915, async (seite) => {
+ const knopf = await seite.$(".mz-zeile__fuss .knopf");
+ if (knopf) { await knopf.click(); await seite.waitForTimeout(400); }
+});
+
+await browser.close();
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+
+const befunde = [handy, computer].filter((b) => b.schiebtQuer || b.zuKlein.length);
+console.log(befunde.length
+ ? `\n${befunde.length} Breite(n) mit Befund — siehe oben.`
+ : "\nKein waagerechtes Schieben, kein zu kleines Beruehrziel.");
+process.exit(0);
diff --git a/server/pruef-manager-ziele.mjs b/server/pruef-manager-ziele.mjs
new file mode 100644
index 00000000..a4233dae
--- /dev/null
+++ b/server/pruef-manager-ziele.mjs
@@ -0,0 +1,734 @@
+/* =====================================================================
+ MANAGER-ZIELE (02.10.2026)
+ =====================================================================
+
+ Geprüft wird, was Filipe bestellt hat — und fünf Dinge, die er nicht
+ bestellt hat, weil sie sonst niemandem auffallen:
+
+ * JEDE Sperre muss auch NEIN sagen können. Eine Prüfung, die nur
+ den Erfolgsfall ansieht, beweist nichts: `every()` liefert auf
+ einem leeren Feld `true`, und ein Trigger, dessen Spaltenname
+ falsch ist, lehnt die Änderung ab, ohne je gelaufen zu sein
+ (RunOne, 01.09.2026). Zu jeder „geht"-Prüfung steht hier eine
+ „geht nicht"-Prüfung.
+
+ * DIE DATUMSLOGIK WIRD MIT EINGESETZTEN TAGEN GEMESSEN, nie gegen
+ die Wanduhr. `gate-oeffnung.mjs` im Shop war grün, bis der
+ Kalender sie überholte, und dann rot, ohne dass etwas kaputt
+ war. Hier liegen die Tage als Zahl im Aufruf — diese Prüfung
+ sagt am 16. November dasselbe wie heute.
+
+ * DIE ZAHL DER GEPRÜFTEN DINGE STEHT IN DER BEDINGUNG, nicht nur
+ im Meldetext. „Alle Rollen abgewiesen" ist auf einer leeren
+ Liste wahr.
+
+ * DIE BEIDEN HÄUSER BLEIBEN GETRENNT. Auf der Crew-Adresse gibt es
+ diese Kachel nicht, auch nicht für DogFather.
+
+ * DER MONATSSCHNITT ÄNDERT DEN VERLAUF NICHT. Wer die Zielzahlen
+ erhöht, darf einen abgeschlossenen Monat nicht nachträglich auf
+ „nicht erreicht" drehen.
+ ===================================================================== */
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const ordner = mkdtempSync(join(tmpdir(), "ws-mz-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+
+import { notbremse } from "./helfer-notbremse.mjs";
+const { eigenerPort } = await import("./helfer-port.mjs");
+const PORT = await eigenerPort(import.meta, "die Pruefung der Manager-Ziele");
+
+process.env.PORT = `${PORT}`;
+process.env.SITE_ACCESS_SECRET = "lokaler-test";
+process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
+process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
+
+const express = (await import("express")).default;
+const ec = express.response.cookie;
+express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
+await import("./index.js");
+notbremse(240_000, "pruef-manager-ziele");
+await new Promise((r) => setTimeout(r, 900));
+const BASIS = `http://127.0.0.1:${PORT}`;
+const API = "/workspace/api/manager-ziele";
+
+let fehler = 0, geprueft = 0;
+const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+const melde = (t) => console.log(t);
+
+/* DER DRITTE AUSGANG. Kann die Prüfung ihre Voraussetzung nicht
+ herstellen, ist das WEDER in Ordnung NOCH ein Befund am Code --
+ sie muss es sagen und abbrechen, statt grün zu melden. */
+function braucht(bedingung, was) {
+ if (bedingung) return;
+ console.log(`\nABBRUCH: konnte nicht nachsehen — ${was}`);
+ console.log("Das ist KEIN Befund am Code. Die Pruefung konnte ihre");
+ console.log("Voraussetzung nicht herstellen und sagt das, statt zu schweigen.\n");
+ try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+ process.exit(2);
+}
+
+/* ---------- Personen anlegen ---------------------------------------- */
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const jetztIso = new Date().toISOString();
+const kennSchluessel = randomBytes(32).toString("hex");
+d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
+ .run("code_kennung_schluessel", kennSchluessel, jetztIso);
+
+/* ERSTELLT: ein Jahr alt. Sonst wären alle erst im Folgemonat
+ pflichtig (Regel 09 der Vorlage) und jede Ampelprüfung wäre
+ neutral — die Prüfung sähe grün aus und hätte nichts gemessen. */
+const ALT = new Date(Date.now() - 365 * 86400_000).toISOString();
+
+function anlegen(name, rolle, code, erstellt = ALT) {
+ const salt = randomBytes(16).toString("hex");
+ const hash = scryptSync(code, salt, 64,
+ { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
+ d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n,"
+ + " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)")
+ .run(name, rolle, hash, salt, 32768,
+ createHmac("sha256", kennSchluessel).update(code).digest("hex"), erstellt);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+
+const ROLLEN = [
+ ["admin", "Filipe"], ["spicy", "Spicy Media"], ["manager", "Patrick"],
+ ["scout", "Kevin"], ["scout", "Sandra"], ["creator", "Luna"],
+ ["hand", "VanVan"], ["modi", "Miss"],
+];
+const CODES = {}, IDS = {};
+for (const [i, [rolle, name]] of ROLLEN.entries()) {
+ const schluessel = IDS[rolle] === undefined ? rolle : `${rolle}2`;
+ CODES[schluessel] = `CODE-Z${String(i).padStart(2, "0")}-000001`;
+ IDS[schluessel] = anlegen(name, rolle, CODES[schluessel]);
+}
+d.close();
+
+/* ---------- Anmelden ------------------------------------------------- */
+const { request } = await import("node:http");
+const CREW_HOST = "crew.dogfather-universe.com";
+
+function roh(pfad, host, koerper, methode) {
+ return new Promise((fertig, schief) => {
+ const daten = koerper ? JSON.stringify(koerper) : null;
+ const a = request({
+ host: "127.0.0.1", port: PORT, path: pfad,
+ method: methode || (daten ? "POST" : "GET"),
+ headers: {
+ ...(host ? { Host: host } : {}),
+ Origin: host ? `https://${host}` : BASIS,
+ ...(daten ? { "Content-Type": "application/json",
+ "Content-Length": Buffer.byteLength(daten) } : {}),
+ },
+ }, (antwort) => {
+ let text = "";
+ antwort.on("data", (t) => { text += t; });
+ antwort.on("end", () => fertig({ code: antwort.statusCode, kopf: antwort.headers, text }));
+ });
+ a.on("error", schief);
+ if (daten) a.write(daten);
+ a.end();
+ });
+}
+
+/** Derselbe Aufruf mit Hausadresse UND Sitzung. `fetch` verwirft den
+ * Host-Kopf (er steht auf der Liste der verbotenen Kopfzeilen), also
+ * geht das nur ueber node:http. */
+function rohMitKeks(pfad, host, keks) {
+ return new Promise((fertig, schief) => {
+ const a = request({
+ host: "127.0.0.1", port: PORT, path: pfad, method: "GET",
+ headers: { Host: host, Origin: `https://${host}`, Cookie: keks || "" },
+ }, (antwort) => {
+ let text = "";
+ antwort.on("data", (t) => { text += t; });
+ antwort.on("end", () => fertig({ code: antwort.statusCode, text }));
+ });
+ a.on("error", schief);
+ a.end();
+ });
+}
+
+async function anmelden(schluessel, rolle, host = null) {
+ const r = await roh("/workspace/api/anmelden", host, { rolle, code: CODES[schluessel] });
+ if (r.code !== 200) return null;
+ return [].concat(r.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ") || null;
+}
+
+const kekse = {};
+melde("\n=== Alle Rollen kommen herein ===");
+for (const schluessel of Object.keys(CODES)) {
+ const rolle = schluessel.replace(/2$/, "");
+ kekse[schluessel] = await anmelden(schluessel, rolle);
+ ok(!!kekse[schluessel], `${schluessel}: angemeldet`);
+}
+braucht(Object.values(kekse).every(Boolean), "nicht alle Rollen konnten sich anmelden");
+
+async function hol(schluessel, pfad, einst = {}) {
+ return fetch(BASIS + pfad, {
+ ...einst,
+ headers: {
+ Cookie: kekse[schluessel] || "", Origin: BASIS,
+ ...(einst.body ? { "Content-Type": "application/json" } : {}),
+ ...(einst.headers || {}),
+ },
+ });
+}
+const schick = (schluessel, pfad, methode, rumpf) =>
+ hol(schluessel, pfad, { method: methode, body: JSON.stringify(rumpf) });
+
+/* =====================================================================
+ 1 · WER DARF HEREIN — und wer ausdrücklich nicht
+ ===================================================================== */
+melde("\n=== Sichtbarkeit: vier Rollen ja, der Rest nein ===");
+const DUERFEN = ["admin", "spicy", "manager", "scout"];
+const DUERFEN_NICHT = ["creator", "hand", "modi"];
+
+let drin = 0;
+for (const r of DUERFEN) {
+ const a = await hol(r, `${API}/stand`);
+ if (a.status === 200) drin++;
+ ok(a.status === 200, `${r} kommt an den Stand (${a.status})`);
+}
+/* DIE ZAHL STEHT IN DER BEDINGUNG. „Alle durchgelassen" wäre auf einer
+ leeren Liste wahr. */
+ok(drin === DUERFEN.length, `genau ${DUERFEN.length} Rollen kommen herein (${drin})`);
+
+let draussen = 0;
+for (const r of DUERFEN_NICHT) {
+ const a = await hol(r, `${API}/stand`);
+ if (a.status === 404) draussen++;
+ ok(a.status === 404, `${r} bekommt 404 (nicht 403) (${a.status})`);
+}
+ok(draussen === DUERFEN_NICHT.length,
+ `genau ${DUERFEN_NICHT.length} Rollen bleiben draussen (${draussen})`);
+
+melde("\n=== Die Seite selbst ist ebenfalls zu ===");
+const seiteCreator = await hol("creator", "/workspace/manager-ziele.html", { redirect: "manual" });
+ok(seiteCreator.status === 302,
+ `Creator wird von der Seite weggeschickt (${seiteCreator.status})`);
+const seiteScout = await hol("scout", "/workspace/manager-ziele.html", { redirect: "manual" });
+ok(seiteScout.status === 200, `Scout bekommt die Seite (${seiteScout.status})`);
+
+melde("\n=== Zwei getrennte Haeuser: auf crew. gibt es die Kachel nicht ===");
+const crewKeks = await anmelden("admin", "admin", CREW_HOST);
+braucht(!!crewKeks, "DogFather konnte sich auf der Crew-Adresse nicht anmelden");
+/* DER KEKS MUSS MIT. Beim ersten Lauf stand hier 401 statt 404 --
+ und das sah aus wie ein Befund an der Haustrennung. Es war einer an
+ meiner Messung: Ohne Sitzung kommt man ueberall nicht herein, das
+ beweist nichts ueber DIESE Kachel. */
+const crewStand = await rohMitKeks(`${API}/stand`, CREW_HOST, crewKeks);
+ok(crewStand.code === 404,
+ `DogFather bekommt auf crew. ein 404 auf die Schnittstelle (${crewStand.code})`);
+const crewMitKeks = await rohMitKeks("/workspace/start.html", CREW_HOST, crewKeks);
+ok(crewMitKeks.code === 200,
+ `…und ist dort trotzdem angemeldet (${crewMitKeks.code}) — die Sitzung war es nicht`);
+
+/* =====================================================================
+ 2 · DIE AMPEL — reine Rechnung, mit eingesetzten Tagen
+ ===================================================================== */
+melde("\n=== Die Ampel (eingesetzte Tage, nicht die Wanduhr) ===");
+const mz = await import("./workspace-manager-ziele.js");
+
+/* Ein Monat mit 31 Tagen. `bisEnde = letzter - tag`. */
+const L = 31;
+ok(mz.stufeFuer(3, 3, 5, L) === "gruen", "Ziel erreicht am 5. -> gruen");
+ok(mz.stufeFuer(4, 3, 5, L) === "gruen", "uebererfuellt (4/3) -> gruen");
+ok(mz.stufeFuer(0, 3, 1, L) === "neutral", "Tag 1, offen -> neutral");
+ok(mz.stufeFuer(1, 3, 15, L) === "neutral", "Tag 15, offen -> neutral");
+ok(mz.stufeFuer(1, 3, 16, L) === "orange", "Tag 16, offen -> orange");
+ok(mz.stufeFuer(1, 3, 23, L) === "orange", "Tag 23 (8 Tage uebrig) -> noch orange");
+ok(mz.stufeFuer(1, 3, 24, L) === "rot", "Tag 24 (7 Tage uebrig) -> rot");
+ok(mz.stufeFuer(1, 3, 31, L) === "rot", "letzter Tag, offen -> rot");
+ok(mz.stufeFuer(0, 0, 31, L) === "aus", "Ziel 0 -> aus, nie eine Warnung");
+/* GEGENPROBE: Die Rechnung muss auch dann rot sagen koennen, wenn der
+ 16. gar nicht erreicht wurde -- in einem kurzen Monat. */
+ok(mz.stufeFuer(0, 1, 21, 28) === "rot", "Februar, Tag 21 (7 uebrig) -> rot");
+ok(mz.stufeFuer(0, 1, 13, 28) === "neutral", "Februar, Tag 13 -> neutral");
+
+ok(mz.stufenText("rot", 0) === "Dringend – heute ist der letzte Tag",
+ "der letzte Tag bekommt seinen eigenen Satz");
+ok(mz.stufenText("rot", 1) === "Dringend – noch 1 Tag", "Einzahl bei einem Tag");
+ok(mz.stufenText("rot", 5) === "Dringend – noch 5 Tage", "Mehrzahl ab zwei Tagen");
+ok(mz.stufenText("orange", 9) === "Sollte bis Monatsmitte erledigt sein",
+ "der Orange-Satz steht woertlich wie in der Vorlage");
+ok(mz.stufenText("neutral", 20) === "", "neutral sagt ausdruecklich nichts");
+
+ok(mz.schlechteste(["gruen", "neutral", "rot"]) === "rot",
+ "die Kachel traegt die schlechteste Farbe");
+ok(mz.schlechteste(["gruen", "aus"]) === "gruen",
+ "eine abgeschaltete Aufgabe verschlechtert nichts");
+
+melde("\n=== Monatsrechnung ===");
+ok(mz.letzterTagVon("2026-02") === 28, "Februar 2026 hat 28 Tage");
+ok(mz.letzterTagVon("2024-02") === 29, "Februar 2024 hat 29 Tage");
+ok(mz.letzterTagVon("2026-10") === 31, "Oktober hat 31 Tage");
+ok(mz.monatPlus("2026-12", 1) === "2027-01", "Dezember + 1 = Januar des Folgejahres");
+ok(mz.monatPlus("2026-01", -1) === "2025-12", "Januar - 1 = Dezember des Vorjahres");
+/* Die Zeitzone: ein Zeitpunkt, der in UTC noch zum Vortag gehoert,
+ aber in Berlin schon zum naechsten Tag. Ohne Europe/Berlin faende
+ diese Zeile den falschen Tag -- und am Monatsersten den falschen
+ Monat. */
+ok(mz.tagBerlin(new Date("2026-06-30T23:30:00Z")) === "2026-07-01",
+ "23:30 UTC am 30.06. ist in Berlin schon der 1. Juli");
+
+/* =====================================================================
+ 3 · EINTRAGEN
+ ===================================================================== */
+melde("\n=== Eintragen ===");
+const standVorher = await (await hol("scout", `${API}/stand`)).json();
+braucht(standVorher && Array.isArray(standVorher.aufgaben),
+ "der Stand kam nicht als Liste zurueck");
+const MONAT = standVorher.monat;
+const HEUTE = standVorher.heute;
+ok(standVorher.aufgaben.length === 4, `vier Aufgaben (${standVorher.aufgaben.length})`);
+ok(standVorher.pflichtig === true, "ein ein Jahr alter Scout ist pflichtig");
+
+const vorherCreator = standVorher.aufgaben.find((a) => a.schluessel === "creator");
+ok(vorherCreator.ziel === 3, `Vorgabe fuer Creator ist 3 (${vorherCreator.ziel})`);
+ok(vorherCreator.zahl === 0, "noch nichts eingetragen");
+
+const neu1 = await schick("scout", `${API}/eintrag`, "POST",
+ { aufgabe: "creator", datum: HEUTE, name: "@LunaTanzt", notiz: "über Discord gefunden" });
+const neu1d = await neu1.json();
+ok(neu1.status === 201, `Creator-Eintrag angelegt (${neu1.status})`);
+ok(neu1d.stand?.aufgaben?.find((a) => a.schluessel === "creator")?.zahl === 1,
+ "der Zaehler steht sofort auf 1 (ohne Neuladen)");
+
+for (const [aufgabe, rumpf] of [
+ ["meeting", { datum: HEUTE }],
+ ["schulung", { datum: HEUTE, art: "talk" }],
+ ["werbung", { datum: HEUTE, art: "agentur", link: "https://www.tiktok.com/@dogi/video/123" }],
+]) {
+ const a = await schick("scout", `${API}/eintrag`, "POST", { aufgabe, ...rumpf });
+ ok(a.status === 201, `${aufgabe}: angelegt (${a.status})`);
+}
+
+const standNachher = await (await hol("scout", `${API}/stand`)).json();
+ok(standNachher.gesamt.erledigt === 4,
+ `Gesamtfortschritt 4 von 8 (${standNachher.gesamt.erledigt} von ${standNachher.gesamt.ziel})`);
+ok(standNachher.gesamt.ziel === 8, "Zielsumme 3+2+2+1 = 8");
+ok(standNachher.aufgaben.find((a) => a.schluessel === "werbung").stufe === "gruen",
+ "Werbung (1 von 1) steht auf gruen");
+
+/* =====================================================================
+ 4 · WAS ABGELEHNT WERDEN MUSS — die Gegenproben
+ ===================================================================== */
+melde("\n=== Abgelehnt wird, was abgelehnt gehoert ===");
+const morgen = new Date(Date.parse(HEUTE + "T12:00:00Z") + 86400_000).toISOString().slice(0, 10);
+const fremderMonat = `${mz.monatPlus(MONAT, -1)}-15`;
+
+const absagen = [
+ ["Datum in der Zukunft", { aufgabe: "meeting", datum: morgen }, "datum_zukunft"],
+ ["Datum aus dem Vormonat", { aufgabe: "meeting", datum: fremderMonat }, "datum_fremder_monat"],
+ ["gar kein Datum", { aufgabe: "meeting", datum: "" }, "datum_fehlt"],
+ ["Creator ohne Namen", { aufgabe: "creator", datum: HEUTE, name: " " }, "name_fehlt"],
+ ["Werbung ohne Link", { aufgabe: "werbung", datum: HEUTE, art: "eigen", link: "" }, "link_ungueltig"],
+ ["Werbung mit javascript:", { aufgabe: "werbung", datum: HEUTE, art: "eigen",
+ link: "javascript:alert(1)" }, "link_ungueltig"],
+ ["Werbung mit blossem Wort", { aufgabe: "werbung", datum: HEUTE, art: "eigen",
+ link: "tiktok" }, "link_ungueltig"],
+ ["Schulung ohne Art", { aufgabe: "schulung", datum: HEUTE, art: "" }, "art_fehlt"],
+ ["Aufgabe, die es nicht gibt", { aufgabe: "kaffee", datum: HEUTE }, "aufgabe_unbekannt"],
+];
+let abgelehnt = 0;
+for (const [was, rumpf, grund] of absagen) {
+ const a = await schick("scout", `${API}/eintrag`, "POST", rumpf);
+ const j = await a.json().catch(() => ({}));
+ const richtig = a.status === 400 && j.fehler === grund;
+ if (richtig) abgelehnt++;
+ ok(richtig, `${was} -> abgelehnt mit „${grund}" (${a.status}/${j.fehler})`);
+}
+ok(abgelehnt === absagen.length,
+ `alle ${absagen.length} Absagen kamen mit dem richtigen Grund (${abgelehnt})`);
+
+/* EINE GUELTIGE FREMDE ADRESSE MUSS DURCH -- Filipe am 02.10.2026:
+ „jede gültige Internetadresse". Ohne diese Zeile bewiese der Block
+ darueber nur, dass die Pruefung streng ist, nicht dass sie richtig
+ ist. */
+const fremdLink = await schick("scout", `${API}/eintrag`, "POST",
+ { aufgabe: "werbung", datum: HEUTE, art: "eigen", link: "https://www.instagram.com/p/abc" });
+ok(fremdLink.status === 201,
+ `eine Instagram-Adresse wird angenommen (${fremdLink.status}) — nicht nur TikTok`);
+
+melde("\n=== Dubletten ===");
+const doppelt = await schick("scout", `${API}/eintrag`, "POST",
+ { aufgabe: "creator", datum: HEUTE, name: "lunatanzt" });
+const doppeltD = await doppelt.json().catch(() => ({}));
+ok(doppelt.status === 409 && doppeltD.fehler === "schon_eingetragen",
+ `„lunatanzt" gilt als derselbe wie „@LunaTanzt" (${doppelt.status})`);
+
+const anderer = await schick("scout2", `${API}/eintrag`, "POST",
+ { aufgabe: "creator", datum: HEUTE, name: "@LunaTanzt" });
+const andererD = await anderer.json().catch(() => ({}));
+ok(anderer.status === 201, `eine ANDERE Person darf denselben Namen eintragen (${anderer.status})`);
+ok(!!andererD.warnung, "…bekommt aber einen Hinweis, dass es jemand anders auch hat");
+
+const neuerName = await schick("scout", `${API}/eintrag`, "POST",
+ { aufgabe: "creator", datum: HEUTE, name: "@ZweiterCreator" });
+ok(neuerName.status === 201, `ein anderer Name geht weiterhin (${neuerName.status})`);
+
+/* =====================================================================
+ 5 · AENDERN UND LOESCHEN
+ ===================================================================== */
+melde("\n=== Aendern und Loeschen ===");
+const liste = await (await hol("scout", `${API}/eintraege?monat=${MONAT}&aufgabe=creator`)).json();
+braucht(liste.eintraege?.length >= 2, "es gibt zu wenige Eintraege zum Aendern");
+const einId = liste.eintraege.find((e) => e.name === "@ZweiterCreator").id;
+
+const geaendert = await schick("scout", `${API}/eintrag/${einId}`, "PATCH",
+ { datum: HEUTE, name: "@ZweiterCreatorNeu", notiz: "Name berichtigt" });
+ok(geaendert.status === 200, `eigener Eintrag geaendert (${geaendert.status})`);
+
+const fremdAendern = await schick("manager", `${API}/eintrag/${einId}`, "PATCH",
+ { datum: HEUTE, name: "@Geklaut" });
+ok(fremdAendern.status === 403,
+ `ein Manager kann einen fremden Eintrag NICHT aendern (${fremdAendern.status})`);
+
+const adminAendern = await schick("admin", `${API}/eintrag/${einId}`, "PATCH",
+ { datum: HEUTE, name: "@VonDogfatherBerichtigt" });
+ok(adminAendern.status === 200, `DogFather darf korrigieren (${adminAendern.status})`);
+
+const dd = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true });
+const protokolliert = dd.prepare(
+ "SELECT COUNT(*) n FROM protokoll WHERE aktion = 'manager_ziele_korrigiert'").get().n;
+dd.close();
+ok(protokolliert >= 1, `jede Korrektur steht im Protokoll (${protokolliert})`);
+
+const fremdLoeschen = await hol("manager", `${API}/eintrag/${einId}`, { method: "DELETE" });
+ok(fremdLoeschen.status === 403,
+ `ein Manager kann einen fremden Eintrag NICHT loeschen (${fremdLoeschen.status})`);
+
+const vorLoeschen = await (await hol("scout", `${API}/stand`)).json();
+const geloescht = await hol("scout", `${API}/eintrag/${einId}`, { method: "DELETE" });
+const geloeschtD = await geloescht.json().catch(() => ({}));
+ok(geloescht.status === 200, `eigener Eintrag geloescht (${geloescht.status})`);
+ok(geloeschtD.stand?.aufgaben?.find((a) => a.schluessel === "creator")?.zahl
+ === vorLoeschen.aufgaben.find((a) => a.schluessel === "creator").zahl - 1,
+ "der Zaehler geht nach dem Loeschen um genau eins zurueck");
+
+/* =====================================================================
+ 6 · DER MONATSSCHNITT — die Sperre muss wirklich sperren
+ ===================================================================== */
+melde("\n=== Vergangene Monate sind zu ===");
+const VORMONAT = mz.monatPlus(MONAT, -1);
+
+/* Eine Zeile aus dem Vormonat einsetzen. Das geht NUR mit geoeffneter
+ Freigabe -- und genau das ist schon die erste Gegenprobe: Ohne sie
+ lehnt der Trigger ab. */
+const db2 = new DatabaseSync(process.env.WORKSPACE_DB);
+let triggerHaelt = false;
+try {
+ db2.prepare("INSERT INTO mz_eintrag (person_id, aufgabe, monat, datum, erstellt)"
+ + " VALUES (?,?,?,?,?)").run(IDS.scout, "meeting", VORMONAT, `${VORMONAT}-05`, jetztIso);
+} catch (f) {
+ triggerHaelt = String(f?.message || "").includes("monat_gesperrt");
+}
+ok(triggerHaelt,
+ "die DATENBANK lehnt einen Eintrag im Vormonat ab (nicht erst der Code)");
+
+const weit = new Date(Date.now() + 600_000).toISOString().replace("T", " ").slice(0, 19);
+db2.prepare("UPDATE mz_freigabe SET offen = 1, bis = ? WHERE eins = 1").run(weit);
+db2.prepare("INSERT INTO mz_eintrag (person_id, aufgabe, monat, datum, erstellt)"
+ + " VALUES (?,?,?,?,?)").run(IDS.scout, "meeting", VORMONAT, `${VORMONAT}-05`, jetztIso);
+const altId = db2.prepare("SELECT last_insert_rowid() AS id").get().id;
+db2.prepare("UPDATE mz_freigabe SET offen = 0, bis = NULL WHERE eins = 1").run();
+db2.close();
+braucht(!!altId, "es liess sich keine Zeile im Vormonat anlegen");
+
+const altAendern = await schick("scout", `${API}/eintrag/${altId}`, "PATCH",
+ { datum: `${VORMONAT}-06` });
+ok(altAendern.status === 409,
+ `ein Scout kann im Vormonat nichts mehr aendern (${altAendern.status})`);
+const altLoeschen = await hol("scout", `${API}/eintrag/${altId}`, { method: "DELETE" });
+ok(altLoeschen.status === 409,
+ `…und auch nichts loeschen (${altLoeschen.status})`);
+
+const altAdmin = await schick("admin", `${API}/eintrag/${altId}`, "PATCH",
+ { datum: `${VORMONAT}-07`, notiz: "Korrektur im Nachhinein" });
+ok(altAdmin.status === 200,
+ `DogFather darf auch im Vormonat korrigieren (${altAdmin.status})`);
+
+/* UND DIE FREIGABE IST DANACH WIEDER ZU. Ein Schloss, das offen
+ bleibt, ist dasselbe wie keines. */
+const db3 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true });
+const freigabe = db3.prepare("SELECT offen FROM mz_freigabe WHERE eins = 1").get();
+db3.close();
+ok(freigabe.offen === 0, `die Freigabe steht nach der Korrektur wieder auf zu (${freigabe.offen})`);
+
+const altNochmal = await schick("scout", `${API}/eintrag/${altId}`, "PATCH",
+ { datum: `${VORMONAT}-08` });
+ok(altNochmal.status === 409,
+ `der Scout kommt auch danach nicht hinein (${altNochmal.status}) — die Freigabe wirkte nur einmal`);
+
+/* =====================================================================
+ 7 · DIE ZIELZAHLEN
+ ===================================================================== */
+melde("\n=== Zielzahlen: aenderbar, aber erst ab naechstem Monat ===");
+const zieleScout = await hol("scout", `${API}/ziele`);
+ok(zieleScout.status === 403, `ein Scout sieht die Zielzahlen nicht (${zieleScout.status})`);
+const zieleManager = await hol("manager", `${API}/ziele`);
+ok(zieleManager.status === 403, `ein Manager auch nicht (${zieleManager.status})`);
+
+for (const r of ["admin", "spicy"]) {
+ const a = await hol(r, `${API}/ziele`);
+ ok(a.status === 200, `${r} darf die Zielzahlen sehen (${a.status})`);
+}
+
+const vorAenderung = await (await hol("scout", `${API}/stand`)).json();
+const zielVorher = vorAenderung.aufgaben.find((a) => a.schluessel === "creator").ziel;
+
+const gesetzt = await schick("spicy", `${API}/ziele`, "PUT",
+ { ziele: { scout: { creator: 9 }, admin: { creator: 0 } } });
+const gesetztD = await gesetzt.json().catch(() => ({}));
+ok(gesetzt.status === 200, `Spicy Media darf die Zielzahlen setzen (${gesetzt.status})`);
+ok(gesetztD.monat === mz.monatPlus(MONAT, 1),
+ `gespeichert wird fuer den NAECHSTEN Monat (${gesetztD.monat})`);
+
+const nachAenderung = await (await hol("scout", `${API}/stand`)).json();
+ok(nachAenderung.aufgaben.find((a) => a.schluessel === "creator").ziel === zielVorher,
+ `der laufende Monat bleibt bei ${zielVorher} — eine Aenderung wirkt NICHT rueckwirkend`);
+
+const zieleJetzt = await (await hol("admin", `${API}/ziele`)).json();
+ok(zieleJetzt.naechster.find((r) => r.rolle === "scout").ziele.creator === 9,
+ "im naechsten Monat steht beim Scout die 9");
+ok(zieleJetzt.jetzt.find((r) => r.rolle === "scout").ziele.creator === zielVorher,
+ "im laufenden Monat steht dort weiterhin die alte Zahl");
+
+const unsinn = await schick("admin", `${API}/ziele`, "PUT", { ziele: { scout: { creator: 500 } } });
+ok(unsinn.status === 400, `eine Zielzahl von 500 wird abgelehnt (${unsinn.status})`);
+const negativ = await schick("admin", `${API}/ziele`, "PUT", { ziele: { scout: { creator: -1 } } });
+ok(negativ.status === 400, `eine negative Zielzahl wird abgelehnt (${negativ.status})`);
+
+/* =====================================================================
+ 8 · DIE TEAM-UEBERSICHT UND DER EXPORT
+ ===================================================================== */
+melde("\n=== Team-Uebersicht ===");
+for (const r of ["manager", "scout"]) {
+ const a = await hol(r, `${API}/team`);
+ ok(a.status === 403, `${r} sieht die Team-Uebersicht nicht (${a.status})`);
+}
+const team = await hol("admin", `${API}/team`);
+const teamD = await team.json();
+ok(team.status === 200, `DogFather sieht die Team-Uebersicht (${team.status})`);
+ok(teamD.leute.length === 5,
+ `fuenf pflichtige Personen in der Liste (${teamD.leute.length}) — Creator, Hand und Modi fehlen zu Recht`);
+ok(teamD.leute.every((p) => ["spicy", "admin", "manager", "scout"].includes(p.rolle)),
+ "und keine einzige fremde Rolle darunter");
+
+const spicyTeam = await hol("spicy", `${API}/team`);
+ok(spicyTeam.status === 200, `Spicy Media ebenfalls (${spicyTeam.status})`);
+
+const fremdeSicht = await hol("scout", `${API}/stand?person=${IDS.scout2}`);
+const fremdeSichtD = await fremdeSicht.json();
+ok(fremdeSichtD.person?.id === IDS.scout,
+ "ein Scout, der eine fremde Nummer in die Adresse schreibt, sieht trotzdem nur sich selbst");
+
+melde("\n=== CSV-Export ===");
+const csvScout = await hol("scout", `${API}/export.csv`);
+ok(csvScout.status === 403, `ein Scout bekommt keinen Export (${csvScout.status})`);
+const csv = await hol("admin", `${API}/export.csv?monat=${MONAT}`);
+/* IN BYTES GEMESSEN, nicht in Zeichen. `Response.text()` entfernt ein
+ fuehrendes BOM beim Dekodieren -- die Pruefung meldete deshalb beim
+ ersten Lauf "kein BOM", obwohl es da war. Das war ein Fehler an der
+ Messung, nicht am Export. */
+const csvBytes = new Uint8Array(await csv.clone().arrayBuffer());
+const csvText = await csv.text();
+ok(csv.status === 200, `DogFather bekommt den Export (${csv.status})`);
+ok(csvBytes[0] === 0xEF && csvBytes[1] === 0xBB && csvBytes[2] === 0xBF,
+ `die Datei beginnt mit dem BOM (${[...csvBytes.slice(0, 3)].map((b) => b.toString(16)).join(" ")}) — sonst zeigt deutsches Excel kaputte Umlaute`);
+ok(csvText.split("\n")[0].includes(";"),
+ "getrennt wird mit Semikolon — sonst steht alles in einer Spalte");
+const csvZeilen = csvText.trim().split(/\r?\n/);
+ok(csvZeilen.length === teamD.leute.length + 1,
+ `eine Kopfzeile und ${teamD.leute.length} Personen (${csvZeilen.length} Zeilen)`);
+ok((csv.headers.get("content-disposition") || "").includes(`manager-ziele-${MONAT}.csv`),
+ "der Dateiname trägt den Monat");
+
+/* =====================================================================
+ 9 · DIE PIPELINE-BRUECKE
+ ===================================================================== */
+melde("\n=== Die Bruecke zur Scout-Pipeline ===");
+const db4 = new DatabaseSync(process.env.WORKSPACE_DB);
+db4.prepare(`INSERT INTO leads (name, handle, status, scout_id, erstellt, erstellt_von, uebergeben_am)
+ VALUES (?,?,?,?,?,?,?)`)
+ .run("Mara K.", "@maratanzt", "uebergeben", IDS.scout, jetztIso, IDS.scout, `${HEUTE}T10:00:00Z`);
+const leadId = db4.prepare("SELECT last_insert_rowid() AS id").get().id;
+/* Ein Lead, der jemand ANDEREM gehoert -- fuer die Gegenprobe. */
+db4.prepare(`INSERT INTO leads (name, handle, status, scout_id, erstellt, erstellt_von, uebergeben_am)
+ VALUES (?,?,?,?,?,?,?)`)
+ .run("Fremd F.", "@fremd", "uebergeben", IDS.scout2, jetztIso, IDS.scout2, `${HEUTE}T10:00:00Z`);
+const fremdLeadId = db4.prepare("SELECT last_insert_rowid() AS id").get().id;
+db4.close();
+braucht(!!leadId && !!fremdLeadId, "die Leads liessen sich nicht anlegen");
+
+const vorschlaege = await (await hol("scout", `${API}/vorschlaege`)).json();
+ok(vorschlaege.vorschlaege.some((v) => v.id === leadId),
+ "der eigene uebergebene Lead steht als Vorschlag da");
+ok(!vorschlaege.vorschlaege.some((v) => v.id === fremdLeadId),
+ "ein fremder Lead steht NICHT darin");
+
+const uebernommen = await schick("scout", `${API}/eintrag`, "POST",
+ { aufgabe: "creator", datum: HEUTE, name: "@maratanzt", lead_id: leadId });
+ok(uebernommen.status === 201, `der Lead laesst sich uebernehmen (${uebernommen.status})`);
+
+const danach = await (await hol("scout", `${API}/vorschlaege`)).json();
+ok(!danach.vorschlaege.some((v) => v.id === leadId),
+ "…und verschwindet danach aus den Vorschlaegen");
+
+/* GEGENPROBE: Eine fremde Lead-Nummer im Rumpf darf nicht uebernommen
+ werden -- der Eintrag entsteht, aber OHNE die Verbindung. Eine
+ Nummer in einem Formular ist kein Besitznachweis. */
+const mitFremd = await schick("scout", `${API}/eintrag`, "POST",
+ { aufgabe: "creator", datum: HEUTE, name: "@nochwer", lead_id: fremdLeadId });
+ok(mitFremd.status === 201, `der Eintrag entsteht trotzdem (${mitFremd.status})`);
+const db5 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true });
+const ohneVerbindung = db5.prepare(
+ "SELECT lead_id FROM mz_eintrag WHERE name = ?").get("@nochwer");
+const mitVerbindung = db5.prepare(
+ "SELECT lead_id FROM mz_eintrag WHERE name = ?").get("@maratanzt");
+db5.close();
+ok(ohneVerbindung?.lead_id === null,
+ "…aber die fremde Lead-Verbindung wird verworfen");
+ok(mitVerbindung?.lead_id === leadId,
+ "die eigene Verbindung dagegen bleibt gespeichert");
+
+const treffer = await (await hol("scout", `${API}/namen?q=mara`)).json();
+ok(treffer.treffer.some((t) => t.id === leadId),
+ "beim Tippen schlaegt die eigene Pipeline den Namen vor");
+const trefferFremd = await (await hol("scout", `${API}/namen?q=fremd`)).json();
+ok(!trefferFremd.treffer.length,
+ "fremde Leads werden beim Tippen NICHT vorgeschlagen");
+
+/* =====================================================================
+ 10 · DIE ERINNERUNGEN
+ ===================================================================== */
+melde("\n=== Die Erinnerungen (eingesetzte Tage) ===");
+const { ARTEN } = await import("./workspace-push.js");
+ok(ARTEN.some((a) => a.schluessel === "manager_ziele"),
+ "es gibt eine eigene, abschaltbare Benachrichtigungsart");
+
+/* `zielRufe` bekommt das Datum als Argument -- dadurch misst diese
+ Prüfung dasselbe, egal an welchem Tag sie läuft. Gerechnet wird in
+ Berlin, deshalb 12:00 UTC: Da ist der Tag in beiden Zeitzonen
+ derselbe. */
+const amTag = (tag) => new Date(`${MONAT}-${String(tag).padStart(2, "0")}T12:00:00Z`);
+const letzterTag = mz.letzterTagVon(MONAT);
+
+const rufe1 = mz.zielRufe(amTag(1));
+ok(rufe1.length > 0, `am 1. geht eine Meldung an alle mit offenen Zielen (${rufe1.length})`);
+ok(rufe1.every((r) => r.merkmal.includes(`${MONAT}-01`) || r.merkmal.endsWith(":fertig")),
+ "das Merkmal enthaelt den TAG — dadurch kann es hoechstens eine pro Tag geben");
+ok(rufe1.some((r) => r.titel === "Neuer Monat"), "…mit dem Titel aus der Vorlage");
+ok(rufe1.every((r) => r.ziel === "/workspace/manager-ziele.html"),
+ "jede Meldung fuehrt direkt zur Kachel");
+
+const RUF_TAGE = [
+ [1, "Neuer Monat"], [10, "Kurze Erinnerung"],
+ [15, "Heute ist Stichtag Monatsmitte"], [16, "Monatsmitte ist vorbei"],
+ [letzterTag - 7, "Dringend – noch 7 Tage"],
+ [letzterTag - 2, "Noch 2 Tage"], [letzterTag - 1, "Noch 1 Tag"],
+ [letzterTag, "Letzter Tag des Monats"],
+];
+let getroffen = 0;
+for (const [tag, titel] of RUF_TAGE) {
+ const r = mz.zielRufe(amTag(tag)).filter((x) => !x.merkmal.endsWith(":fertig"));
+ const passt = r.some((x) => x.titel === titel);
+ if (passt) getroffen++;
+ ok(passt, `Tag ${tag}: „${titel}" (${r[0]?.titel || "nichts"})`);
+}
+ok(getroffen === RUF_TAGE.length,
+ `alle ${RUF_TAGE.length} Anlaesse der Vorlage sind belegt (${getroffen})`);
+
+/* GEGENPROBE: An einem Tag OHNE Anlass darf nichts herausgehen. Ohne
+ diese Zeile bewiese der Block darueber nur, dass immer etwas kommt. */
+const stillerTag = mz.zielRufe(amTag(7)).filter((x) => !x.merkmal.endsWith(":fertig"));
+ok(stillerTag.length === 0,
+ `am 7. ist Ruhe (${stillerTag.length} Meldungen) — es wird nicht jeden Tag gemahnt`);
+
+/* UND WER ALLES HAT, BEKOMMT GENAU EINEN GLUECKWUNSCH. Dafuer wird
+ einer Person alles abgenommen: Ziel 0 ueberall heisst „nichts
+ offen". */
+const db6 = new DatabaseSync(process.env.WORKSPACE_DB);
+for (const a of ["creator", "meeting", "schulung", "werbung"]) {
+ db6.prepare(`INSERT INTO mz_ziel (monat, rolle, aufgabe, ziel, gesetzt_am)
+ VALUES (?,?,?,?,?)
+ ON CONFLICT(monat, rolle, aufgabe) DO UPDATE SET ziel = 0`)
+ .run(MONAT, "manager", a, 0, jetztIso);
+}
+db6.close();
+const fertig = mz.zielRufe(amTag(10)).filter((r) => r.personId === IDS.manager);
+ok(fertig.length === 0,
+ "wer gar keine Ziele hat, bekommt weder Mahnung noch Glueckwunsch");
+
+/* =====================================================================
+ 11 · DIE HINWEISE AUF DER STARTSEITE
+ ===================================================================== */
+melde("\n=== Die Zahl auf der Kachel ===");
+const hinweise = await (await hol("scout", "/workspace/api/hinweise")).json();
+const meine = (hinweise.hinweise || []).filter((h) => String(h.art).startsWith("mz_"));
+ok(meine.length > 0, `der Scout bekommt einen Hinweis zu den Monatszielen (${meine.length})`);
+ok(meine.every((h) => h.ziel === "manager-ziele.html"),
+ "…und er fuehrt auf die richtige Seite");
+
+const hinweiseCreator = await (await hol("creator", "/workspace/api/hinweise")).json();
+const creatorMz = (hinweiseCreator.hinweise || []).filter((h) => String(h.art).startsWith("mz_"));
+ok(creatorMz.length === 0,
+ `ein Creator bekommt keinen (${creatorMz.length}) — kein Weg zu einer Seite, die er nicht hat`);
+
+/* GEGENPROBE: Die Summe der Hinweise muss zur Zahl der offenen
+ Aufgaben passen. Zwei Zaehler waeren zwei Wahrheiten. */
+const standJetzt = await (await hol("scout", `${API}/stand`)).json();
+const summe = meine.reduce((s, h) => s + h.anzahl, 0);
+ok(summe === standJetzt.gesamt.offen,
+ `die Kachelzahl (${summe}) ist dieselbe wie auf der Seite (${standJetzt.gesamt.offen})`);
+
+/* =====================================================================
+ 12 · WER NEU DAZUKOMMT, WIRD NICHT GEMAHNT
+ ===================================================================== */
+melde("\n=== Wer im Laufe des Monats dazukommt ===");
+const dbNeu = new DatabaseSync(process.env.WORKSPACE_DB);
+const heuteNr = Number(HEUTE.slice(8, 10));
+dbNeu.close();
+
+const neuerCode = "CODE-Z99-000001";
+const dbA = new DatabaseSync(process.env.WORKSPACE_DB);
+const salt = randomBytes(16).toString("hex");
+const hash = scryptSync(neuerCode, salt, 64,
+ { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
+dbA.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n,"
+ + " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)")
+ .run("Ganz Neu", "scout", hash, salt, 32768,
+ createHmac("sha256", kennSchluessel).update(neuerCode).digest("hex"),
+ `${HEUTE}T09:00:00Z`);
+const neuId = dbA.prepare("SELECT last_insert_rowid() AS id").get().id;
+dbA.close();
+CODES.scout3 = neuerCode;
+kekse.scout3 = await anmelden("scout3", "scout");
+braucht(!!kekse.scout3, "der neue Scout konnte sich nicht anmelden");
+
+const standNeu = await (await hol("scout3", `${API}/stand`)).json();
+if (heuteNr === 1) {
+ ok(standNeu.pflichtig === true,
+ "wer am 1. dazukommt, ist sofort pflichtig — er hat den vollen Monat");
+} else {
+ ok(standNeu.pflichtig === false,
+ `wer am ${heuteNr}. dazukommt, bekommt diesen Monat keine Warnungen`);
+ const rufeNeu = mz.zielRufe(amTag(Math.max(heuteNr, 16)))
+ .filter((r) => r.personId === neuId);
+ ok(rufeNeu.length === 0, "…und auch keine Erinnerung");
+ ok(standNeu.aufgaben.every((a) => a.stufe === "neutral" || a.stufe === "aus"),
+ "…alle seine Zeilen stehen auf neutral, keine einzige warnt");
+}
+
+/* =====================================================================
+ SCHLUSS
+ ===================================================================== */
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+melde(`\n${geprueft} Pruefungen, ${fehler} Fehler`);
+process.exit(fehler ? 1 : 0);
diff --git a/server/rechte.js b/server/rechte.js
index c38b8404..ca9f3240 100644
--- a/server/rechte.js
+++ b/server/rechte.js
@@ -185,6 +185,24 @@ export const SEITEN = {
bleibt unangetastet -- es war nur niemand da, der es benutzen
konnte. */
"/workspace/leistung.html": ["spicy", "admin", "manager", "scout", "creator"],
+ /* MANAGER-ZIELE (02.10.2026). Die vier festen Monatsaufgaben.
+
+ VIER ROLLEN UND AUSDRUECKLICH OHNE `creator`. Filipe hat am
+ 02.10.2026 entschieden, dass alle vier -- Scout, Manager,
+ DogFather und Spicy Media -- eigene Zaehler haben; Creator haben
+ die Aufgaben nicht und sollen die Kachel auch nicht sehen.
+
+ KACHEL UND TUER GEHOEREN ZUSAMMEN: Diese Zeile ist die Schranke,
+ die Kachel steht in workspace/assets/js/bereiche.js. Nur eines von
+ beiden zu setzen ergibt entweder einen Knopf, der auf die
+ Startseite zurueckwirft, oder einen Weg, den man nur kennen muss.
+
+ UND SIE IST NICHT DIE EINZIGE SCHRANKE: Die Schnittstelle in
+ workspace-manager-ziele.js prueft die Rolle noch einmal selbst
+ und antwortet einem Creator mit 404. Eine Seite zu sperren und
+ ihre Daten offen zu lassen waere dieselbe Luecke wie am
+ 11.09.2026 bei content.html, nur andersherum. */
+ "/workspace/manager-ziele.html": ["spicy", "admin", "manager", "scout"],
/* NUR DogFather (02.09.2026). Die Rollenauswahl verspricht das seit
jeher ("Manager -- dieselben Rechte, ausser der
Personenverwaltung"), die Schranke hielt sich nur nicht daran.
diff --git a/server/workspace-hinweise.js b/server/workspace-hinweise.js
index 0949f580..fb3f875a 100644
--- a/server/workspace-hinweise.js
+++ b/server/workspace-hinweise.js
@@ -33,6 +33,7 @@ import { sichtbar as sichtbarTermine } from "./workspace-kalender.js";
import { sichtbar as sichtbarDateien } from "./workspace-dateien.js";
import { sichtbar as sichtbarEintraege } from "./workspace-bereiche.js";
import { stufenFuer } from "./workspace-checkliste.js";
+import { offeneHinweise as zielHinweise } from "./workspace-manager-ziele.js";
export const hinweisRouter = express.Router();
@@ -414,6 +415,21 @@ hinweisRouter.get("/workspace/api/hinweise", (req, res) => {
}
}
+ /* ---------- Manager-Ziele (02.10.2026) ----------
+
+ KOMMT FERTIG AUS DEM FACHMODUL und wird hier nicht nachgerechnet.
+ Das ist Regel 3 dieser Datei ("die Regeln werden aus den
+ Fachmodulen importiert, nicht abgeschrieben") und zugleich der
+ Grund, warum die Zahl auf der Kachel und die Zahl auf der Seite
+ nie auseinandergehen koennen: Es ist dieselbe Rechnung.
+
+ `dazu` filtert danach weiter -- Adresse und Rolle. Fuer einen
+ Creator liefert das Modul ohnehin nichts; faende sich hier
+ spaeter doch ein Weg, haelt ihn die Rechtetafel auf. */
+ for (const h of zielHinweise(person)) {
+ dazu(h.art, h.stufe, h.text, h.ziel, h.anzahl);
+ }
+
hinweise.sort((x, y) => (RANG[x.stufe] - RANG[y.stufe]) || (y.anzahl - x.anzahl));
res.json({ hinweise, stand: new Date().toISOString() });
} catch (fehler) {
diff --git a/server/workspace-manager-ziele.js b/server/workspace-manager-ziele.js
new file mode 100644
index 00000000..d90520f9
--- /dev/null
+++ b/server/workspace-manager-ziele.js
@@ -0,0 +1,1153 @@
+/* =====================================================================
+ 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.
+
+ 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 "";
+}
+
+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.
+ ===================================================================== */
+function startMonatFuer(personId) {
+ const d = db();
+ const da = d.prepare("SELECT erster_monat FROM mz_start WHERE person_id = ?").get(personId);
+ if (da) return da.erster_monat;
+
+ const p = d.prepare("SELECT erstellt FROM personen WHERE id = ?").get(personId);
+ if (!p) return 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. */
+ const wechsel = d.prepare(
+ `SELECT zeitpunkt FROM protokoll
+ WHERE person_id = ? AND aktion = 'rolle_geaendert'
+ ORDER BY zeitpunkt DESC LIMIT 1`).get(personId);
+
+ const quelle = wechsel?.zeitpunkt || 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 OR IGNORE INTO mz_start (person_id, erster_monat, gesetzt_am, grund)
+ VALUES (?,?,?,?)`).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");
+ return {
+ schluessel: a.schluessel,
+ name: a.name,
+ kurz: a.kurz,
+ hinweis: a.hinweis,
+ feld: a.feld,
+ arten: a.arten || 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 DogFather, nur für eine einzelne Handlung, nur zwei Minuten.
+ Die Vorlage sagt „Vergangene Monate sind gesperrt und nicht mehr
+ bearbeitbar (Ausnahme: DogFather)" — 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: req.person.rolle === "admin",
+ 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 noch bearbeitbar ist, entscheidet der Server und
+ nicht der Browser — sonst wäre der graue Knopf die ganze
+ Sicherung. */
+ bearbeitbar: monat === monatJetzt() || req.person.rolle === "admin",
+ });
+ } catch (fehler) {
+ console.error("[manager-ziele] Eintraege:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+/** Einen Eintrag anlegen. */
+managerZieleRouter.post(`${BASIS}/eintrag`, (req, res) => {
+ try {
+ const heute = tagBerlin();
+ const monat = monatJetzt();
+ monatSichern(monat);
+
+ const gepruefte = eintragPruefen(req.body, monat, heute);
+ if (gepruefte.fehler) return res.status(400).json({ fehler: gepruefte.fehler });
+ const w = gepruefte.werte;
+
+ const doppel = dublette(req.person.id, monat, w);
+ if (doppel.eigen) {
+ return res.status(409).json({
+ 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 übernommen, wenn er wirklich existiert und zu
+ dieser Person gehört. Eine Nummer in der Adresse ist kein
+ Besitznachweis — dieselbe Lücke, 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 === req.person.id || l.erstellt_von === req.person.id);
+ if (!meiner && !siehtAlles(req.person)) w.lead_id = null;
+ else if (!l) w.lead_id = null;
+ }
+
+ const info = db().prepare(
+ `INSERT INTO mz_eintrag
+ (person_id, aufgabe, monat, datum, name, name_klein, link, link_klein,
+ art, notiz, lead_id, doppelt_bei, erstellt)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(
+ req.person.id, 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());
+
+ res.status(201).json({
+ id: Number(info.lastInsertRowid),
+ stand: standFuer(req.person, monat),
+ warnung: doppel.fremd
+ ? { art: "auch_bei", name: doppel.fremd.name,
+ text: `Hinweis: ${doppel.fremd.name || "jemand anderes"} hat das diesen Monat auch eingetragen.` }
+ : null,
+ });
+ } 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 istAdmin = req.person.rolle === "admin";
+ if (fremd && !istAdmin) 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 && !istAdmin) 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 istAdmin = req.person.rolle === "admin";
+ if (fremd && !istAdmin) return res.status(403).json({ fehler: "nicht_deins" });
+
+ const altMonat = alt.monat !== monatJetzt();
+ if (altMonat && !istAdmin) 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 leute = db().prepare(
+ `SELECT id, name, rolle FROM personen
+ WHERE aktiv = 1 AND rolle IN (${PFLICHT_ROLLEN.map(() => "?").join(",")})
+ ORDER BY rolle, name COLLATE NOCASE`).all(...PFLICHT_ROLLEN);
+
+ return leute.map((p) => {
+ const s = standFuer(p, monat);
+ return {
+ id: p.id, name: p.name, rolle: p.rolle,
+ rollenName: ROLLEN_NAME[p.rolle] || p.rolle,
+ 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,
+ })),
+ };
+ });
+}
+
+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", "Pflichtig",
+ ...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) {
+ zeilen.push([
+ p.name, p.rollenName, p.pflichtig ? "ja" : "nein",
+ ...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 [];
+ }
+}
diff --git a/server/workspace-push.js b/server/workspace-push.js
index 4502ac47..9493ae8f 100644
--- a/server/workspace-push.js
+++ b/server/workspace-push.js
@@ -192,6 +192,25 @@ export const ARTEN = [
{ schluessel: "support",
name: "Support: neue Meldung oder Antwort darauf", vorgabe: true },
+ /* ---- MANAGER-ZIELE (02.10.2026) ---------------------------------
+
+ Die vier festen Monatsaufgaben. EINE Art fuer alle sieben
+ Anlaesse (1., 10., 15., 16., sieben Tage vorher, die letzten drei
+ Tage, der Glueckwunsch) und nicht sieben Schalter: Es ist
+ dieselbe Sache in verschiedenen Lautstaerken. Wer die Erinnerung
+ an seine Monatsziele abschalten will, will sie ganz abschalten --
+ nicht "die vom 10., aber die vom 16. behalten".
+
+ `vorgabe: true`, wie alles im Haus, was auf etwas Vereinbartes
+ hinweist. Wer nichts davon hoeren will, schaltet es im
+ Glocken-Knopf ab; das ist eine Entscheidung, die man selbst
+ trifft.
+
+ KEINE AUSNAHME VON DER RUHEZEIT. Eine fehlende Schulung weckt
+ niemanden um drei Uhr nachts. */
+ { schluessel: "manager_ziele",
+ name: "Deine Monatsziele (Manager-Ziele)", vorgabe: true },
+
/* ER IST LIVE (22.09.2026).
Filipe: "die leute sollen auch automatisch eine benarichtigung
@@ -957,6 +976,33 @@ export async function laufen() {
console.error("[push] Live-Hinweis:", f?.message);
}
+ /* --- MANAGER-ZIELE: die vier Monatsaufgaben (02.10.2026) --------
+
+ GERECHNET WIRD IM FACHMODUL, verschickt hier. `zielRufe()`
+ liefert fertige Meldungen; dieser Lauf kennt weder die
+ Zielzahlen noch die Ampel. Andersherum waere es ein
+ Importkreis, und ueber einen Kreis kommen Konstanten als
+ `undefined` an, ohne dass irgendwo ein Fehler erscheint.
+
+ DAS MERKMAL ENTHAELT DEN TAG -- dadurch geht pro Person und Tag
+ hoechstens EINE Meldung heraus, auch wenn dieser Lauf alle
+ fuenf Minuten stattfindet. Ohne das waeren es 288 am Tag.
+
+ Eigener try-Block aus demselben Grund wie beim Live-Hinweis:
+ Ein Fehler in den Monatszielen darf die Aufgaben- und
+ Terminerinnerungen nicht mitnehmen. */
+ try {
+ const { zielRufe } = await import("./workspace-manager-ziele.js");
+ for (const r of zielRufe()) {
+ await senden(r.personId, r.art, {
+ titel: r.titel, text: r.text, ziel: r.ziel,
+ merkmal: r.merkmal, zahl: r.zahl,
+ });
+ }
+ } catch (f) {
+ console.error("[push] Manager-Ziele:", f?.message);
+ }
+
/* Alte Merkmale aufraeumen -- nach 60 Tagen braucht sie niemand
mehr, und die Tabelle soll nicht ewig wachsen. */
const grenze = new Date(Date.now() - 60 * 86400_000).toISOString();
diff --git a/server/workspace.js b/server/workspace.js
index e5e021c1..4ec28e0b 100644
--- a/server/workspace.js
+++ b/server/workspace.js
@@ -35,6 +35,7 @@ import { treffTabellen, treffSperreLesen, MINDESTALTER } from "./treff-tabellen.
import { hilfeTabellen } from "./hilfe-tabellen.js";
import { supportTabellen } from "./support-tabellen.js";
import { notizTabellen } from "./notiz-tabellen.js";
+import { managerZieleTabellen } from "./manager-ziele-tabellen.js";
import { reaktionTabellen } from "./reaktion-tabellen.js";
import { spendenTabellen } from "./spenden-tabellen.js";
import { unterstuetzungTabellen } from "./unterstuetzung-tabellen.js";
@@ -6197,6 +6198,11 @@ export function db() {
/* Der Notizblock (26.09.2026). Haengt an personen (CASCADE) und
kommt deshalb hierher, nicht in den Bauplan darueber. */
notizTabellen(d);
+ /* Die Manager-Ziele (02.10.2026). Hängen an personen (CASCADE) UND
+ an leads (SET NULL) -- beide entstehen im Bauplan darüber, also
+ muss diese Zeile danach kommen. Stünde sie davor, legte SQLite
+ die Fremdschlüssel auf eine Tabelle an, die es noch nicht gibt. */
+ managerZieleTabellen(d);
reaktionTabellen(d);
spendenTabellen(d);
/* Die Wege zum Unterstützen (24.09.2026). Sie hängen an personen
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index a1dfba7e..ad01f3e8 100644
--- a/workspace/anruf-probe.html
+++ b/workspace/anruf-probe.html
@@ -16,8 +16,8 @@
verdeckt. Gefunden hat es pruef-struktur.
Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. -->
-
-
+
+
-
+