Files
dogfather-universe/workspace/assets/js/kalender.js
T
DogFatherGitandClaude Opus 5 23429627ef Termine, die von allein weiterlaufen -- bis man sie abstellt
Wunsch: "ich will dass ich da im kalender auch sachen machen die jede
woche jeden monat automatisiert immer weiter laufen so wie ich es
auswaehle bis ich es selber deaktiviere. aktiviere diese option fuer
jeden."

Acht Rhythmen (taeglich, werktags, woechentlich, alle zwei Wochen,
monatlich am Datum, am Monatsletzten, am n-ten Wochentag, jaehrlich),
wahlweise mit Enddatum oder ohne Ende. Fuer JEDE Rolle -- Creator und
Scouts legen sie fuer sich selbst an, mit denselben Grenzen wie beim
einzelnen Termin.

WARUM ECHTE TERMINE UND KEINE GERECHNETEN AUSPRAEGUNGEN

Der naheliegende Weg waere gewesen, die Wiederholung nur beim Anzeigen
auszurechnen. Das waere ein stiller Ausfall geworden: ACHT andere Stellen
lesen die Tabelle termine direkt -- Calls, Protokolle, Berichte,
Hinweise/Ampel, Suche, Aufgaben ("naechster Termin"), Uebersicht und die
Startseite. In all diesen Ansichten haette schlicht nichts gestanden, und
aufgefallen waere es erst Monate spaeter. Stattdessen macht ein
Nachfueller aus der Regel echte Zeilen (Horizont 180 Tage) -- jedes andere
Modul sieht sie mit, ohne dass dort eine Zeile geaendert werden musste.
Beim geplanten ICS-Abo gilt dasselbe.

KEIN ZEITGEBER. Der Nachfueller laeuft beim Serverstart, beim Anlegen und
Aendern und beim Oeffnen des Kalenders (dort hoechstens alle fuenf
Minuten). Er ist idempotent. Ein Cron waere eine weitere Sache gewesen,
die stillschweigend ausfallen kann.

WAS BEIM ABSTELLEN PASSIERT: Vergangene Termine und alles, was jemand von
Hand angefasst hat (verschoben, umbenannt, abgehakt), bleibt stehen. Nur
die unberuehrte Zukunft wird aus dem Kalender genommen. Wer einen
einzelnen Tag loescht, streicht nur diesen einen -- der Tag wird als
Ausnahme vermerkt, sonst legte der Nachfueller ihn wieder an.

ZEITUMSTELLUNG: Gerechnet wird auf reinen Datumstexten in UTC-Mittag, die
Uhrzeit wird als Text angehaengt. 18:00 bleibt dadurch 18:00 und wandert
im Oktober nicht auf 17:00.

DIE SICHTBARKEITSREGEL STEHT JETZT NUR NOCH EINMAL (workspace.js,
termineSichtbar) statt zweimal fast gleich. Zwei Fassungen waeren
auseinandergelaufen -- und dann haette eine Wiederholung jemandem etwas
gezeigt, was der einzelne Termin ihm verbirgt.

IN DER OBERFLAECHE beschriften sich die Rhythmen nach dem gewaehlten
Datum ("jeden Dienstag", "jeden Monat am letzten Dienstag") statt
abstrakt ("woechentlich"), inklusive des ehrlichen Hinweises, dass "jeden
31." den Februar auslaesst. Darunter steht der ganze Satz in Worten. Eine
neue Leiste "Laeuft von allein" zeigt alle laufenden Wiederholungen mit
Abstell-Schalter -- was von selbst weiterlaeuft, muss man sehen koennen.

GEPRUEFT: 67 Pruefungen (server/pruef-serien.mjs), darunter von Hand
nachgeschlagene Datumsangaben fuer jeden Takt (der letzte Dienstag im
Dezember 2026 ist der 29., nicht der 22.; der 29. Februar nur in
Schaltjahren) und vier GEGENPROBEN, die beweisen, dass die Pruefungen
auch "nicht in Ordnung" sagen koennen. Die Anzahl der Pruefungen steht in
der Bedingung, nicht nur im Meldetext. Bestehende Pruefungen unveraendert
gruen: Kalender 84, Sicht 48, Startansicht 133, Protokoll 52, Ampel 47,
Handy 50, Aufgabenbrett 44, Sprung 43, Personen-Loeschen 40, Uebersicht
33, Workspace-Seiten 32, Formulare 19, Betreuung 18, Grosscheck 15,
Lesbarkeit 14.

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

1173 lines
49 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.
/* ===================================================================
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. */
const LEITUNG = new Set(['admin', 'manager']);
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;
};
const ARTNAME = { call: 'Call', termin: 'Termin', review: 'Review', frist: 'Frist' };
/* 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. */
const ARTFARBE = {
call: '#3fbdf5',
termin: '#8a76ff',
review: '#50a752',
frist: '#d17717',
};
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 = [];
let ansicht = 'monat';
let anker = heuteText(); // Datum, um das herum angezeigt wird
const zeigen = { call: true, termin: true, review: true, frist: 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));
/* ---------- 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. */
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,
zusatz: [t.dauer_min ? t.dauer_min + ' Min' : null,
t.teilnehmer_name ? 'mit ' + t.teilnehmer_name : null,
t.ort || null].filter(Boolean).join(' · '),
ort: t.ort, beschreibung: t.beschreibung,
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,
});
}
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',
});
}
/* 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'));
}
const sichtbare = () => (daten ? daten.eintraege.filter((e) => zeigen[e.art]) : []);
function nachTag() {
const karte = new Map();
for (const e of sichtbare()) {
if (!karte.has(e.tag)) karte.set(e.tag, []);
karte.get(e.tag).push(e);
}
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' : '');
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());
k.append(document.createTextNode(e.titel));
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 && !confirm(
'Wiederholung „' + s.titel + '" abstellen?\n\n'
+ 'Kommende Termine, die noch niemand angefasst hat, werden aus dem '
+ 'Kalender genommen. Vergangene und bearbeitete Termine bleiben stehen.\n\n'
+ 'Du kannst sie jederzeit wieder starten.')) 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 (!confirm('Wiederholung „' + s.titel + '" ganz löschen?\n\n'
+ 'Die Regel ist damit weg. Kommende, unangetastete Termine werden '
+ 'entfernt; vergangene und bearbeitete bleiben als normale Termine '
+ 'stehen.')) 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();
for (const art of ['call', 'termin', 'review', 'frist']) {
const n = daten.eintraege.filter((e) => e.art === art).length;
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);
const rechts = el('div', 'k-zeile__knoepfe');
rechts.append(el('span', 'k-zeile__art', ARTNAME[e.art] || e.art));
if (e.art === 'frist') {
const a = el('a', 'schritt', 'zur Aufgabe');
a.href = 'aufgaben.html';
rechts.append(a);
} else {
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();
}));
if (LEITUNG.has(ich.rolle) || e.erstellt_von === ich.id) {
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. */
const frage = e.serie_id
? 'Nur diesen einen Termin am ' + tagLang(e.tag) + ' streichen?\n\n'
+ 'Die Wiederholung „' + e.titel + '" läuft weiter. Zum Abstellen '
+ 'den Schalter unter „Läuft von allein" benutzen.'
: 'Termin „' + e.titel + '" löschen?';
if (!confirm(frage)) return;
const a = await hole('/workspace/api/termine/' + e.id, { method: 'DELETE' });
if (!a.ok) { melde('Löschen ging nicht.'); return; }
await laden();
}));
}
}
k.append(rechts);
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);
}
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();
const eintraege = sichtbare().filter((e) => e.tag === 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('');
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();
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(); });
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));
}
laden();
});
}
$('tagdialog-zu').addEventListener('click', () => $('tagdialog').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 --------------------------------------------------- */
function formularOeffnen(tag) {
/* 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();
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);
});
$('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;
}
/* 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 {
const a = await hole('/workspace/api/termine', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(koerper),
});
if (!a.ok) {
$('neu-fehler').textContent = (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 { return; }
window.werZeigen?.(ich);
if (LEITUNG.has(ich.rolle)) {
$('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 + ')';
$('f-teilnehmer').append(o);
}
} catch { /* Zuordnung bleibt leer */ }
}
/* 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();
})();
})();