feat: Regeln fuer Benachrichtigungen als reine Logik

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 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-11 08:59:27 +02:00
co-authored by Claude Opus 5
parent 24f613d372
commit ddb13d7dd0
2 changed files with 485 additions and 0 deletions
+256
View File
@@ -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> = {}): 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<string>()
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.',
)
})
})