Reine Logik ohne fetch: die Antworten beider Anbieter werden mit zod geprüft und in eine gemeinsame Form gebracht. Geprüft wird gegen anonymisierte Beispielantworten in test/fixtures — 61 Tests. Zwei Stellen, an denen bewusst abgebrochen statt geraten wird: - Graph liefert Datum und Zeitzone getrennt, ohne Versatz im Text. Wir fragen mit Prefer: outlook.timezone="UTC" an; kommt etwas anderes zurück, wäre jede Umrechnung geraten und würde Termine um Stunden verschieben. - Ein unbekanntes showAs wird UNBEKANNT und zählt damit als belegt. Lieber eine Stunde zu viel als eine Doppelbuchung. Zur Belegung: Ganztägige Termine zählen nicht — ein Urlaubstag oder Geburtstag im Kalender blockiert sonst den ganzen Tag, obwohl er keine Arbeitszeit kostet. Ebenso wenig zählen ausdrücklich als frei markierte Termine. Überlappende Termine werden zusammengelegt, damit eine Doppelbuchung nicht doppelt zählt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
119 lines
3.3 KiB
TypeScript
119 lines
3.3 KiB
TypeScript
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,
|
|
}
|
|
}
|