Öffnungszeiten: Live-Status, Texte und Wechselzeitpunkte

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 23:59:20 +00:00
1 parent 0f25f7cb52
commit 59b0ea799d
2 files changed
+604 -2

No files matched your search

+181 -1
View File
@@ -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
}
+423 -1
View File
@@ -7,8 +7,12 @@ import {
zuUtcOderDanach,
tagesbild,
oeffnungsIntervalle,
uhrText,
statusZu,
wechselListe,
tageListe,
} from '../server/services/zeiten/zeitlogik.js'
import { ausUtc } from '../server/services/termine/zeit.js'
import { ausUtc, zuUtc } from '../server/services/termine/zeit.js'
// Fixture: Wochentage 1..7 (Mo..So). Wert: ['09:00','18:00'] = ein Fenster, [[..],[..]] = zwei Fenster,
// 'rund' / 'vereinbarung' = Modus, fehlt = zu.
@@ -581,6 +585,424 @@ test('Rückgabe der Intervalle ist reine Zahlen-Struktur ohne Seiteneffekt auf d
assert.ok(r.every((i) => Object.keys(i).sort().join() === 'bisMs,vonMs' && Number.isInteger(i.vonMs) && Number.isInteger(i.bisMs)))
})
// ---------------------------------------------------------------- Status und Texte (Task 5)
// Mo 05.10.2026 10:00 Berlin (MESZ)
const JETZT = new Date('2026-10-05T08:00:00.000Z')
const WOCHE_ALLE_RUND = { 1: 'rund', 2: 'rund', 3: 'rund', 4: 'rund', 5: 'rund', 6: 'rund', 7: 'rund' }
const WOCHE_ALLE_9_18 = { 1: ['09:00', '18:00'], 2: ['09:00', '18:00'], 3: ['09:00', '18:00'], 4: ['09:00', '18:00'], 5: ['09:00', '18:00'], 6: ['09:00', '18:00'], 7: ['09:00', '18:00'] }
const status = (d, tag, hhmm) => statusZu(d, hauptplan(d), zuUtc(tag, hhmm))
const nurStatus = (s) => ({ offen: s.offen, text: s.text, kurz: s.kurz })
test('uhrText: volle Stunde ohne Minuten, sonst H:MM, Mitternacht für 00:00 und 24:00', () => {
assert.equal(uhrText('09:00'), '9 Uhr')
assert.equal(uhrText('18:30'), '18:30 Uhr')
assert.equal(uhrText('09:05'), '9:05 Uhr')
assert.equal(uhrText('12:00'), '12 Uhr')
assert.equal(uhrText('23:55'), '23:55 Uhr')
assert.equal(uhrText('00:00'), 'Mitternacht')
assert.equal(uhrText('24:00'), 'Mitternacht')
assert.equal(uhrText('00:30'), '0:30 Uhr')
for (const falsch of ['', '9:00', '24:30', '25:00', '12:60', 'abc', null, undefined, 900]) {
assert.throws(() => uhrText(falsch), RangeError, String(falsch))
}
})
test('offen: schließt um 18 Uhr', () => {
const d = daten({ plaene: [plan({ 1: ['09:00', '18:00'] })] })
assert.deepEqual(statusZu(d, hauptplan(d), JETZT), { offen: true, text: 'Jetzt geöffnet · schließt um 18 Uhr', kurz: 'Geöffnet' })
// Grenzen: Beginn inklusive, Ende exklusive
assert.equal(status(d, '2026-10-05', '09:00').offen, true)
assert.equal(status(d, '2026-10-05', '17:59').offen, true)
assert.equal(status(d, '2026-10-05', '18:00').offen, false)
assert.equal(statusZu(d, hauptplan(d), new Date(zuUtc('2026-10-05', '09:00').getTime() - 1)).offen, false)
// Sekunden zählen mit: 17:59:59 ist noch offen
assert.equal(statusZu(d, hauptplan(d), new Date(zuUtc('2026-10-05', '18:00').getTime() - 1000)).offen, true)
})
test('offen: Schluss mit Minuten „schließt um 18:30 Uhr“, zwei Fenster am Tag', () => {
const d = daten({ plaene: [plan({ 1: [['09:00', '12:30'], ['13:30', '18:30']] })] })
assert.equal(status(d, '2026-10-05', '10:00').text, 'Jetzt geöffnet · schließt um 12:30 Uhr')
assert.equal(status(d, '2026-10-05', '12:30').text, 'Geschlossen · öffnet heute um 13:30 Uhr')
assert.equal(status(d, '2026-10-05', '14:00').text, 'Jetzt geöffnet · schließt um 18:30 Uhr')
})
test('Schluss um Mitternacht: „schließt um Mitternacht“ (00:00 und 24:00)', () => {
for (const ende of ['00:00', '24:00']) {
const d = daten({ plaene: [plan({ 1: ['18:00', ende] })] })
assert.deepEqual(status(d, '2026-10-05', '20:00'), { offen: true, text: 'Jetzt geöffnet · schließt um Mitternacht', kurz: 'Geöffnet' }, ende)
assert.equal(status(d, '2026-10-06', '00:00').offen, false, ende)
}
})
test('Schluss mehr als 24 h entfernt: „bis Dienstag 14 Uhr“', () => {
const d = daten({ plaene: [plan({ 1: ['10:00', '24:00'], 2: ['00:00', '14:00'] })] })
assert.deepEqual(statusZu(d, hauptplan(d), JETZT), { offen: true, text: 'Jetzt geöffnet · bis Dienstag 14 Uhr', kurz: 'Geöffnet' })
assert.equal(status(d, '2026-10-05', '13:59').text, 'Jetzt geöffnet · bis Dienstag 14 Uhr')
// genau 24 h vor dem Schluss ist es nicht „mehr als 24 h“
assert.equal(status(d, '2026-10-05', '14:00').text, 'Jetzt geöffnet · schließt um 14 Uhr')
assert.equal(status(d, '2026-10-06', '00:00').text, 'Jetzt geöffnet · schließt um 14 Uhr')
})
test('Schluss um Mitternacht mehr als 24 h entfernt: der Tag ist der, der endet („bis Dienstag Mitternacht“)', () => {
const d = daten({ plaene: [plan({ 1: ['10:00', '24:00'], 2: 'rund' })] })
assert.equal(statusZu(d, hauptplan(d), JETZT).text, 'Jetzt geöffnet · bis Dienstag Mitternacht')
})
test('Schluss mehr als 6 Tage entfernt: Datum statt Wochentag', () => {
// alles rund, aber am Mo 12.10. nur bis 09:00, danach Urlaub: Schluss in 6 Tagen 23 Stunden
const d = daten({
plaene: [plan(WOCHE_ALLE_RUND)],
ausnahmen: [sondertag({ id: 's', von: '2026-10-12', bis: '2026-10-12', fenster: [['00:00', '09:00']] }), urlaub({ von: '2026-10-13', bis: '2026-10-31' })],
})
assert.deepEqual(statusZu(d, hauptplan(d), JETZT), { offen: true, text: 'Jetzt geöffnet · bis 12.10. um 9 Uhr', kurz: 'Geöffnet' })
// Mitternacht: der endende Tag, nicht der Folgetag (Schluss Di 13.10. 00:00 = Montag Mitternacht)
const e = daten({ plaene: [plan(WOCHE_ALLE_RUND)], ausnahmen: [urlaub({ von: '2026-10-13', bis: '2026-10-31' })] })
assert.equal(status(e, '2026-10-06', '10:00').text, 'Jetzt geöffnet · bis Montag Mitternacht')
assert.equal(statusZu(e, hauptplan(e), JETZT).text, 'Rund um die Uhr geöffnet', 'ab Mo 10:00 sind es 7 Tage 14 Stunden')
})
test('7 Tage durchgehend: „Rund um die Uhr geöffnet“', () => {
const d = daten({ plaene: [plan(WOCHE_ALLE_RUND)] })
assert.deepEqual(statusZu(d, hauptplan(d), JETZT), { offen: true, text: 'Rund um die Uhr geöffnet', kurz: 'Geöffnet' })
// dasselbe als Fenster 00:00–24:00 an jedem Tag (grenzende Fenster werden zusammengefügt)
const e = daten({ plaene: [plan(Object.fromEntries([1, 2, 3, 4, 5, 6, 7].map((t) => [t, ['00:00', '24:00']])))] })
assert.equal(statusZu(e, hauptplan(e), JETZT).text, 'Rund um die Uhr geöffnet')
// genau 7 Tage ab jetzt (Mo 12.10. 10:00 Ende) zählt noch, eine Minute später nicht mehr
const f = daten({
plaene: [plan(WOCHE_ALLE_RUND)],
ausnahmen: [sondertag({ id: 's', von: '2026-10-12', bis: '2026-10-12', fenster: [['00:00', '10:00']] }), urlaub({ von: '2026-10-13', bis: '2026-10-31' })],
})
assert.equal(statusZu(f, hauptplan(f), JETZT).text, 'Rund um die Uhr geöffnet')
assert.equal(status(f, '2026-10-05', '10:01').text, 'Jetzt geöffnet · bis 12.10. um 10 Uhr')
})
test('6 Tage durchgehend sind nicht „rund um die Uhr“', () => {
const d = daten({ plaene: [plan({ 1: 'rund', 2: 'rund', 3: 'rund', 4: 'rund', 5: 'rund', 6: 'rund' })] })
assert.equal(statusZu(d, hauptplan(d), JETZT).text, 'Jetzt geöffnet · bis Samstag Mitternacht')
})
test('zu: öffnet heute / morgen / Wochentag / Datum', () => {
const zu = (woche, tag = '2026-10-05', uhr = '10:00') => nurStatus(status(daten({ plaene: [plan(woche)] }), tag, uhr))
assert.deepEqual(zu({ 1: ['14:00', '18:00'] }), { offen: false, text: 'Geschlossen · öffnet heute um 14 Uhr', kurz: 'Geschlossen' })
assert.equal(zu({ 2: ['09:00', '18:00'] }).text, 'Geschlossen · öffnet morgen um 9 Uhr')
assert.equal(zu({ 5: ['09:30', '18:00'] }).text, 'Geschlossen · öffnet Freitag um 9:30 Uhr')
assert.equal(zu({ 2: ['09:00', '18:00'] }, '2026-10-05', '20:00').text, 'Geschlossen · öffnet morgen um 9 Uhr')
// ≤ 6 Tage: Wochentag (So 11.10. = 6 Tage; Mo nach Di = 6 Tage)
assert.equal(zu({ 7: ['09:00', '18:00'] }).text, 'Geschlossen · öffnet Sonntag um 9 Uhr')
assert.equal(zu({ 1: ['09:00', '18:00'] }, '2026-10-06').text, 'Geschlossen · öffnet Montag um 9 Uhr')
// 7 Tage und mehr: Datum
assert.equal(zu({ 1: ['09:00', '18:00'] }, '2026-10-05', '19:00').text, 'Geschlossen · öffnet am 12.10. um 9 Uhr')
assert.equal(zu({ 1: ['09:00', '18:00'] }, '2026-10-05', '09:00').offen, true)
})
test('zu: Öffnung genau um Mitternacht heißt „0 Uhr“ (nicht „Mitternacht“, das wäre Tagesende)', () => {
const d = daten({ plaene: [plan({ 2: 'rund' })] })
assert.equal(statusZu(d, hauptplan(d), JETZT).text, 'Geschlossen · öffnet morgen um 0 Uhr')
const e = daten({ plaene: [plan({ 2: ['00:00', '06:00'] })] })
assert.equal(statusZu(e, hauptplan(e), JETZT).text, 'Geschlossen · öffnet morgen um 0 Uhr')
})
test('zu: nächste Öffnung nach Feiertag oder Urlaub wird gefunden, auch weit voraus', () => {
const d = daten({ plaene: [plan({ 1: ['09:00', '18:00'] })], ausnahmen: [urlaub({ von: '2026-10-12', bis: '2026-11-16' })] })
// Mo 05.10. 19:00: nächstes Montag 12.10. und 19.10. … alles Urlaub bis 16.11. -> Mo 23.11. = 49 Tage
assert.equal(status(d, '2026-10-05', '19:00').text, 'Geschlossen · öffnet am 23.11. um 9 Uhr')
})
test('keine Öffnung in 60 Tagen: „Derzeit geschlossen“', () => {
const d = daten({ plaene: [plan({})] })
assert.deepEqual(statusZu(d, hauptplan(d), JETZT), { offen: false, text: 'Derzeit geschlossen', kurz: 'Geschlossen' })
// Sondertag genau am 60. Tag (04.12.) wird noch gefunden, am 61. (05.12.) nicht
const am60 = daten({ plaene: [plan({})], ausnahmen: [sondertag({ von: '2026-12-04', bis: '2026-12-04', fenster: [['09:00', '12:00']] })] })
assert.equal(statusZu(am60, hauptplan(am60), JETZT).text, 'Geschlossen · öffnet am 04.12. um 9 Uhr')
const am61 = daten({ plaene: [plan({})], ausnahmen: [sondertag({ von: '2026-12-05', bis: '2026-12-05', fenster: [['09:00', '12:00']] })] })
assert.equal(statusZu(am61, hauptplan(am61), JETZT).text, 'Derzeit geschlossen')
// kein Plan: auch kein Fehler
assert.equal(statusZu({}, null, JETZT).text, 'Derzeit geschlossen')
})
test('Grund an geschlossenen Tagen: Betriebsurlaub bis …', () => {
const p = plan(WOCHE_ALLE_9_18)
const d = daten({ plaene: [p], ausnahmen: [urlaub({ von: '2026-10-05', bis: '2026-10-17', text: 'Sommerpause' })] })
assert.deepEqual(statusZu(d, p, JETZT), { offen: false, text: 'Heute geschlossen (Betriebsurlaub bis 17.10.)', kurz: 'Geschlossen' })
// aneinander grenzende Urlaube zählen zusammen
const zwei = daten({ plaene: [p], ausnahmen: [urlaub({ id: 'u1', von: '2026-10-05', bis: '2026-10-10' }), urlaub({ id: 'u2', von: '2026-10-11', bis: '2026-10-17' })] })
assert.equal(statusZu(zwei, p, JETZT).text, 'Heute geschlossen (Betriebsurlaub bis 17.10.)')
// ein einzelner Urlaubstag
const eins = daten({ plaene: [p], ausnahmen: [urlaub({ von: '2026-10-05', bis: '2026-10-05' })] })
assert.equal(statusZu(eins, p, JETZT).text, 'Heute geschlossen (Betriebsurlaub bis 05.10.)')
// ein geöffneter Sondertag mitten im Urlaub beendet die Kette
const luecke = daten({ plaene: [p], ausnahmen: [urlaub({ von: '2026-10-05', bis: '2026-10-17' }), sondertag({ von: '2026-10-09', bis: '2026-10-09' })] })
assert.equal(statusZu(luecke, p, JETZT).text, 'Heute geschlossen (Betriebsurlaub bis 08.10.)')
// Urlaub eines anderen Plans zählt nicht
const fremd = daten({ plaene: [p], ausnahmen: [urlaub({ von: '2026-10-05', bis: '2026-10-17', plan: 'anderer' })] })
assert.equal(statusZu(fremd, p, JETZT).text, 'Jetzt geöffnet · schließt um 18 Uhr')
// Urlaub ab morgen: heute normal
const morgen = daten({ plaene: [p], ausnahmen: [urlaub({ von: '2026-10-06', bis: '2026-10-17' })] })
assert.equal(statusZu(morgen, p, JETZT).text, 'Jetzt geöffnet · schließt um 18 Uhr')
assert.equal(status(morgen, '2026-10-05', '19:00').text, 'Geschlossen · öffnet am 18.10. um 9 Uhr')
})
test('Grund an geschlossenen Tagen: Feiertag mit Namen (auch geschlossener Prüftag)', () => {
const p = plan(WOCHE_ALLE_9_18)
const d = daten({ plaene: [p] })
assert.deepEqual(status(d, '2026-10-03', '10:00'), { offen: false, text: 'Heute geschlossen (Feiertag: Tag der Deutschen Einheit)', kurz: 'Geschlossen' })
// Prüftag (R5): vom Kunden geschlossen ⇒ derselbe Grundtext
const heiligabend = daten({ plaene: [p], feiertage: { '2026-12-24': { modus: 'zu', fenster: [] } } })
assert.equal(status(heiligabend, '2026-12-24', '10:00').text, 'Heute geschlossen (Feiertag: Heiligabend)')
// Prüftag ohne Abweichung = normaler Wochentag
assert.equal(status(d, '2026-12-24', '10:00').text, 'Jetzt geöffnet · schließt um 18 Uhr')
// Feiertag mit Öffnungszeit (Abweichung): kein Grund, nach Schluss normaler Text
const offen = daten({ plaene: [p], feiertage: { '2026-10-03': { modus: 'zeiten', fenster: [['10:00', '14:00']] } } })
assert.equal(status(offen, '2026-10-03', '10:30').text, 'Jetzt geöffnet · schließt um 14 Uhr')
assert.equal(status(offen, '2026-10-03', '15:00').text, 'Geschlossen · öffnet morgen um 9 Uhr')
assert.equal(status(offen, '2026-10-03', '09:00').text, 'Geschlossen · öffnet heute um 10 Uhr')
})
test('Grund: regulär geschlossene Tage und geschlossene Sondertage haben keinen Grundtext', () => {
const d = daten({ plaene: [plan({ 2: ['09:00', '18:00'] })] })
assert.equal(statusZu(d, hauptplan(d), JETZT).text, 'Geschlossen · öffnet morgen um 9 Uhr')
const s = daten({ plaene: [plan(WOCHE_ALLE_9_18)], ausnahmen: [sondertag({ von: '2026-10-05', bis: '2026-10-05', text: 'Inventur', modus: 'zu', fenster: [] })] })
assert.equal(statusZu(s, hauptplan(s), JETZT).text, 'Geschlossen · öffnet morgen um 9 Uhr')
})
test('Urlaub: ein vom Vortag hineinlaufendes Fenster hält offen, danach „Heute geschlossen“', () => {
// Bar: Freitag 18–02, Samstag ist Betriebsurlaub
const d = daten({ plaene: [plan({ 5: ['18:00', '02:00'], 6: ['18:00', '02:00'] })], ausnahmen: [urlaub({ von: '2026-10-10', bis: '2026-10-11' })] })
assert.deepEqual(status(d, '2026-10-10', '01:00'), { offen: true, text: 'Jetzt geöffnet · schließt um 2 Uhr', kurz: 'Geöffnet' })
assert.equal(status(d, '2026-10-10', '03:00').text, 'Heute geschlossen (Betriebsurlaub bis 11.10.)')
})
test('nach Vereinbarung: „Heute nach Vereinbarung“ mit offen: null', () => {
const d = daten({ plaene: [plan({ 1: 'vereinbarung', 2: ['09:00', '18:00'] })] })
assert.deepEqual(statusZu(d, hauptplan(d), JETZT), { offen: null, text: 'Heute nach Vereinbarung', kurz: 'Nach Vereinbarung' })
// Vereinbarung an einem Feiertag (Abweichung)
const f = daten({ plaene: [plan(WOCHE_ALLE_9_18)], feiertage: { '2026-10-03': { modus: 'vereinbarung', fenster: [] } } })
assert.deepEqual(status(f, '2026-10-03', '10:00'), { offen: null, text: 'Heute nach Vereinbarung', kurz: 'Nach Vereinbarung' })
// ein vom Vortag hineinlaufendes Fenster ist weiter „offen“, danach Vereinbarung
const n = daten({ plaene: [plan({ 7: ['20:00', '02:00'], 1: 'vereinbarung' })] })
assert.equal(status(n, '2026-10-05', '01:00').offen, true)
assert.equal(status(n, '2026-10-05', '02:00').offen, null)
// Vereinbarung erst morgen ändert heute nichts
const m = daten({ plaene: [plan({ 2: 'vereinbarung', 3: ['09:00', '18:00'] })] })
assert.deepEqual(nurStatus(statusZu(m, hauptplan(m), JETZT)), { offen: false, text: 'Geschlossen · öffnet Mittwoch um 9 Uhr', kurz: 'Geschlossen' })
})
test('Fenster 18–02: um 01:00 am Folgetag offen, ab 02:00 zu', () => {
const d = daten({ plaene: [plan({ 6: ['18:00', '02:00'] })] })
assert.deepEqual(status(d, '2026-10-11', '01:00'), { offen: true, text: 'Jetzt geöffnet · schließt um 2 Uhr', kurz: 'Geöffnet' })
assert.equal(status(d, '2026-10-11', '01:59').offen, true)
assert.equal(status(d, '2026-10-11', '02:00').offen, false)
assert.equal(status(d, '2026-10-10', '20:00').text, 'Jetzt geöffnet · schließt um 2 Uhr')
// Bar: Samstag 18–02, Sonntag ist Betriebsurlaub ⇒ bis 02:00 trotzdem offen
const f = daten({ plaene: [plan({ 6: ['18:00', '02:00'] })], ausnahmen: [urlaub({ von: '2026-10-11', bis: '2026-10-11' })] })
assert.equal(status(f, '2026-10-11', '01:30').offen, true)
})
test('Sommerzeit 25.10.: Fenster 02:00–06:00 ⇒ „schließt um 6 Uhr“ (beide 02:30-Zeitpunkte)', () => {
const d = daten({ plaene: [plan({ 7: ['02:00', '06:00'] })] })
// 02:30 MESZ (erstes Vorkommen) und 02:30 MEZ (zweites Vorkommen)
assert.deepEqual(statusZu(d, hauptplan(d), new Date('2026-10-25T00:30:00Z')), { offen: true, text: 'Jetzt geöffnet · schließt um 6 Uhr', kurz: 'Geöffnet' })
assert.equal(statusZu(d, hauptplan(d), new Date('2026-10-25T01:30:00Z')).text, 'Jetzt geöffnet · schließt um 6 Uhr')
assert.equal(statusZu(d, hauptplan(d), new Date('2026-10-25T04:59:00Z')).offen, true)
assert.equal(statusZu(d, hauptplan(d), new Date('2026-10-25T05:00:00Z')).offen, false)
// eine Minute vor Beginn (01:59 MESZ)
assert.equal(statusZu(d, hauptplan(d), new Date('2026-10-24T23:59:00Z')).text, 'Geschlossen · öffnet heute um 2 Uhr')
})
test('Sommerzeit 29.03.: Fenster 02:00–06:00 beginnt in der Lücke um 03:00', () => {
const d = daten({ plaene: [plan({ 7: ['02:00', '06:00'] })] })
assert.equal(status(d, '2026-03-28', '10:00').text, 'Geschlossen · öffnet morgen um 3 Uhr')
assert.equal(status(d, '2026-03-29', '03:30').text, 'Jetzt geöffnet · schließt um 6 Uhr')
})
test('nie „24 Uhr“ in irgendeinem Text (jede Viertelstunde einer Woche, rund/24h-Pläne)', () => {
const plaene = [
plan(WOCHE_ALLE_RUND),
plan(Object.fromEntries([1, 2, 3, 4, 5, 6, 7].map((t) => [t, ['00:00', '24:00']]))),
plan({ 1: ['18:00', '24:00'], 2: ['00:00', '02:00'], 3: ['22:00', '00:00'], 5: 'rund', 6: 'rund', 7: ['06:00', '24:00'] }),
plan({ 1: ['00:00', '24:00'], 2: ['10:00', '24:00'], 3: ['00:00', '12:00'], 5: [['00:00', '08:00'], ['20:00', '24:00']] }),
]
const start = zuUtc('2026-10-05', '00:00').getTime()
const KONTROLLE = /24 Uhr/
for (const p of plaene) {
const d = daten({ plaene: [p] })
for (let k = 0; k < 7 * 96; k += 1) {
const jetzt = new Date(start + k * 15 * 60 * 1000)
const s = statusZu(d, p, jetzt)
assert.doesNotMatch(s.text, KONTROLLE, `${berlin(jetzt.getTime())}: ${s.text}`)
}
for (const w of wechselListe(d, p, new Date(start))) assert.doesNotMatch(w.text, KONTROLLE, w.text)
}
})
test('statusZu verändert die Daten nicht und prüft den Zeitpunkt', () => {
const d = tiefEinfrieren(daten({ plaene: [plan({ 1: ['09:00', '18:00'], 6: ['22:00', '02:00'] })], ausnahmen: [urlaub({ von: '2026-10-12', bis: '2026-10-14' })] }))
assert.equal(statusZu(d, hauptplan(d), JETZT).offen, true)
assert.equal(wechselListe(d, hauptplan(d), JETZT).length > 3, true)
assert.equal(tageListe(d, hauptplan(d), '2026-10-05', 3).length, 3)
for (const falsch of [undefined, null, '2026-10-05', 1759651200000, new Date('x')]) {
assert.throws(() => statusZu(d, hauptplan(d), falsch), RangeError, String(falsch))
assert.throws(() => wechselListe(d, hauptplan(d), falsch), RangeError, String(falsch))
}
})
// ---------------------------------------------------------------- Wechselliste
const wl = (d, now = JETZT) => wechselListe(d, hauptplan(d), now)
const kompakt = (liste) => liste.map((w) => [berlin(w.ab), w.text])
test('wechselListe: erster Eintrag ist jetzt (Beginn der Minute), danach Wechsel um 18:00', () => {
const d = daten({ plaene: [plan({ 1: ['09:00', '18:00'] })] })
const mitSekunden = new Date(JETZT.getTime() + 30500)
const liste = wl(d, mitSekunden)
assert.equal(liste[0].ab, JETZT.getTime())
assert.deepEqual(nurStatus(liste[0]), { offen: true, text: 'Jetzt geöffnet · schließt um 18 Uhr', kurz: 'Geöffnet' })
assert.equal(liste[1].ab, zuUtc('2026-10-05', '18:00').getTime())
assert.deepEqual(nurStatus(liste[1]), { offen: false, text: 'Geschlossen · öffnet am 12.10. um 9 Uhr', kurz: 'Geschlossen' })
assert.deepEqual(Object.keys(liste[0]), ['ab', 'tag', 'offen', 'text', 'kurz'])
assert.ok(liste.every((w) => Number.isInteger(w.ab)))
})
test('wechselListe: vollständige Liste für Mo 09–18, 8 Tage, nur Wechsel und Texttage', () => {
const d = daten({ plaene: [plan({ 1: ['09:00', '18:00'] })] })
assert.deepEqual(kompakt(wl(d)), [
['2026-10-05 10:00', 'Jetzt geöffnet · schließt um 18 Uhr'],
['2026-10-05 18:00', 'Geschlossen · öffnet am 12.10. um 9 Uhr'],
['2026-10-06 00:00', 'Geschlossen · öffnet Montag um 9 Uhr'],
['2026-10-11 00:00', 'Geschlossen · öffnet morgen um 9 Uhr'],
['2026-10-12 00:00', 'Geschlossen · öffnet heute um 9 Uhr'],
['2026-10-12 09:00', 'Jetzt geöffnet · schließt um 18 Uhr'],
['2026-10-12 18:00', 'Geschlossen · öffnet am 19.10. um 9 Uhr'],
['2026-10-13 00:00', 'Geschlossen · öffnet Montag um 9 Uhr'],
])
})
test('wechselListe: Mitternachtseintrag wechselt „morgen“ zu „heute“', () => {
const d = daten({ plaene: [plan({ 2: ['09:00', '18:00'] })] })
const liste = wl(d)
assert.equal(liste[0].text, 'Geschlossen · öffnet morgen um 9 Uhr')
assert.equal(liste[1].ab, zuUtc('2026-10-06', '00:00').getTime())
assert.equal(liste[1].text, 'Geschlossen · öffnet heute um 9 Uhr')
assert.equal(liste[1].offen, false)
assert.equal(liste[2].ab, zuUtc('2026-10-06', '09:00').getTime())
assert.equal(liste[2].offen, true)
})
test('wechselListe hat tag (Berliner Datum bei ab)', () => {
const d = daten({ plaene: [plan({ 6: ['22:00', '02:00'] })] })
const liste = wl(d, new Date('2026-10-10T20:30:00Z')) // Sa 22:30 Berlin, offen bis So 02:00
assert.equal(liste[0].tag, '2026-10-10')
assert.deepEqual(nurStatus(liste[0]), { offen: true, text: 'Jetzt geöffnet · schließt um 2 Uhr', kurz: 'Geöffnet' })
const zu = liste.find((w) => w.offen === false)
assert.equal(berlin(zu.ab), '2026-10-11 02:00')
assert.equal(zu.tag, '2026-10-11')
for (const w of liste) assert.equal(w.tag, ausUtc(new Date(w.ab)).tag)
// knapp vor Mitternacht: tag wechselt mit dem Mitternachtseintrag
const spaet = wl(daten({ plaene: [plan({})] }), new Date('2026-10-05T21:59:30Z')) // Mo 23:59 Berlin
assert.equal(spaet[0].tag, '2026-10-05')
})
test('wechselListe: strikt aufsteigend, jeder Eintrag ändert etwas, innerhalb von 8 Tagen', () => {
const p = plan({ 1: [['09:00', '12:00'], ['13:00', '22:00']], 2: ['09:00', '12:00'], 4: ['20:00', '03:00'], 5: 'rund', 6: 'vereinbarung', 7: ['10:00', '14:00'] })
const d = daten({ plaene: [p], ausnahmen: [urlaub({ von: '2026-10-08', bis: '2026-10-09' })] })
const liste = wl(d)
assert.ok(liste.length > 10)
for (let k = 1; k < liste.length; k += 1) {
assert.ok(liste[k].ab > liste[k - 1].ab)
const a = liste[k - 1]
const b = liste[k]
assert.ok(a.offen !== b.offen || a.text !== b.text || a.kurz !== b.kurz, `Eintrag ${k} ändert nichts`)
}
const ende = zuUtc('2026-10-13', '10:00').getTime()
assert.ok(liste[liste.length - 1].ab < ende)
assert.ok(liste.every((w) => w.ab >= JETZT.getTime()))
})
test('wechselListe stimmt mit statusZu überein (offen und kurz zu jeder Viertelstunde, Text bei kurzen Läufen)', () => {
const p = plan({ 1: [['09:00', '12:00'], ['13:00', '22:00']], 2: ['09:00', '12:00'], 4: ['20:00', '03:00'], 6: 'vereinbarung', 7: ['10:00', '14:00'] })
const d = daten({ plaene: [p], ausnahmen: [urlaub({ von: '2026-10-08', bis: '2026-10-09' })] })
const liste = wl(d)
const ende = zuUtc('2026-10-13', '10:00').getTime()
for (let ms = JETZT.getTime(); ms < ende; ms += 15 * 60 * 1000) {
let w = liste[0]
for (const e of liste) if (e.ab <= ms) w = e
const s = statusZu(d, p, new Date(ms))
assert.deepEqual(nurStatus(w), nurStatus(s), berlin(ms))
}
})
test('wechselListe: Mitternachten an der Zeitumstellung sind Berliner 00:00 (25.10. und 29.03.)', () => {
const d = daten({ plaene: [plan({ 1: ['09:00', '18:00'] })] })
const okt = wl(d, new Date('2026-10-23T08:00:00Z')).map((w) => w.ab)
assert.ok(okt.includes(Date.parse('2026-10-24T22:00:00Z')), '25.10. 00:00 MESZ')
assert.ok(okt.includes(Date.parse('2026-10-25T23:00:00Z')), '26.10. 00:00 MEZ')
const maerz = wl(d, new Date('2026-03-27T08:00:00Z')).map((w) => w.ab)
assert.ok(maerz.includes(Date.parse('2026-03-28T23:00:00Z')), '29.03. 00:00 MEZ')
assert.ok(maerz.includes(Date.parse('2026-03-29T22:00:00Z')), '30.03. 00:00 MESZ')
// 8 Tage über die Umstellung: Wanduhr-Ende, nicht 8 × 24 h
const spaetEintrag = wl(d, new Date('2026-10-23T08:00:00Z'))
assert.ok(spaetEintrag[spaetEintrag.length - 1].ab < Date.parse('2026-10-31T09:00:00Z'))
})
test('wechselListe: Plan rund um die Uhr hat genau einen Eintrag', () => {
const d = daten({ plaene: [plan(WOCHE_ALLE_RUND)] })
const liste = wl(d)
assert.equal(liste.length, 1)
assert.deepEqual(nurStatus(liste[0]), { offen: true, text: 'Rund um die Uhr geöffnet', kurz: 'Geöffnet' })
})
test('wechselListe: Urlaub, Feiertag und Vereinbarung erscheinen mit Grund, wenn der Text wechselt', () => {
const p = plan({ 1: ['09:00', '18:00'], 2: ['09:00', '18:00'], 3: 'vereinbarung', 4: ['09:00', '18:00'] })
const d = daten({ plaene: [p], ausnahmen: [urlaub({ von: '2026-10-08', bis: '2026-10-09' })] })
const texte = wl(d).map((w) => w.text)
assert.ok(texte.includes('Heute nach Vereinbarung'))
assert.ok(texte.includes('Heute geschlossen (Betriebsurlaub bis 09.10.)'))
})
// ---------------------------------------------------------------- tageListe
test('tageListe liefert datum/art/name/modus/fenster für n Tage ab vonTag', () => {
const p = plan({ 1: ['09:00', '18:00'], 6: ['10:00', '14:00'] })
const d = daten({ plaene: [p], ausnahmen: [urlaub({ von: '2026-10-06', bis: '2026-10-06', text: 'Brückentag' })] })
const liste = tageListe(d, p, '2026-10-02', 5)
assert.equal(liste.length, 5)
assert.deepEqual(liste.map((t) => t.datum), ['2026-10-02', '2026-10-03', '2026-10-04', '2026-10-05', '2026-10-06'])
assert.deepEqual(Object.keys(liste[0]), ['datum', 'art', 'name', 'modus', 'fenster'])
assert.deepEqual(liste[1], { datum: '2026-10-03', art: 'feiertag', name: 'Tag der Deutschen Einheit', modus: 'zu', fenster: [] })
assert.deepEqual(liste[3], { datum: '2026-10-05', art: 'regulaer', name: null, modus: 'zeiten', fenster: [['09:00', '18:00']] })
assert.deepEqual(liste[4], { datum: '2026-10-06', art: 'urlaub', name: 'Brückentag', modus: 'zu', fenster: [] })
// dieselben Werte wie tagesbild, nur heißt der Tag hier „datum“
for (const t of liste) {
const { tag, ...b } = tagesbild(d, p, t.datum)
const { datum, ...rest } = t
assert.equal(datum, tag)
assert.deepEqual(rest, b)
}
})
test('tageListe: 60 Tage über den Jahreswechsel, Randfälle und Fehler', () => {
const p = plan({ 1: ['09:00', '18:00'] })
const d = daten({ plaene: [p] })
const liste = tageListe(d, p, '2026-12-01', 60)
assert.equal(liste.length, 60)
assert.equal(liste[0].datum, '2026-12-01')
assert.equal(liste[59].datum, '2027-01-29')
assert.equal(new Set(liste.map((t) => t.datum)).size, 60)
assert.deepEqual(tageListe(d, p, '2026-12-01', 0), [])
assert.throws(() => tageListe(d, p, '2026-02-30', 3), RangeError)
assert.throws(() => tageListe(d, p, undefined, 3), RangeError)
assert.throws(() => tageListe(d, p, '2026-12-01', -1), RangeError)
assert.throws(() => tageListe(d, p, '2026-12-01', 1.5), RangeError)
assert.throws(() => tageListe(d, p, '2026-12-01', '5'), RangeError)
assert.throws(() => tageListe(d, p, '2026-12-01', 1000), RangeError)
// Kopie: Änderungen an den Fenstern der Liste verändern die Daten nicht
const l2 = tageListe(d, p, '2026-10-05', 1)
l2[0].fenster.push(['20:00', '21:00'])
assert.deepEqual(p.tage[1].fenster, [['09:00', '18:00']])
})
// ---------------------------------------------------------------- Reinheit
test('zeitlogik.js ist rein: keine Appwrite- und keine Config-Importe, keine Millisekunden-Tagesrechnung', () => {