From ddb13d7dd009f7c377f6581cc183d4093d15ea3a Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Fri, 11 Sep 2026 08:59:27 +0200 Subject: [PATCH] feat: Regeln fuer Benachrichtigungen als reine Logik MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anlaesse, Ruhezeit ueber Mitternacht, Wiederholungssperre und die Entscheidung, ob eine Meldung jetzt hinausgeht. Ohne Datenbank und ohne Systemzeit — der Zeitpunkt kommt immer als Parameter herein, damit sich alles ohne Warten pruefen laesst. Der Tagesueberblick ist von der Ruhezeit ausgenommen: wer ihn auf 06:00 stellt, bekaeme ihn sonst nie. Die Voreinstellung ist bewusst zurueckhaltend. Wer beim ersten Start mit Meldungen ueberschuettet wird, schaltet alles ab — und dann auch das Nuetzliche. Co-Authored-By: Claude Opus 5 --- src/lib/benachrichtigung.test.ts | 256 +++++++++++++++++++++++++++++++ src/lib/benachrichtigung.ts | 229 +++++++++++++++++++++++++++ 2 files changed, 485 insertions(+) create mode 100644 src/lib/benachrichtigung.test.ts create mode 100644 src/lib/benachrichtigung.ts diff --git a/src/lib/benachrichtigung.test.ts b/src/lib/benachrichtigung.test.ts new file mode 100644 index 0000000..8eb62a0 --- /dev/null +++ b/src/lib/benachrichtigung.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from 'vitest' +import { + VOREINSTELLUNG, + einstellungSchema, + inRuhezeit, + meldungsSchluessel, + ruhezeitText, + sendenEntscheiden, + ueberblickFaellig, + zeitblockErinnerungFaellig, + type Einstellung, + type Meldung, +} from './benachrichtigung' + +/** Dienstag, 17. März 2026. Berlin liegt dann auf UTC+1. */ +function berlin(stunde: number, minute = 0): Date { + return new Date(Date.UTC(2026, 2, 17, stunde - 1, minute)) +} + +/** Samstag, 21. März 2026. */ +function samstag(stunde: number): Date { + return new Date(Date.UTC(2026, 2, 21, stunde - 1, 0)) +} + +const einstellung: Einstellung = { + ...VOREINSTELLUNG, + anlaesse: ['tagesueberblick', 'faelligkeit', 'zeitblock'], + perPush: true, +} + +function meldung(teil: Partial = {}): Meldung { + return { + anlass: 'faelligkeit', + schluessel: 'faelligkeit:a1:2026-03-17', + titel: 'Fällig heute', + text: 'Angebot schreiben', + pfad: '/aufgaben', + ...teil, + } +} + +describe('VOREINSTELLUNG', () => { + it('meldet ab Werk über keinen Weg', () => { + // Wer beim ersten Start überschüttet wird, schaltet alles ab. + expect(VOREINSTELLUNG.perPush).toBe(false) + expect(VOREINSTELLUNG.perMail).toBe(false) + }) + + it('hat eine Ruhezeit über Nacht', () => { + expect(VOREINSTELLUNG.ruheVon).toBe(20 * 60) + expect(VOREINSTELLUNG.ruheBis).toBe(7 * 60) + }) +}) + +describe('inRuhezeit', () => { + it('erkennt die Nacht, obwohl sie über Mitternacht läuft', () => { + expect(inRuhezeit(einstellung, berlin(21))).toBe(true) + expect(inRuhezeit(einstellung, berlin(2))).toBe(true) + expect(inRuhezeit(einstellung, berlin(6, 59))).toBe(true) + }) + + it('lässt den Tag frei', () => { + expect(inRuhezeit(einstellung, berlin(7))).toBe(false) + expect(inRuhezeit(einstellung, berlin(12))).toBe(false) + expect(inRuhezeit(einstellung, berlin(19, 59))).toBe(false) + }) + + it('beginnt pünktlich mit der Ruhe', () => { + expect(inRuhezeit(einstellung, berlin(20))).toBe(true) + }) + + it('kennt auch eine Ruhezeit innerhalb eines Tages', () => { + const mittags: Einstellung = { ...einstellung, ruheVon: 12 * 60, ruheBis: 13 * 60 } + expect(inRuhezeit(mittags, berlin(12, 30))).toBe(true) + expect(inRuhezeit(mittags, berlin(11))).toBe(false) + expect(inRuhezeit(mittags, berlin(14))).toBe(false) + }) + + it('deutet gleiche Zeiten als "keine Ruhezeit"', () => { + const ohne: Einstellung = { ...einstellung, ruheVon: 0, ruheBis: 0 } + expect(inRuhezeit(ohne, berlin(3))).toBe(false) + }) + + it('schweigt das Wochenende, wenn gewünscht', () => { + const still: Einstellung = { ...einstellung, ruheAmWochenende: true } + expect(inRuhezeit(still, samstag(12))).toBe(true) + expect(inRuhezeit(still, berlin(12))).toBe(false) + }) + + it('lässt das Wochenende sonst in Ruhe', () => { + expect(inRuhezeit(einstellung, samstag(12))).toBe(false) + }) +}) + +describe('sendenEntscheiden', () => { + const leer = new Set() + + it('lässt eine gewöhnliche Meldung durch', () => { + expect(sendenEntscheiden(einstellung, meldung(), leer, berlin(10))).toEqual({ + senden: true, + }) + }) + + it('schweigt bei abgeschaltetem Anlass', () => { + const ohne: Einstellung = { ...einstellung, anlaesse: ['tagesueberblick'] } + expect(sendenEntscheiden(ohne, meldung(), leer, berlin(10))).toEqual({ + senden: false, + grund: 'abgeschaltet', + }) + }) + + it('schweigt, wenn es keinen Weg hinaus gibt', () => { + const stumm: Einstellung = { ...einstellung, perPush: false, perMail: false } + expect(sendenEntscheiden(stumm, meldung(), leer, berlin(10))).toEqual({ + senden: false, + grund: 'kein-weg', + }) + }) + + it('schweigt in der Ruhezeit', () => { + expect(sendenEntscheiden(einstellung, meldung(), leer, berlin(22))).toEqual({ + senden: false, + grund: 'ruhezeit', + }) + }) + + it('meldet dieselbe Sache nicht zweimal', () => { + const gemeldet = new Set(['faelligkeit:a1:2026-03-17']) + expect(sendenEntscheiden(einstellung, meldung(), gemeldet, berlin(10))).toEqual({ + senden: false, + grund: 'schon-gemeldet', + }) + }) + + it('lässt den Tagesüberblick auch in der Ruhezeit hinaus', () => { + // Sonst käme er nie, wenn jemand früh aufsteht und die Ruhe bis 07:00 läuft. + const ueberblick = meldung({ + anlass: 'tagesueberblick', + schluessel: 'tagesueberblick:tag:2026-03-17', + }) + expect(sendenEntscheiden(einstellung, ueberblick, leer, berlin(6, 30))).toEqual({ + senden: true, + }) + }) + + it('prüft die Abschaltung vor der Ruhezeit', () => { + // Ein abgeschalteter Anlass ist abgeschaltet, nicht "gerade still". + const ohne: Einstellung = { ...einstellung, anlaesse: [] } + expect(sendenEntscheiden(ohne, meldung(), leer, berlin(22)).senden).toBe(false) + expect( + sendenEntscheiden(ohne, meldung(), leer, berlin(22)) as { grund: string }, + ).toMatchObject({ grund: 'abgeschaltet' }) + }) +}) + +describe('meldungsSchluessel', () => { + it('trennt Anlass, Sache und Tag', () => { + expect(meldungsSchluessel('faelligkeit', 'a1', '2026-03-17')).toBe( + 'faelligkeit:a1:2026-03-17', + ) + }) + + it('erlaubt dieselbe Sache an einem anderen Tag erneut', () => { + // Eine Aufgabe, die heute fällig war, ist es morgen immer noch. + expect(meldungsSchluessel('faelligkeit', 'a1', '2026-03-17')).not.toBe( + meldungsSchluessel('faelligkeit', 'a1', '2026-03-18'), + ) + }) +}) + +describe('ueberblickFaellig', () => { + it('trifft das Zeitfenster', () => { + expect(ueberblickFaellig(einstellung, berlin(7))).toBe(true) + expect(ueberblickFaellig(einstellung, berlin(7, 14))).toBe(true) + }) + + it('verpasst das Fenster davor und danach', () => { + expect(ueberblickFaellig(einstellung, berlin(6, 59))).toBe(false) + expect(ueberblickFaellig(einstellung, berlin(7, 15))).toBe(false) + }) +}) + +describe('zeitblockErinnerungFaellig', () => { + const beginn = berlin(10) + + it('erinnert im Vorlauffenster', () => { + // Vorlauf 10 Minuten, Toleranz 15: von 25 bis 10 Minuten vorher. + expect(zeitblockErinnerungFaellig(einstellung, beginn, berlin(9, 55))).toBe(true) + }) + + it('erinnert nicht zu früh', () => { + expect(zeitblockErinnerungFaellig(einstellung, beginn, berlin(9, 30))).toBe(false) + }) + + it('erinnert nicht mehr, wenn der Block schon läuft', () => { + expect(zeitblockErinnerungFaellig(einstellung, beginn, berlin(10, 5))).toBe(false) + }) +}) + +describe('einstellungSchema', () => { + const gueltig = { + anlaesse: ['tagesueberblick'], + perPush: 'true', + perMail: '', + ueberblickUm: '07:00', + ruheVon: '20:00', + ruheBis: '07:00', + ruheAmWochenende: '', + zeitblockVorlaufMinuten: '10', + } + + it('liest Uhrzeiten in Minuten um', () => { + const ergebnis = einstellungSchema.parse(gueltig) + expect(ergebnis.ueberblickUm).toBe(420) + expect(ergebnis.ruheVon).toBe(1200) + }) + + it('weist unsinnige Uhrzeiten ab', () => { + expect(einstellungSchema.safeParse({ ...gueltig, ruheVon: '25:00' }).success).toBe( + false, + ) + }) + + it('begrenzt den Vorlauf', () => { + expect( + einstellungSchema.safeParse({ ...gueltig, zeitblockVorlaufMinuten: '121' }).success, + ).toBe(false) + expect( + einstellungSchema.safeParse({ ...gueltig, zeitblockVorlaufMinuten: '120' }).success, + ).toBe(true) + }) + + it('weist unbekannte Anlässe ab', () => { + expect( + einstellungSchema.safeParse({ ...gueltig, anlaesse: ['geburtstag'] }).success, + ).toBe(false) + }) +}) + +describe('ruhezeitText', () => { + it('beschreibt die Nachtruhe', () => { + expect(ruhezeitText(einstellung)).toBe('Still von 20:00 bis 07:00.') + }) + + it('nennt das Wochenende mit', () => { + expect(ruhezeitText({ ...einstellung, ruheAmWochenende: true })).toBe( + 'Still von 20:00 bis 07:00 und am ganzen Wochenende.', + ) + }) + + it('sagt es auch, wenn es keine Ruhezeit gibt', () => { + expect(ruhezeitText({ ...einstellung, ruheVon: 0, ruheBis: 0 })).toBe( + 'Keine Ruhezeit.', + ) + }) +}) diff --git a/src/lib/benachrichtigung.ts b/src/lib/benachrichtigung.ts new file mode 100644 index 0000000..0fbb16b --- /dev/null +++ b/src/lib/benachrichtigung.ts @@ -0,0 +1,229 @@ +import { z } from 'zod' +import { + alsUhrzeit, + ausUhrzeit, + minutenSeitMitternacht, + wochentagIndex, +} from '@/lib/zeit' + +/** + * Regeln für Benachrichtigungen. + * + * Der heikle Teil ist nicht die Technik, sondern das Maß: Eine Anwendung, die + * zu oft meldet, wird stummgeschaltet und ist dann wertlos. Deshalb sind die + * Voreinstellungen zurückhaltend, es gibt Ruhezeiten, und dieselbe Sache wird + * nie zweimal gemeldet. + * + * Reine Logik ohne Datenbank und ohne Systemzeit. + */ + +export const ANLAESSE = [ + 'tagesueberblick', + 'faelligkeit', + 'zeitblock', + 'abgleichfehler', +] as const +export type Anlass = (typeof ANLAESSE)[number] + +export const ANLASS_NAME: Record = { + tagesueberblick: 'Tagesüberblick', + faelligkeit: 'Fällige Aufgaben', + zeitblock: 'Gleich beginnt ein Zeitblock', + abgleichfehler: 'Ein Kalender lässt sich nicht abgleichen', +} + +export const ANLASS_BESCHREIBUNG: Record = { + tagesueberblick: + 'Einmal morgens: was heute ansteht, was überfällig ist, wie voll der Tag wird.', + faelligkeit: + 'Wenn eine Aufgabe an diesem Tag fällig wird — höchstens einmal je Aufgabe.', + zeitblock: 'Kurz bevor ein eingeplanter Zeitblock beginnt.', + abgleichfehler: + 'Wenn ein Kalender mehrfach hintereinander nicht erreichbar war. Nicht beim ersten Mal.', +} + +export type Einstellung = { + /** Welche Anlässe überhaupt melden. */ + anlaesse: readonly Anlass[] + perPush: boolean + perMail: boolean + /** Uhrzeit des Tagesüberblicks, Minuten seit Mitternacht. */ + ueberblickUm: number + /** Ruhezeit: von hier bis … nichts melden. */ + ruheVon: number + ruheBis: number + /** Am Wochenende nichts melden. */ + ruheAmWochenende: boolean + /** Vorlauf vor einem Zeitblock, in Minuten. */ + zeitblockVorlaufMinuten: number +} + +/** + * Voreinstellung: bewusst wenig. + * + * Wer mehr will, schaltet es zu. Wer beim ersten Start mit Meldungen + * überschüttet wird, schaltet alles ab — und dann auch das Nützliche. + */ +export const VOREINSTELLUNG: Einstellung = { + anlaesse: ['tagesueberblick', 'faelligkeit'], + perPush: false, + perMail: false, + ueberblickUm: 7 * 60, + ruheVon: 20 * 60, + ruheBis: 7 * 60, + ruheAmWochenende: false, + zeitblockVorlaufMinuten: 10, +} + +/** + * Liegt der Zeitpunkt in der Ruhezeit? + * + * Die Ruhezeit läuft über Mitternacht hinweg — 20:00 bis 07:00 ist der + * Normalfall, nicht die Ausnahme. + */ +export function inRuhezeit(einstellung: Einstellung, zeitpunkt: Date): boolean { + if (einstellung.ruheAmWochenende && wochentagIndex(zeitpunkt) >= 5) return true + + const minute = minutenSeitMitternacht(zeitpunkt) + const { ruheVon, ruheBis } = einstellung + + // Gleiche Zeiten heißen: keine Ruhezeit. + if (ruheVon === ruheBis) return false + + return ruheVon < ruheBis + ? minute >= ruheVon && minute < ruheBis + : minute >= ruheVon || minute < ruheBis +} + +export type Meldung = { + anlass: Anlass + /** Eindeutig je Sache und Tag; verhindert doppelte Meldungen. */ + schluessel: string + titel: string + text: string + /** Wohin der Klick führt. */ + pfad: string +} + +/** + * Soll diese Meldung jetzt hinaus? + * + * Drei Gründe, es zu lassen: der Anlass ist abgeschaltet, es ist Ruhezeit, + * oder genau diese Meldung ging schon einmal hinaus. + */ +export type Entscheidung = + | { senden: true } + | { senden: false; grund: 'abgeschaltet' | 'ruhezeit' | 'schon-gemeldet' | 'kein-weg' } + +export function sendenEntscheiden( + einstellung: Einstellung, + meldung: Meldung, + bereitsGemeldet: ReadonlySet, + zeitpunkt: Date, +): Entscheidung { + if (!einstellung.anlaesse.includes(meldung.anlass)) { + return { senden: false, grund: 'abgeschaltet' } + } + if (!einstellung.perPush && !einstellung.perMail) { + return { senden: false, grund: 'kein-weg' } + } + if (bereitsGemeldet.has(meldung.schluessel)) { + return { senden: false, grund: 'schon-gemeldet' } + } + + // Der Tagesüberblick kommt zu seiner festen Zeit; ihn wegen der Ruhezeit zu + // unterdrücken hieße, ihn nie zu schicken, wenn jemand früh aufsteht. + if (meldung.anlass !== 'tagesueberblick' && inRuhezeit(einstellung, zeitpunkt)) { + return { senden: false, grund: 'ruhezeit' } + } + + return { senden: true } +} + +/** + * Schlüssel einer Meldung: Anlass, Sache und Tag. + * + * Der Tag gehört dazu, damit dieselbe Aufgabe morgen wieder gemeldet werden + * darf — sie ist dann ja immer noch fällig. + */ +export function meldungsSchluessel( + anlass: Anlass, + sache: string, + tagesschluessel: string, +): string { + return `${anlass}:${sache}:${tagesschluessel}` +} + +/** Ist jetzt die Zeit für den Tagesüberblick? */ +export function ueberblickFaellig( + einstellung: Einstellung, + zeitpunkt: Date, + toleranzMinuten = 15, +): boolean { + const minute = minutenSeitMitternacht(zeitpunkt) + return ( + minute >= einstellung.ueberblickUm && + minute < einstellung.ueberblickUm + toleranzMinuten + ) +} + +/** Beginnt der Zeitblock bald genug für eine Erinnerung? */ +export function zeitblockErinnerungFaellig( + einstellung: Einstellung, + beginn: Date, + zeitpunkt: Date, + toleranzMinuten = 15, +): boolean { + const minutenBis = (beginn.getTime() - zeitpunkt.getTime()) / 60_000 + return ( + minutenBis <= einstellung.zeitblockVorlaufMinuten && + minutenBis > einstellung.zeitblockVorlaufMinuten - toleranzMinuten + ) +} + +/** + * Ab wann ein Abgleichfehler gemeldet wird. + * + * Nicht beim ersten Mal: ein Netz hustet, ein Anbieter hat eine Wartung. + * Erst wenn es mehrfach hintereinander nicht klappt, ist es eine Nachricht + * wert. + */ +export const ABGLEICHFEHLER_AB_VERSUCH = 3 + +// --- Eingaben -------------------------------------------------------------- + +const uhrzeit = z + .string() + .trim() + .refine((w) => ausUhrzeit(w) !== null, 'Bitte eine Uhrzeit wie 07:00 angeben.') + .transform((w) => ausUhrzeit(w) as number) + +export const einstellungSchema = z.object({ + anlaesse: z.array(z.enum(ANLAESSE)).default([]), + perPush: z.coerce.boolean().default(false), + perMail: z.coerce.boolean().default(false), + ueberblickUm: uhrzeit, + ruheVon: uhrzeit, + ruheBis: uhrzeit, + ruheAmWochenende: z.coerce.boolean().default(false), + zeitblockVorlaufMinuten: z.coerce + .number() + .int() + .min(0, 'Nicht weniger als null Minuten.') + .max(120, 'Mehr als zwei Stunden Vorlauf ist keine Erinnerung mehr.'), +}) + +export type EinstellungEingabe = z.infer + +/** Lesbare Beschreibung der Ruhezeit für die Oberfläche. */ +export function ruhezeitText(einstellung: Einstellung): string { + if (einstellung.ruheVon === einstellung.ruheBis) { + return einstellung.ruheAmWochenende + ? 'Keine Ruhezeit, aber am Wochenende still.' + : 'Keine Ruhezeit.' + } + const zeiten = `${alsUhrzeit(einstellung.ruheVon)} bis ${alsUhrzeit(einstellung.ruheBis)}` + return einstellung.ruheAmWochenende + ? `Still von ${zeiten} und am ganzen Wochenende.` + : `Still von ${zeiten}.` +}