From 807d7d4a1c825cd48f94065279a4ed4c46b32e8e Mon Sep 17 00:00:00 2001 From: Dogfather Date: Thu, 8 Oct 2026 11:02:54 +0200 Subject: [PATCH] =?UTF-8?q?Das=20Foyer:=20eine=20Seite=20vor=20dem=20Saal,?= =?UTF-8?q?=20mit=20Plan,=20Eintritt=20und=20Vorschl=C3=A4gen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filipe: „diese kachel soll nicht mehr geschlossen sein, ABER wenn man drauf drückt geht zuerst eine seite auf mit reihen/kategorien. links eine reihe wo die nächsten geplanten videos schon bereit stehen. wan und umd wie viel uhr … und das soll geschlossen sein außer für vanvan und dogfather. in der mitte sollen die leute dan wenn eine sendung läuft drauf drücken können und der sendung beitreten. auch bei der vorschau schon und dan rechts will ich die letzte reihe/kategorie, da sollen die leute alle mir videos anraten können … und nur ich und vanvan sehen sie." Haus: Team Dogi (crew.dogfather-universe.com). Die Agentur ist nicht berührt -- pruef-haus-trennung 107/0, pruef-workspace-seiten 37/0. WARUM EINE EIGENE SEITE UND KEIN VIERTER ZUSTAND DES SAALS Der Saal meldet jeden an, der ihn öffnet („dabei"), baut Verbindungen auf und hält den Ereignisstrom offen. Das ist richtig für jemanden, der zusieht -- und falsch für jemanden, der nur nachsehen will, ob heute Abend etwas läuft. Wäre das Foyer der vierte Zustand derselben Seite, zählte jeder Blick als Zuschauer, und die Zahl im Regiepult sähe nach Publikum aus, wo niemand zusieht. Im Foyer wird nur gelesen: Stand holen, zeichnen, alle 20 Sekunden nachfragen -- und nicht, solange der Reiter im Hintergrund liegt. Kein Ereignisstrom: Der hielte eine Verbindung offen, und manche Browser geben je Adresse nur sechs her; im Saal wird jede davon gebraucht. DREI REIHEN LINKS die Warteschlange, nach TERMIN sortiert statt nach Platz. Was keinen hat, steht unten; was vorbei ist, bekommt „· überfällig". Nur admin und rechte Hand -- und für alle anderen steht sie gar nicht erst da, statt leer oder gesperrt zu sein. Ein Kasten mit Schloss erzählt, dass es etwas zu sehen gäbe. Entschieden wird das am Server; die Seite fragt nicht nach der Rolle, sondern danach, ob die Auskunft gekommen ist. MITTE Lampe (Geschlossen / Gleich / Auf Sendung), Vorschaubild, Titel, Satz -- und der Knopf, der NUR dasteht, wenn er auch hineinführt. Er ist ein Link und kein Button: Wer ihn mit der mittleren Maustaste antippt, bekommt einen neuen Reiter. RECHTS Video anraten. Jeder darf schicken, niemand sieht fremde, jeder sieht seine eigenen mit Stand. Vier Sperren, und jede beantwortet etwas anderes: zu schnell (45 s), zu viele offen (5 je Person), schon angeraten, steht schon bereit. Am Telefon steht die MITTE oben. Dort ist „links" nur noch „vor allem anderen", und das wäre die falsche Reihenfolge. WAS SONST NOCH DAZUGEHÖRT * Jede Zeile der Warteschlange im Saal hat jetzt ein Terminfeld. * Die Kachel sagt nicht mehr „geschlossen" -- das Foyer ist immer offen. „gleich" und „● live" bleiben: Sie sagen etwas, das man ohne sie nicht weiß. * reaktion.html steht in OHNE_KACHEL_UEBERALL. Die erlaubten Seiten werden aus den Kachelzielen abgeleitet; als die Kachel aufs Foyer zeigte, war der Saal plötzlich von keiner mehr genannt und antwortete mit einer Umleitung. DREI DOPPELTE ANTWORTEN BESEITIGT * „Wie viele Vorschläge darf einer offen haben" stand VIERMAL: dreimal als `je_person_max || 5` und einmal als Wort „fünf" im Satz (plus ein fünftes Mal in meldung.js). Kommt jetzt vom Server. * `maxlength="200"` im Formular neben VORSCHLAG_NOTIZ_MAX im Server. Das Feld lernt die Grenze von der Auskunft. * Die drei Foyer-Tafeln hatten eine eigene Fläche (Weiß mit 4,5 %) und eine eigene Form. Beides gibt es im Haus längst: Material in start.css (45 Klassen, gemessen), Form in module.css. Die eigene Fläche war dazu fast durchsichtig -- über einem hellen Hintergrundbild stand der Text ohne deckenden Grund. Gefunden hat das nicht eine Prüfung, sondern das Bildschirmfoto. EINE LÜCKE IN DER PRÜFUNG GESCHLOSSEN In module.css steht dieselbe 48er-Klassenliste ACHTMAL. Sieben trugen die Marke MODULLISTE und wurden verglichen; die achte -- die Umrandung beim Tabben (:focus-visible) -- nicht. Sie war damit die Einzige, die still hätte abweichen können, ausgerechnet bei der Regel, die nur sieht, wer mit der Tastatur bedient. pruef-css-klassen erwartet jetzt acht. Eine steigende Prüfzahl, und sie ist begründet. UND EINER, DEN KEIN WERKZEUG FINDEN KANN Der Satz oben lautete „Links steht, was ansteht; rechts kannst du etwas anraten." Für die Community gibt es „links" gar nicht, und am Telefon stehen die Reihen untereinander. Eine Richtung im Text ist eine Aussage über das Aussehen -- und das ändert sich mit dem Bildschirm und mit der Rolle. Jetzt steht dort, WAS geht, nicht WO. GEMESSEN mess-foyer (neu) 70 Messungen, 0 Fehler, 9 Bildschirmfotos pruef-reaktion 538, 0 Fehler (vorher 478) pruef-handy-teamdogi 263 Seitenaufrufe, 0 Befunde (vorher 244) pruef-sackgassen 14, 0 Fehler pruef-crew-adresse 173, 0 Fehler pruef-haus-trennung 107, 0 Fehler pruef-struktur 102, 0 Fehler pruef-neue-seiten 109, 0 Fehler pruef-portnummern 41, 0 Fehler pruef-haus-seiten 38, 0 Fehler pruef-workspace-seiten 37, 0 Fehler pruef-breiten 45 Seiten auf fünf Breiten, 0 beanstandet pruef-community-sicht 10, 0 Fehler pruef-deutsche-texte 12, 0 Fehler pruef-css-klassen grün mess-foyer klickt den ganzen Weg durch: Ein Gast schickt ein Video, DogFather sieht es mit Namen, nimmt es, und es muss links im Plan stehen. Zum Schluss macht es sechs seiner Messungen absichtlich kaputt und prüft, dass sie das merken. NICHT BEHOBEN, WEIL NICHT MEINS: pruef-meldungen ist rot (28 Kennungen ohne Satz, 3 Rohanzeigen) -- gemessen im worktree auf dem letzten Commit schon vorher, Zeichen für Zeichen dieselbe Liste. Die Dateien gehören überwiegend der Agentur; das gehört in einen eigenen Durchgang. Co-Authored-By: Claude Opus 5 --- server/crew-adresse.js | 5 + server/mess-foyer.mjs | 681 ++++++++++++++++++++++++++ server/pruef-community-sicht.mjs | 6 + server/pruef-css-klassen.mjs | 31 +- server/pruef-reaktion.mjs | 310 +++++++++++- server/reaktion-tabellen.js | 110 +++++ server/rechte.js | 7 + server/workspace-reaktion.js | 282 ++++++++++- server/workspace.js | 26 +- workspace/anleitung.html | 28 +- workspace/anruf-probe.html | 10 +- workspace/app.webmanifest | 8 +- workspace/assets/css/foyer.css | 478 ++++++++++++++++++ workspace/assets/css/module.css | 46 +- workspace/assets/css/reaktion.css | 38 ++ workspace/assets/css/start.css | 38 +- workspace/assets/js/meldung.js | 21 + workspace/assets/js/reaktion-foyer.js | 540 ++++++++++++++++++++ workspace/assets/js/reaktion.js | 63 ++- workspace/aufgaben.html | 38 +- workspace/automation.html | 32 +- workspace/befinden.html | 34 +- workspace/bewerben.html | 38 +- workspace/bewerbungen.html | 36 +- workspace/buehne.html | 6 +- workspace/calls.html | 34 +- workspace/chat.html | 42 +- workspace/content.html | 34 +- workspace/crew-index.html | 12 +- workspace/crew.webmanifest | 8 +- workspace/dateien.html | 34 +- workspace/entwicklung.html | 36 +- workspace/hilfe.html | 30 +- workspace/index.html | 10 +- workspace/kalender.html | 34 +- workspace/leistung.html | 32 +- workspace/manager-ziele.html | 28 +- workspace/material.html | 32 +- workspace/notizen.html | 28 +- workspace/personen.html | 32 +- workspace/profil.html | 36 +- workspace/reaktion-foyer.html | 206 ++++++++ workspace/reaktion.html | 40 +- workspace/rechte.html | 34 +- workspace/report.html | 36 +- workspace/scouting.html | 32 +- workspace/start.html | 38 +- workspace/startcheck.html | 34 +- workspace/steckbrief.html | 36 +- workspace/support.html | 28 +- workspace/tafel.html | 6 +- workspace/talente.html | 36 +- workspace/team.html | 32 +- workspace/teamlage.html | 34 +- workspace/teilen.html | 30 +- workspace/treff-moderation.html | 32 +- workspace/treff-regeln.html | 32 +- workspace/uebersicht.html | 32 +- workspace/unsere-seiten.html | 30 +- workspace/unterstuetzen.html | 32 +- workspace/werdegang.html | 32 +- workspace/willkommen.html | 2 +- workspace/wissen.html | 34 +- 63 files changed, 3524 insertions(+), 698 deletions(-) create mode 100644 server/mess-foyer.mjs create mode 100644 workspace/assets/css/foyer.css create mode 100644 workspace/assets/js/reaktion-foyer.js create mode 100644 workspace/reaktion-foyer.html diff --git a/server/crew-adresse.js b/server/crew-adresse.js index 54dfa8e5..99e620a3 100644 --- a/server/crew-adresse.js +++ b/server/crew-adresse.js @@ -387,6 +387,11 @@ export const GEHOERT_ZU_ADRESSE = [ die Daten sind die Sache. */ { datei: "/workspace/notizen.html", zuhause: istCrewAdresse }, { datei: "/workspace/reaktion.html", zuhause: istCrewAdresse }, + /* Das Foyer davor (08.10.2026) -- dieselbe Tuer, dieselbe + Trennung. Ohne diesen Eintrag waere es auf der Agenturadresse + abrufbar, weil express.static jede Datei ausliefert, die man + beim Namen nennt. */ + { datei: "/workspace/reaktion-foyer.html", zuhause: istCrewAdresse }, /* DIE ZWEI BUEHNENQUELLEN (28.09.2026). Sie kommen ohne Anmeldung aus -- geprueft wird ein Schluessel in der Adresse -- und gehoeren trotzdem in genau ein Haus. */ diff --git a/server/mess-foyer.mjs b/server/mess-foyer.mjs new file mode 100644 index 00000000..624cef15 --- /dev/null +++ b/server/mess-foyer.mjs @@ -0,0 +1,681 @@ +/* ===================================================================== + WIE DAS FOYER AUSSIEHT (08.10.2026) + + `pruef-reaktion` misst, was die Schnittstelle antwortet. Das sagt + nichts darueber, ob die drei Reihen auf einem Bildschirm + nebeneinander passen, ob der Knopf in der Mitte ins Auge fiele und + ob am Telefon die Sendung oben steht. + + WARUM EIGENES WERKZEUG UND KEIN ABSCHNITT IN mess-reaktion: + Dort laufen Kameras, Video und Chat -- jede geoeffnete Seite haelt + zwei stehende Verbindungen, und der Browser gibt je Adresse nur + wenige her. Das Foyer braucht keine davon. Es misst in Sekunden, + was dort zwischen zwei Kameraaufbauten untergehen wuerde. + + GEMESSEN WIRD, WAS MAN NICHT ERRATEN KANN: + + * Steht der Plan bei einem Gast wirklich NICHT da -- und steht + auch kein Stueck daraus irgendwo im Dokument? + * Ist die Mitte breiter als die Seiten, und rutschen die zwei + uebrigen Reihen auf, wenn der Plan fehlt? + * Steht am Telefon die Sendung in der ERSTEN Zeile? + * Ist der Knopf bei "zu" weg und bei "gleich"/"live" da? + * Sind alle Beruehrziele 44 Pixel hoch und alle Schriften + mindestens 11,5 Pixel gross? + * Und geht der ganze Weg durch: Gast schickt ein Video, + DogFather sieht es MIT Namen, nimmt es -- und es steht links + im Plan. + + ZU JEDER MESSUNG, DIE "in Ordnung" SAGEN KANN, GEHOERT EINE + GEGENPROBE. Am Ende wird jede der tragenden Messungen absichtlich + kaputtgemacht und muss anschlagen. Eine Messung, die immer + bestaetigt, bestaetigt nichts. + + NIE DIE ECHTE DATENBANK. Eigener Ordner, eigener Port, eigenes + Zertifikat -- und am Ende ist alles weg. + + DREI AUSGAENGE: + 0 in Ordnung + 1 ein Befund + 3 konnte nicht nachsehen (Playwright fehlt, kein Zertifikat, + die Buehne nimmt nichts an, oder der Stand hat sich waehrend + des Laufs geaendert) + + Aufrufen mit: node server/mess-foyer.mjs + ===================================================================== */ +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { request } from "node:http"; +import { createServer as httpsServer } from "node:https"; +import { zertifikatBauen } from "./helfer-openssl.mjs"; +import { notbremse } from "./helfer-notbremse.mjs"; +import { eigenerPort } from "./helfer-port.mjs"; + +const HIER = dirname(fileURLToPath(import.meta.url)); +const ordner = mkdtempSync(join(tmpdir(), "mess-foyer-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +const PORT = await eigenerPort(import.meta); +const HTTPS_PORT = await eigenerPort(import.meta, "mess-foyer (HTTPS)", 1); +process.env.PORT = `${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; +/* Der Sitzungskeks ist `secure`. Der Vorbau unten spricht https, der + Server dahinter nicht -- ohne diesen Griff setzt er den Keks und + der Browser verwirft ihn. */ +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +notbremse(240_000, "mess-foyer"); +await new Promise((r) => setTimeout(r, 1000)); + +const CREW = "crew.dogfather-universe.com"; + +/* ---------- Leute ------------------------------------------------------- */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const kenn = randomBytes(32).toString("hex"); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", kenn, new Date().toISOString()); +const ALT = new Date(Date.now() - 200 * 86400000).toISOString(); +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", kenn).update(code).digest("hex"), ALT); +} +const CODES = { + admin: "CODE-M00-000001", hand: "CODE-M01-000001", + gast: "CODE-M02-000001", gast2: "CODE-M03-000001", +}; +/* VANVAN IST DIE RECHTE HAND und wird mitgemessen, nicht nur + DogFather: Filipe hat "nur ich und vanvan" gesagt, und ob die + zweite Haelfte davon stimmt, sieht man nur, wenn sie dabei ist. */ +anlegen("DogFather", "admin", CODES.admin); +anlegen("VanVan", "hand", CODES.hand); +anlegen("Nachtfalter", "gast", CODES.gast); +anlegen("Lenny", "gast", CODES.gast2); +d.close(); + +function roh(pfad, { method = "GET", keks = "", rumpf = null } = {}) { + return new Promise((fertig, schief) => { + const daten = rumpf === null ? null : Buffer.from(JSON.stringify(rumpf)); + const a = request({ + host: "127.0.0.1", port: PORT, path: pfad, method, + headers: { + Host: CREW, Origin: `https://${CREW}`, + ...(keks ? { Cookie: keks } : {}), + ...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}), + }, + }, (antwort) => { + const teile = []; + antwort.on("data", (s) => teile.push(s)); + antwort.on("end", () => { + const text = Buffer.concat(teile).toString("utf8"); + let j = null; try { j = JSON.parse(text); } catch { /* kein JSON */ } + fertig({ code: antwort.statusCode, text, j, kopf: antwort.headers }); + }); + }); + a.on("error", schief); + if (daten) a.write(daten); + a.end(); + }); +} +async function anmelden(wer) { + const a = await roh("/workspace/api/anmelden", { + method: "POST", + rumpf: { rolle: wer === "gast2" ? "gast" : wer, code: CODES[wer], alter_ok: true }, + }); + return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; "); +} +const k = { + admin: await anmelden("admin"), + hand: await anmelden("hand"), + gast: await anmelden("gast"), + gast2: await anmelden("gast2"), +}; + +/* ---------- Zaehlwerk --------------------------------------------------- */ +let fehler = 0; +let geprueft = 0; +const ok = (b, t) => { + geprueft++; + console.log((b ? " ok " : " FEHL ") + t); + if (!b) fehler++; +}; +function nichtNachsehbar(grund) { + console.log("\n -- NICHT NACHSEHBAR"); + console.log(` ${grund}`); + console.log(" Das ist KEIN Befund am Code -- es heisst nur, dass"); + console.log(" gerade niemand nachsehen konnte.\n"); + try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } + process.exit(3); +} + +/* ---------- Die Buehne fuellen ------------------------------------------ */ +/* ZWEI STUECKE IN DER WARTESCHLANGE, EINES MIT TERMIN UND EINES OHNE. + Genau das ist die Aussage der linken Reihe ("was keinen hat, steht + unten"), und mit nur einem Eintrag liesse sie sich nicht messen. */ +for (const adresse of ["https://youtu.be/9bZkp7q19f0", + "https://www.youtube.com/watch?v=kJQP7kiw5Fk"]) { + const a = await roh("/workspace/api/reaktion/liste", { + method: "POST", keks: k.admin, rumpf: { video: adresse }, + }); + if (a.code >= 400) nichtNachsehbar(`Die Warteschlange nimmt nichts an (HTTP ${a.code}).`); +} +const liste0 = (await roh("/workspace/api/reaktion", { keks: k.admin })).j?.liste || []; +if (liste0.length !== 2) { + nichtNachsehbar(`In der Warteschlange stehen ${liste0.length} Stuecke statt 2.`); +} + +/* Der Termin liegt in der ZUKUNFT und wird relativ gerechnet. Ein + festes Datum waere eine Zeitbombe: Es ist richtig, bis der Kalender + es einholt -- und dann schlaegt "ueberfaellig" an, ohne dass etwas + kaputt ist. */ +{ + const a = await roh(`/workspace/api/reaktion/liste/${liste0[0].id}/wann`, { + method: "PUT", keks: k.admin, + rumpf: { wann: new Date(Date.now() + 3 * 86400000).toISOString() }, + }); + if (a.code >= 400) nichtNachsehbar(`Der Termin laesst sich nicht setzen (HTTP ${a.code}).`); +} +await roh("/workspace/api/reaktion", { + method: "PUT", keks: k.admin, + rumpf: { + titel: "Wir schauen den neuen Trailer", + video: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + beginnt_am: new Date(Date.now() + 7 * 60_000).toISOString(), + }, +}); + +/* ---------- Der Vorbau -------------------------------------------------- */ +const schluesselDatei = join(ordner, "p.key"); +const zertDatei = join(ordner, "p.crt"); +try { + zertifikatBauen(schluesselDatei, zertDatei, CREW); +} catch (f) { + nichtNachsehbar(`Kein Zertifikat zu bauen: ${f?.message}\n` + + " openssl liegt bei Git in mingw64/bin -- im System-PATH\n" + + " steht oft nur Git/cmd."); +} +const vorbau = httpsServer({ + key: readFileSync(schluesselDatei), cert: readFileSync(zertDatei), +}, (von, nach) => { + const weiter = request({ + host: "127.0.0.1", port: PORT, path: von.url, method: von.method, + headers: { ...von.headers, host: `${CREW}:${HTTPS_PORT}` }, + }, (antwort) => { + nach.writeHead(antwort.statusCode || 502, antwort.headers); + antwort.pipe(nach); + }); + weiter.on("error", () => { try { nach.writeHead(502); nach.end(); } catch { /* egal */ } }); + von.pipe(weiter); +}); +await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r)); +const BASIS = `https://${CREW}:${HTTPS_PORT}`; + +let pw = null; +try { + pw = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +} catch (f) { + nichtNachsehbar(`Playwright ist nicht da: ${f?.message}`); +} +const browser = await pw.chromium.launch({ + args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors"], +}); + +async function alsWer(wer, breite, hoehe) { + const kontext = await browser.newContext({ + viewport: { width: breite, height: hoehe }, + /* MIT FINGER, WENN ES EIN HANDY IST. Ohne `hasTouch` meldet der + Browser `pointer: fine` -- und damit greift KEINE Regel aus + `@media (pointer: coarse)`. Dort stehen im ganzen Haus die + 44-Pixel-Beruehrziele. Eine Handy-Messung mit Mauszeiger misst + eine Seite, die auf keinem Handy so aussieht. */ + hasTouch: breite <= 860, + ignoreHTTPSErrors: true, + }); + await kontext.addCookies((k[wer] || "").split("; ").filter(Boolean).map((z) => { + const [n, ...r] = z.split("="); + return { name: n, value: r.join("="), domain: CREW, path: "/" }; + })); + const seite = await kontext.newPage(); + const konsole = []; + seite.on("pageerror", (f) => konsole.push(String(f?.message || f))); + seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); }); + seite.on("response", (a) => { + /* Vorschaubilder kommen von YouTube und sind hier nicht + erreichbar -- das ist die Umgebung und kein Befund. */ + if (a.status() >= 400 && !/ytimg\.com/.test(a.url())) { + konsole.push(`${a.status()} ${a.url().replace(/^https?:\/\/[^/]+/, "")}`); + } + }); + await seite.goto(`${BASIS}/workspace/reaktion-foyer.html`, { waitUntil: "networkidle" }); + /* NICHT AUF EINE ZEIT WARTEN, SONDERN AUF DIE AUSKUNFT. Solange im + Kopf "Wird geladen" steht, ist der Stand noch nicht da -- eine + Messung davor misst den leeren Rahmen. */ + await seite.waitForFunction( + () => !/Wird geladen/.test(document.getElementById("kopf-satz")?.textContent || ""), + null, { timeout: 15000 }); + await seite.waitForTimeout(250); + return { kontext, seite, konsole }; +} + +async function bild(seite, name) { + await seite.screenshot({ path: join(HIER, `mess-foyer-${name}.png`), fullPage: true }); + console.log(` Bild: server/mess-foyer-${name}.png`); +} + +/* ===================================================================== + DIE MESSUNGEN, ALLE IM BROWSER + + Sie stehen als Funktionen da und nicht als Code im Ablauf: Nur so + kann die Gegenprobe am Ende DIESELBE Messung auf eine absichtlich + kaputte Seite anwenden. Zwei Abschriften derselben Frage waeren + zwei Antworten, und eine davon waere irgendwann aelter. + ===================================================================== */ + +/** Welche Reihen stehen da, wo, wie breit -- und wie viele Spalten? */ +const reihenLesen = (seite) => seite.evaluate(() => { + const da = (id) => { + const e = document.getElementById(id); + if (!e) return null; + /* `hidden` allein genuegt nicht: Eine Reihe kann auch durch + `display:none` am Stilblatt verschwinden. Gefragt ist, ob ein + Mensch sie sieht. */ + if (!e.offsetParent) return null; + const r = e.getBoundingClientRect(); + return { oben: Math.round(r.top), breit: Math.round(r.width), hoch: Math.round(r.height) }; + }; + return { + plan: da("reihe-plan"), + saal: da("reihe-saal"), + raten: da("reihe-raten"), + spalten: getComputedStyle(document.getElementById("reihen")) + .gridTemplateColumns.split(/\s+/).filter(Boolean).length, + }; +}); + +/** Steht eines dieser Woerter irgendwo im Dokument? */ +const leckLesen = (seite, woerter) => seite.evaluate((w) => { + const text = document.body.innerText + " " + document.body.innerHTML; + return w.filter((z) => z && text.includes(z)); +}, woerter); + +/** Das kleinste Beruehrziel, die kleinste Schrift, der Ueberstand. */ +const massLesen = (seite) => seite.evaluate(() => { + const wurzel = document.getElementById("foyer"); + const sichtbar = (e) => { + const r = e.getBoundingClientRect(); + return r.width > 0 && r.height > 0 && e.offsetParent !== null; + }; + let ziel = null; + for (const e of wurzel.querySelectorAll("a, button, input, select, textarea")) { + if (!sichtbar(e)) continue; + const r = e.getBoundingClientRect(); + if (!ziel || Math.round(r.height) < ziel.hoch) { + ziel = { + hoch: Math.round(r.height), breit: Math.round(r.width), + was: e.id || e.className || e.tagName, + }; + } + } + let schrift = null; + for (const e of wurzel.querySelectorAll("*")) { + if (!sichtbar(e)) continue; + /* NUR ELEMENTE, DIE SELBST TEXT TRAGEN. Sonst waere jede + Umhuellung mitgezaehlt, und die Messung sagte etwas ueber den + Kasten statt ueber die Schrift. */ + if (![...e.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())) continue; + const gr = parseFloat(getComputedStyle(e).fontSize); + if (!schrift || gr < schrift.gr) schrift = { gr, was: e.id || e.className || e.tagName }; + } + return { + ziel, schrift, + ueber: Math.max(0, document.documentElement.scrollWidth + - document.documentElement.clientWidth), + }; +}); + +/** Was steht in der Mitte? */ +const mitteLesen = (seite) => seite.evaluate(() => { + const t = (id) => document.getElementById(id)?.textContent.trim() || ""; + const knopf = document.getElementById("saal-knopf"); + return { + lampe: t("lampe-wort"), + stand: document.getElementById("lampe")?.dataset.stand || "?", + titel: t("saal-titel"), + satz: t("saal-satz"), + knopfDa: !!(knopf && knopf.offsetParent), + knopfWort: t("saal-knopf-wort"), + knopfZiel: knopf?.getAttribute("href") || "", + }; +}); + +/** Die Zeilen der linken Reihe. */ +const planLesen = (seite) => seite.evaluate(() => [ + ...document.querySelectorAll("#plan-liste .plan-zeile")].map((z) => ({ + titel: z.querySelector(".plan-zeile__titel")?.textContent.trim() || "", + wann: z.querySelector(".plan-zeile__wann")?.textContent.trim() || "", + termin: z.dataset.termin || "", +}))); + +/** Die Zeilen der rechten Reihe. */ +const ratenLesen = (seite) => seite.evaluate(() => [ + ...document.querySelectorAll("#raten-liste .raten-zeile")].map((z) => ({ + titel: z.querySelector(".raten-zeile__titel")?.textContent.trim() || "", + unter: z.querySelector(".raten-zeile__unter")?.textContent.trim() || "", + stand: z.dataset.stand || "", + knoepfe: [...z.querySelectorAll(".raten-knopf")].map((b) => b.textContent.trim()), +}))); + +/* ===================================================================== + 1. GESCHLOSSEN, AM RECHNER, ALS COMMUNITY + ===================================================================== */ +console.log("\n=== 1. Geschlossen, am Rechner, als Community ==="); +{ + const gast = await alsWer("gast", 1440, 900); + const r = await reihenLesen(gast.seite); + ok(r.plan === null, "Gast: die linke Reihe steht gar nicht da"); + ok(r.saal !== null && r.raten !== null, "Gast: Mitte und Anraten stehen da"); + ok(r.spalten === 2, `Gast: zwei Spalten, nicht drei (${r.spalten})`); + ok(!!(r.saal && r.raten) && r.saal.breit > r.raten.breit, + `Gast: die Mitte ist die breitere (${r.saal?.breit} zu ${r.raten?.breit} px)`); + + const leck = await leckLesen(gast.seite, + [...liste0.map((z) => z.titel), ...liste0.map((z) => z.video)]); + ok(leck.length === 0, "Gast: kein Stueck aus der Warteschlange im Dokument" + + (leck.length ? " -> " + leck.join(", ") : "")); + + const m = await mitteLesen(gast.seite); + ok(m.lampe === "Geschlossen", `Gast: die Lampe sagt Geschlossen (${m.lampe})`); + ok(m.knopfDa === false, "Gast: bei zu gibt es keinen Knopf"); + ok(m.satz.length > 20, `Gast: ein Satz erklaert den Zustand (${m.satz.length} Zeichen)`); + + const mass = await massLesen(gast.seite); + ok(mass.ueber === 0, `Gast: nichts ragt seitlich hinaus (${mass.ueber} px)`); + ok(!!mass.schrift && mass.schrift.gr >= 11.5, + `Gast: kleinste Schrift ${mass.schrift?.gr} px (noetig 11,5) bei ${mass.schrift?.was}`); + ok(gast.konsole.length === 0, "Gast: nichts in der Konsole" + + (gast.konsole.length ? " -> " + gast.konsole.slice(0, 3).join(" | ") : "")); + await bild(gast.seite, "1-zu-gast"); + await gast.kontext.close(); +} + +/* ===================================================================== + 2. GESCHLOSSEN, DOGFATHER UND VANVAN + ===================================================================== */ +console.log("\n=== 2. Geschlossen, DogFather und VanVan ==="); +for (const wer of ["admin", "hand"]) { + const chef = await alsWer(wer, 1440, 900); + const r = await reihenLesen(chef.seite); + ok(r.plan !== null, `${wer}: die linke Reihe steht da`); + ok(r.spalten === 3, `${wer}: drei Spalten (${r.spalten})`); + ok(!!(r.plan && r.saal && r.raten) + && r.saal.breit > r.plan.breit && r.saal.breit > r.raten.breit, + `${wer}: die Mitte ist die breiteste ` + + `(${r.plan?.breit} / ${r.saal?.breit} / ${r.raten?.breit} px)`); + + const p = await planLesen(chef.seite); + ok(p.length === 2, `${wer}: beide Stuecke stehen im Plan (${p.length})`); + ok(p[0]?.termin === "ja", `${wer}: das mit Termin steht oben (${p[0]?.termin || "ohne"})`); + ok(/\d{1,2}:\d{2}/.test(p[0]?.wann || ""), + `${wer}: beim ersten steht eine Uhrzeit (${p[0]?.wann})`); + ok(p[1]?.wann === "noch ohne Termin", + `${wer}: das ohne Termin steht unten und sagt das (${p[1]?.wann})`); + + const mass = await massLesen(chef.seite); + ok(mass.ueber === 0, `${wer}: nichts ragt seitlich hinaus (${mass.ueber} px)`); + ok(chef.konsole.length === 0, `${wer}: nichts in der Konsole` + + (chef.konsole.length ? " -> " + chef.konsole.slice(0, 3).join(" | ") : "")); + if (wer === "admin") await bild(chef.seite, "2-zu-dogfather"); + await chef.kontext.close(); +} + +/* ===================================================================== + 3. GLEICH -- DER WARTEBEREICH IST OFFEN + ===================================================================== */ +console.log("\n=== 3. Gleich (Wartebereich) ==="); +await roh("/workspace/api/reaktion/stand", + { method: "POST", keks: k.admin, rumpf: { stand: "vorbereitung" } }); +{ + const gast = await alsWer("gast", 1440, 900); + const m = await mitteLesen(gast.seite); + ok(m.lampe === "Gleich", `die Lampe sagt Gleich (${m.lampe})`); + ok(m.knopfDa, "der Knopf steht da -- auch bei der Vorschau schon"); + ok(m.knopfWort === "In den Wartebereich", `er heisst In den Wartebereich (${m.knopfWort})`); + ok(m.knopfZiel === "reaktion.html", `er fuehrt in den Saal (${m.knopfZiel})`); + ok(/Start um/.test(m.satz), `der Satz nennt die Startzeit (${m.satz})`); + ok(m.titel.includes("Trailer"), `der Titel der Sendung steht da (${m.titel})`); + await bild(gast.seite, "3-gleich-gast"); + await gast.kontext.close(); +} + +/* ===================================================================== + 4. AUF SENDUNG + ===================================================================== */ +console.log("\n=== 4. Auf Sendung ==="); +await roh("/workspace/api/reaktion/stand", + { method: "POST", keks: k.admin, rumpf: { stand: "live" } }); +{ + const gast = await alsWer("gast", 1440, 900); + const m = await mitteLesen(gast.seite); + ok(m.lampe === "Auf Sendung", `die Lampe sagt Auf Sendung (${m.lampe})`); + ok(m.knopfWort === "Jetzt beitreten", `der Knopf heisst Jetzt beitreten (${m.knopfWort})`); + ok(/dieselbe Stelle/.test(m.satz), `der Satz verspricht den Gleichlauf (${m.satz})`); + await bild(gast.seite, "4-live-gast"); + await gast.kontext.close(); +} + +/* ===================================================================== + 5. AM TELEFON -- DIE SENDUNG STEHT OBEN + ===================================================================== */ +console.log("\n=== 5. Am Telefon (390 px) ==="); +{ + const gast = await alsWer("gast", 390, 844); + const r = await reihenLesen(gast.seite); + ok(r.plan === null, "Gast am Telefon: keine linke Reihe"); + ok(!!(r.saal && r.raten) && r.saal.oben < r.raten.oben, + `Gast am Telefon: die Sendung steht ueber dem Anraten ` + + `(${r.saal?.oben} vor ${r.raten?.oben} px)`); + const mass = await massLesen(gast.seite); + ok(mass.ueber === 0, `Gast am Telefon: nichts ragt seitlich hinaus (${mass.ueber} px)`); + ok(!!mass.ziel && mass.ziel.hoch >= 44, + `Gast am Telefon: kleinstes Beruehrziel ${mass.ziel?.hoch} px ` + + `(noetig 44) bei ${mass.ziel?.was}`); + ok(!!mass.schrift && mass.schrift.gr >= 11.5, + `Gast am Telefon: kleinste Schrift ${mass.schrift?.gr} px bei ${mass.schrift?.was}`); + await bild(gast.seite, "5-live-handy-gast"); + await gast.kontext.close(); +} +{ + const chef = await alsWer("admin", 390, 844); + const r = await reihenLesen(chef.seite); + ok(!!(r.plan && r.saal && r.raten) + && r.saal.oben < r.raten.oben && r.raten.oben < r.plan.oben, + "DogFather am Telefon: Sendung, Anraten, Plan -- in dieser Folge " + + `(${r.saal?.oben} / ${r.raten?.oben} / ${r.plan?.oben} px)`); + ok(r.spalten === 1, `DogFather am Telefon: eine Spalte (${r.spalten})`); + const mass = await massLesen(chef.seite); + ok(mass.ueber === 0, `DogFather am Telefon: nichts ragt hinaus (${mass.ueber} px)`); + ok(!!mass.ziel && mass.ziel.hoch >= 44, + `DogFather am Telefon: kleinstes Beruehrziel ${mass.ziel?.hoch} px bei ${mass.ziel?.was}`); + await bild(chef.seite, "6-live-handy-dogfather"); + await chef.kontext.close(); +} + +/* ===================================================================== + 6. DER GANZE WEG: ANRATEN, SEHEN, NEHMEN + + Hier wird nicht die Schnittstelle gefragt, sondern GEKLICKT. Der + Unterschied ist nicht theoretisch: Ein Knopf, an dem nichts haengt, + antwortet in der Pruefung nebenan tadellos. + ===================================================================== */ +console.log("\n=== 6. Anraten, sehen, nehmen ==="); +{ + const gast = await alsWer("gast", 1440, 900); + await gast.seite.fill("#raten-video", "https://www.youtube.com/watch?v=aqz-KE-bpKQ"); + await gast.seite.fill("#raten-notiz", "Das muessen wir zusammen sehen."); + await gast.seite.click("#raten-los"); + await gast.seite.waitForFunction( + () => document.querySelectorAll("#raten-liste .raten-zeile").length === 1, + null, { timeout: 10000 }); + const meine = await ratenLesen(gast.seite); + ok(meine.length === 1, `der Gast sieht seinen eigenen Vorschlag (${meine.length})`); + ok(meine[0]?.stand === "offen", `er steht auf offen (${meine[0]?.stand})`); + ok(meine[0]?.unter === "wartet", + `und sagt wartet -- ohne Namen, auch nicht den eigenen (${meine[0]?.unter})`); + ok(!meine[0]?.knoepfe.includes("Nehmen"), "der Gast hat keinen Nehmen-Knopf"); + ok(meine[0]?.knoepfe.includes("\u00d7"), "aber er darf zurueckziehen, was noch offen ist"); + const fuss = await gast.seite.textContent("#raten-fuss"); + ok(/noch 4 Videos/.test(fuss || ""), `er erfaehrt, wie viele noch gehen (${fuss})`); + await bild(gast.seite, "7-angeraten-gast"); + await gast.kontext.close(); +} +{ + const chef = await alsWer("admin", 1440, 900); + const alle = await ratenLesen(chef.seite); + ok(alle.length === 1, `DogFather sieht den Vorschlag (${alle.length})`); + ok(/Nachtfalter/.test(alle[0]?.unter || ""), + `und bei ihm steht, wer es war (${alle[0]?.unter})`); + ok(alle[0]?.knoepfe.includes("Nehmen") && alle[0]?.knoepfe.includes("Diesmal nicht"), + `er hat beide Knoepfe (${alle[0]?.knoepfe.join(" / ")})`); + const zahl = await chef.seite.textContent("#raten-zahl"); + ok(/1 offen/.test(zahl || ""), `das Schild zaehlt die offenen (${zahl})`); + await bild(chef.seite, "8-angeraten-dogfather"); + + /* NEHMEN -- und links muss es ankommen. */ + await chef.seite.click(".raten-knopf--ja"); + await chef.seite.waitForFunction( + () => document.querySelectorAll("#plan-liste .plan-zeile").length === 3, + null, { timeout: 10000 }); + const p = await planLesen(chef.seite); + ok(p.length === 3, `nach Nehmen stehen drei Stuecke im Plan (${p.length})`); + const danach = await ratenLesen(chef.seite); + ok(danach[0]?.stand === "uebernommen", + `der Vorschlag steht auf uebernommen (${danach[0]?.stand})`); + ok(/kommt dran/.test(danach[0]?.unter || ""), + `und sagt kommt dran (${danach[0]?.unter})`); + await bild(chef.seite, "9-genommen-dogfather"); + await chef.kontext.close(); +} +{ + /* UND DER EINSENDER ERFAEHRT ES. Ohne diese Messung waere der Stand + nur im Pult richtig und beim Gast von gestern. */ + const gast = await alsWer("gast", 1440, 900); + const meine = await ratenLesen(gast.seite); + ok(meine[0]?.stand === "uebernommen", + `der Gast sieht den neuen Stand (${meine[0]?.stand})`); + ok(!meine[0]?.knoepfe.includes("\u00d7"), + "und kann nichts mehr zurueckziehen, was entschieden ist"); + await gast.kontext.close(); +} +{ + /* LENNY SIEHT NICHTS DAVON -- "nur ich und vanvan sehen sie", + gemessen und nicht nur gelesen. */ + const lenny = await alsWer("gast2", 1440, 900); + const seine = await ratenLesen(lenny.seite); + ok(seine.length === 0, `Lenny sieht fremde Vorschlaege nicht (${seine.length})`); + const leck = await leckLesen(lenny.seite, ["Nachtfalter", "aqz-KE-bpKQ"]); + ok(leck.length === 0, "und auch nicht im Dokument" + + (leck.length ? " -> " + leck.join(", ") : "")); + await lenny.kontext.close(); +} + +/* ===================================================================== + 7. DIE GEGENPROBEN + + Fuenf Messungen oben koennen "in Ordnung" sagen. Hier wird jede + absichtlich kaputtgemacht -- schlaegt sie nicht an, ist sie + wertlos, und alle gruenen Haken davor beweisen nichts. + ===================================================================== */ +console.log("\n=== 7. Gegenproben ==="); +{ + const chef = await alsWer("admin", 390, 844); + + /* (a) Die Reihenfolge am Telefon. */ + await chef.seite.addStyleTag({ content: ".foyer-reihe--saal { order: 9 !important; }" }); + await chef.seite.waitForTimeout(120); + const verdreht = await reihenLesen(chef.seite); + ok(!(verdreht.saal.oben < verdreht.raten.oben), + `Gegenprobe: eine verdrehte Reihenfolge wird erkannt ` + + `(${verdreht.saal.oben} / ${verdreht.raten.oben} px)`); + + /* (b) Ein zu kleines Beruehrziel. */ + await chef.seite.addStyleTag({ + content: "#raten-los { min-height: 0 !important; height: 20px !important;" + + " padding: 0 !important; }", + }); + await chef.seite.waitForTimeout(120); + const klein = await massLesen(chef.seite); + ok(!!klein.ziel && klein.ziel.hoch < 44, + `Gegenprobe: ein 20-px-Knopf wird gefunden (${klein.ziel?.hoch} px)`); + + /* (c) Eine zu kleine Schrift. */ + await chef.seite.addStyleTag({ content: "#saal-satz { font-size: 9px !important; }" }); + await chef.seite.waitForTimeout(120); + const winzig = await massLesen(chef.seite); + ok(!!winzig.schrift && winzig.schrift.gr < 11.5, + `Gegenprobe: 9-px-Schrift wird gefunden (${winzig.schrift?.gr} px)`); + + /* (d) Und ein Ueberstand. */ + await chef.seite.addStyleTag({ content: "#saal-titel { width: 900px !important; }" }); + await chef.seite.waitForTimeout(120); + const breit = await massLesen(chef.seite); + ok(breit.ueber > 0, `Gegenprobe: ein Ueberstand wird gemessen (${breit.ueber} px)`); + await chef.kontext.close(); +} +{ + const gast = await alsWer("gast", 1440, 900); + + /* (e) Das Leck. Ein Wort wird von Hand ins Dokument geschrieben -- + findet die Messung es nicht, hat sie oben nichts geprueft. */ + await gast.seite.evaluate(() => { + document.getElementById("saal-satz").textContent += " ZZTESTWORTZZ"; + }); + const leck = await leckLesen(gast.seite, ["ZZTESTWORTZZ"]); + ok(leck.length === 1, "Gegenprobe: ein Wort im Dokument wird gefunden"); + + /* (f) Die Reihen-Messung muss eine AUFGEDECKTE Reihe auch sichtbar + nennen -- sonst waere "der Plan steht nicht da" oben nur die + Folge davon, dass sie nie etwas findet. */ + await gast.seite.evaluate(() => { + document.getElementById("reihe-plan").hidden = false; + }); + await gast.seite.waitForTimeout(120); + const sichtbar = await reihenLesen(gast.seite); + ok(sichtbar.plan !== null, "Gegenprobe: eine aufgedeckte Reihe gilt als sichtbar"); + await gast.kontext.close(); +} + +/* ===================================================================== + SCHLUSS + ===================================================================== */ +await browser.close(); +await new Promise((r) => vorbau.close(r)); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } + +/* DIE ANZAHL GEHOERT IN DIE BEDINGUNG. Faende dieses Werkzeug keine + einzige Messung, waere "0 Fehler" das beste Ergebnis ueberhaupt -- + und voellig wertlos. */ +console.log(`\n${geprueft} Messungen, ${fehler} Fehler`); +if (geprueft < 40) { + console.log(`NICHT NACHSEHBAR: nur ${geprueft} Messungen gelaufen, erwartet mindestens 40.`); + process.exit(3); +} +if (fehler) { + console.log("NICHT IN ORDNUNG"); + process.exit(1); +} +console.log("ALLES IN ORDNUNG"); +process.exit(0); diff --git a/server/pruef-community-sicht.mjs b/server/pruef-community-sicht.mjs index 1c82a375..f784d788 100644 --- a/server/pruef-community-sicht.mjs +++ b/server/pruef-community-sicht.mjs @@ -123,6 +123,12 @@ const COMMUNITY_SEITEN = [ dieser Eintrag hier; die Pruefung sagt beim Anschlagen selbst, welche Seite fehlt oder zu viel ist. */ "reaktion.html", // zusammen schauen (seit 28.09.2026) + /* DAS FOYER davor (08.10.2026): die Seite, auf die die Kachel + seither zeigt. Sie ist IMMER offen -- dort steht, was ansteht, + und dort kann jeder ein Video anraten. Was darin nur die zwei + sehen duerfen, entscheidet der Server bei den Daten und nicht + diese Liste. */ + "reaktion-foyer.html", // das Foyer davor (seit 08.10.2026) "start.html", // die Wand "steckbrief.html", // der eigene Steckbrief "support.html", // etwas ist kaputt (seit 24.09. fuer alle) diff --git a/server/pruef-css-klassen.mjs b/server/pruef-css-klassen.mjs index 95c75faa..fa66a2a9 100644 --- a/server/pruef-css-klassen.mjs +++ b/server/pruef-css-klassen.mjs @@ -620,20 +620,35 @@ console.log("\n=== Rollenlisten: eine Quelle, nicht neun"); } /* ======================================================================= - DIE SIEBEN KOPIEN DER MODULLISTE MÜSSEN GLEICH SEIN + DIE ACHT KOPIEN DER MODULLISTE MÜSSEN GLEICH SEIN module.css beschreibt die Form aller Module -- Silhouette, - Kantenlicht, Raster, Handy, Kontrastmodus. Dieselbe Klassenliste - steht dafür siebenmal in der Datei (im Kontrastmodus dreimal); CSS - kennt keine Variable für Selektoren. + Kantenlicht, Raster, Umrandung beim Tabben, Handy, Kontrastmodus. + Dieselbe Klassenliste steht dafür achtmal in der Datei (im + Kontrastmodus dreimal); CSS kennt keine Variable für Selektoren. + + WARUM DIE ZAHL VON SIEBEN AUF ACHT GESTIEGEN IST (08.10.2026): + Nicht, weil eine Kopie dazugekommen wäre. Die achte stand seit dem + 07.09.2026 da -- die Umrandung beim Tabben (`:focus-visible`) --, + nur ohne Marke. Sie wurde deshalb als Einzige nie verglichen, und + das ausgerechnet bei der Regel, die niemand sieht, solange er die + Maus benutzt. Aufgefallen ist es beim Eintragen von + `.foyer-reihe`: acht Vorkommen der Liste, sieben Marken. Eine + steigende Zahl ist hier also kein Mehraufwand, sondern eine + geschlossene Lücke. + + EINE ZAHL, DIE SICH ÄNDERT, WIRD ERKLÄRT -- auch wenn sie steigt. + Die Hausregel kennt nur den Fall der FALLENDEN Prüfzahl; der Grund + dahinter gilt aber in beide Richtungen: Wer eine Zahl anpasst, + ohne zu sagen warum, hat die Prüfung an das Ergebnis angepasst. Das ist die verwundbare Stelle: Wer eine Karte hinzufügt und nur - sechs der sieben Stellen pflegt, bekommt eine Karte MIT Schräge und + sieben der acht Stellen pflegt, bekommt eine Karte MIT Schräge und OHNE Kantenlicht -- und niemandem fällt es auf, weil nichts kaputt geht. Genau diese Sorte stiller Abweichung fängt hier eine Prüfung ab, statt sich auf Sorgfalt zu verlassen. ======================================================================= */ -console.log("\n=== module.css: die Modulliste steht siebenmal — und überall gleich"); +console.log("\n=== module.css: die Modulliste steht achtmal — und überall gleich"); { const pfad = join(WURZEL, "workspace/assets/css/module.css"); const text = readFileSync(pfad, "utf8"); @@ -649,7 +664,7 @@ console.log("\n=== module.css: die Modulliste steht siebenmal — und überall g /* Die Anzahl gehört in die BEDINGUNG, nicht nur in den Meldetext: Fände die Prüfung keine einzige Liste, wäre "alle gleich" sonst trivial erfüllt -- grün, ohne etwas angesehen zu haben. */ - ok(listen.length === 7, `sieben markierte Kopien gefunden (${listen.length})`); + ok(listen.length === 8, `acht markierte Kopien gefunden (${listen.length})`); const erste = listen[0] || []; ok(erste.length >= 30, `die Liste umfasst ${erste.length} Klassen`); @@ -657,7 +672,7 @@ console.log("\n=== module.css: die Modulliste steht siebenmal — und überall g const abweichung = listen.findIndex( (l) => l.length !== erste.length || l.some((k, i) => k !== erste[i])); ok(abweichung === -1, abweichung === -1 - ? "alle sieben Kopien sind Zeichen für Zeichen gleich" + ? "alle acht Kopien sind Zeichen für Zeichen gleich" : `Kopie ${abweichung + 1} weicht ab (${listen[abweichung].length} statt ${erste.length} Klassen)`); /* GEGENPROBE: Der Vergleich muss auch NEIN sagen können. Ohne sie diff --git a/server/pruef-reaktion.mjs b/server/pruef-reaktion.mjs index 7015631f..ff55179c 100644 --- a/server/pruef-reaktion.mjs +++ b/server/pruef-reaktion.mjs @@ -784,7 +784,10 @@ melde("=== 10. Was um die Seite herum stimmen muss ==="); haben sich in diesem Haus schon zweimal geaendert. */ const w = await import("./workspace.js"); const alle = w.bereicheFuer({ id: IDS.gast, rolle: "gast", name: "Nachtfalter" }, CREW); - const kachel = (alle || []).find((b) => b.ziel === "reaktion.html"); + /* SEIT DEM 08.10.2026 ZEIGT SIE AUFS FOYER. Filipe: „wenn man + drauf drückt geht zuerst eine seite auf mit reihen/kategorien." + In den Saal geht es von dort aus. */ + const kachel = (alle || []).find((b) => b.ziel === "reaktion-foyer.html"); ok(!!kachel, "die Community hat die Reaction-Kachel"); ok(kachel?.live === true, "sie traegt das Merkmal `live` statt einer Ton-Nummer (der Farbraum ist bei 46 voll)"); @@ -3200,6 +3203,311 @@ melde("=== 19. Vorraum, Geraete, zweites Video ==="); "und wer mit der Tastatur bedient, wird gesehen"); } +/* ======================================================================= + 20. DAS FOYER (08.10.2026) + + Filipe: „diese kachel soll nicht mehr geschlossen sein, ABER wenn + man drauf drückt geht zuerst eine seite auf mit reihen/kategorien. + links eine reihe wo die nächsten geplanten videos schon bereit + stehen. wan und umd wie viel uhr … und das soll geschlossen sein + außer für vanvan und dogfather. in der mitte sollen die leute dan + wenn eine sendung läuft drauf drücken können und der sendung + beitreten … und dan rechts … da können die leute videos + reinschicken und nur ich und vanvan sehen sie." + ======================================================================= */ +melde(""); +melde("=== 20. Das Foyer: Plan, Eintritt, Vorschlaege ==="); +{ + /* ---- Die Seite selbst ---- */ + for (const rolle of ["admin", "hand", "linke", "modi", "gast"]) { + const s = await roh(CREW, "/workspace/reaktion-foyer.html", { keks: k[rolle] }); + ok(s.code === 200, `${rolle}: kommt ins Foyer (${s.code})`); + } + { + const kAgentur = await anmelden("admin", AGENTUR); + const a = await roh(AGENTUR, "/workspace/reaktion-foyer.html", { keks: kAgentur }); + ok(!!kAgentur && a.code === 404, `und die Agentur kennt es nicht (${a.code})`); + } + /* DIE KACHEL ZEIGT AUFS FOYER UND NICHT MEHR IN DEN SAAL. Zeigte + sie weiter auf `reaktion.html`, gaebe es das Foyer zwar -- nur + fuehrte kein Weg hin. */ + { + const w = await import("./workspace.js"); + const alle = w.bereicheFuer({ id: IDS.gast, rolle: "gast", name: "Nachtfalter" }, CREW); + const kachel = (alle || []).find((b) => b.name === "Reaction"); + ok(kachel?.ziel === "reaktion-foyer.html", + `die Kachel fuehrt ins Foyer (${kachel?.ziel})`); + ok(kachel?.live === true, + "und traegt weiterhin ihren Zustand (grau / bernstein / rot)"); + /* ==== UND DER SAAL BLEIBT TROTZDEM ERREICHBAR ============== + Er hat seit heute KEINE Kachel mehr, und die Adressregel vom + 15.09. verlangt eine. Ohne den Eintrag in + OHNE_KACHEL_UEBERALL liefe der Knopf „Jetzt beitreten" auf + die Startseite -- wortlos. Genau so ist diese Pruefung beim + Umhaengen rot geworden. */ + ok(w.OHNE_KACHEL_UEBERALL.has("/workspace/reaktion.html"), + "und der Saal ist ohne Kachel trotzdem erreichbar"); + } + + /* ---- Der Termin an der Warteschlange ---- */ + await roh(CREW, "/workspace/api/reaktion/zuruecksetzen", { method: "POST", keks: k.admin }); + const angehaengt = await roh(CREW, "/workspace/api/reaktion/liste", { + method: "POST", keks: k.admin, rumpf: { video: "dQw4w9WgXcQ" }, + }); + const zeile = (angehaengt.j?.liste || [])[0]; + ok(!!zeile, `ein Video liegt bereit (${angehaengt.code})`); + ok(zeile?.wann === "", `und es hat zuerst KEINEN Termin ("${zeile?.wann}")`); + + const termin = new Date(Date.now() + 86400000).toISOString(); + const gesetzt = await roh(CREW, `/workspace/api/reaktion/liste/${zeile.id}/wann`, { + method: "PUT", keks: k.admin, rumpf: { wann: termin }, + }); + ok(gesetzt.code === 200 + && (gesetzt.j?.liste || []).find((z) => z.id === zeile.id)?.wann === termin, + `der Termin laesst sich setzen (${gesetzt.code})`); + + const leer = await roh(CREW, `/workspace/api/reaktion/liste/${zeile.id}/wann`, { + method: "PUT", keks: k.admin, rumpf: { wann: "" }, + }); + ok(leer.code === 200 + && (leer.j?.liste || []).find((z) => z.id === zeile.id)?.wann === "", + "und wieder wegnehmen -- leer ist ein gueltiger Wert und der Normalfall"); + await roh(CREW, `/workspace/api/reaktion/liste/${zeile.id}/wann`, { + method: "PUT", keks: k.admin, rumpf: { wann: termin }, + }); + + const quatsch = await roh(CREW, `/workspace/api/reaktion/liste/${zeile.id}/wann`, { + method: "PUT", keks: k.admin, rumpf: { wann: "irgendwann mal" }, + }); + ok(quatsch.code === 400 && quatsch.j?.fehler === "termin_unklar", + `ein freier Text wird abgelehnt (${quatsch.code}/${quatsch.j?.fehler})`); + + for (const rolle of ["linke", "modi", "gast"]) { + const a = await roh(CREW, `/workspace/api/reaktion/liste/${zeile.id}/wann`, { + method: "PUT", keks: k[rolle], rumpf: { wann: termin }, + }); + ok(a.code === 403, `${rolle}: setzt keinen Termin (${a.code})`); + } + + /* ==== UND DIE GANZE REIHE SIEHT NUR DIE LEITUNG ============== + Filipe: „und das soll geschlossen sein außer für vanvan und + dogfather." Die Reihe im Foyer entsteht aus `liste` -- wer die + nicht bekommt, bekommt die Reihe nicht. */ + { + const beiDenZweien = await roh(CREW, "/workspace/api/reaktion", { keks: k.hand }); + ok(Array.isArray(beiDenZweien.j?.liste) && beiDenZweien.j.liste.length > 0, + "die rechte Hand bekommt den Plan"); + for (const rolle of ["linke", "modi", "gast"]) { + const a = await roh(CREW, "/workspace/api/reaktion", { keks: k[rolle] }); + ok(a.j?.liste === null, `${rolle}: bekommt ihn NICHT (${a.j?.liste})`); + } + } + + /* ---- Die Vorschlaege ---- */ + const v1 = await roh(CREW, "/workspace/api/reaktion/vorschlag", { + method: "POST", keks: k.gast, + rumpf: { video: "https://www.youtube.com/watch?v=9bZkp7q19f0", notiz: "Das waere was" }, + }); + ok(v1.code === 201, `ein Zuschauer darf anraten (${v1.code})`); + ok(v1.j?.vorschlag?.video === "9bZkp7q19f0", + `aus der Adresse wird die Kennung (${v1.j?.vorschlag?.video})`); + + /* EINER JE PERSON UND MINUTE -- gegen den Doppelklick. */ + const vSchnell = await roh(CREW, "/workspace/api/reaktion/vorschlag", { + method: "POST", keks: k.gast, rumpf: { video: "bm0sK2-KkW8" }, + }); + ok(vSchnell.code === 429, `zweimal gleich hintereinander nicht (${vSchnell.code})`); + + const vDoppelt = await roh(CREW, "/workspace/api/reaktion/vorschlag", { + method: "POST", keks: k.gast2, rumpf: { video: "9bZkp7q19f0" }, + }); + ok(vDoppelt.code === 409 && vDoppelt.j?.fehler === "vorschlag_doppelt", + `dasselbe Video nicht zweimal offen (${vDoppelt.code}/${vDoppelt.j?.fehler})`); + + const vBereit = await roh(CREW, "/workspace/api/reaktion/vorschlag", { + method: "POST", keks: k.modi, rumpf: { video: "dQw4w9WgXcQ" }, + }); + ok(vBereit.code === 409 && vBereit.j?.fehler === "steht_schon_bereit", + `und nichts, was schon bereitliegt (${vBereit.code}/${vBereit.j?.fehler})`); + + /* ==== WER SIEHT WAS -- DIE EIGENTLICHE FRAGE ================= + Filipe: „nur ich und vanvan sehen sie." */ + { + const beiDogi = await roh(CREW, "/workspace/api/reaktion/vorschlaege", { keks: k.admin }); + ok(beiDogi.j?.darf_entscheiden === true && (beiDogi.j?.vorschlaege || []).length === 1, + `DogFather sieht alle (${beiDogi.j?.vorschlaege?.length})`); + ok(beiDogi.j?.vorschlaege?.[0]?.name === "Nachtfalter", + `mit dem Namen dessen, der ihn geschickt hat (${beiDogi.j?.vorschlaege?.[0]?.name})`); + ok(beiDogi.j?.offen === 1, `und wie viele offen sind (${beiDogi.j?.offen})`); + + const beiVanVan = await roh(CREW, "/workspace/api/reaktion/vorschlaege", { keks: k.hand }); + ok((beiVanVan.j?.vorschlaege || []).length === 1, "die rechte Hand auch"); + + /* DER EINSENDER SIEHT SEINEN EIGENEN -- und zwar OHNE Namen: + Dort staende er selbst, und eine Zeile, die einem den eigenen + Namen vorliest, ist Platz ohne Auskunft. */ + const beiIhr = await roh(CREW, "/workspace/api/reaktion/vorschlaege", { keks: k.gast }); + ok(beiIhr.j?.darf_entscheiden === false && (beiIhr.j?.vorschlaege || []).length === 1, + "der Einsender sieht seinen eigenen"); + ok(beiIhr.j?.vorschlaege?.[0]?.name === undefined, + "aber keinen Namen"); + ok(beiIhr.j?.offen === null, "und nicht, wie viele insgesamt offen sind"); + + /* UND EIN DRITTER SIEHT GAR NICHTS. Das ist der Satz, um den es + geht. */ + const fremd = await roh(CREW, "/workspace/api/reaktion/vorschlaege", { keks: k.gast2 }); + ok((fremd.j?.vorschlaege || []).length === 0, + `wer nichts geschickt hat, sieht nichts (${fremd.j?.vorschlaege?.length})`); + const modi = await roh(CREW, "/workspace/api/reaktion/vorschlaege", { keks: k.modi }); + ok((modi.j?.vorschlaege || []).length === 0, + "auch ein Modi nicht -- Moderation ist etwas anderes als Leitung"); + } + + /* ---- Entscheiden ---- */ + const offene = await roh(CREW, "/workspace/api/reaktion/vorschlaege", { keks: k.admin }); + const vid = offene.j.vorschlaege[0].id; + for (const rolle of ["linke", "modi", "gast"]) { + const a = await roh(CREW, `/workspace/api/reaktion/vorschlag/${vid}/uebernehmen`, { + method: "POST", keks: k[rolle], + }); + ok(a.code === 403, `${rolle}: entscheidet nicht (${a.code})`); + } + + const vorListe = (await roh(CREW, "/workspace/api/reaktion", { keks: k.admin })) + .j?.liste?.length || 0; + const genommen = await roh(CREW, `/workspace/api/reaktion/vorschlag/${vid}/uebernehmen`, { + method: "POST", keks: k.admin, + }); + ok(genommen.code === 200 && (genommen.j?.liste || []).length === vorListe + 1, + `uebernehmen legt es in die Warteschlange (${vorListe} -> ${genommen.j?.liste?.length})`); + ok((genommen.j?.liste || []).some((z) => z.video === "9bZkp7q19f0"), + "und zwar genau dieses Video"); + + const nochmal = await roh(CREW, `/workspace/api/reaktion/vorschlag/${vid}/uebernehmen`, { + method: "POST", keks: k.admin, + }); + ok(nochmal.code === 409 && nochmal.j?.fehler === "schon_entschieden", + `zweimal uebernehmen geht nicht (${nochmal.code}/${nochmal.j?.fehler})`); + + /* DER EINSENDER ERFAEHRT, WAS DARAUS WURDE. Ohne das waere das + Feld ein Loch, in das man etwas wirft. */ + { + const beiIhr = await roh(CREW, "/workspace/api/reaktion/vorschlaege", { keks: k.gast }); + ok(beiIhr.j?.vorschlaege?.[0]?.stand === "uebernommen", + `und sie sieht, dass er drankommt (${beiIhr.j?.vorschlaege?.[0]?.stand})`); + } + + /* ---- Ablehnen und Zurueckziehen ---- */ + { + const neu = await roh(CREW, "/workspace/api/reaktion/vorschlag", { + method: "POST", keks: k.gast2, rumpf: { video: "bm0sK2-KkW8" }, + }); + ok(neu.code === 201, `noch ein Vorschlag (${neu.code})`); + const id2 = neu.j.vorschlag.id; + + /* Ein Fremder kann ihn nicht wegnehmen -- auch nicht den + offenen. */ + const fremdWeg = await roh(CREW, `/workspace/api/reaktion/vorschlag/${id2}`, { + method: "DELETE", keks: k.gast, + }); + ok(fremdWeg.code === 403 && fremdWeg.j?.fehler === "nicht_deiner", + `ein Fremder nimmt ihn nicht weg (${fremdWeg.code}/${fremdWeg.j?.fehler})`); + + const abgelehnt = await roh(CREW, `/workspace/api/reaktion/vorschlag/${id2}/ablehnen`, { + method: "POST", keks: k.admin, + }); + ok(abgelehnt.code === 200, `ablehnen geht (${abgelehnt.code})`); + const danach = await roh(CREW, "/workspace/api/reaktion/vorschlaege", { keks: k.gast2 }); + ok(danach.j?.vorschlaege?.[0]?.stand === "abgelehnt", + "und er sieht es -- die Zeile bleibt stehen"); + + /* ==== WAS ENTSCHIEDEN IST, ZIEHT ER NICHT ZURUECK ========== + Sonst verschwaende die Spur, dass ueberhaupt entschieden + wurde -- und derselbe Vorschlag saehe beim naechsten Mal aus + wie der erste. */ + const zurueck = await roh(CREW, `/workspace/api/reaktion/vorschlag/${id2}`, { + method: "DELETE", keks: k.gast2, + }); + ok(zurueck.code === 403, + `einen abgelehnten zieht der Einsender nicht zurueck (${zurueck.code})`); + /* GEGENPROBE: einen OFFENEN schon. Ohne sie bewiese das Obige + nur, dass Loeschen ueberhaupt nicht geht. */ + const frisch = await roh(CREW, "/workspace/api/reaktion/vorschlag", { + method: "POST", keks: k.linke, rumpf: { video: "kJQP7kiw5Fk" }, + }); + const weg = await roh(CREW, `/workspace/api/reaktion/vorschlag/${frisch.j.vorschlag.id}`, { + method: "DELETE", keks: k.linke, + }); + ok(frisch.code === 201 && weg.code === 200, + `Gegenprobe: seinen eigenen OFFENEN schon (${weg.code})`); + } + + /* ---- Die Grenze je Person ---- */ + { + const { db } = await import("./workspace.js"); + const { VORSCHLAEGE_JE_PERSON } = await import("./reaktion-tabellen.js"); + /* Zurueckdatieren statt warten: Eine Pruefung, die 45 Sekunden + je Vorschlag wartet, wird beim naechsten Mal uebersprungen. */ + const alt = () => db().prepare("UPDATE reaktion_vorschlaege SET erstellt = ?" + + " WHERE person_id = ?").run(new Date(Date.now() - 600_000).toISOString(), IDS.modi); + + const kennungen = ["aqz-KE-bpKQ", "ZZ5LpwO-An4", "e-ORhEE9VVg", "YQHsXMglC9A", "fJ9rUzIMcZQ"]; + let letzte = null; + for (const v of kennungen) { + alt(); + letzte = await roh(CREW, "/workspace/api/reaktion/vorschlag", { + method: "POST", keks: k.modi, rumpf: { video: v }, + }); + if (letzte.code !== 201) break; + } + alt(); + const einerZuViel = await roh(CREW, "/workspace/api/reaktion/vorschlag", { + method: "POST", keks: k.modi, rumpf: { video: "L_jWHffIx5E" }, + }); + ok(einerZuViel.code === 409 && einerZuViel.j?.fehler === "zu_viele_vorschlaege", + `hoechstens ${VORSCHLAEGE_JE_PERSON} offene je Person ` + + `(${einerZuViel.code}/${einerZuViel.j?.fehler})`); + } + + /* ---- Und was auf der Seite stehen muss ---- */ + { + const html = readFileSync(join(HIER, "..", "workspace", "reaktion-foyer.html"), "utf8"); + const js = readFileSync( + join(HIER, "..", "workspace", "assets", "js", "reaktion-foyer.js"), "utf8"); + const css = readFileSync( + join(HIER, "..", "workspace", "assets", "css", "foyer.css"), "utf8"); + ok(/id="reihe-plan"/.test(html) && /id="reihe-saal"/.test(html) + && /id="reihe-raten"/.test(html), + "die drei Reihen stehen in der Seite"); + ok(/id="saal-knopf"/.test(html) && /href="reaktion\.html"/.test(html), + "und die Mitte fuehrt in den Saal"); + ok(/ z.name)); + if (!listeSpalten.has("wann")) { + d.exec("ALTER TABLE reaktion_liste ADD COLUMN wann TEXT NOT NULL DEFAULT ''"); + console.log("[reaktion] Spalte wann an der Warteschlange angelegt."); + } + const gaeste = new Set( d.prepare("PRAGMA table_info(reaktion_gaeste)").all().map((z) => z.name)); if (!gaeste.has("stand")) { diff --git a/server/rechte.js b/server/rechte.js index 2482e6b2..ea463dd6 100644 --- a/server/rechte.js +++ b/server/rechte.js @@ -580,6 +580,13 @@ export const SEITEN = { ausdruecklich mit drin; wer nicht Host ist, sieht dort die Zuschauerseite. */ "/workspace/reaktion.html": ["admin", "hand", "linke", "modi", "gast"], + /* DAS FOYER (08.10.2026) steht VOR dem Saal und ist fuer + dieselben da. Was darin nur die zwei sehen duerfen -- den + Plan und die Vorschlaege --, entscheidet nicht diese Tafel, + sondern der Server bei den Daten. Eine Seite zu sperren, auf + der drei Reihen stehen und nur eine davon heikel ist, waere + die grobe Antwort auf eine feine Frage. */ + "/workspace/reaktion-foyer.html": ["admin", "hand", "linke", "modi", "gast"], "/workspace/rechte.html": ["admin", "hand"], }; diff --git a/server/workspace-reaktion.js b/server/workspace-reaktion.js index 107dbc02..cebd9ac6 100644 --- a/server/workspace-reaktion.js +++ b/server/workspace-reaktion.js @@ -53,6 +53,7 @@ import { einstellung } from "./workspace.js"; import { mitZugangsdaten, wegeAusfaechern } from "./workspace-turn.js"; import { SICHT_PLAETZE, GAESTE_MAX, EINLADUNG_SEKUNDEN, TEXT_MAX, SCHREIB_BREMSE, + VORSCHLAEGE_JE_PERSON, VORSCHLAG_NOTIZ_MAX, VORSCHLAG_BREMSE_SEKUNDEN, STAENDE, LAYOUTS, TEMPI, KAMERA_GROESSEN, KAMERA_ECKEN, MELDUNG_TAGE, @@ -287,10 +288,33 @@ async function videoTitel(kennung) { /** Die Warteschlange, in ihrer Reihenfolge. */ function listeHolen() { - return db().prepare("SELECT id, video, titel, platz FROM reaktion_liste" + return db().prepare("SELECT id, video, titel, platz, wann FROM reaktion_liste" + " ORDER BY platz, id").all(); } +/* ====================================================================== + DER PLAN FUERS FOYER (08.10.2026) + + Filipe: „links eine reihe wo die nächsten geplanten videos schon + bereit stehen. wan und umd wie viel uhr." + + ES IST DIESELBE LISTE -- nur anders sortiert: nach TERMIN statt + nach Platz. Im Pult ist die Reihenfolge die Aussage („was kommt + als Naechstes im Live"), im Foyer der Termin („Donnerstag um + acht"). + + SORTIERT WIRD IM BROWSER UND NICHT HIER. Ein zweites Feld im + Stand waere eine zweite Antwort auf dieselbe Frage -- dieselben + Zeilen, zweimal uebertragen, und beim naechsten Umbau pflegt + jemand nur eine der beiden Stellen. Was hinausgeht, ist `liste` + mit `wann`; wie man sie anschaut, entscheidet die Seite. + + VERGANGENES FAELLT NICHT WEG. Ein Termin von gestern, den niemand + weggeraeumt hat, steht weiter da und ist damit sichtbar + ueberfaellig. Ihn stillschweigend auszublenden hiesse, dass eine + Vorbereitung verschwindet, ohne dass jemand etwas getan hat. + ====================================================================== */ + /** Das naechste Video aus der Liste ins Bild holen. * * EINE STELLE FUER ZWEI KNOEPFE. „Naechstes" und „Jetzt spielen" @@ -1459,6 +1483,262 @@ reaktionRouter.delete("/workspace/api/reaktion/liste/:id", nurHost, (req, res) = } }); +/* ---------- Wann ein vorbereitetes Video laufen soll --------------------- + Filipe: „links eine reihe wo die nächsten geplanten videos schon + bereit stehen. wan und umd wie viel uhr." + + EIN EIGENER WEG UND KEIN FELD AM ANHAENGEN. Der Termin entsteht + fast nie beim Einfuegen -- man raeumt erst ein und legt danach + fest, wann es laufen soll. Ein Pflichtfeld beim Anhaengen haette + aus dem schnellsten Handgriff des Pults („Adresse einfuegen, + Enter") ein Formular gemacht. + + DER WERT WIRD GEPRUEFT UND NICHT NUR GESPEICHERT. Was hier + hereinkommt, steht spaeter als Datum im Foyer; ein freier Text + ergaebe dort „Invalid Date" -- und das sieht aus, als sei die + Seite kaputt, nicht die Eingabe. */ +reaktionRouter.put("/workspace/api/reaktion/liste/:id/wann", nurHost, + express.json({ limit: "2kb" }), (req, res) => { + try { + const id = Number(req.params.id) || 0; + const roh = String(req.body?.wann ?? "").trim(); + let wann = ""; + if (roh) { + const d = new Date(roh); + if (Number.isNaN(d.getTime())) return res.status(400).json({ fehler: "termin_unklar" }); + wann = d.toISOString(); + } + const a = db().prepare("UPDATE reaktion_liste SET wann = ? WHERE id = ?").run(wann, id); + if (!a.changes) return res.status(404).json({ fehler: "nicht_in_der_liste" }); + + const l = lage(req.person); + melden("reaktion", { was: "liste", lage: oeffentlich(l) }); + res.json(l); + } catch (fehler) { + console.error("[reaktion] Termin:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/* ======================================================================= + DIE VORSCHLAEGE (08.10.2026) + + Filipe: „da sollen die leute alle mir videos anraten können. also + da können die leute videos reinschicken und nur ich und vanvan + sehen sie." + + ----------------------------------------------------------------------- + WER WAS SIEHT -- UND WARUM DER EINSENDER SEINEN EIGENEN SIEHT + + „Nur ich und vanvan sehen sie" heisst: Niemand sieht die + Vorschlaege ANDERER. Dass jemand seinen eigenen zurueckbekommt, + ist kein Widerspruch, sondern die Voraussetzung dafuer, dass er + ueberhaupt einen schickt: Ein Formular, das nach dem Absenden + nichts mehr zeigt, sieht aus wie ein Loch. Er sieht seine eigene + Zeile mit dem Stand -- offen, uebernommen, abgelehnt -- und sonst + nichts. + + DER TITEL WIRD BEI YOUTUBE GEHOLT, nicht vom Einsender getippt. + Sonst stuende im Pult ein Name, den jemand frei gewaehlt hat, + neben einer Kennung, die etwas anderes ist -- und die Entscheidung + fiele ueber den falschen Film. + ======================================================================= */ + +/** Eine Zeile, wie sie hinausgeht. */ +function alsVorschlag(z, fuerHost) { + return { + id: z.id, + video: z.video, + titel: z.titel || "", + notiz: z.notiz || "", + stand: z.stand, + erstellt: z.erstellt, + /* WER ES WAR, SIEHT NUR DIE LEITUNG. Fuer den Einsender steht + dort ohnehin er selbst; den Namen mitzuschicken waere eine + Auskunft ueber andere, sobald sich die Liste einmal aendert. */ + name: fuerHost ? (z.name || "") : undefined, + mein: undefined, + }; +} + +reaktionRouter.get("/workspace/api/reaktion/vorschlaege", (req, res) => { + try { + const istHost = HOST_ROLLEN.has(req.person.rolle); + const reihen = istHost + ? db().prepare(`SELECT * FROM reaktion_vorschlaege + ORDER BY (stand = 'offen') DESC, id DESC LIMIT 200`).all() + : db().prepare(`SELECT * FROM reaktion_vorschlaege WHERE person_id = ? + ORDER BY id DESC LIMIT 50`).all(req.person.id); + res.json({ + vorschlaege: reihen.map((z) => alsVorschlag(z, istHost)), + /* WIE VIELE NOCH OFFEN SIND -- die eine Zahl, die ins Register + und an die Kachel gehoert. Sie wird hier gerechnet und nicht + im Browser aus der Liste gezaehlt: Wer nur seine eigenen + bekommt, zaehlte sonst seine eigenen. */ + offen: istHost + ? (db().prepare("SELECT COUNT(*) AS n FROM reaktion_vorschlaege" + + " WHERE stand = 'offen'").get()?.n || 0) + : null, + /* Was der Einsender wissen muss, um nicht gegen eine Wand zu + laufen: wie viele er noch offen haben darf. */ + je_person_max: VORSCHLAEGE_JE_PERSON, + notiz_max: VORSCHLAG_NOTIZ_MAX, + darf_entscheiden: istHost, + }); + } catch (fehler) { + console.error("[reaktion] Vorschlaege:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +reaktionRouter.post("/workspace/api/reaktion/vorschlag", express.json({ limit: "4kb" }), + async (req, res) => { + try { + const video = videoKennung(String(req.body?.video ?? "")); + if (!video) return res.status(400).json({ fehler: "video_unklar" }); + + /* ==== DREI SPERREN, UND JEDE BEANTWORTET ETWAS ANDERES ==== + (1) Zu schnell -- gegen den Doppelklick. + (2) Zu viele offen -- gegen die Liste, die niemand mehr + durchsieht. + (3) Schon da -- gegen dreimal dieselbe Entscheidung. */ + const kurz = new Date(Date.now() - VORSCHLAG_BREMSE_SEKUNDEN * 1000).toISOString(); + const eben = db().prepare(`SELECT COUNT(*) AS n FROM reaktion_vorschlaege + WHERE person_id = ? AND erstellt > ?`).get(req.person.id, kurz); + if ((eben?.n || 0) >= 1) return res.status(429).json({ fehler: "zu_schnell" }); + + const meine = db().prepare(`SELECT COUNT(*) AS n FROM reaktion_vorschlaege + WHERE person_id = ? AND stand = 'offen'`).get(req.person.id); + if ((meine?.n || 0) >= VORSCHLAEGE_JE_PERSON) { + return res.status(409).json({ fehler: "zu_viele_vorschlaege" }); + } + + const schonOffen = db().prepare(`SELECT id FROM reaktion_vorschlaege + WHERE video = ? AND stand = 'offen'`).get(video); + if (schonOffen) return res.status(409).json({ fehler: "vorschlag_doppelt" }); + + /* Und was schon in der Warteschlange liegt, braucht niemand + mehr anzuraten -- das waere eine Bitte um etwas, das bereits + entschieden ist. */ + const inDerListe = db().prepare("SELECT id FROM reaktion_liste WHERE video = ?") + .get(video); + if (inDerListe) return res.status(409).json({ fehler: "steht_schon_bereit" }); + + const notiz = String(req.body?.notiz ?? "").trim().slice(0, VORSCHLAG_NOTIZ_MAX); + const titel = await videoTitel(video); + const { lastInsertRowid } = db().prepare(`INSERT INTO reaktion_vorschlaege + (person_id, name, video, titel, notiz, stand, erstellt) + VALUES (?,?,?,?,?,'offen',?)`) + .run(req.person.id, req.person.name, video, titel, notiz, jetzt()); + + const z = db().prepare("SELECT * FROM reaktion_vorschlaege WHERE id = ?") + .get(Number(lastInsertRowid)); + + /* NUR DIE ZWEI ERFAHREN ES. Ein Rundruf an den Saal haette + genau das verraten, was niemand sehen soll. */ + stromAn( + db().prepare("SELECT id FROM personen WHERE rolle IN ('admin','hand') AND aktiv = 1") + .all().map((p) => p.id), + "reaktion", { was: "vorschlag" }, + ); + res.status(201).json({ ok: true, vorschlag: alsVorschlag(z, false) }); + } catch (fehler) { + console.error("[reaktion] Vorschlag:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/** Einen Vorschlag uebernehmen -- er wandert in die Warteschlange. */ +reaktionRouter.post("/workspace/api/reaktion/vorschlag/:id(\\d+)/uebernehmen", nurHost, + (req, res) => { + try { + const z = db().prepare("SELECT * FROM reaktion_vorschlaege WHERE id = ?") + .get(Number(req.params.id)); + if (!z) return res.status(404).json({ fehler: "nicht_gefunden" }); + if (z.stand !== "offen") return res.status(409).json({ fehler: "schon_entschieden" }); + + const wie_viele = db().prepare("SELECT COUNT(*) AS n FROM reaktion_liste").get()?.n || 0; + if (wie_viele >= LISTE_MAX) return res.status(409).json({ fehler: "liste_voll" }); + const schon = db().prepare("SELECT id FROM reaktion_liste WHERE video = ?").get(z.video); + if (schon) return res.status(409).json({ fehler: "liste_doppelt" }); + + /* EIN SCHREIBVORGANG. Bricht es zwischen den beiden + Anweisungen ab, stuende der Vorschlag als „uebernommen" da, + ohne dass etwas in der Schlange liegt -- und niemand wuerde + es je bemerken. */ + const letzter = db().prepare("SELECT MAX(platz) AS p FROM reaktion_liste").get()?.p || 0; + db().exec("BEGIN IMMEDIATE"); + try { + db().prepare("INSERT INTO reaktion_liste (video, titel, platz, von_id, gesetzt_am)" + + " VALUES (?,?,?,?,?)") + .run(z.video, z.titel, letzter + 1, req.person.id, jetzt()); + db().prepare(`UPDATE reaktion_vorschlaege SET stand = 'uebernommen', + erledigt_am = ?, erledigt_von = ? WHERE id = ?`) + .run(jetzt(), req.person.id, z.id); + db().exec("COMMIT"); + } catch (f) { db().exec("ROLLBACK"); throw f; } + + protokolliere("reaktion_vorschlag", { + personId: req.person.id, + detail: `uebernommen: ${z.titel || z.video} (von ${z.name || "?"})`, + ip: echteIp(req), + }); + /* DER EINSENDER ERFAEHRT ES. Ohne diese Zeile stuende sein + Vorschlag bei ihm bis zum naechsten Laden auf „offen" -- + und die schoenste Nachricht des Abends kaeme nie an. */ + if (z.person_id) stromAn([z.person_id], "reaktion", { was: "vorschlag" }); + + const l = lage(req.person); + melden("reaktion", { was: "liste", lage: oeffentlich(l) }); + res.json(l); + } catch (fehler) { + console.error("[reaktion] Vorschlag uebernehmen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +reaktionRouter.post("/workspace/api/reaktion/vorschlag/:id(\\d+)/ablehnen", nurHost, + (req, res) => { + try { + const a = db().prepare(`UPDATE reaktion_vorschlaege SET stand = 'abgelehnt', + erledigt_am = ?, erledigt_von = ? WHERE id = ? AND stand = 'offen'`) + .run(jetzt(), req.person.id, Number(req.params.id)); + if (!a.changes) return res.status(409).json({ fehler: "schon_entschieden" }); + const z = db().prepare("SELECT person_id FROM reaktion_vorschlaege WHERE id = ?") + .get(Number(req.params.id)); + if (z?.person_id) stromAn([z.person_id], "reaktion", { was: "vorschlag" }); + res.json({ ok: true }); + } catch (fehler) { + console.error("[reaktion] Vorschlag ablehnen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/** Wegnehmen: der Einsender seinen eigenen OFFENEN, die Leitung jeden. + * + * ==== WARUM DER EINSENDER NUR DEN OFFENEN ZURUECKZIEHEN DARF + * + * Was entschieden ist, ist Geschichte -- und zwar auch fuer ihn. + * Koennte er einen abgelehnten loeschen, verschwaende damit die + * Spur, dass ueberhaupt etwas entschieden wurde; der naechste + * Vorschlag desselben Videos saehe dann aus wie der erste. */ +reaktionRouter.delete("/workspace/api/reaktion/vorschlag/:id(\\d+)", (req, res) => { + try { + const id = Number(req.params.id); + const z = db().prepare("SELECT * FROM reaktion_vorschlaege WHERE id = ?").get(id); + if (!z) return res.status(404).json({ fehler: "nicht_gefunden" }); + const istHost = HOST_ROLLEN.has(req.person.rolle); + const meiner = z.person_id === req.person.id && z.stand === "offen"; + if (!istHost && !meiner) return res.status(403).json({ fehler: "nicht_deiner" }); + + db().prepare("DELETE FROM reaktion_vorschlaege WHERE id = ?").run(id); + res.json({ ok: true }); + } catch (fehler) { + console.error("[reaktion] Vorschlag weg:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + /** Eins hoch oder runter. * * GETAUSCHT WIRD MIT DEM NACHBARN, nicht neu durchnummeriert. Beim diff --git a/server/workspace.js b/server/workspace.js index 57c457b3..e8a69f9b 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -1625,7 +1625,14 @@ const TREFF_BEREICHE = [ ihre Flaeche selbst mitbringt, genau wie `papier` beim Notizblock und `silber` bei den Aufgaben. */ { ...TREFF_GRUPPE, name: "Reaction", unter: "Zusammen schauen \u2013 live mit Kamera und Chat", - zeichen: "reaktion", live: true, ziel: "reaktion.html", szene: "arena" }, + /* ZIEL IST SEIT DEM 08.10.2026 DAS FOYER und nicht mehr der + Saal. Filipe: „wenn man drauf drückt geht zuerst eine seite + auf mit reihen/kategorien." Wer die Kachel antippt, landet + also erst dort, wo steht, was ansteht -- und geht von da aus + hinein. Die Benachrichtigung beim Einschalten zeigt weiter + direkt in den Saal: Wenn es laeuft, will niemand noch eine + Zwischenseite. */ + zeichen: "reaktion", live: true, ziel: "reaktion-foyer.html", szene: "arena" }, /* DIE FARBE, DIE "REGELN & HILFE" HATTE (22.09.2026). Filipes screen5: "die farbe dieser kachel soll die farbe von der kachel: regeln & hilfe werden". Regeln & Hilfe bekommt dafuer @@ -7302,6 +7309,23 @@ export const OHNE_KACHEL_UEBERALL = new Set([ wurde auf crew deshalb NIE angezeigt. Ausgerechnet der, der acht von elf Leuten betrifft. Eine Zeile hier heilt beides. */ "/workspace/anruf-probe.html", + /* DER SAAL DER REACTION (08.10.2026). + + Seit heute zeigt die Kachel auf das FOYER (reaktion-foyer.html); + in den Saal geht es von dort aus. Damit hat `reaktion.html` keine + Kachel mehr -- und die Adressregel vom 15.09. verlangt eine. + + GEMESSEN UND NICHT VERMUTET: `pruef-reaktion` wurde in derselben + Minute rot, in der die Kachel umgehaengt war -- „die Seite steht + der Community offen (302)". Die Seite war unveraendert; sie war + nur nicht mehr erreichbar. Ohne diese Zeile waere der Saal fuer + das ganze Haus auf die Startseite gelaufen, wortlos, und zwar + ausgerechnet ueber den Knopf „Jetzt beitreten". + + Auch die Benachrichtigung beim Einschalten zeigt direkt hierher + (workspace-reaktion.js) -- wenn es laeuft, will niemand noch eine + Zwischenseite. */ + "/workspace/reaktion.html", ]); /** Gibt es diese Seite auf der Adresse, auf der die Person gerade steht? */ diff --git a/workspace/anleitung.html b/workspace/anleitung.html index d262bc7a..72f2ada6 100644 --- a/workspace/anleitung.html +++ b/workspace/anleitung.html @@ -7,17 +7,17 @@ - - - - - + + + + + - - + + @@ -289,16 +289,16 @@ Und anleitung.js braucht es doppelt: Symbol, Farbe, Name und Adresse jeder Karte kommen von dort, nicht vom Server. --> - + - - - - - - + + + + + + diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index a1982ef9..86fd1a4c 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,8 +16,8 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +