diff --git a/src/lib/kalender/belegung.test.ts b/src/lib/kalender/belegung.test.ts new file mode 100644 index 0000000..d0acc0c --- /dev/null +++ b/src/lib/kalender/belegung.test.ts @@ -0,0 +1,337 @@ +import { describe, expect, it } from 'vitest' +import { + arbeitsfensterAmTag, + belegteMinuten, + belegtZeit, + freieFenster, + kapazitaetAmTag, + tagesBelegung, + termineAmTag, + ueberschneidungMinuten, + type BelegenderTermin, + type BereichKapazitaet, +} from './belegung' + +// Dienstag, 17. März 2026. Berlin liegt dann noch auf UTC+1. +const dienstag = new Date('2026-03-17T09:00:00Z') + +const bereich: BereichKapazitaet = { + kapazitaetMo: 360, + kapazitaetDi: 240, + kapazitaetMi: 360, + kapazitaetDo: 360, + kapazitaetFr: 120, + kapazitaetSa: 0, + kapazitaetSo: 0, + // 08:00 bis 17:00 Berliner Zeit. + arbeitsfensterVon: 480, + arbeitsfensterBis: 1020, +} + +function termin( + vonUtc: string, + bisUtc: string, + teil: Partial = {}, +): BelegenderTermin { + return { + beginn: new Date(vonUtc), + ende: new Date(bisUtc), + ganztaegig: false, + zeigtAls: 'GEBUCHT', + ...teil, + } +} + +describe('belegtZeit', () => { + it('zählt einen gewöhnlichen Termin', () => { + expect(belegtZeit(termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'))).toBe(true) + }) + + it('zählt ganztägige Termine nicht', () => { + // Ein Urlaubstag oder Geburtstag kostet keine Arbeitszeit im Fenster. + expect( + belegtZeit( + termin('2026-03-17T00:00:00Z', '2026-03-18T00:00:00Z', { ganztaegig: true }), + ), + ).toBe(false) + }) + + it('zählt als frei markierte Termine nicht', () => { + expect( + belegtZeit( + termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z', { zeigtAls: 'FREI' }), + ), + ).toBe(false) + }) + + it('zählt Vorbehalt und Abwesenheit sehr wohl', () => { + for (const zeigtAls of [ + 'MIT_VORBEHALT', + 'ABWESEND', + 'WOANDERS', + 'UNBEKANNT', + ] as const) { + expect( + belegtZeit(termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z', { zeigtAls })), + ).toBe(true) + } + }) +}) + +describe('ueberschneidungMinuten', () => { + const fenster = { + von: new Date('2026-03-17T08:00:00Z'), + bis: new Date('2026-03-17T12:00:00Z'), + } + + it('rechnet eine vollständige Überschneidung', () => { + expect( + ueberschneidungMinuten( + { von: new Date('2026-03-17T09:00:00Z'), bis: new Date('2026-03-17T10:00:00Z') }, + fenster, + ), + ).toBe(60) + }) + + it('schneidet an den Rändern ab', () => { + expect( + ueberschneidungMinuten( + { von: new Date('2026-03-17T07:00:00Z'), bis: new Date('2026-03-17T09:00:00Z') }, + fenster, + ), + ).toBe(60) + }) + + it('ist ohne Überschneidung null', () => { + expect( + ueberschneidungMinuten( + { von: new Date('2026-03-17T13:00:00Z'), bis: new Date('2026-03-17T14:00:00Z') }, + fenster, + ), + ).toBe(0) + }) + + it('zählt reines Aneinandergrenzen nicht als Überschneidung', () => { + expect( + ueberschneidungMinuten( + { von: new Date('2026-03-17T12:00:00Z'), bis: new Date('2026-03-17T13:00:00Z') }, + fenster, + ), + ).toBe(0) + }) +}) + +describe('arbeitsfensterAmTag', () => { + it('legt das Fenster in Berliner Ortszeit', () => { + const fenster = arbeitsfensterAmTag(dienstag, 480, 1020) + // 08:00 Berlin = 07:00 UTC in der Winterzeit. + expect(fenster.von.toISOString()).toBe('2026-03-17T07:00:00.000Z') + expect(fenster.bis.toISOString()).toBe('2026-03-17T16:00:00.000Z') + }) + + it('verschiebt sich mit der Sommerzeit', () => { + // Ab dem 29. März 2026 gilt in Berlin UTC+2. + const fenster = arbeitsfensterAmTag(new Date('2026-04-07T09:00:00Z'), 480, 1020) + expect(fenster.von.toISOString()).toBe('2026-04-07T06:00:00.000Z') + expect(fenster.bis.toISOString()).toBe('2026-04-07T15:00:00.000Z') + }) +}) + +describe('kapazitaetAmTag', () => { + it('nimmt den richtigen Wochentag', () => { + expect(kapazitaetAmTag(bereich, dienstag)).toBe(240) + // Montag, 16. März. + expect(kapazitaetAmTag(bereich, new Date('2026-03-16T09:00:00Z'))).toBe(360) + // Sonntag, 22. März. + expect(kapazitaetAmTag(bereich, new Date('2026-03-22T09:00:00Z'))).toBe(0) + }) +}) + +describe('belegteMinuten', () => { + const fenster = arbeitsfensterAmTag(dienstag, 480, 1020) + + it('ist ohne Termine null', () => { + expect(belegteMinuten([], fenster)).toBe(0) + }) + + it('summiert getrennte Termine', () => { + expect( + belegteMinuten( + [ + termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'), + termin('2026-03-17T10:00:00Z', '2026-03-17T11:00:00Z'), + ], + fenster, + ), + ).toBe(120) + }) + + it('legt überlappende Termine zusammen, statt doppelt zu zählen', () => { + expect( + belegteMinuten( + [ + termin('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z'), + termin('2026-03-17T09:00:00Z', '2026-03-17T11:00:00Z'), + ], + fenster, + ), + ).toBe(180) + }) + + it('legt lückenlos aneinandergrenzende Termine zusammen', () => { + expect( + belegteMinuten( + [ + termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'), + termin('2026-03-17T09:00:00Z', '2026-03-17T10:00:00Z'), + ], + fenster, + ), + ).toBe(120) + }) + + it('zählt einen vollständig enthaltenen Termin nur einmal', () => { + expect( + belegteMinuten( + [ + termin('2026-03-17T08:00:00Z', '2026-03-17T12:00:00Z'), + termin('2026-03-17T09:00:00Z', '2026-03-17T10:00:00Z'), + ], + fenster, + ), + ).toBe(240) + }) + + it('schneidet an den Rändern des Arbeitsfensters ab', () => { + // 06:00–08:00 UTC = 07:00–09:00 Berlin; nur die zweite Stunde zählt. + expect( + belegteMinuten([termin('2026-03-17T06:00:00Z', '2026-03-17T08:00:00Z')], fenster), + ).toBe(60) + }) + + it('lässt Termine außerhalb des Fensters ganz weg', () => { + // 20:00 Berlin, das Fenster endet um 17:00. + expect( + belegteMinuten([termin('2026-03-17T19:00:00Z', '2026-03-17T20:00:00Z')], fenster), + ).toBe(0) + }) + + it('lässt ganztägige und freie Termine weg', () => { + expect( + belegteMinuten( + [ + termin('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z', { ganztaegig: true }), + termin('2026-03-17T10:00:00Z', '2026-03-17T11:00:00Z', { zeigtAls: 'FREI' }), + ], + fenster, + ), + ).toBe(0) + }) +}) + +describe('tagesBelegung', () => { + it('rechnet Kapazität und Termine gegeneinander', () => { + const belegung = tagesBelegung( + bereich, + [termin('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z')], + dienstag, + ) + expect(belegung.kapazitaet).toBe(240) + expect(belegung.belegt).toBe(120) + expect(belegung.frei).toBe(120) + expect(belegung.prozent).toBe(50) + expect(belegung.ueberbucht).toBe(false) + }) + + it('erkennt einen überbuchten Tag', () => { + const belegung = tagesBelegung( + bereich, + [termin('2026-03-17T07:00:00Z', '2026-03-17T13:00:00Z')], + dienstag, + ) + expect(belegung.belegt).toBe(360) + expect(belegung.frei).toBe(0) + expect(belegung.ueberbucht).toBe(true) + expect(belegung.prozent).toBe(150) + }) + + it('behandelt einen Tag ohne Kapazität als voll', () => { + // Sonntag: keine Kapazität, also gibt es dort nichts zu planen. + const belegung = tagesBelegung(bereich, [], new Date('2026-03-22T09:00:00Z')) + expect(belegung.kapazitaet).toBe(0) + expect(belegung.prozent).toBe(100) + expect(belegung.frei).toBe(0) + }) +}) + +describe('freieFenster', () => { + const fenster = arbeitsfensterAmTag(dienstag, 480, 1020) + + it('gibt ohne Termine das ganze Arbeitsfenster zurück', () => { + const luecken = freieFenster([], fenster) + expect(luecken).toHaveLength(1) + expect(luecken[0]?.von.toISOString()).toBe('2026-03-17T07:00:00.000Z') + expect(luecken[0]?.bis.toISOString()).toBe('2026-03-17T16:00:00.000Z') + }) + + it('findet die Lücke zwischen zwei Terminen', () => { + const luecken = freieFenster( + [ + termin('2026-03-17T07:00:00Z', '2026-03-17T09:00:00Z'), + termin('2026-03-17T11:00:00Z', '2026-03-17T16:00:00Z'), + ], + fenster, + ) + expect(luecken).toHaveLength(1) + expect(luecken[0]?.von.toISOString()).toBe('2026-03-17T09:00:00.000Z') + expect(luecken[0]?.bis.toISOString()).toBe('2026-03-17T11:00:00.000Z') + }) + + it('lässt zu kurze Lücken weg', () => { + const luecken = freieFenster( + [ + termin('2026-03-17T07:00:00Z', '2026-03-17T09:00:00Z'), + // Nur zehn Minuten Pause. + termin('2026-03-17T09:10:00Z', '2026-03-17T16:00:00Z'), + ], + fenster, + 15, + ) + expect(luecken).toEqual([]) + }) + + it('ist bei durchgehender Belegung leer', () => { + expect( + freieFenster([termin('2026-03-17T07:00:00Z', '2026-03-17T16:00:00Z')], fenster), + ).toEqual([]) + }) + + it('übergeht ganztägige Termine und findet den Tag frei', () => { + const luecken = freieFenster( + [termin('2026-03-17T00:00:00Z', '2026-03-18T00:00:00Z', { ganztaegig: true })], + fenster, + ) + expect(luecken).toHaveLength(1) + }) +}) + +describe('termineAmTag', () => { + it('nimmt, was den Berliner Kalendertag berührt', () => { + const liste = [ + termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'), + termin('2026-03-18T08:00:00Z', '2026-03-18T09:00:00Z'), + ] + expect(termineAmTag(liste, dienstag)).toHaveLength(1) + }) + + it('nimmt auch einen Termin, der in den Tag hineinragt', () => { + const uebernacht = termin('2026-03-16T22:00:00Z', '2026-03-17T02:00:00Z') + expect(termineAmTag([uebernacht], dienstag)).toHaveLength(1) + }) + + it('lässt einen Termin weg, der genau am Tagesbeginn endet', () => { + // 23:00 UTC am 16. = 00:00 Berlin am 17., also gerade noch der Vortag. + const davor = termin('2026-03-16T21:00:00Z', '2026-03-16T23:00:00Z') + expect(termineAmTag([davor], dienstag)).toEqual([]) + }) +}) diff --git a/src/lib/kalender/belegung.ts b/src/lib/kalender/belegung.ts new file mode 100644 index 0000000..94306b9 --- /dev/null +++ b/src/lib/kalender/belegung.ts @@ -0,0 +1,217 @@ +import type { ZeigtAls } from '@/generated/prisma/enums' +import { tagesBeginn, tagesEnde, wochentagIndex } from '@/lib/zeit' + +/** + * Wie voll ein Tag schon ist. + * + * Reine Logik mit dem Zeitpunkt als Parameter. Alles rechnet in Minuten und in + * Europe/Berlin — dieselbe Tagesgrenze wie überall sonst im Planer. + */ + +export type BelegenderTermin = { + beginn: Date + ende: Date + ganztaegig: boolean + zeigtAls: ZeigtAls +} + +export type Zeitfenster = { von: Date; bis: Date } + +/** + * Zählt ein Termin gegen die Kapazität? + * + * Ganztägige Termine nicht — ein Urlaubstag oder ein Geburtstag im Kalender + * blockiert sonst den ganzen Tag, obwohl er gar keine Arbeitszeit kostet. + * Ebenso wenig zählen Termine, die ausdrücklich als frei markiert sind. + */ +export function belegtZeit(termin: BelegenderTermin): boolean { + if (termin.ganztaegig) return false + if (termin.zeigtAls === 'FREI') return false + return true +} + +/** Überschneidung zweier Zeiträume in Minuten; ohne Überschneidung null. */ +export function ueberschneidungMinuten(a: Zeitfenster, b: Zeitfenster): number { + const von = Math.max(a.von.getTime(), b.von.getTime()) + const bis = Math.min(a.bis.getTime(), b.bis.getTime()) + if (bis <= von) return 0 + return Math.round((bis - von) / 60_000) +} + +/** Das Arbeitsfenster eines Bereichs an einem bestimmten Tag. */ +export function arbeitsfensterAmTag( + tag: Date, + arbeitsfensterVon: number, + arbeitsfensterBis: number, +): Zeitfenster { + const beginn = tagesBeginn(tag) + return { + von: new Date(beginn.getTime() + arbeitsfensterVon * 60_000), + bis: new Date(beginn.getTime() + arbeitsfensterBis * 60_000), + } +} + +/** + * Wie viele Minuten des Arbeitsfensters an diesem Tag schon durch Termine + * belegt sind. + * + * Überlappende Termine werden zusammengelegt, damit eine Doppelbuchung nicht + * doppelt zählt. + */ +export function belegteMinuten( + termine: readonly BelegenderTermin[], + fenster: Zeitfenster, +): number { + const abschnitte = termine + .filter(belegtZeit) + .map((t) => ({ von: t.beginn, bis: t.ende })) + .filter((t) => ueberschneidungMinuten(t, fenster) > 0) + .map((t) => ({ + von: new Date(Math.max(t.von.getTime(), fenster.von.getTime())), + bis: new Date(Math.min(t.bis.getTime(), fenster.bis.getTime())), + })) + .sort((a, b) => a.von.getTime() - b.von.getTime()) + + let summe = 0 + let offenVon: Date | null = null + let offenBis: Date | null = null + + for (const abschnitt of abschnitte) { + if (offenVon === null || offenBis === null) { + offenVon = abschnitt.von + offenBis = abschnitt.bis + continue + } + if (abschnitt.von.getTime() <= offenBis.getTime()) { + // Überlappt oder schließt an – zusammenlegen. + if (abschnitt.bis.getTime() > offenBis.getTime()) offenBis = abschnitt.bis + continue + } + summe += Math.round((offenBis.getTime() - offenVon.getTime()) / 60_000) + offenVon = abschnitt.von + offenBis = abschnitt.bis + } + + if (offenVon !== null && offenBis !== null) { + summe += Math.round((offenBis.getTime() - offenVon.getTime()) / 60_000) + } + + return summe +} + +export type TagesBelegung = { + /** Tageskapazität des Bereichs an diesem Wochentag, in Minuten. */ + kapazitaet: number + /** Durch Termine belegte Minuten innerhalb des Arbeitsfensters. */ + belegt: number + /** Was von der Kapazität übrig bleibt; nie negativ. */ + frei: number + /** Belegung in Prozent der Kapazität, gerundet. Ohne Kapazität 100. */ + prozent: number + /** Mehr Termine als Kapazität – der Tag ist überbucht. */ + ueberbucht: boolean +} + +export type BereichKapazitaet = { + kapazitaetMo: number + kapazitaetDi: number + kapazitaetMi: number + kapazitaetDo: number + kapazitaetFr: number + kapazitaetSa: number + kapazitaetSo: number + arbeitsfensterVon: number + arbeitsfensterBis: number +} + +const KAPAZITAET_JE_TAG = [ + 'kapazitaetMo', + 'kapazitaetDi', + 'kapazitaetMi', + 'kapazitaetDo', + 'kapazitaetFr', + 'kapazitaetSa', + 'kapazitaetSo', +] as const + +/** Kapazität des Bereichs an genau diesem Tag. */ +export function kapazitaetAmTag(bereich: BereichKapazitaet, tag: Date): number { + const feld = KAPAZITAET_JE_TAG[wochentagIndex(tag)] + return feld ? bereich[feld] : 0 +} + +/** + * Rechnet Kapazität und Termine eines Tages gegeneinander. + * + * Gezählt wird nur, was ins Arbeitsfenster fällt: ein Termin um 22 Uhr + * verkleinert die Kapazität eines Bereichs nicht, dessen Fenster um 17 Uhr + * endet. + */ +export function tagesBelegung( + bereich: BereichKapazitaet, + termine: readonly BelegenderTermin[], + tag: Date, +): TagesBelegung { + const kapazitaet = kapazitaetAmTag(bereich, tag) + const fenster = arbeitsfensterAmTag( + tag, + bereich.arbeitsfensterVon, + bereich.arbeitsfensterBis, + ) + const belegt = belegteMinuten(termine, fenster) + + return { + kapazitaet, + belegt, + frei: Math.max(0, kapazitaet - belegt), + prozent: kapazitaet === 0 ? 100 : Math.round((belegt / kapazitaet) * 100), + ueberbucht: belegt > kapazitaet, + } +} + +/** + * Freie Abschnitte innerhalb des Arbeitsfensters eines Tages — die Lücken, + * in die ab M4 Zeitblöcke gelegt werden können. + */ +export function freieFenster( + termine: readonly BelegenderTermin[], + fenster: Zeitfenster, + mindestensMinuten = 15, +): Zeitfenster[] { + const belegt = termine + .filter(belegtZeit) + .map((t) => ({ + von: new Date(Math.max(t.beginn.getTime(), fenster.von.getTime())), + bis: new Date(Math.min(t.ende.getTime(), fenster.bis.getTime())), + })) + .filter((t) => t.bis.getTime() > t.von.getTime()) + .sort((a, b) => a.von.getTime() - b.von.getTime()) + + const luecken: Zeitfenster[] = [] + let cursor = fenster.von + + for (const abschnitt of belegt) { + if (abschnitt.von.getTime() > cursor.getTime()) { + luecken.push({ von: cursor, bis: abschnitt.von }) + } + if (abschnitt.bis.getTime() > cursor.getTime()) cursor = abschnitt.bis + } + + if (cursor.getTime() < fenster.bis.getTime()) { + luecken.push({ von: cursor, bis: fenster.bis }) + } + + return luecken.filter( + (l) => (l.bis.getTime() - l.von.getTime()) / 60_000 >= mindestensMinuten, + ) +} + +/** Termine, die an diesem Kalendertag überhaupt vorkommen. */ +export function termineAmTag( + termine: readonly T[], + tag: Date, +): T[] { + const von = tagesBeginn(tag).getTime() + const bis = tagesEnde(tag).getTime() + return termine.filter((t) => t.beginn.getTime() <= bis && t.ende.getTime() > von) +} diff --git a/src/lib/kalender/google.test.ts b/src/lib/kalender/google.test.ts new file mode 100644 index 0000000..8c6a274 --- /dev/null +++ b/src/lib/kalender/google.test.ts @@ -0,0 +1,104 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { googleSeiteLesen, googleZeitpunkt, zeigtAlsAusGoogle } from './google' +import { AntwortUnbrauchbar } from './typen' + +function fixture(name: string): unknown { + return JSON.parse( + readFileSync(`test/fixtures/kalender/${name}.json`, 'utf8'), + ) as unknown +} + +describe('zeigtAlsAusGoogle', () => { + it('kennt nur frei und gebucht', () => { + expect(zeigtAlsAusGoogle('transparent')).toBe('FREI') + expect(zeigtAlsAusGoogle('opaque')).toBe('GEBUCHT') + }) + + it('nimmt ein fehlendes Feld als gebucht', () => { + // Google lässt transparency weg, wenn der Termin die Zeit belegt. + expect(zeigtAlsAusGoogle(undefined)).toBe('GEBUCHT') + }) +}) + +describe('googleZeitpunkt', () => { + it('liest eine Zeit mit Versatz', () => { + const ergebnis = googleZeitpunkt({ dateTime: '2026-03-17T14:00:00+01:00' }) + expect(ergebnis.zeitpunkt.toISOString()).toBe('2026-03-17T13:00:00.000Z') + expect(ergebnis.ganztaegig).toBe(false) + }) + + it('erkennt ein reines Datum als ganztägig', () => { + const ergebnis = googleZeitpunkt({ date: '2026-03-20' }) + expect(ergebnis.zeitpunkt.toISOString()).toBe('2026-03-20T00:00:00.000Z') + expect(ergebnis.ganztaegig).toBe(true) + }) + + it('bricht ab, wenn beides fehlt', () => { + expect(() => googleZeitpunkt({})).toThrow(AntwortUnbrauchbar) + }) + + it('bricht bei unlesbaren Angaben ab', () => { + expect(() => googleZeitpunkt({ dateTime: 'irgendwann' })).toThrow(AntwortUnbrauchbar) + expect(() => googleZeitpunkt({ date: '2026-13-45' })).toThrow(AntwortUnbrauchbar) + }) +}) + +describe('googleSeiteLesen', () => { + it('liest die erste Seite samt Seitenverweis', () => { + const seite = googleSeiteLesen(fixture('google-events-seite1')) + expect(seite.geaendert).toHaveLength(2) + expect(seite.weiter).toBe('BEISPIEL-SEITE-2') + expect(seite.deltaVerweis).toBeNull() + }) + + it('rechnet den Versatz nach UTC um', () => { + const [erster] = googleSeiteLesen(fixture('google-events-seite1')).geaendert + expect(erster?.extId).toBe('beispiel-termin-a') + expect(erster?.betreff).toBe('Zahnarzt') + expect(erster?.beginn.toISOString()).toBe('2026-03-17T13:00:00.000Z') + expect(erster?.ende.toISOString()).toBe('2026-03-17T14:00:00.000Z') + expect(erster?.ganztaegig).toBe(false) + }) + + it('erkennt ganztägige Termine am reinen Datum', () => { + const ganztaegig = googleSeiteLesen(fixture('google-events-seite1')).geaendert[1] + expect(ganztaegig?.ganztaegig).toBe(true) + expect(ganztaegig?.betreff).toBe('Urlaubstag') + }) + + it('liest die letzte Seite mit dem Sync-Verweis', () => { + const seite = googleSeiteLesen(fixture('google-events-seite2')) + expect(seite.weiter).toBeNull() + expect(seite.deltaVerweis).toBe('BEISPIEL-SYNC-1') + }) + + it('behandelt abgesagte Termine als entfernt', () => { + const seite = googleSeiteLesen(fixture('google-events-seite2')) + expect(seite.entfernt).toEqual(['beispiel-termin-d']) + expect(seite.geaendert.map((t) => t.extId)).toEqual(['beispiel-termin-c']) + }) + + it('erkennt einen durchlässigen Termin als frei', () => { + const [frei] = googleSeiteLesen(fixture('google-events-seite2')).geaendert + expect(frei?.zeigtAls).toBe('FREI') + }) + + it('kommt mit einer leeren Antwort zurecht', () => { + const seite = googleSeiteLesen(fixture('google-events-leer')) + expect(seite.geaendert).toEqual([]) + expect(seite.entfernt).toEqual([]) + expect(seite.deltaVerweis).toBe('BEISPIEL-SYNC-2') + }) + + it('weist eine Antwort mit falschem Aufbau ab', () => { + expect(() => googleSeiteLesen({ items: 'keine Liste' })).toThrow(AntwortUnbrauchbar) + }) + + it('übergeht Einträge ohne Zeitraum', () => { + const seite = googleSeiteLesen({ + items: [{ id: 'ohne-zeit', status: 'confirmed', summary: 'Kein Zeitraum' }], + }) + expect(seite.geaendert).toEqual([]) + }) +}) diff --git a/src/lib/kalender/google.ts b/src/lib/kalender/google.ts new file mode 100644 index 0000000..074333d --- /dev/null +++ b/src/lib/kalender/google.ts @@ -0,0 +1,118 @@ +import { z } from 'zod' +import type { ZeigtAls } from '@/generated/prisma/enums' +import { + AntwortUnbrauchbar, + type AbgleichSeite, + type RohTermin, +} from '@/lib/kalender/typen' + +/** + * Lesen der Antworten der Google-Kalender-API (`events.list`). + * + * Reine Logik: nimmt geparsten JSON entgegen, prüft ihn mit zod und gibt die + * gemeinsame Form zurück. + */ + +/** + * Google liefert entweder `date` (ganztägig, reines Datum) oder `dateTime` + * (mit Versatz). Genau eines von beidem. + */ +const googleZeit = z.looseObject({ + date: z.string().optional(), + dateTime: z.string().optional(), + timeZone: z.string().optional(), +}) + +const googleTermin = z.looseObject({ + id: z.string().min(1), + etag: z.string().optional(), + status: z.string().optional(), + summary: z.string().nullable().optional(), + start: googleZeit.optional(), + end: googleZeit.optional(), + transparency: z.string().optional(), +}) + +const googleAntwort = z.looseObject({ + items: z.array(googleTermin).default([]), + nextPageToken: z.string().optional(), + nextSyncToken: z.string().optional(), +}) + +/** + * Google kennt nur zwei Zustände: `transparency: "transparent"` heißt frei, + * alles andere (auch das Fehlen des Feldes) heißt gebucht. + */ +export function zeigtAlsAusGoogle(transparency: string | undefined): ZeigtAls { + return transparency === 'transparent' ? 'FREI' : 'GEBUCHT' +} + +export type GoogleZeit = { date?: string | undefined; dateTime?: string | undefined } + +/** + * Wandelt Googles Zeitangabe in einen Zeitpunkt und sagt, ob sie ganztägig + * war. Ein reines Datum wird als Mitternacht UTC gelesen — ganztägige Termine + * zählen ohnehin nicht zur Belegung, es geht nur um die Einordnung im Tag. + */ +export function googleZeitpunkt(zeit: GoogleZeit): { + zeitpunkt: Date + ganztaegig: boolean +} { + if (zeit.date) { + const zeitpunkt = new Date(`${zeit.date}T00:00:00Z`) + if (Number.isNaN(zeitpunkt.getTime())) { + throw new AntwortUnbrauchbar('Google', 'Datumsangabe nicht lesbar') + } + return { zeitpunkt, ganztaegig: true } + } + + if (zeit.dateTime) { + const zeitpunkt = new Date(zeit.dateTime) + if (Number.isNaN(zeitpunkt.getTime())) { + throw new AntwortUnbrauchbar('Google', 'Zeitangabe nicht lesbar') + } + return { zeitpunkt, ganztaegig: false } + } + + throw new AntwortUnbrauchbar('Google', 'Zeitangabe fehlt') +} + +/** Liest eine Antwortseite von `events.list`. */ +export function googleSeiteLesen(inhalt: unknown): AbgleichSeite { + const gelesen = googleAntwort.safeParse(inhalt) + if (!gelesen.success) { + throw new AntwortUnbrauchbar('Google', 'unerwarteter Aufbau') + } + + const geaendert: RohTermin[] = [] + const entfernt: string[] = [] + + for (const eintrag of gelesen.data.items) { + if (eintrag.status === 'cancelled') { + entfernt.push(eintrag.id) + continue + } + + if (!eintrag.start || !eintrag.end) continue + + const beginn = googleZeitpunkt(eintrag.start) + const ende = googleZeitpunkt(eintrag.end) + + geaendert.push({ + extId: eintrag.id, + etag: eintrag.etag ?? null, + betreff: eintrag.summary ?? null, + beginn: beginn.zeitpunkt, + ende: ende.zeitpunkt, + ganztaegig: beginn.ganztaegig, + zeigtAls: zeigtAlsAusGoogle(eintrag.transparency), + }) + } + + return { + geaendert, + entfernt, + weiter: gelesen.data.nextPageToken ?? null, + deltaVerweis: gelesen.data.nextSyncToken ?? null, + } +} diff --git a/src/lib/kalender/graph.test.ts b/src/lib/kalender/graph.test.ts new file mode 100644 index 0000000..34d942a --- /dev/null +++ b/src/lib/kalender/graph.test.ts @@ -0,0 +1,130 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { graphSeiteLesen, graphZeitpunkt, zeigtAlsAusGraph } from './graph' +import { AntwortUnbrauchbar } from './typen' + +/** Beispielantworten aus test/fixtures, anonymisiert. */ +function fixture(name: string): unknown { + return JSON.parse( + readFileSync(`test/fixtures/kalender/${name}.json`, 'utf8'), + ) as unknown +} + +describe('zeigtAlsAusGraph', () => { + it('übersetzt die bekannten Werte', () => { + expect(zeigtAlsAusGraph('free')).toBe('FREI') + expect(zeigtAlsAusGraph('busy')).toBe('GEBUCHT') + expect(zeigtAlsAusGraph('tentative')).toBe('MIT_VORBEHALT') + expect(zeigtAlsAusGraph('oof')).toBe('ABWESEND') + expect(zeigtAlsAusGraph('workingElsewhere')).toBe('WOANDERS') + }) + + it('nimmt Unbekanntes als unbekannt, statt es zu raten', () => { + expect(zeigtAlsAusGraph('etwasNeues')).toBe('UNBEKANNT') + expect(zeigtAlsAusGraph(undefined)).toBe('UNBEKANNT') + }) +}) + +describe('graphZeitpunkt', () => { + it('liest eine UTC-Angabe', () => { + expect( + graphZeitpunkt({ + dateTime: '2026-03-17T08:00:00.0000000', + timeZone: 'UTC', + }).toISOString(), + ).toBe('2026-03-17T08:00:00.000Z') + }) + + it('kürzt die sieben Nachkommastellen, die Date nicht versteht', () => { + expect( + graphZeitpunkt({ + dateTime: '2026-03-17T08:00:00.1234567', + timeZone: 'UTC', + }).toISOString(), + ).toBe('2026-03-17T08:00:00.123Z') + }) + + it('kommt auch ohne Nachkommastellen zurecht', () => { + expect( + graphZeitpunkt({ dateTime: '2026-03-17T08:00:00', timeZone: 'UTC' }).toISOString(), + ).toBe('2026-03-17T08:00:00.000Z') + }) + + it('bricht bei einer anderen Zeitzone ab, statt Termine zu verschieben', () => { + expect(() => + graphZeitpunkt({ + dateTime: '2026-03-17T09:00:00.0000000', + timeZone: 'W. Europe Standard Time', + }), + ).toThrow(AntwortUnbrauchbar) + }) + + it('bricht bei unlesbarer Zeitangabe ab', () => { + expect(() => graphZeitpunkt({ dateTime: 'morgen früh', timeZone: 'UTC' })).toThrow( + AntwortUnbrauchbar, + ) + }) +}) + +describe('graphSeiteLesen', () => { + it('liest die erste Seite samt Verweis auf die nächste', () => { + const seite = graphSeiteLesen(fixture('graph-delta-seite1')) + + expect(seite.geaendert).toHaveLength(2) + expect(seite.entfernt).toEqual([]) + expect(seite.weiter).toContain('$skiptoken=') + expect(seite.deltaVerweis).toBeNull() + }) + + it('übernimmt Betreff, Kennung und Etag', () => { + const [erster] = graphSeiteLesen(fixture('graph-delta-seite1')).geaendert + expect(erster?.extId).toBe('BEISPIEL-TERMIN-1') + expect(erster?.betreff).toBe('Wochenbesprechung') + expect(erster?.etag).toContain('BEISPIELETAG1') + expect(erster?.beginn.toISOString()).toBe('2026-03-17T08:00:00.000Z') + expect(erster?.ende.toISOString()).toBe('2026-03-17T09:00:00.000Z') + expect(erster?.ganztaegig).toBe(false) + expect(erster?.zeigtAls).toBe('GEBUCHT') + }) + + it('erkennt ganztägige Termine', () => { + const ganztaegig = graphSeiteLesen(fixture('graph-delta-seite1')).geaendert[1] + expect(ganztaegig?.ganztaegig).toBe(true) + expect(ganztaegig?.zeigtAls).toBe('ABWESEND') + }) + + it('liest die letzte Seite mit dem Delta-Verweis für die nächste Runde', () => { + const seite = graphSeiteLesen(fixture('graph-delta-seite2')) + expect(seite.weiter).toBeNull() + expect(seite.deltaVerweis).toContain('$deltatoken=') + }) + + it('trennt Entferntes von Geändertem', () => { + const seite = graphSeiteLesen(fixture('graph-delta-seite2')) + expect(seite.entfernt).toEqual(['BEISPIEL-TERMIN-9']) + expect(seite.geaendert.map((t) => t.extId)).toEqual(['BEISPIEL-TERMIN-3']) + }) + + it('erkennt einen als frei markierten Termin', () => { + const [frei] = graphSeiteLesen(fixture('graph-delta-seite2')).geaendert + expect(frei?.zeigtAls).toBe('FREI') + }) + + it('bricht ab, wenn Graph eine andere Zeitzone liefert', () => { + expect(() => graphSeiteLesen(fixture('graph-delta-fremde-zeitzone'))).toThrow( + AntwortUnbrauchbar, + ) + }) + + it('weist eine Antwort ohne value-Liste ab', () => { + expect(() => graphSeiteLesen({ irgendwas: true })).toThrow(AntwortUnbrauchbar) + }) + + it('übergeht Einträge ohne Zeitraum, statt abzubrechen', () => { + const seite = graphSeiteLesen({ + value: [{ id: 'ohne-zeit', subject: 'Kein Zeitraum' }], + }) + expect(seite.geaendert).toEqual([]) + expect(seite.entfernt).toEqual([]) + }) +}) diff --git a/src/lib/kalender/graph.ts b/src/lib/kalender/graph.ts new file mode 100644 index 0000000..fc605c4 --- /dev/null +++ b/src/lib/kalender/graph.ts @@ -0,0 +1,124 @@ +import { z } from 'zod' +import type { ZeigtAls } from '@/generated/prisma/enums' +import { + AntwortUnbrauchbar, + type AbgleichSeite, + type RohTermin, +} from '@/lib/kalender/typen' + +/** + * Lesen der Antworten von Microsoft Graph (`/me/calendarView/delta`). + * + * Reine Logik: nimmt geparsten JSON entgegen, prüft ihn mit zod und gibt die + * gemeinsame Form zurück. Kein fetch, keine Tokens — dadurch gegen die + * Beispielantworten in test/fixtures prüfbar. + */ + +/** + * Graph liefert Datum und Zeitzone getrennt, ohne Versatz im Text. Wir fragen + * mit `Prefer: outlook.timezone="UTC"` an; kommt trotzdem etwas anderes + * zurück, wäre jede Umrechnung geraten – dann lieber abbrechen. + */ +const graphZeit = z.object({ + dateTime: z.string().min(1), + timeZone: z.string().min(1), +}) + +const graphTermin = z.looseObject({ + id: z.string().min(1), + '@odata.etag': z.string().optional(), + '@removed': z.looseObject({ reason: z.string().optional() }).optional(), + subject: z.string().nullable().optional(), + start: graphZeit.optional(), + end: graphZeit.optional(), + isAllDay: z.boolean().optional(), + showAs: z.string().optional(), +}) + +const graphAntwort = z.looseObject({ + value: z.array(graphTermin), + '@odata.nextLink': z.string().optional(), + '@odata.deltaLink': z.string().optional(), +}) + +/** showAs von Graph auf unsere Belegungsarten. */ +export function zeigtAlsAusGraph(showAs: string | undefined): ZeigtAls { + switch (showAs) { + case 'free': + return 'FREI' + case 'busy': + return 'GEBUCHT' + case 'tentative': + return 'MIT_VORBEHALT' + case 'oof': + return 'ABWESEND' + case 'workingElsewhere': + return 'WOANDERS' + default: + return 'UNBEKANNT' + } +} + +/** + * Wandelt Graphs `{ dateTime, timeZone }` in einen Zeitpunkt. + * + * Der Text trägt keinen Versatz, deshalb wird er als UTC gelesen. Meldet + * Graph eine andere Zeitzone, ist die Anfrage falsch gestellt worden — das + * schweigend zu übergehen hieße, Termine um Stunden zu verschieben. + */ +export function graphZeitpunkt(zeit: { dateTime: string; timeZone: string }): Date { + if (zeit.timeZone.toUpperCase() !== 'UTC') { + throw new AntwortUnbrauchbar( + 'Graph', + `unerwartete Zeitzone "${zeit.timeZone}", erwartet wurde UTC`, + ) + } + + // "2016-12-09T20:30:00.0000000" – die sieben Nachkommastellen versteht + // Date nicht, drei genügen. + const gekuerzt = zeit.dateTime.replace(/(\.\d{3})\d+$/, '$1') + const zeitpunkt = new Date(`${gekuerzt}Z`) + + if (Number.isNaN(zeitpunkt.getTime())) { + throw new AntwortUnbrauchbar('Graph', 'Zeitangabe nicht lesbar') + } + return zeitpunkt +} + +/** Liest eine Antwortseite von `/me/calendarView/delta`. */ +export function graphSeiteLesen(inhalt: unknown): AbgleichSeite { + const gelesen = graphAntwort.safeParse(inhalt) + if (!gelesen.success) { + throw new AntwortUnbrauchbar('Graph', 'unerwarteter Aufbau') + } + + const geaendert: RohTermin[] = [] + const entfernt: string[] = [] + + for (const eintrag of gelesen.data.value) { + if (eintrag['@removed']) { + entfernt.push(eintrag.id) + continue + } + + // Ein Eintrag ohne Zeitraum ist kein Termin, den wir einplanen könnten. + if (!eintrag.start || !eintrag.end) continue + + geaendert.push({ + extId: eintrag.id, + etag: eintrag['@odata.etag'] ?? null, + betreff: eintrag.subject ?? null, + beginn: graphZeitpunkt(eintrag.start), + ende: graphZeitpunkt(eintrag.end), + ganztaegig: eintrag.isAllDay === true, + zeigtAls: zeigtAlsAusGraph(eintrag.showAs), + }) + } + + return { + geaendert, + entfernt, + weiter: gelesen.data['@odata.nextLink'] ?? null, + deltaVerweis: gelesen.data['@odata.deltaLink'] ?? null, + } +} diff --git a/src/lib/kalender/typen.ts b/src/lib/kalender/typen.ts new file mode 100644 index 0000000..daa129b --- /dev/null +++ b/src/lib/kalender/typen.ts @@ -0,0 +1,56 @@ +import type { ZeigtAls } from '@/generated/prisma/enums' + +/** + * Gemeinsame Form eines Termins, unabhängig vom Anbieter. + * + * Graph und Google liefern sehr verschiedene Antworten; alles, was danach + * kommt — Belegungsrechnung, Wochenansicht, Schreibweg ab M4 — arbeitet nur + * noch mit dieser Form. + */ +export type RohTermin = { + /** Kennung beim Anbieter. */ + extId: string + /** Für bedingte Schreibzugriffe ab M4. */ + etag: string | null + betreff: string | null + beginn: Date + ende: Date + ganztaegig: boolean + zeigtAls: ZeigtAls +} + +/** + * Ergebnis einer einzelnen Abfrageseite. + * + * Genau eines von `weiter` und `deltaVerweis` ist gesetzt: solange `weiter` + * steht, gibt es weitere Seiten; kommt `deltaVerweis`, ist die Runde zu Ende + * und der Verweis wird für den nächsten Abgleich aufbewahrt. + */ +export type AbgleichSeite = { + geaendert: RohTermin[] + /** Kennungen entfernter Termine. */ + entfernt: string[] + /** Nächste Seite derselben Runde, sonst null. */ + weiter: string | null + /** Ausgangspunkt für die nächste Runde, sonst null. */ + deltaVerweis: string | null +} + +/** Fehler beim Lesen einer Anbieterantwort – Inhalt nie im Klartext ins Log. */ +export class AntwortUnbrauchbar extends Error { + constructor(anbieter: string, grund: string) { + super(`Antwort von ${anbieter} nicht verwertbar: ${grund}`) + this.name = 'AntwortUnbrauchbar' + } +} + +/** + * Der Sitzungsverweis ist abgelaufen; es muss von vorn abgeglichen werden. + * Graph antwortet dann mit 410, Google ebenfalls mit 410. + */ +export class VerweisAbgelaufen extends Error { + constructor(anbieter: string) { + super(`Der Abgleichverweis von ${anbieter} ist abgelaufen.`) + this.name = 'VerweisAbgelaufen' + } +} diff --git a/test/fixtures/kalender/google-events-leer.json b/test/fixtures/kalender/google-events-leer.json new file mode 100644 index 0000000..3501b61 --- /dev/null +++ b/test/fixtures/kalender/google-events-leer.json @@ -0,0 +1,8 @@ +{ + "kind": "calendar#events", + "etag": "\"BEISPIEL-LISTE-3\"", + "summary": "person@example.invalid", + "timeZone": "Europe/Berlin", + "nextSyncToken": "BEISPIEL-SYNC-2", + "items": [] +} diff --git a/test/fixtures/kalender/google-events-seite1.json b/test/fixtures/kalender/google-events-seite1.json new file mode 100644 index 0000000..b607d44 --- /dev/null +++ b/test/fixtures/kalender/google-events-seite1.json @@ -0,0 +1,29 @@ +{ + "kind": "calendar#events", + "etag": "\"BEISPIEL-LISTE-1\"", + "summary": "person@example.invalid", + "timeZone": "Europe/Berlin", + "nextPageToken": "BEISPIEL-SEITE-2", + "items": [ + { + "kind": "calendar#event", + "etag": "\"BEISPIELETAG10\"", + "id": "beispiel-termin-a", + "status": "confirmed", + "summary": "Zahnarzt", + "updated": "2026-03-10T09:00:00.000Z", + "start": { "dateTime": "2026-03-17T14:00:00+01:00", "timeZone": "Europe/Berlin" }, + "end": { "dateTime": "2026-03-17T15:00:00+01:00", "timeZone": "Europe/Berlin" } + }, + { + "kind": "calendar#event", + "etag": "\"BEISPIELETAG11\"", + "id": "beispiel-termin-b", + "status": "confirmed", + "summary": "Urlaubstag", + "updated": "2026-03-11T09:00:00.000Z", + "start": { "date": "2026-03-20" }, + "end": { "date": "2026-03-21" } + } + ] +} diff --git a/test/fixtures/kalender/google-events-seite2.json b/test/fixtures/kalender/google-events-seite2.json new file mode 100644 index 0000000..6fc1bea --- /dev/null +++ b/test/fixtures/kalender/google-events-seite2.json @@ -0,0 +1,27 @@ +{ + "kind": "calendar#events", + "etag": "\"BEISPIEL-LISTE-2\"", + "summary": "person@example.invalid", + "timeZone": "Europe/Berlin", + "nextSyncToken": "BEISPIEL-SYNC-1", + "items": [ + { + "kind": "calendar#event", + "etag": "\"BEISPIELETAG12\"", + "id": "beispiel-termin-c", + "status": "confirmed", + "summary": "Sport", + "transparency": "transparent", + "updated": "2026-03-12T09:00:00.000Z", + "start": { "dateTime": "2026-03-17T18:00:00+01:00", "timeZone": "Europe/Berlin" }, + "end": { "dateTime": "2026-03-17T19:30:00+01:00", "timeZone": "Europe/Berlin" } + }, + { + "kind": "calendar#event", + "etag": "\"BEISPIELETAG13\"", + "id": "beispiel-termin-d", + "status": "cancelled", + "updated": "2026-03-13T09:00:00.000Z" + } + ] +} diff --git a/test/fixtures/kalender/graph-delta-fremde-zeitzone.json b/test/fixtures/kalender/graph-delta-fremde-zeitzone.json new file mode 100644 index 0000000..4c5da01 --- /dev/null +++ b/test/fixtures/kalender/graph-delta-fremde-zeitzone.json @@ -0,0 +1,21 @@ +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#Collection(event)", + "@odata.deltaLink": "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=BEISPIEL-DELTA-2", + "value": [ + { + "@odata.type": "#microsoft.graph.event", + "id": "BEISPIEL-TERMIN-4", + "subject": "Falsch angefragt", + "isAllDay": false, + "showAs": "busy", + "start": { + "dateTime": "2026-03-17T09:00:00.0000000", + "timeZone": "W. Europe Standard Time" + }, + "end": { + "dateTime": "2026-03-17T10:00:00.0000000", + "timeZone": "W. Europe Standard Time" + } + } + ] +} diff --git a/test/fixtures/kalender/graph-delta-seite1.json b/test/fixtures/kalender/graph-delta-seite1.json new file mode 100644 index 0000000..9dcf3ab --- /dev/null +++ b/test/fixtures/kalender/graph-delta-seite1.json @@ -0,0 +1,29 @@ +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#Collection(event)", + "@odata.nextLink": "https://graph.microsoft.com/v1.0/me/calendarView/delta?$skiptoken=BEISPIEL-SKIP-1", + "value": [ + { + "@odata.type": "#microsoft.graph.event", + "@odata.etag": "W/\"BEISPIELETAG1==\"", + "id": "BEISPIEL-TERMIN-1", + "subject": "Wochenbesprechung", + "isAllDay": false, + "showAs": "busy", + "start": { "dateTime": "2026-03-17T08:00:00.0000000", "timeZone": "UTC" }, + "end": { "dateTime": "2026-03-17T09:00:00.0000000", "timeZone": "UTC" }, + "organizer": { + "emailAddress": { "name": "Beispiel Person", "address": "person@example.invalid" } + } + }, + { + "@odata.type": "#microsoft.graph.event", + "@odata.etag": "W/\"BEISPIELETAG2==\"", + "id": "BEISPIEL-TERMIN-2", + "subject": "Betriebsausflug", + "isAllDay": true, + "showAs": "oof", + "start": { "dateTime": "2026-03-18T00:00:00.0000000", "timeZone": "UTC" }, + "end": { "dateTime": "2026-03-19T00:00:00.0000000", "timeZone": "UTC" } + } + ] +} diff --git a/test/fixtures/kalender/graph-delta-seite2.json b/test/fixtures/kalender/graph-delta-seite2.json new file mode 100644 index 0000000..1613c0f --- /dev/null +++ b/test/fixtures/kalender/graph-delta-seite2.json @@ -0,0 +1,21 @@ +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#Collection(event)", + "@odata.deltaLink": "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=BEISPIEL-DELTA-1", + "value": [ + { + "@odata.type": "#microsoft.graph.event", + "@odata.etag": "W/\"BEISPIELETAG3==\"", + "id": "BEISPIEL-TERMIN-3", + "subject": "Mittagspause", + "isAllDay": false, + "showAs": "free", + "start": { "dateTime": "2026-03-17T11:00:00.0000000", "timeZone": "UTC" }, + "end": { "dateTime": "2026-03-17T12:00:00.0000000", "timeZone": "UTC" } + }, + { + "@odata.type": "#microsoft.graph.event", + "id": "BEISPIEL-TERMIN-9", + "@removed": { "reason": "deleted" } + } + ] +}