Files
ClaudeandClaude Opus 5.5 d4c1be91c7 Öffnungszeiten: wechsel der öffentlichen Antwort trägt wieder den Berliner Tag
Das Website-Skript rechnet keine Zeitzonen und braucht tag (Berliner Kalendertag
bei ab) für „heute“ und die nächsten 14 Tage; wechsel-Einträge sind wieder
{ ab, tag, offen, text, kurz }.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:15:42 +00:00

161 lines
7.7 KiB
JavaScript

import { heute } from '../termine/zeit.js'
import { customerOf, findActiveBySiteKey, projectName } from '../siteFeatures.js'
import { zeitenCache } from './cache.js'
import { zeitenAus } from './schalter.js'
import { ladeZeiten } from './speicher.js'
import { gueltigeHinweise, hauptplan, jsonLdFuer, modusUndFenster, tageListe, wechselListe } from './zeitlogik.js'
/**
* Oeffnungszeiten live: die oeffentliche Antwort fuer das Website-Skript (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md,
* Abschnitt 4.1). Die Route (Task 12) reicht den siteKey der URL durch, setzt CORS, ETag und Limits und macht aus einem Fehler
* dieses Moduls ein 503; hier steht nur, was in der Antwort steht und wann sie gebaut wird.
*
* Die Antwort enthaelt nie interne IDs (kein $id, customerId, projectId, featureId, keine Plan-ID, nur den Kurznamen) und keine
* Kundendaten ausser dem Firmennamen und den veroeffentlichten Kontaktangaben.
*/
const SITE_KEY_RE = /^[a-z2-7]{16}$/
const TAGE_VORSCHAU = 60 // Spec 4.1: tage je Plan (heute bis heute + 59)
const ROUTE_BASIS = 'https://www.google.com/maps/dir/?api=1&destination='
const WHATSAPP_BASIS = 'https://wa.me/'
const TELEFON_RE = /^[1-9][0-9]{5,14}$/ // Ziffern mit Laendervorwahl, 6 bis 15 (E.164): so speichert pruefen.js
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
const nichtLeererText = (wert) => (typeof wert === 'string' && wert.trim() !== '' ? wert.trim() : null)
function pruefeJetzt(now) {
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError('now ist kein gueltiges Datum')
return now.getTime()
}
/** Beliebiger Text fuer eine URL; ein einzelnes Surrogat aus von Hand veraenderten Daten wuerde encodeURIComponent sonst werfen. */
const kodiere = (wert) => encodeURIComponent(String(wert).toWellFormed())
/** Nur Ziffern (nie ein Text, der in tel: oder wa.me landen darf, ohne dass er so gespeichert wurde), sonst null. */
const ziffern = (wert) => (typeof wert === 'string' && TELEFON_RE.test(wert) ? wert : null)
/**
* Kontakt-Leiste: null, wenn sie aus ist oder es nichts anzuzeigen gibt; sonst { telefon, routeUrl, whatsappUrl } mit null fuer
* Fehlendes. WhatsApp nur mit bestaetigtAt (der Kunde hat den Datenschutz-Haken gesetzt); der fremde Dienst oeffnet erst beim Klick.
*/
function kontaktAntwort(kontakt) {
if (!istObjekt(kontakt) || kontakt.an !== true) return null
const telefon = ziffern(kontakt.telefon)
const adresse = nichtLeererText(kontakt.adresse)
const wa = istObjekt(kontakt.whatsapp) ? kontakt.whatsapp : null
const nummer = wa && nichtLeererText(wa.bestaetigtAt) ? ziffern(wa.nummer) : null
const routeUrl = adresse ? ROUTE_BASIS + kodiere(adresse) : null
const whatsappUrl = nummer ? `${WHATSAPP_BASIS}${nummer}?text=${kodiere(typeof wa.starttext === 'string' ? wa.starttext : '')}` : null
if (!telefon && !routeUrl && !whatsappUrl) return null
return { telefon, routeUrl, whatsappUrl }
}
/** Team-Einstellungen mit den Vorgaben (null, false, false); nur diese drei Schluessel verlassen den Server. */
function teamAntwort(team) {
const t = istObjekt(team) ? team : {}
return {
navSelektor: nichtLeererText(t.navSelektor),
kontaktleisteAus: t.kontaktleisteAus === true,
hinweisleisteAus: t.hinweisleisteAus === true,
}
}
/** Ein Plan der Antwort; interne Plan-ID bleibt draussen, der Kurzname ist der oeffentliche Schluessel. */
function planAntwort(daten, plan, now, heuteTag) {
const woche = []
for (let tag = 1; tag <= 7; tag += 1) woche.push({ tag, ...modusUndFenster(plan.tage?.[tag]) })
return {
kurz: plan.kurz,
name: typeof plan.name === 'string' ? plan.name : '',
haupt: plan.haupt === true,
woche,
tage: tageListe(daten, plan, heuteTag, TAGE_VORSCHAU),
// tag = Berliner Kalendertag bei ab: das Skript rechnet keine Zeitzonen und braucht ihn fuer „heute“ und die naechsten 14 Tage
wechsel: wechselListe(daten, plan, now).map(({ ab, tag, offen, text, kurz }) => ({ ab, tag, offen, text, kurz })),
}
}
/**
* Baut die Antwort (rein: kein Appwrite, keine Uhr ausser now) aus den gespeicherten Daten.
* @param {{ daten: object|null, team: object, firma: { name: string }, now: Date }} p
* daten null (oder unbrauchbar) = noch nichts gespeichert: plaene leer, kein JSON-LD (jsonld null, damit der Browser vorhandenes
* JSON-LD nicht mit leeren Zeiten ueberschreibt). firma.name kommt vom Aufrufer, firma.adresse ist kontakt.adresse.
* @returns {object} { aktiv: true, stand, plaene, hinweise, kontakt, jsonld, firma, team }
*/
export function baueOeffentlicheAntwort({ daten, team, firma, now } = {}) {
pruefeJetzt(now)
const d = istObjekt(daten) ? daten : null
const heuteTag = heute(now)
const haupt = d ? hauptplan(d) : null
const plaene = Array.isArray(d?.plaene) ? d.plaene.filter((p) => istObjekt(p) && nichtLeererText(p.kurz)) : []
return {
aktiv: true,
stand: now.toISOString(),
plaene: plaene.map((plan) => planAntwort(d, plan, now, heuteTag)),
hinweise: d ? gueltigeHinweise(d, now) : [],
kontakt: kontaktAntwort(d?.kontakt),
jsonld: haupt ? jsonLdFuer(d, haupt, now) : null,
firma: { name: nichtLeererText(firma?.name) ?? '', adresse: nichtLeererText(d?.kontakt?.adresse) },
team: teamAntwort(team),
}
}
const AUS = Object.freeze({ aktiv: false })
/** Friert die Antwort samt allem Darunterliegenden ein: was im Speicher liegt, darf kein Aufrufer mehr veraendern. */
function friere(wert) {
if (wert !== null && typeof wert === 'object' && !Object.isFrozen(wert)) {
Object.freeze(wert)
for (const teil of Object.values(wert)) friere(teil)
}
return wert
}
/** Firmenname des Kunden; fehlen Firmen- und Kundenname, der Projektname, sonst ''. */
async function firmenName(kunde, projectId) {
const name = nichtLeererText(kunde.companyName) ?? nichtLeererText(kunde.name)
if (name) return name
return typeof projectId === 'string' && projectId !== '' ? (nichtLeererText(await projectName(projectId)) ?? '') : ''
}
async function ladeAntwort(siteKey, now) {
// 'setup' antwortet schon: das Team prueft die Leisten, bevor das Ticket geschlossen und die Funktion 'active' wird (Spec 4.4)
const feature = await findActiveBySiteKey(siteKey, 'zeiten', { states: ['setup', 'active'] })
if (!feature) return AUS
const kunde = typeof feature.customerId === 'string' && feature.customerId !== '' ? await customerOf(feature.customerId) : null
if (!kunde) return AUS
const gespeichert = await ladeZeiten(feature.$id)
return baueOeffentlicheAntwort({
daten: gespeichert?.daten ?? null,
team: gespeichert?.team,
firma: { name: await firmenName(kunde, feature.projectId) },
now,
})
}
/**
* Die oeffentliche Antwort zu einem siteKey (nie null, nie ein Fehler fuer Unbekanntes):
* Schalter aus oder siteKey nicht im Format [a-z2-7]{16} => { aktiv: false } ohne Appwrite-Abfrage und ohne Eintrag im Speicher;
* sonst aus dem Speicher (30 s, auch fuer { aktiv: false } bei unbekanntem Schluessel, falschem Zustand oder geloeschtem Kunden);
* sonst Funktion (setup/active) und Kunde laden, Zeiten laden, bauen, merken.
* Appwrite-Fehler gehen unveraendert an den Aufrufer (die Route macht daraus 503) und werden nicht gemerkt.
* Die Antwort ist eingefroren (sie wird geteilt).
* @param {string} siteKey Schluessel aus der URL
* @param {Date} [now]
*/
export async function oeffentlicheZeitenAntwort(siteKey, now = new Date()) {
if (zeitenAus()) return AUS
if (typeof siteKey !== 'string' || !SITE_KEY_RE.test(siteKey)) return AUS
const jetztMs = pruefeJetzt(now)
const gemerkt = zeitenCache.hole(siteKey, jetztMs)
if (gemerkt !== undefined) return gemerkt
const antwort = friere(await ladeAntwort(siteKey, now))
zeitenCache.setze(siteKey, antwort, jetztMs)
return antwort
}