import { Frequency, RRule } from 'rrule' import { tagesdatum } from '@/lib/fristen/datum' /** * Wiederkehrende Fristen. * * Die Wiederholung steht als iCalendar-RRULE an der Frist * ("FREQ=YEARLY;INTERVAL=1"). Gespeichert wird immer nur die naechste * faellige Frist, nicht eine Reihe im Voraus - erst beim Erledigen entsteht * die naechste. Sonst haette man nach fuenf Jahren eine Liste voller * Termine, die nie jemand angefasst hat. * * Die neue Frist zeigt ueber vorgaengerFristId auf die erledigte. Damit * laesst sich die Kette rueckwaerts lesen: "wann habe ich das zuletzt * gemacht?" ist gegenueber dem Gericht eine haeufige Frage. * * Achtung, bewusste Entscheidung: Die abgeleiteten Pflichtfristen aus * pflichtfristen.ts tragen KEINE RRULE. Sie werden aus dem Bestellungsdatum * neu berechnet. Haetten sie zusaetzlich eine RRULE, entstuende beim * Erledigen eine zweite Frist neben der ohnehin abgeleiteten - eine * Dublette, die der eindeutige systemSchluessel nicht abfaengt, weil die * Nachfolgefrist keinen Systemschluessel bekommt. */ export type FristFuerWiederholung = { id: string betreuteId: string vorgangId?: string | null titel: string beschreibung?: string | null datum: Date typ: 'GESETZLICH' | 'BEHOERDLICH' | 'SELBST_GESETZT' | 'WIEDERVORLAGE' rechtsgrundlage?: string | null vorwarnungTage: number[] wiederholungRrule?: string | null ausschlussfrist: boolean } /** Bauplan der Nachfolgefrist, fertig fuer db.frist.create({ data }). */ export type Nachfolgefrist = { betreuteId: string vorgangId: string | null titel: string beschreibung: string | null datum: Date typ: FristFuerWiederholung['typ'] rechtsgrundlage: string | null vorwarnungTage: number[] wiederholungRrule: string ausschlussfrist: boolean vorgaengerFristId: string } /** * Baut die Regel neu auf und setzt DTSTART auf das uebergebene Datum. * * Ein in der Regel mitgeliefertes DTSTART wird dabei absichtlich verworfen: * Massgeblich ist immer der Termin der zuletzt erledigten Frist, sonst * wandert eine monatliche Wiedervorlage nach dem ersten Verschieben wieder * auf das urspruengliche Raster zurueck. */ function regelMitStart(regel: string, dtstart: Date): RRule { const nurRrule = regel .split(/\r?\n/) .map((zeile) => zeile.trim()) .filter((zeile) => zeile !== '' && !/^DTSTART/i.test(zeile)) .join('\n') .replace(/^RRULE:/i, '') const geparst = RRule.parseString(nurRrule) if (geparst.freq === undefined || geparst.freq === null) { throw new Error('Die Wiederholungsregel enthält kein FREQ.') } // rrule rechnet ohne TZID in UTC. Da unsere Fristen ohnehin auf // UTC-Mitternacht liegen, passt das genau zusammen. return new RRule({ ...geparst, dtstart: tagesdatum(dtstart) }) } /** Ist die Regel ueberhaupt lesbar? Fuer die Formularpruefung. */ export function rruleGueltig(regel: string): boolean { try { regelMitStart(regel, new Date(Date.UTC(2026, 0, 1))) return true } catch { return false } } /** * Der naechste Termin nach `nachDatum` - der Tag selbst zaehlt nicht mit, * sonst bekaeme man beim Erledigen dieselbe Frist noch einmal. * Liefert null, wenn die Regel endet (COUNT/UNTIL erschoepft). */ export function naechsterTermin(regel: string, nachDatum: Date): Date | null { let rule: RRule try { rule = regelMitStart(regel, nachDatum) } catch { return null } const treffer = rule.after(tagesdatum(nachDatum), false) return treffer ? tagesdatum(treffer) : null } /** * Der Bauplan der Frist, die beim Erledigen einer wiederkehrenden Frist * entsteht. Null, wenn die Frist nicht wiederkehrt oder die Regel * ausgelaufen ist. * * Gerechnet wird ab dem Solltermin der erledigten Frist, nicht ab dem Tag * der Erledigung: Wer den Bericht drei Tage zu spaet abgibt, soll den * naechsten trotzdem wieder zum Stichtag abgeben und nicht dauerhaft drei * Tage nach hinten rutschen. */ export function naechsteFristAusErledigter(frist: FristFuerWiederholung): Nachfolgefrist | null { if (!frist.wiederholungRrule) return null const naechstes = naechsterTermin(frist.wiederholungRrule, frist.datum) if (!naechstes) return null return { betreuteId: frist.betreuteId, vorgangId: frist.vorgangId ?? null, titel: frist.titel, beschreibung: frist.beschreibung ?? null, datum: naechstes, typ: frist.typ, rechtsgrundlage: frist.rechtsgrundlage ?? null, vorwarnungTage: frist.vorwarnungTage, wiederholungRrule: frist.wiederholungRrule, ausschlussfrist: frist.ausschlussfrist, vorgaengerFristId: frist.id, } } // ----------------------------------------------------------------------------- // Auswahl fuer die Oberflaeche // ----------------------------------------------------------------------------- /** * Die Wiederholungen, die im Betreueralltag tatsaechlich vorkommen. Ein * freies RRULE-Eingabefeld waere fuer die Zielgruppe eine Zumutung; wer * eine exotische Regel braucht, kann sie ueber "Eigene Regel" setzen. */ export const WIEDERHOLUNGEN: { wert: string; beschriftung: string }[] = [ { wert: '', beschriftung: 'Einmalig — wiederholt sich nicht' }, { wert: 'FREQ=WEEKLY;INTERVAL=1', beschriftung: 'Wöchentlich' }, { wert: 'FREQ=WEEKLY;INTERVAL=2', beschriftung: 'Alle zwei Wochen' }, { wert: 'FREQ=MONTHLY;INTERVAL=1', beschriftung: 'Monatlich' }, { wert: 'FREQ=MONTHLY;INTERVAL=3', beschriftung: 'Vierteljährlich' }, { wert: 'FREQ=MONTHLY;INTERVAL=6', beschriftung: 'Halbjährlich' }, { wert: 'FREQ=YEARLY;INTERVAL=1', beschriftung: 'Jährlich' }, { wert: 'FREQ=YEARLY;INTERVAL=2', beschriftung: 'Alle zwei Jahre' }, ] const FREQUENZ_TEXT: Record = { [Frequency.DAILY]: ['Täglich', 'Tage'], [Frequency.WEEKLY]: ['Wöchentlich', 'Wochen'], [Frequency.MONTHLY]: ['Monatlich', 'Monate'], [Frequency.YEARLY]: ['Jährlich', 'Jahre'], } /** Deutsche Klartextbeschreibung einer Regel, z.B. "Alle 3 Monate". */ export function rruleBeschreiben(regel: string | null | undefined): string { if (!regel) return 'Einmalig' const bekannt = WIEDERHOLUNGEN.find((w) => w.wert === regel) if (bekannt && bekannt.wert !== '') return bekannt.beschriftung try { const geparst = RRule.parseString(regel.replace(/^RRULE:/i, '')) const freq = geparst.freq if (freq === undefined || freq === null) return 'Wiederkehrend' const text = FREQUENZ_TEXT[freq] if (!text) return 'Wiederkehrend' const intervall = geparst.interval ?? 1 const grund = intervall === 1 ? text[0] : `Alle ${intervall} ${text[1]}` if (geparst.count) return `${grund}, ${geparst.count}-mal` if (geparst.until) { return `${grund}, bis ${tagesdatum(geparst.until).toLocaleDateString('de-DE', { timeZone: 'UTC' })}` } return grund } catch { return 'Wiederkehrend' } }