Öffnungszeiten: Ersatz-Tabelle und Wochenplan-Hash

ersatzHtml liefert die schlichte Wochentabelle eines Plans als reines ASCII
(Entities, maskierter Planname), wochenplanHash den 16-stelligen sha1 der
Tage des Hauptplans. Dafuer wird modusUndFenster aus der Zeitlogik exportiert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 00:19:22 +00:00
1 parent 61c2f72631
commit 5f52ef54e8
3 files changed
+390 -1

No files matched your search

+116
View File
@@ -0,0 +1,116 @@
import { createHash } from 'node:crypto'
import { hauptplan, modusUndFenster } from './zeitlogik.js'
/**
* Oeffnungszeiten live: statische Ersatz-Tabelle und Wochenplan-Hash (Spec 4.5, rein).
* Kein Appwrite, keine Config, keine Uhr. Die Tabelle steht in den Platzhaltern der Kundenseite
* (ohne JavaScript und nach Abschaltung der Funktion) und zeigt nur den Wochenplan eines Plans,
* keine Ausnahmen, Feiertage oder Hinweise.
*
* Ausgabe ist reines ASCII: Umlaute und ss-Ligatur als benannte Entities (&auml; ... &szlig;), Gedankenstrich
* als &#8211;, jedes andere Nicht-ASCII-Zeichen als numerische Entity (&#NNNN;). Kundentext (Planname) wird
* zuerst HTML-maskiert (& < > " ').
*
* Mitternacht: Ein Fensterende '24:00' steht in der Tabelle als '00:00' ("18:00-00:00"), nie als "24:00".
* Ein einziges Fenster 00:00-24:00 gilt als "rund um die Uhr".
*/
const WOCHENTAGE = Object.freeze(['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'])
const GEDANKENSTRICH = '&#8211;'
const STANDARD_UEBERSCHRIFT = '&Ouml;ffnungszeiten'
const ENTITIES = new Map([
['&', '&amp;'],
['<', '&lt;'],
['>', '&gt;'],
['"', '&quot;'],
["'", '&#39;'],
['\u00e4', '&auml;'],
['\u00f6', '&ouml;'],
['\u00fc', '&uuml;'],
['\u00c4', '&Auml;'],
['\u00d6', '&Ouml;'],
['\u00dc', '&Uuml;'],
['\u00df', '&szlig;'],
])
/**
* Kundentext -> sicheres ASCII-HTML. Steuerzeichen (auch C1) werden zu Leerzeichen (der HTML-Parser
* wuerde numerische Verweise darauf umdeuten), einzelne Surrogate zu U+FFFD.
*/
function asciiText(roh) {
let aus = ''
for (const zeichen of String(roh)) {
const cp = zeichen.codePointAt(0)
if (cp < 0x20 || (cp >= 0x7f && cp <= 0x9f)) aus += ' '
else if (ENTITIES.has(zeichen)) aus += ENTITIES.get(zeichen)
else if (cp < 0x7f) aus += zeichen
else if (cp >= 0xd800 && cp <= 0xdfff) aus += '&#65533;'
else aus += `&#${cp};`
}
return aus
}
/** Text eines Tages; modus/fenster kommen bereits bereinigt aus modusUndFenster (gueltige 'HH:MM'-Fenster). */
function tagText({ modus, fenster }) {
if (modus === 'rund') return 'rund um die Uhr'
if (modus === 'vereinbarung') return 'nach Vereinbarung'
if (modus !== 'zeiten' || fenster.length === 0) return 'geschlossen'
if (fenster.length === 1 && fenster[0][0] === '00:00' && fenster[0][1] === '24:00') return 'rund um die Uhr'
return fenster
.map(([von, bis]) => `${von}${GEDANKENSTRICH}${bis === '24:00' ? '00:00' : bis}`)
.join(', ')
}
/**
* Schlichte Wochentabelle des Plans: <table class="wkz-ersatz"> mit <caption> = Planname (leer => "Oeffnungszeiten")
* und einer Zeile je Wochentag Montag bis Sonntag (<th scope="row"> + <td>). Ohne 'plan' gilt der Hauptplan;
* gibt es keinen Plan, ist das Ergebnis '' (der Aufrufer entscheidet, z. B. 409). Kaputte Tageseintraege
* (unbekannter Modus, fehlende Tage, unlesbare Fenster) zaehlen als geschlossen.
*/
export function ersatzHtml(daten, plan) {
const p = plan && typeof plan === 'object' ? plan : hauptplan(daten)
if (!p) return ''
const name = typeof p.name === 'string' ? p.name.trim() : ''
const zeilen = WOCHENTAGE.map((tag, i) => {
const text = tagText(modusUndFenster(p.tage?.[i + 1]))
return `<tr><th scope="row">${tag}</th><td>${text}</td></tr>`
})
return [
'<table class="wkz-ersatz">',
`<caption>${name ? asciiText(name) : STANDARD_UEBERSCHRIFT}</caption>`,
'<tbody>',
...zeilen,
'</tbody>',
'</table>',
].join('\n')
}
/**
* JSON mit rekursiv sortierten Objektschluesseln (Arrays behalten ihre Reihenfolge), sonst wie JSON.stringify:
* undefined-Werte in Objekten entfallen, der Gesamtwert undefined ergibt undefined.
*/
function stabilesJson(wert) {
if (Array.isArray(wert)) return `[${wert.map((w) => stabilesJson(w) ?? 'null').join(',')}]`
if (wert && typeof wert === 'object') {
const teile = []
for (const schluessel of Object.keys(wert).sort()) {
const v = stabilesJson(wert[schluessel])
if (v !== undefined) teile.push(`${JSON.stringify(schluessel)}:${v}`)
}
return `{${teile.join(',')}}`
}
return JSON.stringify(wert)
}
/**
* Hash des Wochenplans des Hauptplans: sha1 ueber das stabile JSON von 'tage', die ersten 16 Hex-Zeichen.
* Aendert sich nur mit 'tage' des Hauptplans (nicht mit Name, Ausnahmen, Hinweisen, Feiertagen, Land oder anderen
* Plaenen); '' ohne Hauptplan. Hauptplan ohne 'tage' ergibt den Hash von 'null'.
*/
export function wochenplanHash(daten) {
const haupt = hauptplan(daten)
if (!haupt) return ''
const json = stabilesJson(haupt.tage) ?? 'null'
return createHash('sha1').update(json).digest('hex').slice(0, 16)
}
+1 -1
View File
@@ -99,7 +99,7 @@ function sauberFenster(roh) {
} }
/** { modus, fenster } eines gespeicherten Tageseintrags: unbekannter Modus oder 'zeiten' ohne Fenster = zu. */ /** { modus, fenster } eines gespeicherten Tageseintrags: unbekannter Modus oder 'zeiten' ohne Fenster = zu. */
function modusUndFenster(eintrag) { export function modusUndFenster(eintrag) {
const modus = MODI.includes(eintrag?.modus) ? eintrag.modus : 'zu' const modus = MODI.includes(eintrag?.modus) ? eintrag.modus : 'zu'
if (modus !== 'zeiten') return { modus, fenster: [] } if (modus !== 'zeiten') return { modus, fenster: [] }
const fenster = sauberFenster(eintrag.fenster) const fenster = sauberFenster(eintrag.fenster)
+273
View File
@@ -0,0 +1,273 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { ersatzHtml, wochenplanHash } from '../server/services/zeiten/ersatz.js'
import { hauptplan } from '../server/services/zeiten/zeitlogik.js'
// Fixture wie in zeitenZeitlogik.test.js: Wochentage 1..7 (Mo..So).
// Wert: ['09:00','18:00'] = ein Fenster, [[..],[..]] = zwei Fenster, 'rund' / 'vereinbarung' = Modus, fehlt = zu.
function plan(woche = {}, extra = {}) {
const tage = {}
for (let d = 1; d <= 7; d += 1) {
const w = woche[d]
if (!w) tage[d] = { modus: 'zu', fenster: [] }
else if (typeof w === 'string') tage[d] = { modus: w, fenster: [] }
else tage[d] = { modus: 'zeiten', fenster: Array.isArray(w[0]) ? w : [w] }
}
return { id: 'p1', kurz: 'standard', name: 'Standard', haupt: true, tage, ...extra }
}
function daten({ plaene = [plan({ 1: ['09:00', '18:00'] })], ausnahmen = [], feiertage = {}, hinweise = [], land = 'RP' } = {}) {
return { land, plaene, feiertage, ausnahmen, hinweise, kontakt: { an: false, telefon: '', adresse: '', whatsapp: null } }
}
function tiefEinfrieren(x) {
if (x && typeof x === 'object' && !Object.isFrozen(x)) {
Object.freeze(x)
for (const v of Object.values(x)) tiefEinfrieren(v)
}
return x
}
const ASCII = /^[\x00-\x7F]*$/
const WOCHE = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']
/** Zeilen der Tabelle: [['Montag', '09:00&#8211;18:00'], ...] */
function zeilen(html) {
return [...html.matchAll(/<tr><th scope="row">([^<]*)<\/th><td>([^<]*)<\/td><\/tr>/g)].map((m) => [m[1], m[2]])
}
// ------------------------------------------------------------------ ersatzHtml
test('Ersatz-HTML ist reines ASCII', () => {
const p = plan(
{ 1: ['09:00', '18:00'], 2: [['08:00', '12:00'], ['14:00', '18:30']], 3: 'rund', 4: 'vereinbarung', 5: ['18:00', '24:00'], 6: ['22:00', '02:00'] },
{ name: 'Öffnungszeiten für Müller & Söhne – groß \u{1F600}' },
)
const html = ersatzHtml(daten({ plaene: [p] }), p)
assert.equal(typeof html, 'string')
assert.ok(html.length > 0)
assert.match(html, ASCII)
})
test('Ersatz-HTML zeigt alle Wochentage in der Reihenfolge Montag bis Sonntag', () => {
const p = plan({ 1: ['09:00', '18:00'], 6: ['10:00', '14:00'] })
const html = ersatzHtml(daten({ plaene: [p] }), p)
assert.match(html, /^<table class="wkz-ersatz">/)
assert.match(html, /<\/table>\s*$/)
const z = zeilen(html)
assert.deepEqual(z.map((r) => r[0]), WOCHE)
assert.ok(html.includes('09:00&#8211;18:00'))
assert.ok(html.includes('10:00&#8211;14:00'))
assert.equal(z[1][1], 'geschlossen')
assert.equal(z[6][1], 'geschlossen')
for (const tag of WOCHE) {
assert.ok(html.indexOf(`<th scope="row">${tag}</th>`) >= 0, `${tag} fehlt`)
}
})
test('Ersatz-HTML: Caption ist der Planname, bei leerem Namen "Öffnungszeiten"', () => {
const p = plan({ 1: ['09:00', '18:00'] }, { name: 'Sommer' })
assert.match(ersatzHtml(daten({ plaene: [p] }), p), /<caption>Sommer<\/caption>/)
for (const name of ['', ' ', null, undefined, 42]) {
const q = plan({ 1: ['09:00', '18:00'] }, { name })
const html = ersatzHtml(daten({ plaene: [q] }), q)
assert.match(html, /<caption>&Ouml;ffnungszeiten<\/caption>/, `Name ${String(name)}`)
assert.match(html, ASCII)
}
})
test('Ersatz-HTML: Kundentext wird maskiert', () => {
const p = plan({ 1: ['09:00', '18:00'] }, { name: '<b>' })
const html = ersatzHtml(daten({ plaene: [p] }), p)
assert.ok(html.includes('<caption>&lt;b&gt;</caption>'))
assert.ok(!html.includes('<b>'))
const boese = plan({ 1: ['09:00', '18:00'] }, { name: '"><script>alert(1)</script> & \'x\'' })
const h2 = ersatzHtml(daten({ plaene: [boese] }), boese)
assert.ok(!h2.includes('<script'))
assert.ok(h2.includes('&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt; &amp; &#39;x&#39;'))
assert.match(h2, ASCII)
})
test('Ersatz-HTML: Umlaute im Kundentext werden zu Entities, andere Zeichen zu numerischen', () => {
const p = plan({ 1: ['09:00', '18:00'] }, { name: 'Bäckerei Müller – Öl ÄÜß é \u{1F600}' })
const html = ersatzHtml(daten({ plaene: [p] }), p)
assert.ok(html.includes('<caption>B&auml;ckerei M&uuml;ller &#8211; &Ouml;l &Auml;&Uuml;&szlig; &#233; &#128512;</caption>'))
assert.match(html, ASCII)
})
test('Ersatz-HTML: Steuerzeichen und einzelne Surrogate im Namen brechen nichts', () => {
const p = plan({ 1: ['09:00', '18:00'] }, { name: 'a\nb\tc\u0000d\u0085e\ud800f' })
const html = ersatzHtml(daten({ plaene: [p] }), p)
assert.match(html, ASCII)
assert.doesNotMatch(html, /[\x00-\x08\x0b-\x1f\x7f]/)
assert.ok(html.includes('<caption>a b c d e&#65533;f</caption>'))
})
test('Ersatz-HTML: Modi als Text, nie "24:00", Mitternacht als 00:00', () => {
const p = plan({
1: 'rund',
2: 'vereinbarung',
3: ['18:00', '24:00'],
4: ['22:00', '02:00'],
5: [['08:00', '12:00'], ['14:00', '18:30']],
6: ['00:00', '12:00'],
7: ['00:00', '24:00'],
})
const html = ersatzHtml(daten({ plaene: [p] }), p)
const z = zeilen(html)
assert.equal(z[0][1], 'rund um die Uhr')
assert.equal(z[1][1], 'nach Vereinbarung')
assert.equal(z[2][1], '18:00&#8211;00:00')
assert.equal(z[3][1], '22:00&#8211;02:00')
assert.equal(z[4][1], '08:00&#8211;12:00, 14:00&#8211;18:30')
assert.equal(z[5][1], '00:00&#8211;12:00')
assert.equal(z[6][1], 'rund um die Uhr', 'ein Fenster 00:00-24:00 ist rund um die Uhr')
assert.ok(!html.includes('24:00'))
assert.ok(!html.includes('24 Uhr'))
})
test('Ersatz-HTML: Fenster erscheinen nach Beginn sortiert', () => {
const p = plan({ 1: [['14:00', '18:00'], ['08:00', '12:00']] })
assert.equal(zeilen(ersatzHtml(daten({ plaene: [p] }), p))[0][1], '08:00&#8211;12:00, 14:00&#8211;18:00')
})
test('Ersatz-HTML: kaputte Tageseinträge zählen als geschlossen und werfen nicht', () => {
const p = plan({ 1: ['09:00', '18:00'] })
p.tage[2] = { modus: 'irgendwas', fenster: [['09:00', '18:00']] }
p.tage[3] = { modus: 'zeiten', fenster: [] }
p.tage[4] = { modus: 'zeiten', fenster: [['9:00', 'abc'], ['10:00', '10:00'], ['25:00', '26:00']] }
delete p.tage[5]
p.tage[6] = null
p.tage[7] = 'x'
const html = ersatzHtml(daten({ plaene: [p] }), p)
const z = zeilen(html)
assert.equal(z.length, 7)
assert.equal(z[0][1], '09:00&#8211;18:00')
for (let i = 1; i < 7; i += 1) assert.equal(z[i][1], 'geschlossen', WOCHE[i])
const ohneTage = { id: 'x', kurz: 'x', name: 'Ohne', haupt: true }
const leer = zeilen(ersatzHtml(daten({ plaene: [ohneTage] }), ohneTage))
assert.equal(leer.length, 7)
assert.ok(leer.every((r) => r[1] === 'geschlossen'))
})
test('Ersatz-HTML zeigt nur den Wochenplan, keine Ausnahmen, Feiertage oder Hinweise', () => {
const p = plan({ 1: ['09:00', '18:00'] })
const ohne = ersatzHtml(daten({ plaene: [p] }), p)
const mit = ersatzHtml(
daten({
plaene: [p],
ausnahmen: [{ id: 'u', art: 'urlaub', von: '2026-10-05', bis: '2026-10-09', text: 'Urlaub', modus: 'zu', fenster: [], plan: null }],
feiertage: { '2026-10-12': { modus: 'zeiten', fenster: [['10:00', '12:00']] } },
hinweise: [{ id: 'h', art: 'info', text: 'Hallo', plan: null }],
}),
p,
)
assert.equal(mit, ohne)
assert.ok(!mit.includes('Urlaub'))
})
test('Ersatz-HTML nutzt den übergebenen Plan, ohne Plan den Hauptplan, ohne beides den leeren Text', () => {
const haupt = plan({ 1: ['09:00', '18:00'] }, { name: 'Haupt' })
const sommer = plan({ 1: ['10:00', '20:00'] }, { id: 'p2', kurz: 'sommer', name: 'Sommer', haupt: false })
const d = daten({ plaene: [haupt, sommer] })
assert.match(ersatzHtml(d, sommer), /<caption>Sommer<\/caption>[\s\S]*10:00&#8211;20:00/)
assert.match(ersatzHtml(d, haupt), /<caption>Haupt<\/caption>[\s\S]*09:00&#8211;18:00/)
assert.equal(ersatzHtml(d, null), ersatzHtml(d, haupt))
assert.equal(ersatzHtml(d, undefined), ersatzHtml(d, haupt))
assert.equal(ersatzHtml(daten({ plaene: [] }), null), '')
assert.equal(ersatzHtml(null, null), '')
assert.equal(ersatzHtml({}, undefined), '')
})
test('Ersatz-HTML ist deterministisch und verändert die Eingabe nicht', () => {
const p = plan({ 1: [['14:00', '18:00'], ['08:00', '12:00']], 2: 'rund' })
const d = tiefEinfrieren(daten({ plaene: [p] }))
const a = ersatzHtml(d, p)
const b = ersatzHtml(d, p)
assert.equal(a, b)
assert.deepEqual(p.tage[1].fenster, [['14:00', '18:00'], ['08:00', '12:00']])
})
// ------------------------------------------------------------------ wochenplanHash
test('Hash hat 16 Hex-Zeichen und ist die sha1 des stabilen JSON der Tage des Hauptplans', () => {
const p = { id: 'p1', kurz: 's', name: 'S', haupt: true, tage: { 1: { modus: 'zu', fenster: [] } } }
const h = wochenplanHash(daten({ plaene: [p] }))
assert.match(h, /^[0-9a-f]{16}$/)
const erwartet = createHash('sha1').update('{"1":{"fenster":[],"modus":"zu"}}').digest('hex').slice(0, 16)
assert.equal(h, erwartet)
})
test('Hash ändert sich nur mit dem Wochenplan des Hauptplans', () => {
const basis = daten({ plaene: [plan({ 1: ['09:00', '18:00'], 2: ['09:00', '18:00'] })] })
const h = wochenplanHash(basis)
assert.match(h, /^[0-9a-f]{16}$/)
// Ausnahme, Hinweis, Feiertag, Kontakt, Land, Planname: gleich
const anders = daten({
land: 'BY',
plaene: [plan({ 1: ['09:00', '18:00'], 2: ['09:00', '18:00'] }, { name: 'Umbenannt', kurz: 'neu' })],
ausnahmen: [{ id: 'u', art: 'urlaub', von: '2026-10-05', bis: '2026-10-09', text: 'Urlaub', modus: 'zu', fenster: [], plan: null }],
feiertage: { '2026-10-12': { modus: 'rund', fenster: [] } },
hinweise: [{ id: 'h', art: 'info', text: 'Hallo', plan: null }],
})
assert.equal(wochenplanHash(anders), h)
// Montag geändert: anders
const montag = daten({ plaene: [plan({ 1: ['09:00', '17:00'], 2: ['09:00', '18:00'] })] })
assert.notEqual(wochenplanHash(montag), h)
// Modus geändert: anders
assert.notEqual(wochenplanHash(daten({ plaene: [plan({ 1: 'rund', 2: ['09:00', '18:00'] })] })), h)
// zweites Fenster dazu: anders
assert.notEqual(wochenplanHash(daten({ plaene: [plan({ 1: [['09:00', '12:00'], ['13:00', '18:00']], 2: ['09:00', '18:00'] })] })), h)
})
test('Hash: Änderungen an einem anderen Plan ändern ihn nicht, ein anderer Hauptplan schon', () => {
const haupt = plan({ 1: ['09:00', '18:00'] })
const sommer = plan({ 1: ['10:00', '20:00'] }, { id: 'p2', kurz: 'sommer', name: 'Sommer', haupt: false })
const sommer2 = plan({ 3: ['10:00', '20:00'] }, { id: 'p2', kurz: 'sommer', name: 'Sommer', haupt: false })
const h = wochenplanHash(daten({ plaene: [haupt, sommer] }))
assert.equal(wochenplanHash(daten({ plaene: [haupt, sommer2] })), h)
assert.equal(wochenplanHash(daten({ plaene: [sommer, haupt] })), h, 'Reihenfolge der Pläne egal')
const nachBefoerderung = [{ ...haupt, haupt: false }, { ...sommer, haupt: true }]
assert.notEqual(wochenplanHash(daten({ plaene: nachBefoerderung })), h)
assert.equal(hauptplan(daten({ plaene: nachBefoerderung })).kurz, 'sommer')
})
test('Hash hängt nicht von der Schlüsselreihenfolge ab, wohl aber von der Fensterreihenfolge', () => {
const a = { id: 'p', kurz: 'p', name: 'P', haupt: true, tage: { 2: { modus: 'zeiten', fenster: [['09:00', '12:00']] }, 1: { fenster: [], modus: 'zu' } } }
const b = { haupt: true, name: 'P', kurz: 'p', id: 'p', tage: { 1: { modus: 'zu', fenster: [] }, 2: { fenster: [['09:00', '12:00']], modus: 'zeiten' } } }
assert.equal(wochenplanHash(daten({ plaene: [a] })), wochenplanHash(daten({ plaene: [b] })))
const c = plan({ 1: [['08:00', '12:00'], ['14:00', '18:00']] })
const d = plan({ 1: [['14:00', '18:00'], ['08:00', '12:00']] })
assert.notEqual(wochenplanHash(daten({ plaene: [c] })), wochenplanHash(daten({ plaene: [d] })))
})
test('Hash ist leer ohne Hauptplan und wirft bei kaputten Daten nicht', () => {
assert.equal(wochenplanHash(daten({ plaene: [] })), '')
assert.equal(wochenplanHash(daten({ plaene: [plan({ 1: ['09:00', '18:00'] }, { haupt: false })] })), '')
assert.equal(wochenplanHash(null), '')
assert.equal(wochenplanHash(undefined), '')
assert.equal(wochenplanHash({}), '')
assert.equal(wochenplanHash({ plaene: 'x' }), '')
// Hauptplan ohne Tage: stabiler Wert, kein Wurf, anders als mit Tagen
const ohneTage = { id: 'p', kurz: 'p', name: 'P', haupt: true }
assert.match(wochenplanHash(daten({ plaene: [ohneTage] })), /^[0-9a-f]{16}$/)
assert.notEqual(wochenplanHash(daten({ plaene: [ohneTage] })), wochenplanHash(daten()))
})
test('Hash verändert die Eingabe nicht', () => {
const d = tiefEinfrieren(daten({ plaene: [plan({ 1: ['09:00', '18:00'] })] }))
assert.equal(wochenplanHash(d), wochenplanHash(d))
})
test('ersatz.js ist rein: nur node:crypto und zeitlogik.js, kein Appwrite, keine Config, keine Uhr', () => {
const quelle = readFileSync(new URL('../server/services/zeiten/ersatz.js', import.meta.url), 'utf8')
const importe = [...quelle.matchAll(/^import[\s\S]*?from\s+['"]([^'"]+)['"]/gm)].map((m) => m[1])
assert.deepEqual(importe.sort(), ['./zeitlogik.js', 'node:crypto'])
assert.doesNotMatch(quelle, /Date\.now|new Date\(/)
assert.match(quelle, /^[\x00-\x7F]*$/, 'Quelltext selbst nur ASCII')
})