Öffnungszeiten: Website-Skript mit Status und Tabelle

public/embed/zeiten.js ruft die öffentliche Zeiten-API einmal je Seite ab und füllt
die Platzhalter [data-webklar-zeiten] (tabelle, status, badge; data-plan, Hauptplan
ohne Angabe). Reine Funktionen (waehleWechsel, naechsterWechsel, waehleHinweis,
jetztMitServer, tabelleDaten) stehen zwischen den Marken @rein-anfang/@rein-ende.
Timer auf den nächsten Wechsel (jede Wartezeit auf 10 Minuten begrenzt), Neuladen
höchstens alle 10 Minuten, Daten älter als 24 Stunden, Fehler oder aktiv:false
lassen das Ersatz-HTML stehen bzw. stellen es wieder her. Testseite zeiten-demo.html.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 04:01:31 +00:00
1 parent a2d11ad6c4
commit a6a4eae294
3 files changed
+984

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Öffnungszeiten Testseite</title>
<style>
body{margin:0;font-family:system-ui,sans-serif;background:#f6f7f9;color:#1f2937;line-height:1.5}
header,footer{padding:16px 24px;background:#e8ebf0}
main{max-width:760px;margin:0 auto;padding:32px 16px}
h2{margin:32px 0 12px}
.ersatz{margin:0;padding:12px 16px;border:2px dashed #94a3b8;border-radius:12px;background:#fff}
.ersatz table{border-collapse:collapse}
.ersatz th,.ersatz td{padding:.25em .75em .25em 0;text-align:left}
/* Gestaltung ueber CSS-Variablen der Seite (alle optional): [data-webklar-zeiten]{ --wkz-offen:#0f766e; --wkz-heute:#fde68a; } */
</style>
</head>
<body>
<header>Testseite der Kundenwebsite (intern, nicht verlinkt)</header>
<main>
<h1>Öffnungszeiten</h1>
<p>Der siteKey unten ist ein Platzhalter: für echte Daten <code>data-site</code> durch den siteKey des Projekts ersetzen (und bei Bedarf <code>data-api</code> am Script-Tag ergänzen). Ohne Daten (oder bei einem Fehler) bleibt überall das Ersatz-HTML stehen.</p>
<h2>Tabelle (Hauptplan)</h2>
<div data-webklar-zeiten data-modus="tabelle">
<div class="ersatz">
<table>
<caption>&Ouml;ffnungszeiten</caption>
<tr><th scope="row">Montag</th><td>09:00 &#8211; 18:00</td></tr>
<tr><th scope="row">Dienstag</th><td>09:00 &#8211; 18:00</td></tr>
<tr><th scope="row">Mittwoch</th><td>09:00 &#8211; 18:00</td></tr>
<tr><th scope="row">Donnerstag</th><td>09:00 &#8211; 18:00</td></tr>
<tr><th scope="row">Freitag</th><td>09:00 &#8211; 18:00</td></tr>
<tr><th scope="row">Samstag</th><td>09:00 &#8211; 13:00</td></tr>
<tr><th scope="row">Sonntag</th><td>geschlossen</td></tr>
</table>
</div>
</div>
<h2>Status (Hauptplan)</h2>
<div data-webklar-zeiten data-modus="status">
<p class="ersatz">Mo&#8211;Fr 09:00 &#8211; 18:00 Uhr, Sa 09:00 &#8211; 13:00 Uhr.</p>
</div>
<h2>Badge (Plan „haupt“, im Fließtext)</h2>
<p>Wir haben gerade <span data-webklar-zeiten data-modus="badge" data-plan="haupt">Mo&#8211;Fr 09:00 &#8211; 18:00 Uhr</span> für Sie da.</p>
<h2>Unbekannter Plan (Ersatz-HTML bleibt, Warnung in der Konsole)</h2>
<div data-webklar-zeiten data-modus="status" data-plan="gibt-es-nicht">
<p class="ersatz">Dieser Platzhalter nennt einen Plan, den es nicht gibt.</p>
</div>
</main>
<footer>Fußbereich</footer>
<script src="/embed/zeiten.js" data-site="abcdefghijklmnop" defer></script>
</body>
</html>
+524
View File
@@ -0,0 +1,524 @@
/*! WEBklar Oeffnungszeiten live: Einbau-Skript fuer Kundenwebsites.
* <script src=".../embed/zeiten.js" data-site="SITEKEY" defer></script>
* data-api="ADRESSE" am Script-Tag ist optional (sonst die Herkunft des Skripts); den siteKey darf auch ein Platzhalter tragen
* (data-webklar-zeiten="SITEKEY").
*
* Platzhalter (vom Team gesetzt, das Ersatz-HTML darin bleibt ohne JavaScript, ohne Daten und bei Fehlern unveraendert stehen):
* <div data-webklar-zeiten data-modus="tabelle|status|badge" data-plan="KURZNAME">Ersatz-HTML</div>
* tabelle Wochentabelle (<table> mit Caption, Zeilenkoepfen und heutigem Tag mit aria-current="date") plus die Abweichungen der
* naechsten 14 Tage
* status Punkt und Text ("Jetzt geoeffnet · schliesst um 18 Uhr"), ohne aria-live
* badge Kurzform ("Geoeffnet", "Geschlossen", "Nach Vereinbarung")
* Ohne data-plan gilt der Hauptplan; ein unbekannter data-plan laesst das Ersatz-HTML stehen und schreibt eine Konsole-Warnung.
*
* Gestaltung: Light DOM, alle Klassen wkz-..., Stile in @layer wkz (jede Regel der Seite schlaegt sie), Schrift und Farben erbt die Seite.
* Variablen (alle optional, an den Platzhalter oder :root): --wkz-offen, --wkz-zu, --wkz-unklar (Punktfarben), --wkz-offen-hell,
* --wkz-zu-hell, --wkz-unklar-hell, --wkz-offen-text, --wkz-zu-text, --wkz-unklar-text (Badge), --wkz-rand, --wkz-heute.
*
* Ein Abruf je Seite fuer alle Teile; keine Abhaengigkeiten, keine Cookies, kein Storage, keine Anfragen an Dritte. Kundentexte nur
* ueber textContent. Das Skript rechnet nie mit Feiertagen, Kalender oder Zeitzone des Browsers: "jetzt" ist Date.now() (weicht die
* Uhr laut Date-Kopf um mehr als 5 Minuten ab, die Serverzeit), alles andere kommt als Zeichenkette vom Server. Es laedt hoechstens
* alle 10 Minuten neu (und beim Zurueckkehren in den Tab); Daten aelter als 24 Stunden oder ein Fehler lassen das Ersatz-HTML stehen. */
(function () {
'use strict'
if (window.__wkZeiten) return
window.__wkZeiten = true
var SELF = document.currentScript
var STYLE_ID = 'wkz-style'
var API_PFAD = '/api/public/zeiten/v1/'
var SELEKTOR = '[data-webklar-zeiten]'
var TIMER_MAX_MS = 600000 // jede Wartezeit hoechstens 10 Minuten (setTimeout laeuft ab 2^31-1 ms ueber; "ohne Ende" ist 8.64e15)
var TIMER_PUFFER_MS = 25 // ein Timer darf nie vor dem Wechsel feuern
var RELOAD_MS = 600000 // hoechstens alle 10 Minuten ein Abruf
var ALTER_MAX_MS = 24 * 60 * 60 * 1000 // aeltere Daten gelten nicht mehr
var ABRUF_TIMEOUT_MS = 15000
// @rein-anfang (reine Funktionen: kein Zugriff auf Seite oder Uhr; test/zeitenEmbedStatic.test.js fuehrt sie einzeln in einem leeren Kontext aus)
var UHR_TOLERANZ_MS = 5 * 60 * 1000 // groessere Abweichung der Uhr laut Date-Kopf: Serverzeit
var ENDLOS_MS = 8.64e15 // "ohne Ende" der Hinweise (letzter darstellbarer Zeitpunkt)
var TAG_ZEILE = /^\d{4}-\d{2}-\d{2}$/
var WOCHENTAGE = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']
var ART_NAMEN = { feiertag: 'Feiertag', urlaub: 'Betriebsurlaub', sondertag: 'Sondertag' }
function istZahl (v) { return typeof v === 'number' && v > -Infinity && v < Infinity }
function artName (art) { return Object.prototype.hasOwnProperty.call(ART_NAMEN, art) ? ART_NAMEN[art] : '' }
// Der Eintrag, der jetzt gilt: der letzte mit ab <= jetzt (null, wenn noch keiner begonnen hat).
function waehleWechsel (wechsel, jetztMs) {
if (!Array.isArray(wechsel)) return null
var best = null
for (var i = 0; i < wechsel.length; i++) {
var w = wechsel[i]
if (w && istZahl(w.ab) && w.ab <= jetztMs && (best === null || w.ab >= best.ab)) best = w
}
return best
}
// Der naechste Zeitpunkt nach jetzt, an dem sich etwas aendert: Statuswechsel, Hinweis-Beginn oder Hinweis-Ende (null, wenn keiner folgt;
// "ohne Ende" zaehlt nicht).
function naechsterWechsel (wechsel, hinweise, jetztMs) {
var best = null
function pruefe (ms) {
if (istZahl(ms) && ms > jetztMs && ms < ENDLOS_MS && (best === null || ms < best)) best = ms
}
var i
if (Array.isArray(wechsel)) for (i = 0; i < wechsel.length; i++) if (wechsel[i]) pruefe(wechsel[i].ab)
if (Array.isArray(hinweise)) {
for (i = 0; i < hinweise.length; i++) {
if (!hinweise[i]) continue
pruefe(hinweise[i].ab)
pruefe(hinweise[i].bis)
}
}
return best
}
// Der Hinweis, der jetzt gilt (ab <= jetzt < bis): hoechster Rang, bei Gleichstand der spaetere Beginn.
function waehleHinweis (hinweise, jetztMs) {
if (!Array.isArray(hinweise)) return null
var best = null
for (var i = 0; i < hinweise.length; i++) {
var h = hinweise[i]
if (!h || !istZahl(h.rang) || !istZahl(h.ab) || !istZahl(h.bis) || h.ab > jetztMs || h.bis <= jetztMs) continue
if (best === null || h.rang > best.rang || (h.rang === best.rang && h.ab > best.ab)) best = h
}
return best
}
// "Jetzt" in Millisekunden: die lokale Uhr, ausser sie weicht laut Date-Kopf der Antwort um mehr als 5 Minuten von der Serverzeit ab.
function jetztMitServer (datumsKopf, lokalMs) {
var server = typeof datumsKopf === 'string' ? Date.parse(datumsKopf) : NaN
if (!istZahl(server) || Math.abs(server - lokalMs) <= UHR_TOLERANZ_MS) return lokalMs
return server
}
// 'YYYY-MM-DD' -> Tage seit 1970-01-01 (reine Ganzzahlrechnung auf der Zeichenkette des Servers, ohne Kalender des Browsers)
function tageNr (tag) {
var m = parseInt(tag.slice(5, 7), 10)
var y = parseInt(tag.slice(0, 4), 10) - (m <= 2 ? 1 : 0)
var era = Math.floor(y / 400)
var yoe = y - era * 400
var doy = Math.floor((153 * (m > 2 ? m - 3 : m + 9) + 2) / 5) + parseInt(tag.slice(8, 10), 10) - 1
return era * 146097 + yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy - 719468
}
// ISO-Wochentag eines Tages 'YYYY-MM-DD': 1 = Montag ... 7 = Sonntag (der 1.1.1970 war ein Donnerstag)
function wochentagVon (tag) { return ((((tageNr(tag) + 3) % 7) + 7) % 7) + 1 }
// 'Montag, 12.10.'
function datumKurz (tag) { return WOCHENTAGE[wochentagVon(tag) - 1] + ', ' + tag.slice(8, 10) + '.' + tag.slice(5, 7) + '.' }
// Tageszeiten als Text; nie '24:00' (ein Ende um Mitternacht heisst 00:00).
function zeitenText (modus, fenster) {
if (!Array.isArray(fenster)) fenster = []
if (modus === 'rund' || (modus === 'zeiten' && fenster.length === 1 && fenster[0] && fenster[0][0] === '00:00' && fenster[0][1] === '24:00')) return 'rund um die Uhr'
if (modus === 'vereinbarung') return 'nach Vereinbarung'
if (modus !== 'zeiten' || fenster.length === 0) return 'geschlossen'
var teile = []
for (var i = 0; i < fenster.length; i++) {
var f = fenster[i]
if (Array.isArray(f) && typeof f[0] === 'string' && typeof f[1] === 'string') teile.push(f[0] + '–' + (f[1] === '24:00' ? '00:00' : f[1]))
}
return teile.length ? teile.join(', ') : 'geschlossen'
}
// Daten der Tabelle: woche = sieben Zeilen Montag bis Sonntag { tag, name, modus, fenster, text, heute } (heute = der Wochentag von
// heuteTag, dem Berliner Datum aus dem aktuellen Wechsel) und abweichungen14 = die Tage von heuteTag bis heuteTag + 13, an denen etwas
// vom Wochenplan abweicht (Feiertag, Urlaub, Sondertag oder andere Zeiten): { datum, art, name, modus, fenster, text }.
function tabelleDaten (plan, heuteTag) {
var heuteOk = typeof heuteTag === 'string' && TAG_ZEILE.test(heuteTag)
var heuteWt = heuteOk ? wochentagVon(heuteTag) : 0
var roh = plan && Array.isArray(plan.woche) ? plan.woche : []
var woche = []
var t, i
for (t = 1; t <= 7; t++) {
var e = null
for (i = 0; i < roh.length; i++) if (roh[i] && roh[i].tag === t) e = roh[i]
var modus = e ? e.modus : 'zu'
var fenster = e && Array.isArray(e.fenster) ? e.fenster : []
woche.push({ tag: t, name: WOCHENTAGE[t - 1], modus: modus, fenster: fenster, text: zeitenText(modus, fenster), heute: t === heuteWt })
}
var abweichungen14 = []
if (heuteOk && plan && Array.isArray(plan.tage)) {
var start = tageNr(heuteTag)
for (i = 0; i < plan.tage.length; i++) {
var z = plan.tage[i]
if (!z || typeof z.datum !== 'string' || !TAG_ZEILE.test(z.datum)) continue
var abstand = tageNr(z.datum) - start
if (abstand < 0 || abstand > 13) continue
var text = zeitenText(z.modus, z.fenster)
if (!artName(z.art) && text === woche[wochentagVon(z.datum) - 1].text) continue
abweichungen14.push({
datum: z.datum,
art: z.art,
name: typeof z.name === 'string' && z.name !== '' ? z.name : null,
modus: z.modus,
fenster: Array.isArray(z.fenster) ? z.fenster : [],
text: text
})
}
abweichungen14.sort(function (a, b) { return a.datum < b.datum ? -1 : a.datum > b.datum ? 1 : 0 })
}
return { woche: woche, abweichungen14: abweichungen14 }
}
// Aufeinanderfolgende Tage mit gleicher Art, gleichem Namen und gleichen Zeiten in einer Zeile: [{ von, bis, art, name, text }]
function gruppiereAbweichungen (liste) {
var gruppen = []
if (!Array.isArray(liste)) return gruppen
for (var i = 0; i < liste.length; i++) {
var a = liste[i]
var g = gruppen.length ? gruppen[gruppen.length - 1] : null
if (g && g.art === a.art && g.name === a.name && g.text === a.text && tageNr(a.datum) - tageNr(g.bis) === 1) g.bis = a.datum
else gruppen.push({ von: a.datum, bis: a.datum, art: a.art, name: a.name, text: a.text })
}
return gruppen
}
// Eine Zeile der Abweichungen: { datum: 'Dienstag, 06.10. – Donnerstag, 08.10.', text: 'Betriebsurlaub: geschlossen' }
function abweichungsZeile (g) {
var datum = datumKurz(g.von)
if (g.bis && g.bis !== g.von) datum += ' – ' + datumKurz(g.bis)
return { datum: datum, text: (g.name || artName(g.art) || 'Abweichung') + ': ' + g.text }
}
// @rein-ende
// @telefon-anfang (reine Funktion, test/zeitenEmbedStatic.test.js fuehrt sie einzeln aus; dieselbe Regel wie in public/embed/termine.js; fuer Kontakt-Leiste und Hinweis-Knoepfe)
// Telefonnummer des Betriebs (freier Text) als tel:-Adresse. Steht ein Buchstabe oder ein anderes Zeichen als Ziffern, Leerzeichen,
// + - ( ) / . darin ("06301 1234 (ab 9 Uhr)", "0171 1, 0171 2"), ist es keine Nummer, die man waehlen kann: dann '' und der Text bleibt
// Text. Sonst bleiben ein fuehrendes + und die Ziffern; die nationale Null "(0)" hinter der Landesvorwahl entfaellt.
function telefonLink (nr) {
if (typeof nr !== 'string') return ''
var s = nr.replace(/^\s+|\s+$/g, '')
if (!/^[0-9 +()\/.-]+$/.test(s)) return ''
var lv = /^((?:\+|00)\s*\d{1,3})[\s.\/-]*\(\s*0\s*\)/.exec(s)
if (lv) s = lv[1] + s.slice(lv[0].length)
var ziffern = s.replace(/[^0-9]/g, '')
if (ziffern.length < 3) return ''
return 'tel:' + (s.charAt(0) === '+' ? '+' : '') + ziffern
}
// @telefon-ende
var CSS = [
'@layer wkz{',
'.wkz-status,.wkz-badge{--wkz-i-farbe:var(--wkz-unklar,#a16207);--wkz-i-hell:var(--wkz-unklar-hell,#fef3c7);--wkz-i-dunkel:var(--wkz-unklar-text,#78350f)}',
'.wkz-zustand-offen{--wkz-i-farbe:var(--wkz-offen,#15803d);--wkz-i-hell:var(--wkz-offen-hell,#dcfce7);--wkz-i-dunkel:var(--wkz-offen-text,#14532d)}',
'.wkz-zustand-zu{--wkz-i-farbe:var(--wkz-zu,#b91c1c);--wkz-i-hell:var(--wkz-zu-hell,#fee2e2);--wkz-i-dunkel:var(--wkz-zu-text,#7f1d1d)}',
'.wkz-status{display:inline-flex;align-items:center;gap:.5em;font:inherit;color:inherit;line-height:1.4}',
'.wkz-punkt{flex:none;width:.65em;height:.65em;border-radius:50%;background:var(--wkz-i-farbe)}',
'.wkz-badge{display:inline-flex;align-items:center;gap:.45em;padding:.1em .75em;border-radius:999px;font:inherit;font-size:.9em;font-weight:600;line-height:1.5;background:var(--wkz-i-hell);color:var(--wkz-i-dunkel)}',
'.wkz-tabelle{border-collapse:collapse;width:100%;max-width:34rem;font:inherit;color:inherit}',
'.wkz-caption{caption-side:top;text-align:left;font-weight:700;padding:0 0 .5em}',
'.wkz-tabelle th,.wkz-tabelle td{padding:.4em .75em;text-align:left;vertical-align:top;border-bottom:1px solid var(--wkz-rand,rgba(128,128,128,.35))}',
'.wkz-tabelle th{font-weight:600;white-space:nowrap}',
'.wkz-heute{background:var(--wkz-heute,rgba(128,128,128,.14));font-weight:700}',
'.wkz-heute th{font-weight:700}',
'.wkz-abw{margin-top:1em;max-width:34rem;font:inherit;color:inherit}',
'.wkz-abw-titel{margin:0 0 .35em;font-weight:700}',
'.wkz-abw-liste{margin:0;padding:0;list-style:none}',
'.wkz-abw-eintrag{padding:.3em 0;border-bottom:1px solid var(--wkz-rand,rgba(128,128,128,.35))}',
'.wkz-abw-datum{font-weight:600}',
'}'
].join('\n')
// ---------------------------------------------------------------- Zustand (nur im Speicher der Seite)
var daten = null // letzte gueltige Antwort { plaene, hinweise } oder null (aktiv: false, Fehler, noch kein Abruf)
var geladenMs = 0 // Date.now() beim letzten ERFOLGREICHEN Abruf (nicht body.stand: ein 304 liefert alte Daten)
var versatz = 0 // Serverzeit minus lokale Uhr, nur bei mehr als 5 Minuten Abweichung (sonst 0)
var versuchMs = 0 // Beginn des letzten Abrufs, ob erfolgreich oder nicht
var laeuft = false
var timerId = 0
var platzhalter = []
var siteKey = ''
var api = ''
function timer (fn, ms) {
var d = Number(ms)
if (d !== d) d = TIMER_MAX_MS
return setTimeout(function () { fn() }, Math.max(0, Math.min(TIMER_MAX_MS, d)))
}
function jetzt () { return Date.now() + versatz }
function frisch () { return daten !== null && Math.abs(Date.now() - geladenMs) <= ALTER_MAX_MS }
function warne (text) {
if (typeof console !== 'undefined' && console.warn) console.warn('WEBklar Öffnungszeiten: ' + text)
}
function injectStyle () {
if (document.getElementById(STYLE_ID)) return
var st = document.createElement('style')
st.id = STYLE_ID
st.textContent = CSS
;(document.head || document.documentElement).appendChild(st)
}
function el (tag, cls, text) {
var e = document.createElement(tag)
if (cls) e.className = cls
if (text != null) e.textContent = text
return e
}
function attr (node, name) { return String(node.getAttribute(name) || '').replace(/^\s+|\s+$/g, '') }
// ---------------------------------------------------------------- Abruf
function planGueltig (p) {
return !!p && typeof p === 'object' && typeof p.kurz === 'string' && Array.isArray(p.woche) && Array.isArray(p.wechsel) && p.wechsel.length > 0
}
// Antwort uebernehmen. { aktiv: false } ist ein erfolgreicher Abruf (die Platzhalter bekommen ihr Ersatz-HTML zurueck); alles andere als ein
// Objekt zaehlt als Fehler (die alten Daten bleiben bis zur 24-Stunden-Grenze).
function uebernimm (body, datumsKopf) {
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Antwort')
var lokal = Date.now()
geladenMs = lokal
versatz = jetztMitServer(datumsKopf, lokal) - lokal
if (body.aktiv !== true || !Array.isArray(body.plaene)) {
daten = null
return
}
daten = { plaene: body.plaene.filter(planGueltig), hinweise: Array.isArray(body.hinweise) ? body.hinweise : [] }
}
function lade () {
if (laeuft || !siteKey || !api) return
laeuft = true
versuchMs = Date.now()
var opts = { credentials: 'omit' }
var ctrl = typeof AbortController === 'function' ? new AbortController() : null
var abbruch = 0
if (ctrl) {
opts.signal = ctrl.signal
abbruch = timer(function () { ctrl.abort() }, ABRUF_TIMEOUT_MS)
}
function fertig () {
if (abbruch) clearTimeout(abbruch)
laeuft = false
try { render() } finally { plane() }
}
fetch(api + API_PFAD + encodeURIComponent(siteKey), opts).then(function (r) {
if (!r.ok) throw new Error('Status ' + r.status)
var kopf = r.headers && typeof r.headers.get === 'function' ? r.headers.get('Date') : null
return r.json().then(function (body) { uebernimm(body, kopf) })
}).then(fertig, fertig)
}
// ---------------------------------------------------------------- Platzhalter
function neuerPlatzhalter (root) {
// Kopie des Ersatz-HTML: das Original bleibt stehen, bis es ersetzt wird, und kommt (als weitere Kopie) zurueck
var ausweich = document.createDocumentFragment()
for (var c = root.firstChild; c; c = c.nextSibling) ausweich.appendChild(c.cloneNode(true))
var modus = attr(root, 'data-modus')
if (modus !== 'tabelle' && modus !== 'status' && modus !== 'badge') {
warne('data-modus="' + modus + '" ist unbekannt (tabelle, status oder badge): das Ersatz-HTML bleibt stehen.')
modus = ''
}
return { root: root, ausweich: ausweich, modus: modus, plan: attr(root, 'data-plan'), sig: '', gewarnt: '' }
}
function sammle () {
var knoten = document.querySelectorAll(SELEKTOR)
for (var i = 0; i < knoten.length; i++) {
if (knoten[i].hasAttribute('data-wkz-init')) continue
knoten[i].setAttribute('data-wkz-init', '1')
platzhalter.push(neuerPlatzhalter(knoten[i]))
}
}
function leere (root) {
while (root.firstChild) root.removeChild(root.firstChild)
}
function ersetze (p, knoten, sig) {
injectStyle()
leere(p.root)
p.root.appendChild(knoten)
p.sig = sig
}
// Das Ersatz-HTML zurueck, falls das Skript es ersetzt hatte (sonst bleibt alles, wie es ist)
function stelleWiederHer (p) {
if (p.sig === '') return
leere(p.root)
p.root.appendChild(p.ausweich.cloneNode(true))
p.sig = ''
}
function findePlan (d, kurz) {
var i
if (kurz) {
for (i = 0; i < d.plaene.length; i++) if (d.plaene[i].kurz === kurz) return d.plaene[i]
return null
}
for (i = 0; i < d.plaene.length; i++) if (d.plaene[i].haupt === true) return d.plaene[i]
return d.plaene.length ? d.plaene[0] : null
}
// Der Eintrag, der jetzt gilt. Geht die Uhr der Seite (innerhalb der Toleranz) nach, ist der erste Eintrag (Beginn der Minute des Abrufs)
// noch "in der Zukunft": er gilt trotzdem.
function aktuellerEintrag (plan, jetztMs) {
var e = waehleWechsel(plan.wechsel, jetztMs)
if (e) return e
var erster = plan.wechsel[0]
return erster && istZahl(erster.ab) && erster.ab - jetztMs <= UHR_TOLERANZ_MS ? erster : null
}
function zustandKlasse (offen) {
return offen === true ? 'wkz-zustand-offen' : offen === false ? 'wkz-zustand-zu' : 'wkz-zustand-unklar'
}
function zeigeTabelle (p, plan, eintrag) {
var t = tabelleDaten(plan, eintrag.tag)
var gruppen = gruppiereAbweichungen(t.abweichungen14)
var name = typeof plan.name === 'string' && plan.name !== '' ? plan.name : 'Öffnungszeiten'
var sig = 'tabelle|' + JSON.stringify([name, t.woche.map(function (w) { return [w.text, w.heute] }), gruppen])
if (p.sig === sig) return
var tabelle = el('table', 'wkz-tabelle')
tabelle.appendChild(el('caption', 'wkz-caption', name))
var tbody = el('tbody')
for (var i = 0; i < t.woche.length; i++) {
var w = t.woche[i]
var tr = el('tr', w.heute ? 'wkz-zeile wkz-heute' : 'wkz-zeile')
if (w.heute) tr.setAttribute('aria-current', 'date')
var th = el('th', 'wkz-tag', w.name)
th.setAttribute('scope', 'row')
tr.appendChild(th)
tr.appendChild(el('td', 'wkz-zeiten', w.text))
tbody.appendChild(tr)
}
tabelle.appendChild(tbody)
var box = document.createDocumentFragment()
box.appendChild(tabelle)
if (gruppen.length) {
var abw = el('div', 'wkz-abw')
abw.appendChild(el('p', 'wkz-abw-titel', 'Abweichungen in den nächsten 14 Tagen'))
var liste = el('ul', 'wkz-abw-liste')
for (var k = 0; k < gruppen.length; k++) {
var z = abweichungsZeile(gruppen[k])
var li = el('li', 'wkz-abw-eintrag')
li.appendChild(el('span', 'wkz-abw-datum', z.datum))
li.appendChild(document.createTextNode(' '))
li.appendChild(el('span', 'wkz-abw-text', z.text))
liste.appendChild(li)
}
abw.appendChild(liste)
box.appendChild(abw)
}
ersetze(p, box, sig)
}
// Punkt und Text in einem Element (Status: der ausfuehrliche Text, Badge: die Kurzform); kein aria-live
function zeigeZustand (p, cls, text, eintrag) {
if (typeof text !== 'string' || text === '') {
stelleWiederHer(p)
return
}
var sig = cls + '|' + String(eintrag.offen) + '|' + text
if (p.sig === sig) return
var box = el('span', cls + ' ' + zustandKlasse(eintrag.offen))
var punkt = el('span', 'wkz-punkt')
punkt.setAttribute('aria-hidden', 'true')
box.appendChild(punkt)
box.appendChild(el('span', 'wkz-text', text))
ersetze(p, box, sig)
}
function fuelle (p, d, jetztMs) {
var plan = d ? findePlan(d, p.plan) : null
if (d && d.plaene.length && p.plan && !plan && p.gewarnt !== p.plan) {
p.gewarnt = p.plan
warne('Plan "' + p.plan + '" nicht gefunden: das Ersatz-HTML bleibt stehen.')
}
var eintrag = plan && p.modus ? aktuellerEintrag(plan, jetztMs) : null
if (!eintrag) {
stelleWiederHer(p)
return
}
if (p.modus === 'tabelle') zeigeTabelle(p, plan, eintrag)
else if (p.modus === 'status') zeigeZustand(p, 'wkz-status', eintrag.text || eintrag.kurz, eintrag)
else zeigeZustand(p, 'wkz-badge', eintrag.kurz, eintrag)
}
function render () {
sammle()
platzhalter = platzhalter.filter(function (p) { return p.root.isConnected !== false })
var d = frisch() ? daten : null
var t = jetzt()
for (var i = 0; i < platzhalter.length; i++) fuelle(platzhalter[i], d, t)
// Task 26: Hinweis-Leiste, Kontakt-Leiste und JSON-LD werden hier mit denselben Daten (d) gezeichnet
}
// ---------------------------------------------------------------- Timer
// Ein Timer auf den naechsten Wechsel (Status, Hinweis-Beginn oder -Ende) oder das naechste erlaubte Neuladen, was frueher kommt.
function plane () {
if (timerId) clearTimeout(timerId)
var t = jetzt()
var alle = []
if (daten) for (var i = 0; i < daten.plaene.length; i++) alle = alle.concat(daten.plaene[i].wechsel)
var naechster = naechsterWechsel(alle, daten ? daten.hinweise : [], t)
// laeuft ein Abruf, plant dessen Ende neu (sonst wuerde ein faelliges Neuladen den Timer auf 0 setzen)
var bisReload = laeuft ? Infinity : versuchMs + RELOAD_MS - Date.now()
var warte = naechster === null ? bisReload : Math.min(naechster - t, bisReload)
timerId = timer(tick, warte + TIMER_PUFFER_MS)
}
function tick () {
if (timerId) clearTimeout(timerId)
timerId = 0
if (!laeuft && Date.now() - versuchMs >= RELOAD_MS) {
lade() // zeichnet und plant danach neu
return
}
render()
plane()
}
// ---------------------------------------------------------------- Start
function eigenesSkript () {
return SELF || document.querySelector('script[data-wk-zeiten], script[src*="/embed/zeiten.js"]')
}
function bestimmeSiteKey (skript) {
var k = skript ? attr(skript, 'data-site') : ''
for (var i = 0; !k && i < platzhalter.length; i++) k = attr(platzhalter[i].root, 'data-webklar-zeiten')
return k
}
function bestimmeApi (skript) {
var a = skript ? attr(skript, 'data-api') : ''
for (var i = 0; !a && i < platzhalter.length; i++) a = attr(platzhalter[i].root, 'data-api')
a = a.replace(/\/+$/, '')
if (a) return a
if (skript && skript.src) {
try { return new URL(skript.src).origin } catch (e) { return '' }
}
return ''
}
function start () {
sammle()
var skript = eigenesSkript()
siteKey = bestimmeSiteKey(skript)
api = bestimmeApi(skript)
if (!siteKey || !api || typeof fetch !== 'function') {
if (platzhalter.length) warne('siteKey (data-site) oder Adresse der Schnittstelle fehlt: die Platzhalter bleiben beim Ersatz-HTML.')
return
}
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') tick()
})
lade()
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start)
else start()
})()
+403
View File
@@ -0,0 +1,403 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
// Statische Pruefung und Funktionstests des Website-Skripts public/embed/zeiten.js (Oeffnungszeiten live, Aufgabe 25;
// Spezifikation 4.2 und 4.3 Punkt 4, Rulings R6 und Skript-Regeln der Gesamtvorgaben):
// - Das Skript rechnet nie mit Datum oder Zeitzone des Browsers: "jetzt" ist Date.now() (bei grosser Uhrabweichung die Serverzeit aus
// dem Date-Kopf), Wochentage und Kalendertage kommen als Zeichenketten vom Server und werden rein rechnerisch ausgewertet.
// - Kein Browser-Speicher, keine Cookies, keine Fremdadressen im Code; Kundentext nur ueber textContent.
// - Die reinen Funktionen (Marken @rein-anfang / @rein-ende) laufen hier einzeln in einem leeren vm-Kontext.
// Task 26 haengt Hinweis-Leiste, Kontakt-Leiste und JSON-LD an dieselbe Datei und ergaenzt diese Datei um deren Pruefungen.
const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8')
const QUELLE = lies('public/embed/zeiten.js')
/** Quelltext ohne Kommentare (die Kopfzeile darf Beispiele nennen, der Code nicht). */
const OHNE_KOMMENTARE = QUELLE.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
/** Ergebnisse aus dem vm-Kontext haben andere Prototypen (deepStrictEqual wuerde scheitern): ueber JSON angleichen. */
const norm = (x) => JSON.parse(JSON.stringify(x))
test('Quelltext ist gueltiges JavaScript (kompiliert, wird nicht ausgefuehrt)', () => {
assert.doesNotThrow(() => new vm.Script(QUELLE, { filename: 'zeiten.js' }))
})
test('Quelltext: keine Cookies, kein Storage, kein fremder Host', () => {
for (const verboten of ['localStorage', 'sessionStorage', 'document.cookie', 'indexedDB', 'document.referrer', 'sendBeacon', 'navigator.userAgent', 'resolvedOptions']) {
assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf im Skript nicht vorkommen`)
}
// auch nicht in Kommentaren oder Texten: nirgends ein https://-Literal (die Adresse kommt aus data-api oder der Herkunft des Skripts)
assert.doesNotMatch(QUELLE, /https?:\/\//, 'Adressen gehoeren in data-api, nicht in die Datei')
assert.doesNotMatch(OHNE_KOMMENTARE, /\bimport\s*\(|\bimportScripts\b|\bXMLHttpRequest\b|\bWebSocket\b/)
})
test('Quelltext: Date nur als Date.now( und Date.parse(, keine Zeitzonen- und Kalenderrechnung des Browsers', () => {
// erlaubt: Date.now(, Date.parse( und der Kopfname 'Date' (headers.get('Date'))
const rest = OHNE_KOMMENTARE.replace(/\bDate\.now\(/g, '').replace(/\bDate\.parse\(/g, '').replace(/headers\.get\('Date'\)/g, '')
assert.ok(!/\bDate\b/.test(rest), 'Date darf nur als Date.now( oder Date.parse( vorkommen')
assert.ok(OHNE_KOMMENTARE.includes('Date.now('), 'Date.now( fehlt')
assert.ok(OHNE_KOMMENTARE.includes('Date.parse('), 'Date.parse( fehlt')
for (const verboten of ['getHours', 'getMinutes', 'getDay', 'getDate', 'getFullYear', 'getMonth', 'getTime(', 'toLocale', 'Intl.', 'getTimezoneOffset', 'toISOString', 'toUTCString', 'new Date']) {
assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf im Skript nicht vorkommen`)
}
})
test('Quelltext: Kundentexte nie per innerHTML (kein innerHTML, outerHTML, insertAdjacentHTML, document.write, eval, srcdoc)', () => {
for (const verboten of ['innerHTML', 'outerHTML', 'insertAdjacentHTML', 'document.write', 'eval(', 'new Function', 'createContextualFragment', 'srcdoc', 'DOMParser']) {
assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf im Skript nicht vorkommen`)
}
assert.ok(OHNE_KOMMENTARE.includes('textContent'), 'Texte kommen ueber textContent')
})
test('Quelltext: setTimeout nur mit Funktionsargument, in genau einer Stelle, jede Wartezeit auf 0 bis 600000 ms begrenzt (R6)', () => {
const stellen = [...OHNE_KOMMENTARE.matchAll(/\bsetTimeout\(/g)]
assert.equal(stellen.length, 1, 'genau eine Stelle ruft setTimeout auf (Hilfsfunktion timer)')
assert.match(OHNE_KOMMENTARE, /setTimeout\(function \(\) \{/, 'setTimeout bekommt eine Funktion, nie eine Zeichenkette')
assert.doesNotMatch(OHNE_KOMMENTARE, /\bsetInterval\(/, 'kein setInterval')
assert.match(OHNE_KOMMENTARE, /var TIMER_MAX_MS = 600000\b/, 'Obergrenze 10 Minuten (setTimeout laeuft ab 2^31-1 ms ueber)')
const zeile = OHNE_KOMMENTARE.split('\n').find((z) => z.includes('setTimeout('))
const hilfe = OHNE_KOMMENTARE.slice(OHNE_KOMMENTARE.indexOf('function timer ('), OHNE_KOMMENTARE.indexOf(zeile) + zeile.length + 80)
assert.match(hilfe, /Math\.max\(0, Math\.min\(TIMER_MAX_MS,/, 'die Wartezeit wird auf [0, TIMER_MAX_MS] begrenzt')
assert.match(OHNE_KOMMENTARE, /var RELOAD_MS = 600000\b/, 'Neuladen hoechstens alle 10 Minuten')
assert.ok(OHNE_KOMMENTARE.includes('visibilitychange'), 'Neuladen auch beim Zurueckkehren in den Tab')
})
test('Doppel-Laden-Schutz __wkZeiten', () => {
assert.match(OHNE_KOMMENTARE, /if \(window\.__wkZeiten\) return/)
assert.match(OHNE_KOMMENTARE, /window\.__wkZeiten = true/)
assert.ok(OHNE_KOMMENTARE.indexOf('window.__wkZeiten = true') < OHNE_KOMMENTARE.indexOf("var STYLE_ID"), 'der Schutz steht vor allem anderen')
})
test('Abruf: genau ein fetch, ohne Cookies, nur an die Zeiten-Schnittstelle; letzter erfolgreicher Abruf im Speicher der Seite', () => {
assert.equal([...OHNE_KOMMENTARE.matchAll(/\bfetch\(/g)].length, 1, 'genau ein fetch (ein Abruf je Seite fuer alle Teile)')
assert.ok(OHNE_KOMMENTARE.includes("credentials: 'omit'"), "fetch braucht credentials: 'omit'")
assert.ok(OHNE_KOMMENTARE.includes('/api/public/zeiten/v1/'))
assert.match(OHNE_KOMMENTARE, /var ALTER_MAX_MS = 24 \* 60 \* 60 \* 1000\b/, 'Daten aelter als 24 Stunden gelten nicht mehr')
assert.match(OHNE_KOMMENTARE, /geladenMs = /, 'Zeitpunkt des letzten erfolgreichen Abrufs wird gemerkt (nicht body.stand)')
assert.ok(!OHNE_KOMMENTARE.includes('.stand'), 'body.stand zaehlt nicht fuer das Alter der Daten')
assert.ok(OHNE_KOMMENTARE.includes("headers.get('Date')"), 'Serverzeit aus dem Date-Kopf')
})
test('Pflichtbausteine der Spezifikation (Platzhalter, Stile, Tabelle, Plaene, Texte)', () => {
for (const noetig of [
'data-webklar-zeiten', 'data-site', 'data-api', 'data-modus', 'data-plan', 'currentScript',
"'wkz-style'", '@layer wkz', 'wkz-', '--wkz-',
"'caption'", "'scope'", "'row'", "'th'", 'aria-current', "'date'",
'tabelle', 'status', 'badge', 'haupt', 'console.warn',
'Abweichungen in den nächsten 14 Tagen', 'rund um die Uhr', 'nach Vereinbarung', 'geschlossen', 'Montag', 'Sonntag',
'DOMContentLoaded', 'removeChild',
]) {
assert.ok(QUELLE.includes(noetig), `${noetig} fehlt im Skript`)
}
assert.doesNotMatch(OHNE_KOMMENTARE, /aria-live/, 'der Status meldet sich nicht von selbst (kein aria-live)')
})
test('Stile stehen einmal in <style id="wkz-style">, alle Klassen beginnen mit wkz-, alle Variablen mit --wkz-', () => {
const css = /var CSS = \[([\s\S]*?)\]\.join/.exec(QUELLE)
assert.ok(css, 'CSS-Block fehlt')
const klassen = [...css[1].matchAll(/\.([a-zA-Z][\w-]*)/g)].map((m) => m[1])
assert.ok(klassen.length > 15)
for (const k of klassen) assert.ok(k.startsWith('wkz-'), `Klasse ${k} ohne Praefix wkz-`)
assert.ok(css[1].includes('@layer wkz{'), 'Stile in @layer wkz')
for (const v of css[1].matchAll(/(?<![\w-])--([\w-]+)/g)) assert.ok(v[1].startsWith('wkz-'), `Variable --${v[1]} ohne Praefix --wkz-`)
assert.equal([...QUELLE.matchAll(/getElementById\(STYLE_ID\)/g)].length, 1)
// Kontakt- und Hinweisleisten (Task 26) leben im Shadow DOM: hier steht kein Seitenstil dafuer
assert.ok(!/position:\s*fixed|position:fixed/.test(css[1]), 'Platzhalter-Stile setzen nichts fest an den Rand')
})
test('Ersatz-HTML wird nie verbraucht (nur Kopien zurueck auf die Seite) und ohne Daten oder bei Fehlern nicht angefasst', () => {
assert.ok(!/appendChild\(p\.ausweich\)/.test(OHNE_KOMMENTARE), 'das gemerkte Ersatz-HTML darf nur als cloneNode(true) zurueckgesetzt werden')
assert.ok(OHNE_KOMMENTARE.includes('ausweich.cloneNode(true)'))
assert.ok(OHNE_KOMMENTARE.includes('child.cloneNode(true)') || OHNE_KOMMENTARE.includes('.cloneNode(true)'), 'beim Start wird eine Kopie des Ersatz-HTML gemerkt, das Original bleibt stehen')
})
// ---------------------------------------------------------------- Telefonnummer (gleiche Regel wie public/embed/termine.js)
test('telefonLink: Nummern werden gewaehlt, freier Text nie, "(0)" hinter der Landesvorwahl entfaellt', () => {
const m = /\/\/ @telefon-anfang[^\n]*\n([\s\S]*?)\/\/ @telefon-ende/.exec(QUELLE)
assert.ok(m, 'Marken @telefon-anfang/@telefon-ende fehlen')
const telefonLink = vm.runInNewContext(`${m[1]}; telefonLink`, {})
const faelle = [
['06301 1234', 'tel:063011234'],
['+49 6301 1234', 'tel:+4963011234'],
['+49 (0) 6301-1234', 'tel:+4963011234'],
['+49(0)6301 1234', 'tel:+4963011234'],
['0049 (0) 6301 1234', 'tel:004963011234'],
['(06301) 1234', 'tel:063011234'],
['06301/1234-0', 'tel:0630112340'],
[' +49 160 1234567 ', 'tel:+491601234567'],
['06301 1234 (ab 9 Uhr)', ''],
['Tel. 06301 1234', ''],
['06301 1234 oder 0171 555', ''],
['0171 1, 0171 2', ''],
['ruf an', ''],
['', ''], [' ', ''], ['12', ''], ['+', ''], [undefined, ''], [null, ''], [1234567, ''], [{}, ''],
]
for (const [eingabe, soll] of faelle) assert.equal(telefonLink(eingabe), soll, JSON.stringify(eingabe))
})
// ---------------------------------------------------------------- reine Funktionen
/** Die reinen Funktionen des Skripts (zwischen den Marken @rein-anfang und @rein-ende) einzeln ausfuehren. */
function rein() {
const m = /\/\/ @rein-anfang[^\n]*\n([\s\S]*?)\/\/ @rein-ende/.exec(QUELLE)
assert.ok(m, 'Marken @rein-anfang/@rein-ende fehlen')
return vm.runInNewContext(
`${m[1]}; ({ waehleWechsel, naechsterWechsel, waehleHinweis, jetztMitServer, tabelleDaten, wochentagVon, zeitenText, gruppiereAbweichungen, abweichungsZeile })`,
{},
)
}
const MIN = 60 * 1000
const ENDLOS = 8.64e15
test('Marken: die reinen Funktionen stehen zwischen @rein-anfang und @rein-ende und greifen auf nichts ausserhalb zu', () => {
const f = rein()
for (const name of ['waehleWechsel', 'naechsterWechsel', 'waehleHinweis', 'jetztMitServer', 'tabelleDaten']) {
assert.equal(typeof f[name], 'function', name)
}
})
test('waehleWechsel nimmt den letzten Eintrag mit ab ≤ jetzt', () => {
const { waehleWechsel } = rein()
const liste = [
{ ab: 1000, offen: true, text: 'A', kurz: 'a', tag: '2026-10-05' },
{ ab: 2000, offen: false, text: 'B', kurz: 'b', tag: '2026-10-05' },
{ ab: 3000, offen: null, text: 'C', kurz: 'c', tag: '2026-10-06' },
]
assert.equal(waehleWechsel(liste, 2500).text, 'B')
assert.equal(waehleWechsel(liste, 2000).text, 'B', 'ab == jetzt zaehlt schon')
assert.equal(waehleWechsel(liste, 1999).text, 'A')
assert.equal(waehleWechsel(liste, 1000).text, 'A')
assert.equal(waehleWechsel(liste, 999), null, 'vor dem ersten Eintrag: keiner')
assert.equal(waehleWechsel(liste, 9e12).text, 'C', 'nach dem letzten: der letzte')
assert.equal(waehleWechsel([], 5000), null)
assert.equal(waehleWechsel(undefined, 5000), null)
assert.equal(waehleWechsel(null, 5000), null)
// unsortierte Eingabe und unbrauchbare Eintraege stoeren nicht
assert.equal(waehleWechsel([liste[2], null, { ab: 'x' }, liste[0], liste[1]], 2500).text, 'B')
})
test('naechsterWechsel berücksichtigt Hinweis-Beginn und -Ende, nie Vergangenes, nie „ohne Ende“', () => {
const { naechsterWechsel } = rein()
const wechsel = [{ ab: 1000 }, { ab: 5000 }, { ab: 9000 }]
assert.equal(naechsterWechsel(wechsel, [], 1000), 5000, 'ab == jetzt ist schon da, gesucht ist der naechste')
assert.equal(naechsterWechsel(wechsel, [], 4999), 5000)
assert.equal(naechsterWechsel(wechsel, [], 9000), null, 'nichts mehr')
assert.equal(naechsterWechsel([], [], 10), null)
// Hinweis-Beginn
assert.equal(naechsterWechsel(wechsel, [{ ab: 3000, bis: ENDLOS }], 1000), 3000)
// Hinweis-Ende
assert.equal(naechsterWechsel(wechsel, [{ ab: 0, bis: 2500 }], 1000), 2500)
// Beginn und Ende, nimmt das fruehere
assert.equal(naechsterWechsel(wechsel, [{ ab: 4000, bis: 4500 }, { ab: 0, bis: 4800 }], 3999), 4000)
assert.equal(naechsterWechsel(wechsel, [{ ab: 4000, bis: 4500 }, { ab: 0, bis: 4800 }], 4000), 4500)
// unbegrenzt (ab 0, bis 8.64e15): kein Wechsel
assert.equal(naechsterWechsel([], [{ ab: 0, bis: ENDLOS }], 1000), null)
assert.equal(naechsterWechsel(wechsel, [{ ab: 0, bis: ENDLOS }], 9000), null)
// Muell
assert.equal(naechsterWechsel(wechsel, [null, { ab: 'x', bis: NaN }, {}], 4000), 5000)
assert.equal(naechsterWechsel(undefined, undefined, 4000), null)
})
test('waehleHinweis: höchster Rang, bei Gleichstand späterer Beginn; nur gültige (ab ≤ jetzt < bis)', () => {
const { waehleHinweis } = rein()
const info = { art: 'info', text: 'I', rang: 1, ab: 0, bis: ENDLOS }
const aktion = { art: 'aktion', text: 'A', rang: 2, ab: 1000, bis: 9000 }
const urlaub = { art: 'urlaub', text: 'U', rang: 3, ab: 500, bis: 4000 }
const wichtig = { art: 'wichtig', text: 'W', rang: 4, ab: 2000, bis: 3000 }
const liste = [info, aktion, urlaub, wichtig]
assert.equal(waehleHinweis(liste, 100).text, 'I', 'nur der Info-Hinweis gilt schon')
assert.equal(waehleHinweis(liste, 1500).text, 'U', 'Urlaub (3) vor Aktion (2) und Info (1)')
assert.equal(waehleHinweis(liste, 2500).text, 'W', 'Wichtig (4) schlaegt alles')
assert.equal(waehleHinweis(liste, 3000).text, 'U', 'bis ist exklusiv: ab 3000 gilt Wichtig nicht mehr')
assert.equal(waehleHinweis(liste, 4000).text, 'A', 'Urlaub endet bei 4000')
assert.equal(waehleHinweis(liste, 9000).text, 'I')
assert.equal(waehleHinweis([aktion], 999), null, 'noch nicht begonnen')
assert.equal(waehleHinweis([aktion], 9000), null, 'schon vorbei')
// Gleichstand beim Rang: der spaetere Beginn gewinnt, unabhaengig von der Reihenfolge
const frueh = { art: 'aktion', text: 'frueh', rang: 2, ab: 1000, bis: 9000 }
const spaet = { art: 'aktion', text: 'spaet', rang: 2, ab: 3000, bis: 9000 }
assert.equal(waehleHinweis([frueh, spaet], 5000).text, 'spaet')
assert.equal(waehleHinweis([spaet, frueh], 5000).text, 'spaet')
assert.equal(waehleHinweis([frueh, spaet], 2000).text, 'frueh', 'der spaetere hat noch nicht begonnen')
assert.equal(waehleHinweis([], 1), null)
assert.equal(waehleHinweis(undefined, 1), null)
assert.equal(waehleHinweis([null, { rang: 'x' }, {}], 1), null)
})
test('jetztMitServer: 4 min Abweichung ⇒ lokal, 6 min ⇒ Server', () => {
const { jetztMitServer } = rein()
const lokal = Date.parse('2026-10-05T10:00:00Z')
const kopf = (ms) => new Date(ms).toUTCString() // Date-Kopf wie ihn der Server sendet (sekundengenau)
assert.equal(jetztMitServer(kopf(lokal), lokal), lokal, 'identisch')
assert.equal(jetztMitServer(kopf(lokal + 4 * MIN), lokal), lokal, 'Server 4 min voraus: lokale Uhr bleibt')
assert.equal(jetztMitServer(kopf(lokal - 4 * MIN), lokal), lokal, 'Server 4 min zurueck: lokale Uhr bleibt')
assert.equal(jetztMitServer(kopf(lokal + 5 * MIN), lokal), lokal, 'genau 5 min: noch lokal')
assert.equal(jetztMitServer(kopf(lokal + 6 * MIN), lokal), lokal + 6 * MIN, 'Uhr geht 6 min nach: Serverzeit')
assert.equal(jetztMitServer(kopf(lokal - 6 * MIN), lokal), lokal - 6 * MIN, 'Uhr geht 6 min vor: Serverzeit')
assert.equal(jetztMitServer(kopf(lokal + 36 * 60 * MIN), lokal), lokal + 36 * 60 * MIN)
// fehlender oder kaputter Kopf: lokale Uhr
for (const kaputt of [null, undefined, '', 'gestern', 12345, {}]) assert.equal(jetztMitServer(kaputt, lokal), lokal, JSON.stringify(kaputt))
})
test('wochentagVon rechnet den ISO-Wochentag aus der Zeichenkette (1 = Montag) und stimmt mit dem Kalender ueberein', () => {
const { wochentagVon } = rein()
assert.equal(wochentagVon('2026-10-05'), 1, 'Montag')
assert.equal(wochentagVon('2026-10-11'), 7, 'Sonntag')
assert.equal(wochentagVon('1970-01-01'), 4, 'Donnerstag')
assert.equal(wochentagVon('2024-02-29'), 4, 'Schalttag')
// Gegenprobe ueber sieben Jahre, jeden Tag
const ende = Date.UTC(2031, 0, 1)
for (let ms = Date.UTC(2024, 0, 1); ms < ende; ms += 86400000) {
const d = new Date(ms)
const tag = d.toISOString().slice(0, 10)
const iso = d.getUTCDay() === 0 ? 7 : d.getUTCDay()
assert.equal(wochentagVon(tag), iso, tag)
}
})
test('zeitenText: Zeiten, rund um die Uhr, nach Vereinbarung, geschlossen – nie 24:00', () => {
const { zeitenText } = rein()
assert.equal(zeitenText('zeiten', [['09:00', '18:00']]), '09:00–18:00')
assert.equal(zeitenText('zeiten', [['09:00', '12:30'], ['13:30', '18:00']]), '09:00–12:30, 13:30–18:00')
assert.equal(zeitenText('zeiten', [['18:00', '24:00']]), '18:00–00:00', '24:00 wird als 00:00 gezeigt')
assert.equal(zeitenText('zeiten', [['22:00', '02:00']]), '22:00–02:00', 'ueber Mitternacht')
assert.equal(zeitenText('zeiten', [['00:00', '24:00']]), 'rund um die Uhr', 'ganztags = rund um die Uhr')
assert.equal(zeitenText('rund', []), 'rund um die Uhr')
assert.equal(zeitenText('vereinbarung', []), 'nach Vereinbarung')
assert.equal(zeitenText('zu', []), 'geschlossen')
assert.equal(zeitenText('zeiten', []), 'geschlossen', 'Zeiten ohne Fenster = geschlossen')
assert.equal(zeitenText('irgendwas', [['09:00', '10:00']]), 'geschlossen', 'unbekannter Modus = geschlossen')
assert.equal(zeitenText('zeiten', undefined), 'geschlossen')
for (const m of ['zeiten', 'rund', 'zu', 'vereinbarung']) assert.ok(!zeitenText(m, [['08:00', '24:00']]).includes('24:00'))
})
/** Plan wie ihn die oeffentliche API liefert: Montag bis Freitag 09:00–18:00, Samstag 09:00–13:00, Sonntag zu. */
function plan(tage = []) {
const woche = []
for (let tag = 1; tag <= 7; tag += 1) {
if (tag <= 5) woche.push({ tag, modus: 'zeiten', fenster: [['09:00', '18:00']] })
else if (tag === 6) woche.push({ tag, modus: 'zeiten', fenster: [['09:00', '13:00']] })
else woche.push({ tag, modus: 'zu', fenster: [] })
}
return { kurz: 'haupt', name: 'Öffnungszeiten', haupt: true, woche, tage, wechsel: [] }
}
/** n aufeinanderfolgende Tage ab `von`; `abweichend` ersetzt einzelne Tage (datum → Eintrag ohne datum). */
function tageListe(von, n, abweichend = {}, wochenplan = plan().woche) {
const liste = []
for (let i = 0; i < n; i += 1) {
const d = new Date(Date.parse(`${von}T12:00:00Z`) + i * 86400000)
const datum = d.toISOString().slice(0, 10)
const iso = d.getUTCDay() === 0 ? 7 : d.getUTCDay()
const regulaer = { art: 'regulaer', name: null, modus: wochenplan[iso - 1].modus, fenster: wochenplan[iso - 1].fenster }
liste.push({ datum, ...(abweichend[datum] ?? regulaer) })
}
return liste
}
test('tabelleDaten: heutiger Tag markiert, Abweichungen der nächsten 14 Tage', () => {
const { tabelleDaten } = rein()
const tage = tageListe('2026-10-05', 60, {
'2026-10-09': { art: 'sondertag', name: 'Inventur', modus: 'zu', fenster: [] },
'2026-10-12': { art: 'urlaub', name: 'Betriebsurlaub', modus: 'zu', fenster: [] },
'2026-10-13': { art: 'urlaub', name: 'Betriebsurlaub', modus: 'zu', fenster: [] },
'2026-10-17': { art: 'sondertag', name: 'Kurz', modus: 'zeiten', fenster: [['09:00', '11:00']] }, // heute + 12
'2026-10-18': { art: 'feiertag', name: 'Testfeiertag', modus: 'zu', fenster: [] }, // heute + 13: noch drin
'2026-10-19': { art: 'feiertag', name: 'Zu spaet', modus: 'zu', fenster: [] }, // heute + 14: schon draussen
'2026-11-01': { art: 'feiertag', name: 'Allerheiligen', modus: 'zu', fenster: [] },
})
const t = norm(tabelleDaten(plan(tage), '2026-10-05'))
assert.equal(t.woche.length, 7)
assert.deepEqual(t.woche.map((z) => z.name), ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'])
assert.deepEqual(t.woche.map((z) => z.tag), [1, 2, 3, 4, 5, 6, 7])
assert.deepEqual(t.woche.map((z) => z.text), ['09:00–18:00', '09:00–18:00', '09:00–18:00', '09:00–18:00', '09:00–18:00', '09:00–13:00', 'geschlossen'])
assert.deepEqual(t.woche.map((z) => z.heute), [true, false, false, false, false, false, false], '2026-10-05 ist ein Montag')
assert.deepEqual(t.abweichungen14.map((a) => a.datum), ['2026-10-09', '2026-10-12', '2026-10-13', '2026-10-17', '2026-10-18'])
assert.deepEqual(t.abweichungen14[0], { datum: '2026-10-09', art: 'sondertag', name: 'Inventur', modus: 'zu', fenster: [], text: 'geschlossen' })
assert.equal(t.abweichungen14[3].text, '09:00–11:00')
assert.equal(t.abweichungen14[4].name, 'Testfeiertag')
// ein anderer „heute“: Sonntag, 2026-10-11 → Fenster 11.10. bis 24.10.
const sonntag = norm(tabelleDaten(plan(tage), '2026-10-11'))
assert.deepEqual(sonntag.woche.map((z) => z.heute), [false, false, false, false, false, false, true])
assert.deepEqual(sonntag.abweichungen14.map((a) => a.datum), ['2026-10-12', '2026-10-13', '2026-10-17', '2026-10-18', '2026-10-19'], 'Vergangenes (09.10.) raus, 19.10. rein, 01.11. noch nicht')
})
test('tabelleDaten: Sonderfaelle (rund, vereinbarung, 24:00, regulaere Tage ohne Abweichung, kaputte Eingaben)', () => {
const { tabelleDaten } = rein()
const woche = plan().woche
woche[0] = { tag: 1, modus: 'rund', fenster: [] }
woche[1] = { tag: 2, modus: 'vereinbarung', fenster: [] }
woche[2] = { tag: 3, modus: 'zeiten', fenster: [['18:00', '24:00']] }
woche[3] = { tag: 4, modus: 'zeiten', fenster: [['09:00', '12:00'], ['14:00', '18:00']] }
woche[4] = { tag: 5, modus: 'zu', fenster: [] }
const p = { ...plan(tageListe('2026-10-05', 20, {}, woche)), woche: woche.filter((z) => z.tag !== 5) } // Freitag fehlt in den Daten: gilt als geschlossen
const t = norm(tabelleDaten(p, '2026-10-06'))
assert.deepEqual(t.woche.map((z) => z.text), ['rund um die Uhr', 'nach Vereinbarung', '18:00–00:00', '09:00–12:00, 14:00–18:00', 'geschlossen', '09:00–13:00', 'geschlossen'])
assert.ok(t.woche.every((z) => !z.text.includes('24:00')))
assert.deepEqual(t.woche.map((z) => z.heute), [false, true, false, false, false, false, false], 'Dienstag')
assert.deepEqual(t.abweichungen14, [], 'regulaere Tage ohne Abweichung werden nicht aufgelistet')
// ein „regulaerer“ Tag, dessen Zeiten vom Wochenplan abweichen, zaehlt trotzdem als Abweichung
const q = plan(tageListe('2026-10-05', 20, { '2026-10-08': { art: 'regulaer', name: null, modus: 'zeiten', fenster: [['10:00', '15:00']] } }))
assert.deepEqual(norm(tabelleDaten(q, '2026-10-05')).abweichungen14.map((a) => a.datum), ['2026-10-08'])
// Feiertag mit den ueblichen Zeiten ist trotzdem eine Zeile (die Art ist die Abweichung)
const r = plan(tageListe('2026-10-05', 20, { '2026-10-07': { art: 'feiertag', name: 'Fest', modus: 'zeiten', fenster: [['09:00', '18:00']] } }))
assert.deepEqual(norm(tabelleDaten(r, '2026-10-05')).abweichungen14.map((a) => a.datum), ['2026-10-07'])
// kaputte Eingaben: sieben Zeilen „geschlossen“ ohne Markierung, keine Abweichungen, kein Fehler
for (const kaputt of [undefined, null, {}, { woche: 'x', tage: 5 }]) {
const k = norm(tabelleDaten(kaputt, '2026-10-05'))
assert.equal(k.woche.length, 7)
assert.ok(k.woche.every((z) => z.text === 'geschlossen'))
assert.deepEqual(k.abweichungen14, [])
}
// ohne verwertbaren heutigen Tag: keine Markierung und keine Abweichungen
for (const tag of [undefined, null, '', 'heute', '5.10.2026']) {
const k = norm(tabelleDaten(p, tag))
assert.ok(k.woche.every((z) => z.heute === false), String(tag))
assert.deepEqual(k.abweichungen14, [])
}
})
test('Abweichungen: aufeinanderfolgende gleiche Tage in einer Zeile, Beschriftung mit Wochentag und Datum', () => {
const { tabelleDaten, gruppiereAbweichungen, abweichungsZeile } = rein()
const urlaub = { art: 'urlaub', name: 'Betriebsurlaub', modus: 'zu', fenster: [] }
const tage = tageListe('2026-10-05', 20, {
'2026-10-06': urlaub, '2026-10-07': urlaub, '2026-10-08': urlaub,
'2026-10-10': { art: 'sondertag', name: 'Hoffest', modus: 'zeiten', fenster: [['10:00', '16:00']] },
'2026-10-12': { art: 'feiertag', name: 'Feiertag A', modus: 'zu', fenster: [] },
'2026-10-13': { art: 'feiertag', name: 'Feiertag B', modus: 'zu', fenster: [] }, // anderer Name: eigene Zeile
'2026-10-15': { art: 'urlaub', name: null, modus: 'zu', fenster: [] },
})
const gruppen = norm(gruppiereAbweichungen(tabelleDaten(plan(tage), '2026-10-05').abweichungen14))
assert.deepEqual(gruppen.map((g) => [g.von, g.bis]), [
['2026-10-06', '2026-10-08'], ['2026-10-10', '2026-10-10'], ['2026-10-12', '2026-10-12'], ['2026-10-13', '2026-10-13'], ['2026-10-15', '2026-10-15'],
])
assert.deepEqual(norm(abweichungsZeile(gruppen[0])), { datum: 'Dienstag, 06.10. – Donnerstag, 08.10.', text: 'Betriebsurlaub: geschlossen' })
assert.deepEqual(norm(abweichungsZeile(gruppen[1])), { datum: 'Samstag, 10.10.', text: 'Hoffest: 10:00–16:00' })
assert.deepEqual(norm(abweichungsZeile(gruppen[4])), { datum: 'Donnerstag, 15.10.', text: 'Betriebsurlaub: geschlossen' }, 'ohne Namen die Art als Beschriftung')
assert.deepEqual(norm(abweichungsZeile({ von: '2026-11-01', bis: '2026-11-01', art: 'feiertag', name: null, text: 'geschlossen' })), { datum: 'Sonntag, 01.11.', text: 'Feiertag: geschlossen' })
assert.deepEqual(norm(abweichungsZeile({ von: '2026-11-02', bis: '2026-11-02', art: 'sondertag', name: null, text: 'geschlossen' })).text, 'Sondertag: geschlossen')
assert.deepEqual(norm(gruppiereAbweichungen([])), [])
assert.deepEqual(norm(gruppiereAbweichungen(undefined)), [])
})
// ---------------------------------------------------------------- Demo-Seite
test('Demo-Seite: noindex, Skript per defer mit data-site, alle drei Platzhalter-Modi mit Ersatz-HTML', () => {
const demo = lies('public/embed/zeiten-demo.html')
assert.match(demo, /<meta name="robots" content="noindex"/)
assert.match(demo, /<script src="\/embed\/zeiten\.js" data-site="[a-z2-7]{16}" defer><\/script>/)
for (const modus of ['tabelle', 'status', 'badge']) {
assert.match(demo, new RegExp(`<[a-z]+ data-webklar-zeiten data-modus="${modus}"`), `Platzhalter ${modus} fehlt`)
}
assert.match(demo, /data-plan="/, 'ein Platzhalter mit data-plan')
assert.match(demo, /\d\d:\d\d\s*(?:&#8211;|–)\s*\d\d:\d\d/, 'Ersatz-HTML mit Zeiten fehlt')
assert.match(demo, /<table>[\s\S]*<caption>[\s\S]*Montag[\s\S]*Sonntag/, 'Ersatz-Tabelle fehlt')
})