import { zuUtc, ausUtc, 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 } // ------------------------------------------------------------------ Texte, Status und Wechsel const WOCHENTAGE = Object.freeze(['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']) const MINUTE_MS = 60 * 1000 const STUNDE_MS = 60 * MINUTE_MS const SUCH_TAGE = 60 // Spec 6: keine Oeffnung in 60 Tagen => „Derzeit geschlossen“ const RUND_TAGE = 7 // durchgehend mindestens 7 Tage => „Rund um die Uhr geoeffnet“ const WECHSEL_TAGE = 8 // Spec 4.1: wechsel deckt 8 Tage ab const WOCHEN_TAGE = 6 // bis hierhin nennt der Text den Wochentag, danach das Datum const MAX_TAGE_LISTE = 366 const KURZ_OFFEN = 'Geöffnet' const KURZ_ZU = 'Geschlossen' const KURZ_VEREINBARUNG = 'Nach Vereinbarung' function pruefeJetzt(now) { if (!(now instanceof Date) || !Number.isFinite(now.getTime())) throw new RangeError('now: ungueltiger Zeitpunkt') return now.getTime() } /** * Uhrzeit im Fliesstext: '09:00' => '9 Uhr', '18:30' => '18:30 Uhr', '09:05' => '9:05 Uhr'; * '00:00' und '24:00' => 'Mitternacht' (nie „24 Uhr“). Ungueltige Eingabe wirft RangeError. */ export function uhrText(hhmm) { const min = minuten(hhmm) if (min === null) throw new RangeError(`uhrText: ungueltige Uhrzeit ${String(hhmm)}`) if (min === 0 || min === 1440) return 'Mitternacht' const h = Math.floor(min / 60) const m = min % 60 return m === 0 ? `${h} Uhr` : `${h}:${pad(m)} Uhr` } /** 'YYYY-MM-DD' -> 'TT.MM.' */ const datumText = (tag) => `${tag.slice(8, 10)}.${tag.slice(5, 7)}.` const wochentagsName = (tag) => WOCHENTAGE[wochentag(tag) - 1] /** Kalendertage von bis nach (0..max) oder null, wenn weiter weg (nur ueber plusTage gezaehlt). */ function tagAbstand(von, bis, max) { let tag = von for (let n = 0; n <= max; n += 1) { if (tag === bis) return n tag = plusTage(tag, 1) } return null } /** Letzter Tag der zusammenhaengenden Urlaubstage ab tag (tag selbst ist Urlaub). */ function urlaubBis(daten, plan, tag) { let letzter = tag for (let i = 0; i < 400; i += 1) { const naechster = plusTage(letzter, 1) if (tagesbild(daten, plan, naechster).art !== 'urlaub') break letzter = naechster } return letzter } /** Text fuer ein laufendes Intervall, das um bisMs endet (jetzt = Berliner Wanduhr von nowMs). */ function offenText(bisMs, nowMs, jetzt) { const siebenTage = zuUtcOderDanach(plusTage(jetzt.tag, RUND_TAGE), jetzt.uhrzeit).getTime() if (bisMs >= siebenTage) return 'Rund um die Uhr geöffnet' const ende = ausUtc(new Date(bisMs)) if (bisMs - nowMs <= 24 * STUNDE_MS) return `Jetzt geöffnet · schließt um ${uhrText(ende.uhrzeit)}` // Schluss genau um Mitternacht: der Tag, der endet („bis Dienstag Mitternacht“), nicht der Folgetag const tag = ende.uhrzeit === '00:00' ? plusTage(ende.tag, -1) : ende.tag return tagAbstand(jetzt.tag, tag, WOCHEN_TAGE) === null ? `Jetzt geöffnet · bis ${datumText(tag)} um ${uhrText(ende.uhrzeit)}` : `Jetzt geöffnet · bis ${wochentagsName(tag)} ${uhrText(ende.uhrzeit)}` } /** Text fuer die naechste Oeffnung (vonMs) von heute (Berliner Kalendertag) aus gesehen. */ function oeffnetText(vonMs, heute) { const start = ausUtc(new Date(vonMs)) // Beginn um 00:00 heisst „0 Uhr“: „um Mitternacht“ wuerde als Tagesende gelesen const zeit = start.uhrzeit === '00:00' ? '0 Uhr' : uhrText(start.uhrzeit) const abstand = tagAbstand(heute, start.tag, WOCHEN_TAGE) if (abstand === 0) return `Geschlossen · öffnet heute um ${zeit}` if (abstand === 1) return `Geschlossen · öffnet morgen um ${zeit}` if (abstand === null) return `Geschlossen · öffnet am ${datumText(start.tag)} um ${zeit}` return `Geschlossen · öffnet ${wochentagsName(start.tag)} um ${zeit}` } /** * Status zum Zeitpunkt nowMs auf Basis fertiger Intervalle (muessen den Tag des Zeitpunkts minus 1 bis * mindestens 60 Tage danach abdecken, siehe oeffnungsIntervalle). */ function statusAus(daten, plan, nowMs, intervalle) { const jetzt = ausUtc(new Date(nowMs)) const heute = jetzt.tag const laufend = intervalle.find((i) => i.vonMs <= nowMs && nowMs < i.bisMs) if (laufend) return { offen: true, text: offenText(laufend.bisMs, nowMs, jetzt), kurz: KURZ_OFFEN } const bild = tagesbild(daten, plan, heute) if (bild.modus === 'vereinbarung') return { offen: null, text: 'Heute nach Vereinbarung', kurz: KURZ_VEREINBARUNG } if (bild.modus === 'zu' && bild.art === 'urlaub') { const bis = datumText(urlaubBis(daten, plan, heute)) return { offen: false, text: `Heute geschlossen (Betriebsurlaub bis ${bis})`, kurz: KURZ_ZU } } if (bild.modus === 'zu' && bild.art === 'feiertag') { const grund = bild.name ? `Feiertag: ${bild.name}` : 'Feiertag' return { offen: false, text: `Heute geschlossen (${grund})`, kurz: KURZ_ZU } } const grenze = plusTage(heute, SUCH_TAGE) const naechste = intervalle.find((i) => i.vonMs > nowMs) if (naechste && ausUtc(new Date(naechste.vonMs)).tag <= grenze) { return { offen: false, text: oeffnetText(naechste.vonMs, heute), kurz: KURZ_ZU } } return { offen: false, text: 'Derzeit geschlossen', kurz: KURZ_ZU } } /** * Status eines Plans zum Zeitpunkt now (Date, immer Parameter): { offen: true|false|null, text, kurz }. * Texte nach Spec 6 (ohne Anrede, nie „24 Uhr“); offen null = nach Vereinbarung. Wirft RangeError bei * ungueltigem now. */ export function statusZu(daten, plan, now) { const nowMs = pruefeJetzt(now) const heute = ausUtc(now).tag // 60 Tage Vorschau fuer die naechste Oeffnung; ein laenger laufendes Intervall ist ohnehin >= 7 Tage return statusAus(daten, plan, nowMs, oeffnungsIntervalle(daten, plan, heute, plusTage(heute, SUCH_TAGE))) } /** * Alle Statuswechsel der naechsten 8 Tage: [{ ab (UTC-ms), tag (Berliner Datum bei ab), offen, text, kurz }]. * Der erste Eintrag gilt ab Beginn der aktuellen Minute; danach ein Eintrag je Aenderung von offen/text/kurz * an einer Intervallgrenze (Oeffnen/Schliessen) und an jeder Berliner Mitternacht (z. B. „morgen“ -> „heute“). * Nicht enthalten sind Textwechsel mitten am Tag, die nur die Restdauer betreffen (bis Dienstag -> schliesst um). */ export function wechselListe(daten, plan, now) { const startMs = Math.floor(pruefeJetzt(now) / MINUTE_MS) * MINUTE_MS const start = ausUtc(new Date(startMs)) const endeMs = zuUtcOderDanach(plusTage(start.tag, WECHSEL_TAGE), start.uhrzeit).getTime() // 8 Tage Wechsel + 60 Tage Vorschau fuer den letzten Eintrag const intervalle = oeffnungsIntervalle(daten, plan, start.tag, plusTage(start.tag, WECHSEL_TAGE + SUCH_TAGE)) const kandidaten = new Set([startMs]) const vormerken = (ms) => { if (ms > startMs && ms < endeMs) kandidaten.add(ms) } for (const i of intervalle) { vormerken(i.vonMs) vormerken(i.bisMs) } for (let n = 1; n <= WECHSEL_TAGE; n += 1) vormerken(zuUtcOderDanach(plusTage(start.tag, n), '00:00').getTime()) const liste = [] let vorher = null for (const ms of [...kandidaten].sort((a, b) => a - b)) { const s = statusAus(daten, plan, ms, intervalle) if (vorher && vorher.offen === s.offen && vorher.text === s.text && vorher.kurz === s.kurz) continue vorher = s liste.push({ ab: ms, tag: ausUtc(new Date(ms)).tag, offen: s.offen, text: s.text, kurz: s.kurz }) } return liste } /** * Tagesbilder fuer die API: anzahl aufeinanderfolgende Tage ab vonTag als * [{ datum, art, name, modus, fenster }] (wie tagesbild, nur heisst der Tag „datum“). * Ungueltiger Tag oder anzahl ausserhalb 0..366 wirft RangeError. */ export function tageListe(daten, plan, vonTag, anzahl) { pruefeTag(vonTag, 'vonTag') if (!Number.isInteger(anzahl) || anzahl < 0 || anzahl > MAX_TAGE_LISTE) { throw new RangeError(`anzahl: ungueltig ${String(anzahl)}`) } const liste = [] let tag = vonTag for (let i = 0; i < anzahl; i += 1) { const b = tagesbild(daten, plan, tag) liste.push({ datum: b.tag, art: b.art, name: b.name, modus: b.modus, fenster: b.fenster }) tag = plusTage(tag, 1) } return liste }