Files
dogfather-universe/server/workspace-teamlage.js
T
DogFatherGitandClaude Opus 5 ad47fc2b00 Team Dogi: Sternenfeld auf jeder Kachel, und die Sicht zeigt endlich, was sie verspricht
DER GRUND JEDER KACHEL IM HAUS VON TEAM DOGI

Filipe: "ich will dass die hintergrunde von den kacheln immer unviersum
artig ist, es muss richtig geil sein aber immer so dass man alles noch
gut erkkent. und das IN DER GANZEN WEBSITE VON TEAM DOGI. nur die
kacheln. [...] wichtig ist die form der kacheln soll gleich bleiben."

module.css hat eine kanonische KACHELLISTE -- 48 Klassen, siebenmal in
der Datei, von pruef-css-klassen gegeneinander gehalten. Eine achte
Abschrift in crew-haus.css waere die Sorte Fehler, die nicht auffaellt:
heute vollstaendig, bei der naechsten neuen Kachel lautlos nicht mehr.
Deshalb faerbt crew-haus.css keine einzige Kachel. module.css baut den
Grund jetzt aus vier Werten (--sternenfeld, -mass, -lage,
--modul-schleier), und das zweite Haus setzt nur diese vier um. Damit
hat jede Kachel der ganzen Adresse den Himmel -- auch die, die es noch
nicht gibt. Im Agenturhaus steht `none`: kein Pixel aendert sich.

ZWEI DINGE HAT ERST DIE MESSUNG GEFUNDEN, NICHT DAS NACHDENKEN:

1. Die Nebel standen zuerst oben links. Dort ist aber JEDE Kachel dieses
   Hauses schon von sich aus am hellsten -- ihr eigener Lichtverlauf
   laeuft bei allen aus derselben Richtung (155/150/158 Grad) --, und
   genau dort stehen ueberall die Ueberschriften. Hinter der leisesten
   Textzeile lagen dadurch 2,09 % der Bildpunkte unter 4,5:1; im
   Agenturhaus sind es an derselben Stelle 0,115 %. Nach dem Umzug in
   die beiden gegenueberliegenden Ecken: 0,22 % -- und der Nebel durfte
   dabei KRAEFTIGER werden (.80 statt .62), weil er nicht mehr auf dem
   hellsten Punkt liegt. Besser lesbar und deutlicher zu sehen; das ist
   selten und war hier umsonst zu haben.

2. Kleinere, dafuer hellere Sternkerne waren der falsche Weg: Der
   hellste Punkt blieb fast gleich, der Stern wurde nur unschaerfer.
   Entschieden hat die Deckkraft, nicht die Groesse.

Form unangetastet: Fase, Silhouette und die drei Eckwinkel werden vor
und nach dem Hauswechsel Zeichen fuer Zeichen verglichen.

pruef-kachel-universum.mjs (NEU, 37 Pruefungen, Port 4391) misst an
echten Bildpunkten und fragt nicht nach Durchschnitt allein, sondern
nach dem ANTEIL der Punkte unter 4,5:1 -- das unterscheidet einen Punkt
von einer Flaeche. Zwei Gegenproben: ein zu dunkler Text UND ein zu
heller Nebel muessen durchfallen.

MEINE SICHT -- "GENAU SO WIE SIE ES SEHEN"

Filipe: "oben bei meine sicht soll ich auch die sicht von allen jeden
moment sehen koennen und das genau genau so wie sie es sehen alles.
ausser die kalender daten oder chat daten wo ich nicht mit drin bin.."

Gemessen wurde nicht "mit Umschalter gegen ohne" -- das ist bei duenner
Datenlage ueberall gleich und beweist nichts. Gemessen wurde die
Antwort mit Umschalter gegen die Antwort, die die Person SELBST bekommt.
Das hat sechs Stellen gefunden:

* workspace-zentrale.js las `req.person.sicht` -- ein Feld, das es nicht
  gibt. Der Ausdruck war immer `undefined || req.person`, daneben ein
  ausfuehrlicher Kommentar, der genau das Richtige beschrieb. Die grosse
  Kachel zeigte verlaesslich die eigene Lage, waehrend die Zahlen
  darunter der fremden folgten -- zwei Wahrheiten in einer Kachel. Ein
  Tippfehler in einem Variablennamen macht nichts kaputt; er tut nur
  nichts, und genau deshalb faellt so etwas nie von selbst auf. Die
  Route hatte ausserdem ZWEI Personenvariablen; jetzt hat sie eine.
* sichtPerson() gab die angesehene Person ohne Feld `haus` zurueck --
  und nurHaus()/hausBedingung() fangen beide mit `haus !== "crew"` an.
  Jede fremde Sicht war damit eine Agentursicht: In der Sicht auf einen
  Modi kamen die Dateien, Personen und Berichte des anderen Hauses.
  Das Haus haengt jetzt an der ROLLE, nicht an der Adresse.
* leistung, profil, schulung, fruehwarnung, report und teamlage lasen
  weiterhin den Angemeldeten. Nur LESEN ist umgestellt, nie ein Recht --
  und weil DogFather ohnehin alles sehen darf, kann das nichts oeffnen,
  nur weniger zeigen.

Der Sicht-Umschalter zeichnete ausserdem nur die fuenf Rollen aus
bereiche.js; wer eine sechste hat, stand nicht darin. Dieselbe Luecke
wie in der Chat-Auswahl und der Personenliste, zum dritten Mal. Die
Ueberschrift kommt jetzt vom Server (`gruppe`), der Browser zeichnet,
was ankommt -- auch eine Rolle, deren Namen er nicht kennen darf.

DREI STELLEN FOLGEN BEWUSST NICHT: die Personenliste (aus ihr wird der
Umschalter gebaut -- folgte sie der Sicht, kaeme man aus einer fremden
nicht mehr heraus), die Auswahllisten beim Anlegen (Kategorien,
Empfaenger) und steckbrief/mein (ein Formular, das fremd liest und
eigen speichert, zerstoert Daten).

KALENDER UND CHAT BLEIBEN PRIVAT, auch mit Umschalter -- Termine,
Calls und Wiederholungen lesen ab jetzt immer die eigene Person. Eine
Pruefung musste dafuer umgedreht werden: pruef-sicht verlangte bis
heute das Gegenteil ("dafuer gibt es den Umschalter"). Die Gegenprobe
bleibt dieselbe Frage, nur andersherum -- Patrick selbst MUSS seine
Termine sehen, sonst hiesse "DogFather sieht sie nicht" nur, dass sie
niemand sieht. Dabei fiel auf, dass die Managerin gar keinen Call
hatte: Die Pruefung "bleibt privat" war nicht bestanden, sondern nicht
durchfuehrbar. Antwort darauf sind Daten, keine weichere Bedingung.

Gruen: pruef-sicht 84 (vorher 53), pruef-kachel-universum 37 (neu),
pruef-rollen 282, pruef-crew-adresse 129, pruef-start-ansicht 143,
pruef-kalender 104, pruef-haus-trennung 62, pruef-team-ampel 32,
pruef-team-stufen 26, pruef-css-klassen. Stempel 202609110209.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-11 02:10:15 +02:00

651 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
DIE LAGE DES MODI-TEAMS -- an einer Stelle (10.09.2026)
Wunsch Filipe: *"dan will ich auch dass vanvan und ich diese sachen
von den modis auch in der dogfather rolle sehen. da soll es eine
kategorie geben für uns beide nur sonst keinen wo wir dass alles
sehen und behandeln können. so wie mit dem ideen-board."*
DIE DOGFATHER-ROLLE UND DIE RECHTE HAND. Das sind Filipe und VanVan
(beide `admin`) sowie seit dem 10.09.2026 die Stellvertretung. Der
Blueprint V3.0 sagt in Kapitel 3.1 ueber sie: "Kann im Alltag
vertretend Entscheidungen treffen." Genau das passiert hier -- aus
einer Rueckmeldung wird eine Aufgabe oder eine begruendete Absage.
Ihr diese Seite zu verwehren hiesse, ihr die Vertretung zu geben und
den Ort dafuer wegzunehmen.
Fuer alle anderen gibt es diese Seite nicht: 404 wie bei einer
Adresse, die niemand kennt. Ein "kein Zugriff" waere die Auskunft,
dass es sie gibt.
SEHEN UND BEHANDELN, in dieser Reihenfolge: Die Seite zaehlt nur --
bewertet wird dort, wo die Punkte stehen. Jede Zahl ist deshalb ein
Verweis in den jeweiligen Bereich, mit der Person schon
vorausgewaehlt. Eine zweite Stelle zum Bewerten waere eine zweite
Stelle, an der es auseinanderlaeuft.
UND SIE IST KEINE UEBERWACHUNG. Dieselbe Ueberlegung steht schon
ueber team.html: Eine Seite mit Zahlen ueber Kollegen wird als
Kontrolle gelesen, und dann arbeitet niemand mehr offen damit.
Deshalb zaehlt sie, was NOCH NICHT BESPROCHEN ist -- nicht, wer wie
fleissig war. "Offen" heisst hier: Darueber habt ihr noch nicht
geredet. Es ist eine Liste fuer euch, keine Note fuer ihn.
===================================================================== */
import express from "express";
import {
db, sitzungLesen, istDogFather, fuehrtTeamDogi, heuteLokal, protokolliere, echteIp,
} from "./workspace.js";
import { MODI_PUNKTE } from "./workspace-modi-punkte.js";
import { checklistenTabellen } from "./workspace-checkliste.js";
/* Titel und Bereich zu einem Schluessel -- damit im Eingang steht,
WORUM es geht, und nicht nur ein Schluessel. */
const PUNKT = new Map();
for (const [bereich, gruppen] of Object.entries(MODI_PUNKTE)) {
for (const punkte of Object.values(gruppen)) {
for (const p of punkte) PUNKT.set(`${bereich}|${p.schluessel}`, p);
}
}
/* Die Kategorie, unter der eine daraus entstandene Aufgabe einsortiert
wird. Der Bereich sagt es schon -- eine zweite Zuordnung waere eine
zweite Gelegenheit, dass beide auseinanderlaufen. */
const KATEGORIE = { live: "technik", community: "community", technik: "technik" };
function gleicheHerkunft(req, res, next) {
const herkunft = req.get("origin");
if (!herkunft) return next();
let erlaubt;
try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; }
if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" });
next();
}
export const teamlageRouter = express.Router();
/* Wie viele Punkte es je Bereich ueberhaupt gibt. Einmal gerechnet --
die Saetze aendern sich nicht zur Laufzeit. */
const GESAMT = Object.fromEntries(
Object.entries(MODI_PUNKTE).map(([b, g]) => [b, Object.values(g).flat().length]));
/** Wer den Eingang oeffnen darf.
*
* An EINER Stelle und nicht dreimal `istDogFather(person) ||
* person.rolle === "hand"`: Diese Datei hat drei Schranken, und die
* vierte kommt mit der naechsten Route. Wer eine davon vergisst,
* merkt es nicht -- eine vergessene Schranke faellt nur dem auf, der
* hindurchgeht. */
/* =====================================================================
DIE DREI STUFEN (Blueprint V3.0, Kapitel 4.1)
Probe -- in der Einarbeitung, wird eingeschaetzt.
Standard -- der Normalfall: moderiert, arbeitet eigene Aufgaben ab.
Senior -- kann zusaetzlich neue Probe-Modis einschaetzen, mehrere
Kategorien gleichzeitig tragen und die rechte Hand bei
Abwesenheit vertreten.
SIE SIND EINE ARBEITSEINTEILUNG, KEINE RECHTEGRENZE -- jedenfalls
heute noch nicht. Das gehoert ausgesprochen, damit niemand sie
spaeter fuer eine haelt: Was ein Modi in dieser App darf, haengt an
der ROLLE. Die Stufe sagt, wo er im Team steht, und macht den
naechsten Schritt sichtbar. Die Rechte, die der Blueprint dort
nennt, liegen ausserdem groesstenteils gar nicht hier, sondern im
Stream (Timeout, Bann).
Die Reihenfolge ist die Steigerung -- sie steht hier einmal, damit
keine Liste im Browser eine eigene erfindet. */
export const STUFEN = ["probe", "standard", "senior"];
export const STUFEN_NAME = {
probe: "Probe",
standard: "Standard",
senior: "Senior",
};
/** NULL heisst "Probe", nicht "unbekannt".
*
* Ein dritter Zustand waere eine Frage, die niemand beantworten kann,
* und ein neuer Mensch faengt ohnehin in der Einarbeitung an. Die
* Abbildung steht an EINER Stelle -- stuende sie in jeder Abfrage,
* waere sie beim naechsten Mal in einer davon anders. */
function stufeVon(wert) {
return STUFEN.includes(String(wert || "")) ? String(wert) : "probe";
}
/* AUS DER EINEN QUELLE (10.09.2026). Hier stand
`istDogFather(person) || person?.rolle === "hand"` -- dieselbe Regel
noch einmal. Beim naechsten Mal (den Kategorie-Kanaelen) waere sie
ein drittes Mal dagestanden. Jetzt fragen alle dieselbe Funktion. */
function darfEingang(person) {
return fuehrtTeamDogi(person);
}
teamlageRouter.get("/workspace/api/teamlage", (req, res) => {
try {
const person = sitzungLesen(req);
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
/* 404 und nicht 403 -- siehe Kopf. */
if (!darfEingang(person)) return res.status(404).json({ fehler: "nicht_gefunden" });
/* DIE CHECKLISTEN-TABELLEN ENTSTEHEN BEIM ERSTEN AUFRUF EINER
CHECKLISTE -- diese Seite liest sie aber auch.
Wer den Eingang oeffnete, BEVOR je jemand eine Checkliste
angesehen hatte, bekam hier eine 503 ohne Erklaerung. Auf einer
frischen Anlage also beim allerersten Blick. Gefunden hat das
nicht der Betrieb, sondern pruef-team-stufen.mjs, die genau
diesen Zustand herstellt. */
checklistenTabellen();
/* AUCH DIE PAUSIERTEN (10.09.2026, Blueprint Kapitel 4).
Die Mitglieder-Kachel dort nennt ausdruecklich "Status
(aktiv/pausiert)" -- also muss man Pausierte auch SEHEN. Vorher
stand hier `AND aktiv = 1`: Wer jemanden pausierte, bei dem
verschwand er samt seiner offenen Rueckmeldungen aus dem
Eingang. Die warteten weiter auf eine Antwort, nur sah sie
niemand mehr.
Aktive zuerst, dann nach Namen -- wer pausiert ist, steht unten
und nicht mittendrin.
DIE RECHTE HAND GEHOERT DAZU (11.09.2026). Filipe: "die rechte
hand ist da um mir zu helfen aber auch wie die modis um mir zu
sagen was passt und nicht."
Bis hierher stand hier `rolle = 'modi'`. Damit war sie zwar die
Empfaengerin dieser Seite, kam auf ihr aber nicht vor -- und vor
allem: Ihre Rueckmeldungen landeten nirgends. Der Eingang weiter
unten sammelt genau aus dieser Liste; wer nicht darin steht, kann
melden, so viel er will, es kommt nicht an.
`id <> ?` LAESST DEN LESER SELBST HERAUS. Ohne das saehe die
rechte Hand eine Karte ueber sich selbst -- mit ihrer eigenen
Ampel, ihrer eigenen Stufe und ihren eigenen offenen Punkten.
Das ist kein Ueberblick ueber das Team, das ist ein Spiegel.
UND ZWAR DEN, DURCH DESSEN AUGEN GESEHEN WIRD (11.09.2026).
Hier stand `person.id`, also immer der Angemeldete. In der Sicht
auf die rechte Hand stand sie deshalb selbst mit auf ihrer
eigenen Seite -- genau der Spiegel, den der Absatz darueber
verhindern soll. Der Riegel eine Zeile hoeher (`darfEingang`)
bleibt beim Angemeldeten: Das ist ein Recht, keine Anzeige. */
const zeigt = req.sicht || person;
const modis = db().prepare(
`SELECT id, name, bild, letzter_login, aktiv, erstellt, stufe FROM personen
WHERE rolle IN ('hand','modi') AND id <> ?
ORDER BY aktiv DESC, name`).all(zeigt.id);
if (!modis.length) {
/* `eingang` auch hier -- die Oberflaeche darf nicht raten
muessen, ob das Feld fehlt oder leer ist. */
return res.json({
modis: [], eingang: [], angebote: [], bereiche: GESAMT, heute: heuteLokal(),
});
}
const platz = modis.map(() => "?").join(",");
const nummern = modis.map((m) => m.id);
const heute = heuteLokal();
/* ALLES IN VIER ABFRAGEN, nicht vier je Person. Bei zehn Modis
waeren das vierzig -- den Fehler hat das Haus bei den Terminen
schon einmal gemacht und ihn dort ausdruecklich vermerkt. */
const stand = new Map(); // person -> bereich -> {gut, verbessern}
for (const z of db().prepare(
`SELECT creator_id, bereich, stufe, COUNT(*) AS n FROM punkt_stand
WHERE creator_id IN (${platz}) GROUP BY creator_id, bereich, stufe`).all(...nummern)) {
if (!stand.has(z.creator_id)) stand.set(z.creator_id, {});
const b = stand.get(z.creator_id);
b[z.bereich] = b[z.bereich] || { gut: 0, verbessern: 0 };
if (z.stufe === "gut" || z.stufe === "verbessern") b[z.bereich][z.stufe] = z.n;
}
const aufgaben = new Map();
for (const z of db().prepare(
`SELECT verantwortlich_id AS p,
COUNT(*) AS offen,
SUM(CASE WHEN frist IS NOT NULL AND frist < ? THEN 1 ELSE 0 END) AS ueberfaellig
FROM aufgaben
WHERE verantwortlich_id IN (${platz})
AND status NOT IN ('erledigt', 'abgebrochen')
GROUP BY verantwortlich_id`).all(heute, ...nummern)) {
aufgaben.set(z.p, { offen: z.offen, ueberfaellig: z.ueberfaellig || 0 });
}
const ideen = new Map();
for (const z of db().prepare(
`SELECT erstellt_von AS p, COUNT(*) AS n FROM eintraege
WHERE bereich = 'ideen' AND erstellt_von IN (${platz}) GROUP BY erstellt_von`)
.all(...nummern)) {
ideen.set(z.p, z.n);
}
/* =================================================================
DER EINGANG: was die Modis gemeldet haben (10.09.2026)
Filipes Entscheidung: erst ein Vorschlag, dann eine Aufgabe.
Hier steht, was noch nicht behandelt ist -- offen heisst: Ihr
habt noch nicht darauf geantwortet. Was zur Aufgabe wurde oder
begruendet abgelehnt ist, faellt heraus; ein Eingang, der nie
leer wird, wird nicht mehr angesehen. */
const eingang = [];
for (const z of db().prepare(
`SELECT s.bereich, s.schluessel, s.creator_id, s.begruendung, s.am, p.name AS von_name
FROM punkt_stand s LEFT JOIN personen p ON p.id = s.von
WHERE s.creator_id IN (${platz})
AND s.stufe = 'verbessern' AND s.behandelt_am IS NULL
ORDER BY s.am DESC`).all(...nummern)) {
const punkt = PUNKT.get(`${z.bereich}|${z.schluessel}`);
/* Ein Punkt, den es nicht mehr gibt, wird uebersprungen -- aber
gezaehlt waere er sonst und niemand koennte ihn behandeln. */
if (!punkt) continue;
eingang.push({
bereich: z.bereich, schluessel: z.schluessel, person_id: z.creator_id,
titel: punkt.titel, hinweis: punkt.text,
begruendung: z.begruendung || "", am: z.am, von: z.von_name || null,
});
}
/* =================================================================
DIE ANGEBOTE (10.09.2026)
Vorschlaege der Modis, ueber die hier entschieden wird. Sie
stehen im selben Eingang wie die Rueckmeldungen -- EIN Ort, an
dem etwas auf eine Antwort wartet, nicht zwei. Wer zwei Eingaenge
hat, sieht irgendwann in den falschen. */
const angebote = db().prepare(
`SELECT e.id, e.art, e.titel, e.text, e.einsatz, e.bewertung, e.dringlichkeit,
e.datum, e.erstellt_von, p.name AS von_name
FROM eintraege e LEFT JOIN personen p ON p.id = e.erstellt_von
WHERE e.bereich = 'angebote' AND e.status = 'offen'
ORDER BY e.bewertung DESC, e.datum DESC`).all();
/* =================================================================
DIE VERFUEGBARKEITS-AMPEL (Blueprint Kapitel 7.1, 10.09.2026)
"Persoenlicher Kalender (privat): Inhalt ausschliesslich fuer die
jeweilige Person sichtbar; Owner/rechte Hand sehen nur die
Verfuegbarkeits-Ampel, nicht den Klartext."
DIE ERSTE HAELFTE STAND SCHON: termineSichtbar() gibt jedem nur
seine eigenen Termine -- ausdruecklich auch DogFather. Es fehlte
genau die zweite: zu sehen, OB jemand kann, ohne zu sehen, WAS er
vorhat.
DIESE ABFRAGE HOLT NIEMALS titel, beschreibung ODER ort. Nicht
"sie zeigt sie nicht an" -- sie liest sie gar nicht erst. Was
nicht abgefragt wird, kann auch nicht versehentlich in einer
Antwort landen, wenn jemand spaeter ein Feld durchreicht.
WARUM DIE ZUGEHOERIGKEIT HIER NOCH EINMAL STEHT statt ueber
termineSichtbar(): Jene Funktion beantwortet die Frage fuer EINE
Person. Bei zehn Modis waeren das zehn Abfragen -- der Fehler,
den diese Datei zwei Bildschirme weiter oben ausdruecklich
vermeidet. Die Aufzaehlung unten ist dieselbe Regel als
Mengenvereinigung: angelegt von mir, mir zugeordnet, ich bin das
Gegenueber, oder ich stehe in der Teilnehmerliste.
DASS BEIDE DASSELBE SAGEN, IST GEMESSEN und nicht behauptet:
pruef-team-ampel.mjs stellt beide Wege nebeneinander. Ohne diese
Probe waere die Ampel eines Tages leise falsch -- und niemand
haette einen Anhaltspunkt, denn sie zeigt ja nur eine Farbe. */
const ampelTage = 7;
const bisTag = new Date(Date.parse(`${heute}T12:00:00Z`) + (ampelTage - 1) * 86400000)
.toISOString().slice(0, 10);
const belegt = new Map(); // person -> tag -> { termine, minuten }
{
const platzhalter = platz;
const zeilen = db().prepare(`
WITH meins AS (
SELECT creator_id AS pid, id FROM termine WHERE creator_id IN (${platzhalter})
UNION
SELECT teilnehmer_id AS pid, id FROM termine WHERE teilnehmer_id IN (${platzhalter})
UNION
SELECT erstellt_von AS pid, id FROM termine WHERE erstellt_von IN (${platzhalter})
UNION
SELECT tn.person_id AS pid, tn.termin_id FROM termin_teilnehmer tn
WHERE tn.person_id IN (${platzhalter})
)
SELECT m.pid AS p, date(t.beginn) AS tag,
COUNT(*) AS termine, SUM(t.dauer_min) AS minuten
FROM meins m JOIN termine t ON t.id = m.id
WHERE date(t.beginn) BETWEEN ? AND ?
GROUP BY m.pid, date(t.beginn)`)
.all(...nummern, ...nummern, ...nummern, ...nummern, heute, bisTag);
for (const z of zeilen) {
if (!belegt.has(z.p)) belegt.set(z.p, {});
belegt.get(z.p)[z.tag] = { termine: z.termine, minuten: z.minuten || 0 };
}
}
/* DIE DREI STUFEN.
Drei Stunden sind die Grenze zwischen "geht noch was" und "der
Tag ist verplant" -- ein Sendeabend dauert etwa so lang. Der Wert
ist ein Anfangswert und steht hier als Zahl mit Namen, damit man
ihn an EINER Stelle aendern kann, wenn sich das im Alltag anders
anfuehlt.
NULL MINUTEN HEISST "frei", nicht "unbekannt": Ein leerer
Kalender ist eine Aussage. Wer nichts eingetragen hat, ist
ansprechbar -- genau das will man wissen. */
const VOLL_AB_MIN = 180;
const ampelFuer = (eintrag) => {
if (!eintrag || !eintrag.minuten) return "frei";
return eintrag.minuten >= VOLL_AB_MIN ? "voll" : "teils";
};
const tagesliste = [];
for (let i = 0; i < ampelTage; i++) {
tagesliste.push(new Date(Date.parse(`${heute}T12:00:00Z`) + i * 86400000)
.toISOString().slice(0, 10));
}
/* Wie viele Rueckmeldungen an seinen Punkten haengen -- das ist
das Gespraech, nicht die Note. */
const nachrichten = new Map();
for (const z of db().prepare(
`SELECT creator_id AS p, COUNT(*) AS n FROM punkt_nachricht
WHERE creator_id IN (${platz}) GROUP BY creator_id`).all(...nummern)) {
nachrichten.set(z.p, z.n);
}
res.json({
heute,
bereiche: GESAMT,
eingang,
angebote,
/* DIE STUFEN KOMMEN VOM SERVER, nicht aus einer Liste im Browser.
Eine zweite Aufzaehlung dort waere die Stelle, an der beim
naechsten Mal eine Stufe fehlt oder anders heisst -- genau so
stand am 07.09. unter dem Codefeld "PERSOENLICHER ZUGANGSCODE ·
undefined", weil eine Rolle in der Browser-Liste vergessen
worden war. Einmal geschrieben, einmal geaendert. */
stufen: STUFEN.map((wert) => ({ wert, name: STUFEN_NAME[wert] })),
modis: modis.map((m) => {
const s = stand.get(m.id) || {};
const je = {};
for (const [b, gesamt] of Object.entries(GESAMT)) {
const gut = s[b]?.gut || 0;
const verbessern = s[b]?.verbessern || 0;
je[b] = { gesamt, gut, verbessern, offen: Math.max(gesamt - gut - verbessern, 0) };
}
return {
id: m.id,
name: m.name,
bild: m.bild ? `/workspace/api/steckbrief/bild/${m.bild}` : null,
letzter_login: m.letzter_login,
/* Die drei Felder aus Kapitel 4: Status, "dabei seit", Stufe. */
aktiv: !!m.aktiv,
seit: m.erstellt || null,
stufe: stufeVon(m.stufe),
/* NUR DIE AMPEL. Kein Titel, kein Ort, keine Beschreibung --
die stehen nicht einmal in der Abfrage. Die Zahl der
Termine und die belegten Minuten sagen, wie voll es ist,
ohne zu sagen, womit. */
verfuegbar: tagesliste.map((tag) => {
const e = (belegt.get(m.id) || {})[tag];
return { tag, ampel: ampelFuer(e), termine: e?.termine || 0, minuten: e?.minuten || 0 };
}),
aufgaben: aufgaben.get(m.id) || { offen: 0, ueberfaellig: 0 },
ideen: ideen.get(m.id) || 0,
nachrichten: nachrichten.get(m.id) || 0,
bereiche: je,
};
}),
});
} catch (fehler) {
console.error("[workspace] Team-Lage:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* =====================================================================
EINE MELDUNG BEHANDELN (10.09.2026)
Filipes Entscheidung: *"Erst ein Vorschlag, den du annimmst."* Zwei
Antworten, und beide kommen beim Modi an:
annehmen -> daraus wird eine Aufgabe (fuer den, der drueckt)
ablehnen -> mit Begruendung, ohne dass etwas entsteht
WARUM BEIDE EINE NACHRICHT SCHREIBEN: Eine Meldung, auf die nichts
folgt, sieht fuer den Modi aus wie eine, die niemand gelesen hat --
und beim dritten Mal meldet er nichts mehr. Die Nachricht haengt am
Punkt (punkt_nachricht), also genau dort, wo er sie beim naechsten
Mal wiederfindet.
DIE AUFGABE GEHOERT DEM, DER DRUECKT. Filipe oder VanVan -- wer
annimmt, nimmt sie sich. Sie einem Dritten zuzuweisen waere eine
Entscheidung ueber jemand anderen, ohne ihn zu fragen.
===================================================================== */
/* =====================================================================
DIE STUFE SETZEN (Blueprint Kapitel 4.1, Punkt 3: "Rechte anpassen")
NUR DOGFATHER, nicht die rechte Hand. Der Blueprint trennt das
ausdruecklich: Sie hat den gleichen UEBERBLICK, aber
"Verwaltungsrechte optional durch Owner freischaltbar" -- also
standardmaessig aus. Wer im Team auf welcher Stufe steht, ist eine
Personalentscheidung; sie gehoert dem, der die Leute holt.
404 statt 403, wie ueberall auf dieser Seite: Wer nicht darf, muss
nicht erfahren, dass es den Weg gibt.
===================================================================== */
teamlageRouter.put("/workspace/api/teamlage/stufe", gleicheHerkunft,
express.json({ limit: "2kb" }), (req, res) => {
try {
const person = sitzungLesen(req);
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
if (!istDogFather(person)) return res.status(404).json({ fehler: "nicht_gefunden" });
const personId = Number(req.body?.person_id) || 0;
const stufe = String(req.body?.stufe || "");
if (!STUFEN.includes(stufe)) {
return res.status(400).json({ fehler: "Unbekannte Stufe." });
}
/* NUR AN EINEM MODI. Ein Manager oder Creator hat keine Stufe --
sie stuende in der Datenbank und wuerde nirgends angezeigt,
also waere sie ein stiller Datensatz, den niemand mehr
zuordnen kann. Und die rechte Hand hat auch keine: Sie ist
eine Rolle, keine Stufe darin. */
const ziel = db().prepare(
"SELECT id, name, rolle, stufe FROM personen WHERE id = ?").get(personId);
if (!ziel || ziel.rolle !== "modi") {
return res.status(404).json({ fehler: "nicht_gefunden" });
}
const vorher = STUFEN.includes(String(ziel.stufe || "")) ? String(ziel.stufe) : "probe";
if (vorher === stufe) return res.json({ ok: true, stufe, geaendert: false });
db().prepare("UPDATE personen SET stufe = ? WHERE id = ?").run(stufe, ziel.id);
/* Ins Protokoll, weil es eine Personalentscheidung ist: Wer sie
wann getroffen hat, muss spaeter nachvollziehbar sein -- gerade
wenn jemand zurueckgestuft wird. */
protokolliere("stufe_gesetzt", {
personId: ziel.id, rolle: ziel.rolle, ip: echteIp(req),
detail: `${ziel.name}: ${vorher} -> ${stufe}`,
});
return res.json({ ok: true, stufe, geaendert: true });
} catch (fehler) {
console.error("[workspace] Stufe setzen:", fehler?.message);
return res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
teamlageRouter.post("/workspace/api/teamlage/behandeln", gleicheHerkunft,
express.json({ limit: "16kb" }), (req, res) => {
try {
const person = sitzungLesen(req);
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
if (!darfEingang(person)) return res.status(404).json({ fehler: "nicht_gefunden" });
const bereich = String(req.body?.bereich || "");
const schluessel = String(req.body?.schluessel || "");
const personId = Number(req.body?.person_id) || 0;
const art = String(req.body?.art || "");
const grund = String(req.body?.grund ?? "").trim().slice(0, 2000);
if (!["aufgabe", "abgelehnt"].includes(art)) {
return res.status(400).json({ fehler: "Unbekannte Art." });
}
if (!PUNKT.has(`${bereich}|${schluessel}`) || !personId) {
return res.status(404).json({ fehler: "nicht_gefunden" });
}
/* EINE ABSAGE OHNE GRUND IST KEINE ANTWORT. Wer nichts dazu
schreibt, laesst den Modi genauso ratlos zurueck wie
Schweigen -- nur mit dem Unterschied, dass die Meldung jetzt
auch noch aus dem Eingang verschwunden ist. */
if (art === "abgelehnt" && grund.length < 3) {
return res.status(400).json({ fehler: "Bitte kurz sagen, warum nicht." });
}
const meldung = db().prepare(
`SELECT begruendung FROM punkt_stand
WHERE bereich = ? AND schluessel = ? AND creator_id = ?
AND stufe = 'verbessern' AND behandelt_am IS NULL`)
.get(bereich, schluessel, personId);
/* Schon behandelt oder gar nicht gemeldet -- beides ist "gibt es
hier nicht". Zwei Antworten waeren zwei Faelle, die die
Oberflaeche unterscheiden muesste, ohne dass es hilft. */
if (!meldung) return res.status(404).json({ fehler: "nicht_gefunden" });
const punkt = PUNKT.get(`${bereich}|${schluessel}`);
const jetzt = new Date().toISOString();
let aufgabeId = null;
if (art === "aufgabe") {
const { lastInsertRowid } = db().prepare(`INSERT INTO aufgaben
(titel, beschreibung, status, prioritaet, verantwortlich_id,
kategorie, erstellt, erstellt_von)
VALUES (?,?,'offen','mittel',?,?,?,?)`).run(
punkt.titel,
/* Der Satz des Modis steht IN der Aufgabe -- ohne ihn muesste
man zurueckblaettern, um zu wissen, worum es ging. */
meldung.begruendung
? `Gemeldet aus „${punkt.titel}“:
${meldung.begruendung}`
: `Gemeldet aus „${punkt.titel}“.`,
person.id, KATEGORIE[bereich] || "sonstiges", jetzt, person.id);
aufgabeId = Number(lastInsertRowid);
}
db().prepare(
`UPDATE punkt_stand
SET behandelt_am = ?, behandelt_von = ?, behandelt_art = ?, aufgabe_id = ?
WHERE bereich = ? AND schluessel = ? AND creator_id = ?`)
.run(jetzt, person.id, art, aufgabeId, bereich, schluessel, personId);
db().prepare(`INSERT INTO punkt_nachricht
(bereich, schluessel, creator_id, text, von, am) VALUES (?,?,?,?,?,?)`)
.run(bereich, schluessel, personId,
art === "aufgabe"
? `Danke – daraus ist eine Aufgabe geworden.${grund ? " " + grund : ""}`
: `Danke fürs Melden. Das machen wir nicht: ${grund}`,
person.id, jetzt);
protokolliere("meldung_behandelt", {
personId: person.id, rolle: person.rolle, ip: echteIp(req),
detail: `${bereich}/${schluessel} -> ${art}`.slice(0, 120),
});
res.json({ ok: true, art, aufgabe_id: aufgabeId });
} catch (fehler) {
console.error("[workspace] Meldung behandeln:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* =====================================================================
UEBER EIN ANGEBOT ENTSCHEIDEN (10.09.2026)
Filipes Wahl: *"Angebote an dich zur Entscheidung ... Bei Annahme
entstehen die Aufgaben fuer alle Beteiligten auf einmal."*
BEI ANNAHME ZWEI AUFGABEN, nicht eine:
* der Modi, der es vorgeschlagen hat, setzt es um
* und wenn im Angebot steht, was DogFather dafuer tun muesste,
bekommt der, der annimmt, genau das als eigene Aufgabe
Die zweite ist der Kern von Filipes Wunsch ("entscheiden was ich
machen muss"). Ohne sie waere eine Annahme ein Nicken, aus dem nichts
folgt -- und beim dritten Mal schlaegt niemand mehr etwas vor.
ABGELEHNT BRAUCHT EINEN GRUND, wie bei den Rueckmeldungen: Eine
Absage ohne Begruendung ist fuer den, der sie bekommt, dasselbe wie
Schweigen -- nur endgueltiger.
===================================================================== */
teamlageRouter.post("/workspace/api/teamlage/angebot", gleicheHerkunft,
express.json({ limit: "16kb" }), (req, res) => {
try {
const person = sitzungLesen(req);
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
if (!darfEingang(person)) return res.status(404).json({ fehler: "nicht_gefunden" });
const id = Number(req.body?.id) || 0;
const art = String(req.body?.art || "");
const grund = String(req.body?.grund ?? "").trim().slice(0, 2000);
if (!["angenommen", "abgelehnt"].includes(art)) {
return res.status(400).json({ fehler: "Unbekannte Art." });
}
if (art === "abgelehnt" && grund.length < 3) {
return res.status(400).json({ fehler: "Bitte kurz sagen, warum nicht." });
}
const angebot = db().prepare(
`SELECT id, titel, text, einsatz, erstellt_von FROM eintraege
WHERE id = ? AND bereich = 'angebote' AND status = 'offen'`).get(id);
/* Schon entschieden oder gar keins -- beides ist "gibt es hier
nicht". Zwei Antworten waeren zwei Faelle, die die Oberflaeche
unterscheiden muesste, ohne dass es hilft. */
if (!angebot) return res.status(404).json({ fehler: "nicht_gefunden" });
const jetzt = new Date().toISOString();
const angelegt = [];
if (art === "angenommen") {
if (angebot.erstellt_von) {
const { lastInsertRowid } = db().prepare(`INSERT INTO aufgaben
(titel, beschreibung, status, prioritaet, verantwortlich_id,
kategorie, erstellt, erstellt_von)
VALUES (?,?,'offen','mittel',?,'planung',?,?)`).run(
`Umsetzen: ${angebot.titel}`, angebot.text || null,
angebot.erstellt_von, jetzt, person.id);
angelegt.push({ fuer: angebot.erstellt_von, id: Number(lastInsertRowid) });
}
if (angebot.einsatz) {
const { lastInsertRowid } = db().prepare(`INSERT INTO aufgaben
(titel, beschreibung, status, prioritaet, verantwortlich_id,
kategorie, erstellt, erstellt_von)
VALUES (?,?,'offen','mittel',?,'planung',?,?)`).run(
angebot.einsatz, `Aus dem Angebot „${angebot.titel}“.`,
person.id, jetzt, person.id);
angelegt.push({ fuer: person.id, id: Number(lastInsertRowid) });
}
}
db().prepare(
"UPDATE eintraege SET status = ?, geaendert = ? WHERE id = ?")
.run(art, jetzt, angebot.id);
/* Die Antwort gehoert an das Angebot, damit der Modi sie dort
wiederfindet, wo er nachsieht. `text` ist das einzige Feld
dafuer -- angehaengt, nicht ersetzt. */
const antwort = art === "angenommen"
? `
— Angenommen von ${person.name}${grund ? ": " + grund : "."}`
: `
— Nicht umgesetzt (${person.name}): ${grund}`;
db().prepare("UPDATE eintraege SET text = COALESCE(text, '') || ? WHERE id = ?")
.run(antwort, angebot.id);
protokolliere("angebot_entschieden", {
personId: person.id, rolle: person.rolle, ip: echteIp(req),
detail: `#${angebot.id} ${art}`.slice(0, 120),
});
res.json({ ok: true, art, aufgaben: angelegt.length });
} catch (fehler) {
console.error("[workspace] Angebot entscheiden:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});