Files
Webklar-Kundenbereich/public/zeiten-view.js
T
ClaudeandClaude Opus 5.5 b3e96d4a56 Öffnungszeiten: Editor für Hinweis- und Kontakt-Leiste, Google und Vorschau
Hinweis-Leiste (Liste, „Hinweis-Leiste anlegen“, Art, Knopf, Zeitraum, Preis-Hinweis),
Kontakt-Leiste (Telefon, Adresse, WhatsApp mit Datenschutz-Haken, R12 in der Kundenansicht),
Google (Für Google kopieren, Profil-Link), Live-Vorschau mit Regler „Uhrzeit testen“
und UI-Prüfung test/ui/zeiten-portal.ui.mjs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:43:22 +00:00

266 lines
14 KiB
JavaScript

/* Oeffnungszeiten live im Kundenbereich: reine Helfer fuer den Editor ohne DOM (ES-Modul; die Oberflaeche in den spaeteren
zeiten*.js-Dateien nutzt sie, test/zeitenView.test.js prueft sie). Alles hier ist das Gegenstueck zu den Regeln des Servers
(server/services/zeiten/pruefen.js): gleiche Grenzen, gleiche Kurznamen, gleiche Fensterregeln, gleiche Texte. Der Server
bleibt die Instanz, die entscheidet; der Editor zeigt Fehler nur frueher an. test/zeitenView.test.js haelt jede Regel gegen den
Server (zufaellige Eingaben, gleiche Ergebnisse).
Tage sind Berliner Kalendertage 'JJJJ-MM-TT', Uhrzeiten 'HH:MM'. IDs von Plaenen, Ausnahmen und Hinweisen kommen vom Server und
koennen jede Zeichenfolge aus [A-Za-z0-9_-] sein ('__proto__', 'constructor'): hier wird nie ein Objekt nach einer ID befragt,
nur eine Liste durchsucht. */
import { datumKurz, istTag, tagPlus } from './termine-view.js'
/* ------------------------------------------------------------- Grenzen --- */
/** Grenzen der Spezifikation (Server: ZEITEN_LIMITS, test/zeitenView.test.js vergleicht beide). */
export const ZEITEN_LIMITS = Object.freeze({ plaene: 5, ausnahmen: 100, hinweise: 20, urlaubTage: 366 })
/** Laengen der Felder (maxlength der Eingaben). */
export const ZEITEN_MAX = Object.freeze({ hinweisText: 140, name: 60, ausnahmeText: 60, knopfText: 30, starttext: 200, adresse: 200, telefon: 30, link: 300, kurz: 24 })
/** Zeitfenster je Tag (Server: ZEITEN_LIMITS.fensterJeTag). */
export const FENSTER_JE_TAG = 2
/** Betriebsart eines Tages, einer Ausnahme oder eines Feiertags. */
export const MODI = Object.freeze(['zu', 'zeiten', 'rund', 'vereinbarung'])
export const HINWEIS_ARTEN = Object.freeze(['info', 'urlaub', 'aktion', 'wichtig'])
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
const fehlt = (wert) => wert === undefined || wert === null
/* ------------------------------------------------------------------ IDs --- */
const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
const ID_PRAEFIXE = ['p', 'x', 'h']
const zufallsBytes = (n) => crypto.getRandomValues(new Uint8Array(n))
/**
* Neue ID eines Plans ('p'), einer Ausnahme ('x') oder eines Hinweises ('h'): Praefix_ + 8 Zeichen a-z0-9, dieselbe Form wie auf dem
* Server (der behaelt gueltige IDs; so koennen neue Ausnahmen schon vor dem Speichern auf einen neuen Plan verweisen).
* bytes(n) liefert n Zufallsbytes (Vorgabe crypto.getRandomValues; nur fuer Tests austauschbar). Bytes ab 252 werden verworfen
* (252 = 7 * 36): so ist jedes Zeichen gleich wahrscheinlich.
*/
export function neueZeitenId(praefix, bytes = zufallsBytes) {
if (!ID_PRAEFIXE.includes(praefix)) throw new Error(`Unbekanntes ID-Präfix: ${String(praefix)}`)
let zeichen = ''
while (zeichen.length < 8) {
for (const byte of bytes(16)) {
if (byte < 252 && zeichen.length < 8) zeichen += ID_ALPHABET[byte % ID_ALPHABET.length]
}
}
return `${praefix}_${zeichen}`
}
/* ------------------------------------------------------------ Kurznamen --- */
/**
* Kurzname [a-z0-9-]{1,24} aus dem Namen, genau wie auf dem Server: Umlaute ae/oe/ue/ss, Akzente weg, alles Uebrige ein Bindestrich;
* leer = 'plan'; ist er in vorhandene (Liste oder Set) belegt, kommt -2, -3, ... davor (immer <= 24 Zeichen).
* Der Server vergibt den Kurznamen beim ersten Speichern endgueltig und beruecksichtigt dabei auch Namen frueher geloeschter
* Plaene; zeigt der Editor einen Kurznamen vorab, gilt nach dem Speichern der Wert aus der Antwort.
*/
export function kurzname(name, vorhandene = []) {
const belegt = vorhandene instanceof Set ? vorhandene : new Set(vorhandene)
let basis = (typeof name === 'string' ? name : '')
.toLowerCase()
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
.normalize('NFKD').replace(/\p{M}+/gu, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
basis = basis.slice(0, ZEITEN_MAX.kurz).replace(/-+$/, '')
if (basis === '') basis = 'plan'
if (!belegt.has(basis)) return basis
for (let n = 2; ; n += 1) {
const endung = `-${n}`
const kandidat = `${basis.slice(0, ZEITEN_MAX.kurz - endung.length).replace(/-+$/, '')}${endung}`
if (!belegt.has(kandidat)) return kandidat
}
}
/* -------------------------------------------------------------- Fenster --- */
const FENSTER_FEHLER = {
form: 'Ungültige Zeitfenster.',
zuviele: `Pro Tag sind höchstens ${FENSTER_JE_TAG} Zeitfenster möglich.`,
uhrzeit: 'Uhrzeiten bitte als HH:MM in 5-Minuten-Schritten angeben.',
gleich: 'Beginn und Ende dürfen nicht gleich sein.',
nacht: 'Ein Fenster über Mitternacht ist nur als letztes Fenster des Tages möglich.',
ueberschnitt: 'Die Zeitfenster überschneiden sich.',
folgetag: 'Das letzte Fenster reicht bis in das erste Fenster des nächsten Tages hinein.',
leer: 'Bitte trage mindestens ein Zeitfenster ein oder wähle „geschlossen“.',
modus: 'Bitte wähle „geschlossen“, „geöffnet“, „rund um die Uhr“ oder „nach Vereinbarung“.',
eintrag: 'Ungültiger Eintrag.',
}
/** 'HH:MM' -> Minuten (5er-Raster; als Ende bis 24:00), sonst null. */
function minuten(wert, istEnde) {
const m = typeof wert === 'string' ? /^(\d{2}):(\d{2})$/.exec(wert) : null
if (!m) return null
const h = Number(m[1])
const min = Number(m[2])
if (h > 24 || min > 59 || min % 5 !== 0 || (h === 24 && (min !== 0 || !istEnde))) return null
return h * 60 + min
}
/**
* Fensterregeln eines Tages ohne Blick auf den Folgetag: { fehler } oder { ersteVon (Minuten|null), nachtBis (Minuten|null) }.
* nachtBis = Ende des letzten Fensters, wenn es ueber Mitternacht geht. Reihenfolge der Pruefungen wie auf dem Server (so erscheint
* bei mehreren Fehlern derselbe Text).
*/
function fensterRegeln(fenster) {
if (!Array.isArray(fenster)) return { fehler: FENSTER_FEHLER.form }
if (fenster.length > FENSTER_JE_TAG) return { fehler: FENSTER_FEHLER.zuviele }
const f = []
for (const eintrag of fenster) {
const von = Array.isArray(eintrag) && eintrag.length === 2 ? minuten(eintrag[0], false) : null
const bis = von === null ? null : minuten(eintrag[1], true)
if (von === null || bis === null) return { fehler: FENSTER_FEHLER.uhrzeit }
if (bis === von) return { fehler: FENSTER_FEHLER.gleich }
f.push({ von, bis })
}
f.sort((a, b) => a.von - b.von)
for (let i = 0; i < f.length; i += 1) {
if (f[i].bis < f[i].von && i < f.length - 1) return { fehler: FENSTER_FEHLER.nacht }
if (i > 0 && f[i].von < f[i - 1].bis) return { fehler: FENSTER_FEHLER.ueberschnitt }
}
const letztes = f[f.length - 1]
return { ersteVon: f.length ? f[0].von : null, nachtBis: letztes && letztes.bis < letztes.von ? letztes.bis : null }
}
/** Ragt ein Fenster bis nach Mitternacht (nachtBis) in das erste Fenster des Folgetags (ersteVon) hinein? Aneinandergrenzen ist erlaubt. */
const ragtHinein = (nachtBis, ersteVonFolgetag) => nachtBis !== null && ersteVonFolgetag !== null && nachtBis > ersteVonFolgetag
/**
* Pruefung der Zeitfenster eines Tages: Meldung (Du-Form, Text wie auf dem Server) oder null. fenster = [['09:00','12:30'], ...].
* Regeln: hoechstens 2 Fenster, HH:MM im 5-Minuten-Raster (Ende bis 24:00), Ende ungleich Beginn, keine Ueberschneidung (Beruehren
* ist erlaubt), Ende vor Beginn (ueber Mitternacht) nur beim letzten Fenster und nur, wenn es nicht in das erste Fenster des Folgetags
* hineinragt. folgetagErstes = Beginn des ersten Fensters am Folgetag ('HH:MM') oder null (Folgetag zu, rund um die Uhr, nach
* Vereinbarung oder selbst ungueltig). Eine leere Liste verletzt keine Fensterregel; dass ein „geoeffneter“ Tag eines braucht, prueft tagFehler.
*/
export function fensterPruefen(fenster, { folgetagErstes = null } = {}) {
const r = fensterRegeln(fenster)
if (r.fehler) return r.fehler
return ragtHinein(r.nachtBis, minuten(folgetagErstes, false)) ? FENSTER_FEHLER.folgetag : null
}
/** Regeln eines Tages mit Betriebsart: { fehler } oder { ersteVon, nachtBis } (ohne Fenster beide null). Fehlt der Tag, ist er geschlossen. */
function tagRegeln(tag) {
if (fehlt(tag)) return { ersteVon: null, nachtBis: null }
if (!istObjekt(tag)) return { fehler: FENSTER_FEHLER.eintrag }
if (!MODI.includes(tag.modus)) return { fehler: FENSTER_FEHLER.modus }
if (tag.modus !== 'zeiten') return { ersteVon: null, nachtBis: null }
const r = fensterRegeln(tag.fenster)
if (r.fehler) return r
if (r.ersteVon === null) return { fehler: FENSTER_FEHLER.leer }
return r
}
/**
* Wie fensterPruefen, aber fuer einen ganzen Tag { modus, fenster } (Wochentag, Sondertag, Feiertag): der Modus muss einer aus MODI
* sein, „geoeffnet“ braucht mindestens ein Fenster, bei jedem anderen Modus zaehlen Fenster nicht. => Meldung oder null.
*/
export function tagFehler(tag, { folgetagErstes = null } = {}) {
const r = tagRegeln(tag)
if (r.fehler) return r.fehler
return ragtHinein(r.nachtBis, minuten(folgetagErstes, false)) ? FENSTER_FEHLER.folgetag : null
}
/**
* Fehler eines Wochenplans je Wochentag: { '3': 'Text' } (leer = alles in Ordnung). Jeder Tag nach tagFehler; dazu prueft jeder
* Tag sein Nachtfenster gegen das erste Fenster des Folgetags (Sonntag gegen Montag), sofern der Folgetag selbst in Ordnung ist
* (sonst steht dort dessen eigener Fehler). plan = { tage: { 1: { modus, fenster }, ..., 7 } }; fehlende Tage sind geschlossen.
*/
export function wochenFehler(plan) {
const tage = plan?.tage
if (!istObjekt(tage)) return {}
const regeln = {}
for (let d = 1; d <= 7; d += 1) regeln[d] = tagRegeln(tage[d])
const fehler = {}
for (let d = 1; d <= 7; d += 1) {
const r = regeln[d]
const folgetag = regeln[(d % 7) + 1]
if (r.fehler) fehler[d] = r.fehler
else if (!folgetag.fehler && ragtHinein(r.nachtBis, folgetag.ersteVon)) fehler[d] = FENSTER_FEHLER.folgetag
}
return fehler
}
/* --------------------------------------------------------------- Plaene --- */
const NIE_LOESCHBAR = 'Der letzte Zeitplan kann nicht gelöscht werden.'
const HAUPTPLAN_LOESCHEN = 'Erst einen anderen Plan zum Hauptplan machen.'
const PLAN_UNBEKANNT = 'Diesen Zeitplan gibt es nicht.'
/** Neuer Plan mit neuer ID und sieben geschlossenen Tagen (je Tag ein eigenes Objekt). kurz kommt aus kurzname(name, vorhandene). */
export function leererPlan({ name, kurz, haupt = false } = {}) {
const tage = {}
for (let d = 1; d <= 7; d += 1) tage[d] = { modus: 'zu', fenster: [] }
return { id: neueZeitenId('p'), kurz, name, haupt: haupt === true, tage }
}
/** Neue Daten, in denen genau der Plan mit dieser ID der Hauptplan ist; die Eingabe bleibt unveraendert. Unbekannte ID: Daten unveraendert. */
export function hauptplanWechseln(daten, planId) {
const plaene = Array.isArray(daten?.plaene) ? daten.plaene : []
if (!plaene.some((p) => p?.id === planId)) return daten
return { ...daten, plaene: plaene.map((p) => ({ ...p, haupt: p.id === planId })) }
}
/**
* Warum sich der Plan nicht loeschen laesst (Text in Du-Form), sonst null. Der letzte Plan ist immer auch der Hauptplan, hat aber den
* deutlicheren Grund (einen anderen Hauptplan gaebe es gar nicht); danach der Hauptplan: erst einen anderen dazu machen.
*/
export function planLoeschenGrund(daten, planId) {
const plaene = Array.isArray(daten?.plaene) ? daten.plaene : []
const plan = plaene.find((p) => p?.id === planId)
if (!plan) return PLAN_UNBEKANNT
if (plaene.length <= 1) return NIE_LOESCHBAR
if (plan.haupt === true) return HAUPTPLAN_LOESCHEN
return null
}
/* ---------------------------------------------------------- Wochentage --- */
/** Wochentag eines Kalendertags 'JJJJ-MM-TT': 1 = Montag … 7 = Sonntag. */
export function wochentagVon(tag) {
const nr = new Date(`${tag}T00:00:00Z`).getUTCDay() // 0 = Sonntag
return nr === 0 ? 7 : nr
}
/** Der erste Tag ab heute (heute eingeschlossen), der auf den Wochentag (1 = Montag … 7 = Sonntag) fällt: Tag für den Regler „Uhrzeit testen“. */
export function naechstesVorkommen(heute, wochentag) {
return tagPlus(heute, (wochentag - wochentagVon(heute) + 7) % 7)
}
/* ------------------------------------------------------ Hinweis-Leiste --- */
const VORLAUF_TAGE = 7
const tageZwischen = (von, bis) => Math.round((Date.parse(`${bis}T00:00:00Z`) - Date.parse(`${von}T00:00:00Z`)) / 86400000)
const tagMonat = (tag) => datumKurz(tag).slice(0, 6) // '12.10.'
/**
* Vorschlag fuer eine Hinweis-Leiste zu einem Betriebsurlaub (von, bis; bis fehlt = ein Tag): ab 2 Tagen
* { art: 'urlaub', text: 'Betriebsurlaub vom 12.10. bis 26.10.', von: Urlaubsbeginn - 7 Tage, bis: Urlaubsende }, sonst null.
* Dauert der Urlaub so lang, dass 7 Tage Vorlauf die 366 Tage fuer einen Urlaubshinweis sprengen wuerden (der Server lehnte ihn
* ab), beginnt der Hinweis entsprechend spaeter.
*/
export function hinweisVorschlag(urlaub) {
const von = urlaub?.von
const bis = urlaub?.bis
if (!istTag(von) || !istTag(bis) || bis <= von) return null
const fruehestens = tagPlus(bis, -(ZEITEN_LIMITS.urlaubTage - 1))
const mitVorlauf = tagPlus(von, -VORLAUF_TAGE)
return { art: 'urlaub', text: `Betriebsurlaub vom ${tagMonat(von)} bis ${tagMonat(bis)}`, von: mitVorlauf < fruehestens ? fruehestens : mitVorlauf, bis }
}
/** Braucht ein Hinweistext den Hinweis zu Preisangaben (Endpreise, niedrigster Preis der letzten 30 Tage)? Bei „%“ oder „statt“. */
export function preisHinweisNoetig(text) {
return typeof text === 'string' && /%|statt/i.test(text)
}
/* ---------------------------------------------------------------- Links --- */
// Hostname wie ihn URL liefert (klein, Umlaute als Punycode): Labels aus a-z, 0-9 und Bindestrich, oder IPv6 in Klammern (wie der Server).
const HOST_RE = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*|\[[0-9a-f:.]+\])$/
/**
* Ist das ein erlaubtes Ziel fuer einen Link-Knopf? https, hoechstens 300 Zeichen, ohne Leerraum und Zugangsdaten, ASCII-Hostname
* (Umlaute-Domains als Punycode); ein Apostroph im Pfad zaehlt als %27 (so speichert ihn der Server). Aussen stehender Leerraum
* wird wie beim Speichern abgeschnitten.
*/
export function linkOk(url) {
const s = typeof url === 'string' ? url.trim() : ''
if (s === '' || s.length > ZEITEN_MAX.link || /\s/.test(s)) return false
let ziel
try { ziel = new URL(s) } catch { return false }
const href = ziel.href.replace(/'/g, '%27')
return ziel.protocol === 'https:' && !ziel.username && !ziel.password && HOST_RE.test(ziel.hostname) && href.length <= ZEITEN_MAX.link
}