diff --git a/server/pruef-nachwuchs.mjs b/server/pruef-nachwuchs.mjs index 606cba9b..075ce039 100644 --- a/server/pruef-nachwuchs.mjs +++ b/server/pruef-nachwuchs.mjs @@ -20,7 +20,7 @@ Aufrufen mit: node server/pruef-nachwuchs.mjs ===================================================================== */ -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { request as httpAnfrage } from "node:http"; @@ -454,6 +454,199 @@ melde("=== 8. Standzeit ==="); `einen Tag vor der Frist noch nicht (${r2.kandidaten[0].tage} Tage)`); } +/* ======================================================================= + 9. DER VORLAGENTEXT + + Er ist der Unterschied zwischen "Ansprechen" als Aufgabe und + "Ansprechen" als erledigter Sache. Geprueft wird deshalb nicht, DASS + ein Text kommt, sondern dass er aus den angeklickten Merkmalen + gebaut ist -- sonst waere es eine Floskel mit eingesetztem Namen. + ======================================================================= */ +melde(""); +melde("=== 9. Der Vorlagentext ==="); +{ + const zwei = [tal.TALENT_ALLE[0], tal.TALENT_ALLE[1]]; + const text = tal.ansprechtext("Kessi aus dem Chat", zwei.map((m) => m.schluessel)); + + ok(text.startsWith("Hey Kessi,"), + "die Anrede nimmt nur den ersten Namensteil, nicht den ganzen Eintragstitel"); + ok(zwei.every((m) => text.includes(m.du)), + "beide angeklickten Merkmale stehen woertlich im Text"); + ok(/kein Druck|nein/.test(text), + "und der Ausstieg steht drin -- ein Angebot ohne Nein ist keines"); + + /* GEGENPROBE: ohne ein einziges Merkmal darf KEINES drinstehen -- + sonst baut die Funktion sich etwas zusammen, das niemand beobachtet + hat. Das waere die schlimmste Sorte Vorlage. */ + const leer = tal.ansprechtext("Kessi", []); + ok(!tal.TALENT_ALLE.some((m) => leer.includes(m.du)), + "ohne angeklickte Merkmale steht auch keines im Text"); + ok(leer.length > 100, `aber ein vollstaendiger Text bleibt es (${leer.length} Zeichen)`); + ok(leer !== text, "und er ist ein anderer als der mit Merkmalen"); + + /* HAUSREGEL: In Text, den ein Mensch liest, stehen echte Umlaute. + "Haettest du Lust" war genau der Fehler, den diese Zeile findet. */ + const beide = text + " " + leer; + ok(!/\b(fuer|koenn|haett|muess|ueber|waer|moecht)/i.test(beide), + "keine ASCII-Ersatzumlaute in einem Text, den ein Mensch liest"); + ok(/\b(fuer)/i.test("das ist fuer dich"), "und der Sucher findet so einen auch wirklich"); + + /* UND ER KOMMT UEBER DIE LEITUNG AN, nicht nur aus dem Modul. */ + const lage = json(await roh("/workspace/api/talente/lage", HAUS, { cookie: kDogi })); + const k = (lage.kandidaten || [])[0]; + ok(typeof k?.ansprechtext === "string" && k.ansprechtext.length > 100, + "die Karte bringt ihren Text mit"); + const gesetzt = (k?.merkmale || []).map((s) => tal.TALENT_NACH_SCHLUESSEL.get(s)?.du).filter(Boolean); + ok(gesetzt.length > 0 && gesetzt.every((d) => k.ansprechtext.includes(d)), + `und zwar aus ihren ${gesetzt.length} gesetzten Merkmalen`); +} + +/* ======================================================================= + 10. DIE EIGENE KARTE + + Sie ist die Gegenprobe zur ganzen Kategorie: Ueber Menschen wird hier + aufgeschrieben, und wer beschrieben wird, darf es lesen. Aber erst, + wenn ALLE gesetzt haben -- sonst waere die erste Einschaetzung eine + Vorgabe fuer die zweite -- und ohne Namen und ohne Anlasstext. + ======================================================================= */ +melde(""); +melde("=== 10. Die eigene Karte ==="); +{ + const meine = json(await roh("/workspace/api/entwicklung/meine-karte", CREW, { cookie: kModi })); + ok(meine.hat_karte === true, "der Modi hat eine eigene Karte"); + ok(meine.beurteiler === 2, + `zwei Augenpaare urteilen ueber ihn (${meine.beurteiler}) -- er selbst zaehlt nicht mit`); + + const punkte = (meine.bloecke || []).flatMap((b) => b.punkte || []); + ok(punkte.length > 0, `${punkte.length} Punkte in seiner Karte`); + + /* Aus Abschnitt 4: DIESEN Punkt haben beide gesetzt (laeuft/waechst). */ + const beide = punkte.find((p) => p.schluessel === kat.ENTWICKLUNG_FREMD[0].schluessel); + ok(beide && beide.offen === false, "ein Punkt, den beide gesetzt haben, ist offen"); + ok(beide?.antworten?.length === 2, + `mit beiden Antworten nebeneinander (${(beide?.antworten || []).map((a) => a.name).join(" / ")})`); + ok(beide?.einig === false, "und dem ehrlichen Hinweis, dass sie sich uneinig sind"); + + /* GEGENPROBE 1 -- und ohne sie beweist die Zeile darueber nichts: + Einen Punkt, den erst EINER gesetzt hat, bekommt er NICHT. */ + const einer = punkte.find((p) => p.schluessel === kat.ENTWICKLUNG_FREMD[1].schluessel); + ok(einer && einer.offen === true, + "einen Punkt, den erst einer gesetzt hat, sieht er noch nicht"); + ok(einer && einer.antworten === undefined, + "und die Antwort des einen reist dabei auch nicht heimlich mit"); + + /* GEGENPROBE 2: sobald die zweite dazukommt, oeffnet er sich. */ + const nach = await roh(`/workspace/api/entwicklung/person/${idModi}/${kat.ENTWICKLUNG_FREMD[1].schluessel}`, + CREW, { cookie: kHand }, { stufe: "hakt", anlass: "Zweimal zu spaet zur Schicht." }, "PUT"); + ok(nach.code === 200, `die rechte Hand setzt denselben Punkt (${nach.code})`); + const meine2 = await roh("/workspace/api/entwicklung/meine-karte", CREW, { cookie: kModi }); + const jetztOffen = (json(meine2).bloecke || []).flatMap((b) => b.punkte || []) + .find((p) => p.schluessel === kat.ENTWICKLUNG_FREMD[1].schluessel); + ok(jetztOffen?.offen === false && jetztOffen?.antworten?.length >= 1, + "danach steht er in seiner Karte"); + + /* KEINE NAMEN UND KEIN ANLASS. Beides steht in der Karte, die + DogFather sieht -- und beides waere hier ein Vertrauensbruch: + "wer hat das ueber mich gesagt" macht aus einer Entwicklung eine + Abrechnung. */ + const roh2 = meine2.text; + ok(!/Filipe|Rieke/.test(roh2), "kein Name eines Beurteilers in der ganzen Antwort"); + ok(!/zu spaet zur Schicht|Am Samstag/.test(roh2), "und kein einziger Anlasstext"); + ok(/hakt|laeuft|waechst/.test(roh2), + "die Antworten selbst kommen aber an -- sonst waere die Zeile darueber trivial"); + + /* Block 5 gehoert der Person -- er steht in IHRER Selbstauskunft, + nicht in der Fremdkarte. Waere er hier, stuenden Antworten + anderer auf Fragen, die nur sie sich stellt. */ + const selbst = kat.ENTWICKLUNG_SELBST.map((p) => p.schluessel); + ok(!punkte.some((p) => selbst.includes(p.schluessel)), + "Block 5 taucht in der eigenen Karte nicht als Fremdurteil auf"); + + /* Wer gar nicht beurteilt wird, bekommt auch keine leere Karte + vorgesetzt -- eine Karte ohne Inhalt liest sich wie ein Versaeumnis. */ + /* UND DIE RECHTE HAND HAT AUCH EINE. Ueber sie urteilt nur DogFather + -- sie selbst zaehlt nicht mit, sonst waere ihre Karte erst dann + vollstaendig, wenn sie sich selbst eingeschaetzt hat. */ + const ihre = json(await roh("/workspace/api/entwicklung/meine-karte", CREW, { cookie: kHand })); + ok(ihre.hat_karte === true, `auch die rechte Hand hat eine eigene Karte (${JSON.stringify(ihre).slice(0, 90)})`); + ok(ihre.beurteiler === 1, `ueber sie urteilt genau einer (${ihre.beurteiler})`); + + const chef = json(await roh("/workspace/api/entwicklung/meine-karte", HAUS, { cookie: kDogi })); + ok(chef.hat_karte === false, "DogFather selbst hat keine -- ueber ihn urteilt hier niemand"); +} + +/* ======================================================================= + 11. AUS DER PROBE WIRD EIN ZUGANG + + Der letzte Schritt des Trichters ist kein Schritt, sondern eine + Entscheidung. Bis heute stand dort ein Satz ("Zugang in Personen & + Zugaenge anlegen"), und wer ihn vergass, hatte eine Karte auf "Im + Team" und keinen Menschen darin. + + GEPRUEFT WIRD VOR ALLEM EINES: dass es dafuer keinen ZWEITEN Weg + gibt. Ein Zugang entsteht im ganzen Haus an genau einer Stelle. + ======================================================================= */ +melde(""); +melde("=== 11. Probe → Zugang ==="); +{ + const letzte = tal.TALENT_STUFEN.at(-1); + ok(!/Personen/.test(letzte.schritt || ""), + "die letzte Stufe schickt niemanden mehr von Hand in die Personenverwaltung"); + + /* EIN EINZIGER WEG, und es ist der vorhandene. */ + const js = readFileSync(new URL("../workspace/assets/js/talente.js", import.meta.url), "utf8"); + /* OHNE DIE KOMMENTARE gezaehlt: Der Weg wird daneben auch erklaert, + und eine Erklaerung ist kein zweiter Weg. */ + const jsCode = js.replace(/\/\*[\s\S]*?\*\//g, ""); + const wege = jsCode.split("/workspace/api/verwaltung/personen").length - 1; + ok(wege === 1, `talente.js kennt genau einen Weg zum Anlegen (${wege})`); + ok(js.split("/workspace/api/verwaltung/personen").length - 1 > wege, + "und der Kommentar daneben nennt ihn beim Namen -- sonst zaehlt die Zeile darueber nichts weg"); + const eigen = readFileSync(new URL("../server/workspace-entwicklung.js", import.meta.url), "utf8"); + ok(!/personAnlegen|INSERT INTO personen/.test(eigen), + "und der Talente-Server legt selbst keinen einzigen Zugang an"); + + /* DIE RECHTE HAND SIEHT DEN TRICHTER, VERGIBT ABER KEINE ZUGAENGE. */ + const hand = await roh("/workspace/api/verwaltung/personen", CREW, { cookie: kHand }, + { name: "Heimlich", rolle: "modi" }); + ok(hand.code === 404, `die rechte Hand kommt am Anlegen nicht vorbei (${hand.code})`); + const einModi = await roh("/workspace/api/verwaltung/personen", CREW, { cookie: kModi }, + { name: "Heimlich", rolle: "modi" }); + ok(einModi.code === 404, `ein Modi erst recht nicht (${einModi.code})`); + + /* GEGENPROBE: DogFather kann es -- sonst waere "404" oben auch dann + gruen, wenn der Weg gar nicht existiert. */ + const neu = await roh("/workspace/api/verwaltung/personen", HAUS, { cookie: kDogi }, + { name: "Kessi", rolle: "modi" }); + ok(neu.code === 201, `DogFather legt den Zugang an (${neu.code})`); + const person = json(neu); + ok(person.rolle === "modi", `und zwar als Modi (${person.rolle})`); + ok(typeof person.code === "string" && person.code.length > 8, + "der Zugangscode kommt genau einmal zurueck"); + + /* UND DANACH IST ER WEG. Nirgends abrufbar -- auch nicht fuer den, + der ihn angelegt hat. */ + const liste = await roh("/workspace/api/verwaltung/personen", HAUS, { cookie: kDogi }); + ok(liste.code === 200 && !liste.text.includes(person.code), + "danach steht er in keiner Liste mehr"); + + /* Erst jetzt wandert die Karte -- in derselben Reihenfolge wie in der + Oberflaeche: erst der Mensch, dann der Haken. */ + const stufe = await roh(`/workspace/api/talente/${idTalent}/stufe`, HAUS, + { cookie: kDogi }, { stufe: "imteam" }, "PUT"); + ok(stufe.code === 200, `und die Karte geht auf "Im Team" (${stufe.code})`); + const lage = json(await roh("/workspace/api/talente/lage", HAUS, { cookie: kDogi })); + const k = (lage.kandidaten || []).find((x) => x.id === idTalent); + ok(k?.stufe === "imteam" && k?.knopf === null, + "dahinter kommt kein weiterer Knopf -- der Trichter ist zu Ende"); + + /* Der neue Mensch ist wirklich da, mit allem, was daran haengt. */ + const dd = new DatabaseSync(process.env.WORKSPACE_DB); + const z = dd.prepare("SELECT rolle, aktiv FROM personen WHERE name = 'Kessi'").get(); + dd.close(); + ok(z?.rolle === "modi" && z?.aktiv === 1, "und er steht als aktiver Modi in der Datenbank"); +} + /* ------------------------------------------------------------------- */ melde(""); melde(`${geprueft} Pruefungen, ${fehler} Fehler`); diff --git a/server/pruef-neue-seiten.mjs b/server/pruef-neue-seiten.mjs index c19ad9da..15d665a9 100644 --- a/server/pruef-neue-seiten.mjs +++ b/server/pruef-neue-seiten.mjs @@ -121,19 +121,72 @@ await roh("/workspace/api/treff/massnahme", { cookie: kDogi }, /* ETWAS AUF DEN KATALOGSEITEN. Ohne eine Person im Team ist die Entwicklungsseite leer, ohne einen Kandidaten der Trichter -- und - eine leere Seite erfuellt jede Bedingung, ohne etwas zu zeigen. */ + eine leere Seite erfuellt jede Bedingung, ohne etwas zu zeigen. + + UND ZWAR AUF MEHREREN STUFEN. Der Trichter zeigt je Stufe einen + ANDEREN naechsten Schritt; steht nur ein Kandidat auf der ersten, + bleiben die Knoepfe der anderen vier ungesehen. Genau dort sitzen + seit dem 11.09. die zwei neuen: der Vorlagentext auf "Beobachtet" + und "Ins Team holen · Zugang anlegen" auf "Probe". */ +/* EINMAL ANKLOPFEN, DAMIT ES DIE TABELLEN GIBT. Sie entstehen beim + ersten angemeldeten Aufruf des Bereichs, nicht beim Start -- ein + INSERT davor liefe gegen eine Tabelle, die es noch nicht gibt. */ +await roh("/workspace/api/talente/lage", { cookie: kDogi }); +const kat = await import("./workspace-entwicklungs-punkte.js"); +const talKat = await import("./workspace-talent-punkte.js"); const dd = new DatabaseSync(process.env.WORKSPACE_DB); +let idHand = null; { - const salt = randomBytes(16).toString("hex"); - const hash = scryptSync("CODE-MODI-0001", salt, 64, - { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); - dd.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)" - + " VALUES ('Frida','modi',?,?,?,?,1,?)") - .run(hash, salt, 32768, - createHmac("sha256", schluessel).update("CODE-MODI-0001").digest("hex"), jetzt); - dd.prepare("INSERT INTO eintraege (bereich, art, titel, text, datum, erstellt, erstellt_von, einsatz)" - + " VALUES ('talente','ruhig','Kessi aus dem Chat','Faellt seit Wochen auf.',?,?,?,?)") - .run(jetzt.slice(0, 10), jetzt, idDogi, "Im Stream am Dienstag"); + const person = (name, rolle, code) => { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, + { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + dd.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, + createHmac("sha256", schluessel).update(code).digest("hex"), jetzt); + return dd.prepare("SELECT last_insert_rowid() AS id").get().id; + }; + person("Frida", "modi", "CODE-MODI-0001"); + idHand = person("Rieke", "hand", "CODE-HAND-0001"); + + const talent = (titel, text) => { + dd.prepare("INSERT INTO eintraege (bereich, art, titel, text, datum, erstellt, erstellt_von, einsatz)" + + " VALUES ('talente','ruhig',?,?,?,?,?,?)") + .run(titel, text, jetzt.slice(0, 10), jetzt, idDogi, "Im Stream am Dienstag"); + return dd.prepare("SELECT last_insert_rowid() AS id").get().id; + }; + talent("Kessi aus dem Chat", "Faellt seit Wochen auf."); + const idBeobachtet = talent("Malin aus dem Chat", "Haelt den Chat ruhig."); + const idProbe = talent("Tino aus dem Chat", "Laeuft seit vier Wochen mit."); + + const stufe = dd.prepare( + "INSERT INTO talent_stufe (eintrag_id, stufe, seit, von_id) VALUES (?,?,?,?)"); + stufe.run(idBeobachtet, "beobachtet", jetzt, idDogi); + stufe.run(idProbe, "probe", jetzt, idDogi); + + /* MERKMALE fuer den Vorlagentext -- er wird daraus gebaut. Ohne sie + stuende dort der Satz fuer "noch nichts beobachtet", und die neue + Gestaltung waere zwar sichtbar, aber nicht in ihrer laengsten + Fassung. Gemessen werden soll die laengste. */ + const merk = dd.prepare( + "INSERT INTO talent_merkmal (eintrag_id, schluessel, von_id, am) VALUES (?,?,?,?)"); + for (const m of talKat.TALENT_ALLE.slice(0, 3)) { + merk.run(idBeobachtet, m.schluessel, idDogi, jetzt); + } + + /* DIE EIGENE KARTE DER RECHTEN HAND. Sie oeffnet sich erst, wenn + jeder Beurteiler gesetzt hat -- ueber sie urteilt nur DogFather, + also reicht eine Zeile je Punkt. Sechs gesetzte und der Rest offen: + So stehen beide Zustaende nebeneinander auf dem Bildschirm. */ + const stand = dd.prepare( + "INSERT INTO entwicklung_stand (person_id, schluessel, von_id, stufe, anlass, am)" + + " VALUES (?,?,?,?,?,?)"); + const stufen = ["laeuft", "waechst", "laeuft", "hakt", "laeuft", "waechst"]; + kat.ENTWICKLUNG_FREMD.slice(0, 6).forEach((p, i) => { + stand.run(idHand, p.schluessel, idDogi, stufen[i], + stufen[i] === "hakt" ? "Zweimal zu spaet zur Schicht." : null, jetzt); + }); } dd.close(); @@ -299,7 +352,16 @@ const UEBERBREIT = `(() => { return { schiebt, raus }; })()`; -async function ansehen(name, pfad, code, breite, hoehe) { +/* MIT EINEM FERTIGEN KEKS STATT ueber die Anmeldemaske (11.09.2026). + + Die rechte Hand meldet sich an der CREW-WAND an, und die haengt am + Host-Kopf -- den kann ein Browser bei einer Navigation nicht setzen. + Ueber die Maske auf 127.0.0.1 kommt sie also gar nicht herein. + + Das ist kein Umgehen einer Sicherung: Angemeldet wird ueber denselben + Weg wie sonst (/api/anmelden), nur die Maske bleibt aussen vor. Was + die Seite danach zeigt, entscheidet weiterhin der Server. */ +async function ansehen(name, pfad, code, breite, hoehe, keks = null) { const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe } }); const seite = await kontext.newPage(); const konsole = []; @@ -308,14 +370,39 @@ async function ansehen(name, pfad, code, breite, hoehe) { }); seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message)); - await seite.goto(`${BASIS}/workspace/`, { waitUntil: "domcontentloaded" }); - await seite.click(`.rolle[data-rolle="admin"]`); - await seite.fill("#code", code); - await seite.click("button[type=submit]"); - await seite.waitForURL("**/start.html", { timeout: 15000 }); + if (keks) { + await kontext.addCookies(keks.split("; ").filter(Boolean).map((teil) => { + const i = teil.indexOf("="); + return { name: teil.slice(0, i), value: teil.slice(i + 1), domain: "127.0.0.1", path: "/" }; + })); + } else { + await seite.goto(`${BASIS}/workspace/`, { waitUntil: "domcontentloaded" }); + await seite.click(`.rolle[data-rolle="admin"]`); + await seite.fill("#code", code); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 15000 }); + } await seite.goto(`${BASIS}/workspace/${pfad}`, { waitUntil: "networkidle" }); - await seite.waitForTimeout(900); + /* WARTEN, BIS SICH NICHTS MEHR AENDERT -- NICHT 900 MILLISEKUNDEN. + + Gemessen am 11.09.2026: Die eigene Karte der rechten Hand stand + nachweislich auf dem Bildschirm (versteckt: false, Antwort 200), + aber der Text war schon vorher gelesen. Die Pruefung meldete + "steht nicht da" fuer etwas, das dastand -- der schlimmste + Fehlalarm, weil man danach am richtigen Code sucht. + + Eine groessere Zahl waere derselbe Fehler mit einer anderen Zahl. + Die Seite laedt ihre Abschnitte nacheinander; wie lange das + dauert, haengt an ihrer Anzahl. Deshalb eine Regel statt einer + Rechnung: gelesen wird, wenn der Text dreimal hintereinander + derselbe war. Die Frist darueber ist nur die Notbremse. */ + await seite.waitForFunction(() => { + const t = document.querySelector("main")?.innerText || ""; + if (window.__standStill === t) { window.__ruhig = (window.__ruhig || 0) + 1; } + else { window.__standStill = t; window.__ruhig = 0; } + return (window.__ruhig || 0) >= 3; + }, null, { timeout: 12000, polling: 200 }).catch(() => {}); const text = await seite.evaluate(() => (document.querySelector("main")?.innerText || "").trim()); const punkte = await seite.evaluate(() => (document.querySelector("main")?.innerText || "") @@ -323,12 +410,32 @@ async function ansehen(name, pfad, code, breite, hoehe) { const kontrast = await seite.evaluate(KONTRAST); const ueberlapp = await seite.evaluate(UEBERLAPP); const breit = await seite.evaluate(UEBERBREIT); + /* DIE EIGENE KARTE IST DER EINZIGE ABSCHNITT, DER SICH SELBST + AUFDECKT. Bleibt er zu, sieht die Seite vollkommen normal aus -- + deshalb wird hier nicht nur gelesen, was dasteht, sondern gefragt, + was der Server dazu sagt. */ + const karte = await seite.evaluate(async () => { + const e = document.getElementById("karte-block"); + if (!e) return { da: false }; + let antwort = null; + try { + const a = await fetch("/workspace/api/entwicklung/meine-karte", { credentials: "same-origin" }); + antwort = { status: a.status, hat: (await a.json()).hat_karte }; + } catch (f) { antwort = { status: -1, fehler: String(f) }; } + return { da: true, versteckt: e.hidden, antwort }; + }); await seite.screenshot({ path: join(BILDER, `${name}-${breite}.png`), fullPage: true }); await kontext.close(); - return { text, punkte, kontrast, ueberlapp, breit, konsole }; + return { text, punkte, kontrast, ueberlapp, breit, konsole, karte }; } -for (const [name, pfad] of [ +const gesehen = {}; // was auf jedem Bildschirm tatsaechlich stand + +const anm3 = await roh("/workspace/api/anmelden", {}, { rolle: "hand", code: "CODE-HAND-0001" }); +const kHand = [].concat(anm3.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; "); +ok(!!kHand, "die rechte Hand ist angemeldet -- ohne sie bliebe ihre eigene Karte ungesehen"); + +for (const [name, pfad, keks] of [ ["regeln", "treff-regeln.html"], ["rechte", "rechte.html"], ["moderation", "treff-moderation.html"], @@ -338,11 +445,17 @@ for (const [name, pfad] of [ findet andere Fehler. */ ["entwicklung", "entwicklung.html"], ["talente", "talente.html"], + /* UND EINMAL DIESELBE SEITE MIT ANDEREN AUGEN. "Deine Karte" gibt es + nur fuer die, ueber die geurteilt wird -- DogFather sieht sie nie. + Ohne diese Zeile waere der ganze Abschnitt nie auf einem Bildschirm + gewesen. */ + ["entwicklung-hand", "entwicklung.html", kHand], ]) { melde(""); melde(`=== ${pfad} ===`); for (const [breite, hoehe] of [[1440, 1000], [390, 844]]) { - const r = await ansehen(name, pfad, "CODE-DOGI-0001", breite, hoehe); + const r = await ansehen(name, pfad, "CODE-DOGI-0001", breite, hoehe, keks); + if (breite === 1440) { gesehen[name] = r.text; gesehen[name + ":karte"] = r.karte; } const wo = `${breite} px`; ok(r.text.length > 400, `${wo}: die Seite ist gefüllt (${r.text.length} Zeichen)`); @@ -374,6 +487,63 @@ for (const [name, pfad] of [ } } +/* ======================================================================= + WAR DAS NEUE UEBERHAUPT AUF DEM BILDSCHIRM? + + Die Messungen darueber sagen "nichts liegt uebereinander" und "alles + lesbar" -- und beides ist auch dann wahr, wenn das Gesuchte gar nicht + da war. Eine leere Seite erfuellt jede dieser Bedingungen. + + Deshalb hier die Gegenfrage, und zwar in beide Richtungen: Das Neue + MUSS dort stehen, wo es hingehoert, und darf NICHT dort stehen, wo + es nicht hingehoert. Nur die zweite Haelfte beweist, dass die erste + etwas heisst. + ======================================================================= */ +melde(""); +melde("=== Stand das Neue auch wirklich da? ==="); +{ + const t = gesehen.talente || ""; + ok(/Vorlagentext/.test(t), + "Talente: der Knopf fuer den Vorlagentext stand auf der Stufe „Beobachtet“"); + ok(/Ins Team holen/.test(t), + "Talente: „Ins Team holen · Zugang anlegen“ stand auf der Stufe „Probe“"); + /* GEGENPROBE: Der Vorlagentext gehoert auf GENAU EINE Stufe. Stuende + er auf jeder, waere er auf vier von fuenf eine Ablenkung -- und die + Zeile darueber waere trotzdem gruen. */ + /* ZEILENWEISE gezaehlt und nicht als Wort: "Der Vorlagentext liegt + bereit" ist der Satz des naechsten Schritts, der Knopf steht + allein auf seiner Zeile. Als Wort gezaehlt waeren es zwei, und die + Zeile haette einen Fehler gemeldet, den es nicht gibt. */ + const knoepfe = (wort) => t.split("\n").filter((z) => z.trim() === wort).length; + ok(knoepfe("Vorlagentext") === 1, + `und zwar genau einmal, nicht bei jedem Kandidaten (${knoepfe("Vorlagentext")})`); + ok(t.split("Ins Team holen").length - 1 === 1, + `der Zugangsknopf ebenso (${t.split("Ins Team holen").length - 1})`); + + const hand = gesehen["entwicklung-hand"] || ""; + const dogi = gesehen.entwicklung || ""; + /* WENN ES SCHIEFGEHT, SOLL DIE MELDUNG SAGEN, WAS STATTDESSEN DA WAR. + "nicht gefunden" allein laesst offen, ob die Karte fehlt oder die + ganze Seite eine andere war. */ + const obenLinks = (hand.split("\n").map((z) => z.trim()).filter(Boolean)[0] || "(leer)"); + /* OHNE RUECKSICHT AUF GROSS UND KLEIN: innerText liefert, was der + Browser ZEIGT -- und .e-abschnitt ist in Grossbuchstaben gesetzt. + Im Quelltext steht "Deine Karte", auf dem Bildschirm "DEINE + KARTE". Eine Suche mit Ruecksicht darauf meldet "steht nicht da" + fuer etwas, das dasteht. */ + ok(/Deine Karte/i.test(hand), + `Entwicklung: die rechte Hand sieht ihre eigene Karte (${JSON.stringify(gesehen["entwicklung-hand:karte"])} / oben: ${obenLinks})`); + ok(!/Deine Karte/i.test(dogi), + "und DogFather sieht sie nicht -- ueber ihn urteilt hier niemand"); + ok(hand !== dogi && hand.length > 400, + `zwei verschiedene Bildschirme (${dogi.length} / ${hand.length} Zeichen) -- der Keks hat also gewirkt`); + /* UND KEIN NAME EINES BEURTEILERS. Was der Server nicht ausliefert, + kann die Seite nicht zeigen -- aber genau das ist die Behauptung, + und Behauptungen gehoeren gemessen. */ + ok(!/Filipe/.test(hand), + "und in ihrer Karte steht kein Name dessen, der geurteilt hat"); +} + await browser.close(); melde(""); melde(`Bilder liegen in ${BILDER}`); diff --git a/server/pruef-treff.mjs b/server/pruef-treff.mjs index 9493e83d..607c124e 100644 --- a/server/pruef-treff.mjs +++ b/server/pruef-treff.mjs @@ -37,7 +37,7 @@ dann an die falsche Wand und scheiterte, ohne dass man saehe warum. Dieselbe Falle wie am 10.09. bei den Kategorie-Kanaelen. ===================================================================== */ -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { request as httpAnfrage } from "node:http"; @@ -618,6 +618,66 @@ melde("=== 6. Schreiben ==="); ok(a.code !== 200 && a.code !== 201, `aber nicht in ein Brett des Hauses (${a.code})`); } +/* ======================================================================= + 7. WEM EIN EINTRAG GILT, SAGT DER SERVER -- NICHT DIE OBERFLAECHE + + Filipe, 11.09.2026: "auf dieser seite soll nichts stehen von agentur, + diese seite hier ist nur fuer mich meine modis und meine community." + + Er hatte recht, und der Fehler war ein fest eingetragenes Wort: In + bereich.js stand woertlich "Agentur" -- richtig auf der + Agenturadresse, falsch auf jeder anderen. + + GEPRUEFT WIRD MIT EINEM EINZIGEN MENSCHEN AUF ZWEI ADRESSEN. Dieselbe + Person, dasselbe Brett, zwei Waende: Wandert das Wort mit, kommt es + vom Haus. Waere es an der Rolle festgemacht, stuende hier zweimal + dasselbe -- und das ist genau der Fehler, den es zu finden gilt. + ======================================================================= */ +melde(""); +melde("=== 7. Das Wort für „Gilt für“ kommt vom Server ==="); +{ + const kCrew = (await anmelden("admin", "CODE-DOGI-0001", CREW)).keks; + const kHaus = (await anmelden("admin", "CODE-DOGI-0001", HAUS)).keks; + ok(!!kCrew && !!kHaus, "DogFather ist an beiden Waenden angemeldet"); + + const lies = async (brett, host, keks) => { + const a = await roh(`/workspace/api/bereich/${brett}`, host, { cookie: keks }); + let g = null; + try { g = JSON.parse(a.text).gehoert; } catch { /* bleibt null */ } + return { code: a.code, gehoert: g }; + }; + + const treff = await lies("treff", CREW, kCrew); + ok(treff.code === 200 && treff.gehoert === "Der Treff", + `ein Treff-Brett gehoert dem Treff (${treff.gehoert})`); + + const crew = await lies("ideen", CREW, kCrew); + ok(crew.code === 200 && crew.gehoert === "Team Dogi", + `dasselbe Haus-Brett auf crew. gehoert Team Dogi (${crew.gehoert})`); + + /* GEGENPROBE, und ohne sie beweist die Zeile darueber nichts: Auf der + Agenturadresse MUSS weiterhin "Agentur" stehen. Ein Wort, das + ueberall dasselbe sagt, sagt nichts. */ + const haus = await lies("ideen", HAUS, kHaus); + ok(haus.code === 200 && haus.gehoert === "Agentur", + `und auf workspace. weiterhin Agentur (${haus.gehoert})`); + ok(crew.gehoert !== haus.gehoert, + "dieselbe Person, dasselbe Brett, zwei Woerter -- es haengt am Haus"); + + /* UND DIE OBERFLAECHE SCHREIBT ES NICHT MEHR SELBST HIN. */ + const js = readFileSync(new URL("../workspace/assets/js/bereich.js", import.meta.url), "utf8"); + const ohneKommentare = js.replace(/\/\*[\s\S]*?\*\//g, ""); + ok(!/['"`]Agentur/.test(ohneKommentare), + "in bereich.js steht das Wort „Agentur“ in keinem Text mehr"); + ok(/gehoert/.test(ohneKommentare), + "stattdessen fragt sie den Server danach"); + /* Gegenprobe zum Sucher selbst: Er MUSS ein hingeschriebenes + "Agentur" auch wirklich finden, sonst ist die Zeile darueber nur + gruen, weil sie blind ist. */ + ok(/['"`]Agentur/.test("const x = 'Agentur-Eintraege';"), + "und der Sucher findet so ein Wort auch wirklich"); +} + melde(""); console.log(`${geprueft} Pruefungen, ${fehler} Fehler`); console.log(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG"); diff --git a/server/workspace-bereiche.js b/server/workspace-bereiche.js index 288551d7..2fce6c3a 100644 --- a/server/workspace-bereiche.js +++ b/server/workspace-bereiche.js @@ -1093,8 +1093,26 @@ bereicheRouter.get("/workspace/api/bereich/:bereich", (req, res) => { es gaebe eine zweite Wahrheit darueber, welche es sind. So gibt es weiterhin genau eine (TREFF_BRETTER), und der Browser fragt sie, statt sie zu kennen. */ + /* WEM GEHOERT EIN EINTRAG AUF DIESEM BRETT? (11.09.2026) + + Filipe, mit Bildschirmfoto des Feldes "Gilt für: Agentur": + "auf dieser seite soll nichts stehen von agentur, diese seite + hier ist nur für mich meine modis und meine community." + + Er hatte recht, und der Fehler war ein fest eingetragenes Wort: + In bereich.js stand woertlich "Agentur" -- richtig auf der + Agenturadresse, falsch auf jeder anderen. Ein Wort in der + Oberflaeche, das eine Tatsache ueber den Server behauptet, ist + genau die Sorte Zweitschrift, die auseinanderlaeuft. + + Jetzt sagt der Server es. Er weiss beides: welches Brett es ist + und in welchem Haus die Person gerade steht. */ + const gehoert = treffBrett ? "Der Treff" + : (req.sicht || req.person)?.haus === "crew" ? "Team Dogi" + : "Agentur"; + res.json({ - bereich, einstellung, treff: treffBrett, + bereich, einstellung, treff: treffBrett, gehoert, eintraege: treffBrett ? eintraege.map(mitRollenname) : eintraege, }); } catch (fehler) { diff --git a/server/workspace-entwicklung.js b/server/workspace-entwicklung.js index 4e3cfe3e..e45fb860 100644 --- a/server/workspace-entwicklung.js +++ b/server/workspace-entwicklung.js @@ -69,7 +69,7 @@ import { } from "./workspace-entwicklungs-punkte.js"; import { TALENT_STUFEN, TALENT_GRUPPEN, TALENT_MERKMALE, TALENT_WARNZEICHEN, - TALENT_NACH_SCHLUESSEL, talentStufe, talentNaechste, + TALENT_NACH_SCHLUESSEL, talentStufe, talentNaechste, ansprechtext, } from "./workspace-talent-punkte.js"; export const entwicklungRouter = express.Router(); @@ -437,6 +437,96 @@ entwicklungRouter.put("/workspace/api/entwicklung/mich/:schluessel", gleicheHerk } }); +/* ---------- Die eigene Karte ------------------------------------------ + + Filipes Frage aus dem Plan, Punkt 2: "Sieht die Person ihre eigene + Entwicklungskarte?" -- und die Antwort war ja, aber unter zwei + Bedingungen. Beide sind hier gebaut, und beide sind der Grund, warum + es ueberhaupt geht: + + ERST, WENN ALLE GESETZT HABEN. Eine halbe Karte ist schlimmer als + keine: Wer sieht, dass einer "da hakt es" gesetzt hat und der andere + noch nichts, liest daraus einen Streit, den es nicht gibt. + + WER "ALLE" IST, wird gerechnet, nicht angenommen: alle aktiven + Personen, die das Team fuehren -- OHNE die Person selbst. Sonst + muesste die rechte Hand sich erst selbst beurteilen, um zu sehen, + was DogFather ueber sie denkt. + + OHNE NAMEN UND OHNE ANLASS. Sie sieht "2x Laeuft" oder "1x Laeuft, + 1x Waechst gerade" -- nicht, wer was gesetzt hat, und nicht, was + danebengeschrieben steht. Der Anlass ist fuer ein Gespraech + aufgeschrieben, nicht zum Lesen ohne den Menschen dazu. Genau + deshalb steht auf der Seite der Satz, dass man danach fragen soll. + + UND WER GAR KEINE KARTE HAT, bekommt das gesagt. DogFather steht + nicht in KARTEN_ROLLEN -- eine leere Seite waere fuer ihn ein + Fehler, ein Satz ist eine Auskunft. + ------------------------------------------------------------------- */ +entwicklungRouter.get("/workspace/api/entwicklung/meine-karte", (req, res) => { + try { + if (!KARTEN_ROLLEN.includes(req.person.rolle)) { + return res.json({ hat_karte: false }); + } + + /* Wer ueber diese Person urteilt -- alle ausser ihr selbst. */ + const beurteiler = db().prepare( + "SELECT id FROM personen WHERE rolle IN ('admin','hand') AND aktiv = 1 AND id <> ?") + .all(req.person.id).map((z) => z.id); + + const staende = db().prepare( + "SELECT schluessel, von_id, stufe, am FROM entwicklung_stand WHERE person_id = ?") + .all(req.person.id).filter((z) => beurteiler.includes(z.von_id)); + + const nachPunkt = new Map(); + for (const z of staende) { + if (!nachPunkt.has(z.schluessel)) nachPunkt.set(z.schluessel, []); + nachPunkt.get(z.schluessel).push(z); + } + + const bloecke = ENTWICKLUNG_BLOECKE + .filter((b) => !b.nurSelbst) + .map((b) => ({ + schluessel: b.schluessel, name: b.name, text: b.text, + punkte: (ENTWICKLUNG_PUNKTE[b.schluessel] || []).map((pt) => { + const liste = nachPunkt.get(pt.schluessel) || []; + /* VOLLSTAENDIG heisst: jeder Beurteiler hat gesetzt. */ + const fertig = beurteiler.length > 0 && liste.length >= beurteiler.length; + if (!fertig) { + return { schluessel: pt.schluessel, titel: pt.titel, stufe: pt.stufe, offen: true }; + } + const zaehler = {}; + for (const z of liste) zaehler[z.stufe] = (zaehler[z.stufe] || 0) + 1; + return { + schluessel: pt.schluessel, titel: pt.titel, stufe: pt.stufe, offen: false, + /* Nach der Reihenfolge des Katalogs, nicht nach Haeufigkeit + -- sonst stuende bei einer Abweichung mal das eine, mal + das andere zuerst, und das liest sich wie eine Wertung. */ + antworten: ENTWICKLUNG_STUFEN + .filter((x) => zaehler[x.schluessel]) + .map((x) => ({ stufe: x.schluessel, name: x.name, anzahl: zaehler[x.schluessel] })), + einig: new Set(liste.map((z) => z.stufe)).size === 1, + am: liste.map((z) => z.am).sort().at(-1) || null, + }; + }), + })); + + const alle = bloecke.flatMap((b) => b.punkte); + res.json({ + hat_karte: true, + beurteiler: beurteiler.length, + fertig: alle.filter((x) => !x.offen).length, + gesamt: alle.length, + stufen: ENTWICKLUNG_STUFEN, + erwartung: ENTWICKLUNG_ERWARTUNG, + bloecke, + }); + } catch (f) { + console.error("[entwicklung] Meine Karte:", f?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + /* ---------- Die Ampel ohne Namen -------------------------------------- */ /* WAS HIER HERAUSKOMMT UND WAS NICHT. @@ -541,6 +631,11 @@ function talentKarte(e, merkmale) { merkmale: gesetzt.filter((s) => !TALENT_NACH_SCHLUESSEL.get(s)?.warnung), warnzeichen: warn, notiz: stand?.notiz || null, + /* DER VORLAGENTEXT -- gebaut aus den Merkmalen, die wirklich + angeklickt wurden. Er kommt MIT der Karte und nicht ueber einen + eigenen Weg: Er aendert sich mit jedem Klick, und ein Text, den + man extra abrufen muss, ist beim Abrufen schon veraltet. */ + ansprechtext: ansprechtext(e.titel, gesetzt), }; } diff --git a/server/workspace-talent-punkte.js b/server/workspace-talent-punkte.js index e201e81c..69aa4547 100644 --- a/server/workspace-talent-punkte.js +++ b/server/workspace-talent-punkte.js @@ -73,13 +73,13 @@ export const TALENT_STUFEN = [ { schluessel: "probe", name: "Probe", rang: 4, frist: 30, bedeutet: "Echte Schicht, kleiner Rahmen, fester Buddy – 30 Tage, beidseitig ausgesprochen.", - schritt: "Nach 30 Tagen: übernehmen oder sauber beenden.", + schritt: "Nach 30 Tagen: übernehmen – der Zugang entsteht dabei – oder sauber beenden.", knopf: "Ins Team", }, { schluessel: "imteam", name: "Im Team", rang: 5, frist: null, bedeutet: "Zugang steht. Ab hier zählt die Entwicklungskarte, nicht mehr diese.", - schritt: "Zugang in „Personen & Zugänge“ anlegen, Rolle Modi.", + schritt: "Nichts mehr zu tun – der Zugang steht, ab hier zählt die Entwicklungskarte.", knopf: null, }, ]; @@ -116,58 +116,79 @@ export const TALENT_GRUPPEN = [ export const TALENT_MERKMALE = { ruhe: [ - { schluessel: "t-ruhe-streit", titel: "Bleibt ruhig, wenn im Chat gestritten wird", + { schluessel: "t-ruhe-streit", du: "auch dann ruhig bleibst, wenn es im Chat hitzig wird", + titel: "Bleibt ruhig, wenn im Chat gestritten wird", text: "Der Normalfall, an dem sich alles zeigt." }, - { schluessel: "t-ruhe-provokation", titel: "Geht auf Provokation nicht ein", + { schluessel: "t-ruhe-provokation", du: "auf Provokation nicht anspringst", + titel: "Geht auf Provokation nicht ein", text: "Nicht ignorieren – nicht mitgehen. Zwei verschiedene Dinge." }, - { schluessel: "t-ruhe-widerspricht", titel: "Widerspricht, ohne auszuteilen", + { schluessel: "t-ruhe-widerspricht", du: "widersprichst, ohne auszuteilen", + titel: "Widerspricht, ohne auszuteilen", text: "Wer nie widerspricht, ist nicht ruhig, sondern still. Das ist etwas anderes." }, - { schluessel: "t-ruhe-gegen-sich", titel: "Hat sich auch dann im Griff, wenn es gegen ihn selbst geht", + { schluessel: "t-ruhe-gegen-sich", du: "dich sogar dann im Griff hast, wenn es gegen dich selbst geht", + titel: "Hat sich auch dann im Griff, wenn es gegen ihn selbst geht", text: "Der härteste der vier – und der aussagekräftigste." }, ], hilft: [ - { schluessel: "t-hilft-vor-team", titel: "Beantwortet Fragen anderer, bevor jemand vom Team da ist", + { schluessel: "t-hilft-vor-team", du: "Fragen von anderen beantwortest, bevor jemand von uns da ist", + titel: "Beantwortet Fragen anderer, bevor jemand vom Team da ist", text: "Tut die Arbeit schon, ohne den Titel zu haben." }, - { schluessel: "t-hilft-erklaert-neuen", titel: "Erklärt Neuen, wie es hier läuft", + { schluessel: "t-hilft-erklaert-neuen", du: "Neuen erklärst, wie es hier läuft", + titel: "Erklärt Neuen, wie es hier läuft", text: "Setzt voraus, dass er es selbst verstanden hat." }, - { schluessel: "t-hilft-meldet-statt-moderiert", titel: "Meldet etwas, statt selbst zu moderieren", + { schluessel: "t-hilft-meldet-statt-moderiert", du: "Sachen meldest, statt selbst durchzugreifen", + titel: "Meldet etwas, statt selbst zu moderieren", text: "Genau die richtige Grenze zu kennen, ist das eigentliche Signal – " + "wer ohne Rolle durchgreift, wird es mit Rolle erst recht." }, - { schluessel: "t-hilft-unbeobachtet", titel: "Hilft auch dann, wenn es niemand sieht", + { schluessel: "t-hilft-unbeobachtet", du: "auch dann hilfst, wenn gerade niemand hinsieht", + titel: "Hilft auch dann, wenn es niemand sieht", text: "Schwer zu beobachten, deshalb selten angeklickt – und wenn, dann zählt es." }, ], haus: [ - { schluessel: "t-haus-regeln", titel: "Kennt die Regeln, ohne sie nachzulesen", + { schluessel: "t-haus-regeln", du: "die Regeln hier kennst, ohne nachzulesen", + titel: "Kennt die Regeln, ohne sie nachzulesen", text: "Und wendet sie im Gespräch an, nicht als Zitat." }, - { schluessel: "t-haus-leute", titel: "Kennt die Leute – weiß, wer wer ist", + { schluessel: "t-haus-leute", du: "die Leute kennst und weißt, wer wer ist", + titel: "Kennt die Leute – weiß, wer wer ist", text: "Ein halbes Jahr Einarbeitung, die schon passiert ist." }, - { schluessel: "t-haus-witze", titel: "Versteht die Witze, die nur hier funktionieren", + { schluessel: "t-haus-witze", du: "die Witze verstehst, die nur hier funktionieren", + titel: "Versteht die Witze, die nur hier funktionieren", text: "Klingt nebensächlich und ist der Unterschied zwischen Gast und Zuhause." }, - { schluessel: "t-haus-frueh-dabei", titel: "War schon da, als es kleiner war", + { schluessel: "t-haus-frueh-dabei", du: "schon dabei warst, als es hier noch kleiner war", + titel: "War schon da, als es kleiner war", text: "Nicht mehr wert – aber belastbarer, weil länger beobachtet." }, ], da: [ - { schluessel: "t-da-regelmaessig", titel: "Über Wochen regelmäßig, nicht nur bei den großen Streams", + { schluessel: "t-da-regelmaessig", du: "über Wochen regelmäßig da bist, nicht nur bei den großen Streams", + titel: "Über Wochen regelmäßig, nicht nur bei den großen Streams", text: "Regelmäßigkeit ist planbar, Begeisterung nicht." }, - { schluessel: "t-da-auch-wenig-los", titel: "Auch dann, wenn wenig los ist", + { schluessel: "t-da-auch-wenig-los", du: "auch dann kommst, wenn wenig los ist", + titel: "Auch dann, wenn wenig los ist", text: "Die kleinen Streams sind der ehrlichere Test." }, - { schluessel: "t-da-luecke", titel: "Zu Zeiten, zu denen wir niemanden haben", + { schluessel: "t-da-luecke", du: "oft genau dann da bist, wenn wir sonst niemanden haben", + titel: "Zu Zeiten, zu denen wir niemanden haben", text: "Operativ der entscheidendste Punkt der ganzen Liste: Ein guter Kandidat " + "zur falschen Zeit löst kein einziges Problem." }, - { schluessel: "t-da-kommt-wieder", titel: "Kommt nach einer Pause von selbst wieder", + { schluessel: "t-da-kommt-wieder", du: "nach einer Pause von selbst wieder auftauchst", + titel: "Kommt nach einer Pause von selbst wieder", text: "Pausen sind normal. Wiederkommen ist das Signal." }, ], empfohlen: [ - { schluessel: "t-emp-vom-team", titel: "Von jemandem aus dem Team vorgeschlagen", + { schluessel: "t-emp-vom-team", du: "jemandem aus dem Team aufgefallen bist", + titel: "Von jemandem aus dem Team vorgeschlagen", text: "Ein Augenpaar, das den Alltag kennt." }, - { schluessel: "t-emp-mehrfach", titel: "Mehr als eine Person hat unabhängig denselben Namen genannt", + { schluessel: "t-emp-mehrfach", du: "gleich mehreren aus dem Team unabhängig voneinander aufgefallen bist", + titel: "Mehr als eine Person hat unabhängig denselben Namen genannt", text: "Der stärkste Einzelbefund im ganzen Katalog. Zwei unabhängige Nennungen " + "schlagen jede einzelne Beobachtung." }, - { schluessel: "t-emp-schicht-tauschen", titel: "Jemand aus dem Team würde eine Schicht mit ihm tauschen", + { schluessel: "t-emp-schicht-tauschen", du: "jemand aus dem Team gern eine Schicht mit dir tauschen würde", + titel: "Jemand aus dem Team würde eine Schicht mit ihm tauschen", text: "Die ehrlichste Frage, die man dem Team stellen kann." }, - { schluessel: "t-emp-schon-ausgeholfen", titel: "Hat schon mal ausgeholfen, ohne Rolle", + { schluessel: "t-emp-schon-ausgeholfen", du: "schon mal ausgeholfen hast, ohne dass du musstest", + titel: "Hat schon mal ausgeholfen, ohne Rolle", text: "Der beste verfügbare Beweis – er hat es getan." }, - { schluessel: "t-emp-von-zuschauer", titel: "Wurde von einem Zuschauer vorgeschlagen", + { schluessel: "t-emp-von-zuschauer", du: "auch anderen Zuschauern aufgefallen bist", + titel: "Wurde von einem Zuschauer vorgeschlagen", text: "Schwächer als eine Nennung aus dem Team, aber es zählt." }, ], }; @@ -217,6 +238,81 @@ export const TALENT_NACH_SCHLUESSEL = new Map([ export const talentStufe = (s) => TALENT_STUFEN.find((x) => x.schluessel === s) || null; +/* ===================================================================== + DER VORLAGENTEXT ZUM ANSPRECHEN (11.09.2026) + ===================================================================== + + Auf Stufe 2 steht als naechster Schritt "Ansprechen". Bis heute stand + daneben "der Vorlagentext liegt bereit" -- und es gab keinen. Ein + Versprechen an eine Schublade. + + ER WIRD GEBAUT, NICHT ABGESCHRIEBEN. Aus den Merkmalen, die wirklich + angeklickt wurden. Ein allgemeiner Text ("wir fanden dich toll") + klingt wie ein Serienbrief, weil er einer ist; drei konkrete + Beobachtungen klingen nach jemandem, der hingesehen hat -- und das + ist der Unterschied, ob jemand zusagt. + + HOECHSTENS DREI. Wer fuenf Beobachtungen auflistet, klingt nach + Personalakte. Die drei kommen in der Reihenfolge des Katalogs, und + die faengt bei "Ruhe" an -- dem am besten belegten Merkmal. + + DREI SAETZE, DIE IMMER DRINSTEHEN, und jeder hat einen Grund: + * "kein Druck, sag ruhig nein" -- wer sich gedraengt fuehlt, sagt + ja und hoert nach drei Wochen auf. + * "30 Tage, danach schauen wir beide" -- die Probezeit wird + BEIDSEITIG ausgesprochen. Wer weiss, dass er ohne Gesichtsverlust + wieder gehen kann, sagt eher ehrlich, wie es ihm damit geht. + * "du musst nichts koennen" -- die haeufigste Absage kommt nicht + aus Desinteresse, sondern aus der Annahme, man muesse etwas + mitbringen. + + ER WIRD NICHT VERSCHICKT. Er steht da zum Kopieren. Eine Nachricht, + die ein Programm verschickt, liest sich wie eine Nachricht, die ein + Programm verschickt hat. */ +export function ansprechtext(name, gesetzteMerkmale) { + /* NUR DAS ERSTE WORT. Im Brett steht selten ein Name allein -- + "Kessi aus dem Chat" ist eine Notiz, keine Anrede. "Hey Kessi aus + dem Chat," liest sich wie ein Serienbrief, und genau das soll der + Text nicht sein. + + Es ist eine VORLAGE zum Kopieren, keine Nachricht, die abgeschickt + wird -- wer den Namen anders schreiben will, tut das im Text. */ + const sauber = String(name || "").trim().split(/[\s,(–-]+/)[0].trim() || "du"; + const beobachtet = (gesetzteMerkmale || []) + .map((sch) => TALENT_ALLE.find((m) => m.schluessel === sch)?.du) + .filter(Boolean) + .slice(0, 3); + + const mitte = beobachtet.length + ? "Mir ist aufgefallen, dass du " + (beobachtet.length === 1 + ? beobachtet[0] + : beobachtet.slice(0, -1).join(", ") + " und " + beobachtet[beobachtet.length - 1]) + "." + : "Du bist mir hier schon öfter positiv aufgefallen."; + + /* ECHTE UMLAUTE UND ECHTE STRICHE. + + In Kommentaren schreibt dieses Haus ASCII; in TEXT, den jemand + liest, nicht. Der erste Entwurf stand voller "Haettest" und + "koennen" -- und dieser Text hier ist der einzige im ganzen + Bereich, den ein Fremder zu sehen bekommt. "Haettest du Lust" sieht + aus wie ein Fehler, weil es einer ist. Derselbe Befund stand schon + am 10.09. in workspace-personen.js. */ + return [ + `Hey ${sauber},`, + "", + mitte, + "", + "Deshalb die Frage: Hättest du Lust, bei uns im Mod-Team mitzumachen?", + "", + "Du musst dafür nichts können – alles, was es zu wissen gibt, zeigen wir dir. " + + "Wir würden mit 30 Tagen anfangen, und danach schauen wir beide, ob es passt. " + + "Wenn nicht, ist das völlig in Ordnung und ändert nichts.", + "", + "Und ganz wichtig: kein Druck. Sag ruhig nein oder „gerade nicht“ – " + + "das nehme ich dir null übel.", + ].join("\n"); +} + /** Die naechste Stufe -- oder null am Ende des Trichters. */ export function talentNaechste(s) { const i = TALENT_STUFEN.findIndex((x) => x.schluessel === s); diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index ed7f69ea..19191867 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=202609112230", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609112313", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609112230", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609112313", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609112230", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609112313", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609112230", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609112313", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/entwicklung.css b/workspace/assets/css/entwicklung.css index 3287c414..d819d34e 100644 --- a/workspace/assets/css/entwicklung.css +++ b/workspace/assets/css/entwicklung.css @@ -363,3 +363,67 @@ .t-karte__warn { margin-left: 0; } .e-punkt__text, .e-block__satz { font-size: .88rem; } } + +/* ===================================================================== + DER VORLAGENTEXT UND DIE EIGENE KARTE (11.09.2026) + ===================================================================== */ + +.t-text { + margin: 14px 0 0; padding: 15px 17px; + background: rgba(0, 0, 0, .3); + border: 1px solid var(--rand-hell); +} +.t-text__titel { margin: 0 0 5px; font-size: .95rem; font-weight: 640; color: var(--text); } +.t-text__satz { + margin: 0 0 11px; max-width: 62ch; + font-size: .82rem; line-height: 1.6; color: var(--text-leise); +} +.t-text__feld { + display: block; width: 100%; + font: inherit; font-size: .86rem; line-height: 1.65; + padding: 11px 13px; resize: vertical; + color: var(--text); + background: rgba(0, 0, 0, .35); + border: 1px solid var(--rand); +} +.t-text__knoepfe { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 11px; } + +/* Zwei, die seit dem Bau der Seite ohne eigene Regel dastanden: der + Knopf am naechsten Schritt und die Beschriftung eines Merkmals. Sie + sahen richtig aus, weil .schritt und .t-merkmal sie getragen haben -- + aber wer eines davon umbaut, merkt es hier nicht. */ +.t-schritt__knopf { margin-top: 2px; } +.t-schritt__text { margin-top: 2px; } +.t-merkmal__titel { text-align: left; line-height: 1.35; } + +/* DIE EIGENE KARTE HAT KEINE KNOEPFE. Hier wird nichts gesetzt, hier + wird gelesen -- deshalb ein anderer Grund als auf der Karte, auf der + man klickt, und kein einziges anklickbares Feld darin. */ +.e-meine { + margin: 30px 0 0; padding: 20px 22px; + border: 1px solid var(--rand); + background: color-mix(in srgb, var(--tinte) 94%, #ffffff 6%); +} +.e-meine__satz { + margin: 0 0 16px; max-width: 64ch; + font-size: .88rem; line-height: 1.65; color: var(--text-leise); +} +.e-punkt--meine { background: rgba(0, 0, 0, .2); } +.e-meine__antworten { display: flex; flex-wrap: wrap; gap: 7px; align-items: center; } +.e-meine__antwort { + font-size: .8rem; font-weight: 600; padding: 4px 11px; border-radius: 999px; + color: var(--text-leise); + border: 1px solid var(--rand-hell); +} +.e-meine__antwort[data-stufe="laeuft"] { + color: var(--text); border-color: color-mix(in srgb, #668e6e 55%, transparent); +} +.e-meine__antwort[data-stufe="waechst"] { + color: var(--text); border-color: color-mix(in srgb, #8ec9ff 50%, transparent); +} +.e-meine__antwort[data-stufe="hakt"] { + color: var(--text); border-color: color-mix(in srgb, #cc9451 55%, transparent); +} +/* „Darüber seid ihr euch nicht einig“ ist keine Warnung, sondern eine + Einladung zu fragen – deshalb leise und ohne Farbe. */ +.e-meine__uneinig { font-size: .78rem; color: var(--text-still); font-style: italic; } diff --git a/workspace/assets/css/personen.css b/workspace/assets/css/personen.css index 86472a72..45bc8d62 100644 --- a/workspace/assets/css/personen.css +++ b/workspace/assets/css/personen.css @@ -41,27 +41,13 @@ .person__knoepfe { display: flex; gap: 7px; flex-wrap: wrap; justify-content: flex-end; } -/* ---------- Code-Anzeige ---------------------------------------------- */ +/* ---------- Code-Anzeige ---------------------------------------------- -.code-kasten { - margin: 0 0 26px; padding: 20px 22px; - background: linear-gradient(100deg, rgba(63, 189, 245, .12), rgba(138, 118, 255, .09)); - border: 1px solid rgba(63, 189, 245, .5); - border-radius: var(--radius); -} -.code-kasten__schild { - margin: 0 0 8px; font-size: .78rem; font-weight: 600; - letter-spacing: .06em; text-transform: uppercase; color: var(--text-leise); -} -.code-kasten__code { - margin: 0 0 12px; - font-family: "Cascadia Mono", Consolas, "SF Mono", Menlo, monospace; - font-size: clamp(1.3rem, 3.4vw, 1.9rem); font-weight: 700; - letter-spacing: .12em; color: #eaf4fd; - user-select: all; /* ein Klick markiert den ganzen Code */ - word-break: break-all; -} -.code-kasten__hinweis { margin: 0 0 14px; font-size: .85rem; color: var(--text-leise); } + STEHT SEIT DEM 11.09.2026 IN start.css. Nicht aufgeraeumt, sondern + umgezogen: Seit dem Talente-Trichter entsteht ein Zugang an zwei + Stellen, und talente.html laedt diese Datei nicht. Der Kasten waere + dort unsichtbar gewesen -- ein Code in weisser Grundschrift, ohne + Rahmen, mitten im Text. Gesucht unter ".code-kasten" in start.css. */ /* ---------- Protokoll --------------------------------------------------- */ diff --git a/workspace/assets/css/start.css b/workspace/assets/css/start.css index c240fee6..83d9376f 100644 --- a/workspace/assets/css/start.css +++ b/workspace/assets/css/start.css @@ -7845,3 +7845,36 @@ body.start .k-leiste .feldschild::after { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } + +/* ===================================================================== + DIE CODE-ANZEIGE (umgezogen aus personen.css, 11.09.2026) + + Ein Zugangscode wird im ganzen Haus nur an zwei Stellen sichtbar: + in "Personen & Zugaenge" und am Ende des Talente-Trichters. Beide + zeigen ihn gleich -- gleiche Kante, gleiche Schrift, gleicher Satz + darunter. Zwei Gestaltungen fuer dasselbe Geheimnis waeren zwei + Gelegenheiten, eine davon zu vergessen. + + Er steht hier und nicht in module.css: module.css beschreibt die + Form ALLER Karten, das hier ist ein einzelner Baustein. + ===================================================================== */ + +.code-kasten { + margin: 0 0 26px; padding: 20px 22px; + background: linear-gradient(100deg, rgba(63, 189, 245, .12), rgba(138, 118, 255, .09)); + border: 1px solid rgba(63, 189, 245, .5); + border-radius: var(--radius); +} +.code-kasten__schild { + margin: 0 0 8px; font-size: .78rem; font-weight: 600; + letter-spacing: .06em; text-transform: uppercase; color: var(--text-leise); +} +.code-kasten__code { + margin: 0 0 12px; + font-family: "Cascadia Mono", Consolas, "SF Mono", Menlo, monospace; + font-size: clamp(1.3rem, 3.4vw, 1.9rem); font-weight: 700; + letter-spacing: .12em; color: #eaf4fd; + user-select: all; /* ein Klick markiert den ganzen Code */ + word-break: break-all; +} +.code-kasten__hinweis { margin: 0 0 14px; font-size: .85rem; color: var(--text-leise); } diff --git a/workspace/assets/js/bereich.js b/workspace/assets/js/bereich.js index fea8e520..8d3251b1 100644 --- a/workspace/assets/js/bereich.js +++ b/workspace/assets/js/bereich.js @@ -861,14 +861,28 @@ NICHT aufgesetzt -- sie erlaubt getippte Namen, und genau das soll es hier nicht geben. */ if (einstellung.ohneCreatorBezug) { + /* DAS WORT KOMMT VOM SERVER (11.09.2026). + + Hier stand fest "Agentur" -- richtig auf der Agenturadresse, + falsch auf jeder anderen. Filipe, mit Bildschirmfoto: "auf + dieser seite soll nichts stehen von agentur, diese seite hier + ist nur für mich meine modis und meine community." + + Der Server weiss, welches Brett es ist und in welchem Haus + die Person steht; er schickt das Wort mit (daten.gehoert). + Faellt die Angabe aus, bleibt das Feld LEER statt falsch -- + ein fehlendes Wort sieht man, ein falsches nicht. */ + const gehoert = daten?.gehoert || ''; $('schild-creator').textContent = 'Gilt für'; $('f-creator').textContent = ''; const o = document.createElement('option'); - o.value = ''; o.textContent = 'Agentur'; + o.value = ''; o.textContent = gehoert || '—'; $('f-creator').append(o); const hinweis = $('hinweis-creator'); - hinweis.textContent = 'Agentur-Einträge gehören allen – jeder im Workspace sieht sie.'; - hinweis.hidden = false; + hinweis.textContent = gehoert + ? `Gehört allen hier – wer ${gehoert} sieht, sieht auch diesen Eintrag.` + : ''; + hinweis.hidden = !gehoert; } else { try { const { personen } = await (await hole('/workspace/api/personen')).json(); diff --git a/workspace/assets/js/entwicklung.js b/workspace/assets/js/entwicklung.js index 3c49edcf..2b44f690 100644 --- a/workspace/assets/js/entwicklung.js +++ b/workspace/assets/js/entwicklung.js @@ -244,6 +244,72 @@ return z; } + /* ---- Deine eigene Karte ----------------------------------------------- + + WAS HIER STEHT UND WAS NICHT: die Antworten, nicht die Namen, und + nicht der Anlass. „2 × Läuft" oder „1 × Läuft, 1 × Wächst gerade". + + Der Anlass ist für ein Gespräch aufgeschrieben, nicht zum Lesen + ohne den Menschen dazu — deshalb steht hier stattdessen der Satz, + dass man danach fragen soll. Ein Satz aus einer Notiz, aus dem + Zusammenhang gerissen, richtet mehr an, als er erklärt. */ + async function meineKarte() { + let a; + try { a = await hole('/workspace/api/entwicklung/meine-karte'); } catch { return; } + if (!a.ok) return; + const r = await a.json(); + if (!r.hat_karte) return; // DogFather hat keine + + $('karte-block').hidden = false; + const satz = $('karte-satz'); + const ziel = $('meine-karte'); + ziel.textContent = ''; + ziel.setAttribute('aria-busy', 'false'); + + if (!r.beurteiler) { + satz.textContent = 'Es gibt gerade niemanden, der sie führt. Sobald jemand ' + + 'da ist, steht hier etwas.'; + return; + } + if (!r.fertig) { + satz.textContent = `Noch nichts fertig. Eine Beobachtung erscheint hier erst, ` + + `wenn alle ${r.beurteiler} sie gesetzt haben – eine halbe Karte ist ` + + `schlimmer als keine.`; + return; + } + + satz.textContent = `${r.fertig} von ${r.gesamt} Beobachtungen sind durch. ` + + 'Du siehst die Antworten, nicht wer was gesetzt hat – und nicht, was ' + + 'danebengeschrieben steht. Das gehört ins Gespräch: frag danach.'; + + for (const b of r.bloecke) { + const fertige = b.punkte.filter((pt) => !pt.offen); + if (!fertige.length) continue; + const abschnitt = el('section', 'e-block'); + abschnitt.append(el('h3', 'e-block__titel', b.name)); + for (const pt of fertige) { + const zeile = el('article', 'e-punkt e-punkt--meine'); + if (pt.einig) zeile.dataset.stufe = pt.antworten[0].stufe; + zeile.append(el('h4', 'e-punkt__titel', pt.titel)); + const antw = el('div', 'e-meine__antworten'); + for (const x of pt.antworten) { + const chip = el('span', 'e-meine__antwort', `${x.anzahl} × ${x.name}`); + chip.dataset.stufe = x.stufe; + antw.append(chip); + } + if (!pt.einig) { + const hin = el('span', 'e-meine__uneinig', 'darüber seid ihr euch nicht einig'); + hin.title = 'Zwei Leute haben dasselbe verschieden gesehen. Das ist normal – ' + + 'und ein guter Anlass, danach zu fragen.'; + antw.append(hin); + } + zeile.append(antw); + abschnitt.append(zeile); + } + ziel.append(abschnitt); + } + } + /* ---- Die eigenen sechs Fragen ---------------------------------------- */ async function meins() { const a = await hole('/workspace/api/entwicklung/mich'); @@ -305,6 +371,7 @@ 'Sechs Fragen über dich. Niemand aus dem Team sieht deine Antworten.'; } + await meineKarte(); await meins(); } catch { /* umgeleitet */ } })(); diff --git a/workspace/assets/js/talente.js b/workspace/assets/js/talente.js index 30869478..508c39fd 100644 --- a/workspace/assets/js/talente.js +++ b/workspace/assets/js/talente.js @@ -37,6 +37,7 @@ } let lage = null; + let darfAnlegen = false; // darf ich hier einen Modi-Zugang anlegen? const offen = new Set(); // welche Karten aufgeklappt sind /* ---- Der Trichter oben ---------------------------------------------- */ @@ -86,23 +87,64 @@ 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); + /* DER LETZTE SCHRITT IST KEIN SCHRITT, SONDERN EINE ENTSCHEIDUNG. + Aus einem Namen wird ein Mensch mit einem Zugang. Deshalb haengt + genau daran das Anlegen selbst -- und nicht ein Satz, den man + danach von Hand nachbaut. Bis heute stand hier "Zugang in + Personen & Zugaenge anlegen, Rolle Modi", und wer das vergass, + hatte eine Karte auf "Im Team" und keinen Menschen darin. */ + const zugangNoetig = k.naechste === 'imteam'; + if (k.knopf && k.naechste && (!zugangNoetig || darfAnlegen)) { + const b = el('button', 'schritt t-schritt__knopf', + zugangNoetig ? 'Ins Team holen · Zugang anlegen' : k.knopf); b.type = 'button'; b.addEventListener('click', async () => { b.disabled = true; + let zugang = null; + if (zugangNoetig) { + zugang = await zugangAnlegen(k); + if (!zugang) { b.disabled = false; return; } + } 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; } + if (!r.ok) { + /* ZWEI DINGE SIND PASSIERT, EINES DAVON HAT GEKLAPPT. Ein + blosses "ging nicht" waere hier die gefaehrlichste + Auskunft von allen -- der Zugang steht ja, und niemand + wuesste es. */ + melde(zugang + ? `Der Zugang für ${zugang.name} steht – der Code steht oben. Nur die Karte ` + + 'blieb auf „Probe“. Bitte den Schritt gleich noch einmal setzen.' + : 'Ging nicht.'); + return; + } offen.add(k.id); await laden(); }); schritt.append(b); + } else if (zugangNoetig && k.knopf) { + /* KEIN KNOPF INS LEERE. Die rechte Hand sieht den Trichter + vollstaendig, aber Zugaenge vergibt nur DogFather. Ein Knopf, + der ihr jedes Mal "darfst du nicht" antwortet, waere schlechter + als gar keiner -- er verspricht etwas. */ + schritt.append(el('p', 't-schritt__liegt', + 'Den Zugang legt DogFather an – sag ihm Bescheid. Die Karte bleibt so ' + + 'lange auf „Probe“.')); } + /* DER VORLAGENTEXT -- genau auf der Stufe, auf der er gebraucht + wird. Eine Schaltfläche „Text anzeigen" auf jeder Stufe wäre auf + vier von fünf davon eine Ablenkung. */ + if (k.stufe === 'beobachtet' && k.ansprechtext) { + const b = el('button', 'schritt t-schritt__text', 'Vorlagentext'); + b.type = 'button'; + b.addEventListener('click', () => textZeigen(k)); + 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.')); @@ -150,6 +192,139 @@ return a; } + /* ---- Aus der Probe wird ein Zugang --------------------------------- + + DERSELBE WEG WIE IN "PERSONEN & ZUGAENGE", nicht ein zweiter. + /workspace/api/verwaltung/personen ist die einzige Stelle im ganzen + Haus, an der ein Zugang entsteht -- mit ihrer Rechtepruefung, ihrer + Rollenpruefung, ihrem Protokolleintrag und ihrem genau einmal + angezeigten Code. Ein eigener Weg von hier waere eine zweite Tuer + zu genau dem, was am wenigsten zwei Tueren haben darf. + + NICHT ueber hole(): Diese Adresse antwortet Nicht-DogFather mit + 404, und hole() versteht ein 404 als "Seite gibt es nicht" und + schickt zur Startseite. Ein Rechtefehler wuerde die rechte Hand + also aus dem Trichter werfen. Hier wird deshalb selbst gelesen. + + @returns {object|null} die angelegte Person, oder null bei Abbruch. */ + async function zugangAnlegen(k) { + if (!darfAnlegen) { melde('Zugänge legt DogFather an.'); return null; } + + const vorschlag = String(k.titel || '').split(/[\s,(–-]+/)[0] || ''; + const name = prompt( + 'Wie soll die Person im Workspace heißen?\n\n' + + 'Das ist der Name, den euer Team sieht. Gleich danach bekommst du ' + + 'einmalig ihren Zugangscode – danach nie wieder.', + vorschlag); + if (name === null) return null; // abgebrochen + if (String(name).trim().length < 2) { melde('Der Name ist zu kurz.'); return null; } + + let a; + try { + a = await fetch('/workspace/api/verwaltung/personen', { + credentials: 'same-origin', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: String(name).trim(), rolle: 'modi' }), + }); + } catch { melde('Keine Verbindung – der Zugang wurde nicht angelegt.'); return null; } + + if (a.status === 404 || a.status === 403) { + melde('Zugänge legt DogFather an – sag ihm Bescheid.'); + return null; + } + if (!a.ok) { + const grund = (await a.json().catch(() => ({}))).fehler; + melde(grund || 'Der Zugang ließ sich nicht anlegen.'); + return null; + } + const neu = await a.json(); + codeZeigen(neu); + return neu; + } + + /* Der Code steht in einem Kasten, den man SCHLIESSEN muss -- nicht in + einer Meldung, die von selbst verschwindet. Er ist das Einzige auf + dieser Seite, das es nur ein einziges Mal gibt. */ + function codeZeigen(neu) { + $('code-name').textContent = `${neu.name} · Modi`; + $('code-wert').textContent = neu.code; + $('code-kasten').hidden = false; + $('code-kasten').scrollIntoView({ behavior: 'smooth', block: 'center' }); + $('code-kopieren').focus(); + } + + $('code-kopieren')?.addEventListener('click', async () => { + const code = $('code-wert').textContent; + try { await navigator.clipboard.writeText(code); melde('Code kopiert.'); } + catch { + /* Ohne Zwischenablage-Recht: markieren, dann geht Strg+C. Der + Code steht ja da -- "ging nicht" waere hier unbrauchbar. */ + getSelection()?.removeAllRanges(); + const r = document.createRange(); + r.selectNodeContents($('code-wert')); + getSelection()?.addRange(r); + melde('Markiert – mit Strg+C kopieren.'); + } + }); + + $('code-schliessen')?.addEventListener('click', () => { + $('code-kasten').hidden = true; + $('code-wert').textContent = ''; // auch aus der Seite raus + melde(''); + }); + + /* DER TEXT WIRD GEZEIGT, NICHT VERSCHICKT. + + Eine Nachricht, die ein Programm abschickt, liest sich wie eine + Nachricht, die ein Programm abgeschickt hat — und genau das soll + dieser Text nicht sein. Er steht da zum Lesen, Ändern und + Kopieren, in einem Feld, das man bearbeiten kann. */ + function textZeigen(k) { + const alt = document.getElementById('t-textfeld'); + if (alt) alt.closest('.t-text')?.remove(); + + const kasten = el('div', 't-text'); + kasten.append(el('h3', 't-text__titel', `Vorschlag für ${k.titel}`)); + kasten.append(el('p', 't-text__satz', + 'Gebaut aus den Merkmalen, die du angeklickt hast. Ändere ihn ruhig – ' + + 'er soll nach dir klingen, nicht nach einer Vorlage. Abgeschickt wird ' + + 'hier nichts.')); + const feld = el('textarea', 't-text__feld'); + feld.id = 't-textfeld'; + feld.rows = 14; + feld.value = k.ansprechtext; + kasten.append(feld); + + const reihe = el('div', 't-text__knoepfe'); + const kopieren = el('button', 'schritt', 'Kopieren'); + kopieren.type = 'button'; + kopieren.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(feld.value); + melde('Kopiert. Jetzt einfügen, wo du ihn ansprichst.'); + } catch { + /* Ohne Zwischenablage-Recht: markieren, dann geht Strg+C. + Eine Fehlermeldung „ging nicht" wäre hier unbrauchbar — der + Text ist ja da. */ + feld.select(); + melde('Markiert – mit Strg+C kopieren.'); + } + }); + reihe.append(kopieren); + + const zu = el('button', 'schritt', 'Schließen'); + zu.type = 'button'; + zu.addEventListener('click', () => kasten.remove()); + reihe.append(zu); + kasten.append(reihe); + + const karte = [...document.querySelectorAll('.t-karte')] + .find((x) => x.querySelector('.t-karte__titel')?.textContent === k.titel); + (karte || $('liste')).append(kasten); + feld.focus(); + } + function merkmal(k, m, istWarnung) { const gesetzt = (istWarnung ? k.warnzeichen : k.merkmale).includes(m.schluessel); const b = el('button', 't-merkmal'); @@ -210,5 +385,24 @@ zeichne(); } - (async () => { try { await laden(); } catch { /* umgeleitet */ } })(); + /* WER ZUGAENGE ANLEGEN DARF, SAGT DER SERVER -- dieselbe Auskunft, + aus der auch "Personen & Zugaenge" ihre Rollenknoepfe baut. Eine + eigene Rollenabfrage hier waere eine zweite Meinung darueber, und + die zweite ist die, die irgendwann mehr durchlaesst. + + VOR dem ersten Zeichnen, nicht daneben: Sonst steht der Knopf + einen Wimpernschlag lang da und verschwindet wieder. */ + async function ichHolen() { + try { + const a = await hole('/workspace/api/ich'); + if (!a.ok) return; + const ich = await a.json(); + window.werZeigen?.(ich); + darfAnlegen = Array.isArray(ich?.darf_anlegen) && ich.darf_anlegen.includes('modi'); + } catch { /* umgeleitet */ } + } + + (async () => { + try { await ichHolen(); await laden(); } catch { /* umgeleitet */ } + })(); })(); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index dd262ade..2a176a0b 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 e0a2b01b..87a9f78d 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 dab23a84..1d5534e9 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 5ade6bbf..f815d9cc 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 240cb8f6..d8786675 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 458f1789..f31cad90 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 6810d6f0..98962ed9 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 c223ae6b..8a2cfb6c 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=202609112230", + "src": "/assets/img/app-symbole/crew-192.png?v=202609112313", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-512.png?v=202609112230", + "src": "/assets/img/app-symbole/crew-512.png?v=202609112313", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609112230", + "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609112313", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609112230", + "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609112313", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/dateien.html b/workspace/dateien.html index b8d516d0..1a286640 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 index 4457f223..47eca8f5 100644 --- a/workspace/entwicklung.html +++ b/workspace/entwicklung.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -71,6 +71,21 @@ + + +