diff --git a/server/helfer-deutsch.mjs b/server/helfer-deutsch.mjs index 3246d320..76006e32 100644 --- a/server/helfer-deutsch.mjs +++ b/server/helfer-deutsch.mjs @@ -32,6 +32,17 @@ export const ERSATZSCHREIBUNGEN = [ "staerk", "stoer", "taegl", "taet", "traeg", "ueber", "uebung", "ungefaehr", "veraend", "verlaeuf", "vorschlaeg", "waehl", "waehr", "waere", "weiss", "woert", "zaehl", "zugaeng", "zurueck", + /* NACHGETRAGEN AM 17.09.2026, weil das Netz Loecher hatte: Auf einem + Bildschirmfoto der Wunschliste stand "WAS IHR EUCH WUENSCHT", und + die Liste schwieg -- "wuensch" fehlte schlicht. Ein Netz, das man + nicht nachzieht, faengt beim naechsten Mal wieder nichts. + + Deshalb steht hier auch, WIE man das findet: nicht durch Nachdenken, + sondern indem man sich das fertige Bild ansieht. */ + "ausfuehr", "duerf", "einfueh", "erklaer", "fuehl", "fuehr", "fuenf", + "gluec", "gruen", "guelt", "haeuf", "kuend", "muede", "natuerl", + "schuetz", "spuer", "stueck", "traeum", "tuer", "uebrig", + "unterstuetz", "wuensch", "wuerd", ]; /** Welche Ersatzschreibungen stecken in diesem Text? Leer heisst sauber. */ diff --git a/server/pruef-deutsche-texte.mjs b/server/pruef-deutsche-texte.mjs index 61ec402e..690edb18 100644 --- a/server/pruef-deutsche-texte.mjs +++ b/server/pruef-deutsche-texte.mjs @@ -142,6 +142,43 @@ melde("=== 2. Was fest auf den Seiten steht ==="); ok(stellen === 0, `ihr sichtbarer Text ist frei von Ersatzschreibungen (${stellen} Funde)`); } +/* ======================================================================= + 2b. DIE BESCHRIFTUNGEN DER COMMUNITY-BEREICHE + ======================================================================= */ +melde(""); +melde("=== 2b. Was an den Brettern steht ==="); +{ + /* NACHGETRAGEN AM 17.09.2026, und der Weg dorthin gehoert dazu: + Auf einem Bildschirmfoto der Wunschliste stand "WAS IHR EUCH + WUENSCHT" und "FUER DEN STREAM". Diese Pruefung sah es nicht -- + sie kannte die Kataloge und die festen Seitentexte, aber nicht die + Bereichseinstellungen, aus denen jede Ueberschrift und jede + Art-Beschriftung kommt. + + Sechs Stellen waren es. Gefunden hat sie kein Gedankengang, + sondern ein Blick auf das fertige Bild -- deshalb steht das hier. */ + const b = await import("./workspace-bereiche.js"); + const texte = []; + const sammeln = (wo, wert) => { + if (typeof wert === "string" && wert.length > 2) texte.push([wo, wert]); + }; + for (const [name, e] of Object.entries(b.BEREICHE || {})) { + for (const [feld, wert] of Object.entries(e)) { + sammeln(`${name}.${feld}`, wert); + if (wert && typeof wert === "object" && !Array.isArray(wert)) { + for (const [a, x] of Object.entries(wert)) sammeln(`${name}.${feld}.${a}`, x); + } + } + } + ok(texte.length >= 60, `${texte.length} Beschriftungen gefunden`); + const schmutzig = texte.filter(([, t]) => ersatzschreibungen(t).length); + for (const [wo, t] of schmutzig) { + melde(` ${wo}: [${ersatzschreibungen(t).join(", ")}] ${t}`); + } + ok(schmutzig.length === 0, + `keine schreibt Umlaute als ae/oe/ue/ss (${schmutzig.length} Funde)`); +} + /* ======================================================================= 3. GEGENPROBE -- KANN DIE PRUEFUNG UEBERHAUPT ROT WERDEN? ======================================================================= */ @@ -156,6 +193,11 @@ melde("=== 3. Die Gegenprobe ==="); const gefunden = ersatzschreibungen(alt); ok(gefunden.length >= 3, `der alte Kategorietext schlaegt an (${gefunden.join(", ")})`); + /* UND DER SATZ VON HEUTE, an dem das Netz ein Loch hatte. Ohne ihn + waere nicht bewiesen, dass die Liste nachgezogen wurde. */ + ok(ersatzschreibungen("Was ihr euch wuenscht").length > 0, + "auch \"wuenscht\" schlaegt an -- das tat es heute Morgen noch nicht"); + /* UND DIE ANDERE RICHTUNG, genauso wichtig: Der reparierte Text darf NICHT anschlagen. Sonst waere die Liste einfach zu breit. */ const neu = "Was im Livechat passiert, während gesendet wird. " diff --git a/server/pruef-wunschliste.mjs b/server/pruef-wunschliste.mjs new file mode 100644 index 00000000..37ff269d --- /dev/null +++ b/server/pruef-wunschliste.mjs @@ -0,0 +1,301 @@ +/* ===================================================================== + DIE WUNSCHLISTE IST EINE RANGFOLGE, KEINE LISTE (17.09.2026) + + Aus dem Plan im Vault, Stufe 3. + + Eine Wunschliste ohne sichtbare Erfüllung ist ein Briefkasten ohne + Postbote. Nach dem dritten unbeantworteten Wunsch schreibt niemand + mehr. + + --------------------------------------------------------------------- + ZWEI DINGE, DIE BEIM MESSEN ANDERS WAREN ALS IM PLAN + + 1. Die Sortierung nach Stimmen GAB es schon (seit 11.09.). Im Plan + stand "fehlt" -- eine Bestandsliste altert, auch die eigene von + gestern Nacht. + 2. Die Datenbank kannte 'angenommen' und 'abgelehnt' bereits in ihrer + CHECK-Regel; nur die Pruefung im Server liess sie nicht durch. Die + Tabelle war der Logik voraus. + + --------------------------------------------------------------------- + DIE VIER FRAGEN + + 1. Steht der meistgewuenschte oben -- auch wenn er der aelteste ist? + 2. Lassen sich alle vier Zustaende setzen, ein erfundener nicht? + 3. Gelten sie NUR hier? ("Diesmal nicht" an einem Schutzvorfall + bedeutet nichts.) + 4. Bleibt ein GEMACHTER Wunsch sichtbar -- nur nicht mehr oben? + + Die vierte ist die wichtigste. Loeschen waere die falsche Antwort: + Gerade der erfuellte Wunsch ist der Beweis, dass sich Schreiben + lohnt. + + Aufrufen mit: node server/pruef-wunschliste.mjs + Bild: SCHIRM=1 node server/pruef-wunschliste.mjs + ===================================================================== */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { portMussFreiSein } from "./helfer-port.mjs"; +import { heuteLokal, tagLokal } from "./helfer-tag.mjs"; + +const PORT = await portMussFreiSein(4483, "pruef-wunschliste"); +const ordner = mkdtempSync(join(tmpdir(), "ws-wunsch-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = String(PORT); +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; +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-wunschliste"); +await new Promise((r) => setTimeout(r, 700)); +const BASIS = `http://127.0.0.1:${PORT}`; + +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 { 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) { + 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); +} +anlegen("Filipe", "admin", "CODE-DOGI-0001"); +anlegen("VanVan", "hand", "CODE-HAND-0001"); +anlegen("Marina", "modi", "CODE-MODI-0001"); +anlegen("Nane", "modi", "CODE-NANE-0001"); +d.close(); + +async function anmelden(kachel, code) { + const a = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: kachel, code }), + }); + return (a.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; "); +} +const dogi = await anmelden("admin", "CODE-DOGI-0001"); +const hand = await anmelden("creator", "CODE-HAND-0001"); +const modi = await anmelden("creator", "CODE-MODI-0001"); +const nane = await anmelden("creator", "CODE-NANE-0001"); + +const kopf = (keks) => ({ "Content-Type": "application/json", cookie: keks, origin: BASIS }); + +async function wunsch(titel, tage) { + const a = await fetch(BASIS + "/workspace/api/bereich/wunsch", { + method: "POST", headers: kopf(dogi), + body: JSON.stringify({ art: "stream", titel, datum: tagLokal(tage) }), + }); + return (await a.json().catch(() => null))?.id || 0; +} +async function willIch(id, keks) { + const a = await fetch(`${BASIS}/workspace/api/treff/eintrag/${id}/willich`, + { method: "PUT", headers: kopf(keks), body: "{}" }); + return a.status; +} +async function liste(keks = dogi) { + const a = await fetch(BASIS + "/workspace/api/bereich/wunsch", { headers: { cookie: keks } }); + return await a.json().catch(() => null); +} +async function setzen(id, feld, keks = dogi) { + const a = await fetch(`${BASIS}/workspace/api/bereich/wunsch/${id}`, { + method: "PATCH", headers: kopf(keks), body: JSON.stringify(feld), + }); + return { status: a.status, daten: await a.json().catch(() => null) }; +} + +/* ---------- Drei Wuensche, verschieden alt und verschieden beliebt --- */ +const alt = await wunsch("Ein Zockerabend mit der Community", -20); +const mittel = await wunsch("Mehr Technik-Streams", -8); +const neu = await wunsch("Ein Q&A mit Casper", -1); +/* DER AELTESTE bekommt die meisten Stimmen -- genau das ist der Fall, + der eine Sortierung nach Datum entlarvt. */ +for (const k of [hand, modi, nane]) await willIch(alt, k); +await willIch(mittel, hand); + +/* ======================================================================= + 1. DER MEISTGEWUENSCHTE STEHT OBEN + ======================================================================= */ +melde(""); +melde("=== 1. Die Rangfolge ==="); +{ + const j = await liste(); + const namen = (j?.eintraege || []).map((e) => e.titel); + ok((j?.eintraege || []).length === 3, `drei Wuensche (${namen.length})`); + ok(namen[0] === "Ein Zockerabend mit der Community", + `der meistgewuenschte steht oben, obwohl er der aelteste ist ("${namen[0]}")`); + const stimmen = (j?.eintraege || []).map((e) => Number(e.stimmen) || 0); + ok(stimmen.join(",") === "3,1,0", `und die Stimmen stimmen (${stimmen.join(", ")})`); + /* GEGENPROBE zur Sortierung: Waere sie nach Datum, stuende der + juengste oben. Er steht unten -- ohne Stimmen. */ + ok(namen[namen.length - 1] === "Ein Q&A mit Casper", + `der juengste ohne Stimmen steht unten ("${namen[namen.length - 1]}")`); +} + +/* ======================================================================= + 2. DIE VIER ZUSTAENDE + ======================================================================= */ +melde(""); +melde("=== 2. Der Weg eines Wunsches ==="); +{ + const j = await liste(); + const zust = j?.einstellung?.zustaende || {}; + ok(Object.keys(zust).length === 4, + `vier Zustaende kommen mit (${Object.values(zust).join(" · ")})`); + ok(!!zust.abgelehnt, + `darunter ein ausdrueckliches Nein ("${zust.abgelehnt}")`); + + for (const z of ["angenommen", "erledigt", "abgelehnt", "offen"]) { + const a = await setzen(mittel, { status: z }); + ok(a.status === 200, `"${z}" laesst sich setzen (${a.status} ${a.daten?.fehler || ""})`); + } + const x = await setzen(mittel, { status: "vielleicht" }); + ok(x.status === 400, `ein erfundener nicht (${x.status})`); + + /* DER GEPLANTE TAG: "Wird gemacht" allein ist ein Versprechen, + "Wird gemacht -- am 24.09." ist ein Termin. */ + const g = await setzen(mittel, { status: "angenommen", geplant: tagLokal(7) }); + ok(g.status === 200, `ein geplanter Tag dazu (${g.status})`); + const jj = await liste(); + const m = (jj?.eintraege || []).find((e) => e.id === mittel); + ok(m?.geplant === tagLokal(7), `und er kommt zurueck (${m?.geplant})`); + const gf = await setzen(mittel, { geplant: "übermorgen" }); + ok(gf.status === 400, `ein erfundener Tag nicht (${gf.status})`); +} + +/* ======================================================================= + 3. DIE ZUSTAENDE GELTEN NUR HIER + ======================================================================= */ +melde(""); +melde("=== 3. Nicht ueberall ==="); +{ + /* "Diesmal nicht" an einem Anschlag bedeutet nichts -- eine + gemeinsame Liste haette es trotzdem erlaubt. */ + const a = await fetch(BASIS + "/workspace/api/bereich/anschlag", { + method: "POST", headers: kopf(dogi), + body: JSON.stringify({ art: "regel", titel: "Eine Ansage", datum: heuteLokal() }), + }); + const id = (await a.json().catch(() => null))?.id; + if (!id) { melde(" -- kein Anschlag anlegbar -- Art unbekannt, Abschnitt uebersprungen"); } + else { + const b = await fetch(`${BASIS}/workspace/api/bereich/anschlag/${id}`, { + method: "PATCH", headers: kopf(dogi), body: JSON.stringify({ status: "abgelehnt" }), + }); + ok(b.status === 400, `"abgelehnt" gibt es am Anschlagbrett nicht (${b.status})`); + const c = await fetch(`${BASIS}/workspace/api/bereich/anschlag/${id}`, { + method: "PATCH", headers: kopf(dogi), body: JSON.stringify({ status: "erledigt" }), + }); + ok(c.status === 200, `"erledigt" dagegen schon (${c.status})`); + } +} + +/* ======================================================================= + 4. EIN GEMACHTER WUNSCH BLEIBT SICHTBAR + ======================================================================= */ +melde(""); +melde("=== 4. Erfuellt heisst nicht verschwunden ==="); +{ + /* Der MEISTGEWUENSCHTE wird gemacht. Er darf nicht verschwinden -- + gerade er ist der Beweis, dass sich Schreiben lohnt. Er soll nur + nicht mehr die offenen verdraengen. */ + await setzen(alt, { status: "erledigt" }); + const j = await liste(); + const namen = (j?.eintraege || []).map((e) => e.titel); + const drin = namen.includes("Ein Zockerabend mit der Community"); + ok(drin, "der gemachte Wunsch steht weiter in der Liste"); + ok(namen[0] !== "Ein Zockerabend mit der Community", + `aber nicht mehr oben (oben steht jetzt "${namen[0]}")`); + ok(namen[namen.length - 1] === "Ein Zockerabend mit der Community", + "sondern unten bei den entschiedenen"); +} + +/* ======================================================================= + 5. IM BROWSER + ======================================================================= */ +melde(""); +melde("=== 5. Was man sieht ==="); +const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await chromium.launch(); +try { + const kontext = await browser.newContext({ viewport: { width: 390, height: 844 } }); + await kontext.addCookies(dogi.split("; ").map((k) => { + const [name, ...rest] = k.split("="); + return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" }; + })); + const seite = await kontext.newPage(); + const konsole = []; + seite.on("console", (e) => { if (e.type() === "error") konsole.push(e.text()); }); + await seite.goto(`${BASIS}/workspace/bereich.html?b=wunsch`, { waitUntil: "networkidle" }); + await seite.waitForSelector(".wunsch-rang", { timeout: 15_000 }); + await seite.waitForTimeout(500); + + const m = await seite.evaluate(() => ({ + balken: [...document.querySelectorAll(".wunsch-rang__fuellung")] + .map((b) => b.style.width), + spitze: document.querySelectorAll('.wunsch-rang[data-spitze="ja"]').length, + staende: [...document.querySelectorAll(".wunsch-stand")] + .map((z) => `${z.dataset.stand}:${z.textContent.trim()}`), + wahlKnoepfe: document.querySelectorAll(".wunsch-wahl__knopf").length, + gedrueckt: document.querySelectorAll('.wunsch-wahl__knopf[aria-pressed="true"]').length, + sprache: document.querySelector(".wunsch-rang")?.getAttribute("aria-label") || "", + })); + + ok(m.balken.length === 3, `drei Rangbalken (${m.balken.join(", ")})`); + /* DER ABSTAND IST DIE AUSSAGE: drei Stimmen = voll, eine = ein + Drittel, keine = leer. Ohne diese Zeile waere ein Balken, der + immer 100 % zeigt, genauso gruen. */ + ok(m.balken[0] === "100%" || m.balken.includes("100%"), + `der meistgewuenschte ist voll (${m.balken.join(", ")})`); + ok(m.balken.includes("33%"), `einer bei einem Drittel (${m.balken.join(", ")})`); + ok(m.balken.includes("0%"), "und einer leer"); + ok(m.spitze === 1, `genau einer ist als Spitze markiert (${m.spitze})`); + ok(m.staende.length === 3, `jeder Wunsch zeigt seinen Zustand (${m.staende.join(" | ")})`); + ok(m.staende.some((z) => z.startsWith("erledigt")), "darunter der gemachte"); + /* VIER KNOEPFE JE WUNSCH, und genau einer ist der aktuelle. */ + ok(m.wahlKnoepfe === 12, `vier Knoepfe je Wunsch (${m.wahlKnoepfe})`); + ok(m.gedrueckt === 3, `und je einer ist der aktuelle (${m.gedrueckt})`); + /* EIN BALKEN IST FUER EIN VORLESEPROGRAMM NICHTS -- ausser man sagt + es in Worten. */ + ok(/wollen das auch|will das auch|niemand/.test(m.sprache), + `der Balken spricht auch ("${m.sprache}")`); + ok(konsole.length === 0, + konsole.length ? `Konsole: ${konsole[0]}` : "keine Fehler in der Konsole"); + + if (process.env.SCHIRM) { + const pfad = fileURLToPath(new URL("pruef-wunschliste.png", import.meta.url)); + await seite.screenshot({ path: pfad, fullPage: false }); + melde(" -- Bildschirmfoto: server/pruef-wunschliste.png"); + } + await kontext.close(); +} finally { + 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/workspace-bereiche.js b/server/workspace-bereiche.js index 6bfecde2..b69971f5 100644 --- a/server/workspace-bereiche.js +++ b/server/workspace-bereiche.js @@ -117,11 +117,39 @@ export const BEREICHE = { wunsch: { name: "Wunschliste", - ober: "Was ihr euch wuenscht", + ober: "Was ihr euch wünscht", + /* DER WEG EINES WUNSCHES (17.09.2026). + + Bis heute kannte ein Eintrag zwei Zustaende: offen und erledigt. + Fuer eine Wunschliste ist das zu wenig -- und der Mangel ist + kein Schoenheitsfehler, sondern der Grund, warum solche Listen + sterben: + + Eine Wunschliste ohne sichtbare Erfuellung ist ein Briefkasten + ohne Postbote. Nach dem dritten unbeantworteten Wunsch + schreibt niemand mehr. + + "Diesmal nicht" gehoert ausdruecklich dazu. Ein Nein ist eine + Antwort; Schweigen ist keine. Wer sieht, dass entschieden wurde, + schreibt beim naechsten Mal wieder -- auch wenn die Entscheidung + gegen ihn ausfiel. + + DIE DATENBANK KONNTE DAS SCHON. Ihre CHECK-Regel erlaubt seit + laengerem 'angenommen' und 'abgelehnt'; nur die Pruefung im + Server liess sie nicht durch. Die Tabelle war der Logik voraus. */ + zustaende: { + offen: "Offen", + angenommen: "Wird gemacht", + erledigt: "Gemacht", + abgelehnt: "Diesmal nicht", + }, + /* Dazu ein Datum: "Wird gemacht" allein ist ein Versprechen, + "Wird gemacht -- am 24.09." ist ein Termin. */ + mitGeplant: true, arten: { - stream: "Fuer den Stream", + stream: "Für den Stream", technik: "Technik", - gemeinsam: "Fuer die Community", + gemeinsam: "Für die Community", sonstiges: "Sonstiges", }, bewertung: false, @@ -152,14 +180,14 @@ export const BEREICHE = { regeln: { name: "Regeln & Hilfe", - ober: "Wie es hier laeuft", + ober: "Wie es hier läuft", /* REGELN IN EINFACHER SPRACHE UND AN ECHTEN FAELLEN. Vage Regeln erzeugen Grenzfaelle, und die muss dann jeder Modi einzeln entscheiden -- das ist der Weg, auf dem ein Team uneinheitlich wird und die Community es merkt. */ arten: { regel: "Regel", - frage: "Haeufige Frage", + frage: "Häufige Frage", hilfe: "Hilfe", folge: "Was passiert, wenn", }, @@ -182,7 +210,7 @@ export const BEREICHE = { weil es stimmt -- und weil es die richtige Erwartung setzt. */ arten: { gesucht: "Wir suchen", - lust: "Ich haette Lust", + lust: "Ich hätte Lust", frage: "Frage dazu", }, bewertung: false, @@ -1072,7 +1100,18 @@ bereicheRouter.get("/workspace/api/bereich/:bereich", (req, res) => { const reihenfolge = bereich === "anschlag" ? "angeheftet DESC, e.datum DESC, e.id DESC" : bereich === "wunsch" - ? "stimmen DESC, e.datum DESC, e.id DESC" + /* ENTSCHIEDENES NACH UNTEN -- ABER NICHT WEG (17.09.2026). + + Die Sortierung nach Stimmen gibt es seit dem 11.09. Seit es + vier Zustaende gibt, reicht sie nicht mehr: Ein gemachter + Wunsch mit vielen Stimmen stuende dauerhaft ganz oben und + verdraengte die offenen, ueber die noch zu entscheiden ist. + + Loeschen waere die falsche Antwort. Gerade der ERFUELLTE + Wunsch ist der Beweis, dass sich Schreiben lohnt -- er + gehoert gesehen, nur nicht an der ersten Stelle. */ + ? `CASE e.status WHEN 'offen' THEN 0 WHEN 'angenommen' THEN 0 ELSE 1 END, + stimmen DESC, e.datum DESC, e.id DESC` : `CASE e.status WHEN 'offen' THEN 0 ELSE 1 END, CASE e.dringlichkeit WHEN 'hoch' THEN 0 WHEN 'mittel' THEN 1 ELSE 2 END, e.datum DESC, e.id DESC`; @@ -1244,9 +1283,26 @@ function pruefe(bereich, körper, { neu }) { else aus.uhrzeit = u; } if (körper.status !== undefined) { - if (!STATUS.includes(körper.status)) fehler.push("Unbekannter Status."); + /* JE BEREICH, NICHT GLOBAL (17.09.2026). Die Wunschliste kennt + vier Zustaende, alle anderen zwei. Eine gemeinsame Liste haette + "Diesmal nicht" auch an einem Schutzvorfall erlaubt -- und dort + bedeutet es nichts. */ + const erlaubt = einstellung.zustaende + ? Object.keys(einstellung.zustaende) : STATUS; + if (!erlaubt.includes(körper.status)) fehler.push("Unbekannter Status."); else aus.status = körper.status; } + /* DER GEPLANTE TERMIN eines Wunsches. Bisher gab es das Feld nur in + der Content-Planung; hier traegt es dieselbe Bedeutung und darf + deshalb dasselbe Format haben. Leer heisst "noch kein Termin" und + nicht "unveraendert". */ + if (einstellung.mitGeplant && körper.geplant !== undefined) { + const g = String(körper.geplant ?? "").trim(); + if (!g) aus.geplant = null; + else if (!/^\d{4}-\d{2}-\d{2}$/.test(g) || Number.isNaN(Date.parse(g))) { + fehler.push("Der geplante Tag ist ungültig."); + } else aus.geplant = g; + } /* Bewertung und Dringlichkeit gibt es nur dort, wo der Bereich sie vorsieht -- sonst werden sie stillschweigend verworfen. */ if (einstellung.bewertung && körper.bewertung !== undefined) { diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index 7e63d168..4932c13e 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=202609171113", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609171125", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609171113", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609171125", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609171113", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609171125", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609171113", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609171125", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/bereich.css b/workspace/assets/css/bereich.css index 9c89d7bb..cf5a89ce 100644 --- a/workspace/assets/css/bereich.css +++ b/workspace/assets/css/bereich.css @@ -763,3 +763,85 @@ font-size: .74rem; letter-spacing: normal; color: var(--text-still); font-variant-numeric: tabular-nums; } + +/* ===================================================================== + DIE WUNSCHLISTE ALS RANGFOLGE (17.09.2026) + + Aus dem Plan im Vault, Stufe 3. Eine Wunschliste ist keine Liste — + sie ist eine Rangfolge mit einem Zustand. Ohne beides ist sie ein + Briefkasten ohne Postbote: Nach dem dritten unbeantworteten Wunsch + schreibt niemand mehr. + ------------------------------------------------------------------- */ + +/* DER RANGBALKEN. Er zeigt den ABSTAND zum meistgewünschten, nicht die + absolute Zahl — die steht ohnehin auf dem Knopf daneben. Ein Wunsch + mit 12 Stimmen neben einem mit 14 sieht sonst aus wie einer mit 1 + neben einem mit 40. + + Drei Pixel hoch und ohne Beschriftung: Er ist ein Hinweis, kein + Diagramm. Wer die Zahl will, liest sie. */ +.wunsch-rang { + height: 3px; margin: 10px 0 2px; + border-radius: 999px; + background: rgba(255, 255, 255, .05); + overflow: hidden; +} +.wunsch-rang__fuellung { + height: 100%; border-radius: inherit; + background: linear-gradient(90deg, + color-mix(in srgb, var(--akzent) 70%, transparent), + color-mix(in srgb, var(--akzent) 30%, transparent)); + transition: width .3s ease; +} +/* Der meistgewünschte bekommt etwas mehr — aber nur etwas. Er ist der + erste unter Gleichen, nicht der Gewinner. */ +.wunsch-rang[data-spitze="ja"] .wunsch-rang__fuellung { + background: linear-gradient(90deg, var(--akzent), + color-mix(in srgb, var(--akzent) 45%, transparent)); +} +@media (prefers-reduced-motion: reduce) { + .wunsch-rang__fuellung { transition: none; } +} + +/* DER ZUSTAND. Er steht im Kopf neben dem Titel, weil er das ist, + weswegen jemand wiederkommt. + + „Diesmal nicht" ist bewusst NICHT rot. Ein Nein ist eine Antwort und + keine Strafe — und wer ein rotes Schild an seinem Wunsch sieht, + schreibt keinen zweiten. */ +.wunsch-stand { + --w-ton: #6f89a8; + display: inline-flex; align-items: center; + padding: 2px 9px; border-radius: 999px; + font-size: .74rem; font-weight: 600; white-space: nowrap; + color: color-mix(in srgb, var(--w-ton) 72%, #ffffff); + background: color-mix(in srgb, var(--w-ton) 14%, transparent); + border: 1px solid color-mix(in srgb, var(--w-ton) 32%, transparent); +} +.wunsch-stand[data-stand="offen"] { --w-ton: #6f89a8; } +.wunsch-stand[data-stand="angenommen"] { --w-ton: #5f93c4; } +.wunsch-stand[data-stand="erledigt"] { --w-ton: #4dab77; } +.wunsch-stand[data-stand="abgelehnt"] { --w-ton: #8e8577; } + +/* DIE ENTSCHEIDUNG — vier Knöpfe statt eines Umschalters. Der aktuelle + Zustand ist abgeschaltet: Ein Knopf, der nichts tut, gehört nicht + anfassbar auszusehen. */ +.wunsch-wahl { display: flex; flex-wrap: wrap; gap: 6px; } +.wunsch-wahl__knopf { + font: inherit; font-size: .78rem; font-weight: 600; + padding: 6px 12px; border-radius: 999px; cursor: pointer; + color: var(--text-leise); + background: transparent; + border: 1px solid var(--rand-hell); + transition: color var(--tempo), border-color var(--tempo), background var(--tempo); +} +.wunsch-wahl__knopf:hover:not(:disabled) { + color: var(--text); border-color: var(--rand); + background: rgba(255, 255, 255, .04); +} +.wunsch-wahl__knopf[aria-pressed="true"] { + color: var(--text); + border-color: color-mix(in srgb, var(--akzent) 50%, transparent); + background: color-mix(in srgb, var(--akzent) 12%, transparent); + cursor: default; +} diff --git a/workspace/assets/js/bereich.js b/workspace/assets/js/bereich.js index d4b57003..62d4384b 100644 --- a/workspace/assets/js/bereich.js +++ b/workspace/assets/js/bereich.js @@ -47,6 +47,12 @@ return true; }; + /* DIE HOECHSTE STIMMENZAHL DER LISTE. Sie ist der Massstab fuer den + Balken: Ein Wunsch mit 12 Stimmen neben einem mit 14 sieht sonst + genauso aus wie einer mit 1 neben einem mit 40. Der Balken zeigt + den ABSTAND, nicht die absolute Zahl -- die steht ohnehin daneben. */ + let maxStimmen = 0; + const $ = (id) => document.getElementById(id); const UNTERZEILE = { live: 'Vorbereitung, Mitschrift und Auswertung – LIVE systematisch verbessern.', @@ -299,6 +305,17 @@ } kopf.append(el('h3', 'eintrag-titel', e.titel)); + /* DER ZUSTAND EINES WUNSCHES steht im Kopf, nicht im Kleingedruckten + (17.09.2026). Er ist das, weswegen jemand wiederkommt: Ein Wunsch, + bei dem nie etwas passiert, ist ein Briefkasten ohne Postbote. */ + if (einstellung?.zustaende && e.status) { + const name = einstellung.zustaende[e.status] || e.status; + const wann = e.geplant ? ` · ${datum(String(e.geplant).slice(0, 10))}` : ''; + const z = el('span', 'wunsch-stand', name + wann); + z.dataset.stand = e.status; + kopf.append(z); + } + if (einstellung.bewertung && e.bewertung) { const b = el('span', 'bewertung'); b.setAttribute('aria-label', `Bewertung ${e.bewertung} von 5`); @@ -311,6 +328,26 @@ } k.append(kopf); + /* DER RANGBALKEN. Eine Wunschliste ist eine Rangfolge, keine Liste + -- und eine Rangfolge sieht man erst, wenn der Abstand sichtbar + ist. Die Zahl allein beantwortet "wie viele", der Balken "wie + viel mehr als die anderen". */ + if (bereich === 'wunsch' && maxStimmen > 0) { + const n = Number(e.stimmen) || 0; + const balken = el('div', 'wunsch-rang'); + const fuell = el('div', 'wunsch-rang__fuellung'); + fuell.style.width = `${Math.round((n / maxStimmen) * 100)}%`; + if (n === maxStimmen && n > 0) balken.dataset.spitze = 'ja'; + balken.append(fuell); + /* Fuer Vorleseprogramme eine SPRACHE, kein Balken. */ + balken.setAttribute('role', 'img'); + balken.setAttribute('aria-label', + n === 0 ? 'Noch niemand will das auch' + : `${n} ${n === 1 ? 'Person will' : 'Personen wollen'} das auch` + + (n === maxStimmen ? ', das ist der meistgewünschte' : '')); + k.append(balken); + } + /* ---- Agentur-Event: Zeitraum, Aufgaben, Regeln ------------------- Die Reihenfolge ist nicht beliebig. Wer auf ein Event schaut, fragt in genau dieser Folge: Laeuft das ueberhaupt noch? Was @@ -525,6 +562,41 @@ if (!darfEintragen()) { k.append(fuss); return k; } const knoepfe = el('div', 'eintrag-knoepfe'); + + /* VIER ZUSTAENDE STATT EINES UMSCHALTERS (17.09.2026). + + "erledigt / wieder offen" reicht fuer einen Vorfall. Ein Wunsch + braucht mehr: Er wird angenommen, bekommt einen Tag, wird gemacht + -- oder eben nicht. "Diesmal nicht" gehoert ausdruecklich dazu: + Ein Nein ist eine Antwort, Schweigen ist keine. */ + if (einstellung?.zustaende) { + const reihe = el('div', 'wunsch-wahl'); + reihe.setAttribute('role', 'group'); + reihe.setAttribute('aria-label', 'Zustand setzen'); + for (const [wert, name] of Object.entries(einstellung.zustaende)) { + const b = el('button', 'wunsch-wahl__knopf', name); + b.type = 'button'; + b.dataset.stand = wert; + b.setAttribute('aria-pressed', String(e.status === wert)); + if (e.status === wert) b.disabled = true; + b.addEventListener('click', async () => { + b.disabled = true; + const a = await hole(`/workspace/api/bereich/${bereich}/${e.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: wert }), + }); + if (!a.ok) { melde('Ging nicht.'); b.disabled = false; return; } + await laden(); + }); + reihe.append(b); + } + knoepfe.append(reihe); + k.append(knoepfe); + k.append(fuss); + return k; + } + knoepfe.append(tuKnopf(e.status === 'offen' ? 'erledigt ▶' : '◀ wieder offen', async () => { const a = await hole(`/workspace/api/bereich/${bereich}/${e.id}`, { method: 'PATCH', @@ -681,6 +753,8 @@ offene" stellt, will trotzdem wissen, wann der naechste Stream ist. */ countdownZeigen(daten.eintraege); + maxStimmen = (daten.eintraege || []) + .reduce((n, e) => Math.max(n, Number(e.stimmen) || 0), 0); if (!sichtbar.length) { ziel.append(el('p', 'leer-hinweis', daten.eintraege.length diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index a078bbd7..6e4b04b2 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. --> - - - - - - + + + + + + - +
@@ -291,10 +291,10 @@ - - - - - + + + + +