diff --git a/prisma/migrations/20260910184542_zeitbloecke/migration.sql b/prisma/migrations/20260910184542_zeitbloecke/migration.sql new file mode 100644 index 0000000..51cabeb --- /dev/null +++ b/prisma/migrations/20260910184542_zeitbloecke/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8c3c8d1..70c018f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -222,8 +222,9 @@ model Aufgabe { erstelltAm DateTime @default(now()) aktualisiertAm DateTime @updatedAt - bereich Bereich? @relation(fields: [bereichId], references: [id], onDelete: SetNull) - projekt Projekt? @relation(fields: [projektId], references: [id], onDelete: SetNull) + 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 @@ -305,9 +306,10 @@ model Kalenderkonto { erstelltAm DateTime @default(now()) aktualisiertAm DateTime @updatedAt - benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) + benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) termine Termin[] - standardFuerBereiche Bereich[] @relation("StandardKalenderkonto") + 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") +} diff --git a/src/lib/zeitblock.test.ts b/src/lib/zeitblock.test.ts new file mode 100644 index 0000000..596e168 --- /dev/null +++ b/src/lib/zeitblock.test.ts @@ -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, + ) + }) +}) diff --git a/src/lib/zeitblock.ts b/src/lib/zeitblock.ts new file mode 100644 index 0000000..7b3d687 --- /dev/null +++ b/src/lib/zeitblock.ts @@ -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, 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