diff --git a/server/pruef-vorschlaege.mjs b/server/pruef-vorschlaege.mjs new file mode 100644 index 00000000..0c6f6da3 --- /dev/null +++ b/server/pruef-vorschlaege.mjs @@ -0,0 +1,178 @@ +/* ===================================================================== + pruef-vorschlaege.mjs — Die fertigen Vorschläge lassen sich wechseln + + Filipe, 20.09.2026: „fertige vorschläge, überall auf der ganzen + kompletten website, will ich dass man auch immer wieder neuladen + kann, so dass wenn man alle benutzt hat man neue erstellen kann oder + wenn keiner uns gefällt wir komplett neue erstellen können + automatisch, mach das richtig geil dass es immer wieder möglich ist + die liste zu wechseln wenn sie uns nicht gefällt oder leer ist." + + --------------------------------------------------------------------- + WAS HIER DIE EIGENTLICHE FRAGE IST + + Nicht „gibt es einen Knopf" — sondern „kommt nach dem Druck etwas + ANDERES". Ein Knopf, der dieselben vier Karten noch einmal malt, + besteht jede Prüfung, die nur nach dem Knopf sucht, und ist + trotzdem wertlos. Deshalb werden hier die Titel VORHER und NACHHER + verglichen. + + UND DER RUNDLAUF: Wer oft genug drückt, muss wieder am Anfang + landen statt vor einer leeren Liste. Das wird gemessen, indem so oft + weitergeschaltet wird, dass der Vorrat sicher durch ist. + + AUFRUF: node server/pruef-vorschlaege.mjs + ===================================================================== */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { portMussFreiSein } from "./helfer-port.mjs"; +import { notbremse } from "./helfer-notbremse.mjs"; + +const PORT = await portMussFreiSein(4436, "pruef-vorschlaege"); +const ordner = mkdtempSync(join(tmpdir(), "ws-vorschlag-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = String(PORT); +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +notbremse(200_000, "pruef-vorschlaege"); +await new Promise((r) => setTimeout(r, 900)); +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 ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +console.log("\n=== Fertige Vorschläge wechseln ===\n"); + +/* ---- Anmelden: ein Gast, denn nur wer NICHT schreiben darf, bekommt + den Katalog ueberhaupt. Genau das ist der Sinn: Vorschlaege sind + fuer leere Bretter da. ------------------------------------------ */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const salt = randomBytes(16).toString("hex"); +const hash = scryptSync("CODE-DOGI-0001", 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, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,1,?)").run("Filipe", "admin", hash, salt, 32768, new Date().toISOString()); +d.close(); + +const an = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json", Origin: BASIS }, + body: JSON.stringify({ rolle: "admin", code: "CODE-DOGI-0001" }), +}); +const keks = (an.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; "); +ok(an.ok && !!keks, "angemeldet"); + +const hole = async (weg) => { + const a = await fetch(BASIS + weg, { headers: { Cookie: keks } }); + return a.ok ? a.json() : null; +}; + +/* ---- 1. Welches Brett hat den groessten Vorrat? ------------------- */ +/* NICHT geraten: gemessen. Ein fest eingetragener Bereichsname waere + falsch, sobald jemand den Katalog umbaut -- und die Pruefung liefe + dann auf einem Brett mit drei Vorschlaegen und koennte gar nichts + ueber das Weiterschalten sagen. */ +const { STARTKATALOG } = await import("./workspace-bereiche.js") + .then((m) => ({ STARTKATALOG: m.STARTKATALOG })) + .catch(() => ({ STARTKATALOG: null })); + +let bereich = null, vorrat = 0; +for (const b of ["regeln", "mitmachen", "content", "community", "live", "technik", "schutz"]) { + const r = await hole(`/workspace/api/bereich/${b}/start`); + if (r && r.offen > vorrat) { bereich = b; vorrat = r.offen; } +} +/* DRITTER AUSGANG: Ohne ein Brett mit mehr als vier offenen + Vorschlaegen ist die Frage "kommt etwas anderes" nicht zu + beantworten -- und das muss ein FEHLER sein, kein Schweigen. */ +ok(!!bereich && vorrat > 4, + `Brett mit genug Vorrat gefunden: "${bereich}" mit ${vorrat} offenen`); +if (!bereich || vorrat <= 4) { + console.log("\n KONNTE NICHT NACHSEHEN: Kein Brett hat mehr als vier offene"); + console.log(" Vorschlaege. Ohne das ist nicht messbar, ob das Weiterschalten"); + console.log(" etwas anderes liefert. Katalog pruefen: server/workspace-*-start.js\n"); + process.exit(1); +} + +/* ---- 2. Es kommt ein FENSTER, nicht die ganze Wand ---------------- */ +const s0 = await hole(`/workspace/api/bereich/${bereich}/start?ab=0`); +ok(s0.vorschlaege.length < vorrat, + `es kommen ${s0.vorschlaege.length} von ${vorrat} auf einmal - nicht alle`); +ok(s0.vorschlaege.length === 4, `das Fenster ist vier gross (${s0.vorschlaege.length})`); +ok(s0.mehr_da === true, "und der Server sagt, dass es mehr gibt (mehr_da)"); +ok(s0.offen === vorrat, `"offen" zaehlt weiterhin alle (${s0.offen}) - davon haengt "Alle N uebernehmen" ab`); + +/* ---- 3. DIE EIGENTLICHE FRAGE: kommt etwas anderes? --------------- */ +const s1 = await hole(`/workspace/api/bereich/${bereich}/start?ab=4`); +const t0 = s0.vorschlaege.map((v) => v.titel); +const t1 = s1.vorschlaege.map((v) => v.titel); +const gleich = t1.filter((t) => t0.includes(t)).length; +ok(gleich === 0, + `nach dem Weiterschalten ist KEINER der vier derselbe (${gleich} Ueberschneidungen)`); +console.log(` vorher : ${t0.map((t) => t.slice(0, 22)).join(" | ")}`); +console.log(` nachher: ${t1.map((t) => t.slice(0, 22)).join(" | ")}`); + +/* GEGENPROBE: Dieselbe Abfrage OHNE Weiterschalten muss dieselben + liefern. Sonst misst der Vergleich oben nur Zufall -- eine Liste, + die sich bei jedem Abruf mischt, haette den Test auch bestanden und + waere trotzdem kaputt (man findet einen Vorschlag nie wieder). */ +const s0b = await hole(`/workspace/api/bereich/${bereich}/start?ab=0`); +ok(JSON.stringify(s0b.vorschlaege.map((v) => v.titel)) === JSON.stringify(t0), + "Gegenprobe: ohne Weiterschalten kommen dieselben - die Liste wuerfelt nicht"); + +/* ---- 4. KEIN ENDE: der Vorrat laeuft im Kreis --------------------- */ +/* Weit ueber den Vorrat hinaus. Wer so oft drueckt, darf nicht vor + einer leeren Liste stehen -- das war Filipes Punkt ("oder leer ist"). */ +const weit = await hole(`/workspace/api/bereich/${bereich}/start?ab=${vorrat * 3 + 2}`); +ok((weit?.vorschlaege?.length || 0) === 4, + `weit hinter dem Ende kommen immer noch vier (${weit?.vorschlaege?.length}) - kein leerer Bildschirm`); +const rund = await hole(`/workspace/api/bereich/${bereich}/start?ab=${vorrat}`); +ok(JSON.stringify(rund.vorschlaege.map((v) => v.titel)) === JSON.stringify(t0), + "genau einmal herum gibt wieder den Anfang - der Rundlauf schliesst"); + +/* ---- 5. Unsinn in der Adresse darf nichts umwerfen ---------------- */ +for (const mies of ["-7", "abc", "9e99", "", "1.5"]) { + const r = await hole(`/workspace/api/bereich/${bereich}/start?ab=${encodeURIComponent(mies)}`); + ok((r?.vorschlaege?.length || 0) === 4, ` "ab=${mies}" liefert trotzdem vier`); +} + +/* ---- 6. Ein kleines Brett bekommt KEINEN Wechselknopf ------------- */ +/* Ein Knopf, der dieselben drei Karten noch einmal malt, ist ein + Knopf, der nichts tut -- und der Verdacht, die Seite haenge, ist + schlimmer als ein fehlender Knopf. */ +let klein = null; +for (const b of ["highlight", "ansteht", "agentur", "wunsch", "ideen", "angebote"]) { + const r = await hole(`/workspace/api/bereich/${b}/start`); + if (r && r.offen > 0 && r.offen <= 4) { klein = { b, r }; break; } +} +ok(!!klein, `Brett mit hoechstens vier Vorschlaegen gefunden ("${klein?.b}", ${klein?.r?.offen})`); +ok(klein?.r?.mehr_da === false, + " und dort sagt der Server: nichts zu wechseln (mehr_da ist falsch)"); +ok((klein?.r?.vorschlaege?.length || 0) === klein?.r?.offen, + ` es kommen trotzdem alle ${klein?.r?.offen} auf einmal - nichts versteckt`); + +/* ---- 7. Und die Seite baut den Knopf auch ------------------------- */ +const { readFileSync } = await import("node:fs"); +const { fileURLToPath } = await import("node:url"); +const { dirname } = await import("node:path"); +const HIER = dirname(fileURLToPath(import.meta.url)); +const js = readFileSync(join(HIER, "..", "workspace", "assets", "js", "bereich.js"), "utf8"); +ok(/katalogAb \+= Math\.max\(1, daten\.vorschlaege\.length\)/.test(js), + "die Seite zaehlt um das weiter, was wirklich kam - keine zweite 4 im Code"); +ok(/if \(daten\.mehr_da\)/.test(js), + "und zeigt den Knopf nur, wenn der Server sagt, dass es mehr gibt"); +ok(/let katalogAb = 0;[\s\S]{0,200}async function startkatalogZeigen/.test(js), + "der Merker steht AUSSERHALB der Funktion - sonst spraenge die Liste nach jedem Uebernehmen zurueck"); + +console.log(`\n${fehler ? fehler + " FEHLER" : "Alles in Ordnung"} — ${geprueft} Prüfungen\n`); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +process.exit(fehler ? 1 : 0); diff --git a/server/workspace-bereiche.js b/server/workspace-bereiche.js index 76626cfd..bc02e629 100644 --- a/server/workspace-bereiche.js +++ b/server/workspace-bereiche.js @@ -1846,9 +1846,59 @@ bereicheRouter.get("/workspace/api/bereich/:bereich/start", (req, res) => { Wahrheit, und beim Umformulieren liefe sie auseinander. */ const da = new Set(db().prepare( "SELECT titel FROM eintraege WHERE bereich = ?").all(bereich).map((z) => z.titel)); + + /* ==== EIN FENSTER STATT DER GANZEN LISTE (20.09.2026) =========== + + Filipe: "fertige vorschlaege, ueberall auf der ganzen kompletten + website, will ich dass man auch immer wieder neuladen kann, so + dass wenn man alle benutzt hat man neue erstellen kann oder wenn + keiner uns gefaellt wir komplett neue erstellen koennen + automatisch ... dass es immer wieder moeglich ist die liste zu + wechseln wenn sie uns nicht gefaellt oder leer ist." + + BISHER KAMEN ALLE AUF EINMAL. Damit gab es nichts zu wechseln -- + die Liste war entweder ganz da oder ganz weg. Und bei 17 + Vorschlaegen (Brett "regeln") ist eine Wand aus Karten das + Gegenteil von "uebernehmen, was passt". + + JETZT: vier auf einmal, und ein Knopf holt die naechsten vier. + Ist der Vorrat durch, faengt er von vorn an -- `ab` wird mit + Rest gerechnet, es gibt also kein Ende und keine Sackgasse. + + WAS ES NICHT IST: Die Vorschlaege werden nicht ERZEUGT. Sie sind + ein geschriebener Vorrat (113 Stueck auf 16 Brettern), und das + steht auch in der Antwort -- `vorrat` sagt, wie viele es + insgesamt sind. Der Oberflaeche zu erlauben, "neue" zu + versprechen, die es nicht gibt, waere eine Luege mit + Fortschrittsanzeige. */ + const FENSTER = 4; + const offeneListe = liste.map((v, nr) => ({ nr, ...v, schon: da.has(v.titel) })) + .filter((v) => !v.schon); + + const roh = Number(req.query?.ab); + /* MODULO STATT GRENZE: Wer oft genug weiterklickt, landet wieder am + Anfang -- statt vor einer leeren Liste zu stehen. */ + const ab = offeneListe.length + ? ((Number.isFinite(roh) ? Math.trunc(roh) : 0) % offeneListe.length + + offeneListe.length) % offeneListe.length + : 0; + + const fenster = []; + for (let i = 0; i < Math.min(FENSTER, offeneListe.length); i++) { + fenster.push(offeneListe[(ab + i) % offeneListe.length]); + } + res.json({ - vorschlaege: liste.map((v, nr) => ({ nr, ...v, schon: da.has(v.titel) })), - offen: liste.filter((v) => !da.has(v.titel)).length, + vorschlaege: fenster, + /* `offen` bleibt, was es war: wie viele es NOCH gibt. Die + Oberflaeche zaehlt damit "Alle N uebernehmen" richtig. */ + offen: offeneListe.length, + /* NEU: Gibt es ueberhaupt etwas zu wechseln? Ohne diese Angabe + muesste die Seite rechnen, und ein Knopf, der nichts anderes + zeigt, ist ein Knopf, der nichts tut. */ + mehr_da: offeneListe.length > FENSTER, + ab, + vorrat: liste.length, }); } catch (fehler) { console.error("[workspace] Startkatalog:", fehler?.message); diff --git a/workspace/assets/css/bereich.css b/workspace/assets/css/bereich.css index a6d8ab0d..edc7d804 100644 --- a/workspace/assets/css/bereich.css +++ b/workspace/assets/css/bereich.css @@ -998,6 +998,25 @@ font-size: .85rem; color: var(--text-leise); } .startkatalog__alle { flex: 0 0 auto; } +.startkatalog__andere { flex: 0 0 auto; } + +/* DER WECHSELKNOPF IST DER ZWEITE, NICHT DER ERSTE (20.09.2026). + + Neben ihm steht "Alle N uebernehmen" -- das ist die Handlung, die + etwas bewirkt. "Andere zeigen" blaettert nur. Stuenden beide gleich + kraeftig da, muesste man jedes Mal lesen, welcher welcher ist. + Deshalb bekommt er dieselbe Form und weniger Farbe: erkennbar als + Knopf, aber nicht als Angebot. */ +.startkatalog__andere { + background: transparent; + border-color: var(--rand); + color: var(--text-leise); +} +.startkatalog__andere:hover { + color: var(--text); + border-color: color-mix(in srgb, var(--akzent) 45%, var(--rand)); + background: color-mix(in srgb, var(--akzent) 7%, transparent); +} /* Auto-fill: Auf dem Handy eine Spalte, am Rechner drei — gemessen, nicht gerechnet. */ diff --git a/workspace/assets/js/bereich.js b/workspace/assets/js/bereich.js index 00ffe94d..e82f12fb 100644 --- a/workspace/assets/js/bereich.js +++ b/workspace/assets/js/bereich.js @@ -1641,13 +1641,32 @@ Fertige Texte, die jemand aus dem Team uebernimmt. Nicht automatisch: Ein Text, den niemand gelesen hat, stuende sonst als Ansage des Teams auf einer oeffentlichen Seite. */ + /* WO IM VORRAT WIR GERADE STEHEN (20.09.2026). + + Filipe: "fertige vorschlaege ... will ich dass man auch immer + wieder neuladen kann, so dass wenn man alle benutzt hat man neue + erstellen kann oder wenn keiner uns gefaellt wir komplett neue + erstellen koennen automatisch." + + Bisher kamen ALLE auf einmal -- damit gab es nichts zu wechseln. + Jetzt zeigt der Server ein Fenster, und dieser Merker sagt ihm, wo + es anfangen soll. Er steht ausserhalb der Funktion, weil `laden()` + sie neu aufruft: Waere er drin, spraenge die Liste nach jedem + Uebernehmen zurueck an den Anfang. + + KEINE EIGENE FENSTERGROESSE HIER. Weitergezaehlt wird um das, was + tatsaechlich kam (`vorschlaege.length`) -- eine zweite 4 an dieser + Stelle waere eine Zahl, die beim naechsten Anfassen auseinanderlaeuft. */ + let katalogAb = 0; + async function startkatalogZeigen() { const ziel = $('startkatalog'); if (!ziel) return; if (!darfEintragen()) { ziel.hidden = true; return; } let daten = null; try { - const a = await fetch(`/workspace/api/bereich/${bereich}/start`, + const a = await fetch( + `/workspace/api/bereich/${bereich}/start?ab=${katalogAb}`, { credentials: 'same-origin' }); if (!a.ok) { ziel.hidden = true; return; } daten = await a.json(); @@ -1660,7 +1679,9 @@ kopf.append(el('h2', 'startkatalog__titel', 'Fertige Vorschläge')); kopf.append(el('p', 'startkatalog__unter', `${daten.offen} ${daten.offen === 1 ? 'Vorschlag ist' : 'Vorschläge sind'} ` - + 'vorbereitet. Übernehmen, was passt – ändern kannst du danach alles.')); + + (daten.mehr_da + ? 'vorbereitet. Hier stehen die nächsten – übernimm, was passt.' + : 'vorbereitet. Übernehmen, was passt – ändern kannst du danach alles.'))); const alle = el('button', 'schritt startkatalog__alle', `Alle ${daten.offen} übernehmen`); alle.type = 'button'; @@ -1673,6 +1694,32 @@ await laden(); }); kopf.append(alle); + + /* ---- ANDERE ZEIGEN ------------------------------------------------ + Nur wenn es ueberhaupt andere GIBT. Ein Knopf, der dieselben vier + noch einmal bringt, ist ein Knopf, der nichts tut -- und der + Verdacht, dass die Seite haengt, ist schlimmer als ein fehlender + Knopf. Deshalb entscheidet der Server (`mehr_da`) und nicht eine + Rechnung hier. + + DER VORRAT ENDET NICHT: Der Server rechnet `ab` mit Rest, nach dem + letzten kommt wieder der erste. Es gibt also keinen Zustand + "durchgeklickt, jetzt ist leer". */ + if (daten.mehr_da) { + const andere = el('button', 'schritt startkatalog__andere', + 'Andere Vorschläge zeigen'); + andere.type = 'button'; + andere.setAttribute('aria-label', + 'Andere Vorschläge zeigen – die nächsten aus dem Vorrat'); + andere.addEventListener('click', () => { + /* Um das weiterzaehlen, was wirklich kam. Bei drei gezeigten + Karten um drei -- sonst uebersprungen wir eine. */ + katalogAb += Math.max(1, daten.vorschlaege.length); + startkatalogZeigen(); + }); + kopf.append(andere); + } + ziel.append(kopf); const liste = el('div', 'startkatalog__liste');