Files
planer/src/lib/zeitblock.ts
T
Florian WildeandClaude Opus 5 5791047982 feat(zeitbloecke): Datenmodell und Schleifenschutz
Ein Zeitblock ist eine Aufgabe, die zu einer bestimmten Zeit in einem
Kalender steht. Er lebt an zwei Orten — hier und beim Anbieter — und beide
Seiten dürfen ihn verschieben. 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, sha256(startISO|endeISO).
Beim Schreiben merkt sich der Zeitblock, was er in den Kalender gelegt hat;
kommt derselbe Fingerabdruck zurück, war es die eigene Änderung. Weicht er
ab, hat jemand anderes verschoben — und dann gewinnt der Kalender.

Bewusst nur über Beginn und Ende: Titel und Beschreibung ändern sich auch
aus harmlosen Gründen, und für die Planung zählt allein, wann der Block
liegt.

30 Tests, darunter zwei, die den eigentlichen Zweck prüfen: dass es sich
über mehrere Runden nicht hochschaukelt und nach einer übernommenen
fremden Verschiebung wieder zur Ruhe kommt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 20:49:52 +02:00

178 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Exclude<LageFehler, null>, 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<typeof zeitblockSchema>