Files
dogfather-universe/workspace/assets/js/bereich.js
T
DogFatherGitandClaude Opus 5 7eec2edf3f Stufe 3: Die Wunschliste bekommt einen Rang und einen Weg
Aus dem Plan im Vault. Eine Wunschliste ohne sichtbare Erfuellung ist
ein Briefkasten ohne Postbote -- nach dem dritten unbeantworteten
Wunsch schreibt niemand mehr.

ZWEIMAL LAG MEIN PLAN DANEBEN, beide Male zu pessimistisch:
- Die Sortierung nach Stimmen GAB es schon, seit dem 11.09.
- Die Datenbank erlaubte 'angenommen' und 'abgelehnt' laengst in ihrer
  CHECK-Regel; nur die Pruefung im Server liess sie nicht durch. Die
  Tabelle war der Logik voraus.

Auch ein Plan von gestern Nacht ist eine Bestandsliste und altert.

WAS DAZUGEKOMMEN IST
- Vier Zustaende JE BEREICH statt zwei global: Offen -> Wird gemacht
  -> Gemacht, dazu "Diesmal nicht". Eine gemeinsame Liste haette
  "Diesmal nicht" auch an einem Schutzvorfall erlaubt, und dort
  bedeutet es nichts.
- Ein geplanter Tag dazu: "Wird gemacht" allein ist ein Versprechen,
  "Wird gemacht -- am 24.09." ist ein Termin.
- Der Rangbalken. Eine Rangfolge sieht man erst, wenn der ABSTAND
  sichtbar ist: 12 Stimmen neben 14 sehen sonst aus wie 1 neben 40.
  Fuer ein Vorleseprogramm spricht er in Worten.
- Entschiedenes rutscht nach unten, wird aber NICHT geloescht. Gerade
  der erfuellte Wunsch ist der Beweis, dass sich Schreiben lohnt.

"DIESMAL NICHT" IST DER WICHTIGSTE DER VIER, und es ist bewusst nicht
rot. Ein Nein ist eine Antwort, Schweigen ist keine -- aber wer ein
rotes Schild an seinem Wunsch sieht, schreibt keinen zweiten.

UND AUF DEM BILDSCHIRMFOTO STAND "WAS IHR EUCH WUENSCHT".
Die Umlaut-Pruefung von gestern Nacht sah es nicht: Sie kannte die
Kataloge und die festen Seitentexte, aber nicht die
Bereichseinstellungen, aus denen JEDE Ueberschrift und jede
Art-Beschriftung kommt. Sechs Stellen ("Fuer den Stream", "Wie es hier
laeuft", "Haeufige Frage", "Ich haette Lust" ...).

Gefunden hat sie kein Gedankengang, sondern ein Blick auf das fertige
Bild. Die Wortliste hatte ausserdem Loecher -- "wuensch" fehlte
schlicht; 23 Stuecke nachgetragen. pruef-deutsche-texte deckt jetzt
auch die 120 Beschriftungen der Bereiche ab (9 -> 12 Pruefungen), mit
einer Gegenprobe auf genau den Satz, der heute Morgen durchrutschte.

NEU: pruef-wunschliste (30 Pruefungen)
Der aelteste Wunsch bekommt die meisten Stimmen -- genau der Fall, der
eine Sortierung nach Datum entlarvt. Dazu: alle vier Zustaende setzen,
ein erfundener nicht, "abgelehnt" am Anschlagbrett ABGELEHNT, der
geplante Tag, die Balkenbreiten (100/33/0 %), und dass der gemachte
Wunsch weiter in der Liste steht -- nur nicht mehr oben.

pruef-countdown 22, pruef-treff 66, pruef-bereiche-lesend,
pruef-deutsche-texte 12: gruen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-17 11:28:37 +02:00

1202 lines
54 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;
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;
const $ = (id) => document.getElementById(id);
const UNTERZEILE = {
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;
/* 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;
let lage = null;
let filter = '';
/* 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) };
}
async function hole(pfad, optionen = {}) {
const a = await fetch(pfad, { credentials: 'same-origin', ...optionen });
if (a.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); }
if (a.status === 404) { location.assign('/workspace/start.html'); throw new Error('unbekannt'); }
return a;
}
function tuKnopf(text, tu) {
const b = el('button', 'schritt', text);
b.type = 'button';
b.addEventListener('click', async () => {
b.disabled = true; melde('');
try { await tu(); } catch { /* umgeleitet */ } finally { b.disabled = false; }
});
return b;
}
/* ---------- Zeichnen --------------------------------------------------- */
function karte(e) {
const k = el('article', 'eintrag-karte');
k.dataset.dringlich = e.dringlichkeit;
k.dataset.status = e.status;
const kopf = el('div', 'eintrag-kopf');
kopf.append(el('span', 'marke-art', einstellung.arten[e.art] || e.art));
/* 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 (!confirm(`„${e.titel}“ als Nachricht schicken?\n\n`
+ 'Die Person bekommt Art, Titel und Text im Chat – einmalig.')) 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((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);
}
k.append(kopf);
/* 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 = istEventArt(e.art) ? eventStand(e) : null;
if (stand) {
k.dataset.event = stand.schluessel;
const zeile = el('p', 'event-zeitraum');
zeile.dataset.stand = stand.schluessel;
zeile.append(el('span', 'event-zeitraum__marke', stand.text));
zeile.append(el('span', 'event-zeitraum__spanne',
e.event_ende && e.event_ende !== e.datum
? `${datum(e.datum)} – ${datum(e.event_ende)}`
: datum(e.datum)));
k.append(zeile);
}
if (e.text) k.append(el('p', 'eintrag-text', e.text));
if (stand) {
for (const [feld, name] of [['event_aufgaben', 'Aufgaben'], ['event_regeln', 'Regeln']]) {
if (!e[feld]) continue;
const block = el('div', 'event-block');
block.append(el('h4', 'event-block__schild', name));
/* Zeilenumbrueche bleiben stehen (white-space: pre-line im CSS).
Eine Aufzaehlung, die zu einem Absatz zusammenlaeuft, ist
genau das, was diese beiden Felder verhindern sollen. */
block.append(el('p', 'event-block__text', e[feld]));
k.append(block);
}
}
const fuss = el('div', 'eintrag-fuss');
/* Beim Event steht der Zeitraum schon oben -- das Datum hier waere
dieselbe Zahl ein zweites Mal. */
if (!stand) 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));
if (e.status === 'erledigt') fuss.append(el('span', null, '· erledigt'));
/* ---- 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));
}
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. */
if (!lage.team && e.erstellt_von !== ich.id) {
zeile.append(tuKnopf('melden', async () => {
const grund = prompt('Was ist daran nicht in Ordnung?\n\n'
+ 'Bitte schreib es in einem Satz – sonst können wir es nicht prüfen.');
if (grund === null) return;
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.'
: ((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((await a.json().catch(() => ({}))).fehler
|| ('Es hängen schon ' + lage.anschlag_max + ' Ansagen.'));
return;
}
await laden();
}));
}
/* FREIGEBEN. Bei Terminen heisst es "im Treff 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 Treff 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 (!confirm('„' + e.titel + '“ zu den Talenten ü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.'
: (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 grund = prompt('Warum wird „' + e.titel + '“ entfernt?\n\n'
+ 'Der Grund wird festgehalten – die Person hat ein Recht darauf.');
if (grund === null) return;
/* 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((await a.json().catch(() => ({}))).fehler || 'Ging nicht.');
return;
}
await laden();
}));
}
}
if (zeile.childNodes.length) k.append(zeile);
}
if (!darfEintragen()) { k.append(fuss); return k; }
const knoepfe = el('div', 'eintrag-knoepfe');
/* 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.'
: (j.fehler || 'Ging nicht.'));
return;
}
melde('Steht jetzt im Aufgabenbrett – bei dir.');
await laden();
}));
}
if (LEITUNG.has(ich.rolle) || e.erstellt_von === ich.id) {
knoepfe.append(tuKnopf('löschen', async () => {
if (!confirm(`„${e.titel}" wirklich löschen?`)) return;
const a = await hole(`/workspace/api/bereich/${bereich}/${e.id}`, { method: 'DELETE' });
if (!a.ok) { melde('Löschen hat nicht geklappt.'); return; }
await laden();
}));
}
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(); });
}
}
/* 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 */ }
}
function filterBauen() {
const ziel = $('filter');
ziel.textContent = '';
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'));
ziel.append(knopf('offen', 'Nur offene'));
for (const [wert, name] of Object.entries(einstellung.arten)) ziel.append(knopf(wert, name));
}
/* 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; }
}
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;
treffBrett = !!daten.treff;
await lageHolen();
filterBauen();
const ziel = $('liste');
ziel.textContent = '';
ziel.setAttribute('aria-busy', 'false');
const sichtbar = daten.eintraege.filter((e) => {
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);
maxStimmen = (daten.eintraege || [])
.reduce((n, e) => Math.max(n, Number(e.stimmen) || 0), 0);
if (!sichtbar.length) {
ziel.append(el('p', 'leer-hinweis', daten.eintraege.length
? 'Zu dieser Auswahl gibt es nichts.'
: '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();
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;
});
const kopf = el('h2', 'zeitstrahl__schild', ABSCHNITT_NAME[r]);
kopf.append(el('span', 'zeitstrahl__zahl', String(fach.length)));
ziel.append(kopf);
for (const e of fach) {
const k = karte(e);
karten.set(e.id, k);
ziel.append(k);
}
}
} 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. */
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;
if (einstellung?.dringlichkeit) $('feld-dringlich').hidden = event;
if (einstellung?.vertraulich) $('feld-vertraulich').hidden = event;
$('schild-datum').textContent = event ? 'Von' : 'Datum';
$('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) $('f-titel').focus();
};
$('neu-oeffnen').addEventListener('click', () => {
$('f-datum').value = window.heuteLokal();
formularAnpassen();
umschalten(true);
});
$('neu-abbrechen').addEventListener('click', () => {
$('neu').reset(); $('neu-fehler').textContent = ''; formularAnpassen(); 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);
$('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();
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 = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.';
return;
}
$('neu').reset(); umschalten(false);
await laden();
} catch { /* umgeleitet */ } finally { knopf.disabled = false; }
});
/* ---------- Start -------------------------------------------------------- */
(async () => {
bereich = new URLSearchParams(location.search).get('b') || '';
/* 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 { 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;
document.title = einstellung.name + ' · Creator Workspace';
$('marke-bereich').textContent = einstellung.name;
$('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. Bringt ein Bereich ein
eigenes mit, gilt seines; sonst bleibt es wie bisher. */
if (einstellung.ober && $('ober')) $('ober').textContent = einstellung.ober;
$('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;
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) {
const a = el('a', 'treff-regelweg', 'Regeln & Hilfe');
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)`;
}
/* 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();
})();
})();