feat(kalender): Antworten von Graph und Google lesen, Belegung rechnen
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
79e6c0d556
commit
6fda2a579e
@@ -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> = {},
|
||||
): 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([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user