Files
Webklar-Kundenbereich/server/services/zeiten/zeitlogik.js
T
ClaudeandClaude Opus 5.5 5f52ef54e8 Öffnungszeiten: Ersatz-Tabelle und Wochenplan-Hash
ersatzHtml liefert die schlichte Wochentabelle eines Plans als reines ASCII
(Entities, maskierter Planname), wochenplanHash den 16-stelligen sha1 der
Tage des Hauptplans. Dafuer wird modusUndFenster aus der Zeitlogik exportiert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:19:22 +00:00

651 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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. */
export 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
}
// ------------------------------------------------------------------ Hinweise
/** Rang der Hinweisarten (Spec 4.1): der Browser zeigt den gueltigen Hinweis mit dem hoechsten Rang. */
export const HINWEIS_RANG = Object.freeze({ wichtig: 4, urlaub: 3, aktion: 2, info: 1 })
const HINWEIS_VORLAUF_TAGE = 8 // Spec 4.1: aktuell gueltige Hinweise + Beginn in 8 Tagen
const ENDLOS_MS = 8.64e15 // letzter darstellbarer Date-Wert: „ohne Ende“ bleibt eine vergleichbare Zahl (JSON-tauglich)
const KNOPF_ARTEN = Object.freeze(['link', 'anruf'])
const fehlt = (v) => v === undefined || v === null || v === ''
/**
* Zeitraum eines Hinweises als UTC-Millisekunden { ab, bis }: von = Beginn 00:00, bis = Ende 24:00 (also der
* Beginn des Folgetags), beides in Berliner Zeit. Fehlt von, beginnt der Hinweis „schon immer“ (ab 0), fehlt bis,
* endet er nie (bis 8.64e15, der letzte darstellbare Date-Wert). Ein vorhandener, aber unlesbarer Tag
* (oder ein Hinweis, der kein Objekt ist) ergibt null.
*/
export function hinweisZeitraum(h) {
if (!h || typeof h !== 'object') return null
const { von, bis } = h
if ((!fehlt(von) && !gueltigerTag(von)) || (!fehlt(bis) && !gueltigerTag(bis))) return null
return {
ab: fehlt(von) ? 0 : zuUtcOderDanach(von, '00:00').getTime(),
bis: fehlt(bis) ? ENDLOS_MS : zuUtcOderDanach(bis, '24:00').getTime(),
}
}
/** Knopf eines Hinweises als Kopie { art, ziel, text } oder null, wenn er unbrauchbar ist (Task 8 prueft beim Speichern). */
function knopfKopie(k) {
if (!k || typeof k !== 'object' || !KNOPF_ARTEN.includes(k.art) || typeof k.ziel !== 'string' || k.ziel === '') return null
return { art: k.art, ziel: k.ziel, text: typeof k.text === 'string' ? k.text : '' }
}
/**
* Die zum Zeitpunkt now gueltigen Hinweise sowie die, die in hoechstens 8 Tagen beginnen:
* [{ art, text, knopf, ab, bis, rang }] in gespeicherter Reihenfolge, ohne interne IDs. Die Auswahl des
* anzuzeigenden Hinweises trifft der Browser (hoechster Rang, bei Gleichstand spaeterer Beginn).
* Uebersprungen werden Hinweise ohne Text, mit unbekannter Art, unlesbaren Tagen oder bis <= von.
* „8 Tage“ = dieselbe Wanduhrzeit 8 Kalendertage spaeter (minutengenau, wie wechselListe).
*/
export function gueltigeHinweise(daten, now) {
const nowMs = pruefeJetzt(now)
const jetzt = ausUtc(now)
const grenzeMs = zuUtcOderDanach(plusTage(jetzt.tag, HINWEIS_VORLAUF_TAGE), jetzt.uhrzeit).getTime()
const liste = Array.isArray(daten?.hinweise) ? daten.hinweise : []
const ergebnis = []
for (const h of liste) {
if (!h || typeof h.text !== 'string' || h.text.trim() === '') continue
if (typeof h.art !== 'string' || !Object.hasOwn(HINWEIS_RANG, h.art)) continue
const z = hinweisZeitraum(h)
if (!z || z.bis <= z.ab || z.bis <= nowMs || z.ab > grenzeMs) continue
ergebnis.push({ art: h.art, text: h.text, knopf: knopfKopie(h.knopf), ab: z.ab, bis: z.bis, rang: HINWEIS_RANG[h.art] })
}
return ergebnis
}
// ------------------------------------------------------------------ JSON-LD und Google-Text
const WOCHENTAGE_EN = Object.freeze(['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'])
const SONDER_TAGE = 60 // Spec 4.3/3.4: Sonderzeiten der naechsten 60 Tage (heute inklusive)
/** Ist das einzige/gemeinte Fenster 00:00–24:00 (= rund um die Uhr)? */
const ganztags = (f) => f[0] === '00:00' && f[1] === '24:00'
/** Ende fuer Anzeige und JSON-LD: nie '24:00', sondern '00:00' (closes < opens = Folgetag). */
const endeAnzeige = (bis) => (bis === '24:00' ? '00:00' : bis)
/**
* Oeffnungspaare [opens, closes] eines Tagesbildes fuer JSON-LD: 'rund' und ein Fenster 00:00–24:00 =
* 00:00–23:59, Fenster ueber Mitternacht mit closes < opens, Ende 24:00 als 00:00; 'zu' und 'vereinbarung' = [].
*/
function ldPaare(modus, fenster) {
if (modus === 'rund') return [['00:00', '23:59']]
if (modus !== 'zeiten') return []
return fenster.map((f) => (ganztags(f) ? ['00:00', '23:59'] : [f[0], endeAnzeige(f[1])]))
}
const ldSpezifikation = (extra) => ({ '@type': 'OpeningHoursSpecification', ...extra })
/**
* Sonderzeiten der naechsten 60 Tage (heute bis heute + 59) als zusammenhaengende Laeufe gleicher Zeiten:
* [{ von, bis, text, name, zeiten }] mit zeiten = JSON-String der Tagesfenster; gleiche aufeinanderfolgende
* Sondertage (Art 'feiertag'/'urlaub'/'sondertag', nicht 'regulaer') sind ein Lauf. Mit gruppeNachGrund
* trennen auch unterschiedliche Namen/Arten die Laeufe (Google-Text), sonst nicht (JSON-LD).
*/
function sonderLaeufe(daten, plan, heute, gruppeNachGrund, zeitenVon) {
const laeufe = []
let lauf = null
for (const t of tageListe(daten, plan, heute, SONDER_TAGE)) {
if (t.art === 'regulaer') {
lauf = null
continue
}
const zeiten = zeitenVon(t)
const grund = gruppeNachGrund ? `${t.art}|${t.name ?? ''}` : ''
if (lauf && lauf.zeiten === zeiten && lauf.grund === grund && plusTage(lauf.bis, 1) === t.datum) {
lauf.bis = t.datum
} else {
lauf = { von: t.datum, bis: t.datum, zeiten, grund, tag: t }
laeufe.push(lauf)
}
}
return laeufe
}
/**
* Daten fuer schema.org: { openingHoursSpecification, specialOpeningHoursSpecification, neuerBlock }.
* - openingHoursSpecification: je Wochentag und Fenster ein Eintrag { '@type', dayOfWeek, opens, closes };
* Fenster ueber Mitternacht mit closes < opens, Ende 24:00 als '00:00', „rund“ 00:00–23:59,
* „nach Vereinbarung“ und „zu“ ausgelassen.
* - specialOpeningHoursSpecification: Feiertage mit Abweichung oder Vorgabe, Urlaub und Sondertage der naechsten
* 60 Tage (heute bis heute + 59, ein laufender Zeitraum beginnt heute) als { '@type', validFrom, validThrough,
* opens, closes }; aufeinanderfolgende Tage mit gleichen Zeiten sind ein Zeitraum, geschlossen = 00:00–00:00.
* „Nach Vereinbarung“ kennt JSON-LD nicht: an Sondertagen zaehlt es als geschlossen (sonst blieben die
* Wochenzeiten des Tages stehen).
* - neuerBlock: vollstaendiger Block { '@context': 'https://schema.org', '@type': 'LocalBusiness', ... } ohne
* name/address/url (die setzt das Website-Skript; das https-Literal liefert deshalb der Server).
* Ohne Plan (null) sind beide Listen leer.
*/
export function jsonLdFuer(daten, plan, now) {
pruefeJetzt(now)
const heute = ausUtc(now).tag
const woche = []
const sonder = []
if (plan && typeof plan === 'object') {
for (let tag = 1; tag <= 7; tag += 1) {
const { modus, fenster } = modusUndFenster(plan.tage?.[tag])
for (const [opens, closes] of ldPaare(modus, fenster)) {
woche.push(ldSpezifikation({ dayOfWeek: WOCHENTAGE_EN[tag - 1], opens, closes }))
}
}
const geschlossen = [['00:00', '00:00']]
const zeitenVon = (t) => JSON.stringify(ldPaare(t.modus, t.fenster))
for (const lauf of sonderLaeufe(daten, plan, heute, false, zeitenVon)) {
const paare = ldPaare(lauf.tag.modus, lauf.tag.fenster)
for (const [opens, closes] of paare.length ? paare : geschlossen) {
sonder.push(ldSpezifikation({ validFrom: lauf.von, validThrough: lauf.bis, opens, closes }))
}
}
}
return {
openingHoursSpecification: woche,
specialOpeningHoursSpecification: sonder,
neuerBlock: {
'@context': 'https://schema.org',
'@type': 'LocalBusiness',
openingHoursSpecification: woche.map((e) => ({ ...e })),
specialOpeningHoursSpecification: sonder.map((e) => ({ ...e })),
},
}
}
/** Tageszeiten als Text: „09:00–12:30, 13:30–18:00“, „geschlossen“, „rund um die Uhr“, „nach Vereinbarung“ (nie '24:00'). */
function tagText(modus, fenster) {
if (modus === 'rund' || (modus === 'zeiten' && fenster.length === 1 && ganztags(fenster[0]))) return 'rund um die Uhr'
if (modus === 'vereinbarung') return 'nach Vereinbarung'
if (modus !== 'zeiten') return 'geschlossen'
return fenster.map((f) => `${f[0]}–${endeAnzeige(f[1])}`).join(', ')
}
/**
* Text zum Kopieren in das Google-Profil (Spec 3.4 Bereich 6): sieben Zeilen Montag bis Sonntag
* („Montag: 09:00–12:30, 13:30–18:00“, „Sonntag: geschlossen“), danach, durch eine Leerzeile getrennt, die
* Sonderzeiten der naechsten 60 Tage („12.10.–16.10.: geschlossen (Betriebsurlaub)“, „03.10.: geschlossen
* (Tag der Deutschen Einheit)“; aufeinanderfolgende Tage mit gleichen Zeiten und gleichem Grund in einer Zeile).
* Zeilen mit \n getrennt, kein abschliessender Zeilenumbruch. Ohne Plan ''.
*/
export function googleText(daten, plan, now) {
pruefeJetzt(now)
const heute = ausUtc(now).tag
if (!plan || typeof plan !== 'object') return ''
const zeilen = []
for (let tag = 1; tag <= 7; tag += 1) {
const { modus, fenster } = modusUndFenster(plan.tage?.[tag])
zeilen.push(`${WOCHENTAGE[tag - 1]}: ${tagText(modus, fenster)}`)
}
const laeufe = sonderLaeufe(daten, plan, heute, true, (t) => tagText(t.modus, t.fenster))
if (laeufe.length) {
zeilen.push('', `Sonderzeiten (nächste ${SONDER_TAGE} Tage):`)
for (const lauf of laeufe) {
const datum = lauf.von === lauf.bis ? datumText(lauf.von) : `${datumText(lauf.von)}–${datumText(lauf.bis)}`
const grund = lauf.tag.name ?? (lauf.tag.art === 'urlaub' ? 'Betriebsurlaub' : null)
zeilen.push(`${datum}: ${lauf.zeiten}${grund ? ` (${grund})` : ''}`)
}
}
return zeilen.join('\n')
}
// ------------------------------------------------------------------ Erinnerung
const ERINNERUNG_TAGE = 7 // Spec 7: Tage in 1–7 Tagen
const ERINNERUNG_VON = '08:00' // Berliner Zeit, einschliesslich
const ERINNERUNG_BIS = '20:00' // Berliner Zeit, ausschliesslich
/** Hat der Kunde fuer diesen Tag unter daten.feiertage schon eine Abweichung gespeichert? */
function abweichungGespeichert(daten, tag) {
const f = daten?.feiertage
return !!f && typeof f === 'object' && Object.hasOwn(f, tag) && !!f[tag] && typeof f[tag] === 'object'
}
/**
* Feiertage und „bitte pruefen“-Tage des Landes in 1–7 Berliner Tagen ab heute, zu denen der Kunde erinnert werden
* soll: [{ tag, name, art: 'feiertag'|'pruefen' }]. Nicht dabei sind Tage, an denen der Hauptplan an diesem
* Wochentag ohnehin zu ist, eine Ausnahme (Urlaub/Sondertag, global oder fuer den Hauptplan) den Tag abdeckt oder
* der Kunde unter daten.feiertage[tag] schon eine Abweichung gespeichert hat. Ohne Hauptplan [].
*/
export function erinnerungsTage(daten, now) {
pruefeJetzt(now)
const haupt = hauptplan(daten)
if (!haupt) return []
const heute = ausUtc(now).tag
const ergebnis = []
for (const e of feiertageImZeitraum(plusTage(heute, 1), plusTage(heute, ERINNERUNG_TAGE), landVon(daten))) {
if (modusUndFenster(haupt.tage?.[wochentag(e.tag)]).modus === 'zu') continue
if (ausnahmeFuer(daten, haupt.id, e.tag, 'urlaub') || ausnahmeFuer(daten, haupt.id, e.tag, 'sondertag')) continue
if (abweichungGespeichert(daten, e.tag)) continue
ergebnis.push({ tag: e.tag, name: e.name, art: e.art })
}
return ergebnis
}
/** Liegt now zwischen 08:00 (einschliesslich) und 20:00 (ausschliesslich) Berliner Zeit? */
export function imErinnerungsfenster(now) {
pruefeJetzt(now)
const { uhrzeit } = ausUtc(now)
return uhrzeit >= ERINNERUNG_VON && uhrzeit < ERINNERUNG_BIS
}