diff --git a/server/services/zeiten/zeitlogik.js b/server/services/zeiten/zeitlogik.js index 620c93c..db4f954 100644 --- a/server/services/zeiten/zeitlogik.js +++ b/server/services/zeiten/zeitlogik.js @@ -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 +} diff --git a/test/zeitenZeitlogik.test.js b/test/zeitenZeitlogik.test.js index b60d2a8..65c8683 100644 --- a/test/zeitenZeitlogik.test.js +++ b/test/zeitenZeitlogik.test.js @@ -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', () => {