Files
Webklar-Kundenbereich/server/services/zeiten/zeitlogik.js
T
ClaudeandClaude Opus 5.5 0f25f7cb52 Öffnungszeiten: Tagesbild und Öffnungsintervalle
Reine Zeitlogik in Europe/Berlin: tagesbild (Sondertag > Urlaub > Feiertag > Wochenplan,
planbezogen vor global, später angelegt gewinnt), oeffnungsIntervalle (zusammengefügt,
Fenster über Mitternacht gehören zum Starttag), zuUtcOderDanach (März-Lücke auf 03:00).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:44:28 +00:00

237 lines
10 KiB
JavaScript

import { zuUtc, wochentag, plusTage } from '../termine/zeit.js'
import { BUNDESLAENDER, feiertageImZeitraum } from '../termine/feiertage.js'
/**
* Oeffnungszeiten live: Zeitlogik (rein, Europe/Berlin).
* Kein Appwrite, keine Config, kein eigener Zeitgeber: Aufrufer geben Daten und 'now' hinein.
* Alles rechnet in Berliner Wanduhrzeit ueber termine/zeit.js; Kalendertage nur mit plusTage, nie als Vielfache von Tages-Millisekunden.
*
* Datenform (Spec 2.1): daten = { land, plaene: [{ id, kurz, name, haupt, tage: { 1..7: { modus, fenster } } }],
* feiertage: { 'YYYY-MM-DD': { modus, fenster } }, ausnahmen: [{ id, art: 'urlaub'|'sondertag', von, bis, text,
* modus, fenster, plan: null|planId }], ... }. modus: 'zu' | 'zeiten' | 'rund' | 'vereinbarung';
* fenster: [['09:00','12:30'], ...] (nur bei 'zeiten'; 'bis' vor 'von' = bis zum Folgetag).
*
* Alle Funktionen sind tolerant gegenueber kaputten gespeicherten Daten (unbekannter Modus, fehlende Tage,
* unlesbare Fenster => zu), werfen aber bei einem ungueltigen Tag-Argument (Programmierfehler).
*/
const TAG_RE = /^\d{4}-\d{2}-\d{2}$/
const ZEIT_RE = /^\d{2}:\d{2}$/
const MODI = Object.freeze(['zu', 'zeiten', 'rund', 'vereinbarung'])
const STANDARD_LAND = 'RP'
const pad = (n) => String(n).padStart(2, '0')
/** Ist t ein echter Kalendertag 'YYYY-MM-DD' (kein 30.02.)? */
function gueltigerTag(t) {
return typeof t === 'string' && TAG_RE.test(t) && plusTage(t, 0) === t
}
function pruefeTag(t, name) {
if (!gueltigerTag(t)) throw new RangeError(`${name}: ungueltiger Tag ${String(t)}`)
}
/** 'HH:MM' (auch '24:00') -> Minuten seit 00:00, sonst null. */
function minuten(hhmm) {
if (typeof hhmm !== 'string' || !ZEIT_RE.test(hhmm)) return null
const h = Number(hhmm.slice(0, 2))
const m = Number(hhmm.slice(3, 5))
if (m > 59 || h > 24 || (h === 24 && m !== 0)) return null
return h * 60 + m
}
const textOderNull = (t) => (typeof t === 'string' && t.trim() !== '' ? t : null)
// ------------------------------------------------------------------ Plaene
/** Der Hauptplan (haupt: true) oder null. */
export function hauptplan(daten) {
const plaene = daten?.plaene
if (!Array.isArray(plaene)) return null
return plaene.find((p) => p && p.haupt === true) ?? null
}
/** Der Plan mit diesem Kurznamen ([a-z0-9-], exakter Vergleich) oder null. */
export function planNachKurz(daten, kurz) {
const plaene = daten?.plaene
if (!Array.isArray(plaene) || typeof kurz !== 'string' || kurz === '') return null
return plaene.find((p) => p && p.kurz === kurz) ?? null
}
/** Bundesland der Daten; fehlend oder unbekannt = Vorgabe RP (Spec 2.1). */
export function landVon(daten) {
return BUNDESLAENDER.includes(daten?.land) ? daten.land : STANDARD_LAND
}
// ------------------------------------------------------------------ Wanduhr -> UTC
/**
* Berliner Wanduhr -> UTC-Zeitpunkt wie zuUtc, aber ohne null: Faellt die Zeit in die Luecke der
* Maerz-Umstellung (02:00-02:59), gilt die erste existierende Minute danach (03:00). Doppelte Zeiten
* (Oktober) ergeben das erste Vorkommen, '24:00' den Folgetag 00:00. Ungueltige Eingabe wirft RangeError.
*/
export function zuUtcOderDanach(tag, hhmm) {
const direkt = zuUtc(tag, hhmm)
if (direkt) return direkt
const min = minuten(hhmm)
if (!gueltigerTag(tag) || min === null) throw new RangeError(`ungueltige Wanduhrzeit ${String(tag)} ${String(hhmm)}`)
for (let m = min + 1; m <= 1440; m += 1) {
const kandidat = zuUtc(tag, `${pad(Math.floor(m / 60))}:${pad(m % 60)}`)
if (kandidat) return kandidat
}
throw new RangeError(`keine existierende Wanduhrzeit nach ${tag} ${hhmm}`)
}
// ------------------------------------------------------------------ Tagesbild
/** Gespeicherte Fenster -> nur brauchbare, nach Beginn sortiert, als Kopie ('24:00' nur als Ende, von != bis). */
function sauberFenster(roh) {
if (!Array.isArray(roh)) return []
const fenster = []
for (const e of roh) {
if (!Array.isArray(e) || e.length !== 2) continue
const von = minuten(e[0])
const bis = minuten(e[1])
if (von === null || bis === null || von >= 1440 || von === bis) continue
fenster.push([e[0], e[1]])
}
return fenster.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
}
/** { modus, fenster } eines gespeicherten Tageseintrags: unbekannter Modus oder 'zeiten' ohne Fenster = zu. */
function modusUndFenster(eintrag) {
const modus = MODI.includes(eintrag?.modus) ? eintrag.modus : 'zu'
if (modus !== 'zeiten') return { modus, fenster: [] }
const fenster = sauberFenster(eintrag.fenster)
return fenster.length ? { modus, fenster } : { modus: 'zu', fenster: [] }
}
/**
* Die geltende Ausnahme dieser Art fuer Plan und Tag: planbezogene schlagen globale (plan null),
* bei Gleichstand gewinnt die spaeter angelegte (hoeherer Index). Ausnahmen mit unlesbarem Zeitraum,
* von > bis oder fuer einen anderen Plan zaehlen nicht; fehlendes 'bis' = ein Tag.
*/
function ausnahmeFuer(daten, planId, tag, art) {
const liste = Array.isArray(daten?.ausnahmen) ? daten.ausnahmen : []
let treffer = null
let trefferRang = -1
for (const a of liste) {
if (!a || a.art !== art) continue
const von = a.von
const bis = a.bis ?? a.von
if (typeof von !== 'string' || typeof bis !== 'string' || !TAG_RE.test(von) || !TAG_RE.test(bis)) continue
if (tag < von || tag > bis) continue
const global = a.plan === null || a.plan === undefined
if (!global && a.plan !== planId) continue
const rang = global ? 0 : 1
// Liste in Anlegereihenfolge: bei gleichem Rang ersetzt der spaetere (>=)
if (rang >= trefferRang) {
treffer = a
trefferRang = rang
}
}
return treffer
}
/**
* Feiertage je (Jahr, Land), einmal berechnet: Map tag -> { name, art: 'feiertag'|'pruefen' }.
* Begrenzt, damit beliebige Jahre den Speicher nicht fuellen.
*/
const FEIERTAGE_CACHE = new Map()
function feiertageDesJahres(jahr, land) {
const schluessel = `${jahr}|${land}`
let karte = FEIERTAGE_CACHE.get(schluessel)
if (!karte) {
karte = new Map()
for (const e of feiertageImZeitraum(`${jahr}-01-01`, `${jahr}-12-31`, land)) {
karte.set(e.tag, Object.freeze({ name: e.name, art: e.art }))
}
if (FEIERTAGE_CACHE.size >= 200) FEIERTAGE_CACHE.clear()
FEIERTAGE_CACHE.set(schluessel, karte)
}
return karte
}
/**
* Das Tagesbild eines Plans an einem Berliner Kalendertag (Spec 6, spezifisch vor allgemein):
* Sondertag > Betriebsurlaub > Feiertag (Abweichung oder Vorgabe) > Wochenplan.
*
* Rueckgabe { tag, art: 'regulaer'|'feiertag'|'urlaub'|'sondertag', name, modus, fenster }:
* - name: Feiertagsname, bei Urlaub/Sondertag der Text der Ausnahme, sonst null.
* - Urlaub ist immer zu. Gesetzlicher Feiertag ohne Abweichung ist zu; ein „bitte pruefen“-Tag
* (Heiligabend, Silvester, ...) folgt dem Wochenplan (art 'regulaer'), ausser der Kunde hat unter
* daten.feiertage[tag] eine Abweichung gespeichert (dann art 'feiertag' mit dem Namen des Tages).
* - fenster ist immer ein neues Array (bei 'zu', 'rund', 'vereinbarung' leer); 'bis' kann '24:00' sein.
*/
export function tagesbild(daten, plan, tag) {
pruefeTag(tag, 'tag')
const planId = plan?.id
const sonder = ausnahmeFuer(daten, planId, tag, 'sondertag')
if (sonder) return { tag, art: 'sondertag', name: textOderNull(sonder.text), ...modusUndFenster(sonder) }
const urlaub = ausnahmeFuer(daten, planId, tag, 'urlaub')
if (urlaub) return { tag, art: 'urlaub', name: textOderNull(urlaub.text), modus: 'zu', fenster: [] }
const feiertag = feiertageDesJahres(tag.slice(0, 4), landVon(daten)).get(tag)
if (feiertag) {
const abweichung = daten?.feiertage?.[tag]
if (abweichung && MODI.includes(abweichung.modus)) {
return { tag, art: 'feiertag', name: feiertag.name, ...modusUndFenster(abweichung) }
}
if (feiertag.art === 'feiertag') return { tag, art: 'feiertag', name: feiertag.name, modus: 'zu', fenster: [] }
}
return { tag, art: 'regulaer', name: null, ...modusUndFenster(plan?.tage?.[wochentag(tag)]) }
}
// ------------------------------------------------------------------ Oeffnungsintervalle
/**
* Zusammengefuegte Oeffnungsintervalle [{ vonMs, bisMs }] (UTC-Millisekunden, sortiert) fuer die
* Berliner Kalendertage vonTag bis bisTag (inklusive).
* - Fenster gehoeren zum Starttag; 'bis' vor 'von' laeuft bis zum Folgetag und folgt der Regel des Starttags
* (auch wenn der Folgetag Feiertag, Urlaub oder zu ist). Ein am letzten Tag beginnendes Fenster wird
* bis zu seinem Ende geliefert.
* - Enthalten ist auch das vom Vortag in vonTag hineinlaufende Fenster, ungekuerzt (es beginnt vor vonTag 00:00).
* - 'rund' = 00:00 bis Folgetag 00:00; 'zu' und 'vereinbarung' ergeben nichts.
* - Ueberlappende und aneinander grenzende Intervalle (auch ueber Tage) sind zusammengefuegt.
* - Fenster in der Maerz-Luecke beginnen/enden um 03:00, Fenster ohne Dauer fallen weg.
* vonTag nach bisTag => []. Ungueltige Tage werfen RangeError.
*/
export function oeffnungsIntervalle(daten, plan, vonTag, bisTag) {
pruefeTag(vonTag, 'vonTag')
pruefeTag(bisTag, 'bisTag')
if (vonTag > bisTag) return []
const bereichStart = zuUtcOderDanach(vonTag, '00:00').getTime()
const roh = []
const nimm = (vonMs, bisMs) => {
if (bisMs > vonMs && bisMs > bereichStart) roh.push({ vonMs, bisMs })
}
for (let tag = plusTage(vonTag, -1); tag <= bisTag; tag = plusTage(tag, 1)) {
const bild = tagesbild(daten, plan, tag)
if (bild.modus === 'rund') {
nimm(zuUtcOderDanach(tag, '00:00').getTime(), zuUtcOderDanach(tag, '24:00').getTime())
} else if (bild.modus === 'zeiten') {
for (const [von, bis] of bild.fenster) {
const ueberMitternacht = bis <= von
nimm(
zuUtcOderDanach(tag, von).getTime(),
zuUtcOderDanach(ueberMitternacht ? plusTage(tag, 1) : tag, bis).getTime(),
)
}
}
}
roh.sort((a, b) => a.vonMs - b.vonMs || a.bisMs - b.bisMs)
const zusammen = []
for (const i of roh) {
const letztes = zusammen[zusammen.length - 1]
if (letztes && i.vonMs <= letztes.bisMs) letztes.bisMs = Math.max(letztes.bisMs, i.bisMs)
else zusammen.push({ vonMs: i.vonMs, bisMs: i.bisMs })
}
return zusammen
}