From 61c2f7263184bb329f888508cce021bd5136a7b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 00:12:20 +0000 Subject: [PATCH] =?UTF-8?q?=C3=96ffnungszeiten:=20Hinweise,=20JSON-LD,=20G?= =?UTF-8?q?oogle-Text=20und=20Erinnerungstage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/services/zeiten/zeitlogik.js | 234 +++++++++++++++ test/zeitenZeitlogik.test.js | 441 +++++++++++++++++++++++++++- 2 files changed, 673 insertions(+), 2 deletions(-) diff --git a/server/services/zeiten/zeitlogik.js b/server/services/zeiten/zeitlogik.js index db4f954..0a96422 100644 --- a/server/services/zeiten/zeitlogik.js +++ b/server/services/zeiten/zeitlogik.js @@ -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 +} diff --git a/test/zeitenZeitlogik.test.js b/test/zeitenZeitlogik.test.js index 65c8683..00f90fc 100644 --- a/test/zeitenZeitlogik.test.js +++ b/test/zeitenZeitlogik.test.js @@ -11,6 +11,13 @@ import { statusZu, wechselListe, tageListe, + HINWEIS_RANG, + hinweisZeitraum, + gueltigeHinweise, + jsonLdFuer, + googleText, + erinnerungsTage, + imErinnerungsfenster, } from '../server/services/zeiten/zeitlogik.js' import { ausUtc, zuUtc } from '../server/services/termine/zeit.js' @@ -27,13 +34,13 @@ function plan(woche = {}, extra = {}) { return { id: 'p1', kurz: 'standard', name: 'Standard', haupt: true, tage, ...extra } } -function daten({ land = 'RP', plaene = [plan({ 1: ['09:00', '18:00'] })], ausnahmen = [], feiertage = {} } = {}) { +function daten({ land = 'RP', plaene = [plan({ 1: ['09:00', '18:00'] })], ausnahmen = [], feiertage = {}, hinweise = [] } = {}) { return { land, plaene, feiertage, ausnahmen, - hinweise: [], + hinweise, kontakt: { an: false, telefon: '', adresse: '', whatsapp: null }, } } @@ -1003,6 +1010,436 @@ test('tageListe: 60 Tage über den Jahreswechsel, Randfälle und Fehler', () => assert.deepEqual(p.tage[1].fenster, [['09:00', '18:00']]) }) +// ---------------------------------------------------------------- Hinweise (Task 6) + +const hinweis = (id, art, von, bis, extra) => ({ id, art, text: `Text ${id}`, knopf: null, von, bis, ...extra }) + +test('HINWEIS_RANG: Wichtig > Urlaub > Aktion > Info, eingefroren', () => { + assert.deepEqual(HINWEIS_RANG, { wichtig: 4, urlaub: 3, aktion: 2, info: 1 }) + assert.ok(Object.isFrozen(HINWEIS_RANG)) +}) + +test('Hinweis-Zeitraum in Berliner Zeit über die Zeitumstellung', () => { + const iso = (ms) => new Date(ms).toISOString() + // Oktober: Ende der Sommerzeit am 25.10. (Tag hat 25 Stunden) + const z = hinweisZeitraum({ von: '2026-10-24', bis: '2026-10-25' }) + assert.equal(iso(z.ab), '2026-10-23T22:00:00.000Z') + assert.equal(iso(z.bis), '2026-10-25T23:00:00.000Z') + // März: Beginn der Sommerzeit am 29.03. + const m = hinweisZeitraum({ von: '2026-03-28', bis: '2026-03-29' }) + assert.equal(iso(m.ab), '2026-03-27T23:00:00.000Z') + assert.equal(iso(m.bis), '2026-03-29T22:00:00.000Z') + // ein einzelner Normaltag: von 00:00 bis 24:00 + const e = hinweisZeitraum({ von: '2026-10-05', bis: '2026-10-05' }) + assert.equal(iso(e.ab), '2026-10-04T22:00:00.000Z') + assert.equal(iso(e.bis), '2026-10-05T22:00:00.000Z') +}) + +test('Hinweis-Zeitraum: fehlendes von/bis = unbegrenzt (als Zahl), kaputte Tage = null', () => { + const ENDLOS = 8.64e15 + assert.deepEqual(hinweisZeitraum({}), { ab: 0, bis: ENDLOS }) + assert.deepEqual(hinweisZeitraum({ von: null, bis: '' }), { ab: 0, bis: ENDLOS }) + assert.equal(new Date(hinweisZeitraum({ von: '2026-10-05' }).ab).toISOString(), '2026-10-04T22:00:00.000Z') + assert.equal(hinweisZeitraum({ von: '2026-10-05' }).bis, ENDLOS) + assert.equal(hinweisZeitraum({ bis: '2026-10-05' }).ab, 0) + assert.ok(Number.isFinite(new Date(ENDLOS).getTime()), 'ENDLOS ist noch ein gültiges Date') + for (const kaputt of [{ von: '2026-02-30' }, { von: 'morgen' }, { bis: '05.10.2026' }, { von: 20261005 }, null, undefined, 'x']) { + assert.equal(hinweisZeitraum(kaputt), null, JSON.stringify(kaputt)) + } +}) + +test('gueltigeHinweise: vergangene fehlen, künftige ≤ 8 Tage sind dabei, Rang gesetzt', () => { + const d = daten({ + hinweise: [ + hinweis('alt', 'info', '2026-09-20', '2026-10-04'), // endete gestern 24:00 + hinweis('heute', 'aktion', '2026-10-01', '2026-10-05'), // läuft bis Ende heute + hinweis('kommt', 'urlaub', '2026-10-13', '2026-10-26', { knopf: { art: 'link', ziel: 'https://example.org/x', text: 'Mehr' } }), + hinweis('spaet', 'wichtig', '2026-10-14', '2026-10-15'), // Beginn in 8 Tagen 14 Stunden + hinweis('offen', 'info', '2026-10-01', undefined), // ohne Ende + ], + }) + const liste = gueltigeHinweise(d, JETZT) + assert.deepEqual(liste.map((h) => h.text), ['Text heute', 'Text kommt', 'Text offen'], 'gespeicherte Reihenfolge') + assert.deepEqual(liste[1], { + art: 'urlaub', + text: 'Text kommt', + knopf: { art: 'link', ziel: 'https://example.org/x', text: 'Mehr' }, + ab: Date.parse('2026-10-12T22:00:00Z'), + bis: Date.parse('2026-10-26T23:00:00Z'), + rang: 3, + }) + assert.deepEqual(liste.map((h) => h.rang), [2, 3, 1]) + assert.deepEqual(Object.keys(liste[0]), ['art', 'text', 'knopf', 'ab', 'bis', 'rang'], 'keine internen IDs') + assert.equal(liste[0].knopf, null) + assert.equal(liste[2].bis, 8.64e15) + // der Knopf ist eine Kopie + assert.notEqual(liste[1].knopf, d.hinweise[2].knopf) +}) + +test('gueltigeHinweise: Beginn genau 8 Tage voraus zählt, eine Minute später nicht', () => { + const d = daten({ hinweise: [hinweis('x', 'info', '2026-10-13', '2026-10-13')] }) + // 05.10. 00:00 Berlin + 8 Tage = 13.10. 00:00 = Beginn des Hinweises + assert.equal(gueltigeHinweise(d, new Date('2026-10-04T22:00:00Z')).length, 1) + // 04.10. 23:59 Berlin + 8 Tage = 12.10. 23:59, der Hinweis beginnt eine Minute später + assert.equal(gueltigeHinweise(d, new Date('2026-10-04T21:59:00Z')).length, 0) + // innerhalb des Tages und nach dem Ende + assert.equal(gueltigeHinweise(d, new Date('2026-10-13T21:59:00Z')).length, 1, 'bis 13.10. 23:59 gültig') + assert.equal(gueltigeHinweise(d, new Date('2026-10-13T22:00:00Z')).length, 0, 'ab 14.10. 00:00 vorbei') +}) + +test('gueltigeHinweise: Tag rechnet in Berliner Zeit (Mitternacht über die Zeitumstellung)', () => { + const d = daten({ hinweise: [hinweis('x', 'wichtig', '2026-10-24', '2026-10-25')] }) + assert.equal(gueltigeHinweise(d, new Date('2026-10-25T22:59:00Z')).length, 1, '25.10. 23:59 CET… noch gültig') + assert.equal(gueltigeHinweise(d, new Date('2026-10-25T23:00:00Z')).length, 0, '26.10. 00:00 Berlin: vorbei') +}) + +test('gueltigeHinweise: kaputte Einträge werden übersprungen, nichts wird verändert', () => { + const d = daten({ + hinweise: [ + null, + 'text', + hinweis('ohnetext', 'info', '2026-10-01', '2026-10-31', { text: ' ' }), + hinweis('textzahl', 'info', '2026-10-01', '2026-10-31', { text: 5 }), + hinweis('artfalsch', 'dringend', '2026-10-01', '2026-10-31'), + hinweis('artproto', 'constructor', '2026-10-01', '2026-10-31'), + hinweis('tagkaputt', 'info', '2026-02-30', '2026-10-31'), + hinweis('umgekehrt', 'info', '2026-10-20', '2026-10-10'), + hinweis('knopfkaputt', 'info', '2026-10-01', '2026-10-31', { knopf: { art: 'script', ziel: 'x', text: 'y' } }), + hinweis('gut', 'wichtig', '2026-10-01', '2026-10-31'), + ], + }) + tiefEinfrieren(d) + const liste = gueltigeHinweise(d, JETZT) + assert.deepEqual(liste.map((h) => h.text), ['Text knopfkaputt', 'Text gut']) + assert.equal(liste[0].knopf, null, 'unbekannte Knopfart wird verworfen, der Hinweis bleibt') + assert.deepEqual(gueltigeHinweise({}, JETZT), []) + assert.deepEqual(gueltigeHinweise(null, JETZT), []) + assert.deepEqual(gueltigeHinweise({ hinweise: 'kaputt' }, JETZT), []) + assert.throws(() => gueltigeHinweise(d, new Date('x')), RangeError) + assert.throws(() => gueltigeHinweise(d, null), RangeError) +}) + +// ---------------------------------------------------------------- JSON-LD (Task 6) + +const LD = (extra) => ({ '@type': 'OpeningHoursSpecification', ...extra }) + +test('JSON-LD: Mitternachtsfenster mit closes < opens', () => { + const d = daten({ plaene: [plan({ 1: [['09:00', '12:30'], ['13:30', '18:00']], 5: ['10:00', '24:00'], 6: ['18:00', '02:00'] })] }) + const ld = jsonLdFuer(d, hauptplan(d), JETZT) + assert.deepEqual(ld.openingHoursSpecification, [ + LD({ dayOfWeek: 'Monday', opens: '09:00', closes: '12:30' }), + LD({ dayOfWeek: 'Monday', opens: '13:30', closes: '18:00' }), + LD({ dayOfWeek: 'Friday', opens: '10:00', closes: '00:00' }), + LD({ dayOfWeek: 'Saturday', opens: '18:00', closes: '02:00' }), + ]) + // nie '24:00' im JSON-LD + assert.doesNotMatch(JSON.stringify(ld), /24:00/) +}) + +test('JSON-LD: rund um die Uhr 00:00–23:59, nach Vereinbarung fehlt', () => { + const d = daten({ plaene: [plan({ 1: 'rund', 2: 'vereinbarung', 3: ['00:00', '24:00'], 4: ['09:00', '18:00'] })] }) + const ld = jsonLdFuer(d, hauptplan(d), JETZT) + assert.deepEqual(ld.openingHoursSpecification, [ + LD({ dayOfWeek: 'Monday', opens: '00:00', closes: '23:59' }), + LD({ dayOfWeek: 'Wednesday', opens: '00:00', closes: '23:59' }), // Fenster 00:00–24:00 = rund + LD({ dayOfWeek: 'Thursday', opens: '09:00', closes: '18:00' }), + ]) + assert.ok(!ld.openingHoursSpecification.some((e) => e.dayOfWeek === 'Tuesday')) + // Tage ohne Öffnung (zu, fehlend) kommen nicht vor + const leer = jsonLdFuer(daten({ plaene: [plan({})] }), plan({}), JETZT) + assert.deepEqual(leer.openingHoursSpecification, []) +}) + +test('JSON-LD: Urlaub als Sonderzeit geschlossen 00:00–00:00 mit validFrom/validThrough', () => { + const d = daten({ plaene: [plan(WOCHE_ALLE_9_18)], ausnahmen: [urlaub({ von: '2026-10-12', bis: '2026-10-16' })] }) + const ld = jsonLdFuer(d, hauptplan(d), JETZT) + // ein Eintrag für den ganzen Urlaub, dazu Allerheiligen (RP, So 01.11.) im Fenster der nächsten 60 Tage + assert.deepEqual(ld.specialOpeningHoursSpecification, [ + LD({ validFrom: '2026-10-12', validThrough: '2026-10-16', opens: '00:00', closes: '00:00' }), + LD({ validFrom: '2026-11-01', validThrough: '2026-11-01', opens: '00:00', closes: '00:00' }), + ]) +}) + +test('JSON-LD: Sonderzeiten – Sondertag mit Zeiten, rund, Vereinbarung als geschlossen', () => { + const d = daten({ + land: 'BE', + plaene: [plan(WOCHE_ALLE_9_18)], + ausnahmen: [ + sondertag({ id: 's1', von: '2026-10-10', bis: '2026-10-10', fenster: [['10:00', '14:00'], ['18:00', '02:00']] }), + sondertag({ id: 's2', von: '2026-10-20', bis: '2026-10-20', modus: 'rund', fenster: [] }), + sondertag({ id: 's3', von: '2026-10-22', bis: '2026-10-22', modus: 'vereinbarung', fenster: [] }), + ], + }) + const ld = jsonLdFuer(d, hauptplan(d), JETZT) + assert.deepEqual(ld.specialOpeningHoursSpecification, [ + LD({ validFrom: '2026-10-10', validThrough: '2026-10-10', opens: '10:00', closes: '14:00' }), + LD({ validFrom: '2026-10-10', validThrough: '2026-10-10', opens: '18:00', closes: '02:00' }), + LD({ validFrom: '2026-10-20', validThrough: '2026-10-20', opens: '00:00', closes: '23:59' }), + // „nach Vereinbarung“ hat in JSON-LD keine Form; sonst blieben die Wochenzeiten des Tages stehen + LD({ validFrom: '2026-10-22', validThrough: '2026-10-22', opens: '00:00', closes: '00:00' }), + ]) +}) + +test('JSON-LD: Sonderzeiten – nächste 60 Tage ab heute, gleiche aufeinanderfolgende Tage zusammengefasst', () => { + const d = daten({ + land: 'BE', // BE: kein Feiertag zwischen 05.10. und 03.12. + plaene: [plan(WOCHE_ALLE_9_18)], + ausnahmen: [ + sondertag({ id: 'gestern', von: '2026-10-04', bis: '2026-10-04' }), + urlaub({ id: 'laeuft', von: '2026-10-01', bis: '2026-10-07' }), + urlaub({ id: 'a', von: '2026-10-12', bis: '2026-10-13' }), + urlaub({ id: 'b', von: '2026-10-15', bis: '2026-10-16' }), // Lücke am 14.10.: eigener Eintrag + urlaub({ id: 'letzter', von: '2026-12-03', bis: '2026-12-03' }), // heute + 59 + urlaub({ id: 'zuweit', von: '2026-12-04', bis: '2026-12-10' }), // heute + 60 + ], + }) + const spans = jsonLdFuer(d, hauptplan(d), JETZT).specialOpeningHoursSpecification.map((e) => [e.validFrom, e.validThrough]) + assert.deepEqual(spans, [ + ['2026-10-05', '2026-10-07'], // läuft schon: ab heute + ['2026-10-12', '2026-10-13'], + ['2026-10-15', '2026-10-16'], + ['2026-12-03', '2026-12-03'], + ]) +}) + +test('JSON-LD: Urlaub und angrenzender Feiertag mit gleichen Zeiten werden ein Zeitraum', () => { + const d = daten({ plaene: [plan(WOCHE_ALLE_9_18)], ausnahmen: [urlaub({ von: '2026-10-30', bis: '2026-10-31' })] }) + const spans = jsonLdFuer(d, hauptplan(d), JETZT).specialOpeningHoursSpecification.map((e) => [e.validFrom, e.validThrough]) + assert.deepEqual(spans, [['2026-10-30', '2026-11-01']], 'Allerheiligen (RP) schließt direkt an') +}) + +test('JSON-LD: neuerBlock mit @context vom Server, ohne name/address/url', () => { + const d = daten({ plaene: [plan({ 1: ['09:00', '18:00'] })], ausnahmen: [urlaub({ von: '2026-10-12', bis: '2026-10-16' })] }) + const ld = jsonLdFuer(d, hauptplan(d), JETZT) + assert.deepEqual(Object.keys(ld), ['openingHoursSpecification', 'specialOpeningHoursSpecification', 'neuerBlock']) + assert.equal(ld.neuerBlock['@context'], 'https://schema.org') + assert.equal(ld.neuerBlock['@type'], 'LocalBusiness') + assert.deepEqual(Object.keys(ld.neuerBlock), ['@context', '@type', 'openingHoursSpecification', 'specialOpeningHoursSpecification']) + assert.deepEqual(ld.neuerBlock.openingHoursSpecification, ld.openingHoursSpecification) + assert.deepEqual(ld.neuerBlock.specialOpeningHoursSpecification, ld.specialOpeningHoursSpecification) + for (const verboten of ['name', 'address', 'url']) assert.ok(!(verboten in ld.neuerBlock), verboten) + // als JSON ausgeliefert: dieselbe Form nach dem Rundlauf + assert.deepEqual(JSON.parse(JSON.stringify(ld)), ld) +}) + +test('JSON-LD: ohne Plan keine Angaben, Eingaben bleiben unverändert', () => { + const d = tiefEinfrieren(daten({ plaene: [plan({ 1: ['09:00', '18:00'], 6: ['18:00', '02:00'] })], ausnahmen: [urlaub({ von: '2026-10-12', bis: '2026-10-16' })] })) + for (const kein of [null, undefined, 'x']) { + const ld = jsonLdFuer(d, kein, JETZT) + assert.deepEqual(ld.openingHoursSpecification, []) + assert.deepEqual(ld.specialOpeningHoursSpecification, []) + assert.equal(ld.neuerBlock['@context'], 'https://schema.org') + } + assert.deepEqual(jsonLdFuer(d, hauptplan(d), JETZT), jsonLdFuer(d, hauptplan(d), JETZT)) + assert.throws(() => jsonLdFuer(d, hauptplan(d), new Date('x')), RangeError) +}) + +test('JSON-LD: heute ist der Berliner Tag (kurz nach Mitternacht Berlin ist noch UTC-Vortag)', () => { + const d = daten({ land: 'BE', plaene: [plan(WOCHE_ALLE_9_18)], ausnahmen: [sondertag({ von: '2026-10-05', bis: '2026-10-05' })] }) + // 05.10. 00:30 Berlin = 04.10. 22:30 UTC: der 05.10. ist heute und noch dabei + assert.equal(jsonLdFuer(d, hauptplan(d), new Date('2026-10-04T22:30:00Z')).specialOpeningHoursSpecification.length, 1) + // 06.10. 00:30 Berlin: der 05.10. ist vorbei + assert.equal(jsonLdFuer(d, hauptplan(d), new Date('2026-10-05T22:30:00Z')).specialOpeningHoursSpecification.length, 0) +}) + +// ---------------------------------------------------------------- Google-Text (Task 6) + +test('Google-Text: Montag zuerst, „geschlossen“, Sonderzeiten danach', () => { + const d = daten({ + plaene: [plan({ 1: [['09:00', '12:30'], ['13:30', '18:00']], 2: ['09:00', '18:00'], 3: 'rund', 4: 'vereinbarung', 5: ['10:00', '24:00'], 6: ['18:00', '02:00'] })], + ausnahmen: [ + urlaub({ id: 'u', von: '2026-10-12', bis: '2026-10-16', text: 'Betriebsurlaub' }), + sondertag({ id: 's', von: '2026-10-10', bis: '2026-10-10', text: 'Tag der offenen Tür', fenster: [['10:00', '14:00']] }), + ], + }) + const text = googleText(d, hauptplan(d), JETZT) + assert.ok(text.startsWith('Montag: 09:00–12:30, 13:30–18:00\n')) + assert.equal( + text, + [ + 'Montag: 09:00–12:30, 13:30–18:00', + 'Dienstag: 09:00–18:00', + 'Mittwoch: rund um die Uhr', + 'Donnerstag: nach Vereinbarung', + 'Freitag: 10:00–00:00', + 'Samstag: 18:00–02:00', + 'Sonntag: geschlossen', + '', + 'Sonderzeiten (nächste 60 Tage):', + '10.10.: 10:00–14:00 (Tag der offenen Tür)', + '12.10.–16.10.: geschlossen (Betriebsurlaub)', + '01.11.: geschlossen (Allerheiligen)', + ].join('\n'), + ) + assert.doesNotMatch(text, /24:00|24 Uhr/) +}) + +test('Google-Text: ohne Sonderzeiten genau sieben Zeilen, ohne Anhängsel', () => { + const d = daten({ land: 'BE', plaene: [plan({ 1: ['09:00', '18:00'] })] }) + const text = googleText(d, hauptplan(d), JETZT) + assert.deepEqual(text.split('\n'), [ + 'Montag: 09:00–18:00', + 'Dienstag: geschlossen', + 'Mittwoch: geschlossen', + 'Donnerstag: geschlossen', + 'Freitag: geschlossen', + 'Samstag: geschlossen', + 'Sonntag: geschlossen', + ]) +}) + +test('Google-Text: Sonderzeiten fassen gleiche Tage mit gleichem Grund zusammen, Zeiten statt „geschlossen“', () => { + const d = daten({ + land: 'BE', + plaene: [plan(WOCHE_ALLE_9_18)], + ausnahmen: [ + urlaub({ id: 'u1', von: '2026-10-12', bis: '2026-10-14', text: 'Betriebsferien' }), + urlaub({ id: 'u2', von: '2026-10-15', bis: '2026-10-15', text: 'Inventur' }), // anderer Grund: eigene Zeile + urlaub({ id: 'u3', von: '2026-10-20', bis: '2026-10-20', text: '' }), // ohne Text: Vorgabe „Betriebsurlaub“ + sondertag({ id: 's1', von: '2026-10-24', bis: '2026-10-25', text: '', modus: 'zeiten', fenster: [['10:00', '12:00'], ['14:00', '24:00']] }), + sondertag({ id: 's2', von: '2026-10-26', bis: '2026-10-26', text: 'Messe', modus: 'rund', fenster: [] }), + sondertag({ id: 's3', von: '2026-10-27', bis: '2026-10-27', text: 'Fortbildung', modus: 'vereinbarung', fenster: [] }), + // heute und das Ende des Fensters: 05.10. + 59 = 03.12. + sondertag({ id: 's4', von: '2026-10-05', bis: '2026-10-05', text: 'Heute', fenster: [['12:00', '13:00']] }), + sondertag({ id: 's5', von: '2026-12-03', bis: '2026-12-04', text: 'Ende', fenster: [['12:00', '13:00']] }), + sondertag({ id: 's6', von: '2026-10-04', bis: '2026-10-04', text: 'Gestern', fenster: [['12:00', '13:00']] }), + ], + }) + const zeilen = googleText(d, hauptplan(d), JETZT).split('\n').slice(9) + assert.deepEqual(zeilen, [ + '05.10.: 12:00–13:00 (Heute)', + '12.10.–14.10.: geschlossen (Betriebsferien)', + '15.10.: geschlossen (Inventur)', + '20.10.: geschlossen (Betriebsurlaub)', + '24.10.–25.10.: 10:00–12:00, 14:00–00:00', + '26.10.: rund um die Uhr (Messe)', + '27.10.: nach Vereinbarung (Fortbildung)', + '03.12.: 12:00–13:00 (Ende)', + ]) +}) + +test('Google-Text: Fenster 00:00–24:00 heißt „rund um die Uhr“, ohne Plan leer, Eingaben unverändert', () => { + const d = tiefEinfrieren(daten({ plaene: [plan({ 1: ['00:00', '24:00'] })] })) + assert.equal(googleText(d, hauptplan(d), JETZT).split('\n')[0], 'Montag: rund um die Uhr') + assert.equal(googleText(d, null, JETZT), '') + assert.equal(googleText(d, undefined, JETZT), '') + assert.throws(() => googleText(d, hauptplan(d), new Date('x')), RangeError) +}) + +// ---------------------------------------------------------------- Erinnerungstage (Task 6) + +// Mo 28.09.2026 10:00 Berlin: 1–7 Tage = 29.09. bis 05.10. (Sa 03.10. = Tag der Deutschen Einheit) +const MO_28_09 = new Date('2026-09-28T08:00:00Z') +const MIT_SAMSTAG = { 1: ['09:00', '18:00'], 6: ['09:00', '14:00'] } + +test('Erinnerung: Feiertag in 1–7 Tagen, offener Wochentag', () => { + const d = daten({ plaene: [plan(MIT_SAMSTAG)] }) + assert.deepEqual(erinnerungsTage(d, MO_28_09), [{ tag: '2026-10-03', name: 'Tag der Deutschen Einheit', art: 'feiertag' }]) +}) + +test('Erinnerung: Grenzen 1 bis 7 Tage in Berliner Tagen', () => { + const d = daten({ plaene: [plan(MIT_SAMSTAG)] }) + const tage = (iso) => erinnerungsTage(d, new Date(iso)).map((e) => e.tag) + assert.deepEqual(tage('2026-09-26T08:00:00Z'), ['2026-10-03'], 'in 7 Tagen (Sa 26.09. + 7)') + assert.deepEqual(tage('2026-09-25T08:00:00Z'), [], 'in 8 Tagen: noch nicht') + assert.deepEqual(tage('2026-10-02T08:00:00Z'), ['2026-10-03'], 'morgen') + assert.deepEqual(tage('2026-10-03T08:00:00Z'), [], 'heute: zu spät') + // 02.10. 23:30 Berlin (21:30 UTC): der Feiertag ist morgen; 03.10. 00:30 Berlin (22:30 UTC Vortag): heute + assert.deepEqual(tage('2026-10-02T21:30:00Z'), ['2026-10-03']) + assert.deepEqual(tage('2026-10-02T22:30:00Z'), []) +}) + +test('Erinnerung: nicht bei geschlossenem Tag, Ausnahme oder gespeicherter Abweichung', () => { + const jetzt = MO_28_09 + // Samstag im Hauptplan ohnehin zu + assert.deepEqual(erinnerungsTage(daten({ plaene: [plan({ 1: ['09:00', '18:00'] })] }), jetzt), []) + // Ausnahme deckt den Tag ab: Urlaub, Sondertag (global oder für den Hauptplan) + const offen = plan(MIT_SAMSTAG) + assert.deepEqual(erinnerungsTage(daten({ plaene: [offen], ausnahmen: [urlaub({ von: '2026-10-01', bis: '2026-10-05' })] }), jetzt), []) + assert.deepEqual(erinnerungsTage(daten({ plaene: [offen], ausnahmen: [sondertag({ von: '2026-10-03', bis: '2026-10-03' })] }), jetzt), []) + assert.deepEqual(erinnerungsTage(daten({ plaene: [offen], ausnahmen: [sondertag({ von: '2026-10-03', bis: '2026-10-03', plan: 'p1' })] }), jetzt), []) + // eine Ausnahme nur für einen anderen Plan deckt den Hauptplan nicht ab + const anderer = plan({}, { id: 'p2', kurz: 'kueche', haupt: false }) + assert.equal( + erinnerungsTage(daten({ plaene: [offen, anderer], ausnahmen: [sondertag({ von: '2026-10-03', bis: '2026-10-03', plan: 'p2' })] }), jetzt).length, + 1, + ) + // Ausnahme eines anderen Tages stört nicht + assert.equal(erinnerungsTage(daten({ plaene: [offen], ausnahmen: [urlaub({ von: '2026-10-04', bis: '2026-10-10' })] }), jetzt).length, 1) + // gespeicherte Abweichung für den Tag (egal welcher Modus) + for (const abw of [{ modus: 'zu', fenster: [] }, { modus: 'zeiten', fenster: [['10:00', '12:00']] }, { modus: 'vereinbarung', fenster: [] }]) { + assert.deepEqual(erinnerungsTage(daten({ plaene: [offen], feiertage: { '2026-10-03': abw } }), jetzt), [], abw.modus) + } + // Abweichung für einen anderen Tag zählt nicht + assert.equal(erinnerungsTage(daten({ plaene: [offen], feiertage: { '2026-10-04': { modus: 'zu', fenster: [] } } }), jetzt).length, 1) +}) + +test('Erinnerung: Wochentag nach Vereinbarung wird erinnert, ohne Hauptplan nicht', () => { + const ver = daten({ plaene: [plan({ 6: 'vereinbarung' })] }) + assert.equal(erinnerungsTage(ver, MO_28_09).length, 1) + const rund = daten({ plaene: [plan({ 6: 'rund' })] }) + assert.equal(erinnerungsTage(rund, MO_28_09).length, 1) + // kein Hauptplan (noch nichts gespeichert, nur Nebenpläne): keine Erinnerung + assert.deepEqual(erinnerungsTage(daten({ plaene: [] }), MO_28_09), []) + assert.deepEqual(erinnerungsTage(daten({ plaene: [plan(MIT_SAMSTAG, { haupt: false })] }), MO_28_09), []) + assert.deepEqual(erinnerungsTage({}, MO_28_09), []) + assert.deepEqual(erinnerungsTage(null, MO_28_09), []) +}) + +test('Erinnerung: Heiligabend als „pruefen“', () => { + // Mo 21.12.2026: 22.–28.12. enthält Do 24.12. (Heiligabend), 25./26.12. (1./2. Weihnachtstag) + const d = daten({ plaene: [plan({ 1: ['09:00', '18:00'], 4: ['09:00', '18:00'], 5: ['09:00', '18:00'], 6: ['09:00', '14:00'] })] }) + const liste = erinnerungsTage(d, new Date('2026-12-21T08:00:00Z')) + assert.deepEqual(liste, [ + { tag: '2026-12-24', name: 'Heiligabend', art: 'pruefen' }, + { tag: '2026-12-25', name: '1. Weihnachtstag', art: 'feiertag' }, + { tag: '2026-12-26', name: '2. Weihnachtstag', art: 'feiertag' }, + ]) + // Silvester und Neujahr über den Jahreswechsel + const s = erinnerungsTage(d, new Date('2026-12-28T08:00:00Z')) + assert.deepEqual(s.map((e) => [e.tag, e.art]), [['2026-12-31', 'pruefen'], ['2027-01-01', 'feiertag']]) +}) + +test('Erinnerung: Bundesland aus den Daten, Prüftage regional (Mariä Himmelfahrt in BY)', () => { + // 15.08.2026 ist ein Samstag; Mo 10.08. + 5 + const woche = { 6: ['09:00', '14:00'] } + const by = erinnerungsTage(daten({ land: 'BY', plaene: [plan(woche)] }), new Date('2026-08-10T08:00:00Z')) + assert.deepEqual(by, [{ tag: '2026-08-15', name: 'Mariä Himmelfahrt', art: 'pruefen' }]) + assert.deepEqual(erinnerungsTage(daten({ land: 'BE', plaene: [plan(woche)] }), new Date('2026-08-10T08:00:00Z')), []) + // unbekanntes Land: Vorgabe RP (dort 03.10. Feiertag) + assert.equal(erinnerungsTage(daten({ land: 'XX', plaene: [plan(MIT_SAMSTAG)] }), MO_28_09).length, 1) +}) + +test('Erinnerung: Eingaben bleiben unverändert, ungültiges now wirft', () => { + const d = tiefEinfrieren(daten({ plaene: [plan(MIT_SAMSTAG)] })) + assert.equal(erinnerungsTage(d, MO_28_09).length, 1) + assert.throws(() => erinnerungsTage(d, new Date('x')), RangeError) + assert.throws(() => erinnerungsTage(d, undefined), RangeError) +}) + +test('Erinnerungsfenster 08:00–20:00 Berlin', () => { + // Winter (MEZ = UTC+1): 08:00 Berlin = 07:00 UTC + const winter = (hhmm) => imErinnerungsfenster(new Date(`2026-12-01T${hhmm}:00Z`)) + assert.equal(winter('06:59'), false) // 07:59 Berlin + assert.equal(winter('07:00'), true) // 08:00 + assert.equal(winter('18:59'), true) // 19:59 + assert.equal(winter('19:00'), false) // 20:00 + // Sommer (MESZ = UTC+2): 08:00 Berlin = 06:00 UTC + const sommer = (hhmm) => imErinnerungsfenster(new Date(`2026-07-01T${hhmm}:00Z`)) + assert.equal(sommer('05:59'), false) + assert.equal(sommer('06:00'), true) + assert.equal(sommer('17:59'), true) + assert.equal(sommer('18:00'), false) + // Sekunden zählen nicht über die Minute hinaus + assert.equal(imErinnerungsfenster(new Date('2026-12-01T18:59:59Z')), true) + assert.equal(imErinnerungsfenster(new Date('2026-12-01T06:59:59Z')), false) + // Nacht und Mittag + assert.equal(imErinnerungsfenster(new Date('2026-12-01T00:00:00Z')), false) + assert.equal(imErinnerungsfenster(new Date('2026-12-01T11:00:00Z')), true) + assert.throws(() => imErinnerungsfenster(new Date('x')), RangeError) + assert.throws(() => imErinnerungsfenster(undefined), RangeError) +}) + // ---------------------------------------------------------------- Reinheit test('zeitlogik.js ist rein: keine Appwrite- und keine Config-Importe, keine Millisekunden-Tagesrechnung', () => {