Öffnungszeiten: Hinweise, JSON-LD, Google-Text und Erinnerungstage

Reine Funktionen in zeitlogik.js: hinweisZeitraum/gueltigeHinweise (Berliner Kalendertage,
Rang, Beginn in 8 Tagen), jsonLdFuer (Mitternachtsfenster, rund 00:00–23:59, Sonderzeiten
der nächsten 60 Tage, neuerBlock mit @context), googleText, erinnerungsTage und
imErinnerungsfenster (08:00–20:00 Berlin).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 00:12:20 +00:00
1 parent 59b0ea799d
commit 61c2f72631
2 files changed
+673 -2

No files matched your search

+234
View File
@@ -414,3 +414,237 @@ export function tageListe(daten, plan, vonTag, anzahl) {
}
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
}