Files
dogfather-universe/server/workspace-entwicklung.js
T
DogFatherGit 3a49e30aa4 Die Probezeit hat einen Inhalt -- und man sieht, wer was gesehen hat
Filipe: "wie talente bewertet werden, aufgaben bekommen, analysiert
werden von vanvan und mir, alles moegliche, wie gesagt ich will hoch
profissionel arbeiten."

WAS GEMESSEN FEHLTE

  Die Stufe "Probe" hatte eine Frist von 30 Tagen, einen Buddy, ein
  Datum -- und einen Satz: "Nach 30 Tagen: uebernehmen oder sauber
  beenden." Worauf diese Entscheidung sich gruenden soll, stand
  nirgends. Eine Probezeit ohne Inhalt ist keine Probe, sondern eine
  Wartezeit; am dreissigsten Tag entscheidet dann das Bauchgefuehl
  dessen, der zuletzt etwas gesehen hat.

  Und: `von_id` stand seit dem Bau in talent_merkmal und wurde nie
  ausgeliefert. Man sah, DASS ein Merkmal gesetzt ist -- nicht, von
  wem. Bei zwei Leuten, die unabhaengig beobachten, ist genau das die
  Auskunft.

DER PROBEPLAN

  Zwoelf Schritte in drei Phasen: mitlaufen -> selbst machen -> allein.
  Die Reihenfolge ist der Punkt (Discord empfiehlt sie so, in der
  Ausbildung heisst sie "see one, do one"). Wer die mittlere Stufe
  ueberspringt, hat am Tag 30 zwei Sorten Kandidaten, die man nicht
  unterscheiden kann.

  JEDER SCHRITT HAT EINEN ZUSTAENDIGEN -- sechs das Talent, drei der
  Buddy, drei die Leitung. Ohne diese Angabe wandern in jeder Probezeit
  dieselben drei an niemanden: Der Buddy denkt, die Leitung macht es.

  KEINE PUNKTZAHL. Am Ende stehen drei Fragen, die ein Mensch
  beantwortet; die Haken sagen nur, ob er genug gesehen hat. Und
  "entscheidbar" haengt an DREI Schritten, nicht an einer Anzahl: Wer
  nie allein gearbeitet hat und wen das Team nie beurteilt hat, ueber
  den weiss man nichts, egal wie viele andere Haken stehen. Neun von
  zwoelf reichen nicht, drei genuegen -- wenn es die richtigen sind.

WAS DAS HINSEHEN GEFUNDEN HAT

  `.t-schritt` gibt es auf dieser Seite schon -- es ist die Zeile mit
  dem naechsten Schritt der Trichterstufe. Meine Regeln standen
  SPAETER in derselben Datei und haetten sie ueberschrieben. Gefunden
  hat das keine Ueberlegung, sondern das Nachzaehlen im Browser: 13
  Elemente mit `.t-schritt`, obwohl der Plan zwoelf hat.

ZWEI PRUEFUNGEN WAREN SCHON VORHER ROT (gemessen mit git stash)

  pruef-schritt (6 rot) und pruef-befinden (4 rot) -- seit den
  Erweiterungen von heute frueh: aus sechs Befinden-Fragen wurden
  zwoelf, aus vier Kategorien sechs, und die Personenkachel zaehlt seit
  heute in Kaesten statt in einem Satz. Die Zahlen standen als feste
  Werte in den Pruefdateien.

  Nicht die Zahlen angepasst, sondern ABGELEITET: aus
  ENTWICKLUNG_BLOECKE und ENTWICKLUNG_PUNKTE.befinden. Eine feste Zahl
  ist eine Rechnung von gestern; wer sie nachtraegt, traegt sie beim
  naechsten Mal wieder nach.

GEPRUEFT

  pruef-nachwuchs 123 -> 152. Vier Gegenproben, jede trifft ihr Ziel:
  Liste auch vor der Probe -> 1 rot, Abhaken ohne Stufenpruefung -> 7
  rot, "entscheidbar" nach Anzahl statt nach den drei -> 3 rot, Name
  nicht mitgeliefert -> 1 rot.

  Dazu gruen: schritt 65, befinden 75, entwicklung 43, uebergang,
  neue-seiten, rechtetafel, css-klassen, struktur, deutsche-texte,
  modi-wortleck.
2026-09-17 16:15:21 +02:00

1211 lines
53 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.
/* =====================================================================
Entwicklung & Nachwuchs — die Mechanik (11.09.2026)
=====================================================================
Filipe: *„ich will dass diese zwei kategorien perfektionniert werden,
ich will dass fertige aufgaben da stehen und so … so dass dogfather
und rechte hand es so einfach wie möglich haben aber so professionell
wie auch nur möglich."*
Die Kataloge stehen in workspace-entwicklungs-punkte.js und
workspace-talent-punkte.js. Hier steht, was mit ihnen passiert.
---------------------------------------------------------------------
EINE EIGENE TABELLE — UND WARUM DER PLAN DARIN FALSCH LAG
Der Plan im Vault sagt: „zwei Spalten, keine neue Tabelle" — der
Stand solle in `punkt_stand` liegen, derselben Tabelle wie die
Checkliste. Nachgemessen geht das NICHT:
PRIMARY KEY (bereich, schluessel, creator_id)
Es passt genau EIN Stand je Punkt und Person hinein. Der Kern dieses
Bereichs ist aber, dass DogFather und die rechte Hand UNABHAENGIG
voneinander setzen und die Abweichung das eigentliche Ergebnis ist.
Dafuer muesste der Schluessel um den Beurteiler wachsen — also ein
Tabellenneubau an lebenden Checklisten-Daten, fuer einen Zweck, der
mit der Checkliste nichts zu tun hat.
Deshalb eine eigene Tabelle. Der Plan war ein Wegweiser, keine
Wahrheit; gemessen wurde vor dem Bauen, nicht danach.
---------------------------------------------------------------------
DREI RIEGEL, DIE VON ANFANG AN STEHEN
1. BLOCK 5 GEHOERT DER PERSON. „Wie geht es dir damit?" beantwortet
nur sie selbst — lesend UND schreibend. Auch DogFather nicht. Was
nach aussen dringt, ist eine Ampel ohne Namen.
2. DER STAND DES ANDEREN KOMMT ERST NACH DEM EIGENEN. Wer zuerst
sieht, was der andere angeklickt hat, klickt dasselbe — das ist
kein Verdacht, sondern ein gut belegter Effekt. Er wird hier nicht
durch Disziplin verhindert, sondern dadurch, dass die Auskunft
erst gar nicht ausgeliefert wird.
3. TALENTE ENTHAELT NAMEN VON MENSCHEN, DIE NICHTS DAVON WISSEN.
Der Bereich gehoert DogFather und der rechten Hand. Kein Modi,
keine Agentur, und ganz sicher nicht die Community.
---------------------------------------------------------------------
UND EINE ABWEICHUNG VOM PLAN, DIE FILIPES SATZ GESCHULDET IST
Der Plan verlangt einen Anlass (die drei SBI-Felder) bei JEDEM Klick
ausser „kann ich nicht sagen". Bei 28 Punkten je Person waeren das 28
Textfelder — und „so einfach wie moeglich" war die andere Haelfte des
Auftrags. Pflicht ist der Anlass deshalb dort, wo er etwas
entscheidet: bei **„Da hakt es"**. Bei „Laeuft" und „Waechst gerade"
steht dasselbe Feld da, freiwillig — wer etwas Konkretes gesehen hat,
schreibt es hin, und beim Gespraech steht es da.
===================================================================== */
import express from "express";
import {
db, sitzungLesen, protokolliere, echteIp, fuehrtTeamDogi, ROLLEN_NAME,
ROLLEN_SORTIERUNG,
} from "./workspace.js";
import {
ENTWICKLUNG_BLOECKE, ENTWICKLUNG_PUNKTE, ENTWICKLUNG_STUFEN,
ENTWICKLUNG_ERWARTUNG, ENTWICKLUNG_FREMD, ENTWICKLUNG_SELBST,
ENTWICKLUNG_NACH_SCHLUESSEL,
} from "./workspace-entwicklungs-punkte.js";
import {
TALENT_STUFEN, TALENT_GRUPPEN, TALENT_MERKMALE, TALENT_WARNZEICHEN,
// (Probeplan siehe unten -- eigene Datei, eigener Gegenstand)
SCHICHT_WEIT_TAGE, SCHICHT_MAX_TAGE, ERSTER_TAG,
TALENT_NACH_SCHLUESSEL, talentStufe, talentNaechste, ansprechtext,
} from "./workspace-talent-punkte.js";
import {
PROBE_PHASEN, PROBE_SCHRITTE, PROBE_WER, PROBE_ALLE, PROBE_FRAGEN,
PROBE_NACH_SCHLUESSEL, probeStand,
} from "./workspace-probeplan.js";
export const entwicklungRouter = express.Router();
const jetzt = () => new Date().toISOString();
const TAG = 86400000;
const ANLASS_MAX = 1200;
/* WANN EINE EINSCHAETZUNG VERBLASST.
Nach 90 Tagen zaehlt ein Stand nicht mehr als aktuell. Er wird NICHT
geloescht -- man soll sehen, dass etwas alt ist, statt dass es
verschwindet. Eine Akte, die alles behaelt, hat hier nichts zu
suchen; eine, die heimlich vergisst, auch nicht. */
export const VERBLASST_NACH_TAGEN = 90;
/* Die Rollen, ueber die eine Entwicklungskarte gefuehrt wird. Das Team
von Dogi -- nicht die Agentur, nicht die Community. Als Liste und
nicht als "alle ausser X": Eine neue Rolle soll hier NICHT
automatisch eine Personalakte bekommen. */
const KARTEN_ROLLEN = ["hand", "modi"];
/** Wer einen Neuen einarbeiten kann.
*
* DIESELBEN WIE BEI DER KARTE, PLUS DOGFATHER. Er steht nicht ueber
* dem Team, er gehoert dazu -- und wer eine Probe begleitet, macht
* dieselbe Arbeit wie alle anderen. Ein Manager oder Scout dagegen
* arbeitet im anderen Haus und kann niemanden durch eine Schicht
* begleiten, die er selbst nicht macht. */
const BUDDY_ROLLEN = ["admin", "hand", "modi"];
/** Welchen Zugang der letzte Schritt des Trichters anlegt.
*
* ER STEHT HIER UND NICHT IM BROWSER (16.09.2026). Die Talente-Seite
* hatte `rolle: 'modi'` als festen Text im Skript -- und jede Datei
* unter `workspace/` liefert der Server an JEDEN aus, der die Adresse
* kennt. Nachgemessen am 16.09.: `talente.js` kommt ohne jede
* Anmeldung mit 200 aus dem offenen Netz, 25 KB Quelltext, der
* Rollenname mittendrin.
*
* Damit stand der verborgene Zugang in einer Datei, die man nicht
* einmal geschenkt bekommen muss -- man kann sie abrufen. Die
* Rollentabelle nebenan sagt seit dem 11.09. genau das Richtige dazu:
* „Der Name steht hier und NICHT in einer Datei, die jeder
* herunterlaedt -- ausgeliefert wird er nur an den, der ihn selbst
* traegt, und an die, die ihn sehen duerfen."
*
* Die Seite fragt ihn deshalb ab, statt ihn zu kennen. Die Antwort
* geht ueber `nurLeitung` -- also nur an DogFather und die rechte
* Hand. */
const ZUGANG_ROLLE = "modi";
/* =====================================================================
DIE TABELLEN
===================================================================== */
let bereit = false;
/** Auch von der Befindensseite aufgerufen.
*
* Sie liest `entwicklung_stand`, legt die Tabelle aber nicht selbst an
* -- eine zweite Abschrift derselben Spalten waere die Liste, die beim
* naechsten Zuwachs auseinanderlaeuft. Gefunden hat das die Pruefung:
* Wer die neue Seite oeffnete, ohne dass vorher jemand die
* Entwicklungsseite besucht hatte, bekam "no such table". */
export function tabellen() {
if (bereit) return;
db().exec(`
/* Eine Einschaetzung: WER hat bei WEM zu WELCHEM Punkt was gesetzt.
Der Beurteiler steht IM Schluessel. Genau daran ist die Idee
gescheitert, dieselbe Tabelle wie die Checkliste zu benutzen --
dort passt nur ein Stand je Punkt und Person hinein. */
CREATE TABLE IF NOT EXISTS entwicklung_stand (
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
schluessel TEXT NOT NULL,
von_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
stufe TEXT NOT NULL CHECK (stufe IN ('laeuft','waechst','hakt','unklar')),
anlass TEXT,
am TEXT NOT NULL,
PRIMARY KEY (person_id, schluessel, von_id)
);
CREATE INDEX IF NOT EXISTS idx_entwicklung_person
ON entwicklung_stand (person_id, am);
/* Ein angeklicktes Merkmal an einem Kandidaten.
KEIN von_id IM SCHLUESSEL, anders als oben -- und das ist
Absicht: Bei einem Talent geht es nicht um zwei Meinungen,
sondern darum, OB etwas beobachtet wurde. Zwei Leute, die
dasselbe gesehen haben, haben es einmal gesehen. Wer es gesetzt
hat, steht trotzdem dabei. */
CREATE TABLE IF NOT EXISTS talent_merkmal (
eintrag_id INTEGER NOT NULL REFERENCES eintraege(id) ON DELETE CASCADE,
schluessel TEXT NOT NULL,
von_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
am TEXT NOT NULL,
PRIMARY KEY (eintrag_id, schluessel)
);
/* Die Trichterstufe eines Kandidaten.
EIGENE TABELLE UND NICHT eintraege.status: Der Status dort hat
drei Werte fuer etwas ganz anderes (offen/erledigt/angenommen),
und die Stufe braucht ausserdem ein SEIT -- ohne das gibt es
keine Standzeit, und ohne Standzeit wird aus dem Trichter wieder
eine Liste. */
/* WAS IN DER PROBEZEIT SCHON PASSIERT IST (17.09.2026).
Filipe: "wie talente ... aufgaben bekommen, analysiert werden von
vanvan und mir."
EIGENE TABELLE UND NICHT DIE AUFGABEN: Ein Talent ist ein EINTRAG,
kein Zugang -- es gibt keine Person, der man eine Aufgabe geben
koennte. Und die Schritte gehoeren nicht alle ihm: drei davon tut
der Buddy, drei die Leitung. Eine Aufgabe auf dem Brett waere
damit entweder herrenlos oder an der falschen Person.
MIT von_id UND am, anders als bei den Merkmalen mit Absicht
SICHTBAR: Bei einem Schritt zaehlt, WER ihn gesehen hat. "Das
Team wurde gefragt" von dir und von der rechten Hand sind zwei
verschiedene Auskuenfte. */
CREATE TABLE IF NOT EXISTS talent_probe (
eintrag_id INTEGER NOT NULL REFERENCES eintraege(id) ON DELETE CASCADE,
schluessel TEXT NOT NULL,
von_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
am TEXT NOT NULL,
notiz TEXT,
PRIMARY KEY (eintrag_id, schluessel)
);
CREATE TABLE IF NOT EXISTS talent_stufe (
eintrag_id INTEGER PRIMARY KEY REFERENCES eintraege(id) ON DELETE CASCADE,
stufe TEXT NOT NULL
CHECK (stufe IN ('aufgefallen','beobachtet','angesprochen','probe','imteam')),
seit TEXT NOT NULL,
von_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
notiz TEXT
);
`);
/* DIE BEIDEN SPALTEN DES ÜBERGANGS (15.09.2026).
Sie stehen hier und nicht in der Umstellungsliste von workspace.js:
Die ueberspringt eine Tabelle stillschweigend, wenn es sie noch
nicht gibt -- und talent_stufe entsteht erst beim ersten Aufruf
dieser Seite, nicht beim Start. Hier, zwei Zeilen nach dem CREATE,
ist sie sicher da.
ON DELETE SET NULL beim Buddy: Wer geht, nimmt die Probe eines
anderen nicht mit. Die Karte verliert den Namen und faellt damit
genau dort auf, wo jemand einen neuen Buddy bestimmen muss. */
for (const [spalte, typ] of [
["buddy_id", "INTEGER REFERENCES personen(id) ON DELETE SET NULL"],
["erste_schicht", "TEXT"],
]) {
try {
const da = db().prepare("PRAGMA table_info(talent_stufe)").all().map((s) => s.name);
if (!da.includes(spalte)) {
db().exec(`ALTER TABLE talent_stufe ADD COLUMN ${spalte} ${typ}`);
console.log(`[talente] Spalte '${spalte}' ergaenzt.`);
}
} catch (f) { console.error(`[talente] Spalte '${spalte}':`, f?.message); }
}
bereit = true;
}
/* =====================================================================
DIE HUERDEN
===================================================================== */
function angemeldet(req, res, next) {
const person = sitzungLesen(req);
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
req.person = person;
try { tabellen(); } catch (f) {
console.error("[entwicklung] Tabellen:", f?.message);
return res.status(503).json({ fehler: "nicht_verfuegbar" });
}
next();
}
/** Nur DogFather und die rechte Hand. 404 und nicht 403 -- wer „kein
* Zugriff" liest, fragt als Naechstes, wer denn dann. */
function nurLeitung(req, res, next) {
if (fuehrtTeamDogi(req.person)) return next();
return res.status(404).json({ fehler: "nicht_gefunden" });
}
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();
}
entwicklungRouter.use("/workspace/api/entwicklung", angemeldet);
entwicklungRouter.use("/workspace/api/talente", angemeldet);
entwicklungRouter.use("/workspace/api/talente", nurLeitung);
const nummer = (x) => { const n = Number(x); return Number.isInteger(n) && n > 0 ? n : 0; };
const text = (x, max) => String(x ?? "").trim().slice(0, max);
const tageSeit = (iso) => {
const t = Date.parse(iso || "");
return Number.isFinite(t) ? Math.floor((Date.now() - t) / TAG) : null;
};
/* =====================================================================
ENTWICKLUNG
===================================================================== */
/* Der Katalog, wie ihn DIESE Person sehen darf.
Block 5 faellt fuer alle ausser der Person selbst heraus -- und zwar
schon hier, nicht erst in der Oberflaeche. */
function katalogFuer(person, ueberMichSelbst) {
return ENTWICKLUNG_BLOECKE
.filter((b) => !b.nurSelbst || ueberMichSelbst)
.map((b) => ({ ...b, punkte: ENTWICKLUNG_PUNKTE[b.schluessel] || [] }));
}
entwicklungRouter.get("/workspace/api/entwicklung/lage", (req, res) => {
try {
const leitung = fuehrtTeamDogi(req.person);
const platz = KARTEN_ROLLEN.map(() => "?").join(", ");
const personen = leitung
? db().prepare(`SELECT id, name, rolle, erstellt FROM personen
WHERE rolle IN (${platz}) AND aktiv = 1
ORDER BY ${ROLLEN_SORTIERUNG}, name COLLATE NOCASE`)
.all(...KARTEN_ROLLEN)
: [];
/* Je Person: wie viele Punkte ICH schon gesetzt habe, wie viele
davon verblasst sind, und wie viele wir beide verschieden sehen.
Die Abweichung ist das Ergebnis -- deshalb steht sie schon in
der Uebersicht und nicht erst in der Karte. */
const gesetzt = db().prepare(
"SELECT person_id, COUNT(*) AS n FROM entwicklung_stand WHERE von_id = ? GROUP BY person_id")
.all(req.person.id);
const meineZahl = new Map(gesetzt.map((z) => [z.person_id, z.n]));
const alt = db().prepare(
`SELECT person_id, COUNT(*) AS n FROM entwicklung_stand
WHERE von_id = ? AND am < ? GROUP BY person_id`)
.all(req.person.id, new Date(Date.now() - VERBLASST_NACH_TAGEN * TAG).toISOString());
const alteZahl = new Map(alt.map((z) => [z.person_id, z.n]));
const anders = db().prepare(
`SELECT a.person_id, COUNT(*) AS n
FROM entwicklung_stand a JOIN entwicklung_stand b
ON a.person_id = b.person_id AND a.schluessel = b.schluessel
WHERE a.von_id = ? AND b.von_id <> ? AND a.stufe <> b.stufe
AND a.stufe <> 'unklar' AND b.stufe <> 'unklar'
GROUP BY a.person_id`).all(req.person.id, req.person.id);
const andersZahl = new Map(anders.map((z) => [z.person_id, z.n]));
res.json({
leitung,
stufen: ENTWICKLUNG_STUFEN,
erwartung: ENTWICKLUNG_ERWARTUNG,
verblasst_nach: VERBLASST_NACH_TAGEN,
gesamt_fremd: ENTWICKLUNG_FREMD.length,
personen: personen.map((p) => ({
id: p.id, name: p.name,
rolle_name: ROLLEN_NAME[p.rolle] ?? p.rolle,
dabei_seit_tagen: tageSeit(p.erstellt),
gesetzt: meineZahl.get(p.id) || 0,
veraltet: alteZahl.get(p.id) || 0,
abweichungen: andersZahl.get(p.id) || 0,
})),
/* Die eigene Karte -- fuer jeden, der im Team ist. */
ich: { id: req.person.id, name: req.person.name },
});
} catch (f) {
console.error("[entwicklung] Lage:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
entwicklungRouter.get("/workspace/api/entwicklung/person/:id", nurLeitung, (req, res) => {
try {
const id = nummer(req.params.id);
const p = id && db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?").get(id);
if (!p || !KARTEN_ROLLEN.includes(p.rolle)) {
return res.status(404).json({ fehler: "nicht_gefunden" });
}
const meine = new Map(db().prepare(
"SELECT schluessel, stufe, anlass, am FROM entwicklung_stand WHERE person_id = ? AND von_id = ?")
.all(id, req.person.id).map((z) => [z.schluessel, z]));
/* DER STAND DES ANDEREN -- nur zu Punkten, bei denen ich selbst
schon gesetzt habe. Der Riegel sitzt hier, nicht in der
Oberflaeche: Was nicht ausgeliefert wird, kann niemand ankern. */
const fremde = db().prepare(
`SELECT s.schluessel, s.stufe, s.anlass, s.am, v.name AS von_name
FROM entwicklung_stand s LEFT JOIN personen v ON v.id = s.von_id
WHERE s.person_id = ? AND s.von_id <> ?`).all(id, req.person.id);
const grenze = Date.now() - VERBLASST_NACH_TAGEN * TAG;
const schritte = schritteVon(p.id);
const bloecke = katalogFuer(req.person, false).map((b) => ({
...b,
punkte: b.punkte.map((pt) => {
const m = meine.get(pt.schluessel) || null;
const andere = m
? fremde.filter((f) => f.schluessel === pt.schluessel)
.map((f) => ({ von_name: f.von_name, stufe: f.stufe, anlass: f.anlass, am: f.am }))
: null;
return {
...pt,
meins: m ? { ...m, verblasst: Date.parse(m.am) < grenze } : null,
andere,
/* WIE VIELE ES SONST NOCH GIBT -- ohne zu sagen, was sie
gesetzt haben. Ohne diese Zahl waere „noch nichts von der
rechten Hand" und „sie hat gesetzt, du siehst es nur
nicht" dasselbe Bild, und das erste ist eine Auskunft,
das zweite eine Taeuschung. */
andere_offen: m ? 0 : fremde.filter((f) => f.schluessel === pt.schluessel).length,
/* DER SCHRITT ZU DIESEM PUNKT. Er steht an der Karte und
nicht auf einer eigenen Liste: Wer die Beobachtung sieht,
soll im selben Blick sehen, ob daraus etwas geworden ist. */
schritt: schritte.get(pt.schluessel) || null,
};
}),
}));
/* DIE ABWEICHUNGEN ZUERST. Sie sind das Ergebnis, nicht ein
Nebenprodukt: Wo zwei Leute dasselbe sehen, gibt es nichts zu
besprechen. */
const abweichungen = [];
for (const b of bloecke) {
for (const pt of b.punkte) {
if (!pt.meins || !pt.andere?.length) continue;
for (const a of pt.andere) {
if (a.stufe === pt.meins.stufe) continue;
if (a.stufe === "unklar" || pt.meins.stufe === "unklar") continue;
abweichungen.push({
schluessel: pt.schluessel, titel: pt.titel, block: b.name,
meins: pt.meins.stufe, andere: a.stufe, von_name: a.von_name,
});
}
}
}
res.json({
person: { id: p.id, name: p.name, rolle_name: ROLLEN_NAME[p.rolle] ?? p.rolle },
stufen: ENTWICKLUNG_STUFEN,
erwartung: ENTWICKLUNG_ERWARTUNG,
bloecke,
abweichungen,
gesetzt: meine.size,
gesamt: ENTWICKLUNG_FREMD.length,
});
} catch (f) {
console.error("[entwicklung] Person:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* =====================================================================
AUS EINER BEOBACHTUNG WIRD EIN SCHRITT (15.09.2026)
Filipe: „ich will die noch viel besser, perfektionniert […] so
einfach wie möglich für jeden."
---------------------------------------------------------------------
WAS GEFEHLT HAT
Die Karte konnte bisher genau eines: festhalten, dass etwas hakt --
mit Anlass, das war schon richtig. Danach passierte nichts. Beim
nächsten Öffnen stand dieselbe Beobachtung da, nur älter.
Die Quellen zur laufenden Entwicklungsbegleitung sagen übereinstimmend
zweierlei: weg von der Bewertung, hin zum Gespräch -- und ein
Entwicklungsplan wirkt dann, wenn er an einer ECHTEN Aufgabe hängt.
Nicht „daran arbeiten wir", sondern etwas, das auf dem Aufgabenbrett
steht, einen Verantwortlichen hat und irgendwann erledigt ist.
Genau dieses Muster gibt es im Haus schon: Aus einer Idee auf einem
Brett wird eine Aufgabe. Hier ist es dasselbe, nur mit einem anderen
Ausgangspunkt -- und deshalb sieht es auch gleich aus.
---------------------------------------------------------------------
UND DANN SCHLIESST SICH DER KREIS
Ist der Schritt erledigt, sagt die Karte das und fragt dieselbe Frage
noch einmal. Das ist der Rhythmus, den die Quellen meinen: nicht
einmal im Jahr eine Bewertung, sondern eine Runde, die zu Ende geht.
Quellen:
https://www.quantumworkplace.com/future-of-work/continuous-performance-management
https://www.aihr.com/blog/continuous-performance-management/
https://www.deel.com/blog/implement-continuous-performance-management-system/
===================================================================== */
/** Wie lange ein Schritt Zeit bekommt. Vorschlag, kein Gesetz --
* auf dem Aufgabenbrett lässt sich die Frist ändern wie bei jeder
* anderen Aufgabe auch. Zwei Wochen sind nah genug, dass man sich
* erinnert, und weit genug, dass sich etwas ändern kann. */
export const SCHRITT_TAGE = 14;
/** Die Schritte zu den Punkten einer Person — einer je Punkt.
*
* ES IST IMMER DER JÜNGSTE. Wer denselben Punkt ein zweites Mal
* angeht, soll den neuen Schritt sehen und nicht den von vor einem
* halben Jahr. */
function schritteVon(personId) {
const zeilen = db().prepare(
`SELECT id, titel, status, frist, erledigt_am, aus_punkt, erstellt
FROM aufgaben
WHERE verantwortlich_id = ? AND aus_punkt IS NOT NULL
ORDER BY id DESC`).all(personId);
const nach = new Map();
for (const z of zeilen) if (!nach.has(z.aus_punkt)) nach.set(z.aus_punkt, z);
return nach;
}
entwicklungRouter.post("/workspace/api/entwicklung/person/:id/:schluessel/schritt",
gleicheHerkunft, nurLeitung, express.json({ limit: "8kb" }), (req, res) => {
try {
const id = nummer(req.params.id);
const schluessel = String(req.params.schluessel || "");
const p = id && db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?").get(id);
if (!p || !KARTEN_ROLLEN.includes(p.rolle)) {
return res.status(404).json({ fehler: "nicht_gefunden" });
}
const punkt = ENTWICKLUNG_FREMD.find((x) => x.schluessel === schluessel);
if (!punkt) return res.status(404).json({ fehler: "nicht_gefunden" });
/* NUR AUS EINEM „DA HAKT ES" WIRD EIN SCHRITT -- und nur aus dem
EIGENEN. Aus „läuft" einen Schritt zu machen hieße, jemandem
Arbeit zu geben für etwas, das gut läuft; aus der Beobachtung
eines anderen hieße, in seinem Namen zu handeln. */
const stand = db().prepare(
`SELECT stufe, anlass FROM entwicklung_stand
WHERE person_id = ? AND schluessel = ? AND von_id = ?`)
.get(id, schluessel, req.person.id);
if (!stand || stand.stufe !== "hakt") {
return res.status(400).json({ fehler: "kein_hakt" });
}
/* EIN OFFENER SCHRITT REICHT. Zwei Aufgaben zum selben Punkt
wären zwei Erinnerungen an dieselbe Sache -- und die zweite
macht die erste wertlos. */
const offen = db().prepare(
`SELECT id FROM aufgaben
WHERE verantwortlich_id = ? AND aus_punkt = ?
AND status NOT IN ('erledigt','abgebrochen')`).get(id, schluessel);
if (offen) return res.status(400).json({ fehler: "schon_da", aufgabe_id: offen.id });
const frist = new Date(Date.now() + SCHRITT_TAGE * 86400000).toISOString().slice(0, 10);
const jetztIso = jetzt();
db().prepare(`INSERT INTO aufgaben
(titel, beschreibung, status, prioritaet, verantwortlich_id,
frist, erstellt, erstellt_von, aus_punkt)
VALUES (?,?,'offen','mittel',?,?,?,?,?)`)
.run(
punkt.titel,
/* DER ANLASS WANDERT MIT. Ohne ihn stünde auf dem Brett ein
Satz ohne Anlass, und die Aufgabe wäre ein Vorwurf. */
`${punkt.text}\n\nWoran es aufgefallen ist: ${stand.anlass || "–"}`,
id, frist, jetztIso, req.person.id, schluessel);
const neu = db().prepare("SELECT last_insert_rowid() AS id").get().id;
protokolliere("entwicklung_schritt", {
personId: req.person.id, ip: echteIp(req),
/* KEIN ANLASS IM PROTOKOLL. Dass ein Schritt entstanden ist,
gehört hinein; woran jemand etwas gemerkt hat, nicht. */
detail: `${punkt.titel} für ${p.name}`.slice(0, 120),
});
res.status(201).json({ id: neu, titel: punkt.titel, frist });
} catch (f) {
console.error("[entwicklung] Schritt:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
const STUFEN_SATZ = new Set(ENTWICKLUNG_STUFEN.map((s) => s.schluessel));
entwicklungRouter.put("/workspace/api/entwicklung/person/:id/:schluessel",
gleicheHerkunft, nurLeitung, (req, res) => {
try {
const id = nummer(req.params.id);
const schluessel = String(req.params.schluessel || "");
const stufe = String(req.body?.stufe || "");
const anlass = text(req.body?.anlass, ANLASS_MAX);
const p = id && db().prepare("SELECT id, rolle FROM personen WHERE id = ?").get(id);
if (!p || !KARTEN_ROLLEN.includes(p.rolle)) {
return res.status(404).json({ fehler: "nicht_gefunden" });
}
/* NUR PUNKTE, DIE ANDERE SETZEN DUERFEN. Block 5 faellt hier
heraus -- und zwar an derselben Stelle, an der auch die
Auslieferung ihn wegnimmt. Ein Schluessel aus Block 5 sieht
von aussen genauso aus wie ein erfundener. */
if (!ENTWICKLUNG_FREMD.some((x) => x.schluessel === schluessel)) {
return res.status(404).json({ fehler: "nicht_gefunden" });
}
if (!STUFEN_SATZ.has(stufe)) return res.status(400).json({ fehler: "unbekannte_stufe" });
/* DER ANLASS IST PFLICHT, WO ER ETWAS ENTSCHEIDET.
„Da hakt es" ohne Beispiel ist eine Meinung; mit Beispiel ist
es ein Gespraech. Bei den anderen beiden steht dasselbe Feld
freiwillig da. */
if (stufe === "hakt" && anlass.length < 10) {
return res.status(400).json({
fehler: "Woran hast du das gemerkt? Ein Satz genügt – ohne ihn "
+ "ist es beim Gespräch nur noch ein Gefühl.",
});
}
db().prepare(`INSERT INTO entwicklung_stand
(person_id, schluessel, von_id, stufe, anlass, am)
VALUES (?,?,?,?,?,?)
ON CONFLICT(person_id, schluessel, von_id) DO UPDATE SET
stufe = excluded.stufe, anlass = excluded.anlass, am = excluded.am`)
.run(id, schluessel, req.person.id, stufe, anlass || null, jetzt());
res.json({ ok: true });
} catch (f) {
console.error("[entwicklung] Setzen:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Block 5: die eigene Karte -------------------------------- */
/* KEIN :id IN DER ADRESSE, und das ist der Riegel.
Eine Route /entwicklung/mich/:id waere eine Einladung, eine fremde
Nummer einzusetzen -- und dann haengt alles an einer einzigen
Vergleichszeile. Hier gibt es die Nummer gar nicht: Es ist immer die
Person, die fragt. */
entwicklungRouter.get("/workspace/api/entwicklung/mich", (req, res) => {
try {
const meine = new Map(db().prepare(
"SELECT schluessel, stufe, anlass, am FROM entwicklung_stand WHERE person_id = ? AND von_id = ?")
.all(req.person.id, req.person.id).map((z) => [z.schluessel, z]));
const block = ENTWICKLUNG_BLOECKE.find((b) => b.nurSelbst);
res.json({
block,
stufen: ENTWICKLUNG_STUFEN,
punkte: (ENTWICKLUNG_PUNKTE[block.schluessel] || []).map((pt) => ({
...pt, meins: meine.get(pt.schluessel) || null,
})),
});
} catch (f) {
console.error("[entwicklung] Mich:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
entwicklungRouter.put("/workspace/api/entwicklung/mich/:schluessel", gleicheHerkunft,
(req, res) => {
try {
const schluessel = String(req.params.schluessel || "");
const stufe = String(req.body?.stufe || "");
if (!ENTWICKLUNG_SELBST.some((x) => x.schluessel === schluessel)) {
return res.status(404).json({ fehler: "nicht_gefunden" });
}
if (!STUFEN_SATZ.has(stufe)) return res.status(400).json({ fehler: "unbekannte_stufe" });
/* KEIN ANLASS, NIRGENDS. Hier antwortet jemand ueber sich selbst;
ein Pflichtfeld waere die zuverlaessigste Art, ehrliche
Antworten zu verhindern. */
db().prepare(`INSERT INTO entwicklung_stand
(person_id, schluessel, von_id, stufe, anlass, am)
VALUES (?,?,?,?,NULL,?)
ON CONFLICT(person_id, schluessel, von_id) DO UPDATE SET
stufe = excluded.stufe, am = excluded.am`)
.run(req.person.id, schluessel, req.person.id, stufe, jetzt());
res.json({ ok: true });
} catch (f) {
console.error("[entwicklung] Mich setzen:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Die eigene Karte ------------------------------------------
Filipes Frage aus dem Plan, Punkt 2: "Sieht die Person ihre eigene
Entwicklungskarte?" -- und die Antwort war ja, aber unter zwei
Bedingungen. Beide sind hier gebaut, und beide sind der Grund, warum
es ueberhaupt geht:
ERST, WENN ALLE GESETZT HABEN. Eine halbe Karte ist schlimmer als
keine: Wer sieht, dass einer "da hakt es" gesetzt hat und der andere
noch nichts, liest daraus einen Streit, den es nicht gibt.
WER "ALLE" IST, wird gerechnet, nicht angenommen: alle aktiven
Personen, die das Team fuehren -- OHNE die Person selbst. Sonst
muesste die rechte Hand sich erst selbst beurteilen, um zu sehen,
was DogFather ueber sie denkt.
OHNE NAMEN UND OHNE ANLASS. Sie sieht "2x Laeuft" oder "1x Laeuft,
1x Waechst gerade" -- nicht, wer was gesetzt hat, und nicht, was
danebengeschrieben steht. Der Anlass ist fuer ein Gespraech
aufgeschrieben, nicht zum Lesen ohne den Menschen dazu. Genau
deshalb steht auf der Seite der Satz, dass man danach fragen soll.
UND WER GAR KEINE KARTE HAT, bekommt das gesagt. DogFather steht
nicht in KARTEN_ROLLEN -- eine leere Seite waere fuer ihn ein
Fehler, ein Satz ist eine Auskunft.
------------------------------------------------------------------- */
entwicklungRouter.get("/workspace/api/entwicklung/meine-karte", (req, res) => {
try {
if (!KARTEN_ROLLEN.includes(req.person.rolle)) {
return res.json({ hat_karte: false });
}
/* Wer ueber diese Person urteilt -- alle ausser ihr selbst. */
const beurteiler = db().prepare(
"SELECT id FROM personen WHERE rolle IN ('admin','hand') AND aktiv = 1 AND id <> ?")
.all(req.person.id).map((z) => z.id);
const staende = db().prepare(
"SELECT schluessel, von_id, stufe, am FROM entwicklung_stand WHERE person_id = ?")
.all(req.person.id).filter((z) => beurteiler.includes(z.von_id));
const nachPunkt = new Map();
for (const z of staende) {
if (!nachPunkt.has(z.schluessel)) nachPunkt.set(z.schluessel, []);
nachPunkt.get(z.schluessel).push(z);
}
const bloecke = ENTWICKLUNG_BLOECKE
.filter((b) => !b.nurSelbst)
.map((b) => ({
schluessel: b.schluessel, name: b.name, text: b.text,
punkte: (ENTWICKLUNG_PUNKTE[b.schluessel] || []).map((pt) => {
const liste = nachPunkt.get(pt.schluessel) || [];
/* VOLLSTAENDIG heisst: jeder Beurteiler hat gesetzt. */
const fertig = beurteiler.length > 0 && liste.length >= beurteiler.length;
if (!fertig) {
return { schluessel: pt.schluessel, titel: pt.titel, stufe: pt.stufe, offen: true };
}
const zaehler = {};
for (const z of liste) zaehler[z.stufe] = (zaehler[z.stufe] || 0) + 1;
return {
schluessel: pt.schluessel, titel: pt.titel, stufe: pt.stufe, offen: false,
/* Nach der Reihenfolge des Katalogs, nicht nach Haeufigkeit
-- sonst stuende bei einer Abweichung mal das eine, mal
das andere zuerst, und das liest sich wie eine Wertung. */
antworten: ENTWICKLUNG_STUFEN
.filter((x) => zaehler[x.schluessel])
.map((x) => ({ stufe: x.schluessel, name: x.name, anzahl: zaehler[x.schluessel] })),
einig: new Set(liste.map((z) => z.stufe)).size === 1,
am: liste.map((z) => z.am).sort().at(-1) || null,
};
}),
}));
const alle = bloecke.flatMap((b) => b.punkte);
res.json({
hat_karte: true,
beurteiler: beurteiler.length,
fertig: alle.filter((x) => !x.offen).length,
gesamt: alle.length,
stufen: ENTWICKLUNG_STUFEN,
erwartung: ENTWICKLUNG_ERWARTUNG,
bloecke,
});
} catch (f) {
console.error("[entwicklung] Meine Karte:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Die Ampel ohne Namen -------------------------------------- */
/* WAS HIER HERAUSKOMMT UND WAS NICHT.
Heraus kommt: wie viele im Team gerade mindestens einen Punkt aus
Block 5 auf „da hakt es" stehen haben -- und ab zwei Personen auch
das nicht mehr genau, sondern als Spanne.
Nicht heraus kommt: wer, welcher Punkt, seit wann.
WARUM UEBERHAUPT ETWAS: Ein Block, den niemand je sieht, ist ein
Block, den niemand ausfuellt. Die Ampel ist der einzige Weg von
dort nach draussen -- und sie ist absichtlich so schmal, dass man
daraus niemanden erkennen kann.
AB WELCHER ZAHL SIE UEBERHAUPT ETWAS SAGT: Bei einem Team von zwei
Personen waere „einer hat gerade viel" dasselbe wie ein Name.
Deshalb schweigt sie unter MINDESTENS_TEAM Personen -- mit Grund,
nicht stillschweigend. */
const MINDESTENS_TEAM = 3;
entwicklungRouter.get("/workspace/api/entwicklung/ampel", nurLeitung, (req, res) => {
try {
const platz = KARTEN_ROLLEN.map(() => "?").join(", ");
const { n: team } = db().prepare(
`SELECT COUNT(*) AS n FROM personen WHERE rolle IN (${platz}) AND aktiv = 1`)
.all(...KARTEN_ROLLEN)[0] || { n: 0 };
if (team < MINDESTENS_TEAM) {
/* DREI VERSCHIEDENE LAGEN, DREI VERSCHIEDENE SAETZE.
Der erste Entwurf hatte einen Satz fuer alle: "Solange ihr zu
{n} seid …". Im Bildschirmfoto stand dort "Solange ihr zu 1
seid" -- und bei einem leeren Team "zu 0". Ein Satz, der bei
den haeufigsten Faellen falsches Deutsch ergibt, liest sich
wie ein Fehler, und ab da glaubt man auch dem Rest nicht mehr.
Gefunden hat das kein Test, sondern ein Blick auf die Seite. */
return res.json({
sagbar: false, team,
grund: team === 0
? "Noch ist niemand im Team. Sobald jemand dazukommt, kann hier etwas stehen."
: team === 1
? "Es ist gerade eine Person im Team – da wäre jede Auskunft hier "
+ "dasselbe wie ein Name. Frag sie direkt."
: `Ihr seid zu ${team}. Jede Auskunft an dieser Stelle wäre damit `
+ "so gut wie ein Name – frag sie direkt, das ist hier ohnehin der bessere Weg.",
});
}
const schluessel = ENTWICKLUNG_SELBST.map((p) => p.schluessel);
const pl = schluessel.map(() => "?").join(", ");
const { n: betroffen } = db().prepare(
`SELECT COUNT(DISTINCT person_id) AS n FROM entwicklung_stand
WHERE schluessel IN (${pl}) AND stufe = 'hakt' AND person_id = von_id`)
.all(...schluessel)[0] || { n: 0 };
const { n: geantwortet } = db().prepare(
`SELECT COUNT(DISTINCT person_id) AS n FROM entwicklung_stand
WHERE schluessel IN (${pl}) AND person_id = von_id`)
.all(...schluessel)[0] || { n: 0 };
res.json({
sagbar: true, team, geantwortet,
/* Keine genaue Zahl: „einer" waere bei kleinen Teams wieder ein
Name. Es gibt drei Antworten, und mehr braucht es nicht. */
lage: betroffen === 0 ? "ruhig" : betroffen === 1 ? "jemand" : "mehrere",
text: betroffen === 0
? (geantwortet === 0
? "Noch hat niemand geantwortet. Die Ampel sagt erst etwas, wenn jemand da war."
: "Gerade meldet niemand, dass es zu viel wird.")
: betroffen === 1
? "Jemand im Team hat gerade viel. Kein Name – frag reihum, nicht gezielt."
: "Mehrere im Team haben gerade viel. Das ist selten ein Zufall.",
});
} catch (f) {
console.error("[entwicklung] Ampel:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* =====================================================================
TALENTE
===================================================================== */
/** Ein Tagesdatum in Ortszeit, YYYY-MM-DD.
*
* NICHT toISOString().slice(0,10) -- das rechnet in UTC, und zwischen
* Mitternacht und zwei Uhr morgens liefert es in Deutschland den
* Vortag. Genau diese Zeile steht als offener Befund in pruef-struktur. */
function tagIn(d = new Date()) {
return new Intl.DateTimeFormat("sv-SE", { timeZone: "Europe/Berlin" }).format(d);
}
/** Wie viele Tage liegen zwischen heute und einem Tagesdatum?
* Negativ heisst: vorbei. */
function tageBis(tag) {
if (!tag) return null;
const heute = Date.parse(tagIn() + "T12:00:00Z");
const ziel = Date.parse(tag + "T12:00:00Z");
if (!Number.isFinite(ziel)) return null;
return Math.round((ziel - heute) / 86400000);
}
/** Der Übergang: ein Buddy und ein Datum — mehr braucht die Probe nicht.
*
* ER WIRD ABGELEITET, NICHT GESPEICHERT. Ein Satz wie „die erste
* Schicht war vor drei Tagen" in der Datenbank waere ab morgen falsch.
* Aus zwei Feldern wird hier jedes Mal neu gerechnet, was gerade gilt. */
function uebergangVon(stand) {
if (!stand?.erste_schicht && !stand?.buddy_id) return null;
const buddy = stand.buddy_id
? db().prepare("SELECT name, rolle, aktiv FROM personen WHERE id = ?").get(stand.buddy_id)
: null;
const bis = tageBis(stand.erste_schicht);
return {
buddy_id: stand.buddy_id || null,
buddy_name: buddy?.name || null,
/* WER GEGANGEN IST, IST KEIN BUDDY MEHR -- und zwar auf beiden
Wegen, auf denen jemand gehen kann.
Der erste Entwurf fragte "`buddy_id` gesetzt UND die Person weg".
Damit war der zweite Weg blind: Wird ein Mensch GELOESCHT, setzt
ON DELETE SET NULL die Spalte auf NULL -- die Bedingung war
falsch, die Karte sah unauffaellig aus, und eine laufende Probe
stand ohne jeden Buddy da. Gefunden hat das die Pruefung, nicht
das Lesen; sie loescht die Person wirklich.
Richtig ist die Frage, auf die es ankommt: Gibt es jemanden, der
gerade einarbeitet? */
buddy_weg: !buddy?.aktiv,
erste_schicht: stand.erste_schicht || null,
bis_schicht: bis,
/* Drei Lagen, drei Saetze -- derselbe Grund wie bei der Ampel:
ein Satz fuer alle ergibt bei der Haelfte falsches Deutsch. */
satz: !stand.erste_schicht ? null
: bis > SCHICHT_WEIT_TAGE
? `Erste Schicht in ${bis} Tagen. Das ist weit weg – bis dahin hört man nichts `
+ "voneinander, und genau da springen die meisten ab."
: bis > 1 ? `Erste Schicht in ${bis} Tagen.`
: bis === 1 ? "Erste Schicht ist morgen."
: bis === 0 ? "Erste Schicht ist heute."
: bis === -1 ? "Erste Schicht war gestern."
: `Erste Schicht war vor ${-bis} Tagen.`,
};
}
/** Die Karte eines Kandidaten -- Stufe, Standzeit, was zu tun ist. */
function talentKarte(e, merkmale) {
const stand = db().prepare(
"SELECT stufe, seit, notiz, buddy_id, erste_schicht FROM talent_stufe WHERE eintrag_id = ?")
.get(e.id);
const stufe = talentStufe(stand?.stufe) || TALENT_STUFEN[0];
const seit = stand?.seit || e.erstellt;
const tage = tageSeit(seit);
const gesetzt = merkmale.filter((m) => m.eintrag_id === e.id).map((m) => m.schluessel);
const warn = gesetzt.filter((s) => TALENT_NACH_SCHLUESSEL.get(s)?.warnung);
return {
id: e.id, titel: e.titel, text: e.text, einsatz: e.einsatz,
stufe: stufe.schluessel, stufe_name: stufe.name,
bedeutet: stufe.bedeutet, schritt: stufe.schritt, knopf: stufe.knopf,
naechste: talentNaechste(stufe.schluessel)?.schluessel || null,
seit, tage,
/* LIEGENGEBLIEBEN IST EINE AUSKUNFT UEBER UNS, nicht ueber den
Kandidaten. Deshalb steht sie an der Karte und nicht in einer
Erinnerungsmail, die man wegklickt. */
liegt: stufe.frist !== null && tage !== null && tage > stufe.frist,
merkmale: gesetzt.filter((s) => !TALENT_NACH_SCHLUESSEL.get(s)?.warnung),
warnzeichen: warn,
notiz: stand?.notiz || null,
/* DER ÜBERGANG. Er steht an der Karte und nicht in einem eigenen
Abruf: Er aendert sich mit jedem Tag, und was man extra holen
muss, ist beim Holen schon veraltet. */
uebergang: uebergangVon(stand),
/* DER VORLAGENTEXT -- gebaut aus den Merkmalen, die wirklich
angeklickt wurden. Er kommt MIT der Karte und nicht ueber einen
eigenen Weg: Er aendert sich mit jedem Klick, und ein Text, den
man extra abrufen muss, ist beim Abrufen schon veraltet. */
ansprechtext: ansprechtext(e.titel, gesetzt),
/* WER WAS GESEHEN HAT (17.09.2026).
Filipe: "analysiert werden von vanvan und mir."
`von_id` stand seit dem Bau in der Tabelle und wurde nie
ausgeliefert -- man sah, DASS ein Merkmal gesetzt ist, nicht von
wem. Bei zwei Leuten, die unabhaengig beobachten, ist genau das
die Auskunft: Was ihr BEIDE gesehen habt, ist mehr wert als was
einer von euch gesehen hat. */
gesehen_von: Object.fromEntries(merkmale
.filter((m) => m.eintrag_id === e.id && m.von_name)
.map((m) => [m.schluessel, m.von_name])),
/* DIE PROBE. Sie steht an der Karte und nicht hinter einem eigenen
Abruf -- sonst holt man sie erst, wenn man schon entschieden
hat. */
probe: probeVon(e.id, stufe.schluessel),
};
}
/* =====================================================================
DIE PROBEZEIT EINES KANDIDATEN
Sie wird nur fuer die Stufe "probe" (und danach) gefuellt. An einem
Kandidaten, der noch beobachtet wird, waere eine leere Zwoelferliste
nur Laerm -- und Laerm, den man dreimal wegliest, liest man auch
dann weg, wenn er gilt.
===================================================================== */
function probeVon(eintragId, stufe) {
if (stufe !== "probe" && stufe !== "imteam") return null;
const zeilen = db().prepare(`
SELECT p.schluessel, p.am, p.notiz, x.name AS von_name
FROM talent_probe p
LEFT JOIN personen x ON x.id = p.von_id
WHERE p.eintrag_id = ?`).all(eintragId);
const stand = probeStand(zeilen.map((z) => z.schluessel));
const je = new Map(zeilen.map((z) => [z.schluessel, z]));
return {
...stand,
schritte: PROBE_ALLE.map((s) => ({
...s,
wer_name: PROBE_WER[s.wer] || s.wer,
fertig: je.has(s.schluessel),
am: je.get(s.schluessel)?.am || null,
von: je.get(s.schluessel)?.von_name || null,
notiz: je.get(s.schluessel)?.notiz || null,
})),
fragen: PROBE_FRAGEN,
};
}
entwicklungRouter.get("/workspace/api/talente/lage", (req, res) => {
try {
const eintraege = db().prepare(
`SELECT id, titel, text, einsatz, erstellt FROM eintraege
WHERE bereich = 'talente' ORDER BY id DESC LIMIT 300`).all();
const merkmale = eintraege.length
? db().prepare(
`SELECT m.eintrag_id, m.schluessel, x.name AS von_name
FROM talent_merkmal m
LEFT JOIN personen x ON x.id = m.von_id
WHERE m.eintrag_id IN (${eintraege.map(() => "?").join(", ")})`)
.all(...eintraege.map((e) => e.id))
: [];
const karten = eintraege.map((e) => talentKarte(e, merkmale));
res.json({
stufen: TALENT_STUFEN,
gruppen: TALENT_GRUPPEN.map((g) => ({ ...g, merkmale: TALENT_MERKMALE[g.schluessel] || [] })),
warnzeichen: TALENT_WARNZEICHEN,
/* WER BUDDY SEIN KANN -- in der Reihenfolge des Hauses, nicht in
der der Datenbank. Sie kommt mit der Lage, damit die Auswahl
keine zweite Liste braucht, die man pflegen muesste. */
buddys: db().prepare(
`SELECT id, name, rolle FROM personen
WHERE aktiv = 1 AND rolle IN (${BUDDY_ROLLEN.map(() => "?").join(", ")})
ORDER BY ${ROLLEN_SORTIERUNG}, name COLLATE NOCASE`).all(...BUDDY_ROLLEN),
erster_tag: ERSTER_TAG,
schicht_weit_tage: SCHICHT_WEIT_TAGE,
/* WELCHEN ZUGANG DER LETZTE SCHRITT ANLEGT -- Schluessel und
Wort. Beides kommt von hier, damit im Browser kein fester Text
dazu steht; siehe ZUGANG_ROLLE oben. */
zugang: { rolle: ZUGANG_ROLLE, name: ROLLEN_NAME[ZUGANG_ROLLE] ?? ZUGANG_ROLLE },
kandidaten: karten,
/* Wie viele auf jeder Stufe liegen -- der Trichter als Zahlen.
Abgeleitet, damit er nicht danebenliegen kann. */
trichter: TALENT_STUFEN.map((s) => ({
schluessel: s.schluessel, name: s.name,
anzahl: karten.filter((k) => k.stufe === s.schluessel).length,
liegt: karten.filter((k) => k.stufe === s.schluessel && k.liegt).length,
})),
});
} catch (f) {
console.error("[talente] Lage:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
entwicklungRouter.put("/workspace/api/talente/:id/merkmal/:schluessel", gleicheHerkunft,
(req, res) => {
try {
const id = nummer(req.params.id);
const schluessel = String(req.params.schluessel || "");
const e = id && db().prepare(
"SELECT id, bereich FROM eintraege WHERE id = ?").get(id);
if (!e || e.bereich !== "talente") return res.status(404).json({ fehler: "nicht_gefunden" });
if (!TALENT_NACH_SCHLUESSEL.has(schluessel)) {
return res.status(404).json({ fehler: "nicht_gefunden" });
}
/* EIN KNOPF, ZWEI RICHTUNGEN. Ein Merkmal ist gesehen oder nicht;
ein zweiter Klick nimmt es zurueck. Kein eigener Weg zum
Abwaehlen -- der waere die Stelle, an der die Rechtepruefung
irgendwann fehlt. */
const schon = db().prepare(
"SELECT 1 AS x FROM talent_merkmal WHERE eintrag_id = ? AND schluessel = ?")
.get(id, schluessel);
if (schon) {
db().prepare("DELETE FROM talent_merkmal WHERE eintrag_id = ? AND schluessel = ?")
.run(id, schluessel);
} else {
db().prepare(
"INSERT INTO talent_merkmal (eintrag_id, schluessel, von_id, am) VALUES (?,?,?,?)")
.run(id, schluessel, req.person.id, jetzt());
}
res.json({ ok: true, gesetzt: !schon });
} catch (f) {
console.error("[talente] Merkmal:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* =====================================================================
EINEN SCHRITT DER PROBE ABHAKEN (17.09.2026)
EIN KNOPF, ZWEI RICHTUNGEN -- wie bei den Merkmalen. Ein zweiter
Klick nimmt zurueck; ein eigener Weg zum Abwaehlen waere die Stelle,
an der die Rechtepruefung irgendwann fehlt.
NUR AUF DER STUFE "PROBE". Wer einen Schritt abhakt, bevor die Probe
laeuft, haekchen einen Zeitraum ab, den es nicht gibt -- und am Tag
30 stuende eine volle Liste da, die nichts bedeutet.
===================================================================== */
entwicklungRouter.put("/workspace/api/talente/:id/probe/:schluessel", gleicheHerkunft,
express.json({ limit: "8kb" }), (req, res) => {
try {
const id = nummer(req.params.id);
const schluessel = String(req.params.schluessel || "");
const e = id && db().prepare(
"SELECT id, bereich FROM eintraege WHERE id = ?").get(id);
if (!e || e.bereich !== "talente") return res.status(404).json({ fehler: "nicht_gefunden" });
if (!PROBE_NACH_SCHLUESSEL.has(schluessel)) {
return res.status(404).json({ fehler: "nicht_gefunden" });
}
const stand = db().prepare("SELECT stufe FROM talent_stufe WHERE eintrag_id = ?").get(id);
if (stand?.stufe !== "probe") {
return res.status(409).json({
fehler: "Das gehört in die Probezeit – die läuft für ihn gerade nicht.",
});
}
const schon = db().prepare(
"SELECT 1 AS x FROM talent_probe WHERE eintrag_id = ? AND schluessel = ?")
.get(id, schluessel);
if (schon) {
db().prepare("DELETE FROM talent_probe WHERE eintrag_id = ? AND schluessel = ?")
.run(id, schluessel);
} else {
const notiz = String(req.body?.notiz ?? "").trim().slice(0, 400) || null;
db().prepare(
"INSERT INTO talent_probe (eintrag_id, schluessel, von_id, am, notiz)"
+ " VALUES (?,?,?,?,?)")
.run(id, schluessel, req.person.id, jetzt(), notiz);
}
res.json({ ok: true, fertig: !schon });
} catch (f) {
console.error("[talente] Probe:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Buddy und Datum prüfen. Gibt `{ fehlt: [...] }` oder `{ buddyId, schicht }`.
*
* EINE STELLE, ZWEI AUFRUFER: der Stufenwechsel auf „Probe" und das
* spaetere Aendern. Zwei Abschriften derselben Regeln waeren zwei
* Regeln, und die zweite ist immer die, die etwas durchlaesst. */
function uebergangPruefen(koerper, { pflicht }) {
const fehlt = [];
const buddyId = nummer(koerper?.buddy_id);
const b = buddyId
? db().prepare("SELECT id, aktiv, rolle FROM personen WHERE id = ?").get(buddyId) : null;
if (buddyId && (!b || !b.aktiv || !BUDDY_ROLLEN.includes(b.rolle))) fehlt.push("buddy_unbekannt");
else if (!buddyId && pflicht) fehlt.push("buddy");
const roh = String(koerper?.erste_schicht || "").trim();
if (roh) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(roh)) fehlt.push("datum_unlesbar");
else {
const bis = tageBis(roh);
/* VERGANGENHEIT IST KEIN TERMIN. Wer eine Schicht nachtraegt, die
schon war, meint etwas anderes -- naemlich, dass die Probe
laeuft. Dann gehoert das Datum von damals hinein, nicht heute:
deshalb sind bis zu sieben Tage rueckwaerts erlaubt. */
if (bis === null) fehlt.push("datum_unlesbar");
else if (bis < -7) fehlt.push("datum_zu_alt");
else if (bis > SCHICHT_MAX_TAGE) fehlt.push("datum_zu_weit");
}
} else if (pflicht) fehlt.push("erste_schicht");
return fehlt.length ? { fehlt } : { buddyId: buddyId || null, schicht: roh || null };
}
/** Buddy oder Datum ändern, ohne die Stufe anzufassen.
*
* Ein eigener Weg, weil es ein eigener Vorgang ist: Ein Buddy hoert
* auf, ein Termin verschiebt sich. Wer dafuer die Stufe neu setzen
* muesste, setzt dabei `seit` zurueck -- und die Standzeit, die genau
* diese Verschleppung sichtbar macht, faengt wieder bei null an. */
entwicklungRouter.put("/workspace/api/talente/:id/uebergang", gleicheHerkunft, (req, res) => {
try {
const id = nummer(req.params.id);
const e = id && db().prepare("SELECT id, bereich, titel FROM eintraege WHERE id = ?").get(id);
if (!e || e.bereich !== "talente") return res.status(404).json({ fehler: "nicht_gefunden" });
const stand = db().prepare("SELECT stufe FROM talent_stufe WHERE eintrag_id = ?").get(id);
if (!stand) return res.status(404).json({ fehler: "nicht_gefunden" });
const p = uebergangPruefen(req.body, { pflicht: stand.stufe === "probe" });
if (p.fehlt) return res.status(400).json({ fehler: "uebergang_offen", fehlt: p.fehlt });
db().prepare("UPDATE talent_stufe SET buddy_id = ?, erste_schicht = ? WHERE eintrag_id = ?")
.run(p.buddyId, p.schicht, id);
protokolliere("talent_uebergang", {
personId: req.person.id, ip: echteIp(req),
detail: `${e.titel}: Buddy/erste Schicht geaendert`.slice(0, 120),
});
res.json({ ok: true });
} catch (f) {
console.error("[talente] Uebergang:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
entwicklungRouter.put("/workspace/api/talente/:id/stufe", gleicheHerkunft, (req, res) => {
try {
const id = nummer(req.params.id);
const stufe = String(req.body?.stufe || "");
const notiz = text(req.body?.notiz, ANLASS_MAX);
const e = id && db().prepare("SELECT id, bereich, titel FROM eintraege WHERE id = ?").get(id);
if (!e || e.bereich !== "talente") return res.status(404).json({ fehler: "nicht_gefunden" });
if (!talentStufe(stufe)) return res.status(400).json({ fehler: "unbekannte_stufe" });
/* AUF DIE PROBE KOMMT NIEMAND OHNE BUDDY UND DATUM.
In der Stufenbeschreibung stand „fester Buddy" seit dem ersten
Tag -- ein Feld dafuer gab es nicht. Ein Versprechen ohne Feld
ist eines, an das sich nach zwei Wochen niemand erinnert, und
genau an dieser Stelle verlieren Teams die Leute, die sie gerade
gewonnen haben.
EINE ABSAGE MIT GRUND, nicht ein „ging nicht": Die Oberflaeche
fragt beides vorher ab, aber die Regel gehoert hierher. Was nur
im Browser steht, gilt nur fuer den, der den Browser benutzt. */
const p = stufe === "probe"
? uebergangPruefen(req.body, { pflicht: true })
: uebergangPruefen(req.body, { pflicht: false });
if (p.fehlt) return res.status(400).json({ fehler: "uebergang_offen", fehlt: p.fehlt });
/* COALESCE, NICHT ÜBERSCHREIBEN: Wer aus der Probe ins Team geht,
schickt Buddy und Datum nicht noch einmal mit -- und die beiden
sollen bleiben, wo sie sind. Sie sind die Geschichte dieser
Probe, nicht ein Formularfeld. */
db().prepare(`INSERT INTO talent_stufe
(eintrag_id, stufe, seit, von_id, notiz, buddy_id, erste_schicht)
VALUES (?,?,?,?,?,?,?)
ON CONFLICT(eintrag_id) DO UPDATE SET
stufe = excluded.stufe, seit = excluded.seit,
von_id = excluded.von_id, notiz = excluded.notiz,
buddy_id = COALESCE(excluded.buddy_id, talent_stufe.buddy_id),
erste_schicht = COALESCE(excluded.erste_schicht, talent_stufe.erste_schicht)`)
.run(id, stufe, jetzt(), req.person.id, notiz || null, p.buddyId, p.schicht);
protokolliere("talent_stufe", {
personId: req.person.id, ip: echteIp(req),
detail: `${e.titel}: ${stufe}`.slice(0, 120),
});
res.json({ ok: true, stufe });
} catch (f) {
console.error("[talente] Stufe:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});