Files
DogFatherGitandClaude Opus 5 95c4599baa Anfuehrungszeichen: 199 falsche Schlusszeichen im sichtbaren Text
Deutsch oeffnet mit „ und schliesst mit “. An 199 Stellen, die ein
Mensch liest, stand als Schlusszeichen ein GERADES " -- „Nächstes"
statt „Nächstes“. Auf sechzehn oeffentlichen Seiten, in vierzig
Dateien des Workspace und in zwoelf Servermodulen, die Texte
verschicken.

Auf dem Bildschirm sieht man den Unterschied sofort. Beim Schreiben
nicht: Das gerade " liegt auf der Tastatur, die anderen nicht.

WARUM EIN ERSTER ANLAUF ZURUECKGENOMMEN WURDE

Ein gerades " ist an vielen Stellen SYNTAX und kein Schriftzeichen --
Grenze einer Zeichenkette, Grenze eines HTML-Attributs, Zeichen in
einem regulaeren Ausdruck. Wer stumpf ersetzt, macht aus

    „<a href="https://…                  ein kaputtes Attribut
    /^["'„»\s]+|["'“«.\s]+$/              einen kaputten Ausdruck
    "… nichts „mal " + "eben …"          eine kaputte Zeichenkette

DIE UNTERSCHEIDUNG LAEUFT AN MERKMALEN, NICHT AN EINER LISTE

  < > = dazwischen        -> HTML-Marke oder Attribut
  endet auf Leerzeichen   -> die Zeichenkette hoert hier auf, der
                             Satz geht in der naechsten Zeile weiter.
                             Ein deutsches Schlusszeichen steht NIE
                             hinter einem Leerzeichen.
  Rueckstrich mittendrin  -> regulaerer Ausdruck
  ${ ohne }               -> mitten in einem Ausdruck

Eine Liste erlaubter Ausnahmen waere die naechste, die niemand
pflegt. Zwoelf Stellen bleiben dadurch stehen, alle zwoelf einzeln
angesehen und alle zwoelf zu Recht -- dort steht das richtige
Schlusszeichen ohnehin weiter unten im Satz.

Fuenf davon waren allerdings ECHTE Fehler HINTER dem Link
(`…>HasiDog</a>".`) -- die erste Regel hatte nur das Attribut
gesehen, nicht den Satz danach. Gezielt nachgezogen.

Ein maskiertes `\"` in workspace-vorlagen.js (28 Hooks) wird zu “ --
ohne Rueckstrich, denn “ begrenzt nichts.

KOMMENTARE BLEIBEN, WIE SIE SIND. Dort liest es niemand ausser mir;
eine Wache, die auch Kosmetik anmahnt, wird weggeklickt. Beim ersten
Messen fielen ausserdem acht Stellen aus buehne.html faelschlich an,
weil `/* */` in HTML (in <style> und <script>) nicht ausgeblendet
war -- jetzt schon.

DIE WACHE DAZU

pruef-struktur prueft es ab sofort mit derselben Regel: 322 Dateien
mit sichtbarem Text, 0 Funde, und die zwoelf bewussten Ausnahmen
werden GEZAEHLT und genannt (erlaubt: 12). Eine Ausnahme, die niemand
sieht, waechst -- und irgendwann steht der echte Fall darin.

GEPRUEFT

  pruef-struktur   59 -> 68 Pruefungen, 0 Fehler
  node --check auf allen geaenderten JS-Dateien
  nachgemessen: 199 geaendert, 12 mit Grund stehen geblieben

  gruen geblieben: bewerbung-aufgaben 163, nachwuchs 262,
  reaktion 421, support 63, content 45, terminregel 35, treff 85

  Und nachgesehen, ob eine Pruefung noch die alte Schreibweise
  ERWARTET: 13 Fundstellen, alle dreizehn nur Text in ihrer eigenen
  Ausgabe, keine einzige ein Vergleich mit dem Seitentext.

GEGENPROBE: In reaktion.html ein Schlusszeichen zurueckgedreht ->
„workspace/reaktion.html:546 „Nächstes"", mit Datei und Zeile. Und
die Erkennung einzeln gegen HTML-Attribut, fortgesetzte
Zeichenkette, regulaeren Ausdruck und eingesetzten Wert geprueft.

Stempel gesetzt: workspace 670 Verweise in 45 Dateien, oeffentlich
554 in 48 Seiten.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 01:56:15 +02:00

2189 lines
97 KiB
JavaScript
Raw Permalink 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.
/* ===================================================================
Kalender
Vier Ansichten auf DENSELBEN Daten -- der Server liefert einen
Zeitraum, hier wird er unterschiedlich dargestellt. So kann keine
Ansicht etwas anderes zeigen als die andere.
Monat Überblick, Rhythmus, wo Lücken sind
Woche die sieben Tage groß, mit Uhrzeiten
Liste Arbeitsansicht: was kommt als Nächstes
Zeitstrahl Vorlauf und Ballungen auf einer Achse
Termine (Calls, Termine, Reviews) und Fristen aus den Aufgaben liegen
auf einer Achse. Fristen erscheinen bewusst OHNE Uhrzeit als
Tagesmarke -- eine Frist ist ein Stichtag, kein Termin, und würde
sonst Platz blockieren, an dem nichts stattfindet.
---------------------------------------------------------------------
ANLÄSSE UND FEIERTAGE
Ein leerer Kalender ist nicht nur kahl, er ist nutzlos: Die Frage beim
Planen lautet nie "was habe ich schon eingetragen", sondern "worauf
muss ich zuarbeiten". Für Creator sind das Halloween, Weihnachten,
Silvester, Valentinstag, Black Friday -- dort ist das Publikum wach,
dorthin gehört der Vorlauf.
Der Kalender rechnet diese Tage selbst aus, für jedes beliebige Jahr:
Ostern über die Gauß-Formel, davon abgeleitet Karfreitag, Himmelfahrt
und Pfingsten, dazu die beweglichen Termine (Muttertag, Black Friday,
die vier Advente). Kein Dienst, keine Liste zum Nachpflegen, keine
Kosten, funktioniert offline und im Jahr 2040.
Bewusst NUR die neun bundesweiten Feiertage. Fronleichnam,
Reformationstag und Allerheiligen gelten je nach Bundesland -- ein
Kalender, der sie überall anzeigt, wäre für die Hälfte der Leute
schlicht falsch. Lieber weniger behaupten als etwas Falsches.
=================================================================== */
(() => {
'use strict';
/* DogFather und Manager haben dieselben Rechte -- die Unterscheidung
macht nur die Personenverwaltung, und die prüft 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;
const $ = (id) => document.getElementById(id);
const el = (tag, klasse, text) => {
const k = document.createElement(tag);
if (klasse) k.className = klasse;
if (text !== undefined) k.textContent = text;
return k;
};
/* Die Namen der Arten. `frist` steht mit drin, obwohl es keine
Terminart ist: Aufgabenfristen erscheinen im Kalender als
Eintraege und brauchen dort auch eine Beschriftung. */
const ARTNAME = {
call: 'Call', termin: 'Termin', review: 'Review', frist: 'Frist',
bigmatch: 'BigMatch', turnier: 'Turnier', special: 'Special-Live',
collab: 'Collab', raid: 'Raid-Train', charity: 'Charity',
/* WAS AUF DEN BRETTERN STEHT UND EINEN TERMIN HAT (17.09.2026).
Eine EINZIGE Art, nicht eine je Brett. Der Filter haette sonst
achtzehn Schalter, und eine Leiste mit achtzehn Schaltern liest
niemand mehr -- derselbe Grund, aus dem die sechs Auftritts-Arten
nur bei Bedarf erscheinen.
UNTERSCHIEDEN WIRD UEBER DAS WORT, NICHT UEBER DIE FARBE: In der
Pille steht der Name des Bretts. Eine Farbe je Brett waere eine
von Hand gepflegte Liste -- und die waere beim naechsten neuen
Brett vergessen, genau wie die Filterliste, die hier schon
einmal stehengeblieben ist (siehe unten bei `zeigen`). */
brett: 'Vom Brett',
/* Der Name steht auch hier, damit Filterleiste und Beschriftung
dieselbe Quelle haben. Ohne Eintrag hiesse der Block in der
Leiste schlicht "belegt" in Kleinbuchstaben -- und niemand
wuesste, dass man ihn abschalten kann. */
belegt: 'Belegt (andere Seite)',
};
/* Jede Art trägt ihre eigene Farbe -- dieselbe Idee wie bei den Kacheln
auf der Startseite: Die Farbe gehört zur Sache, nicht zum Rang. */
/* EINE FARBE JE ART (08.09.2026 erweitert).
Die drei internen Arten behalten ihre Farben -- wer sie kennt, soll
sie wiedererkennen. Die sechs Auftritte bekommen WAERMERE, kraeftigere
Toene: Im Monatsraster sieht man dadurch auf einen Blick, ob eine
Woche voller Arbeitstermine steckt oder voller Streams, ohne ein
Wort zu lesen.
Innerhalb der Auftritte ist die Zuordnung nicht beliebig:
bigmatch Rot -- ein Duell, das schaerfste Format
turnier Bernstein -- mehrere Runden, laenger, ruhiger als ein Duell
special Magenta -- die Ausnahme, die auffallen soll
collab Tuerkis -- gemeinsam, freundlich, kein Wettkampf
raid Orange -- Bewegung, eine Kette
charity Gruengold -- Spenden, warm und nicht schreiend
Alle neun sind gegen den dunklen Grund auf mindestens 4,5:1
geprueft (pruef-buehne misst die Texte, die sie faerben). */
const ARTFARBE = {
call: '#3fbdf5',
termin: '#8a76ff',
review: '#50a752',
frist: '#d17717',
bigmatch: '#ff5f6d',
turnier: '#e3b341',
special: '#f065c8',
collab: '#2fd6c4',
raid: '#ff9040',
charity: '#a7d64a',
/* BEWUSST RUHIGER ALS DIE AUFTRITTE. Ein Zettel vom Brett ist
keine Sendung -- er soll im Monatsraster sichtbar sein, aber
einem BigMatch nicht die Aufmerksamkeit wegnehmen. Stahlblau ist
ausserdem der letzte Ton, der sich von allen neun anderen klar
unterscheidet: nicht das helle Blau der Calls, nicht das Violett
der Termine, nicht das Tuerkis der Collabs. */
brett: '#7f9cc0',
/* ==== BELEGT AUF DER ANDEREN SEITE (24.09.2026) ==================
Der leiseste Ton im ganzen Satz, und das ist Absicht: Ein
belegter Block ist keine Verabredung, die man ansieht, sondern
eine Stelle, an der man nichts hinlegt. Er soll auffallen, wenn
man eine Zeit SUCHT, und sonst nicht.
Grau und nicht eine der neun Farben -- jede von ihnen hiesse
„eine Art von Termin", und genau das ist hier nicht bekannt.
Man weiss, DASS etwas ist, und nichts darueber, WAS. */
belegt: '#5c6672',
};
/* Warum ein Zettel vom Brett im Kalender steht -- in Worten, die man
ohne Erklaerung versteht. Die Schluessel kommen vom Server; steht
ein unbekannter dabei, bleibt die Zeile einfach ohne dieses Stueck
statt einen Rohwert zu zeigen. */
const GRUNDWORT = {
zugesagt: 'zugesagt',
zeitraum: 'laeuft ueber mehrere Tage',
uhrzeit: null,
termin: 'geplant',
};
const FEIERTAG_FARBE = '#ff9aa2';
const ANLASS_FARBE = '#d8a13a';
const WOCHENTAGE_KURZ = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
/* Sonntag zuerst -- das ist die Zählung von getUTCDay(), nicht die
deutsche Wochenordnung. Wer hier Montag an den Anfang setzt,
verschiebt jeden Wochentagsnamen um eins. */
const WOCHENTAGE_LANG = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch',
'Donnerstag', 'Freitag', 'Samstag'];
const MONATE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
const ORDNUNG = ['', 'ersten', 'zweiten', 'dritten', 'vierten', 'fünften'];
let ich = null;
let daten = null;
let serien = [];
/* WER SONST NOCH DABEI IST (05.09.2026).
Einzelne Schalter statt eines <select multiple> -- dasselbe Muster
wie bei den Dateien: Ein Mehrfach-Auswahlfeld verliert bei einem
Fehlklick die ganze Auswahl und ist am Handy kaum zu bedienen. */
let alleLeute = [];
const ROLLENKURZ = window.Bereiche.ROLLEN_NAME;
/* DIE GRUPPENUEBERSCHRIFT UND IHRE REIHENFOLGE STANDEN HIER (bis
11.09.2026). Beides macht jetzt window.Bereiche.gruppieren(): Die
Ueberschrift kommt vom Server mit, die Reihenfolge auch.
Der alte Kommentar an dieser Stelle ist trotzdem lehrreich und
deshalb aufgehoben: Eine unbekannte Rolle landete bei indexOf()
auf -1 und damit VOR allen anderen -- Spicy Media stand in der
Teilnehmerwahl ganz oben statt an ihrem Platz. Kein Absturz, nur
still falsch. Genau diese Sorte Fehler kann eine Reihenfolge, die
niemand im Browser pflegt, nicht mehr machen. */
/** Ein bis zwei Buchstaben, die als Zeichen im Kreis stehen.
* "Filipe Fernandes Ribeiro" -> "FR", "Luna" -> "L".
* Absichtlich Anfang UND Ende: Bei "Patrick von Hohenstein" wäre
* "PV" nichtssagend, "PH" nicht. */
function initialen(name) {
const teile = String(name || '').trim().split(/\s+/).filter(Boolean);
if (!teile.length) return '?';
/* [...] statt [0]: Ein Name kann mit einem Zeichen beginnen, das
aus zwei Code-Einheiten besteht (Emoji, manche Schriften).
name[0] würde davon die Hälfte nehmen und Bruch anzeigen. */
const erst = [...teile[0]][0] || '';
const letzt = teile.length > 1 ? ([...teile[teile.length - 1]][0] || '') : '';
return (erst + letzt).toLocaleUpperCase('de-DE');
}
/** Baut die Wahl -- nach Rollen gruppiert.
*
* `gewaehlt` sind die Nummern, die an sein sollen: beim Bearbeiten
* die des Termins, sonst nichts.
*
* WARUM GRUPPIERT: Vorher stand die Rolle an JEDEM Namen, neunmal
* hintereinander, als eine lange graue Reihe. Jetzt trägt die
* Überschrift das Wort einmal, und man springt zur Gruppe statt zu
* suchen. */
function baueDabei(gewaehlt = []) {
const ziel = $('dabei-wahl');
if (!ziel) return;
const an = new Set(gewaehlt.map(Number));
ziel.textContent = '';
if (!alleLeute.length) {
$('dabei-hinweis').textContent = '';
return;
}
/* HIER FEHLTE TEAM DOGI GANZ (11.09.2026).
Diese Schleife lief über ROLLENFOLGE — fünf Agentur-Rollen. Wer
eine sechste hat, wurde nicht gezeichnet: kein Fehler, keine
Lücke, kein Hinweis. Anders als im Chat gab es hier nicht einmal
einen Nachzügler-Block. DogFather konnte seine rechte Hand und
seine Modis also zu keinem Termin einladen, und auf dem
Bildschirm sah das vollkommen normal aus.
Gefunden beim Aufräumen der Reihenfolge, nicht beim Suchen. */
const gruppen = window.Bereiche?.gruppieren?.(alleLeute)
|| [{ titel: 'Alle', rolle: '', leute: alleLeute }];
for (const g of gruppen) {
const rolle = g.rolle;
const leute = g.leute;
const gruppe = document.createElement('div');
gruppe.className = 'k-dabei__gruppe';
gruppe.dataset.rolle = rolle;
const titel = document.createElement('p');
titel.className = 'k-dabei__titel';
const punkt = document.createElement('span');
punkt.className = 'k-dabei__punkt';
punkt.setAttribute('aria-hidden', 'true');
titel.append(punkt, document.createTextNode(g.titel));
const liste = document.createElement('div');
liste.className = 'k-dabei__liste';
/* Die Gruppe ist eine Gruppe von Schaltern -- ein Vorleseprogramm
soll die Überschrift dazu ansagen, sonst hört man neun Namen
ohne zu wissen, wovon. */
liste.setAttribute('role', 'group');
liste.setAttribute('aria-label', g.titel);
for (const p of leute) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'k-dabei__knopf';
b.dataset.id = String(p.id);
b.dataset.rolle = p.rolle;
if (an.has(p.id)) b.dataset.an = 'ja';
const z = document.createElement('span');
z.className = 'k-dabei__zeichen';
z.setAttribute('aria-hidden', 'true'); // der Name steht direkt daneben
z.textContent = initialen(p.name);
const n = document.createElement('span');
n.className = 'k-dabei__name';
n.textContent = p.name;
const haken = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
haken.setAttribute('class', 'k-dabei__haken');
haken.setAttribute('viewBox', '0 0 16 16');
haken.setAttribute('aria-hidden', 'true');
haken.innerHTML = '<path d="M3 8.5l3.2 3.2L13 5"/>';
b.append(z, n, haken);
/* aria-pressed sagt einem Vorleseprogramm, dass das ein
Schalter ist und wie er steht -- ohne das hört man nur
"Knopf, Cigdem" und weiß nie, ob sie dabei ist. */
b.setAttribute('aria-pressed', an.has(p.id) ? 'true' : 'false');
b.addEventListener('click', () => {
const jetztAn = b.dataset.an !== 'ja';
if (jetztAn) b.dataset.an = 'ja'; else delete b.dataset.an;
b.setAttribute('aria-pressed', jetztAn ? 'true' : 'false');
dabeiHinweis();
});
liste.append(b);
}
gruppe.append(titel, liste);
ziel.append(gruppe);
}
dabeiHinweis();
}
/** Die Nummern, die gerade angehakt sind. */
const dabeiGewaehlt = () =>
[...$('dabei-wahl').querySelectorAll('.k-dabei__knopf[data-an="ja"]')].map((b) => Number(b.dataset.id));
function dabeiHinweis() {
const n = dabeiGewaehlt().length;
/* Sagt, was passiert -- nicht nur, wie viele es sind. Wer hier
steht, sieht den Termin in seinem Kalender; das ist der
eigentliche Punkt und niemandem von aussen anzusehen. */
$('dabei-hinweis').textContent = n
? `${n} ${n === 1 ? 'Person sieht' : 'Personen sehen'} diesen Termin im eigenen Kalender.`
: 'Niemand zusätzlich ausgewählt.';
}
let ansicht = 'monat';
let anker = heuteText(); // Datum, um das herum angezeigt wird
/* WAS NICHT AUSDRUECKLICH ABGEWAEHLT IST, IST SICHTBAR (08.09.2026).
Hier stand `{ call: true, termin: true, review: true, frist: true }`
-- eine zweite, von Hand gepflegte Liste der Arten neben ARTNAME.
Als die sechs Auftritts-Arten dazukamen, blieb sie stehen. Unten
filtert `sichtbare()` mit `zeigen[e.art]`, und fuer 'bigmatch' ist
das `undefined`. Ergebnis: Ein BigMatch liess sich anlegen, der
Server meldete 201, er stand in der Datenbank -- und war im
Kalender in KEINER Ansicht zu sehen. Ohne Fehler, ohne Hinweis.
Filipe haette einen Eintrag gemacht und nichts waere passiert.
Zwei Lehren, beide hier umgesetzt:
1. Die Liste wird ABGELEITET, nicht gefuehrt. ARTNAME ist die eine
Quelle; wer dort eine Art ergaenzt, hat sie ueberall.
2. Der Vorgabewert einer Sichtbarkeitsfrage muss SICHTBAR sein.
`sichtbare()` prueft deshalb `!== false` statt auf Wahrheit --
eine unbekannte Art faellt dann auf, statt zu verschwinden.
Ein Eintrag zu viel im Kalender ist ein Schoenheitsfehler, ein
fehlender ist ein verpasster Termin. */
const zeigen = Object.fromEntries(Object.keys(ARTNAME).map((a) => [a, true]));
/* ---------- Datumsrechnung ----------------------------------------------
Alles läuft in UTC-Mittag. Grund: Wer mit lokalen Zeiten rechnet,
verliert bei der Zeitumstellung einen Tag -- der 27. Oktober wird
zum 26., und niemand findet den Fehler, weil er nur zweimal im Jahr
auftritt. Mittag ist von beiden Umstellungen weit genug weg. */
const alsText = (d) => d.toISOString().slice(0, 10);
const ausText = (t) => new Date(t + 'T12:00:00Z');
function heuteText() {
const n = new Date();
return `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, '0')}`
+ `-${String(n.getDate()).padStart(2, '0')}`;
}
const plus = (d, n) => { const k = new Date(d); k.setUTCDate(k.getUTCDate() + n); return k; };
const datum = (jahr, monat, tag) => new Date(Date.UTC(jahr, monat - 1, tag, 12));
/** Ostersonntag (Gauß/Meeus, gregorianisch). Alle beweglichen
* kirchlichen Feiertage hängen daran, deshalb steht diese eine Formel
* am Anfang. */
function ostersonntag(jahr) {
const a = jahr % 19, b = Math.floor(jahr / 100), c = jahr % 100;
const d = Math.floor(b / 4), e = b % 4;
const f = Math.floor((b + 8) / 25), g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4), k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const monat = Math.floor((h + l - 7 * m + 114) / 31);
const tag = ((h + l - 7 * m + 114) % 31) + 1;
return new Date(Date.UTC(jahr, monat - 1, tag, 12));
}
/** n-ter bestimmter Wochentag eines Monats. wochentag: 0 = Sonntag. */
function nterWochentag(jahr, monat, wochentag, n) {
const d = datum(jahr, monat, 1);
const versatz = (wochentag - d.getUTCDay() + 7) % 7;
return plus(d, versatz + (n - 1) * 7);
}
/** ISO-8601-Kalenderwoche: Woche 1 ist die mit dem ersten Donnerstag. */
function kalenderwoche(d) {
const t = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
t.setUTCDate(t.getUTCDate() - ((t.getUTCDay() + 6) % 7) + 3);
const ersterDo = new Date(Date.UTC(t.getUTCFullYear(), 0, 4));
ersterDo.setUTCDate(ersterDo.getUTCDate() - ((ersterDo.getUTCDay() + 6) % 7) + 3);
return 1 + Math.round((t - ersterDo) / (7 * 86400000));
}
/** Montag der Woche, in der dieses Datum liegt. */
function montagVon(d) {
const k = new Date(d);
k.setUTCDate(k.getUTCDate() - ((k.getUTCDay() + 6) % 7));
return k;
}
const fmt = (o) => new Intl.DateTimeFormat('de-DE', { timeZone: 'UTC', ...o });
const tagLang = (t) =>
fmt({ weekday: 'long', day: '2-digit', month: 'long' }).format(ausText(t));
/* Kurzfassung fuer eine Pille: „24.09." -- ohne Jahr und ohne
Wochentag. In einer Zelle von 150 px ist jedes weitere Zeichen
eines, das den Titel abschneidet. */
const kurz = (t) => fmt({ day: '2-digit', month: '2-digit' }).format(ausText(t));
/* ---------- Anlässe ------------------------------------------------------ */
const anlassSpeicher = new Map();
function anlaesse(jahr) {
if (anlassSpeicher.has(jahr)) return anlassSpeicher.get(jahr);
const o = ostersonntag(jahr);
const roh = [
/* Gesetzliche Feiertage, BUNDESWEIT. Nur die neun, die in allen
Ländern gelten -- siehe Begründung ganz oben. */
{ d: datum(jahr, 1, 1), n: 'Neujahr', art: 'feiertag', sym: '🎆' },
{ d: plus(o, -2), n: 'Karfreitag', art: 'feiertag', sym: '✝' },
{ d: plus(o, 1), n: 'Ostermontag', art: 'feiertag', sym: '🐣' },
{ d: datum(jahr, 5, 1), n: 'Tag der Arbeit', art: 'feiertag', sym: '🛠' },
{ d: plus(o, 39), n: 'Christi Himmelfahrt', art: 'feiertag', sym: '☁' },
{ d: plus(o, 50), n: 'Pfingstmontag', art: 'feiertag', sym: '🕊' },
{ d: datum(jahr, 10, 3), n: 'Tag der Deutschen Einheit', art: 'feiertag', sym: '🇩🇪' },
{ d: datum(jahr, 12, 25), n: '1. Weihnachtstag', art: 'feiertag', sym: '🎄' },
{ d: datum(jahr, 12, 26), n: '2. Weihnachtstag', art: 'feiertag', sym: '🎄' },
/* Anlässe, an denen das Publikum wach ist. Für Creator zählt nicht
der Umsatz eines Ladens, sondern die Aufmerksamkeit: Wann sind
Leute online, wann lohnt sich ein besonderer Stream, wann ein
Themenvideo. */
{ d: datum(jahr, 2, 14), n: 'Valentinstag', art: 'anlass', sym: '💗' },
{ d: plus(o, -48), n: 'Rosenmontag · Karneval', art: 'anlass', sym: '🎭' },
{ d: o, n: 'Ostersonntag', art: 'anlass', sym: '🐰' },
{ d: nterWochentag(jahr, 5, 0, 2), n: 'Muttertag', art: 'anlass', sym: '💐' },
{ d: plus(o, 39), n: 'Vatertag', art: 'anlass', sym: '🍺' },
{ d: datum(jahr, 10, 31), n: 'Halloween', art: 'anlass', sym: '🎃' },
{ d: datum(jahr, 11, 11), n: 'Martinstag', art: 'anlass', sym: '🏮' },
/* Black Friday: der Freitag nach dem vierten Donnerstag im
November -- nicht "der letzte Freitag", das ist in manchen
Jahren ein anderer Tag. */
{ d: plus(nterWochentag(jahr, 11, 4, 4), 1), n: 'Black Friday', art: 'anlass', sym: '🏷' },
{ d: plus(nterWochentag(jahr, 11, 4, 4), 4), n: 'Cyber Monday', art: 'anlass', sym: '🏷' },
{ d: datum(jahr, 12, 6), n: 'Nikolaus', art: 'anlass', sym: '🥾' },
{ d: datum(jahr, 12, 24), n: 'Heiligabend', art: 'anlass', sym: '🎁' },
{ d: datum(jahr, 12, 31), n: 'Silvester', art: 'anlass', sym: '🎇' },
];
/* Die vier Advente: rückwärts vom vierten, dem letzten Sonntag vor
dem 25.12. Rückwärts gerechnet, weil "der vierte" der definierte
ist -- vorwärts zu zählen wäre in Jahren, in denen der 24.12.
selbst ein Sonntag ist, um eine Woche daneben. */
const w = datum(jahr, 12, 25);
const vierter = plus(w, -(w.getUTCDay() === 0 ? 7 : w.getUTCDay()));
for (let i = 0; i < 4; i++) {
roh.push({ d: plus(vierter, -7 * (3 - i)), n: `${i + 1}. Advent`, art: 'anlass', sym: '🕯' });
}
const karte = new Map();
for (const a of roh) {
const t = alsText(a.d);
if (!karte.has(t)) karte.set(t, []);
karte.get(t).push({ name: a.n, art: a.art, sym: a.sym, tag: t });
}
anlassSpeicher.set(jahr, karte);
return karte;
}
const anlaesseAm = (t) => anlaesse(Number(t.slice(0, 4))).get(t) || [];
const istFeiertag = (t) => anlaesseAm(t).some((a) => a.art === 'feiertag');
/** Alle Anlässe in einem Zeitraum, nach Datum sortiert. Läuft über
* Jahresgrenzen -- ein Dezember-Zeitraum reicht in den Januar. */
function anlaesseIn(von, bis) {
const raus = [];
for (let j = Number(von.slice(0, 4)); j <= Number(bis.slice(0, 4)); j++) {
for (const [tag, liste] of anlaesse(j)) if (tag >= von && tag <= bis) raus.push(...liste);
}
return raus.sort((x, y) => x.tag.localeCompare(y.tag));
}
/* ---------- Zeitraum ------------------------------------------------------ */
function zeitraum() {
const a = ausText(anker);
if (ansicht === 'woche') {
const mo = montagVon(a);
return { von: alsText(mo), bis: alsText(plus(mo, 6)) };
}
if (ansicht === 'liste' || ansicht === 'zeitstrahl') {
/* Arbeitsansichten zeigen einen rollenden Zeitraum ab dem Anker,
nicht einen Kalendermonat: Beim Arbeiten interessiert "was kommt
als Nächstes", nicht "was steht im September". */
return { von: alsText(a), bis: alsText(plus(a, 41)) };
}
/* Monat: ganze Wochen, damit das Raster aufgeht. */
const erster = new Date(Date.UTC(a.getUTCFullYear(), a.getUTCMonth(), 1, 12));
const letzter = new Date(Date.UTC(a.getUTCFullYear(), a.getUTCMonth() + 1, 0, 12));
return { von: alsText(montagVon(erster)), bis: alsText(plus(montagVon(letzter), 6)) };
}
function zeitraumText() {
const a = ausText(anker);
if (ansicht === 'monat') return fmt({ month: 'long', year: 'numeric' }).format(a);
const { von, bis } = zeitraum();
const f = (s) => fmt({ day: '2-digit', month: 'short' }).format(ausText(s));
return `${f(von)} – ${f(bis)}`;
}
function unterText() {
const { von, bis } = zeitraum();
const kvon = kalenderwoche(ausText(von)), kbis = kalenderwoche(ausText(bis));
const n = sichtbare().length;
const kw = kvon === kbis ? `KW ${kvon}` : `KW ${kvon} – ${kbis}`;
return `${kw} · ${n} ${n === 1 ? 'Eintrag' : 'Einträge'}`;
}
function springen(richtung) {
const a = ausText(anker);
if (ansicht === 'monat') a.setUTCMonth(a.getUTCMonth() + richtung);
else if (ansicht === 'woche') a.setUTCDate(a.getUTCDate() + 7 * richtung);
else a.setUTCDate(a.getUTCDate() + 42 * richtung);
anker = alsText(a);
laden();
}
/* ---------- Die Einträge auf eine Achse bringen --------------------------
Termine und Fristen kommen aus zwei Tabellen, sehen aber im Kalender
gleich aus. Sie werden deshalb EINMAL in dieselbe Form gebracht --
jede Ansicht arbeitet danach nur noch mit dieser einen Liste und
kann gar nicht mehr etwas anderes zeigen als die andere. */
/** "mit Luna", "mit Luna und Pat", "mit Luna, Pat +2".
*
* Fällt auf den frei eingetragenen Namen zurück, wenn keine Person
* verknüpft ist ("Agentur Müller") -- der steht nicht in der
* Teilnehmerliste, weil er kein Konto hat, gehört aber genauso
* dazu. */
function mitWem(t) {
const namen = (t.teilnehmer || []).map((p) => p.name);
if (!namen.length) return t.teilnehmer_name ? 'mit ' + t.teilnehmer_name : null;
if (namen.length === 1) return 'mit ' + namen[0];
if (namen.length === 2) return `mit ${namen[0]} und ${namen[1]}`;
return `mit ${namen[0]}, ${namen[1]} +${namen.length - 2}`;
}
/* Darf diese Person zu einem Termin eine Checkliste anlegen? Kommt
vom Server (siehe laden()). */
let checklisteMoeglich = false;
function vereinheitlichen(roh) {
const raus = [];
for (const t of roh.termine || []) {
raus.push({
art: t.art, tag: t.beginn.slice(0, 10), uhr: t.beginn.slice(11, 16),
titel: t.titel, erledigt: !!t.erledigt, id: t.id,
farbe: ARTFARBE[t.art] || ARTFARBE.termin,
/* WER DABEI IST -- alle, nicht nur das Haupt-Gegenueber
(05.09.2026). Bei zwei Leuten stehen beide Namen da, ab drei
wird gekürzt: "mit Luna, Pat +2". Die ganze Liste in einer
Tageszeile würde alles andere aus dem Bild schieben; die
vollständige Aufzählung steht im Detail darunter. */
zusatz: [t.dauer_min ? t.dauer_min + ' Min' : null,
mitWem(t),
t.ort || null].filter(Boolean).join(' · '),
ort: t.ort, beschreibung: t.beschreibung,
teilnehmer: t.teilnehmer || [],
creator_name: t.creator_name, teilnehmer_name: t.teilnehmer_name,
erstellt_von: t.erstellt_von, dauer_min: t.dauer_min,
serie_id: t.serie_id || null,
/* Wie viele Punkte der Live-Checkliste zu diesem Termin schon
angelegt sind. Kommt vom Server; fehlt die Angabe, gibt es
den Knopf gar nicht. */
checkliste: t.checkliste_aufgaben ?? null,
});
}
/* ==== DIE ZEITEN DER ANDEREN SEITE (24.09.2026) ==================
Filipe: „Getrennt, aber ‚belegt' bleibt sichtbar."
Der Server schickt hier NUR Beginn und Dauer -- kein Titel,
keine Nummer, keine Person. Der Browser kann also gar nichts
ueber die andere Seite verraten, selbst wenn er wollte: Es ist
nicht da. Das ist der Grund, warum die Trennung im SERVER
gemacht wird und nicht durch Weglassen beim Zeichnen.
KEINE `id` UND KEIN `ziel`: Damit ist der Block nicht anklickbar
und bekommt in der Tagesansicht keine Knoepfe. Ein „erledigt" an
einem fremden Termin waere eine Aenderung im anderen Haus --
genau das, was nicht mehr passieren soll. */
for (const b of roh.belegt || []) {
raus.push({
art: 'belegt', tag: b.beginn.slice(0, 10), uhr: b.beginn.slice(11, 16),
titel: 'belegt', erledigt: false, id: null,
farbe: ARTFARBE.belegt,
zusatz: [b.dauer_min ? b.dauer_min + ' Min' : null,
'auf der anderen Seite'].filter(Boolean).join(' · '),
dauer_min: b.dauer_min,
});
}
for (const f of roh.fristen || []) {
raus.push({
art: 'frist', tag: f.frist, uhr: null,
titel: f.titel, erledigt: false, id: 'a' + f.id,
farbe: ARTFARBE.frist,
zusatz: ['Priorität ' + f.prioritaet,
f.verantwortlich_name || null].filter(Boolean).join(' · '),
ziel: 'aufgaben.html',
});
}
/* ---- Was auf den Brettern steht (17.09.2026) --------------------
Filipe, zu einer Clip-Karte: „ich will dass die auch in einer
kalender drin sind."
WELCHE hier ankommen, entscheidet der Server (workspace-
kalender.js) -- und zwar nach der Regel „nur, wofuer jemand eine
Zeit bestimmt hat". Der Browser rechnet das NICHT nach: Eine
zweite Fassung derselben Regel ist die, die beim naechsten
Nachschaerfen auseinanderlaeuft. Er zeigt, was kommt. */
for (const b of roh.brettsachen || []) {
raus.push({
art: 'brett', tag: b.wann, uhr: b.uhrzeit || null,
titel: b.titel, erledigt: false, id: 'b' + b.id,
farbe: ARTFARBE.brett,
brett_name: b.brett_name,
/* DER GRUND STEHT DABEI. „Zugesagt am 24." ist etwas anderes
als „liegt auf dem 24." -- das erste ist ein Versprechen an
jemanden, das zweite eine Notiz an sich selbst. Wer das nicht
unterscheiden kann, behandelt beides gleich, und dann zaehlt
die Zusage so wenig wie die Notiz. */
grund: b.grund,
bis: b.event_ende || null,
zusatz: [b.brett_name, GRUNDWORT[b.grund] || null,
b.event_ende && b.event_ende !== b.wann
? 'bis ' + kurz(b.event_ende) : null,
b.von_name ? 'von ' + b.von_name : null].filter(Boolean).join(' · '),
beschreibung: b.text || null,
/* Der Weg zurueck zum Brett. Ein Kalendereintrag, den man nur
ansehen und nicht oeffnen kann, ist eine Sackgasse -- und der
ganze Vorgang (kommentieren, erledigen, verschieben) steht
dort, nicht hier. */
/* Auf den ZETTEL, nicht nur auf das Brett. Zwischen zwanzig
anderen ist der gesuchte sonst wieder von Hand zu suchen --
und dann hat der Link nichts gebracht. `?zeigen=` ist der
Weg, den das Haus dafuer schon hat (kopf.js). */
ziel: 'bereich.html?b=' + encodeURIComponent(b.bereich)
+ '&zeigen=eintrag-' + b.id,
});
}
/* Erst nach Tag, dann nach Uhrzeit. Fristen haben keine Uhrzeit und
stehen deshalb ans Ende des Tages -- sie blockieren keinen Slot. */
return raus.sort((a, b) =>
a.tag.localeCompare(b.tag) || (a.uhr || '99:99').localeCompare(b.uhr || '99:99'));
}
/* `!== false`, nicht auf Wahrheit pruefen -- Begruendung oben bei
`zeigen`. Eine Art, die niemand eingetragen hat, wird angezeigt
statt verschluckt. */
const sichtbare = () => (daten ? daten.eintraege.filter((e) => zeigen[e.art] !== false) : []);
/* =====================================================================
EIN TERMIN VON--BIS STEHT AN JEDEM TAG DAZWISCHEN (20.09.2026)
Filipe: „wenn ich termine eintrage mit von bis, will ich dass die
ganze zeit markiert ist und nicht nur der anfang."
BISHER stand er nur am Anfangstag. Wer im Monatsraster auf den
Mittwoch sah, sah nichts -- obwohl die Aktion von Montag bis
Freitag lief. Genau in der Ansicht, in der man plant, war der
belegte Zeitraum unsichtbar, und man legte etwas mitten hinein.
WARUM GENAU HIER: `nachTag()` ist die EINZIGE Stelle, an der
Eintraege auf Tage verteilt werden -- Monat, Woche, Liste und
Zeitstrahl holen sich alle `karte.get(tag)`. Vier Ansichten
einzeln nachzuruesten waere vier Stellen, an denen die naechste
Ansicht vergessen wird.
DAS ENDE WIRD ABGELEITET, NICHT GEPFLEGT:
* `bis` -- Brettkarten mit event_ende
* Uhr + Dauer -- ein Live von 22:00 ueber 240 Minuten endet um
02:00 am naechsten Tag. Das stand bisher
NIRGENDS, obwohl die Zahlen da waren.
Eine Liste „diese Arten haben ein Ende" waere die Liste, die beim
naechsten Termintyp veraltet.
DIE KOPIEN AENDERN `tag` NICHT. Wer auf eine Kachel am Mittwoch
tippt, will den Termin bearbeiten -- und der faengt am Montag an.
Wuerde die Kopie `tag` auf den Mittwoch setzen, verschoebe ein
Speichern den Termin auf den Tag, an dem man zufaellig
hingetippt hat. Der Anzeigetag steht deshalb getrennt daneben. */
/** Der letzte Tag, an dem dieser Eintrag noch laeuft. */
function endTagVon(e) {
if (e.bis) return String(e.bis).slice(0, 10);
if (e.uhr && Number(e.dauer_min) > 0) {
const [st, mi] = e.uhr.split(':').map(Number);
const start = ausText(e.tag);
if (Number.isNaN(st) || Number.isNaN(start.getTime())) return e.tag;
/* AB MITTERNACHT RECHNEN, NICHT AB `ausText`. `ausText` legt den
Tag bewusst auf 12:00 UTC (damit Zeitzonen ihn nicht um einen
Tag verschieben) -- wer darauf die Uhrzeit ADDIERT, rechnet
zwoelf Stunden zu viel. Erster Anlauf ergab damit fuer
„13:00, 60 Minuten" den FOLGETAG. */
const mitternacht = Date.UTC(
start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate());
/* Minus eine Millisekunde: Ein Stream von 22:00 ueber genau zwei
Stunden endet um 00:00 -- er laeuft am naechsten Tag NICHT
mehr, auch wenn die Zahl dort steht. */
const endeMs = mitternacht
+ ((st * 60 + mi + Number(e.dauer_min)) * 60000) - 1;
return alsText(new Date(Math.max(endeMs, mitternacht)));
}
return e.tag;
}
/* Eine Obergrenze, die nicht schoen ist, aber ehrlich: Ein Eintrag
mit kaputtem Enddatum (Jahr 9999) wuerde sonst Millionen Kopien
bauen und den Browser anhalten. 400 Tage sind mehr als jede
Ansicht je zeigt -- die Grenze ist also nie im Weg und faengt nur
den Unfall ab. */
const SPANNE_MAX = 400;
function nachTag() {
const karte = new Map();
const lege = (tag, e) => {
if (!karte.has(tag)) karte.set(tag, []);
karte.get(tag).push(e);
};
for (const e of sichtbare()) {
const ende = endTagVon(e);
if (!ende || ende <= e.tag) { lege(e.tag, e); continue; }
const tage = Math.min(SPANNE_MAX,
Math.round((ausText(ende) - ausText(e.tag)) / 86400000) + 1);
let d = ausText(e.tag);
for (let i = 0; i < tage; i++) {
lege(alsText(d), {
...e,
/* Der Tag, an dem die Kachel steht -- NICHT `tag`. */
spann_tag: alsText(d),
spann_nr: i + 1,
spann_tage: tage,
spann_bis: ende,
spann_pos: i === 0 ? 'start' : (i === tage - 1 ? 'ende' : 'mitte'),
});
d = plus(d, 1);
}
}
return karte;
}
/** Alle Tage des Zeitraums, an denen etwas steht -- eigene Einträge
* ODER ein Anlass. Ohne die Anlässe wäre der Fehler teuer: Liste und
* Zeitstrahl hätten "nichts an" gemeldet, während in derselben Woche
* Halloween lag. Genau die Ansicht, in der man plant, hätte den
* Anlass verschwiegen. */
function tageMitInhalt(karte) {
const { von, bis } = zeitraum();
const tage = new Set(karte.keys());
for (const a of anlaesseIn(von, bis)) tage.add(a.tag);
return [...tage].filter((t) => t >= von && t <= bis).sort();
}
/* ---------- Bausteine ----------------------------------------------------- */
function pille(e) {
const k = el(e.ziel ? 'a' : 'div', 'k-pille' + (e.erledigt ? ' erledigt' : ''));
if (e.ziel) k.href = e.ziel;
k.style.setProperty('--pfarbe', e.farbe);
k.title = `${e.titel}${e.zusatz ? ' — ' + e.zusatz : ''}`
+ (e.serie_id ? ' — wiederholt sich' : '')
/* Der ganze Zeitraum, nicht nur „Tag 2/4": Wer am Mittwoch
draufzeigt, will wissen, WANN es anfing und aufhoert -- sonst
muss er selbst zurueckblaettern und zaehlen. */
+ (e.spann_tage > 1
? ` — läuft vom ${tagLang(e.tag)} bis ${tagLang(e.spann_bis)}`
+ ` (Tag ${e.spann_nr} von ${e.spann_tage})`
: '');
/* ---- LAEUFT UEBER MEHRERE TAGE (20.09.2026) --------------------
Drei Dinge unterscheiden die Kachel eines Zeitraums von der eines
Punkttermins, und alle drei sind noetig:
1. `data-spanne` -- daraus macht das Stilblatt einen
durchgehenden Balken: links angeschnitten, wo er gestern
schon lief, rechts angeschnitten, wo er morgen weiterlaeuft.
Man sieht an der FORM, dass es weitergeht, ohne ein Wort.
2. „Tag 2/4" statt der Uhrzeit auf den Folgetagen. Die Uhrzeit
gehoert zum Anfang; sie am Mittwoch zu wiederholen behauptet,
es ginge am Mittwoch um 20:00 wieder los.
3. Der ganze Zeitraum im Titel -- fuer Maus und Vorleseprogramm.
AUF DEM ANFANGSTAG BLEIBT ALLES WIE VORHER. Wer den Termin
eintraegt, sieht dort die Uhrzeit, die er eingetragen hat. */
if (e.spann_tage > 1) {
k.dataset.spanne = e.spann_pos;
k.style.setProperty('--spann-tage', String(e.spann_tage));
}
if (e.spann_tage > 1 && e.spann_pos !== 'start') {
k.append(el('span', 'k-pille__zeit k-pille__lauf',
`Tag ${e.spann_nr}/${e.spann_tage}`));
} else if (e.uhr) k.append(el('span', 'k-pille__zeit', e.uhr));
/* Das Zeichen steht im Raster VOR dem Titel, nicht dahinter: Eine
Zelle im Monatsraster ist gut 150 px breit, der Titel wird dort
abgeschnitten -- ein Zeichen am Ende wäre genau in der Ansicht
unsichtbar, in der man den Rhythmus erkennen will. In der Liste
(siehe zeile()) ist Platz, dort steht es hinter dem Titel und
liest sich natürlicher. */
if (e.serie_id) k.append(wiederholZeichen());
/* WOHER ER KOMMT, STEHT VOR DEM TITEL (17.09.2026).
„Wer ist euer Komet?!" allein sagt im Kalender nichts -- man
weiss nicht, ob das ein Stream, eine Aufgabe oder ein Zettel
ist. „Content · Wer ist euer Komet?!" beantwortet das in einem
Wort, und zwar VOR dem Titel: Eine Zelle im Monatsraster ist gut
150 px breit und schneidet hinten ab. Dieselbe Ueberlegung wie
beim Wiederhol-Zeichen eine Zeile darueber.
UND ES IST EINE ZUSAGE ERKENNBAR. Was jemandem versprochen
wurde, traegt ein eigenes Zeichen -- ein Versprechen, das
aussieht wie eine Notiz, wird auch wie eine behandelt. */
/* EINE ZUSAGE TRAEGT EIN HAKEN, und der steht VOR dem Titel.
„Wird gemacht -- am 24.09." ist ein Versprechen an jemanden;
ein selbstgesetztes Datum ist eine Notiz an sich selbst. Wer das
nicht unterscheiden kann, behandelt beides gleich -- und dann
zaehlt die Zusage so wenig wie die Notiz.
Zwei Zeichen statt eines Wortes: Im Monatsraster ist jede
Buchstabenbreite eine, die dem Titel fehlt. */
if (e.grund === 'zugesagt') k.append(el('span', 'k-pille__zusage', '\u2713'));
k.append(document.createTextNode(e.titel));
/* WOHER ER KOMMT, STEHT DARUNTER -- nicht davor (17.09.2026).
Der erste Anlauf setzte den Brettnamen VOR den Titel. Am
Bildschirmfoto war sofort zu sehen, warum das nicht geht: In
einer Monatszelle von rund 150 px blieb von „Clip am Wochenende"
genau „Cli…" uebrig. Das Etikett hat die Information verdraengt,
die es einordnen sollte.
In der zweiten Zeile kostet es KEINE Breite. Hoehe ist im
Monatsraster der billigere Platz: Eine Zelle ist gut 110 px hoch
und traegt meist ein bis zwei Eintraege. */
if (e.brett_name) k.append(el('span', 'k-pille__brett', e.brett_name));
return k;
}
/** Das Wiederhol-Zeichen. Einmal gebaut, überall gleich -- und mit
* einer Beschriftung für Vorleseprogramme, die das Zeichen selbst
* sonst als "Pfeil im Kreis" oder gar nicht vorlesen. */
function wiederholZeichen() {
const s = el('span', 'k-wdh-zeichen', '↻');
s.setAttribute('role', 'img');
s.setAttribute('aria-label', 'wiederholt sich');
s.title = 'Gehört zu einer Wiederholung';
return s;
}
function anlassMarke(a) {
const k = el('span', 'k-anlass' + (a.art === 'feiertag' ? ' feiertag' : ''),
a.sym + ' ' + a.name);
k.style.setProperty('--afarbe', a.art === 'feiertag' ? FEIERTAG_FARBE : ANLASS_FARBE);
k.title = a.art === 'feiertag'
? a.name + ' — gesetzlicher Feiertag (bundesweit).'
: a.name + ' — Anlass, an dem das Publikum wach ist.';
return k;
}
/** Macht eine Tagesfläche anklickbar -- mit Maus UND Tastatur. Ein
* Klick auf eine Pille darin führt weiterhin zum Eintrag; nur der
* leere Teil öffnet den Tag. Sonst käme man an die verlinkten
* Einträge nicht mehr heran. */
function tagKlickbar(k, tag) {
k.dataset.tag = tag;
k.tabIndex = 0;
k.setAttribute('role', 'button');
k.setAttribute('aria-label',
fmt({ weekday: 'long', day: 'numeric', month: 'long' }).format(ausText(tag))
+ ' — öffnen');
k.addEventListener('click', (ev) => {
if (ev.target.closest('a')) return;
tagOeffnen(tag);
});
k.addEventListener('keydown', (ev) => {
if (ev.target !== k) return;
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); tagOeffnen(tag); }
});
}
/* ---------- Anlasskarten -------------------------------------------------
EINE Bauanleitung für beide Reihen. Zwei getrennte hätten früher oder
später unterschiedlich ausgesehen, weil eine Änderung nur an einer
Stelle nachgezogen wird. */
function anlassKarte(a) {
const tage = Math.round((ausText(a.tag) - ausText(daten.heute)) / 86400000);
const k = el('div', 'k-anlasskarte'
+ (a.art === 'feiertag' ? ' feiertag' : '') + (tage < 0 ? ' vorbei' : ''));
const af = a.art === 'feiertag' ? FEIERTAG_FARBE : ANLASS_FARBE;
k.style.setProperty('--afarbe', af);
/* --ton mitsetzen: Davon nimmt das Licht unter dem Zeiger seine
Farbe (siehe kopf.js). Sonst leuchtete jede Karte im Farbton der
SEITE statt in ihrem eigenen. */
k.style.setProperty('--ton', af);
k.append(el('span', 'k-anlasskarte__sym', a.sym));
const txt = el('div', 'k-anlasskarte__txt');
txt.append(el('span', 'k-anlasskarte__name', a.name));
txt.append(el('span', 'k-anlasskarte__wann',
fmt({ weekday: 'short', day: '2-digit', month: 'long' }).format(ausText(a.tag))));
k.append(txt);
const rest = el('div', 'k-anlasskarte__rest',
tage < 0 ? 'vorbei' : tage === 0 ? 'heute' : tage === 1 ? 'morgen' : `in ${tage} Tagen`);
/* Drei Wochen sind die Faustregel für Vorlauf: Idee, drehen,
schneiden, hochladen. Wer erst in der Woche davor anfängt,
veröffentlicht hinterher. */
if (a.art === 'anlass' && tage > 0 && tage <= 21) {
rest.append(el('small', null, 'Vorlauf läuft'));
}
k.append(rest);
k.tabIndex = 0;
k.setAttribute('role', 'button');
k.setAttribute('aria-label', a.name + ' — zum Tag springen');
k.addEventListener('click', () => zumTag(a.tag));
k.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); zumTag(a.tag); }
});
return k;
}
/* Reihe 1: was als Nächstes kommt. Ab HEUTE, nicht ab dem angezeigten
Monat -- sie beantwortet "was kommt auf mich zu", und das ändert
sich nicht dadurch, dass man weiterblättert. */
function baueSaison() {
const ziel = $('saison');
ziel.replaceChildren();
const jahr = Number(daten.heute.slice(0, 4));
const alle = [];
for (const j of [jahr, jahr + 1]) for (const l of anlaesse(j).values()) alle.push(...l);
const kommend = alle.filter((a) => a.tag >= daten.heute)
.sort((a, b) => a.tag.localeCompare(b.tag)).slice(0, 3);
kommend.forEach((a) => ziel.append(anlassKarte(a)));
$('saison-block').hidden = !kommend.length;
}
/* Reihe 2: die Anlässe des angezeigten Zeitraums. Die Reihe darüber
blickt nach vorn; diese beschreibt, was man gerade ansieht -- sonst
müsste man ins Raster hinunterscrollen und die Tage absuchen, nur um
zu wissen, ob überhaupt etwas ansteht. */
function baueMonatsanlaesse() {
const ziel = $('monatsanlaesse');
const titel = $('monat-titel');
ziel.replaceChildren();
let von, bis;
if (ansicht === 'monat') {
/* Der KALENDERMONAT, nicht der angezeigte Sechs-Wochen-Block --
sonst stünden hier Anlässe aus dem Nachbarmonat, die nur
zufällig mit im Bild sind. */
const a = ausText(anker);
von = alsText(new Date(Date.UTC(a.getUTCFullYear(), a.getUTCMonth(), 1, 12)));
bis = alsText(new Date(Date.UTC(a.getUTCFullYear(), a.getUTCMonth() + 1, 0, 12)));
titel.textContent = 'Im ' + fmt({ month: 'long', year: 'numeric' }).format(a);
} else {
({ von, bis } = zeitraum());
const f = (s) => fmt({ day: '2-digit', month: 'short' }).format(ausText(s));
titel.textContent = `Von ${f(von)} bis ${f(bis)}`;
}
const liste = anlaesseIn(von, bis);
if (!liste.length) {
ziel.append(el('p', 'k-anlassleer', 'In diesem Zeitraum steht kein besonderer Anlass an.'));
} else {
liste.forEach((a) => ziel.append(anlassKarte(a)));
}
$('monat-block').hidden = false;
}
/* ---------- Wiederholungen ------------------------------------------------
Wunsch vom 02.09.2026: Termine, die "jede Woche, jeden Monat
automatisiert immer weiter laufen -- bis ich es selber deaktiviere".
Zwei Teile: die Auswahl im Formular (was soll sich wiederholen) und
die Liste darunter (was läuft gerade und wie stelle ich es ab).
Die Beschriftungen der Auswahl hängen vom GEWÄHLTEN DATUM ab, nicht
von abstrakten Wörtern: "jeden Dienstag" statt "wöchentlich", "jeden
Monat am 2." statt "monatlich". Wer "monatlich" liest, weiß nicht,
ob der 31. Januar im Februar ausfällt oder auf den 28. rutscht --
hier steht es dran. Der Wortlaut ist derselbe wie auf dem Server
(workspace-serien.js, taktText); die Liste unten zeigt IMMER die
Fassung des Servers, damit im Zweifel eine von beiden gilt. */
function taktLage(tagText) {
const d = ausText(tagText);
const j = d.getUTCFullYear(), m = d.getUTCMonth(), tag = d.getUTCDate();
const letzter = new Date(Date.UTC(j, m + 1, 0, 12)).getUTCDate();
return {
tag, monat: m, wochentag: d.getUTCDay(),
nter: Math.ceil(tag / 7),
letzterWochentag: tag + 7 > letzter,
};
}
/** Die möglichen Rhythmen für ein bestimmtes Startdatum, jeweils mit
* dem Satz, der davon in der Vorschau steht. */
function taktListe(tagText) {
const l = taktLage(tagText);
const wt = WOCHENTAGE_LANG[l.wochentag];
return [
{ wert: 'taeglich', text: 'jeden Tag' },
{ wert: 'werktags', text: 'jeden Werktag (Mo–Fr)' },
{ wert: 'woechentlich', text: `jeden ${wt}` },
{ wert: 'zweiwoechentlich', text: `alle zwei Wochen, ${wt}s` },
{
wert: 'monatlich_datum',
text: `jeden Monat am ${l.tag}.`,
/* Ehrlich bleiben: Ein 29./30./31. gibt es nicht in jedem Monat.
Ausgelassen wird er dann, nicht vorgezogen -- und das gehört
in die Auswahl, nicht in eine Fußnote, die niemand liest. */
hinweis: l.tag > 28 ? ` (Monate ohne den ${l.tag}. fallen aus)` : '',
},
{ wert: 'monatlich_letzter', text: 'jeden Monat am letzten Tag' },
{
wert: 'monatlich_wochentag',
text: l.letzterWochentag
? `jeden Monat am letzten ${wt}`
: `jeden Monat am ${ORDNUNG[l.nter]} ${wt}`,
},
{ wert: 'jaehrlich', text: `jedes Jahr am ${l.tag}. ${MONATE[l.monat]}` },
];
}
/** Füllt die Auswahl neu -- bei jedem Datumswechsel, denn "jeden
* Dienstag" wird zu "jeden Mittwoch", sobald man das Datum ändert.
* Die getroffene Wahl bleibt dabei erhalten. */
function taktAuswahlFuellen() {
const feld = $('f-takt');
const tag = ($('f-beginn').value || '').slice(0, 10);
const gewaehlt = feld.value;
feld.replaceChildren();
const leer = document.createElement('option');
leer.value = ''; leer.textContent = 'Einmalig – wiederholt sich nicht';
feld.append(leer);
if (!/^\d{4}-\d{2}-\d{2}$/.test(tag)) return;
for (const t of taktListe(tag)) {
const o = document.createElement('option');
o.value = t.wert;
o.textContent = t.text + (t.hinweis || '');
feld.append(o);
}
feld.value = gewaehlt;
/* War der alte Wert nicht mehr dabei, fällt die Auswahl auf
"Einmalig" zurück -- lieber nichts wiederholen als etwas
anderes als angezeigt. */
if (feld.value !== gewaehlt) feld.value = '';
wdhVorschau();
}
/** Der Satz unter der Auswahl: was gleich entsteht, in Worten. */
function wdhVorschau() {
const takt = $('f-takt').value;
$('feld-wdh').hidden = !takt;
if (!takt) { $('wdh-satz').textContent = ''; return; }
const tag = ($('f-beginn').value || '').slice(0, 10);
const uhr = ($('f-beginn').value || '').slice(11, 16);
const eintrag = taktListe(tag).find((t) => t.wert === takt);
const ende = $('f-ende').value;
$('wdh-satz').textContent = 'Läuft ' + (eintrag ? eintrag.text : takt)
+ (uhr ? ` um ${uhr} Uhr` : '')
+ (ende ? ` bis zum ${tagLang(ende)}` : ', bis du sie abstellst')
+ '.';
}
/* ---------- Die Liste der laufenden Wiederholungen ------------------------ */
async function serienLaden() {
try {
const a = await hole('/workspace/api/serien');
if (!a.ok) { serien = []; return; }
serien = (await a.json()).serien || [];
} catch { serien = []; }
}
function baueSerien() {
const ziel = $('serien');
ziel.replaceChildren();
$('serien-block').hidden = !serien.length;
if (!serien.length) return;
for (const s of serien) {
const k = el('div', 'k-serie');
k.dataset.aktiv = s.aktiv ? 'ja' : 'nein';
const farbe = ARTFARBE[s.art] || ARTFARBE.termin;
k.style.setProperty('--sfarbe', farbe);
k.style.setProperty('--ton', farbe);
const kopf = el('div', 'k-serie__kopf');
kopf.append(el('span', 'k-serie__titel', s.titel));
kopf.append(el('span', 'k-serie__art', ARTNAME[s.art] || s.art));
/* Der Zustand als Wort, nicht nur als Farbe -- "pausiert" muss man
lesen können, ohne Farben unterscheiden zu müssen. */
kopf.append(el('span', 'k-serie__zustand', s.aktiv ? 'läuft' : 'pausiert'));
k.append(kopf);
/* Der Satz kommt vom Server. Bewusst nicht hier noch einmal
gebaut: Wenn zwei Stellen denselben Satz formulieren, sagen sie
irgendwann Verschiedenes -- und dann glaubt man der falschen. */
k.append(el('p', 'k-serie__satz', s.satz));
k.append(el('p', 'k-serie__naechster', s.naechster
? 'Nächster Termin: ' + tagLang(s.naechster.slice(0, 10))
+ ', ' + s.naechster.slice(11, 16) + ' Uhr'
: s.aktiv ? 'Kein weiterer Termin – die Serie ist ausgelaufen.'
: 'Pausiert – es kommt nichts nach.'));
if (s.darf_aendern) {
const knoepfe = el('div', 'k-serie__knoepfe');
knoepfe.append(tuKnopf(s.aktiv ? 'abstellen' : 'wieder starten', async () => {
if (s.aktiv && !await frageNach({
titel: 'Wiederholung „' + s.titel + '“ abstellen?',
was: 'Kommende Termine, die noch niemand angefasst hat, werden aus '
+ 'dem Kalender genommen.',
bleibt: 'Vergangene und bearbeitete Termine bleiben stehen. Du kannst '
+ 'die Wiederholung jederzeit wieder starten.',
ja: 'Abstellen',
})) return;
const a = await hole('/workspace/api/serien/' + s.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aktiv: s.aktiv ? 0 : 1 }),
});
if (!a.ok) { melde('Umschalten ging nicht.'); return; }
await laden();
}));
knoepfe.append(tuKnopf('löschen', async () => {
if (!await frageNach({
titel: 'Wiederholung „' + s.titel + '“ ganz löschen?',
was: 'Die Regel ist damit weg. Kommende, noch unangetastete Termine '
+ 'werden entfernt.',
bleibt: 'Vergangene und schon bearbeitete Termine bleiben als '
+ 'normale Termine stehen.',
endgueltig: true, ja: 'Wiederholung löschen',
})) return;
const a = await hole('/workspace/api/serien/' + s.id, { method: 'DELETE' });
if (!a.ok) { melde('Löschen ging nicht.'); return; }
await laden();
}));
k.append(knoepfe);
}
ziel.append(k);
}
}
/* ---------- Filter -------------------------------------------------------- */
function baueFilter() {
const ziel = $('filter');
ziel.replaceChildren();
/* DIE LISTE WIRD ABGELEITET (08.09.2026) -- vorher stand hier
`['call','termin','review','frist']` fest, die dritte Fassung
derselben Aufzaehlung in einer Datei.
Die vier Grundarten bekommen immer einen Schalter, auch bei null
Eintraegen: Sie sind der Alltag, und ein Schalter, der auftaucht
und verschwindet, verwirrt mehr, als er hilft. Die sechs
Auftritts-Arten erscheinen nur, wenn es sie im Zeitraum wirklich
gibt -- zehn Schalter, davon sechs mit "(0)", waeren eine Leiste,
die niemand mehr liest. */
const GRUND = ['call', 'termin', 'review', 'frist'];
const anzahl = (art) => daten.eintraege.filter((e) => e.art === art).length;
const arten = [...GRUND,
...Object.keys(ARTNAME).filter((a) => !GRUND.includes(a) && anzahl(a) > 0)];
for (const art of arten) {
const n = anzahl(art);
const b = el('button', 'k-chip');
b.type = 'button';
b.setAttribute('aria-pressed', String(zeigen[art]));
b.style.setProperty('--cfarbe', ARTFARBE[art]);
b.append(el('span', 'k-chip__punkt'));
b.append(document.createTextNode(`${ARTNAME[art]} (${n})`));
b.addEventListener('click', () => { zeigen[art] = !zeigen[art]; zeichnen(); });
ziel.append(b);
}
}
/* ---------- Ansicht: Monat ------------------------------------------------ */
function baueMonat(karte) {
const { von, bis } = zeitraum();
const monat = ausText(anker).getUTCMonth();
const raster = el('div', 'k-raster');
raster.append(el('div', 'k-spaltenkopf k-spaltenkopf--kw', 'KW'));
for (const w of WOCHENTAGE_KURZ) raster.append(el('div', 'k-spaltenkopf', w));
let d = ausText(von);
const ende = ausText(bis);
while (d <= ende) {
const kw = el('div', 'k-kw', String(kalenderwoche(d)));
if (alsText(d) <= daten.heute && alsText(plus(d, 6)) >= daten.heute) kw.dataset.jetzt = 'ja';
raster.append(kw);
for (let i = 0; i < 7; i++) {
const tag = alsText(d);
const zelle = el('div', 'k-tag');
if (d.getUTCMonth() !== monat) zelle.dataset.fremd = 'ja';
if (tag === daten.heute) zelle.dataset.heute = 'ja';
if (istFeiertag(tag)) zelle.dataset.feiertag = 'ja';
if (d.getUTCDay() === 0 || d.getUTCDay() === 6) zelle.dataset.wochenende = 'ja';
const kopf = el('div', 'k-tag__kopf');
kopf.append(el('span', 'k-tag__zahl', String(d.getUTCDate())));
if (d.getUTCDate() === 1) {
kopf.append(el('span', 'k-tag__monat', fmt({ month: 'short' }).format(d)));
}
if (tag === daten.heute) kopf.append(el('span', 'k-tag__heute', 'heute'));
zelle.append(kopf);
for (const a of anlaesseAm(tag)) zelle.append(anlassMarke(a));
for (const e of (karte.get(tag) || [])) zelle.append(pille(e));
tagKlickbar(zelle, tag);
raster.append(zelle);
d = plus(d, 1);
}
}
return raster;
}
/* ---------- Ansicht: Woche ------------------------------------------------ */
function baueWoche(karte) {
const { von } = zeitraum();
const raster = el('div', 'k-woche');
let d = ausText(von);
for (let i = 0; i < 7; i++) {
const tag = alsText(d);
const spalte = el('div', 'k-wochentag');
if (tag === daten.heute) spalte.dataset.heute = 'ja';
if (istFeiertag(tag)) spalte.dataset.feiertag = 'ja';
const kopf = el('div', 'k-wochentag__kopf');
kopf.append(el('span', 'k-wochentag__name', WOCHENTAGE_KURZ[i]));
kopf.append(el('span', 'k-wochentag__zahl', String(d.getUTCDate())));
spalte.append(kopf);
for (const a of anlaesseAm(tag)) spalte.append(anlassMarke(a));
const eintraege = karte.get(tag) || [];
for (const e of eintraege) spalte.append(pille(e));
if (!eintraege.length && !anlaesseAm(tag).length) {
spalte.append(el('p', 'k-wochentag__leer', 'frei'));
}
tagKlickbar(spalte, tag);
raster.append(spalte);
d = plus(d, 1);
}
return raster;
}
/* ---------- Ansicht: Liste ------------------------------------------------ */
function baueListe(karte) {
const wurzel = el('div', 'k-liste');
const tage = tageMitInhalt(karte);
if (!tage.length) {
wurzel.append(leerHinweis());
return wurzel;
}
for (const tag of tage) {
const block = el('section', 'k-listentag');
if (tag < daten.heute) block.dataset.vergangen = 'ja';
if (tag === daten.heute) block.dataset.heute = 'ja';
const kopf = el('div', 'k-listentag__kopf');
kopf.append(el('span', 'k-listentag__datum',
fmt({ weekday: 'long', day: '2-digit', month: 'long' }).format(ausText(tag))));
kopf.append(el('span', 'k-listentag__kw', 'KW ' + kalenderwoche(ausText(tag))));
if (tag === daten.heute) kopf.append(el('span', 'k-listentag__marke', 'Heute'));
block.append(kopf);
for (const a of anlaesseAm(tag)) block.append(anlassMarke(a));
for (const e of (karte.get(tag) || [])) block.append(zeile(e));
wurzel.append(block);
}
return wurzel;
}
/** Eine ausführliche Zeile für Liste und Tagesdialog -- mit den
* Knöpfen, die es vorher schon gab (erledigt, löschen). */
function zeile(e) {
const k = el('div', 'k-zeile');
k.dataset.art = e.art;
k.dataset.erledigt = e.erledigt ? 'ja' : 'nein';
k.style.setProperty('--zfarbe', e.farbe);
k.style.setProperty('--ton', e.farbe);
k.append(el('span', 'k-zeile__zeit', e.uhr ? e.uhr + ' Uhr' : '—'));
const titel = el('span', 'k-zeile__titel', e.titel);
if (e.serie_id) titel.append(wiederholZeichen());
k.append(titel);
/* DIE ART GEHOERT ZUM TITEL, NICHT ZU DEN KNOEPFEN (07.09.2026).
Sie stand bisher als erstes Element in der Knopfreihe -- und sah
damit aus wie ein Knopf, der nicht reagiert. Sie ist aber eine
ANGABE ueber den Termin ("das ist ein Termin, kein Call"), genau
wie Uhrzeit und Titel. Jetzt steht sie neben dem Titel, und die
Knopfreihe enthaelt nur noch Dinge, die etwas tun. */
/* Bei einem Zettel vom Brett steht dort das BRETT und nicht „Vom
Brett" -- in der Liste ist Platz fuer das Wort, das wirklich
etwas sagt. In der Filterleiste bleibt es eine Art; dort waeren
achtzehn Schalter unbrauchbar. */
titel.append(el('span', 'k-zeile__art',
e.brett_name || ARTNAME[e.art] || e.art));
const rechts = el('div', 'k-zeile__knoepfe');
/* WAS NICHT AUS DEM KALENDER STAMMT, BEKOMMT KEINE KALENDER-KNOEPFE.
Ohne diese Zeile waere der Zettel vom Brett im `else`-Zweig
gelandet -- mit Wecker, „erledigt" und „loeschen". Und „erledigt"
haette `PATCH /workspace/api/termine/b12` geschickt: eine Nummer,
die es dort nicht gibt, an eine Schnittstelle, die davon nichts
weiss. Der Fehler waere still gewesen (eine 404 im Hintergrund),
und der Knopf haette einfach nichts getan.
Dasselbe gilt seit jeher fuer die Frist -- deshalb steht beides
in EINER Bedingung und nicht in zwei: Es ist dieselbe Regel. */
if (e.art === 'belegt') {
/* KEIN KNOPF. Ein „erledigt" an einem Block der anderen Seite
schickte `PATCH /workspace/api/termine/null` -- und waere,
wenn es ginge, eine Aenderung im anderen Haus. Hier steht
stattdessen der eine Satz, der alles sagt, was man wissen
darf. */
rechts.append(el('span', 'k-pille__zeit', 'auf der anderen Seite'));
} else if (e.art === 'frist' || e.art === 'brett') {
const a = el('a', 'schritt', e.art === 'brett' ? 'zum Brett' : 'zur Aufgabe');
a.href = e.ziel || 'aufgaben.html';
rechts.append(a);
} else {
/* DER WECKER (07.09.2026). Er steht bei JEDEM Termin, den man
sieht -- auch bei fremden: Ein Wecker ist eine Verabredung mit
sich selbst, keine Aenderung am Termin. Deshalb steht er auch
VOR "erledigt": Er ist das, was man am haeufigsten will. */
rechts.append(weckerKnopf(e));
rechts.append(tuKnopf(e.erledigt ? 'wieder offen' : 'erledigt', async () => {
const a = await hole('/workspace/api/termine/' + e.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ erledigt: !e.erledigt }),
});
if (!a.ok) { melde('Ändern ging nicht.'); return; }
await laden();
}));
/* =================================================================
DIE LIVE-CHECKLISTE (10.09.2026, Kapitel 5.7/11)
Das Dokument will sie "automatisch pro Live-Termin". Filipes
Entscheidung war ein Knopf: Vierzehn Aufgaben, die bei jedem
Termin von selbst erscheinen, ueberrumpeln -- und was ungefragt
Dinge anlegt, ist schwer wieder loszuwerden.
DER KNOPF SAGT, WAS SCHON DA IST. "Checkliste (14)" statt
"Checkliste" -- sonst drueckt man ihn zur Sicherheit noch
einmal und weiss hinterher nicht, ob doppelt angelegt wurde.
(Der Server legt nichts doppelt an; der Knopf soll trotzdem
nicht dazu einladen, es zu versuchen.) */
if (checklisteMoeglich && e.id && e.art !== 'frist') {
const wieViele = e.checkliste || 0;
rechts.append(tuKnopf(wieViele ? `Checkliste (${wieViele})` : 'Checkliste anlegen',
async () => {
const a = await hole('/workspace/api/vorlagen/uebernehmen', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ art: 'katalog_termin', termin_id: e.id }),
});
if (!a.ok) {
const f = await a.json().catch(() => ({}));
melde(sagWas(f.fehler, 'Das hat nicht geklappt.'));
return;
}
const d = await a.json().catch(() => ({}));
melde(d.angelegt
? `${d.angelegt} Punkte auf dem Aufgabenbrett.`
: 'Die Checkliste steht schon.');
await laden();
}));
}
if (darfAendern(e)) {
/* BEARBEITEN STAND NIE DA (07.09.2026). Filipe: "ich hab die
gemacht und kann sie nicht bearbeiten." Der Server liess das
Aendern die ganze Zeit zu -- im Tagesfenster gab es nur
"erledigt" und "loeschen". Ein Recht ohne Knopf ist kein
Recht. */
rechts.append(tuKnopf('bearbeiten', () => {
$('tagdialog').close();
formularOeffnen(e.tag, e);
$('neu').scrollIntoView({ block: 'center', behavior: window.sanft?.() || 'auto' });
}));
rechts.append(tuKnopf('löschen', async () => {
/* Bei einem Serientermin muss unmissverständlich dastehen,
dass NUR dieser eine Tag entfällt. Sonst löscht jemand ihn
in der Annahme, die Wiederholung sei damit abgestellt --
und wundert sich nächste Woche. */
if (!await frageNach({
titel: e.serie_id
? 'Nur diesen einen Termin am ' + tagLang(e.tag) + ' streichen?'
: 'Termin „' + e.titel + '“ löschen?',
was: e.serie_id
? 'Nur dieser eine Tag entfällt.'
: 'Der Termin verschwindet aus dem Kalender.',
bleibt: e.serie_id
? 'Die Wiederholung „' + e.titel + '“ läuft weiter. Zum Abstellen '
+ 'den Schalter unter „Läuft von allein“ benutzen.'
: 'Alle anderen Termine bleiben stehen.',
endgueltig: true, ja: e.serie_id ? 'Diesen Tag streichen' : 'Löschen',
})) return;
const a = await hole('/workspace/api/termine/' + e.id, { method: 'DELETE' });
if (!a.ok) { melde('Löschen ging nicht.'); return; }
await laden();
}));
}
}
/* DIE NEBENDATEN VOR DEN KNOEPFEN (07.09.2026).
Im Raster bestimmt die Reihenfolge im Dokument, welche Zeile ein
Feld bekommt. Die Knopfreihe stand hier vorher VOR den
Nebendaten -- und landete damit zwischen Titel und "30 Min ·
TikTok". Im Bildschirmfoto stand die Beschreibung unter den
Knoepfen, als gehoerte sie zu ihnen.
Reihenfolge im Dokument statt `order` im Stil: Sie gilt auch fuer
Vorleseprogramme und die Tastatur -- `order` verschiebt nur das
Bild und laesst beide beim alten Weg. */
if (e.zusatz || e.beschreibung) {
const z = el('div', 'k-zeile__zusatz');
if (e.ort && /^https?:\/\//i.test(e.ort)) {
/* Nur echte http(s)-Adressen werden zum Verweis -- sonst ließe
sich hier javascript: einschleusen. */
const teile = e.zusatz.split(' · ').filter((s) => s !== e.ort);
if (teile.length) z.append(el('span', null, teile.join(' · ')));
const a = el('a', null, e.ort.replace(/^https?:\/\//i, '').slice(0, 60));
a.href = e.ort; a.target = '_blank'; a.rel = 'noopener noreferrer';
z.append(a);
} else if (e.zusatz) {
z.append(el('span', null, e.zusatz));
}
if (e.beschreibung) z.append(el('span', null, e.beschreibung));
k.append(z);
}
k.append(rechts);
return k;
}
/* ---------- Ansicht: Zeitstrahl ------------------------------------------
Sechs Wochen auf einer Achse. Zeigt auf einen Blick, wo sich etwas
ballt und wo Luft ist -- die Frage "wann habe ich Vorlauf" lässt
sich in einem Monatsraster nicht beantworten. */
function baueZeitstrahl(karte) {
const { von, bis } = zeitraum();
const wurzel = el('div', 'k-strahl');
const tage = Math.round((ausText(bis) - ausText(von)) / 86400000) + 1;
let d = ausText(von);
for (let i = 0; i < tage; i++) {
const tag = alsText(d);
const eintraege = karte.get(tag) || [];
const anl = anlaesseAm(tag);
const balken = el('div', 'k-strahl__tag');
if (tag === daten.heute) balken.dataset.heute = 'ja';
if (istFeiertag(tag)) balken.dataset.feiertag = 'ja';
if (d.getUTCDay() === 0 || d.getUTCDay() === 6) balken.dataset.wochenende = 'ja';
/* Die Höhe des Balkens ist die Menge des Tages. Gedeckelt bei
fünf: Ein Tag mit zwölf Terminen soll nicht die ganze Grafik
sprengen, und ab fünf ist die Aussage ohnehin "voll". */
balken.style.setProperty('--menge', String(Math.min(eintraege.length, 5)));
balken.title = `${fmt({ weekday: 'long', day: '2-digit', month: 'long' }).format(d)}`
+ ` — ${eintraege.length} ${eintraege.length === 1 ? 'Eintrag' : 'Einträge'}`
+ (anl.length ? ' · ' + anl.map((a) => a.name).join(', ') : '');
const saeule = el('span', 'k-strahl__saeule');
if (eintraege.length) {
/* Die Farbe des Balkens ist die des ersten Eintrags an dem Tag --
man erkennt Call-Wochen und Review-Wochen von weitem. */
saeule.style.setProperty('--sfarbe', eintraege[0].farbe);
}
balken.append(saeule);
if (anl.length) {
balken.append(el('span', 'k-strahl__sym', anl[0].sym));
}
if (d.getUTCDay() === 1) {
balken.append(el('span', 'k-strahl__kw', String(kalenderwoche(d))));
}
tagKlickbar(balken, tag);
wurzel.append(balken);
d = plus(d, 1);
}
const rahmen = el('div', 'k-strahl-rahmen');
rahmen.append(wurzel);
rahmen.append(el('p', 'k-strahl__fuss',
'Ein Strich je Tag, die Höhe ist die Menge. Die Zahl unten ist die Kalenderwoche.'));
return rahmen;
}
function leerHinweis() {
const k = el('div', 'k-leer');
k.append(el('p', 'k-leer__satz', 'In diesem Zeitraum steht nichts an.'));
k.append(el('p', 'k-leer__tipp',
'Auf einen Tag klicken, um ihn zu öffnen – oder oben rechts einen Termin anlegen.'));
return k;
}
/* ---------- Tagesdialog --------------------------------------------------- */
function tagOeffnen(tag) {
const d = ausText(tag);
$('tagdialog-titel').textContent =
fmt({ weekday: 'long', day: '2-digit', month: 'long', year: 'numeric' }).format(d);
$('tagdialog-unter').textContent = 'KW ' + kalenderwoche(d)
+ (tag === daten.heute ? ' · heute' : '');
const anl = $('tagdialog-anlaesse');
anl.replaceChildren();
for (const a of anlaesseAm(tag)) anl.append(anlassMarke(a));
const liste = $('tagdialog-liste');
liste.replaceChildren();
/* UEBER `nachTag()`, NICHT UEBER `e.tag` (20.09.2026).
Vorher stand hier ein eigener Filter auf den Anfangstag. Folge:
Im Monatsraster war die Aktion am Mittwoch markiert -- tippte man
die Zelle an, sagte der Tagesdialog „An diesem Tag steht
nichts." Zwei Antworten auf dieselbe Frage, und die zweite
widerspricht dem, was man gerade gesehen hat.
`nachTag()` ist die eine Stelle, die das entscheidet. */
const eintraege = nachTag().get(tag) || [];
if (!eintraege.length) {
liste.append(el('p', 'leise', 'An diesem Tag steht nichts.'));
} else {
for (const e of eintraege) liste.append(zeile(e));
}
$('tagdialog-neu').onclick = () => {
$('tagdialog').close();
formularOeffnen(tag);
};
$('tagdialog').showModal();
}
function zumTag(tag) {
/* Zum Tag springen heißt: den Monat anzeigen, in dem er liegt, und
ihn dann öffnen. Sonst müsste man erst selbst dorthin blättern. */
anker = tag;
laden().then(() => tagOeffnen(tag));
}
/* ---------- Zeichnen ------------------------------------------------------ */
function zeichnen() {
const ziel = $('kalender');
ziel.replaceChildren();
ziel.setAttribute('aria-busy', 'false');
ziel.dataset.ansicht = ansicht;
$('zeitraum').textContent = zeitraumText();
$('zeitraum-unter').textContent = unterText();
baueFilter();
const karte = nachTag();
if (ansicht === 'monat') ziel.append(baueMonat(karte));
else if (ansicht === 'woche') ziel.append(baueWoche(karte));
else if (ansicht === 'zeitstrahl') ziel.append(baueZeitstrahl(karte));
else ziel.append(baueListe(karte));
}
/* ---------- Laden --------------------------------------------------------- */
const melde = (t) => { $('fehler').textContent = t || ''; };
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'); }
return a;
}
function tuKnopf(text, tu) {
const b = el('button', 'schritt', text);
b.type = 'button';
b.addEventListener('click', async (ev) => {
ev.stopPropagation();
b.disabled = true; melde('');
try { await tu(); } catch { /* umgeleitet */ } finally { b.disabled = false; }
});
return b;
}
async function laden() {
melde('');
/* Die Beschriftung der Schrittknoepfe folgt dem Zustand -- nach
jedem Sprung und jedem Umschalten. Sie hier zu setzen und nicht
an jeder Aufrufstelle heisst: Es gibt genau EINEN Ort, an dem sie
stimmen muss. */
schrittBeschriften();
const { von, bis } = zeitraum();
const tage = Math.round((ausText(bis) - ausText(von)) / 86400000) + 1;
try {
const a = await hole(`/workspace/api/termine?von=${von}&tage=${tage}`);
if (!a.ok) { melde('Kalender konnte nicht geladen werden.'); return; }
const roh = await a.json();
/* OB der Knopf hingehoert, sagt der Server -- nicht ein
Rollenvergleich hier. Diese Datei bekommt jeder ausgeliefert,
der die Seite oeffnet; ein Rollenname darin waere mit einem
Blick in den Quelltext gefunden. */
checklisteMoeglich = !!roh.checkliste_moeglich;
daten = { eintraege: vereinheitlichen(roh), heute: heuteText() };
/* Die Wiederholungen NACH den Terminen holen: Der Serveraufruf
oben legt fehlende Ausprägungen an, und erst danach stimmt der
"nächste Termin" in der Liste darunter. */
await serienLaden();
baueSaison();
baueMonatsanlaesse();
baueSerien();
zeichnen();
/* Sprung vom Bericht -- erst NACH dem Zeichnen, vorher gibt es die
Tagesbloecke nicht. Nur beim ersten Mal: Sonst reisst es die
Ansicht bei jedem Monatswechsel wieder an dieselbe Stelle. */
if (!gesprungen) { gesprungen = true; sprungZeigen(); }
} catch { /* umgeleitet */ }
}
/* ---------- Sprung vom Bericht -------------------------------------------
Der Bericht zaehlt "Termine gewesen" und "Termine stehen an" -- beides
Zeitraeume, keine Kategorien. In der Monatsansicht liessen sie sich
gar nicht sinnvoll hervorheben (ein Tageskaestchen enthaelt beides).
Deshalb schaltet der Sprung auf die LISTE um: Dort ist jeder Tag ein
eigener Block, und vergangen/kommend ist bereits daran vermerkt. */
function sprungZeigen() {
const was = window.zielSchluessel?.();
if (was !== 'vergangen' && was !== 'kommend') return;
const alle = [...document.querySelectorAll('.k-listentag')];
const treffer = was === 'vergangen'
? alle.filter((b) => b.dataset.vergangen === 'ja')
: alle.filter((b) => b.dataset.vergangen !== 'ja');
window.zielMarkieren?.(treffer, was === 'vergangen'
? 'Tage im Zeitraum, die schon vorbei sind'
: 'Tage, an denen noch etwas ansteht');
}
let gesprungen = false;
/* ---------- Bedienung ----------------------------------------------------- */
$('zurueck-zeit').addEventListener('click', () => springen(-1));
$('vor-zeit').addEventListener('click', () => springen(1));
$('heute-knopf').addEventListener('click', () => { anker = heuteText(); laden(); });
/* ---------- Blättern direkt über dem Kalender (09.09.2026) -------------
Filipe: "ich will dass genau da über dem kalender auch noch buttons
sind um den monat zu wechseln, tage wechseln."
DIESELBE Funktion wie oben, nur an der Stelle, an der man hinsieht.
`springen()` wird nicht kopiert, sondern gerufen -- zwei Fassungen
desselben Schritts wären zwei Gelegenheiten, dass die eine später
anders springt als die andere.
Beschriftet, nicht nur bepfeilt: Ein Pfeil sagt nicht, wie weit er
springt. In der Monatsansicht ist ein Schritt ein Monat, in der
Woche eine Woche, in Liste und Zeitstrahl sechs Wochen -- das steht
drauf und wird beim Umschalten mitgeführt. */
const SCHRITT_NAME = {
monat: 'Monat', woche: 'Woche', liste: 'Zeitraum', zeitstrahl: 'Zeitraum',
};
function schrittBeschriften() {
const wort = SCHRITT_NAME[ansicht] || 'Zeitraum';
const z = $('schritt-zurueck-text');
const v = $('schritt-vor-text');
if (z) z.textContent = `${wort} zurück`;
if (v) v.textContent = `${wort} vor`;
$('schritt-zurueck')?.setAttribute('aria-label', `Einen ${wort} zurück`);
$('schritt-vor')?.setAttribute('aria-label', `Einen ${wort} vor`);
/* "Heute" tritt zurück, solange man schon dort steht -- ein Knopf,
der nichts tut, ist ein Knopf, den man einmal drückt und danach
nicht mehr ernst nimmt. Ausgegraut statt versteckt: Sonst
springen die drei Knöpfe beim Blättern in der Breite. */
const daheim = anker === heuteText();
const h = $('schritt-heute');
if (h) {
h.disabled = daheim;
h.title = daheim ? 'Du bist bereits beim heutigen Tag.' : 'Zurück zum heutigen Tag';
}
}
$('schritt-zurueck')?.addEventListener('click', () => springen(-1));
$('schritt-vor')?.addEventListener('click', () => springen(1));
$('schritt-heute')?.addEventListener('click', () => { anker = heuteText(); laden(); });
for (const b of document.querySelectorAll('.k-ansicht')) {
b.addEventListener('click', () => {
ansicht = b.dataset.ansicht;
for (const x of document.querySelectorAll('.k-ansicht')) {
x.setAttribute('aria-pressed', String(x === b));
}
schrittBeschriften();
laden();
});
}
$('tagdialog-zu').addEventListener('click', () => $('tagdialog').close());
$('wecker-zu')?.addEventListener('click', () => $('wecker-dialog').close());
/* Klick auf den Schleier schliesst -- wie beim Tagesfenster. Zwei
Dialoge, die sich verschieden bedienen lassen, sind einer zu viel. */
$('wecker-dialog')?.addEventListener('click', (ev) => {
if (ev.target === $('wecker-dialog')) $('wecker-dialog').close();
});
/* Klick auf den Rand schließt -- erwartet man bei einem Dialog. */
$('tagdialog').addEventListener('mousedown', (ev) => {
if (ev.target === $('tagdialog')) $('tagdialog').close();
});
/* Pfeiltasten blättern, solange man nicht gerade in einem Feld tippt. */
document.addEventListener('keydown', (ev) => {
if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
if ($('tagdialog').open) return;
const z = document.activeElement;
if (z && (z.tagName === 'INPUT' || z.tagName === 'TEXTAREA' || z.tagName === 'SELECT'
|| z.isContentEditable)) return;
if (ev.key === 'ArrowLeft') { ev.preventDefault(); springen(-1); }
else if (ev.key === 'ArrowRight') { ev.preventDefault(); springen(1); }
else if (ev.key === 't' || ev.key === 'T') { anker = heuteText(); laden(); }
});
/* ---------- Neuer Termin --------------------------------------------------- */
/* WELCHEN TERMIN BEARBEITEN WIR GERADE? 0 heisst: einen neuen
anlegen. Ein einziger Merker statt eines zweiten Formulars -- ein
zweites saehe genauso aus und liefe beim naechsten Feld
auseinander. */
let bearbeiteId = 0;
/** Darf ich diesen Termin aendern? Dieselbe Antwort wie im Server
* (workspace-kalender.js): die Leitung -- und wer ihn selbst
* eingetragen hat. Filipe: "jeder der in seinem kalender was
* eintraegt soll es auch bearbeiten koennen, nur diese person
* selber." Hier geht es nur darum, keinen Knopf hinzustellen, der
* eine Absage bringt; die Sperre steht im Server. */
const darfAendern = (e) => LEITUNG.has(ich.rolle) || e.erstellt_von === ich.id;
/* =====================================================================
DER WECKER -- BEDIENUNG (07.09.2026)
Wunsch Filipe: "wie so ein wecker, den man in den eintraegen
aktivieren oder ausschalten kann ... auch mehrmals informiert,
einmal eine woche vorher, einmal drei tage vorher und einmal am tag
selber. das soll jeder fuer sich selbst einstellen koennen."
EIN KNOPF, DER SEINEN ZUSTAND ZEIGT. Keine Glocke, die man erst
anklicken muss, um zu erfahren, ob sie an ist: Steht eine Zahl
daran, sind so viele Wecker gesetzt. Das beantwortet die haeufigste
Frage ("bekomme ich hier eine Erinnerung?") ohne einen Klick.
Die Auswahl selbst ist eine Liste zum Ankreuzen, kein Menue: Es
sind sechs Moeglichkeiten, mehrere davon gleichzeitig, und beides
kann ein Auswahlmenue nicht.
Gespeichert wird SOFORT beim Ankreuzen. Ein "Speichern"-Knopf
daneben waere ein zweiter Schritt fuer eine Entscheidung, die aus
einem Klick besteht -- und der haeufigste Weg, dass eine
Einstellung doch nicht ankommt. */
const WECKER_NAME = {
10080: 'Eine Woche vorher',
4320: 'Drei Tage vorher',
1440: 'Einen Tag vorher',
720: 'Am selben Tag (12 Std.)',
60: 'Eine Stunde vorher',
10: 'Zehn Minuten vorher',
};
function weckerKnopf(e) {
const knopf = el('button', 'wecker-knopf');
knopf.type = 'button';
knopf.title = 'Erinnerungen für diesen Termin';
const zahl = el('span', 'wecker-knopf__zahl');
knopf.append(weckerZeichen(), zahl);
const stand = (anzahl) => {
knopf.dataset.an = anzahl > 0 ? 'ja' : 'nein';
zahl.textContent = anzahl > 0 ? String(anzahl) : '';
knopf.setAttribute('aria-label',
anzahl > 0 ? `${anzahl} Erinnerung(en) gesetzt` : 'Keine Erinnerung gesetzt');
};
stand(0);
/* Der Stand wird beim Aufbau geholt, nicht erst beim Klick: Sonst
saehe man erst nach dem Oeffnen, ob ueberhaupt etwas gesetzt ist
-- und das ist die Frage, die man von aussen beantwortet haben
will. */
(async () => {
try {
const a = await hole(`/workspace/api/termine/${e.id}/wecker`);
if (a.ok) stand(((await a.json()).meine || []).length);
} catch { /* ohne Stand bleibt der Knopf leise */ }
})();
knopf.addEventListener('click', () => weckerOeffnen(e, stand));
return knopf;
}
function weckerZeichen() {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('class', 'wecker-knopf__svg');
svg.setAttribute('aria-hidden', 'true');
svg.innerHTML = '<path class="fuell" d="M12 4.2c3.6 0 6.2 2.7 6.2 6.3v3.3l1.4 2.5H4.4l1.4-2.5v-3.3'
+ 'c0-3.6 2.6-6.3 6.2-6.3z"/>'
+ '<path d="M12 4.2c3.6 0 6.2 2.7 6.2 6.3v3.3l1.4 2.5H4.4l1.4-2.5v-3.3c0-3.6 2.6-6.3 6.2-6.3z"/>'
+ '<path d="M10.2 19.2a1.9 1.9 0 0 0 3.6 0"/>'
+ '<path d="M12 2.2v2M4.8 5.2 3.4 3.9M19.2 5.2l1.4-1.3"/>';
return svg;
}
async function weckerOeffnen(e, stand) {
const dlg = $('wecker-dialog');
if (!dlg) return;
$('wecker-titel').textContent = e.titel;
$('wecker-wann').textContent = `${tagLang(e.tag)}${e.uhr ? ' · ' + e.uhr + ' Uhr' : ''}`;
const liste = $('wecker-liste');
liste.textContent = 'Wird geladen …';
dlg.showModal();
let daten;
try {
const a = await hole(`/workspace/api/termine/${e.id}/wecker`);
if (!a.ok) throw new Error('nicht ok');
daten = await a.json();
} catch {
liste.textContent = 'Die Erinnerungen konnten nicht geladen werden.';
return;
}
const gewaehlt = new Set(daten.meine || []);
liste.textContent = '';
for (const m of daten.stufen || []) {
const zeile = el('label', 'wecker-zeile');
const kasten = document.createElement('input');
kasten.type = 'checkbox';
kasten.checked = gewaehlt.has(m);
kasten.addEventListener('change', async () => {
if (kasten.checked) gewaehlt.add(m); else gewaehlt.delete(m);
/* Die Obergrenze steht im Server; hier wird sie nur gezeigt,
damit niemand ankreuzt, was gleich wieder verschwindet. */
if (gewaehlt.size > (daten.hoechstens || 5)) {
gewaehlt.delete(m); kasten.checked = false;
$('wecker-fehler').textContent =
`Mehr als ${daten.hoechstens} Erinnerungen je Termin gehen nicht.`;
return;
}
$('wecker-fehler').textContent = '';
try {
const a = await hole(`/workspace/api/termine/${e.id}/wecker`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ minuten: [...gewaehlt] }),
});
if (!a.ok) throw new Error('nicht ok');
stand(gewaehlt.size);
} catch {
$('wecker-fehler').textContent = 'Speichern ging nicht.';
}
});
zeile.append(kasten, el('span', 'wecker-zeile__text', WECKER_NAME[m] || `${m} Minuten vorher`));
liste.append(zeile);
}
}
/* =====================================================================
DAS FORMULAR PASST SICH DER ART AN (08.09.2026)
Wunsch Filipe: "auch so, dass wenn man die sachen aussucht, die
ganze kachel und die sachen, die man eintippen muss, auch zu der
jeweiligen kategorie passen."
WAS SICH AENDERT, SIND DIE FRAGEN -- nicht die Datenbank. Ein
BigMatch fragt nach dem GEGNER, ein Turnier nach dem FORMAT, ein
Collab nach den LEUTEN. Es ist jedes Mal dasselbe Feld "Titel";
was fehlt, ist die richtige Frage davor. Eigene Spalten je Art
waeren neun halbleere Felder in einer Tabelle und beim naechsten
Format wieder eines mehr.
Dasselbe fuer Ort und Notiz: "Ort oder Link" ist bei einem
Raid-Train die falsche Frage -- dort geht es um die KETTE, also um
die Reihenfolge der Kanaele.
Und die Dauer bekommt einen Vorschlag: Ein Call dauert 30 Minuten,
ein Turnier selten unter zwei Stunden. Vorgeschlagen, nicht
erzwungen -- und nur bei einem NEUEN Eintrag, nie beim Bearbeiten:
Wer eine Dauer eingetragen hat, will sie nicht durch einen
Artwechsel verlieren. */
const ART_FELDER = {
call: { titel: 'Worum geht es?', titelHilfe: 'z. B. Wochen-Call mit Luna',
ort: 'Ort oder Link', ortHilfe: 'Discord, Meet-Link, Adresse …', dauer: 30 },
termin: { titel: 'Worum geht es?', titelHilfe: 'z. B. Fotoshooting',
ort: 'Ort oder Link', ortHilfe: 'Adresse, Discord, Link …', dauer: 30 },
review: { titel: 'Was wird durchgesehen?', titelHilfe: 'z. B. Monatsrückblick Luna',
ort: 'Ort oder Link', ortHilfe: 'Discord, Meet-Link …', dauer: 45 },
bigmatch: { titel: 'Gegen wen?', titelHilfe: 'z. B. BigMatch vs. Beanii',
ort: 'Wo läuft es?', ortHilfe: 'TikTok, Twitch, Kanal-Link …', dauer: 60,
notiz: 'Regeln, Einsatz, Uhrzeit des Matches' },
turnier: { titel: 'Welches Turnier?', titelHilfe: 'z. B. Herbst-Cup, Runde 2',
ort: 'Wo läuft es?', ortHilfe: 'Plattform oder Turnier-Link', dauer: 120,
notiz: 'Modus, Runde, Gegner, Preisgeld' },
special: { titel: 'Was ist das Besondere?', titelHilfe: 'z. B. 24-Stunden-Stream',
ort: 'Wo läuft es?', ortHilfe: 'TikTok, Twitch, YouTube …', dauer: 180,
notiz: 'Ablauf, Gäste, was angekündigt wurde' },
collab: { titel: 'Mit wem?', titelHilfe: 'z. B. Collab mit Miesmuschel',
ort: 'Wessen Kanal?', ortHilfe: 'Kanal oder Link', dauer: 90,
notiz: 'Thema, wer moderiert, Absprachen' },
raid: { titel: 'Welche Kette?', titelHilfe: 'z. B. Raid-Train Freitagabend',
ort: 'Reihenfolge der Kanäle', ortHilfe: 'wer nach wem, mit Links', dauer: 60,
notiz: 'Startzeit je Kanal, Übergabe-Absprache' },
charity: { titel: 'Für wen wird gesammelt?', titelHilfe: 'z. B. Spendenstream Tierheim',
ort: 'Wo läuft es?', ortHilfe: 'Plattform, Spendenlink', dauer: 180,
notiz: 'Spendenziel, Link, Ablauf' },
};
function artFelderSetzen(neuerEintrag) {
const art = $('f-art').value;
const f = ART_FELDER[art] || ART_FELDER.termin;
const schild = (feld, text, zusatz) => {
const l = document.querySelector(`label[for="${feld}"]`);
if (!l) return;
l.textContent = text;
if (zusatz) {
const s2 = el('span', 'leise', ' (optional)');
l.append(s2);
}
};
schild('f-titel', f.titel, false);
$('f-titel').placeholder = f.titelHilfe || '';
schild('f-ort', f.ort, true);
$('f-ort').placeholder = f.ortHilfe || '';
schild('f-text', f.notiz ? 'Notiz' : 'Notiz', true);
$('f-text').placeholder = f.notiz || '';
/* Die Dauer nur bei einem NEUEN Eintrag vorschlagen -- siehe oben. */
if (neuerEintrag) $('f-dauer').value = f.dauer || 30;
}
function formularOeffnen(tag, termin) {
/* Vorbelegung: der angeklickte Tag um 18:00 -- ein realistischer
Zeitpunkt für einen Call, spart in der Regel zwei Klicks. Ohne Tag
(Knopf oben): morgen. */
const d = tag ? new Date(tag + 'T18:00:00') : new Date(Date.now() + 86400_000);
if (!tag) d.setHours(18, 0, 0, 0);
$('f-beginn').value = new Date(d.getTime() - d.getTimezoneOffset() * 60000)
.toISOString().slice(0, 16);
/* Die Auswahl der Rhythmen hängt am Datum ("jeden Dienstag") und
muss deshalb bei jedem Öffnen neu beschriftet werden. */
taktAuswahlFuellen();
/* Die Teilnehmerschalter zurücksetzen -- ein frisches Formular darf
nicht die Auswahl des letzten Termins mitbringen. `reset()` fasst
sie nicht an, weil es keine Formularfelder sind. */
baueDabei([]);
bearbeiteId = 0;
$('neu-speichern').textContent = 'Anlegen';
artFelderSetzen(true);
/* BEARBEITEN: dieselben Felder, gefuellt. Der Rhythmus bleibt
aussen vor -- eine Wiederholung ist eine REGEL und wird unter
"Laeuft von allein" geaendert, nicht an einer ihrer
Auspraegungen. Wer das hier zulaesst, bekommt einen Termin, der
aus der Reihe faellt, ohne dass jemand weiss warum. */
if (termin) {
bearbeiteId = termin.id;
$('f-titel').value = termin.titel || '';
$('f-art').value = termin.art || 'termin';
if (termin.beginn) $('f-beginn').value = String(termin.beginn).slice(0, 16);
$('f-dauer').value = termin.dauer_min || 30;
$('f-ort').value = termin.ort || '';
$('f-text').value = termin.beschreibung || '';
baueDabei(termin.teilnehmer_ids || []);
/* DAS GEGENUEBER VORBELEGEN -- und das war ein Datenverlust
(08.09.2026, screen19).
"Mit wem" wurde beim Bearbeiten NIE gefuellt. Das Feld stand
leer da, und weil `koerper.teilnehmer_extern` beim Speichern
trotzdem mitgeschickt wird, ueberschrieb der leere Wert den
vorhandenen: Wer einen Termin mit einem frei eingetragenen
Namen ("BananaStift") bearbeitete und speicherte, hatte den
Namen danach verloren -- ohne ihn je gesehen zu haben.
Filipe: "wenn ich auf bearbeiten drücke will ich dass mir immer
die daten angezeigt werden die schon ausgewählt wurden, damit
ich auch immer sehe okee das war alles und das änder ich."
Nur fuer die Leitung, wie beim Speichern auch: Fuer die anderen
Rollen gibt es das Feld gar nicht, und `personenwahl` liefe ins
Leere. */
if (LEITUNG.has(ich.rolle)) {
window.personenwahl?.('f-teilnehmer')
?.setzen(termin.creator_id ?? termin.teilnehmer_id ?? '',
termin.teilnehmer_extern || '');
}
$('f-takt').value = '';
wdhVorschau();
/* `false`: Beim Bearbeiten bleibt die eingetragene Dauer stehen. */
artFelderSetzen(false);
$('neu-speichern').textContent = 'Speichern';
}
umschalten(true);
}
const umschalten = (auf) => {
$('neu').hidden = !auf;
$('neu-oeffnen').hidden = auf;
if (auf) $('f-titel').focus();
};
$('neu-oeffnen').addEventListener('click', () => formularOeffnen(null));
$('neu-abbrechen').addEventListener('click', () => {
$('neu').reset();
$('neu-fehler').textContent = '';
/* reset() setzt die Auswahl auf ihren ersten Eintrag zurück, aber
nicht den Vorschausatz und die Ende-Zeile -- die blieben sonst
stehen und behaupteten beim nächsten Öffnen eine Wiederholung,
die gar nicht gewählt ist. */
$('f-takt').value = '';
wdhVorschau();
umschalten(false);
});
/* Beim Wechsel der Art die Fragen umstellen. `true` heisst: Die
Dauer darf vorgeschlagen werden -- wer die Art wechselt, hat den
Eintrag noch nicht fertig. Beim Bearbeiten wird der Wechsel
ebenfalls hier ausgeloest, aber `formularOeffnen` hat die echte
Dauer danach schon gesetzt. */
$('f-art').addEventListener('change', () => artFelderSetzen(true));
$('f-beginn').addEventListener('change', taktAuswahlFuellen);
$('f-takt').addEventListener('change', wdhVorschau);
$('f-ende').addEventListener('change', wdhVorschau);
$('neu').addEventListener('submit', async (e) => {
e.preventDefault();
$('neu-fehler').textContent = '';
const knopf = $('neu-speichern');
const koerper = {
titel: $('f-titel').value,
art: $('f-art').value,
beginn: $('f-beginn').value,
dauer_min: Number($('f-dauer').value) || 30,
ort: $('f-ort').value,
beschreibung: $('f-text').value,
};
if (LEITUNG.has(ich.rolle)) {
const wahl = $('f-teilnehmer').value;
koerper.teilnehmer_id = wahl || null;
koerper.creator_id = wahl || null;
/* Ein frei eingetragener Name statt einer Person. Der Server
raeumt dann creator_id ab -- siehe workspace-kalender.js. */
koerper.teilnehmer_extern = $('f-teilnehmer').dataset.extern || null;
}
/* Wer sonst noch dabei ist -- fuer JEDE Rolle, nicht nur die
Leitung. Stand vorher im Block darueber und wurde damit bei
Scouts und Creators nie mitgeschickt.
Das Haupt-Gegenueber gehoert hier NICHT hinein: Der Server legt
es ohnehin dazu (teilnehmerSetzen mit hauptId), und zweimal
dieselbe Nummer zu schicken hiesse, sich auf die Reihenfolge zu
verlassen. */
if (!$('feld-dabei').hidden) koerper.teilnehmer = dabeiGewaehlt();
/* Mit Rhythmus entsteht keine einzelne Zeile, sondern eine Regel --
der Server legt daraus die Termine an, den ersten eingeschlossen.
Deshalb wird hier nichts zusätzlich abgeschickt: Sonst stünde der
erste Termin doppelt im Kalender. */
if ($('f-takt').value) {
koerper.wiederholung = {
takt: $('f-takt').value,
ende_tag: $('f-ende').value || null,
};
}
knopf.disabled = true;
try {
/* Ein Formular, zwei Wege. Beim Bearbeiten faellt die
Wiederholung heraus (siehe formularOeffnen) -- sie wuerde beim
PATCH eine zweite Serie anlegen. */
if (bearbeiteId) delete koerper.wiederholung;
const a = await hole(
bearbeiteId ? '/workspace/api/termine/' + bearbeiteId : '/workspace/api/termine', {
method: bearbeiteId ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(koerper),
});
if (!a.ok) {
$('neu-fehler').textContent = sagWas((await a.json().catch(() => ({}))).fehler, 'Ging nicht.');
return;
}
$('neu').reset();
$('f-takt').value = '';
wdhVorschau();
umschalten(false);
/* Zum Tag des neuen Termins springen -- sonst legt man etwas an
und sieht es nicht, weil es im nächsten Monat liegt. */
const tag = koerper.beginn.slice(0, 10);
const { von, bis } = zeitraum();
if (tag < von || tag > bis) anker = tag;
await laden();
} catch { /* umgeleitet */ } finally { knopf.disabled = false; }
});
/* ---------- Start --------------------------------------------------------- */
(async () => {
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);
/* WER DARF WEN MARKIEREN — und wer entscheidet das.
"Mit wem" (die Haupt-Zuordnung) bleibt der Leitung vorbehalten:
Das verschiebt einen Termin in einen fremden Kalender als dessen
Eigentümer, und das ist eine Zuordnung, keine Einladung.
"Wer ist noch dabei?" bekommt dagegen JEDE Rolle (05.09.2026:
*"das soll für jeden verfügbar sein"*). Sicher ist das, weil
nicht diese Zeile über die Auswahl entscheidet, sondern der
Server an zwei Stellen:
* /api/personen liefert über sichtbarePersonenIds nur die
zugeteilten Namen -- ein Creator sieht seine Betreuer, keine
fremden Creator.
* teilnehmerSetzen() prüft jede Nummer noch einmal gegen
darfEintragen() und lässt Unerlaubtes still weg. Wer die
Anfrage von Hand baut, kommt damit keinen Schritt weiter.
Die Oberfläche zu verstecken war also nie die Sicherung -- sie
hat nur denen etwas weggenommen, die es brauchen. */
const darfZuordnen = LEITUNG.has(ich.rolle);
/* "Mit wem" -- die Haupt-Zuordnung. Nur Leitung: Sie macht die
andere Person zum Gegenüber des Termins, das ist eine Zuordnung
und keine Einladung. Liste: /api/personen (zeigt nach unten). */
if (darfZuordnen) {
$('feld-teilnehmer').hidden = false;
try {
const { personen } = await (await hole('/workspace/api/personen')).json();
for (const p of personen) {
const o = document.createElement('option');
o.value = String(p.id);
o.textContent = p.name + ' (' + p.rolle + ')';
/* Beim ersten Anlauf landeten diese zwei Zeilen an einem
<span> in der Teilnehmerliste statt hier am <option> -- das
Suchmuster passte auf beide. Wirkungslos, aber irrefuehrend:
Es sah aus, als sei die Stelle erledigt. */
o.dataset.person = 'ja';
if (p.bild) o.dataset.bild = p.bild;
$('f-teilnehmer').append(o);
}
} catch { /* Zuordnung bleibt leer */ }
window.personenwahl?.('f-teilnehmer', { hinweis: "Kein Konto im Workspace – der Name steht nur an diesem Eintrag." });
}
/* "Wer ist noch dabei?" -- für jede Rolle. Eigene Liste über
/api/einladbar, die auch nach oben zeigt: Ein Creator findet
dort seinen Scout, ein Scout seinen Manager. */
try {
const { personen } = await (await hole('/workspace/api/einladbar')).json();
/* Sich selbst braucht niemand zu markieren -- der eigene Termin
steht ohnehin im eigenen Kalender. Ein Knopf, der nichts
bewirkt, verwirrt nur. */
alleLeute = (personen || []).filter((p) => p.id !== ich.id);
/* Bleibt niemand übrig (jemand ganz ohne Zuordnung), hat das
Feld keinen Zweck -- ein leerer Kasten mit Überschrift sieht
aus wie ein Fehler. Sonst: zeigen. */
$('feld-dabei').hidden = !alleLeute.length;
baueDabei();
} catch { $('feld-dabei').hidden = true; }
/* Vom Bericht kommend: VOR dem ersten Laden auf die Liste stellen,
sonst baut die Seite erst den Monat auf und wirft ihn gleich
wieder weg. Der Ansichtsknopf wird mitgesetzt -- sonst zeigt die
Leiste "Monat" an, waehrend die Liste dasteht. */
const wohin = window.zielSchluessel?.();
if (wohin === 'vergangen' || wohin === 'kommend') {
ansicht = 'liste';
for (const x of document.querySelectorAll('.k-ansicht')) {
x.setAttribute('aria-pressed', String(x.dataset.ansicht === 'liste'));
}
}
await laden();
})();
})();