/* Terminplaner im Kundenbereich: reine Helfer ohne DOM (ES-Modul; termine.js nutzt sie, test/termineView.test.js prueft sie). Tage sind Berliner Kalendertage 'JJJJ-MM-TT' (so liefert und erwartet sie /api/termine). Gerechnet wird nur mit dem Kalender (UTC-Mitternacht), nie mit der Zeitzone des Browsers; nur heuteBerlin() und endeZeit() fragen die Uhr bzw. Zeitzone Berlin. */ const TAG_RE = /^(\d{4})-(\d{2})-(\d{2})$/ const MONATE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'] const WOCHENTAGE = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'] const TAG_MS = 24 * 60 * 60 * 1000 /** Die Schnittstelle liefert Buchungen fuer hoechstens 62 Tage nach dem Beginn des Zeitraums. */ const MAX_TAGE = 62 const GELOESCHT = 'Gelöscht' /* ------------------------------------------------------------ Kalender --- */ function utc(tag) { const m = TAG_RE.exec(String(tag)) return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])) } const tagVon = (ms) => new Date(ms).toISOString().slice(0, 10) /** Der Tag n Kalendertage nach (oder vor, n < 0) dem Tag. */ export function tagPlus(tag, n) { return tagVon(utc(tag) + n * TAG_MS) } /** Montag bis Sonntag der Woche, in der der Tag liegt. */ export function wocheVon(tag) { const wochentag = new Date(utc(tag)).getUTCDay() // 0 = Sonntag const montag = tagPlus(tag, -((wochentag + 6) % 7)) return { von: montag, bis: tagPlus(montag, 6) } } /** Erster bis letzter Tag des Monats, in dem der Tag liegt. */ export function monatVon(tag) { const [jahr, monat] = String(tag).split('-').map(Number) return { von: `${tag.slice(0, 7)}-01`, bis: tagVon(Date.UTC(jahr, monat, 0)) } } /** * Zeitraum fuer die Abfrage der Buchungen. ansicht: 'tag' | 'woche' | 'monat' | 'anfragen' (ab heute, so weit die * Schnittstelle reicht). */ export function bereichFuer(ansicht, tag, heute) { if (ansicht === 'woche') return wocheVon(tag) if (ansicht === 'monat') return monatVon(tag) if (ansicht === 'anfragen') return { von: heute, bis: tagPlus(heute, MAX_TAGE) } return { von: tag, bis: tag } } /** Naechster (richtung 1) oder vorheriger (-1) Zeitraum: Tag +-1, Woche +-7, Monat auf den 1. des Nachbarmonats. */ export function verschiebeTag(ansicht, tag, richtung) { if (ansicht === 'woche') return tagPlus(tag, 7 * richtung) if (ansicht === 'monat') { const [jahr, monat] = tag.split('-').map(Number) return tagVon(Date.UTC(jahr, monat - 1 + richtung, 1)) } return tagPlus(tag, richtung) } /** Heutiger Kalendertag in Berlin (nicht der des Browsers). */ export function heuteBerlin(jetzt = new Date()) { return jetzt.toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }) } /* --------------------------------------------------------------- Datum --- */ const tagOhneJahr = (tag) => `${Number(tag.slice(8))}. ${MONATE[Number(tag.slice(5, 7)) - 1]}` /** 'Montag, 5. Oktober 2026' */ export function datumLang(tag) { return `${WOCHENTAGE[new Date(utc(tag)).getUTCDay()]}, ${tagOhneJahr(tag)} ${tag.slice(0, 4)}` } /** '05.10.2026' */ export function datumKurz(tag) { return `${tag.slice(8)}.${tag.slice(5, 7)}.${tag.slice(0, 4)}` } /** Ueberschrift eines Tages in der Liste: 'Heute · Montag, 5. Oktober'; das Jahr nur, wenn es nicht das heutige ist. */ export function tagUeberschrift(tag, heute) { const rel = tag === heute ? 'Heute · ' : tag === tagPlus(heute, 1) ? 'Morgen · ' : tag === tagPlus(heute, -1) ? 'Gestern · ' : '' const jahr = tag.slice(0, 4) === heute.slice(0, 4) ? '' : ` ${tag.slice(0, 4)}` return `${rel}${WOCHENTAGE[new Date(utc(tag)).getUTCDay()]}, ${tagOhneJahr(tag)}${jahr}` } /** Titel des gewaehlten Zeitraums ueber der Liste: '5.–11. Oktober 2026', 'Oktober 2026', 'Montag, 5. Oktober 2026'. */ export function zeitraumTitel(ansicht, tag) { if (ansicht === 'anfragen') return 'Offene Anfragen' if (ansicht === 'monat') return `${MONATE[Number(tag.slice(5, 7)) - 1]} ${tag.slice(0, 4)}` if (ansicht === 'woche') { const { von, bis } = wocheVon(tag) if (von.slice(0, 7) === bis.slice(0, 7)) return `${Number(von.slice(8))}.–${tagOhneJahr(bis)} ${bis.slice(0, 4)}` if (von.slice(0, 4) === bis.slice(0, 4)) return `${tagOhneJahr(von)} – ${tagOhneJahr(bis)} ${bis.slice(0, 4)}` return `${tagOhneJahr(von)} ${von.slice(0, 4)} – ${tagOhneJahr(bis)} ${bis.slice(0, 4)}` } return datumLang(tag) } const ENDE_FORMAT = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }) /** Ende des Termins als Berliner Wanduhr 'HH:MM' (aus endsAt); '' wenn kein Zeitpunkt. */ export function endeZeit(buchung) { const ende = new Date(buchung?.endsAt) return Number.isNaN(ende.getTime()) ? '' : ENDE_FORMAT.format(ende) } /* ------------------------------------------------------------- Uhrzeit --- */ /** 'H:MM', 'HH:MM' oder 'HH.MM' ⇒ 'HH:MM'; ende erlaubt 24:00. Das 5-Minuten-Raster prueft der Server (und fensterParsen). */ export function zeitNormal(text, { ende = false } = {}) { const m = typeof text === 'string' ? /^(\d{1,2})[:.](\d{2})$/.exec(text.trim()) : null if (!m) return null const stunde = Number(m[1]) const minute = Number(m[2]) if (minute > 59 || stunde > 24 || (stunde === 24 && (!ende || minute !== 0))) return null return `${String(stunde).padStart(2, '0')}:${m[2]}` } const inMinuten = (zeit) => Number(zeit.slice(0, 2)) * 60 + Number(zeit.slice(3)) /** * Zeitfenster eines Wochentags aus Text: '09:00–12:30, 13:30–18:00' ⇒ [['09:00','12:30'], ['13:30','18:00']]. * Getrennt durch Komma, Strichpunkt, Zeilenumbruch oder „und“; Bis-Strich: -, –, — oder „bis“. Leerer Text ⇒ [] (geschlossen). * Ungueltig ⇒ null: keine Uhrzeit, kein 5-Minuten-Raster, Ende nicht nach dem Beginn, nicht aufsteigend, Ueberlappung. */ export function fensterParsen(text) { if (typeof text !== 'string') return null if (text.trim() === '') return [] const fenster = [] let vorher = null for (const teil of text.split(/[,;\n]|\bund\b/)) { const m = /^\s*(\S+?)\s*(?:[-–—]|\bbis\b)\s*(\S+)\s*$/.exec(teil) const von = m ? zeitNormal(m[1]) : null const bis = m ? zeitNormal(m[2], { ende: true }) : null if (von === null || bis === null || inMinuten(von) % 5 !== 0 || inMinuten(bis) % 5 !== 0) return null if (inMinuten(bis) <= inMinuten(von) || (vorher !== null && inMinuten(von) < inMinuten(vorher))) return null vorher = bis fenster.push([von, bis]) } return fenster } /** [['09:00','12:30'], ['13:30','18:00']] ⇒ '09:00–12:30, 13:30–18:00' (Umkehrung von fensterParsen). */ export function fensterText(fenster) { return (Array.isArray(fenster) ? fenster : []).map(([von, bis]) => `${von}–${bis}`).join(', ') } /* -------------------------------------------------------------- Status --- */ const STATUS = { gehalten: ['Reserviert', 'wait'], angefragt: ['Angefragt', 'wait'], bestaetigt: ['Bestätigt', 'ok'], erledigt: ['Erledigt', 'off'], nichterschienen: ['Nicht erschienen', 'bad'], abgesagt: ['Abgesagt', 'off'], abgelehnt: ['Abgelehnt', 'off'], verfallen: ['Verfallen', 'off'], } /** Anzeigetext eines Status; Unbekanntes nie roh. */ export function statusLabel(status) { return Object.hasOwn(STATUS, status) ? STATUS[status][0] : 'Unbekannt' } /** Farbton der Pille: ok | wait | off | bad. */ export function statusTon(status) { return Object.hasOwn(STATUS, status) ? STATUS[status][1] : 'off' } /** Filter der Terminliste; status = Liste fuer ?status= der Schnittstelle (null = alle). */ export const FILTER = [ { key: 'alle', label: 'Alle', status: null }, { key: 'offen', label: 'Offen', status: ['angefragt', 'bestaetigt'] }, { key: 'angefragt', label: 'Angefragt', status: ['angefragt'] }, { key: 'bestaetigt', label: 'Bestätigt', status: ['bestaetigt'] }, { key: 'abgesagt', label: 'Abgesagt', status: ['abgesagt', 'abgelehnt', 'verfallen'] }, ] /** Wert fuer ?status= ('' = alle Status). */ export function filterStatus(key) { return FILTER.find((f) => f.key === key)?.status?.join(',') ?? '' } /** „3 Anfragen warten“ / „1 Anfrage wartet“; bei 0 oder ohne Zahl ''. */ export function anfragenText(n) { if (!Number.isInteger(n) || n < 1) return '' return n === 1 ? '1 Anfrage wartet' : `${n} Anfragen warten` } /** Gruende fuer die Absage durch den Betrieb (Pflichtauswahl; der Gast liest sie in der Absage-Mail). */ export const ABSAGE_GRUENDE = ['Krankheit', 'Betriebliche Gründe', 'Urlaub oder Betriebsferien', 'Auf Wunsch des Gastes', 'Sonstiges'] /* ----------------------------------------------------------- Einstellung --- */ /** „Person“, „Platz“ oder „Person/Platz“, je nachdem, was der Betrieb als Ressourcen hat. */ export function ressourcenWort(ressourcen) { const typen = new Set((Array.isArray(ressourcen) ? ressourcen : []).map((r) => r?.typ)) if (typen.size === 1 && typen.has('platz')) return 'Platz' if (typen.size === 1 && typen.has('person')) return 'Person' return 'Person/Platz' } const FEHLT = { 'betrieb.telefon': 'deine Telefonnummer', 'betrieb.adresse': 'deine Anschrift' } /** Was fuer die Buchbarkeit noch fehlt (fehlt = Pfade aus GET /einstellungen): „deine Telefonnummer und deine Anschrift“; '' wenn nichts. */ export function fehltText(fehlt) { const teile = (Array.isArray(fehlt) ? fehlt : []).map((pfad) => FEHLT[pfad]).filter(Boolean) return teile.join(' und ') } /* ------------------------------------------------------------- Buchungen --- */ export const istSperre = (buchung) => buchung?.quelle === 'sperre' /** Buchungen nach Berliner Tag gruppiert (datum), Tage und Termine aufsteigend: [{ tag, buchungen }]. */ export function gruppiereNachTag(buchungen) { const nachTag = new Map() for (const b of [...(Array.isArray(buchungen) ? buchungen : [])].sort((a, c) => (a.startsAt < c.startsAt ? -1 : a.startsAt > c.startsAt ? 1 : 0))) { if (!nachTag.has(b.datum)) nachTag.set(b.datum, []) nachTag.get(b.datum).push(b) } return [...nachTag.keys()].sort().map((tag) => ({ tag, buchungen: nachTag.get(tag) })) } const hatGastdaten = (b) => Boolean((b.gastName && b.gastName !== GELOESCHT) || b.gastEmail || b.gastTelefon || b.gastNachricht) /** * Aktionen, die der Betrieb fuer die Buchung anbieten darf (jetzt = ms), in der Reihenfolge der Knoepfe: * annehmen | ablehnen | verschieben | absagen | nichterschienen | anonymisieren | entsperren. * Der Server prueft alles noch einmal; das hier haelt nur sinnlose Knoepfe weg. */ export function moeglicheAktionen(buchung, jetzt) { const b = buchung || {} const beginn = Date.parse(b.startsAt) const ende = Date.parse(b.endsAt) if (istSperre(b)) return ende > jetzt ? ['entsperren'] : [] const aktionen = [] const begonnen = !(beginn > jetzt) if (b.status === 'angefragt') { if (!begonnen) aktionen.push('annehmen') aktionen.push('ablehnen') if (!begonnen) aktionen.push('verschieben') } else if (b.status === 'bestaetigt') { if (!begonnen) aktionen.push('verschieben', 'absagen') else aktionen.push('nichterschienen') } else if (b.status === 'erledigt') { aktionen.push('nichterschienen') } if (b.status !== 'gehalten' && hatGastdaten(b)) aktionen.push('anonymisieren') return aktionen }