diff --git a/server/services/termine/slots.js b/server/services/termine/slots.js new file mode 100644 index 0000000..9f2f25d --- /dev/null +++ b/server/services/termine/slots.js @@ -0,0 +1,259 @@ +import { istFeiertag } from './feiertage.js' +import { heute, monatInfo, plusTage, wochentag, zuUtc } from './zeit.js' + +/** + * Terminplaner-Verfuegbarkeit (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 3): reine Funktionen ohne + * Appwrite und ohne config; `now` kommt immer als Parameter. Oeffnungsfenster sind Berliner Wanduhr, Belegungen + * sind UTC-Zeitpunkte und werden nur als Zeitpunkte verglichen (nie je Kalendertag), damit eine Belegung vom Vortag + * (22:00 bis 06:00) und die Zeitumstellung stimmen. + * + * Rangfolge fuer die Fenster eines Tages: Sonderoeffnung > Abwesenheit > Feiertag > Plan. + * Eine Sonderoeffnung gilt fuer alle Ressourcen und ersetzt Plan, Feiertag und Abwesenheit (leere Liste = geschlossen). + * Sonst schliesst eine Abwesenheit (Betrieb: ressourceId null) die Ressource, dann ein Feiertag (wenn + * feiertageGeschlossen), sonst gilt der Wochenplan. + */ + +const MIN = 60000 +const RASTER_VORGABE = 15 +const FEIERTAG_CACHE_MAX = 5000 +/** Status, die eine Buchung nicht (mehr) belegen und nicht als Last des Tages zaehlen. */ +const NICHT_BELEGT = new Set(['abgesagt', 'abgelehnt', 'verfallen']) + +const pad = (n) => String(n).padStart(2, '0') +const hhmm = (min) => `${pad(Math.floor(min / 60))}:${pad(min % 60)}` + +function pruefeNow(now, name) { + if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError(`${name}: now fehlt`) +} + +/** Zeitpunkt als ms: Date, ISO-String (auch mit +00:00) oder Zahl; sonst NaN. */ +function zeitMs(wert) { + if (wert instanceof Date) return wert.getTime() + if (typeof wert === 'string' && wert !== '') return new Date(wert).getTime() + if (typeof wert === 'number') return wert + return Number.NaN +} + +/** 'HH:MM' -> Minuten seit Mitternacht ('24:00' = 1440), sonst null. */ +function minuten(wert) { + const m = typeof wert === 'string' ? /^(\d{2}):(\d{2})$/.exec(wert) : null + if (!m) return null + const h = Number(m[1]) + const min = Number(m[2]) + if (h > 24 || min > 59 || (h === 24 && min !== 0)) return null + return h * 60 + min +} + +// istFeiertag kostet ~1,4 ms: das Ergebnis ist je Tag und Land fest, also einmal rechnen und merken. +const feiertagMerker = new Map() +function feiertag(tag, land) { + const schluessel = `${tag}|${land}` + let wert = feiertagMerker.get(schluessel) + if (wert === undefined) { + wert = istFeiertag(tag, land) + if (feiertagMerker.size >= FEIERTAG_CACHE_MAX) feiertagMerker.clear() + feiertagMerker.set(schluessel, wert) + } + return wert +} + +// ---------------------------------------------------------------- Belegung + +function istBelegend(buchung, jetztMs) { + if (buchung.status === 'angefragt' || buchung.status === 'bestaetigt') return true + if (buchung.status === 'gehalten') return zeitMs(buchung.heldUntil) > jetztMs // abgelaufen oder ohne Haltezeit = frei + return false +} + +/** + * Belegte Zeiten aus Buchungs-Dokumenten (Appwrite-Form: $id, ressourceId, status, startsAt, blockEndsAt, heldUntil): + * belegend sind gehalten (nur solange heldUntil > now), angefragt und bestaetigt (auch Sperren). ende = blockEndsAt. + * ohneBuchung ($id) nimmt eine Buchung heraus, z. B. beim Verschieben. Kaputte Eintraege werden uebersprungen. + * => [{ ressourceId, start: Date, ende: Date }] + */ +export function belegungAus(buchungen, now, { ohneBuchung = null } = {}) { + pruefeNow(now, 'belegungAus') + const jetztMs = now.getTime() + const aus = [] + for (const b of Array.isArray(buchungen) ? buchungen : []) { + if (!b || typeof b !== 'object' || typeof b.ressourceId !== 'string') continue + if (ohneBuchung !== null && b.$id === ohneBuchung) continue + if (!istBelegend(b, jetztMs)) continue + const start = zeitMs(b.startsAt) + const ende = zeitMs(b.blockEndsAt) + if (!Number.isFinite(start) || !Number.isFinite(ende) || ende <= start) continue + aus.push({ ressourceId: b.ressourceId, start: new Date(start), ende: new Date(ende) }) + } + return aus +} + +/** Ende des belegten Blocks: Beginn + Dauer + Pause danach, in echten Minuten (auch ueber die Zeitumstellung). */ +export function blockEnde(start, leistung) { + return new Date(start.getTime() + ((leistung.dauer || 0) + (leistung.pufferNach || 0)) * MIN) +} + +// ---------------------------------------------------------------- Startzeiten eines Fensters + +/** Naechste existierende Wanduhrzeit ab minute (Luecke der Maerz-Umstellung ueberspringen) als ms, sonst null. */ +function naechsterInstant(tag, minute) { + for (let m = minute; m <= 1440 && m <= minute + 60; m += 5) { + const d = zuUtc(tag, hhmm(m)) + if (d) return d.getTime() + } + return null +} + +/** + * Startzeiten eines Fensters [von, bis] (Minuten der Berliner Wanduhr) im Raster ab Fensterbeginn, deren Block + * (blockMs) ganz im Fenster liegt. => { endeMs, starts: [{ uhrzeit, ms }] } oder null (unbrauchbares Fenster). + * Wanduhrzeiten ohne Zeitpunkt (Maerz-Luecke) entfallen, doppelte (Oktober) sind das erste Vorkommen. + */ +function fensterStarts(tag, von, bis, raster, blockMs) { + const beginn = zuUtc(tag, hhmm(von)) + const schluss = zuUtc(tag, hhmm(bis)) + const starts = [] + if (beginn && schluss && schluss.getTime() - beginn.getTime() === (bis - von) * MIN) { + // Kein Uebergang im Fenster: Wanduhr und echte Zeit laufen gleich, die Zeitpunkte ergeben sich durch Addieren. + const endeMs = schluss.getTime() + for (let m = von, ms = beginn.getTime(); m < bis && ms + blockMs <= endeMs; m += raster, ms += raster * MIN) starts.push({ uhrzeit: hhmm(m), ms }) + return { endeMs, starts } + } + // Umstelltag: jede Startzeit einzeln umrechnen. Die Zeitpunkte wachsen mit der Wanduhr, sobald einer nicht mehr passt, passt keiner mehr. + const endeMs = schluss ? schluss.getTime() : naechsterInstant(tag, bis) + if (endeMs === null) return null + for (let m = von; m < bis; m += raster) { + const d = zuUtc(tag, hhmm(m)) + if (!d) continue + if (d.getTime() + blockMs > endeMs) break + starts.push({ uhrzeit: hhmm(m), ms: d.getTime() }) + } + return { endeMs, starts } +} + +// ---------------------------------------------------------------- Kontext und Tagesberechnung + +/** Gemeinsame Vorbereitung fuer freieZeiten und freieTage; null, wenn nichts buchbar sein kann. */ +function kontext({ einstellung, leistungId, ressourceId = 'egal', belegung, now, ohneRegeln = false }, name) { + pruefeNow(now, name) + const leistung = (einstellung?.leistungen || []).find((l) => l?.id === leistungId) + if (!leistung || !leistung.aktiv || !(leistung.dauer > 0)) return null + const zugeordnet = new Set(leistung.ressourcen || []) + let ressourcen = (einstellung.ressourcen || []).filter((r) => r?.aktiv && zugeordnet.has(r.id)) + if (ressourceId !== 'egal') ressourcen = ressourcen.filter((r) => r.id === ressourceId) + if (ressourcen.length === 0) return null + + const belegtJeRessource = new Map() + for (const b of Array.isArray(belegung) ? belegung : []) { + const start = zeitMs(b?.start) + const ende = zeitMs(b?.ende) + if (typeof b?.ressourceId !== 'string' || !(ende > start)) continue + if (!belegtJeRessource.has(b.ressourceId)) belegtJeRessource.set(b.ressourceId, []) + belegtJeRessource.get(b.ressourceId).push([start, ende]) + } + + const regeln = einstellung.regeln || {} + const jetzt = now.getTime() + const heuteTag = heute(now) + return { + einstellung, + ressourcen, + raster: Number.isInteger(regeln.raster) && regeln.raster > 0 ? regeln.raster : RASTER_VORGABE, + blockMs: (leistung.dauer + (leistung.pufferNach || 0)) * MIN, + belegt: belegtJeRessource, + heute: heuteTag, + fruehestensMs: ohneRegeln ? jetzt : jetzt + (regeln.vorlaufMin || 0) * MIN, // nie in der Vergangenheit + letzterTag: ohneRegeln || !Number.isInteger(regeln.horizontTage) ? null : plusTage(heuteTag, regeln.horizontTage), + } +} + +const abwesend = (abwesenheiten, ressourceId, tag) => (abwesenheiten || []).some( + (a) => a && (a.ressourceId === null || a.ressourceId === undefined || a.ressourceId === ressourceId) && a.von <= tag && tag <= a.bis, +) + +/** + * Freie Startzeiten eines (gueltigen) Berliner Kalendertags, aufsteigend; nurPruefen bricht nach der ersten ab. + * => [{ uhrzeit, start: Date, ressourceIds }] + */ +function tagZeiten(ctx, tag, nurPruefen) { + if (tag < ctx.heute) return [] + if (ctx.letzterTag !== null && tag > ctx.letzterTag) return [] + const e = ctx.einstellung + const sonder = Object.hasOwn(e.sonderoeffnungen || {}, tag) && Array.isArray(e.sonderoeffnungen[tag]) ? e.sonderoeffnungen[tag] : null + const feiertagZu = sonder === null && e.feiertageGeschlossen !== false && feiertag(tag, e.bundesland) + const wt = wochentag(tag) + + const treffer = new Map() // ms -> Eintrag + for (const r of ctx.ressourcen) { + let fenster = sonder + if (fenster === null) fenster = feiertagZu || abwesend(e.abwesenheiten, r.id, tag) ? [] : (r.plan?.[wt] || []) + const belegtR = ctx.belegt.get(r.id) || [] + for (const f of Array.isArray(fenster) ? fenster : []) { + const von = minuten(f?.[0]) + const bis = minuten(f?.[1]) + if (von === null || bis === null || bis <= von) continue + const fs = fensterStarts(tag, von, bis, ctx.raster, ctx.blockMs) + if (!fs || fs.starts.length === 0) continue + const imFenster = belegtR.filter(([s, en]) => s < fs.endeMs && en > fs.starts[0].ms) + for (const { uhrzeit, ms } of fs.starts) { + if (ms < ctx.fruehestensMs) continue + const ende = ms + ctx.blockMs + if (imFenster.some(([s, en]) => s < ende && en > ms)) continue + const vorhanden = treffer.get(ms) + if (vorhanden) vorhanden.ressourceIds.push(r.id) + else treffer.set(ms, { uhrzeit, start: new Date(ms), ressourceIds: [r.id] }) + if (nurPruefen) return [...treffer.values()] + } + } + } + return [...treffer.values()].sort((a, b) => a.start - b.start) +} + +// ---------------------------------------------------------------- Oeffentliche Abfragen + +/** + * Freie Startzeiten einer Leistung an einem Berliner Kalendertag. ressourceId: eine Ressource oder 'egal' + * (Vereinigung ueber alle aktiven Ressourcen der Leistung, ressourceIds in der Reihenfolge der Einstellung). + * belegung = belegungAus(...). ohneRegeln hebt Vorlauf und Horizont auf (nie die Vergangenheit), z. B. fuer manuelle Termine. + * Ungueltiger Tag, unbekannte oder inaktive Leistung, nicht zugeordnete oder inaktive Ressource => []. + * => [{ uhrzeit: 'HH:MM', start: Date, ressourceIds }] + */ +export function freieZeiten(parameter) { + const ctx = kontext(parameter, 'freieZeiten') + const tag = parameter.tag + if (!ctx || typeof tag !== 'string' || zuUtc(tag, '12:00') === null) return [] + return tagZeiten(ctx, tag, false) +} + +/** Tage ('YYYY-MM-DD') des Monats ('YYYY-MM') mit mindestens einer freien Startzeit; sonst wie freieZeiten. */ +export function freieTage(parameter) { + const info = monatInfo(parameter.monat) + const ctx = kontext(parameter, 'freieTage') + if (!info || !ctx) return [] + const tage = [] + for (let i = 1; i <= info.anzahlTage; i += 1) { + const tag = `${parameter.monat}-${pad(i)}` + if (tagZeiten(ctx, tag, true).length > 0) tage.push(tag) + } + return tage +} + +/** + * Wahl unter mehreren freien Ressourcen ('egal'): die mit den wenigsten Buchungen an dem Tag; bei Gleichstand die + * in der Einstellung zuerst genannte. Abgesagte, abgelehnte und verfallene Buchungen sowie Sperren zaehlen nicht. + * Keine Kandidaten => null. + */ +export function waehleRessource(ids, buchungenAmTag, einstellung) { + const kandidaten = [...new Set((Array.isArray(ids) ? ids : []).filter((id) => typeof id === 'string'))] + if (kandidaten.length === 0) return null + const anzahl = new Map(kandidaten.map((id) => [id, 0])) + for (const b of Array.isArray(buchungenAmTag) ? buchungenAmTag : []) { + if (!b || b.quelle === 'sperre' || NICHT_BELEGT.has(b.status) || !anzahl.has(b.ressourceId)) continue + anzahl.set(b.ressourceId, anzahl.get(b.ressourceId) + 1) + } + const rang = new Map((einstellung?.ressourcen || []).map((r, i) => [r?.id, i])) + const rangVon = (id) => (rang.has(id) ? rang.get(id) : Infinity) // unbekannte zuletzt, dort gilt die Reihenfolge von ids + return kandidaten.reduce((beste, id) => { + if (anzahl.get(id) !== anzahl.get(beste)) return anzahl.get(id) < anzahl.get(beste) ? id : beste + return rangVon(id) < rangVon(beste) ? id : beste + }) +} diff --git a/test/termineSlots.test.js b/test/termineSlots.test.js new file mode 100644 index 0000000..eb7bfc2 --- /dev/null +++ b/test/termineSlots.test.js @@ -0,0 +1,529 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { belegungAus, freieZeiten, freieTage, waehleRessource, blockEnde } from '../server/services/termine/slots.js' +import { zuUtc } from '../server/services/termine/zeit.js' +import { ausVorlage } from '../server/services/termine/vorlagen.js' + +// ---------------------------------------------------------------- Bausteine + +const R1 = 'r_aaaaaaaa' +const R2 = 'r_bbbbbbbb' +const R3 = 'r_cccccccc' +const S = 's_aaaaaaaa' +const JETZT = new Date('2026-10-01T06:00:00Z') // Do 01.10.2026 08:00 Berlin +const MO = '2026-10-12' // Montag + +const wochenplan = (fenster, tage = [1, 2, 3, 4, 5, 6, 7]) => Object.fromEntries(tage.map((t) => [t, fenster])) +const ressource = (id, plan, extra = {}) => ({ id, typ: 'person', name: id, aktiv: true, plan, ...extra }) +const TAG = [['09:00', '17:00']] + +function einstellung({ + ressourcen = [ressource(R1, wochenplan(TAG))], dauer = 60, pufferNach = 0, raster = 15, vorlaufMin = 0, horizontTage = 365, + zuordnung = null, ...rest +} = {}) { + return { + bundesland: 'RP', + feiertageGeschlossen: true, + sonderoeffnungen: {}, + abwesenheiten: [], + regeln: { raster, vorlaufMin, horizontTage }, + ressourcen, + leistungen: [{ id: S, dauer, pufferNach, ressourcen: zuordnung || ressourcen.map((r) => r.id), aktiv: true }], + ...rest, + } +} + +const frei = (e, tag, extra = {}) => freieZeiten({ einstellung: e, leistungId: S, ressourceId: 'egal', tag, belegung: [], now: JETZT, ...extra }) +const uhrzeiten = (liste) => liste.map((z) => z.uhrzeit) +const appwrite = (date) => date.toISOString().replace('Z', '+00:00') // Appwrite liefert +00:00 +const buchung = (id, ressourceId, von, bis, status = 'bestaetigt', extra = {}) => ({ + $id: id, ressourceId, status, startsAt: appwrite(von), blockEndsAt: appwrite(bis), ...extra, +}) +const belegt = (ressourceId, von, bis) => ({ ressourceId, start: von, ende: bis }) +const um = (tag, hhmm) => zuUtc(tag, hhmm) + +// ---------------------------------------------------------------- belegungAus + +test('belegungAus: nur belegende Status, Haltung nur solange heldUntil in der Zukunft liegt', () => { + const now = new Date('2026-10-12T08:00:00Z') + const von = new Date('2026-10-12T10:00:00Z') + const bis = new Date('2026-10-12T11:00:00Z') + const buchungen = [ + buchung('b1', R1, von, bis, 'bestaetigt'), + buchung('b2', R1, von, bis, 'angefragt'), + buchung('b3', R1, von, bis, 'gehalten', { heldUntil: appwrite(new Date('2026-10-12T08:00:01Z')) }), + buchung('b4', R1, von, bis, 'gehalten', { heldUntil: appwrite(new Date('2026-10-12T08:00:00Z')) }), // genau jetzt = abgelaufen + buchung('b5', R1, von, bis, 'gehalten', { heldUntil: appwrite(new Date('2026-10-12T07:00:00Z')) }), + buchung('b6', R1, von, bis, 'gehalten'), // ohne heldUntil + ...['erledigt', 'nichterschienen', 'abgesagt', 'abgelehnt', 'verfallen', 'unbekannt'].map((s, i) => buchung(`x${i}`, R1, von, bis, s)), + ] + const ergebnis = belegungAus(buchungen, now) + assert.equal(ergebnis.length, 3) + assert.deepEqual(ergebnis[0], { ressourceId: R1, start: von, ende: bis }) + assert.ok(ergebnis.every((b) => b.start instanceof Date && b.ende instanceof Date)) +}) + +test('belegungAus: ende ist blockEndsAt (mit Puffer), +00:00-Schreibweise wird gelesen', () => { + const [eintrag] = belegungAus([{ $id: 'b', ressourceId: R2, status: 'bestaetigt', startsAt: '2026-10-12T08:00:00.000+00:00', blockEndsAt: '2026-10-12T09:15:00.000+00:00' }], JETZT) + assert.equal(eintrag.ressourceId, R2) + assert.equal(eintrag.start.toISOString(), '2026-10-12T08:00:00.000Z') + assert.equal(eintrag.ende.toISOString(), '2026-10-12T09:15:00.000Z') +}) + +test('belegungAus: ohneBuchung nimmt die eigene Buchung heraus', () => { + const von = new Date('2026-10-12T08:00:00Z') + const bis = new Date('2026-10-12T09:00:00Z') + const buchungen = [buchung('eigene', R1, von, bis), buchung('fremde', R1, new Date('2026-10-12T10:00:00Z'), new Date('2026-10-12T11:00:00Z'))] + assert.equal(belegungAus(buchungen, JETZT, { ohneBuchung: 'eigene' }).length, 1) + assert.equal(belegungAus(buchungen, JETZT, { ohneBuchung: 'eigene' })[0].start.toISOString(), '2026-10-12T10:00:00.000Z') + assert.equal(belegungAus(buchungen, JETZT, {}).length, 2) + assert.equal(belegungAus(buchungen, JETZT).length, 2) +}) + +test('belegungAus: kaputte Eintraege werden uebersprungen, falsche Parameter bleiben ohne Absturz', () => { + const gut = buchung('g', R1, new Date('2026-10-12T08:00:00Z'), new Date('2026-10-12T09:00:00Z')) + const liste = [ + null, undefined, 'x', 42, {}, + { ...gut, $id: 'a', startsAt: 'kein Datum' }, + { ...gut, $id: 'b', blockEndsAt: null }, + { ...gut, $id: 'c', blockEndsAt: gut.startsAt }, // Ende nicht nach Beginn + { ...gut, $id: 'd', ressourceId: undefined }, + gut, + ] + assert.deepEqual(belegungAus(liste, JETZT).map((b) => b.ressourceId), [R1]) + assert.deepEqual(belegungAus(null, JETZT), []) + assert.throws(() => belegungAus([], undefined), TypeError) +}) + +// ---------------------------------------------------------------- blockEnde + +test('blockEnde: Dauer plus Puffer in echten Minuten', () => { + const start = new Date('2026-10-12T08:00:00Z') + assert.equal(blockEnde(start, { dauer: 45, pufferNach: 15 }).toISOString(), '2026-10-12T09:00:00.000Z') + assert.equal(blockEnde(start, { dauer: 30 }).toISOString(), '2026-10-12T08:30:00.000Z') + assert.equal(start.toISOString(), '2026-10-12T08:00:00.000Z', 'start bleibt unveraendert') +}) + +test('blockEnde: 60 Minuten um 02:00 am 25.10.2026 dauern real 60 Minuten', () => { + const start = um('2026-10-25', '02:00') + assert.equal(start.toISOString(), '2026-10-25T00:00:00.000Z') + assert.equal(blockEnde(start, { dauer: 60, pufferNach: 0 }).toISOString(), '2026-10-25T01:00:00.000Z') +}) + +// ---------------------------------------------------------------- Plan, Pause, Puffer, Fensterende + +test('Pause: zwischen den Fenstern gibt es keine Startzeiten', () => { + const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['09:00', '12:00'], ['13:00', '17:00']]))], raster: 60 }) + assert.deepEqual(uhrzeiten(frei(e, MO)), ['09:00', '10:00', '11:00', '13:00', '14:00', '15:00', '16:00']) +}) + +test('Puffer am Pausenrand: Termin plus Puffer muss ganz ins Fenster passen', () => { + const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['09:00', '12:00'], ['13:00', '17:00']]))], dauer: 60, pufferNach: 15, raster: 15 }) + const zeiten = uhrzeiten(frei(e, MO)) + assert.ok(zeiten.includes('10:45'), 'letzter Start vor der Pause: 10:45 + 75 min = 12:00') + assert.ok(!zeiten.includes('11:00'), '11:00 + 75 min ragt in die Pause') + assert.ok(zeiten.includes('15:45')) + assert.ok(!zeiten.includes('16:00')) + assert.equal(zeiten.at(-1), '15:45') +}) + +test('letzter Termin endet genau am Fensterende, nicht eine Rasterstufe spaeter', () => { + const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['09:00', '12:00']]))], dauer: 45, raster: 15 }) + const liste = frei(e, MO) + assert.deepEqual(uhrzeiten(liste).slice(-2), ['11:00', '11:15']) + assert.equal(blockEnde(liste.at(-1).start, { dauer: 45, pufferNach: 0 }).getTime(), um(MO, '12:00').getTime()) +}) + +test('Raster beginnt am Fensterbeginn (13:10 mit 30-Minuten-Raster)', () => { + const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['13:10', '15:00']]))], dauer: 30, raster: 30 }) + assert.deepEqual(uhrzeiten(frei(e, MO)), ['13:10', '13:40', '14:10']) +}) + +test('Fenster bis 24:00 funktioniert', () => { + const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['22:00', '24:00']]))], dauer: 60, raster: 30 }) + assert.deepEqual(uhrzeiten(frei(e, MO)), ['22:00', '22:30', '23:00']) +}) + +test('Tag ohne Plan ist leer, start ist ein Date in UTC', () => { + const e = einstellung({ ressourcen: [ressource(R1, wochenplan(TAG, [1, 2, 3, 4, 5]))] }) + assert.deepEqual(frei(e, '2026-10-17'), []) // Samstag + const [erste] = frei(e, MO) + assert.equal(erste.uhrzeit, '09:00') + assert.ok(erste.start instanceof Date) + assert.equal(erste.start.toISOString(), '2026-10-12T07:00:00.000Z') + assert.deepEqual(erste.ressourceIds, [R1]) +}) + +// ---------------------------------------------------------------- Vorlauf, Horizont, Vergangenheit + +test('Vorlauf: Start fruehestens now + vorlaufMin (genau gleich ist erlaubt)', () => { + const now = new Date('2026-10-12T06:00:00Z') // Mo 08:00 Berlin + const plan = [ressource(R1, wochenplan([['09:00', '12:00']]))] + assert.deepEqual(uhrzeiten(frei(einstellung({ ressourcen: plan, raster: 30, vorlaufMin: 120 }), MO, { now })), ['10:00', '10:30', '11:00']) + assert.deepEqual(uhrzeiten(frei(einstellung({ ressourcen: plan, raster: 30, vorlaufMin: 60 }), MO, { now })), ['09:00', '09:30', '10:00', '10:30', '11:00']) + assert.deepEqual(uhrzeiten(frei(einstellung({ ressourcen: plan, raster: 30, vorlaufMin: 65 }), MO, { now })), ['09:30', '10:00', '10:30', '11:00']) + assert.deepEqual(uhrzeiten(frei(einstellung({ ressourcen: plan, raster: 30, vorlaufMin: 120 }), MO, { now, ohneRegeln: true })), ['09:00', '09:30', '10:00', '10:30', '11:00']) +}) + +test('Horizont: letzter buchbarer Tag ist heute + horizontTage', () => { + const e = einstellung({ horizontTage: 7 }) + assert.ok(frei(e, '2026-10-08').length > 0, 'Tag 7 ist noch buchbar') + assert.deepEqual(frei(e, '2026-10-09'), []) + assert.ok(frei(e, '2026-10-09', { ohneRegeln: true }).length > 0, 'ohneRegeln hebt den Horizont auf') + assert.ok(frei(e, '2027-03-01', { ohneRegeln: true }).length > 0) +}) + +test('Horizont zaehlt ab dem Berliner Kalendertag (nicht UTC)', () => { + const e = einstellung({ horizontTage: 1 }) + const now = new Date('2026-10-11T22:30:00Z') // schon Mo 12.10. 00:30 Berlin + assert.ok(frei(e, '2026-10-13', { now }).length > 0) + assert.deepEqual(frei(e, '2026-10-14', { now }), []) +}) + +test('nie Vergangenheit, auch nicht mit ohneRegeln', () => { + const e = einstellung({ raster: 30, vorlaufMin: 240 }) + const now = new Date('2026-10-12T10:07:00Z') // 12:07 Berlin + assert.equal(frei(e, MO, { now, ohneRegeln: true })[0].uhrzeit, '12:30') + assert.deepEqual(frei(e, MO, { now }), [], 'mit Vorlauf 240 min: fruehestens 16:07, 16:30 + 60 min passt nicht mehr in den Tag') + assert.deepEqual(frei(e, '2026-10-11', { now, ohneRegeln: true }), []) + assert.deepEqual(frei(e, '2026-09-01', { now, ohneRegeln: true }), []) +}) + +// ---------------------------------------------------------------- Abwesenheit, Feiertag, Sonderoeffnung + +test('Abwesenheit einer Ressource: nur diese Ressource faellt aus, von und bis gelten ganztaegig inklusive', () => { + const e = einstellung({ + ressourcen: [ressource(R1, wochenplan(TAG)), ressource(R2, wochenplan(TAG))], + abwesenheiten: [{ id: 'a_aaaaaaaa', ressourceId: R1, von: '2026-10-12', bis: '2026-10-13', titel: 'Urlaub' }], + }) + assert.deepEqual(frei(e, '2026-10-12', { ressourceId: R1 }), []) + assert.deepEqual(frei(e, '2026-10-13', { ressourceId: R1 }), []) + assert.ok(frei(e, '2026-10-14', { ressourceId: R1 }).length > 0) + assert.ok(frei(e, '2026-10-11', { ressourceId: R1 }).length > 0) + assert.deepEqual(frei(e, '2026-10-12', { ressourceId: R2 })[0].ressourceIds, [R2]) + assert.deepEqual(frei(e, '2026-10-12')[0].ressourceIds, [R2], 'egal: nur die anwesende Person') +}) + +test('Abwesenheit des Betriebs (ressourceId null) sperrt alle Ressourcen', () => { + const e = einstellung({ + ressourcen: [ressource(R1, wochenplan(TAG)), ressource(R2, wochenplan(TAG))], + abwesenheiten: [{ id: 'a_bbbbbbbb', ressourceId: null, von: '2026-10-12', bis: '2026-10-14', titel: 'Betriebsurlaub' }], + }) + for (const tag of ['2026-10-12', '2026-10-13', '2026-10-14']) { + assert.deepEqual(frei(e, tag), [], tag) + assert.deepEqual(frei(e, tag, { ressourceId: R1 }), [], tag) + } + assert.ok(frei(e, '2026-10-15').length > 0) +}) + +test('Feiertag schliesst, wenn feiertageGeschlossen; abhaengig vom Bundesland', () => { + const ressourcen = [ressource(R1, wochenplan(TAG))] + const feiertag = '2026-10-03' // Samstag, Tag der Deutschen Einheit + assert.deepEqual(frei(einstellung({ ressourcen }), feiertag), []) + assert.ok(frei(einstellung({ ressourcen, feiertageGeschlossen: false }), feiertag).length > 0) + assert.deepEqual(frei(einstellung({ ressourcen, bundesland: 'BE' }), '2026-12-25'), []) + // Allerheiligen (01.11.2026, Sonntag): in RP Feiertag, in BE nicht + assert.deepEqual(frei(einstellung({ ressourcen, bundesland: 'RP' }), '2026-11-01'), []) + assert.ok(frei(einstellung({ ressourcen, bundesland: 'BE' }), '2026-11-01').length > 0) +}) + +test('Sonderoeffnung schlaegt den Feiertag und ersetzt den Plan', () => { + const ressourcen = [ressource(R1, wochenplan([['09:00', '17:00']]))] + const feiertag = '2026-10-03' + const e = einstellung({ ressourcen, raster: 60, sonderoeffnungen: { [feiertag]: [['10:00', '13:00']] } }) + assert.deepEqual(uhrzeiten(frei(e, feiertag)), ['10:00', '11:00', '12:00']) + // auch an einem normalen Tag ersetzt sie den Plan + const e2 = einstellung({ ressourcen, raster: 60, sonderoeffnungen: { [MO]: [['14:00', '16:00']] } }) + assert.deepEqual(uhrzeiten(frei(e2, MO)), ['14:00', '15:00']) + assert.equal(frei(e2, '2026-10-13').length, 8) +}) + +test('Sonderoeffnung mit leerer Liste: an diesem Tag geschlossen', () => { + const e = einstellung({ sonderoeffnungen: { [MO]: [] } }) + assert.deepEqual(frei(e, MO), []) + assert.ok(frei(e, '2026-10-13').length > 0) +}) + +test('Sonderoeffnung gilt fuer alle Ressourcen und schlaegt die Abwesenheit (Rangfolge Sonderoeffnung > Abwesenheit > Feiertag > Plan)', () => { + const e = einstellung({ + ressourcen: [ressource(R1, wochenplan(TAG)), ressource(R2, wochenplan([['08:00', '09:00']]))], + raster: 60, + sonderoeffnungen: { [MO]: [['10:00', '12:00']] }, + abwesenheiten: [{ id: 'a_cccccccc', ressourceId: R1, von: MO, bis: MO, titel: 'Urlaub' }], + }) + const liste = frei(e, MO) + assert.deepEqual(uhrzeiten(liste), ['10:00', '11:00']) + assert.deepEqual(liste[0].ressourceIds, [R1, R2]) +}) + +// ---------------------------------------------------------------- Belegung + +test('Belegung blockiert Ueberlappung, angrenzende Zeiten bleiben frei', () => { + const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['09:00', '13:00']]))], raster: 30 }) + const belegung = [belegt(R1, um(MO, '10:00'), um(MO, '11:00'))] + assert.deepEqual(uhrzeiten(frei(e, MO, { belegung })), ['09:00', '11:00', '11:30', '12:00']) + // 09:30 + 60 min ragt in die Belegung, 09:00 endet genau am Beginn der Belegung +}) + +test('Belegung mit Puffer: blockEndsAt zaehlt, nicht das Terminende', () => { + const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['09:00', '13:00']]))], raster: 15, dauer: 30 }) + const belegung = belegungAus([buchung('b', R1, um(MO, '10:00'), um(MO, '10:45'))], JETZT) // 30 min + 15 min Puffer + const zeiten = uhrzeiten(frei(e, MO, { belegung })) + assert.ok(!zeiten.includes('10:30')) + assert.ok(zeiten.includes('10:45')) + assert.ok(zeiten.includes('09:30')) + assert.ok(!zeiten.includes('09:45'), '09:45 + 30 min ragt in die Belegung') +}) + +test('Belegung einer anderen Ressource stoert nicht', () => { + const e = einstellung({ ressourcen: [ressource(R1, wochenplan(TAG)), ressource(R2, wochenplan(TAG))], raster: 60 }) + const belegung = [belegt(R2, um(MO, '09:00'), um(MO, '17:00'))] + assert.equal(frei(e, MO, { ressourceId: R1, belegung }).length, 8) + assert.deepEqual(frei(e, MO, { ressourceId: R2, belegung }), []) +}) + +test('abgelaufene Haltung blockiert nicht, gueltige Haltung schon', () => { + const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['09:00', '11:00']]))], raster: 60 }) + const now = new Date('2026-10-12T05:00:00Z') // Mo 07:00 Berlin + const haltung = (heldUntil) => buchung('h', R1, um(MO, '09:00'), um(MO, '10:00'), 'gehalten', { heldUntil: appwrite(heldUntil) }) + const abgelaufen = belegungAus([haltung(new Date('2026-10-12T04:59:00Z'))], now) + const gueltig = belegungAus([haltung(new Date('2026-10-12T05:30:00Z'))], now) + assert.deepEqual(uhrzeiten(frei(e, MO, { belegung: abgelaufen, now })), ['09:00', '10:00']) + assert.deepEqual(uhrzeiten(frei(e, MO, { belegung: gueltig, now })), ['10:00']) +}) + +test('ohneBuchung gibt die eigene Zeit beim Verschieben frei', () => { + const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['09:00', '11:00']]))], raster: 60 }) + const buchungen = [buchung('eigene', R1, um(MO, '09:00'), um(MO, '10:00')), buchung('fremde', R1, um(MO, '10:00'), um(MO, '11:00'))] + assert.deepEqual(frei(e, MO, { belegung: belegungAus(buchungen, JETZT) }), []) + assert.deepEqual(uhrzeiten(frei(e, MO, { belegung: belegungAus(buchungen, JETZT, { ohneBuchung: 'eigene' }) })), ['09:00']) +}) + +test('Belegung von 22:00 am Vortag bis 06:00 blockiert 05:30', () => { + const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['05:00', '09:00']]))], raster: 30, dauer: 30 }) + const belegung = belegungAus([buchung('nacht', R1, um('2026-10-11', '22:00'), um(MO, '06:00'))], JETZT) + assert.equal(belegung[0].start.toISOString(), '2026-10-11T20:00:00.000Z') + const zeiten = uhrzeiten(frei(e, MO, { belegung })) + assert.ok(!zeiten.includes('05:00')) + assert.ok(!zeiten.includes('05:30')) + assert.deepEqual(zeiten, ['06:00', '06:30', '07:00', '07:30', '08:00', '08:30']) + // am Vortag blockiert dieselbe Belegung 22:00 bis 24:00 + const vortag = einstellung({ ressourcen: [ressource(R1, wochenplan([['20:00', '24:00']]))], raster: 60, dauer: 60 }) + assert.deepEqual(uhrzeiten(frei(vortag, '2026-10-11', { belegung })), ['20:00', '21:00']) +}) + +// ---------------------------------------------------------------- Egal, Ressourcen, Leistungen + +test('egal: Vereinigung ueber alle Ressourcen der Leistung, ressourceIds nach Liste', () => { + const e = einstellung({ + ressourcen: [ressource(R1, wochenplan([['09:00', '12:00']])), ressource(R2, wochenplan([['11:00', '14:00']]))], + raster: 60, + }) + const liste = frei(e, MO) + assert.deepEqual(liste.map((z) => [z.uhrzeit, z.ressourceIds]), [ + ['09:00', [R1]], ['10:00', [R1]], ['11:00', [R1, R2]], ['12:00', [R2]], ['13:00', [R2]], + ]) +}) + +test('egal mit zwei Personen: belegte Person verschwindet nur dort, wo die andere frei ist', () => { + const e = einstellung({ ressourcen: [ressource(R1, wochenplan(TAG)), ressource(R2, wochenplan(TAG))], raster: 60, dauer: 60 }) + const belegung = [belegt(R1, um(MO, '10:00'), um(MO, '11:00')), belegt(R1, um(MO, '12:00'), um(MO, '13:00')), belegt(R2, um(MO, '12:00'), um(MO, '13:00'))] + const liste = frei(e, MO, { belegung }) + const zu = (uhr) => liste.find((z) => z.uhrzeit === uhr) + assert.deepEqual(zu('09:00').ressourceIds, [R1, R2]) + assert.deepEqual(zu('10:00').ressourceIds, [R2]) + assert.equal(zu('12:00'), undefined, 'beide belegt: Zeit entfaellt') + assert.deepEqual(zu('13:00').ressourceIds, [R1, R2]) + assert.ok(!uhrzeiten(frei(e, MO, { belegung, ressourceId: R1 })).includes('10:00')) + assert.ok(uhrzeiten(frei(e, MO, { belegung, ressourceId: R2 })).includes('10:00')) +}) + +test('Ressourcen: nur aktive und der Leistung zugeordnete, unbekannte oder nicht zugeordnete ergeben nichts', () => { + const e = einstellung({ + ressourcen: [ressource(R1, wochenplan(TAG)), ressource(R2, wochenplan(TAG), { aktiv: false }), ressource(R3, wochenplan(TAG))], + zuordnung: [R1, R2], + raster: 60, + }) + assert.deepEqual(frei(e, MO)[0].ressourceIds, [R1]) + assert.deepEqual(frei(e, MO, { ressourceId: R2 }), [], 'inaktive Ressource') + assert.deepEqual(frei(e, MO, { ressourceId: R3 }), [], 'Leistung nicht zugeordnet') + assert.deepEqual(frei(e, MO, { ressourceId: 'r_unbekannt' }), []) +}) + +test('Leistung: unbekannt oder inaktiv ergibt nichts, ungueltiger Tag auch', () => { + const e = einstellung() + assert.ok(frei(e, MO).length > 0) + assert.deepEqual(freieZeiten({ einstellung: e, leistungId: 's_unbekannt', ressourceId: 'egal', tag: MO, belegung: [], now: JETZT }), []) + const inaktiv = { ...e, leistungen: [{ ...e.leistungen[0], aktiv: false }] } + assert.deepEqual(frei(inaktiv, MO), []) + for (const tag of ['2026-02-30', '2026-10-1', 'morgen', '', undefined, null, 20261012]) assert.deepEqual(frei(e, tag), [], String(tag)) + assert.throws(() => freieZeiten({ einstellung: e, leistungId: S, tag: MO, belegung: [] }), TypeError) +}) + +test('mit einer echten Branchenvorlage (Salon) liefert die Berechnung Termine', () => { + const e = ausVorlage('salon', { name: 'Salon Test', email: 'salon@example.com' }, JETZT) + const leistung = e.leistungen[0] + const liste = freieZeiten({ einstellung: e, leistungId: leistung.id, ressourceId: 'egal', tag: '2026-10-13', belegung: [], now: JETZT }) + assert.ok(liste.length > 0) + for (const z of liste) { + assert.match(z.uhrzeit, /^\d\d:\d\d$/) + assert.ok(z.start instanceof Date) + assert.ok(z.ressourceIds.length > 0) + } +}) + +// ---------------------------------------------------------------- Zeitumstellung + +test('2026-03-29: keine Startzeiten 02:xx, Termine laufen ueber die Luecke in echten Minuten', () => { + const now = new Date('2026-03-01T00:00:00Z') + const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['00:00', '06:00']]))], dauer: 30, raster: 30 }) + const liste = frei(e, '2026-03-29', { now }) + assert.deepEqual(uhrzeiten(liste), ['00:00', '00:30', '01:00', '01:30', '03:00', '03:30', '04:00', '04:30', '05:00', '05:30']) + assert.ok(!uhrzeiten(liste).some((u) => u.startsWith('02:'))) + const start = (uhr) => liste.find((z) => z.uhrzeit === uhr).start.toISOString() + assert.equal(start('01:30'), '2026-03-29T00:30:00.000Z') + assert.equal(start('03:00'), '2026-03-29T01:00:00.000Z') // direkt nach 01:30 + 30 min + assert.equal(start('05:30'), '2026-03-29T03:30:00.000Z') +}) + +test('2026-03-29: Fenster, das in der Luecke beginnt oder endet', () => { + const now = new Date('2026-03-01T00:00:00Z') + const beginn = einstellung({ ressourcen: [ressource(R1, wochenplan([['02:00', '05:00']]))], dauer: 60, raster: 30 }) + assert.deepEqual(uhrzeiten(frei(beginn, '2026-03-29', { now })), ['03:00', '03:30', '04:00']) + const ende = einstellung({ ressourcen: [ressource(R1, wochenplan([['00:00', '02:30']]))], dauer: 30, raster: 30 }) + assert.deepEqual(uhrzeiten(frei(ende, '2026-03-29', { now })), ['00:00', '00:30', '01:00', '01:30']) + const nurLuecke = einstellung({ ressourcen: [ressource(R1, wochenplan([['02:00', '03:00']]))], dauer: 30, raster: 30 }) + assert.deepEqual(frei(nurLuecke, '2026-03-29', { now }), []) +}) + +test('2026-10-25: doppelte Stunde, 60-Minuten-Termin um 02:00 dauert real 60 Minuten', () => { + const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['00:00', '06:00']]))], dauer: 60, raster: 60 }) + const liste = frei(e, '2026-10-25') + assert.deepEqual(uhrzeiten(liste), ['00:00', '01:00', '02:00', '03:00', '04:00', '05:00'], 'jede Uhrzeit hoechstens einmal') + const start = (uhr) => liste.find((z) => z.uhrzeit === uhr).start.toISOString() + assert.equal(start('02:00'), '2026-10-25T00:00:00.000Z', 'erstes Vorkommen (Sommerzeit)') + assert.equal(start('03:00'), '2026-10-25T02:00:00.000Z') + // Fremdbuchung in der zweiten 02-Uhr-Stunde (01:00Z bis 02:00Z): Termine um 02:00 (00:00Z bis 01:00Z) und 03:00 (ab 02:00Z) bleiben frei + const zweiteStunde = [belegt(R1, new Date('2026-10-25T01:00:00Z'), new Date('2026-10-25T02:00:00Z'))] + assert.deepEqual(uhrzeiten(frei(e, '2026-10-25', { belegung: zweiteStunde })), ['00:00', '01:00', '02:00', '03:00', '04:00', '05:00']) + // Fremdbuchung 00:30Z bis 01:30Z blockiert den Termin um 02:00 (00:00Z bis 01:00Z), nicht 01:00 (23:00Z bis 00:00Z) und nicht 03:00 (ab 02:00Z) + const ueberlappt = [belegt(R1, new Date('2026-10-25T00:30:00Z'), new Date('2026-10-25T01:30:00Z'))] + assert.deepEqual(uhrzeiten(frei(e, '2026-10-25', { belegung: ueberlappt })), ['00:00', '01:00', '03:00', '04:00', '05:00']) +}) + +test('2026-10-25: letzter Termin endet genau um 06:00', () => { + const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['00:00', '06:00']]))], dauer: 60, raster: 30 }) + const liste = frei(e, '2026-10-25') + assert.equal(liste.at(-1).uhrzeit, '05:00') + assert.equal(blockEnde(liste.at(-1).start, { dauer: 60, pufferNach: 0 }).toISOString(), '2026-10-25T05:00:00.000Z') // 06:00 MEZ +}) + +test('Umstelltage: Ergebnis entspricht einer Referenzrechnung je Startzeit (zuUtc je Kandidat)', () => { + const dauer = 30 + const raster = 15 + const mins = (hhmm) => Number(hhmm.slice(0, 2)) * 60 + Number(hhmm.slice(3)) + const hhmm = (m) => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}` + const fenster = [['00:00', '06:00'], ['01:00', '04:00'], ['02:00', '05:00'], ['00:00', '02:30'], ['02:30', '06:00'], ['08:00', '24:00'], ['22:00', '24:00'], ['09:00', '17:00']] + for (const tag of ['2026-03-27', '2026-03-28', '2026-03-29', '2026-03-30', '2026-10-24', '2026-10-25', '2026-10-26']) { + const now = tag < '2026-06' ? new Date('2026-03-01T00:00:00Z') : JETZT + for (const [von, bis] of fenster) { + const e = einstellung({ ressourcen: [ressource(R1, wochenplan([[von, bis]]))], dauer, raster }) + const ende = (zuUtc(tag, bis) || zuUtc(tag, '03:00')).getTime() + const erwartet = [] + for (let m = mins(von); m < mins(bis); m += raster) { + const d = zuUtc(tag, hhmm(m)) + if (d && d.getTime() + dauer * 60000 <= ende) erwartet.push([hhmm(m), d.toISOString()]) + } + const ist = frei(e, tag, { now }).map((z) => [z.uhrzeit, z.start.toISOString()]) + assert.deepEqual(ist, erwartet, `${tag} ${von}-${bis}`) + } + } +}) + +// ---------------------------------------------------------------- freieTage + +test('freieTage: Tage mit mindestens einer freien Zeit, ohne Feiertage und volle Tage', () => { + const e = einstellung({ + ressourcen: [ressource(R1, wochenplan(TAG, [1, 2, 3, 4, 5, 6]))], + abwesenheiten: [{ id: 'a_dddddddd', ressourceId: null, von: '2026-10-19', bis: '2026-10-20', titel: 'Fortbildung' }], + }) + const belegung = [belegt(R1, um('2026-10-14', '08:00'), um('2026-10-14', '18:00'))] + const tage = freieTage({ einstellung: e, leistungId: S, ressourceId: 'egal', monat: '2026-10', belegung, now: JETZT }) + assert.deepEqual(tage, [ + '2026-10-01', '2026-10-02', /* 03. Feiertag */ '2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', + '2026-10-12', '2026-10-13', /* 14. voll */ '2026-10-15', '2026-10-16', '2026-10-17', + /* 19./20. abwesend */ '2026-10-21', '2026-10-22', '2026-10-23', '2026-10-24', + '2026-10-26', '2026-10-27', '2026-10-28', '2026-10-29', '2026-10-30', '2026-10-31', + ]) +}) + +test('freieTage: vergangene Tage, Vorlauf und Horizont begrenzen den Monat', () => { + const e = einstellung({ horizontTage: 10, vorlaufMin: 120 }) + const jetzt = new Date('2026-10-05T14:00:00Z') // Mo 16:00 Berlin: 16:00 + 2 h = 18:00, heute nichts mehr + const tage = freieTage({ einstellung: e, leistungId: S, ressourceId: 'egal', monat: '2026-10', belegung: [], now: jetzt }) + assert.equal(tage[0], '2026-10-06') + assert.equal(tage.at(-1), '2026-10-15') + const ohne = freieTage({ einstellung: e, leistungId: S, ressourceId: 'egal', monat: '2026-10', belegung: [], now: jetzt, ohneRegeln: true }) + assert.equal(ohne[0], '2026-10-05', 'ohneRegeln: heute 16:00 + 60 min passt noch bis 17:00') + assert.equal(ohne.at(-1), '2026-10-31') +}) + +test('freieTage: jeder Tag stimmt mit freieZeiten ueberein, ungueltiger Monat ergibt nichts', () => { + const e = einstellung({ + ressourcen: [ressource(R1, wochenplan([['09:00', '12:00']], [1, 3, 5])), ressource(R2, wochenplan([['10:00', '11:00']], [2]))], + dauer: 60, + }) + const belegung = [belegt(R1, um('2026-11-04', '09:00'), um('2026-11-04', '12:00'))] + const basis = { einstellung: e, leistungId: S, ressourceId: 'egal', belegung, now: JETZT } + const tage = freieTage({ ...basis, monat: '2026-11' }) + assert.ok(tage.length > 5) + for (let i = 1; i <= 30; i += 1) { + const tag = `2026-11-${String(i).padStart(2, '0')}` + assert.equal(tage.includes(tag), freieZeiten({ ...basis, tag }).length > 0, tag) + } + for (const monat of ['2026-13', '2026-00', '2026-1', 'Oktober', '', undefined, null]) assert.deepEqual(freieTage({ ...basis, monat }), [], String(monat)) + assert.deepEqual(freieTage({ ...basis, leistungId: 's_unbekannt', monat: '2026-11' }), []) + assert.deepEqual(freieTage({ ...basis, ressourceId: R3, monat: '2026-11' }), []) +}) + +test('freieTage: Februar und Schaltjahr haben die richtige Tageszahl', () => { + const e = einstellung({ horizontTage: 365 * 3 }) + const basis = { einstellung: e, leistungId: S, ressourceId: 'egal', belegung: [], now: JETZT } + assert.equal(freieTage({ ...basis, monat: '2027-02' }).at(-1), '2027-02-28') + assert.equal(freieTage({ ...basis, monat: '2028-02' }).at(-1), '2028-02-29') +}) + +// ---------------------------------------------------------------- waehleRessource + +test('waehleRessource: die Ressource mit den wenigsten Buchungen an dem Tag', () => { + const e = einstellung({ ressourcen: [ressource(R1, {}), ressource(R2, {}), ressource(R3, {})] }) + const tagesBuchungen = [ + { ressourceId: R1, status: 'bestaetigt' }, { ressourceId: R1, status: 'angefragt' }, + { ressourceId: R2, status: 'bestaetigt' }, + { ressourceId: R3, status: 'bestaetigt' }, { ressourceId: R3, status: 'bestaetigt' }, { ressourceId: R3, status: 'bestaetigt' }, + ] + assert.equal(waehleRessource([R1, R2, R3], tagesBuchungen, e), R2) + assert.equal(waehleRessource([R1, R3], tagesBuchungen, e), R1) + assert.equal(waehleRessource([R3], tagesBuchungen, e), R3) +}) + +test('waehleRessource: bei Gleichstand gilt die Reihenfolge der Liste, abgesagte Termine und Sperren zaehlen nicht', () => { + const e = einstellung({ ressourcen: [ressource(R1, {}), ressource(R2, {}), ressource(R3, {})] }) + assert.equal(waehleRessource([R3, R2, R1], [], e), R1) + assert.equal(waehleRessource([R2, R3], [], e), R2) + const tagesBuchungen = [ + { ressourceId: R1, status: 'abgesagt' }, { ressourceId: R1, status: 'verfallen' }, { ressourceId: R1, status: 'abgelehnt' }, + { ressourceId: R1, status: 'bestaetigt', quelle: 'sperre' }, + { ressourceId: R2, status: 'bestaetigt' }, + ] + assert.equal(waehleRessource([R2, R1], tagesBuchungen, e), R1) + assert.equal(waehleRessource([R2, R1], [{ ressourceId: R1, status: 'erledigt' }], e), R2, 'erledigte Termine des Tages zaehlen mit') +}) + +test('waehleRessource: keine Kandidaten ergibt null, fremde Ressourcen werden nicht gezaehlt', () => { + const e = einstellung() + assert.equal(waehleRessource([], [], e), null) + assert.equal(waehleRessource(undefined, [], e), null) + assert.equal(waehleRessource([R1], [{ ressourceId: R2, status: 'bestaetigt' }], e), R1) + assert.equal(waehleRessource(['r_unbekannt', R1], [], e), R1, 'bekannte Ressourcen der Einstellung zuerst') +})