import { istFeiertag } from './feiertage.js' import { pruefeNow, zeitMs } from './util.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. * * Fenster eines Tages: Oeffnungsfenster aus dem Wochenplan der Ressource oder aus der Sonderoeffnung des Datums * (gilt fuer alle Ressourcen, ersetzt Plan und Feiertagssperre; leere Liste = geschlossen), minus Feiertag (wenn * feiertageGeschlossen und keine Sonderoeffnung), minus Abwesenheiten: eine Abwesenheit der Ressource oder des * Betriebs (ressourceId null) nimmt den Tag der betroffenen Ressourcen immer heraus, auch an einem Sonderoeffnungstag. */ 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)}` /** '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 /** Belegt die Buchung zum Zeitpunkt jetztMs? gehalten nur solange heldUntil > jetzt, angefragt und bestaetigt immer. */ export 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 = [] if (!abwesend(e.abwesenheiten, r.id, tag)) fenster = sonder ?? (feiertagZu ? [] : (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. * Mit now (optional) zaehlt eine Haltung nur, solange heldUntil > now. Keine Kandidaten => null. */ export function waehleRessource(ids, buchungenAmTag, einstellung, now = null) { if (now !== null) pruefeNow(now, 'waehleRessource') const jetztMs = now === null ? null : now.getTime() 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 if (jetztMs !== null && b.status === 'gehalten' && !istBelegend(b, jetztMs)) 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 }) }