diff --git a/server/index.js b/server/index.js index 26d97659..edddefe2 100644 --- a/server/index.js +++ b/server/index.js @@ -18,6 +18,7 @@ import { aufgabenRouter } from "./workspace-aufgaben.js"; import { personenRouter } from "./workspace-personen.js"; import { profilRouter } from "./workspace-profil.js"; import { kalenderRouter } from "./workspace-kalender.js"; +import { serienRouter, serienStarten } from "./workspace-serien.js"; import { dateienRouter } from "./workspace-dateien.js"; import { bereicheRouter } from "./workspace-bereiche.js"; import { reportRouter } from "./workspace-reports.js"; @@ -120,6 +121,7 @@ app.use(aufgabenRouter); app.use(personenRouter); app.use(profilRouter); app.use(kalenderRouter); +app.use(serienRouter); app.use(dateienRouter); app.use(bereicheRouter); app.use(reportRouter); @@ -137,6 +139,14 @@ app.use(contentRouter); Systemdienst laeuft und warum eine Dateikopie hier falsch waere. */ sicherungStarten(); +/* Wiederkehrende Termine einmal auffuellen. Kein Takt, kein Dienst -- + siehe workspace-serien.js: Der Nachfueller legt nur an, was fehlt, und + laeuft ausserdem bei jedem Oeffnen des Kalenders. Der Lauf beim Start + sorgt dafuer, dass die Termine auch dann bereitliegen, wenn an einem + Tag niemand den Kalender oeffnet, aber jemand die Startseite, die + Call-Liste oder die Suche benutzt -- die lesen dieselbe Tabelle. */ +serienStarten(); + /* Sicherheits-Fix 20.08.2026 (Audit vor dem geplanten öffentlichen Start morgen). SITE_DIR ist der GESAMTE Repo-Ordner (join(__dirname, "..")) — express.static liefert ohne diese Sperre daher nicht nur die eigentliche Website aus, sondern auch den Server- diff --git a/server/pruef-serien.mjs b/server/pruef-serien.mjs new file mode 100644 index 00000000..2a40d234 --- /dev/null +++ b/server/pruef-serien.mjs @@ -0,0 +1,466 @@ +/* ===================================================================== + Prüfung der wiederkehrenden Termine (02.09.2026). + + Drei Teile, und der erste ist der wichtigste: + + 1. DIE RECHNUNG, gegen von Hand nachgeschlagene Datumsangaben. + Eine Terminserie ist genau die Sorte Sache, die still falsch sein + kann: "jeden letzten Freitag" um eine Woche daneben fällt beim + Ansehen niemandem auf, sondern erst, wenn jemand vor einem leeren + Meeting sitzt. Deshalb werden hier feste Daten geprüft und nicht + "es kommt irgendetwas heraus". + + 2. DER WEG DURCH DIE ANWENDUNG: anlegen, nachfüllen, einen einzelnen + Tag streichen, abstellen, wieder anstellen. + + 3. DIE OBERFLÄCHE im echten Browser, samt der Frage, ob Formular und + Serverliste DENSELBEN Satz sagen. + + Zu jeder Prüfung, die "alles in Ordnung" melden kann, gehört eine + GEGENPROBE, die beweist, dass sie auch "nicht in Ordnung" sagen kann + (Projektregel vom 01.09.2026). Sie stehen unten ausdrücklich als + solche gekennzeichnet. Und die ANZAHL der gelaufenen Prüfungen wird + am Ende gegen eine Erwartung gehalten -- ein grüner Lauf mit der + halben Menge Prüfungen ist kein grüner Lauf. + ===================================================================== */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-serien-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = "4189"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; +process.env.SITE_ACCESS_CODE_VANVAN = "VANVAN-TEST-1"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 700)); +const BASIS = "http://127.0.0.1:4189"; +setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 200_000).unref?.(); + +const tagText = (d = new Date()) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}` + + `-${String(d.getDate()).padStart(2, "0")}`; + +let fehler = 0; +let geprueft = 0; +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; +const gleich = (a, b, t) => ok(JSON.stringify(a) === JSON.stringify(b), + `${t}${JSON.stringify(a) === JSON.stringify(b) ? "" : `\n erwartet ${JSON.stringify(b)}\n bekommen ${JSON.stringify(a)}`}`); + +/* =================================================================== + TEIL 1 — Die Rechnung + =================================================================== + Alle Daten hier sind von Hand nachgeschlagen. 2026 ist KEIN + Schaltjahr, der 1. September 2026 ist ein Dienstag. */ + +const { serienTage, taktText, serienText } = await import("./workspace-serien.js"); +const regel = (o) => ({ uhrzeit: "18:00", ende_tag: null, ...o }); + +console.log("\n— Rechnung: Takte —"); + +gleich(serienTage(regel({ takt: "woechentlich", start_tag: "2026-09-01" }), "2026-09-01", "2026-09-30"), + ["2026-09-01", "2026-09-08", "2026-09-15", "2026-09-22", "2026-09-29"], + "wöchentlich ab Dienstag, 01.09.2026 → alle fünf Dienstage im September"); + +gleich(serienTage(regel({ takt: "zweiwoechentlich", start_tag: "2026-09-01" }), "2026-09-01", "2026-10-15"), + ["2026-09-01", "2026-09-15", "2026-09-29", "2026-10-13"], + "alle zwei Wochen → jeder zweite Dienstag, über den Monatswechsel hinweg"); + +gleich(serienTage(regel({ takt: "werktags", start_tag: "2026-09-04" }), "2026-09-04", "2026-09-11"), + ["2026-09-04", "2026-09-07", "2026-09-08", "2026-09-09", "2026-09-10", "2026-09-11"], + "werktags → das Wochenende 05./06.09. fällt aus, wird nicht verschoben"); + +gleich(serienTage(regel({ takt: "taeglich", start_tag: "2026-09-01" }), "2026-09-01", "2026-09-05"), + ["2026-09-01", "2026-09-02", "2026-09-03", "2026-09-04", "2026-09-05"], + "täglich → fünf Tage sind fünf Termine"); + +console.log("\n— Rechnung: Monatstakte, die still falsch sein können —"); + +/* Der 31.: Februar, April, Juni, September und November haben ihn + nicht. Ausgelassen, NICHT auf den 28./30. vorgezogen. */ +gleich(serienTage(regel({ takt: "monatlich_datum", start_tag: "2026-01-31" }), "2026-01-01", "2026-12-31"), + ["2026-01-31", "2026-03-31", "2026-05-31", "2026-07-31", "2026-08-31", + "2026-10-31", "2026-12-31"], + "jeden Monat am 31. → sieben Termine 2026, Monate ohne 31. fallen aus"); + +gleich(serienTage(regel({ takt: "monatlich_letzter", start_tag: "2026-01-31" }), "2026-01-01", "2026-05-31"), + ["2026-01-31", "2026-02-28", "2026-03-31", "2026-04-30", "2026-05-31"], + "jeden Monatsletzten → 28.02. (2026 ist kein Schaltjahr), 30.04."); + +gleich(serienTage(regel({ takt: "monatlich_letzter", start_tag: "2028-01-31" }), "2028-02-01", "2028-02-29"), + ["2028-02-29"], + "Monatsletzter im Schaltjahr 2028 → der 29. Februar"); + +/* 08.09.2026 ist der ZWEITE Dienstag. Danach: 13.10. (Okt. 1 = Do, + erster Di = 6.), 10.11. (Nov. 1 = So, erster Di = 3.). */ +gleich(serienTage(regel({ takt: "monatlich_wochentag", start_tag: "2026-09-08" }), "2026-09-01", "2026-11-30"), + ["2026-09-08", "2026-10-13", "2026-11-10"], + "jeden zweiten Dienstag im Monat → 08.09., 13.10., 10.11."); + +/* 29.09.2026 ist der LETZTE Dienstag im September (der 30. ist ein + Mittwoch). Rückwärts gerechnet: Oktober 27., November 24. Wer + vorwärts zählte ("der vierte"), läge im Oktober richtig und im + Dezember falsch -- deshalb steht der Dezember mit in der Prüfung: + Dezember 2026 hat FÜNF Dienstage (1., 8., 15., 22., 29.). */ +gleich(serienTage(regel({ takt: "monatlich_wochentag", start_tag: "2026-09-29" }), "2026-09-01", "2026-12-31"), + ["2026-09-29", "2026-10-27", "2026-11-24", "2026-12-29"], + "jeden LETZTEN Dienstag im Monat → im Dezember der 29., nicht der 22."); + +gleich(serienTage(regel({ takt: "jaehrlich", start_tag: "2028-02-29" }), "2028-01-01", "2033-12-31"), + ["2028-02-29", "2032-02-29"], + "jährlich am 29. Februar → nur in Schaltjahren, 2029–2031 fallen aus"); + +console.log("\n— Rechnung: Grenzen —"); + +gleich(serienTage(regel({ takt: "woechentlich", start_tag: "2026-09-01", ende_tag: "2026-09-16" }), + "2026-09-01", "2026-12-31"), + ["2026-09-01", "2026-09-08", "2026-09-15"], + "Enddatum → nach dem 16.09. kommt nichts mehr"); + +gleich(serienTage(regel({ takt: "woechentlich", start_tag: "2026-09-22" }), "2026-09-01", "2026-09-30"), + ["2026-09-22", "2026-09-29"], + "vor dem Start liegt nichts, auch wenn der Zeitraum früher beginnt"); + +ok(serienTage(regel({ takt: "taeglich", start_tag: "2020-01-01" }), "2026-09-01", "2026-09-03").length === 3, + "eine Serie von 2020 kostet keine tausend Durchläufe (vorgespult) → drei Tage"); + +ok(serienTage(regel({ takt: "unsinn", start_tag: "2026-09-01" }), "2026-09-01", "2026-09-30").length === 0, + "ein unbekannter Takt liefert nichts, statt etwas zu erfinden"); + +console.log("\n— Rechnung: der Klartext —"); + +gleich(taktText({ takt: "woechentlich", start_tag: "2026-09-01" }), "jeden Dienstag", + "Klartext wöchentlich"); +gleich(taktText({ takt: "monatlich_wochentag", start_tag: "2026-09-29" }), + "jeden Monat am letzten Dienstag", "Klartext letzter Wochentag"); +gleich(taktText({ takt: "monatlich_wochentag", start_tag: "2026-09-08" }), + "jeden Monat am zweiten Dienstag", "Klartext n-ter Wochentag"); +gleich(serienText({ takt: "woechentlich", start_tag: "2026-09-01", uhrzeit: "18:00", ende_tag: null }), + "jeden Dienstag um 18:00 Uhr, bis du sie abstellst", + "der ganze Satz nennt die Uhrzeit UND dass die Serie ohne Ende läuft"); + +/* =================================================================== + TEIL 2 — Der Weg durch die Anwendung + =================================================================== */ + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); + +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idTili = anlegen("Tili", "creator", "CODE-TILI-0001"); + +const zahl = (sql, ...w) => d.prepare(sql).get(...w)?.n ?? 0; + +const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await chromium.launch(); + +async function anmelden(rolle, code) { + const kontext = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); + const seite = await kontext.newPage(); + await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); + await seite.click(`.rolle[data-rolle="${rolle}"]`); + await seite.fill("#code", code); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 15000 }); + return seite; +} + +/** Ein API-Aufruf aus der angemeldeten Seite heraus -- mit echter + * Sitzung, echtem Origin, echter Schranke. */ +const api = (seite, pfad, optionen) => seite.evaluate(async ([p, o]) => { + const a = await fetch(p, { + credentials: "same-origin", + ...(o || {}), + headers: o?.koerper ? { "Content-Type": "application/json" } : undefined, + body: o?.koerper ? JSON.stringify(o.koerper) : undefined, + }); + return { status: a.status, daten: await a.json().catch(() => ({})) }; +}, [pfad, optionen]); + +const seiteDogi = await anmelden("admin", "CODE-DOGI-0001"); + +console.log("\n— Anlegen über das Terminformular —"); + +/* Start morgen, damit die Serie ganz in der Zukunft liegt und die + Aufräum-Regeln ("was noch bevorsteht") überhaupt greifen können. */ +const morgen = tagText(new Date(Date.now() + 86400_000)); +const antwort = await api(seiteDogi, "/workspace/api/termine", { + method: "POST", + koerper: { + titel: "Wochen-Call mit Tili", art: "call", beginn: morgen + "T18:00", + dauer_min: 45, creator_id: idTili, teilnehmer_id: idTili, + wiederholung: { takt: "woechentlich", ende_tag: null }, + }, +}); +ok(antwort.status === 201 && antwort.daten.serie_id > 0, + "ein Termin mit Rhythmus legt eine Serie an (201 + serie_id)"); +const serieId = antwort.daten.serie_id; + +const erwarteteAnzahl = serienTage( + { takt: "woechentlich", start_tag: morgen, uhrzeit: "18:00", ende_tag: null }, + tagText(), tagText(new Date(Date.now() + 180 * 86400_000))).length; +const angelegt = zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id = ?", serieId); +ok(erwarteteAnzahl > 20, `der Horizont von 180 Tagen ergibt ${erwarteteAnzahl} Termine (mehr als 20)`); +ok(angelegt === erwarteteAnzahl, + `die Serie hat genau diese ${erwarteteAnzahl} Termine wirklich angelegt (${angelegt})`); +ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id IS NULL") === 0, + "und KEINEN einzelnen Termin daneben – der erste stünde sonst doppelt da"); +ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id = ? AND beginn LIKE '%T18:00'", serieId) === angelegt, + "alle Ausprägungen stehen auf 18:00 – auch über die Zeitumstellung hinweg"); +ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id = ? AND creator_id = ? AND dauer_min = 45", + serieId, idTili) === angelegt, + "Zuordnung und Dauer sind auf jeder Ausprägung angekommen"); + +console.log("\n— Nachfüllen legt nichts doppelt an —"); + +const { serieNachfuellen, nachfuellenAlle } = await import("./workspace-serien.js"); +const nochmal = serieNachfuellen(serieId); +ok(nochmal === 0, "ein zweiter Lauf des Nachfüllers legt 0 neue Termine an"); +ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id = ?", serieId) === angelegt, + "die Anzahl ist unverändert"); + +console.log("\n— Einen einzelnen Tag streichen —"); + +const zweiter = d.prepare( + "SELECT id, serie_tag FROM termine WHERE serie_id = ? ORDER BY beginn LIMIT 1 OFFSET 1").get(serieId); +const geloescht = await api(seiteDogi, "/workspace/api/termine/" + zweiter.id, { method: "DELETE" }); +ok(geloescht.status === 200, "ein einzelner Serientermin lässt sich löschen"); +ok(zahl("SELECT COUNT(*) AS n FROM termin_serien_aus WHERE serie_id = ? AND tag = ?", + serieId, zweiter.serie_tag) === 1, + "der Tag ist als Ausnahme vermerkt"); +serieNachfuellen(serieId); +ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id = ? AND serie_tag = ?", + serieId, zweiter.serie_tag) === 0, + "der Nachfüller bringt ihn NICHT zurück"); + +/* GEGENPROBE. Ohne sie wäre die Zeile darüber wertlos: Sie wäre auch + dann grün, wenn der Nachfüller überhaupt nichts täte. Wird die + Ausnahme entfernt, MUSS der Termin wiederkommen. */ +d.prepare("DELETE FROM termin_serien_aus WHERE serie_id = ? AND tag = ?").run(serieId, zweiter.serie_tag); +serieNachfuellen(serieId); +ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id = ? AND serie_tag = ?", + serieId, zweiter.serie_tag) === 1, + "GEGENPROBE: ohne die Ausnahme legt der Nachfüller ihn sehr wohl wieder an"); + +console.log("\n— Abstellen räumt die Zukunft, aber nicht die Arbeit —"); + +/* Einen Termin von Hand anfassen: Er gilt danach als berührt und darf + beim Abstellen nicht verschwinden. */ +const dritter = d.prepare( + "SELECT id FROM termine WHERE serie_id = ? ORDER BY beginn LIMIT 1 OFFSET 2").get(serieId); +await api(seiteDogi, "/workspace/api/termine/" + dritter.id, { + method: "PATCH", koerper: { titel: "Von Hand umbenannt" }, +}); +ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE id = ? AND serie_beruehrt = 1", dritter.id) === 1, + "ein von Hand geänderter Serientermin gilt als berührt"); + +const aus = await api(seiteDogi, "/workspace/api/serien/" + serieId, { + method: "PATCH", koerper: { aktiv: 0 }, +}); +ok(aus.status === 200, "die Serie lässt sich abstellen"); +ok(zahl("SELECT COUNT(*) AS n FROM termin_serien WHERE id = ? AND aktiv = 0", serieId) === 1, + "sie steht als abgestellt in der Datenbank"); +ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id = ? AND serie_beruehrt = 0", serieId) === 0, + "alle unberührten künftigen Termine sind aus dem Kalender genommen"); +ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE id = ?", dritter.id) === 1, + "der von Hand bearbeitete Termin steht weiterhin da"); + +/* GEGENPROBE zum Abstellen: Der Nachfüller darf für eine abgestellte + Serie nichts mehr tun -- sonst wäre "deaktiviert" nur ein Wort. */ +const trotzdem = serieNachfuellen(serieId); +ok(trotzdem === 0, "GEGENPROBE: der Nachfüller legt für eine abgestellte Serie nichts nach"); +nachfuellenAlle(true); +ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id = ? AND serie_beruehrt = 0", serieId) === 0, + "auch der Lauf über alle Serien lässt sie in Ruhe"); + +console.log("\n— Wieder anstellen —"); + +const an = await api(seiteDogi, "/workspace/api/serien/" + serieId, { + method: "PATCH", koerper: { aktiv: 1 }, +}); +ok(an.status === 200 && an.daten.angelegt > 20, + `wieder angestellt legt die Termine neu an (${an.daten.angelegt})`); + +console.log("\n— Fuer jeden: auch ein Creator darf eine Wiederholung anlegen —"); + +const seiteTili = await anmelden("creator", "CODE-TILI-0001"); +const vonTili = await api(seiteTili, "/workspace/api/termine", { + method: "POST", + koerper: { + titel: "Eigener Drehtag", art: "termin", beginn: morgen + "T09:00", + dauer_min: 60, wiederholung: { takt: "monatlich_wochentag", ende_tag: null }, + }, +}); +ok(vonTili.status === 201 && vonTili.daten.serie_id > 0, + "ein Creator legt eine eigene Wiederholung an"); +ok(zahl("SELECT COUNT(*) AS n FROM termin_serien WHERE id = ? AND creator_id = ? AND teilnehmer_id = ?", + vonTili.daten.serie_id, idTili, idTili) === 1, + "sie ist zwangsweise ihm selbst zugeordnet – die Serie umgeht die Regel nicht"); + +/* Sichtbarkeit. Tili sieht ZWEI Serien -- ihre eigene und den + Wochen-Call, der mit ihr stattfindet. Das ist richtig so: Die Regel + lautet "woran ich beteiligt bin", und beim Wochen-Call ist sie + creator_id und teilnehmer_id. Ändern darf sie ihn aber nicht, sonst + könnte sie einen Call abstellen, den das Management angesetzt hat. */ +const sichtTili = await api(seiteTili, "/workspace/api/serien"); +ok(sichtTili.daten.serien.length === 2, + `Tili sieht ihre eigene UND den Call, an dem sie teilnimmt (${sichtTili.daten.serien?.length})`); +ok(sichtTili.daten.serien.filter((s) => s.darf_aendern).length === 1, + "ändern darf sie aber nur ihre eigene – nicht die des Managements"); + +const fremd = await api(seiteTili, "/workspace/api/serien/" + serieId, { + method: "PATCH", koerper: { aktiv: 0 }, +}); +ok(fremd.status === 403, "GEGENPROBE: der Versuch, die fremde Serie abzustellen, wird abgewiesen"); +ok(zahl("SELECT COUNT(*) AS n FROM termin_serien WHERE id = ? AND aktiv = 1", serieId) === 1, + "und sie läuft danach unverändert weiter"); + +/* Eine Person, die mit beiden nichts zu tun hat, sieht nichts. Ohne + diese Prüfung wäre die Zeile darüber grün, selbst wenn jeder alles + sähe -- Tili ist an beiden Serien beteiligt. */ +const idScout = anlegen("Ben", "scout", "CODE-BEN-00001"); +const seiteBen = await anmelden("scout", "CODE-BEN-00001"); +const sichtBen = await api(seiteBen, "/workspace/api/serien"); +ok(sichtBen.daten.serien.length === 0, + `ein unbeteiligter Scout sieht keine der beiden Serien (${sichtBen.daten.serien?.length})`); +ok(idScout > 0, "der Scout ist dabei wirklich angelegt worden – sonst prüfte die Zeile davor nichts"); + +console.log("\n— Fehleingaben —"); + +for (const [koerper, was] of [ + [{ takt: "hackerei", start_tag: morgen, uhrzeit: "10:00", titel: "X" }, "unbekannter Takt"], + [{ takt: "woechentlich", start_tag: "morgen", uhrzeit: "10:00", titel: "X" }, "Datum als Wort"], + [{ takt: "woechentlich", start_tag: morgen, uhrzeit: "25:00", titel: "X" }, "Uhrzeit 25:00"], + [{ takt: "woechentlich", start_tag: morgen, uhrzeit: "10:00", titel: "X", ende_tag: "2020-01-01" }, + "Ende vor dem Start"], +]) { + const a = await api(seiteDogi, "/workspace/api/serien", { method: "POST", koerper }); + ok(a.status === 400, `abgelehnt: ${was}`); +} + +/* =================================================================== + TEIL 3 — Die Oberfläche + =================================================================== */ + +console.log("\n— Der Kalender im Browser —"); + +await seiteDogi.goto(BASIS + "/workspace/kalender.html", { waitUntil: "networkidle" }); +await seiteDogi.waitForSelector(".k-serie", { timeout: 15000 }); + +const liste = await seiteDogi.evaluate(() => ({ + serien: [...document.querySelectorAll(".k-serie")].map((k) => ({ + titel: k.querySelector(".k-serie__titel")?.textContent, + satz: k.querySelector(".k-serie__satz")?.textContent, + zustand: k.querySelector(".k-serie__zustand")?.textContent, + naechster: k.querySelector(".k-serie__naechster")?.textContent, + knoepfe: [...k.querySelectorAll(".k-serie__knoepfe button")].map((b) => b.textContent), + })), + zeichen: document.querySelectorAll(".k-pille .k-wdh-zeichen").length, + pillen: document.querySelectorAll(".k-pille").length, + sichtbar: !document.getElementById("serien-block").hidden, + /* Abgeschnittener Text ist der häufigste stille Fehler -- er sieht + auf den ersten Blick nach Absicht aus. */ + abgeschnitten: [...document.querySelectorAll(".k-serie__titel, .k-serie__satz, .k-serie__naechster")] + .filter((e) => e.scrollWidth > e.clientWidth + 1).map((e) => e.textContent), + ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth, +})); + +ok(liste.sichtbar, "die Leiste „Läuft von allein\" ist sichtbar"); +ok(liste.serien.length === 2, `beide Serien stehen darin (${liste.serien.length})`); +ok(liste.serien.some((s) => s.satz === "jeden Dienstag um 18:00 Uhr, bis du sie abstellst") + || liste.serien.some((s) => /^jeden \w+ um 18:00 Uhr, bis du sie abstellst$/.test(s.satz)), + "der Satz nennt Rhythmus, Uhrzeit und dass sie ohne Ende läuft"); +ok(liste.serien.every((s) => /Nächster Termin: /.test(s.naechster)), + "zu jeder Serie steht, wann sie das nächste Mal zuschlägt"); +ok(liste.serien.every((s) => s.zustand === "läuft"), + "der Zustand steht als WORT da, nicht nur als Farbe"); +ok(liste.serien.every((s) => s.knoepfe.includes("abstellen") && s.knoepfe.includes("löschen")), + "abstellen und löschen sind erreichbar"); +ok(liste.pillen > 0 && liste.zeichen === liste.pillen, + `jeder Termin im Raster trägt das Wiederhol-Zeichen (${liste.zeichen}/${liste.pillen})`); +ok(!liste.abgeschnitten.length, + `kein Text ist abgeschnitten${liste.abgeschnitten.length ? ": " + liste.abgeschnitten.join(" | ") : ""}`); +ok(liste.ueber <= 0, "nichts ragt seitlich aus der Seite"); + +/* Die Liste im Bild -- sie ist der Teil, den man beim Abstellen sucht. */ +await seiteDogi.evaluate(() => + document.getElementById("serien-block").scrollIntoView({ block: "center" })); +await seiteDogi.screenshot({ path: "pruef-serien-liste.png", fullPage: false }); + +console.log("\n— Formular: die Auswahl beschriftet sich nach dem Datum —"); + +await seiteDogi.click("#neu-oeffnen"); +await seiteDogi.fill("#f-beginn", "2026-09-29T18:00"); +await seiteDogi.dispatchEvent("#f-beginn", "change"); +const auswahl = await seiteDogi.evaluate(() => + [...document.querySelectorAll("#f-takt option")].map((o) => o.textContent)); +ok(auswahl.includes("jeden Dienstag"), "„jeden Dienstag\" statt „wöchentlich\""); +ok(auswahl.includes("jeden Monat am letzten Dienstag"), + "der 29.09. ist der letzte Dienstag – die Auswahl sagt es"); +ok(auswahl.some((t) => t.startsWith("jeden Monat am 29.")), + "„jeden Monat am 29.\" ist als eigene Möglichkeit da"); +ok(auswahl.some((t) => t.includes("(Monate ohne den 29. fallen aus)")), + "und der Hinweis, dass der Februar dabei ausfällt, steht dran"); + +/* Der Vorschausatz im Formular MUSS derselbe sein wie der, den der + Server später in die Liste schreibt. Formular und Liste dürfen nicht + Verschiedenes behaupten -- sonst glaubt man der falschen Stelle. */ +await seiteDogi.selectOption("#f-takt", "monatlich_wochentag"); +const vorschau = await seiteDogi.textContent("#wdh-satz"); +ok(/^Läuft jeden Monat am letzten Dienstag um 18:00 Uhr, bis du sie abstellst\.$/.test(vorschau.trim()), + `die Vorschau sagt den ganzen Satz: „${vorschau.trim()}"`); +gleich(vorschau.trim(), "Läuft " + serienText( + { takt: "monatlich_wochentag", start_tag: "2026-09-29", uhrzeit: "18:00", ende_tag: null }) + ".", + "Formular und Server formulieren WORTGLEICH – sonst laufen sie auseinander"); + +await seiteDogi.screenshot({ path: "pruef-serien-computer.png", fullPage: false }); +const handy = await browser.newContext({ viewport: { width: 390, height: 844 } }); +const seiteHandy = await handy.newPage(); +await seiteHandy.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); +await seiteHandy.click('.rolle[data-rolle="admin"]'); +await seiteHandy.fill("#code", "CODE-DOGI-0001"); +await seiteHandy.click("button[type=submit]"); +await seiteHandy.waitForURL("**/start.html", { timeout: 15000 }); +await seiteHandy.goto(BASIS + "/workspace/kalender.html", { waitUntil: "networkidle" }); +await seiteHandy.waitForSelector(".k-serie", { timeout: 15000 }); +const engUeber = await seiteHandy.evaluate(() => + document.documentElement.scrollWidth - document.documentElement.clientWidth); +ok(engUeber <= 0, "auch auf dem Handy ragt nichts heraus"); +await seiteHandy.screenshot({ path: "pruef-serien-handy.png", fullPage: false }); + +/* =================================================================== + Abschluss — die ANZAHL zählt mit + =================================================================== + Ein grüner Lauf mit weniger Prüfungen als beim letzten Mal ist kein + grüner Lauf: Genau so fallen Prüfungen still weg, wenn sich der + Datenbestand ändert (Projektregel vom 28.08.2026). */ +/* Stand 02.09.2026: 66 Prüfungen vor dieser Zeile. Sinkt die Zahl, ist + eine Prüfung stillschweigend weggefallen -- dann gehört das erklärt, + bevor der Lauf als grün gilt. */ +const ERWARTET = 66; +console.log(""); +ok(geprueft >= ERWARTET, + `es sind ${geprueft} Prüfungen gelaufen (erwartet mindestens ${ERWARTET})`); + +await browser.close(); +d.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } + +console.log(`\n${fehler ? "FEHLER: " + fehler : "Alles in Ordnung"} — ${geprueft} Prüfungen`); +process.exit(fehler ? 1 : 0); diff --git a/server/workspace-kalender.js b/server/workspace-kalender.js index 7f540ffd..81433127 100644 --- a/server/workspace-kalender.js +++ b/server/workspace-kalender.js @@ -13,8 +13,11 @@ import express from "express"; import { - db, protokolliere, echteIp, sitzungLesen, betreutWo, istLeitung, istDogFather, heuteLokal, + db, protokolliere, echteIp, sitzungLesen, istLeitung, termineSichtbar, heuteLokal, } from "./workspace.js"; +import { + nachfuellenAlle, serienPruefen, serieAnlegen, zuordnungErzwingen, +} from "./workspace-serien.js"; export const kalenderRouter = express.Router(); @@ -43,30 +46,22 @@ function gleicheHerkunft(req, res, next) { kalenderRouter.use("/workspace/api/termine", angemeldet); -/* Wer sieht welchen Termin? Gleiche Logik wie bei den Aufgaben: - Management alles, sonst nur, woran man selbst beteiligt ist. */ -export function sichtbar(person) { - /* NUR DogFather sieht alles (01.09.2026). Vorher stand hier - istLeitung() -- damit sah auch jeder Manager jeden Creator. Ein - Manager faellt jetzt in dieselbe Regel wie ein Scout: nur die - Creator, die ihm zugeteilt sind. Ausdruecklicher Wunsch: - "NUR DIE ROLLE DOGFATHER SOLL WIRKLICH ALLEINE ALLES SEHEN." +/* Wer sieht welchen Termin? - Geaendert wird ausschliesslich, wer was SIEHT. Was ein Manager - darf (freigeben, aendern, Personen verwalten), haengt weiterhin an - istLeitung und bleibt unveraendert -- sonst haette dieser eine - Wunsch stillschweigend seine halben Rechte mitgenommen. */ - if (istDogFather(person)) return { wo: "1=1", werte: [] }; - const eigen = "(t.creator_id = ? OR t.teilnehmer_id = ? OR t.erstellt_von = ?)"; - const werte = [person.id, person.id, person.id]; - const b = betreutWo(person, "t.creator_id"); - if (!b) return { wo: eigen, werte }; - return { wo: `(${eigen} OR ${b.wo})`, werte: [...werte, ...b.werte] }; -} + Die Regel selbst steht seit dem 02.09.2026 in workspace.js, weil die + Wiederholungen (workspace-serien.js) genau dieselbe brauchen -- nur + auf einer anderen Tabelle. Zwei fast gleiche Fassungen wären früher + oder später auseinandergelaufen, und dann hätte eine Wiederholung + jemandem etwas gezeigt, was der einzelne Termin ihm verbirgt. + + Der Name bleibt hier stehen: workspace-calls.js, -hinweise.js und + -suche.js holen ihn von hier. */ +export const sichtbar = (person) => termineSichtbar(person, "t"); const SPALTEN = ` t.id, t.titel, t.beschreibung, t.art, t.beginn, t.dauer_min, t.ort, t.creator_id, t.teilnehmer_id, t.erledigt, t.erstellt, t.erstellt_von, + t.serie_id, t.serie_tag, pc.name AS creator_name, pt.name AS teilnehmer_name`; const VERBUND = ` @@ -78,6 +73,11 @@ const VERBUND = ` kalenderRouter.get("/workspace/api/termine", (req, res) => { try { + /* Wiederkehrende Termine nachlegen, BEVOR gelesen wird -- sonst + fehlte im Kalender genau der Termin, für den man ihn öffnet. + Der Nachfüller bremst sich selbst (höchstens alle fünf Minuten) + und legt nur an, was noch fehlt. */ + nachfuellenAlle(); const { wo, werte } = sichtbar(req.sicht || req.person); /* Zeitraum. Ohne Angabe: ab heute 00:00, 90 Tage nach vorn -- das @@ -189,6 +189,31 @@ kalenderRouter.post("/workspace/api/termine", gleicheHerkunft, (req, res) => { } } + /* Wurde im Formular ein Rhythmus gewählt, entsteht KEIN einzelner + Termin, sondern eine Regel -- und aus ihr sofort die Termine des + Horizonts, den ersten eingeschlossen. Beides anzulegen wäre der + naheliegende Fehler gewesen: Der erste Termin stünde dann doppelt + da, einmal von Hand und einmal aus der Serie. + + Der Weg führt bewusst über dieselbe Prüfung und dieselbe + Anlege-Funktion wie die eigene Serien-Schnittstelle. Eine zweite, + verkürzte Fassung hier hätte irgendwann andere Grenzen gehabt. */ + const w = req.body?.wiederholung; + if (w && w.takt) { + const serie = { + ...aus, + takt: w.takt, + start_tag: aus.beginn.slice(0, 10), + uhrzeit: aus.beginn.slice(11, 16), + ende_tag: w.ende_tag ?? null, + }; + const gepruft = serienPruefen(serie, { neu: true }); + if (gepruft.fehler.length) return res.status(400).json({ fehler: gepruft.fehler.join(" ") }); + zuordnungErzwingen(gepruft.aus, req.person); + const { id, angelegt } = serieAnlegen(gepruft.aus, req.person); + return res.status(201).json({ serie_id: id, angelegt }); + } + const { lastInsertRowid } = db().prepare(` INSERT INTO termine (titel, beschreibung, art, beginn, dauer_min, ort, creator_id, teilnehmer_id, @@ -227,8 +252,14 @@ kalenderRouter.patch("/workspace/api/termine/:id", gleicheHerkunft, (req, res) = const felder = Object.keys(aus); if (!felder.length) return res.status(400).json({ fehler: "nichts_zu_aendern" }); - db().prepare(`UPDATE termine SET ${felder.map((f) => `${f} = ?`).join(", ")} WHERE id = ?`) - .run(...felder.map((f) => aus[f]), id); + /* Stammt der Termin aus einer Wiederholung, gilt er ab jetzt als + "angefasst". Das entscheidet später zweierlei: Beim Abstellen der + Serie bleibt er stehen, und beim Ändern der Regel wird er nicht + neu gebaut. Was jemand verschoben, umbenannt oder abgehakt hat, + räumt die Automatik ihm nicht weg. */ + const beruehrt = termin.serie_id ? ", serie_beruehrt = 1" : ""; + db().prepare(`UPDATE termine SET ${felder.map((f) => `${f} = ?`).join(", ")}${beruehrt} + WHERE id = ?`).run(...felder.map((f) => aus[f]), id); protokolliere("termin_geaendert", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), @@ -247,7 +278,8 @@ kalenderRouter.delete("/workspace/api/termine/:id", gleicheHerkunft, (req, res) if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); const { wo, werte } = sichtbar(req.person); - const termin = db().prepare(`SELECT t.id, t.titel, t.erstellt_von ${VERBUND} WHERE ${wo} AND t.id = ?`) + const termin = db().prepare( + `SELECT t.id, t.titel, t.erstellt_von, t.serie_id, t.serie_tag ${VERBUND} WHERE ${wo} AND t.id = ?`) .get(...werte, id); if (!termin) return res.status(404).json({ fehler: "nicht_gefunden" }); @@ -258,6 +290,17 @@ kalenderRouter.delete("/workspace/api/termine/:id", gleicheHerkunft, (req, res) return res.status(403).json({ fehler: "nicht_erlaubt" }); } + /* Eine einzelne Ausprägung zu löschen heisst "dieses eine Mal + nicht" -- nicht "die ganze Serie weg". Ohne diese Vormerkung + legte der Nachfüller den Termin beim nächsten Öffnen des + Kalenders wieder an, und der gelöschte Termin wäre kommentarlos + zurück. Der Sinn der Serie bleibt erhalten: Sie läuft weiter, nur + dieser Tag fällt aus. */ + if (termin.serie_id && termin.serie_tag) { + db().prepare("INSERT OR IGNORE INTO termin_serien_aus (serie_id, tag) VALUES (?,?)") + .run(termin.serie_id, termin.serie_tag); + } + db().prepare("DELETE FROM termine WHERE id = ?").run(id); protokolliere("termin_geloescht", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), diff --git a/server/workspace-serien.js b/server/workspace-serien.js new file mode 100644 index 00000000..129b5076 --- /dev/null +++ b/server/workspace-serien.js @@ -0,0 +1,593 @@ +/* ===================================================================== + workspace-serien.js — Termine, die von allein weiterlaufen. + + Wunsch vom 02.09.2026: "ich will dass ich da im kalender auch sachen + machen die jede woche jeden monat automatisiert immer weiter laufen + so wie ich es auswähle bis ich es selber deaktiviere." + + --------------------------------------------------------------------- + WARUM ECHTE TERMINE UND KEINE GERECHNETEN AUSPRÄGUNGEN + + Der naheliegende Weg wäre gewesen, die Wiederholung nur beim Anzeigen + auszurechnen: eine Regel in der Datenbank, und der Kalender malt die + Termine dazu. Das wäre falsch gewesen. ACHT andere Stellen lesen die + Tabelle `termine` direkt: + + workspace-calls.js die Call-Liste und die Protokolle + workspace-reports.js "Termine gewesen / stehen an" + workspace-hinweise.js die Ampel und die Hinweise + workspace-suche.js die Suche über alles + workspace-aufgaben.js "nächster Termin" an einer Aufgabe + workspace-personen.js was an einer Person hängt + workspace-content.js Veröffentlichungstermine + die Startseite/Übersicht "heute steht an" + + Eine nur im Kalender gerechnete Wiederholung wäre in all diesen + Ansichten unsichtbar geblieben — und niemandem wäre es aufgefallen, + weil dort einfach nichts gestanden hätte. Genau die Sorte stiller + Ausfall, die man erst Monate später bemerkt. + + Deshalb: Die Serie ist die Regel, der Nachfüller macht daraus echte + Zeilen in `termine`, und jedes andere Modul sieht sie mit, ohne dass + dort eine einzige Zeile geändert werden musste. Beim geplanten + ICS-Abo (Perfektionsplan 3.2) gilt dasselbe. + + --------------------------------------------------------------------- + KEIN ZEITGEBER, KEIN DIENST + + Der Nachfüller hängt an keinem Cron und an keinem Timer. Er läuft beim + Start, beim Anlegen/Ändern einer Serie und beim Öffnen des Kalenders + (dort höchstens alle fünf Minuten). Er ist idempotent: Was es schon + gibt, legt er nicht noch einmal an. Ein Zeitgeber hätte nichts + hinzugefügt, aber eine weitere Sache, die stillschweigend ausfallen + kann. + + --------------------------------------------------------------------- + GERECHNET WIRD AUF DATUMSTEXTEN, IN UTC-MITTAG + + Dieselbe Regel wie im Kalender-Frontend: 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. + + Die Uhrzeit wird NICHT mitgerechnet, sondern als Text angehängt. + Dadurch bleibt "18:00" über die Zeitumstellung hinweg 18:00 und + wandert nicht auf 17:00 — was bei einem wöchentlichen Call genau das + Falsche wäre. + ===================================================================== */ + +import express from "express"; +import { + db, protokolliere, echteIp, sitzungLesen, istLeitung, termineSichtbar, + heuteLokal, tagLokal, +} from "./workspace.js"; + +export const serienRouter = express.Router(); + +/* Wie weit im Voraus echte Termine bereitliegen. 180 Tage decken den + 90-Tage-Plan aus dem Konzept mit Reserve ab und halten die Tabelle + klein: täglich sind das 180 Zeilen je Serie, monatlich sechs. */ +export const HORIZONT_TAGE = 180; + +/* Notbremse gegen eine Regel, die versehentlich Unmengen erzeugt. 400 + Ausprägungen sind mehr als der Horizont je hergibt (täglich = 180) -- + greift die Grenze trotzdem, stimmt etwas an der Rechnung nicht. */ +const GRENZE = 400; + +const TITEL_MAX = 160; +const TEXT_MAX = 4000; +const ORT_MAX = 400; +const ARTEN = ["termin", "call", "review"]; +const TAKTE = [ + "taeglich", "werktags", "woechentlich", "zweiwoechentlich", + "monatlich_datum", "monatlich_letzter", "monatlich_wochentag", "jaehrlich", +]; + +const jetztStempel = () => new Date().toISOString(); + +/* "Jetzt" in derselben Schreibweise wie `beginn` (Ortszeit, auf die + Minute). Damit lässt sich "liegt noch vor uns" mit einem + Text-Vergleich beantworten -- dasselbe Format, dieselbe Sortierung. */ +function jetztLokal() { + const d = new Date(); + const p = (n) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}` + + `T${p(d.getHours())}:${p(d.getMinutes())}`; +} + +/* ---------- Datumsrechnung (UTC-Mittag, siehe Kopf) ---------------------- */ + +const alsText = (d) => d.toISOString().slice(0, 10); +const ausText = (t) => new Date(t + "T12:00:00Z"); +const plusTage = (d, n) => { const k = new Date(d); k.setUTCDate(k.getUTCDate() + n); return k; }; +const tagImMonat = (j, m, tag) => new Date(Date.UTC(j, m, tag, 12)); +const letzterImMonat = (j, m) => new Date(Date.UTC(j, m + 1, 0, 12)).getUTCDate(); + +const WOCHENTAGE = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"]; +const ORDNUNG = ["", "ersten", "zweiten", "dritten", "vierten", "fünften"]; + +/** Was die Regel eines Starttags über den Monat aussagt: das wievielte + * Vorkommen dieses Wochentags es ist, und ob es das letzte im Monat + * ist. Beides braucht sowohl die Rechnung als auch der Klartext. */ +function monatslage(startTag) { + const d = ausText(startTag); + const j = d.getUTCFullYear(), m = d.getUTCMonth(), tag = d.getUTCDate(); + return { + jahr: j, monat: m, tag, wochentag: d.getUTCDay(), + nter: Math.ceil(tag / 7), + letzterWochentag: tag + 7 > letzterImMonat(j, m), + letzterTag: tag === letzterImMonat(j, m), + }; +} + +/** Alle Tage, an denen diese Serie zwischen `von` und `bis` stattfindet + * (beide einschliesslich), als 'JJJJ-MM-TT'. + * + * Kein Termin liegt je vor dem Starttag oder nach `ende_tag` -- diese + * beiden Grenzen werden hier durchgesetzt und nicht beim Aufrufer: + * Eine Grenze, die jeder Aufrufer selbst ziehen muss, wird irgendwo + * vergessen. */ +export function serienTage(serie, von, bis, grenze = GRENZE) { + const raus = []; + if (!serie?.start_tag || !TAKTE.includes(serie.takt)) return raus; + + /* Der frühere der beiden Endpunkte gewinnt, der spätere der beiden + Anfänge -- so bleibt der Zeitraum immer innerhalb der Serie. */ + const ende = serie.ende_tag && serie.ende_tag < bis ? serie.ende_tag : bis; + const anfang = von > serie.start_tag ? von : serie.start_tag; + if (anfang > ende) return raus; + + /* --- Takte mit festem Tagesabstand -------------------------------- */ + const schritt = { taeglich: 1, werktags: 1, woechentlich: 7, zweiwoechentlich: 14 }[serie.takt]; + if (schritt) { + const start = ausText(serie.start_tag); + /* Vorspulen statt Tag für Tag zählen: Eine Serie, die vor drei + Jahren begann, soll nicht tausend Schleifendurchläufe kosten. */ + const abstand = Math.round((ausText(anfang) - start) / 86400000); + let d = abstand > 0 ? plusTage(start, Math.floor(abstand / schritt) * schritt) : start; + while (raus.length < grenze) { + const t = alsText(d); + if (t > ende) break; + /* "Werktags" ist täglich ohne Samstag und Sonntag. Ein Wochenende + wird ausgelassen, nicht verschoben -- ein Montagstermin, der + plötzlich am Sonntag steht, wäre schlimmer als keiner. */ + const werktag = d.getUTCDay() >= 1 && d.getUTCDay() <= 5; + if (t >= anfang && (serie.takt !== "werktags" || werktag)) raus.push(t); + d = plusTage(d, schritt); + } + return raus; + } + + /* --- Takte, die in Monaten oder Jahren springen -------------------- */ + const lage = monatslage(serie.start_tag); + const schrittM = serie.takt === "jaehrlich" ? 12 : 1; + const vonD = ausText(anfang); + const abstandM = (vonD.getUTCFullYear() - lage.jahr) * 12 + (vonD.getUTCMonth() - lage.monat); + let i = abstandM > 0 ? Math.floor(abstandM / schrittM) : 0; + + for (let n = 0; raus.length < grenze && n < 700; n++) { + const versatz = (i + n) * schrittM; + const j = lage.jahr + Math.floor((lage.monat + versatz) / 12); + const m = (lage.monat + versatz) % 12; + const letzter = letzterImMonat(j, m); + + /* Sind wir am Ende vorbei? Der Erste des Monats ist der früheste + Tag, den dieser Durchlauf noch liefern könnte. */ + if (alsText(tagImMonat(j, m, 1)) > ende) break; + + let tag = null; + if (serie.takt === "monatlich_letzter") { + tag = letzter; + } else if (serie.takt === "monatlich_wochentag") { + if (lage.letzterWochentag) { + /* "Jeden letzten Freitag im Monat" -- rückwärts vom Monatsende. + Vorwärts zu zählen wäre in Monaten mit fünf Freitagen um eine + Woche daneben. */ + const letzterD = tagImMonat(j, m, letzter); + tag = letzter - ((letzterD.getUTCDay() - lage.wochentag + 7) % 7); + } else { + const erster = tagImMonat(j, m, 1); + const z = 1 + ((lage.wochentag - erster.getUTCDay() + 7) % 7) + (lage.nter - 1) * 7; + if (z <= letzter) tag = z; + } + } else { + /* Gleiches Datum. Monate, die diesen Tag nicht haben, werden + AUSGELASSEN und nicht auf den 28. vorgezogen: "am 31." heisst + am 31. Wer den Monatsletzten will, wählt genau das -- die + Auswahl im Formular bietet beides getrennt an, damit hier + nichts geraten werden muss. */ + if (lage.tag <= letzter) tag = lage.tag; + } + + if (!tag) continue; + const t = alsText(tagImMonat(j, m, tag)); + if (t > ende) break; + if (t >= anfang) raus.push(t); + } + return raus; +} + +/** Der Takt in einem Satz, den man vorlesen kann. Steht im Formular als + * Vorschau und in der Liste der Wiederholungen -- wer eine Regel + * abstellen will, muss sie zuerst wiedererkennen. */ +export function taktText(serie) { + const l = monatslage(serie.start_tag); + const wt = WOCHENTAGE[l.wochentag]; + switch (serie.takt) { + case "taeglich": return "jeden Tag"; + case "werktags": return "jeden Werktag (Mo–Fr)"; + case "woechentlich": return `jeden ${wt}`; + case "zweiwoechentlich": return `alle zwei Wochen, ${wt}s`; + case "monatlich_datum": return `jeden Monat am ${l.tag}.`; + case "monatlich_letzter": return "jeden Monat am letzten Tag"; + case "monatlich_wochentag": + return l.letzterWochentag + ? `jeden Monat am letzten ${wt}` + : `jeden Monat am ${ORDNUNG[l.nter]} ${wt}`; + case "jaehrlich": + return `jedes Jahr am ${l.tag}. ${["Januar", "Februar", "März", "April", "Mai", "Juni", + "Juli", "August", "September", "Oktober", "November", "Dezember"][l.monat]}`; + default: return serie.takt; + } +} + +/** Der ganze Satz, inklusive Uhrzeit und Ende. */ +export function serienText(serie) { + const bis = serie.ende_tag + ? ` bis zum ${serie.ende_tag.slice(8)}.${serie.ende_tag.slice(5, 7)}.${serie.ende_tag.slice(0, 4)}` + : ", bis du sie abstellst"; + return `${taktText(serie)} um ${serie.uhrzeit} Uhr${bis}`; +} + +/* ---------- Der Nachfüller ------------------------------------------------ + Legt fehlende Ausprägungen an. Idempotent: Was schon da ist oder + ausdrücklich ausgelassen wurde, bleibt unangetastet. */ + +export function serieNachfuellen(serieId) { + const d = db(); + const s = d.prepare("SELECT * FROM termin_serien WHERE id = ?").get(serieId); + if (!s || !s.aktiv) return 0; + + const von = heuteLokal(); + const bis = tagLokal(HORIZONT_TAGE); + const tage = serienTage(s, von, bis); + if (!tage.length) return 0; + + /* Was es schon gibt und was ausdrücklich ausgelassen wurde -- beides + in einem Rutsch, nicht je Tag einzeln abgefragt. */ + const da = new Set(d.prepare( + "SELECT serie_tag FROM termine WHERE serie_id = ? AND serie_tag >= ?") + .all(s.id, von).map((r) => r.serie_tag)); + const aus = new Set(d.prepare( + "SELECT tag FROM termin_serien_aus WHERE serie_id = ?") + .all(s.id).map((r) => r.tag)); + + const einfuegen = d.prepare(` + INSERT INTO termine + (titel, beschreibung, art, beginn, dauer_min, ort, creator_id, teilnehmer_id, + erledigt, erstellt, erstellt_von, serie_id, serie_tag, serie_beruehrt) + VALUES (?,?,?,?,?,?,?,?,0,?,?,?,?,0)`); + + let neu = 0; + const stempel = jetztStempel(); + for (const tag of tage) { + if (da.has(tag) || aus.has(tag)) continue; + einfuegen.run(s.titel, s.beschreibung, s.art, `${tag}T${s.uhrzeit}`, s.dauer_min, + s.ort, s.creator_id, s.teilnehmer_id, stempel, s.erstellt_von, s.id, tag); + neu++; + } + return neu; +} + +/* Wann zuletzt über alle Serien nachgelegt wurde. Der Kalender wird oft + geöffnet; ohne diese Bremse liefe die Rechnung bei jedem Aufruf, + obwohl sich frühestens am nächsten Tag etwas ändern kann. */ +let letzterLauf = 0; +const BREMSE_MS = 5 * 60_000; + +export function nachfuellenAlle(erzwingen = false) { + const jetzt = Date.now(); + if (!erzwingen && jetzt - letzterLauf < BREMSE_MS) return 0; + letzterLauf = jetzt; + let neu = 0; + try { + for (const s of db().prepare("SELECT id FROM termin_serien WHERE aktiv = 1").all()) { + neu += serieNachfuellen(s.id); + } + } catch (fehler) { + console.error("[workspace] Wiederholungen nachfüllen:", fehler?.message); + } + return neu; +} + +/** Beim Serverstart einmal auffüllen -- damit die Termine auch dann + * bereitliegen, wenn an diesem Tag niemand den Kalender öffnet, aber + * jemand die Startseite oder die Call-Liste ansieht. */ +export function serienStarten() { + try { + const neu = nachfuellenAlle(true); + if (neu) console.log(`[workspace] ${neu} wiederkehrende Termine ergänzt.`); + } catch (fehler) { + console.error("[workspace] Wiederholungen beim Start:", fehler?.message); + } +} + +/** Räumt die Zukunft einer Serie ab: alles, was noch bevorsteht und + * niemand angefasst hat. Berührte und vergangene Termine bleiben -- + * ausdrücklicher Wunsch: was geleistet oder bearbeitet wurde, wird + * nicht weggeräumt. */ +function zukunftEntfernen(serieId) { + return db().prepare(` + DELETE FROM termine + WHERE serie_id = ? AND serie_beruehrt = 0 AND erledigt = 0 AND beginn > ?`) + .run(serieId, jetztLokal()).changes ?? 0; +} + +/* ---------- Prüfen -------------------------------------------------------- */ + +export function serienPruefen(körper, { neu }) { + const fehler = []; + const aus = {}; + + if (neu || körper.titel !== undefined) { + const t = String(körper.titel ?? "").trim(); + if (t.length < 2) fehler.push("Titel fehlt."); + else if (t.length > TITEL_MAX) fehler.push("Titel ist zu lang."); + else aus.titel = t; + } + if (neu || körper.takt !== undefined) { + if (!TAKTE.includes(körper.takt)) fehler.push("Unbekannter Rhythmus."); + else aus.takt = körper.takt; + } + if (neu || körper.start_tag !== undefined) { + const t = String(körper.start_tag ?? "").trim(); + if (!/^\d{4}-\d{2}-\d{2}$/.test(t) || Number.isNaN(Date.parse(t + "T12:00:00Z"))) { + fehler.push("Startdatum fehlt oder ist ungültig."); + } else aus.start_tag = t; + } + if (neu || körper.uhrzeit !== undefined) { + const u = String(körper.uhrzeit ?? "").trim(); + if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(u)) fehler.push("Uhrzeit fehlt oder ist ungültig."); + else aus.uhrzeit = u; + } + if (körper.ende_tag !== undefined) { + const t = String(körper.ende_tag ?? "").trim(); + if (!t) aus.ende_tag = null; + else if (!/^\d{4}-\d{2}-\d{2}$/.test(t)) fehler.push("Enddatum ist ungültig."); + else aus.ende_tag = t; + } + if (körper.art !== undefined) { + if (!ARTEN.includes(körper.art)) fehler.push("Unbekannte Art."); + else aus.art = körper.art; + } + if (körper.dauer_min !== undefined) { + const d = Number(körper.dauer_min); + if (!Number.isInteger(d) || d < 5 || d > 24 * 60) { + fehler.push("Dauer muss zwischen 5 und 1440 Minuten liegen."); + } else aus.dauer_min = d; + } + if (körper.ort !== undefined) { + const o = String(körper.ort ?? "").trim(); + if (o.length > ORT_MAX) fehler.push("Ort/Link ist zu lang."); + else aus.ort = o || null; + } + if (körper.beschreibung !== undefined) { + const t = String(körper.beschreibung ?? "").trim(); + if (t.length > TEXT_MAX) fehler.push("Beschreibung ist zu lang."); + else aus.beschreibung = t || null; + } + for (const feld of ["creator_id", "teilnehmer_id"]) { + if (körper[feld] === undefined) continue; + const w = körper[feld]; + if (w === null || w === "") { aus[feld] = null; continue; } + const z = Number(w); + if (!Number.isInteger(z) || z < 1) fehler.push("Ungültige Zuordnung."); + else aus[feld] = z; + } + + /* Ein Ende vor dem Anfang ergäbe eine Serie ohne einen einzigen + Termin -- sie stünde in der Liste und täte nichts. */ + const start = aus.start_tag ?? körper.start_tag; + if (aus.ende_tag && start && aus.ende_tag < start) { + fehler.push("Das Ende liegt vor dem Start."); + } + return { aus, fehler }; +} + +/** Legt eine Serie an und füllt sie sofort auf. Wird auch vom + * Kalender-Router benutzt, wenn im Terminformular ein Rhythmus gewählt + * wurde -- ein Weg, eine Prüfung, ein Protokolleintrag. */ +export function serieAnlegen(aus, person) { + const d = db(); + const { lastInsertRowid } = d.prepare(` + INSERT INTO termin_serien + (titel, beschreibung, art, takt, start_tag, uhrzeit, ende_tag, dauer_min, ort, + creator_id, teilnehmer_id, aktiv, erstellt, erstellt_von) + VALUES (?,?,?,?,?,?,?,?,?,?,?,1,?,?)`).run( + aus.titel, aus.beschreibung ?? null, aus.art ?? "termin", aus.takt, aus.start_tag, + aus.uhrzeit, aus.ende_tag ?? null, aus.dauer_min ?? 30, aus.ort ?? null, + aus.creator_id ?? null, aus.teilnehmer_id ?? null, jetztStempel(), person.id); + + const id = Number(lastInsertRowid); + const angelegt = serieNachfuellen(id); + protokolliere("serie_angelegt", { + personId: person.id, rolle: person.rolle, ip: null, + detail: `#${id} ${aus.titel} (${aus.takt}, ${angelegt} Termine)`.slice(0, 120), + }); + return { id, angelegt }; +} + +/** Zuordnung erzwingen für alle, die nicht Leitung sind: Sie legen nur + * für sich selbst an. Dieselbe Regel wie beim einzelnen Termin -- sonst + * wäre die Wiederholung ein Weg, sie zu umgehen. */ +export function zuordnungErzwingen(aus, person) { + if (istLeitung(person)) return; + aus.creator_id = person.rolle === "creator" ? person.id : null; + aus.teilnehmer_id = person.id; +} + +/* ---------- Schranken ----------------------------------------------------- */ + +function angemeldet(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + req.person = person; + next(); +} + +function gleicheHerkunft(req, res, next) { + const herkunft = req.get("origin"); + if (!herkunft) return next(); + let erlaubt; + try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; } + if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" }); + next(); +} + +serienRouter.use("/workspace/api/serien", angemeldet); + +/** Die Serie holen, die diese Person sehen darf. Gibt es sie nicht oder + * darf sie diese Person nicht sehen, ist die Antwort dieselbe (404) -- + * sonst verriete ein 403, dass es die Serie gibt. */ +function serieHolen(person, id) { + const { wo, werte } = termineSichtbar(person, "s"); + return db().prepare(`SELECT s.* FROM termin_serien s WHERE ${wo} AND s.id = ?`) + .get(...werte, id); +} + +/* Ändern und Abstellen darf die Leitung -- und wer die Serie angelegt + hat. Sonst könnte ein Creator einen Wochen-Call abstellen, den das + Management angesetzt hat. Gleiche Regel wie beim Löschen eines + einzelnen Termins. */ +const darfAendern = (person, serie) => istLeitung(person) || serie.erstellt_von === person.id; + +/* ---------- Lesen ---------------------------------------------------------- */ + +serienRouter.get("/workspace/api/serien", (req, res) => { + try { + nachfuellenAlle(); + const person = req.sicht || req.person; + const { wo, werte } = termineSichtbar(person, "s"); + const roh = db().prepare(` + SELECT s.*, pc.name AS creator_name, pt.name AS teilnehmer_name + FROM termin_serien s + LEFT JOIN personen pc ON pc.id = s.creator_id + LEFT JOIN personen pt ON pt.id = s.teilnehmer_id + WHERE ${wo} + ORDER BY s.aktiv DESC, s.erstellt DESC`).all(...werte); + + const heute = heuteLokal(); + const serien = roh.map((s) => { + /* Der nächste Termin wird gerechnet, nicht aus der Tabelle + gelesen: Eine abgestellte Serie hat dort keine Zukunft mehr, + soll aber trotzdem zeigen können, wann sie wieder liefe. */ + const naechste = s.aktiv ? serienTage(s, heute, tagLokal(HORIZONT_TAGE), 1) : []; + return { + ...s, + satz: serienText(s), + naechster: naechste[0] ? `${naechste[0]}T${s.uhrzeit}` : null, + darf_aendern: darfAendern(req.person, s) ? 1 : 0, + }; + }); + res.json({ serien }); + } catch (fehler) { + console.error("[workspace] Serien lesen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ---------- Anlegen -------------------------------------------------------- */ + +serienRouter.post("/workspace/api/serien", gleicheHerkunft, (req, res) => { + try { + const { aus, fehler } = serienPruefen(req.body || {}, { neu: true }); + if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") }); + + zuordnungErzwingen(aus, req.person); + for (const feld of ["creator_id", "teilnehmer_id"]) { + if (aus[feld] && !db().prepare("SELECT 1 FROM personen WHERE id = ? AND aktiv = 1").get(aus[feld])) { + return res.status(400).json({ fehler: "Zugeordnete Person gibt es nicht." }); + } + } + const { id, angelegt } = serieAnlegen(aus, req.person); + res.status(201).json({ id, angelegt }); + } catch (fehler) { + console.error("[workspace] Serie anlegen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ---------- Ändern, Abstellen, Wieder anstellen ---------------------------- */ + +serienRouter.patch("/workspace/api/serien/:id", gleicheHerkunft, (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + const serie = serieHolen(req.person, id); + if (!serie) return res.status(404).json({ fehler: "nicht_gefunden" }); + if (!darfAendern(req.person, serie)) return res.status(403).json({ fehler: "nicht_erlaubt" }); + + const { aus, fehler } = serienPruefen(req.body || {}, { neu: false }); + if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") }); + if (!istLeitung(req.person)) { delete aus.creator_id; delete aus.teilnehmer_id; } + if (req.body?.aktiv !== undefined) aus.aktiv = req.body.aktiv ? 1 : 0; + + /* Auch gegen den BESTEHENDEN Start prüfen, nicht nur gegen einen + mitgeschickten: Wer nur ein Enddatum nachreicht, könnte es sonst + vor den Anfang legen. */ + if (aus.ende_tag && aus.ende_tag < (aus.start_tag ?? serie.start_tag)) { + return res.status(400).json({ fehler: "Das Ende liegt vor dem Start." }); + } + + const felder = Object.keys(aus); + if (!felder.length) return res.status(400).json({ fehler: "nichts_zu_aendern" }); + + db().prepare(`UPDATE termin_serien SET ${felder.map((f) => `${f} = ?`).join(", ")}, + geaendert = ? WHERE id = ?`) + .run(...felder.map((f) => aus[f]), jetztStempel(), id); + + /* Die noch unberührte Zukunft wird neu gebaut. Sie einfach stehen zu + lassen wäre der stille Fehler: Man verschiebt den Wochen-Call auf + 19:00, die nächsten zwölf Termine stünden weiter auf 18:00, und + auffallen würde es erst beim ersten verpassten Call. */ + const weg = zukunftEntfernen(id); + const neu = aus.aktiv === 0 ? 0 : serieNachfuellen(id); + + protokolliere(aus.aktiv === 0 ? "serie_abgestellt" + : aus.aktiv === 1 ? "serie_angestellt" : "serie_geaendert", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${id} ${felder.join(",")} (−${weg}/+${neu})`.slice(0, 120), + }); + res.json({ ok: true, entfernt: weg, angelegt: neu }); + } catch (fehler) { + console.error("[workspace] Serie ändern:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ---------- Löschen -------------------------------------------------------- */ + +serienRouter.delete("/workspace/api/serien/:id", gleicheHerkunft, (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + const serie = serieHolen(req.person, id); + if (!serie) return res.status(404).json({ fehler: "nicht_gefunden" }); + if (!darfAendern(req.person, serie)) return res.status(403).json({ fehler: "nicht_erlaubt" }); + + /* Erst die unberührte Zukunft, dann die Regel. Was war und was + jemand angefasst hat, bleibt als ganz normaler Termin stehen -- + serie_id fällt dabei auf NULL (ON DELETE SET NULL). */ + const weg = zukunftEntfernen(id); + db().prepare("DELETE FROM termin_serien WHERE id = ?").run(id); + + protokolliere("serie_geloescht", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${id} ${serie.titel} (−${weg})`.slice(0, 120), + }); + res.json({ ok: true, entfernt: weg }); + } catch (fehler) { + console.error("[workspace] Serie löschen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); diff --git a/server/workspace.js b/server/workspace.js index 529d64eb..0af60e19 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -179,6 +179,21 @@ function umstellungen(d) { ["personen", "instagram", "TEXT"], ["personen", "youtube", "TEXT"], ["personen", "twitch", "TEXT"], + + /* ---- Wiederkehrende Termine (02.09.2026) ---- + Drei Spalten an `termine`, damit eine Ausprägung weiss, woher sie + stammt: + serie_id die Regel, aus der sie entstanden ist + serie_tag der geplante Tag -- daran erkennt der Nachfüller, + dass es diese Ausprägung schon gibt, und legt sie + kein zweites Mal an + serie_beruehrt jemand hat sie von Hand angefasst (verschoben, + umbenannt, abgehakt). Solche Termine bleiben beim + Abstellen der Serie stehen -- was jemand + bearbeitet hat, wird ihm nicht weggeräumt. */ + ["termine", "serie_id", "INTEGER REFERENCES termin_serien(id) ON DELETE SET NULL"], + ["termine", "serie_tag", "TEXT"], + ["termine", "serie_beruehrt", "INTEGER NOT NULL DEFAULT 0"], ]) { try { const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name); @@ -191,6 +206,17 @@ function umstellungen(d) { } } + /* Der Nachfüller fragt bei jedem Lauf "welche Ausprägungen dieser + Serie gibt es schon?". Ohne diesen Verbund-Index liest SQLite dafür + die ganze Termintabelle. Er steht hier unten und nicht oben im + Bauplan, weil die beiden Spalten erst durch die Schleife darüber + entstehen -- oben gäbe es sie beim ersten Start noch nicht. */ + try { + d.exec("CREATE INDEX IF NOT EXISTS idx_termine_serie ON termine (serie_id, serie_tag)"); + } catch (fehler) { + console.error("[workspace] Index idx_termine_serie:", fehler?.message); + } + /* Content-Saeulen: die drei bis fuenf Themen, aus denen der Kanal besteht. Aus der Recherche: 3-5 Saeulen nach der 70/20/10-Regel (70 % Wert, 20 % Community, 10 % Eigenwerbung); eine Saeule wird @@ -435,6 +461,61 @@ export function db() { ); CREATE INDEX IF NOT EXISTS idx_termine_beginn ON termine (beginn); + /* Wiederkehrende Termine (02.09.2026) -------------------------------- + + Eine Serie ist eine REGEL, kein Termin: "jeden Dienstag um 18:00 + Uhr, bis ich es abstelle". Aus ihr entstehen echte Zeilen in + der Tabelle termine. + + Warum echte Zeilen und nicht bloss gerechnete Ausprägungen: + ACHT andere Stellen lesen termine direkt -- Calls, Protokolle, + Berichte, Hinweise/Ampel, Suche, Aufgaben ("nächster Termin"), + Übersicht, Startseite. Eine nur im Kalender gerechnete + Wiederholung wäre in all diesen Ansichten unsichtbar gewesen, + ohne dass es jemandem auffällt, und beim geplanten ICS-Abo + ebenfalls. Der Nachfüller hält stattdessen einen Horizont echt + gefüllt -- damit sieht jedes Modul die Wiederholung, ohne dass + dort eine einzige Zeile geändert werden musste. + + uhrzeit statt beginn: Eine Regel kennt keinen Zeitpunkt, nur + eine Uhrzeit und einen Takt. Gerechnet wird auf reinen + Datumstexten -- deshalb bleibt 18:00 auch über die + Zeitumstellung hinweg 18:00 und wandert nicht auf 17:00. */ + CREATE TABLE IF NOT EXISTS termin_serien ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + titel TEXT NOT NULL, + beschreibung TEXT, + art TEXT NOT NULL DEFAULT 'termin' + CHECK (art IN ('termin','call','review')), + takt TEXT NOT NULL + CHECK (takt IN ('taeglich','werktags','woechentlich', + 'zweiwoechentlich','monatlich_datum', + 'monatlich_letzter','monatlich_wochentag', + 'jaehrlich')), + start_tag TEXT NOT NULL, + uhrzeit TEXT NOT NULL, + ende_tag TEXT, + dauer_min INTEGER NOT NULL DEFAULT 30, + ort TEXT, + creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, + teilnehmer_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, + aktiv INTEGER NOT NULL DEFAULT 1, + erstellt TEXT NOT NULL, + erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL, + geaendert TEXT + ); + CREATE INDEX IF NOT EXISTS idx_serien_aktiv ON termin_serien (aktiv); + + /* Ausgelassene Tage einer Serie. Wer eine einzelne Ausprägung + löscht, meint "dieses eine Mal nicht" -- ohne diese Tabelle + legte der Nachfüller sie beim nächsten Aufruf wieder an, und der + gelöschte Termin wäre kommentarlos zurück. */ + CREATE TABLE IF NOT EXISTS termin_serien_aus ( + serie_id INTEGER NOT NULL REFERENCES termin_serien(id) ON DELETE CASCADE, + tag TEXT NOT NULL, + PRIMARY KEY (serie_id, tag) + ); + /* Dateiablage. Auf der Platte traegt jede Datei einen erzeugten Zufallsnamen (name_datei), der Originalname steht nur hier. Dadurch kann ein Dateiname weder Pfade verlassen noch etwas @@ -1069,6 +1150,34 @@ export function betreutWo(person, spalte) { return { wo: `${spalte} IN (${ids.map(() => "?").join(",")})`, werte: ids }; } +/* Wer sieht welchen Termin -- und damit auch: welche Wiederholung. + + NUR DogFather sieht alles (01.09.2026). Vorher stand hier istLeitung() + -- damit sah auch jeder Manager jeden Creator. Ein Manager faellt + jetzt in dieselbe Regel wie ein Scout: nur die Creator, die ihm + zugeteilt sind. Ausdruecklicher Wunsch: "NUR DIE ROLLE DOGFATHER SOLL + WIRKLICH ALLEINE ALLES SEHEN." + + Geaendert wird ausschliesslich, wer was SIEHT. Was ein Manager DARF + (freigeben, aendern, Personen verwalten), haengt weiterhin an + istLeitung und bleibt unveraendert -- sonst haette dieser eine Wunsch + stillschweigend seine halben Rechte mitgenommen. + + Der Praefix ist der Tabellenname im jeweiligen SQL: "t" fuer termine, + "s" fuer termin_serien. Diese Regel steht bewusst nur EINMAL: Eine + zweite, fast gleiche Fassung fuer die Serien waere frueher oder + spaeter auseinandergelaufen -- und dann haette eine Wiederholung + jemandem etwas gezeigt, was der einzelne Termin ihm verbirgt. */ +export function termineSichtbar(person, praefix = "t") { + if (istDogFather(person)) return { wo: "1=1", werte: [] }; + const p = praefix; + const eigen = `(${p}.creator_id = ? OR ${p}.teilnehmer_id = ? OR ${p}.erstellt_von = ?)`; + const werte = [person.id, person.id, person.id]; + const b = betreutWo(person, `${p}.creator_id`); + if (!b) return { wo: eigen, werte }; + return { wo: `(${eigen} OR ${b.wo})`, werte: [...werte, ...b.werte] }; +} + /* ===================================================================== DIE SICHT EINES ANDEREN — nur fuer DogFather. diff --git a/workspace/assets/css/kalender.css b/workspace/assets/css/kalender.css index 6de949e3..1972043c 100644 --- a/workspace/assets/css/kalender.css +++ b/workspace/assets/css/kalender.css @@ -544,6 +544,110 @@ .k-zeile { grid-template-columns: auto 1fr; } .k-zeile__knoepfe { grid-column: 1 / -1; } .k-anlassreihe { grid-template-columns: 1fr; } + .k-wdh { grid-template-columns: 1fr; } + .k-serienliste { grid-template-columns: 1fr; } +} + +/* =================================================================== + WIEDERHOLUNGEN (02.09.2026) + + Termine, die von allein weiterlaufen. Zwei Stellen: + * die Ende-Zeile im Formular samt Vorschausatz + * die Liste "Läuft von allein" über dem Kalender + + Gedeckt gehalten wie alles andere hier. Eine Automatik darf nicht + lauter sein als das, was ein Mensch eingetragen hat -- sie soll + auffindbar sein, nicht auffällig. + =================================================================== */ + +.k-wdh { + display: grid; + grid-template-columns: minmax(180px, 260px) 1fr; + align-items: end; + gap: 10px 18px; +} +.k-wdh__satz { + margin: 0 0 4px; + font-size: .84rem; line-height: 1.5; + color: var(--text-leise); +} +.k-wdh__satz:not(:empty)::before { + content: "↻"; + margin-right: 7px; + color: var(--akzent); +} + +/* Das Zeichen an einem einzelnen Termin. Klein, gedeckt, ohne eigenen + Platzanspruch -- der Titel bleibt die Hauptsache. */ +.k-wdh-zeichen { + margin-left: 5px; + font-size: .78em; + opacity: .65; + vertical-align: baseline; +} +/* In der Pille steht es VOR dem Titel (sonst schneidet die schmale + Monatszelle es ab) -- dort gehört der Abstand entsprechend nach + rechts. Die Pille kürzt mit text-overflow, nicht als Flexbox: Was + links steht, bleibt sichtbar. */ +.k-pille .k-wdh-zeichen { margin: 0 4px 0 0; } + +.k-serienliste { + display: grid; gap: 9px; + grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr)); + margin-top: 10px; +} + +.k-serie { + --sfarbe: var(--akzent); + position: relative; overflow: hidden; + padding: 13px 15px 12px; + border: 1px solid var(--rand); border-radius: var(--radius-klein); + background: var(--flaeche); +} +/* Der Farbstreifen links ist derselbe Gedanke wie in der Liste: Die + Farbe gehört zur Art des Termins, nicht zum Rang der Serie. */ +.k-serie::before { + content: ""; + position: absolute; inset: 0 auto 0 0; width: 3px; + background: var(--sfarbe); + opacity: .75; +} +/* Pausiert: blasser UND gestrichelt. Nur blasser wäre im Kontrastmodus + nicht von "läuft" zu unterscheiden. */ +.k-serie[data-aktiv="nein"] { opacity: .72; border-style: dashed; } +.k-serie[data-aktiv="nein"]::before { opacity: .35; } + +.k-serie__kopf { + display: flex; align-items: center; flex-wrap: wrap; gap: 8px; + margin-bottom: 6px; +} +.k-serie__titel { font-weight: 600; color: var(--text); } +.k-serie__art { + padding: 2px 9px; border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--sfarbe) 32%, transparent); + color: var(--sfarbe); + font-size: .68rem; letter-spacing: .08em; text-transform: uppercase; +} +.k-serie__zustand { + margin-left: auto; + font-size: .7rem; letter-spacing: .09em; text-transform: uppercase; + color: var(--text-still); +} +.k-serie[data-aktiv="ja"] .k-serie__zustand { color: #6fbf7a; } + +.k-serie__satz { + margin: 0; + font-size: .86rem; line-height: 1.5; + color: var(--text-leise); +} +.k-serie__naechster { + margin: 4px 0 0; + font-size: .78rem; color: var(--text-still); + font-variant-numeric: tabular-nums; +} +.k-serie__knoepfe { + display: flex; flex-wrap: wrap; gap: 7px; + margin-top: 10px; } @media (prefers-reduced-motion: reduce) { @@ -558,4 +662,9 @@ .k-zeile::before, .k-anlasskarte::before { background: CanvasText; } .k-pille, .k-anlass { border: 1px solid CanvasText; } .k-strahl__saeule { background: CanvasText; } + .k-serie::before { background: CanvasText; } + .k-serie { border: 1px solid CanvasText; } + /* Deckkraft trägt im Kontrastmodus nicht -- der gestrichelte Rand + bleibt das Unterscheidungsmerkmal einer pausierten Serie. */ + .k-serie[data-aktiv="nein"] { opacity: 1; border-style: dashed; } } diff --git a/workspace/assets/js/kalender.js b/workspace/assets/js/kalender.js index 90b620be..32ba70ad 100644 --- a/workspace/assets/js/kalender.js +++ b/workspace/assets/js/kalender.js @@ -63,9 +63,18 @@ const ANLASS_FARBE = '#d8a13a'; const WOCHENTAGE_KURZ = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; + /* Sonntag zuerst -- das ist die Zählung von getUTCDay(), nicht die + deutsche Wochenordnung. Wer hier Montag an den Anfang setzt, + verschiebt jeden Wochentagsnamen um eins. */ + const WOCHENTAGE_LANG = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', + 'Donnerstag', 'Freitag', 'Samstag']; + const MONATE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', + 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; + const ORDNUNG = ['', 'ersten', 'zweiten', 'dritten', 'vierten', 'fünften']; let ich = null; let daten = null; + let serien = []; let ansicht = 'monat'; let anker = heuteText(); // Datum, um das herum angezeigt wird const zeigen = { call: true, termin: true, review: true, frist: true }; @@ -126,6 +135,8 @@ } const fmt = (o) => new Intl.DateTimeFormat('de-DE', { timeZone: 'UTC', ...o }); + const tagLang = (t) => + fmt({ weekday: 'long', day: '2-digit', month: 'long' }).format(ausText(t)); /* ---------- Anlässe ------------------------------------------------------ */ @@ -265,6 +276,7 @@ ort: t.ort, beschreibung: t.beschreibung, creator_name: t.creator_name, teilnehmer_name: t.teilnehmer_name, erstellt_von: t.erstellt_von, dauer_min: t.dauer_min, + serie_id: t.serie_id || null, }); } for (const f of roh.fristen || []) { @@ -312,12 +324,31 @@ 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 : ''}`; + k.title = `${e.titel}${e.zusatz ? ' — ' + e.zusatz : ''}` + + (e.serie_id ? ' — wiederholt sich' : ''); if (e.uhr) k.append(el('span', 'k-pille__zeit', e.uhr)); + /* Das Zeichen steht im Raster VOR dem Titel, nicht dahinter: Eine + Zelle im Monatsraster ist gut 150 px breit, der Titel wird dort + abgeschnitten -- ein Zeichen am Ende wäre genau in der Ansicht + unsichtbar, in der man den Rhythmus erkennen will. In der Liste + (siehe zeile()) ist Platz, dort steht es hinter dem Titel und + liest sich natürlicher. */ + if (e.serie_id) k.append(wiederholZeichen()); k.append(document.createTextNode(e.titel)); return k; } + /** Das Wiederhol-Zeichen. Einmal gebaut, überall gleich -- und mit + * einer Beschriftung für Vorleseprogramme, die das Zeichen selbst + * sonst als "Pfeil im Kreis" oder gar nicht vorlesen. */ + function wiederholZeichen() { + const s = el('span', 'k-wdh-zeichen', '↻'); + s.setAttribute('role', 'img'); + s.setAttribute('aria-label', 'wiederholt sich'); + s.title = 'Gehört zu einer Wiederholung'; + return s; + } + function anlassMarke(a) { const k = el('span', 'k-anlass' + (a.art === 'feiertag' ? ' feiertag' : ''), a.sym + ' ' + a.name); @@ -440,6 +471,174 @@ $('monat-block').hidden = false; } + /* ---------- Wiederholungen ------------------------------------------------ + Wunsch vom 02.09.2026: Termine, die "jede Woche, jeden Monat + automatisiert immer weiter laufen -- bis ich es selber deaktiviere". + + Zwei Teile: die Auswahl im Formular (was soll sich wiederholen) und + die Liste darunter (was läuft gerade und wie stelle ich es ab). + + Die Beschriftungen der Auswahl hängen vom GEWÄHLTEN DATUM ab, nicht + von abstrakten Wörtern: "jeden Dienstag" statt "wöchentlich", "jeden + Monat am 2." statt "monatlich". Wer "monatlich" liest, weiß nicht, + ob der 31. Januar im Februar ausfällt oder auf den 28. rutscht -- + hier steht es dran. Der Wortlaut ist derselbe wie auf dem Server + (workspace-serien.js, taktText); die Liste unten zeigt IMMER die + Fassung des Servers, damit im Zweifel eine von beiden gilt. */ + + function taktLage(tagText) { + const d = ausText(tagText); + const j = d.getUTCFullYear(), m = d.getUTCMonth(), tag = d.getUTCDate(); + const letzter = new Date(Date.UTC(j, m + 1, 0, 12)).getUTCDate(); + return { + tag, monat: m, wochentag: d.getUTCDay(), + nter: Math.ceil(tag / 7), + letzterWochentag: tag + 7 > letzter, + }; + } + + /** Die möglichen Rhythmen für ein bestimmtes Startdatum, jeweils mit + * dem Satz, der davon in der Vorschau steht. */ + function taktListe(tagText) { + const l = taktLage(tagText); + const wt = WOCHENTAGE_LANG[l.wochentag]; + return [ + { wert: 'taeglich', text: 'jeden Tag' }, + { wert: 'werktags', text: 'jeden Werktag (Mo–Fr)' }, + { wert: 'woechentlich', text: `jeden ${wt}` }, + { wert: 'zweiwoechentlich', text: `alle zwei Wochen, ${wt}s` }, + { + wert: 'monatlich_datum', + text: `jeden Monat am ${l.tag}.`, + /* Ehrlich bleiben: Ein 29./30./31. gibt es nicht in jedem Monat. + Ausgelassen wird er dann, nicht vorgezogen -- und das gehört + in die Auswahl, nicht in eine Fußnote, die niemand liest. */ + hinweis: l.tag > 28 ? ` (Monate ohne den ${l.tag}. fallen aus)` : '', + }, + { wert: 'monatlich_letzter', text: 'jeden Monat am letzten Tag' }, + { + wert: 'monatlich_wochentag', + text: l.letzterWochentag + ? `jeden Monat am letzten ${wt}` + : `jeden Monat am ${ORDNUNG[l.nter]} ${wt}`, + }, + { wert: 'jaehrlich', text: `jedes Jahr am ${l.tag}. ${MONATE[l.monat]}` }, + ]; + } + + /** Füllt die Auswahl neu -- bei jedem Datumswechsel, denn "jeden + * Dienstag" wird zu "jeden Mittwoch", sobald man das Datum ändert. + * Die getroffene Wahl bleibt dabei erhalten. */ + function taktAuswahlFuellen() { + const feld = $('f-takt'); + const tag = ($('f-beginn').value || '').slice(0, 10); + const gewaehlt = feld.value; + feld.replaceChildren(); + const leer = document.createElement('option'); + leer.value = ''; leer.textContent = 'Einmalig – wiederholt sich nicht'; + feld.append(leer); + if (!/^\d{4}-\d{2}-\d{2}$/.test(tag)) return; + for (const t of taktListe(tag)) { + const o = document.createElement('option'); + o.value = t.wert; + o.textContent = t.text + (t.hinweis || ''); + feld.append(o); + } + feld.value = gewaehlt; + /* War der alte Wert nicht mehr dabei, fällt die Auswahl auf + "Einmalig" zurück -- lieber nichts wiederholen als etwas + anderes als angezeigt. */ + if (feld.value !== gewaehlt) feld.value = ''; + wdhVorschau(); + } + + /** Der Satz unter der Auswahl: was gleich entsteht, in Worten. */ + function wdhVorschau() { + const takt = $('f-takt').value; + $('feld-wdh').hidden = !takt; + if (!takt) { $('wdh-satz').textContent = ''; return; } + const tag = ($('f-beginn').value || '').slice(0, 10); + const uhr = ($('f-beginn').value || '').slice(11, 16); + const eintrag = taktListe(tag).find((t) => t.wert === takt); + const ende = $('f-ende').value; + $('wdh-satz').textContent = 'Läuft ' + (eintrag ? eintrag.text : takt) + + (uhr ? ` um ${uhr} Uhr` : '') + + (ende ? ` bis zum ${tagLang(ende)}` : ', bis du sie abstellst') + + '.'; + } + + /* ---------- Die Liste der laufenden Wiederholungen ------------------------ */ + + async function serienLaden() { + try { + const a = await hole('/workspace/api/serien'); + if (!a.ok) { serien = []; return; } + serien = (await a.json()).serien || []; + } catch { serien = []; } + } + + function baueSerien() { + const ziel = $('serien'); + ziel.replaceChildren(); + $('serien-block').hidden = !serien.length; + if (!serien.length) return; + + for (const s of serien) { + const k = el('div', 'k-serie'); + k.dataset.aktiv = s.aktiv ? 'ja' : 'nein'; + const farbe = ARTFARBE[s.art] || ARTFARBE.termin; + k.style.setProperty('--sfarbe', farbe); + k.style.setProperty('--ton', farbe); + + const kopf = el('div', 'k-serie__kopf'); + kopf.append(el('span', 'k-serie__titel', s.titel)); + kopf.append(el('span', 'k-serie__art', ARTNAME[s.art] || s.art)); + /* Der Zustand als Wort, nicht nur als Farbe -- "pausiert" muss man + lesen können, ohne Farben unterscheiden zu müssen. */ + kopf.append(el('span', 'k-serie__zustand', s.aktiv ? 'läuft' : 'pausiert')); + k.append(kopf); + + /* Der Satz kommt vom Server. Bewusst nicht hier noch einmal + gebaut: Wenn zwei Stellen denselben Satz formulieren, sagen sie + irgendwann Verschiedenes -- und dann glaubt man der falschen. */ + k.append(el('p', 'k-serie__satz', s.satz)); + k.append(el('p', 'k-serie__naechster', s.naechster + ? 'Nächster Termin: ' + tagLang(s.naechster.slice(0, 10)) + + ', ' + s.naechster.slice(11, 16) + ' Uhr' + : s.aktiv ? 'Kein weiterer Termin – die Serie ist ausgelaufen.' + : 'Pausiert – es kommt nichts nach.')); + + if (s.darf_aendern) { + const knoepfe = el('div', 'k-serie__knoepfe'); + knoepfe.append(tuKnopf(s.aktiv ? 'abstellen' : 'wieder starten', async () => { + if (s.aktiv && !confirm( + 'Wiederholung „' + s.titel + '" abstellen?\n\n' + + 'Kommende Termine, die noch niemand angefasst hat, werden aus dem ' + + 'Kalender genommen. Vergangene und bearbeitete Termine bleiben stehen.\n\n' + + 'Du kannst sie jederzeit wieder starten.')) return; + const a = await hole('/workspace/api/serien/' + s.id, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ aktiv: s.aktiv ? 0 : 1 }), + }); + if (!a.ok) { melde('Umschalten ging nicht.'); return; } + await laden(); + })); + knoepfe.append(tuKnopf('löschen', async () => { + if (!confirm('Wiederholung „' + s.titel + '" ganz löschen?\n\n' + + 'Die Regel ist damit weg. Kommende, unangetastete Termine werden ' + + 'entfernt; vergangene und bearbeitete bleiben als normale Termine ' + + 'stehen.')) return; + const a = await hole('/workspace/api/serien/' + s.id, { method: 'DELETE' }); + if (!a.ok) { melde('Löschen ging nicht.'); return; } + await laden(); + })); + k.append(knoepfe); + } + ziel.append(k); + } + } + /* ---------- Filter -------------------------------------------------------- */ function baueFilter() { @@ -570,7 +769,9 @@ k.style.setProperty('--ton', e.farbe); k.append(el('span', 'k-zeile__zeit', e.uhr ? e.uhr + ' Uhr' : '—')); - k.append(el('span', 'k-zeile__titel', e.titel)); + const titel = el('span', 'k-zeile__titel', e.titel); + if (e.serie_id) titel.append(wiederholZeichen()); + k.append(titel); const rechts = el('div', 'k-zeile__knoepfe'); rechts.append(el('span', 'k-zeile__art', ARTNAME[e.art] || e.art)); @@ -591,7 +792,16 @@ })); if (LEITUNG.has(ich.rolle) || e.erstellt_von === ich.id) { rechts.append(tuKnopf('löschen', async () => { - if (!confirm('Termin „' + e.titel + '" löschen?')) return; + /* Bei einem Serientermin muss unmissverständlich dastehen, + dass NUR dieser eine Tag entfällt. Sonst löscht jemand ihn + in der Annahme, die Wiederholung sei damit abgestellt -- + und wundert sich nächste Woche. */ + const frage = e.serie_id + ? 'Nur diesen einen Termin am ' + tagLang(e.tag) + ' streichen?\n\n' + + 'Die Wiederholung „' + e.titel + '" läuft weiter. Zum Abstellen ' + + 'den Schalter unter „Läuft von allein" benutzen.' + : 'Termin „' + e.titel + '" löschen?'; + if (!confirm(frage)) return; const a = await hole('/workspace/api/termine/' + e.id, { method: 'DELETE' }); if (!a.ok) { melde('Löschen ging nicht.'); return; } await laden(); @@ -765,8 +975,13 @@ if (!a.ok) { melde('Kalender konnte nicht geladen werden.'); return; } const roh = await a.json(); daten = { eintraege: vereinheitlichen(roh), heute: heuteText() }; + /* Die Wiederholungen NACH den Terminen holen: Der Serveraufruf + oben legt fehlende Ausprägungen an, und erst danach stimmt der + "nächste Termin" in der Liste darunter. */ + await serienLaden(); baueSaison(); baueMonatsanlaesse(); + baueSerien(); zeichnen(); /* Sprung vom Bericht -- erst NACH dem Zeichnen, vorher gibt es die Tagesbloecke nicht. Nur beim ersten Mal: Sonst reisst es die @@ -839,6 +1054,9 @@ if (!tag) d.setHours(18, 0, 0, 0); $('f-beginn').value = new Date(d.getTime() - d.getTimezoneOffset() * 60000) .toISOString().slice(0, 16); + /* Die Auswahl der Rhythmen hängt am Datum ("jeden Dienstag") und + muss deshalb bei jedem Öffnen neu beschriftet werden. */ + taktAuswahlFuellen(); umschalten(true); } @@ -849,9 +1067,21 @@ }; $('neu-oeffnen').addEventListener('click', () => formularOeffnen(null)); $('neu-abbrechen').addEventListener('click', () => { - $('neu').reset(); $('neu-fehler').textContent = ''; umschalten(false); + $('neu').reset(); + $('neu-fehler').textContent = ''; + /* reset() setzt die Auswahl auf ihren ersten Eintrag zurück, aber + nicht den Vorschausatz und die Ende-Zeile -- die blieben sonst + stehen und behaupteten beim nächsten Öffnen eine Wiederholung, + die gar nicht gewählt ist. */ + $('f-takt').value = ''; + wdhVorschau(); + umschalten(false); }); + $('f-beginn').addEventListener('change', taktAuswahlFuellen); + $('f-takt').addEventListener('change', wdhVorschau); + $('f-ende').addEventListener('change', wdhVorschau); + $('neu').addEventListener('submit', async (e) => { e.preventDefault(); $('neu-fehler').textContent = ''; @@ -869,6 +1099,16 @@ koerper.teilnehmer_id = wahl || null; koerper.creator_id = wahl || null; } + /* Mit Rhythmus entsteht keine einzelne Zeile, sondern eine Regel -- + der Server legt daraus die Termine an, den ersten eingeschlossen. + Deshalb wird hier nichts zusätzlich abgeschickt: Sonst stünde der + erste Termin doppelt im Kalender. */ + if ($('f-takt').value) { + koerper.wiederholung = { + takt: $('f-takt').value, + ende_tag: $('f-ende').value || null, + }; + } knopf.disabled = true; try { const a = await hole('/workspace/api/termine', { @@ -880,7 +1120,10 @@ $('neu-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.'; return; } - $('neu').reset(); umschalten(false); + $('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); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 58538a0c..c1a18dea 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -7,9 +7,9 @@ - - - + + +
@@ -152,9 +152,9 @@ - - - - + + + +