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>
178 lines
6.0 KiB
TypeScript
178 lines
6.0 KiB
TypeScript
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>
|