Phase 7: Benachrichtigungen und Termine

Terminkalender mit Serienterminen, Selbstanmeldung, Warteliste und
Anwesenheitsliste. Vier Zustellwege hinter Schnittstellen: echtes SMTP,
ntfy (selbst gehostet), Telegram und Web-Push. Kalenderabonnement als
ICS-Feed, digitaler Dienstausweis mit signiertem QR und Pruefseite.

Niemand wird ungefragt benachrichtigt: ein neuer Zustellweg hat eine leere
Ereignisliste, und der Seed legt bewusst keine an. Was das Netz der Wehr
verlaesst, bekommt nur die Kurzfassung ohne Namen und Details.

Kein CalDAV (Lesen genuegt, ICS kann jeder Kalender) und kein WhatsApp
(Verarbeitung ueber Meta, Kosten je Konversation, Telefonbuchabgleich) --
beides im README begruendet.

56 neue Unit-Tests, 17 neue E2E-Tests, Version 0.8.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-04 08:39:38 +02:00
co-authored by Claude Opus 5
parent c779266450
commit 0b8874da26
134 changed files with 9340 additions and 123 deletions
+191
View File
@@ -0,0 +1,191 @@
import { describe, expect, it } from "vitest";
import { Serienmuster } from "@prisma/client";
import { HOECHSTZAHL, serienlaenge, serientermine } from "@/lib/termin/serie";
/**
* Serientermine.
*
* Geprüft wird gegen den Kalender, nicht gegen die Implementierung: „monatlich"
* heißt derselbe Tag im nächsten Monat, und der 31. Januar landet im Februar
* auf dem 28. — nicht im März, wie es passiert, wenn man stur Monate addiert.
*/
const D = (jahr: number, monat: number, tag: number, stunde = 19) =>
new Date(Date.UTC(jahr, monat - 1, tag, stunde, 0, 0));
describe("serientermine", () => {
it("gibt bei KEINE genau den einen Termin zurück", () => {
const termine = serientermine({
beginn: D(2026, 3, 10),
muster: Serienmuster.KEINE,
bis: D(2027, 3, 10),
});
expect(termine).toHaveLength(1);
expect(termine[0]).toEqual(D(2026, 3, 10));
});
it("legt wöchentlich immer denselben Wochentag", () => {
const termine = serientermine({
beginn: D(2026, 3, 10), // Dienstag
muster: Serienmuster.WOECHENTLICH,
bis: D(2026, 4, 7),
});
expect(termine).toEqual([
D(2026, 3, 10),
D(2026, 3, 17),
D(2026, 3, 24),
D(2026, 3, 31),
D(2026, 4, 7),
]);
// Alle am selben Wochentag — das ist der Punkt einer Serie.
for (const termin of termine) {
expect(termin.getUTCDay()).toBe(D(2026, 3, 10).getUTCDay());
}
});
it("überspringt bei ZWEIWOECHENTLICH jede zweite Woche", () => {
const termine = serientermine({
beginn: D(2026, 3, 10),
muster: Serienmuster.ZWEIWOECHENTLICH,
bis: D(2026, 4, 30),
});
expect(termine).toEqual([D(2026, 3, 10), D(2026, 3, 24), D(2026, 4, 7), D(2026, 4, 21)]);
});
it("behält die Uhrzeit über die ganze Serie", () => {
const termine = serientermine({
beginn: D(2026, 3, 10, 19),
muster: Serienmuster.WOECHENTLICH,
bis: D(2026, 4, 30),
});
for (const termin of termine) {
expect(termin.getUTCHours()).toBe(19);
expect(termin.getUTCMinutes()).toBe(0);
}
});
it("rechnet monatlich auf dem Kalender, nicht in 30-Tage-Schritten", () => {
const termine = serientermine({
beginn: D(2026, 1, 15),
muster: Serienmuster.MONATLICH,
bis: D(2026, 5, 20),
});
expect(termine).toEqual([
D(2026, 1, 15),
D(2026, 2, 15),
D(2026, 3, 15),
D(2026, 4, 15),
D(2026, 5, 15),
]);
});
/**
* Der Fall, an dem jede naive Umsetzung scheitert: setMonth(+1) auf dem
* 31. Januar ergibt den 3. März, weil der Februar keinen 31. hat. Ein
* Mensch erwartet den letzten Februartag.
*/
it("schiebt den 31. nicht in den übernächsten Monat", () => {
const termine = serientermine({
beginn: D(2026, 1, 31),
muster: Serienmuster.MONATLICH,
bis: D(2026, 4, 30),
});
expect(termine).toEqual([
D(2026, 1, 31),
D(2026, 2, 28), // kein 31. Februar
D(2026, 3, 31),
D(2026, 4, 30), // kein 31. April
]);
});
it("kennt den 29. Februar im Schaltjahr", () => {
const termine = serientermine({
beginn: D(2024, 2, 29),
muster: Serienmuster.JAEHRLICH,
bis: D(2028, 3, 1),
});
expect(termine).toEqual([
D(2024, 2, 29),
D(2025, 2, 28), // kein Schaltjahr
D(2026, 2, 28),
D(2027, 2, 28),
D(2028, 2, 29), // wieder Schaltjahr
]);
});
it("nimmt einen Termin am Endtag noch mit", () => {
const termine = serientermine({
beginn: D(2026, 3, 10),
muster: Serienmuster.WOECHENTLICH,
bis: D(2026, 3, 24),
});
expect(termine.at(-1)).toEqual(D(2026, 3, 24));
});
it("liefert bei einem Endtag vor dem Beginn nur den Beginn", () => {
const termine = serientermine({
beginn: D(2026, 3, 10),
muster: Serienmuster.WOECHENTLICH,
bis: D(2026, 1, 1),
});
expect(termine).toEqual([D(2026, 3, 10)]);
});
/**
* Eine Serie, die versehentlich bis 2099 läuft, darf nicht zehntausend
* Zeilen anlegen. Die Oberfläche zeigt vorher an, dass gekappt wird.
*/
it("kappt bei der Höchstzahl", () => {
const termine = serientermine({
beginn: D(2026, 1, 1),
muster: Serienmuster.WOECHENTLICH,
bis: D(2099, 1, 1),
});
expect(termine).toHaveLength(HOECHSTZAHL);
});
it("achtet eine niedrigere eigene Höchstzahl", () => {
const termine = serientermine({
beginn: D(2026, 1, 1),
muster: Serienmuster.WOECHENTLICH,
bis: D(2099, 1, 1),
hoechstzahl: 5,
});
expect(termine).toHaveLength(5);
});
it("lässt sich die Höchstzahl nicht hochsetzen", () => {
const termine = serientermine({
beginn: D(2026, 1, 1),
muster: Serienmuster.WOECHENTLICH,
bis: D(2099, 1, 1),
hoechstzahl: 10_000,
});
expect(termine).toHaveLength(HOECHSTZAHL);
});
});
describe("serienlaenge", () => {
it("zählt dasselbe, was serientermine liefert", () => {
const angaben = {
beginn: D(2026, 3, 10),
muster: Serienmuster.ZWEIWOECHENTLICH,
bis: D(2026, 9, 10),
};
expect(serienlaenge(angaben)).toBe(serientermine(angaben).length);
});
});