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
+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,
)
})
})