diff --git a/server/index.js b/server/index.js index f0fe7bd5..596dc89b 100644 --- a/server/index.js +++ b/server/index.js @@ -24,6 +24,7 @@ import { serienRouter, serienStarten } from "./workspace-serien.js"; import { dateienRouter } from "./workspace-dateien.js"; import { bereicheRouter } from "./workspace-bereiche.js"; import { treffRouter } from "./workspace-treff.js"; +import { entwicklungRouter } from "./workspace-entwicklung.js"; import { reportRouter } from "./workspace-reports.js"; import { scoutRouter } from "./workspace-scouts.js"; import { callRouter } from "./workspace-calls.js"; @@ -243,6 +244,11 @@ app.use(bereicheRouter); Weg zu denselben Zeilen waere ein zweiter Ort fuer dieselbe Rechtepruefung. */ app.use(treffRouter); +/* Entwicklung & Nachwuchs (11.09.2026): die zwei Kataloge zum Anklicken. + Die Bretter mit den freien Notizen laufen weiter ueber bereicheRouter + -- beides nebeneinander ist Absicht: Der Katalog ist das, was man + regelmaessig durchgeht, die Notiz das, was dazwischen auffaellt. */ +app.use(entwicklungRouter); app.use(reportRouter); app.use(scoutRouter); app.use(callRouter); diff --git a/server/pruef-entwicklung.mjs b/server/pruef-entwicklung.mjs index c06429a0..c8681aaa 100644 --- a/server/pruef-entwicklung.mjs +++ b/server/pruef-entwicklung.mjs @@ -137,9 +137,23 @@ console.log("=== Die beiden Bereiche ==="); ok(arten.includes("last"), "„Zu viel gerade“ ist dabei -- der haeufigste Grund aufzuhoeren"); + /* DAS NOTIZBRETT MUSS ERREICHBAR BLEIBEN -- gerade WEIL die Kachel + nicht mehr darauf zeigt. + + Genau hier ist am 11.09.2026 etwas kaputtgegangen: Die Liste der + Bretter, die ein Modi oder die rechte Hand oeffnen darf, wird aus + den KACHELZIELEN abgeleitet. Als die Kacheln auf die Katalogseiten + umgestellt wurden, fielen beide Bretter aus der Liste -- und die + Seite verlinkte auf ein 404. Kein Fehler im Riegel, sondern in + seiner Quelle. + + Diese Zeile ist die einzige, die das gemerkt hat. */ const b = await hole(TAL, { host: CREW, keks: hand.keks }); ok(b.daten?.einstellung?.name === "Talente", `die rechte Hand kann Talente oeffnen (${b.daten?.einstellung?.name})`); + const bE = await hole(ENTW, { host: CREW, keks: hand.keks }); + ok(bE.daten?.einstellung?.name === "Entwicklung", + `und das Notizbrett Entwicklung auch (${bE.daten?.einstellung?.name})`); const tArten = Object.keys(b.daten?.einstellung?.arten || {}); ok(tArten.includes("empfohlen"), `die Empfehlung aus dem Team ist dabei (${tArten.join(", ")})`); @@ -240,17 +254,55 @@ console.log("=== An die Person schicken ==="); console.log(""); console.log("=== Die Kacheln ==="); { + /* DIE KACHELN ZEIGEN SEIT DEM 11.09.2026 AUF DIE KATALOGSEITEN. + + Vorher stand hier `b=(entwicklung|talente)` -- das Ziel war ein + Brett. Filipe: "ich will dass fertige aufgaben da stehen und so". + Seither fuehrt die Kachel auf entwicklung.html bzw. talente.html; + das Notizbrett gibt es weiter und ist von dort verlinkt. + + GEPRUEFT WIRD DER NAME DER SEITE UND NICHT MEHR DAS BRETT -- eine + Kachel, die auf ein Brett zeigt, waere ab jetzt der Rueckschritt, + den diese Zeile melden soll. */ for (const [wer, keks] of [["DogFather", dogi.keks], ["die rechte Hand", hand.keks]]) { const a = await hole("/workspace/api/ich", { host: CREW, keks }); const alle = [...(a.daten?.bereiche || []), ...(a.daten?.bereiche_zusatz || [])]; - const beide = alle.filter((k) => /b=(entwicklung|talente)/.test(k.ziel || "")); + /* NACH DEM NAMEN UND NICHT NACH DEM ZIEL. + + Seit dem 11.09.2026 zeigen DREI Kacheln auf entwicklung.html: die + beiden Katalogkacheln und „Wie geht's dir?" -- dieselbe Seite, + drei verschiedene Zwecke, weil der Server entscheidet, was ein + Modi dort sieht. Ein Filter nach dem Ziel zaehlt sie alle und + meldet einen Fehler, wo keiner ist. + + Der Name ist hier die ehrlichere Frage: Es geht um DIESE zwei + Kacheln, nicht um alles, was zufaellig dieselbe Adresse hat. */ + const beide = alle.filter((k) => ["Entwicklung", "Talente"].includes(k.name)); ok(beide.length === 2, `${wer}: beide Kacheln (${beide.map((k) => k.name).join(", ") || "keine"})`); ok(beide.every((k) => k.gruppe === "Entwicklung & Nachwuchs"), `${wer}: in einer eigenen Gruppe (${[...new Set(beide.map((k) => k.gruppe))].join(", ")})`); } + /* DIE DRITTE KACHEL -- und sie gehoert ausdruecklich ALLEN im Team. + + „Wie geht's dir?" ist die einzige Kachel des Hauses, deren Inhalt + niemand ausser der Person selbst je sieht. Sie steht auch bei + DogFather und der rechten Hand: Wenn es jemandem zu viel wird, + dann ihnen genauso. Eine Kachel, die nur die anderen ausfuellen, + waere genau die Schieflage, die dieses Haus nicht haben soll. */ + for (const [wer, keks] of [["DogFather", dogi.keks], ["die rechte Hand", hand.keks], + ["ein Modi", ayla.keks]]) { + const x = await hole("/workspace/api/ich", { host: CREW, keks }); + const alleX = [...(x.daten?.bereiche || []), ...(x.daten?.bereiche_zusatz || [])]; + const befinden = alleX.find((k) => k.ziel === "entwicklung.html" + && !["Entwicklung", "Talente"].includes(k.name)); + ok(!!befinden, `${wer} hat „${befinden?.name || "—"}"`); + ok(befinden?.gruppe === "Für dich", + `und sie steht bei „Für dich" (${befinden?.gruppe})`); + } + const a = await hole("/workspace/api/ich", { host: CREW, keks: ayla.keks }); const alle = [...(a.daten?.bereiche || []), ...(a.daten?.bereiche_zusatz || [])]; - ok(!alle.some((k) => /b=(entwicklung|talente)/.test(k.ziel || "")), + ok(!alle.some((k) => ["Entwicklung", "Talente"].includes(k.name)), `ein Teammitglied bekommt sie nicht angeboten (${alle.length} Kacheln)`); /* Kein Farbton doppelt -- zwei Kacheln in derselben Farbe sind ein diff --git a/server/pruef-nachwuchs.mjs b/server/pruef-nachwuchs.mjs new file mode 100644 index 00000000..606cba9b --- /dev/null +++ b/server/pruef-nachwuchs.mjs @@ -0,0 +1,462 @@ +/* ===================================================================== + Entwicklung & Nachwuchs — die Riegel (11.09.2026) + + Der Bereich hat vier Regeln, und jede einzelne wäre ohne Prüfung + eine Zusage statt einer Eigenschaft: + + 1. Block 5 gehört der Person. DogFather sieht ihn nicht — lesend + UND schreibend. Mit Gegenprobe: die Person selbst sieht ihn. + 2. Der Stand des anderen kommt erst nach dem eigenen Klick. Mit + Gegenprobe: danach kommt er auch wirklich. + 3. „Da hakt es" ohne Anlass wird abgelehnt. Mit Gegenprobe: mit + Anlass geht es durch. + 4. Talente gehört DogFather und der rechten Hand. Ein Modi bekommt + 404 — und zwar auf Seite UND Schnittstelle. + + Dazu der Katalog selbst (Schlüssel, Stufen, keine Dopplungen) und + das Verblassen nach 90 Tagen — von beiden Seiten der Grenze, sonst + beweist es nichts. + + Aufrufen mit: node server/pruef-nachwuchs.mjs + ===================================================================== */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { request as httpAnfrage } from "node:http"; +import { portMussFreiSein } from "./helfer-port.mjs"; + +const PORT = await portMussFreiSein(4404, "pruef-nachwuchs"); + +const ordner = mkdtempSync(join(tmpdir(), "ws-nachwuchs-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = String(PORT); +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +import { notbremse } from "./helfer-notbremse.mjs"; +await import("./index.js"); +notbremse(240_000, "pruef-nachwuchs"); +await new Promise((r) => setTimeout(r, 700)); + +process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); +process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); + +let fehler = 0, geprueft = 0; +const melde = (t) => console.log(t); +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +const CREW = "crew.dogfather-universe.com"; +const HAUS = "workspace.dogfather-universe.com"; + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const kat = await import("./workspace-entwicklungs-punkte.js"); +const tal = await import("./workspace-talent-punkte.js"); +const rechte = await import("./rechte.js"); + +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +const schluessel = randomBytes(32).toString("hex"); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", schluessel, jetzt); +function anlegen(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"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, createHmac("sha256", schluessel).update(code).digest("hex"), jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idHand = anlegen("Rieke", "hand", "CODE-HAND-0001"); +const idModi = anlegen("Frida", "modi", "CODE-MODI-0001"); +anlegen("Nina", "modi", "CODE-MODI-0002"); +anlegen("Mara", "manager", "CODE-MANA-0001"); + +/* Ein Kandidat im Talente-Brett -- ohne ihn waere jede Zeile ueber den + Trichter ein gruener Haken ueber der Null. */ +d.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 idTalent = d.prepare("SELECT last_insert_rowid() AS id").get().id; +d.close(); + +function roh(pfad, host, kopf = {}, koerper = null, methode = null) { + if (methode === "DELETE") koerper = null; + return new Promise((fertig, schief) => { + const a = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: pfad, + method: methode || (koerper ? "POST" : "GET"), + headers: { Host: host, ...(koerper ? { "Content-Type": "application/json" } : {}), ...kopf }, + }, (antwort) => { + let text = ""; + antwort.on("data", (s) => { text += s; }); + antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers })); + }); + a.on("error", schief); + if (koerper) a.write(JSON.stringify(koerper)); + a.end(); + }); +} +const json = (a) => { try { return JSON.parse(a.text); } catch { return {}; } }; +async function anmelden(rolle, code, host) { + const r = await roh("/workspace/api/anmelden", host, {}, { rolle, code }); + return [].concat(r.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; "); +} +const kDogi = await anmelden("admin", "CODE-DOGI-0001", HAUS); +const kHand = await anmelden("hand", "CODE-HAND-0001", CREW); +const kModi = await anmelden("modi", "CODE-MODI-0001", CREW); +ok(!!kDogi && !!kHand && !!kModi, "DogFather, rechte Hand und ein Modi sind angemeldet"); + +/* ======================================================================= + 1. DER KATALOG SELBST + ======================================================================= */ +melde(""); +melde("=== 1. Die zwei Kataloge ==="); +{ + const alle = kat.ENTWICKLUNG_ALLE; + ok(alle.length > 20, `${alle.length} Beobachtungen im Katalog`); + ok(new Set(alle.map((p) => p.schluessel)).size === alle.length, + "kein Schlüssel doppelt"); + ok(alle.every((p) => p.schluessel.startsWith("e-")), + "jeder Schlüssel beginnt mit e- (keine Kollision mit der Checkliste)"); + ok(alle.every((p) => p.titel && p.text && p.stufe), + "jede Beobachtung hat Titel, Erklärung und Erwartungsstufe"); + const stufen = new Set(alle.map((p) => p.stufe)); + ok(kat.ENTWICKLUNG_ERWARTUNG.every((e) => stufen.has(e.schluessel)), + `alle drei Erwartungsstufen kommen vor (${[...stufen].join(", ")})`); + + /* DER RIEGEL IST EINE EIGENSCHAFT DES KATALOGS, nicht nur des + Servers: Genau ein Block ist `nurSelbst`, und die abgeleiteten + Listen passen dazu. Verrutscht das hier, greift der Server + weiterhin -- aber die Oberflaeche zeigte etwas anderes an. */ + const selbstBloecke = kat.ENTWICKLUNG_BLOECKE.filter((b) => b.nurSelbst); + ok(selbstBloecke.length === 1, `genau ein Block gehört der Person selbst (${selbstBloecke.length})`); + ok(kat.ENTWICKLUNG_FREMD.length + kat.ENTWICKLUNG_SELBST.length === alle.length, + `${kat.ENTWICKLUNG_FREMD.length} fremd + ${kat.ENTWICKLUNG_SELBST.length} selbst = ${alle.length}`); + ok(kat.ENTWICKLUNG_SELBST.length > 0 && kat.ENTWICKLUNG_FREMD.length > 0, + "beide Listen haben Inhalt — sonst prüft die Zeile darüber nichts"); + + const merk = tal.TALENT_ALLE; + ok(merk.length > 15, `${merk.length} Merkmale im Talente-Katalog`); + ok(tal.TALENT_WARNZEICHEN.length > 0, `${tal.TALENT_WARNZEICHEN.length} Warnzeichen, getrennt geführt`); + ok(tal.TALENT_NACH_SCHLUESSEL.size === merk.length + tal.TALENT_WARNZEICHEN.length, + "kein Schlüssel doppelt zwischen Merkmalen und Warnzeichen"); + ok([...merk, ...tal.TALENT_WARNZEICHEN].every((m) => m.schluessel.startsWith("t-")), + "jeder Schlüssel beginnt mit t-"); + + const raenge = tal.TALENT_STUFEN.map((s) => s.rang); + ok(JSON.stringify(raenge) === JSON.stringify([...raenge].sort((a, b) => a - b)), + `die fünf Stufen stehen in der richtigen Reihenfolge (${tal.TALENT_STUFEN.map((s) => s.name).join(" → ")})`); + ok(tal.TALENT_STUFEN.slice(0, -1).every((s) => s.knopf && s.schritt), + "jede Stufe außer der letzten hat GENAU EINEN nächsten Schritt mit Knopf"); + ok(tal.talentNaechste("imteam") === null, "und hinter der letzten kommt nichts mehr"); + ok(tal.talentStufe("gibt-es-nicht") === null, + "eine erfundene Stufe ergibt null statt der ersten — sie soll auffallen"); +} + +/* ======================================================================= + 2. BLOCK 5 GEHÖRT DER PERSON + ======================================================================= */ +melde(""); +melde("=== 2. Block 5 ==="); +{ + const selbst = kat.ENTWICKLUNG_SELBST[0].schluessel; + + const karte = await roh(`/workspace/api/entwicklung/person/${idModi}`, HAUS, { cookie: kDogi }); + const bloecke = json(karte).bloecke || []; + const alleSchluessel = bloecke.flatMap((b) => (b.punkte || []).map((p) => p.schluessel)); + ok(karte.code === 200 && alleSchluessel.length > 0, + `DogFather bekommt die Karte des Modis (${alleSchluessel.length} Punkte)`); + const durch = kat.ENTWICKLUNG_SELBST.filter((p) => alleSchluessel.includes(p.schluessel)); + ok(durch.length === 0, + `und KEINEN Punkt aus Block 5${durch.length ? " -- DURCH: " + durch.map((p) => p.schluessel).join(", ") : ""}`); + ok(!/Wie geht es dir/.test(karte.text), "auch der Blockname steht nicht in der Antwort"); + + /* Und schreiben geht auch nicht -- mit derselben Antwort wie ein + erfundener Schluessel, damit man an der Antwort nichts ablesen kann. */ + const schreib = await roh(`/workspace/api/entwicklung/person/${idModi}/${selbst}`, HAUS, + { cookie: kDogi }, { stufe: "laeuft" }, "PUT"); + const erfunden = await roh(`/workspace/api/entwicklung/person/${idModi}/e-gibt-es-nicht`, HAUS, + { cookie: kDogi }, { stufe: "laeuft" }, "PUT"); + ok(schreib.code === 404, `DogFather kann dort auch nichts setzen (${schreib.code})`); + ok(schreib.code === erfunden.code, + `dieselbe Antwort wie bei einem erfundenen Punkt (${schreib.code}/${erfunden.code})`); + + /* GEGENPROBE: Die Person selbst sieht ihn sehr wohl. Ohne diese + Zeile waeren die drei darueber auch dann gruen, wenn Block 5 fuer + NIEMANDEN existierte. */ + const meins = await roh("/workspace/api/entwicklung/mich", CREW, { cookie: kModi }); + const meine = (json(meins).punkte || []).map((p) => p.schluessel); + ok(meins.code === 200 && meine.length === kat.ENTWICKLUNG_SELBST.length, + `der Modi selbst sieht alle ${meine.length} Fragen`); + + const setzen = await roh(`/workspace/api/entwicklung/mich/${selbst}`, CREW, + { cookie: kModi }, { stufe: "hakt" }, "PUT"); + ok(setzen.code === 200, `und kann sie beantworten (${setzen.code})`); + + /* Und danach steht sie immer noch nicht in DogFathers Karte. */ + const nachher = await roh(`/workspace/api/entwicklung/person/${idModi}`, HAUS, { cookie: kDogi }); + ok(!nachher.text.includes(selbst), + "auch nach der Antwort steht sie nicht in DogFathers Karte"); + + /* Und in die andere Richtung: ueber jemand ANDEREN kann man dort + nichts eintragen -- es gibt gar keine Nummer in der Adresse. */ + const fremdPunkt = await roh(`/workspace/api/entwicklung/mich/${kat.ENTWICKLUNG_FREMD[0].schluessel}`, + CREW, { cookie: kModi }, { stufe: "laeuft" }, "PUT"); + ok(fremdPunkt.code === 404, + `über „mich" lässt sich kein fremder Punkt setzen (${fremdPunkt.code})`); +} + +/* ======================================================================= + 3. DIE AMPEL VERRÄT KEINEN NAMEN + ======================================================================= */ +melde(""); +melde("=== 3. Die Ampel ==="); +{ + /* BEIDE SEITEN DER GRENZE, sonst beweist es nichts. + + Die Ampel schweigt, solange das Team so klein ist, dass „jemandem + geht es gerade zu viel" dasselbe waere wie ein Name. Eine Pruefung, + die nur eine Seite ansieht, waere auch dann gruen, wenn die Ampel + IMMER schwiege -- oder immer redete. Beides waere ein Fehler, und + keiner davon faellt von allein auf. + + Die Schwelle wird nicht abgeschrieben, sondern aus dem Verhalten + abgeleitet: Erst mit vollem Team messen, dann eine Person + abschalten und noch einmal. Was dazwischen kippt, IST die Grenze. */ + const voll = json(await roh("/workspace/api/entwicklung/ampel", HAUS, { cookie: kDogi })); + ok(voll.team >= 3 && voll.sagbar === true, + `bei ${voll.team} Personen sagt die Ampel etwas (${voll.lage})`); + ok(typeof voll.geantwortet === "number" && voll.geantwortet >= 1, + `und zaehlt, wie viele geantwortet haben (${voll.geantwortet} von ${voll.team})`); + /* Ein Modi hat in Abschnitt 2 „hakt" gesetzt -- die Ampel muss das + sehen, ohne zu sagen, wer es war. */ + ok(voll.lage === "jemand", + `sie meldet die Lage aus Abschnitt 2 (${voll.lage})`); + ok(!/Frida|Nina|Rieke|Filipe/.test(JSON.stringify(voll)), + "und in der ganzen Antwort steht kein einziger Name"); + + /* Jetzt eine Person abschalten -- das Team faellt unter die Schwelle. */ + const dd = new DatabaseSync(process.env.WORKSPACE_DB); + dd.prepare("UPDATE personen SET aktiv = 0 WHERE name = 'Nina'").run(); + dd.close(); + + const klein = json(await roh("/workspace/api/entwicklung/ampel", HAUS, { cookie: kDogi })); + ok(klein.team === voll.team - 1 && klein.sagbar === false, + `bei ${klein.team} Personen schweigt sie`); + ok(/Name/.test(klein.grund || ""), + "und sagt, warum – ein stilles Nichts sieht aus wie ein Fehler"); + ok(!/Frida|Nina|Rieke|Filipe/.test(JSON.stringify(klein)), + "auch im schweigenden Fall steht kein Name da"); + + const dd2 = new DatabaseSync(process.env.WORKSPACE_DB); + dd2.prepare("UPDATE personen SET aktiv = 1 WHERE name = 'Nina'").run(); + dd2.close(); + + const modi = await roh("/workspace/api/entwicklung/ampel", CREW, { cookie: kModi }); + ok(modi.code === 404, `ein Modi bekommt sie gar nicht (${modi.code})`); +} + +/* ======================================================================= + 4. DER STAND DES ANDEREN KOMMT ERST NACH DEM EIGENEN + ======================================================================= */ +melde(""); +melde("=== 4. Zwei Augenpaare ==="); +{ + const punkt = kat.ENTWICKLUNG_FREMD[0].schluessel; + + const gesetzt = await roh(`/workspace/api/entwicklung/person/${idModi}/${punkt}`, CREW, + { cookie: kHand }, { stufe: "laeuft", anlass: "Gestern im Chat gesehen." }, "PUT"); + ok(gesetzt.code === 200, `die rechte Hand setzt einen Punkt (${gesetzt.code})`); + + const vorher = await roh(`/workspace/api/entwicklung/person/${idModi}`, HAUS, { cookie: kDogi }); + const p1 = (json(vorher).bloecke || []).flatMap((b) => b.punkte || []) + .find((p) => p.schluessel === punkt); + ok(p1 && p1.andere === null, + "DogFather sieht ihren Stand NICHT, bevor er selbst gesetzt hat"); + ok(p1 && p1.andere_offen === 1, + `aber er sieht, DASS es einen gibt (${p1?.andere_offen}) – sonst wäre „noch niemand" dasselbe Bild`); + ok(!vorher.text.includes("Gestern im Chat gesehen"), + "und ihr Anlasstext wird gar nicht erst ausgeliefert"); + + const eigen = await roh(`/workspace/api/entwicklung/person/${idModi}/${punkt}`, HAUS, + { cookie: kDogi }, { stufe: "waechst" }, "PUT"); + ok(eigen.code === 200, `DogFather setzt selbst (${eigen.code})`); + + /* GEGENPROBE: jetzt kommt er. */ + const nachher = await roh(`/workspace/api/entwicklung/person/${idModi}`, HAUS, { cookie: kDogi }); + const p2 = (json(nachher).bloecke || []).flatMap((b) => b.punkte || []) + .find((p) => p.schluessel === punkt); + ok(p2 && p2.andere?.length === 1 && p2.andere[0].stufe === "laeuft", + `danach sieht er ihn (${p2?.andere?.[0]?.stufe})`); + + /* UND DIE ABWEICHUNG IST DAS ERGEBNIS. */ + const ab = json(nachher).abweichungen || []; + ok(ab.length === 1 && ab[0].schluessel === punkt, + `die Abweichung steht oben in der Karte (${ab.length})`); + ok(ab[0].meins === "waechst" && ab[0].andere === "laeuft", + `mit beiden Ständen nebeneinander (${ab[0]?.meins} / ${ab[0]?.andere})`); +} + +/* ======================================================================= + 5. „DA HAKT ES" BRAUCHT EINEN ANLASS + ======================================================================= */ +melde(""); +melde("=== 5. Kein Klick ohne Anlass ==="); +{ + const punkt = kat.ENTWICKLUNG_FREMD[1].schluessel; + const ohne = await roh(`/workspace/api/entwicklung/person/${idModi}/${punkt}`, HAUS, + { cookie: kDogi }, { stufe: "hakt", anlass: "" }, "PUT"); + ok(ohne.code === 400, `„Da hakt es" ohne Anlass wird abgelehnt (${ohne.code})`); + ok(/gemerkt|Satz/.test(json(ohne).fehler || ""), + "und die Absage sagt, was fehlt, statt nur nein"); + + const mit = await roh(`/workspace/api/entwicklung/person/${idModi}/${punkt}`, HAUS, + { cookie: kDogi }, { stufe: "hakt", anlass: "Am Samstag zweimal nicht reagiert." }, "PUT"); + ok(mit.code === 200, `mit Anlass geht es durch (${mit.code})`); + + /* GEGENPROBE: „Läuft" braucht keinen — sonst wären es 28 Pflichtfelder + je Person, und „so einfach wie möglich" war die halbe Aufgabe. */ + const laeuft = await roh(`/workspace/api/entwicklung/person/${idModi}/${kat.ENTWICKLUNG_FREMD[2].schluessel}`, + HAUS, { cookie: kDogi }, { stufe: "laeuft" }, "PUT"); + ok(laeuft.code === 200, `„Läuft" geht ohne Anlass (${laeuft.code})`); +} + +/* ======================================================================= + 6. NACH 90 TAGEN VERBLASST EINE EINSCHÄTZUNG + ======================================================================= */ +melde(""); +melde("=== 6. Verblassen ==="); +{ + const modul = await import("./workspace-entwicklung.js"); + const alt = kat.ENTWICKLUNG_FREMD[3].schluessel; + const dd = new DatabaseSync(process.env.WORKSPACE_DB); + dd.prepare(`INSERT INTO entwicklung_stand (person_id, schluessel, von_id, stufe, anlass, am) + VALUES (?,?,?,'laeuft',NULL,?)`) + .run(idModi, alt, idDogi, + new Date(Date.now() - (modul.VERBLASST_NACH_TAGEN + 10) * 86400000).toISOString()); + dd.close(); + + const r = await roh(`/workspace/api/entwicklung/person/${idModi}`, HAUS, { cookie: kDogi }); + const punkte = (json(r).bloecke || []).flatMap((b) => b.punkte || []); + const alterPunkt = punkte.find((p) => p.schluessel === alt); + ok(alterPunkt?.meins?.verblasst === true, + `ein Stand von vor ${modul.VERBLASST_NACH_TAGEN + 10} Tagen gilt als verblasst`); + + /* GEGENPROBE, und ohne sie beweist die Zeile darüber nichts: Einer + von heute gilt NICHT als verblasst. */ + const frisch = punkte.find((p) => p.schluessel === kat.ENTWICKLUNG_FREMD[0].schluessel); + ok(frisch?.meins?.verblasst === false, + "einer von heute nicht"); + + const lage = json(await roh("/workspace/api/entwicklung/lage", HAUS, { cookie: kDogi })); + const zeile = (lage.personen || []).find((p) => p.id === idModi); + ok(zeile?.veraltet === 1, `und die Übersicht zählt ihn (${zeile?.veraltet})`); + ok(zeile?.abweichungen === 1, `samt der Abweichung (${zeile?.abweichungen})`); +} + +/* ======================================================================= + 7. TALENTE GEHÖRT DOGFATHER UND DER RECHTEN HAND + ======================================================================= */ +melde(""); +melde("=== 7. Talente ==="); +{ + const lage = await roh("/workspace/api/talente/lage", HAUS, { cookie: kDogi }); + const r = json(lage); + ok(lage.code === 200 && (r.kandidaten || []).length === 1, + `DogFather sieht den Trichter (${(r.kandidaten || []).length} Kandidat)`); + ok((r.trichter || []).length === tal.TALENT_STUFEN.length, + `alle ${r.trichter?.length} Stufen kommen mit`); + const k = r.kandidaten[0]; + ok(k.stufe === "aufgefallen", + `ein neuer Kandidat startet auf „${k.stufe_name}" (${k.stufe})`); + ok(!!k.schritt && !!k.knopf, `und hat genau einen nächsten Schritt: „${k.knopf}"`); + + /* DER RIEGEL. Auf Seite UND Schnittstelle -- eine Kachel, die es + nicht gibt, ist keine Sicherung. */ + const modiApi = await roh("/workspace/api/talente/lage", CREW, { cookie: kModi }); + ok(modiApi.code === 404, `ein Modi bekommt die Schnittstelle nicht (${modiApi.code})`); + const modiSeite = await roh("/workspace/talente.html", CREW, { cookie: kModi }); + ok(modiSeite.code !== 200, `und die Seite auch nicht (${modiSeite.code})`); + const wer = rechte.SEITEN["/workspace/talente.html"] || []; + ok(wer.length === 2 && wer.includes("admin") && wer.includes("hand"), + `die Tafel gibt sie genau an ${wer.join(" und ")}`); + + /* GEGENPROBE: die rechte Hand kommt hinein. */ + const handApi = await roh("/workspace/api/talente/lage", CREW, { cookie: kHand }); + ok(handApi.code === 200, `die rechte Hand schon (${handApi.code})`); + + /* Merkmale: ein Klick setzt, der zweite nimmt zurück. */ + const m = tal.TALENT_ALLE[0].schluessel; + const w = tal.TALENT_WARNZEICHEN[0].schluessel; + const eins = await roh(`/workspace/api/talente/${idTalent}/merkmal/${m}`, HAUS, + { cookie: kDogi }, {}, "PUT"); + ok(eins.code === 200 && json(eins).gesetzt === true, "ein Merkmal wird gesetzt"); + const zwei = await roh(`/workspace/api/talente/${idTalent}/merkmal/${m}`, HAUS, + { cookie: kDogi }, {}, "PUT"); + ok(zwei.code === 200 && json(zwei).gesetzt === false, "und der zweite Klick nimmt es zurück"); + await roh(`/workspace/api/talente/${idTalent}/merkmal/${m}`, HAUS, { cookie: kDogi }, {}, "PUT"); + await roh(`/workspace/api/talente/${idTalent}/merkmal/${w}`, HAUS, { cookie: kDogi }, {}, "PUT"); + + const nach = json(await roh("/workspace/api/talente/lage", HAUS, { cookie: kDogi })).kandidaten[0]; + ok(nach.merkmale.length === 1 && nach.warnzeichen.length === 1, + `Merkmale und Warnzeichen stehen getrennt (${nach.merkmale.length} / ${nach.warnzeichen.length})`); + ok(!nach.merkmale.includes(w), + "ein Warnzeichen landet nicht zwischen den Merkmalen"); + + /* Die Stufe wandert -- und die Standzeit beginnt von vorn. */ + const weiter = await roh(`/workspace/api/talente/${idTalent}/stufe`, HAUS, + { cookie: kDogi }, { stufe: "beobachtet" }, "PUT"); + ok(weiter.code === 200, `ein Schritt weiter im Trichter (${weiter.code})`); + const nach2 = json(await roh("/workspace/api/talente/lage", HAUS, { cookie: kDogi })).kandidaten[0]; + ok(nach2.stufe === "beobachtet" && nach2.tage === 0, + `Stufe „${nach2.stufe_name}", Standzeit beginnt bei ${nach2.tage}`); + ok(nach2.liegt === false, "und liegt damit auch nicht"); + + const erfunden = await roh(`/workspace/api/talente/${idTalent}/stufe`, HAUS, + { cookie: kDogi }, { stufe: "chef" }, "PUT"); + ok(erfunden.code === 400, `eine erfundene Stufe wird abgelehnt (${erfunden.code})`); +} + +/* ======================================================================= + 8. LIEGENGEBLIEBEN IST EINE AUSKUNFT ÜBER UNS + ======================================================================= */ +melde(""); +melde("=== 8. Standzeit ==="); +{ + const stufe = tal.talentStufe("beobachtet"); + const dd = new DatabaseSync(process.env.WORKSPACE_DB); + dd.prepare("UPDATE talent_stufe SET seit = ? WHERE eintrag_id = ?") + .run(new Date(Date.now() - (stufe.frist + 11) * 86400000).toISOString(), idTalent); + dd.close(); + + const r = json(await roh("/workspace/api/talente/lage", HAUS, { cookie: kDogi })); + const k = r.kandidaten[0]; + ok(k.liegt === true && k.tage === stufe.frist + 11, + `nach ${k.tage} Tagen auf einer Stufe mit Frist ${stufe.frist} gilt die Karte als liegengeblieben`); + const t = (r.trichter || []).find((x) => x.schluessel === "beobachtet"); + ok(t?.liegt === 1, `und der Trichter zählt sie (${t?.liegt})`); + + /* GEGENPROBE von der anderen Seite der Grenze -- sonst wäre „liegt" + auch dann grün, wenn es immer liegt. */ + const dd2 = new DatabaseSync(process.env.WORKSPACE_DB); + dd2.prepare("UPDATE talent_stufe SET seit = ? WHERE eintrag_id = ?") + .run(new Date(Date.now() - (stufe.frist - 1) * 86400000).toISOString(), idTalent); + dd2.close(); + const r2 = json(await roh("/workspace/api/talente/lage", HAUS, { cookie: kDogi })); + ok(r2.kandidaten[0].liegt === false, + `einen Tag vor der Frist noch nicht (${r2.kandidaten[0].tage} Tage)`); +} + +/* ------------------------------------------------------------------- */ +melde(""); +melde(`${geprueft} Pruefungen, ${fehler} Fehler`); +melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG"); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ } +process.exit(fehler ? 1 : 0); diff --git a/server/pruef-treff-seiten.mjs b/server/pruef-neue-seiten.mjs similarity index 86% rename from server/pruef-treff-seiten.mjs rename to server/pruef-neue-seiten.mjs index 9a6843f6..c19ad9da 100644 --- a/server/pruef-treff-seiten.mjs +++ b/server/pruef-neue-seiten.mjs @@ -1,5 +1,10 @@ /* ===================================================================== - Die drei neuen Seiten — angesehen, nicht nur geprüft (11.09.2026) + Die neuen Seiten — angesehen, nicht nur geprüft (11.09.2026) + + HIESS BIS HEUTE pruef-treff-seiten.mjs. Mit den zwei Katalogseiten + (Entwicklung, Talente) stimmte der Name nicht mehr -- und ein + Dateiname, der etwas anderes verspricht als sein Inhalt, ist die + Sorte Kleinigkeit, wegen der jemand die falsche Datei oeffnet. pruef-treff und pruef-treff-werkzeuge messen, was der Server ANTWORTET. Diese Datei sieht nach, was auf dem Bildschirm LANDET. @@ -23,7 +28,7 @@ über das, was hinter einer halbdurchsichtigen Fläche liegt. * Und auf 390 px Breite: Bricht etwas aus dem Bild heraus? - Aufrufen mit: node server/pruef-treff-seiten.mjs + Aufrufen mit: node server/pruef-neue-seiten.mjs ===================================================================== */ import { mkdtempSync, rmSync, mkdirSync } from "node:fs"; @@ -32,7 +37,7 @@ import { join } from "node:path"; import { request as httpAnfrage } from "node:http"; import { portMussFreiSein } from "./helfer-port.mjs"; -const PORT = await portMussFreiSein(4403, "pruef-treff-seiten"); +const PORT = await portMussFreiSein(4403, "pruef-neue-seiten"); const ordner = mkdtempSync(join(tmpdir(), "ws-treffs-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); @@ -45,7 +50,7 @@ const ec = express.response.cookie; express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; import { notbremse } from "./helfer-notbremse.mjs"; await import("./index.js"); -notbremse(240_000, "pruef-treff-seiten"); +notbremse(240_000, "pruef-neue-seiten"); await new Promise((r) => setTimeout(r, 700)); process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); @@ -114,6 +119,24 @@ await roh(`/workspace/api/treff/eintrag/${beitrag}/melden`, { cookie: kGast }, await roh("/workspace/api/treff/massnahme", { cookie: kDogi }, { person_id: idKessi, art: "hinweis", grund: "Bitte keine Nummern posten – einmalig, kein Drama." }); +/* 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. */ +const dd = new DatabaseSync(process.env.WORKSPACE_DB); +{ + 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"); +} +dd.close(); + const { chromium } = await import( "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); const browser = await chromium.launch(); @@ -149,7 +172,19 @@ const KONTRAST = `(() => { let gemessen = 0; for (const e of document.querySelectorAll("p, li, h1, h2, h3, td, th, span, a, button")) { const t = (e.textContent || "").trim(); - if (!t || e.children.length) continue; + if (!t) continue; + /* ELEMENTE MIT KINDERN WERDEN UEBERSPRUNGEN, und das ist richtig: + Ihr textContent ist der Text der Kinder, die einzeln drankommen. + Es war aber ein STILLER Aussetzer -- ein

mit einem Link + darin fiel heraus, und der eigene Text daneben wurde nie + gemessen. Genau so ist die Fusszeile der Talente-Seite durch die + Pruefung gekommen, waehrend sie auf der Buehne lag. + + Jetzt zaehlt der EIGENE Text: Hat ein Element neben seinen + Kindern eigene Zeichen, wird es mitgemessen. */ + const eigen = [...e.childNodes] + .filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join(""); + if (e.children.length && eigen.length < 3) continue; const k = e.getBoundingClientRect(); if (k.width < 4 || k.height < 4) continue; const s = getComputedStyle(e); @@ -297,6 +332,12 @@ for (const [name, pfad] of [ ["regeln", "treff-regeln.html"], ["rechte", "rechte.html"], ["moderation", "treff-moderation.html"], + /* Die zwei Katalogseiten. Sie stehen hier und nicht in + pruef-nachwuchs: Dort wird gemessen, was der Server ANTWORTET, + hier, was auf dem Bildschirm LANDET. Beides ist noetig, und beides + findet andere Fehler. */ + ["entwicklung", "entwicklung.html"], + ["talente", "talente.html"], ]) { melde(""); melde(`=== ${pfad} ===`); diff --git a/server/rechte.js b/server/rechte.js index 097c9979..99d67c50 100644 --- a/server/rechte.js +++ b/server/rechte.js @@ -281,6 +281,24 @@ export const SEITEN = { Stufen und die Bedingungen des Ausschlusses. */ "/workspace/treff-regeln.html": ["gast", "modi", "hand", "admin"], + /* DIE ENTWICKLUNGSKARTE (11.09.2026). + + DogFather und die rechte Hand fuehren sie ueber das Team. Ein Modi + kommt ebenfalls auf die Seite -- aber er sieht dort AUSSCHLIESSLICH + Block 5, die sechs Fragen ueber sich selbst. Das entscheidet nicht + diese Tafel, sondern workspace-entwicklung.js: Hier steht nur, wer + die Tuer oeffnen darf, nicht was dahinter liegt. + + ZWEI SCHLOESSER, ABSICHTLICH. Faellt die Rechtepruefung im Modul + weg, steht immer noch diese Zeile; faellt diese Zeile weg, steht + immer noch das Modul. */ + "/workspace/entwicklung.html": ["admin", "hand", "modi"], + + /* TALENTE. Hier stehen Namen von Menschen, die nichts davon wissen -- + ausdruecklich OHNE "modi": Wer beobachtet wird, ohne es zu wissen, + hat ein Recht darauf, dass der Kreis klein bleibt. */ + "/workspace/talente.html": ["admin", "hand"], + /* MELDUNGEN & MASSNAHMEN. Nur wer moderiert -- ausdruecklich OHNE "gast": Dort stehen die Namen derer, die gemeldet haben. Eine Meldung, die der Gemeldete lesen kann, ist keine Meldung mehr, diff --git a/server/workspace-entwicklung.js b/server/workspace-entwicklung.js new file mode 100644 index 00000000..4e3cfe3e --- /dev/null +++ b/server/workspace-entwicklung.js @@ -0,0 +1,637 @@ +/* ===================================================================== + 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, +} 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, + TALENT_NACH_SCHLUESSEL, talentStufe, talentNaechste, +} from "./workspace-talent-punkte.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"]; + +/* ===================================================================== + DIE TABELLEN + ===================================================================== */ + +let bereit = false; +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. */ + 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 + ); + `); + 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 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 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, + }; + }), + })); + + /* 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" }); + } +}); + +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 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 + ===================================================================== */ + +/** Die Karte eines Kandidaten -- Stufe, Standzeit, was zu tun ist. */ +function talentKarte(e, merkmale) { + const stand = db().prepare("SELECT stufe, seit, notiz 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, + }; +} + +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 eintrag_id, schluessel FROM talent_merkmal + WHERE 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, + 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" }); + } + }); + +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" }); + + db().prepare(`INSERT INTO talent_stufe (eintrag_id, stufe, seit, von_id, notiz) + VALUES (?,?,?,?,?) + ON CONFLICT(eintrag_id) DO UPDATE SET + stufe = excluded.stufe, seit = excluded.seit, + von_id = excluded.von_id, notiz = excluded.notiz`) + .run(id, stufe, jetzt(), req.person.id, notiz || null); + + 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" }); + } +}); diff --git a/server/workspace-entwicklungs-punkte.js b/server/workspace-entwicklungs-punkte.js new file mode 100644 index 00000000..bc9582fa --- /dev/null +++ b/server/workspace-entwicklungs-punkte.js @@ -0,0 +1,275 @@ +/* ===================================================================== + Der Katalog „Entwicklung" (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."* + + Hier steht die eine Hälfte davon: das, was man anklickt, statt es zu + tippen. Die Herleitung jeder einzelnen Zeile steht im Vault + (02 Projekte/Entwicklung & Nachwuchs – Plan.md, Abschnitt 1 und 3); + hier steht bei jedem Punkt in zwei Sätzen, WARUM er im Katalog ist. + + --------------------------------------------------------------------- + VIER ENTSCHEIDUNGEN, DIE MAN DEM KATALOG NICHT ANSIEHT + --------------------------------------------------------------------- + + 1. ES SIND BEOBACHTUNGEN, KEINE EIGENSCHAFTEN. + Nicht „ist zuverlässig", sondern „Absagen kommen rechtzeitig". + Eine Eigenschaft kann man nur bestätigen oder bestreiten; ein + Verhalten kann man gesehen haben — oder eben nicht. Das ist der + Unterschied zwischen einem Gespräch und einem Urteil. + + 2. JEDER PUNKT IST ALS ETWAS FORMULIERT, DAS PASSIERT — nicht als + etwas, das fehlt. „Fragen bleiben nicht unbeantwortet" statt + „ignoriert Fragen". Die Liste soll man mit jemandem durchgehen + können, ohne dass sie wie eine Anklageschrift klingt. + + 3. DIE STUFEN SIND KEINE NOTEN, SONDERN ERWARTUNGEN. + `probe` ist das, was ab Tag eins gilt. `standard` das, was nach + ein paar Monaten normal ist. `senior` das, wofür man jemanden + fragt. Wer auf Probe ist, wird nicht daran gemessen, ob ein + Bereich ohne ihn läuft — und die Oberfläche zeigt das auch so. + + 4. KEINE GESAMTNOTE, NIRGENDS. Es gibt keine Punktzahl, keinen + Durchschnitt, keine Rangliste. Das ist keine Nachlässigkeit, + sondern der wichtigste Einzelentscheid des ganzen Bereichs: Eine + Zahl über einem Menschen wird gelesen, verglichen und + weitererzählt. Gebraucht wird ein Gesprächsanlass, keine Note. + + --------------------------------------------------------------------- + UND DER LETZTE BLOCK GEHOERT NICHT EUCH + + Block 5 beantwortet ausschliesslich die Person selbst. DogFather und + die rechte Hand sehen dort keinen einzigen Klick — nur eine Ampel + ohne Namen. Das ist kein Entgegenkommen, sondern die Bedingung + dafuer, dass ehrlich geantwortet wird; eine unehrliche Antwort waere + schlechter als keine. + + Die sechs Punkte darin stammen aus der Forschung dazu, warum + freiwillige Moderatoren aufhoeren — in dieser Reihenfolge: zu wenig + Zeit, Streit im Team, fehlende Eskalationswege, seelische Last ohne + Ansprechpartner, fehlende Autonomie, fehlende Anerkennung. + ===================================================================== */ + +/** Die vier Antworten. Reihenfolge und Bedeutung stehen hier, nicht in + * der Oberflaeche -- sonst hiesse dieselbe Antwort auf zwei Seiten + * unterschiedlich. + * + * "unklar" IST EINE ECHTE ANTWORT und kein Weglassen. Ohne sie klickt + * man im Zweifel "laeuft", weil das die freundlichste Taste ist -- + * und ab da steht im Katalog eine Beobachtung, die niemand gemacht + * hat. */ +export const ENTWICKLUNG_STUFEN = [ + { schluessel: "laeuft", name: "Läuft", zeichen: "✓", + text: "Habe ich gesehen. Passt." }, + { schluessel: "waechst", name: "Wächst gerade", zeichen: "↗", + text: "Geht in die richtige Richtung – noch nicht durchgehend." }, + { schluessel: "hakt", name: "Da hakt es", zeichen: "!", + text: "Ist mir aufgefallen. Kein Vorwurf – ein Thema." }, + { schluessel: "unklar", name: "Kann ich nicht sagen", zeichen: "–", + text: "Habe ich nicht gesehen. Ehrlicher als zu raten." }, +]; + +/** Die Erwartungsstufen. `rang` nur zum Sortieren -- er ist keine Note. */ +export const ENTWICKLUNG_ERWARTUNG = [ + { schluessel: "probe", name: "Ab Tag eins", rang: 1, + text: "Das gilt für alle, auch in der Probezeit." }, + { schluessel: "standard", name: "Nach ein paar Monaten", rang: 2, + text: "Das ist normal, wenn jemand angekommen ist." }, + { schluessel: "senior", name: "Wofür man jemanden fragt", rang: 3, + text: "Das kann nicht jeder – und es wird auch nicht von jedem erwartet." }, +]; + +/** Die fünf Blöcke. `nurSelbst` ist der Riegel: Was hier `true` steht, + * sieht ausser der Person NIEMAND -- auch DogFather nicht. */ +export const ENTWICKLUNG_BLOECKE = [ + { schluessel: "chat", name: "Im Chat", + text: "Die tägliche Arbeit. Was hier passiert, sehen die Zuschauer – " + + "und es entscheidet, ob sie wiederkommen." }, + { schluessel: "druck", name: "Wenn es eng wird", + text: "Der Teil, der sich nicht üben lässt. Hier zeigt sich, was bleibt, " + + "wenn es unangenehm wird." }, + { schluessel: "team", name: "Im Team", + text: "Wie die Zusammenarbeit läuft. Streit im Team ist der zweithäufigste " + + "Grund, warum Leute aufhören – noch vor der Arbeit selbst." }, + { schluessel: "selbst", name: "Wie viel von selbst", + text: "Der Unterschied zwischen Ausführen und Mitdenken. Und – weiter unten – " + + "zwischen Mitdenken und Überlastung." }, + { schluessel: "befinden", name: "Wie geht es dir damit?", nurSelbst: true, + text: "Diese sechs Fragen beantwortest nur du. Niemand aus dem Team sieht " + + "deine Antworten – auch DogFather nicht. Sichtbar wird nur, ob es im " + + "Team gerade jemandem zu viel wird, ohne Namen." }, +]; + +export const ENTWICKLUNG_PUNKTE = { + /* ---- Block 1: Im Chat --------------------------------------------- */ + chat: [ + { schluessel: "e-chat-neue-begruesst", stufe: "probe", + titel: "Neue werden begrüßt statt übersehen", + text: "Der erste Satz an einen Neuen entscheidet, ob er wiederkommt. " + + "Kostet nichts und passiert trotzdem selten." }, + { schluessel: "e-chat-fragen-beantwortet", stufe: "probe", + titel: "Fragen bleiben nicht unbeantwortet", + text: "Eine unbeantwortete Frage ist schlimmer als eine abgelehnte – " + + "sie sagt: du bist hier nicht gemeint." }, + { schluessel: "e-chat-regel-erklaert", stufe: "probe", + titel: "Regeln werden erklärt, bevor sie durchgesetzt werden", + text: "Wer erst löscht und dann erklärt, hat einen Gegner statt eines Mitglieds." }, + { schluessel: "e-chat-frueh-abgefangen", stufe: "standard", + titel: "Ein Spruch wird abgefangen, bevor eine Debatte daraus wird", + text: "Die eigentliche Arbeit passiert vorher, nicht beim Timeout. " + + "Was man hier sieht, ist das, was NICHT passiert ist." }, + { schluessel: "e-chat-ton-bleibt", stufe: "standard", + titel: "Der Ton bleibt derselbe, auch wenn der Chat kippt", + text: "Nicht Freundlichkeit – Gleichmäßigkeit. Genau darauf achtet ein Chat: " + + "ob dieselbe Sache heute anders behandelt wird als gestern." }, + { schluessel: "e-chat-ohne-buehne", stufe: "standard", + titel: "Es wird eingegriffen, ohne den Chat zur Bühne zu machen", + text: "Eine Bestrafung mit Publikum erzeugt Publikum." }, + { schluessel: "e-chat-laeuft-ohne-blick", stufe: "senior", + titel: "Der Chat läuft auch, wenn gerade niemand hinsieht", + text: "Das Gegenteil von Anwesenheit. Man merkt es nur daran, dass nichts passiert." }, + { schluessel: "e-chat-weitergereicht", stufe: "senior", + titel: "Neue werden weitergereicht, nicht nur bedient", + text: "Aus einem beantworteten Neuen wird ein Mitglied, sobald er jemanden kennt." }, + ], + + /* ---- Block 2: Wenn es eng wird ------------------------------------ */ + druck: [ + { schluessel: "e-druck-fragt-statt-raet", stufe: "probe", + titel: "Bei Unsicherheit wird gefragt statt geraten", + text: "Fragen ist ein Zeichen von Sicherheit, nicht von Schwäche – " + + "und genau das misst, ob sich jemand im Team traut." }, + { schluessel: "e-druck-eigener-fehler", stufe: "probe", + titel: "Eine eigene Fehlentscheidung wird von selbst angesprochen", + text: "Der einzige Punkt der Liste, der sich nicht vortäuschen lässt." }, + { schluessel: "e-druck-nicht-persoenlich", stufe: "standard", + titel: "Provokation geht nicht ins Persönliche zurück", + text: "Unhöflichkeit ist in vergleichbaren Gemeinschaften der häufigste " + + "Ablehnungsgrund – noch vor jedem Können." }, + { schluessel: "e-druck-notiz-statt-erinnerung", stufe: "standard", + titel: "Nach einem Vorfall steht eine Notiz da, keine Erinnerung", + text: "Am nächsten Tag fehlt die Hälfte. Eine Notiz von gestern schlägt " + + "ein gutes Gedächtnis." }, + { schluessel: "e-druck-frueh-eskaliert", stufe: "standard", + titel: "Es wird eskaliert, bevor es zu spät ist – nicht danach", + text: "Zu früh eskalieren ist ein kleiner Fehler. Zu spät ist der teure." }, + { schluessel: "e-druck-streit-beruhigt", stufe: "senior", + titel: "Ein Streit zwischen zwei anderen wird beruhigt, ohne Partei zu sein", + text: "Unparteilichkeit unter Druck – in jeder Moderationsanleitung der erste Punkt." }, + { schluessel: "e-druck-unpopulaer-getragen", stufe: "senior", + titel: "Eine unpopuläre Entscheidung wird getragen, auch öffentlich", + text: "Wer eine Regel nur durchsetzt, solange Applaus kommt, setzt sie nicht durch." }, + ], + + /* ---- Block 3: Im Team --------------------------------------------- */ + team: [ + { schluessel: "e-team-absage-rechtzeitig", stufe: "probe", + titel: "Absagen kommen rechtzeitig, nicht im letzten Moment", + text: "Nicht Zuverlässigkeit – Vorlaufzeit. Die kann man planen, " + + "eine Absage um 19:55 nicht." }, + { schluessel: "e-team-teamchat-gelesen", stufe: "probe", + titel: "Was im Teamchat steht, wird gelesen", + text: "Der billigste Punkt der Liste und der am häufigsten gerissene." }, + { schluessel: "e-team-wissen-geteilt", stufe: "standard", + titel: "Wissen wird geteilt, nicht gehortet", + text: "Wer als Einziger weiß, wie etwas geht, ist kein Gewinn, sondern ein " + + "Ausfallrisiko – und darf nicht krank werden." }, + { schluessel: "e-team-kritik-direkt", stufe: "standard", + titel: "Kritik an anderen kommt zu ihnen, nicht über sie", + text: "Streit im Team ist der zweithäufigste Grund, warum Moderatoren " + + "aufhören. Dieser eine Punkt bewacht ihn." }, + { schluessel: "e-team-zusagen-halten", stufe: "standard", + titel: "Zusagen halten – und wenn nicht, wird es gesagt", + text: "Der zweite Halbsatz ist der wichtigere." }, + { schluessel: "e-team-neue-eingearbeitet", stufe: "senior", + titel: "Neue werden eingearbeitet, ohne dass jemand darum bittet", + text: "Der Punkt, an dem aus Mitarbeit Verantwortung wird." }, + { schluessel: "e-team-widerspruch-nach-oben", stufe: "senior", + titel: "Widerspruch kommt auch mir gegenüber", + text: "Ausdrücklich so formuliert: Ein Team, das dem Sprecher nicht " + + "widerspricht, hat aufgehört zu denken. Hier steht niemand über dem anderen." }, + ], + + /* ---- Block 4: Wie viel von selbst ---------------------------------- */ + selbst: [ + { schluessel: "e-selbst-ohne-erinnerung", stufe: "probe", + titel: "Aufgaben werden abgearbeitet, ohne Erinnerung", + text: "Die untere Kante. Alles darüber ist ein Plus, das hier ist die Grundlage." }, + { schluessel: "e-selbst-nicht-in-der-liste", stufe: "standard", + titel: "Etwas wird gesehen und gemacht, was nicht in der Liste stand", + text: "Der Unterschied zwischen Ausführen und Mitdenken." }, + { schluessel: "e-selbst-vorschlag-statt-klage", stufe: "standard", + titel: "Eigene Vorschläge kommen als Vorschlag, nicht als Beschwerde", + text: "Dieselbe Beobachtung, zwei Formen – eine davon ist brauchbar." }, + { schluessel: "e-selbst-abgegeben", stufe: "standard", + titel: "Aufgaben werden abgegeben, statt alles selbst zu machen", + text: "Die häufigste Ursache für Überlastung ist nicht die Menge, " + + "sondern das Nicht-Abgeben." }, + { schluessel: "e-selbst-bereich-laeuft-allein", stufe: "senior", + titel: "Ein Bereich läuft komplett ohne mich", + text: "Der Punkt, an dem eine Stufe wirklich verdient ist." }, + { schluessel: "e-selbst-nein-gesagt", stufe: "senior", + titel: "Es wird nein gesagt, wenn es zu viel ist", + text: "Steht bewusst ganz oben statt als Mangel: Nein sagen zu können ist " + + "die schwerste Fähigkeit im Ehrenamt – und sie verhindert genau den " + + "Ausfall, der sonst kommt." }, + ], + + /* ---- Block 5: Wie geht es dir damit? (nur die Person selbst) ------- */ + befinden: [ + { schluessel: "e-ich-menge-passt", stufe: "probe", + titel: "Die Menge passt gerade", + text: "Zu wenig Zeit ist der meistgenannte Grund, warum Moderatoren aufhören." }, + { schluessel: "e-ich-niemand-schwierig", stufe: "probe", + titel: "Es gibt gerade niemanden im Team, mit dem es schwierig ist", + text: "Der zweithäufigste Grund – und der, über den am wenigsten geredet wird." }, + { schluessel: "e-ich-weiss-an-wen", stufe: "probe", + titel: "Ich weiß, an wen ich mich wende, wenn etwas eskaliert", + text: "Ein fehlender Eskalationsweg fühlt sich nicht wie ein fehlender Weg an, " + + "sondern wie Unsicherheit." }, + { schluessel: "e-ich-jemand-zum-reden", stufe: "probe", + titel: "Nach einer hässlichen Schicht gibt es jemanden zum Reden", + text: "Wer Hässliches wegmoderiert, nimmt es mit. Das geht nicht von allein weg." }, + { schluessel: "e-ich-nein-ohne-problem", stufe: "probe", + titel: "Ich kann nein sagen, ohne dass es ein Problem ist", + text: "Nicht ob du darfst – ob es sich so anfühlt. Das ist der Unterschied." }, + { schluessel: "e-ich-faellt-auf", stufe: "probe", + titel: "Ich habe das Gefühl, dass es auffällt, wenn ich da bin", + text: "Fehlende Anerkennung ist der am häufigsten genannte emotionale Preis – " + + "und der billigste zu beheben." }, + ], +}; + +/* --------------------------------------------------------------------- + ABGELEITET, NICHT ABGESCHRIEBEN + + Jede Zahl ueber diesen Katalog wird gerechnet. Eine Ueberschrift + "38 Beobachtungen" waere beim naechsten Punkt still falsch -- und + genau das war sie schon: Der Plan im Vault sagt 38, gezaehlt sind es + 34. Deshalb steht nirgends eine Zahl, die jemand pflegen muesste. + ------------------------------------------------------------------- */ + +/** Alle Punkte als flache Liste, mit ihrem Block. */ +export const ENTWICKLUNG_ALLE = ENTWICKLUNG_BLOECKE.flatMap((b) => + (ENTWICKLUNG_PUNKTE[b.schluessel] || []).map((p) => ({ ...p, block: b.schluessel }))); + +/** Die Punkte, die andere ueber jemanden setzen duerfen -- also alles + * ausser Block 5. Als eigene Liste, damit die Riegel im Server sie + * nicht jedes Mal neu zusammensuchen muessen. */ +export const ENTWICKLUNG_FREMD = ENTWICKLUNG_ALLE.filter((p) => { + const b = ENTWICKLUNG_BLOECKE.find((x) => x.schluessel === p.block); + return !b?.nurSelbst; +}); + +/** Und die, die nur die Person selbst beantwortet. */ +export const ENTWICKLUNG_SELBST = ENTWICKLUNG_ALLE.filter((p) => { + const b = ENTWICKLUNG_BLOECKE.find((x) => x.schluessel === p.block); + return !!b?.nurSelbst; +}); + +/** Schnellzugriff Schluessel -> Punkt. */ +export const ENTWICKLUNG_NACH_SCHLUESSEL = new Map( + ENTWICKLUNG_ALLE.map((p) => [p.schluessel, p])); diff --git a/server/workspace-talent-punkte.js b/server/workspace-talent-punkte.js new file mode 100644 index 00000000..e201e81c --- /dev/null +++ b/server/workspace-talent-punkte.js @@ -0,0 +1,225 @@ +/* ===================================================================== + Der Katalog „Talente" (11.09.2026) + + Zuschauer, die ins Team passen könnten. Die andere Hälfte von + Filipes Auftrag: *„fertige Sachen, so dass DogFather und rechte Hand + es so einfach wie möglich haben."* + + Herleitung im Vault (02 Projekte/Entwicklung & Nachwuchs – Plan.md, + Abschnitt 1.3 und 4). Hier steht bei jedem Eintrag, warum er drin + ist — und bei zweien, warum sie es NICHT sind. + + --------------------------------------------------------------------- + TALENTE IST EIN TRICHTER, KEINE LISTE + + Eine Liste wächst und wird nie wieder angesehen. Ein Trichter hat + Stufen, und jede Stufe hat GENAU EINEN nächsten Schritt — nicht drei + Möglichkeiten, zwischen denen man sich entscheiden müsste. Wer eine + Karte öffnet, soll in zwei Sekunden wissen, was jetzt dran ist. + + UND DIE STANDZEIT GEHOERT ZUR ANZEIGE. „Seit 41 Tagen beobachtet, + nie angesprochen" ist eine Auskunft über UNS, nicht über den + Kandidaten. Ohne sie verwandelt sich der Trichter in eine Liste + zurück. + + --------------------------------------------------------------------- + ZWEI DINGE STEHEN AUSDRUECKLICH NICHT IM KATALOG + + „SCHREIBT VIEL IM CHAT." Angenehm im Chat zu sein und ein guter + Moderator zu sein sind nachweislich verschiedene Dinge. Die beiden + zu verwechseln ist der häufigste Fehler bei der Modsuche — und weil + die Zahl so leicht zu bekommen wäre, steht hier, dass sie fehlt. + + HEIMLICHE TESTS. Kein Zweitkonto, das jemanden auf die Probe stellt. + Das kostet mehr Vertrauen, als es an Erkenntnis bringt — und zwar + auch bei denen, die davon nur hören. + + --------------------------------------------------------------------- + UND EIN RIEGEL, DER NICHT VERHANDELBAR IST + + In diesem Katalog stehen Namen von Menschen, die nichts davon + wissen. Er gehört DogFather und der rechten Hand, sonst niemandem — + kein Modi, keine Agentur, und ganz sicher nicht der Community. + Durchgesetzt wird das in workspace-entwicklung.js und in der + Rechtetafel, nicht hier; hier steht nur, warum. + ===================================================================== */ + +/** Die fünf Stufen des Trichters. + * + * `schritt` ist der EINE nächste Handgriff. `frist` die Zahl der Tage, + * nach denen eine Karte auf dieser Stufe als liegengeblieben gilt -- + * sie wird nicht gemahnt, sie wird ANGEZEIGT. Eine Erinnerung, die + * man wegklicken kann, wird weggeklickt; eine Zahl, die dasteht, + * nicht. */ +export const TALENT_STUFEN = [ + { + schluessel: "aufgefallen", name: "Aufgefallen", rang: 1, frist: 14, + bedeutet: "Ein Name ist gefallen. Mehr nicht.", + schritt: "Beobachten einschalten – ab jetzt Merkmale sammeln.", + knopf: "Beobachten", + }, + { + schluessel: "beobachtet", name: "Beobachtet", rang: 2, frist: 30, + bedeutet: "Merkmale werden über Wochen gesammelt.", + schritt: "Ansprechen – der Vorlagentext liegt bereit.", + knopf: "Ansprechen", + }, + { + schluessel: "angesprochen", name: "Angesprochen", rang: 3, frist: 7, + bedeutet: "Gefragt, ob überhaupt Interesse besteht.", + schritt: "Antwort eintragen: ja, nein oder später.", + knopf: "Auf Probe nehmen", + }, + { + 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.", + 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.", + knopf: null, + }, +]; + +/** Die Merkmale, in fünf Gruppen. Angeklickt, nicht getippt. + * + * DIE REIHENFOLGE DER GRUPPEN IST NICHT ZUFAELLIG: „Ruhe" steht + * vorn, weil es das am besten belegte Einzelmerkmal ueberhaupt ist -- + * und weil die erste Gruppe die ist, die man immer ausfuellt. */ +export const TALENT_GRUPPEN = [ + { + schluessel: "ruhe", name: "Ruhe", + text: "Das am stärksten belegte Einzelmerkmal. Wer unter Druck ruhig bleibt, " + + "kann alles andere lernen – umgekehrt nicht.", + }, + { + schluessel: "hilft", name: "Hilft von selbst", + text: "Nicht ob jemand helfen WILL, sondern ob er es schon tut, ohne gefragt zu sein.", + }, + { + schluessel: "haus", name: "Kennt das Haus", + text: "Regeln, Leute, Witze. Das kann man nicht einarbeiten – das hat man oder nicht.", + }, + { + schluessel: "da", name: "Ist da", + text: "Regelmäßigkeit schlägt Menge. Und eine Zeit, zu der ihr niemanden habt, " + + "schlägt beides.", + }, + { + schluessel: "empfohlen", name: "Aus dem Team", + text: "Wer aus dem Team kommt, ist schon einmal durch ein Augenpaar gegangen.", + }, +]; + +export const TALENT_MERKMALE = { + ruhe: [ + { schluessel: "t-ruhe-streit", 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", + text: "Nicht ignorieren – nicht mitgehen. Zwei verschiedene Dinge." }, + { schluessel: "t-ruhe-widerspricht", 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", + 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", + text: "Tut die Arbeit schon, ohne den Titel zu haben." }, + { schluessel: "t-hilft-erklaert-neuen", 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", + 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", + 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", + text: "Und wendet sie im Gespräch an, nicht als Zitat." }, + { schluessel: "t-haus-leute", 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", + 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", + 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", + text: "Regelmäßigkeit ist planbar, Begeisterung nicht." }, + { schluessel: "t-da-auch-wenig-los", 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", + 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", + text: "Pausen sind normal. Wiederkommen ist das Signal." }, + ], + empfohlen: [ + { schluessel: "t-emp-vom-team", 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", + 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", + text: "Die ehrlichste Frage, die man dem Team stellen kann." }, + { schluessel: "t-emp-schon-ausgeholfen", 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", + text: "Schwächer als eine Nennung aus dem Team, aber es zählt." }, + ], +}; + +/** Die Warnzeichen. Getrennt und in einem warmen Ton — nicht rot. + * + * WARUM SIE UEBERHAUPT GETRENNT STEHEN: Bei Moderatoren sagt das + * Fehlverhalten mehr voraus als das Koennen. Ein Warnzeichen neben + * zwanzig Haken ginge unter; getrennt steht es da. + * + * UND WARUM NICHT ROT: Keines davon ist ein Ausschluss. Rot heisst + * "nein" -- gemeint ist "hier laenger hinsehen". Ein Ton, der mehr + * sagt als die Sache, macht aus einem Hinweis ein Urteil. */ +export const TALENT_WARNZEICHEN = [ + { schluessel: "t-warn-will-die-rolle", titel: "Will die Rolle sehr deutlich", + text: "Der klassischste Warnhinweis überhaupt. Kein Ausschluss – " + + "aber ein Grund, länger hinzusehen." }, + { schluessel: "t-warn-war-ausfaellig", titel: "War im Chat schon einmal ausfällig", + text: "Muss nicht das letzte Wort sein. Aber es gehört auf die Karte, " + + "nicht in die Erinnerung von einem." }, + { schluessel: "t-warn-nur-ein-thema", titel: "Nur bei einem Thema da", + text: "Begeisterung für eine Sache, nicht für den Ort. Hält, solange das Thema hält." }, + { schluessel: "t-warn-verschwindet", titel: "Verschwindet für Wochen und kommt wieder", + text: "Schwer zu verplanen – unabhängig davon, wie gut jemand ist." }, + { schluessel: "t-warn-redet-ueber", titel: "Redet über andere, nicht mit ihnen", + text: "Sagt Streit im Team voraus – den zweithäufigsten Ausstiegsgrund." }, + { schluessel: "t-warn-nur-bei-dir", titel: "Nur da, wenn du sendest", + text: "An eine Person gebunden, nicht an die Community. Das hält nicht." }, +]; + +/* --------------------------------------------------------------------- + Abgeleitet, nicht abgeschrieben -- dieselbe Regel wie im anderen + Katalog: keine Zahl, die jemand pflegen muesste. + ------------------------------------------------------------------- */ + +export const TALENT_ALLE = TALENT_GRUPPEN.flatMap((g) => + (TALENT_MERKMALE[g.schluessel] || []).map((m) => ({ ...m, gruppe: g.schluessel }))); + +export const TALENT_NACH_SCHLUESSEL = new Map([ + ...TALENT_ALLE.map((m) => [m.schluessel, { ...m, warnung: false }]), + ...TALENT_WARNZEICHEN.map((w) => [w.schluessel, { ...w, warnung: true }]), +]); + +/** Die Stufe zu einem Schluessel -- und `null`, wenn es sie nicht gibt. + * Kein Rueckfall auf die erste Stufe: Eine erfundene Stufe soll + * auffallen, nicht stillschweigend zu "Aufgefallen" werden. */ +export const talentStufe = (s) => + TALENT_STUFEN.find((x) => x.schluessel === s) || null; + +/** Die naechste Stufe -- oder null am Ende des Trichters. */ +export function talentNaechste(s) { + const i = TALENT_STUFEN.findIndex((x) => x.schluessel === s); + if (i < 0 || i + 1 >= TALENT_STUFEN.length) return null; + return TALENT_STUFEN[i + 1]; +} diff --git a/server/workspace.js b/server/workspace.js index 6b149d27..99b870e9 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -629,13 +629,18 @@ const FUEHRUNG_GRUPPE = { const ENTWICKLUNG_KACHEL = { ...FUEHRUNG_GRUPPE, name: "Entwicklung", unter: "Was gut läuft, woran ihr arbeitet – je Person", - zeichen: "steckbrief", ton: 26, ziel: "bereich.html?b=entwicklung", szene: "studio", + /* SEIT DEM 11.09.2026 FUEHRT DIE KACHEL AUF DEN KATALOG, nicht mehr + auf das Notizbrett. Filipe: "ich will dass fertige aufgaben da + stehen und so". Das Brett gibt es weiter -- es ist von der + Katalogseite aus verlinkt und nimmt auf, was zwischen zwei + Durchgaengen auffaellt. */ + zeichen: "steckbrief", ton: 26, ziel: "entwicklung.html", szene: "studio", }; const TALENTE_KACHEL = { ...FUEHRUNG_GRUPPE, name: "Talente", unter: "Zuschauer, die ins Team passen könnten", - zeichen: "scouting", ton: 27, ziel: "bereich.html?b=talente", szene: "portal", + zeichen: "scouting", ton: 27, ziel: "talente.html", szene: "portal", }; const PERSONEN_KACHEL = { @@ -700,11 +705,29 @@ function vorGruppe(liste, gruppe, ...neue) { community", 11.09.2026), und sie kommen dort ueber ZUSATZ_BEREICHE.admin mit eigenem gruppeNach. Zwei Adressen, zwei Plaetze, beide entschieden. */ +/* WIE GEHT'S DIR? -- die einzige Kachel des Hauses, deren Inhalt + niemand ausser der Person selbst je sieht (11.09.2026). + + SIE STEHT BEI "FUER DICH" und nicht bei "Taeglich": Es sind sechs + Fragen, die man einmal im Monat beantwortet, nicht jeden Tag. Eine + taegliche Kachel, die man nicht taeglich braucht, wird nach einer + Woche uebersehen. + + UND SIE STEHT AUCH BEI DOGFATHER UND DER RECHTEN HAND. Sie sind + Teil des Teams, nicht darueber -- wenn es jemandem zu viel wird, dann + ihnen genauso. Eine Kachel, die nur die anderen ausfuellen, waere + genau die Schieflage, die dieses Haus nicht haben soll. */ +const BEFINDEN_KACHEL = { + gruppe: "Für dich", gruppeUnter: "Dein Platz im Team", + name: "Wie geht's dir?", unter: "Sechs Fragen – die Antworten sieht nur du", + zeichen: "steckbrief", ton: 37, ziel: "entwicklung.html", szene: "lounge", +}; + const HAND_BEREICHE = vorGruppe( [...MODI_BEREICHE, EINGANG_KACHEL, PERSONEN_KACHEL], "Rund ums Live", ENTWICKLUNG_KACHEL, TALENTE_KACHEL, -); +).concat(BEFINDEN_KACHEL); /* ===================================================================== @@ -797,7 +820,8 @@ export const TREFF_ROLLEN = new Set(["gast", "modi", "hand", "admin"]); Zusammengesetzt und nicht in MODI_BEREICHE hineingeschrieben, weil diese Liste weiter oben steht als die Kacheln des Treffs. Eine Kopie dort waere die Stelle, an der beim naechsten neuen Brett eines fehlt. */ -const MODI_MIT_TREFF = [...MODI_BEREICHE, ...TREFF_BEREICHE, TREFF_MODERATION_KACHEL]; +const MODI_MIT_TREFF = [...MODI_BEREICHE, BEFINDEN_KACHEL, ...TREFF_BEREICHE, + TREFF_MODERATION_KACHEL]; const HAND_MIT_TREFF = [...HAND_BEREICHE, ...TREFF_BEREICHE, TREFF_MODERATION_KACHEL]; /* AUSSEN_ROLLEN kommt aus crew-adresse.js und wird hier nur @@ -861,10 +885,39 @@ const AGENTUR_LISTE = new Set(["spicy", "admin", "manager", "scout", "creator"]) Gebraucht wird sie, weil `bereich.html` seit heute fuer ihn offen ist -- ohne diese Einschraenkung kaeme er ueber die Adresszeile auch in die Agentur-Ablage, die ihn nichts angeht. */ -export const MODI_BEREICHE_ERLAUBT = new Set( - HAND_MIT_TREFF +/* EINE KACHEL IST NICHT MEHR DER EINZIGE WEG ZU EINEM BRETT + (11.09.2026, gefunden von pruef-entwicklung). + + Die Ableitung oben liest die Bretter aus den Kachelzielen. Das war + richtig, solange jede Kachel auf ein Brett zeigte. Seit „Entwicklung" + und „Talente" auf ihre KATALOGSEITEN zeigen, fielen genau diese + beiden Bretter aus der Liste -- und die rechte Hand bekam auf dem + Notizbrett, das die Katalogseite selbst verlinkt, ein 404. + + Kein Fehler im Riegel, sondern in seiner QUELLE: Die Frage "welche + Bretter darf diese Rolle oeffnen" laesst sich nicht mehr allein aus + den Kacheln beantworten, weil es jetzt Bretter gibt, die man ueber + eine Seite erreicht statt ueber eine Kachel. + + Beides wird deshalb zusammengezogen -- und die zweite Liste ist + ausdruecklich KURZ und begruendet, nicht offen: Hier stehen nur die + Bretter, zu denen eine erlaubte SEITE verlinkt. Wer ein drittes + dazunimmt, ohne es einzutragen, bekommt dasselbe 404 und findet + diesen Absatz. */ +const BRETTER_UEBER_SEITEN = [ + /* Beide sind von entwicklung.html bzw. talente.html aus verlinkt -- + das Notizbrett neben dem Katalog. Wer die Seite oeffnen darf, + muss auch dorthin kommen. */ + "entwicklung", + "talente", +]; + +export const MODI_BEREICHE_ERLAUBT = new Set([ + ...HAND_MIT_TREFF .map((k) => (/bereich\.html\?b=([a-z]+)/.exec(k.ziel) || [])[1]) - .filter(Boolean)); + .filter(Boolean), + ...BRETTER_UEBER_SEITEN, +]); /* Der Satz unter der Begruessung. Die fuenf bekannten Rollen haben ihn in assets/js/start.js stehen ("Creator · dein eigener Bereich"); fuer diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index f376972c..8f49c029 100644 --- a/workspace/app.webmanifest +++ b/workspace/app.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/workspace-192.png?v=202609111836", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609112217", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609111836", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609112217", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609111836", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609112217", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609111836", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609112217", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/entwicklung.css b/workspace/assets/css/entwicklung.css new file mode 100644 index 00000000..3287c414 --- /dev/null +++ b/workspace/assets/css/entwicklung.css @@ -0,0 +1,365 @@ +/* ===================================================================== + Entwicklung & Nachwuchs — die zwei Katalogseiten (11.09.2026) + + Zwei Seiten, ein Stil: Auf beiden klickt man kurze Aussagen an, + beide zeigen daneben eine Zahl, die etwas über UNS sagt (wie alt die + Einschätzung ist, wie lange eine Karte liegt). + + --------------------------------------------------------------------- + DIE WICHTIGSTE ENTSCHEIDUNG IST EINE FARBE, DIE NICHT VORKOMMT + + Es gibt hier kein Rot. „Da hakt es" ist eine Beobachtung, kein Alarm; + ein Warnzeichen an einem Kandidaten ist ein Grund hinzusehen, kein + Nein. Rot würde in beiden Fällen mehr sagen als die Sache — und aus + einem Gesprächsanlass ein Urteil machen. + + Stattdessen: warm (Bernstein) für „hakt" und für Warnzeichen, kühl + für „läuft", babyblau für „wächst", grau für „kann ich nicht sagen". + Vier unterscheidbare Töne, keiner davon laut. Das ist auch die + Hausregel — augenschonend, gedeckt, dunkles Schema. + + --------------------------------------------------------------------- + UND EINE, DIE MAN NICHT SIEHT: ES GIBT KEINEN BALKEN + + Kein Fortschrittsbalken je Person, kein Prozentwert, keine Punktzahl. + Eine Zahl über einem Menschen wird gelesen, verglichen und + weitererzählt. Was hier gebraucht wird, ist ein Gesprächsanlass. + ===================================================================== */ + +/* --------------------------------------------------------------------- + Gemeinsam + ------------------------------------------------------------------- */ + +.e-abschnitt { + margin: 26px 0 12px; + font-size: .82rem; + font-weight: 650; + letter-spacing: .09em; + text-transform: uppercase; + color: var(--text-still); +} + +/* --------------------------------------------------------------------- + Die Ampel + + Sie steht ganz oben und ist absichtlich die einzige Fläche auf der + Seite, die eine Farbe trägt. Unter 34 Beobachtungen hätte sie + niemand je gelesen. + ------------------------------------------------------------------- */ +.e-ampel { + margin: 0 0 22px; + padding: 14px 18px; + border: 1px solid var(--rand); + border-left: 3px solid var(--rand-hell); + background: color-mix(in srgb, var(--tinte) 94%, #ffffff 6%); +} +.e-ampel[data-lage="jemand"] { border-left-color: #cc9451; } +.e-ampel[data-lage="mehrere"] { border-left-color: #cc7a51; } +.e-ampel[data-lage="ruhig"] { border-left-color: #668e6e; } +.e-ampel__satz { margin: 0; font-size: .93rem; line-height: 1.6; color: var(--text); } +.e-ampel__zusatz { + margin: 7px 0 0; font-size: .8rem; line-height: 1.55; color: var(--text-still); +} + +/* --------------------------------------------------------------------- + Die Personenreihe + ------------------------------------------------------------------- */ +.e-personen { display: flex; flex-wrap: wrap; gap: 10px; } + +.e-person { + display: flex; flex-direction: column; gap: 3px; + min-width: 210px; flex: 1 1 210px; + padding: 13px 15px; text-align: left; cursor: pointer; + font: inherit; + background: color-mix(in srgb, var(--tinte) 94%, #ffffff 6%); + border: 1px solid var(--rand); + transition: border-color .16s ease; +} +.e-person:hover, .e-person:focus-visible { + border-color: color-mix(in srgb, var(--akzent) 45%, transparent); +} +.e-person__name { font-size: .98rem; font-weight: 640; color: var(--text); } +.e-person__rolle { font-size: .78rem; color: var(--text-still); } +.e-person__zahlen { display: flex; flex-wrap: wrap; gap: 5px 10px; margin-top: 6px; } +.e-person__zahl { font-size: .77rem; color: var(--text-leise); } +/* Die Abweichung ist das Ergebnis dieser Seite — sie darf als Einzige + auffallen. */ +.e-person__zahl--wichtig { color: var(--text); font-weight: 620; } +.e-person__zahl--alt { color: var(--text-still); } + +/* --------------------------------------------------------------------- + Die Karte + ------------------------------------------------------------------- */ +.e-karte { margin: 26px 0 0; } +.e-karte__kopf { + display: flex; flex-wrap: wrap; align-items: baseline; gap: 10px; + padding-bottom: 10px; margin-bottom: 16px; + border-bottom: 1px solid var(--rand-hell); +} +.e-karte__name { margin: 0; font-size: 1.22rem; font-weight: 660; color: var(--text); } +.e-karte__rolle { font-size: .82rem; color: var(--text-still); } +.e-karte__stand { font-size: .8rem; color: var(--text-leise); margin-left: auto; } +.e-karte__brett { text-decoration: none; } + +/* Die Abweichungen. Sie stehen oben und bekommen als Einzige einen + Rahmen, der auffällt — sie sind der Grund, warum zwei Leute getrennt + klicken. */ +.e-abweichung { + margin: 0 0 22px; padding: 15px 18px; + border: 1px solid color-mix(in srgb, var(--akzent) 32%, transparent); + background: color-mix(in srgb, var(--akzent) 7%, var(--tinte)); +} +.e-abweichung__titel { margin: 0 0 5px; font-size: 1rem; font-weight: 650; color: var(--text); } +.e-abweichung__satz { margin: 0 0 10px; font-size: .85rem; color: var(--text-leise); } +.e-abweichung__liste { margin: 0; padding-left: 1.1em; } +.e-abweichung__liste li { margin-bottom: 6px; } +.e-abweichung__punkt { display: block; font-size: .88rem; color: var(--text); } +.e-abweichung__gegen { display: block; font-size: .8rem; color: var(--text-leise); } + +/* --------------------------------------------------------------------- + Ein Block und seine Punkte + ------------------------------------------------------------------- */ +.e-block { margin: 0 0 26px; } +.e-block__titel { margin: 0 0 4px; font-size: 1.02rem; font-weight: 650; color: var(--text); } +.e-block__satz { + margin: 0 0 12px; max-width: 64ch; + font-size: .85rem; line-height: 1.6; color: var(--text-leise); +} + +.e-punkt { + margin: 0 0 9px; padding: 12px 15px; + background: color-mix(in srgb, var(--tinte) 94%, #ffffff 6%); + border: 1px solid var(--rand); + /* Der linke Balken trägt die Antwort. Er ist die einzige Stelle, an + der man beim Überfliegen sieht, wie eine Zeile steht — ohne dass + die ganze Karte einfärbt. */ + border-left: 3px solid transparent; +} +.e-punkt[data-stufe="laeuft"] { border-left-color: #668e6e; } +.e-punkt[data-stufe="waechst"] { border-left-color: #8ec9ff; } +.e-punkt[data-stufe="hakt"] { border-left-color: #cc9451; } +.e-punkt[data-stufe="unklar"] { border-left-color: var(--rand-hell); } +/* Verblasst: die Zeile tritt zurück, verschwindet aber nicht. Eine + Akte, die heimlich vergisst, wäre schlimmer als eine, die alles + behält. */ +.e-punkt[data-verblasst="ja"] { opacity: .68; } + +.e-punkt__kopf { display: flex; flex-wrap: wrap; align-items: baseline; gap: 9px; } +.e-punkt__titel { margin: 0; font-size: .95rem; font-weight: 620; color: var(--text); } +.e-punkt__erwartung { + /* .75rem = 12 px und nicht .7rem = 11,2 px. + + pruef-css-klassen zaehlt die Stellen unter 11,5 px gegen eine + Grundlinie und meldet JEDE neue -- hier war es eine. Elf Pixel in + Grossbuchstaben mit Sperrung liest man auf einem Handy nicht mehr, + man erkennt sie hoechstens wieder. Und das hier ist keine Zierde, + sondern die Angabe, ab wann etwas ueberhaupt erwartet wird. */ + font-size: .75rem; letter-spacing: .04em; text-transform: uppercase; + padding: 2px 7px; border: 1px solid var(--rand-hell); border-radius: 999px; + color: var(--text-still); +} +/* Die Erwartungsstufe ist KEINE Note — deshalb drei fast gleiche Töne + und kein Ampelverlauf. Sie sagt, ab wann etwas erwartet wird, nicht + wie gut jemand ist. */ +.e-punkt__erwartung[data-erwartung="senior"] { color: var(--text-leise); } +.e-punkt__text { + margin: 5px 0 10px; max-width: 66ch; + font-size: .83rem; line-height: 1.6; color: var(--text-leise); +} + +.e-wahl { display: flex; flex-wrap: wrap; gap: 6px; } +.e-wahl__knopf { + display: inline-flex; align-items: center; gap: 6px; + font: inherit; font-size: .8rem; font-weight: 600; + padding: 6px 12px; border-radius: 999px; cursor: pointer; + color: var(--text-leise); + background: transparent; + border: 1px solid var(--rand-hell); + transition: background .16s ease, color .16s ease, border-color .16s ease; +} +.e-wahl__zeichen { font-size: .9rem; line-height: 1; } +.e-wahl__knopf:hover:not(:disabled) { color: var(--text); } +.e-wahl__knopf[aria-pressed="true"] { color: var(--text); } +.e-wahl__knopf[data-stufe="laeuft"][aria-pressed="true"] { + background: color-mix(in srgb, #668e6e 22%, transparent); + border-color: color-mix(in srgb, #668e6e 55%, transparent); +} +.e-wahl__knopf[data-stufe="waechst"][aria-pressed="true"] { + background: color-mix(in srgb, #8ec9ff 18%, transparent); + border-color: color-mix(in srgb, #8ec9ff 50%, transparent); +} +.e-wahl__knopf[data-stufe="hakt"][aria-pressed="true"] { + background: color-mix(in srgb, #cc9451 20%, transparent); + border-color: color-mix(in srgb, #cc9451 55%, transparent); +} +.e-wahl__knopf[data-stufe="unklar"][aria-pressed="true"] { + background: rgba(255, 255, 255, .06); + border-color: var(--rand-hell); +} + +.e-punkt__anlass { + display: block; width: 100%; margin: 9px 0 0; + font: inherit; font-size: .84rem; line-height: 1.5; + padding: 8px 10px; resize: vertical; + color: var(--text); + background: rgba(0, 0, 0, .28); + border: 1px solid var(--rand); +} +.e-punkt__anlass::placeholder { color: var(--text-still); } + +.e-punkt__andere, .e-punkt__verdeckt, .e-punkt__wann { + margin: 8px 0 0; font-size: .78rem; line-height: 1.5; color: var(--text-still); +} +.e-punkt__andere { color: var(--text-leise); } +/* Der verdeckte Stand: sichtbar, dass es ihn gibt — unsichtbar, was er + ist. Ohne diese Zeile sähen „noch niemand" und „du darfst es noch + nicht sehen" gleich aus, und das erste ist eine Auskunft, das zweite + eine Täuschung. */ +.e-punkt__verdeckt { font-style: italic; } + +/* Die eigenen sechs Fragen. Sie stehen ganz unten und tragen als + Einzige eine eigene Umrandung — sie gehören nicht in dieselbe Liste + wie das, was über andere gesagt wird. */ +.e-meins { + margin: 34px 0 0; padding: 20px 22px; + border: 1px solid color-mix(in srgb, var(--akzent) 26%, transparent); + background: color-mix(in srgb, var(--tinte) 94%, #ffffff 6%); +} +.e-meins__satz { + margin: 0 0 16px; max-width: 62ch; + font-size: .88rem; line-height: 1.65; color: var(--text-leise); +} +.e-punkt--meins { background: rgba(0, 0, 0, .2); } + +/* --------------------------------------------------------------------- + Talente — der Trichter + ------------------------------------------------------------------- */ +.t-trichter { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 24px; } +.t-stufe { + display: flex; flex-direction: column; gap: 1px; + flex: 1 1 130px; padding: 11px 14px; + background: color-mix(in srgb, var(--tinte) 94%, #ffffff 6%); + border: 1px solid var(--rand); + /* Der Trichter wird nach hinten leiser — nicht bunter. Fünf Farben + für fünf Stufen wären fünf Bedeutungen, die keine sind. */ + border-top: 2px solid color-mix(in srgb, var(--akzent) 40%, transparent); +} +.t-stufe[data-stufe="probe"] { border-top-color: color-mix(in srgb, var(--akzent) 65%, transparent); } +.t-stufe[data-stufe="imteam"] { border-top-color: #668e6e; } +.t-stufe__zahl { font-size: 1.5rem; font-weight: 660; color: var(--text); line-height: 1.1; } +.t-stufe__name { font-size: .79rem; color: var(--text-leise); } +.t-stufe__liegt { font-size: .74rem; color: #cc9451; margin-top: 3px; } + +.t-liste { display: block; } +.t-karte { + margin: 0 0 14px; padding: 16px 18px; + background: color-mix(in srgb, var(--tinte) 94%, #ffffff 6%); + border: 1px solid var(--rand); + border-left: 3px solid transparent; +} +.t-karte[data-liegt="ja"] { border-left-color: #cc9451; } +.t-karte__kopf { display: flex; flex-wrap: wrap; align-items: baseline; gap: 10px; } +.t-karte__titel { margin: 0; font-size: 1.05rem; font-weight: 650; color: var(--text); } +.t-karte__stufe { + font-size: .74rem; letter-spacing: .05em; text-transform: uppercase; + padding: 2px 8px; border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--akzent) 40%, transparent); + color: var(--text-leise); +} +.t-karte__zeit { font-size: .78rem; color: var(--text-still); } +.t-karte__warn { + font-size: .76rem; color: #cc9451; margin-left: auto; +} +.t-karte__wo, .t-karte__text { + margin: 8px 0 0; max-width: 66ch; + font-size: .85rem; line-height: 1.6; color: var(--text-leise); +} +.t-karte__wo { color: var(--text-still); font-size: .8rem; } + +.t-schritt { + display: flex; flex-wrap: wrap; align-items: center; gap: 10px; + margin: 13px 0 0; padding: 11px 13px; + background: rgba(0, 0, 0, .24); + border: 1px solid var(--rand); +} +.t-schritt__satz { margin: 0; flex: 1 1 220px; font-size: .86rem; color: var(--text); } +.t-schritt__liegt { margin: 0; flex: 1 1 100%; font-size: .79rem; color: #cc9451; } + +.t-karte__auf { + margin: 12px 0 0; font: inherit; font-size: .8rem; + padding: 5px 0; cursor: pointer; + background: none; border: 0; border-bottom: 1px solid var(--rand-hell); + color: var(--text-leise); +} +.t-karte__auf:hover { color: var(--text); } + +.t-gruppe { margin: 16px 0 0; } +.t-gruppe__titel { margin: 0 0 3px; font-size: .9rem; font-weight: 640; color: var(--text); } +.t-gruppe__satz { + margin: 0 0 8px; max-width: 64ch; + font-size: .8rem; line-height: 1.55; color: var(--text-still); +} +.t-merkmale { display: flex; flex-wrap: wrap; gap: 6px; } + +.t-merkmal { + display: inline-flex; align-items: center; gap: 7px; + font: inherit; font-size: .8rem; text-align: left; + padding: 6px 12px; border-radius: 999px; cursor: pointer; + color: var(--text-leise); + background: transparent; + border: 1px solid var(--rand-hell); + transition: background .16s ease, color .16s ease, border-color .16s ease; +} +.t-merkmal__haken { font-size: .85rem; line-height: 1; color: var(--text-still); } +.t-merkmal:hover:not(:disabled) { color: var(--text); } +.t-merkmal[aria-pressed="true"] { + color: var(--text); + background: color-mix(in srgb, var(--akzent) 14%, transparent); + border-color: color-mix(in srgb, var(--akzent) 42%, transparent); +} +.t-merkmal[aria-pressed="true"] .t-merkmal__haken { color: var(--akzent); } +/* Warnzeichen: warm, nicht rot. Keines davon ist ein Nein. */ +.t-merkmal[data-warnung="ja"][aria-pressed="true"] { + background: color-mix(in srgb, #cc9451 20%, transparent); + border-color: color-mix(in srgb, #cc9451 55%, transparent); +} +.t-merkmal[data-warnung="ja"][aria-pressed="true"] .t-merkmal__haken { color: #cc9451; } + +.t-gruppe--warn .t-gruppe__titel { color: #cc9451; } + +.t-leer { + padding: 22px; text-align: center; + border: 1px dashed var(--rand-hell); + color: var(--text-leise); +} +.t-leer p { margin: 0 0 12px; max-width: 54ch; margin-inline: auto; font-size: .88rem; line-height: 1.6; } +.t-leer .schritt { text-decoration: none; } + +/* DIE FUSSZEILE BRAUCHT DENSELBEN GRUND WIE ALLES ANDERE. + + Sie stand als einziger Text der Seite direkt auf der Bühne — dem + Spicy-Media-Motiv mit Neonlicht und Chilischoten. Lesbar war es + knapp; gemessen war es gar nicht, weil die Kontrastprüfung + Elemente mit Kindern überspringt und hier ein Link darin steht. + + Das ist derselbe Befund wie am Nachmittag bei der Rechtetafel, nur + eine Ecke weiter — und der Grund, warum in diesem Haus jede Fläche + mit Text eine deckende Unterlage bekommt statt einer + halbdurchsichtigen. */ +.t-fuss { + margin: 18px 0 0; padding: 11px 14px; + font-size: .8rem; line-height: 1.55; color: var(--text-still); + background: color-mix(in srgb, var(--tinte) 94%, #ffffff 6%); + border: 1px solid var(--rand); +} +.t-fuss a { color: var(--text-leise); } + +@media (prefers-reduced-motion: reduce) { + .e-person, .e-wahl__knopf, .t-merkmal { transition: none; } +} + +@media (max-width: 620px) { + .e-karte__stand { margin-left: 0; } + .t-karte__warn { margin-left: 0; } + .e-punkt__text, .e-block__satz { font-size: .88rem; } +} diff --git a/workspace/assets/css/start.css b/workspace/assets/css/start.css index 85230f0e..c240fee6 100644 --- a/workspace/assets/css/start.css +++ b/workspace/assets/css/start.css @@ -1091,6 +1091,13 @@ body.start > main { position: relative; z-index: 1; } nächsten (#8e5b77 „Highlights", #2f2f7f „Wer sieht was"). Auch das liegt über dem schwächsten Paar der vorhandenen Töne (24,9). */ [data-ton="36"] { --ton: #7c2771; } /* Meldungen & Maßnahmen */ +/* Der 37., nach derselben Rechnung: #46762e, Abstand 25,6 zum nächsten + Nachbarn — knapp über dem schwächsten Paar der ursprünglichen 34 + (24,9). Bei 37 Tönen in einem augenschonenden Ausschnitt (kein Rot, + keine Neonsättigung) ist das die Grenze des Machbaren; wird es enger, + ist die nächste Frage nicht „noch ein Ton", sondern „braucht diese + Kachel wirklich eine eigene Farbe". */ +[data-ton="37"] { --ton: #46762e; } /* Wie geht's dir? */ /* Rückfallwert für alles, was einen Ton braucht, aber keine Nummer hat. Steht bewusst in :where() -- damit zählt die Zeile beim Wettstreit der diff --git a/workspace/assets/js/entwicklung.js b/workspace/assets/js/entwicklung.js new file mode 100644 index 00000000..3c49edcf --- /dev/null +++ b/workspace/assets/js/entwicklung.js @@ -0,0 +1,311 @@ +/* ===================================================================== + Die Entwicklungskarte (11.09.2026) + + Vier Knöpfe je Beobachtung, ein Feld für den Anlass, und oben die + Abweichungen zwischen zwei Augenpaaren. Mehr ist es nicht — und das + ist Absicht: „so einfach wie möglich, aber so professionell wie + möglich" (Filipe). + + WAS HIER NICHT PASSIERT: rechnen. Es gibt keine Punktzahl, keinen + Durchschnitt, keine Ampel je Person. Eine Zahl über einem Menschen + wird gelesen, verglichen und weitererzählt — gebraucht wird ein + Gesprächsanlass. + + UND KEINE RECHTEPRÜFUNG. Ob jemand Block 5 sehen darf, entscheidet + der Server: Er liefert ihn gar nicht erst aus. Was hier steht, ist + höchstens ein Abschnitt, der leer bleibt. + ===================================================================== */ +(() => { + const $ = (id) => document.getElementById(id); + const el = (t, k, text) => { + const n = document.createElement(t); + if (k) n.className = k; + if (text !== undefined) n.textContent = text; + return n; + }; + const melde = (t) => { const f = $('fehler'); if (f) { f.textContent = t; f.hidden = !t; } }; + + const datum = (iso) => { + if (!iso) return ''; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString('de-DE', + { day: 'numeric', month: 'short', year: 'numeric' }); + }; + + async function hole(pfad, optionen = {}) { + const a = await fetch(pfad, { credentials: 'same-origin', ...optionen }); + if (a.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); } + if (a.status === 404) { location.assign('/workspace/start.html'); throw new Error('unbekannt'); } + return a; + } + + let stufen = []; + let offen = null; // die gerade geöffnete Person + + /* ---- Die vier Knöpfe ------------------------------------------------ + Sie stehen immer in derselben Reihenfolge, auch wenn einer gesetzt + ist. Ein Knopfsatz, der sich umsortiert, sobald man ihn benutzt, + ist der sicherste Weg, beim zweiten Mal danebenzutippen. */ + function knopfsatz(aktuell, waehle) { + const reihe = el('div', 'e-wahl'); + for (const s of stufen) { + const b = el('button', 'e-wahl__knopf'); + b.type = 'button'; + b.dataset.stufe = s.schluessel; + b.setAttribute('aria-pressed', String(aktuell === s.schluessel)); + b.title = s.text; + b.append(el('span', 'e-wahl__zeichen', s.zeichen)); + b.append(el('span', 'e-wahl__name', s.name)); + b.addEventListener('click', () => waehle(s.schluessel, b)); + reihe.append(b); + } + return reihe; + } + + /* ---- Die Ampel ------------------------------------------------------ */ + async function ampel() { + const ziel = $('ampel'); + let a; + try { a = await hole('/workspace/api/entwicklung/ampel'); } catch { return; } + if (!a.ok) return; // für einen Modi gibt es sie nicht + const r = await a.json(); + ziel.textContent = ''; + ziel.hidden = false; + ziel.dataset.lage = r.sagbar ? r.lage : 'still'; + ziel.append(el('p', 'e-ampel__satz', r.sagbar ? r.text : r.grund)); + if (r.sagbar) { + ziel.append(el('p', 'e-ampel__zusatz', + `${r.geantwortet} von ${r.team} im Team haben die sechs Fragen schon beantwortet. ` + + 'Namen stehen hier nicht – auch für dich nicht.')); + } + } + + /* ---- Die Personen --------------------------------------------------- */ + function personKarte(p, lage) { + const k = el('button', 'e-person'); + k.type = 'button'; + k.append(el('span', 'e-person__name', p.name)); + k.append(el('span', 'e-person__rolle', p.rolle_name)); + + const zahlen = el('span', 'e-person__zahlen'); + zahlen.append(el('span', 'e-person__zahl', + `${p.gesetzt} von ${lage.gesamt_fremd} angesehen`)); + /* DIE ABWEICHUNG STEHT VORNE UND NICHT IM DETAIL. Sie ist das + Ergebnis dieser Seite: Wo zwei Leute dasselbe sehen, gibt es + nichts zu besprechen. */ + if (p.abweichungen) { + const z = el('span', 'e-person__zahl e-person__zahl--wichtig', + `${p.abweichungen} × verschieden gesehen`); + z.title = 'Hier lohnt das Gespräch – nicht dort, wo ihr einig seid.'; + zahlen.append(z); + } + if (p.veraltet) { + const z = el('span', 'e-person__zahl e-person__zahl--alt', + `${p.veraltet} älter als ${lage.verblasst_nach} Tage`); + z.title = 'Eine alte Einschätzung ist keine Einschätzung mehr.'; + zahlen.append(z); + } + k.append(zahlen); + k.addEventListener('click', () => karte(p.id)); + return k; + } + + /* ---- Die Karte einer Person ----------------------------------------- */ + async function karte(id) { + offen = id; + const ziel = $('karte'); + ziel.hidden = false; + ziel.textContent = ''; + ziel.append(el('p', 'leise', 'wird geladen …')); + ziel.scrollIntoView({ behavior: 'smooth', block: 'start' }); + + const a = await hole(`/workspace/api/entwicklung/person/${id}`); + if (!a.ok) { melde('Die Karte ließ sich nicht laden.'); return; } + const r = await a.json(); + stufen = r.stufen; + ziel.textContent = ''; + + const kopf = el('div', 'e-karte__kopf'); + kopf.append(el('h2', 'e-karte__name', r.person.name)); + kopf.append(el('span', 'e-karte__rolle', r.person.rolle_name)); + kopf.append(el('span', 'e-karte__stand', `${r.gesetzt} von ${r.gesamt} angesehen`)); + /* Der Weg zum Notizbrett. Der Katalog ist das, was man regelmäßig + durchgeht; die Notiz das, was zwischendurch auffällt. Beides + nebeneinander, nicht statt. */ + const brett = el('a', 'schritt e-karte__brett', 'Notizen zu dieser Person'); + brett.href = 'bereich.html?b=entwicklung'; + kopf.append(brett); + ziel.append(kopf); + + /* ---- Abweichungen zuerst ------------------------------------------ + Sie stehen oben, weil sie das Einzige sind, was diese Seite + liefert und eine Liste nicht: die Stelle, an der zwei Leute + dieselbe Person verschieden sehen. */ + if (r.abweichungen.length) { + const box = el('div', 'e-abweichung'); + box.append(el('h3', 'e-abweichung__titel', + `${r.abweichungen.length} × seht ihr das verschieden`)); + box.append(el('p', 'e-abweichung__satz', + 'Genau hier lohnt das Gespräch. Wo ihr einig seid, gibt es nichts zu besprechen.')); + const liste = el('ul', 'e-abweichung__liste'); + for (const ab of r.abweichungen) { + const li = document.createElement('li'); + li.append(el('span', 'e-abweichung__punkt', ab.titel)); + const nn = (s) => stufen.find((x) => x.schluessel === s)?.name || s; + li.append(el('span', 'e-abweichung__gegen', + `du: ${nn(ab.meins)} · ${ab.von_name}: ${nn(ab.andere)}`)); + liste.append(li); + } + box.append(liste); + ziel.append(box); + } + + /* ---- Die Blöcke ---------------------------------------------------- */ + for (const b of r.bloecke) { + const abschnitt = el('section', 'e-block'); + abschnitt.append(el('h3', 'e-block__titel', b.name)); + abschnitt.append(el('p', 'e-block__satz', b.text)); + for (const pt of b.punkte) abschnitt.append(punktZeile(id, pt, r.erwartung)); + ziel.append(abschnitt); + } + } + + function punktZeile(personId, pt, erwartung) { + const z = el('article', 'e-punkt'); + if (pt.meins) z.dataset.stufe = pt.meins.stufe; + if (pt.meins?.verblasst) z.dataset.verblasst = 'ja'; + + const kopf = el('div', 'e-punkt__kopf'); + kopf.append(el('h4', 'e-punkt__titel', pt.titel)); + const erw = erwartung.find((e) => e.schluessel === pt.stufe); + if (erw) { + const m = el('span', 'e-punkt__erwartung', erw.name); + m.dataset.erwartung = pt.stufe; + m.title = erw.text; + kopf.append(m); + } + z.append(kopf); + z.append(el('p', 'e-punkt__text', pt.text)); + + /* Das Anlassfeld. Es steht IMMER da, nicht erst nach dem Klick: + Ein Feld, das aufklappt, kostet einen zweiten Blick, und genau + an der Stelle hört man auf zu schreiben. */ + const feld = el('textarea', 'e-punkt__anlass'); + feld.rows = 2; + feld.value = pt.meins?.anlass || ''; + feld.placeholder = 'Woran hast du das gemerkt? (bei „Da hakt es" nötig)'; + + z.append(knopfsatz(pt.meins?.stufe || null, async (stufe, knopf) => { + const reihe = knopf.parentElement; + for (const b of reihe.querySelectorAll('.e-wahl__knopf')) { + b.setAttribute('aria-pressed', String(b === knopf)); + } + z.dataset.stufe = stufe; + const a = await hole(`/workspace/api/entwicklung/person/${personId}/${pt.schluessel}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ stufe, anlass: feld.value }), + }); + if (!a.ok) { + const j = await a.json().catch(() => ({})); + melde(j.fehler || 'Ging nicht.'); + feld.focus(); + return; + } + melde(''); + /* Neu laden, sobald der erste Klick auf einem Punkt sitzt: Erst + danach darf der Stand des anderen überhaupt ausgeliefert + werden, und dann soll er auch sichtbar werden. */ + if (!pt.meins) await karte(personId); + })); + + z.append(feld); + + if (pt.andere?.length) { + for (const a of pt.andere) { + const nn = stufen.find((x) => x.schluessel === a.stufe); + const zeile = el('p', 'e-punkt__andere', + `${a.von_name}: ${nn?.name || a.stufe}${a.anlass ? ' – ' + a.anlass : ''}`); + zeile.dataset.stufe = a.stufe; + z.append(zeile); + } + } else if (pt.andere_offen) { + /* WARUM DA ETWAS STEHT UND NICHT NICHTS: „Noch niemand" und „du + darfst es noch nicht sehen" sähen sonst gleich aus — und das + erste ist eine Auskunft, das zweite eine Täuschung. */ + z.append(el('p', 'e-punkt__verdeckt', + `${pt.andere_offen} andere Einschätzung liegt vor – sichtbar, sobald du selbst gesetzt hast.`)); + } + + if (pt.meins) { + z.append(el('p', 'e-punkt__wann', + `von dir am ${datum(pt.meins.am)}${pt.meins.verblasst ? ' – älter als 90 Tage' : ''}`)); + } + return z; + } + + /* ---- Die eigenen sechs Fragen ---------------------------------------- */ + async function meins() { + const a = await hole('/workspace/api/entwicklung/mich'); + if (!a.ok) return; + const r = await a.json(); + stufen = stufen.length ? stufen : r.stufen; + $('meins-block').hidden = false; + $('meins-titel').textContent = r.block.name; + $('meins-satz').textContent = r.block.text; + const ziel = $('meins'); + ziel.textContent = ''; + ziel.setAttribute('aria-busy', 'false'); + for (const pt of r.punkte) { + const z = el('article', 'e-punkt e-punkt--meins'); + if (pt.meins) z.dataset.stufe = pt.meins.stufe; + z.append(el('h4', 'e-punkt__titel', pt.titel)); + z.append(el('p', 'e-punkt__text', pt.text)); + z.append(knopfsatz(pt.meins?.stufe || null, async (stufe, knopf) => { + for (const b of knopf.parentElement.querySelectorAll('.e-wahl__knopf')) { + b.setAttribute('aria-pressed', String(b === knopf)); + } + z.dataset.stufe = stufe; + const s = await hole(`/workspace/api/entwicklung/mich/${pt.schluessel}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ stufe }), + }); + if (!s.ok) melde('Ging nicht.'); + })); + ziel.append(z); + } + } + + /* ---- Start ------------------------------------------------------------ */ + (async () => { + try { + const a = await hole('/workspace/api/entwicklung/lage'); + if (!a.ok) { melde('Ging nicht.'); return; } + const lage = await a.json(); + stufen = lage.stufen; + + if (lage.leitung) { + $('personen-block').hidden = false; + const ziel = $('personen'); + ziel.textContent = ''; + ziel.setAttribute('aria-busy', 'false'); + if (!lage.personen.length) { + ziel.append(el('p', 'leise', + 'Noch niemand im Team. Zugänge legst du in „Personen & Zugänge" an.')); + } + for (const p of lage.personen) ziel.append(personKarte(p, lage)); + await ampel(); + } else { + /* Für einen Modi ist DIESE Seite die sechs Fragen. Der Titel + sagt das auch — sonst steht über sechs Fragen das Wort + „Entwicklung", und das liest sich wie eine Beurteilung. */ + $('titel').textContent = "Wie geht's dir?"; + $('unterzeile').textContent = + 'Sechs Fragen über dich. Niemand aus dem Team sieht deine Antworten.'; + } + + await meins(); + } catch { /* umgeleitet */ } + })(); +})(); diff --git a/workspace/assets/js/talente.js b/workspace/assets/js/talente.js new file mode 100644 index 00000000..30869478 --- /dev/null +++ b/workspace/assets/js/talente.js @@ -0,0 +1,214 @@ +/* ===================================================================== + Talente — der Trichter (11.09.2026) + + Fünf Stufen, und jede hat GENAU EINEN nächsten Schritt. Das ist der + ganze Unterschied zu einer Liste: Eine Liste wächst und wird nie + wieder angesehen; ein Trichter fragt bei jeder Karte „und jetzt?". + + DREI DINGE, DIE DIESE SEITE ABSICHTLICH TUT: + + 1. SIE ZEIGT DIE STANDZEIT. „Seit 41 Tagen beobachtet, nie + angesprochen" ist eine Auskunft über UNS, nicht über den + Kandidaten. Ohne sie wird der Trichter wieder zur Liste. + + 2. SIE TRENNT WARNZEICHEN VON MERKMALEN. Bei Moderatoren sagt das + Fehlverhalten mehr voraus als das Können — ein Warnzeichen neben + zwanzig Haken ginge unter. + + 3. SIE RECHNET NICHT. Keine Punktzahl, keine Eignung in Prozent. + Zwanzig Haken und ein Warnzeichen ergeben keine Zahl, sondern ein + Gespräch. + ===================================================================== */ +(() => { + const $ = (id) => document.getElementById(id); + const el = (t, k, text) => { + const n = document.createElement(t); + if (k) n.className = k; + if (text !== undefined) n.textContent = text; + return n; + }; + const melde = (t) => { const f = $('fehler'); if (f) { f.textContent = t; f.hidden = !t; } }; + + async function hole(pfad, optionen = {}) { + const a = await fetch(pfad, { credentials: 'same-origin', ...optionen }); + if (a.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); } + if (a.status === 404) { location.assign('/workspace/start.html'); throw new Error('unbekannt'); } + return a; + } + + let lage = null; + const offen = new Set(); // welche Karten aufgeklappt sind + + /* ---- Der Trichter oben ---------------------------------------------- */ + function trichter() { + const ziel = $('trichter'); + ziel.textContent = ''; + ziel.setAttribute('aria-busy', 'false'); + for (const s of lage.trichter) { + const k = el('div', 't-stufe'); + k.dataset.stufe = s.schluessel; + k.append(el('span', 't-stufe__zahl', String(s.anzahl))); + k.append(el('span', 't-stufe__name', s.name)); + if (s.liegt) { + /* LIEGENGEBLIEBEN IST UNSERE ZAHL, nicht die des Kandidaten. + Sie steht deshalb am Trichter und nicht als Mahnung. */ + const z = el('span', 't-stufe__liegt', `${s.liegt} liegt`); + z.title = 'So lange, dass es eine Auskunft über uns ist – nicht über sie.'; + k.append(z); + } + ziel.append(k); + } + } + + /* ---- Eine Karte ------------------------------------------------------ */ + function karte(k) { + const a = el('article', 't-karte'); + a.dataset.stufe = k.stufe; + if (k.liegt) a.dataset.liegt = 'ja'; + + const kopf = el('div', 't-karte__kopf'); + kopf.append(el('h2', 't-karte__titel', k.titel)); + kopf.append(el('span', 't-karte__stufe', k.stufe_name)); + kopf.append(el('span', 't-karte__zeit', + k.tage === null ? '' : `seit ${k.tage} ${k.tage === 1 ? 'Tag' : 'Tagen'}`)); + if (k.warnzeichen.length) { + const w = el('span', 't-karte__warn', `${k.warnzeichen.length} × hinsehen`); + w.title = 'Kein Ausschluss – ein Grund, länger hinzusehen.'; + kopf.append(w); + } + a.append(kopf); + + if (k.einsatz) a.append(el('p', 't-karte__wo', k.einsatz)); + if (k.text) a.append(el('p', 't-karte__text', k.text)); + + /* DER EINE NÄCHSTE SCHRITT. Nicht drei Möglichkeiten — einer. + Wer eine Karte öffnet, soll in zwei Sekunden wissen, was dran + ist, und nicht erst eine Entscheidung treffen müssen. */ + const schritt = el('div', 't-schritt'); + schritt.append(el('p', 't-schritt__satz', k.schritt)); + if (k.knopf && k.naechste) { + const b = el('button', 'schritt t-schritt__knopf', k.knopf); + b.type = 'button'; + b.addEventListener('click', async () => { + b.disabled = true; + const r = await hole(`/workspace/api/talente/${k.id}/stufe`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ stufe: k.naechste }), + }); + b.disabled = false; + if (!r.ok) { melde('Ging nicht.'); return; } + offen.add(k.id); + await laden(); + }); + schritt.append(b); + } + if (k.liegt) { + schritt.append(el('p', 't-schritt__liegt', + 'Das liegt länger, als es sollte. Entweder weiter – oder bewusst beenden.')); + } + a.append(schritt); + + /* ---- Merkmale, aufklappbar ------------------------------------------ + Zugeklappt steht dort eine Zahl, aufgeklappt die 21 Merkmale. + Immer offen wären es bei zehn Kandidaten 270 Kästchen auf einer + Seite — und dann klickt niemand mehr eines an. */ + const auf = offen.has(k.id); + const schalter = el('button', 't-karte__auf', + auf ? 'Merkmale zuklappen' + : `Merkmale ansehen (${k.merkmale.length} gesetzt${k.warnzeichen.length + ? `, ${k.warnzeichen.length} Warnzeichen` : ''})`); + schalter.type = 'button'; + schalter.setAttribute('aria-expanded', String(auf)); + schalter.addEventListener('click', () => { + if (offen.has(k.id)) offen.delete(k.id); else offen.add(k.id); + zeichne(); + }); + a.append(schalter); + + if (auf) { + for (const g of lage.gruppen) { + const block = el('section', 't-gruppe'); + block.append(el('h3', 't-gruppe__titel', g.name)); + block.append(el('p', 't-gruppe__satz', g.text)); + const reihe = el('div', 't-merkmale'); + for (const m of g.merkmale) reihe.append(merkmal(k, m, false)); + block.append(reihe); + a.append(block); + } + const warn = el('section', 't-gruppe t-gruppe--warn'); + warn.append(el('h3', 't-gruppe__titel', 'Hinsehen')); + warn.append(el('p', 't-gruppe__satz', + 'Keines davon ist ein Ausschluss. Bei Moderatoren sagt das Verhalten, ' + + 'das stört, mehr voraus als das, was jemand kann.')); + const reihe = el('div', 't-merkmale'); + for (const m of lage.warnzeichen) reihe.append(merkmal(k, m, true)); + warn.append(reihe); + a.append(warn); + } + + return a; + } + + function merkmal(k, m, istWarnung) { + const gesetzt = (istWarnung ? k.warnzeichen : k.merkmale).includes(m.schluessel); + const b = el('button', 't-merkmal'); + b.type = 'button'; + if (istWarnung) b.dataset.warnung = 'ja'; + b.setAttribute('aria-pressed', String(gesetzt)); + b.title = m.text; + b.append(el('span', 't-merkmal__haken', gesetzt ? '✓' : '+')); + b.append(el('span', 't-merkmal__titel', m.titel)); + b.addEventListener('click', async () => { + b.disabled = true; + const r = await hole(`/workspace/api/talente/${k.id}/merkmal/${m.schluessel}`, + { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: '{}' }); + b.disabled = false; + if (!r.ok) { melde('Ging nicht.'); return; } + await laden(); + }); + return b; + } + + /* ---- Zeichnen --------------------------------------------------------- */ + function zeichne() { + trichter(); + const ziel = $('liste'); + ziel.textContent = ''; + ziel.setAttribute('aria-busy', 'false'); + if (!lage.kandidaten.length) { + const leer = el('div', 't-leer'); + leer.append(el('p', null, + 'Noch niemand auf der Liste. Ein Talent entsteht als Eintrag im Brett ' + + '„Talente" – dort schreibst du auf, wem du etwas zutraust, und hier ' + + 'wird daraus ein Weg.')); + const a = el('a', 'schritt', 'Zum Brett „Talente"'); + a.href = 'bereich.html?b=talente'; + leer.append(a); + ziel.append(leer); + return; + } + /* LIEGENGEBLIEBENE ZUERST. Die Reihenfolge ist die eigentliche + Arbeitsanweisung dieser Seite: Was liegt, ist das, was dran ist. */ + const sortiert = [...lage.kandidaten].sort((a, b) => + (b.liegt ? 1 : 0) - (a.liegt ? 1 : 0) || (b.tage || 0) - (a.tage || 0)); + for (const k of sortiert) ziel.append(karte(k)); + + const fuss = el('p', 't-fuss'); + fuss.append('Ein neues Talent legst du im Brett an – '); + const a = el('a', null, 'Talente-Brett öffnen'); + a.href = 'bereich.html?b=talente'; + fuss.append(a); + fuss.append('. Von dort landet es hier automatisch auf „Aufgefallen".'); + ziel.append(fuss); + } + + async function laden() { + const a = await hole('/workspace/api/talente/lage'); + if (!a.ok) { melde('Ging nicht.'); return; } + lage = await a.json(); + zeichne(); + } + + (async () => { try { await laden(); } catch { /* umgeleitet */ } })(); +})(); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 3de2a79d..0c225977 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -12,19 +12,19 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - + + + + + + - + @@ -252,10 +252,10 @@ - - - - - + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index 66d36b92..8dd34020 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -147,10 +147,10 @@ - - - - - + + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index 4e749710..0835b058 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -181,14 +181,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index 3a32296b..71078cb1 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -89,11 +89,11 @@ - - - - - - + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html index 784aba32..b6fe9d7e 100644 --- a/workspace/chat.html +++ b/workspace/chat.html @@ -12,26 +12,26 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -341,10 +341,10 @@ - - - - - + + + + + diff --git a/workspace/content.html b/workspace/content.html index ff2ca708..4939b3f5 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -179,12 +179,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/crew-index.html b/workspace/crew-index.html index 4229a849..7c94cd78 100644 --- a/workspace/crew-index.html +++ b/workspace/crew-index.html @@ -36,15 +36,15 @@ - - - + + + - + @@ -319,6 +319,6 @@ - + diff --git a/workspace/crew.webmanifest b/workspace/crew.webmanifest index 18905119..90bc5832 100644 --- a/workspace/crew.webmanifest +++ b/workspace/crew.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/crew-192.png?v=202609111836", + "src": "/assets/img/app-symbole/crew-192.png?v=202609112217", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-512.png?v=202609111836", + "src": "/assets/img/app-symbole/crew-512.png?v=202609112217", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609111836", + "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609112217", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609111836", + "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609112217", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/dateien.html b/workspace/dateien.html index bb508d36..04fc40ab 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -108,10 +108,10 @@ - - - - - + + + + + diff --git a/workspace/entwicklung.html b/workspace/entwicklung.html new file mode 100644 index 00000000..4bc4dbd6 --- /dev/null +++ b/workspace/entwicklung.html @@ -0,0 +1,87 @@ + + + + + +Entwicklung · Spicy & Dogi + + + + + + + + + + + + + + + +

+ +
+ +

Team Dogi · Entwicklung

+
+ … + +
+
+ +
+ +
+
+

Entwicklung & Nachwuchs

+

Entwicklung

+

+ Anklicken statt tippen. Keine Noten, keine Punktzahl – ein Gesprächsanlass. +

+
+
+ + + + + + + + + + + + + +
+ + + + + + + diff --git a/workspace/index.html b/workspace/index.html index 47243a68..bba28c19 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -30,9 +30,9 @@ - - - + + + @@ -401,6 +401,6 @@ - + diff --git a/workspace/kalender.html b/workspace/kalender.html index 629326c4..f3980e0d 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -377,11 +377,11 @@ - - - - - - + + + + + + diff --git a/workspace/leistung.html b/workspace/leistung.html index f2be59fc..ec8ce70f 100644 --- a/workspace/leistung.html +++ b/workspace/leistung.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -382,10 +382,10 @@ - - - - - + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index 9283886d..8f541b96 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -423,10 +423,10 @@ - - - - - + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index 32bb8376..1ef688bb 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -214,11 +214,11 @@ - - - - - - + + + + + + diff --git a/workspace/rechte.html b/workspace/rechte.html index da1987ee..dfa94156 100644 --- a/workspace/rechte.html +++ b/workspace/rechte.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -117,9 +117,9 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - - - - + + + + diff --git a/workspace/report.html b/workspace/report.html index 5c800e9d..d75d8449 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -106,11 +106,11 @@ - - - - - - + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index a3ff5c07..8a876732 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -173,10 +173,10 @@ - - - - - + + + + + diff --git a/workspace/start.html b/workspace/start.html index 3293a099..a9565ad0 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -12,21 +12,21 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - + + - + @@ -674,10 +674,10 @@

- - - - - + + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index 1e1cee6a..d50992b7 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -85,11 +85,11 @@ - - - - - - + + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index 46b08c05..d3352939 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -126,10 +126,10 @@ - - - - - + + + + + diff --git a/workspace/talente.html b/workspace/talente.html new file mode 100644 index 00000000..25338a8c --- /dev/null +++ b/workspace/talente.html @@ -0,0 +1,80 @@ + + + + + +Talente · Spicy & Dogi + + + + + + + + + + + + + + + + + +
+ +

Team Dogi · Talente

+
+ … + +
+
+ +
+ +
+
+

Entwicklung & Nachwuchs

+

Talente

+

+ Zuschauer, die ins Team passen könnten. Ein Trichter, keine Liste – + jede Stufe hat genau einen nächsten Schritt. +

+
+
+ + + + + +
+ +
+

wird geladen …

+
+ +
+ + + + + + + diff --git a/workspace/team.html b/workspace/team.html index ca442f8b..8930646f 100644 --- a/workspace/team.html +++ b/workspace/team.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + @@ -110,10 +110,10 @@ - - - - - + + + + + diff --git a/workspace/teamlage.html b/workspace/teamlage.html index d036c822..a331a11b 100644 --- a/workspace/teamlage.html +++ b/workspace/teamlage.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + - - - - + + + + diff --git a/workspace/treff-moderation.html b/workspace/treff-moderation.html index dcb781bb..c87def74 100644 --- a/workspace/treff-moderation.html +++ b/workspace/treff-moderation.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -110,9 +110,9 @@ - - - - + + + + diff --git a/workspace/treff-regeln.html b/workspace/treff-regeln.html index d8b7841e..65d54150 100644 --- a/workspace/treff-regeln.html +++ b/workspace/treff-regeln.html @@ -7,18 +7,18 @@ - - - - - - - + + + + + + + - + @@ -190,9 +190,9 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - - - - + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index 933c63b1..87645792 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -12,22 +12,22 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -149,11 +149,11 @@ - + - - - - + + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 5c5180d2..a8198ea8 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -190,10 +190,10 @@ - - - - - + + + + +