/* 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 }