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>
651 lines
29 KiB
JavaScript
651 lines
29 KiB
JavaScript
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
|
||
}
|