Files

253 lines
12 KiB
JavaScript

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
})
}