import { createHash } from 'node:crypto' import { z } from 'zod' /** * Zeitblöcke und der Schleifenschutz. * * Ein Zeitblock ist eine Aufgabe, die zu einer bestimmten Zeit in einem * Kalender steht. Er lebt an zwei Orten: hier und im Kalender des Anbieters. * Beide Seiten dürfen ihn verschieben — und genau daraus entsteht die Gefahr * einer Endlosschleife: * * Die App schreibt → der Abgleich liest die eigene Änderung → hält sie für * fremd → schreibt zurück → der Abgleich liest wieder → … * * Der Ausweg ist ein Fingerabdruck über den Zeitraum. Beim Schreiben merkt * sich der Zeitblock, was er in den Kalender gelegt hat. Kommt beim nächsten * Abgleich derselbe Fingerabdruck zurück, war es die eigene Änderung und es * passiert nichts. Weicht er ab, hat jemand anderes verschoben — dann gewinnt * der Kalender. * * Reine Logik ohne Datenbank und ohne Systemzeit. */ /** * Fingerabdruck eines Zeitraums. * * Bewusst nur über Beginn und Ende: Titel und Beschreibung ändern sich auch * aus harmlosen Gründen (etwa weil der Aufgabentitel bearbeitet wurde), und * für die Planung zählt allein, wann der Block liegt. */ export function zeitraumHash(beginn: Date, ende: Date): string { return createHash('sha256') .update(`${beginn.toISOString()}|${ende.toISOString()}`) .digest('hex') } /** Was beim Abgleich mit einem Zeitblock zu tun ist. */ export type Abgleichentscheidung = | { tun: 'nichts'; grund: 'unveraendert' | 'eigene-aenderung' } | { tun: 'uebernehmen'; grund: 'fremd-verschoben'; beginn: Date; ende: Date } | { tun: 'loesen'; grund: 'im-kalender-entfernt' } export type ZeitblockStand = { /** Was zuletzt in den Kalender geschrieben wurde. */ hash: string | null beginn: Date ende: Date } export type KalenderStand = { beginn: Date ende: Date } | null /** * Entscheidet, was beim Abgleich mit einem Zeitblock geschehen soll. * * Der Kalender gewinnt: Wurde ein Block dort verschoben, übernimmt der Planer * das, statt zurückzuschreiben. Wer im Kalender etwas verschiebt, meint es so * — und merkt es sofort, wenn der Planer es wieder wegzieht. */ export function abgleichEntscheiden( zeitblock: ZeitblockStand, imKalender: KalenderStand, ): Abgleichentscheidung { if (!imKalender) { // Im Kalender gelöscht. Der Zeitblock verliert seine Verankerung; die // Aufgabe selbst bleibt bestehen. return { tun: 'loesen', grund: 'im-kalender-entfernt' } } const imKalenderHash = zeitraumHash(imKalender.beginn, imKalender.ende) // Steht im Kalender genau das, was wir zuletzt geschrieben haben, war es // die eigene Änderung – nichts zu tun. Das bricht die Schleife. if (zeitblock.hash !== null && imKalenderHash === zeitblock.hash) { return { tun: 'nichts', grund: 'eigene-aenderung' } } // Stimmt der Kalender ohnehin mit unserem Stand überein, ist auch nichts // zu tun – etwa beim allerersten Abgleich nach dem Anlegen. const eigenerHash = zeitraumHash(zeitblock.beginn, zeitblock.ende) if (imKalenderHash === eigenerHash) { return { tun: 'nichts', grund: 'unveraendert' } } return { tun: 'uebernehmen', grund: 'fremd-verschoben', beginn: imKalender.beginn, ende: imKalender.ende, } } /** * Muss beim Speichern in den Kalender geschrieben werden? * * Nur bei einer echten Verschiebung. Wird bloß der Aufgabentitel geändert, * bleibt der Fingerabdruck gleich und es gibt keinen Grund, den Anbieter zu * behelligen. */ export function mussSchreiben( zeitblock: ZeitblockStand, neuerBeginn: Date, neuesEnde: Date, ): boolean { return zeitblock.hash !== zeitraumHash(neuerBeginn, neuesEnde) } // --- Lage und Dauer -------------------------------------------------------- export const ZEITBLOCK_MIN_MINUTEN = 15 export const ZEITBLOCK_MAX_MINUTEN = 8 * 60 /** Raster, in dem Zeitblöcke einrasten. */ export const RASTER_MINUTEN = 15 export function dauerMinuten(beginn: Date, ende: Date): number { return Math.round((ende.getTime() - beginn.getTime()) / 60_000) } /** Rundet einen Zeitpunkt auf das Raster ab. */ export function aufRaster(zeitpunkt: Date, raster = RASTER_MINUTEN): Date { const minuten = raster * 60_000 return new Date(Math.round(zeitpunkt.getTime() / minuten) * minuten) } export type LageFehler = 'zu-kurz' | 'zu-lang' | 'ende-vor-beginn' | 'nicht-im-raster' | null /** Prüft die Lage eines Zeitblocks. Gibt null zurück, wenn sie stimmt. */ export function lagePruefen(beginn: Date, ende: Date): LageFehler { if (ende.getTime() <= beginn.getTime()) return 'ende-vor-beginn' const dauer = dauerMinuten(beginn, ende) if (dauer < ZEITBLOCK_MIN_MINUTEN) return 'zu-kurz' if (dauer > ZEITBLOCK_MAX_MINUTEN) return 'zu-lang' if ( beginn.getTime() % (RASTER_MINUTEN * 60_000) !== 0 || ende.getTime() % (RASTER_MINUTEN * 60_000) !== 0 ) { return 'nicht-im-raster' } return null } export const LAGE_FEHLER_TEXT: Record, string> = { 'zu-kurz': `Ein Zeitblock dauert mindestens ${ZEITBLOCK_MIN_MINUTEN} Minuten.`, 'zu-lang': 'Mehr als acht Stunden am Stück gehört in mehrere Zeitblöcke.', 'ende-vor-beginn': 'Das Ende muss nach dem Beginn liegen.', 'nicht-im-raster': `Zeitblöcke rasten in ${RASTER_MINUTEN}-Minuten-Schritten ein.`, } /** Überschneiden sich zwei Zeitblöcke? Aneinandergrenzen zählt nicht. */ export function ueberschneidetSich( a: { beginn: Date; ende: Date }, b: { beginn: Date; ende: Date }, ): boolean { return a.beginn.getTime() < b.ende.getTime() && b.beginn.getTime() < a.ende.getTime() } // --- Eingaben -------------------------------------------------------------- export const zeitblockSchema = z .object({ aufgabeId: z.string().min(1), kalenderkontoId: z.string().min(1), beginn: z.coerce.date('Bitte einen gültigen Beginn angeben.'), ende: z.coerce.date('Bitte ein gültiges Ende angeben.'), }) .refine((w) => lagePruefen(w.beginn, w.ende) === null, { path: ['ende'], message: 'Die Lage des Zeitblocks stimmt nicht.', }) export type ZeitblockEingabe = z.infer