Files
dogfather-universe/workspace/assets/js/bereich.js
T
DogFatherGitandClaude Opus 5 3e1e0810ba 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]>
2026-10-03 19:02:05 +02:00

4028 lines
179 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ===================================================================
Eine Ansicht für fünf Bereiche (LIVE, Content, Technik, Community,
Schutz). Welcher gemeint ist, steht in der Adresse: ?b=live
Welche Arten es gibt und ob Bewertung oder Dringlichkeit dazugehören,
sagt der Server in der Antwort -- diese Datei kennt die Bereiche
nicht auswendig. Ein neuer Bereich braucht hier also keine Zeile.
=================================================================== */
(() => {
'use strict';
/* DogFather und Manager haben dieselben Rechte -- die Unterscheidung
macht nur die Personenverwaltung, und die prueft der Server. */
/* Die Leitungsliste steht in bereiche.js -- eine Antwort statt neun.
Sie stand hier frueher selbst, ohne 'spicy', und genau das war der
Fehler, den Filipe am 07.09.2026 gefunden hat. */
const LEITUNG = window.Bereiche.LEITUNG;
/* Wer hier eintraegt: DogFather, Manager und Scout. Der Creator liest
nur mit -- diese Bereiche sind die Betreuungsakte, nicht sein
Notizbuch. Der Server setzt das ebenfalls durch (siehe
workspace-bereiche.js); hier geht es nur darum, ihm keine Knoepfe
hinzustellen, die ohnehin nur eine Absage bringen. */
/* WER HIER ETWAS EINTRAGEN DARF.
Bis zum 11.09.2026 stand hier nur "alle außer dem Creator". Das
stimmte, solange jedes Brett jedem Angemeldeten offenstand. Mit den
sieben Brettern des Treffs stimmt es nicht mehr: Auf dem
Anschlagbrett und bei "Was ansteht" schreibt nur das Team, und wer
neu ist, darf die erste Woche überhaupt nicht schreiben.
OHNE DIESE ZEILE HÄTTE EIN MITGLIED DEN KNOPF GESEHEN und beim
Drücken eine Absage bekommen -- genau die Sorte Knopf, die in
diesem Haus als Fehler gilt: Er verspricht etwas, das der Server
ablehnt, und der Mensch davor hält sich selbst für den Fehler.
DIE ANTWORT KOMMT VOM SERVER (/api/treff/lage), nicht aus einer
zweiten Regel hier. Sonst gäbe es die Schreibregel zweimal, und die
Fassung im Browser kann man ändern. Faellt die Auskunft aus, bleibt
die Lage leer und es gilt wieder die alte Regel -- der Knopf ist
dann da und der Server sagt notfalls nein. Das ist die richtige
Richtung: Ein Knopf zu viel ist ärgerlich, ein fehlender Riegel
wäre ein Loch. */
const darfEintragen = () => {
if (!ich || ich.rolle === 'creator') return false;
/* FAELLT DIE LAGE AUS, HEISST DAS NICHT "DARF ALLES"
(berichtigt 19.09.2026). `lageHolen()` schluckt jeden Fehler;
war `lage` null, fiel die Pruefung hier durch auf `true` --
fuer einen Zuschauer also von "darf auf keinem Brett" auf
"darf ueberall". Der Knopf erschien, das Formular ging auf,
man tippte -- und der Server lehnte mit 403 ab. Ein Formular,
das der Server ablehnt, laesst den Menschen davor sich selbst
fuer den Fehler halten. */
if (treffBrett && !lage) return false;
if (treffBrett && lage && lage.schreiben) return lage.schreiben[bereich] === true;
return true;
};
/* DIE HOECHSTE STIMMENZAHL DER LISTE. Sie ist der Massstab fuer den
Balken: Ein Wunsch mit 12 Stimmen neben einem mit 14 sieht sonst
genauso aus wie einer mit 1 neben einem mit 40. Der Balken zeigt
den ABSTAND, nicht die absolute Zahl -- die steht ohnehin daneben. */
let maxStimmen = 0;
/* Wie ein Brett in einem Satz heisst. Kurz, weil es mitten in einer
Zeile steht: „Aus Wunschliste: …" liest sich, „Aus dem Bereich
Wunschliste (wunsch): …" nicht. */
/* Wie ein Kanal auf der Karte heisst. Kurz -- er steht neben einem
Knopf, nicht in einer Ueberschrift. */
const KANAL_WORT = {
dogfather: 'DogFather', clips: 'DogFather Clips', hasidog: 'HasiDog',
};
const BRETT_WORT = {
anschlag: 'Anschlagbrett', ansteht: 'Was ansteht', treff: 'Rudel',
wunsch: 'Wunschliste', highlight: 'Highlights', regeln: 'Regeln',
mitmachen: 'Mitmachen',
};
/* WELCHER WEG VON WO AUS SINN ERGIBT. Bewusst wenige: Ein Menü mit
sieben Zielen an jeder Karte waere ein Menü, kein Angebot. Was
hier steht, ist der Weg, den eine Sache im echten Betrieb nimmt. */
const KETTE_ZIELE = {
wunsch: [
{ nach: 'ansteht', wort: 'Daraus einen Termin machen' },
{ nach: 'highlight', wort: 'Daraus ein Highlight machen' },
],
ansteht: [{ nach: 'highlight', wort: 'Daraus ein Highlight machen' }],
treff: [{ nach: 'wunsch', wort: 'Daraus einen Wunsch machen' }],
highlight: [{ nach: 'anschlag', wort: 'Am Anschlagbrett zeigen' }],
};
const $ = (id) => document.getElementById(id);
/* ---- DIE SIEBEN COMMUNITY-BRETTER (18.09.2026) -----------------
Sie hatten hier keinen Eintrag. Ein Mitglied sah deshalb nur:
— TERMINE
Was ansteht
Hier schreibt das Team.
Die Oberzeile ist eine Kategorie, kein Satz; darunter stand
ausschliesslich, OB man schreiben darf. Was das Brett IST und was
man damit tut, stand nirgends -- auf drei Brettern sogar
wortgleich dasselbe („Ab „Dabei" kannst du hier schreiben").
Jeder Satz sagt deshalb zwei Dinge: wofuer das Brett da ist und
was man hier konkret tut. Der Hinweis auf das Schreibrecht haengt
sich dahinter -- in dieser Reihenfolge, weil „was ist das hier"
vor „darf ich mitmachen" kommt. */
const UNTERZEILE = {
treff: 'Der offene Raum: Hallo sagen, fragen, loben – und sagen, wenn etwas nicht stimmt.',
anschlag: 'Was gerade gilt, kurz und an einer Stelle. Mehr als ein paar Sachen hängen hier nie.',
ansteht: 'Die nächsten Streams, Events und Turniere – mit Countdown, damit du nichts verpasst.',
wunsch: 'Was ihr euch wünscht. Drück „Will ich auch“ – danach wird sortiert, was am meisten gewollt ist.',
highlight: 'Eure Clips, Bilder und Fanart. Der Knopf führt zu TikTok – dort zählt das Liken wirklich.',
regeln: 'Wie es hier läuft – zum Nachschlagen, nicht zum Auswendiglernen. Oben steht, wo was steht.',
mitmachen: 'Wie du dazugehören kannst: was gerade gesucht wird und wie du dich meldest.',
live: 'Vorbereitung, Mitschrift und Auswertung – LIVE systematisch verbessern.',
content: 'Ideen, Produktion und Veröffentlichtes an einer Stelle statt in Chats.',
technik: 'Setups, Fehlerbilder und Lösungen pro Creator sauber dokumentiert.',
community: 'Moderation, Aktionen und Konflikte – ruhig statt Drama.',
schutz: 'Richtlinien, Vorfälle und was daraus gelernt wurde.',
/* Der Satz nennt die Trennung beim Namen, die das Konzept fordert:
Betreuung und Umsetzung sind unsere Seite, offizielle Wege sind
Agenturseite. Solange das nur im Kopf steht, wird es bei jedem
Streitfall neu verhandelt. */
agentur: 'Kampagnen, Schulungen und der offizielle Weg zur Agentur – mit Stand und Antwort.',
};
let ich = null;
let bereich = null;
let einstellung = null;
/* DIE ACCOUNTS, so wie der Server sie schickt (20.09.2026).
Auf Modulebene, weil zwei Stellen sie brauchen: die Galerie (drei
Spalten in DIESER Reihenfolge) und das Formular (Auswahlliste).
Beide aus derselben Auskunft -- eine zweite Liste im Browser waere
die, die beim vierten Account vergessen wird. */
let kanaeleDa = [];
/* Ist dies ein Brett des Treffs, und was darf ich dort? Beides sagt
der Server (daten.treff und /api/treff/lage) -- im Browser steht
keine Liste der Bretter und keine Rechteregel, sondern nur die
Antwort auf "was darf ICH hier gerade". Eine Regel, die im Browser
noch einmal steht, ist die, die auseinanderlaeuft; und die Fassung
im Browser kann man aendern. */
let treffBrett = false;
/* Darf ich den Kalender oeffnen? Kommt vom Server (kalender_offen).
Vorgabe `true`: Antwortet ein aelterer Server das Feld noch nicht,
bleibt es wie bisher -- ein fehlender Link waere fuer das Team ein
Rueckschritt, ein ueberzaehliger fuer die Community nur der alte
Zustand. */
let kalenderOffen = true;
let lage = null;
/* WELCHE BRETTER NACH ART GEGLIEDERT WERDEN.
Stand frueher mitten in der Zeichenfunktion -- dort konnte
`filterBauen` sie nicht sehen, und deshalb baute die Filterreihe
auf genau diesen Brettern eine ZWEITE Leiste mit denselben
Woertern wie die Sprungmarken darunter. */
const NACH_ART = {
regeln: ['regel', 'folge', 'hilfe', 'frage'],
mitmachen: ['gesucht', 'lust', 'frage'],
};
let filter = '';
/* WELCHER KANAL -- eine ZWEITE Dimension neben `filter`, nicht
derselbe Wert.
Ein gemeinsamer Wert wäre einfacher zu bauen und falsch zu
benutzen: Man könnte dann „nur offene" ODER „nur HasiDog" fragen,
aber nie beides. Und genau das will man, sobald aus drei Kanälen
Videos nebeneinander liegen. */
let kanalFilter = '';
/* =====================================================================
DIE HIGHLIGHTS NACH ZEIT (20.09.2026)
Filipe: "die videos will ich auch kategorisiert haben nach woche,
monat, jahr und ganz oben aktuell also heute."
WARUM DAS NOETIG WURDE: Highlights sammeln sich. Nach einem halben
Jahr steht das Beste vom Mai zwischen dem von gestern, und die
Galerie beantwortet die Frage, die man wirklich hat ("was ist
GERADE gut gelaufen"), gar nicht mehr.
DIE ZEITRAEUME SIND KALENDERZEITRAEUME, KEINE RUECKBLICKE. "Diese
Woche" heisst seit Montag, nicht "die letzten sieben Tage". Wer am
Dienstag "diese Woche" fragt, meint Montag und Dienstag -- nicht
den Mittwoch davor. Ein rollendes Fenster waere leichter zu rechnen
und beantwortet eine andere Frage.
UND SIE STAPELN SICH NICHT. Man waehlt EINEN -- "heute" ist auch
in "diese Woche" enthalten, und zwei sich enthaltende Filter
gleichzeitig anzubieten erzeugt nur die Frage, was ihre
Kombination bedeutet. */
let zeitFilter = '';
const ZEIT_WORT = {
heute: 'Aktuell', woche: 'Diese Woche',
monat: 'Dieser Monat', jahr: 'Dieses Jahr',
};
/** Ab wann zaehlt dieser Zeitraum? Gibt einen Zeitstempel in
* Millisekunden -- oder 0 fuer "alles". */
function zeitAb(was) {
const d = new Date();
d.setHours(0, 0, 0, 0);
if (was === 'heute') return d.getTime();
if (was === 'woche') {
/* Montag als Wochenanfang. `getDay()` liefert Sonntag = 0 --
ohne die Korrektur faenge die Woche am Sonntag an, und in
Deutschland steht der am Ende. */
const wt = (d.getDay() + 6) % 7;
d.setDate(d.getDate() - wt);
return d.getTime();
}
if (was === 'monat') { d.setDate(1); return d.getTime(); }
if (was === 'jahr') { d.setMonth(0, 1); return d.getTime(); }
return 0;
}
/** Wann ist dieser Eintrag entstanden? `erstellt` ist die Wahrheit --
* `datum` ist ein Feld, das jemand von Hand setzen kann, und ein
* Highlight vom Mai mit dem Datum von heute gehoert nicht unter
* "Aktuell". */
const entstandAm = (e) => {
const t = Date.parse(e.erstellt || e.datum || '');
return Number.isNaN(t) ? 0 : t;
};
/* Nur beim ersten Laden springen -- sonst reisst es die Seite nach
jeder Bewertung erneut an dieselbe Stelle. */
let gesprungen = false;
const el = (tag, klasse, text) => {
const k = document.createElement(tag);
if (klasse) k.className = klasse;
if (text !== undefined) k.textContent = text;
return k;
};
const melde = (t) => { $('fehler').textContent = t || ''; };
const datum = (iso) => (iso ? iso.slice(0, 10).split('-').reverse().join('.') : '—');
/* ---------- Agentur-Events ---------------------------------------------
Ein Event ist kein Eintrag mit Ueberschrift und Text, sondern eine
Aktion mit Zeitraum, Aufgaben und Regeln. Welche Art das ist, sagt
der Server (die Arten kommen aus workspace-bereiche.js) -- die
Kennung steht hier trotzdem woertlich, weil das Formular je Art
anders aussieht und es dafuer keine allgemeine Regel gibt. Sie
steht an EINER Stelle, damit sie sich nicht verteilt. */
const EVENT_BEREICH = 'agentur';
const EVENT_ART = 'kampagne';
const istEventArt = (art) => bereich === EVENT_BEREICH && art === EVENT_ART;
/* =====================================================================
"WANN IST DER NAECHSTE STREAM?" (17.09.2026)
Filipe wollte fuer den Community-Bereich "eine richtige
perfektion". Bei "Was ansteht" ist das keine Frage der Optik: Es
gibt genau EINE Frage, die ein Zuschauer hier hat, und sie stand
bisher in Zeile vier einer Liste.
BEIM BAUEN KAM EINE LUECKE HERAUS, die im Plan nicht stand: Ein
Eintrag hatte nur einen TAG, keine Uhrzeit -- die Pruefung im
Server lehnte eine Zeitangabe ausdruecklich ab. Ein Countdown "in
3 Std 12 Min" war damit gar nicht moeglich. Die Spalte `uhrzeit`
ist deshalb heute dazugekommen; sie ist OPTIONAL, denn viele
Termine haben keine Uhrzeit, und ein Pflichtfeld haette dafuer
eine erfundene erzwungen.
OHNE UHRZEIT ZAEHLT ER IN TAGEN. "Morgen" ist eine ehrliche
Antwort; "morgen um 00:00" waere eine erfundene. */
/* Der Zeitpunkt eines Eintrags als Millisekunden -- Ortszeit.
`new Date("2026-09-17T20:00:00")` OHNE Z ist Ortszeit, und genau
das ist gemeint: Der Stream faengt um 20 Uhr HIER an. */
function zeitpunktVon(e) {
const tag = String(e?.datum || '').slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(tag)) return null;
const zeit = /^\d{2}:\d{2}$/.test(String(e.uhrzeit || '')) ? e.uhrzeit : null;
const d = new Date(`${tag}T${zeit || '00:00'}:00`);
return Number.isNaN(d.getTime()) ? null : { ms: d.getTime(), tag, zeit };
}
/* Wie lange noch? Gibt Worte zurueck, keine Zahl -- "in 3 Std 12 Min"
liest sich in einem Zug, "0.134 Tage" nicht. */
function nochText(e, jetztMs = Date.now()) {
const z = zeitpunktVon(e);
if (!z) return null;
const heute = window.heuteLokal ? window.heuteLokal() : new Date().toISOString().slice(0, 10);
const tageHin = Math.round(
(new Date(`${z.tag}T00:00:00`).getTime() - new Date(`${heute}T00:00:00`).getTime()) / 86400000);
if (tageHin < 0) return { wann: 'vorbei', stufe: 'vorbei' };
/* MIT UHRZEIT UND HEUTE ODER MORGEN: in Stunden. Weiter entfernt
sagt eine Stundenzahl nichts mehr -- "in 73 Std" muss man erst
umrechnen, "in 3 Tagen" nicht. */
if (z.zeit && tageHin <= 1) {
const min = Math.round((z.ms - jetztMs) / 60000);
if (min < 0) return { wann: tageHin === 0 ? 'läuft' : 'vorbei', stufe: tageHin === 0 ? 'jetzt' : 'vorbei' };
if (min < 60) return { wann: `in ${min} Min`, stufe: 'jetzt' };
const std = Math.floor(min / 60);
const rest = min % 60;
return { wann: `in ${std} Std${rest ? ' ' + rest + ' Min' : ''}`, stufe: 'bald' };
}
if (tageHin === 0) return { wann: 'heute', stufe: 'jetzt' };
if (tageHin === 1) return { wann: 'morgen', stufe: 'bald' };
if (tageHin <= 7) return { wann: `in ${tageHin} Tagen`, stufe: 'woche' };
return { wann: datum(z.tag), stufe: 'spaeter' };
}
/* In welchen Abschnitt des Zeitstrahls gehoert der Eintrag? */
function abschnittVon(e) {
const n = nochText(e);
if (!n) return 'ohne';
if (n.stufe === 'vorbei') return 'vorbei';
if (n.stufe === 'jetzt' || n.stufe === 'bald') return 'heute';
if (n.stufe === 'woche') return 'woche';
return 'spaeter';
}
const ABSCHNITT_NAME = {
heute: 'Heute und morgen', woche: 'Diese Woche',
spaeter: 'Später', ohne: 'Ohne Datum', vorbei: 'Vorbei',
};
/* Der Kasten ganz oben. */
function countdownZeigen(eintraege) {
const ziel = $('countdown');
if (!ziel) return;
if (bereich !== 'ansteht') { ziel.hidden = true; return; }
ziel.hidden = false;
ziel.textContent = '';
const kommend = (eintraege || [])
.map((e) => ({ e, n: nochText(e), z: zeitpunktVon(e) }))
.filter((x) => x.n && x.n.stufe !== 'vorbei')
.sort((a, b) => a.z.ms - b.z.ms);
if (!kommend.length) {
/* EIN SATZ STATT EINER LEEREN FLAECHE. Ein Bereich, der nichts
sagt, wenn nichts da ist, sieht kaputt aus statt leer. */
ziel.dataset.leer = 'ja';
ziel.append(el('p', 'countdown__leer',
'Noch kein Termin eingetragen. Der nächste Stream steht hier, sobald er feststeht.'));
return;
}
delete ziel.dataset.leer;
const { e, n } = kommend[0];
ziel.dataset.stufe = n.stufe;
ziel.append(el('p', 'countdown__schild', 'Als Nächstes'));
ziel.append(el('p', 'countdown__wann', n.wann));
ziel.append(el('p', 'countdown__titel', e.titel || 'Termin'));
const wann = e.uhrzeit
? `${datum(String(e.datum).slice(0, 10))} um ${e.uhrzeit} Uhr`
: datum(String(e.datum).slice(0, 10));
ziel.append(el('p', 'countdown__datum', wann));
if (kommend.length > 1) {
ziel.append(el('p', 'countdown__weitere',
`${kommend.length - 1} weitere${kommend.length === 2 ? 'r Termin' : ' Termine'} danach`));
}
}
/* Laeuft das Event noch, kommt es erst, ist es vorbei?
Der Vergleich laeuft auf den ISO-Zeichenketten, nicht auf Date --
"2026-09-12" < "2026-09-30" ist als Text schon richtig, und ein
Date() haette hier eine Zeitzone erfunden, die niemand gemeint hat.
Ohne Ende gilt der Beginntag als der ganze Zeitraum. */
function eventStand(e) {
const heute = window.heuteLokal ? window.heuteLokal() : new Date().toISOString().slice(0, 10);
const von = (e.datum || '').slice(0, 10);
const bis = (e.event_ende || von).slice(0, 10);
if (!von) return null;
if (heute < von) return { schluessel: 'kommt', text: 'startet ' + datum(von) };
if (heute > bis) return { schluessel: 'vorbei', text: 'vorbei seit ' + datum(bis) };
return { schluessel: 'laeuft', text: von === bis ? 'heute' : 'läuft bis ' + datum(bis) };
}
/* =====================================================================
WIE LANGE NOCH? (03.10.2026)
Die eine Frage, die bei einem Wettbewerb wirklich zaehlt -- und
die Karte hat sie bis heute nicht beantwortet. Dort stand dreimal
dasselbe Datum ("laeuft bis 16.10.2026", "03.10.2026 - 16.10.2026",
"Im Kalender am 03.10.2026") und nirgends die Zahl, die man
eigentlich wissen will.
IN TAGEN, NICHT IN STUNDEN. Ein Event laeuft ueber Wochen; "noch
312 Stunden" muss man erst umrechnen. Am LETZTEN Tag wechselt der
Ton: Dann ist "heute ist der letzte Tag" die Auskunft, und sie
gehoert hervorgehoben.
GERECHNET WIRD AUF TAGEN, nicht auf Zeitpunkten. Ein Event endet
am 16.10. -- also mit dem Ablauf dieses Tages, nicht um 00:00 Uhr.
Wer mit Date.now() rechnet, meldet am Morgen des letzten Tages
"vorbei".
*/
const TAG_MS = 86400000;
const alsTag = (iso) => {
const t = String(iso || '').slice(0, 10);
return /^\d{4}-\d{2}-\d{2}$/.test(t) ? new Date(t + 'T00:00:00').getTime() : null;
};
const tageZwischen = (a, b) => Math.round((b - a) / TAG_MS);
/** Der Satz unter dem Titel: "noch 13 Tage", "letzter Tag",
* "startet morgen", "vorbei seit 12 Tagen". */
function eventNoch(e) {
const heuteIso = window.heuteLokal ? window.heuteLokal()
: new Date().toISOString().slice(0, 10);
const heute = alsTag(heuteIso);
const von = alsTag(e.datum);
const bis = alsTag(e.event_ende || e.datum);
if (heute === null || von === null || bis === null) return null;
if (heute < von) {
const n = tageZwischen(heute, von);
return { wort: n === 1 ? 'startet morgen' : `startet in ${n} Tagen`, dringend: n <= 1 };
}
if (heute > bis) {
const n = tageZwischen(bis, heute);
return { wort: n === 1 ? 'seit gestern vorbei' : `vorbei seit ${n} Tagen`, dringend: false };
}
const n = tageZwischen(heute, bis);
if (n === 0) return { wort: 'heute ist der letzte Tag', dringend: true };
if (n === 1) return { wort: 'noch 1 Tag', dringend: true };
/* "Noch 2 Tage" ist knapp, "noch 3" ist es nicht mehr -- die Grenze
ist gesetzt und nicht gemessen, aber sie steht an EINER Stelle. */
return { wort: `noch ${n} Tage`, dringend: n <= 2 };
}
/** Wie viel des Zeitraums ist herum? 0 bis 1, oder null.
*
* FUER EINEN BALKEN, nicht fuer eine Zahl. "Wie viel Zeit ist noch"
* sieht man an einer Laenge sofort und muss sie nicht lesen -- genau
* wie beim Rangbalken der Wunschliste darueber.
*
* EIN EINTAEGIGES EVENT hat keinen Verlauf (von === bis, Division
* durch null). Dann gibt es keinen Balken: Ein Balken, der bei einem
* Tagesevent immer voll oder immer leer stuende, sagt nichts. */
function eventFortschritt(e) {
const heuteIso = window.heuteLokal ? window.heuteLokal()
: new Date().toISOString().slice(0, 10);
const heute = alsTag(heuteIso);
const von = alsTag(e.datum);
const bis = alsTag(e.event_ende || e.datum);
if (heute === null || von === null || bis === null || bis <= von) return null;
const anteil = (heute - von) / (bis - von);
return Math.max(0, Math.min(1, anteil));
}
/* Der Abruf steht seit dem 22.09.2026 in assets/js/holen.js.
Er stand ACHTMAL da, Zeichen fuer Zeichen gleich -- und als
die Regel darin sich als falsch herausstellte (jeder 404
warf auf die Startseite, auch bei einer blossen Handlung),
haette man sie achtmal aendern muessen. */
const hole = (pfad, optionen) => window.holen(pfad, optionen);
/** Ein Bild auswaehlen und an den Beitrag haengen.
*
* DAS DATEIFELD WIRD JEDES MAL NEU GEBAUT und danach weggeworfen.
* Ein dauerhaftes im HTML waere ein Feld fuer vierzig Karten --
* und beim zweiten Mal dieselbe Datei, weil der Browser den Wert
* behaelt, wenn man denselben Namen erneut waehlt.
*
* DIE VORSCHAU GIBT ES HIER NICHT. Beim Support ist sie wichtig
* (man schreibt einen Text dazu und will sehen, was man mitgibt);
* hier geht das Bild sofort raus und steht eine Sekunde spaeter
* als Streifen an der Karte. Eine Vorschau dazwischen waere ein
* Schritt, den niemand braucht. */
function bildWaehlen(e) {
const feld = document.createElement('input');
feld.type = 'file';
feld.accept = 'image/png,image/jpeg,image/webp,image/gif';
feld.addEventListener('change', async () => {
const datei = feld.files?.[0];
if (!datei) return;
/* Die Grenze steht hier nur als Freundlichkeit -- abgelehnt
wird am Server. Sie erspart es, zwoelf Megabyte erst
hochzuladen, um danach eine Absage zu lesen. */
if (datei.size > 12 * 1024 * 1024) {
melde('Das Bild ist größer als 12 MB – bitte ein kleineres.');
return;
}
melde('Bild wird hochgeladen …');
try {
const a = await hole(
`/workspace/api/bereich/${bereich}/${e.id}/bild`,
{
method: 'POST',
headers: { 'x-name': encodeURIComponent(datei.name || 'Bild') },
body: datei,
});
if (!a.ok) {
melde(sagWas((await a.json().catch(() => ({}))).fehler,
'Das Bild ließ sich nicht anhängen.'));
return;
}
melde('Bild ist dran.');
await laden();
} catch { /* umgeleitet */ }
});
feld.click();
}
/* `klasse` kam am 03.10.2026 dazu, fuer den Loeschknopf an der
Eventkarte. Vorher sahen dort vier Knoepfe gleich aus -- darunter
der eine, den man nicht zuruecknehmen kann. Die Unterscheidung
gehoert an den Knopf und nicht in eine Regel, die ihn ueber seine
Stellung in der Reihe sucht: Kommt ein fuenfter dazu, trifft so
eine Regel den Falschen. */
function tuKnopf(text, tu, klasse) {
const b = el('button', 'schritt' + (klasse ? ' ' + klasse : ''), text);
b.type = 'button';
b.addEventListener('click', async () => {
b.disabled = true; melde('');
try { await tu(); } catch { /* umgeleitet */ } finally { b.disabled = false; }
});
return b;
}
/* ---------- Zeichnen --------------------------------------------------- */
/** Sagt unter dem Datumsfeld, ob dieser Tag im Kalender landet.
*
* DIESELBE REGEL WIE AUF DEM SERVER -- aber bewusst nur die eine
* Haelfte, die der Browser ueberhaupt beantworten kann: Liegt der
* Tag in der Zukunft? Ob am Ende `geplant`, `uhrzeit` oder
* `event_ende` gesetzt sind, entscheidet der Server, und die Karte
* zeigt danach sein Ergebnis. Der Hinweis hier ist eine Vorschau,
* keine zweite Wahrheit -- deshalb steht er im Konjunktiv-Ton
* („steht damit im Kalender") und nicht als Zusage. */
function kalenderhinweisSetzen() {
const feld = $('f-datum');
const ziel = $('hinweis-datum');
if (!feld || !ziel) return;
const wert = feld.value;
/* Denselben Rueckfall wie an den zwei Stellen darueber: `heuteLokal`
kommt aus kopf.js, und wenn das noch nicht geladen ist, waere ein
direkter Aufruf ein Absturz mitten im Formularaufbau. */
const heute = window.heuteLokal ? window.heuteLokal()
: new Date().toISOString().slice(0, 10);
if (!wert) { ziel.textContent = ''; return; }
if (wert > heute) {
ziel.textContent = 'Liegt in der Zukunft – steht damit im Kalender.';
ziel.dataset.imkalender = 'ja';
} else if ($('f-uhrzeit') && !$('feld-uhrzeit').hidden && $('f-uhrzeit').value) {
ziel.textContent = 'Mit Uhrzeit – steht damit im Kalender.';
ziel.dataset.imkalender = 'ja';
} else {
ziel.textContent = wert === heute
? 'Heute – das ist der Tag der Notiz, kein Termin.'
: 'Liegt in der Vergangenheit – kein Termin.';
delete ziel.dataset.imkalender;
}
}
/** Setzt den Ton der Kachel, aus der dieses Brett kommt.
*
* Sucht in beiden Kachellisten, die der Browser ohnehin hat. Findet
* sich nichts, bleibt es beim Standardton -- eine Seite ohne Farbe
* ist unauffaellig, eine Seite mit der FALSCHEN Farbe fuehrt in die
* Irre. */
function tonSetzen(schluessel) {
const ziel = 'bereich.html?b=' + schluessel;
let ton = null;
/* 1. Die Kacheln vom Server (Treff, Modis, rechte Hand). */
for (const k of (ich?.bereiche || [])) {
if (k?.ziel === ziel && k.ton) { ton = k.ton; break; }
}
/* 2. Die Kacheln der Agentur, die im Browser stehen. */
if (!ton) {
for (const g of (window.Bereiche?.GRUPPEN || [])) {
for (const k of (g.bereiche || [])) {
if (k?.ziel === ziel && k.ton) { ton = k.ton; break; }
}
if (ton) break;
}
}
/* 3. Und die Kacheln, die einer Rolle zusaetzlich angehaengt
werden (bereiche_zusatz) -- dort steht der Treff fuer
DogFather. */
if (!ton) {
for (const k of (ich?.bereiche_zusatz || [])) {
if (k?.ziel === ziel && k.ton) { ton = k.ton; break; }
}
}
if (ton) document.body.dataset.ton = String(ton);
}
/* =====================================================================
DIE BUEHNE EINES EVENTS (03.10.2026)
Filipe: "ich will das viel geiler viel perfekter."
GEMESSEN, WARUM: Auf 1280 px war eine Eventkarte 1196 px hoch --
hoeher als das Fenster. 638 px davon, also 53 %, waren das Banner,
und zwar leeres Banner: Der Titel stand DARUNTER, bei 1482 px auf
der Seite. Man musste zweimal scrollen, um zu erfahren, wie das
Event ueberhaupt heisst. Dazu war der Titel 15,36 px gross -- 1,4 px
mehr als der Fliesstext, der darunter stand.
ALSO STEHT DER TITEL JETZT AUF DEM BILD. Das Banner ist kein Anhang
mehr, sondern der Hintergrund des Kopfes: Es behaelt seine Wirkung,
verliert aber die Hoehe, die es ohne Inhalt verbraucht hat. Die
Hoehe ist gedeckelt (CSS), damit ein hochkantes Bild die Karte
nicht erneut aufreisst.
OHNE BANNER GIBT ES TROTZDEM EINE BUEHNE -- dann aus Farbe. Eine
Karte, die je nach Bild zwei voellig verschiedene Formen hat, liest
man als Unordnung; und ein Event ohne Bild ist kein Event zweiter
Klasse.
DIE LESBARKEIT KOMMT NICHT VOM ZUFALL: Ueber dem Bild liegt ein
Verlauf von unten (siehe .ev-buehne__schleier im CSS). Ein heller
Banner wuerde sonst weisse Schrift auf Weiss ergeben -- und genau
das kann man einem Bild, das jemand spaeter hochlaedt, nicht
ansehen. Der Schleier gilt deshalb immer, nicht nur bei dunklen
Bildern. */
function eventBuehne(e, stand, kopf) {
const b = el('header', 'ev-buehne');
b.dataset.stand = stand.schluessel;
const bild = (e.bilder || [])[0];
if (bild) {
b.dataset.mitbild = 'ja';
const img = el('img', 'ev-buehne__bild');
img.src = '/workspace/api/dateien/' + bild.id + '/bild';
/* LEERES alt IST HIER RICHTIG: Das Banner ist Schmuck hinter dem
Titel, der direkt darauf steht. Ein zweites Mal "Bild zu
Oktober Event" vorgelesen zu bekommen, nachdem der Titel schon
kam, ist Laerm, keine Auskunft. */
img.alt = '';
img.loading = 'lazy';
img.decoding = 'async';
/* Faellt das Bild aus, bleibt die Buehne stehen -- nur eben als
Farbe. Ein kaputtes Bildsymbol mitten im Kopf saehe aus, als
sei die Seite defekt. */
img.addEventListener('error', () => { img.remove(); delete b.dataset.mitbild; });
b.append(img);
/* ==== DAS BANNER IN VOLLER GROESSE (03.10.2026) ==============
EINE VERSCHLECHTERUNG, DIE BEIM NACHSEHEN AUFFIEL: Vorher war
das Bild ein Verweis -- ein Klick zeigte es ganz. Als es zur
Buehne wurde, fiel dieser Weg weg, und das ist bei genau
diesen Bildern der falsche Verlust: Auf Filipes Oktober-Banner
steht die eigentliche Ansage IM Bild ("Schaffst du es, ab
HEUTE bis zum 16.10.2026") samt der drei Bedingungen. Die
Buehne zeigt davon einen Ausschnitt -- sie muss, sonst waere
sie wieder 638 px hoch.
DESHALB EIN EIGENER KNOPF und nicht die ganze Flaeche: Auf der
Buehne stehen Titel und Zeitangabe, und Text, den man nicht
markieren kann, ohne dass sich ein Fenster oeffnet, ist
aergerlich. Ein kleiner Knopf oben rechts ist eindeutig --
und er sagt mit Worten, was er tut, nicht nur mit einem
Symbol. */
const auf = el('a', 'ev-buehne__ganz');
auf.href = '/workspace/api/dateien/' + bild.id + '/bild';
auf.target = '_blank';
auf.rel = 'noopener';
auf.append(el('span', 'ev-buehne__ganz-zeichen'));
auf.append(el('span', 'ev-buehne__ganz-wort', 'Banner ganz'));
auf.title = 'Zeigt das Banner in voller Größe in einem neuen Fenster';
b.append(auf);
}
b.append(el('div', 'ev-buehne__schleier'));
const inhalt = el('div', 'ev-buehne__inhalt');
kopf.classList.add('ev-buehne__kopf');
inhalt.append(kopf);
/* DIE ZEITZEILE -- einmal, nicht dreimal.
"noch 13 Tage" steht VOR dem Datum: Es ist die Antwort, das
Datum ist der Beleg dazu. Und ein Zeitraum gehoert als Spanne
gelesen, nicht als zwei Einzeldaten -- deshalb ein Gedankenstrich
und kein zweites Feld. */
const zeit = el('p', 'ev-zeit');
const noch = eventNoch(e);
if (noch) {
const m = el('span', 'ev-noch', noch.wort);
m.dataset.stand = stand.schluessel;
if (noch.dringend) m.dataset.dringend = 'ja';
zeit.append(m);
}
const von = datum(e.datum);
const bis = e.event_ende && e.event_ende !== e.datum ? datum(e.event_ende) : null;
zeit.append(el('span', 'ev-spanne', bis ? von + ' – ' + bis : von));
inhalt.append(zeit);
/* DER ZEITBALKEN. "Wie viel ist herum" sieht man an einer Laenge
sofort; als Zahl muesste man sie lesen und umrechnen. Fuer
Vorleseprogramme steht derselbe Inhalt als Satz da -- ein Balken
ohne Beschriftung ist fuer sie gar nicht vorhanden. */
const anteil = eventFortschritt(e);
if (anteil !== null && stand.schluessel !== 'kommt') {
const bal = el('div', 'ev-zeitbalken');
const f = el('div', 'ev-zeitbalken__fuellung');
f.style.width = Math.round(anteil * 100) + '%';
bal.append(f);
bal.setAttribute('role', 'img');
bal.setAttribute('aria-label', noch
? 'Zeitraum: ' + noch.wort
: Math.round(anteil * 100) + ' Prozent des Zeitraums vorbei');
inhalt.append(bal);
}
b.append(inhalt);
return b;
}
/* =====================================================================
DIE AUFGABEN ZUM ABHAKEN (03.10.2026)
Bisher stand hier ein Absatz mit white-space: pre-line -- also
genau das, was DogFather getippt hatte, Zeichen fuer Zeichen. Im
echten Oktober-Event sah das so aus:
.Jeden Tag Live gehen
.22-Live-Stunden erreichen
. 33k Diamanten erreichen
Einmal mit Leerzeichen, zweimal ohne. Das ist keine Schlamperei,
sondern die unvermeidliche Folge davon, dass die Aufzaehlungs-
zeichen von Hand getippt werden. Jetzt setzt die Liste sie selbst
(siehe punkteAusText im Server), und die Einrueckung stimmt
zwangslaeufig.
JEDER HAKT FUER SICH AB. Der Haken ist eine Selbstauskunft: "diese
Bedingung habe ich erfuellt". Niemand kann ihn bei jemand anderem
setzen, auch die Leitung nicht -- sonst waere es eine Bewertung.
DIE ZAHL KOMMT VOM SERVER, auch nach dem Klicken. Im Browser
weiterzuzaehlen ("war 1, jetzt 2") geht schief, sobald zwei
Fenster offen sind oder eine Aufgabe inzwischen umgeschrieben
wurde. Angezeigt wird sie trotzdem sofort -- bei einem Haken ist
eine halbe Sekunde Wartezeit der Unterschied zwischen "hat
geklappt" und "hab ich das jetzt gedrueckt?". Antwortet der Server
anders, gilt seine Zahl. Dieselbe Ueberlegung steht schon beim
Knopf "Will ich auch".
EIN VORBEIGELAUFENES EVENT WIRD NICHT MEHR ABGEHAKT. Der Haken
waere eine Behauptung ueber etwas, das nicht mehr stattfindet --
und die Liste bleibt trotzdem lesbar, damit man nachschlagen kann,
worum es ging. */
function eventAufgaben(e, stand) {
const punkte = Array.isArray(e.event_punkte) ? e.event_punkte : [];
if (!punkte.length) return null;
const block = el('section', 'ev-block ev-block--aufgaben');
const kopf = el('div', 'ev-block__kopf');
kopf.append(el('h4', 'ev-block__schild', 'Aufgaben'));
const offen = stand.schluessel !== 'vorbei';
let geschafft = punkte.filter((p) => p.meiner).length;
const zaehler = el('span', 'ev-zaehler');
const zahlMalen = () => {
zaehler.textContent = geschafft + ' von ' + punkte.length;
const voll = punkte.length > 0 && geschafft === punkte.length;
zaehler.dataset.voll = voll ? 'ja' : '';
block.dataset.fertig = voll ? 'ja' : '';
};
zahlMalen();
kopf.append(zaehler);
block.append(kopf);
const liste = el('ul', 'ev-liste');
for (const p of punkte) {
const zeile = el('li', 'ev-punkt');
if (!offen) {
/* Ohne Knopf, aber mit demselben Zeichen davor: Die Liste soll
gleich aussehen, nur eben nicht mehr bedienbar. */
zeile.dataset.abgehakt = p.meiner ? 'ja' : '';
zeile.append(el('span', 'ev-punkt__kaestchen'));
zeile.append(el('span', 'ev-punkt__text', p.text));
liste.append(zeile);
continue;
}
const knopf = el('button', 'ev-punkt__knopf');
knopf.type = 'button';
let meiner = !!p.meiner;
const malen = () => {
knopf.setAttribute('aria-pressed', String(meiner));
zeile.dataset.abgehakt = meiner ? 'ja' : '';
};
knopf.append(el('span', 'ev-punkt__kaestchen'));
knopf.append(el('span', 'ev-punkt__text', p.text));
malen();
knopf.addEventListener('click', async () => {
knopf.disabled = true;
meiner = !meiner;
geschafft += meiner ? 1 : -1;
malen(); zahlMalen();
try {
const a = await hole('/workspace/api/eintrag/' + e.id + '/punkt/' + p.schluessel,
meiner
? { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: '{}' }
: { method: 'DELETE' });
if (a.ok) {
const j = await a.json();
geschafft = j.geschafft;
const frisch = (j.punkte || []).find((x) => x.schluessel === p.schluessel);
if (frisch) meiner = !!frisch.meiner;
malen(); zahlMalen();
} else {
/* ZURUECKDREHEN UND SAGEN, WORAN ES LAG. Ein Haken, der
aussieht wie gesetzt und keiner ist, ist schlimmer als
ein Klick, der sichtbar nicht geklappt hat. */
meiner = !meiner;
geschafft += meiner ? 1 : -1;
malen(); zahlMalen();
melde(sagWas((await a.json().catch(() => ({}))).fehler,
'Das Abhaken hat nicht geklappt.'));
}
} catch {
/* umgeleitet -- holen.js schickt bei 401 zur Anmeldung */
} finally { knopf.disabled = false; }
});
zeile.append(knopf);
liste.append(zeile);
}
block.append(liste);
/* WER MACHT MIT? Nur fuer die Leitung (der Server schickt das Feld
sonst gar nicht), und nur, wenn ueberhaupt jemand angefangen hat.
Eine Ueberschrift "Dabei" ueber einer leeren Flaeche sieht aus,
als sei die Abfrage kaputt. */
if (Array.isArray(e.event_stand) && e.event_stand.length) {
const wer = el('div', 'ev-mitmacher');
wer.append(el('span', 'ev-mitmacher__schild', 'Dabei'));
for (const m of e.event_stand) {
const x = el('span', 'ev-mitmacher__person',
(m.name || 'jemand') + ' · ' + m.geschafft + '/' + punkte.length);
if (m.geschafft >= punkte.length) x.dataset.voll = 'ja';
wer.append(x);
}
block.append(wer);
}
return block;
}
/** Die Regeln -- dieselbe Liste, aber ohne Haken.
*
* BEWUSST ANDERS ALS DIE AUFGABEN. Vorher waren beide derselbe graue
* Kasten mit derselben Ueberschriftzeile; nebeneinander sahen "was
* ich tun soll" und "was dabei gilt" gleich wichtig aus. Eine Regel
* ist aber kein Schritt: Man hakt sie nicht ab, man haelt sie ein. */
function eventRegeln(e) {
const punkte = Array.isArray(e.event_regelpunkte) ? e.event_regelpunkte : [];
if (!punkte.length) return null;
const block = el('section', 'ev-block ev-block--regeln');
const kopf = el('div', 'ev-block__kopf');
kopf.append(el('h4', 'ev-block__schild', 'Regeln'));
block.append(kopf);
const liste = el('ul', 'ev-liste ev-liste--still');
for (const p of punkte) {
const zeile = el('li', 'ev-punkt ev-punkt--regel');
zeile.append(el('span', 'ev-punkt__zeichen'));
zeile.append(el('span', 'ev-punkt__text', p.text));
liste.append(zeile);
}
block.append(liste);
return block;
}
/** Der dritte Ausgang fuer die Event-Bloecke.
*
* Kommt vom Server keine Punkteliste (alte Fassung im Netz, Feld
* leer geblieben, Abfrage gescheitert), stand hier sonst GAR NICHTS
* -- die Aufgaben eines laufenden Wettbewerbs waeren wortlos
* verschwunden. Das ist der schlimmere Ausgang: Eine schlichte
* Liste ist ein kleiner Schoenheitsfehler, eine fehlende Bedingung
* ein inhaltlicher Verlust.
*
* Es ist bewusst dieselbe Form wie frueher (Absatz mit erhaltenen
* Zeilenumbruechen), nur im neuen Rahmen. */
function eventRueckfall(name, text) {
const block = el('section', 'ev-block ev-block--roh');
const kopf = el('div', 'ev-block__kopf');
kopf.append(el('h4', 'ev-block__schild', name));
block.append(kopf);
block.append(el('p', 'ev-rohtext', text));
return block;
}
function karte(e) {
const k = el('article', 'eintrag-karte');
k.dataset.dringlich = e.dringlichkeit;
k.dataset.status = e.status;
/* IST DAS EIN EVENT? Dann sieht diese Karte anders aus -- Buehne
statt Bildstreifen, Listen statt Textbloecke, der Zeitraum einmal
statt dreimal. Die Frage wird HIER gestellt und nicht erst unten
bei den Bloecken: Davon haengt auch ab, ob das Bild oben als
Streifen erscheint (bei einem Event ist es der Hintergrund des
Kopfes) -- und diese Entscheidung faellt weiter oben als die
bisherige Zeile `const stand = ...`. */
const evStand = istEventArt(e.art) ? eventStand(e) : null;
if (evStand) k.dataset.eventkarte = 'ja';
/* DIE ART GEHOERT AN DIE KARTE (22.09.2026).
Am Anschlagbrett traegt sie die Farbe: Ansage, Neue Regel,
Hinweis, Danke an euch. Sie stand bisher nur im Text der Karte
-- eine Auskunft, die man lesen muss, statt einer, die man
sieht.
Das Merkmal steht an JEDER Karte und nicht nur am
Anschlagbrett: Es ist eine Tatsache ueber den Eintrag, keine
Anzeige. Wer sie auf einem anderen Brett braucht, findet sie
vor -- und muss nicht erst diese Zeile aendern. */
if (e.art) k.dataset.art = e.art;
/* DER GANZE KASTEN IST DER WEG ZUM VIDEO (19.09.2026).
Filipe: „die community soll sofort auf das video geschickt werden
sobald sie auf die kachel drücken, also die ganze kacheln will
ich egal wo man drauf drückt, foto text.... egal wo sobald man
drauf drückt weiter geleitet".
Er hat recht, und der Grund ist handfest: Auf dem Handy ist der
Knopf „Auf TikTok ansehen" ein Ziel von rund 150 × 44 px in
einer Karte von 350 × 600. Wer das Bild antippt — die größte und
einladendste Fläche — erwartet, dass etwas passiert, und es
passiert nichts. Das fühlt sich an wie eine kaputte Seite.
NUR DORT, WO ES AUCH EIN VIDEO GIBT. Auf den anderen Brettern
bleibt die Karte eine Karte: Eine Fläche, die manchmal klickt und
manchmal nicht, ist schlimmer als eine, die es nie tut.
UND NUR, SOLANGE DAS VIDEO NOCH DA IST. Ist es bei TikTok
gelöscht (`quelle_fehlt_seit`, Stufe 5), führt der Weg auf eine
Fehlerseite — und wer darauf tippt, hält UNS für kaputt. Dann
bleibt die Karte still, und der Hinweis „Bei TikTok nicht mehr
da" steht weiterhin da, wo er hingehört. */
const videoWeg = (e.quelle_url && !e.quelle_fehlt_seit) ? String(e.quelle_url) : '';
/* ---- UND DER KLICK HAENGT GLEICH HIER DRAN ---------------------
ER STAND ZUERST GANZ UNTEN, VOR `return k` -- und war fuer die
Community wirkungslos. Diese Funktion hat DREI Rueckgabepunkte,
und der erste davon lautet:
if (!darfEintragen()) { k.append(fuss); return k; }
Wer nicht schreiben darf, bekommt die Karte also frueher zurueck
-- und genau das sind die Mitglieder, fuer die Filipe es
ueberhaupt wollte. Beim Team funktionierte es, bei der Community
nicht: der unangenehmste Fehler ueberhaupt, weil er beim
Selbsttest unsichtbar ist.
Deshalb steht es jetzt dort, wo `videoWeg` entsteht: „Diese
Karte ist ein Weg" ist eine Eigenschaft der Daten, kein
Nachtrag am Ende. Damit gilt sie fuer jeden Rueckgabeweg, auch
fuer den naechsten, den jemand einbaut.
WARUM KEIN <a> UM ALLES: In der Karte stecken schon Links (der
TikTok-Knopf, „Daraus wurde", die Bilder) und Knoepfe (loeschen,
melden, zur Aufgabe). Ein Link in einem Link ist ungueltiges
HTML -- der Browser beendet den aeusseren still, und danach ist
ein Teil der Karte tot, ohne Fehlermeldung.
WARUM DIE KARTE NICHT FOKUSSIERBAR WIRD: Der Knopf „Auf TikTok
ansehen" ist bereits der Tastaturweg. Ein zweiter Halt mit
demselben Ziel saehe fuer jemanden, der sich durchtabbt, nach
einem Fehler aus. Die Flaeche ist eine Bequemlichkeit fuer den
Daumen, nicht der eigentliche Weg. */
if (videoWeg) {
k.dataset.klickbar = 'ja';
const eigeneBedeutung = (ziel) =>
!!ziel.closest('a, button, input, textarea, select, label, [role="button"]');
k.addEventListener('click', (ev) => {
if (ev.button !== 0) return;
if (eigeneBedeutung(ev.target)) return;
/* Wer Text markiert, will lesen und nicht weg -- der haeufigste
Aerger an klickbaren Karten ueberhaupt. */
const auswahl = window.getSelection?.();
if (auswahl && !auswahl.isCollapsed && String(auswahl).trim()) return;
window.open(videoWeg, '_blank', 'noopener,noreferrer');
});
/* Mittlere Maustaste: dieselbe Erwartung wie bei einem Link. */
k.addEventListener('auxclick', (ev) => {
if (ev.button !== 1) return;
if (eigeneBedeutung(ev.target)) return;
ev.preventDefault();
window.open(videoWeg, '_blank', 'noopener,noreferrer');
});
}
const kopf = el('div', 'eintrag-kopf');
const marke = el('span', 'marke-art', einstellung.arten[e.art] || e.art);
/* Die Art als Merkmal am Element -- daran haengt der Ton (siehe
tools/art-farben.mjs). Kein Klassenname je Art: Der waere eine
zweite Liste, die mit der ersten auseinanderlaeuft. */
if (e.art) marke.dataset.art = e.art;
kopf.append(marke);
/* DIE VERTRAULICHE ZEILE SAGT ES SELBST (10.09.2026).
Sie kommt ohnehin nur bei DogFather und beim Schreiber an -- der
Server laesst sie sonst gar nicht heraus. Der Hinweis ist also
keine Sperre, sondern eine Auskunft: Ohne ihn saehe die eigene
Zeile aus wie jede andere, und beim naechsten Mal schriebe man
offen, was man vertraulich gemeint hat. */
/* AN DIE PERSON SCHICKEN (11.09.2026).
Nur im Entwicklungs-Bereich, und nur dort, wo ein Eintrag
überhaupt zu jemandem gehört. Er ist die einzige Brücke aus einer
Aufzeichnung, die die Person selbst nicht sieht -- und deshalb
ein ausdrücklicher Schritt und keine Automatik.
WURDE SCHON GESCHICKT? Dann steht dort ein Satz statt eines
Knopfs. Ein Knopf, der beim zweiten Drücken eine Absage bringt,
ist eine Falle; und zweimal dasselbe Lob ist kein doppeltes Lob,
sondern ein Versehen, das jeder bemerkt. */
if (bereich === 'entwicklung' && e.creator_id) {
if (e.gesendet_am) {
const hin = el('span', 'marke-art marke-art--weg',
`geschickt ${new Date(e.gesendet_am).toLocaleDateString('de-DE',
{ day: 'numeric', month: 'short' })}`);
hin.title = 'Diese Person hat den Eintrag als Nachricht bekommen.';
kopf.append(hin);
} else {
const b = el('button', 'eintrag-schicken', 'An die Person schicken');
b.type = 'button';
b.title = 'Schickt Art, Titel und Text als Nachricht in den Chat.';
b.addEventListener('click', async () => {
if (!await frageNach({
titel: `„${e.titel}“ als Nachricht schicken?`,
was: 'Die Person bekommt Art, Titel und Text im Chat – einmalig.',
bleibt: 'Der Eintrag bleibt hier stehen. Zurückholen lässt sich '
+ 'die Nachricht nicht, sobald sie unterwegs ist.',
ja: 'Schicken',
})) return;
b.disabled = true;
const a = await hole(`/workspace/api/bereich/entwicklung/${e.id}/senden`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' } });
if (!a.ok) {
melde(sagWas((await a.json().catch(() => ({}))).fehler, 'Ging nicht.'));
b.disabled = false;
return;
}
await laden();
});
kopf.append(b);
}
}
if (e.nur_leitung) {
const v = el('span', 'marke-art marke-art--nur', 'nur DogFather');
v.title = 'Das liest ausser dir nur DogFather.';
kopf.append(v);
}
kopf.append(el('h3', 'eintrag-titel', e.titel));
/* DER ZUSTAND EINES WUNSCHES steht im Kopf, nicht im Kleingedruckten
(17.09.2026). Er ist das, weswegen jemand wiederkommt: Ein Wunsch,
bei dem nie etwas passiert, ist ein Briefkasten ohne Postbote. */
if (einstellung?.zustaende && e.status) {
const name = einstellung.zustaende[e.status] || e.status;
const wann = e.geplant ? ` · ${datum(String(e.geplant).slice(0, 10))}` : '';
const z = el('span', 'wunsch-stand', name + wann);
z.dataset.stand = e.status;
kopf.append(z);
}
if (einstellung.bewertung && e.bewertung) {
const b = el('span', 'bewertung');
b.setAttribute('aria-label', `Bewertung ${e.bewertung} von 5`);
for (let i = 1; i <= 5; i++) {
const punkt = el('span', 'bewertung__punkt');
if (i <= e.bewertung) punkt.dataset.voll = 'ja';
b.append(punkt);
}
kopf.append(b);
}
/* DAS BILD STEHT OBEN, VOR DEM TITEL (17.09.2026) -- aus dem Plan
im Vault, Stufe 5.
"Highlights -- eure Clips und Bilder" zeigte bisher Textkarten
mit einem Anhang darunter. Ein Bild als Anhang unter einer
Ueberschrift ist kein Highlight; dieser Bereich lebt vom SEHEN.
Der Text wird damit zur Bildunterschrift, nicht umgekehrt.
ANSPRINGBAR UND MIT NAMEN: Ein Bild ohne `alt` ist fuer einen
Teil der Zuschauer gar nicht da, und eine Galerie aus
nicht-anspringbaren Kacheln ist keine Galerie. Deshalb ein
echter Verweis mit Beschriftung -- das kann die Tastatur, ohne
eine Zeile JavaScript. */
/* BEI EINEM EVENT NICHT HIER: Das erste Bild wird zur Buehne
hinter dem Titel (siehe eventBuehne). Ein Streifen DARUEBER und
dasselbe Bild nochmal dahinter waere die Doppelung, die diese
Karte gerade verloren hat. */
if (!evStand && Array.isArray(e.bilder) && e.bilder.length) {
const bg = el('div', 'bildstreifen');
if (e.bilder.length > 1) bg.dataset.mehrere = 'ja';
for (const b of e.bilder) {
const a = el('a', 'bildstreifen__feld');
/* GIBT ES EIN VIDEO, FÜHRT AUCH DAS BILD DORTHIN (19.09.2026).
Vorher öffnete das Bild sich selbst in voller Größe. Bei
einem Clip ist das Bild aber das Standbild des Videos — wer
darauf tippt, will das Video sehen und nicht das Standbild
größer. Genau das meinte Filipe mit „foto".
Ohne Video bleibt es beim Vollbild: Auf den anderen Brettern
ist ein Bild wirklich nur ein Bild. */
a.href = videoWeg || `/workspace/api/dateien/${b.id}/bild`;
a.target = '_blank';
a.rel = videoWeg ? 'noopener noreferrer' : 'noopener';
const bild = el('img', 'bildstreifen__bild');
bild.src = `/workspace/api/dateien/${b.id}/bild`;
bild.loading = 'lazy';
bild.decoding = 'async';
/* Der Titel des Beitrags ist die beste Beschreibung, die es
hier gibt -- ein Dateiname wie "IMG_2831.jpg" sagt niemandem
etwas.
UND DAS ZIEL GEHOERT DAZU (19.09.2026): Seit das Bild zum
Video führt, wäre „Bild zu ..." eine Falschauskunft für jeden,
der die Seite vorlesen lässt -- er würde ein Bild erwarten
und in einer fremden App landen. */
bild.alt = e.titel ? `Bild zu „${e.titel}“` : (b.name || 'Bild');
if (videoWeg) {
a.setAttribute('aria-label',
e.titel ? `Video ansehen: ${e.titel}` : 'Video ansehen');
a.title = 'Öffnet das Video in einem neuen Fenster';
}
/* Ist es doch kein Bild (oder die Datei fehlt), verschwindet
der Rahmen, statt ein kaputtes Symbol zu zeigen. */
bild.addEventListener('error', () => { a.remove(); if (!bg.childNodes.length) bg.remove(); });
a.append(bild);
bg.append(a);
}
k.append(bg);
}
/* Beim Event wandert derselbe Kopf auf die Buehne -- mit allem,
was darin steckt (Etikett, Titel, "nur DogFather", Bewertung).
Ihn dort neu zu bauen hiesse, jede dieser Sonderregeln ein
zweites Mal zu pflegen. */
if (evStand) k.append(eventBuehne(e, evStand, kopf));
else k.append(kopf);
/* ---- DAS VIDEO (17.09.2026), Stufe A des Video-Plans ----------
Der Knopf ist die Antwort auf "und dann liken": Er fuehrt
dorthin, wo das Liken zaehlt. Ein Herz bei uns, das bei TikTok
nichts bewirkt, waere eine Attrappe.
`rel="noopener"` ist Pflicht: Ohne das kann die geoeffnete Seite
ueber `window.opener` auf unsere zugreifen. */
if (e.quelle_url) {
const z = el('div', 'videozeile');
if (e.quelle_kanal) {
const k = el('span', 'videozeile__kanal',
KANAL_WORT[e.quelle_kanal] || e.quelle_kanal);
k.dataset.kanal = e.quelle_kanal;
z.append(k);
}
/* IST DAS VIDEO BEI TIKTOK NOCH DA? (Stufe 5, 18.09.2026)
Wird es dort geloescht oder auf privat gestellt, bleibt der
Eintrag hier stehen. Der Knopf fuehrt dann auf eine
Fehlerseite -- und wer darauf tippt, haelt UNS fuer kaputt.
Der Knopf verschwindet trotzdem nicht: Ein Eintrag, bei dem
wortlos etwas fehlt, wirft die Frage auf, ob man sich
taeuscht. Es steht jetzt einfach da. Das Cover bleibt
ohnehin -- es liegt bei uns, nicht bei TikTok. */
if (e.quelle_fehlt_seit) {
const f = el('span', 'videozeile__weg videozeile__fort',
'Bei TikTok nicht mehr da');
f.title = 'Seit ' + (e.quelle_fehlt_seit || '').slice(0, 10)
+ ' antwortet TikTok zu diesem Video nicht mehr.';
z.append(f);
} else {
const a = el('a', 'schritt videozeile__weg', 'Auf TikTok ansehen →');
a.href = e.quelle_url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
z.append(a);
}
k.append(z);
}
/* ---- DIE KETTE (17.09.2026), Stufe 8 des Plans -----------------
Woher kam das hier, und was ist daraus geworden? Beides steht
auf der Karte, nicht in einem Detail: Der Zusammenhang ist die
Aussage. Ein Clip, unter dem steht „Euer Wunsch — von Lena", ist
etwas anderes als ein Clip. */
if (e.aus) {
const z = el('p', 'kette kette--her');
z.append(el('span', 'kette__wort', 'Aus'));
/* ==== NICHT DENSELBEN TEXT ZWEIMAL (24.09.2026) ==============
Hier stand immer "Brett: voller Titel der Quelle". Bei einem
Eintrag, der AUS einem anderen entstanden ist, traegt er
meistens denselben Titel -- und dann stand auf Filipes
Bildschirmfoto ein langer Satz als Ueberschrift und darunter
derselbe lange Satz noch einmal.
Die Zeile soll sagen, WOHER etwas kam. Wenn der Titel schon
darueber steht, ist der Brettname die ganze Auskunft; der
Rest ist Abschrift.
Verglichen werden die ersten vierzig Zeichen und nicht der
ganze Text: Beim Uebernehmen wird manchmal ein Wort
angehaengt oder ein Doppelpunkt gesetzt, und zwei Titel, die
sich nur darin unterscheiden, sind fuer den Leser derselbe. */
const brett = BRETT_WORT[e.aus.bereich] || e.aus.bereich;
const eigen = String(e.titel || '').trim();
const quelle = String(e.aus.titel || '').trim();
const anfang = (t) => t.slice(0, 40).toLowerCase();
const dasselbe = !!quelle && !!eigen
&& (quelle === eigen || anfang(quelle) === anfang(eigen));
/* Und auch ein ANDERER Titel wird nicht zum Absatz: Diese Zeile
ist ein Verweis, kein Zitat. Geschnitten wird an einer
Wortgrenze -- mitten im Wort abzubrechen liest sich wie ein
Fehler. */
const kurz = (t) => {
if (t.length <= 60) return t;
const schnitt = t.slice(0, 60);
const luecke = schnitt.lastIndexOf(' ');
return (luecke > 30 ? schnitt.slice(0, luecke) : schnitt) + ' …';
};
const a = el('a', 'kette__weg',
dasselbe ? brett : `${brett}: ${kurz(quelle)}`);
a.href = `/workspace/bereich.html?b=${encodeURIComponent(e.aus.bereich)}`;
z.append(a);
if (e.aus.von_name) z.append(el('span', 'kette__wer', '· von ' + e.aus.von_name));
k.append(z);
}
if (Array.isArray(e.wurde) && e.wurde.length) {
const z = el('p', 'kette kette--hin');
z.append(el('span', 'kette__wort', 'Daraus wurde'));
for (const w of e.wurde) {
const a = el('a', 'kette__weg',
`${BRETT_WORT[w.bereich] || w.bereich}: ${w.titel}`);
a.href = `/workspace/bereich.html?b=${encodeURIComponent(w.bereich)}`;
z.append(a);
}
k.append(z);
}
/* DER RANGBALKEN. Eine Wunschliste ist eine Rangfolge, keine Liste
-- und eine Rangfolge sieht man erst, wenn der Abstand sichtbar
ist. Die Zahl allein beantwortet "wie viele", der Balken "wie
viel mehr als die anderen". */
if (bereich === 'wunsch' && maxStimmen > 0) {
const n = Number(e.stimmen) || 0;
const balken = el('div', 'wunsch-rang');
const fuell = el('div', 'wunsch-rang__fuellung');
fuell.style.width = `${Math.round((n / maxStimmen) * 100)}%`;
if (n === maxStimmen && n > 0) balken.dataset.spitze = 'ja';
balken.append(fuell);
/* Fuer Vorleseprogramme eine SPRACHE, kein Balken. */
balken.setAttribute('role', 'img');
balken.setAttribute('aria-label',
n === 0 ? 'Noch niemand will das auch'
: `${n} ${n === 1 ? 'Person will' : 'Personen wollen'} das auch`
+ (n === maxStimmen ? ', das ist der meistgewünschte' : ''));
k.append(balken);
}
/* ---- Agentur-Event: Zeitraum, Aufgaben, Regeln -------------------
Die Reihenfolge ist nicht beliebig. Wer auf ein Event schaut,
fragt in genau dieser Folge: Laeuft das ueberhaupt noch? Was
muss ich tun? Was gilt dabei? Steht der Zeitraum unten im Fuss
-- da, wo bei allen anderen Karten das Datum steht --, liest man
erst die Aufgaben und danach, dass es seit einer Woche vorbei
ist. */
const stand = evStand;
/* Der Zustand bleibt als Merkmal an der Karte -- daran haengt im
CSS, wie ein vorbeigelaufenes Event zuruecktritt. Die ZEILE, die
hier frueher stand ("laeuft bis 16.10.2026" plus Spanne), ist
weg: Sie steht jetzt in der Buehne, und zwar einmal. */
if (stand) k.dataset.event = stand.schluessel;
if (e.text) {
const t = el('p', 'eintrag-text');
t.append(mitFett(e.text));
k.append(t);
}
if (stand) {
/* DIE ZERLEGUNG MACHT DER SERVER (event_punkte /
event_regelpunkte). Sie hier zu wiederholen hiesse, die
Rechenvorschrift fuer den Schluessel eines Punktes an zwei
Stellen zu haben -- und ein Haken, der nach einem Umbau am
falschen Punkt haengt, faellt niemandem auf.
FAELLT DIE AUSKUNFT AUS (alte Serverfassung, leeres Feld),
bleibt der Textblock als Rueckfall stehen. Lieber eine
schlichte Liste als eine Karte, auf der die Aufgaben fehlen. */
const auf = eventAufgaben(e, stand)
|| (e.event_aufgaben ? eventRueckfall('Aufgaben', e.event_aufgaben) : null);
const reg = eventRegeln(e)
|| (e.event_regeln ? eventRueckfall('Regeln', e.event_regeln) : null);
/* BEIDE IN EINE REIHE, nicht untereinander an die Karte.
Auf 1280 px ist eine Karte rund 1160 px breit. "30
Live-Stunden" ueber diese Breite zu ziehen und darunter noch
einmal "Nur eigene Konten" verschenkt zwei Drittel der Flaeche
und macht die Karte unnoetig lang.
Wie viele Spalten daraus werden, entscheidet das CSS -- und
zwar durch MESSEN (auto-fit mit Mindestbreite), nicht durch
eine Fensterbreite in einer Zahl. Eine feste Schwelle waere
beim naechsten zusaetzlichen Block wieder falsch. */
if (auf || reg) {
const reihe = el('div', 'ev-bloecke');
if (auf) reihe.append(auf);
if (reg) reihe.append(reg);
k.append(reihe);
}
}
/* ---- STEHT IM KALENDER (17.09.2026) ------------------------------
Der Kalender zeigt seit heute, was auf den Brettern steht und
einen Termin hat. Die Verbindung war einseitig: vom Kalender
aufs Brett ja, zurueck nicht. Am Kartenfuss stand bloss ein
Datum -- und „24.09.2026" sieht aus wie „17.09.2026", obwohl das
eine ein Termin ist und das andere der Tag, an dem jemand
getippt hat.
OB ER DRINSTEHT, SAGT DER SERVER (`e.termin`). Hier wird es
nicht nachgerechnet: Eine zweite Fassung der Regel behauptet
irgendwann etwas, das der Kalender nicht zeigt -- und eine
Anzeige, die etwas Falsches sagt, ist schlimmer als keine.
ES IST EIN LINK, keine Marke. Wer liest „steht im Kalender",
will als Naechstes nachsehen, was noch an dem Tag ist. */
/* BEIM EVENT NICHT: Dort steht der Zeitraum schon in der Buehne.
"Im Kalender am 03.10.2026" waere die DRITTE Zeile mit demselben
Datum gewesen -- gemessen am echten Oktober-Event standen dort
"laeuft bis 16.10.", "03.10. - 16.10." und "Im Kalender am
03.10." untereinander. */
if (e.termin && !evStand) {
/* LINK NUR, WENN DER KALENDER FUER MICH OFFEN IST (19.09.2026).
Fuer die Community ist er es nicht -- `kalender.html` steht in
der Rechtetafel auf ALLE, und ALLE ist ausdruecklich ohne
"gast". Bis heute stand hier trotzdem ein Link, und ein Klick
landete wortlos auf der Startseite: sichtbar, beschriftet,
vielversprechend -- und eine Sackgasse.
GEFRAGT WIRD DER SERVER (`kalender_offen`), nicht die Rolle.
Eine Rollenabfrage hier waere eine vierte Fassung derselben
Regel; die vierte ist die, die beim naechsten Umbau etwas
durchlaesst.
DIE AUSKUNFT BLEIBT in jedem Fall stehen -- "Im Kalender am
24.09." ist auch ohne Weg dorthin richtig und nuetzlich. Es
faellt nur der Klick weg, der nirgendwohin fuehrt. */
const zeile = el(kalenderOffen ? 'a' : 'span', 'im-kalender');
zeile.dataset.grund = e.termin.grund;
if (kalenderOffen) zeile.href = 'kalender.html';
zeile.append(el('span', 'im-kalender__zeichen', '\u{1F5D3}'));
zeile.append(el('span', 'im-kalender__text',
(e.termin.grund === 'zugesagt' ? 'Zugesagt für ' : 'Im Kalender am ')
+ datum(e.termin.tag)
+ (e.uhrzeit ? ' um ' + e.uhrzeit : '')));
k.append(zeile);
}
const fuss = el('div', 'eintrag-fuss');
/* Beim Event steht der Zeitraum schon oben -- das Datum hier waere
dieselbe Zahl ein zweites Mal. Und steht der Zettel im Kalender,
steht der Tag ebenfalls schon da; dann waere das Erfassungsdatum
hier eine ZWEITE Zahl neben einer wichtigeren -- und die
verwirrt mehr, als sie sagt. */
if (!stand && !e.termin) fuss.append(el('span', null, datum(e.datum)));
if (LEITUNG.has(ich.rolle) && e.creator_name) fuss.append(el('span', null, '· ' + e.creator_name));
if (e.erstellt_name) fuss.append(el('span', null, '· von ' + e.erstellt_name));
/* ---- Der Treff -------------------------------------------------
DER VOLLE ROLLENNAME AM BEITRAG (Filipe, 11.09.2026: "voller
Rollenname zeigen"). Er kommt fertig vom Server; hier steht
keine Zuordnung Rolle -> Name, die waere eine Liste aller Rollen
dieses Hauses auf jedem Rechner der Community.
WARUM DAS UEBERHAUPT DRANSTEHT: In einem Raum mit Zuschauern und
Moderation muss man sehen, wer spricht. "Frida" und "Frida ·
jemand aus dem Team" sind zwei verschiedene Saetze -- der zweite ist eine
Ansage, der erste eine Meinung.
BEI MITGLIEDERN STEHT NICHTS DA. "Kessi · Community" waere eine
Auszeichnung fuer das Normale; der Rollenname ist hier eine
Auskunft ueber Verantwortung, nicht ueber Zugehoerigkeit. */
if (treffBrett && e.erstellt_rolle_name && e.erstellt_rolle !== 'gast') {
fuss.append(el('span', 'treff-rolle', '· ' + e.erstellt_rolle_name));
}
/* „erledigt" ANS ENDE, nicht zwischen Name und Rolle (18.09.2026).
Vorher stand dort „von Filipe · erledigt · DogFather" -- der
Rollenname gehört aber zum Namen davor, und „erledigt" ist eine
Aussage über den Eintrag, nicht über die Person. Dazwischen
gelesen wirkt es, als sei jemand erledigt. */
if (e.status === 'erledigt') fuss.append(el('span', null, '· erledigt'));
if (treffBrett && lage) {
const zeile = el('div', 'treff-knoepfe');
/* WILL ICH AUCH. Die Zahl steht AM Knopf und nicht daneben: Sie
ist das Ergebnis des Knopfes, nicht eine Angabe ueber den
Eintrag. Und sie aendert sich sofort im Bild, bevor der Server
geantwortet hat -- bei einem Knopf, der eine Meinung abgibt,
ist eine halbe Sekunde Verzoegerung der Unterschied zwischen
"hat geklappt" und "hab ich das jetzt gedrueckt?". Antwortet
der Server anders, gilt seine Zahl. */
if (bereich === 'wunsch') {
const w = el('button', 'treff-will', '');
w.type = 'button';
let meine = !!e.meine_stimme;
let zahl = Number(e.stimmen) || 0;
const male = () => {
w.textContent = (meine ? '★ ' : '☆ ') + 'Will ich auch · ' + zahl;
w.setAttribute('aria-pressed', String(meine));
};
male();
w.addEventListener('click', async () => {
w.disabled = true;
meine = !meine; zahl += meine ? 1 : -1; male();
try {
/* EIN DELETE TRAEGT HIER KEINEN KOERPER (gemessen 11.09.2026).
Der naheliegende Aufbau waere gewesen, PUT und DELETE
gleich zu behandeln -- gleicher Kopf, gleicher leerer
Koerper. Das geht NICHT: Nodes HTTP-Parser weist ein
DELETE mit Koerper mit einem leeren 400 ab, noch bevor
Express die Anfrage sieht. Man sucht den Fehler dann in
der Route, und dort ist keiner.
Nachgemessen: PUT mit Koerper -> 200, DELETE ohne
Koerper -> 200, DELETE mit Koerper -> 400 mit genau
einem Kopffeld ("connection: close"). Das ist die
Handschrift des Parsers, nicht die einer Anwendung. */
const a = await hole('/workspace/api/treff/eintrag/' + e.id + '/willich',
meine
? { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: '{}' }
: { method: 'DELETE' });
if (a.ok) { const j = await a.json(); zahl = j.stimmen; meine = j.meine; male(); }
} catch { /* umgeleitet */ } finally { w.disabled = false; }
});
zeile.append(w);
}
/* MELDEN. Nur fuer die Community und nur an fremden Beitraegen --
den eigenen kann man loeschen, das ist der kuerzere Weg.
Der Grund ist Pflicht (DSA Art. 16); der Server prueft ihn
noch einmal, die Abfrage hier erspart nur den Fehlschlag. */
/* NICHT AN BEITRAEGEN DES TEAMS (18.09.2026).
Unter jeder Regel stand „melden" -- auch unter „Achtzehn plus
bleibt draussen". Man meldet keine Regel: Sie ist der Text des
Teams selbst, und ein Knopf darunter legt nahe, das Team
moderiere sich gegenseitig. Auf „Regeln & Hilfe" standen so
siebzehn Melden-Knoepfe, von denen keiner einen Sinn hatte.
Wer mit dem Team reden will, findet den Weg unter „Hilfe" --
dort steht, wie man DogFather und die Moderation erreicht.
IM ZWEIFEL BLEIBT DER KNOPF: Fehlt die Rollenangabe (alte
Eintraege), gilt der Beitrag nicht als Team-Beitrag. Lieber
ein Knopf zu viel als ein fehlender Meldeweg. */
const vomTeam = !!e.erstellt_rolle && e.erstellt_rolle !== 'gast';
if (!lage.team && e.erstellt_von !== ich.id && !vomTeam) {
zeile.append(tuKnopf('melden', async () => {
const a0 = await frageNach({
titel: 'Diesen Beitrag melden?',
was: 'Das Team sieht sich den Beitrag an. Du bleibst dabei ungenannt.',
bleibt: 'Der Beitrag bleibt vorerst stehen – gemeldet heißt noch '
+ 'nicht entfernt.',
ja: 'Melden',
grund: 'Was ist daran nicht in Ordnung?', grundPflicht: true,
platzhalter: 'Bitte in einem Satz – sonst können wir es nicht prüfen.',
});
if (!a0) return;
const grund = a0.grund;
const a = await hole('/workspace/api/treff/eintrag/' + e.id + '/melden', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grund }),
});
melde(a.ok ? 'Danke – wir sehen uns das an.'
: (sagWas((await a.json().catch(() => ({}))).fehler, 'Ging nicht.')));
}));
}
if (lage.team) {
/* ANHEFTEN. Hoechstens drei; die Zahl kommt vom Server
(lage.anschlag_max), damit sie nicht an zwei Stellen steht. */
if (bereich === 'anschlag') {
const dran = !!e.angeheftet;
zeile.append(tuKnopf(dran ? 'abnehmen' : 'anheften', async () => {
const a = await hole('/workspace/api/treff/eintrag/' + e.id + '/anheften',
dran
? { method: 'DELETE' }
: { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: '{}' });
if (!a.ok) {
melde(sagWas((await a.json().catch(() => ({}))).fehler,
'Es hängen schon ' + lage.anschlag_max + ' Ansagen.'));
return;
}
await laden();
}));
}
/* FREIGEBEN. Bei Terminen heisst es "im Rudel zeigen", bei
Highlights "freigeben" -- derselbe Vorgang, zwei Woerter,
weil es zwei verschiedene Entscheidungen sind. */
if (bereich === 'ansteht' || bereich === 'highlight') {
const frei = !!e.freigegeben;
const wort = bereich === 'ansteht'
? (frei ? 'nicht mehr zeigen' : 'im Rudel zeigen')
: (frei ? 'zurückziehen' : 'freigeben');
if (!frei) {
const m = el('span', 'marke-art marke-art--nur', 'noch nicht sichtbar');
m.title = 'Die Community sieht das erst nach der Freigabe.';
zeile.append(m);
}
zeile.append(tuKnopf(wort, async () => {
const a = await hole('/workspace/api/treff/eintrag/' + e.id + '/freigeben',
frei
? { method: 'DELETE' }
: { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: '{}' });
if (!a.ok) { melde('Ging nicht.'); return; }
await laden();
}));
}
/* AUS "MITMACHEN" WIRD EIN TALENT. Ein ausdruecklicher Schritt
und keine Automatik: Wer sich meldet, ist ein Kandidat --
dass er einer wird, entscheidet ein Mensch. */
if (bereich === 'mitmachen' && e.erstellt_rolle === 'gast') {
zeile.append(tuKnopf('zu den Talenten', async () => {
if (!await frageNach({
titel: '„' + e.titel + '“ zu den Talenten übernehmen?',
was: 'Die Person erscheint danach in der Talente-Liste.',
bleibt: 'Der Beitrag hier bleibt stehen. Übernehmen heißt noch '
+ 'keine Zusage – es entscheidet weiterhin ein Mensch.',
ja: 'Übernehmen',
})) return;
const a = await hole('/workspace/api/treff/mitmachen/' + e.id + '/uebernehmen',
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
const j = await a.json().catch(() => ({}));
melde(a.ok ? 'Steht jetzt bei den Talenten.'
: (j.fehler === 'schon_uebernommen' ? 'Steht dort schon.'
: (sagWas(j.fehler, 'Ging nicht.'))));
}));
}
/* ENTFERNEN MIT GRUND (DSA Art. 17). Eigener Knopf neben dem
gewohnten "loeschen" -- denn "loeschen" gibt es weiterhin
fuer den eigenen Beitrag, und der braucht keine Begruendung. */
if (e.erstellt_von !== ich.id) {
zeile.append(tuKnopf('entfernen', async () => {
const a1 = await frageNach({
titel: '„' + e.titel + '“ entfernen?',
was: 'Der Beitrag verschwindet vom Brett.',
bleibt: 'Der Grund wird festgehalten und der Person mitgeteilt – '
+ 'sie hat ein Recht darauf (DSA Art. 17).',
endgueltig: true, ja: 'Entfernen',
grund: 'Warum wird er entfernt?', grundPflicht: true,
/* WIE LANG, SAGT DER SERVER (24.09.2026). Der Dialog
liess bisher drei Zeichen durch, der Server verlangt
zehn -- wer "spam" tippte, kam durch die Nachfrage und
bekam danach eine Absage. `grund_min` steht seit dem
11.09. in /api/treff/lage und wurde nie benutzt; eine
10 hier waere die zweite Fassung derselben Zahl. */
grundMin: lage?.grund_min || 10,
platzhalter: 'z. B. Beleidigende Sprache gegenüber einem anderen Mitglied.',
});
if (!a1) return;
const grund = a1.grund;
/* DER GRUND REIST IN DER ADRESSE, nicht im Koerper -- aus
demselben gemessenen Grund wie oben. Er ist kein
Geheimnis: Er wird der Person, die den Beitrag
geschrieben hat, ohnehin mitgeteilt, und er steht im
Protokoll. */
const a = await hole('/workspace/api/bereich/' + bereich + '/' + e.id
+ '?grund=' + encodeURIComponent(grund), { method: 'DELETE' });
if (!a.ok) {
melde(sagWas((await a.json().catch(() => ({}))).fehler, 'Ging nicht.'));
return;
}
await laden();
}));
}
}
if (zeile.childNodes.length) k.append(zeile);
}
/* ---- DAS GESPRAECH (17.09.2026), Stufe 6 des Plans -------------
"Das Rudel" war eine Liste aus Karten. Der einzige Bereich, in
dem Menschen MITEINANDER reden sollen, zwang sie, nebeneinander
zu reden: Jeder schrieb eine eigene Karte, niemand antwortete
jemandem.
ANTWORTEN STEHEN EINGERUECKT UNTER IHREM BEITRAG, aelteste
zuerst -- so liest man ein Gespraech. Und nur EINE Stufe
eingerueckt: Auf 390 px ist bei der zweiten Schluss, dann steht
der Text in einer Spalte von vier Woertern. */
if (Array.isArray(e.antworten)) {
const strang = el('div', 'antworten');
for (const a of e.antworten) {
const z = el('div', 'antwort');
const kopf2 = el('p', 'antwort__kopf');
kopf2.append(el('span', 'antwort__wer', a.von_name || 'jemand'));
if (a.von_rolle_name) {
kopf2.append(el('span', 'antwort__rolle', a.von_rolle_name));
}
kopf2.append(el('span', 'antwort__wann', datum(String(a.erstellt).slice(0, 10))));
z.append(kopf2);
z.append(el('p', 'antwort__text', a.text));
/* Die eigene immer, fremde nur das Team -- was der Server
genauso entscheidet. Ein Knopf, den der Server ablehnt,
waere schlimmer als keiner. */
if (a.meine || darfEintragen()) {
const w = el('button', 'antwort__weg', 'löschen');
w.type = 'button';
w.addEventListener('click', async () => {
w.disabled = true;
const r = await hole(`/workspace/api/antwort/${a.id}`, { method: 'DELETE' });
if (!r.ok) { melde('Ging nicht.'); w.disabled = false; return; }
await laden();
});
z.append(w);
}
strang.append(z);
}
/* DAS FELD STEHT IMMER DA, nicht hinter einem "Antworten"-Knopf.
Ein Gespraech faengt nicht an, wenn man erst einen Knopf
suchen muss -- und ein leeres Feld ist die deutlichste
Einladung, die es gibt.
ABER NUR FUER DEN, DER AUCH DARF. Der Server entscheidet das
mit derselben Regel (`darfSchreiben`); ein Feld, dessen
Absenden der Server ablehnt, ist schlimmer als keins -- man
tippt umsonst und bekommt eine Absage fuer etwas, das man
angeboten bekommen hat. */
if (darfEintragen()) {
const form = el('form', 'antwort-neu');
const feld = el('input', 'antwort-neu__feld');
feld.type = 'text';
feld.maxLength = 1200;
feld.placeholder = e.antworten.length ? 'Antworten …' : 'Als Erster antworten …';
feld.setAttribute('aria-label', `Antwort auf „${e.titel}“`);
const ab = el('button', 'antwort-neu__ab', 'Senden');
ab.type = 'submit';
form.append(feld, ab);
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
const text = feld.value.trim();
if (!text) return;
ab.disabled = true;
const r = await hole(`/workspace/api/bereich/${bereich}/${e.id}/antwort`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
if (!r.ok) {
melde(sagWas((await r.json().catch(() => ({}))).fehler, 'Ging nicht.'));
ab.disabled = false; return;
}
feld.value = '';
await laden();
});
strang.append(form);
}
k.append(strang);
}
if (!darfEintragen()) { k.append(fuss); return k; }
const knoepfe = el('div', 'eintrag-knoepfe');
/* ---- DER WEG WEITER ---------------------------------------------
Die Handlung, nicht ein Auswahlfeld: Wer ein Formular ausfuellt,
denkt nicht an den Wunsch von letzter Woche. Hier steht der Knopf
an dem Wunsch, um den es geht.
Schon gegangene Wege fallen weg -- ein Knopf, der beim zweiten
Druecken nichts tut, ist eine Sackgasse. */
for (const ziel of (KETTE_ZIELE[bereich] || [])) {
if ((e.wurde || []).some((w) => w.bereich === ziel.nach)) continue;
knoepfe.append(tuKnopf(ziel.wort, async () => {
const a = await hole(`/workspace/api/bereich/${bereich}/${e.id}/daraus`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nach: ziel.nach }),
});
if (!a.ok) { melde('Ging nicht.'); return; }
await laden();
}));
}
/* ---- VIDEO DAZU (Stufe 7, 18.09.2026) --------------------------
„Ein Video aus einem Wunsch zeigt den Namen." Der Knopf steht
AN DEM WUNSCH -- aus demselben Grund wie die Kettenknoepfe
darueber: Wer oben im Formular einen Link einfuegt, denkt nicht
an den Wunsch von letzter Woche. Hier denkt er an nichts
anderes.
Er verschwindet, sobald aus dem Wunsch ein Highlight MIT Video
geworden ist. Steht dort schon ein Highlight ohne Video, bleibt
er -- der Server haengt das Video dann an den vorhandenen
Eintrag, statt ein zweites anzulegen. */
if (VIDEO_AUS.includes(bereich)
&& !(e.wurde || []).some((w) => w.bereich === 'highlight' && w.hat_video)) {
knoepfe.append(tuKnopf('Video dazu einfügen', () => {
/* Schon offen? Dann wieder zu. Ein Knopf, der beim zweiten
Druecken ein zweites Feld baut, ist ein Fehler, den man
erst sieht, wenn drei davon dastehen. */
const alt = k.querySelector('.videodazu');
if (alt) { alt.remove(); return; }
/* DIREKT UNTER DIE KNOPFREIHE, nicht ans Ende der Karte.
Zuerst stand hier `k.append` -- und das Bildschirmfoto zeigte
das Feld UNTER der Fusszeile („18.09.2026 - von Lena"),
getrennt von dem Knopf, den man gerade gedrueckt hat. Eine
Zahl haette das nie gemeldet: Das Feld war da, es reagierte,
alle Pruefungen gruen. */
knoepfe.insertAdjacentElement('afterend', videoDazuFeld(e));
}));
}
/* VIER ZUSTAENDE STATT EINES UMSCHALTERS (17.09.2026).
"erledigt / wieder offen" reicht fuer einen Vorfall. Ein Wunsch
braucht mehr: Er wird angenommen, bekommt einen Tag, wird gemacht
-- oder eben nicht. "Diesmal nicht" gehoert ausdruecklich dazu:
Ein Nein ist eine Antwort, Schweigen ist keine. */
if (einstellung?.zustaende) {
const reihe = el('div', 'wunsch-wahl');
reihe.setAttribute('role', 'group');
reihe.setAttribute('aria-label', 'Zustand setzen');
for (const [wert, name] of Object.entries(einstellung.zustaende)) {
const b = el('button', 'wunsch-wahl__knopf', name);
b.type = 'button';
b.dataset.stand = wert;
b.setAttribute('aria-pressed', String(e.status === wert));
if (e.status === wert) b.disabled = true;
b.addEventListener('click', async () => {
b.disabled = true;
const a = await hole(`/workspace/api/bereich/${bereich}/${e.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: wert }),
});
if (!a.ok) { melde('Ging nicht.'); b.disabled = false; return; }
await laden();
});
reihe.append(b);
}
knoepfe.append(reihe);
k.append(knoepfe);
k.append(fuss);
return k;
}
knoepfe.append(tuKnopf(e.status === 'offen' ? 'erledigt ▶' : '◀ wieder offen', async () => {
const a = await hole(`/workspace/api/bereich/${bereich}/${e.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: e.status === 'offen' ? 'erledigt' : 'offen' }),
});
if (!a.ok) { melde('Ändern hat nicht geklappt.'); return; }
await laden();
}));
/* AUS EINER IDEE WIRD ARBEIT (15.09.2026, Stufe 9).
HIER und nicht in der Treff-Knopfreihe: Die gehoert ganz zum
Treff (`if (treffBrett && lage)`), und dort waere der Knopf
genau am falschen Ort -- an den Beitraegen der Community, und an
keinem Arbeitsbrett. Zweimal hintereinander dort gelandet;
gefunden hat es beide Male die Pruefung im Browser mit "null
Knoepfe auf dem Ideenbrett", nicht das Lesen.
Welche Bretter Uebernahmen kennen, entscheidet der Server. Eine
zweite Liste hier waere die, die veraltet.
IST ES SCHON GESCHEHEN, steht statt des Knopfes der Verweis. Ein
Knopf, der beim zweiten Druecken "gibt es schon" sagt, laedt zum
zweiten Druecken ein. */
const schon = uebernahmen[e.id];
if (schon) {
const v = el('a', 'eintrag__aufgabe', 'Aufgabe · ' + schon.status);
v.href = 'aufgaben.html#a' + schon.aufgabe_id;
v.title = 'Aus diesem Eintrag ist eine Aufgabe geworden.';
knoepfe.append(v);
} else if (uebernahmeMoeglich) {
knoepfe.append(tuKnopf('zur Aufgabe', async () => {
const a = await hole('/workspace/api/eintrag/' + e.id + '/zur-aufgabe',
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
const j = await a.json().catch(() => ({}));
if (!a.ok) {
melde(j.fehler === 'schon_uebernommen'
? 'Daraus gibt es schon eine Aufgabe.'
: (sagWas(j.fehler, 'Ging nicht.')));
return;
}
melde('Steht jetzt im Aufgabenbrett – bei dir.');
await laden();
}));
}
/* ==== EIN BILD ANHAENGEN (24.09.2026) =========================
Filipe: "mach das man da bitte screenshots oder kurzschnitte
von den live reinposten kann. nach dem selben prinzip wie bei
den anderen nebendran."
DER KNOPF STEHT AN DER KARTE, nicht im Anlege-Formular. Das ist
der Unterschied zu "beim Schreiben ein Bild mitschicken": Ein
Bildschirmfoto faellt einem meist spaeter ein -- beim
Nachschauen, beim Erklaeren, wenn jemand fragt. Wer es nur beim
Anlegen mitgeben koennte, muesste den Beitrag loeschen und neu
schreiben.
ER ERSCHEINT NUR, WENN NOCH PLATZ IST. Drei Bilder je Beitrag
(der Server setzt die Grenze durch); beim vierten waere der
Knopf einer, der eine Absage bringt. */
if (darfEintragen() && (e.bilder || []).length < 3) {
knoepfe.append(tuKnopf('Bild anhängen', () => bildWaehlen(e)));
}
if (LEITUNG.has(ich.rolle) || e.erstellt_von === ich.id) {
knoepfe.append(tuKnopf('löschen', async () => {
/* EIN FREMDER BEITRAG AUF EINEM TREFF-BRETT BRAUCHT EINEN
GRUND (24.09.2026, DSA Art. 17).
Hier stand ein Löschen ohne Grund -- und der Server lehnt
das seit dem 11.09. ab. Gemessen an einem echten Fall:
DogFather wollte drei übernommene Vorschläge wegnehmen, die
eine Modi angelegt hatte. Der Knopf daneben ("entfernen")
konnte es, dieser antwortete mit "Löschen hat nicht
geklappt." -- ohne zu sagen, woran es lag. Zwei Knöpfe
nebeneinander, einer geht, einer nicht, und die Meldung
erklärt nichts.
`treffBrett` kommt vom Server (daten.treff), nicht aus einer
Liste hier -- eine abgeschriebene Liste der Bretter wäre die
zweite Wahrheit, die beim nächsten neuen Brett ausläuft. */
const brauchtGrund = treffBrett && e.erstellt_von !== ich.id;
const antwort = await frageNach({
titel: `„${e.titel}“ löschen?`,
was: 'Der Eintrag verschwindet vom Brett – für alle.',
bleibt: brauchtGrund
? 'Nachrichten, die dazu geschrieben wurden, gehen mit. Der Grund '
+ 'wird festgehalten und der Person mitgeteilt – sie hat ein '
+ 'Recht darauf (DSA Art. 17).'
: 'Nachrichten, die dazu geschrieben wurden, gehen mit.',
endgueltig: true, ja: 'Löschen',
...(brauchtGrund ? {
grund: 'Warum wird er gelöscht?', grundPflicht: true,
grundMin: lage?.grund_min || 10,
platzhalter: 'z. B. Gehört nicht auf dieses Brett.',
} : {}),
});
if (!antwort) return;
const grund = brauchtGrund ? (antwort?.grund || '') : '';
const a = await hole(`/workspace/api/bereich/${bereich}/${e.id}`
+ (grund ? '?grund=' + encodeURIComponent(grund) : ''), { method: 'DELETE' });
if (!a.ok) {
/* SAGEN, WORAN ES LAG. "Löschen hat nicht geklappt" schickt
einen auf die Suche; die Antwort des Servers steht da und
wurde nur weggeworfen. */
melde(sagWas((await a.json().catch(() => ({}))).fehler,
'Löschen hat nicht geklappt.'));
return;
}
await laden();
}, 'schritt--gefahr'));
}
fuss.append(knoepfe);
k.append(fuss);
return k;
}
/* Sprung vom Bericht (01.09.2026). "dringend" ist kein Filter der
Seite -- die Dringlichkeit steht als data-dringlich an der Karte,
und genau die wird hervorgehoben. "offen" dagegen IST ein Filter,
der wurde schon vor dem ersten Laden gesetzt. */
function sprungZeigen(ziel, karten) {
if (gesprungen) return;
gesprungen = true;
const was = window.zielSchluessel?.();
if (was === 'dringend') {
/* OFFEN und hoch -- genau so zaehlt der Bericht (workspace-reports.js:
"status = 'offen' AND dringlichkeit = 'hoch'"). Ohne das
status-Merkmal hebt die Seite auch erledigte dringende Eintraege
hervor: Man klickt auf "2" und bekommt drei markiert. Gefunden
von pruef-sprung.mjs -- mit blossem Auge waere das nie
aufgefallen, beide Zahlen sehen plausibel aus. */
window.zielMarkieren?.(
ziel.querySelectorAll('.eintrag-karte[data-dringlich="hoch"][data-status="offen"]'),
'Einträge, die offen und als dringend eingestuft sind');
} else if (was === 'offen') {
window.zielMarkieren?.([...karten.values()], 'Einträge, die noch offen sind',
async () => { filter = ''; await laden(); });
} else if (was.startsWith('eintrag-')) {
/* EIN EINZELNER ZETTEL (17.09.2026).
Der Kalender zeigt seit heute, was auf den Brettern steht und
einen Termin hat. Ein Klick darauf fuehrte bisher auf das
BRETT -- und dort steht der gesuchte Zettel irgendwo zwischen
zwanzig anderen. Wer ihn dann von Hand sucht, hat vom Link
nichts gehabt.
BENUTZT WIRD DER VORHANDENE WEG (`?zeigen=`, kopf.js) und
keine zweite Mechanik: Sonst springt die eine Seite hart, die
andere weich, und niemand traut dem Weg mehr -- das steht
woertlich in der Begruendung dieses Helfers. */
const k = karten.get(Number(was.slice(8)));
window.zielMarkieren?.(k ? [k] : [], 'diesen einen Eintrag',
async () => { filter = ''; await laden(); });
}
}
/* Einmal je Seitenaufruf. Sie wird an ZWEI Stellen gebraucht -- beim
Aufbau des Formulars und beim Zeichnen der Karten -- und beide
brauchen sie, BEVOR sie etwas anzeigen. Ein zweiter Abruf wäre eine
zweite Gelegenheit für zwei verschiedene Antworten auf dem selben
Bildschirm. */
async function lageHolen() {
if (!treffBrett || lage) return;
try {
const l = await fetch('/workspace/api/treff/lage', { credentials: 'same-origin' });
if (l.ok) lage = await l.json();
} catch { /* dann gilt die alte Regel, siehe darfEintragen */ }
}
/* ---- FETTSCHRIFT IM TEXT (18.09.2026) --------------------------
Im Startkatalog steht an zehn Stellen `**Hinweis**`, `**Pause**`,
`**DogFather**` -- gemeint als Hervorhebung, angezeigt als
Sternchen. Auf „Regeln & Hilfe" stand damit woertlich:
Drei Stufen: **Hinweis** (jemand aus dem Team sagt dir, was
nicht ging), **Pause** ..., **Ausschluss**.
Das sind ausgerechnet die drei Stufen, um die es geht -- und sie
sahen aus wie ein Tippfehler.
KEIN innerHTML, AUCH NICHT HIER. Der Text kommt aus der Datenbank,
und dort schreiben Menschen hinein. Gebaut wird deshalb aus
einzelnen Knoten: Was nicht zwischen zwei Sternchenpaaren steht,
wird Text und kann nichts anderes werden. Ein `<img onerror=…>`
bleibt ein Wort.
Ein einzelnes Sternchen bleibt ein Sternchen: `3 * 4` ist keine
Hervorhebung, und wer das anders faende, haette eine Regel, die
beim Rechnen zuschlaegt. */
/* AUS EINER QUELLE FUER DAS GANZE HAUS (21.09.2026).
Hier stand eine eigene kleine Fassung, die nur `**fett**` kannte
-- und sie war die EINZIGE Gestaltung im Haus. Im Chat gab es
nichts, bei den Aufgaben nichts.
Filipe: "ich will dass die leute auch immer die art von text
aussuchen können, fettgedrückt, unterstrichen und so alles ...
das überall."
Jetzt kommt sie aus textform.js -- dieselbe, die auch der Chat
benutzt. Vier Zeichen statt einem, und wer sie nicht kennt,
bekommt eine Leiste ueber dem Schreibfeld.
ALTES BLEIBT LESBAR: `**fett**` funktioniert unveraendert weiter,
es ist dort das erste der vier Zeichen. Nichts, was jemand schon
geschrieben hat, sieht ab heute anders aus.
DER RUECKFALL IST ABSICHT: Faellt textform.js aus (alte Fassung im
Zwischenspeicher), steht der Text roh da -- mit Sternchen, aber
vollstaendig. Ein leerer Eintrag waere schlimmer als ein
ungestalteter. */
function mitFett(text) {
if (window.Textform) return window.Textform.zeichne(text);
const stueck = document.createDocumentFragment();
stueck.append(document.createTextNode(String(text == null ? '' : text)));
return stueck;
}
/* ---- STARTHILFE BEIM SCHREIBEN (18.09.2026) ---------------------
Filipe: „ich will auch dass du fertige und beispiele machst fuer
die community damit die auch start hilfe haben ueberall."
Wer zum ersten Mal schreibt, steht vor einem leeren Feld. Die
Vorlagen geben einen Anfang -- mit LUECKEN, die nur der
Schreibende fuellen kann. Eine Vorlage, die man absenden KANN,
wird abgesendet; dann steht dort zehnmal derselbe Satz.
SIE VERSCHWINDEN, SOBALD JEMAND TIPPT. Hilfe, die stehen bleibt,
wenn man sie nicht mehr braucht, ist keine Hilfe mehr, sondern
etwas, das man wegklickt -- und beim naechsten Mal gleich
ignoriert. */
/* HEISST `starthilfe` UND NICHT `vorlagen`.
`vorlagenZeigen` und `#vorlagen` gab es hier schon: die Checkliste
fuer Creator auf live/community/technik. Meine gleichnamige
Funktion wurde davon ueberschrieben -- die spaetere Deklaration
gewinnt --, und im HTML standen zwei Elemente mit derselben
Kennung. Die Starthilfe erschien deshalb NIE, ohne eine einzige
Fehlermeldung. Aufgefallen ist es nur, weil ich nachgesehen habe,
ob die Knoepfe wirklich dastehen. */
let starthilfeGeholt = '';
async function starthilfeZeigen() {
const kasten = $('starthilfe');
const reihe = $('starthilfe-reihe');
if (!kasten || !reihe) return;
if (starthilfeGeholt === bereich) return;
starthilfeGeholt = bereich;
reihe.textContent = '';
kasten.hidden = true;
let liste = [];
try {
const a = await hole(`/workspace/api/bereich/${bereich}/vorlagen`);
if (!a.ok) return;
liste = (await a.json())?.vorlagen || [];
} catch { return; }
if (!liste.length) return;
for (const v of liste) {
const b = el('button', 'starthilfe__beispiel', einstellung?.arten?.[v.art] || v.art);
b.type = 'button';
/* Der volle Text als Titel-Attribut: Wer wissen will, was ihn
erwartet, muss nicht erst klicken. */
b.title = `${v.titel}\n\n${v.text}`;
b.addEventListener('click', () => {
const art = $('f-art');
if (art && [...art.options].some((o) => o.value === v.art)) {
art.value = v.art;
art.dispatchEvent(new Event('change'));
}
$('f-titel').value = v.titel;
$('f-text').value = v.text;
kasten.hidden = true;
/* Der Fokus wandert in die Ueberschrift und markiert sie: Der
erste Handgriff ist, den Platzhalter zu ersetzen. */
$('f-titel').focus();
$('f-titel').select();
});
reihe.append(b);
}
kasten.hidden = false;
/* Sobald jemand selbst schreibt, sind die Beispiele erledigt. */
for (const id of ['f-titel', 'f-text']) {
$(id)?.addEventListener('input', () => { kasten.hidden = true; }, { once: true });
}
}
function filterBauen(eintraege = []) {
const ziel = $('filter');
ziel.textContent = '';
/* ---- EIN NACHSCHLAGEWERK BRAUCHT KEINEN FILTER (18.09.2026) ----
Auf „Regeln & Hilfe" standen zwei Knopfreihen direkt
untereinander -- „Regel · Häufige Frage · Hilfe · Was passiert,
wenn" und darunter dieselben vier Wörter noch einmal. Die eine
filterte, die andere sprang zur Überschrift. Von außen sahen sie
gleich aus, und niemand konnte erraten, was der Unterschied ist.
Dort, wo nach Art gegliedert wird, ist der Filter der
überflüssige von beiden: Die Gliederung zeigt ohnehin schon
alles, sortiert und mit Überschriften. Die Sprungmarken bleiben
-- sie sind das Inhaltsverzeichnis. */
if (NACH_ART[bereich]) { filter = ''; return; }
/* ---- UND AUF EINEM LEEREN BRETT GAR KEINEN (18.09.2026) --------
Am ersten Tag steht auf jedem Brett ein Satz wie „Noch hat
niemand Hallo gesagt. Sei der Erste — ein Satz reicht." Darueber
standen fuenf Filterknoepfe: „Alle · Frage · Das war gut · …"
Filter fuer nichts.
Das ist die schlechteste Stelle fuer Bedienelemente, die ins
Leere fuehren: Wer zum ersten Mal hier ist, soll den Satz lesen
und schreiben -- nicht ueberlegen, was die sechs Knoepfe tun.
`!filter` ist wieder der wichtige Teil: Ist die Liste leer, WEIL
gefiltert wird, muss die Reihe stehen bleiben. Sonst kaeme man
nie wieder zu „Alle" zurueck. */
if (!eintraege.length && !filter) return;
const knopf = (wert, text) => {
const b = el('button', 'schritt', text);
b.type = 'button';
b.setAttribute('aria-pressed', String(filter === wert));
b.addEventListener('click', async () => { filter = wert; await laden(); });
return b;
};
ziel.append(knopf('', 'Alle'));
/* „NUR OFFENE" NUR, WENN ES ETWAS ZU FILTERN GIBT (18.09.2026).
Fuenf der sieben Community-Bretter kennen gar keine Zustaende --
ein Clip ist nicht „offen" oder „erledigt". Der Knopf stand
trotzdem ueberall und tat dort nichts. Ein Knopf, der nichts
tut, kostet das Vertrauen in den naechsten: Man drueckt ihn,
nichts passiert, und beim uebernaechsten fragt man sich, ob man
etwas falsch macht.
Dieselbe Regel wie bei der Kanalzeile: Sie erscheint nur, wenn
es mehr als eine Moeglichkeit gibt.
`|| filter === 'offen'` ist kein Beiwerk: Ist der Filter aktiv,
sieht man gerade KEINE erledigten mehr -- der Knopf wuerde unter
der eigenen Hand verschwinden, und man kaeme nicht zurueck. */
const gibtErledigte = eintraege.some((e) => e.status && e.status !== 'offen');
if (gibtErledigte || filter === 'offen') ziel.append(knopf('offen', 'Nur offene'));
for (const [wert, name] of Object.entries(einstellung.arten)) ziel.append(knopf(wert, name));
/* ---- UND DIE KANALZEILE, wenn hier Videos liegen ----------------
SIE ERSCHEINT NUR, WENN ES ETWAS ZU FILTERN GIBT. Die Kanäle
werden aus den vorhandenen Einträgen abgeleitet, nicht
aufgezählt: Auf einem Brett ohne Videos steht keine leere Leiste,
und auf einem mit nur einem Kanal steht kein Filter, der nichts
tut. Ein Knopf, der immer dasselbe zeigt, macht misstrauisch.
ERST AB ZWEI KANÄLEN. Bei einem gibt es nichts zu wählen. */
/* ---- DIE ZEITLEISTE, NUR AUF DEN HIGHLIGHTS (20.09.2026) -------
Filipe: "die videos will ich auch kategorisiert haben nach
woche, monat, jahr und ganz oben aktuell also heute."
SIE STEHT VOR der Kanal-Abbruchstelle darunter. Dort steigt die
Funktion aus, wenn es weniger als zwei Kanaele gibt -- stuende
die Zeitleiste dahinter, waere sie auf einem Brett mit nur
einem Account nicht da. Und genau dann braucht man sie:
Ein Kanal, viele Monate.
JEDER KNOPF TRAEGT SEINE ZAHL. Ohne sie klickt man auf
"Aktuell" und landet auf einer leeren Seite, ohne zu wissen, ob
der Filter greift oder nichts passiert ist. Mit ihr sieht man
vorher, dass heute nichts dazugekommen ist -- und klickt gar
nicht erst. */
if (bereich === 'highlight' && eintraege.length) {
const zeile = el('div', 'filter__zeiten');
zeile.setAttribute('role', 'group');
zeile.setAttribute('aria-label', 'Nach Zeitraum filtern');
const zknopf = (wert, text) => {
const b = el('button', 'schritt filter__zeit', text);
b.type = 'button';
if (wert) b.dataset.zeit = wert;
b.setAttribute('aria-pressed', String(zeitFilter === wert));
const ab = zeitAb(wert);
const zahl = wert
? eintraege.filter((e) => entstandAm(e) >= ab).length
: eintraege.length;
b.append(el('span', 'filter__zeit-zahl', String(zahl)));
/* Ein Zeitraum ohne einen einzigen Eintrag laesst sich nicht
waehlen. Er bleibt aber STEHEN, mit seiner Null -- ihn
auszublenden hiesse, die Leiste wechselt ihre Form, sobald
jemand etwas einstellt, und man sucht den Knopf, den man
gestern noch hatte. */
if (!zahl && wert) { b.disabled = true; b.title = 'Dafür gibt es nichts'; }
b.addEventListener('click', async () => {
/* Zweiter Klick hebt auf -- wie beim Kanalfilter daneben.
Zwei Filter nebeneinander, die sich verschieden bedienen
lassen, sind einer zu viel. */
zeitFilter = (zeitFilter === wert) ? '' : wert;
await laden();
});
return b;
};
zeile.append(zknopf('heute', ZEIT_WORT.heute));
zeile.append(zknopf('woche', ZEIT_WORT.woche));
zeile.append(zknopf('monat', ZEIT_WORT.monat));
zeile.append(zknopf('jahr', ZEIT_WORT.jahr));
zeile.append(zknopf('', 'Alles'));
ziel.append(zeile);
}
const kanaele = [...new Set(eintraege.map((e) => e.quelle_kanal).filter(Boolean))];
if (kanaele.length < 2) { kanalFilter = ''; return; }
const zweite = el('div', 'filter__kanaele');
zweite.setAttribute('role', 'group');
zweite.setAttribute('aria-label', 'Nach Kanal filtern');
const kanalKnopf = (wert, text, zahl) => {
const b = el('button', 'schritt filter__kanal', text);
b.type = 'button';
if (wert) b.dataset.kanal = wert;
b.setAttribute('aria-pressed', String(kanalFilter === wert));
if (zahl !== null) b.append(el('span', 'filter__kanal-zahl', String(zahl)));
b.addEventListener('click', async () => {
/* Ein zweiter Klick auf denselben Kanal hebt ihn auf -- sonst
sucht man den Weg zurück zu „Alle Kanäle" und findet ihn
nicht, weil man nicht mehr weiß, dass man gefiltert hat. */
kanalFilter = (kanalFilter === wert) ? '' : wert;
await laden();
});
return b;
};
zweite.append(kanalKnopf('', 'Alle Kanäle', null));
/* IN DER REIHENFOLGE VON KANAL_WORT, nicht in der, in der die
Einträge zufällig kommen. Sonst springen die Knöpfe, sobald
jemand ein Video einfügt. */
for (const wert of Object.keys(KANAL_WORT)) {
if (!kanaele.includes(wert)) continue;
zweite.append(kanalKnopf(wert, KANAL_WORT[wert],
eintraege.filter((e) => e.quelle_kanal === wert).length));
}
ziel.append(zweite);
}
/* WAS AUS DEN EINTRAEGEN GEWORDEN IST -- einmal fuer das ganze Brett.
Bei zwanzig Ideen waeren einzelne Abfragen zwanzig Anfragen, und die
Liste stuende zwanzigmal kurz falsch da, bevor sie stimmt. */
let uebernahmen = {};
let uebernahmeMoeglich = false;
async function uebernahmenLaden() {
try {
const a = await fetch(`/workspace/api/bereich/${encodeURIComponent(bereich)}/uebernahmen`,
{ credentials: 'same-origin' });
if (!a.ok) { uebernahmen = {}; uebernahmeMoeglich = false; return; }
const d = await a.json();
uebernahmen = d.uebernahmen || {};
/* OB DER KNOPF HINGEHOERT, ENTSCHEIDET DER SERVER. Eine zweite
Liste der Bretter hier im Browser waere die, die veraltet --
derselbe Fehler wie bei der Rollenreihenfolge am 11.09. */
uebernahmeMoeglich = d.moeglich === true;
} catch { uebernahmen = {}; uebernahmeMoeglich = false; }
}
/* ---- EIN VIDEO EINLESEN ---------------------------------------
Link einfuegen, den Rest macht der Server. Nur bei den Highlights:
Dort gehoeren Clips hin, und ein Feld an jedem anderen Brett waere
eine Einladung, sie ueberall hinzuschreiben. */
/* Aus welchen Brettern heraus ein Video eingefuegt werden kann.
„mitmachen" und „regeln" stehen bewusst nicht dabei: Dort geht es
nicht um Inhalte, aus denen ein Clip wird. */
const VIDEO_AUS = ['wunsch', 'ansteht'];
/** Das Eingabefeld, das direkt an der Karte aufgeht. */
function videoDazuFeld(e) {
const kasten = el('div', 'videodazu');
const feld = el('input', 'videodazu__feld');
feld.type = 'url';
feld.placeholder = 'TikTok-Link einfügen';
/* Ohne Beschriftung waere das Feld fuer eine Vorleseansage ein
Kaestchen ohne Bedeutung. Sichtbar ist der Knopftext daneben. */
feld.setAttribute('aria-label', 'TikTok-Link zu diesem Eintrag');
const senden = el('button', 'schritt videodazu__senden', 'Übernehmen');
senden.type = 'button';
const sagt = el('p', 'videodazu__sagt');
senden.addEventListener('click', async () => {
const url = feld.value.trim();
if (!url) { feld.focus(); return; }
senden.disabled = true;
sagt.textContent = 'Wird geholt …';
try {
const a = await hole('/workspace/api/bereich/highlight/video', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, aus: e.id }),
});
const r = await a.json().catch(() => ({}));
if (!a.ok) {
/* Der Grund des Servers, nicht „Ging nicht" -- er weiss, ob
der Account fremd war oder die Adresse keine ist. */
sagt.textContent = sagWas(r.fehler, 'Ging nicht.');
senden.disabled = false;
return;
}
await laden();
} finally { senden.disabled = false; }
});
feld.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') { ev.preventDefault(); senden.click(); }
});
kasten.append(feld, senden, sagt);
/* Der Fokus wandert ins Feld: Wer den Knopf gedrueckt hat, will
tippen, nicht erst noch zielen. */
setTimeout(() => feld.focus(), 0);
return kasten;
}
function videoFeldZeigen() {
const ziel = $('videoholen');
if (!ziel) return;
ziel.hidden = !(bereich === 'highlight' && darfEintragen());
/* DIE ABLAGE WAEHLEN (22.09.2026).
Gefuellt wird aus derselben Liste wie das normale Formular
(`kaneleDa` kommt vom Server) -- eine zweite Aufzaehlung hier
waere die, die beim naechsten Kanal vergessen wird.
"Aus dem Link" bleibt die Vorgabe. Der Server erkennt den
Account ohnehin; die Wahl ist fuer die Faelle, in denen das
Erkannte nicht das Gewuenschte ist -- ein Ausschnitt vom
Hauptkanal gehoert oft unter "Clips". */
const wohin = $('video-kanal');
if (wohin && !ziel.hidden
&& wohin.dataset.stand !== String(kanaeleDa.length)) {
wohin.dataset.stand = String(kanaeleDa.length);
wohin.textContent = '';
const auto = document.createElement('option');
auto.value = '';
auto.textContent = 'Aus dem Link erkennen';
wohin.append(auto);
for (const k of kanaeleDa) {
const o = document.createElement('option');
o.value = k.wert;
o.textContent = k.name;
wohin.append(o);
}
}
}
function videoFeldBedienen() {
const form = $('video-form');
if (!form || form.dataset.bereit === 'ja') return;
form.dataset.bereit = 'ja';
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
const feld = $('video-url');
const knopf = $('video-holen');
const url = feld.value.trim();
if (!url) return;
knopf.disabled = true;
/* "Wird geholt" statt eines stillen Wartens: Der Server fragt
TikTok, und das dauert einen Moment. Ein Knopf, der nichts tut
und nichts sagt, wird ein zweites Mal gedrueckt. */
const hinweis = $('video-hinweis');
const alterText = hinweis?.textContent || '';
if (hinweis) hinweis.textContent = 'Wird geholt …';
try {
const a = await hole(`/workspace/api/bereich/${bereich}/video`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, kanal: $('video-kanal')?.value || '' }),
});
const r = await a.json().catch(() => ({}));
if (!a.ok) {
melde(sagWas(r.fehler, 'Ging nicht.'));
if (hinweis) hinweis.textContent = alterText;
knopf.disabled = false;
return;
}
feld.value = '';
if (hinweis) hinweis.textContent = r.schon
? 'Das Video war schon da.' : alterText;
await laden();
} finally { knopf.disabled = false; }
});
}
/* ---- DER STARTKATALOG ------------------------------------------------
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?ab=${katalogAb}`,
{ credentials: 'same-origin' });
if (!a.ok) { ziel.hidden = true; return; }
daten = await a.json();
} catch { ziel.hidden = true; return; }
if (!daten?.offen) { ziel.hidden = true; return; }
ziel.hidden = false;
ziel.textContent = '';
const kopf = el('div', 'startkatalog__kopf');
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'} `
+ (daten.mehr_da
? 'vorbereitet. Hier stehen die nächsten – übernimm, was passt.'
: 'vorbereitet. Übernehmen, was passt – ändern kannst du danach alles.')));
/* EIN VORSCHLAG IST EIN VORSCHLAG (21.09.2026).
Filipe: „immer wenn es um was geht was ich machen soll ... dass
ich immer am ende entscheide ob ich was mache oder nicht. ich
kann immer annehmen oder ablehnen."
WARUM DAS HIER STEHT UND NICHT AUF JEDER KARTE: Auf den Karten
stehen Fragen wie „Was soll DogFather im nächsten Stream
unbedingt machen?". Wer so gefragt wird, schreibt etwas hin und
rechnet damit. Passiert es nicht, sieht es aus wie ein
gebrochenes Versprechen — obwohl nie eines gegeben wurde. Der
Satz steht deshalb ÜBER den Karten, wo man ihn liest, bevor man
die erste antippt.
Viermal derselbe Satz nebeneinander wäre das Gegenteil: Eine
Zeile, die überall steht, liest niemand mehr.
DER TEXT KOMMT VOM SERVER. Er steht dort an derselben Liste wie
die Vorschläge selbst — eine Abschrift hier wäre die zweite
Fassung, und die erste, die veraltet. */
if (daten.hinweis) {
kopf.append(el('p', 'startkatalog__entscheidung', daten.hinweis));
}
const alle = el('button', 'schritt startkatalog__alle',
`Alle ${daten.offen} übernehmen`);
alle.type = 'button';
alle.addEventListener('click', async () => {
alle.disabled = true;
const a = await hole(`/workspace/api/bereich/${bereich}/start`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
});
if (!a.ok) { melde('Ging nicht.'); alle.disabled = false; return; }
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');
for (const v of daten.vorschlaege) {
if (v.schon) continue;
const k = el('div', 'startkatalog__karte');
k.append(el('span', 'marke-art', einstellung?.arten?.[v.art] || v.art));
k.append(el('h3', 'startkatalog__name', v.titel));
if (v.text) {
/* Auch hier -- sonst sieht das Team im Vorschlag Sternchen und
auf dem Brett danach Fettschrift. Zwei Darstellungen
desselben Textes sind eine zu viel. */
const vt = el('p', 'startkatalog__text');
vt.append(mitFett(v.text));
k.append(vt);
}
const b = el('button', 'schritt', 'Übernehmen');
b.type = 'button';
b.addEventListener('click', async () => {
b.disabled = true;
const a = await hole(`/workspace/api/bereich/${bereich}/start`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nr: v.nr }),
});
if (!a.ok) { melde('Ging nicht.'); b.disabled = false; return; }
await laden();
});
k.append(b);
liste.append(k);
}
ziel.append(liste);
}
/* =====================================================================
DER NAECHSTE SCHRITT (17.09.2026)
Der Server schickt entweder einen fertigen Block oder nichts. Diese
Funktion weiss nicht, worum es geht -- und genau das ist der Punkt:
Die Frage "darf diese Person?" haengt an der Rolle, und diese Datei
kann ohne Anmeldung aus dem offenen Netz gelesen werden.
Beim naechsten Bereich, der so einen Schritt braucht, ist hier
nichts zu tun.
===================================================================== */
function weiterZeigen(w) {
const kasten = $('weiter');
if (!kasten) return;
kasten.textContent = '';
if (!w || !w.ziel) { kasten.hidden = true; return; }
kasten.hidden = false;
kasten.dataset.neu = 'ja';
const text = el('div');
text.append(el('p', 'bw-tor__wort', w.titel || ''));
if (w.text) text.append(el('p', 'leise', w.text));
const hin = el('a', 'knopf', w.knopf || 'Weiter');
hin.href = w.ziel;
kasten.append(text, hin);
}
async function laden() {
try {
await uebernahmenLaden();
const a = await hole(`/workspace/api/bereich/${bereich}`);
if (!a.ok) { melde('Einträge konnten nicht geladen werden.'); return; }
const daten = await a.json();
einstellung = daten.einstellung;
kanaeleDa = Array.isArray(daten.kanaele) ? daten.kanaele : [];
treffBrett = !!daten.treff;
kalenderOffen = daten.kalender_offen !== false;
weiterZeigen(daten.weiter);
await lageHolen();
filterBauen(daten.eintraege || []);
const ziel = $('liste');
ziel.textContent = '';
ziel.setAttribute('aria-busy', 'false');
/* HIGHLIGHTS SIND EINE GALERIE, keine Spalte aus Karten. Das
Raster steht hier und nicht fest im HTML, weil dieselbe Seite
acht Bretter bedient -- ein Raster in der Datei waere fuer
sieben davon falsch. */
/* SEIT DEN KANALSPALTEN LIEGT DAS RASTER EINE EBENE TIEFER
(20.09.2026). `#liste[data-galerie]` ist selbst ein Raster --
der Spaltenkasten wurde darin zu EINER Zelle von 376 px, und
die drei Accounts standen untereinander statt nebeneinander.
Gemessen, nicht vermutet: Elternbreite 1160, Kind 376.
Die Galerie gehoert jetzt in die einzelne Spalte (siehe
`.kanalspalte__inhalt[data-galerie]`). */
delete ziel.dataset.galerie;
/* DAS ANSCHLAGBRETT IST EIN SCHWARZES BRETT: wenige, GROSSE
Aushaenge. Ein Aushang zwischen dreissig anderen ist kein
Aushang -- die Wirkung entsteht durch die Groesse und die
Begrenzung. */
if (bereich === 'anschlag') ziel.dataset.aushang = 'ja';
else delete ziel.dataset.aushang;
const sichtbar = daten.eintraege.filter((e) => {
/* DER KANAL ZUERST und als eigene Bedingung: Er schließt den
Art-Filter nicht aus, er kommt dazu. „Nur offene Clips von
HasiDog" ist eine sinnvolle Frage. */
if (kanalFilter && e.quelle_kanal !== kanalFilter) return false;
/* DER ZEITFILTER GILT NUR FUER HIGHLIGHTS. Auf den anderen
Brettern steht er gar nicht erst da -- eine Regel, die
unsichtbar mitfiltert, ist ein Brett, das Dinge verschluckt. */
if (bereich === 'highlight' && zeitFilter
&& entstandAm(e) < zeitAb(zeitFilter)) return false;
if (!filter) return true;
if (filter === 'offen') return e.status === 'offen';
return e.art === filter;
});
/* DER COUNTDOWN steht ueber der Liste und rechnet mit ALLEN
Eintraegen, nicht mit der gefilterten Auswahl: Wer auf "nur
offene" stellt, will trotzdem wissen, wann der naechste Stream
ist. */
countdownZeigen(daten.eintraege);
/* NACH der Liste, nicht davor: Der Katalog verschwindet, sobald
alles uebernommen ist -- und das haengt an dem, was gerade
geladen wurde. */
startkatalogZeigen();
videoFeldZeigen();
videoFeldBedienen();
maxStimmen = (daten.eintraege || [])
.reduce((n, e) => Math.max(n, Number(e.stimmen) || 0), 0);
if (!sichtbar.length) {
/* DER SATZ GEHOERT ZUM BEREICH (17.09.2026). "Noch kein Eintrag
in diesem Bereich" stand auf allen acht gleich -- und drei
Bildschirmfotos davon nebeneinander sahen aus wie ein defektes
System. Jeder Bereich sagt jetzt in seinen eigenen Worten,
was hier hingehoert. */
ziel.append(el('p', 'leer-hinweis', daten.eintraege.length
? 'Zu dieser Auswahl gibt es nichts.'
: (einstellung?.leer || 'Noch kein Eintrag in diesem Bereich.')));
/* AUCH HIER springen -- und das ist kein Schoenheitsfehler,
sondern der wichtigere Fall: Wer auf "3 dringende Punkte"
klickt und auf einer leeren Seite landet, glaubt, er sei
falsch abgebogen. Die Leiste sagt ihm, dass er richtig ist und
die Sache erledigt. */
sprungZeigen(ziel, new Map());
return;
}
const karten = new Map();
/* ==== NICHT ALLES AUF EINMAL (23.09.2026) =====================
Filipe: "mach die ganze seite noch viel uebersichtlicher und
ohne so dass es extrem lange listen gibt mit der zeit."
Das "mit der zeit" ist der Kern. Heute liegen neun Highlights
da und alles passt; in einem Jahr sind es dreihundert, und
dann ist jede Spalte eine Rolle ohne Ende. Der Fehler faellt
erst auf, wenn er schon laestig ist.
AN EINER STELLE FUER ALLE DREI FORMEN. Die Seite legt Karten
an drei Stellen in einen Kasten -- Kanalspalten, Abschnitte
nach Art, Zeitstrahl. Dreimal dasselbe hinzuschreiben hiesse,
dass beim naechsten Umbau zwei davon nachgezogen werden und
eine vergessen wird.
ZWOELF, WEIL ZWEI NEBENEINANDER PASSEN: sechs Reihen, genug um
zu sehen was zuletzt los war, ohne bis zum Anfang der Zeit zu
scrollen. Eine Zahl ist hier unvermeidlich; sie steht deshalb
EINMAL da und traegt ihren Grund.
ES VERSCHWINDET NICHTS. Der Knopf sagt, wie viele noch kommen,
und holt sie auf einen Druck. Die Zahlen in den Koepfen zaehlen
weiter ALLE -- sonst stimmten sie nicht mehr mit dem ueberein,
was der Abschnitt enthaelt.
SORTIERT IST SCHON: Der Server liefert nach Datum absteigend
(e.datum DESC, e.id DESC). "Die ersten zwoelf" sind also die
neuesten zwoelf, nicht die erstbesten. */
const AUF_EINMAL = 12;
/* ==== NACH ALTER GEGLIEDERT (24.09.2026) ======================
Filipe: "ich will da unterkategorien die man auf und zu
klappen kann, so wie heute gestern, diese woche, diesen
monate, über ein monat, über 6 monate, über ein jahr."
Das ist die bessere Antwort auf die langen Listen als die
Zahl von gestern. Zwoelf ist willkuerlich; eine Gliederung
nach Alter beantwortet die Frage, die man an so eine Liste
wirklich stellt -- "was ist neu?". Und was alt ist, steht
zugeklappt da: sichtbar, dass es existiert, ohne den Weg zu
verstellen.
DIE ERSTEN VIER STUFEN SIND KALENDERBEZOGEN, die letzten drei
altersbezogen. Das klingt uneinheitlich und ist genau richtig:
"Diese Woche" heisst seit Montag, nicht "in den letzten
sieben Tagen" -- am Dienstag ist der Freitag davor nicht
"diese Woche". Fuer alles aeltere zaehlt dagegen der Abstand,
weil "Mai" niemandem sagt, wie lange das her ist.
EINE STUFE OHNE EINTRAEGE ERSCHEINT NICHT. Sieben leere
Ueberschriften waeren schlimmer als eine lange Liste. */
const ZEITSTUFEN = [
{ schluessel: 'heute', name: 'Heute' },
{ schluessel: 'gestern', name: 'Gestern' },
{ schluessel: 'woche', name: 'Diese Woche' },
{ schluessel: 'monat', name: 'Dieser Monat' },
{ schluessel: 'aelter', name: 'Über einen Monat' },
{ schluessel: 'halb', name: 'Über sechs Monate' },
{ schluessel: 'jahr', name: 'Über ein Jahr' },
];
/** In welche Stufe faellt dieser Eintrag?
*
* GERECHNET WIRD IN ORTSZEIT, nicht in UTC. Ein Eintrag von
* gestern 23:40 waere in UTC schon heute -- und stuende dann
* unter "Heute", waehrend das Datum daneben gestern sagt.
* window.heuteLokal() ist die Stelle im Haus, die das richtig
* macht. */
function stufeVon(e) {
const roh = String(e?.datum || '').slice(0, 10);
if (!roh) return 'jahr';
const heute = window.heuteLokal
? window.heuteLokal() : new Date().toISOString().slice(0, 10);
if (roh === heute) return 'heute';
const tag = new Date(heute + 'T00:00:00');
const dann = new Date(roh + 'T00:00:00');
if (Number.isNaN(dann.getTime())) return 'jahr';
const gestern = new Date(tag); gestern.setDate(tag.getDate() - 1);
if (dann.getTime() === gestern.getTime()) return 'gestern';
/* Montag als Wochenanfang -- getDay() liefert Sonntag = 0, und
in Deutschland steht der am Ende. Dieselbe Rechnung wie in
zeitAb() weiter oben. */
const montag = new Date(tag);
montag.setDate(tag.getDate() - ((tag.getDay() + 6) % 7));
if (dann >= montag) return 'woche';
const ersterDesMonats = new Date(tag.getFullYear(), tag.getMonth(), 1);
if (dann >= ersterDesMonats) return 'monat';
const halbesJahr = new Date(tag); halbesJahr.setMonth(tag.getMonth() - 6);
const einJahr = new Date(tag); einJahr.setFullYear(tag.getFullYear() - 1);
if (dann < einJahr) return 'jahr';
if (dann < halbesJahr) return 'halb';
return 'aelter';
}
/** Die Karten eines Fachs in einen Kasten legen -- gegliedert
* nach Alter, jede Gruppe auf- und zuklappbar.
*
* "merker" unterscheidet die Gruppen zweier Spalten: Ohne ihn
* teilten sich "Heute bei DogFather" und "Heute bei HasiDog"
* denselben Zustand, und wer die eine zuklappt, klappt die
* andere mit. */
function kartenLegen(kasten, fach, merker) {
/* HIER STAND EINE SCHWELLE -- UND SIE WAR FALSCH (24.09.2026).
"if (fach.length <= AUF_EINMAL) { stapelLegen(...); return; }"
Begruendung damals: "Bei wenigen Eintraegen waere eine
Gliederung Aufwand ohne Nutzen: sieben Ueberschriften fuer
fuenf Karten."
Das klang vernuenftig und war trotzdem falsch, aus zwei
Gruenden. Erstens hat Filipe die Unterkategorien verlangt,
nicht eine Bedingung dafuer -- eine eigene Einschraenkung
an eine Ansage zu haengen ist keine Sorgfalt, sondern eine
Entscheidung, die mir nicht zusteht. Zweitens stimmte die
Begruendung gar nicht: Leere Stufen erscheinen ohnehin
nicht (siehe "continue" weiter unten), also gibt es bei
fuenf Karten nie sieben Ueberschriften, sondern hoechstens
so viele, wie es wirklich verschiedene Alter gibt. Meist
eine, manchmal zwei.
Gemerkt hat es Filipe, nicht ich: Seine Spalten haben 3, 1,
1 und 4 Eintraege -- die Schwelle lag bei 13. Er hat die
Gliederung also nie zu sehen bekommen. */
const faecher = new Map(ZEITSTUFEN.map((s) => [s.schluessel, []]));
for (const e of fach) {
const s = stufeVon(e);
(faecher.get(s) || faecher.get('jahr')).push(e);
}
let erste = true;
for (const stufe of ZEITSTUFEN) {
const drin = faecher.get(stufe.schluessel) || [];
if (!drin.length) continue; /* leere Stufe: gar nicht erst */
const kopf = el('button', 'zeitgruppe__kopf');
kopf.type = 'button';
kopf.append(el('span', 'zeitgruppe__wort', stufe.name));
kopf.append(el('span', 'zeitgruppe__zahl', String(drin.length)));
if (window.klappPfeil) kopf.append(window.klappPfeil());
kasten.append(kopf);
const innen = el('div', 'zeitgruppe__inhalt');
innen.dataset.galerie = 'ja';
stapelLegen(innen, drin);
kasten.append(innen);
/* DIE NEUESTE GRUPPE STEHT OFFEN, die uebrigen zu. Wer die
Seite aufmacht, will sehen was neu ist -- und nicht erst
etwas aufklappen muessen. Alles Aeltere ist sichtbar
VORHANDEN, ohne den Weg zu verstellen.
Die eigene Wahl gewinnt: abschnittKlappbar merkt sich je
Gruppe, was zuletzt eingestellt war. */
/* Der vierte Wert ist ZU-Vorgabe, kein Objekt -- nachgesehen
in kopf.js, nicht geraten. Die erste Gruppe steht offen
(zu = false), alle weiteren zu. */
window.abschnittKlappbar?.(kopf, innen,
'zeit:' + merker + ':' + stufe.schluessel, !erste);
erste = false;
}
}
/** Ein Stapel Karten mit der Grenze bei zwoelf -- der Rest auf
* einen Druck. Gilt jetzt JE GRUPPE: Auch "Über ein Jahr" kann
* dreihundert Eintraege haben, und dann hilft die Gliederung
* allein nicht. */
function stapelLegen(kasten, fach) {
for (const e of fach.slice(0, AUF_EINMAL)) {
const k = karte(e);
karten.set(e.id, k);
kasten.append(k);
}
const rest = fach.slice(AUF_EINMAL);
if (!rest.length) return;
const mehr = el('button', 'mehr-zeigen',
rest.length === 1 ? 'Einen älteren zeigen'
: rest.length + ' ältere zeigen');
mehr.type = 'button';
mehr.addEventListener('click', () => {
for (const e of rest) {
const k = karte(e);
karten.set(e.id, k);
kasten.insertBefore(k, mehr);
}
/* Der Knopf hat seine Arbeit getan. Stehen zu lassen hiesse,
einen anzubieten, der nichts mehr tut. */
mehr.remove();
});
kasten.append(mehr);
}
/* =====================================================================
DIE HIGHLIGHTS: DREI KANAELE NEBENEINANDER (20.09.2026)
Filipe: "die highlights sollen in 3 kategorien sein
nebeneinander, dogfather, hasidog und dogfather clips. und die
liste soll man auch zu und auf klappen koennen."
WARUM DAS MEHR IST ALS EINE SORTIERUNG: Die drei Accounts haben
eigene Zuschauer und einen eigenen Ton -- das steht so an
KANAELE im Server. Eine gemeinsame Galerie behauptet, sie
waeren ein Topf. Wer fuer Clips schneidet, sucht dann in jeder
Reihe nach den Kacheln, die ihn angehen.
DIE REIHENFOLGE KOMMT VOM SERVER, nicht aus einer Liste hier.
Der Browser hatte dafuer bis heute `KANAL_WORT` -- dieselben
drei Namen in anderer Reihenfolge, ohne Handles. Beim vierten
Account waere genau diese Kopie die vergessene.
EINE VIERTE SPALTE GIBT ES NUR, WENN SIE GEBRAUCHT WIRD:
Bilder und Fanart ohne TikTok-Link gehoeren zu keinem Kanal.
Sie verschwinden zu lassen waere der schlimmere Fehler -- ein
Eintrag, den man angelegt hat und nirgends wiederfindet.
ZUKLAPPEN ueber dieselben zwei Hilfen wie ueberall
(`abschnittKlappbar`, `klappPfeil`), mit einem Merker je Kanal.
Ein zweiter Klappmechanismus waere eine zweite Sache zum
Lernen und eine zweite zum Kaputtgehen.
===================================================================== */
if (bereich === 'highlight') {
const kanaele = kanaeleDa.length
? kanaeleDa
/* Faellt die Auskunft aus (alte Auslieferung, fremde Rolle),
wird aus den Eintraegen selbst abgeleitet -- lieber die
Namen ohne Reihenfolge als eine leere Seite. */
: [...new Set(sichtbar.map((e) => e.quelle_kanal).filter(Boolean))]
.map((w) => ({ wert: w, name: KANAL_WORT[w] || w }));
const faecher = new Map(kanaele.map((k) => [k.wert, []]));
const ohne = [];
for (const e of sichtbar) {
if (e.quelle_kanal && faecher.has(e.quelle_kanal)) faecher.get(e.quelle_kanal).push(e);
else ohne.push(e);
}
const spalten = el('div', 'kanalspalten');
const bauen = (schluessel, name, handle, fach, satz) => {
const sp = el('section', 'kanalspalte');
sp.dataset.kanal = schluessel || 'ohne';
const kopf = el('button', 'kanalspalte__kopf');
kopf.type = 'button';
const wort = el('span', 'kanalspalte__wort', name);
if (handle) wort.append(el('span', 'kanalspalte__handle', handle));
kopf.append(wort);
kopf.append(el('span', 'kanalspalte__zahl', String(fach.length)));
if (window.klappPfeil) kopf.append(window.klappPfeil());
sp.append(kopf);
const kasten = el('div', 'kanalspalte__inhalt');
/* EIN SATZ, DER SAGT, WAS ZU TUN IST. Nur wo es etwas zu tun
gibt -- eine Erklaerung an jeder Spalte waere Fuellwerk. */
if (satz) {
const hinweis = el('p', 'kanalspalte__satz', satz);
kasten.append(hinweis);
}
/* Die Galerie-Form kommt hierher -- in der Spalte, nicht um
sie herum. In einer schmalen Spalte ergibt das eine Kachel
je Reihe, bei viel Platz zwei. Gerechnet wird nichts. */
kasten.dataset.galerie = 'ja';
if (!fach.length) {
/* Eine leere Spalte sagt, dass sie leer IST -- sonst sieht
sie aus, als haette die Seite nicht fertig geladen. */
kasten.append(el('p', 'leise kanalspalte__leer',
zeitFilter
? `Für ${ZEIT_WORT[zeitFilter].toLowerCase()} steht hier nichts.`
: 'Hier steht noch nichts.'));
}
kartenLegen(kasten, fach, bereich + ':kanal:' + (schluessel || 'ohne'));
sp.append(kasten);
window.abschnittKlappbar?.(kopf, kasten, `highlight:kanal:${schluessel || 'ohne'}`);
spalten.append(sp);
};
for (const k of kanaele) bauen(k.wert, k.name, k.handle, faecher.get(k.wert) || []);
/* ==== DIE VIERTE SPALTE HEISST NACH IHREM INHALT (23.09.2026)
Filipe: "der ohne account ist total sinnlos, mach was
anderes draus."
Er hat recht, und zwar aus zwei Gruenden. Erstens sagte der
Name nur, was FEHLT -- nicht, was dort liegt und schon gar
nicht, was zu tun ist. Zweitens lagen dort zwei
verschiedene Sachen:
ein CLIP ohne Account ist eine Luecke. Clips kommen von
einem Kanal; steht keiner dabei, wurde der Link nicht
erkannt oder nicht mitgegeben. Das gehoert einsortiert.
ein BILD oder FANART ohne Account ist keine Luecke. Es
gibt keinen TikTok-Beitrag dazu, und das ist in Ordnung.
Beides unter derselben Ueberschrift zu sammeln, heisst: Wer
die Zahl sieht, weiss nicht, ob er etwas tun muss. Genau das
macht sie "sinnlos".
Also entscheidet der Inhalt ueber Namen und Satz. Getrennt
wird NICHT in zwei Spalten -- das waere eine mehr, und
Filipe hat im selben Satz um weniger gebeten. */
if (ohne.length) {
const clips = ohne.filter((e) => e.art === 'clip').length;
bauen('', clips ? 'Noch einzusortieren' : 'Ohne TikTok-Quelle', null, ohne,
clips
? (clips === 1
? 'Ein Clip hier hat keinen Account. Öffne ihn und trag ihn nach – '
+ 'dann steht er bei den anderen seines Kanals.'
: `${clips} Clips hier haben keinen Account. Öffne sie und trag ihn `
+ 'nach – dann stehen sie bei den anderen ihres Kanals.')
: 'Eigene Bilder und Fanart gehören zu keinem TikTok-Account. '
+ 'Hier ist nichts zu tun.');
}
ziel.append(spalten);
sprungZeigen(ziel, karten);
return;
}
/* ---- STUFE 7: JEDES BRETT BEKOMMT SEINE FORM ------------------
"Regeln & Hilfe" ist ein NACHSCHLAGEWERK, kein Feed: Man
schlaegt es im Streitfall auf und will finden, nicht scrollen.
Also nach Art gruppiert, mit Ueberschriften -- und ganz oben
Sprungmarken.
"Mitmachen" ist ein WEG: erst die drei Schritte, dann die
Gebiete, dann die Fragen. Eine Liste beantwortet "was gibt
es"; die Frage lautet aber "was tue ich als Erstes". */
if (NACH_ART[bereich] && !filter) {
const reihe = NACH_ART[bereich];
const faecher = new Map(reihe.map((a) => [a, []]));
for (const e of sichtbar) {
if (!faecher.has(e.art)) faecher.set(e.art, []);
faecher.get(e.art).push(e);
}
/* DIE SPRUNGMARKEN ZUERST. Sie sind der Unterschied zwischen
einem Dokument und einer langen Seite. */
const marken = el('nav', 'brettmarken');
marken.setAttribute('aria-label', 'Zu einem Abschnitt springen');
/* „Springe zu" davor: Ohne diese zwei Wörter sieht eine Reihe
von Wörtern aus wie eine Reihe von Filtern -- genau die
Verwechslung, die hier behoben wird. Mit ihnen ist es ein
Inhaltsverzeichnis, und das ist es auch. */
marken.append(el('span', 'brettmarken__wort', 'Springe zu'));
for (const a of faecher.keys()) {
const anzahl = faecher.get(a).length;
if (!anzahl) continue;
const m = el('a', 'brettmarken__weg', einstellung?.arten?.[a] || a);
/* Die Anzahl gehört dazu: Sie sagt, ob sich der Sprung lohnt
-- und sie verrät, dass hier nicht gefiltert wird. */
m.append(el('span', 'brettmarken__zahl', String(anzahl)));
m.href = `#abschnitt-${a}`;
marken.append(m);
}
if (marken.childNodes.length > 2) ziel.append(marken);
for (const a of faecher.keys()) {
const fach = faecher.get(a);
if (!fach.length) continue;
/* JEDER ABSCHNITT LAESST SICH ZUKLAPPEN (19.09.2026).
Filipe: "man soll auf dieser seite jede kategorie auf und
zu klappen koennen mit einem button."
Der Kopf ist jetzt ein echter <button> und kein <h2> mit
Klickzuhoerer -- sonst waere er fuer die Tastatur nicht
erreichbar. Die Ueberschrift steckt darin, damit
Vorleseprogramme die Gliederung behalten.
Die Sprungmarke (#abschnitt-…) bleibt am Kopf: Wer aus dem
Inhaltsverzeichnis herspringt, landet weiterhin richtig --
auch wenn der Abschnitt gerade zu ist. */
const kopf2 = el('button', 'brettabschnitt');
kopf2.type = 'button';
kopf2.id = `abschnitt-${a}`;
kopf2.append(el('span', 'brettabschnitt__wort',
einstellung?.arten?.[a] || a));
kopf2.append(el('span', 'brettabschnitt__zahl', String(fach.length)));
if (window.klappPfeil) kopf2.append(window.klappPfeil());
ziel.append(kopf2);
const kasten = el('div', 'brettabschnitt__inhalt');
kartenLegen(kasten, fach, bereich + ':art:' + a);
ziel.append(kasten);
/* Der Merker haengt am BRETT und an der Art -- sonst waere
"Regel zugeklappt" auf allen Brettern gleichzeitig zu. */
window.abschnittKlappbar?.(kopf2, kasten, `${bereich}:${a}`);
}
sprungZeigen(ziel, karten);
return;
}
if (bereich === 'ansteht') {
/* ALS ZEITSTRAHL, NICHT ALS LISTE (17.09.2026).
Eine Liste beantwortet "was gibt es". Hier lautet die Frage
aber "was kommt WANN" -- und die beantwortet erst eine
Reihenfolge mit Abschnitten. Vorbei Gewesenes steht unten,
nicht dazwischen: Ein abgelaufener Termin mitten zwischen
kommenden liest sich wie ein Fehler. */
const REIHE = ['heute', 'woche', 'spaeter', 'ohne', 'vorbei'];
const faecher = new Map(REIHE.map((r) => [r, []]));
for (const e of sichtbar) faecher.get(abschnittVon(e)).push(e);
for (const r of REIHE) {
const fach = faecher.get(r);
if (!fach.length) continue;
/* Innerhalb eines Abschnitts nach Zeitpunkt, das Vergangene
umgekehrt -- das zuletzt Gewesene zuerst. */
fach.sort((a, b) => {
const za = zeitpunktVon(a)?.ms ?? 0;
const zb = zeitpunktVon(b)?.ms ?? 0;
return r === 'vorbei' ? zb - za : za - zb;
});
/* Auch hier zuklappbar -- Filipe: "ueberall wo so eine
liste entstehen kann". Vorbei Gewesenes steht dabei von
sich aus zu: Es ist Nachschlagewerk, nicht das, weswegen
jemand die Seite oeffnet. */
const kopf = el('button', 'zeitstrahl__schild');
kopf.type = 'button';
kopf.append(el('span', 'zeitstrahl__wort', ABSCHNITT_NAME[r]));
kopf.append(el('span', 'zeitstrahl__zahl', String(fach.length)));
if (window.klappPfeil) kopf.append(window.klappPfeil());
ziel.append(kopf);
const kasten = el('div', 'zeitstrahl__inhalt');
kartenLegen(kasten, fach, bereich + ':zeit:' + r);
ziel.append(kasten);
window.abschnittKlappbar?.(kopf, kasten, `ansteht:${r}`, r === 'vorbei');
}
} else if (bereich === 'anschlag') {
/* =================================================================
DAS ANSCHLAGBRETT: WAS GERADE HAENGT, UND WAS ABGEHAENGT IST
=================================================================
Filipe: „die sachen sollen in der seite vom anschlagbrett
immer automatisch nach 24h von da verschwinden. weil alles
hat seine kategorie und was nicht ist auch nicht um ewig auf
der seite zu bleiben fertig."
WER ENTSCHEIDET, IST DER SERVER. `e.abgehaengt` kommt mit der
Antwort; hier wird nur einsortiert. Die Stunden im Browser
nachzurechnen waere eine zweite Fassung derselben Regel --
und eine davon waere irgendwann aelter als die andere.
ABGEHAENGT HEISST ZUGEKLAPPT, NICHT GELOESCHT. Er sagt „von
DA verschwinden": von der Seite. Wer eine Ansage geschrieben
hat, soll sie wiederfinden; sie rueckwirkend verschwinden zu
lassen waere etwas anderes als sie abzuhaengen. Die Zeile
darunter ist leise und zu -- auf dem Brett selbst steht sie
damit nicht mehr.
================================================================= */
const haengt = sichtbar.filter((e) => !e.abgehaengt);
const ab = sichtbar.filter((e) => e.abgehaengt);
for (const e of haengt) {
const k = karte(e);
karten.set(e.id, k);
ziel.append(k);
}
/* NICHTS HAENGT MEHR, ABER ES GAB ETWAS: Dann steht der
Leersatz des Bretts nicht da (die Liste ist ja nicht leer),
und man saehe eine Seite, auf der nichts ist und nichts
erklaert wird. */
if (!haengt.length && ab.length) {
ziel.append(el('p', 'leer-hinweis',
'Gerade hängt nichts. Was hier stand, ist abgehängt – es gilt nicht mehr.'));
}
if (ab.length) {
const kopf = el('h2', 'anschlag-alt__kopf');
kopf.append(el('span', 'anschlag-alt__wort', 'Abgehängt'));
kopf.append(el('span', 'anschlag-alt__zahl', String(ab.length)));
const kasten = el('div', 'anschlag-alt');
for (const e of ab) {
const k = karte(e);
k.dataset.abgehaengt = 'ja';
karten.set(e.id, k);
kasten.append(k);
}
ziel.append(kopf);
ziel.append(kasten);
/* ZU, SOLANGE NIEMAND SIE AUFMACHT -- derselbe Klappmechanismus
wie bei „Was ansteht". Ein eigener waere ein zweiter, der
sich anders verhaelt. */
window.abschnittKlappbar?.(kopf, kasten, 'anschlag:alt', true);
}
} else {
for (const e of sichtbar) {
const k = karte(e);
karten.set(e.id, k);
ziel.append(k);
}
}
sprungZeigen(ziel, karten);
/* Bewertung und Rueckmeldungen. In EINER Abfrage fuer die ganze
Liste -- zwanzig Eintraege einzeln zu fragen waeren zwanzig
Anfragen, und die Seite ruckelte sichtbar beim Aufbau.
Nur in den Bereichen, in denen eine Beurteilung ueberhaupt
Sinn ergibt: LIVE, Community und Technik. Bei Content sind es
eigene Ideen des Creators -- die bewertet niemand mit einer
Ampel. */
/* "agentur" ist seit dem 06.09.2026 dabei, und aus demselben
Grund wie die anderen vier: Ein Anliegen an die Agentur hat
einen STAND -- eingereicht, in Klärung, beantwortet. Genau
dafür ist die Ampel da, samt der Nachrichten daran. Ohne sie
wäre der "offizielle Weg" wieder nur eine Liste, in der etwas
steht und niemand weiß, wie es ausgegangen ist. */
if (["live", "community", "technik", "schutz", "agentur"].includes(bereich)
&& window.ampelAnhaengen && karten.size) {
const stand = await window.ampelnHolen([...karten.keys()]);
for (const [id, k] of karten) {
window.ampelAnhaengen(k, id, {
ampel: stand.ampeln?.[id] || null,
anzahl: stand.anzahlNachrichten?.[id] || 0,
});
}
}
} catch { /* umgeleitet */ } finally {
/* IM finally, nicht dahinter. laden() steigt oben frueh aus,
wenn die Liste leer ist -- und dann wurden diese beiden
Bloecke nie gebaut. Also ausgerechnet auf der leeren Seite,
fuer die sie gedacht sind. Die Browserpruefung hat es sofort
gemeldet ("eine Unterweisung: 0"). */
await vorlagenZeigen();
await schulungZeigen();
}
}
/* Unterweisungen -- nur im Bereich Schutz & Regeln. Dort gehoeren
Richtlinien hin, und dort sucht sie auch jemand. */
async function schulungZeigen() {
const behaelter = $('schulung');
if (!behaelter || bereich !== "schutz" || !window.schulungBlock) return;
await window.schulungBlock(behaelter);
}
/* Fertige Punkte zum Uebernehmen -- derzeit nur fuer die LIVE-Analyse.
Sie stehen ueber der Liste: Wer noch nichts hat, soll nicht erst an
einem leeren Kasten vorbeiscrollen.
Welcher Abschnitt gezeigt wird, richtet sich nach dem gewaehlten
Filter. Ohne Filter die Vorbereitung -- das ist der Teil, ohne den
die anderen beiden gar nicht erst sinnvoll sind. */
/* Die FESTE Liste -- nicht mehr Vorschlaege zum Uebernehmen.
Der Unterschied ist grundlegend: Eine Liste, die man sich erst
holen muss, ist keine Checkliste. Und zwei Creator haetten
unterschiedliche Listen gehabt, je nachdem wer sich was geholt
hat -- was einen Vergleich unmoeglich macht, und um Vergleich
geht es bei einer Betreuung.
Jetzt stehen dieselben Punkte fuer alle fest da, gruppiert. Was
sich unterscheidet, ist der Stand je Creator. */
async function vorlagenZeigen() {
const behaelter = $('vorlagen');
if (!behaelter || !window.checkliste) return;
if (!['live', 'community', 'technik'].includes(bereich)) return;
await window.checkliste(behaelter, bereich);
}
/* Fuer wen wird uebernommen? Ein Creator immer fuer sich (der Server
setzt das ohnehin), ein Betreuer fuer den gewaehlten Creator. */
function creatorWahl() {
const w = document.getElementById('f-creator');
return w && w.value ? Number(w.value) : null;
}
/* ---------- Neuer Eintrag ---------------------------------------------- */
/* Das Formular sieht bei einem Agentur-Event anders aus als bei allem
anderen -- nicht "zusaetzlich", sondern anders: Aus "Überschrift"
wird der Titel des Events, aus "Text" die Beschreibung, aus dem
Datum ein Zeitraum, und Dringlichkeit hat hier nichts zu suchen
(ein Event ist nicht dringend, es hat einen Termin).
Umgeschaltet wird bei JEDEM Artwechsel und beim Oeffnen. Nur beim
Oeffnen zu schalten waere der bequeme Fehler: Man oeffnet mit
"Schulung", waehlt "Agentur-Events" und fuellt ein Formular aus,
das noch nach der alten Art fragt. */
/* =====================================================================
DAS EVENT-FORMULAR (03.10.2026)
Filipe: "und auch wenn man die eintraegt und so soll viel mehr
perfektioniert und uebersichtlicher sein."
WORAN ES LAG, SIEHT MAN AM ERGEBNIS. Im echten Oktober-Event stand
auf der Karte:
.Jeden Tag Live gehen
.22-Live-Stunden erreichen
. 33k Diamanten erreichen
Einmal mit Leerzeichen hinter dem Punkt, zweimal ohne. Das ist
keine Unachtsamkeit, sondern die zwangslaeufige Folge eines leeren
Textfeldes: Wer eine Aufzaehlung tippt, setzt die Zeichen selbst --
und dann sehen drei Zeilen auf drei Arten aus.
DIE LOESUNG IST NICHT, DAS ERGEBNIS ZU PUTZEN, sondern die Eingabe
zu aendern: EINE ZEILE, EIN FELD. Das Aufzaehlungszeichen setzt
das Formular, nicht der Mensch.
DAS TEXTFELD BLEIBT TROTZDEM DIE WAHRHEIT. Abgeschickt wird
weiterhin `event_aufgaben` als Text -- der Server bleibt
unveraendert, und wer lieber tippt (oder etwas aus einer Nachricht
einfuegt), schaltet mit einem Knopf zurueck. Ein Editor, der den
einzigen Weg darstellt, ist eine Sackgasse, sobald er einen Fall
nicht kann.
EINGEFUEGTER MEHRZEILIGER TEXT WIRD AUFGETEILT. Der haeufigste
Weg, wie so eine Liste entsteht, ist Kopieren aus einer Nachricht.
Landete das alles in einem Feld, waere der Editor genau dort im
Weg, wo er helfen soll.
===================================================================== */
/** Blendet die Textgestaltungs-Leiste (F K U S) mit ihrem Feld um.
*
* SIE GEHOERT ZUM TEXTFELD, NICHT ZUM FORMULAR. Solange der
* Zeilen-Editor dran ist, ist das Feld verborgen -- die Leiste
* stand aber weiter da und schwebte unter einer Liste, zu der sie
* nicht gehoert. Beim ersten Lauf sah man unter "+ Punkt" ein
* "F K U S", das nichts bewirkte.
*
* SIE WIRD JEDES MAL NEU GESUCHT und nicht beim Aufbau gemerkt:
* textform.js haengt sie NACH dem Formularaufbau an (sie haengt an
* `laden()`), und etwas zu merken, was es noch nicht gibt, ergibt
* null. */
function textformUmblenden(feld, zeigen) {
const l = feld?.parentElement?.querySelector('.textform');
if (l) l.hidden = !zeigen;
}
/** Baut den Zeilen-Editor vor ein Textfeld.
* feldId: die Nummer des <textarea>, das die Wahrheit bleibt. */
function zeilenEditor(feldId, wort, beispiel) {
const feld = $(feldId);
if (!feld || feld.dataset.editor === 'ja') return;
feld.dataset.editor = 'ja';
const rahmen = el('div', 'zeileneditor');
const liste = el('div', 'zeileneditor__liste');
rahmen.append(liste);
/* Aus dem Feld in den Editor -- beim Oeffnen und beim Zurueck-
schalten. Zerlegt wird nach derselben Regel wie im Server
(Zeilenumbruch, fuehrendes Aufzaehlungszeichen weg); dass beide
dasselbe tun, weist pruef-eventkarte.mjs nach. */
const auseinander = (text) => String(text || '')
.split(/\r?\n/)
.map((z) => z.replace(/^[\s]*[-–—•·*▪◦‣>+]+[\s]*/u, '').trim())
.filter(Boolean);
/* ... und zurueck. Das Zeichen setzt das Formular, einheitlich. */
const zusammen = () => [...liste.querySelectorAll('.zeileneditor__feld')]
.map((f) => f.value.trim())
.filter(Boolean)
.map((t) => '· ' + t)
.join('\n');
const schreiben = () => {
feld.value = zusammen();
/* Das Ereignis von Hand ausloesen: Die Vorschau haengt daran,
und ein Wert, der per Skript gesetzt wird, loest `input` nicht
von selbst aus. */
feld.dispatchEvent(new Event('input', { bubbles: true }));
};
function zeileBauen(wert, fokus) {
const z = el('div', 'zeileneditor__zeile');
const punkt = el('span', 'zeileneditor__zeichen');
punkt.setAttribute('aria-hidden', 'true');
const f = el('input', 'zeileneditor__feld');
f.type = 'text';
f.value = wert || '';
f.maxLength = 300;
f.setAttribute('aria-label', wort + ': ein Punkt');
/* DER PLATZHALTER GEHOERT ZUM FELD. "z. B. 22 Live-Stunden
erreichen" stand im ersten Lauf auch unter REGELN -- und ein
Beispiel, das zur Ueberschrift darueber nicht passt, ist eine
Fehlanleitung, keine Hilfe. */
f.placeholder = beispiel;
f.addEventListener('input', schreiben);
/* EINFUEGEN AUS EINER NACHRICHT. Der haeufigste Weg, wie so eine
Liste entsteht. Ohne das landete der ganze Block in einem
Feld -- und der Editor waere genau dort hinderlich, wo er
helfen soll. */
f.addEventListener('paste', (ev) => {
const roh = ev.clipboardData?.getData('text') || '';
if (!/\r?\n/.test(roh)) return;
ev.preventDefault();
const teile = auseinander(roh);
if (!teile.length) return;
f.value = teile[0];
let davor = z;
for (const t of teile.slice(1)) {
const neu = zeileBauen(t);
davor.insertAdjacentElement('afterend', neu);
davor = neu;
}
schreiben();
});
f.addEventListener('keydown', (ev) => {
/* ENTER MACHT EINE NEUE ZEILE -- so wie in jeder Liste, die
man kennt. Ohne das muesste man nach jedem Punkt zur Maus
greifen, und bei fuenf Punkten ist das fuenfmal. */
if (ev.key === 'Enter') {
ev.preventDefault();
const neu = zeileBauen('', true);
z.insertAdjacentElement('afterend', neu);
schreiben();
return;
}
/* RUECKTASTE IM LEEREN FELD nimmt die Zeile weg und setzt den
Schreibzeiger ans Ende der vorigen -- dieselbe Erwartung wie
in einem Textfeld. Die letzte Zeile bleibt stehen: ein
Editor ohne ein einziges Feld hat keinen Weg zurueck. */
if (ev.key === 'Backspace' && !f.value) {
const alle = [...liste.querySelectorAll('.zeileneditor__zeile')];
if (alle.length <= 1) return;
ev.preventDefault();
const stelle = alle.indexOf(z);
const ziel = alle[stelle - 1] || alle[stelle + 1];
z.remove();
schreiben();
const zf = ziel?.querySelector('.zeileneditor__feld');
if (zf) { zf.focus(); zf.setSelectionRange(zf.value.length, zf.value.length); }
}
});
const weg = el('button', 'zeileneditor__weg');
weg.type = 'button';
weg.setAttribute('aria-label', 'Diesen Punkt entfernen');
weg.title = 'Diesen Punkt entfernen';
weg.addEventListener('click', () => {
const alle = [...liste.querySelectorAll('.zeileneditor__zeile')];
if (alle.length <= 1) { f.value = ''; schreiben(); f.focus(); return; }
z.remove();
schreiben();
});
z.append(punkt, f, weg);
if (fokus) setTimeout(() => f.focus(), 0);
return z;
}
const fuellen = () => {
liste.textContent = '';
const teile = auseinander(feld.value);
if (!teile.length) liste.append(zeileBauen(''));
else for (const t of teile) liste.append(zeileBauen(t));
};
fuellen();
const fuss = el('div', 'zeileneditor__fuss');
const mehr = el('button', 'zeileneditor__mehr', '+ Punkt');
mehr.type = 'button';
mehr.addEventListener('click', () => {
liste.append(zeileBauen('', true));
schreiben();
});
fuss.append(mehr);
/* DER WEG ZURUECK ZUM TEXTFELD. Er steht leise daneben, nicht als
zweiter Hauptknopf: Der Normalfall ist die Liste. */
const umschalten = el('button', 'zeileneditor__wechsel', 'als Text bearbeiten');
umschalten.type = 'button';
umschalten.addEventListener('click', () => {
const alsText = rahmen.hidden !== true;
if (alsText) {
rahmen.hidden = true;
feld.hidden = false;
textformUmblenden(feld, true);
umschalten.textContent = 'als Liste bearbeiten';
feld.focus();
} else {
/* Zurueck in die Liste: neu einlesen, damit auch das ankommt,
was im Textfeld getippt wurde. */
fuellen();
rahmen.hidden = false;
feld.hidden = true;
textformUmblenden(feld, false);
umschalten.textContent = 'als Text bearbeiten';
}
});
fuss.append(umschalten);
rahmen.append(fuss);
feld.hidden = true;
textformUmblenden(feld, false);
feld.insertAdjacentElement('beforebegin', rahmen);
/* Der Umschalter gehoert NEBEN beides und nicht in den Rahmen --
sonst verschwindet er mit, sobald man zum Textfeld wechselt. */
rahmen.insertAdjacentElement('afterend', fuss);
/* Wird das Feld von aussen gesetzt (Vorlage uebernehmen, Formular
zuruecksetzen), muss die Liste nachziehen. */
feld.addEventListener('frischfuellen', fuellen);
}
/* =====================================================================
DIE VORSCHAU (03.10.2026)
Sie zeigt die BUEHNE, und zwar mit genau der Funktion, die sie
spaeter auch auf dem Brett baut (eventBuehne). Eine nachgebaute
Vorschau waere eine zweite Fassung derselben Sache -- und die
zeigt irgendwann etwas, das hinterher anders aussieht. Dann ist
sie schlimmer als keine.
SIE ERSCHEINT ERST, WENN ES ETWAS ZU ZEIGEN GIBT. Eine leere
Vorschau ueber einem leeren Formular ist nur ein weiterer Kasten.
===================================================================== */
function vorschauZeichnen() {
const ziel = $('ev-vorschau');
if (!ziel) return;
const event = istEventArt($('f-art').value);
const titel = ($('f-titel')?.value || '').trim();
if (!event || !titel) { ziel.hidden = true; ziel.textContent = ''; return; }
const heute = window.heuteLokal ? window.heuteLokal()
: new Date().toISOString().slice(0, 10);
const e = {
id: 0,
titel,
art: EVENT_ART,
datum: $('f-datum')?.value || heute,
event_ende: $('f-ende')?.value || null,
bilder: [],
};
const stand = eventStand(e);
if (!stand) { ziel.hidden = true; ziel.textContent = ''; return; }
ziel.hidden = false;
ziel.textContent = '';
ziel.append(el('p', 'ev-vorschau__schild', 'So sieht es nachher aus'));
const karte = el('article', 'eintrag-karte');
karte.dataset.eventkarte = 'ja';
karte.dataset.event = stand.schluessel;
karte.dataset.status = 'offen';
/* Derselbe Kopf wie auf dem Brett: Etikett und Titel. */
const kopf = el('div', 'eintrag-kopf');
kopf.append(el('span', 'marke-art', einstellung?.arten?.[EVENT_ART] || 'Agentur-Events'));
kopf.append(el('h3', 'eintrag-titel', titel));
karte.append(eventBuehne(e, stand, kopf));
ziel.append(karte);
}
/** Wie lange laeuft das Event? Steht unter dem Zeitraum.
*
* NICHT NUR ZIERDE: "02.10. bis 15.10." liest man als Spanne, aber
* ob das zwei Wochen sind, rechnet man im Kopf nach. Und ein Ende
* VOR dem Anfang ist ein Tippfehler, den man sonst erst auf der
* Karte sieht -- dort steht dann "vorbei seit 3 Tagen" an einem
* Event, das gerade erst angelegt wurde. */
function dauerZeigen() {
const ziel = $('hinweis-dauer');
if (!ziel) return;
const event = istEventArt($('f-art').value);
const von = $('f-datum')?.value || '';
const bis = $('f-ende')?.value || '';
if (!event || !von) { ziel.textContent = ''; delete ziel.dataset.warnung; return; }
if (!bis) {
ziel.textContent = 'Ohne Ende gilt der eine Tag.';
delete ziel.dataset.warnung;
return;
}
const a = new Date(von + 'T00:00:00').getTime();
const b = new Date(bis + 'T00:00:00').getTime();
if (Number.isNaN(a) || Number.isNaN(b)) { ziel.textContent = ''; return; }
if (b < a) {
ziel.textContent = 'Das Ende liegt vor dem Anfang.';
ziel.dataset.warnung = 'ja';
return;
}
delete ziel.dataset.warnung;
/* EINSCHLIESSLICH BEIDER TAGE: Ein Event vom 2. bis zum 15. laeuft
14 Tage, nicht 13 -- der letzte Tag zaehlt mit, und genau so
rechnet auch eventNoch auf der Karte. */
const tage = Math.round((b - a) / 86400000) + 1;
ziel.textContent = tage === 1 ? 'Ein Tag.' : `Läuft ${tage} Tage.`;
}
/** Das Formular in den Zustand "frisch" bringen.
*
* AN EINER STELLE, weil es zwei Anlaesse gibt (Abbrechen und
* erfolgreiches Speichern) und beide dasselbe meinen. Vorher gab es
* zwei Fassungen, und die kuerzere liess Vorschau und Zeilen-Editor
* stehen -- der Unterschied faellt erst auf, wenn jemand zwei
* Events nacheinander anlegt. */
function formularLeeren() {
$('neu').reset();
$('neu-fehler').textContent = '';
/* `form.reset()` leert die Textfelder, aber die Zeilenfelder davor
stehen noch voll da -- und beim naechsten Tippen schrieben sie
den alten Stand zurueck. */
for (const f of ['f-aufgaben', 'f-regeln']) {
$(f)?.dispatchEvent(new Event('frischfuellen'));
}
/* Und das raeumt Dauerhinweis und Vorschau mit ab. */
formularAnpassen();
}
function formularAnpassen() {
const event = istEventArt($('f-art').value);
/* DIE UHRZEIT NUR DORT, WO TERMINE STEHEN (17.09.2026). Ein
Anschlag oder ein Wunsch hat keine Uhrzeit -- ein Feld, das da
ist und nie gebraucht wird, macht das Formular laenger und die
Entscheidung schwerer. */
const uhrFeld = $('feld-uhrzeit');
if (uhrFeld) {
uhrFeld.hidden = bereich !== 'ansteht';
if (uhrFeld.hidden && $('f-uhrzeit')) $('f-uhrzeit').value = '';
}
$('feld-ende').hidden = !event;
$('feld-aufgaben').hidden = !event;
$('feld-regeln').hidden = !event;
/* DER ZEILEN-EDITOR WIRD ERST GEBAUT, WENN ER GEBRAUCHT WIRD --
und nur einmal (zeilenEditor merkt sich das am Feld). Ihn beim
Laden der Seite fuer jedes Brett aufzubauen hiesse, zwei
Eingabehilfen vorzuhalten, die auf fuenf von sechs Brettern nie
sichtbar werden. */
if (event) {
zeilenEditor('f-aufgaben', 'Aufgaben', 'z. B. 22 Live-Stunden erreichen');
zeilenEditor('f-regeln', 'Regeln', 'z. B. Nur Streams auf dem eigenen Konto');
}
dauerZeigen();
vorschauZeichnen();
if (einstellung?.dringlichkeit) $('feld-dringlich').hidden = event;
if (einstellung?.vertraulich) $('feld-vertraulich').hidden = event;
$('schild-datum').textContent = event ? 'Von' : 'Datum';
/* WAS DAS DATUM BEWIRKT, STEHT DARUNTER (17.09.2026).
Das Feld ist mit HEUTE vorbelegt und heisst schlicht „Datum".
Damit war die Kalenderfunktion zwar vorhanden, aber unauffindbar:
Niemand konnte wissen, dass ein Tag in der Zukunft den Zettel in
den Kalender bringt. Eine Funktion, die niemand findet, ist
keine.
Der Satz aendert sich MIT DER EINGABE, statt allgemein zu
bleiben -- ein fester Hinweis („kann im Kalender erscheinen")
beantwortet die Frage nicht, die man gerade hat: „Steht der
jetzt drin oder nicht?" */
kalenderhinweisSetzen();
$('schild-titel').textContent = event ? 'Titel des Events' : 'Überschrift';
/* textContent wuerde das <span class="leise"> mit erschlagen --
deshalb nur der erste Textknoten, das kleine "(optional)" bleibt
stehen. */
const textSchild = $('schild-text').firstChild;
if (textSchild) textSchild.nodeValue = event ? 'Beschreibung ' : 'Text ';
/* Leeren, was gerade unsichtbar wird. Sonst reicht ein Wechsel von
"Agentur-Events" auf "Schulung", und das Ende des Zeitraums
faehrt unsichtbar mit -- der Server nimmt es an, weil es zum
Bereich gehoert, und an der Schulung stuende ein Enddatum, das
niemand eingetragen hat und niemand sieht. */
if (!event) {
$('f-ende').value = '';
$('f-aufgaben').value = '';
$('f-regeln').value = '';
}
}
const umschalten = (auf) => {
/* Auch hier die Regel, nicht nur einmal beim Aufbau: Diese Funktion
ist die einzige Stelle, die das Formular je wieder sichtbar macht.
Sperrt man nur beim Laden, holt der naechste Aufruf den Knopf
zurueck -- und niemand faellt es auf, weil es der Alltagsweg nicht
ist. */
if (!darfEintragen()) { $('neu').hidden = true; $('neu-oeffnen').hidden = true; return; }
$('neu').hidden = !auf; $('neu-oeffnen').hidden = auf;
if (auf) starthilfeZeigen();
if (auf) $('f-titel').focus();
};
$('neu-oeffnen').addEventListener('click', () => {
$('f-datum').value = window.heuteLokal();
kalenderhinweisSetzen();
formularAnpassen();
umschalten(true);
});
$('neu-abbrechen').addEventListener('click', () => {
formularLeeren(); umschalten(false);
});
/* "change" und nicht "input": Die Auswahl wird ueber wahl.js zu einer
Knopfliste umgebaut, und die meldet ihre Wahl als change-Ereignis
am urspruenglichen <select> -- ohne dieses Ereignis merkte das
Formular vom Artwechsel nichts. */
$('f-art').addEventListener('change', formularAnpassen);
/* Dauer und Vorschau haengen an vier Feldern. Ein einziger Zuhoerer
am Formular statt vier einzelnen: Kommt ein fuenftes Feld dazu,
ist es automatisch dabei -- eine Liste von Feldnamen waere die,
die beim naechsten Umbau nicht nachgezogen wird. */
$('neu')?.addEventListener('input', () => { dauerZeigen(); vorschauZeichnen(); });
$('neu')?.addEventListener('change', () => { dauerZeigen(); vorschauZeichnen(); });
/* DER HINWEIS ZIEHT MIT. Ohne diese beiden Zeilen stuende er auf dem
Stand vom Oeffnen des Formulars -- also dauerhaft auf „Heute", auch
nachdem jemand den 24. eingetragen hat. Ein Hinweis, der beim Tippen
stehenbleibt, sagt nach dem ersten Tastendruck etwas Falsches, und
das ist schlechter als gar keiner. */
$('f-datum')?.addEventListener('change', kalenderhinweisSetzen);
$('f-datum')?.addEventListener('input', kalenderhinweisSetzen);
$('f-uhrzeit')?.addEventListener('change', kalenderhinweisSetzen);
$('f-uhrzeit')?.addEventListener('input', kalenderhinweisSetzen);
$('neu').addEventListener('submit', async (e) => {
e.preventDefault();
$('neu-fehler').textContent = '';
const knopf = $('neu-speichern');
const daten = {
art: $('f-art').value,
titel: $('f-titel').value,
text: $('f-text').value,
datum: $('f-datum').value,
};
if ($('feld-uhrzeit') && !$('feld-uhrzeit').hidden) {
daten.uhrzeit = $('f-uhrzeit').value || '';
}
const event = istEventArt(daten.art);
if (einstellung.dringlichkeit && !event) daten.dringlichkeit = $('f-dringlich').value;
if (einstellung.vertraulich) daten.nur_leitung = $('f-vertraulich').checked ? 1 : 0;
/* Verborgen heisst auch: nichts mitschicken. Ein Wert, der in einem
unsichtbaren Feld stehenbleibt, wandert sonst beim naechsten
Speichern mit -- und niemand sieht, woher er kommt. */
if (einstellung.einsatzFeld && $('f-einsatz')) daten.einsatz = $('f-einsatz').value.trim();
/* Nur, wenn das Feld auch sichtbar ist -- ein Wert aus einem
verborgenen Feld wandert sonst beim naechsten Speichern mit, und
niemand sieht, woher er kommt. Dieselbe Regel wie eine Zeile
darueber. */
if ($('feld-kanal') && !$('feld-kanal').hidden) {
daten.quelle_kanal = $('f-kanal').value || null;
}
if (einstellung.bewertung) daten.bewertung = $('f-bewertung').value || null;
if (event) {
daten.event_ende = $('f-ende').value || null;
daten.event_aufgaben = $('f-aufgaben').value || null;
daten.event_regeln = $('f-regeln').value || null;
}
/* Im Agentur-Bereich gehoert ein Eintrag allen -- es gibt nichts
zuzuordnen, also wird auch nichts mitgeschickt. Der Server setzt
beides ohnehin auf leer (ohneCreatorBezug); hier zu schweigen
spart die stille Annahme, das Formular haette etwas zu sagen. */
if (LEITUNG.has(ich.rolle) && !einstellung.ohneCreatorBezug) {
daten.creator_id = $('f-creator').value || null;
daten.creator_extern = $('f-creator').dataset.extern || null;
}
knopf.disabled = true;
try {
const a = await hole(`/workspace/api/bereich/${bereich}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(daten),
});
if (!a.ok) {
$('neu-fehler').textContent = sagWas((await a.json().catch(() => ({}))).fehler, 'Ging nicht.');
return;
}
/* DIESELBE FUNKTION WIE BEIM ABBRECHEN (03.10.2026).
Hier stand `$('neu').reset()` allein -- also eine ZWEITE
Fassung des Zuruecksetzens, die weniger tat als die beim
Abbrechen. Gemessen hat das die Pruefung: Nach dem Speichern
blieb die Vorschau mit dem gerade angelegten Event stehen
(verborgen: false, 81 Zeichen Inhalt), und beim naechsten
"Neuer Eintrag" standen die Aufgaben des vorigen Events noch
im Editor -- man haette sie versehentlich ein zweites Mal
eingetragen.
`form.reset()` allein kann das gar nicht leisten: Es setzt
Formularfelder auf ihre Vorgabe zurueck und weiss nichts von
Zeilen-Editoren, Hinweisen oder einer Vorschau, die daneben
stehen. */
formularLeeren(); umschalten(false);
await laden();
} catch { /* umgeleitet */ } finally { knopf.disabled = false; }
});
/* ---------- Start -------------------------------------------------------- */
(async () => {
bereich = new URLSearchParams(location.search).get('b') || '';
/* WELCHES BRETT DAS IST, STEHT AM KOERPER (22.09.2026).
Die Stilvorlage braucht es, um dem Anschlagbrett eine eigene
Sprache zu geben. Sie darueber zu setzen statt ueber eine
zusaetzliche Klasse an jeder Karte heisst: EINE Stelle, und sie
stimmt auch fuer alles, was morgen dazukommt. */
if (/^[a-z]+$/.test(bereich)) document.body.dataset.bereich = bereich;
/* Wird eine Bewertung gesetzt, baut sich die Liste neu auf. Der
Zuhoerer haengt am Dokument und wird genau einmal gesetzt --
laden() laeuft mehrfach, und zwei Zuhoerer wuerden die Liste
zweimal neu bauen. */
document.addEventListener('ampel-geaendert', () => laden());
if (!/^[a-z]+$/.test(bereich)) { location.assign('/workspace/start.html'); return; }
try {
const a = await hole('/workspace/api/ich');
if (!a.ok) { location.assign('/workspace/'); return; }
ich = await a.json();
} catch {
/* NICHT MEHR STUMM (20.09.2026). Hier stand `catch { return; }`
-- und damit blieb die Seite bei jedem Netzaussetzer fuer
immer auf "wird geladen …" stehen. Gemessen auf 13 von 21
Seiten. `ladefehler()` sagt, was los ist, und bietet einen
Knopf an, der es noch einmal versucht. */
window.ladefehler?.();
return;
}
window.werZeigen?.(ich);
let daten;
try {
const a = await hole(`/workspace/api/bereich/${bereich}`);
if (!a.ok) { location.assign('/workspace/start.html'); return; }
daten = await a.json();
} catch { return; }
einstellung = daten.einstellung;
kanaeleDa = Array.isArray(daten.kanaele) ? daten.kanaele : [];
document.title = einstellung.name + ' · Creator Workspace';
$('marke-bereich').textContent = einstellung.name;
/* ---- DIE SEITE TRÄGT DIE FARBE IHRER KACHEL (19.09.2026) -------
Filipe: „die kacheln sollen auch in jeder seite die farben der
kategorie haben. ich will dass alles perfekt angepasst ist."
Vorher war jede Bereichsseite gleich blau, egal aus welcher
Kachel man kam. Die Kachel hat einen Ton, die Seite dahinter
nicht — beim Klick verlor man die einzige Farbe, an der man sich
orientieren konnte.
KEINE DRITTE LISTE. Der Ton steht bereits an zwei Stellen, und
der Browser hat beide vorliegen:
`ich.bereiche` die Kacheln vom Server (Treff, Modis)
`window.Bereiche.GRUPPEN` die Kacheln der Agentur
Hier wird nur gesucht, nicht noch einmal aufgeschrieben — eine
eigene Zuordnung wäre die, die beim nächsten neuen Brett
vergessen wird.
Gefunden wird über das ZIEL, nicht über den Namen: Der Name
ändert sich (heute erst wieder von „Entwicklung" zu „Eure
Aufgaben"), das Ziel bleibt. */
tonSetzen(bereich);
$('titel').textContent = einstellung.name;
/* Das Wort ueber dem Titel. Es steht fest in bereich.html
("Betreuung") und stimmte fuer jede Akte -- nicht aber fuer eine
Sammelstelle, die niemandem gehoert.
SEIT DEM 20.09.2026 SCHICKT DER SERVER SIE IMMER: entweder das
eigene `ober` des Bretts oder, wenn es keines hat, die GRUPPE
der Kachel, ueber die dieser Mensch hergekommen ist. Sechs
Bretter hatten keines -- ueber dreien davon stand deshalb
dauerhaft „Betreuung", auch fuer einen Modi, der niemanden
betreut.
Bleibt sie leer, verschwindet die Zeile ganz. Eine leere Zeile,
die Platz einnimmt, sieht aus wie ein Fehler. */
const oberZeile = $('ober');
if (oberZeile) {
oberZeile.textContent = einstellung.ober || '';
oberZeile.hidden = !einstellung.ober;
/* WER ENTSCHEIDET (21.09.2026).
Steht auf jedem Brett, für das es Vorschläge gibt — und damit
genau dort, wo Fragen wie „Was soll DogFather im nächsten
Stream unbedingt machen?" veröffentlicht werden.
WARUM DAS NICHT NUR HÖFLICH IST: Wer so gefragt wird, schreibt
etwas hin und rechnet damit. Passiert es dann nicht, sieht es
aus wie ein gebrochenes Versprechen — obwohl nie eines gegeben
wurde. Der Satz verhindert die Enttäuschung vorher, statt sie
hinterher zu erklären.
Er kommt vom Server, aus derselben Quelle wie der Satz über
den Vorschlagskarten. Zwei Fassungen desselben Satzes wären
eine zu viel. */
const entscheidZeile = document.getElementById('brett-entscheidung');
if (entscheidZeile) {
entscheidZeile.textContent = einstellung.entscheidung || '';
entscheidZeile.hidden = !einstellung.entscheidung;
}
}
$('unterzeile').textContent = UNTERZEILE[bereich] || '';
/* VOR dem Aufbau des Formulars. Danach wäre zu spät: Der Knopf
stünde dann kurz da und verschwände wieder -- sichtbar, und
ausgerechnet an der Stelle, an der jemand gerade überlegt, ob er
schreiben darf. */
treffBrett = !!daten.treff;
kalenderOffen = daten.kalender_offen !== false;
await lageHolen();
/* DER WEG ZU DEN REGELN STEHT AUF JEDEM BRETT DES TREFFS
(11.09.2026).
Nicht nur auf dem Brett "Regeln & Hilfe". Wer die Regeln braucht,
braucht sie in dem Moment, in dem er etwas schreiben will -- und
das ist fast nie dort. Ein Mensch, der erst zurueck zur
Startseite und dann auf die richtige Kachel muss, liest sie
nicht; er schreibt einfach.
ALS LINK UND NICHT ALS EINGEBLENDETER TEXT: Die Regeln sind zu
lang, um sie ueber jedes Brett zu haengen, und ein Kasten, den
man jeden Tag wegklickt, wird nach drei Tagen nicht mehr
gesehen. */
if (daten.treff) {
const vorhanden = $('treff-regelweg');
if (!vorhanden) {
/* „Die Regeln" und nicht „Regeln & Hilfe": So heisst auch das
BRETT, auf dem dieser Link steht -- und ein Link, der den
Namen der Seite traegt, auf der man schon ist, sieht aus wie
ein Fehler. Seit dem 18.09.2026 heisst die Seite dahinter
„Die Regeln"; hier steht derselbe Name. */
const a = el('a', 'treff-regelweg', 'Die Regeln');
a.id = 'treff-regelweg';
a.href = 'treff-regeln.html';
$('unterzeile').after(a);
}
}
/* Fuer den Creator verschwindet alles Schreibende -- und die
Unterzeile sagt, warum. Ein leerer Bereich ohne Erklaerung wirkt
kaputt; einer mit Erklaerung wirkt gedacht. */
if (!darfEintragen()) {
$('neu-oeffnen').hidden = true;
$('neu').hidden = true;
/* Zwei verschiedene Saetze, weil es zwei verschiedene Wahrheiten
sind. In den Betreuungsbereichen sieht ein Creator, was ZU IHM
gehoert -- in der Agentur sieht er ALLES. Stuende hier weiter
"was zu dir gehört", laese sich eine vollstaendige Liste wie
ein Ausschnitt, und die naheliegende Frage waere: Was sehe ich
hier eigentlich nicht? */
/* IM TREFF STIMMT KEINER DER BEIDEN SÄTZE. Dort geht es weder um
die Agentur noch um Betreuung, sondern entweder um ein Brett,
das dem Team gehört, oder um eine Stufe, die noch nicht
erreicht ist. Und der Server weiß beides genau -- er schickt
den Satz mit, statt dass der Browser ihn errät. */
$('unterzeile').textContent = (UNTERZEILE[bereich] || '')
+ (treffBrett
? ' ' + (lage?.warum?.[bereich]
|| 'Hier schreibt das Team – lesen kannst du alles.')
: einstellung.fuerAlle
? ' Eingetragen wird von der Agentur – du siehst alles, was hier steht.'
: ' Hier trägt deine Betreuung ein – du siehst alles, was zu dir gehört.');
}
for (const [wert, name] of Object.entries(einstellung.arten)) {
const o = document.createElement('option');
o.value = wert; o.textContent = name;
$('f-art').append(o);
}
$('feld-dringlich').hidden = !einstellung.dringlichkeit;
$('feld-vertraulich').hidden = !einstellung.vertraulich;
/* =================================================================
ZWEI ZAHLEN, ANDERE NAMEN (10.09.2026)
Bei den Angeboten heisst `bewertung` NUTZEN und `dringlichkeit`
AUFWAND. Die Felder gibt es laengst -- sie mit neuen Namen zu
belegen ist besser, als zwei weitere Spalten anzulegen, die
dasselbe koennen. Aber ein Feld, ueber dem "Dringlichkeit" steht
und das den Aufwand meint, ist falsch beschriftet, und das faellt
beim Ausfuellen niemandem auf -- erst beim Lesen.
Die Namen kommen aus den Bereichsangaben; steht dort keiner,
bleibt es wie bisher. */
if (einstellung.dringlichkeitName && $('dringlich-schild')) {
$('dringlich-schild').textContent = einstellung.dringlichkeitName;
}
if (einstellung.bewertungName && $('bewertung-schild')) {
$('bewertung-schild').textContent = `${einstellung.bewertungName} (1–5)`;
}
/* ---- ZU WELCHEM ACCOUNT? (20.09.2026) -------------------------
Filipe: "und wenn man was hinzufuegt soll man auch den account
auswaehlen koennen."
Nur auf den Highlights und nur, wenn der Server die Kanaele
ueberhaupt mitgeschickt hat. Eine leere Auswahlliste waere eine
Frage ohne moegliche Antwort.
DIE LISTE KOMMT VOM SERVER, nicht aus KANAL_WORT hier. Beim
vierten Account ist die Kopie hier die vergessene -- das steht
so schon am Kanal-Begriff selbst. */
const kanalFeld = $('feld-kanal'), kanalWahl = $('f-kanal');
if (kanalFeld && kanalWahl) {
const liste = kanaeleDa;
kanalFeld.hidden = !(bereich === 'highlight' && liste.length);
if (!kanalFeld.hidden && kanalWahl.dataset.stand !== String(liste.length)) {
kanalWahl.dataset.stand = String(liste.length);
kanalWahl.textContent = '';
/* LEER ZUERST UND ALS VORGABE. Ein Pflichtfeld erzwaenge hier
einen falschen Kanal, und ein falscher Eintrag ist
schlechter als ein leerer. */
const leer = document.createElement('option');
leer.value = ''; leer.textContent = 'Noch offen';
kanalWahl.append(leer);
for (const k of liste) {
const o = document.createElement('option');
o.value = k.wert;
o.textContent = k.handle ? `${k.name} (${k.handle})` : k.name;
kanalWahl.append(o);
}
}
if ($('hinweis-kanal')) {
$('hinweis-kanal').textContent = 'Bei einem TikTok-Link erkennt die '
+ 'Seite den Account selbst – das hier ist für Bilder und Momente ohne Link.';
}
}
/* Das Einsatz-Feld nur, wo der Bereich es kennt. */
if ($('feld-einsatz')) $('feld-einsatz').hidden = !einstellung.einsatzFeld;
if (einstellung.einsatzName && $('einsatz-schild')) {
$('einsatz-schild').textContent = einstellung.einsatzName;
}
$('feld-bewertung').hidden = !einstellung.bewertung;
if (LEITUNG.has(ich.rolle)) {
$('feld-creator').hidden = false;
/* AGENTUR: EINE EINZIGE WAHL, UND DIE HEISST "AGENTUR" (07.09.2026).
Hier stand dieselbe Creator-Liste wie ueberall -- und sie war
eine Falle. Wer ein Event fuer alle anlegt und dabei aus
Gewohnheit einen Namen anklickt, hat es damit UNSICHTBAR
gemacht: fuer alle anderen Creator, ohne Warnung, ohne dass es
der Liste danach anzusehen waere.
Kein Feld auszublenden waere die halbe Loesung gewesen: Dann
fragt man sich, wem der Eintrag gehoert. Eine Liste mit genau
einer Wahl beantwortet die Frage, statt sie wegzunehmen.
Die Personenwahl (freier Name, Suchfeld) wird hier bewusst
NICHT aufgesetzt -- sie erlaubt getippte Namen, und genau das
soll es hier nicht geben. */
if (einstellung.ohneCreatorBezug) {
/* DAS WORT KOMMT VOM SERVER (11.09.2026).
Hier stand fest "Agentur" -- richtig auf der Agenturadresse,
falsch auf jeder anderen. Filipe, mit Bildschirmfoto: "auf
dieser seite soll nichts stehen von agentur, diese seite hier
ist nur für mich meine modis und meine community."
Der Server weiss, welches Brett es ist und in welchem Haus
die Person steht; er schickt das Wort mit (daten.gehoert).
Faellt die Angabe aus, bleibt das Feld LEER statt falsch --
ein fehlendes Wort sieht man, ein falsches nicht. */
const gehoert = daten?.gehoert || '';
$('schild-creator').textContent = 'Gilt für';
$('f-creator').textContent = '';
const o = document.createElement('option');
o.value = ''; o.textContent = gehoert || '—';
$('f-creator').append(o);
const hinweis = $('hinweis-creator');
hinweis.textContent = gehoert
? `Gehört allen hier – wer ${gehoert} sieht, sieht auch diesen Eintrag.`
: '';
hinweis.hidden = !gehoert;
} else {
try {
const { personen } = await (await hole('/workspace/api/personen')).json();
for (const p of personen.filter((p) => p.rolle === 'creator')) {
const o = document.createElement('option');
o.value = String(p.id); o.textContent = p.name;
/* Bild ans <option> -- wahl.js zeichnet daraus das Gesicht. */
o.dataset.person = 'ja';
if (p.bild) o.dataset.bild = p.bild;
$('f-creator').append(o);
}
} catch { /* Zuordnung bleibt leer */ }
window.personenwahl?.('f-creator', { hinweis: "Kein Konto im Workspace – der Eintrag gehört dann zu keiner Person und taucht in personenbezogenen Auswertungen nicht auf. Sichtbar bleibt er für dich." });
}
}
formularAnpassen();
/* Kommt jemand vom Bericht mit "nur die offenen", wird der Filter
VOR dem ersten Laden gesetzt -- sonst baut die Seite alles auf,
um es gleich wieder wegzuwerfen, und das flackert sichtbar. */
if (window.zielSchluessel?.() === 'offen') filter = 'offen';
await laden();
/* DIE LEISTE FUER DIE TEXTGESTALTUNG (21.09.2026). Nach `laden()`,
weil das Formular davor noch nicht zusammengesetzt ist -- die
Felder haengen am Bereich, und der steht erst danach fest. */
if (window.Textform) {
for (const id of ['f-text', 'f-aufgaben', 'f-regeln']) {
const feld = document.getElementById(id);
if (!feld) continue;
window.Textform.leiste(feld);
/* ERST DANACH VERSTECKEN. Der Zeilen-Editor steht bei den
Event-Feldern schon (er entsteht in formularAnpassen, und
das laeuft innerhalb von laden()) -- die Leiste kommt also
NACH ihm und waere sonst als einziges Stueck des Textfeldes
sichtbar geblieben. Das war im ersten Bildschirmfoto das
"F K U S" unter der Punkteliste. */
if (feld.dataset.editor === 'ja' && feld.hidden) {
const l = feld.parentElement?.querySelector('.textform');
if (l) l.hidden = true;
}
}
}
})();
})();