Filipe: "Es darf vor allem keine Stellen geben, an denen ein Benutzer
etwas falsch machen kann, nur weil die Seite es nicht verstaendlich
genug erklaert."
Gemessen: 30 Stellen in 16 Dateien benutzten confirm() oder prompt().
Das Haus hatte die richtige Bauweise laengst -- einen <dialog>, in
aufgaben.html sogar ausfuehrlich begruendet -- aber sie stand IN EINER
SEITE. Wer anderswo etwas loeschen liess, hatte sie nicht.
confirm('Wirklich loeschen?') stellt die falsche Frage: Es fragt, ob
man sicher ist, und nennt nicht, WAS passiert, was BLEIBT und ob es
ZURUECK geht. Jetzt beantwortet jeder der 41 Dialoge alle drei.
Neu: workspace/assets/js/nachfrage.js -- window.frageNach() mit
Pflichtgrund, Zahlenfeld, einzeiliger Eingabe und Abtippsicherung.
Drei Ausgaenge: <dialog> / confirm()-Notnagel fuer Safari vor 15.4 /
Abbruch (Esc, Klick daneben, "Doch nicht" -- immer false).
DREIMAL DERSELBE FALLSTRICK, dreimal nachgemessen statt vermutet:
.dialog stand in aufgaben.css und leistung.css -> auf dateien.html
waere der Dialog ein weisser Systemkasten gewesen. 14 Regeln
klammergenau nach module.css verschoben (Klammern gezaehlt, nicht
per Muster geschnitten -- heute frueh hat ein nicht-gieriges
Muster schon einmal CSS zerrissen).
Das Formular trug .neu neu--blank -- und .neu gibt seine Abstaende
nur in aufgaben.css. Gemessen: padding 0px, und die Felder
verloren ihre height:44px. Jetzt steht alles unter
.nachfrage__form in module.css; der Dialog borgt nichts mehr.
Die erste Fassung der Pruefung zaehlte nachfrage.js SELBST als
Nutzer -- damit war jede Seite trivialerweise "Nutzer" und die
Pruefung gruen ohne Inhalt. Jetzt ausdruecklich ausgenommen.
ZWEI FUNDE NEBENBEI:
hilfeAufraeumen() wird im Betrieb NIE aufgerufen. Der Kommentar
behauptete "wird beim Start aufgerufen (siehe index.js)" -- das
war nie wahr; einziger Aufrufer ist die eigene Pruefung. Folge:
geschlossene vertrauliche Faelle bleiben unbegrenzt stehen. NICHT
eingeschaltet (das loescht echte Daten und ist Filipes
Entscheidung), sondern der Kommentar richtiggestellt.
Einen Hilfe-Fall zu schliessen ist endgueltig -- es gibt keine
Route, die ihn wieder oeffnet. Vorher stand darueber nur die
Frage nach einem Schlusswort. Jetzt sagt der Dialog es.
pruef-struktur hat meine eigene Pruefung von heute Nachmittag
erwischt: Sie bildete ihr Datum aus UTC. Beim Beheben erst
heuteLokal(datum) genommen -- die Funktion nimmt gar kein Argument
und haette still "heute" statt "+3 Tage" geliefert. Jetzt tagLokal(3),
nachgerechnet: Abstand 3 Tage.
Am Bildschirm angesehen (Rechner 1280, Handy 390): passt rein, Esc
ergibt false, Fokus liegt auf dem harmlosen Knopf, Knoepfe 44px auf
Touch. Der Platzhalter im Abtippfeld zeigte den erwarteten Namen --
das sah aus wie ein schon ausgefuelltes Feld, entfernt.
Neu: server/pruef-nachfrage.mjs -- 17/0, mit sechs Gegenproben und
beiden Richtungen (wer fragt, laedt die Datei; wer nie fragt, laedt
sie nicht -- sonst truege die Anmeldewand 4,8 KB fuer nichts).
pruef-meldungen 8/0, pruef-css-klassen gruen, pruef-struktur gruen,
pruef-leistung gruen.
2013 lines
88 KiB
JavaScript
2013 lines
88 KiB
JavaScript
/* ===================================================================
|
||
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',
|
||
};
|
||
/* 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',
|
||
};
|
||
/* 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,
|
||
});
|
||
}
|
||
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) : []);
|
||
|
||
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());
|
||
/* 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 === '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: 'smooth' });
|
||
}));
|
||
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();
|
||
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('');
|
||
/* 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 { 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();
|
||
})();
|
||
})();
|