Terminplaner TP2: Fragebogen-Helfer im Client

Reine Helfer in public/termine-view.js: FRAGEBOGEN_SCHRITTE (Spiegel von
SCHRITTE und SCHRITT_TITEL), schrittStatus, ersterOffenerSchritt,
ressourcenAusNamen, buchungsartFuerAlle, buchungsartLage und
zusammenfassung (acht Gruppen, nur Text, Bundesland nur bei ausdruecklicher
Wahl, nie feedToken). Tests in test/termineFragebogenView.test.js, Spiegel-Test
in test/termineView.test.js.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 20:48:10 +00:00
1 parent 91b1a3bcd8
commit edaefd4237
3 files changed
+707 -1

No files matched your search

+245
View File
@@ -2,6 +2,8 @@
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. */
import { ANREDE_WAHL } from './termine-gaestetexte.js'
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']
@@ -475,3 +477,246 @@ export function abschaltText(abgeschaltet) {
: 'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und '
return { davor, danach: `.${loeschen}`, mitExport: true }
}
/* --------------------------------------------------------- Fragebogen (TP2) --- */
/**
* Die acht Schritte des Fragebogens als [Schluessel, Titel], in der Reihenfolge des Servers. Spiegel von SCHRITTE und SCHRITT_TITEL in
* server/services/termine/fragebogen.js (test/termineView.test.js vergleicht beide; der Titel steht auch in „Bitte bestaetige den Schritt …“).
* Der neunte Schritt „Pruefen und bestellen“ ist kein Fragebogen-Schritt und wird nie bestaetigt.
*/
export const FRAGEBOGEN_SCHRITTE = [
['branche', 'Branche und Betrieb'],
['wer', 'Wer wird gebucht'],
['leistungen', 'Leistungen'],
['zeiten', 'Zeiten'],
['regeln', 'Regeln'],
['gastfelder', 'Gastfelder'],
['benachrichtigung', 'Benachrichtigung'],
['website', 'Website'],
]
/** Stand der Schritte aus fragebogen.bestaetigt: [{ key, titel, erledigt }] in Schrittfolge. Ohne Fragebogen ist nichts erledigt. */
export function schrittStatus(fragebogen) {
const bestaetigt = Array.isArray(fragebogen?.bestaetigt) ? fragebogen.bestaetigt : []
return FRAGEBOGEN_SCHRITTE.map(([key, titel]) => ({ key, titel, erledigt: bestaetigt.includes(key) }))
}
/** Schluessel des ersten Schritts ohne Haken (die erste Luecke, nicht die Zahl der Haken); sind alle bestaetigt: 'bestellen'. */
export function ersterOffenerSchritt(fragebogen) {
return schrittStatus(fragebogen).find((schritt) => !schritt.erledigt)?.key ?? 'bestellen'
}
const leerzeichenEins = (wert) => wert.replace(/\s+/g, ' ').trim()
const nameSchluessel = (name) => leerzeichenEins(name).normalize('NFC').toLowerCase()
/**
* Personen oder Plaetze aus den Namen im Schritt „Wer wird gebucht“. bisher = ressourcen der Einstellung, namen = Liste von Texten,
* typ = 'person' | 'platz' (gilt fuer alle, auch fuer behaltene Eintraege; sonst Person), neueId(belegt) = Erzeuger einer freien ID
* (belegt: Set aller schon vergebenen IDs inklusive der alten, auch der wegfallenden; Vorgabe eindeutigeId('r', belegt); eine belegte ID wirft).
* Namen: getrimmt, Leerraum zusammengefasst, auf MAX.ressourcenName gekuerzt, ohne Leere und ohne Doppelte (Gross-/Kleinschreibung zaehlt
* nicht, die erste Schreibweise gilt), hoechstens LIMITS.ressourcen. Zuordnung: gleiche Namen behalten ID und Plan; uebrige alte Eintraege
* werden in ihrer Reihenfolge den uebrigen Namen zugeteilt (umbenannt, ID und Plan bleiben); weitere Namen bekommen eine neue ID und eine
* Kopie des Plans des ersten Eintrags (ohne Eintrag: leerer Plan). Alle Eintraege sind aktiv. Ergebnis in der Reihenfolge der Namen:
* { ressourcen, entfernt: IDs der weggefallenen Eintraege, neu: IDs der neuen }. Die Eintraege tragen nur Felder, die der Server aus dem
* Client uebernimmt (kein feedToken, den vergibt und behaelt der Server). Die Zuordnung der Leistungen setzt der Aufrufer.
*/
export function ressourcenAusNamen({ bisher, typ, namen, neueId: erzeugeId = (belegt) => eindeutigeId('r', belegt) } = {}) {
const art = typ === 'platz' ? 'platz' : 'person'
const alt = (Array.isArray(bisher) ? bisher : []).filter((r) => r !== null && typeof r === 'object' && typeof r.id === 'string')
const sauber = []
const gesehen = new Set()
for (const roh of Array.isArray(namen) ? namen : []) {
if (typeof roh !== 'string') continue
const name = leerzeichenEins([...leerzeichenEins(roh)].slice(0, MAX.ressourcenName).join(''))
if (name === '' || gesehen.has(nameSchluessel(name))) continue
gesehen.add(nameSchluessel(name))
sauber.push(name)
if (sauber.length === LIMITS.ressourcen) break
}
// 1. gleiche Namen behalten ihren alten Eintrag, 2. die uebrigen alten Eintraege gehen der Reihe nach an die uebrigen Namen
const frei = new Set(alt)
const zugeteilt = sauber.map((name) => {
const treffer = alt.find((r) => frei.has(r) && typeof r.name === 'string' && nameSchluessel(r.name) === nameSchluessel(name))
if (treffer) frei.delete(treffer)
return treffer ?? null
})
const uebrige = alt.filter((r) => frei.has(r))
sauber.forEach((name, i) => { if (zugeteilt[i] === null && uebrige.length > 0) zugeteilt[i] = uebrige.shift() })
const belegt = new Set(alt.map((r) => r.id))
const ressourcen = []
const neu = []
sauber.forEach((name, i) => {
const alter = zugeteilt[i]
let id
let plan
if (alter) {
id = alter.id
plan = alter.plan
} else {
id = erzeugeId(new Set(belegt))
if (typeof id !== 'string' || belegt.has(id)) throw new Error(`neueId lieferte keine freie ID: ${String(id).slice(0, 40)}`)
belegt.add(id)
neu.push(id)
plan = ressourcen[0]?.plan
}
ressourcen.push({ id, typ: art, name, aktiv: true, plan: structuredClone(plan !== null && typeof plan === 'object' ? plan : {}) })
})
return { ressourcen, entfernt: uebrige.map((r) => r.id), neu }
}
const BUCHUNGSARTEN = ['sofort', 'anfrage']
/** Kopie der Leistungen, in der jede (auch eine inaktive) die Buchungsart art hat; sonst aendert sich nichts. Unbekannte Art: unveraenderte Kopie. */
export function buchungsartFuerAlle(leistungen, art) {
return (Array.isArray(leistungen) ? leistungen : []).map((leistung) => (BUCHUNGSARTEN.includes(art) ? { ...leistung, buchungsart: art } : { ...leistung }))
}
/** 'sofort' | 'anfrage', wenn alle Leistungen gleich sind (auch ohne Leistungen und bei fehlender Angabe: sofort, wie beim Server), sonst 'gemischt'. */
export function buchungsartLage(leistungen) {
const arten = new Set((Array.isArray(leistungen) ? leistungen : []).map((leistung) => (leistung?.buchungsart === 'anfrage' ? 'anfrage' : 'sofort')))
if (arten.size > 1) return 'gemischt'
return arten.has('anfrage') ? 'anfrage' : 'sofort'
}
// ---- Zusammenfassung auf der Bestellseite
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
const istText = (wert) => typeof wert === 'string' && wert.trim() !== ''
const einheit = (n, einzahl, mehrzahl) => `${n} ${n === 1 ? einzahl : mehrzahl}`
const wahlName = (wahl, key) => wahl.find(([k]) => k === key)?.[1]
/** Kundentext als eine Zeile: Umbrueche werden Leerzeichen, ueber max Zeichen gekuerzt (mit „…“). */
function kurzText(wert, max) {
const zeichen = [...leerzeichenEins(wert)]
return zeichen.length > max ? `${zeichen.slice(0, max - 1).join('').trimEnd()}…` : zeichen.join('')
}
/** 0 ⇒ 'keine', ganze Tage, ganze Stunden, sonst Minuten. */
function zeitspanne(minuten) {
if (minuten === 0) return 'keine'
if (minuten % 1440 === 0) return einheit(minuten / 1440, 'Tag', 'Tage')
if (minuten % 60 === 0) return einheit(minuten / 60, 'Stunde', 'Stunden')
return einheit(minuten, 'Minute', 'Minuten')
}
/** Wochenplan einer Ressource als Zeilen: aufeinanderfolgende Tage mit gleichen Zeiten zusammengefasst, danach 'Geschlossen: …'. */
function wochenplanZeilen(plan, vorsatz) {
const texte = planTexte(plan)
const tage = WOCHENTAGE_PLAN
const bereich = (von, bis) => (von === bis ? tage[von][1] : `${tage[von][1]}–${tage[bis][1]}`)
const offen = []
const zu = []
for (let i = 0; i < tage.length;) {
const text = texte[tage[i][0]]
let bis = i
while (bis + 1 < tage.length && texte[tage[bis + 1][0]] === text) bis += 1
if (text === '') zu.push(bereich(i, bis))
else offen.push(`${bereich(i, bis)}: ${text}`)
i = bis + 1
}
if (zu.length) offen.push(`Geschlossen: ${zu.join(', ')}`)
return offen.map((zeile) => `${vorsatz}${zeile}`)
}
const GAESTE_SIND = { verbraucher: 'Privatpersonen', unternehmer: 'Unternehmen', gemischt: 'Privatpersonen und Unternehmen' }
const BUCHUNGSART_TEXT = { sofort: 'sofort buchbar', anfrage: 'auf Anfrage', gemischt: 'gemischt, je Leistung' }
/**
* Zusammenfassung der Angaben fuer „Pruefen und bestellen“: acht Gruppen in der Reihenfolge der Schritte, jede { schritt, titel, zeilen },
* die Zeilen nur Text (der Aufrufer setzt sie per textContent; Kundentext bleibt unveraendert, lange Wuensche sind gekuerzt). Es kommt nur vor,
* was angegeben ist; Fehlendes nennt bestellpruefung des Servers. Das Bundesland steht nur, wenn der Kunde es ausdruecklich gewaehlt hat
* (fragebogen.bundeslandGewaehlt: der Serverstandard RP ist keine Wahl). Nie feedToken oder IDs. branchen = Liste { key, name } aus GET /einstellungen.
*/
export function zusammenfassung(einstellung, fragebogen, branchen) {
const e = istObjekt(einstellung) ? einstellung : {}
const f = istObjekt(fragebogen) ? fragebogen : {}
const betrieb = istObjekt(e.betrieb) ? e.betrieb : {}
const ressourcen = (Array.isArray(e.ressourcen) ? e.ressourcen : []).filter(istObjekt)
const leistungen = (Array.isArray(e.leistungen) ? e.leistungen : []).filter(istObjekt)
const regeln = istObjekt(e.regeln) ? e.regeln : {}
const gastFelder = istObjekt(e.gastFelder) ? e.gastFelder : {}
const erinnerung = istObjekt(e.erinnerung) ? e.erinnerung : {}
const recht = istObjekt(e.recht) ? e.recht : {}
const einbau = istObjekt(f.einbau) ? f.einbau : {}
const gruppen = Object.fromEntries(FRAGEBOGEN_SCHRITTE.map(([key]) => [key, []]))
// zeile(schritt, text): nur ein nicht-leerer Text kommt in die Gruppe (so fehlt eine Zeile, wenn `angabe && …` falsch ist)
const zeile = (schritt, text) => { if (typeof text === 'string' && text !== '') gruppen[schritt].push(text) }
const bundesland = f.bundeslandGewaehlt === true ? wahlName(BUNDESLAENDER, e.bundesland) : undefined
const guteWahl = (wahl, key) => wahlName(wahl, key) !== undefined
// 1. Branche und Betrieb
const branche = (Array.isArray(branchen) ? branchen : []).find((b) => istObjekt(b) && b.key === f.branche)
zeile('branche', istText(branche?.name) && `Branche: ${branche.name}`)
zeile('branche', istText(betrieb.name) && `Betrieb: ${betrieb.name}`)
zeile('branche', istText(betrieb.adresse) && `Anschrift: ${betrieb.adresse}`)
// 2. Wer wird gebucht: Personen und Plaetze getrennt, in der Reihenfolge ihres ersten Auftretens
const nachTyp = new Map()
for (const r of ressourcen.filter((x) => istText(x.name))) {
const wort = r.typ === 'platz' ? 'Plätze' : 'Personen'
if (!nachTyp.has(wort)) nachTyp.set(wort, [])
nachTyp.get(wort).push(`${r.name}${r.aktiv === false ? ' (nicht aktiv)' : ''}`)
}
for (const [wort, namen] of nachTyp) zeile('wer', `${wort}: ${namen.join(', ')}`)
// 3. Leistungen
for (const l of leistungen.filter((x) => istText(x.name))) {
zeile('leistungen', `${l.name}: ${Number.isInteger(l.dauer) ? `${l.dauer} Min., ` : ''}${preisAnzeige(l)}${l.aktiv === false ? ' (nicht aktiv)' : ''}`)
}
zeile('leistungen', leistungen.some((l) => preisAnzeige(l) !== 'ohne Preis') && `Preise geprüft: ${f.preiseGeprueft === true ? 'ja' : 'nein'}`)
// 4. Zeiten
const aktive = ressourcen.filter((r) => r.aktiv !== false)
for (const r of aktive) for (const text of wochenplanZeilen(r.plan, aktive.length > 1 && istText(r.name) ? `${r.name}: ` : '')) zeile('zeiten', text)
zeile('zeiten', typeof e.feiertageGeschlossen === 'boolean' && `Feiertage: ${e.feiertageGeschlossen ? 'geschlossen' : 'geöffnet'}`)
zeile('zeiten', bundesland !== undefined && `Bundesland: ${bundesland}`)
const abwesend = Array.isArray(e.abwesenheiten) ? e.abwesenheiten.length : 0
zeile('zeiten', abwesend > 0 && `Abwesenheiten: ${abwesend} eingetragen`)
const sonder = istObjekt(e.sonderoeffnungen) ? Object.keys(e.sonderoeffnungen).length : 0
zeile('zeiten', sonder > 0 && `Besondere Öffnungstage: ${sonder}`)
// 5. Regeln
zeile('regeln', leistungen.length > 0 && `Buchungsart: ${BUCHUNGSART_TEXT[buchungsartLage(leistungen)]}`)
zeile('regeln', Number.isInteger(regeln.raster) && `Raster: ${regeln.raster} Minuten`)
zeile('regeln', Number.isInteger(regeln.vorlaufMin) && regeln.vorlaufMin >= 0 && `Vorlaufzeit: ${zeitspanne(regeln.vorlaufMin)}`)
zeile('regeln', Number.isInteger(regeln.horizontTage) && `Buchungszeitraum: ${einheit(regeln.horizontTage, 'Tag', 'Tage')} im Voraus`)
zeile('regeln', Number.isInteger(regeln.absageBisStd) && `Absage durch Gäste: bis ${einheit(regeln.absageBisStd, 'Stunde', 'Stunden')} vorher`)
zeile('regeln', Number.isInteger(regeln.maxOffeneJeEmail) && `Offene Termine je E-Mail-Adresse: höchstens ${regeln.maxOffeneJeEmail}`)
zeile('regeln', typeof regeln.personWaehlbar === 'boolean' && `${ressourcenWort(ressourcen)} wählbar: ${regeln.personWaehlbar ? 'ja' : 'nein'}`)
zeile('regeln', Object.hasOwn(GAESTE_SIND, recht.gaesteSind) && `Deine Gäste sind: ${GAESTE_SIND[recht.gaesteSind]}`)
zeile('regeln', recht.freizeit === true && 'Leistungen sind Freizeitangebote mit festem Termin')
const arten = (istObjekt(e.zahlung) && Array.isArray(e.zahlung.vorOrtArten) ? e.zahlung.vorOrtArten : []).map((a) => wahlName(ZAHLARTEN, a)).filter(Boolean)
zeile('regeln', arten.length > 0 && `Zahlung: vor Ort (${arten.join(', ')})`)
zeile('regeln', istObjekt(e.storno) && `Stornobedingungen: ${istText(e.storno.text) ? 'Text hinterlegt' : 'kein Text'}`)
// 6. Gastfelder
zeile('gastfelder', guteWahl(GAST_FELD_WAHL, gastFelder.telefon) && `Telefonnummer des Gastes: ${wahlName(GAST_FELD_WAHL, gastFelder.telefon)}`)
zeile('gastfelder', guteWahl(GAST_FELD_WAHL, gastFelder.nachricht) && `Nachricht des Gastes: ${wahlName(GAST_FELD_WAHL, gastFelder.nachricht)}`)
for (const z of (Array.isArray(gastFelder.zusatz) ? gastFelder.zusatz : []).filter((x) => istObjekt(x) && istText(x.label))) {
zeile('gastfelder', `Zusatzfeld „${z.label}“: ${z.pflicht === true ? 'Pflicht' : 'Optional'}${z.typ === 'auswahl' ? ' (Auswahl)' : ''}`)
}
// 7. Benachrichtigung
zeile('benachrichtigung', istText(betrieb.email) && `Benachrichtigungen an: ${betrieb.email}`)
zeile('benachrichtigung', guteWahl(ANREDE_WAHL, e.anrede) && `Anrede der Gäste: ${wahlName(ANREDE_WAHL, e.anrede)}`)
if (typeof erinnerung.anbieten === 'boolean') {
zeile('benachrichtigung', erinnerung.anbieten ? `E-Mail-Erinnerung für Gäste: angeboten, ${einheit(erinnerung.stundenVorher, 'Stunde', 'Stunden')} vorher` : 'E-Mail-Erinnerung für Gäste: aus')
}
// 8. Website und Einbau
zeile('website', istText(betrieb.telefon) && `Telefon: ${betrieb.telefon}`)
zeile('website', istText(betrieb.impressumUrl) && `Impressum: ${betrieb.impressumUrl}`)
zeile('website', istText(betrieb.datenschutzUrl) && `Datenschutzerklärung: ${betrieb.datenschutzUrl}`)
zeile('website', istText(betrieb.agbUrl) && `AGB: ${betrieb.agbUrl}`)
zeile('website', istText(betrieb.websiteUrl) && `Website: ${betrieb.websiteUrl}`)
zeile('website', einbau.modus === 'inline' && 'Darstellung: direkt auf deiner Seite')
zeile('website', einbau.modus === 'knopf' && `Darstellung: hinter einem Knopf${istText(einbau.knopfText) ? ` „${einbau.knopfText}“` : ''}`)
for (const [label, wert] of [['Stelle', einbau.stelle], ['Gestaltungswunsch', einbau.gestaltung], ['Bemerkung', einbau.bemerkung]]) {
zeile('website', istText(wert) && `${label}: ${kurzText(wert, 200)}`)
}
return FRAGEBOGEN_SCHRITTE.map(([schritt, titel]) => ({ schritt, titel, zeilen: gruppen[schritt] }))
}