Agentur-Events: die Karte als Aushang, Aufgaben zum Abhaken

Filipe, mit einem Bildschirmfoto des Oktober-Events: "ich will das viel
geiler viel perfekter. und auch wenn man die eintraegt und so soll viel
mehr perfektioniert und uebersichtlicher sein."

VORHER GEMESSEN (1280 px, eine einzige Eventkarte):

  Kartenhoehe            1196 px   -- das Fenster ist 900 px
  davon Banner            638 px   -- 53 %, ohne einen Buchstaben
  Titel steht bei        1482 px   -- zweimal scrollen bis zum Namen
  Titelgroesse          15,36 px   -- 1,4 px mehr als der Fliesstext
  Etikett               10,88 px   -- unter der eigenen Grenze (11,5)
  Datum                     3 x    -- in drei Zeilen untereinander
  Knoepfe              4 x gleich  -- "loeschen" wie "zur Aufgabe"

NACHHER, dieselbe Messung: Karte 618 px, Buehne 203 px, Titel 75 px
unter der Kartenkante und 24,8 px gross, Etikett 12 px, das Datum
einmal, "loeschen" abgesetzt.

DIE KARTE
Das Banner ist nicht mehr ein Anhang ueber dem Titel, sondern die
Buehne dahinter -- Hoehe gedeckelt, Titel darauf. Dazu die Frage, die
bei einem Wettbewerb wirklich zaehlt und bisher nirgends beantwortet
wurde: "noch 12 Tage", mit Zeitbalken. Ein Knopf "Banner ganz" holt das
vollstaendige Bild zurueck, das beim Umbau sonst verloren gegangen
waere -- bei Filipes Banner steht die Ansage IM Bild.

DIE AUFGABEN
Aus Fliesstext wird eine Liste, und jeder hakt fuer sich ab (neue
Tabelle event_punkte, Schluessel aus dem Zeilentext). Umsortieren
laesst den Haken, wo er ist; wird die Bedingung selbst umgeschrieben,
faellt er -- beides nachgemessen. Die Leitung sieht, wer wie weit ist,
eine Creatorin sieht diese Liste gar nicht erst.

DAS FORMULAR
Ein Feld je Zeile statt eines leeren Textfeldes. Im echten Event stand
".Jeden Tag Live gehen" neben ". 33k Diamanten erreichen" -- einmal mit
Leerzeichen, einmal ohne. Das ist die zwangslaeufige Folge davon, dass
die Aufzaehlungszeichen von Hand getippt werden; jetzt setzt sie das
Formular. Dazu "Laeuft 14 Tage.", eine Warnung bei verdrehtem Zeitraum
und eine Vorschau, die dieselbe Funktion benutzt wie die echte Karte.

NEBENBEI GEFUNDEN UND BEHOBEN
* Das Banner kam bei Creatorn mit 404 zurueck -- also an genau der
  Karte, die fuer sie gemacht ist. Die Regel "wer das Brett sieht,
  sieht das Bild" galt nur fuer die sieben Community-Bretter. Jetzt
  fragt der Bildweg dieselbe Regel wie das Brett (darfEintragSehen);
  die Gegenprobe zeigt, dass ein fremder Eintrag weiterhin 404 gibt.
* Zwei Stellen setzten das Formular zurueck, die kuerzere liess
  Vorschau und Zeilen-Editor stehen -- beim naechsten "Neuer Eintrag"
  standen die Aufgaben des vorigen Events noch da.
* Der Schriftgrund ragte auf dem Handy 6 px ueber die Karte (feste
  -20 px gegen 14 px Polsterung); jetzt an die Polsterung gekoppelt.

GEPRUEFT
pruef-eventkarte.mjs (neu): 77 Pruefungen, 0 Fehler -- mit Gegenproben
zu jeder Zusage und einer Kontrastmessung am Bildpunkt auf einem
weissen Banner (14,7:1; ohne den Schriftgrund 1,05:1).
pruef-agentur.mjs auf die neuen Bausteine nachgezogen, Zahl unveraendert
bei 62. pruef-eintrag-bild.mjs 24/0.

OFFEN, NICHT AUS DIESEM UMBAU: Das Brett `agentur` ist auf der
Crew-Adresse erreichbar. Zweimal gemessen, mit und ohne diese
Aenderung -- gleiches Ergebnis. Gehoert zur Trennungsregel vom
24.09.2026 und wird getrennt entschieden.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-10-03 19:02:05 +02:00
co-authored by Claude Opus 5
parent ae2f12c67d
commit 3e1e0810ba
54 changed files with 3773 additions and 798 deletions
+61
View File
@@ -0,0 +1,61 @@
/* =====================================================================
EIN PNG SCHREIBEN (03.10.2026)
Das Gegenstueck zu helfer-png.mjs, das eines LIEST. Gebraucht fuer
die Eventkarten-Pruefung: Ob der Titel auf einer Buehne lesbar
bleibt, haengt davon ab, was fuer ein Banner darunter liegt -- und
der interessante Fall ist nicht das dunkle, sondern das HELLE, das
jemand spaeter hochlaedt. Ein solches Bild muss die Pruefung also
selbst herstellen koennen.
WARUM SELBST GESCHRIEBEN: Dieselbe Begruendung wie bei helfer-png.mjs
-- das Projekt hat kein Bildmodul, und fuer ein Testbild eines
nachzuinstallieren waere eine Abhaengigkeit, die jemand pflegen
muss. Node bringt zlib mit; ein unkomprimiertes RGB-PNG ist danach
nur noch Pruefsumme und Zeilenfilter.
===================================================================== */
import { deflateSync } from "node:zlib";
const tabelle = (() => {
const t = new Int32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
t[n] = c;
}
return t;
})();
function crc32(buf) {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) c = tabelle[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function teil(name, inhalt) {
const kopf = Buffer.alloc(8);
kopf.writeUInt32BE(inhalt.length, 0);
kopf.write(name, 4, "ascii");
const pruef = Buffer.alloc(4);
pruef.writeUInt32BE(crc32(Buffer.concat([Buffer.from(name, "ascii"), inhalt])), 0);
return Buffer.concat([kopf, inhalt, pruef]);
}
/** male(x, y, b, h) -> [r,g,b] */
export function pngBauen(b, h, male) {
const roh = Buffer.alloc((b * 3 + 1) * h);
let p = 0;
for (let y = 0; y < h; y++) {
roh[p++] = 0;
for (let x = 0; x < b; x++) {
const f = male(x, y, b, h);
roh[p++] = f[0]; roh[p++] = f[1]; roh[p++] = f[2];
}
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(b, 0); ihdr.writeUInt32BE(h, 4);
ihdr[8] = 8; ihdr[9] = 2; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
teil("IHDR", ihdr),
teil("IDAT", deflateSync(roh, { level: 9 })),
teil("IEND", Buffer.alloc(0)),
]);
}
+30 -7
View File
@@ -697,33 +697,56 @@ melde("\n=== Die Kachel ===");
Regeln koennen vollstaendig in der Antwort stehen und trotzdem
nirgends gezeichnet werden -- ein vergessener Aufruf in karte()
reicht, und niemand merkt es, weil die Karte ja da ist. */
/* ==== AUF DIE NEUE KARTE UMGESTELLT (03.10.2026) ================
Die Eventkarte wurde umgebaut (siehe pruef-eventkarte.mjs): Buehne
mit Titel auf dem Banner, Zeitraum einmal statt dreimal, Aufgaben
als Liste zum Abhaken. Dabei haben die Bausteine neue Namen
bekommen -- `.event-zeitraum__marke` heisst jetzt `.ev-noch`,
`.event-block__text` ist zu einer Liste aus `.ev-punkt__text`
geworden.
DIESE PRUEFUNG WURDE DESHALB NICHT GELOESCHT, SONDERN NACHGEZOGEN:
Sie sagt weiterhin dieselben FUENF Dinge zu, nur an der neuen
Stelle. Eine Pruefung wegzunehmen, weil sie nach einem Umbau rot
wird, senkt die Zahl und beweist danach nichts mehr
(Projektgedaechtnis, 28.08.2026: 789 statt 804 Pruefungen, und
acht davon fielen still weg).
WAS SICH INHALTLICH AENDERT: Der Zustand steht nicht mehr als
"laeuft bis TT.MM.JJJJ" da, sondern als "noch N Tage" -- dieselbe
Aussage, nur beantwortet sie jetzt die Frage, die man wirklich
hat. Geprueft wird deshalb auf ein WORT (nicht nur Farbe), nicht
mehr auf einen bestimmten Satz. */
const event = await seite.$$eval(".eintrag-karte", (karten) => {
const k = karten.find((x) => x.querySelector(".eintrag-titel")?.textContent.trim() === "Sommer-Aktion");
if (!k) return null;
return {
stand: k.dataset.event,
marke: k.querySelector(".event-zeitraum__marke")?.textContent.trim(),
spanne: k.querySelector(".event-zeitraum__spanne")?.textContent.trim(),
schilder: [...k.querySelectorAll(".event-block__schild")].map((s) => s.textContent.trim()),
texte: [...k.querySelectorAll(".event-block__text")].map((s) => s.textContent.trim()),
marke: k.querySelector(".ev-noch")?.textContent.trim(),
spanne: k.querySelector(".ev-spanne")?.textContent.trim(),
schilder: [...k.querySelectorAll(".ev-block__schild")].map((s) => s.textContent.trim()),
texte: [...k.querySelectorAll(".ev-punkt__text")].map((s) => s.textContent.trim()),
};
});
ok(!!event, "die Event-Karte Sommer-Aktion steht auf der Seite");
ok(event?.stand === "laeuft", `sie ist als laufend gekennzeichnet ("${event?.stand}")`);
ok(/läuft bis/.test(event?.marke || ""),
/* Ein Wort, kein leerer Platzhalter -- und es muss zum Zustand
passen: "noch N Tage" bzw. "heute ist der letzte Tag". */
ok(/^(noch \d+ Tage?|heute ist der letzte Tag)$/.test(event?.marke || ""),
`der Zustand steht als WORT da, nicht nur als Farbe ("${event?.marke}")`);
ok(/–/.test(event?.spanne || ""), `und der Zeitraum als Spanne ("${event?.spanne}")`);
ok(event?.schilder.join(",") === "Aufgaben,Regeln",
`beide Bloecke sind beschriftet (${event?.schilder.join(", ")})`);
ok(event?.texte.includes("1 Punkt je Live-Stunde") && event?.texte.includes("Nur eigenes Konto"),
"und tragen wirklich ihren Inhalt");
`und tragen wirklich ihren Inhalt (${JSON.stringify(event?.texte)})`);
/* GEGENPROBE dazu: Eine Karte, die KEIN Event ist, darf nichts davon
haben. Sonst bewiese der Block oben nur, dass die Elemente
existieren -- nicht, dass sie zur richtigen Art gehoeren. */
const schulung = await seite.$$eval(".eintrag-karte", (karten) => {
const k = karten.find((x) => x.querySelector(".eintrag-titel")?.textContent.trim() === "Test schulung");
return k ? { event: k.dataset.event ?? null, bloecke: k.querySelectorAll(".event-block").length } : null;
return k ? { event: k.dataset.event ?? null, bloecke: k.querySelectorAll(".ev-block").length } : null;
});
ok(schulung && !schulung.event && schulung.bloecke === 0,
`eine Schulung bekommt weder Zustand noch Event-Bloecke (${JSON.stringify(schulung)})`);
+900
View File
@@ -0,0 +1,900 @@
/* =====================================================================
AGENTUR-EVENTS: KARTE, PUNKTE UND FORMULAR (03.10.2026)
Filipe, mit einem Bildschirmfoto des Oktober-Events: „ich will das
viel geiler viel perfekter. und auch wenn man die eintraegt und so
soll viel mehr perfektioniert und uebersichtlicher sein."
Umgebaut wurden drei Dinge, und alle drei werden hier nachgemessen:
1. DIE KARTE Buehne mit Titel auf dem Banner statt 638 px
leerem Bild darueber; Zeitraum einmal statt
dreimal; „noch 13 Tage" als Antwort.
2. DIE PUNKTE Aus Fliesstext wird eine Liste zum Abhaken,
jeder fuer sich. Neue Tabelle event_punkte.
3. DAS FORMULAR Ein Feld je Zeile statt eines leeren Textfeldes,
in das man Aufzaehlungszeichen selbst tippt.
WAS HIER AUSDRUECKLICH NICHT NUR BESTAETIGT WIRD:
Zu jeder Zusage gehoert eine Gegenprobe, die zeigt, dass die
Pruefung auch „nicht in Ordnung" sagen kann -- sonst bestaetigt sie
nichts (Projektgedaechtnis, 01.09.2026: `every()` auf einem leeren
Feld meldet `true`). Deshalb steht die Zahl der geprueften Dinge in
der BEDINGUNG, nicht nur im Meldetext.
KEINE WANDUHR ALS ANNAHME: Alle Daten sind relativ zu heute
gerechnet (`tag(-1)`, `tag(+12)`). Ein festes Datum waere eine
Zeitbombe -- gruen, bis der Kalender es einholt (Projektgedaechtnis,
06.09.2026, gate-oeffnung.mjs).
===================================================================== */
import { mkdtempSync, rmSync, appendFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-evk-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
import { notbremse } from "./helfer-notbremse.mjs";
const { eigenerPort } = await import("./helfer-port.mjs");
const PORT = await eigenerPort(import.meta, "die Eventkarten-Pruefung");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
await import("./index.js");
notbremse(300_000, "pruef-eventkarte");
await new Promise((r) => setTimeout(r, 1000));
const BASIS = "http://127.0.0.1:" + PORT;
const PROTOKOLL = join(process.cwd(), "pruef-eventkarte-lauf.txt");
try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ }
const melde = (z) => {
console.log(z);
try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ }
};
let fehler = 0, geprueft = 0;
const ok = (b, t) => { geprueft++; melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
/* ---- Bestand ---------------------------------------------------------- */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
for (let i = 0; i < 60; i++) {
try {
const p = new DatabaseSync(process.env.WORKSPACE_DB);
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
} catch { await new Promise((r) => setTimeout(r, 250)); }
}
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().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, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idDogi = anlegen("Dogfather", "admin", "CODE-DOGI-0001");
const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001");
const heute = new Date();
const tag = (v) => new Date(heute.getTime() + v * 86400000).toISOString().slice(0, 10);
function evt(titel, von, bis, auf, reg) {
d.prepare("INSERT INTO eintraege (titel, text, bereich, art, status, datum, event_ende,"
+ " event_aufgaben, event_regeln, dringlichkeit, erstellt, erstellt_von)"
+ " VALUES (?,?,'agentur','kampagne','offen',?,?,?,?,'mittel',?,?)")
.run(titel, "Beschreibung zum Test.", von, bis, auf, reg, jetzt, idDogi);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
/* Die drei Zustaende, die eventStand kennt -- jeder einzeln, nicht als
Summe. Und die Aufgaben genau so krumm getippt wie im echten Event:
einmal ohne Leerzeichen hinter dem Punkt, einmal mit. */
const laufend = evt("Oktober Event", tag(-1), tag(12),
"·Jeden Tag Live gehen\n·22-Live-Stunden erreichen\n· 33k Diamanten erreichen",
"· Nur Streams auf dem eigenen Konto\n· Punkte zählen bis 23:59 am letzten Tag");
const kommend = evt("November Challenge", tag(20), tag(45),
"· 30 Live-Stunden", "· Nur eigene Konten");
const vorbei = evt("September Rückblick", tag(-40), tag(-12),
"· 20 Live-Stunden", "· Nur eigene Konten");
/* Ein gewoehnlicher Eintrag auf demselben Brett: Er darf sich NICHT
mitverwandeln. Ohne ihn wuerde "nur Events sind betroffen" hier gar
nicht geprueft. */
d.prepare("INSERT INTO eintraege (titel, text, bereich, art, status, datum, dringlichkeit, erstellt, erstellt_von)"
+ " VALUES (?,?,'agentur','schulung','offen',?, 'mittel', ?, ?)")
.run("Einarbeitung für neue Modis", "Kein Event.", tag(0), jetzt, idDogi);
/* EIN EINTRAG, DEN LUNA NICHT SEHEN DARF -- fuer die Gegenprobe zur
Bildweg-Reparatur weiter unten. `entwicklung` ist eine Aufzeichnung
UEBER eine Person; sie selbst sieht sie nicht. Ohne diesen Eintrag
waere "es wurden keine Rechte ausgeweitet" eine Behauptung ohne
Beleg. */
d.prepare("INSERT INTO eintraege (titel, text, bereich, art, status, datum, dringlichkeit, erstellt, erstellt_von, creator_id)"
+ " VALUES (?,?,'entwicklung','notiz','offen',?, 'mittel', ?, ?, ?)")
.run("Vertrauliche Aufzeichnung", "Nicht fuer Luna.", tag(0), jetzt, idDogi, idDogi);
const geheim = d.prepare("SELECT last_insert_rowid() AS id").get().id;
d.close();
/* Ein Banner fuer das laufende Event -- ohne Bild waere der Knopf
"Banner ganz" nicht pruefbar, und die Buehne mit Bild ist der Fall,
um den es Filipe ging. */
import { pngBauen } from "./helfer-png-bauen.mjs";
const bannerBild = pngBauen(1280, 720, (x, y, b, h) => {
const g = Math.max(0, 1 - Math.hypot(x / b - .5, y / h - .5) * 1.8);
return [Math.round(26 + g * 120), Math.round(10 + g * 30), Math.round(46 + g * 150)];
});
/* ---- Zugang ----------------------------------------------------------- */
const { request } = await import("node:http");
const HOST = "workspace.dogfather-universe.com";
function roh(pfad, o = {}) {
return new Promise((f, s) => {
const daten = o.rumpf === undefined ? null
: (Buffer.isBuffer(o.rumpf) ? o.rumpf : Buffer.from(JSON.stringify(o.rumpf)));
const a = request({
host: "127.0.0.1", port: PORT, path: pfad,
method: o.methode || (daten ? "POST" : "GET"),
headers: {
Host: HOST, Origin: "https://" + HOST,
...(o.keks ? { Cookie: o.keks } : {}),
...(daten ? { "Content-Length": daten.length } : {}),
...(o.kopf || {}),
},
}, (r) => {
const t = [];
r.on("data", (x) => t.push(x));
r.on("end", () => f({ code: r.statusCode, kopf: r.headers, text: Buffer.concat(t).toString("utf8") }));
});
a.on("error", s);
if (daten) a.write(daten);
a.end();
});
}
async function anmelden(rolle, code) {
const a = await roh("/workspace/api/anmelden", {
rumpf: { rolle, code }, kopf: { "Content-Type": "application/json" } });
if (a.code !== 200) { melde("ABBRUCH: Anmeldung " + rolle + " -> " + a.code); process.exit(3); }
return [].concat(a.kopf["set-cookie"] || []).map((c) => c.split(";")[0]).join("; ");
}
const keksDogi = await anmelden("admin", "CODE-DOGI-0001");
const keksLuna = await anmelden("creator", "CODE-CREA-0001");
const brett = async (keks) => JSON.parse((await roh("/workspace/api/bereich/agentur", { keks })).text);
let bannerId = null, geheimBildId = null;
{
const h = await roh("/workspace/api/bereich/agentur/" + laufend + "/bild", {
keks: keksDogi, rumpf: bannerBild, methode: "POST",
kopf: { "Content-Type": "image/png", "x-name": encodeURIComponent("banner.png") } });
if (h.code !== 201) {
melde("ABBRUCH: Banner liess sich nicht anhaengen (" + h.code + ") -- ohne es");
melde(" prueft diese Datei die Buehne MIT Bild gar nicht.");
process.exit(3);
}
bannerId = JSON.parse(h.text).id;
const g = await roh("/workspace/api/bereich/entwicklung/" + geheim + "/bild", {
keks: keksDogi, rumpf: bannerBild, methode: "POST",
kopf: { "Content-Type": "image/png", "x-name": encodeURIComponent("geheim.png") } });
if (g.code !== 201) {
melde("ABBRUCH: das Bild fuer die Gegenprobe ging nicht durch (" + g.code + ").");
melde(" Ohne es wuerde unten eine Rechtefrage geprueft, die gar nicht stattfindet.");
process.exit(3);
}
geheimBildId = JSON.parse(g.text).id;
}
melde("\n=== 0. Der Bildweg: wer den Eintrag sieht, sieht sein Bild ===");
{
/* GEMESSEN AM 03.10.2026, und es war ein echter Mangel: Das Banner
eines Agentur-Events kam bei einer Creatorin mit 404 zurueck --
an genau der Karte, die fuer sie gemacht ist. Grund: Die Regel
"wer das Brett sieht, sieht das Bild" galt nur fuer die sieben
Community-Bretter, und danach griff die Ablage-Regel, die an
Creator-Zuordnungen haengt. Ein Agentur-Event hat keine. */
const alsDogi = await roh("/workspace/api/dateien/" + bannerId + "/bild", { keks: keksDogi });
const alsLuna = await roh("/workspace/api/dateien/" + bannerId + "/bild", { keks: keksLuna });
ok(alsDogi.code === 200, `DogFather bekommt das Banner (HTTP ${alsDogi.code})`);
ok(alsLuna.code === 200, `und die Creatorin auch (HTTP ${alsLuna.code}, vorher 404)`);
/* DIE GEGENPROBE, auf die es ankommt: Die Reparatur darf NICHT mehr
oeffnen als noetig. Ein Eintrag, den Luna nicht sehen darf, muss
sein Bild weiterhin verweigern -- sonst waere aus einer Luecke
eine groessere geworden. */
const verboten = await roh("/workspace/api/dateien/" + geheimBildId + "/bild", { keks: keksLuna });
ok(verboten.code === 404,
`ein Bild an einem fremden Eintrag bleibt verborgen (HTTP ${verboten.code})`);
/* Und dass dieses Bild ueberhaupt existiert -- sonst waere die
Gegenprobe gruen, weil es nichts zu sehen gab. */
const existiert = await roh("/workspace/api/dateien/" + geheimBildId + "/bild", { keks: keksDogi });
ok(existiert.code === 200,
`es gibt dieses Bild wirklich (DogFather: HTTP ${existiert.code})`);
}
/* =======================================================================
1. Die Punkte: aus Fliesstext wird eine Liste
======================================================================= */
melde("\n=== 1. Die Punkte kommen als Liste vom Server ===");
{
const daten = await brett(keksDogi);
const e = (daten.eintraege || []).find((x) => x.titel === "Oktober Event");
ok(!!e, "das laufende Event ist auf dem Brett");
const p = e?.event_punkte || [];
ok(p.length === 3, `3 Aufgabenpunkte (${p.length})`);
/* DAS IST DER EIGENTLICHE MANGEL AUS FILIPES BILDSCHIRMFOTO: Im
Original stand „·Jeden Tag Live gehen" neben „· 33k Diamanten
erreichen" -- einmal ohne, einmal mit Leerzeichen. */
ok(p[0]?.text === "Jeden Tag Live gehen",
`ohne Leerzeichen getippt -> sauber: ${JSON.stringify(p[0]?.text)}`);
ok(p[2]?.text === "33k Diamanten erreichen",
`mit Leerzeichen getippt -> gleich sauber: ${JSON.stringify(p[2]?.text)}`);
ok(p.every((x) => /^[0-9a-f]{16}$/.test(x.schluessel || "")) && p.length > 0,
"jeder Punkt hat einen Schluessel (16 Hexzeichen)");
ok((e?.event_regelpunkte || []).length === 2,
`2 Regelpunkte (${(e?.event_regelpunkte || []).length})`);
ok(e?.event_geschafft === 0, `noch nichts abgehakt (${e?.event_geschafft})`);
/* GEGENPROBE: Ein Eintrag, der KEIN Event ist, bekommt keine Punkte.
Ohne sie wuesste niemand, ob die Zerlegung nicht einfach ueberall
laeuft -- und dann waere "nur Events sind betroffen" unbelegt. */
const schulung = (daten.eintraege || []).find((x) => x.art === "schulung");
ok(!!schulung, "der Nicht-Event-Eintrag ist da (sonst prueft die Gegenprobe nichts)");
ok(schulung && schulung.event_punkte === undefined,
"ein Eintrag ohne Eventfelder bekommt keine Punkteliste");
}
/* =======================================================================
2. Abhaken -- und was dabei NICHT geht
======================================================================= */
melde("\n=== 2. Abhaken: jeder fuer sich ===");
let schluesselA = null, schluesselB = null;
{
const daten = await brett(keksDogi);
const e = daten.eintraege.find((x) => x.titel === "Oktober Event");
schluesselA = e.event_punkte[1].schluessel; /* 22-Live-Stunden */
schluesselB = e.event_punkte[2].schluessel; /* 33k Diamanten */
const a = await roh(`/workspace/api/eintrag/${laufend}/punkt/${schluesselA}`, {
keks: keksLuna, methode: "PUT", rumpf: {}, kopf: { "Content-Type": "application/json" } });
ok(a.code === 200, `Luna hakt einen Punkt ab (HTTP ${a.code})`);
const j = JSON.parse(a.text || "{}");
ok(j.geschafft === 1 && j.gesamt === 3, `Antwort sagt ${j.geschafft} von ${j.gesamt} (erwartet 1 von 3)`);
/* HAELT ES AUCH? Die Zahl aus der Antwort koennte gerechnet sein,
ohne dass etwas gespeichert wurde. Deshalb ein frischer Abruf. */
const nachher = await brett(keksLuna);
const eL = nachher.eintraege.find((x) => x.titel === "Oktober Event");
ok(eL.event_geschafft === 1, `nach frischem Abruf: ${eL.event_geschafft} abgehakt`);
ok(eL.event_punkte.filter((p) => p.meiner).length === 1
&& eL.event_punkte[1].meiner === true,
"und zwar genau der angeklickte Punkt");
/* DER KERN DER ZUSAGE: DogFather sieht Lunas Haken NICHT als seinen
eigenen. Ohne diese Pruefung waere "jeder hakt fuer sich ab" eine
Behauptung. */
const beiDogi = await brett(keksDogi);
const eD = beiDogi.eintraege.find((x) => x.titel === "Oktober Event");
ok(eD.event_geschafft === 0,
`DogFather sieht Lunas Haken nicht als seinen (${eD.event_geschafft})`);
ok(eD.event_punkte.every((p) => p.meiner === false),
"kein einziger Punkt ist bei ihm angehakt");
/* ... sieht aber, DASS jemand dabei ist -- er ist die Leitung. */
ok(Array.isArray(eD.event_stand) && eD.event_stand.length === 1
&& eD.event_stand[0].name === "Luna" && eD.event_stand[0].geschafft === 1,
`die Leitung sieht den Stand: ${JSON.stringify(eD.event_stand)}`);
/* GEGENPROBE ZUR LEITUNG: Luna selbst darf diese Liste NICHT
bekommen -- sonst waere "wer macht mit" eine Auskunft fuer alle. */
ok(eL.event_stand === undefined,
"eine Creatorin bekommt die Mitmacherliste gar nicht erst");
/* Zweimal dasselbe ist kein Fehler, aendert aber auch nichts. */
const nochmal = await roh(`/workspace/api/eintrag/${laufend}/punkt/${schluesselA}`, {
keks: keksLuna, methode: "PUT", rumpf: {}, kopf: { "Content-Type": "application/json" } });
const j2 = JSON.parse(nochmal.text || "{}");
ok(nochmal.code === 200 && j2.geschafft === 1,
`zweimal derselbe Haken bleibt 1 (HTTP ${nochmal.code}, ${j2.geschafft})`);
/* Und wieder weg. */
const ab = await roh(`/workspace/api/eintrag/${laufend}/punkt/${schluesselA}`, {
keks: keksLuna, methode: "DELETE" });
const j3 = JSON.parse(ab.text || "{}");
ok(ab.code === 200 && j3.geschafft === 0, `Haken wieder weg (${j3.geschafft})`);
/* Fuer die folgenden Pruefungen wieder setzen. */
await roh(`/workspace/api/eintrag/${laufend}/punkt/${schluesselA}`, {
keks: keksLuna, methode: "PUT", rumpf: {}, kopf: { "Content-Type": "application/json" } });
}
melde("\n=== 2b. Was NICHT geht ===");
{
/* EIN ERFUNDENER SCHLUESSEL. Ohne diese Schranke koennte sich jeder
„3 von 3" setzen, ohne dass eine der drei Aufgaben je dastand --
und eine Zahl, die man sich ausdenken kann, zaehlt nichts. */
const a = await roh(`/workspace/api/eintrag/${laufend}/punkt/00112233445566ff`, {
keks: keksLuna, methode: "PUT", rumpf: {}, kopf: { "Content-Type": "application/json" } });
ok(a.code === 404, `erfundener Punkt wird abgelehnt (HTTP ${a.code})`);
/* Unsinn in der Adresse. */
const b = await roh(`/workspace/api/eintrag/${laufend}/punkt/..%2F..%2Fetc`, {
keks: keksLuna, methode: "PUT", rumpf: {}, kopf: { "Content-Type": "application/json" } });
ok(b.code === 400 || b.code === 404, `kein Schluesselformat -> abgelehnt (HTTP ${b.code})`);
/* Ohne Anmeldung gar nichts. */
const c = await roh(`/workspace/api/eintrag/${laufend}/punkt/${schluesselB}`, {
methode: "PUT", rumpf: {}, kopf: { "Content-Type": "application/json" } });
ok(c.code === 401, `ohne Anmeldung abgewiesen (HTTP ${c.code})`);
/* Fremde Herkunft (CSRF). */
const e = await new Promise((f, s) => {
const a2 = request({
host: "127.0.0.1", port: PORT,
path: `/workspace/api/eintrag/${laufend}/punkt/${schluesselB}`,
method: "PUT",
headers: { Host: HOST, Origin: "https://boeswillig.example", Cookie: keksLuna,
"Content-Type": "application/json", "Content-Length": 2 },
}, (r) => { const t = []; r.on("data", (x) => t.push(x));
r.on("end", () => f({ code: r.statusCode })); });
a2.on("error", s); a2.write("{}"); a2.end();
});
ok(e.code === 403, `fremde Herkunft abgewiesen (HTTP ${e.code})`);
}
/* =======================================================================
3. Der Schluessel ueberlebt das Umsortieren -- aber nicht das Umschreiben
======================================================================= */
melde("\n=== 3. Haken bleiben am richtigen Punkt ===");
{
/* UMSORTIEREN: Der Haken an „22-Live-Stunden" muss bleiben, wo er
ist -- auch wenn eine Zeile DAVOR eingefuegt wird. Haengt er an
einer Zeilennummer, wandert er still auf eine andere Aufgabe, und
jemand hat etwas abgehakt, was er nie gemacht hat. */
const a = await roh(`/workspace/api/bereich/agentur/${laufend}`, {
keks: keksDogi, methode: "PATCH",
rumpf: { event_aufgaben: "· GANZ NEU ganz oben\n· 33k Diamanten erreichen\n· 22-Live-Stunden erreichen\n· Jeden Tag Live gehen" },
kopf: { "Content-Type": "application/json" } });
ok(a.code === 200, `Aufgaben umsortiert und ergaenzt (HTTP ${a.code})`);
const nach = await brett(keksLuna);
const e = nach.eintraege.find((x) => x.titel === "Oktober Event");
ok((e.event_punkte || []).length === 4, `jetzt 4 Punkte (${(e.event_punkte || []).length})`);
const haken = (e.event_punkte || []).filter((p) => p.meiner);
ok(haken.length === 1 && haken[0].text === "22-Live-Stunden erreichen",
`der Haken sitzt weiterhin an: ${JSON.stringify(haken.map((h) => h.text))}`);
/* GEGENPROBE: Wird die Bedingung SELBST geaendert, muss der Haken
fallen -- eine geaenderte Bedingung ist eine andere Bedingung, und
ein Haken daran waere eine Falschauskunft. */
const b = await roh(`/workspace/api/bereich/agentur/${laufend}`, {
keks: keksDogi, methode: "PATCH",
rumpf: { event_aufgaben: "· GANZ NEU ganz oben\n· 33k Diamanten erreichen\n· 30-Live-Stunden erreichen\n· Jeden Tag Live gehen" },
kopf: { "Content-Type": "application/json" } });
ok(b.code === 200, `Bedingung umgeschrieben: 22 -> 30 Stunden (HTTP ${b.code})`);
const nach2 = await brett(keksLuna);
const e2 = nach2.eintraege.find((x) => x.titel === "Oktober Event");
ok((e2.event_punkte || []).filter((p) => p.meiner).length === 0,
"der Haken ist gefallen -- 30 Stunden sind nicht 22");
ok(e2.event_geschafft === 0, `und die Zahl stimmt dazu (${e2.event_geschafft})`);
/* Zurueckstellen fuer die Oberflaechenpruefung. */
await roh(`/workspace/api/bereich/agentur/${laufend}`, {
keks: keksDogi, methode: "PATCH",
rumpf: { event_aufgaben: "·Jeden Tag Live gehen\n·22-Live-Stunden erreichen\n· 33k Diamanten erreichen" },
kopf: { "Content-Type": "application/json" } });
}
/* =======================================================================
4. Die Oberflaeche
======================================================================= */
melde("\n=== 4. Die Karte im Browser ===");
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch();
const klagen = [];
async function alsRolle(rolle, code, breite = 1280) {
const ctx = await browser.newContext({ viewport: { width: breite, height: 1000 }, hasTouch: breite <= 860 });
await ctx.route("**/*", (route) => {
const u = new URL(route.request().url());
if (u.hostname === "127.0.0.1") {
return route.continue({ headers: { ...route.request().headers(), host: HOST } });
}
return route.continue();
});
const seite = await ctx.newPage();
seite.on("pageerror", (f) => klagen.push("pageerror: " + f.message));
seite.on("console", (m) => { if (m.type() === "error") klagen.push("console: " + m.text().slice(0, 160)); });
/* WELCHE Datei fehlt -- die Konsolenmeldung sagt nur "404 (Not
Found)". Beim ersten Lauf standen hier drei davon, und sie waren
nicht zuzuordnen; eine Pruefung, die nicht sagen kann, woran sie
scheitert, kostet eine Stunde Suchen (Projektgedaechtnis,
11.09.2026). */
seite.on("response", (r) => {
if (r.status() >= 400) klagen.push(`HTTP ${r.status()}: ${new URL(r.url()).pathname}`);
});
await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
await seite.click('.rolle[data-rolle="' + rolle + '"]');
await seite.fill("#code", code);
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 25000 });
await seite.goto(BASIS + "/workspace/bereich.html?b=agentur", { waitUntil: "networkidle" });
await seite.waitForSelector(".eintrag-karte", { timeout: 15000 });
await seite.waitForTimeout(700);
return { ctx, seite };
}
{
const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001", 1280);
const karten = await seite.$$('.eintrag-karte[data-eventkarte="ja"]');
ok(karten.length === 3, `3 Eventkarten (${karten.length}) -- die Schulung ist keine`);
/* GEGENPROBE: Die Schulung bleibt eine gewoehnliche Karte. Ohne sie
waere "nur Events sind umgebaut" unbelegt. */
const schulung = await seite.$('.eintrag-karte:not([data-eventkarte="ja"])');
ok(!!schulung, "der Nicht-Event-Eintrag ist weiterhin eine gewoehnliche Karte");
const masse = await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("Oktober Event"));
const b = k.querySelector(".ev-buehne");
const bild = k.querySelector(".ev-buehne__bild");
const t = k.querySelector(".eintrag-titel");
const kr = k.getBoundingClientRect();
const tr = t.getBoundingClientRect();
return {
karte: Math.round(kr.height),
buehne: b ? Math.round(b.getBoundingClientRect().height) : 0,
bildBreite: bild ? Math.round(bild.getBoundingClientRect().width) : 0,
buehneBreite: b ? Math.round(b.getBoundingClientRect().width) : 0,
titelGroesse: parseFloat(getComputedStyle(t).fontSize),
textGroesse: parseFloat(getComputedStyle(k.querySelector(".eintrag-text")).fontSize),
markeGroesse: parseFloat(getComputedStyle(k.querySelector(".marke-art")).fontSize),
/* Wie weit oben steht der Titel INNERHALB der Karte? Das war der
eigentliche Mangel: vorher unter 638 px Bild. */
titelInKarte: Math.round(tr.top - kr.top),
datumZeilen: [...k.querySelectorAll(".ev-spanne, .im-kalender, .event-zeitraum")]
.map((x) => x.textContent.trim()),
noch: k.querySelector(".ev-noch")?.textContent || null,
};
});
melde(" -- gemessen: " + JSON.stringify(masse));
/* DIE ZAHLEN AUS DEM BEFUND, jede einzeln. */
ok(masse.titelInKarte < 180,
`der Titel steht ${masse.titelInKarte} px unter der Kartenkante (vorher 638+, Grenze 180)`);
ok(masse.titelGroesse >= 18,
`Titel ${masse.titelGroesse} px (vorher 15,36 -- Grenze 18)`);
ok(masse.titelGroesse >= masse.textGroesse * 1.3,
`Titel deutlich groesser als der Text darunter (${masse.titelGroesse} vs ${masse.textGroesse})`);
/* Die projekteigene Grenze fuer Grossbuchstaben mit Sperrung. Sie
galt bisher nur unter 760 px Fensterbreite -- eine Grenze, die nur
bei schmalen Fenstern gilt, ist keine. */
ok(masse.markeGroesse >= 11.5,
`Etikett ${masse.markeGroesse} px (vorher 10,88 -- Grenze 11,5)`);
ok(masse.buehne <= 320 && masse.buehne > 0,
`Buehne ${masse.buehne} px hoch (vorher 638)`);
ok(masse.datumZeilen.length === 1,
`das Datum steht einmal, nicht dreimal: ${JSON.stringify(masse.datumZeilen)}`);
ok(/^noch \d+ Tage?$/.test(masse.noch || ""),
`der Countdown beantwortet "wie lange noch": ${JSON.stringify(masse.noch)}`);
/* Die drei Zustaende -- jeder einzeln nachgewiesen, nicht als Summe. */
const staende = await seite.evaluate(() => {
const raus = {};
for (const k of document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')) {
const t = k.querySelector(".eintrag-titel")?.textContent || "?";
raus[t] = { stand: k.dataset.event, wort: k.querySelector(".ev-noch")?.textContent };
}
return raus;
});
ok(staende["Oktober Event"]?.stand === "laeuft",
`laufend: ${JSON.stringify(staende["Oktober Event"])}`);
ok(staende["November Challenge"]?.stand === "kommt"
&& /^startet in \d+ Tagen$/.test(staende["November Challenge"]?.wort || ""),
`kommend: ${JSON.stringify(staende["November Challenge"])}`);
ok(staende["September Rückblick"]?.stand === "vorbei"
&& /^vorbei seit \d+ Tagen$/.test(staende["September Rückblick"]?.wort || ""),
`vorbei: ${JSON.stringify(staende["September Rückblick"])}`);
/* EIN VORBEIGELAUFENES EVENT WIRD NICHT MEHR ABGEHAKT -- ein Haken
waere eine Behauptung ueber etwas, das nicht mehr stattfindet. */
const knoepfeVorbei = await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("September"));
return {
knoepfe: k.querySelectorAll(".ev-punkt__knopf").length,
zeilen: k.querySelectorAll(".ev-punkt").length,
};
});
ok(knoepfeVorbei.zeilen > 0 && knoepfeVorbei.knoepfe === 0,
`vorbei: ${knoepfeVorbei.zeilen} Zeilen lesbar, ${knoepfeVorbei.knoepfe} Knoepfe`);
/* DER WEG ZUM GANZEN BANNER. Er war beim ersten Entwurf verloren
gegangen: Vorher war das Bild ein Verweis, als Buehne nicht mehr --
und bei Filipes Banner steht die eigentliche Ansage IM Bild. */
const ganz = await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("Oktober Event"));
const a = k.querySelector(".ev-buehne__ganz");
const t = k.querySelector(".eintrag-titel");
if (!a) return null;
const ar = a.getBoundingClientRect(), tr = t.getBoundingClientRect();
return {
ziel: a.getAttribute("href"),
neuesFenster: a.getAttribute("target") === "_blank" && /noopener/.test(a.getAttribute("rel") || ""),
wort: a.textContent.trim(),
/* Deckt er den Titel zu? Das haeufigste Problem bei einem Knopf,
der absolut in einer Ecke sitzt. */
ueberlappt: !(ar.bottom < tr.top || ar.top > tr.bottom || ar.right < tr.left || ar.left > tr.right),
hoehe: Math.round(ar.height),
};
});
ok(!!ganz && /^\/workspace\/api\/dateien\/\d+\/bild$/.test(ganz.ziel || ""),
`"Banner ganz" fuehrt zum vollen Bild: ${JSON.stringify(ganz)}`);
ok(!!ganz && ganz.neuesFenster, "und zwar in einem neuen Fenster, mit noopener");
ok(!!ganz && !ganz.ueberlappt, "er liegt nicht ueber dem Titel");
/* GEGENPROBE: Ohne Bild gibt es ihn nicht -- ein Knopf, der auf eine
Fehlerseite fuehrt, ist schlimmer als keiner. */
const ohneBild = await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("November Challenge"));
return { mitbild: k.querySelector(".ev-buehne")?.dataset.mitbild || null,
knopf: k.querySelectorAll(".ev-buehne__ganz").length };
});
ok(ohneBild.mitbild === null && ohneBild.knopf === 0,
`ohne Banner kein Knopf: ${JSON.stringify(ohneBild)}`);
/* Der Loeschknopf unterscheidet sich jetzt von den anderen. */
const gefahr = await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("Oktober Event"));
const l = k.querySelector(".schritt--gefahr");
const andere = k.querySelector(".eintrag-knoepfe .schritt:not(.schritt--gefahr)");
return l && andere
? { l: getComputedStyle(l).color, a: getComputedStyle(andere).color, text: l.textContent.trim() }
: null;
});
ok(!!gefahr && gefahr.text === "löschen" && gefahr.l !== gefahr.a,
`"löschen" sieht anders aus als die uebrigen Knoepfe: ${JSON.stringify(gefahr)}`);
await ctx.close();
}
melde("\n=== 5. Abhaken im Browser, mit Neuladen ===");
{
const { ctx, seite } = await alsRolle("creator", "CODE-CREA-0001", 1280);
/* DEN ZAEHLER DER RICHTIGEN KARTE LESEN.
Beim ersten Lauf stand hier `seite.textContent(".ev-zaehler")` --
das nimmt den ERSTEN auf der Seite, und der gehoerte zur November-
Challenge ("0 von 1"). Die Pruefung meldete daraufhin FEHL, obwohl
am Zaehler nichts falsch war. Eine Pruefung, die das Falsche misst,
kostet dieselbe Zeit wie ein echter Fehler. */
const OKT = '.eintrag-karte[data-eventkarte="ja"]:has(.eintrag-titel:text-is("Oktober Event"))';
const zaehlerLesen = async () =>
(await seite.textContent(OKT + " .ev-zaehler") || "").trim();
const vorher = await zaehlerLesen();
ok(/^\d+ von \d+$/.test(vorher), `Zaehler steht da: ${JSON.stringify(vorher)}`);
/* Luna hat aus Teil 2 noch einen Haken -- also 1 von 3. */
ok(vorher === "1 von 3", `Lunas Stand vom Server: ${vorher}`);
const knoepfe = await seite.$$(OKT + " .ev-punkt__knopf");
ok(knoepfe.length === 3, `${knoepfe.length} abhakbare Punkte an diesem Event`);
/* Den NICHT bereits abgehakten nehmen -- sonst zaehlt der Klick
herunter statt herauf, und die Pruefung misst das Gegenteil. */
await knoepfe[0].click();
await seite.waitForTimeout(600);
const danach = await zaehlerLesen();
ok(danach === "2 von 3", `nach dem Klick: ${danach}`);
/* HAELT ES EINEN SEITENWECHSEL AUS? Eine Anzeige, die nur im
Browser zaehlt, sieht genauso aus -- und ist nichts wert. */
await seite.reload({ waitUntil: "networkidle" });
await seite.waitForSelector(".ev-zaehler", { timeout: 15000 });
await seite.waitForTimeout(500);
const nachNeuladen = await zaehlerLesen();
ok(nachNeuladen === "2 von 3", `nach dem Neuladen immer noch: ${nachNeuladen}`);
/* Und wieder ab. */
const k2 = await seite.$$(OKT + " .ev-punkt__knopf");
await k2[0].click();
await seite.waitForTimeout(600);
const ab = await zaehlerLesen();
ok(ab === "1 von 3", `wieder abgewaehlt: ${ab}`);
await ctx.close();
}
melde("\n=== 6. Das Formular ===");
{
const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001", 1280);
await seite.click("#neu-oeffnen");
await seite.waitForTimeout(250);
await seite.selectOption("#f-art", "kampagne");
await seite.waitForTimeout(350);
const editoren = await seite.$$(".zeileneditor");
ok(editoren.length === 2, `zwei Zeilen-Editoren (Aufgaben, Regeln): ${editoren.length}`);
/* DIE TEXTGESTALTUNGS-LEISTE GEHOERT ZUM VERBORGENEN FELD und darf
nicht allein unter der Punkteliste schweben. */
const leisten = await seite.evaluate(() => {
const raus = [];
for (const id of ["f-aufgaben", "f-regeln"]) {
const f = document.getElementById(id);
const l = f?.parentElement?.querySelector(".textform");
raus.push({ id, feldVerborgen: !!f?.hidden, leisteVerborgen: l ? !!l.hidden : null });
}
return raus;
});
ok(leisten.length === 2 && leisten.every((x) => x.feldVerborgen && x.leisteVerborgen === true),
`Leiste ist mit ihrem Feld verborgen: ${JSON.stringify(leisten)}`);
/* Platzhalter passend zum Feld -- "22 Live-Stunden" unter REGELN
waere eine Fehlanleitung. */
const halter = await seite.evaluate(() =>
[...document.querySelectorAll(".zeileneditor")].map((r) =>
r.querySelector(".zeileneditor__feld")?.placeholder || ""));
ok(halter.length === 2 && halter[0] !== halter[1]
&& /Live-Stunden/.test(halter[0]) && /Konto/.test(halter[1]),
`je Feld ein eigenes Beispiel: ${JSON.stringify(halter)}`);
await seite.fill("#f-titel", "Testevent");
await seite.fill("#f-datum", tag(0));
await seite.fill("#f-ende", tag(13));
await seite.waitForTimeout(250);
const dauer = (await seite.textContent("#hinweis-dauer") || "").trim();
ok(dauer === "Läuft 14 Tage.", `Dauer wird mitgerechnet: ${JSON.stringify(dauer)}`);
/* GEGENPROBE: Ein Ende VOR dem Anfang muss auffallen -- sonst stuende
auf der frischen Karte "vorbei seit 3 Tagen". */
await seite.fill("#f-ende", tag(-3));
await seite.waitForTimeout(250);
const warnung = (await seite.textContent("#hinweis-dauer") || "").trim();
const warnMerkmal = await seite.getAttribute("#hinweis-dauer", "data-warnung");
ok(/vor dem Anfang/.test(warnung) && warnMerkmal === "ja",
`verdrehter Zeitraum faellt auf: ${JSON.stringify(warnung)}`);
await seite.fill("#f-ende", tag(13));
await seite.waitForTimeout(250);
/* Die Vorschau zeigt die echte Buehne. */
const vorschau = await seite.evaluate(() => {
const v = document.getElementById("ev-vorschau");
return {
sichtbar: v ? !v.hidden : false,
buehne: !!v?.querySelector(".ev-buehne"),
titel: v?.querySelector(".eintrag-titel")?.textContent || null,
noch: v?.querySelector(".ev-noch")?.textContent || null,
};
});
ok(vorschau.sichtbar && vorschau.buehne && vorschau.titel === "Testevent"
&& /^noch \d+ Tage?$/.test(vorschau.noch || ""),
`Vorschau zeigt die echte Buehne: ${JSON.stringify(vorschau)}`);
/* DER KERN DES FORMULAR-UMBAUS: Was am Ende gespeichert wird, ist
gleichmaessig -- egal wie jemand tippt. */
const felder = await seite.$$(".zeileneditor__feld");
await felder[0].fill("Jeden Tag Live gehen");
await seite.click(".zeileneditor__mehr");
const f2 = await seite.$$(".zeileneditor__feld");
await f2[1].fill("22 Live-Stunden erreichen");
await seite.waitForTimeout(250);
const roh2 = await seite.inputValue("#f-aufgaben");
ok(roh2 === "· Jeden Tag Live gehen\n· 22 Live-Stunden erreichen",
`gleichmaessig gesetzte Zeichen: ${JSON.stringify(roh2)}`);
/* Und das Ganze wirklich abschicken -- bis auf die Karte. */
await seite.click("#neu-speichern");
await seite.waitForTimeout(1200);
/* IM BRETT SUCHEN, NICHT IM FORMULAR.
Beim ersten Lauf stand hier ein Suchlauf ueber ALLE Karten der
Seite -- und die VORSCHAU im Formular ist ebenfalls eine Karte mit
demselben Merkmal und demselben Titel. Gefunden wurde sie zuerst,
und sie hat naturgemaess keine Aufgabenliste. Die Pruefung meldete
einen Fehler, den es nicht gab. */
const neu = await seite.evaluate(() => {
const liste = document.getElementById("liste");
const k = [...liste.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("Testevent"));
return k ? {
punkte: [...k.querySelectorAll(".ev-block--aufgaben .ev-punkt__text")].map((x) => x.textContent),
zaehler: k.querySelector(".ev-zaehler")?.textContent || null,
} : null;
});
ok(!!neu && neu.punkte.length === 2
&& neu.punkte[0] === "Jeden Tag Live gehen"
&& neu.punkte[1] === "22 Live-Stunden erreichen",
`aus dem Editor wurde die Liste auf der Karte: ${JSON.stringify(neu)}`);
ok(neu?.zaehler === "0 von 2", `und der Zaehler steht auf null: ${neu?.zaehler}`);
/* NACH DEM SPEICHERN IST DIE VORSCHAU LEER. Sonst stuende beim
naechsten "Neuer Eintrag" das vorige Event darin -- und man
glaubte, es sei nicht gespeichert worden. */
const nachher = await seite.evaluate(() => {
const v = document.getElementById("ev-vorschau");
return { verborgen: !!v?.hidden, inhalt: (v?.textContent || "").trim().length };
});
ok(nachher.verborgen && nachher.inhalt === 0,
`die Vorschau ist zurueckgesetzt: ${JSON.stringify(nachher)}`);
await ctx.close();
}
melde("\n=== 7. Auf dem Handy (412 px) ===");
{
const { ctx, seite } = await alsRolle("creator", "CODE-CREA-0001", 412);
const handy = await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("Oktober Event"));
const knopf = k.querySelector(".ev-punkt__knopf");
const b = k.querySelector(".ev-buehne");
return {
buehne: Math.round(b.getBoundingClientRect().height),
knopfHoehe: knopf ? Math.round(knopf.getBoundingClientRect().height) : 0,
/* Laeuft etwas ueber den Rand? Das haeufigste Handy-Problem. */
ueberlauf: Math.round(k.scrollWidth - k.clientWidth),
bloeckeSpalten: getComputedStyle(k.querySelector(".ev-bloecke")).gridTemplateColumns,
ganzHoehe: (() => { const a = k.querySelector(".ev-buehne__ganz");
return a ? Math.round(a.getBoundingClientRect().height) : null; })(),
ganzBreite: (() => { const a = k.querySelector(".ev-buehne__ganz");
return a ? Math.round(a.getBoundingClientRect().width) : null; })(),
};
});
melde(" -- gemessen: " + JSON.stringify(handy));
ok(handy.knopfHoehe >= 44,
`das Beruehrziel ist ${handy.knopfHoehe} px hoch (Grenze 44)`);
/* UND WENN DOCH, DANN WELCHES ELEMENT? Eine Zahl allein schickt
einen auf die Suche; der Schuldige steht im DOM und wird nur nicht
genannt. (Projektgedaechtnis: eine Pruefung, die nicht sagen kann,
woran sie scheitert, kostet eine Stunde.) */
if (handy.ueberlauf > 1) {
const taeter = await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("Oktober Event"));
const kr = k.getBoundingClientRect();
return [...k.querySelectorAll("*")]
.map((e) => ({ was: e.className || e.tagName,
rechts: Math.round(e.getBoundingClientRect().right - kr.right) }))
.filter((x) => x.rechts > 0)
.sort((a, b) => b.rechts - a.rechts).slice(0, 5);
});
melde(" -- was ueberlaeuft: " + JSON.stringify(taeter)
+ (taeter.length === 0
? " (KEIN Element -- dann ist es ein ::before/::after, das"
+ " querySelectorAll nicht findet. Beim ersten Mal war es"
+ " .ev-buehne__inhalt::before mit festem -20px gegen 14px Polsterung.)"
: ""));
}
ok(handy.ueberlauf <= 1, `nichts laeuft ueber den Rand (${handy.ueberlauf} px)`);
ok(handy.bloeckeSpalten.split(" ").length === 1,
`auf 412 px eine Spalte, nicht zwei: ${handy.bloeckeSpalten}`);
ok(handy.buehne <= 240 && handy.buehne > 0, `Buehne ${handy.buehne} px`);
/* Auf dem Handy schrumpft "Banner ganz" auf die Lupe -- das Ziel
darf dabei nicht unter 32 px fallen. (32 statt 44: Er sitzt frei
in einer Ecke ohne Nachbarn, und sein Wort bleibt fuer
Vorleseprogramme erhalten.) */
ok(handy.ganzHoehe !== null && handy.ganzHoehe >= 32 && handy.ganzBreite >= 32,
`der Banner-Knopf bleibt greifbar: ${handy.ganzBreite} x ${handy.ganzHoehe} px`);
await ctx.close();
}
melde("\n=== 8. Das andere Haus bleibt unberuehrt ===");
{
/* DIE REGEL VOM 24.09.2026: "Ab sofort wird JEDER Umbau getrennt
gefuehrt. Eine Aenderung an einem der beiden Haeuser darf im
anderen nichts bewirken ... Wer etwas baut, sagt vorher, fuer
WELCHES Haus es ist, und weist danach nach, dass das andere
unveraendert ist."
DAS IST HIER NICHT SELBSTVERSTAENDLICH: `bereich.css` wird von
JEDEM Brett geladen, auch von denen der Crew, und `tuKnopf` ist
die Knopffabrik aller Bretter. Beides wurde angefasst. Dass
trotzdem nur Agentur-Events betroffen sind, haengt daran, dass
jede neue Regel an `[data-eventkarte="ja"]` oder einer `ev-`
Klasse haengt -- und dieses Merkmal setzt nur istEventArt().
Eine Behauptung, solange sie niemand nachmisst. */
const ctx = await browser.newContext({ viewport: { width: 1280, height: 1000 } });
await ctx.route("**/*", (route) => {
const u = new URL(route.request().url());
if (u.hostname === "127.0.0.1") {
return route.continue({ headers: { ...route.request().headers(), host: "crew.dogfather-universe.com" } });
}
return route.continue();
});
const seite = await ctx.newPage();
seite.on("pageerror", (f) => klagen.push("crew pageerror: " + f.message));
await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
await seite.click('.rolle[data-rolle="admin"]');
await seite.fill("#code", "CODE-DOGI-0001");
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 25000 });
/* Das Brett der Agentur gibt es auf dieser Adresse gar nicht. */
await seite.goto(BASIS + "/workspace/bereich.html?b=agentur", { waitUntil: "networkidle" });
await seite.waitForTimeout(900);
const dort = await seite.evaluate(() => ({
adresse: location.pathname,
eventkarten: document.querySelectorAll('[data-eventkarte="ja"]').length,
evTeile: document.querySelectorAll('[class*="ev-buehne"], [class*="ev-punkt"], .zeileneditor').length,
}));
/* ==== EIN BEFUND, DER NICHT AUS DIESEM UMBAU STAMMT ===============
Hier stand zuerst die Zusage "auf der Crew-Adresse nichts davon" --
und sie wurde rot: Das Agentur-Brett ist dort erreichbar, mit
Inhalt. Vor dem Schreiben einer Reparatur erst nachgemessen, ob
ich das verursacht habe (03.10.2026, server/_mess/haus.mjs, einmal
mit und einmal ohne den Umbau):
Host crew.dogfather-universe.com haus=crew
/workspace/api/bereich/agentur -> 200, 1 Eintrag
ZWEIMAL DASSELBE, mit und ohne. Der Durchgriff ist also aelter als
dieser Umbau und gehoert zur Regel vom 24.09.2026 ("Kein Bestand
wird geteilt"), nicht hierher -- er ist Filipe gemeldet und wird
getrennt entschieden. Ihn hier nebenbei zuzumauern hiesse, am
Rechtesystem beider Haeuser zu drehen, waehrend eigentlich eine
Karte umgebaut wird.
WAS DIESE DATEI DESHALB ZUSAGT, ist das, wofuer sie zustaendig
ist: Der Umbau aendert an der Crew-Adresse NICHTS -- die Karten
sehen dort aus wie auf der Agenturadresse, und die eigenen Bretter
der Crew bleiben unberuehrt (gleich darunter).
Die Zahl wird trotzdem ausgegeben: Faellt sie eines Tages auf 0,
ist der Durchgriff behoben, und das soll man hier sehen. */
melde(" -- Crew-Adresse, Brett `agentur`: " + JSON.stringify(dort)
+ (dort.eventkarten > 0
? " (vorbestehender Durchgriff, siehe Kommentar -- nicht aus diesem Umbau)"
: " (der Durchgriff ist behoben)"));
/* Wenn das Brett dort erscheint, dann wenigstens vollstaendig und
ohne Fehler -- eine halb gebaute Karte waere ein echter Schaden. */
ok(dort.eventkarten === 0 || dort.evTeile > 0,
`was dort erscheint, ist vollstaendig gebaut (${dort.eventkarten} Karten, ${dort.evTeile} Teile)`);
/* Und ein Brett, das es dort WIRKLICH gibt, sieht aus wie immer.
Ohne diese Haelfte waere oben nur bewiesen, dass die Seite leer
ist -- das waere sie auch bei einem kaputten Brett. */
await seite.goto(BASIS + "/workspace/bereich.html?b=treff", { waitUntil: "networkidle" });
await seite.waitForTimeout(900);
const treff = await seite.evaluate(() => ({
liste: !!document.getElementById("liste"),
formular: !!document.getElementById("neu"),
knoepfe: document.querySelectorAll(".schritt").length,
gefahr: document.querySelectorAll(".schritt--gefahr").length,
evTeile: document.querySelectorAll('[class*="ev-buehne"], [class*="ev-punkt"], .zeileneditor').length,
}));
ok(treff.liste && treff.formular,
`ein Crew-Brett steht und funktioniert: ${JSON.stringify(treff)}`);
ok(treff.evTeile === 0, `und zeigt keinen Teil des Event-Umbaus (${treff.evTeile})`);
/* Auch der Loeschknopf bleibt dort der alte: Die Markierung wird
zwar ueberall gesetzt, aber gestaltet wird sie nur innerhalb einer
Eventkarte. */
const farben = await seite.evaluate(() => {
const g = document.querySelector(".schritt--gefahr");
const n = document.querySelector(".schritt:not(.schritt--gefahr)");
return g && n ? { gefahr: getComputedStyle(g).color, normal: getComputedStyle(n).color } : null;
});
ok(farben === null || farben.gefahr === farben.normal,
`der Loeschknopf sieht dort aus wie immer: ${JSON.stringify(farben)}`);
await ctx.close();
}
await browser.close();
melde("\n=== Browser-Meldungen ===");
ok(klagen.length === 0, klagen.length ? "Klagen: " + klagen.join(" | ") : "keine Fehler in der Konsole");
melde("");
melde(`${geprueft} geprueft, ${fehler} Fehler`);
if (!geprueft) { melde("ABBRUCH: nichts geprueft -- das ist ein Fehler, kein Erfolg."); process.exit(3); }
process.exit(fehler ? 1 : 0);
+294 -1
View File
@@ -20,7 +20,7 @@ import express from "express";
Fassung, und an ihr haengt die Sicherheit aller Uploads im Haus. */
import { writeFileSync, mkdirSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { randomBytes } from "node:crypto";
import { randomBytes, createHash } from "node:crypto";
import { dateiErkennen } from "./workspace-chat.js";
import { TREFF_START, VORSCHLAG_HINWEIS } from "./workspace-treff-start.js";
import { vorlagenFuer } from "./workspace-treff-vorlagen.js";
@@ -752,6 +752,89 @@ const MIT_ANTWORTEN = new Set(["treff", "wunsch"]);
const TITEL_MAX = 160;
const TEXT_MAX = 6000;
/* =====================================================================
DIE PUNKTE EINES AGENTUR-EVENTS (03.10.2026)
`event_aufgaben` und `event_regeln` sind Fliesstext, den DogFather
tippt -- eine Zeile je Punkt, meistens mit einem Aufzaehlungszeichen
davor. Dass er von Hand gesetzt wird, sieht man dem Ergebnis an: Im
echten Oktober-Event stand „·Jeden Tag Live gehen" neben
„· 33k Diamanten erreichen", einmal mit Leerzeichen, einmal ohne.
HIER WIRD DARAUS EINE LISTE. Die Oberflaeche bekommt Punkte, keinen
Absatz -- damit sie Haken daran haengen kann und die Einrueckung
nicht mehr davon abhaengt, wie jemand getippt hat.
DER SPEICHERTEXT BLEIBT UNVERAENDERT. Zerlegt wird beim LESEN, nicht
beim Schreiben: Wer das Event spaeter bearbeitet, soll genau das
wiederfinden, was er geschrieben hat. Und ein Textfeld, das beim
Speichern umformatiert wird, ist ein Textfeld, dem man nicht traut.
===================================================================== */
/* Die Zeichen, mit denen Menschen Aufzaehlungen anfangen. Sie gehoeren
nicht zum Inhalt des Punktes -- und wer heute „·" tippt und morgen
„-", meint denselben Punkt. */
const PUNKT_ZEICHEN = /^[\s]*[-–—•·*▪◦‣>+]+[\s]*/u;
/** Der Kern einer Aufgabenzeile, ohne Zierde.
* Nur fuer den Vergleich gedacht -- angezeigt wird der Originaltext. */
function punktKern(zeile) {
return String(zeile || "")
.replace(PUNKT_ZEICHEN, "")
/* Eine fuehrende Nummerierung („1.", „2)") ebenfalls weg: Wer
einen Punkt davorsetzt, verschiebt sonst alle Nummern und damit
alle Schluessel -- genau der stille Haken-Umzug, den die Tabelle
verhindern soll. */
.replace(/^\s*\d+[.)]\s*/, "")
.replace(/\s+/gu, " ")
.trim()
.toLowerCase();
}
/** Der Schluessel, unter dem ein Haken gespeichert wird.
*
* AUS DEM TEXT, NICHT AUS DER ZEILENNUMMER -- die Begruendung steht
* bei der Tabelle `event_punkte` in workspace.js. Hier nur das Wie:
* Kern bilden, davon die ersten 16 Zeichen des SHA-256. 16 Zeichen
* sind 64 Bit; bei hoechstens ein paar Dutzend Punkten je Event ist
* eine Verwechslung ausgeschlossen, und der Schluessel bleibt kurz
* genug, um in einer Adresse zu stehen. */
export function punktSchluessel(zeile) {
return createHash("sha256").update(punktKern(zeile), "utf8")
.digest("hex").slice(0, 16);
}
/** Zerlegt ein Textfeld in Punkte.
*
* LEERE ZEILEN FALLEN WEG, aber nicht die Absicht dahinter: Wer eine
* Leerzeile zwischen zwei Bloecke setzt, trennt -- und das ergibt in
* einer Liste ohnehin keinen eigenen Punkt.
*
* GLEICHE PUNKTE NUR EINMAL: Stuende „22 Live-Stunden" zweimal da,
* haetten beide denselben Schluessel. Ein Haken wuerde dann an zwei
* Stellen gleichzeitig erscheinen und die Zaehlung „2 von 3" waere
* falsch. Der zweite faellt deshalb weg -- zusammen mit der Zeile,
* denn eine doppelte Bedingung ist ohnehin ein Tippfehler. */
export function punkteAusText(text) {
const raus = [];
const gesehen = new Set();
for (const zeile of String(text || "").split(/\r?\n/)) {
const kern = punktKern(zeile);
if (!kern) continue;
const schluessel = punktSchluessel(zeile);
if (gesehen.has(schluessel)) continue;
gesehen.add(schluessel);
raus.push({
schluessel,
/* Angezeigt wird der Text OHNE das getippte Aufzaehlungszeichen:
Die Liste setzt ihr eigenes, und zwei davon nebeneinander
sehen nach einem Fehler aus. */
text: String(zeile).replace(PUNKT_ZEICHEN, "").trim(),
});
}
return raus;
}
const jetzt = () => new Date().toISOString();
function angemeldet(req, res, next) {
@@ -1525,6 +1608,73 @@ bereicheRouter.get("/workspace/api/bereich/:bereich", (req, res) => {
}
for (const e of eintraege) e.antworten = jeE.get(e.id) || [];
}
/* ==== DIE PUNKTE EINES EVENTS (03.10.2026) ====================
Aus dem Fliesstext in event_aufgaben/event_regeln wird eine
Liste, und an die Aufgaben kommen die Haken der eigenen
Person. Zerlegt wird HIER und nicht im Browser: Der Schluessel
eines Punktes entscheidet, wo ein Haken landet -- eine zweite
Rechenvorschrift dafuer waere die, die beim naechsten Umbau
auslaeuft, und zwar still.
IN EINER ABFRAGE FUER DIE GANZE LISTE, wie bei den Bildern und
den Antworten darueber. Ein Event hat ein Dutzend Punkte und
ein Brett ein Dutzend Events; einzeln gefragt waere das bei
jedem Seitenaufbau ein Schwall kleiner Abfragen.
NUR DORT, WO ES EVENTS GIBT. Auf den uebrigen Brettern sind
diese Felder leer, und eine Abfrage mit leerer Liste ist eine
Abfrage ohne Zweck. */
const events = eintraege.filter((e) => e.event_aufgaben || e.event_regeln);
if (events.length) {
const ichId = (req.sicht || req.person)?.id;
const p5 = events.map(() => "?").join(", ");
/* Meine eigenen Haken ... */
const meine = new Map();
for (const z of db().prepare(
`SELECT eintrag_id, punkt FROM event_punkte
WHERE eintrag_id IN (${p5}) AND person_id = ?`)
.all(...events.map((e) => e.id), ichId)) {
if (!meine.has(z.eintrag_id)) meine.set(z.eintrag_id, new Set());
meine.get(z.eintrag_id).add(z.punkt);
}
/* ... und fuer die Leitung: wer macht mit, und wie weit?
NUR FUER DIE LEITUNG. „Wer hat was geschafft" ist eine
Auskunft ueber andere Personen; die gehoert nicht an jede
Karte, die irgendwer aufmacht. Wer nicht zur Leitung gehoert,
bekommt das Feld gar nicht erst -- nicht leer, sondern
ueberhaupt nicht: Ein leeres Feld laedt dazu ein, es spaeter
„versehentlich" zu fuellen. */
const stand = new Map();
if (istLeitung(req.person)) {
for (const z of db().prepare(
`SELECT ep.eintrag_id, ep.person_id, p.name, COUNT(*) AS n
FROM event_punkte ep
LEFT JOIN personen p ON p.id = ep.person_id
WHERE ep.eintrag_id IN (${p5})
GROUP BY ep.eintrag_id, ep.person_id
ORDER BY n DESC, p.name`)
.all(...events.map((e) => e.id))) {
if (!stand.has(z.eintrag_id)) stand.set(z.eintrag_id, []);
stand.get(z.eintrag_id).push({ id: z.person_id, name: z.name, geschafft: z.n });
}
}
for (const e of events) {
const haken = meine.get(e.id) || new Set();
e.event_punkte = punkteAusText(e.event_aufgaben)
.map((p) => ({ ...p, meiner: haken.has(p.schluessel) }));
e.event_regelpunkte = punkteAusText(e.event_regeln);
/* DIE ZAHL KOMMT MIT, statt im Browser gezaehlt zu werden --
sie steht sonst an zwei Stellen, und „2 von 3" ist genau
die Sorte Angabe, die man nicht zweimal haben will. */
e.event_geschafft = e.event_punkte.filter((p) => p.meiner).length;
if (stand.size) e.event_stand = stand.get(e.id) || [];
}
}
}
/* DASS DIES EIN BRETT DES TREFFS IST, SAGT DER SERVER (11.09.2026).
@@ -2555,6 +2705,41 @@ bereicheRouter.post("/workspace/api/bereich/:bereich", gleicheHerkunft, (req, re
/* ---------- Ändern und Löschen ------------------------------------------- */
/** Darf diese Person diesen Eintrag sehen?
*
* ENTSTANDEN AM 03.10.2026, weil das Banner eines Agentur-Events fuer
* Creator mit 404 beantwortet wurde -- gemessen, nicht vermutet:
*
* workspace.dogfather-universe.com admin Bild=200
* workspace.dogfather-universe.com creator Bild=404
*
* Der Bildweg (workspace-dateien.js) kannte bisher zwei Regeln: die
* Treff-Regel ("wer das Brett sieht, sieht das Bild" -- aber nur fuer
* die sieben Community-Bretter) und sonst die ABLAGE-Regel, die an
* Creator-Zuordnungen haengt. Ein Agentur-Event ist weder das eine
* noch das andere: Es gehoert niemandem, und `agentur` ist kein
* Treff-Brett. Also fiel es durch beide.
*
* Vor dem Umbau fiel das kaum auf -- das Bild war ein Anhang unter
* dem Text. Seit es die Buehne hinter dem Titel ist, saehe eine
* Creatorin eine leere Flaeche an genau der Karte, die fuer sie
* gemacht ist.
*
* WARUM EINE FUNKTION UND KEINE ZWEITE LISTE: Der naheliegende Weg
* waere gewesen, in workspace-dateien.js `|| e.bereich === "agentur"`
* zu ergaenzen. Das waere eine zweite Stelle, an der steht, wer was
* sehen darf -- und die laeuft beim naechsten Brett aus. Hier wird
* stattdessen dieselbe Regel gefragt, die auch das Brett selbst
* benutzt (sichtbarEintrag). Was dort verborgen ist, bleibt es auch
* hier; es gibt keine Rechteausweitung, nur keine Luecke mehr. */
export function darfEintragSehen(person, eintragId) {
if (!person || !Number.isInteger(Number(eintragId))) return false;
const regel = sichtbarEintrag(person);
if (!regel) return false;
return !!db().prepare(`SELECT e.id ${VERBUND} WHERE ${regel.wo} AND e.id = ?`)
.get(...regel.werte, Number(eintragId));
}
function holen(req, id) {
const regel = sichtbarEintrag(req.person);
if (!regel) return null;
@@ -2674,6 +2859,114 @@ function darfBildAnhaengen(req, res, next) {
next();
}
/* =====================================================================
EINEN PUNKT ABHAKEN (03.10.2026)
Filipe: „ich will das viel geiler viel perfekter. und auch wenn man
die eintraegt und so soll viel mehr perfektioniert und
uebersichtlicher sein."
Ein Agentur-Event stellt Bedingungen -- „jeden Tag live",
„22 Live-Stunden", „33k Diamanten". Wer mitmacht, will sehen, wo er
steht, ohne nachzufragen. Genau das ist der Unterschied zwischen
einem Aushang und einer Aktion, an der jemand teilnimmt.
JEDER HAKT NUR FUER SICH SELBST. Es gibt bewusst keinen Weg, einen
Haken bei einer ANDEREN Person zu setzen -- auch nicht fuer die
Leitung. „Ich habe meine 22 Stunden" ist eine Aussage ueber sich,
und wer sie fuer andere treffen koennte, macht aus einer Selbstaus-
kunft eine Bewertung. Die Person steht deshalb nirgends in der
Adresse; sie kommt aus der Sitzung.
KEIN KOERPER BEIM DELETE (gemessen 11.09.2026, steht so schon in
bereich.js): Nodes HTTP-Parser weist ein DELETE mit Koerper mit
einem leeren 400 ab, bevor Express die Anfrage sieht.
===================================================================== */
function punktZugang(req, res, next) {
const id = Number(req.params.id);
const schluessel = String(req.params.punkt || "");
/* Der Schluessel ist 16 Hexzeichen -- was anders aussieht, kommt
nicht aus unserer Liste und hat in der Tabelle nichts zu suchen.
Ohne diese Pruefung koennte jemand beliebigen Text ablegen und die
Tabelle als Notizbuch missbrauchen. */
if (!Number.isInteger(id) || !/^[0-9a-f]{16}$/.test(schluessel)) {
return res.status(400).json({ fehler: "ungueltig" });
}
const eintrag = holen(req, id);
if (!eintrag) return res.status(404).json({ fehler: "nicht_gefunden" });
/* GEHOERT DER PUNKT UEBERHAUPT ZU DIESEM EVENT?
Das ist der eigentliche Grund fuer diesen Waechter. Ohne ihn
koennte man einen beliebigen gueltig aussehenden Schluessel
setzen; die Zahl „3 von 3" waere danach erreichbar, ohne dass eine
der drei Aufgaben je dastand. Eine Zaehlung, die man sich selbst
ausdenken kann, zaehlt nichts.
Gefragt wird die LISTE, nicht der Text: punkteAusText ist die eine
Stelle, die entscheidet, was ein Punkt ist. */
const punkte = punkteAusText(eintrag.event_aufgaben);
if (!punkte.some((p) => p.schluessel === schluessel)) {
return res.status(404).json({ fehler: "punkt_unbekannt" });
}
req.eventEintrag = eintrag;
req.eventPunkt = schluessel;
next();
}
bereicheRouter.put("/workspace/api/eintrag/:id(\\d+)/punkt/:punkt",
angemeldet, gleicheHerkunft, punktZugang, (req, res) => {
try {
/* ON CONFLICT DO NOTHING und kein Fehler beim zweiten Mal: Zwei
Klicks kurz hintereinander (oder ein zweites Geraet) sind kein
Versehen, das man melden muesste -- das Ergebnis ist in beiden
Faellen „der Haken ist gesetzt". */
db().prepare(
"INSERT INTO event_punkte (eintrag_id, person_id, punkt, gesetzt)"
+ " VALUES (?,?,?,?) ON CONFLICT DO NOTHING")
.run(req.eventEintrag.id, req.person.id, req.eventPunkt, jetzt());
res.json(punktStand(req.eventEintrag, req.person.id));
} catch (fehler) {
console.error("[bereich] Punkt setzen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
bereicheRouter.delete("/workspace/api/eintrag/:id(\\d+)/punkt/:punkt",
angemeldet, gleicheHerkunft, punktZugang, (req, res) => {
try {
db().prepare(
"DELETE FROM event_punkte WHERE eintrag_id = ? AND person_id = ? AND punkt = ?")
.run(req.eventEintrag.id, req.person.id, req.eventPunkt);
res.json(punktStand(req.eventEintrag, req.person.id));
} catch (fehler) {
console.error("[bereich] Punkt loeschen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Was nach einem Haken zurueckgeht: der gueltige Stand, vom Server.
*
* NICHT „ok". Der Browser rechnet sonst selbst weiter („war 2, jetzt
* 3") -- und liegt daneben, sobald zwei Fenster offen sind oder eine
* Aufgabe inzwischen umgeschrieben wurde. Die Zahl, die auf der Karte
* steht, kommt immer von hier. */
function punktStand(eintrag, personId) {
const punkte = punkteAusText(eintrag.event_aufgaben);
const meine = new Set(db().prepare(
"SELECT punkt FROM event_punkte WHERE eintrag_id = ? AND person_id = ?")
.all(eintrag.id, personId).map((z) => z.punkt));
return {
gesamt: punkte.length,
/* Nur zaehlen, was AUCH in der Liste steht: Wird eine Aufgabe
umgeschrieben, bleibt ihr alter Haken als Zeile liegen (das ist
richtig, siehe Tabelle) -- mitzaehlen darf er nicht. */
geschafft: punkte.filter((p) => meine.has(p.schluessel)).length,
punkte: punkte.map((p) => ({ ...p, meiner: meine.has(p.schluessel) })),
};
}
bereicheRouter.post("/workspace/api/bereich/:bereich/:id(\\d+)/bild",
gleicheHerkunft,
darfBildAnhaengen,
+26
View File
@@ -36,6 +36,9 @@ import {
ohneTeamDogi, ohneAgentur, siehtModis,
TREFF_BRETTER, TREFF_ROLLEN,
} from "./workspace.js";
/* Die eine Stelle, die beantwortet, wer welchen Eintrag sehen darf.
Kein Zirkel: workspace-bereiche.js kennt diese Datei nicht. */
import { darfEintragSehen } from "./workspace-bereiche.js";
export const dateienRouter = express.Router();
@@ -658,6 +661,29 @@ dateienRouter.get("/workspace/api/dateien/:id/bild", (req, res) => {
const e = db().prepare("SELECT bereich FROM eintraege WHERE id = ?").get(datei.eintrag_id);
erlaubt = !!e && TREFF_BRETTER.includes(e.bereich)
&& TREFF_ROLLEN.has((req.sicht || req.person)?.rolle);
/* ==== UND SONST: WER DEN EINTRAG SIEHT, SIEHT SEIN BILD =======
(03.10.2026, gemessen am Banner eines Agentur-Events.)
Die Zeile darueber deckt die sieben Community-Bretter ab. Ein
Agentur-Event ist keines davon -- und es gehoert auch
niemandem, also griff danach auch die Ablage-Regel nicht, die
an Creator-Zuordnungen haengt. Ergebnis, nachgemessen:
admin -> Bild 200
creator -> Bild 404
Also genau an der Karte, die fuer Creator gemacht ist, eine
leere Buehne. Vor dem Umbau war das Bild ein Anhang unter dem
Text und fiel kaum auf.
GEFRAGT WIRD DIESELBE REGEL WIE AUF DEM BRETT
(darfEintragSehen -> sichtbarEintrag). Keine zweite Liste von
Brettnamen hier: Was dort verborgen ist, bleibt auch hier
verborgen, und beim naechsten neuen Brett muss niemand daran
denken. Die Gegenprobe dazu steht in pruef-eventkarte.mjs --
ein Eintrag, den die Person NICHT sehen darf, liefert
weiterhin 404. */
if (!erlaubt) erlaubt = darfEintragSehen(req.sicht || req.person, datei.eintrag_id);
}
if (!erlaubt) {
const { wo, werte } = sichtbar(req.person);
+51
View File
@@ -5940,6 +5940,57 @@ export function db() {
geaendert TEXT
);
CREATE INDEX IF NOT EXISTS idx_eintraege_bereich ON eintraege (bereich, creator_id);
/* =================================================================
WER HAT WELCHEN PUNKT EINES EVENTS GESCHAFFT? (03.10.2026)
Filipe: „ich will das viel geiler viel perfekter."
Ein Agentur-Event ist ein Wettbewerb mit Bedingungen -- „jeden
Tag live", „22 Live-Stunden", „33k Diamanten". Bis heute stand
das als Fliesstext auf der Karte, und jede Creatorin musste
sich selbst merken, wo sie steht. Genau daran stirbt eine
Aktion: nicht am Aufruf, sondern daran, dass niemand seinen
eigenen Stand sieht.
EINE ZEILE IST EIN HAKEN -- eine Person, ein Punkt. Kein Feld
am Eintrag mit einer Liste darin: „jeder fuer sich" heisst,
dass zwanzig Personen am SELBEN Event verschiedene Haken
haben. Dieselbe Ueberlegung steht schon bei termin_wecker.
WARUM EIN SCHLUESSEL AUS DEM TEXT UND KEINE ZEILENNUMMER:
Die Aufgaben stehen als Text in der Spalte
event_aufgaben;
DogFather kann sie jederzeit umschreiben. Haengt der Haken an
„Zeile 2", wandert er beim Einfuegen einer Zeile darueber
stillschweigend auf eine andere Aufgabe -- und jemand hat
plotzlich etwas abgehakt, was er nie gemacht hat. Das ist der
schlimmere Ausgang, weil es niemandem auffaellt.
Haengt er dagegen am TEXT der Zeile, gilt: Zeilen umsortieren
oder daruebersetzen aendert nichts; wird die Aufgabe selbst
umgeschrieben, faellt ihr Haken weg. Letzteres ist richtig --
eine geaenderte Bedingung ist eine andere Bedingung, und ein
Haken daran waere eine Falschauskunft.
Der Schluessel wird in workspace-bereiche.js gebildet
(punktSchluessel); hier steht er nur als Text. Eine zweite
Rechenvorschrift in der Datenbank waere die, die auslaeuft.
ON DELETE CASCADE an beiden Seiten: Ein Haken ohne Event ist
nichts, und einer ohne Person auch nicht. */
CREATE TABLE IF NOT EXISTS event_punkte (
eintrag_id INTEGER NOT NULL REFERENCES eintraege(id) ON DELETE CASCADE,
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
punkt TEXT NOT NULL,
gesetzt TEXT NOT NULL,
PRIMARY KEY (eintrag_id, person_id, punkt)
);
/* Fuer „wie weit ist mein Team?" -- die Abfrage geht ueber das
Event, nicht ueber die Person. */
CREATE INDEX IF NOT EXISTS idx_event_punkte_eintrag
ON event_punkte (eintrag_id);
/* Die Tabellen des Rudels folgen weiter unten, gleich nachdem
dieser Bauplan durch ist -- sie haengen an eintraege und
personen und koennen deshalb nicht davor stehen. Ihr SQL steht