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
+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,