253 lines
12 KiB
JavaScript
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
|
|
})
|
|
}
|