diff --git a/server/index.js b/server/index.js index c7157f1c..ad8e211e 100644 --- a/server/index.js +++ b/server/index.js @@ -29,6 +29,7 @@ import { rechteRouter, tafelLaden } from "./workspace-rechte.js"; import { aufbewahrungRouter, aufbewahrungStarten } from "./workspace-aufbewahrung.js"; import { auskunftRouter } from "./workspace-auskunft.js"; import { uebernahmeRouter } from "./workspace-uebernahme.js"; +import { befindenRouter } from "./workspace-befinden.js"; import { reportRouter } from "./workspace-reports.js"; import { scoutRouter } from "./workspace-scouts.js"; import { callRouter } from "./workspace-calls.js"; @@ -258,6 +259,7 @@ app.use(rechteRouter); app.use(aufbewahrungRouter); app.use(auskunftRouter); app.use(uebernahmeRouter); +app.use(befindenRouter); app.use(reportRouter); app.use(scoutRouter); app.use(callRouter); diff --git a/server/pruef-befinden.mjs b/server/pruef-befinden.mjs new file mode 100644 index 00000000..022d1497 --- /dev/null +++ b/server/pruef-befinden.mjs @@ -0,0 +1,374 @@ +/* ===================================================================== + Wie geht's dir? (15.09.2026) + + Filipe, mit dem Bildschirmfoto der Kachel: „ich will dass du diese + seite perfektionnierst, denn gerade wenn ich drauf drücke geht die + entwicklungsseite auf." + + --------------------------------------------------------------------- + DER FEHLER WAR SCHLIMMER ALS EIN FALSCHER VERWEIS + + Auf der Kachel steht „die Antworten sieht nur du" — und sie öffnete + eine Seite voller Namen und Beobachtungen über ANDERE Menschen. Die + erste Prüfung hier ist deshalb: Führt die Kachel dorthin, wo sie + hinführt zu führen verspricht? + + --------------------------------------------------------------------- + UND DIE EIGENTLICHE FRAGE: BLEIBT ES PRIVAT? + + Ein Verlauf über das Befinden eines Menschen ist das Empfindlichste, + was dieses Haus speichert. Er existiert nur, weil er IHM hilft. Also: + + Sieht DogFather ihn? (darf nicht) + Steht etwas davon im Protokoll? (nur DASS, nie WAS) + Ändert er die Ampel? (darf nicht) + + Aufrufen mit: node server/pruef-befinden.mjs + ===================================================================== */ + +import { mkdtempSync, rmSync, readFileSync } 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(4419, "pruef-befinden"); + +const ordner = mkdtempSync(join(tmpdir(), "ws-bef-")); +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(180_000, "pruef-befinden"); +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 modul = await import("./workspace-befinden.js"); +const kat = await import("./workspace-entwicklungs-punkte.js"); +const rechte = await import("./rechte.js"); + +const oeffne = () => new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); + +let idDogi, idFrida; +{ + const d = oeffne(); + 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); + const 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; + }; + idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); + idFrida = anlegen("Frida", "modi", "CODE-MODI-0001"); + d.close(); +} + +function ruf(pfad, host, methode, kopf = {}, koerper = null) { + return new Promise((fertig, schief) => { + const a = httpAnfrage({ host: "127.0.0.1", port: PORT, path: pfad, method: methode, + 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 ruf("/workspace/api/anmelden", host, "POST", {}, { rolle, code }); + return [].concat(r.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; "); +} +/* DOGFATHER AUF DER TEAM-ADRESSE. + + Nicht aus Bequemlichkeit: Auf workspace. liefert der Server ihm + `bereiche: null` -- der Browser nimmt dort seine eigene Liste, und + die kennt diese Kachel gar nicht. Die Kachel, die Filipe im + Bildschirmfoto angetippt hat, steht auf crew. Wer sie auf der + falschen Adresse sucht, prueft an ihr vorbei. */ +const kDogi = await anmelden("admin", "CODE-DOGI-0001", CREW); +const kFrida = await anmelden("modi", "CODE-MODI-0001", CREW); +ok(!!kDogi && !!kFrida, "DogFather und ein Modi sind angemeldet"); + +/* ======================================================================= + 1. DIE KACHEL HÄLT, WAS SIE VERSPRICHT + + Der gemeldete Fehler. Er war schlimmer als ein falscher Verweis: Auf + der Kachel steht „die Antworten sieht nur du" -- und sie oeffnete + eine Seite voller Namen anderer Menschen. + ======================================================================= */ +melde(""); +melde("=== 1. Die Kachel ==="); +{ + const ich = json(await ruf("/workspace/api/ich", CREW, "GET", { cookie: kDogi })); + const alle = [...(ich.bereiche || []), ...(ich.bereiche_zusatz || [])]; + const kachel = alle.find((k) => /Wie geht/i.test(k.name || "")); + ok(!!kachel, `die Kachel „${kachel?.name}“ steht bei DogFather`); + ok(kachel?.ziel === "befinden.html", + `und zeigt auf die eigene Seite (${kachel?.ziel})`); + /* GEGENPROBE -- ohne sie beweist die Zeile nichts: Die Kachel + „Entwicklung" zeigt weiterhin auf die Entwicklungsseite. */ + const andere = alle.find((k) => k.name === "Entwicklung"); + ok(andere?.ziel === "entwicklung.html", + `die Kachel „Entwicklung“ zeigt weiterhin dorthin (${andere?.ziel})`); + + ok((rechte.tafelJetzt()["/workspace/befinden.html"] || []).includes("modi"), + "die Seite steht in der Rechtetafel und ein Modi darf sie öffnen"); + ok(!(rechte.tafelJetzt()["/workspace/befinden.html"] || []).includes("creator"), + "die Agentur nicht — sie hat auch keine Entwicklungskarte"); + + const beiFrida = json(await ruf("/workspace/api/ich", CREW, "GET", { cookie: kFrida })); + const ihre = [...(beiFrida.bereiche || []), ...(beiFrida.bereiche_zusatz || [])] + .find((k) => /Wie geht/i.test(k.name || "")); + ok(ihre?.ziel === "befinden.html", "und bei einem Modi führt sie ebenfalls dorthin"); +} + +/* ======================================================================= + 2. SECHS FRAGEN, EIN RHYTHMUS + ======================================================================= */ +melde(""); +melde("=== 2. Sechs Fragen, ein Rhythmus ==="); +{ + const d = json(await ruf("/workspace/api/befinden", CREW, "GET", { cookie: kFrida })); + ok(d.punkte?.length === kat.ENTWICKLUNG_SELBST.length, + `${d.punkte?.length} Fragen — so viele wie im Katalog`); + /* Die Recherche: fuenf bis fuenfzehn, unter zwei Minuten. Sechs sind + richtig; zwanzig waeren es nicht. */ + ok(d.punkte.length >= 5 && d.punkte.length <= 15, + "zwischen fünf und fünfzehn — kurz genug, dass man sie wirklich beantwortet"); + ok(d.rhythmus_tage === modul.RHYTHMUS_TAGE, + `alle ${d.rhythmus_tage} Tage wird wieder gefragt`); + ok(d.rhythmus?.faellig === true, "am Anfang ist sofort etwas fällig"); + ok(d.rhythmus?.letzte === null, "und es gab noch keine Runde"); + ok(Array.isArray(d.verlauf) && d.verlauf.length === 0, "der Verlauf ist leer"); + + /* EINE HALBE RUNDE IST KEINE RUNDE. */ + const halb = await ruf("/workspace/api/befinden/runde", CREW, "POST", { cookie: kFrida }, {}); + ok(halb.code === 400, `ohne Antworten lässt sich nichts festhalten (${halb.code})`); + ok(json(halb).fehler === "unvollstaendig" + && json(halb).offen === d.punkte.length, + `und die Absage sagt, wie viele fehlen (${json(halb).offen} von ${json(halb).gesamt})`); +} + +/* ======================================================================= + 3. EINE RUNDE, EIN VERLAUF + ======================================================================= */ +melde(""); +melde("=== 3. Der Verlauf entsteht ==="); +{ + const d = json(await ruf("/workspace/api/befinden", CREW, "GET", { cookie: kFrida })); + for (const p of d.punkte) { + await ruf(`/workspace/api/entwicklung/mich/${p.schluessel}`, CREW, "PUT", + { cookie: kFrida }, { stufe: "laeuft" }); + } + const erste = await ruf("/workspace/api/befinden/runde", CREW, "POST", { cookie: kFrida }, {}); + ok(erste.code === 201, `mit allen Antworten geht es (${erste.code})`); + ok(json(erste).anzahl === d.punkte.length, + `${json(erste).anzahl} Antworten sind in den Verlauf gewandert`); + + const nach = json(await ruf("/workspace/api/befinden", CREW, "GET", { cookie: kFrida })); + ok(nach.verlauf.length === 1, `der Verlauf hat ${nach.verlauf.length} Runde`); + ok(nach.rhythmus.faellig === false, + "und es ist erst einmal nichts mehr fällig — die Seite fragt nicht jeden Tag"); + ok(nach.rhythmus.vollstaendig === true, "die Runde gilt als vollständig"); + + /* Eine zweite Runde mit anderen Antworten -- erst dann gibt es ein + Muster, und erst dann ist der Verlauf ueberhaupt etwas wert. */ + await ruf(`/workspace/api/entwicklung/mich/${d.punkte[0].schluessel}`, CREW, "PUT", + { cookie: kFrida }, { stufe: "hakt", anlass: "Diese Woche war viel los." }); + const zweite = await ruf("/workspace/api/befinden/runde", CREW, "POST", { cookie: kFrida }, {}); + ok(zweite.code === 201, `eine zweite Runde geht auch sofort (${zweite.code})`); + + const nach2 = json(await ruf("/workspace/api/befinden", CREW, "GET", { cookie: kFrida })); + ok(nach2.verlauf.length === 2, `jetzt ${nach2.verlauf.length} Runden`); + const neu = nach2.verlauf[0].antworten[d.punkte[0].schluessel]; + const alt = nach2.verlauf[1].antworten[d.punkte[0].schluessel]; + ok(neu === "hakt" && alt === "laeuft", + `und man sieht die Veränderung (${alt} → ${neu})`); + /* DIE ANTWORTEN DER ALTEN RUNDE BLEIBEN, WIE SIE WAREN. Ein Verlauf, + der sich rueckwirkend aendert, ist kein Verlauf. */ + ok(Object.keys(nach2.verlauf[1].antworten).length === d.punkte.length, + "die alte Runde steht unverändert da"); +} + +/* ======================================================================= + 4. ES BLEIBT PRIVAT + + Die eigentliche Frage. Ein Verlauf ueber das Befinden eines Menschen + ist das Empfindlichste, was dieses Haus speichert. + ======================================================================= */ +melde(""); +melde("=== 4. Privat ==="); +{ + /* DogFather bekommt SEINEN Verlauf, nicht ihren. */ + const seiner = json(await ruf("/workspace/api/befinden", CREW, "GET", { cookie: kDogi })); + ok(seiner.verlauf.length === 0, + `DogFather hat einen eigenen, leeren Verlauf (${seiner.verlauf.length})`); + + /* Es gibt gar keinen Weg zu einem fremden. */ + const fremd = await ruf(`/workspace/api/befinden?person=${idFrida}`, CREW, "GET", { cookie: kDogi }); + ok(json(fremd).verlauf?.length === 0, + "auch mit einer fremden Nummer in der Adresse bekommt er nur seinen eigenen"); + + /* Im Protokoll steht nur, DASS eine Runde war -- nie was darin stand. */ + const d = oeffne(); + const spur = d.prepare( + "SELECT detail FROM protokoll WHERE aktion = 'befinden_runde' ORDER BY id DESC").all(); + const verlaufZeilen = d.prepare("SELECT COUNT(*) AS n FROM befinden_verlauf").get().n; + d.close(); + ok(spur.length === 2, `${spur.length} Runden stehen im Protokoll`); + ok(spur.every((z) => !/laeuft|hakt|waechst|unklar/.test(z.detail || "")), + `aber keine einzige Antwort darin (${spur[0]?.detail})`); + ok(verlaufZeilen === 12, `im Verlauf liegen ${verlaufZeilen} Antworten (2 Runden x 6)`); + + /* UND DIE AMPEL ZAEHLT WEITER NUR DEN AKTUELLEN STAND, ohne Namen. */ + const a = await ruf("/workspace/api/entwicklung/ampel", CREW, "GET", { cookie: kDogi }); + ok(a.code === 200, `die Ampel antwortet (${a.code})`); + ok(!/Frida/.test(a.text), "und nennt keinen Namen"); +} + +/* ======================================================================= + 5. DIE ENTWICKLUNGSSEITE TUT NUR NOCH EINE SACHE + ======================================================================= */ +melde(""); +melde("=== 5. Eine Seite, ein Zweck ==="); +{ + const e = readFileSync(new URL("../workspace/entwicklung.html", import.meta.url), "utf8"); + const b = readFileSync(new URL("../workspace/befinden.html", import.meta.url), "utf8"); + + ok(/href="befinden\.html"/.test(e), + "die Entwicklungsseite verweist auf die sechs Fragen"); + ok(!/id="meins"/.test(e), + "und zeichnet sie nicht mehr selbst — zwei Stände wären zwei Wahrheiten"); + ok(/id="fragen"/.test(b) && /id="verlauf"/.test(b), + "die neue Seite hat die Fragen und den Verlauf"); + /* AUF DER EIGENEN SEITE STEHT KEIN FREMDER NAME -- nicht einmal ein + Platz dafuer. */ + ok(!/personen-block|meine-karte|abweichung/.test(b), + "und keinen einzigen Abschnitt über andere Menschen"); + + ok(/befinden\.js/.test(b) && !/entwicklung\.js/.test(b), + "sie lädt ihr eigenes Skript, nicht das der Entwicklungsseite"); +} + +/* ======================================================================= + 6. IM BROWSER + ======================================================================= */ +melde(""); +melde("=== 6. Im Browser ==="); +{ + const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); + const browser = await chromium.launch(); + const kontext = await browser.newContext({ viewport: { width: 1280, height: 1000 } }); + const seite = await kontext.newPage(); + const konsole = []; + seite.on("console", (m) => { if (m.type() === "error" && !/favicon|ERR_/i.test(m.text())) konsole.push(m.text()); }); + seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message)); + + /* ALS MODI, NICHT ALS DOGFATHER: Die Seite gehoert dem Menschen, der + sie oeffnet -- und der haeufigste Fall ist ein Modi. Der Keks kommt + von der Anmeldung oben, weil Playwright die Host-Kopfzeile beim + Seitenaufruf nicht setzen kann. */ + await kontext.addCookies(kFrida.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: "/" }; + })); + await seite.goto(`http://127.0.0.1:${PORT}/workspace/start.html`, { waitUntil: "networkidle" }); + await seite.waitForTimeout(700); + + /* DER GEMELDETE FEHLER, IM BROWSER NACHGESTELLT: auf die Kachel + druecken und nachsehen, wo man landet. */ + const ziel = await seite.evaluate(() => { + const k = [...document.querySelectorAll('a')] + .find((a) => /Wie geht/i.test(a.textContent || '')); + return k ? k.getAttribute('href') : null; + }); + ok(/befinden\.html/.test(ziel || ""), + `die Kachel auf der Startseite führt zu ${ziel}`); + + await seite.goto(`http://127.0.0.1:${PORT}/workspace/befinden.html`, { waitUntil: "networkidle" }); + await seite.waitForTimeout(900); + + const stand = await seite.evaluate(() => ({ + fragen: document.querySelectorAll('.e-punkt--meins').length, + knoepfe: document.querySelectorAll('.e-wahl__knopf').length, + takt: document.getElementById('takt')?.hidden === false, + taktText: document.getElementById('takt-satz')?.textContent.trim() || '', + verlauf: document.getElementById('verlauf-block')?.hidden === false, + spalten: document.querySelectorAll('.b-verlauf__zeile--kopf .b-verlauf__datum').length, + punkte: document.querySelectorAll('.b-verlauf__punkt').length, + text: document.querySelector('main')?.innerText || '', + })); + ok(stand.fragen >= 5, `${stand.fragen} Fragen stehen auf der Seite`); + ok(stand.knoepfe === stand.fragen * 4, `mit je vier Knöpfen (${stand.knoepfe})`); + ok(stand.takt === true, `der Rhythmus steht oben: „${stand.taktText.slice(0, 70)}“`); + /* FRIDA HAT ZWEI RUNDEN -- bei ihr MUSS er dastehen. */ + ok(stand.verlauf === true, "ihr Verlauf steht auf der Seite"); + ok(stand.spalten === 2, `mit zwei Ständen nebeneinander (${stand.spalten})`); + ok(stand.punkte === 12, `und zwölf Antworten darin (${stand.punkte})`); + /* KEIN FREMDER NAME AUF DEM BILDSCHIRM. Ihr eigener darf oben in der + Kopfleiste stehen -- gemessen wird deshalb nur der Inhalt, und + gesucht wird nach DogFather, also nach dem, der hier nichts zu + suchen hat. */ + ok(!/Filipe/.test(stand.text), "und kein anderer Mensch steht darauf"); + + /* GEGENPROBE mit einem zweiten Menschen: DogFather hat keine einzige + Runde. Bei ihm muss derselbe Block WEG sein -- sonst hiesse „er + erscheint ab der zweiten Runde" nur, dass er immer erscheint. */ + const zweiter = await browser.newContext({ viewport: { width: 1280, height: 1000 } }); + await zweiter.addCookies(kDogi.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: "/" }; + })); + const seite2 = await zweiter.newPage(); + await seite2.goto(`http://127.0.0.1:${PORT}/workspace/befinden.html`, { waitUntil: "networkidle" }); + await seite2.waitForTimeout(800); + const ohne = await seite2.evaluate(() => ({ + fragen: document.querySelectorAll('.e-punkt--meins').length, + verlauf: document.getElementById('verlauf-block')?.hidden === false, + knopf: document.getElementById('takt-knopf')?.hidden === false, + })); + ok(ohne.fragen >= 5, `auch bei ihm stehen die Fragen da (${ohne.fragen})`); + ok(ohne.verlauf === false, "aber kein Verlauf — er hat noch keine Runde"); + ok(ohne.knopf === false, + "und kein Knopf zum Abschließen, solange nichts beantwortet ist"); + + ok(konsole.length === 0, + konsole.length ? "Fehler im Browser: " + konsole[0].slice(0, 90) : "keine Fehler im Browser"); + await browser.close(); +} + +/* ------------------------------------------------------------------- */ +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-entwicklung.mjs b/server/pruef-entwicklung.mjs index c8681aaa..44e75678 100644 --- a/server/pruef-entwicklung.mjs +++ b/server/pruef-entwicklung.mjs @@ -293,11 +293,23 @@ console.log("=== Die Kacheln ==="); ["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)); + /* SEIT DEM 15.09.2026 HAT SIE EINE EIGENE SEITE. + + Bis dahin zeigte sie auf entwicklung.html -- und damit auf Namen + und Beobachtungen ueber ANDERE, waehrend auf der Kachel steht + "die Antworten sieht nur du". Filipe hat es gemeldet, und die + Zeile hier hat den Fehler mitgetragen, weil sie genau danach + gesucht hat. */ + const befinden = alleX.find((k) => k.ziel === "befinden.html"); ok(!!befinden, `${wer} hat „${befinden?.name || "—"}"`); ok(befinden?.gruppe === "Für dich", `und sie steht bei „Für dich" (${befinden?.gruppe})`); + /* GEGENPROBE: Sie fuehrt NICHT mehr dorthin, wo andere Menschen + stehen -- sonst waere der gemeldete Fehler beim naechsten Umbau + still wieder da. */ + ok(!alleX.some((k) => k.ziel === "entwicklung.html" + && !["Entwicklung", "Talente"].includes(k.name)), + `${wer}: keine Kachel fuehrt mehr ersatzweise auf die Entwicklungsseite`); } const a = await hole("/workspace/api/ich", { host: CREW, keks: ayla.keks }); diff --git a/server/rechte.js b/server/rechte.js index f322e3af..acab947a 100644 --- a/server/rechte.js +++ b/server/rechte.js @@ -292,6 +292,12 @@ export const SEITEN = { ZWEI SCHLOESSER, ABSICHTLICH. Faellt die Rechtepruefung im Modul weg, steht immer noch diese Zeile; faellt diese Zeile weg, steht immer noch das Modul. */ + /* WIE GEHT'S DIR? -- dieselben Rollen wie die Entwicklungsseite, und + das ist kein Zufall: Es sind die Menschen, ueber die dort etwas + steht. Wer beobachtet wird, darf auch ueber sich selbst sprechen. + Die Agentur hat diese Seite nicht -- sie hat auch keine + Entwicklungskarte. */ + "/workspace/befinden.html": ["admin", "hand", "modi"], "/workspace/entwicklung.html": ["admin", "hand", "modi"], /* TALENTE. Hier stehen Namen von Menschen, die nichts davon wissen -- diff --git a/server/workspace-befinden.js b/server/workspace-befinden.js new file mode 100644 index 00000000..f700736a --- /dev/null +++ b/server/workspace-befinden.js @@ -0,0 +1,255 @@ +/* ===================================================================== + Wie geht's dir? (15.09.2026) + + Filipe, mit dem Bildschirmfoto der Kachel: *„ich will dass du diese + seite perfektionnierst, denn gerade wenn ich drauf drücke geht die + entwicklungsseite auf."* + + Er hat recht, und es war schlimmer als ein falscher Verweis: Die + Kachel verspricht „sechs Fragen – die Antworten sieht nur du" und + öffnete eine Seite mit Namen und Beobachtungen über andere Menschen. + Wer ihr glaubt und draufdrückt, sieht im ersten Moment das Gegenteil + dessen, was draufsteht. + + --------------------------------------------------------------------- + EINE SEITE, EIN ZWECK + + `entwicklung.html` bediente zwei Fragen: „wie geht es dem Team?" und + „wie geht es mir?". Das ist eine Seite zu wenig. Die sechs Fragen + haben jetzt ihre eigene — und `entwicklung.html` wird dadurch selbst + einfacher, weil sie nur noch eine Sache tut. + + --------------------------------------------------------------------- + WAS DIE RECHERCHE ERGEBEN HAT (15.09.2026) + + Zu Puls-/Befindensabfragen in kleinen Teams sagen die Quellen + übereinstimmend dreierlei: + + 1. KURZ. Fünf bis fünfzehn Fragen, unter zwei Minuten. Sechs sind + also richtig — daran ändert sich nichts. + + 2. WIEDERHOLT. Monatlich oder zweiwöchentlich. Genau das fehlte: + Bisher beantwortete man die sechs Fragen EINMAL, und die Antwort + stand für immer. Ein Befinden von vor drei Monaten ist keine + Auskunft mehr, sondern ein Andenken. + + 3. DER VERLAUF IST DIE AUSKUNFT, nicht der Einzelwert. „Niedrige + Energie über mehrere Wochen" ist das Warnzeichen — ein einzelnes + „da hakt es" ist ein schlechter Tag. + + Deshalb zwei Dinge neu: ein RHYTHMUS (nach 14 Tagen wird wieder + gefragt) und ein VERLAUF, den nur die Person selbst sieht. + + Quellen: + https://www.specific.app/blog/pulse-survey-questions-best-questions-for-burnout-to-detect-and-address-employee-engagement-risk + https://formbricks.com/blog/pulse-survey-questions + https://www.vantagecircle.com/en/blog/pulse-surveys/ + + --------------------------------------------------------------------- + DER VERLAUF GEHÖRT IHR ALLEIN + + Er wird in einer eigenen Tabelle geführt, und kein anderer Weg im + Haus liest sie. Die Ampel (wie viele es gerade schwer haben) zählt + weiterhin nur den AKTUELLEN Stand und kennt keine Namen — daran + ändert der Verlauf nichts. + + Das ist kein Nebensatz: Eine Verlaufsakte über das Befinden eines + Menschen ist das Empfindlichste, was dieses Haus speichern kann. Sie + existiert nur, weil sie IHM hilft, sein eigenes Muster zu sehen. + ===================================================================== */ + +import express from "express"; +import { db, sitzungLesen, protokolliere, echteIp } from "./workspace.js"; +import { + ENTWICKLUNG_BLOECKE, ENTWICKLUNG_PUNKTE, ENTWICKLUNG_STUFEN, +} from "./workspace-entwicklungs-punkte.js"; +import { tabellen as entwicklungTabellen } from "./workspace-entwicklung.js"; + +export const befindenRouter = express.Router(); + +/** Nach wie vielen Tagen wieder gefragt wird. + * + * 14 Tage — zweiwöchentlich, das untere Ende dessen, was die Quellen + * empfehlen (monatlich bis zweiwöchentlich). Häufiger wäre + * Umfragemüdigkeit: Wer jede Woche gefragt wird, klickt irgendwann + * dasselbe an wie beim letzten Mal, ohne nachzudenken — und dann misst + * die Abfrage nur noch sich selbst. */ +export const RHYTHMUS_TAGE = 14; + +/** So viele frühere Antworten bekommt die Person zu sehen. + * + * Sechs Stände sind drei Monate — lang genug, um ein Muster zu sehen, + * kurz genug, dass es kein Archiv wird. */ +export const VERLAUF_MAX = 6; + +const jetzt = () => new Date().toISOString(); + +let bereit = false; +function tabellen() { + if (bereit) return; + db().exec(` + /* DER VERLAUF DES EIGENEN BEFINDENS. + + Eigene Tabelle und nicht „entwicklung_stand“: Dort steht der + AKTUELLE Stand, einer je Punkt und Beurteiler, und der wird + ueberschrieben. Genau das ist dort richtig -- eine Beobachtung + ueber einen Menschen soll aktuell sein, nicht historisch. + + Hier ist es umgekehrt: Der Verlauf IST die Auskunft. Zwei + verschiedene Beduerfnisse, zwei Tabellen. Sie in eine zu zwingen + hiesse, eines von beiden falsch zu machen. + + KEIN von_id: Was hier steht, hat die Person ueber sich selbst + gesagt. Eine Spalte fuer "wer hat das eingetragen" waere die + Einladung, eines Tages auch jemand anderen eintragen zu lassen. */ + CREATE TABLE IF NOT EXISTS befinden_verlauf ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE, + runde TEXT NOT NULL, -- Zeitpunkt der Runde, alle sechs teilen ihn + schluessel TEXT NOT NULL, + stufe TEXT NOT NULL, + am TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_befinden_person + ON befinden_verlauf (person_id, runde); + `); + /* UND DIE TABELLE, AUS DER DIE ANTWORTEN KOMMEN. + + Sie wird hier nicht angelegt, sondern angefordert: Die sechs + Antworten stehen in `entwicklung_stand`, und die legt die + Entwicklungsseite an -- beim ersten Aufruf, nicht beim Start. + Wer die neue Seite oeffnete, bevor irgendjemand die + Entwicklungsseite besucht hatte, bekam deshalb "no such table" + und eine 503. Gefunden hat das die Pruefung, nicht das Lesen. */ + entwicklungTabellen(); + bereit = true; +} + +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("[befinden] Tabellen:", f?.message); + return res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + next(); +} + +/** Der Block mit den sechs Fragen. Er ist als einziger `nurSelbst`. */ +function block() { + return ENTWICKLUNG_BLOECKE.find((b) => b.nurSelbst) || null; +} + +/** Wann war die letzte vollständige Runde — und ist wieder Zeit? */ +function rhythmus(personId) { + const b = block(); + const gesamt = (ENTWICKLUNG_PUNKTE[b.schluessel] || []).length; + const letzte = db().prepare( + `SELECT runde, COUNT(*) AS n FROM befinden_verlauf + WHERE person_id = ? GROUP BY runde ORDER BY runde DESC LIMIT 1`).get(personId); + if (!letzte) return { letzte: null, faellig: true, tage: null, vollstaendig: false }; + const tage = Math.floor((Date.now() - Date.parse(letzte.runde)) / 86400000); + return { + letzte: letzte.runde, + tage, + vollstaendig: letzte.n >= gesamt, + /* FAELLIG heisst: die letzte Runde ist alt genug -- ODER sie war nie + fertig. Eine halb beantwortete Runde ist keine Runde. */ + faellig: tage >= RHYTHMUS_TAGE || letzte.n < gesamt, + }; +} + +/* ===================================================================== + DIE SEITE + ===================================================================== */ + +befindenRouter.get("/workspace/api/befinden", angemeldet, (req, res) => { + try { + const b = block(); + if (!b) return res.status(503).json({ fehler: "kein_block" }); + + 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])); + + /* DER VERLAUF -- nach Runden gebuendelt, neueste zuerst. */ + const runden = db().prepare( + `SELECT runde FROM befinden_verlauf WHERE person_id = ? + GROUP BY runde ORDER BY runde DESC LIMIT ?`).all(req.person.id, VERLAUF_MAX) + .map((z) => z.runde); + const zeilen = runden.length + ? db().prepare( + `SELECT runde, schluessel, stufe FROM befinden_verlauf + WHERE person_id = ? AND runde IN (${runden.map(() => "?").join(",")})`) + .all(req.person.id, ...runden) + : []; + const verlauf = runden.map((r) => ({ + runde: r, + antworten: Object.fromEntries( + zeilen.filter((z) => z.runde === r).map((z) => [z.schluessel, z.stufe])), + })); + + res.json({ + block: b, + stufen: ENTWICKLUNG_STUFEN, + rhythmus_tage: RHYTHMUS_TAGE, + rhythmus: rhythmus(req.person.id), + punkte: (ENTWICKLUNG_PUNKTE[b.schluessel] || []).map((pt) => ({ + ...pt, meins: meine.get(pt.schluessel) || null, + })), + verlauf, + }); + } catch (f) { + console.error("[befinden] Lesen:", f?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Eine Runde abschließen: alle sechs Antworten wandern in den Verlauf. + * + * ALS RUNDE UND NICHT JE KLICK: Ein Verlauf, der jeden einzelnen Klick + * festhält, zeigt kein Muster, sondern Rauschen — und er würde jedes + * Zögern mitschreiben. Was zählt, ist der Stand, wenn jemand fertig + * ist. */ +befindenRouter.post("/workspace/api/befinden/runde", express.json({ limit: "8kb" }), + angemeldet, (req, res) => { + try { + const b = block(); + const punkte = ENTWICKLUNG_PUNKTE[b.schluessel] || []; + const meine = new Map(db().prepare( + `SELECT schluessel, stufe FROM entwicklung_stand + WHERE person_id = ? AND von_id = ?`) + .all(req.person.id, req.person.id).map((z) => [z.schluessel, z.stufe])); + + const fehlt = punkte.filter((p) => !meine.has(p.schluessel)); + if (fehlt.length) { + /* EINE HALBE RUNDE IST KEINE RUNDE. Sie wuerde im Verlauf wie + ein vollstaendiger Stand aussehen und das Muster verfaelschen. */ + return res.status(400).json({ + fehler: "unvollstaendig", offen: fehlt.length, gesamt: punkte.length, + }); + } + + const runde = jetzt(); + const rein = db().prepare( + `INSERT INTO befinden_verlauf (person_id, runde, schluessel, stufe, am) + VALUES (?,?,?,?,?)`); + for (const p of punkte) rein.run(req.person.id, runde, p.schluessel, meine.get(p.schluessel), runde); + + /* IM PROTOKOLL STEHT NUR, DASS es passiert ist -- nicht was. + Das Protokoll liest DogFather; die Antworten gehen ihn nichts + an, und ein "detail" mit Stufen darin waere genau die + Hintertuer, die es hier nicht geben darf. */ + protokolliere("befinden_runde", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `${punkte.length} Fragen`, + }); + + res.status(201).json({ runde, anzahl: punkte.length, rhythmus: rhythmus(req.person.id) }); + } catch (f) { + console.error("[befinden] Runde:", f?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); diff --git a/server/workspace-entwicklung.js b/server/workspace-entwicklung.js index 9a97aefe..56d6bea2 100644 --- a/server/workspace-entwicklung.js +++ b/server/workspace-entwicklung.js @@ -97,7 +97,14 @@ const KARTEN_ROLLEN = ["hand", "modi"]; ===================================================================== */ let bereit = false; -function tabellen() { +/** Auch von der Befindensseite aufgerufen. + * + * Sie liest `entwicklung_stand`, legt die Tabelle aber nicht selbst an + * -- eine zweite Abschrift derselben Spalten waere die Liste, die beim + * naechsten Zuwachs auseinanderlaeuft. Gefunden hat das die Pruefung: + * Wer die neue Seite oeffnete, ohne dass vorher jemand die + * Entwicklungsseite besucht hatte, bekam "no such table". */ +export function tabellen() { if (bereit) return; db().exec(` /* Eine Einschaetzung: WER hat bei WEM zu WELCHEM Punkt was gesetzt. diff --git a/server/workspace.js b/server/workspace.js index c5efe3bb..d3694fb9 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -833,10 +833,24 @@ const RECHTE_KACHEL = { zeichen: "schutz", ton: 35, ziel: "rechte.html", szene: "studio", }; +/* WIE GEHT'S DIR? -- EIGENE SEITE SEIT DEM 15.09.2026. + + Filipe mit dem Bildschirmfoto der Kachel: "ich will dass du diese + seite perfektionnierst, denn gerade wenn ich drauf druecke geht die + entwicklungsseite auf." + + Er hatte recht, und es war schlimmer als ein falscher Verweis: Die + Kachel verspricht "die Antworten sieht nur du" und oeffnete eine + Seite mit Namen und Beobachtungen ueber ANDERE Menschen. Wer ihr + glaubt und draufdrueckt, sieht im ersten Moment das Gegenteil dessen, + was draufsteht. + + Jetzt: eine Seite, ein Zweck. entwicklung.html wird dadurch selbst + einfacher -- sie tut nur noch eine Sache. */ 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", + zeichen: "steckbrief", ton: 37, ziel: "befinden.html", szene: "lounge", }; const HAND_BEREICHE = vorGruppe( diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index ba8491e8..e0cfa737 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=202609151328", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609151345", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609151328", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609151345", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609151328", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609151345", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609151328", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609151345", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/entwicklung.css b/workspace/assets/css/entwicklung.css index d819d34e..e04885c9 100644 --- a/workspace/assets/css/entwicklung.css +++ b/workspace/assets/css/entwicklung.css @@ -427,3 +427,75 @@ /* „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; } + +/* ===================================================================== + WIE GEHT'S DIR? -- die eigene Seite (15.09.2026) + ===================================================================== */ + +/* Der Takt steht ueber den Fragen: Wer hereinkommt, soll zuerst + wissen, ob gerade etwas von ihm erwartet wird. */ +.b-takt { + margin: 0 0 22px; padding: 14px 17px; + background: color-mix(in srgb, var(--tinte) 94%, #ffffff 6%); + border: 1px solid var(--rand); + border-left: 3px solid var(--rand-hell); + display: flex; flex-wrap: wrap; align-items: center; gap: 12px; +} +/* Faellig heisst: es ist Zeit. Kein Alarm -- eine Einladung. */ +.b-takt[data-lage="faellig"] { border-left-color: color-mix(in srgb, #8ec9ff 60%, transparent); } +.b-takt[data-lage="bereit"] { border-left-color: color-mix(in srgb, #668e6e 60%, transparent); } +.b-takt__satz { + margin: 0; flex: 1 1 24ch; max-width: 70ch; + font-size: .86rem; line-height: 1.6; color: var(--text-leise); +} + +.b-verlauf { margin: 34px 0 0; } +.b-verlauf__satz { + margin: 0 0 14px; max-width: 68ch; + font-size: .85rem; line-height: 1.6; color: var(--text-leise); +} +.b-verlauf__gitter { + display: grid; gap: 4px; + padding: 14px 16px; + background: color-mix(in srgb, var(--tinte) 94%, #ffffff 6%); + border: 1px solid var(--rand); + overflow-x: auto; +} +.b-verlauf__zeile { + display: grid; + grid-template-columns: minmax(12ch, 1fr) repeat(auto-fit, minmax(2.4rem, 2.4rem)); + align-items: center; gap: 6px; +} +.b-verlauf__frage { + font-size: .8rem; line-height: 1.35; color: var(--text-leise); + padding-right: 8px; +} +.b-verlauf__datum { + font-size: .72rem; color: var(--text-still); text-align: center; + font-variant-numeric: tabular-nums; +} +.b-verlauf__zeile--kopf { margin-bottom: 4px; } +/* Das Zeichen sagt die Antwort, die Farbe verstaerkt sie nur -- wer + Farben schlecht sieht, liest dasselbe Zeichen. */ +.b-verlauf__punkt { + display: grid; place-items: center; + width: 2.2rem; height: 1.9rem; + font-size: .9rem; font-weight: 700; + border-radius: var(--radius); + color: var(--text-still); + background: rgba(0, 0, 0, .25); + border: 1px solid var(--rand); +} +.b-verlauf__punkt[data-stufe="laeuft"] { color: #7fb08a; border-color: color-mix(in srgb, #668e6e 55%, transparent); } +.b-verlauf__punkt[data-stufe="waechst"] { color: #8ec9ff; border-color: color-mix(in srgb, #8ec9ff 45%, transparent); } +.b-verlauf__punkt[data-stufe="hakt"] { color: #d8a76a; border-color: color-mix(in srgb, #cc9451 55%, transparent); } +.b-verlauf__punkt[data-stufe="leer"] { opacity: .4; } + +/* Der Verweis auf der Entwicklungsseite -- ruhig, er ist kein Angebot, + sondern ein Hinweis. */ +.e-meins--verweis { margin-top: 30px; } + +@media (max-width: 560px) { + .b-verlauf__zeile { grid-template-columns: minmax(9ch, 1fr) repeat(auto-fit, minmax(2rem, 2rem)); } + .b-verlauf__punkt { width: 1.9rem; } +} diff --git a/workspace/assets/js/befinden.js b/workspace/assets/js/befinden.js new file mode 100644 index 00000000..7874949a --- /dev/null +++ b/workspace/assets/js/befinden.js @@ -0,0 +1,220 @@ +/* ===================================================================== + Wie geht's dir? (15.09.2026) + + Eine Seite, ein Zweck. Vorher führte die Kachel auf die + Entwicklungsseite — also auf Namen und Beobachtungen über andere + Menschen, während auf der Kachel „die Antworten sieht nur du" stand. + + --------------------------------------------------------------------- + DREI DINGE, DIE DIESE SEITE ANDERS MACHT ALS EIN FRAGEBOGEN + + 1. SIE SPEICHERT SOFORT. Kein „Absenden", das man vergessen kann. + Jeder Klick steht. Der Knopf am Ende schließt nur die RUNDE ab — + er trägt den Stand in den Verlauf ein. + + 2. SIE FRAGT WIEDER. Nach vierzehn Tagen. Ein Befinden von vor drei + Monaten ist keine Auskunft mehr, sondern ein Andenken. + + 3. SIE ZEIGT DEN VERLAUF. Das Warnzeichen ist nicht ein einzelnes + „da hakt es" — das ist ein schlechter Tag. Es ist dieselbe Antwort + über mehrere Runden. Sehen kann das nur die Person selbst. + ===================================================================== */ +(() => { + 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 stufen = []; + let daten = null; + + /* ---- Die vier Knöpfe zu einer Frage --------------------------------- */ + function knopfsatz(gewaehlt, beiKlick) { + 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(gewaehlt === s.schluessel)); + b.append(el('span', 'e-wahl__zeichen', s.zeichen)); + b.append(document.createTextNode(s.name)); + b.addEventListener('click', () => beiKlick(s.schluessel, b)); + reihe.append(b); + } + return reihe; + } + + /* ---- Der Rhythmus --------------------------------------------------- */ + function takt() { + const r = daten.rhythmus; + const kasten = $('takt'); + const satz = $('takt-satz'); + const knopf = $('takt-knopf'); + kasten.hidden = false; + + const offen = daten.punkte.filter((p) => !p.meins).length; + const alle = daten.punkte.length; + + if (!r.letzte) { + satz.textContent = offen + ? `Noch ${offen} von ${alle} Fragen offen. Wenn du durch bist, halte den Stand fest – ` + + `dann siehst du beim nächsten Mal, was sich verändert hat.` + : 'Alle sechs beantwortet. Halte den Stand fest, dann hast du beim nächsten Mal ' + + 'einen Vergleich.'; + kasten.dataset.lage = offen ? 'offen' : 'bereit'; + } else if (r.faellig) { + satz.textContent = `Deine letzte Runde ist ${r.tage} Tage her. ` + + (offen ? `Noch ${offen} von ${alle} Fragen offen.` + : 'Geh die sechs Fragen noch einmal durch – es dauert zwei Minuten.'); + kasten.dataset.lage = 'faellig'; + } else { + const rest = daten.rhythmus_tage - r.tage; + /* „Vor 0 Tagen" ist keine Zeitangabe, sondern eine Rechnung, die + man laut vorliest. Drei Faelle, drei Saetze -- derselbe Grund, + aus dem die Ampel nebenan drei hat. */ + const wann = r.tage === 0 ? 'Heute festgehalten.' + : r.tage === 1 ? 'Gestern festgehalten.' + : `Zuletzt vor ${r.tage} Tagen festgehalten.`; + satz.textContent = `${wann} ` + + (rest <= 1 ? 'Morgen fragt die Seite wieder – ' + : `In ${rest} Tagen fragt die Seite wieder – `) + + `du kannst aber jederzeit etwas ändern.`; + kasten.dataset.lage = 'ruhig'; + } + + /* DER KNOPF ERSCHEINT NUR, WENN ER ETWAS TUN KANN. Ein Knopf, der + "erst alle sechs beantworten" sagt, ist eine Absage mit + Umweg -- der Satz darüber sagt es schon. */ + knopf.hidden = offen > 0; + } + + /* ---- Die sechs Fragen ------------------------------------------------ */ + function fragen() { + const ziel = $('fragen'); + ziel.textContent = ''; + ziel.setAttribute('aria-busy', 'false'); + + for (const pt of daten.punkte) { + const z = el('article', 'e-punkt e-punkt--meins'); + if (pt.meins) z.dataset.stufe = pt.meins.stufe; + z.append(el('h3', '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.'); return; } + melde(''); + /* Den Stand im Kopf mitführen, statt die ganze Seite neu zu + holen: Ein Neuaufbau nach jedem Klick würde die Seite unter + den Fingern springen lassen. */ + pt.meins = { ...(pt.meins || {}), stufe }; + takt(); + })); + ziel.append(z); + } + } + + /* ---- Der Verlauf ----------------------------------------------------- */ + function verlauf() { + const block = $('verlauf-block'); + const v = daten.verlauf || []; + if (v.length < 2) { + /* MIT EINER EINZIGEN RUNDE GIBT ES KEINEN VERLAUF. Eine Spalte + allein sähe aus wie ein Muster und wäre keins. */ + block.hidden = true; + return; + } + block.hidden = false; + $('verlauf-satz').textContent = + `Deine letzten ${v.length} Stände. Was zählt, ist nicht ein einzelnes Kreuz – ` + + `sondern dieselbe Antwort mehrmals hintereinander.`; + + const ziel = $('verlauf'); + ziel.textContent = ''; + + /* Älteste links: Ein Verlauf liest sich von links nach rechts. */ + const runden = [...v].reverse(); + const kopf = el('div', 'b-verlauf__zeile b-verlauf__zeile--kopf'); + kopf.append(el('span', 'b-verlauf__frage', '')); + for (const r of runden) { + const d = new Date(r.runde); + kopf.append(el('span', 'b-verlauf__datum', + d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }))); + } + ziel.append(kopf); + + const name = {}; + for (const s of stufen) name[s.schluessel] = s; + + for (const pt of daten.punkte) { + const zeile = el('div', 'b-verlauf__zeile'); + zeile.append(el('span', 'b-verlauf__frage', pt.titel)); + for (const r of runden) { + const stufe = r.antworten[pt.schluessel] || null; + const p = el('span', 'b-verlauf__punkt', stufe ? name[stufe]?.zeichen || '·' : '·'); + p.dataset.stufe = stufe || 'leer'; + /* DAS ZEICHEN ALLEIN IST NICHT DIE AUSKUNFT -- der Name steht + im Titel, damit auch ein Vorleseprogramm ihn ansagt. */ + p.title = stufe ? `${pt.titel}: ${name[stufe]?.name || stufe}` : 'keine Antwort'; + p.setAttribute('aria-label', p.title); + zeile.append(p); + } + ziel.append(zeile); + } + } + + /* ---- Runde abschließen ----------------------------------------------- */ + $('takt-knopf')?.addEventListener('click', async (e) => { + const k = e.currentTarget; + k.disabled = true; + try { + const a = await hole('/workspace/api/befinden/runde', + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); + const j = await a.json().catch(() => ({})); + if (!a.ok) { + melde(j.fehler === 'unvollstaendig' + ? `Es fehlen noch ${j.offen} von ${j.gesamt} Fragen.` + : 'Ging nicht.'); + return; + } + melde('Festgehalten. Beim nächsten Mal siehst du, was sich verändert hat.'); + await laden(); + } finally { k.disabled = false; } + }); + + async function laden() { + const a = await hole('/workspace/api/befinden'); + if (!a.ok) { melde('Ging nicht.'); return; } + daten = await a.json(); + stufen = daten.stufen || []; + takt(); + fragen(); + verlauf(); + } + + (async () => { + try { + const a = await fetch('/workspace/api/ich', { credentials: 'same-origin' }); + if (a.ok) window.werZeigen?.(await a.json()); + } catch { /* der Kopf holt es sonst selbst */ } + try { await laden(); } catch { /* umgeleitet */ } + })(); +})(); diff --git a/workspace/assets/js/entwicklung.js b/workspace/assets/js/entwicklung.js index 2b44f690..090fae80 100644 --- a/workspace/assets/js/entwicklung.js +++ b/workspace/assets/js/entwicklung.js @@ -310,39 +310,6 @@ } } - /* ---- 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 { @@ -363,16 +330,27 @@ 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?"; + /* Für einen Modi ist DIESE Seite seine eigene Karte — das, + was die Leitung über ihn notiert hat, und was er selbst + dazu sagt. Nicht „Entwicklung" als Überschrift: Das liest + sich wie eine Beurteilung, und darum geht es hier nicht. + + Bis zum 15.09.2026 stand hier „Wie geht's dir?" — damals + bestand die Seite für ihn aus den sechs Fragen. Die haben + jetzt ihre eigene Seite, also stimmte der Titel nicht mehr. + Ein Titel, der etwas anderes verspricht als die Seite zeigt, + ist derselbe Fehler wie der an der Kachel. */ + $('titel').textContent = 'Deine Entwicklung'; $('unterzeile').textContent = - 'Sechs Fragen über dich. Niemand aus dem Team sieht deine Antworten.'; + 'Was im Team über deine Arbeit notiert ist – und was du selbst dazu sagst. ' + + 'Keine Noten, keine Punktzahl.'; } await meineKarte(); - await meins(); + /* Die sechs Fragen stehen seit dem 15.09.2026 auf einer eigenen + Seite. Hier bleibt nur der Verweis -- er wird fuer alle + gezeigt, die dort ueberhaupt etwas zu beantworten haben. */ + if ($('meins-block')) $('meins-block').hidden = false; } catch { /* umgeleitet */ } })(); })(); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 8013b0b5..2ffee13d 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. --> - - - - - - + + + + + + - +
@@ -282,10 +282,10 @@ - - - - - + + + + +