Aus der Probe wird ein Zugang - und die Seite sagt nicht mehr "Agentur"

Die drei offenen Punkte aus Abschnitt 9 des Plans, plus Filipes zweite
Ansage: "auf dieser seite soll nichts stehen von agentur, diese seite
hier ist nur fuer mich meine modis und meine community."

1. PROBE -> ZUGANG (ein Knopf statt eines Satzes)

Auf der Stufe "Probe" stand bisher "Zugang in Personen & Zugaenge
anlegen, Rolle Modi" - und wer das vergass, hatte eine Karte auf "Im
Team" und keinen Menschen darin. Jetzt haengt das Anlegen am Schritt
selbst.

Ueber DENSELBEN Weg wie in "Personen & Zugaenge", nicht ueber einen
zweiten: POST /workspace/api/verwaltung/personen, mit ihrer
Rechtepruefung, ihrem Protokolleintrag und ihrem genau einmal
angezeigten Code. Der Talente-Server legt selbst keinen einzigen Zugang
an, und die Pruefung zaehlt nach, dass es bei einem Weg bleibt.

Der Code steht im gleichen Kasten wie dort - die Gestaltung ist aus
personen.css nach start.css umgezogen, weil talente.html sie sonst nicht
laedt. Ein Geheimnis sieht im ganzen Haus gleich aus. Er steht
ausserhalb der Liste, sonst waere er in dem Moment weg, in dem er
entsteht: wenn die Karte auf "Im Team" springt.

Die rechte Hand bekommt an dieser Stelle KEINEN Knopf, sondern einen
Satz. Ein Knopf, der ihr jedes Mal "darfst du nicht" antwortet, waere
schlechter als gar keiner - er verspricht etwas.

2. DIE EIGENE KARTE (Abschnitt "Deine Karte")

Wer beschrieben wird, darf es lesen - aber erst, wenn ALLE gesetzt
haben (sonst waere die erste Einschaetzung eine Vorgabe fuer die
zweite), und ohne Namen und ohne Anlasstext. Gemessen in beide
Richtungen: vorher nicht sichtbar, nachher sichtbar.

3. DER VORLAGENTEXT

Gebaut aus den angeklickten Merkmalen, in einem Feld zum Aendern, nicht
zum Abschicken. Ohne Merkmale steht auch keines drin.

4. "GILT FUER" SAGT DER SERVER

In bereich.js stand woertlich "Agentur" - richtig auf der
Agenturadresse, falsch auf jeder anderen. Jetzt liefert der Server
`gehoert` ("Der Treff" / "Team Dogi" / "Agentur"); gemessen mit einem
einzigen Menschen auf zwei Adressen.

PRUEFUNGEN: treff 58 -> 66, nachwuchs 69 -> 109, neue-seiten 70 -> 93.
Die Katalogseiten werden jetzt auch mit den Augen der rechten Hand
angesehen - ohne das waere "Deine Karte" nie auf einem Bildschirm
gewesen. Und pruef-neue-seiten wartet nicht mehr 900 ms, sondern bis
sich der Text nicht mehr aendert: Die Karte stand da und wurde
trotzdem als fehlend gemeldet, weil zu frueh gelesen wurde.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-11 23:13:50 +02:00
co-authored by Claude Opus 5
parent 44e5ace109
commit bba3642664
41 changed files with 1447 additions and 420 deletions
+194 -1
View File
@@ -20,7 +20,7 @@
Aufrufen mit: node server/pruef-nachwuchs.mjs
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { request as httpAnfrage } from "node:http";
@@ -454,6 +454,199 @@ melde("=== 8. Standzeit ===");
`einen Tag vor der Frist noch nicht (${r2.kandidaten[0].tage} Tage)`);
}
/* =======================================================================
9. DER VORLAGENTEXT
Er ist der Unterschied zwischen "Ansprechen" als Aufgabe und
"Ansprechen" als erledigter Sache. Geprueft wird deshalb nicht, DASS
ein Text kommt, sondern dass er aus den angeklickten Merkmalen
gebaut ist -- sonst waere es eine Floskel mit eingesetztem Namen.
======================================================================= */
melde("");
melde("=== 9. Der Vorlagentext ===");
{
const zwei = [tal.TALENT_ALLE[0], tal.TALENT_ALLE[1]];
const text = tal.ansprechtext("Kessi aus dem Chat", zwei.map((m) => m.schluessel));
ok(text.startsWith("Hey Kessi,"),
"die Anrede nimmt nur den ersten Namensteil, nicht den ganzen Eintragstitel");
ok(zwei.every((m) => text.includes(m.du)),
"beide angeklickten Merkmale stehen woertlich im Text");
ok(/kein Druck|nein/.test(text),
"und der Ausstieg steht drin -- ein Angebot ohne Nein ist keines");
/* GEGENPROBE: ohne ein einziges Merkmal darf KEINES drinstehen --
sonst baut die Funktion sich etwas zusammen, das niemand beobachtet
hat. Das waere die schlimmste Sorte Vorlage. */
const leer = tal.ansprechtext("Kessi", []);
ok(!tal.TALENT_ALLE.some((m) => leer.includes(m.du)),
"ohne angeklickte Merkmale steht auch keines im Text");
ok(leer.length > 100, `aber ein vollstaendiger Text bleibt es (${leer.length} Zeichen)`);
ok(leer !== text, "und er ist ein anderer als der mit Merkmalen");
/* HAUSREGEL: In Text, den ein Mensch liest, stehen echte Umlaute.
"Haettest du Lust" war genau der Fehler, den diese Zeile findet. */
const beide = text + " " + leer;
ok(!/\b(fuer|koenn|haett|muess|ueber|waer|moecht)/i.test(beide),
"keine ASCII-Ersatzumlaute in einem Text, den ein Mensch liest");
ok(/\b(fuer)/i.test("das ist fuer dich"), "und der Sucher findet so einen auch wirklich");
/* UND ER KOMMT UEBER DIE LEITUNG AN, nicht nur aus dem Modul. */
const lage = json(await roh("/workspace/api/talente/lage", HAUS, { cookie: kDogi }));
const k = (lage.kandidaten || [])[0];
ok(typeof k?.ansprechtext === "string" && k.ansprechtext.length > 100,
"die Karte bringt ihren Text mit");
const gesetzt = (k?.merkmale || []).map((s) => tal.TALENT_NACH_SCHLUESSEL.get(s)?.du).filter(Boolean);
ok(gesetzt.length > 0 && gesetzt.every((d) => k.ansprechtext.includes(d)),
`und zwar aus ihren ${gesetzt.length} gesetzten Merkmalen`);
}
/* =======================================================================
10. DIE EIGENE KARTE
Sie ist die Gegenprobe zur ganzen Kategorie: Ueber Menschen wird hier
aufgeschrieben, und wer beschrieben wird, darf es lesen. Aber erst,
wenn ALLE gesetzt haben -- sonst waere die erste Einschaetzung eine
Vorgabe fuer die zweite -- und ohne Namen und ohne Anlasstext.
======================================================================= */
melde("");
melde("=== 10. Die eigene Karte ===");
{
const meine = json(await roh("/workspace/api/entwicklung/meine-karte", CREW, { cookie: kModi }));
ok(meine.hat_karte === true, "der Modi hat eine eigene Karte");
ok(meine.beurteiler === 2,
`zwei Augenpaare urteilen ueber ihn (${meine.beurteiler}) -- er selbst zaehlt nicht mit`);
const punkte = (meine.bloecke || []).flatMap((b) => b.punkte || []);
ok(punkte.length > 0, `${punkte.length} Punkte in seiner Karte`);
/* Aus Abschnitt 4: DIESEN Punkt haben beide gesetzt (laeuft/waechst). */
const beide = punkte.find((p) => p.schluessel === kat.ENTWICKLUNG_FREMD[0].schluessel);
ok(beide && beide.offen === false, "ein Punkt, den beide gesetzt haben, ist offen");
ok(beide?.antworten?.length === 2,
`mit beiden Antworten nebeneinander (${(beide?.antworten || []).map((a) => a.name).join(" / ")})`);
ok(beide?.einig === false, "und dem ehrlichen Hinweis, dass sie sich uneinig sind");
/* GEGENPROBE 1 -- und ohne sie beweist die Zeile darueber nichts:
Einen Punkt, den erst EINER gesetzt hat, bekommt er NICHT. */
const einer = punkte.find((p) => p.schluessel === kat.ENTWICKLUNG_FREMD[1].schluessel);
ok(einer && einer.offen === true,
"einen Punkt, den erst einer gesetzt hat, sieht er noch nicht");
ok(einer && einer.antworten === undefined,
"und die Antwort des einen reist dabei auch nicht heimlich mit");
/* GEGENPROBE 2: sobald die zweite dazukommt, oeffnet er sich. */
const nach = await roh(`/workspace/api/entwicklung/person/${idModi}/${kat.ENTWICKLUNG_FREMD[1].schluessel}`,
CREW, { cookie: kHand }, { stufe: "hakt", anlass: "Zweimal zu spaet zur Schicht." }, "PUT");
ok(nach.code === 200, `die rechte Hand setzt denselben Punkt (${nach.code})`);
const meine2 = await roh("/workspace/api/entwicklung/meine-karte", CREW, { cookie: kModi });
const jetztOffen = (json(meine2).bloecke || []).flatMap((b) => b.punkte || [])
.find((p) => p.schluessel === kat.ENTWICKLUNG_FREMD[1].schluessel);
ok(jetztOffen?.offen === false && jetztOffen?.antworten?.length >= 1,
"danach steht er in seiner Karte");
/* KEINE NAMEN UND KEIN ANLASS. Beides steht in der Karte, die
DogFather sieht -- und beides waere hier ein Vertrauensbruch:
"wer hat das ueber mich gesagt" macht aus einer Entwicklung eine
Abrechnung. */
const roh2 = meine2.text;
ok(!/Filipe|Rieke/.test(roh2), "kein Name eines Beurteilers in der ganzen Antwort");
ok(!/zu spaet zur Schicht|Am Samstag/.test(roh2), "und kein einziger Anlasstext");
ok(/hakt|laeuft|waechst/.test(roh2),
"die Antworten selbst kommen aber an -- sonst waere die Zeile darueber trivial");
/* Block 5 gehoert der Person -- er steht in IHRER Selbstauskunft,
nicht in der Fremdkarte. Waere er hier, stuenden Antworten
anderer auf Fragen, die nur sie sich stellt. */
const selbst = kat.ENTWICKLUNG_SELBST.map((p) => p.schluessel);
ok(!punkte.some((p) => selbst.includes(p.schluessel)),
"Block 5 taucht in der eigenen Karte nicht als Fremdurteil auf");
/* Wer gar nicht beurteilt wird, bekommt auch keine leere Karte
vorgesetzt -- eine Karte ohne Inhalt liest sich wie ein Versaeumnis. */
/* UND DIE RECHTE HAND HAT AUCH EINE. Ueber sie urteilt nur DogFather
-- sie selbst zaehlt nicht mit, sonst waere ihre Karte erst dann
vollstaendig, wenn sie sich selbst eingeschaetzt hat. */
const ihre = json(await roh("/workspace/api/entwicklung/meine-karte", CREW, { cookie: kHand }));
ok(ihre.hat_karte === true, `auch die rechte Hand hat eine eigene Karte (${JSON.stringify(ihre).slice(0, 90)})`);
ok(ihre.beurteiler === 1, `ueber sie urteilt genau einer (${ihre.beurteiler})`);
const chef = json(await roh("/workspace/api/entwicklung/meine-karte", HAUS, { cookie: kDogi }));
ok(chef.hat_karte === false, "DogFather selbst hat keine -- ueber ihn urteilt hier niemand");
}
/* =======================================================================
11. AUS DER PROBE WIRD EIN ZUGANG
Der letzte Schritt des Trichters ist kein Schritt, sondern eine
Entscheidung. Bis heute stand dort ein Satz ("Zugang in Personen &
Zugaenge anlegen"), und wer ihn vergass, hatte eine Karte auf "Im
Team" und keinen Menschen darin.
GEPRUEFT WIRD VOR ALLEM EINES: dass es dafuer keinen ZWEITEN Weg
gibt. Ein Zugang entsteht im ganzen Haus an genau einer Stelle.
======================================================================= */
melde("");
melde("=== 11. Probe → Zugang ===");
{
const letzte = tal.TALENT_STUFEN.at(-1);
ok(!/Personen/.test(letzte.schritt || ""),
"die letzte Stufe schickt niemanden mehr von Hand in die Personenverwaltung");
/* EIN EINZIGER WEG, und es ist der vorhandene. */
const js = readFileSync(new URL("../workspace/assets/js/talente.js", import.meta.url), "utf8");
/* OHNE DIE KOMMENTARE gezaehlt: Der Weg wird daneben auch erklaert,
und eine Erklaerung ist kein zweiter Weg. */
const jsCode = js.replace(/\/\*[\s\S]*?\*\//g, "");
const wege = jsCode.split("/workspace/api/verwaltung/personen").length - 1;
ok(wege === 1, `talente.js kennt genau einen Weg zum Anlegen (${wege})`);
ok(js.split("/workspace/api/verwaltung/personen").length - 1 > wege,
"und der Kommentar daneben nennt ihn beim Namen -- sonst zaehlt die Zeile darueber nichts weg");
const eigen = readFileSync(new URL("../server/workspace-entwicklung.js", import.meta.url), "utf8");
ok(!/personAnlegen|INSERT INTO personen/.test(eigen),
"und der Talente-Server legt selbst keinen einzigen Zugang an");
/* DIE RECHTE HAND SIEHT DEN TRICHTER, VERGIBT ABER KEINE ZUGAENGE. */
const hand = await roh("/workspace/api/verwaltung/personen", CREW, { cookie: kHand },
{ name: "Heimlich", rolle: "modi" });
ok(hand.code === 404, `die rechte Hand kommt am Anlegen nicht vorbei (${hand.code})`);
const einModi = await roh("/workspace/api/verwaltung/personen", CREW, { cookie: kModi },
{ name: "Heimlich", rolle: "modi" });
ok(einModi.code === 404, `ein Modi erst recht nicht (${einModi.code})`);
/* GEGENPROBE: DogFather kann es -- sonst waere "404" oben auch dann
gruen, wenn der Weg gar nicht existiert. */
const neu = await roh("/workspace/api/verwaltung/personen", HAUS, { cookie: kDogi },
{ name: "Kessi", rolle: "modi" });
ok(neu.code === 201, `DogFather legt den Zugang an (${neu.code})`);
const person = json(neu);
ok(person.rolle === "modi", `und zwar als Modi (${person.rolle})`);
ok(typeof person.code === "string" && person.code.length > 8,
"der Zugangscode kommt genau einmal zurueck");
/* UND DANACH IST ER WEG. Nirgends abrufbar -- auch nicht fuer den,
der ihn angelegt hat. */
const liste = await roh("/workspace/api/verwaltung/personen", HAUS, { cookie: kDogi });
ok(liste.code === 200 && !liste.text.includes(person.code),
"danach steht er in keiner Liste mehr");
/* Erst jetzt wandert die Karte -- in derselben Reihenfolge wie in der
Oberflaeche: erst der Mensch, dann der Haken. */
const stufe = await roh(`/workspace/api/talente/${idTalent}/stufe`, HAUS,
{ cookie: kDogi }, { stufe: "imteam" }, "PUT");
ok(stufe.code === 200, `und die Karte geht auf "Im Team" (${stufe.code})`);
const lage = json(await roh("/workspace/api/talente/lage", HAUS, { cookie: kDogi }));
const k = (lage.kandidaten || []).find((x) => x.id === idTalent);
ok(k?.stufe === "imteam" && k?.knopf === null,
"dahinter kommt kein weiterer Knopf -- der Trichter ist zu Ende");
/* Der neue Mensch ist wirklich da, mit allem, was daran haengt. */
const dd = new DatabaseSync(process.env.WORKSPACE_DB);
const z = dd.prepare("SELECT rolle, aktiv FROM personen WHERE name = 'Kessi'").get();
dd.close();
ok(z?.rolle === "modi" && z?.aktiv === 1, "und er steht als aktiver Modi in der Datenbank");
}
/* ------------------------------------------------------------------- */
melde("");
melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
+191 -21
View File
@@ -121,19 +121,72 @@ await roh("/workspace/api/treff/massnahme", { cookie: kDogi },
/* ETWAS AUF DEN KATALOGSEITEN. Ohne eine Person im Team ist die
Entwicklungsseite leer, ohne einen Kandidaten der Trichter -- und
eine leere Seite erfuellt jede Bedingung, ohne etwas zu zeigen. */
eine leere Seite erfuellt jede Bedingung, ohne etwas zu zeigen.
UND ZWAR AUF MEHREREN STUFEN. Der Trichter zeigt je Stufe einen
ANDEREN naechsten Schritt; steht nur ein Kandidat auf der ersten,
bleiben die Knoepfe der anderen vier ungesehen. Genau dort sitzen
seit dem 11.09. die zwei neuen: der Vorlagentext auf "Beobachtet"
und "Ins Team holen · Zugang anlegen" auf "Probe". */
/* EINMAL ANKLOPFEN, DAMIT ES DIE TABELLEN GIBT. Sie entstehen beim
ersten angemeldeten Aufruf des Bereichs, nicht beim Start -- ein
INSERT davor liefe gegen eine Tabelle, die es noch nicht gibt. */
await roh("/workspace/api/talente/lage", { cookie: kDogi });
const kat = await import("./workspace-entwicklungs-punkte.js");
const talKat = await import("./workspace-talent-punkte.js");
const dd = new DatabaseSync(process.env.WORKSPACE_DB);
let idHand = null;
{
const salt = randomBytes(16).toString("hex");
const hash = scryptSync("CODE-MODI-0001", salt, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
dd.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES ('Frida','modi',?,?,?,?,1,?)")
.run(hash, salt, 32768,
createHmac("sha256", schluessel).update("CODE-MODI-0001").digest("hex"), jetzt);
dd.prepare("INSERT INTO eintraege (bereich, art, titel, text, datum, erstellt, erstellt_von, einsatz)"
+ " VALUES ('talente','ruhig','Kessi aus dem Chat','Faellt seit Wochen auf.',?,?,?,?)")
.run(jetzt.slice(0, 10), jetzt, idDogi, "Im Stream am Dienstag");
const person = (name, rolle, code) => {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
dd.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", schluessel).update(code).digest("hex"), jetzt);
return dd.prepare("SELECT last_insert_rowid() AS id").get().id;
};
person("Frida", "modi", "CODE-MODI-0001");
idHand = person("Rieke", "hand", "CODE-HAND-0001");
const talent = (titel, text) => {
dd.prepare("INSERT INTO eintraege (bereich, art, titel, text, datum, erstellt, erstellt_von, einsatz)"
+ " VALUES ('talente','ruhig',?,?,?,?,?,?)")
.run(titel, text, jetzt.slice(0, 10), jetzt, idDogi, "Im Stream am Dienstag");
return dd.prepare("SELECT last_insert_rowid() AS id").get().id;
};
talent("Kessi aus dem Chat", "Faellt seit Wochen auf.");
const idBeobachtet = talent("Malin aus dem Chat", "Haelt den Chat ruhig.");
const idProbe = talent("Tino aus dem Chat", "Laeuft seit vier Wochen mit.");
const stufe = dd.prepare(
"INSERT INTO talent_stufe (eintrag_id, stufe, seit, von_id) VALUES (?,?,?,?)");
stufe.run(idBeobachtet, "beobachtet", jetzt, idDogi);
stufe.run(idProbe, "probe", jetzt, idDogi);
/* MERKMALE fuer den Vorlagentext -- er wird daraus gebaut. Ohne sie
stuende dort der Satz fuer "noch nichts beobachtet", und die neue
Gestaltung waere zwar sichtbar, aber nicht in ihrer laengsten
Fassung. Gemessen werden soll die laengste. */
const merk = dd.prepare(
"INSERT INTO talent_merkmal (eintrag_id, schluessel, von_id, am) VALUES (?,?,?,?)");
for (const m of talKat.TALENT_ALLE.slice(0, 3)) {
merk.run(idBeobachtet, m.schluessel, idDogi, jetzt);
}
/* DIE EIGENE KARTE DER RECHTEN HAND. Sie oeffnet sich erst, wenn
jeder Beurteiler gesetzt hat -- ueber sie urteilt nur DogFather,
also reicht eine Zeile je Punkt. Sechs gesetzte und der Rest offen:
So stehen beide Zustaende nebeneinander auf dem Bildschirm. */
const stand = dd.prepare(
"INSERT INTO entwicklung_stand (person_id, schluessel, von_id, stufe, anlass, am)"
+ " VALUES (?,?,?,?,?,?)");
const stufen = ["laeuft", "waechst", "laeuft", "hakt", "laeuft", "waechst"];
kat.ENTWICKLUNG_FREMD.slice(0, 6).forEach((p, i) => {
stand.run(idHand, p.schluessel, idDogi, stufen[i],
stufen[i] === "hakt" ? "Zweimal zu spaet zur Schicht." : null, jetzt);
});
}
dd.close();
@@ -299,7 +352,16 @@ const UEBERBREIT = `(() => {
return { schiebt, raus };
})()`;
async function ansehen(name, pfad, code, breite, hoehe) {
/* MIT EINEM FERTIGEN KEKS STATT ueber die Anmeldemaske (11.09.2026).
Die rechte Hand meldet sich an der CREW-WAND an, und die haengt am
Host-Kopf -- den kann ein Browser bei einer Navigation nicht setzen.
Ueber die Maske auf 127.0.0.1 kommt sie also gar nicht herein.
Das ist kein Umgehen einer Sicherung: Angemeldet wird ueber denselben
Weg wie sonst (/api/anmelden), nur die Maske bleibt aussen vor. Was
die Seite danach zeigt, entscheidet weiterhin der Server. */
async function ansehen(name, pfad, code, breite, hoehe, keks = null) {
const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe } });
const seite = await kontext.newPage();
const konsole = [];
@@ -308,14 +370,39 @@ async function ansehen(name, pfad, code, breite, hoehe) {
});
seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message));
await seite.goto(`${BASIS}/workspace/`, { waitUntil: "domcontentloaded" });
await seite.click(`.rolle[data-rolle="admin"]`);
await seite.fill("#code", code);
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 15000 });
if (keks) {
await kontext.addCookies(keks.split("; ").filter(Boolean).map((teil) => {
const i = teil.indexOf("=");
return { name: teil.slice(0, i), value: teil.slice(i + 1), domain: "127.0.0.1", path: "/" };
}));
} else {
await seite.goto(`${BASIS}/workspace/`, { waitUntil: "domcontentloaded" });
await seite.click(`.rolle[data-rolle="admin"]`);
await seite.fill("#code", code);
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 15000 });
}
await seite.goto(`${BASIS}/workspace/${pfad}`, { waitUntil: "networkidle" });
await seite.waitForTimeout(900);
/* WARTEN, BIS SICH NICHTS MEHR AENDERT -- NICHT 900 MILLISEKUNDEN.
Gemessen am 11.09.2026: Die eigene Karte der rechten Hand stand
nachweislich auf dem Bildschirm (versteckt: false, Antwort 200),
aber der Text war schon vorher gelesen. Die Pruefung meldete
"steht nicht da" fuer etwas, das dastand -- der schlimmste
Fehlalarm, weil man danach am richtigen Code sucht.
Eine groessere Zahl waere derselbe Fehler mit einer anderen Zahl.
Die Seite laedt ihre Abschnitte nacheinander; wie lange das
dauert, haengt an ihrer Anzahl. Deshalb eine Regel statt einer
Rechnung: gelesen wird, wenn der Text dreimal hintereinander
derselbe war. Die Frist darueber ist nur die Notbremse. */
await seite.waitForFunction(() => {
const t = document.querySelector("main")?.innerText || "";
if (window.__standStill === t) { window.__ruhig = (window.__ruhig || 0) + 1; }
else { window.__standStill = t; window.__ruhig = 0; }
return (window.__ruhig || 0) >= 3;
}, null, { timeout: 12000, polling: 200 }).catch(() => {});
const text = await seite.evaluate(() => (document.querySelector("main")?.innerText || "").trim());
const punkte = await seite.evaluate(() => (document.querySelector("main")?.innerText || "")
@@ -323,12 +410,32 @@ async function ansehen(name, pfad, code, breite, hoehe) {
const kontrast = await seite.evaluate(KONTRAST);
const ueberlapp = await seite.evaluate(UEBERLAPP);
const breit = await seite.evaluate(UEBERBREIT);
/* DIE EIGENE KARTE IST DER EINZIGE ABSCHNITT, DER SICH SELBST
AUFDECKT. Bleibt er zu, sieht die Seite vollkommen normal aus --
deshalb wird hier nicht nur gelesen, was dasteht, sondern gefragt,
was der Server dazu sagt. */
const karte = await seite.evaluate(async () => {
const e = document.getElementById("karte-block");
if (!e) return { da: false };
let antwort = null;
try {
const a = await fetch("/workspace/api/entwicklung/meine-karte", { credentials: "same-origin" });
antwort = { status: a.status, hat: (await a.json()).hat_karte };
} catch (f) { antwort = { status: -1, fehler: String(f) }; }
return { da: true, versteckt: e.hidden, antwort };
});
await seite.screenshot({ path: join(BILDER, `${name}-${breite}.png`), fullPage: true });
await kontext.close();
return { text, punkte, kontrast, ueberlapp, breit, konsole };
return { text, punkte, kontrast, ueberlapp, breit, konsole, karte };
}
for (const [name, pfad] of [
const gesehen = {}; // was auf jedem Bildschirm tatsaechlich stand
const anm3 = await roh("/workspace/api/anmelden", {}, { rolle: "hand", code: "CODE-HAND-0001" });
const kHand = [].concat(anm3.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; ");
ok(!!kHand, "die rechte Hand ist angemeldet -- ohne sie bliebe ihre eigene Karte ungesehen");
for (const [name, pfad, keks] of [
["regeln", "treff-regeln.html"],
["rechte", "rechte.html"],
["moderation", "treff-moderation.html"],
@@ -338,11 +445,17 @@ for (const [name, pfad] of [
findet andere Fehler. */
["entwicklung", "entwicklung.html"],
["talente", "talente.html"],
/* UND EINMAL DIESELBE SEITE MIT ANDEREN AUGEN. "Deine Karte" gibt es
nur fuer die, ueber die geurteilt wird -- DogFather sieht sie nie.
Ohne diese Zeile waere der ganze Abschnitt nie auf einem Bildschirm
gewesen. */
["entwicklung-hand", "entwicklung.html", kHand],
]) {
melde("");
melde(`=== ${pfad} ===`);
for (const [breite, hoehe] of [[1440, 1000], [390, 844]]) {
const r = await ansehen(name, pfad, "CODE-DOGI-0001", breite, hoehe);
const r = await ansehen(name, pfad, "CODE-DOGI-0001", breite, hoehe, keks);
if (breite === 1440) { gesehen[name] = r.text; gesehen[name + ":karte"] = r.karte; }
const wo = `${breite} px`;
ok(r.text.length > 400, `${wo}: die Seite ist gefüllt (${r.text.length} Zeichen)`);
@@ -374,6 +487,63 @@ for (const [name, pfad] of [
}
}
/* =======================================================================
WAR DAS NEUE UEBERHAUPT AUF DEM BILDSCHIRM?
Die Messungen darueber sagen "nichts liegt uebereinander" und "alles
lesbar" -- und beides ist auch dann wahr, wenn das Gesuchte gar nicht
da war. Eine leere Seite erfuellt jede dieser Bedingungen.
Deshalb hier die Gegenfrage, und zwar in beide Richtungen: Das Neue
MUSS dort stehen, wo es hingehoert, und darf NICHT dort stehen, wo
es nicht hingehoert. Nur die zweite Haelfte beweist, dass die erste
etwas heisst.
======================================================================= */
melde("");
melde("=== Stand das Neue auch wirklich da? ===");
{
const t = gesehen.talente || "";
ok(/Vorlagentext/.test(t),
"Talente: der Knopf fuer den Vorlagentext stand auf der Stufe „Beobachtet“");
ok(/Ins Team holen/.test(t),
"Talente: „Ins Team holen · Zugang anlegen“ stand auf der Stufe „Probe“");
/* GEGENPROBE: Der Vorlagentext gehoert auf GENAU EINE Stufe. Stuende
er auf jeder, waere er auf vier von fuenf eine Ablenkung -- und die
Zeile darueber waere trotzdem gruen. */
/* ZEILENWEISE gezaehlt und nicht als Wort: "Der Vorlagentext liegt
bereit" ist der Satz des naechsten Schritts, der Knopf steht
allein auf seiner Zeile. Als Wort gezaehlt waeren es zwei, und die
Zeile haette einen Fehler gemeldet, den es nicht gibt. */
const knoepfe = (wort) => t.split("\n").filter((z) => z.trim() === wort).length;
ok(knoepfe("Vorlagentext") === 1,
`und zwar genau einmal, nicht bei jedem Kandidaten (${knoepfe("Vorlagentext")})`);
ok(t.split("Ins Team holen").length - 1 === 1,
`der Zugangsknopf ebenso (${t.split("Ins Team holen").length - 1})`);
const hand = gesehen["entwicklung-hand"] || "";
const dogi = gesehen.entwicklung || "";
/* WENN ES SCHIEFGEHT, SOLL DIE MELDUNG SAGEN, WAS STATTDESSEN DA WAR.
"nicht gefunden" allein laesst offen, ob die Karte fehlt oder die
ganze Seite eine andere war. */
const obenLinks = (hand.split("\n").map((z) => z.trim()).filter(Boolean)[0] || "(leer)");
/* OHNE RUECKSICHT AUF GROSS UND KLEIN: innerText liefert, was der
Browser ZEIGT -- und .e-abschnitt ist in Grossbuchstaben gesetzt.
Im Quelltext steht "Deine Karte", auf dem Bildschirm "DEINE
KARTE". Eine Suche mit Ruecksicht darauf meldet "steht nicht da"
fuer etwas, das dasteht. */
ok(/Deine Karte/i.test(hand),
`Entwicklung: die rechte Hand sieht ihre eigene Karte (${JSON.stringify(gesehen["entwicklung-hand:karte"])} / oben: ${obenLinks})`);
ok(!/Deine Karte/i.test(dogi),
"und DogFather sieht sie nicht -- ueber ihn urteilt hier niemand");
ok(hand !== dogi && hand.length > 400,
`zwei verschiedene Bildschirme (${dogi.length} / ${hand.length} Zeichen) -- der Keks hat also gewirkt`);
/* UND KEIN NAME EINES BEURTEILERS. Was der Server nicht ausliefert,
kann die Seite nicht zeigen -- aber genau das ist die Behauptung,
und Behauptungen gehoeren gemessen. */
ok(!/Filipe/.test(hand),
"und in ihrer Karte steht kein Name dessen, der geurteilt hat");
}
await browser.close();
melde("");
melde(`Bilder liegen in ${BILDER}`);
+61 -1
View File
@@ -37,7 +37,7 @@
dann an die falsche Wand und scheiterte, ohne dass man saehe warum.
Dieselbe Falle wie am 10.09. bei den Kategorie-Kanaelen.
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { request as httpAnfrage } from "node:http";
@@ -618,6 +618,66 @@ melde("=== 6. Schreiben ===");
ok(a.code !== 200 && a.code !== 201, `aber nicht in ein Brett des Hauses (${a.code})`);
}
/* =======================================================================
7. WEM EIN EINTRAG GILT, SAGT DER SERVER -- NICHT DIE OBERFLAECHE
Filipe, 11.09.2026: "auf dieser seite soll nichts stehen von agentur,
diese seite hier ist nur fuer mich meine modis und meine community."
Er hatte recht, und der Fehler war ein fest eingetragenes Wort: In
bereich.js stand woertlich "Agentur" -- richtig auf der
Agenturadresse, falsch auf jeder anderen.
GEPRUEFT WIRD MIT EINEM EINZIGEN MENSCHEN AUF ZWEI ADRESSEN. Dieselbe
Person, dasselbe Brett, zwei Waende: Wandert das Wort mit, kommt es
vom Haus. Waere es an der Rolle festgemacht, stuende hier zweimal
dasselbe -- und das ist genau der Fehler, den es zu finden gilt.
======================================================================= */
melde("");
melde("=== 7. Das Wort für „Gilt für“ kommt vom Server ===");
{
const kCrew = (await anmelden("admin", "CODE-DOGI-0001", CREW)).keks;
const kHaus = (await anmelden("admin", "CODE-DOGI-0001", HAUS)).keks;
ok(!!kCrew && !!kHaus, "DogFather ist an beiden Waenden angemeldet");
const lies = async (brett, host, keks) => {
const a = await roh(`/workspace/api/bereich/${brett}`, host, { cookie: keks });
let g = null;
try { g = JSON.parse(a.text).gehoert; } catch { /* bleibt null */ }
return { code: a.code, gehoert: g };
};
const treff = await lies("treff", CREW, kCrew);
ok(treff.code === 200 && treff.gehoert === "Der Treff",
`ein Treff-Brett gehoert dem Treff (${treff.gehoert})`);
const crew = await lies("ideen", CREW, kCrew);
ok(crew.code === 200 && crew.gehoert === "Team Dogi",
`dasselbe Haus-Brett auf crew. gehoert Team Dogi (${crew.gehoert})`);
/* GEGENPROBE, und ohne sie beweist die Zeile darueber nichts: Auf der
Agenturadresse MUSS weiterhin "Agentur" stehen. Ein Wort, das
ueberall dasselbe sagt, sagt nichts. */
const haus = await lies("ideen", HAUS, kHaus);
ok(haus.code === 200 && haus.gehoert === "Agentur",
`und auf workspace. weiterhin Agentur (${haus.gehoert})`);
ok(crew.gehoert !== haus.gehoert,
"dieselbe Person, dasselbe Brett, zwei Woerter -- es haengt am Haus");
/* UND DIE OBERFLAECHE SCHREIBT ES NICHT MEHR SELBST HIN. */
const js = readFileSync(new URL("../workspace/assets/js/bereich.js", import.meta.url), "utf8");
const ohneKommentare = js.replace(/\/\*[\s\S]*?\*\//g, "");
ok(!/['"`]Agentur/.test(ohneKommentare),
"in bereich.js steht das Wort „Agentur“ in keinem Text mehr");
ok(/gehoert/.test(ohneKommentare),
"stattdessen fragt sie den Server danach");
/* Gegenprobe zum Sucher selbst: Er MUSS ein hingeschriebenes
"Agentur" auch wirklich finden, sonst ist die Zeile darueber nur
gruen, weil sie blind ist. */
ok(/['"`]Agentur/.test("const x = 'Agentur-Eintraege';"),
"und der Sucher findet so ein Wort auch wirklich");
}
melde("");
console.log(`${geprueft} Pruefungen, ${fehler} Fehler`);
console.log(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG");
+19 -1
View File
@@ -1093,8 +1093,26 @@ bereicheRouter.get("/workspace/api/bereich/:bereich", (req, res) => {
es gaebe eine zweite Wahrheit darueber, welche es sind. So gibt
es weiterhin genau eine (TREFF_BRETTER), und der Browser fragt
sie, statt sie zu kennen. */
/* WEM GEHOERT EIN EINTRAG AUF DIESEM BRETT? (11.09.2026)
Filipe, mit Bildschirmfoto des Feldes "Gilt für: Agentur":
"auf dieser seite soll nichts stehen von agentur, diese seite
hier ist nur für mich meine modis und meine community."
Er hatte recht, und der Fehler war ein fest eingetragenes Wort:
In bereich.js stand woertlich "Agentur" -- richtig auf der
Agenturadresse, falsch auf jeder anderen. Ein Wort in der
Oberflaeche, das eine Tatsache ueber den Server behauptet, ist
genau die Sorte Zweitschrift, die auseinanderlaeuft.
Jetzt sagt der Server es. Er weiss beides: welches Brett es ist
und in welchem Haus die Person gerade steht. */
const gehoert = treffBrett ? "Der Treff"
: (req.sicht || req.person)?.haus === "crew" ? "Team Dogi"
: "Agentur";
res.json({
bereich, einstellung, treff: treffBrett,
bereich, einstellung, treff: treffBrett, gehoert,
eintraege: treffBrett ? eintraege.map(mitRollenname) : eintraege,
});
} catch (fehler) {
+96 -1
View File
@@ -69,7 +69,7 @@ import {
} from "./workspace-entwicklungs-punkte.js";
import {
TALENT_STUFEN, TALENT_GRUPPEN, TALENT_MERKMALE, TALENT_WARNZEICHEN,
TALENT_NACH_SCHLUESSEL, talentStufe, talentNaechste,
TALENT_NACH_SCHLUESSEL, talentStufe, talentNaechste, ansprechtext,
} from "./workspace-talent-punkte.js";
export const entwicklungRouter = express.Router();
@@ -437,6 +437,96 @@ entwicklungRouter.put("/workspace/api/entwicklung/mich/:schluessel", gleicheHerk
}
});
/* ---------- 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.
@@ -541,6 +631,11 @@ function talentKarte(e, merkmale) {
merkmale: gesetzt.filter((s) => !TALENT_NACH_SCHLUESSEL.get(s)?.warnung),
warnzeichen: warn,
notiz: stand?.notiz || null,
/* 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),
};
}
+119 -23
View File
@@ -73,13 +73,13 @@ export const TALENT_STUFEN = [
{
schluessel: "probe", name: "Probe", rang: 4, frist: 30,
bedeutet: "Echte Schicht, kleiner Rahmen, fester Buddy – 30 Tage, beidseitig ausgesprochen.",
schritt: "Nach 30 Tagen: übernehmen oder sauber beenden.",
schritt: "Nach 30 Tagen: übernehmen – der Zugang entsteht dabei – oder sauber beenden.",
knopf: "Ins Team",
},
{
schluessel: "imteam", name: "Im Team", rang: 5, frist: null,
bedeutet: "Zugang steht. Ab hier zählt die Entwicklungskarte, nicht mehr diese.",
schritt: "Zugang in „Personen & Zugänge“ anlegen, Rolle Modi.",
schritt: "Nichts mehr zu tun – der Zugang steht, ab hier zählt die Entwicklungskarte.",
knopf: null,
},
];
@@ -116,58 +116,79 @@ export const TALENT_GRUPPEN = [
export const TALENT_MERKMALE = {
ruhe: [
{ schluessel: "t-ruhe-streit", titel: "Bleibt ruhig, wenn im Chat gestritten wird",
{ schluessel: "t-ruhe-streit", du: "auch dann ruhig bleibst, wenn es im Chat hitzig wird",
titel: "Bleibt ruhig, wenn im Chat gestritten wird",
text: "Der Normalfall, an dem sich alles zeigt." },
{ schluessel: "t-ruhe-provokation", titel: "Geht auf Provokation nicht ein",
{ schluessel: "t-ruhe-provokation", du: "auf Provokation nicht anspringst",
titel: "Geht auf Provokation nicht ein",
text: "Nicht ignorieren – nicht mitgehen. Zwei verschiedene Dinge." },
{ schluessel: "t-ruhe-widerspricht", titel: "Widerspricht, ohne auszuteilen",
{ schluessel: "t-ruhe-widerspricht", du: "widersprichst, ohne auszuteilen",
titel: "Widerspricht, ohne auszuteilen",
text: "Wer nie widerspricht, ist nicht ruhig, sondern still. Das ist etwas anderes." },
{ schluessel: "t-ruhe-gegen-sich", titel: "Hat sich auch dann im Griff, wenn es gegen ihn selbst geht",
{ schluessel: "t-ruhe-gegen-sich", du: "dich sogar dann im Griff hast, wenn es gegen dich selbst geht",
titel: "Hat sich auch dann im Griff, wenn es gegen ihn selbst geht",
text: "Der härteste der vier – und der aussagekräftigste." },
],
hilft: [
{ schluessel: "t-hilft-vor-team", titel: "Beantwortet Fragen anderer, bevor jemand vom Team da ist",
{ schluessel: "t-hilft-vor-team", du: "Fragen von anderen beantwortest, bevor jemand von uns da ist",
titel: "Beantwortet Fragen anderer, bevor jemand vom Team da ist",
text: "Tut die Arbeit schon, ohne den Titel zu haben." },
{ schluessel: "t-hilft-erklaert-neuen", titel: "Erklärt Neuen, wie es hier läuft",
{ schluessel: "t-hilft-erklaert-neuen", du: "Neuen erklärst, wie es hier läuft",
titel: "Erklärt Neuen, wie es hier läuft",
text: "Setzt voraus, dass er es selbst verstanden hat." },
{ schluessel: "t-hilft-meldet-statt-moderiert", titel: "Meldet etwas, statt selbst zu moderieren",
{ schluessel: "t-hilft-meldet-statt-moderiert", du: "Sachen meldest, statt selbst durchzugreifen",
titel: "Meldet etwas, statt selbst zu moderieren",
text: "Genau die richtige Grenze zu kennen, ist das eigentliche Signal – "
+ "wer ohne Rolle durchgreift, wird es mit Rolle erst recht." },
{ schluessel: "t-hilft-unbeobachtet", titel: "Hilft auch dann, wenn es niemand sieht",
{ schluessel: "t-hilft-unbeobachtet", du: "auch dann hilfst, wenn gerade niemand hinsieht",
titel: "Hilft auch dann, wenn es niemand sieht",
text: "Schwer zu beobachten, deshalb selten angeklickt – und wenn, dann zählt es." },
],
haus: [
{ schluessel: "t-haus-regeln", titel: "Kennt die Regeln, ohne sie nachzulesen",
{ schluessel: "t-haus-regeln", du: "die Regeln hier kennst, ohne nachzulesen",
titel: "Kennt die Regeln, ohne sie nachzulesen",
text: "Und wendet sie im Gespräch an, nicht als Zitat." },
{ schluessel: "t-haus-leute", titel: "Kennt die Leute – weiß, wer wer ist",
{ schluessel: "t-haus-leute", du: "die Leute kennst und weißt, wer wer ist",
titel: "Kennt die Leute – weiß, wer wer ist",
text: "Ein halbes Jahr Einarbeitung, die schon passiert ist." },
{ schluessel: "t-haus-witze", titel: "Versteht die Witze, die nur hier funktionieren",
{ schluessel: "t-haus-witze", du: "die Witze verstehst, die nur hier funktionieren",
titel: "Versteht die Witze, die nur hier funktionieren",
text: "Klingt nebensächlich und ist der Unterschied zwischen Gast und Zuhause." },
{ schluessel: "t-haus-frueh-dabei", titel: "War schon da, als es kleiner war",
{ schluessel: "t-haus-frueh-dabei", du: "schon dabei warst, als es hier noch kleiner war",
titel: "War schon da, als es kleiner war",
text: "Nicht mehr wert – aber belastbarer, weil länger beobachtet." },
],
da: [
{ schluessel: "t-da-regelmaessig", titel: "Über Wochen regelmäßig, nicht nur bei den großen Streams",
{ schluessel: "t-da-regelmaessig", du: "über Wochen regelmäßig da bist, nicht nur bei den großen Streams",
titel: "Über Wochen regelmäßig, nicht nur bei den großen Streams",
text: "Regelmäßigkeit ist planbar, Begeisterung nicht." },
{ schluessel: "t-da-auch-wenig-los", titel: "Auch dann, wenn wenig los ist",
{ schluessel: "t-da-auch-wenig-los", du: "auch dann kommst, wenn wenig los ist",
titel: "Auch dann, wenn wenig los ist",
text: "Die kleinen Streams sind der ehrlichere Test." },
{ schluessel: "t-da-luecke", titel: "Zu Zeiten, zu denen wir niemanden haben",
{ schluessel: "t-da-luecke", du: "oft genau dann da bist, wenn wir sonst niemanden haben",
titel: "Zu Zeiten, zu denen wir niemanden haben",
text: "Operativ der entscheidendste Punkt der ganzen Liste: Ein guter Kandidat "
+ "zur falschen Zeit löst kein einziges Problem." },
{ schluessel: "t-da-kommt-wieder", titel: "Kommt nach einer Pause von selbst wieder",
{ schluessel: "t-da-kommt-wieder", du: "nach einer Pause von selbst wieder auftauchst",
titel: "Kommt nach einer Pause von selbst wieder",
text: "Pausen sind normal. Wiederkommen ist das Signal." },
],
empfohlen: [
{ schluessel: "t-emp-vom-team", titel: "Von jemandem aus dem Team vorgeschlagen",
{ schluessel: "t-emp-vom-team", du: "jemandem aus dem Team aufgefallen bist",
titel: "Von jemandem aus dem Team vorgeschlagen",
text: "Ein Augenpaar, das den Alltag kennt." },
{ schluessel: "t-emp-mehrfach", titel: "Mehr als eine Person hat unabhängig denselben Namen genannt",
{ schluessel: "t-emp-mehrfach", du: "gleich mehreren aus dem Team unabhängig voneinander aufgefallen bist",
titel: "Mehr als eine Person hat unabhängig denselben Namen genannt",
text: "Der stärkste Einzelbefund im ganzen Katalog. Zwei unabhängige Nennungen "
+ "schlagen jede einzelne Beobachtung." },
{ schluessel: "t-emp-schicht-tauschen", titel: "Jemand aus dem Team würde eine Schicht mit ihm tauschen",
{ schluessel: "t-emp-schicht-tauschen", du: "jemand aus dem Team gern eine Schicht mit dir tauschen würde",
titel: "Jemand aus dem Team würde eine Schicht mit ihm tauschen",
text: "Die ehrlichste Frage, die man dem Team stellen kann." },
{ schluessel: "t-emp-schon-ausgeholfen", titel: "Hat schon mal ausgeholfen, ohne Rolle",
{ schluessel: "t-emp-schon-ausgeholfen", du: "schon mal ausgeholfen hast, ohne dass du musstest",
titel: "Hat schon mal ausgeholfen, ohne Rolle",
text: "Der beste verfügbare Beweis – er hat es getan." },
{ schluessel: "t-emp-von-zuschauer", titel: "Wurde von einem Zuschauer vorgeschlagen",
{ schluessel: "t-emp-von-zuschauer", du: "auch anderen Zuschauern aufgefallen bist",
titel: "Wurde von einem Zuschauer vorgeschlagen",
text: "Schwächer als eine Nennung aus dem Team, aber es zählt." },
],
};
@@ -217,6 +238,81 @@ export const TALENT_NACH_SCHLUESSEL = new Map([
export const talentStufe = (s) =>
TALENT_STUFEN.find((x) => x.schluessel === s) || null;
/* =====================================================================
DER VORLAGENTEXT ZUM ANSPRECHEN (11.09.2026)
=====================================================================
Auf Stufe 2 steht als naechster Schritt "Ansprechen". Bis heute stand
daneben "der Vorlagentext liegt bereit" -- und es gab keinen. Ein
Versprechen an eine Schublade.
ER WIRD GEBAUT, NICHT ABGESCHRIEBEN. Aus den Merkmalen, die wirklich
angeklickt wurden. Ein allgemeiner Text ("wir fanden dich toll")
klingt wie ein Serienbrief, weil er einer ist; drei konkrete
Beobachtungen klingen nach jemandem, der hingesehen hat -- und das
ist der Unterschied, ob jemand zusagt.
HOECHSTENS DREI. Wer fuenf Beobachtungen auflistet, klingt nach
Personalakte. Die drei kommen in der Reihenfolge des Katalogs, und
die faengt bei "Ruhe" an -- dem am besten belegten Merkmal.
DREI SAETZE, DIE IMMER DRINSTEHEN, und jeder hat einen Grund:
* "kein Druck, sag ruhig nein" -- wer sich gedraengt fuehlt, sagt
ja und hoert nach drei Wochen auf.
* "30 Tage, danach schauen wir beide" -- die Probezeit wird
BEIDSEITIG ausgesprochen. Wer weiss, dass er ohne Gesichtsverlust
wieder gehen kann, sagt eher ehrlich, wie es ihm damit geht.
* "du musst nichts koennen" -- die haeufigste Absage kommt nicht
aus Desinteresse, sondern aus der Annahme, man muesse etwas
mitbringen.
ER WIRD NICHT VERSCHICKT. Er steht da zum Kopieren. Eine Nachricht,
die ein Programm verschickt, liest sich wie eine Nachricht, die ein
Programm verschickt hat. */
export function ansprechtext(name, gesetzteMerkmale) {
/* NUR DAS ERSTE WORT. Im Brett steht selten ein Name allein --
"Kessi aus dem Chat" ist eine Notiz, keine Anrede. "Hey Kessi aus
dem Chat," liest sich wie ein Serienbrief, und genau das soll der
Text nicht sein.
Es ist eine VORLAGE zum Kopieren, keine Nachricht, die abgeschickt
wird -- wer den Namen anders schreiben will, tut das im Text. */
const sauber = String(name || "").trim().split(/[\s,(–-]+/)[0].trim() || "du";
const beobachtet = (gesetzteMerkmale || [])
.map((sch) => TALENT_ALLE.find((m) => m.schluessel === sch)?.du)
.filter(Boolean)
.slice(0, 3);
const mitte = beobachtet.length
? "Mir ist aufgefallen, dass du " + (beobachtet.length === 1
? beobachtet[0]
: beobachtet.slice(0, -1).join(", ") + " und " + beobachtet[beobachtet.length - 1]) + "."
: "Du bist mir hier schon öfter positiv aufgefallen.";
/* ECHTE UMLAUTE UND ECHTE STRICHE.
In Kommentaren schreibt dieses Haus ASCII; in TEXT, den jemand
liest, nicht. Der erste Entwurf stand voller "Haettest" und
"koennen" -- und dieser Text hier ist der einzige im ganzen
Bereich, den ein Fremder zu sehen bekommt. "Haettest du Lust" sieht
aus wie ein Fehler, weil es einer ist. Derselbe Befund stand schon
am 10.09. in workspace-personen.js. */
return [
`Hey ${sauber},`,
"",
mitte,
"",
"Deshalb die Frage: Hättest du Lust, bei uns im Mod-Team mitzumachen?",
"",
"Du musst dafür nichts können – alles, was es zu wissen gibt, zeigen wir dir. "
+ "Wir würden mit 30 Tagen anfangen, und danach schauen wir beide, ob es passt. "
+ "Wenn nicht, ist das völlig in Ordnung und ändert nichts.",
"",
"Und ganz wichtig: kein Druck. Sag ruhig nein oder „gerade nicht“ – "
+ "das nehme ich dir null übel.",
].join("\n");
}
/** Die naechste Stufe -- oder null am Ende des Trichters. */
export function talentNaechste(s) {
const i = TALENT_STUFEN.findIndex((x) => x.schluessel === s);