Öffnungszeiten: Live-Status, Texte und Wechselzeitpunkte
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0f25f7cb52
commit
59b0ea799d
2 files changed
+604
-2
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { zuUtc, wochentag, plusTage } from '../termine/zeit.js'
|
||||
import { zuUtc, ausUtc, wochentag, plusTage } from '../termine/zeit.js'
|
||||
import { BUNDESLAENDER, feiertageImZeitraum } from '../termine/feiertage.js'
|
||||
|
||||
/**
|
||||
@@ -234,3 +234,183 @@ export function oeffnungsIntervalle(daten, plan, vonTag, bisTag) {
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user