diff --git a/public/embed/zeiten-demo.html b/public/embed/zeiten-demo.html new file mode 100644 index 0000000..e02e371 --- /dev/null +++ b/public/embed/zeiten-demo.html @@ -0,0 +1,57 @@ + + + + + + +Öffnungszeiten Testseite + + + +
Testseite der Kundenwebsite (intern, nicht verlinkt)
+
+

Öffnungszeiten

+

Der siteKey unten ist ein Platzhalter: für echte Daten data-site durch den siteKey des Projekts ersetzen (und bei Bedarf data-api am Script-Tag ergänzen). Ohne Daten (oder bei einem Fehler) bleibt überall das Ersatz-HTML stehen.

+ +

Tabelle (Hauptplan)

+
+
+ + + + + + + + + +
Öffnungszeiten
Montag09:00 – 18:00
Dienstag09:00 – 18:00
Mittwoch09:00 – 18:00
Donnerstag09:00 – 18:00
Freitag09:00 – 18:00
Samstag09:00 – 13:00
Sonntaggeschlossen
+
+
+ +

Status (Hauptplan)

+
+

Mo–Fr 09:00 – 18:00 Uhr, Sa 09:00 – 13:00 Uhr.

+
+ +

Badge (Plan „haupt“, im Fließtext)

+

Wir haben gerade Mo–Fr 09:00 – 18:00 Uhr für Sie da.

+ +

Unbekannter Plan (Ersatz-HTML bleibt, Warnung in der Konsole)

+
+

Dieser Platzhalter nennt einen Plan, den es nicht gibt.

+
+
+ + + + diff --git a/public/embed/zeiten.js b/public/embed/zeiten.js new file mode 100644 index 0000000..54c8d93 --- /dev/null +++ b/public/embed/zeiten.js @@ -0,0 +1,524 @@ +/*! WEBklar Oeffnungszeiten live: Einbau-Skript fuer Kundenwebsites. + * + * 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): + *
Ersatz-HTML
+ * tabelle Wochentabelle ( 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() +})() diff --git a/test/zeitenEmbedStatic.test.js b/test/zeitenEmbedStatic.test.js new file mode 100644 index 0000000..28a6c5d --- /dev/null +++ b/test/zeitenEmbedStatic.test.js @@ -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