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:
co-authored by
Claude Opus 5
parent
24f613d372
commit
ddb13d7dd0
@@ -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.',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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<Anlass, string> = {
|
||||
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<Anlass, string> = {
|
||||
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<string>,
|
||||
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<typeof einstellungSchema>
|
||||
|
||||
/** 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}.`
|
||||
}
|
||||
Reference in New Issue
Block a user