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>
This commit is contained in:
Florian Wilde
2026-09-10 20:49:52 +02:00
co-authored by Claude Opus 5
parent bd9df22a18
commit 5791047982
4 changed files with 541 additions and 4 deletions
@@ -0,0 +1,31 @@
-- CreateTable
CREATE TABLE "zeitblock" (
"id" TEXT NOT NULL,
"aufgabeId" TEXT NOT NULL,
"kalenderkontoId" TEXT NOT NULL,
"beginn" TIMESTAMP(3) NOT NULL,
"ende" TIMESTAMP(3) NOT NULL,
"extId" TEXT,
"etag" TEXT,
"hash" TEXT,
"letzterFehler" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "zeitblock_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "zeitblock_beginn_ende_idx" ON "zeitblock"("beginn", "ende");
-- CreateIndex
CREATE INDEX "zeitblock_aufgabeId_idx" ON "zeitblock"("aufgabeId");
-- CreateIndex
CREATE UNIQUE INDEX "zeitblock_kalenderkontoId_extId_key" ON "zeitblock"("kalenderkontoId", "extId");
-- AddForeignKey
ALTER TABLE "zeitblock" ADD CONSTRAINT "zeitblock_aufgabeId_fkey" FOREIGN KEY ("aufgabeId") REFERENCES "aufgabe"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "zeitblock" ADD CONSTRAINT "zeitblock_kalenderkontoId_fkey" FOREIGN KEY ("kalenderkontoId") REFERENCES "kalenderkonto"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+38
View File
@@ -224,6 +224,7 @@ model Aufgabe {
bereich Bereich? @relation(fields: [bereichId], references: [id], onDelete: SetNull)
projekt Projekt? @relation(fields: [projektId], references: [id], onDelete: SetNull)
zeitbloecke Zeitblock[]
/// Integrationen upserten immer ueber diese beiden Felder. Von Hand
/// erfasste Aufgaben haben quelleRef = null; PostgreSQL sieht NULL-Werte
@@ -308,6 +309,7 @@ model Kalenderkonto {
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
termine Termin[]
standardFuerBereiche Bereich[] @relation("StandardKalenderkonto")
zeitblocks Zeitblock[]
/// Dasselbe Konto und derselbe Kalender nur einmal je Benutzer.
@@unique([benutzerId, anbieter, extKontoId, kalenderId])
@@ -340,3 +342,39 @@ model Termin {
@@index([beginn, ende])
@@map("termin")
}
/// Eine Aufgabe, die zu einer bestimmten Zeit in einem Kalender steht.
///
/// Der Zeitblock lebt an zwei Orten: hier und im Kalender des Anbieters.
/// Beide Seiten duerfen ihn verschieben; der Fingerabdruck "hash" verhindert,
/// dass sich beide gegenseitig hochschaukeln. Siehe src/lib/zeitblock.ts.
model Zeitblock {
id String @id @default(cuid())
aufgabeId String
kalenderkontoId String
beginn DateTime
ende DateTime
/// Kennung des Termins beim Anbieter; null, solange noch nicht geschrieben.
extId String?
/// Fuer bedingte Schreibzugriffe (If-Match).
etag String?
/// sha256(startISO|endeISO) dessen, was zuletzt geschrieben wurde.
hash String?
/// Klartextmeldung des letzten Fehlschlags beim Schreiben, ohne Tokens.
letzterFehler String?
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
aufgabe Aufgabe @relation(fields: [aufgabeId], references: [id], onDelete: Cascade)
kalenderkonto Kalenderkonto @relation(fields: [kalenderkontoId], references: [id], onDelete: Cascade)
/// Ein Termin im Kalender gehoert zu hoechstens einem Zeitblock.
@@unique([kalenderkontoId, extId])
@@index([beginn, ende])
@@index([aufgabeId])
@@map("zeitblock")
}
+291
View File
@@ -0,0 +1,291 @@
import { describe, expect, it } from 'vitest'
import {
RASTER_MINUTEN,
ZEITBLOCK_MAX_MINUTEN,
ZEITBLOCK_MIN_MINUTEN,
abgleichEntscheiden,
aufRaster,
dauerMinuten,
lagePruefen,
mussSchreiben,
ueberschneidetSich,
zeitblockSchema,
zeitraumHash,
} from './zeitblock'
const beginn = new Date('2026-03-17T09:00:00Z')
const ende = new Date('2026-03-17T10:00:00Z')
describe('zeitraumHash', () => {
it('ist für denselben Zeitraum gleich', () => {
expect(zeitraumHash(beginn, ende)).toBe(
zeitraumHash(new Date(beginn), new Date(ende)),
)
})
it('ändert sich, wenn der Beginn wandert', () => {
expect(zeitraumHash(beginn, ende)).not.toBe(
zeitraumHash(new Date('2026-03-17T09:15:00Z'), ende),
)
})
it('ändert sich, wenn das Ende wandert', () => {
expect(zeitraumHash(beginn, ende)).not.toBe(
zeitraumHash(beginn, new Date('2026-03-17T10:30:00Z')),
)
})
it('ist unabhängig davon, wie der Zeitpunkt entstanden ist', () => {
// Derselbe Augenblick, anders geschrieben.
expect(zeitraumHash(new Date('2026-03-17T09:00:00.000Z'), ende)).toBe(
zeitraumHash(new Date('2026-03-17T10:00:00+01:00'), ende),
)
})
})
describe('abgleichEntscheiden — der Schleifenschutz', () => {
it('tut nichts, wenn im Kalender genau das steht, was wir geschrieben haben', () => {
// Das ist der Fall, der die Schleife bricht: der Abgleich liest die
// eigene Änderung und erkennt sie am Fingerabdruck wieder.
const entscheidung = abgleichEntscheiden(
{ hash: zeitraumHash(beginn, ende), beginn, ende },
{ beginn, ende },
)
expect(entscheidung).toEqual({ tun: 'nichts', grund: 'eigene-aenderung' })
})
it('tut auch ohne Fingerabdruck nichts, wenn beide Seiten übereinstimmen', () => {
const entscheidung = abgleichEntscheiden(
{ hash: null, beginn, ende },
{ beginn, ende },
)
expect(entscheidung).toEqual({ tun: 'nichts', grund: 'unveraendert' })
})
it('übernimmt eine fremde Verschiebung — der Kalender gewinnt', () => {
const verschobenerBeginn = new Date('2026-03-17T11:00:00Z')
const verschobenesEnde = new Date('2026-03-17T12:00:00Z')
const entscheidung = abgleichEntscheiden(
{ hash: zeitraumHash(beginn, ende), beginn, ende },
{ beginn: verschobenerBeginn, ende: verschobenesEnde },
)
expect(entscheidung).toEqual({
tun: 'uebernehmen',
grund: 'fremd-verschoben',
beginn: verschobenerBeginn,
ende: verschobenesEnde,
})
})
it('erkennt eine fremde Verschiebung auch ohne vorherigen Fingerabdruck', () => {
const entscheidung = abgleichEntscheiden(
{ hash: null, beginn, ende },
{
beginn: new Date('2026-03-17T11:00:00Z'),
ende: new Date('2026-03-17T12:00:00Z'),
},
)
expect(entscheidung.tun).toBe('uebernehmen')
})
it('löst den Zeitblock, wenn der Termin im Kalender verschwunden ist', () => {
const entscheidung = abgleichEntscheiden(
{ hash: zeitraumHash(beginn, ende), beginn, ende },
null,
)
expect(entscheidung).toEqual({ tun: 'loesen', grund: 'im-kalender-entfernt' })
})
it('schaukelt sich über mehrere Runden nicht hoch', () => {
// Die App schreibt, der Abgleich läuft dreimal – und tut jedes Mal nichts.
let stand = { hash: zeitraumHash(beginn, ende), beginn, ende }
for (let runde = 0; runde < 3; runde++) {
const entscheidung = abgleichEntscheiden(stand, {
beginn: stand.beginn,
ende: stand.ende,
})
expect(entscheidung.tun).toBe('nichts')
stand = { ...stand }
}
})
it('kommt nach einer übernommenen Verschiebung zur Ruhe', () => {
const neuerBeginn = new Date('2026-03-17T11:00:00Z')
const neuesEnde = new Date('2026-03-17T12:00:00Z')
// Erste Runde: fremde Verschiebung wird übernommen.
const erste = abgleichEntscheiden(
{ hash: zeitraumHash(beginn, ende), beginn, ende },
{ beginn: neuerBeginn, ende: neuesEnde },
)
expect(erste.tun).toBe('uebernehmen')
// Danach steht der neue Stand samt Fingerabdruck; die zweite Runde
// erkennt ihn wieder.
const zweite = abgleichEntscheiden(
{
hash: zeitraumHash(neuerBeginn, neuesEnde),
beginn: neuerBeginn,
ende: neuesEnde,
},
{ beginn: neuerBeginn, ende: neuesEnde },
)
expect(zweite.tun).toBe('nichts')
})
})
describe('mussSchreiben', () => {
it('schreibt bei einer echten Verschiebung', () => {
expect(
mussSchreiben(
{ hash: zeitraumHash(beginn, ende), beginn, ende },
new Date('2026-03-17T11:00:00Z'),
new Date('2026-03-17T12:00:00Z'),
),
).toBe(true)
})
it('schreibt nicht, wenn der Zeitraum gleich bleibt', () => {
// Etwa wenn nur der Aufgabentitel geändert wurde.
expect(
mussSchreiben({ hash: zeitraumHash(beginn, ende), beginn, ende }, beginn, ende),
).toBe(false)
})
it('schreibt, solange noch nichts geschrieben wurde', () => {
expect(mussSchreiben({ hash: null, beginn, ende }, beginn, ende)).toBe(true)
})
})
describe('dauerMinuten', () => {
it('rechnet in Minuten', () => {
expect(dauerMinuten(beginn, ende)).toBe(60)
expect(dauerMinuten(beginn, new Date('2026-03-17T09:45:00Z'))).toBe(45)
})
})
describe('aufRaster', () => {
it('rastet auf die nächste Viertelstunde ein', () => {
expect(aufRaster(new Date('2026-03-17T09:07:00Z')).toISOString()).toBe(
'2026-03-17T09:00:00.000Z',
)
expect(aufRaster(new Date('2026-03-17T09:08:00Z')).toISOString()).toBe(
'2026-03-17T09:15:00.000Z',
)
})
it('lässt einen bereits eingerasteten Zeitpunkt in Ruhe', () => {
expect(aufRaster(beginn).toISOString()).toBe(beginn.toISOString())
})
})
describe('lagePruefen', () => {
it('nimmt eine gültige Lage an', () => {
expect(lagePruefen(beginn, ende)).toBeNull()
})
it('weist ein Ende vor dem Beginn ab', () => {
expect(lagePruefen(ende, beginn)).toBe('ende-vor-beginn')
expect(lagePruefen(beginn, beginn)).toBe('ende-vor-beginn')
})
it('weist zu kurze Blöcke ab und nimmt die Untergrenze an', () => {
const knappZuKurz = new Date(beginn.getTime() + (ZEITBLOCK_MIN_MINUTEN - 15) * 60_000)
expect(lagePruefen(beginn, knappZuKurz)).toBe('ende-vor-beginn')
const genauMinimum = new Date(beginn.getTime() + ZEITBLOCK_MIN_MINUTEN * 60_000)
expect(lagePruefen(beginn, genauMinimum)).toBeNull()
})
it('weist zu lange Blöcke ab und nimmt die Obergrenze an', () => {
const genauMaximum = new Date(beginn.getTime() + ZEITBLOCK_MAX_MINUTEN * 60_000)
expect(lagePruefen(beginn, genauMaximum)).toBeNull()
const zuLang = new Date(
beginn.getTime() + (ZEITBLOCK_MAX_MINUTEN + RASTER_MINUTEN) * 60_000,
)
expect(lagePruefen(beginn, zuLang)).toBe('zu-lang')
})
it('weist Zeiten außerhalb des Rasters ab', () => {
expect(lagePruefen(new Date('2026-03-17T09:07:00Z'), ende)).toBe('nicht-im-raster')
expect(lagePruefen(beginn, new Date('2026-03-17T10:07:00Z'))).toBe('nicht-im-raster')
})
})
describe('ueberschneidetSich', () => {
const block = { beginn, ende }
it('erkennt eine Überschneidung', () => {
expect(
ueberschneidetSich(block, {
beginn: new Date('2026-03-17T09:30:00Z'),
ende: new Date('2026-03-17T10:30:00Z'),
}),
).toBe(true)
})
it('zählt reines Aneinandergrenzen nicht', () => {
expect(
ueberschneidetSich(block, {
beginn: ende,
ende: new Date('2026-03-17T11:00:00Z'),
}),
).toBe(false)
})
it('erkennt vollständige Umschließung', () => {
expect(
ueberschneidetSich(block, {
beginn: new Date('2026-03-17T08:00:00Z'),
ende: new Date('2026-03-17T12:00:00Z'),
}),
).toBe(true)
})
it('erkennt getrennte Zeiträume', () => {
expect(
ueberschneidetSich(block, {
beginn: new Date('2026-03-17T14:00:00Z'),
ende: new Date('2026-03-17T15:00:00Z'),
}),
).toBe(false)
})
})
describe('zeitblockSchema', () => {
const gueltig = {
aufgabeId: 'a1',
kalenderkontoId: 'k1',
beginn: '2026-03-17T09:00:00.000Z',
ende: '2026-03-17T10:00:00.000Z',
}
it('nimmt eine gültige Eingabe an', () => {
const ergebnis = zeitblockSchema.parse(gueltig)
expect(ergebnis.beginn).toBeInstanceOf(Date)
expect(dauerMinuten(ergebnis.beginn, ergebnis.ende)).toBe(60)
})
it('weist eine unmögliche Lage ab', () => {
expect(
zeitblockSchema.safeParse({ ...gueltig, ende: '2026-03-17T08:00:00.000Z' }).success,
).toBe(false)
})
it('weist Zeiten außerhalb des Rasters ab', () => {
expect(
zeitblockSchema.safeParse({ ...gueltig, beginn: '2026-03-17T09:07:00.000Z' })
.success,
).toBe(false)
})
it('verlangt Aufgabe und Kalenderkonto', () => {
expect(zeitblockSchema.safeParse({ ...gueltig, aufgabeId: '' }).success).toBe(false)
expect(zeitblockSchema.safeParse({ ...gueltig, kalenderkontoId: '' }).success).toBe(
false,
)
})
})
+177
View File
@@ -0,0 +1,177 @@
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>