diff --git a/server/pruef-team-stufen.mjs b/server/pruef-team-stufen.mjs new file mode 100644 index 00000000..fe52dfbc --- /dev/null +++ b/server/pruef-team-stufen.mjs @@ -0,0 +1,239 @@ +/* ===================================================================== + DIE MITGLIEDER-KACHEL UND DIE DREI STUFEN (Blueprint Kapitel 4/4.1) + + Vier Fragen, und die dritte ist die, an der es schiefgeht: + + 1. Stehen die Angaben aus Kapitel 4 in der Antwort -- Status, + "dabei seit", Stufe? + 2. Erscheinen auch PAUSIERTE? Vorher stand in der Abfrage + `AND aktiv = 1`: Wer jemanden pausierte, bei dem verschwand er + samt seiner offenen Rueckmeldungen aus dem Eingang. Die + warteten weiter auf eine Antwort, nur sah sie niemand mehr. + 3. WER DARF DIE STUFE SETZEN? Der Blueprint trennt das + ausdruecklich: gleicher Ueberblick fuer die rechte Hand, + "Verwaltungsrechte optional durch Owner freischaltbar" -- also + standardmaessig aus. Eine Rechtegrenze, die man nicht misst, + ist eine Absichtserklaerung. + 4. Ueberlebt die Stufe den naechsten Aufruf? Ein Wert, der nur im + Browser steht, sieht bis zum Neuladen richtig aus. + + MIT GEGENPROBE: Am Ende wird dieselbe Person auf eine andere Stufe + gesetzt und wieder zurueck. Ohne diesen Teil waere nicht bewiesen, + dass die Pruefung ueberhaupt einen Unterschied bemerkt. + ===================================================================== */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-stufen-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = "4375"; +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-team-stufen"); +await new Promise((r) => setTimeout(r, 700)); +const BASIS = "http://127.0.0.1:4375"; + +let fehler = 0, geprueft = 0, offen = 0; +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; +const unklar = (t) => { offen++; console.log(" -- " + t); }; + +/* ---------- Personen ------------------------------------------------- */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); + +const schluessel = randomBytes(32).toString("hex"); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", schluessel, jetzt); + +function anlegen(name, rolle, code, aktiv = 1, erstellt = jetzt) { + 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 (?,?,?,?,?,?,?,?)") + .run(name, rolle, hash, salt, 32768, + createHmac("sha256", schluessel).update(code).digest("hex"), aktiv, erstellt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} + +/* Ein Datum weit genug zurueck, damit "seit ... Tagen dabei" etwas + anderes ergibt als "seit heute" -- und RELATIV gerechnet, nicht fest + hingeschrieben: Ein festes Datum waere in einem halben Jahr eine + andere Aussage (Lehre vom 06.09.2026). */ +const vor40Tagen = new Date(Date.now() - 40 * 86400000).toISOString(); + +anlegen("Filipe", "admin", "CODE-DOGI-0001"); +anlegen("Rieke", "hand", "CODE-HAND-0001"); +const idMarina = anlegen("Marina", "modi", "CODE-MODI-0001", 1, vor40Tagen); +const idKim = anlegen("Kim", "modi", "CODE-MODI-0002", 0); // pausiert +const idCreator = anlegen("Tili", "creator", "CODE-TILI-0001"); + +async function anmelden(rolle, code) { + const a = await fetch(`${BASIS}/workspace/api/anmelden`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle, code }), + }); + return (a.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; "); +} +const holeLage = (keks) => fetch(`${BASIS}/workspace/api/teamlage`, { headers: { Cookie: keks } }); +const setzeStufe = (keks, person_id, stufe) => + fetch(`${BASIS}/workspace/api/teamlage/stufe`, { + method: "PUT", + headers: { Cookie: keks, "Content-Type": "application/json", Origin: BASIS }, + body: JSON.stringify({ person_id, stufe }), + }); + +const keksDogi = await anmelden("admin", "CODE-DOGI-0001"); +const keksHand = await anmelden("creator", "CODE-HAND-0001"); +const keksModi = await anmelden("creator", "CODE-MODI-0001"); +if (!keksDogi || !keksHand || !keksModi) { + console.error("FEHLER: Anmeldung misslang -- ohne sie misst diese Datei nichts."); + process.exit(1); +} + +/* ===================================================================== + 1. WAS IN DER ANTWORT STEHT + ===================================================================== */ +console.log(""); +console.log("--- Die Mitglieder-Kachel (Kapitel 4) ---"); + +const a1 = await holeLage(keksDogi); +ok(a1.status === 200, `DogFather bekommt die Lage (${a1.status})`); +const lage = await a1.json(); +const leute = lage.modis || []; + +ok(leute.length === 2, `beide Modis stehen da, auch der pausierte (${leute.length})`); + +const marina = leute.find((m) => m.id === idMarina); +const kim = leute.find((m) => m.id === idKim); +ok(!!marina && !!kim, "beide sind namentlich wiederzufinden"); + +ok(marina?.aktiv === true && kim?.aktiv === false, + `Status stimmt (Marina aktiv: ${marina?.aktiv}, Kim aktiv: ${kim?.aktiv})`); +/* AKTIVE ZUERST -- wer pausiert ist, steht unten und nicht mittendrin. */ +ok(leute[0]?.id === idMarina, "die Aktiven stehen oben"); + +ok(typeof marina?.seit === "string" && marina.seit.startsWith(vor40Tagen.slice(0, 10)), + `"dabei seit" kommt mit (${(marina?.seit || "").slice(0, 10)})`); + +/* NULL HEISST PROBE, nicht "unbekannt" -- an EINER Stelle im Server + abgebildet. Beide Modis wurden ohne Stufe angelegt. */ +ok(marina?.stufe === "probe" && kim?.stufe === "probe", + `ohne Eintrag ist die Stufe "Probe" (${marina?.stufe} / ${kim?.stufe})`); + +/* Die Liste der Stufen kommt VOM SERVER -- eine zweite im Browser + waere die Stelle, an der beim naechsten Mal eine fehlt. */ +const stufen = (lage.stufen || []).map((s) => s.wert); +ok(JSON.stringify(stufen) === JSON.stringify(["probe", "standard", "senior"]), + `die drei Stufen kommen mit, in ihrer Reihenfolge (${stufen.join(", ")})`); +ok((lage.stufen || []).every((s) => s.name && s.name !== s.wert), + "und jede hat einen lesbaren Namen"); + +/* ===================================================================== + 2. WER DARF SIE SETZEN + ===================================================================== */ +console.log(""); +console.log("--- Wer die Stufe setzen darf ---"); + +const gesetzt = await setzeStufe(keksDogi, idMarina, "senior"); +ok(gesetzt.status === 200, `DogFather darf (${gesetzt.status})`); + +/* DIE RECHTE HAND SIEHT ALLES UND SETZT NICHTS. Blueprint 3.1: + "Verwaltungsrechte optional durch Owner freischaltbar" -- also aus. + 404 und nicht 403: Wer nicht darf, muss nicht erfahren, dass es den + Weg gibt. */ +const handSieht = await holeLage(keksHand); +ok(handSieht.status === 200, `die rechte Hand sieht die Lage (${handSieht.status})`); +const handSetzt = await setzeStufe(keksHand, idMarina, "probe"); +ok(handSetzt.status === 404, `aber sie setzt keine Stufe (${handSetzt.status})`); + +const modiSetzt = await setzeStufe(keksModi, idMarina, "probe"); +ok(modiSetzt.status === 404, `ein Modi erst recht nicht (${modiSetzt.status})`); +const modiSieht = await holeLage(keksModi); +ok(modiSieht.status === 404, `und er sieht die Seite gar nicht (${modiSieht.status})`); + +/* ===================================================================== + 3. WAS ABGEWIESEN WIRD + ===================================================================== */ +console.log(""); +console.log("--- Was nicht durchgeht ---"); + +const erfunden = await setzeStufe(keksDogi, idMarina, "chef"); +ok(erfunden.status === 400, `eine erfundene Stufe wird abgewiesen (${erfunden.status})`); + +/* An einem Creator hat die Stufe nichts verloren -- sie stuende in der + Datenbank und wuerde nirgends angezeigt. */ +const anCreator = await setzeStufe(keksDogi, idCreator, "senior"); +ok(anCreator.status === 404, `an einem Creator geht sie nicht (${anCreator.status})`); +const nachCreator = d.prepare("SELECT stufe FROM personen WHERE id = ?").get(idCreator); +ok(!nachCreator?.stufe, "und es steht auch nichts in der Datenbank"); + +const anNiemand = await setzeStufe(keksDogi, 99999, "senior"); +ok(anNiemand.status === 404, `an einer unbekannten Nummer auch nicht (${anNiemand.status})`); + +/* ===================================================================== + 4. UEBERLEBT SIE DEN NAECHSTEN AUFRUF? + ===================================================================== */ +console.log(""); +console.log("--- Beim naechsten Laden ---"); + +const a2 = await holeLage(keksDogi); +const marina2 = ((await a2.json()).modis || []).find((m) => m.id === idMarina); +ok(marina2?.stufe === "senior", `die neue Stufe kommt zurueck (${marina2?.stufe})`); +const inDb = d.prepare("SELECT stufe FROM personen WHERE id = ?").get(idMarina); +ok(inDb?.stufe === "senior", `und steht in der Datenbank (${inDb?.stufe})`); + +/* Eine Personalentscheidung gehoert ins Protokoll -- gerade wenn + jemand zurueckgestuft wird, muss spaeter nachvollziehbar sein, wer + das wann getan hat. */ +const spur = d.prepare( + "SELECT COUNT(*) AS n FROM protokoll WHERE aktion = 'stufe_gesetzt'").get()?.n || 0; +ok(spur >= 1, `die Entscheidung steht im Protokoll (${spur})`); + +/* ===================================================================== + DIE GEGENPROBE + + Bis hierhin koennte alles auch dann gruen sein, wenn der Server + jeden Wert einfach annimmt und die Anzeige ihn nie wieder aendert. + Also zurueckstufen und noch einmal nachsehen. + ===================================================================== */ +console.log(""); +console.log("--- Gegenprobe: wieder zurueck ---"); + +const zurueck = await setzeStufe(keksDogi, idMarina, "standard"); +ok(zurueck.status === 200, `zurueckstufen geht (${zurueck.status})`); +const a3 = await holeLage(keksDogi); +const marina3 = ((await a3.json()).modis || []).find((m) => m.id === idMarina); +ok(marina3?.stufe === "standard", + `und die Anzeige folgt (${marina3?.stufe}) -- sie bleibt also nicht auf "senior" stehen`); + +/* Dieselbe Stufe noch einmal: kein Fehler, aber auch keine zweite + Protokollzeile -- sonst waere das Protokoll nach einem Tag Klicken + unlesbar. */ +const vorher = d.prepare( + "SELECT COUNT(*) AS n FROM protokoll WHERE aktion = 'stufe_gesetzt'").get()?.n || 0; +const nochmal = await setzeStufe(keksDogi, idMarina, "standard"); +const nachher = d.prepare( + "SELECT COUNT(*) AS n FROM protokoll WHERE aktion = 'stufe_gesetzt'").get()?.n || 0; +ok(nochmal.status === 200 && nachher === vorher, + `dieselbe Stufe noch einmal aendert nichts (${vorher} -> ${nachher} Zeilen)`); + +/* ---------- Ende ------------------------------------------------------ */ +d.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Windows haelt manchmal */ } + +console.log(""); +console.log(`${fehler === 0 ? "BESTANDEN" : "FEHLGESCHLAGEN"}` + + ` -- ${geprueft} geprueft, ${fehler} Fehler, ${offen} nicht nachsehbar`); +process.exit(fehler === 0 ? 0 : 1); diff --git a/server/workspace-checkliste.js b/server/workspace-checkliste.js index 80c97818..aa812afa 100644 --- a/server/workspace-checkliste.js +++ b/server/workspace-checkliste.js @@ -174,6 +174,22 @@ for (const [bereich, liste] of Object.entries(LISTEN)) { } let bereit = false; +/* AUSGEGEBEN, seit die Eingang-Seite dieselben Tabellen liest + (10.09.2026). + + Sie entstehen beim ersten Aufruf einer Checkliste. Wer die + Eingang-Seite oeffnete, BEVOR das je passiert war, bekam dort eine + 503 ohne Erklaerung -- auf einer frischen Anlage also beim ersten + Blick ueberhaupt. Gefunden hat das nicht der Betrieb, sondern eine + neue Pruefung, die genau diesen Zustand herstellt: Server frisch, + Checkliste nie geoeffnet. + + Die Alternative waere ein zweites CREATE TABLE in der Eingang-Seite + gewesen. Genau daraus werden zwei Schemata, die sich langsam + auseinanderentwickeln. Das Schema hat einen Besitzer; er gibt es + heraus. */ +export function checklistenTabellen() { tabellen(); } + function tabellen() { if (bereit) return; db().exec(` diff --git a/server/workspace-teamlage.js b/server/workspace-teamlage.js index 240d8a02..ef79c517 100644 --- a/server/workspace-teamlage.js +++ b/server/workspace-teamlage.js @@ -36,6 +36,7 @@ import { db, sitzungLesen, istDogFather, heuteLokal, protokolliere, echteIp, } from "./workspace.js"; import { MODI_PUNKTE } from "./workspace-modi-punkte.js"; +import { checklistenTabellen } from "./workspace-checkliste.js"; /* Titel und Bereich zu einem Schluessel -- damit im Eingang steht, WORUM es geht, und nicht nur ein Schluessel. */ @@ -74,6 +75,43 @@ const GESAMT = Object.fromEntries( * vierte kommt mit der naechsten Route. Wer eine davon vergisst, * merkt es nicht -- eine vergessene Schranke faellt nur dem auf, der * hindurchgeht. */ +/* ===================================================================== + DIE DREI STUFEN (Blueprint V3.0, Kapitel 4.1) + + Probe -- in der Einarbeitung, wird eingeschaetzt. + Standard -- der Normalfall: moderiert, arbeitet eigene Aufgaben ab. + Senior -- kann zusaetzlich neue Probe-Modis einschaetzen, mehrere + Kategorien gleichzeitig tragen und die rechte Hand bei + Abwesenheit vertreten. + + SIE SIND EINE ARBEITSEINTEILUNG, KEINE RECHTEGRENZE -- jedenfalls + heute noch nicht. Das gehoert ausgesprochen, damit niemand sie + spaeter fuer eine haelt: Was ein Modi in dieser App darf, haengt an + der ROLLE. Die Stufe sagt, wo er im Team steht, und macht den + naechsten Schritt sichtbar. Die Rechte, die der Blueprint dort + nennt, liegen ausserdem groesstenteils gar nicht hier, sondern im + Stream (Timeout, Bann). + + Die Reihenfolge ist die Steigerung -- sie steht hier einmal, damit + keine Liste im Browser eine eigene erfindet. */ +export const STUFEN = ["probe", "standard", "senior"]; + +export const STUFEN_NAME = { + probe: "Probe", + standard: "Standard", + senior: "Senior", +}; + +/** NULL heisst "Probe", nicht "unbekannt". + * + * Ein dritter Zustand waere eine Frage, die niemand beantworten kann, + * und ein neuer Mensch faengt ohnehin in der Einarbeitung an. Die + * Abbildung steht an EINER Stelle -- stuende sie in jeder Abfrage, + * waere sie beim naechsten Mal in einer davon anders. */ +function stufeVon(wert) { + return STUFEN.includes(String(wert || "")) ? String(wert) : "probe"; +} + function darfEingang(person) { return istDogFather(person) || person?.rolle === "hand"; } @@ -85,9 +123,30 @@ teamlageRouter.get("/workspace/api/teamlage", (req, res) => { /* 404 und nicht 403 -- siehe Kopf. */ if (!darfEingang(person)) return res.status(404).json({ fehler: "nicht_gefunden" }); + /* DIE CHECKLISTEN-TABELLEN ENTSTEHEN BEIM ERSTEN AUFRUF EINER + CHECKLISTE -- diese Seite liest sie aber auch. + + Wer den Eingang oeffnete, BEVOR je jemand eine Checkliste + angesehen hatte, bekam hier eine 503 ohne Erklaerung. Auf einer + frischen Anlage also beim allerersten Blick. Gefunden hat das + nicht der Betrieb, sondern pruef-team-stufen.mjs, die genau + diesen Zustand herstellt. */ + checklistenTabellen(); + + /* AUCH DIE PAUSIERTEN (10.09.2026, Blueprint Kapitel 4). + + Die Mitglieder-Kachel dort nennt ausdruecklich "Status + (aktiv/pausiert)" -- also muss man Pausierte auch SEHEN. Vorher + stand hier `AND aktiv = 1`: Wer jemanden pausierte, bei dem + verschwand er samt seiner offenen Rueckmeldungen aus dem + Eingang. Die warteten weiter auf eine Antwort, nur sah sie + niemand mehr. + + Aktive zuerst, dann nach Namen -- wer pausiert ist, steht unten + und nicht mittendrin. */ const modis = db().prepare( - `SELECT id, name, bild, letzter_login FROM personen - WHERE rolle = 'modi' AND aktiv = 1 ORDER BY name`).all(); + `SELECT id, name, bild, letzter_login, aktiv, erstellt, stufe FROM personen + WHERE rolle = 'modi' ORDER BY aktiv DESC, name`).all(); if (!modis.length) { /* `eingang` auch hier -- die Oberflaeche darf nicht raten @@ -187,6 +246,14 @@ teamlageRouter.get("/workspace/api/teamlage", (req, res) => { bereiche: GESAMT, eingang, angebote, + /* DIE STUFEN KOMMEN VOM SERVER, nicht aus einer Liste im Browser. + + Eine zweite Aufzaehlung dort waere die Stelle, an der beim + naechsten Mal eine Stufe fehlt oder anders heisst -- genau so + stand am 07.09. unter dem Codefeld "PERSOENLICHER ZUGANGSCODE · + undefined", weil eine Rolle in der Browser-Liste vergessen + worden war. Einmal geschrieben, einmal geaendert. */ + stufen: STUFEN.map((wert) => ({ wert, name: STUFEN_NAME[wert] })), modis: modis.map((m) => { const s = stand.get(m.id) || {}; const je = {}; @@ -200,6 +267,10 @@ teamlageRouter.get("/workspace/api/teamlage", (req, res) => { name: m.name, bild: m.bild ? `/workspace/api/steckbrief/bild/${m.bild}` : null, letzter_login: m.letzter_login, + /* Die drei Felder aus Kapitel 4: Status, "dabei seit", Stufe. */ + aktiv: !!m.aktiv, + seit: m.erstellt || null, + stufe: stufeVon(m.stufe), aufgaben: aufgaben.get(m.id) || { offen: 0, ueberfaellig: 0 }, ideen: ideen.get(m.id) || 0, nachrichten: nachrichten.get(m.id) || 0, @@ -232,6 +303,59 @@ teamlageRouter.get("/workspace/api/teamlage", (req, res) => { annimmt, nimmt sie sich. Sie einem Dritten zuzuweisen waere eine Entscheidung ueber jemand anderen, ohne ihn zu fragen. ===================================================================== */ +/* ===================================================================== + DIE STUFE SETZEN (Blueprint Kapitel 4.1, Punkt 3: "Rechte anpassen") + + NUR DOGFATHER, nicht die rechte Hand. Der Blueprint trennt das + ausdruecklich: Sie hat den gleichen UEBERBLICK, aber + "Verwaltungsrechte optional durch Owner freischaltbar" -- also + standardmaessig aus. Wer im Team auf welcher Stufe steht, ist eine + Personalentscheidung; sie gehoert dem, der die Leute holt. + + 404 statt 403, wie ueberall auf dieser Seite: Wer nicht darf, muss + nicht erfahren, dass es den Weg gibt. + ===================================================================== */ +teamlageRouter.put("/workspace/api/teamlage/stufe", gleicheHerkunft, + express.json({ limit: "2kb" }), (req, res) => { + try { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + if (!istDogFather(person)) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const personId = Number(req.body?.person_id) || 0; + const stufe = String(req.body?.stufe || ""); + if (!STUFEN.includes(stufe)) { + return res.status(400).json({ fehler: "Unbekannte Stufe." }); + } + + /* NUR AN EINEM MODI. Ein Manager oder Creator hat keine Stufe -- + sie stuende in der Datenbank und wuerde nirgends angezeigt, + also waere sie ein stiller Datensatz, den niemand mehr + zuordnen kann. Und die rechte Hand hat auch keine: Sie ist + eine Rolle, keine Stufe darin. */ + const ziel = db().prepare( + "SELECT id, name, rolle, stufe FROM personen WHERE id = ?").get(personId); + if (!ziel || ziel.rolle !== "modi") { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + const vorher = STUFEN.includes(String(ziel.stufe || "")) ? String(ziel.stufe) : "probe"; + if (vorher === stufe) return res.json({ ok: true, stufe, geaendert: false }); + + db().prepare("UPDATE personen SET stufe = ? WHERE id = ?").run(stufe, ziel.id); + /* Ins Protokoll, weil es eine Personalentscheidung ist: Wer sie + wann getroffen hat, muss spaeter nachvollziehbar sein -- gerade + wenn jemand zurueckgestuft wird. */ + protokolliere("stufe_gesetzt", { + personId: ziel.id, rolle: ziel.rolle, ip: echteIp(req), + detail: `${ziel.name}: ${vorher} -> ${stufe}`, + }); + return res.json({ ok: true, stufe, geaendert: true }); + } catch (fehler) { + console.error("[workspace] Stufe setzen:", fehler?.message); + return res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + teamlageRouter.post("/workspace/api/teamlage/behandeln", gleicheHerkunft, express.json({ limit: "16kb" }), (req, res) => { try { diff --git a/server/workspace.js b/server/workspace.js index 3ef41f58..d0644481 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -1037,6 +1037,32 @@ function umstellungen(d) { allein scrypt. Ein Treffer hier allein laesst niemanden herein. */ ["personen", "code_kennung", "TEXT"], + /* DIE STUFE IM TEAM (10.09.2026). + + Blueprint V3.0, Kapitel 4.1: Probe, Standard, Senior. Ein + Probe-Modi ist in der Einarbeitung, ein Senior kann die rechte + Hand bei Abwesenheit vertreten. + + WARUM OHNE CHECK-REGEL, anders als bei `rolle`: Eine CHECK-Liste + laesst sich in SQLite nur ueber einen Tabellenneubau erweitern + (siehe checkListeErweitern) -- und die Stufen sind eine + ARBEITSEINTEILUNG, keine Rechtegrenze. Kaeme morgen eine vierte + dazu, waere ein Tabellenneubau auf einer Live-Datenbank ein + hoher Preis fuer eine Beschriftung. Geprueft wird deshalb im + Code, an genau einer Stelle (STUFEN), und was dort nicht + draufsteht, wird abgewiesen. + + NULL HEISST "Probe" und nicht "unbekannt". Ein dritter Zustand + waere eine Frage, die niemand beantworten kann ("was ist er + denn nun?"); und ein neuer Mensch faengt ohnehin in der + Einarbeitung an. Beim Lesen wird NULL deshalb auf 'probe' + abgebildet -- an einer Stelle, nicht in jeder Abfrage. + + Die Spalte heisst wie `punkt_stand.stufe`, meint aber etwas + anderes (dort: gut/verbessern). Zwei Tabellen, kein Konflikt -- + aber wer beides im selben Kopf hat, sollte es wissen. */ + ["personen", "stufe", "TEXT"], + /* DIE KATEGORIE EINER AUFGABE (10.09.2026). Kapitel 6.1 des Anforderungsdokuments verlangt sie an jeder diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index 79e9a098..a0c456ae 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=202609101602", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609101626", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609101602", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609101626", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609101602", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609101626", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609101602", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609101626", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/team.css b/workspace/assets/css/team.css index 141228d9..5502ae32 100644 --- a/workspace/assets/css/team.css +++ b/workspace/assets/css/team.css @@ -316,3 +316,251 @@ .tz, .tl, .tperson { border: 1px solid CanvasText; } .tperson::after { background: CanvasText; } } + +/* ======================================================================= + DIE MITGLIEDER-KACHEL AUS KAPITEL 4 (10.09.2026) + + Der Blueprint verlangt dort neben Name, Foto und Zahlen auch Status + (aktiv/pausiert), das Datum "dabei seit" und -- nach 4.1 -- die + Stufe. Drei kurze Angaben, die zusammen in eine Zeile gehoeren: + Einzeln untereinander waeren sie drei Zeilen fuer neun Woerter. + + ALLE DREI IM SELBEN GEWAND wie `tperson__zuletzt` daneben (Pille, + 999px, .74rem). Ein zweites Gewand fuer dieselbe Sorte Angabe waere + der Anfang davon, dass die Karte nach Baustelle aussieht. + ==================================================================== */ +.tperson__merkmale { + display: flex; flex-wrap: wrap; gap: 6px; + margin-top: 10px; +} +.tmerkmal { + padding: 4px 10px; border-radius: 999px; + font-size: .74rem; font-weight: 600; + color: var(--text-leise); + background: rgba(255, 255, 255, .05); + border: 1px solid var(--rand); +} +/* "dabei seit" ist die leiseste der drei -- sie beantwortet keine + Frage, sie gibt nur Zusammenhang. */ +.tmerkmal--leise { opacity: .78; } + +/* PAUSIERT FAELLT AUF, aber warm und nicht alarmierend: Eine Pause ist + kein Fehler. Derselbe Ton wie die Warnung daneben, nur ruhiger -- + sonst liest man "pausiert" als "kaputt". */ +.tmerkmal--pause { + color: color-mix(in srgb, var(--warn, #ff9aa2) 70%, #ffffff); + border-color: color-mix(in srgb, var(--warn, #ff9aa2) 38%, transparent); + background: rgba(255, 154, 162, .1); +} + +/* DIE DREI STUFEN steigern sich sichtbar, ohne dass eine davon nach + Rang aussieht: gleiche Form, nur mehr Farbe. Probe bleibt bewusst + das ruhigste Schild -- wer neu ist, soll nicht markiert wirken. */ +.tmerkmal--probe { + color: color-mix(in srgb, #9fb6c6 82%, #ffffff); + border-color: rgba(159, 182, 198, .3); + background: rgba(159, 182, 198, .08); +} +.tmerkmal--standard { + color: color-mix(in srgb, #7fc4e8 82%, #ffffff); + border-color: rgba(127, 196, 232, .34); + background: rgba(127, 196, 232, .1); +} +.tmerkmal--senior { + color: color-mix(in srgb, #8fd6a6 84%, #ffffff); + border-color: rgba(143, 214, 166, .38); + background: rgba(143, 214, 166, .12); +} + +/* ---------- Die Stufe setzen (nur DogFather) --------------------------- */ +.tstufen { + display: flex; flex-wrap: wrap; align-items: center; gap: 6px; + margin-top: 12px; padding-top: 11px; + border-top: 1px solid var(--rand); +} +/* DAS SCHILD NIMMT DIE GANZE ZEILE. + + Ohne `flex-basis: 100%` teilt es sich die erste Zeile mit der ersten + Stufe -- im Bildschirmfoto stand "STUFE Probe" oben und darunter + "Standard Senior". Drei gleichwertige Knoepfe, ungleich verteilt: + Man liest sie dann als zwei Gruppen statt als eine Reihe. */ +.tstufen__schild { + flex-basis: 100%; + margin-bottom: 2px; + font-size: .72rem; font-weight: 700; letter-spacing: .1em; + text-transform: uppercase; color: var(--text-still); +} +.tstufen__knopf { + padding: 6px 12px; border-radius: 999px; + font: inherit; font-size: .78rem; font-weight: 600; + color: var(--text-leise); + background: rgba(255, 255, 255, .04); + border: 1px solid var(--rand); + cursor: pointer; + transition: background .15s ease, color .15s ease, border-color .15s ease; +} +.tstufen__knopf:hover:not([disabled]) { + color: var(--text); background: rgba(255, 255, 255, .08); +} +.tstufen__knopf:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; } +/* Die gewaehlte Stufe ist gefuellt, nicht bloss umrandet: Bei drei + gleich aussehenden Pillen erkennt man einen Rahmenwechsel zu + langsam, gerade auf einem hellen Bildschirm. */ +.tstufen__knopf[data-an="ja"] { + color: #0a121e; + background: color-mix(in srgb, var(--akzent, #7fc4e8) 82%, #ffffff); + border-color: transparent; +} +.tstufen__knopf[disabled] { opacity: .55; cursor: default; } +@media (prefers-reduced-motion: reduce) { .tstufen__knopf { transition: none; } } + +/* ======================================================================= + DER EINGANG WIRD LESBAR STATT LEER (10.09.2026) + + Filipe zum Bildschirmfoto: "das muss viel krasser sein." + + Er hatte recht, und der Grund war nicht Geschmack: Auf der Seite + standen drei gleich hohe Spalten, von denen zwei leer waren und + trotzdem ueber den halben Bildschirm gingen. Die einzige Karte mit + Inhalt lag darin wie ein Zettel in einem Umzugskarton -- und war + ausserdem fahl, weil ihre Rollenfarbe auf ein helles Grau zurueckfiel. + + Vier Aenderungen, und jede loest etwas Konkretes: + 1. Leere Spalten schrumpfen (`align-items: start`). + 2. Die Karte traegt die Farbe der Stufe -- kein Nebel mehr, und + man sieht am Rand, wer wo steht. + 3. Aus sechs Zahlenkaesten je Person werden drei Balken: "wie + weit" statt "wie viele offen". + 4. Die drei Zahlen der Seite stehen oben als Zahlen, nicht in + einem Satz. + ==================================================================== */ + +/* 1. ZWEI REIHEN, UND LEERE KAESTEN SCHRUMPFEN. + + `.t-lage` war ein Raster mit `auto-fit, minmax(150px, 1fr)` fuer + ALLES: die zwei Wartelisten und jede Person. Bei fuenf Kaesten + nebeneinander blieben rund 150 px je Spalte, und im + Bildschirmfoto stand "Community 12 von" -- abgeschnitten mitten + in der Zahl. Ausserdem machte die laengste Liste die ganze Reihe + so hoch wie sich selbst. + + Jetzt untereinander: erst was eine Antwort braucht, dann das + Team. `align-items: start` haelt leere Kaesten kurz -- `stretch` + ist die Vorgabe und zieht sonst jeden auf die Hoehe des + hoechsten. */ +.t-lage { + display: grid; gap: 18px; + grid-template-columns: 1fr; + align-items: start; +} +/* 300 px ist nachgemessen und nicht geraten: Darunter bricht + "Community" von der Zahl daneben ab, und die Stufenknoepfe fallen in + drei Zeilen. Auf einem Handy bleibt es bei einer Spalte. */ +.t-wartet, .t-leute { + display: grid; gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr)); + align-items: start; +} + +/* 2. DIE STUFE ALS FARBE DER KARTE. + Dieselben drei Toene wie die Schilder darin -- ein vierter waere + eine zweite Sprache fuer dieselbe Sache. */ +.tperson[data-stufe="probe"] { --r: #9fb6c6; } +.tperson[data-stufe="standard"] { --r: #7fc4e8; } +.tperson[data-stufe="senior"] { --r: #8fd6a6; } +/* Pausiert wird leiser, nicht ausgegraut: 0,72 statt 0,6 (das ist der + Wert fuer GESPERRT). Eine Pause ist kein Entzug. */ +.tperson[data-pause="ja"] { opacity: .72; } + +/* 3. DIE BEREICHE ALS BALKEN --------------------------------------- */ +.tfort { + display: grid; gap: 8px; + margin-top: 14px; padding-top: 13px; + border-top: 1px solid var(--rand); +} +.tfort__zeile { + display: grid; gap: 6px; + padding: 10px 12px; border-radius: 12px; + text-decoration: none; color: inherit; + background: rgba(255, 255, 255, .028); + border: 1px solid var(--rand); + transition: background .15s ease, border-color .15s ease; +} +.tfort__zeile:hover { + background: rgba(255, 255, 255, .06); + border-color: color-mix(in srgb, var(--r) 40%, transparent); +} +.tfort__zeile:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; } +.tfort__kopf { + display: flex; align-items: baseline; justify-content: space-between; gap: 10px; +} +.tfort__name { font-size: .88rem; font-weight: 650; color: var(--text); } +.tfort__zahl { + flex: none; + font-size: .78rem; font-weight: 700; color: var(--text-leise); + font-variant-numeric: tabular-nums; +} +/* DIE BAHN. 8 px hoch -- hoch genug, um die Farbe zu erkennen, flach + genug, um nicht wie ein Ladebalken zu wirken. Der leere Teil bleibt + sichtbar dunkel: Ein Balken ohne Bahn zeigt nur, was da ist, nicht + wie viel fehlt. */ +.tfort__bahn { + display: flex; overflow: hidden; + height: 8px; border-radius: 999px; + background: rgba(255, 255, 255, .07); +} +.tfort__bahn > i { display: block; height: 100%; } +/* GRUEN = besprochen und in Ordnung. BERNSTEIN = besprochen, da ist + was. Beide fuellen den Balken, denn beide heissen "angesehen" -- + der Unterschied liegt im Ergebnis, nicht im Fortschritt. */ +.tfort__gut { background: color-mix(in srgb, #8fd6a6 78%, #5f8a9f); } +.tfort__verbessern { background: color-mix(in srgb, #ffc978 82%, #b07a3a); } +.tfort__unter { + font-size: .76rem; color: var(--text-still); line-height: 1.5; +} + +/* 4. DIE DREI ZAHLEN DER SEITE -------------------------------------- */ +.tkopfzahlen { + grid-column: 1 / -1; /* ueber die ganze Breite, keine vierte Spalte */ + display: grid; gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(min(190px, 100%), 1fr)); + margin-bottom: 4px; +} +.tkz { + display: grid; gap: 3px; align-content: start; + padding: 15px 17px; border-radius: 14px; + background: + linear-gradient(160deg, rgba(127, 196, 232, .07), transparent 60%), + linear-gradient(178deg, rgba(10, 14, 22, .95), rgba(5, 8, 14, .98)); + border: 1px solid var(--rand); +} +.tkz__wert { + font-size: clamp(1.6rem, 4vw, 2.2rem); font-weight: 750; + line-height: 1.05; color: var(--text); + font-variant-numeric: tabular-nums; +} +.tkz__schild { + font-size: .8rem; color: var(--text-leise); line-height: 1.4; +} +/* WAS WARTET, FAELLT AUF -- aber nur, wenn wirklich etwas wartet. Eine + Null in Warnfarbe waere ein Alarm ohne Anlass, und ab dem dritten Mal + sieht man ihn nicht mehr. */ +.tkz[data-dran="ja"] { + border-color: color-mix(in srgb, #ffc978 42%, transparent); + background: + linear-gradient(160deg, rgba(255, 201, 120, .12), transparent 62%), + linear-gradient(178deg, rgba(10, 14, 22, .95), rgba(5, 8, 14, .98)); +} +.tkz[data-dran="ja"] .tkz__wert { color: color-mix(in srgb, #ffc978 78%, #ffffff); } +.tkz__bahn { + display: block; overflow: hidden; + height: 8px; margin-top: 8px; border-radius: 999px; + background: rgba(255, 255, 255, .07); +} +.tkz__bahn > i { + display: block; height: 100%; + background: linear-gradient(90deg, + color-mix(in srgb, #5f8a9f 80%, #ffffff), + color-mix(in srgb, #8fd6a6 76%, #5f8a9f)); +} +@media (prefers-reduced-motion: reduce) { .tfort__zeile { transition: none; } } diff --git a/workspace/assets/js/teamlage.js b/workspace/assets/js/teamlage.js index ae5bcfd8..9026f2c7 100644 --- a/workspace/assets/js/teamlage.js +++ b/workspace/assets/js/teamlage.js @@ -30,6 +30,13 @@ }; const melde = (t) => { const f = $('fehler'); if (f) f.textContent = t || ''; }; + let ich = null; + /* Die Stufen kommen mit der Antwort des Servers -- eine eigene Liste + hier waere die zweite, und die zweite laeuft irgendwann auseinander. */ + let STUFEN = []; + const stufenName = (wert) => + STUFEN.find((s) => s.wert === wert)?.name || STUFEN[0]?.name || ''; + 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'); } @@ -62,6 +69,18 @@ function karte(m) { const k = el('article', 'tperson'); + /* DIE KARTE TRAEGT DIE FARBE DER STUFE (10.09.2026). + + Vorher fiel `--r` auf `--text-still` zurueck -- ein helles Grau, + aus dem das Kantenlicht einen fahlen Schleier ueber die ganze + Karte legte. Auf dem Bildschirmfoto sah sie neben den beiden + dunklen Spalten daneben aus wie ueberbelichtet. + + Die Stufe als Farbe zu nehmen loest zwei Dinge auf einmal: Der + Schleier wird zu einem klaren Ton, und man sieht schon am Rand + der Karte, wer wo steht -- ohne das Schild zu lesen. */ + k.dataset.stufe = m.stufe || 'probe'; + if (!m.aktiv) k.dataset.pause = 'ja'; const kopf = el('div', 'tperson__kopf'); const zeichen = el('span', 'tperson__zeichen'); @@ -87,30 +106,128 @@ kopf.append(text); k.append(kopf); + /* =================================================================== + DIE MITGLIEDER-KACHEL AUS KAPITEL 4 (10.09.2026) + + Der Blueprint nennt dort: "Name & Foto · Rolle(n)/Kategorie(n) · + Status (aktiv/pausiert) · Anzahl offener Aufgaben · Datum 'Modi + seit' · Schnellaktionen". Name, Foto und die Zahlen standen + schon; Status, "dabei seit" und die Stufe kommen hier dazu. + ================================================================ */ + const merkmale = el('div', 'tperson__merkmale'); + + /* PAUSIERT ZUERST. Wer pausiert ist, erklaert jede andere Zahl + darunter -- null Aufgaben sind dann kein Grund nachzufragen. */ + if (!m.aktiv) merkmale.append(el('span', 'tmerkmal tmerkmal--pause', 'pausiert')); + + merkmale.append(el('span', `tmerkmal tmerkmal--${m.stufe || 'probe'}`, + stufenName(m.stufe))); + + if (m.seit) { + const tage = tageHer(m.seit); + merkmale.append(el('span', 'tmerkmal tmerkmal--leise', + tage === null ? 'dabei' + : tage < 1 ? 'seit heute dabei' + : tage < 31 ? `seit ${tage} Tagen dabei` + : `seit ${Math.round(tage / 30.4)} Monaten dabei`)); + } + k.append(merkmale); + + /* DIE STUFE SETZEN -- nur DogFather. Die rechte Hand sieht sie, + aendert sie aber nicht: Wer im Team auf welcher Stufe steht, ist + eine Personalentscheidung (Blueprint 4.1, "Rechte anpassen" + liegt beim Owner). Der Server sagt dasselbe noch einmal. */ + if (ich?.rolle === 'admin') { + const wahl = el('div', 'tstufen'); + wahl.append(el('span', 'tstufen__schild', 'Stufe')); + for (const st of STUFEN) { + const b = el('button', 'tstufen__knopf', st.name); + b.type = 'button'; + b.dataset.stufe = st.wert; + if ((m.stufe || STUFEN[0]?.wert) === st.wert) b.dataset.an = 'ja'; + b.addEventListener('click', () => stufeSetzen(wahl, m, st.wert)); + wahl.append(b); + } + k.append(wahl); + } + + /* SCHNELLAKTION: schreiben. `?mit=` legt das Gespraech beim ersten + Mal an und findet beim zweiten dasselbe wieder -- der Weg gibt es + seit dem 06.09. und wird hier nur benutzt, nicht neu gebaut. + Nachgemessen: Die rechte Hand und die Modis duerfen einander + schreiben, ein Manager ist fuer beide nicht erreichbar. */ + const aktionen = el('div', 'tpipe__reihe'); + const schreiben = el('a', 'schritt', `${m.name} schreiben`); + schreiben.href = `chat.html?mit=${m.id}`; + aktionen.append(schreiben); + k.append(aktionen); + const zahlen = el('div', 'tperson__zahlen'); zahlen.append(zahl(m.aufgaben.offen, 'Aufgaben offen')); zahlen.append(zahl(m.aufgaben.ueberfaellig, 'überfällig')); zahlen.append(zahl(m.ideen, 'Ideen')); zahlen.append(zahl(m.nachrichten, 'Rückmeldungen')); + k.append(zahlen); + + /* =================================================================== + DIE DREI BEREICHE ALS BALKEN (10.09.2026) + + Hier standen bis eben drei weitere Zahlenkaesten ("40 Live-Ablauf: + offen") und darunter noch einmal drei Verweise mit denselben + Zahlen in Klammern. Sechs Kaesten fuer drei Aussagen -- und die + wichtigste stand in keinem davon: WIE WEIT sind wir? + + "40 offen" beantwortet das nicht. Vierzig von vierzig ist nicht + dasselbe wie vierzig von hundert, und genau das ist die Frage, + die man beim Hinsehen hat. + + Ein Balken beantwortet sie ohne Rechnen: gut und "verbessern" + fuellen ihn von links, der Rest ist offen. Zwei Farben, weil es + zwei verschiedene Dinge sind -- "besprochen und in Ordnung" und + "besprochen, da ist was". Beide sind ERLEDIGT im Sinne von + "angesehen", und deshalb fuellen beide den Balken. + + Die ganze Zeile ist der Verweis. Vorher war es ein eigener Knopf + darunter; ein Balken, der aussieht wie eine Anzeige, aber klickbar + ist, muss auch wie ein Ziel aussehen -- deshalb Rahmen und + Hover. */ + const fortschritt = el('div', 'tfort'); for (const b of BEREICHE) { const z = m.bereiche[b.schluessel]; - if (z) zahlen.append(zahl(z.offen, `${b.name}: offen`)); + if (!z) continue; + const gesamt = Math.max(z.gesamt || 0, 1); + const beurteilt = (z.gut || 0) + (z.verbessern || 0); + + const a = el('a', 'tfort__zeile'); + a.href = `bereich.html?b=${b.schluessel}&creator_id=${m.id}`; + + const kopfz = el('span', 'tfort__kopf'); + kopfz.append(el('span', 'tfort__name', b.name)); + kopfz.append(el('span', 'tfort__zahl', `${beurteilt} von ${z.gesamt}`)); + a.append(kopfz); + + const bahn = el('span', 'tfort__bahn'); + const gut = el('i', 'tfort__gut'); + gut.style.width = `${((z.gut || 0) / gesamt) * 100}%`; + const schlecht = el('i', 'tfort__verbessern'); + schlecht.style.width = `${((z.verbessern || 0) / gesamt) * 100}%`; + bahn.append(gut, schlecht); + a.append(bahn); + + /* Die Aufschluesselung in Worten -- fuer alle, die den Balken + nicht sehen koennen, und fuer die Faelle, in denen zwei + Prozentpunkte Unterschied nicht auffallen. */ + a.append(el('span', 'tfort__unter', + z.verbessern + ? `${z.gut} in Ordnung · ${z.verbessern} zu besprechen · ${z.offen} offen` + : `${z.gut} in Ordnung · ${z.offen} offen`)); + fortschritt.append(a); } - k.append(zahlen); + if (fortschritt.children.length) k.append(fortschritt); /* UND HIER WIRD BEHANDELT. Ein Verweis je Bereich, der die Person mitnimmt -- ohne sie muesste man dort noch einmal auswaehlen, und beim zweiten Mal weiss man nicht mehr, warum man hier war. */ - const wege = el('div', 'tpipe__reihe'); - for (const b of BEREICHE) { - const z = m.bereiche[b.schluessel]; - if (!z) continue; - const a = el('a', 'schritt', - `${b.name} ansehen (${z.gut} gut · ${z.verbessern} verbessern)`); - a.href = `bereich.html?b=${b.schluessel}&creator_id=${m.id}`; - wege.append(a); - } - k.append(wege); return k; } @@ -125,6 +242,52 @@ die nichts folgt, sieht fuer ihn aus wie eine, die niemand gelesen hat -- und beim dritten Mal meldet er nichts mehr. ===================================================================== */ + /* =================================================================== + DIE STUFE SETZEN + + Sofort sichtbar, dann speichern. Andersherum -- erst der Server, + dann die Anzeige -- fuehlt sich bei jedem Druck traege an, obwohl + nichts langsam ist. Schlaegt es fehl, springt die Auswahl zurueck + UND es steht eine Meldung da: Eine Anzeige, die sich lautlos + zurueckdreht, sieht wie ein Wackelkontakt aus. + ================================================================ */ + async function stufeSetzen(wahl, m, stufe) { + const knoepfe = [...wahl.querySelectorAll('.tstufen__knopf')]; + const vorher = knoepfe.find((b) => b.dataset.an === 'ja')?.dataset.stufe || null; + if (vorher === stufe) return; + + const zeigen = (wert) => { + for (const b of knoepfe) { + if (b.dataset.stufe === wert) b.dataset.an = 'ja'; + else delete b.dataset.an; + } + }; + zeigen(stufe); + for (const b of knoepfe) b.disabled = true; + try { + const a = await hole('/workspace/api/teamlage/stufe', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ person_id: m.id, stufe }), + }); + if (!a.ok) throw new Error('abgelehnt'); + m.stufe = stufe; + /* Das Schild oben in derselben Karte zieht mit -- sonst stuende + dort die alte Stufe neben der neuen Auswahl. */ + const schild = wahl.parentElement?.querySelector('[class*="tmerkmal--"]:not(.tmerkmal--pause):not(.tmerkmal--leise)'); + if (schild) { + schild.className = `tmerkmal tmerkmal--${stufe}`; + schild.textContent = stufenName(stufe); + } + melde(''); + } catch { + zeigen(vorher); + melde('Die Stufe konnte nicht gespeichert werden.'); + } finally { + for (const b of knoepfe) b.disabled = false; + } + } + function eingangBauen(liste) { const k = el('section', 'tperson'); k.append(el('p', 'feldschild', 'Rückmeldungen aus dem Team')); @@ -279,6 +442,11 @@ const a = await hole('/workspace/api/teamlage'); if (!a.ok) { melde('Die Lage konnte nicht geladen werden.'); return; } const daten = await a.json(); + /* VOR dem Bauen der Karten: Sie fragen die Liste ab. Stuende + diese Zeile darunter, waere die Stufe beim ersten Aufbau leer + und erst nach dem naechsten Laden da -- ein Fehler, den man + beim Entwickeln nie sieht, weil man ohnehin neu laedt. */ + STUFEN = Array.isArray(daten.stufen) && daten.stufen.length ? daten.stufen : STUFEN; ziel.textContent = ''; ziel.setAttribute('aria-busy', 'false'); @@ -289,14 +457,85 @@ deshalb steht hier auch, was zu tun waere. */ const wartend = (daten.eingang || []).length + (daten.angebote || []).length; $('kopf-unter').textContent = leute.length - ? `${leute.length} ${leute.length === 1 ? 'Person' : 'Personen'} · ` - + `${wartend} ${wartend === 1 ? 'Rückmeldung wartet' : 'Rückmeldungen warten'} · ` - + `${offenGesamt} Punkte noch nicht beurteilt` + ? `${leute.length} ${leute.length === 1 ? 'Person' : 'Personen'} im Team` : 'Noch niemand im Team. Zugänge legst du unter „Personen & Zugänge“ an.'; - ziel.append(eingangBauen(daten.eingang || [])); - ziel.append(angeboteBauen(daten.angebote || [])); - for (const m of leute) ziel.append(karte(m)); + /* =================================================================== + DIE DREI ZAHLEN, DIE DIE SEITE BEANTWORTET (10.09.2026) + + Sie standen als Fliesstext in einer Zeile: "1 Person · 0 + Rueckmeldungen warten · 101 Punkte noch nicht beurteilt". + Genau das sind aber die drei Fragen, wegen derer man diese + Seite ueberhaupt aufmacht -- und in einem Satz muss man sie + lesen statt zu sehen. + + DIE DRITTE BEKOMMT EINEN BALKEN. "101 noch nicht beurteilt" + beantwortet nicht, wie weit man ist: Hundertein von hundertzwei + ist etwas anderes als hundertein von dreihundert. Der Balken + beantwortet es ohne Rechnen. + + BEI NIEMANDEM IM TEAM bleibt die Leiste ganz weg. Drei Nullen + mit Balken auf null sehen aus wie ein Fehler; der Satz oben + sagt dann, was zu tun ist. */ + const punkteGesamt = leute.reduce((sum, m) => + sum + Object.values(m.bereiche).reduce((x, b) => x + (b.gesamt || 0), 0), 0); + const beurteilt = Math.max(punkteGesamt - offenGesamt, 0); + const pausiert = leute.filter((m) => !m.aktiv).length; + + if (leute.length) { + const leiste = el('div', 'tkopfzahlen'); + + const eins = el('div', 'tkz'); + eins.append(el('strong', 'tkz__wert', String(leute.length))); + eins.append(el('span', 'tkz__schild', + pausiert ? `im Team · ${pausiert} pausiert` : 'im Team')); + leiste.append(eins); + + const zwei = el('div', 'tkz'); + if (wartend) zwei.dataset.dran = 'ja'; + zwei.append(el('strong', 'tkz__wert', String(wartend))); + zwei.append(el('span', 'tkz__schild', + wartend === 1 ? 'wartet auf dich' : 'warten auf dich')); + leiste.append(zwei); + + const drei = el('div', 'tkz'); + drei.append(el('strong', 'tkz__wert', `${beurteilt}`)); + drei.append(el('span', 'tkz__schild', `von ${punkteGesamt} Punkten besprochen`)); + const bahn = el('span', 'tkz__bahn'); + const voll = el('i', ''); + voll.style.width = `${punkteGesamt ? (beurteilt / punkteGesamt) * 100 : 0}%`; + bahn.append(voll); + drei.append(bahn); + leiste.append(drei); + + ziel.append(leiste); + } + + /* =================================================================== + ZWEI REIHEN STATT EINER (10.09.2026) + + Vorher lagen alle fuenf Kaesten in EINEM Raster: die beiden + Wartelisten und je eine Karte pro Person. Zwei Folgen, beide + schlecht: + + * DIE SPALTEN WURDEN ZU SCHMAL. Bei fuenf nebeneinander blieb + jeder rund 150 px, und im Bildschirmfoto stand "Community + 12 von" -- abgeschnitten mitten in der Zahl. + * DIE LAENGSTE BESTIMMTE DIE HOEHE ALLER. Zehn Rueckmeldungen + machten die Reihe so hoch wie zehn Rueckmeldungen; daneben + standen drei kurze Karten in sehr viel Luft. + + Es sind auch zwei verschiedene Dinge: oben, was eine ANTWORT + braucht, unten, wer im Team ist. Getrennte Reihen sagen das, + ohne es hinzuschreiben. */ + const wartet = el('div', 't-wartet'); + wartet.append(eingangBauen(daten.eingang || [])); + wartet.append(angeboteBauen(daten.angebote || [])); + ziel.append(wartet); + + const runde = el('div', 't-leute'); + for (const m of leute) runde.append(karte(m)); + ziel.append(runde); } catch { /* umgeleitet */ } } @@ -304,7 +543,15 @@ try { const a = await hole('/workspace/api/ich'); if (!a.ok) { location.assign('/workspace/'); return; } - window.werZeigen?.(await a.json()); + /* Wer zusieht, entscheidet, ob die Stufenwahl erscheint. Die + Antwort wurde bisher nur weitergereicht -- gemerkt wird sie + erst, seit es hier etwas gibt, das nicht jeder darf. + + DER SERVER ENTSCHEIDET TROTZDEM: Diese Zeile blendet nur aus. + Wer den Aufruf von Hand nachbaut, bekommt 404 -- das steht in + workspace-teamlage.js und nicht hier. */ + ich = await a.json(); + window.werZeigen?.(ich); } catch { return; } $('abmelden')?.addEventListener('click', async () => { try { await fetch('/workspace/api/abmelden', { method: 'POST', credentials: 'same-origin' }); } diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 5aaae75e..57b44376 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -12,12 +12,12 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - + + + + + +
@@ -245,10 +245,10 @@ - - - - - + + + + +