Terminplaner: Einbauanleitung als reiner Generator (Modell bis Benachrichtigungen)

einbauanleitung({ feature, project, einstellung, fragebogen, version, now, updatedAt, kunde,
entwurf }) setzt die ersten sechs Abschnitte der Team-Anleitung zusammen (modell, leistungen,
ressourcen, regeln, gastfelder, benachrichtigungen, feste Reihenfolge, nicht kopierbar) plus
stand { version, updatedAt } und den Volltext. Rein: kein I/O, config nur beim Aufruf
(Portal-Adresse, Haltezeit); gleiche Eingabe und gleiches now ergeben gleiche Ausgabe, die
Eingabe bleibt unveraendert.

Feldweissliste statt Objektkopie: nie feedToken, keine Betriebs-E-Mail, keine
Leistungsbeschreibung, keine unbekannten Zusatzfelder. Kundentexte stehen unmaskiert als Text,
einzeilige Werte werden auf eine Zeile gebracht (keine vorgetaeuschte Ueberschrift).
Abwesenheiten und Sonderoeffnungen nur ab heute (Berliner Kalendertag), Listen ueber 20
Eintraege enden mit "und N weitere". Hilfen wochenplanText und kuerzeListe sind exportiert.

Warnungen und Kurzfassung bleiben leer (kommen mit D3b und D4); ohne Einstellung entstehen
keine Abschnitte.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-05 00:07:38 +00:00
1 parent ee454017b0
commit 13bbadc729
2 files changed
+800 -8

No files matched your search

+318 -6
View File
@@ -1,12 +1,18 @@
import { VORGABE_FARBE } from './einstellungen.js'
import { config } from '../../config.js'
import { VORGABE_FARBE, preisHinweis, preisText, sichtbareLeistungen, zahlungText, zeigtWiderruf } from './einstellungen.js'
import { RECHTSTEXTE_ENTWURF } from './rechtstexte.js'
import { datumKurz, heute } from './zeit.js'
/**
* Einbau des Terminplaners auf der Website des Kunden: Bausteine fuer Einbau-Code und Farbvorschlag
* (Spezifikation 2026-10-03-terminplaner-tp2-verkauf-design.md, Abschnitt Einbauanleitung; Anleitung fuer das Team:
* docs/funktionen/termine-einbau.md, Abschnitte 1 und 3).
* Einbau des Terminplaners auf der Website des Kunden (Spezifikation 2026-10-03-terminplaner-tp2-verkauf-design.md, Abschnitt
* Einbauanleitung; Anleitung fuer das Team: docs/funktionen/termine-einbau.md).
*
* Reine Funktionen ohne Appwrite und ohne config: die Adresse des Portals (`apiBase`) und alle Werte kommen als Parameter.
* Der Einbau-Code stand bis TP2 in routes/termine.js; GET /api/termine/einstellungen ruft ihn von hier auf (Modus inline, die Kundenansicht bleibt unveraendert).
* Zwei Teile in einer Datei:
* - Bausteine (esc, einbauSnippet, Farbfunktionen, cssVorschlag): reine Funktionen ohne Appwrite und ohne config, die Adresse
* des Portals (`apiBase`) und alle Werte kommen als Parameter. Der Einbau-Code stand bis TP2 in routes/termine.js;
* GET /api/termine/einstellungen ruft ihn von hier auf (Modus inline, die Kundenansicht bleibt unveraendert).
* - Generator einbauanleitung() (ganz unten): setzt aus den Einstellungen des Betriebs die Anleitung fuer das Team zusammen.
* Auch er ist rein (kein I/O, keine Zufallswerte); config wird nur beim Aufruf gelesen (Portal-Adresse, Haltezeit).
*
* Die Farbfunktionen (luminanz, textFarbeAuf) sind 1:1 die aus public/embed/termine.js (Marken @farben-anfang/@farben-ende),
* bewusst kopiert und nicht importiert: das Widget ist ein eigenstaendiges Browser-Skript. test/termineEmbedStatic.test.js
@@ -119,3 +125,309 @@ export function cssVorschlag(farbe) {
].join('\n')
return { css, akzent, akzentText, kontrastText: textKontrast, kontrastSeite: seitenKontrast, hinweise }
}
// ================================================================ Generator einbauanleitung()
// Task D3a: Modell, Leistungen, Ressourcen, Regeln, Gastfelder, Benachrichtigungen. Task D3b haengt Einbau bis Abrechnung, die
// Warnungen und den vollen Volltext an, Task D4 die Kurzfassung (`kurz`).
//
// Feldweissliste: gelesen werden nur ausdruecklich benannte Felder, nie ganze Objekte. Nie ausgegeben werden feedToken (Kalender-
// Abo der Ressource), die Betriebs-E-Mail (sie steht nur im Datenschutz-Absatz, D3b), Beschreibungen der Leistungen und alles,
// was sonst an den Objekten haengt. Kundentexte (Namen, Titel, Storno-Text) stehen unmaskiert als Text; wer sie anzeigt, setzt
// sie als Text ein. Einzeilige Werte werden auf eine Zeile gebracht, damit ein Name keine Ueberschrift vortaeuschen kann.
const WOCHENTAGE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const TAG_RE = /^\d{4}-\d{2}-\d{2}$/
const MAX_ABWESENHEITEN = 20
const MAX_SONDEROEFFNUNGEN = 20
const NICHT_ANGEGEBEN = 'nicht angegeben'
const GAESTE = { verbraucher: 'Privatpersonen (Verbraucher)', unternehmer: 'Unternehmen', gemischt: 'Privatpersonen und Unternehmen' }
const BUCHUNGSART = { sofort: 'sofort buchbar', anfrage: 'nur als Anfrage' }
const GAST_FELD = { aus: 'aus', optional: 'optional', pflicht: 'Pflicht' }
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
const liste = (wert) => (Array.isArray(wert) ? wert : [])
const anzahl = (n, einzahl, mehrzahl) => `${n} ${n === 1 ? einzahl : mehrzahl}`
const imWort = (map, schluessel, vorgabe) => (typeof schluessel === 'string' && Object.hasOwn(map, schluessel) ? map[schluessel] : vorgabe)
/** Einzeiliger Text: Leerraum, Umbrueche und Steuerzeichen werden zu einem Leerzeichen, Rand getrimmt; Nicht-Text = ''. */
const zeile = (wert) => (typeof wert === 'string' ? wert.replace(/[\p{Cc}\p{Zl}\p{Zp}\s]+/gu, ' ').trim() : '')
/** '2026-10-05' => '05.10.2026'. */
const tagText = (tag) => tag.split('-').reverse().join('.')
/** [['09:00','12:30'],['14:00','18:00']] => '09:00–12:30, 14:00–18:00'; Unbrauchbares faellt weg. */
function fensterText(fenster) {
return liste(fenster)
.filter((f) => Array.isArray(f) && f.length === 2 && typeof f[0] === 'string' && typeof f[1] === 'string')
.map(([von, bis]) => `${zeile(von)}–${zeile(bis)}`)
.join(', ')
}
/** Wochenplan { 1: [['09:00','12:30'], …], … } (ISO-Wochentage) als Zeilen 'Mo 09:00–12:30, 14:00–18:00', Montag bis Sonntag, ohne leere Tage. */
export function wochenplanText(plan) {
const aus = []
for (let tag = 1; tag <= 7; tag += 1) {
const text = fensterText(istObjekt(plan) ? plan[tag] : null)
if (text) aus.push(`${WOCHENTAGE[tag - 1]} ${text}`)
}
return aus
}
/** Die ersten `max` Eintraege, bei mehr als `max` gefolgt von '… und N weitere'. Aendert die Liste nicht. */
export function kuerzeListe(eintraege, max) {
if (eintraege.length <= max) return [...eintraege]
return [...eintraege.slice(0, max), `… und ${eintraege.length - max} weitere`]
}
const neuerAbschnitt = (id, titel, text) => ({ id, titel, text, kopierbar: false })
/** Zeilen als Aufzaehlung: '- Eintrag'. */
const aufzaehlung = (eintraege) => eintraege.map((eintrag) => `- ${eintrag}`)
// ---------------------------------------------------------------- Abschnitt Modell
function standText(d) {
if (!Number.isInteger(d.version) || d.version < 1) return 'keine gespeicherte Version'
return `Version ${d.version}${d.geaendert ? `, zuletzt geändert ${d.geaendert}` : ''}`
}
function abschnittModell(d) {
const { e, betrieb: b } = d
const aktive = (eintraege) => eintraege.filter((x) => x.aktiv).length
const angabe = (wert) => zeile(wert) || NICHT_ANGEGEBEN
const gaeste = imWort(GAESTE, e.recht?.gaesteSind, GAESTE.verbraucher) + (e.recht?.freizeit === true ? ', Freizeitleistung' : '')
return neuerAbschnitt('modell', 'Modell', [
`Erzeugt am ${datumKurz(d.now)}`,
`Stand der Einstellungen: ${standText(d)}`,
...(d.kunde ? [`Kunde: ${d.kunde}`] : []),
...(d.projekt ? [`Projekt: ${d.projekt}`] : []),
`Betrieb: ${angabe(b.name)}`,
`Anschrift: ${angabe(b.adresse)}`,
`Telefon: ${angabe(b.telefon)}`,
`Impressum: ${angabe(b.impressumUrl)}`,
`Datenschutzerklärung: ${angabe(b.datenschutzUrl)}`,
...(zeile(b.agbUrl) ? [`AGB: ${zeile(b.agbUrl)}`] : []),
...(zeile(b.websiteUrl) ? [`Website: ${zeile(b.websiteUrl)}`] : []),
`Farbe: ${d.farbe}`,
`Anrede der Gäste: ${e.anrede === 'du' ? 'Du' : 'Sie'}`,
`Gäste: ${gaeste}`,
`Umfang: ${anzahl(d.ressourcen.length, 'Person oder Platz', 'Personen und Plätze')} (${aktive(d.ressourcen)} aktiv), `
+ `${anzahl(d.leistungen.length, 'Leistung', 'Leistungen')} (${aktive(d.leistungen)} aktiv)`,
`Portal-Adresse (data-api): ${d.portal}`,
].join('\n'))
}
// ---------------------------------------------------------------- Abschnitt Leistungen
/** 'Personen: Anna, Ben' | 'Plätze: …' | 'Personen und Plätze: …'; unbekannte Zuordnungen fallen weg. */
function zuordnungText(ids, nachId) {
const treffer = liste(ids).map((id) => nachId.get(id)).filter(Boolean)
if (treffer.length === 0) return 'keine Person und kein Platz zugeordnet'
const plaetze = treffer.filter((r) => r.typ === 'platz').length
const label = plaetze === 0 ? 'Personen' : plaetze === treffer.length ? 'Plätze' : 'Personen und Plätze'
return `${label}: ${treffer.map((r) => r.name).join(', ')}`
}
function leistungsZeile(l, nachId) {
const dauer = Number.isFinite(l.dauer) ? `${l.dauer} Min.` : 'Dauer fehlt'
const pause = Number.isFinite(l.pufferNach) && l.pufferNach > 0 ? ` (+ ${l.pufferNach} Min. Pause danach)` : ''
return `${zeile(l.name) || 'ohne Namen'}${l.aktiv ? '' : ' (inaktiv)'}: ${dauer}${pause}, ${preisText(l) || 'kein Preis'}, `
+ `${imWort(BUCHUNGSART, l.buchungsart, zeile(l.buchungsart) || BUCHUNGSART.sofort)}, ${zuordnungText(l.ressourcen, nachId)}`
}
function abschnittLeistungen(d) {
if (d.leistungen.length === 0) return neuerAbschnitt('leistungen', 'Leistungen', 'Keine Leistungen angelegt.')
const aktiv = d.leistungen.filter((l) => l.aktiv).length
return neuerAbschnitt('leistungen', 'Leistungen', [
`${anzahl(d.leistungen.length, 'Leistung', 'Leistungen')} (${aktiv} aktiv):`,
...aufzaehlung(d.leistungen.map((l) => leistungsZeile(l, d.nachId))),
].join('\n'))
}
// ---------------------------------------------------------------- Abschnitt Ressourcen
function ressourcenBlock(r) {
const plan = wochenplanText(r.plan)
return [
`- ${zeile(r.name) || 'ohne Namen'} (${r.typ === 'platz' ? 'Platz' : 'Person'}${r.aktiv ? '' : ', inaktiv'})`,
...(plan.length ? plan : ['keine Zeiten eingetragen']).map((z) => ` ${z}`),
].join('\n')
}
/** Abwesenheiten, die heute oder spaeter enden (laufende bleiben), nach Beginn sortiert. */
function abwesenheitsZeilen(d) {
const eintraege = liste(d.e.abwesenheiten)
.filter((a) => istObjekt(a) && TAG_RE.test(a.von) && TAG_RE.test(a.bis) && a.bis >= d.heuteTag)
.sort((a, b) => (a.von < b.von ? -1 : a.von > b.von ? 1 : a.bis < b.bis ? -1 : a.bis > b.bis ? 1 : 0))
.map((a) => {
const zeitraum = a.von === a.bis ? tagText(a.von) : `${tagText(a.von)}–${tagText(a.bis)}`
const wer = a.ressourceId ? d.nachId.get(a.ressourceId)?.name ?? 'unbekannt' : 'alle'
return `${zeitraum}: ${zeile(a.titel) || 'Abwesenheit'} (${wer})`
})
return aufzaehlung(kuerzeListe(eintraege, MAX_ABWESENHEITEN))
}
/** Sonderoeffnungszeiten ab heute ([] = an diesem Tag geschlossen), nach Tag sortiert. */
function sonderoeffnungsZeilen(d) {
const roh = istObjekt(d.e.sonderoeffnungen) ? d.e.sonderoeffnungen : {}
const eintraege = Object.keys(roh)
.filter((tag) => TAG_RE.test(tag) && tag >= d.heuteTag)
.sort()
.map((tag) => `${tagText(tag)}: ${fensterText(roh[tag]) || 'geschlossen'}`)
return aufzaehlung(kuerzeListe(eintraege, MAX_SONDEROEFFNUNGEN))
}
function abschnittRessourcen(d) {
const abwesenheiten = abwesenheitsZeilen(d)
const sonder = sonderoeffnungsZeilen(d)
return neuerAbschnitt('ressourcen', 'Personen und Plätze', [
d.ressourcen.length ? d.ressourcen.map(ressourcenBlock).join('\n') : 'Keine Personen oder Plätze angelegt.',
...(abwesenheiten.length ? [['Abwesenheiten (ab heute):', ...abwesenheiten].join('\n')] : []),
...(sonder.length ? [['Sonderöffnungszeiten (ab heute, für alle):', ...sonder].join('\n')] : []),
].join('\n\n'))
}
// ---------------------------------------------------------------- Abschnitt Regeln
/** Vorlauf in der groessten glatten Einheit: '45 Minuten', '2 Stunden', '1 Tag'; 0 = kein Vorlauf. */
function vorlaufText(minuten) {
if (!Number.isFinite(minuten)) return NICHT_ANGEGEBEN
if (minuten === 0) return 'kein Vorlauf'
if (minuten % 1440 === 0) return anzahl(minuten / 1440, 'Tag', 'Tage')
if (minuten % 60 === 0) return anzahl(minuten / 60, 'Stunde', 'Stunden')
return anzahl(minuten, 'Minute', 'Minuten')
}
function stornoZeilen(storno) {
const text = typeof storno?.text === 'string' ? storno.text.trim() : ''
if (!text) return ['Stornobedingungen: keine hinterlegt']
return [`Stornobedingungen (Fassung ${zeile(storno.version) || 'ohne Angabe'}):`, ...text.split(/\r\n|\r|\n/).map((z) => ` ${z}`)]
}
function abschnittRegeln(d) {
const { e } = d
const r = istObjekt(e.regeln) ? e.regeln : {}
const mit = (wert, f) => (Number.isFinite(wert) ? f(wert) : NICHT_ANGEGEBEN)
return neuerAbschnitt('regeln', 'Regeln', [
`Raster: ${mit(r.raster, (n) => anzahl(n, 'Minute', 'Minuten'))}`,
`Vorlauf: ${vorlaufText(r.vorlaufMin)}`,
`Buchungszeitraum: ${mit(r.horizontTage, (n) => `${anzahl(n, 'Tag', 'Tage')} im Voraus`)}`,
`Absage durch Gäste: ${mit(r.absageBisStd, (n) => (n === 0 ? 'bis zum Terminbeginn' : `bis ${anzahl(n, 'Stunde', 'Stunden')} vor dem Termin`))}`,
`Offene Termine je E-Mail-Adresse: ${mit(r.maxOffeneJeEmail, (n) => `höchstens ${n}`)}`,
`Person wählbar: ${r.personWaehlbar === false ? 'nein' : 'ja'}`,
`Feiertage: ${e.feiertageGeschlossen === false ? 'geöffnet wie im Wochenplan' : 'geschlossen'} (Bundesland ${zeile(e.bundesland) || NICHT_ANGEGEBEN})`,
zahlungText(e),
`Preishinweis: ${preisHinweis(e)}`,
zeigtWiderruf(e)
? 'Widerruf: Widerrufsbelehrung und Widerrufsfunktion werden Gästen angezeigt'
: 'Widerruf: keine Widerrufsbelehrung (Gäste sind keine Verbraucher oder es ist keine Leistung sichtbar)',
...stornoZeilen(e.storno),
].join('\n'))
}
// ---------------------------------------------------------------- Abschnitt Gastfelder
function abschnittGastfelder(d) {
const g = istObjekt(d.e.gastFelder) ? d.e.gastFelder : {}
const zusatz = liste(g.zusatz).filter(istObjekt).map((z) => {
const art = z.typ === 'auswahl' ? `Auswahl: ${liste(z.optionen).map(zeile).filter(Boolean).join(', ')}` : 'Text'
return `${zeile(z.label) || 'ohne Beschriftung'} (${art}; ${z.pflicht === true ? 'Pflicht' : 'optional'})`
})
return neuerAbschnitt('gastfelder', 'Angaben der Gäste', [
'Name: Pflicht',
'E-Mail-Adresse: Pflicht',
`Telefon: ${imWort(GAST_FELD, g.telefon, NICHT_ANGEGEBEN)}`,
`Nachricht: ${imWort(GAST_FELD, g.nachricht, NICHT_ANGEGEBEN)}`,
...(zusatz.length ? [`Zusatzfelder (${zusatz.length}):`, ...aufzaehlung(zusatz)] : ['Zusatzfelder: keine']),
].join('\n'))
}
// ---------------------------------------------------------------- Abschnitt Benachrichtigungen
function abschnittBenachrichtigungen(d) {
const { e } = d
const sichtbar = sichtbareLeistungen(e)
const sofort = sichtbar.some((l) => l.buchungsart !== 'anfrage')
const anfrage = sichtbar.some((l) => l.buchungsart === 'anfrage')
const widerruf = zeigtWiderruf(e)
const erinnerung = e.erinnerung?.anbieten === true && Number.isFinite(e.erinnerung.stundenVorher)
const haltezeit = Number.isFinite(d.haltezeit) ? ` (gültig ${anzahl(d.haltezeit, 'Minute', 'Minuten')})` : ''
return neuerAbschnitt('benachrichtigungen', 'Benachrichtigungen', [
`Gäste werden mit „${e.anrede === 'du' ? 'Du' : 'Sie'}“ angesprochen. Absender ist der Betriebsname (${zeile(d.betrieb.name) || NICHT_ANGEGEBEN}), `
+ 'Antworten gehen an den Betrieb.',
['Mails an Gäste:', ...aufzaehlung([
`Bestätigungslink zur E-Mail-Adresse${haltezeit}`,
...(sofort ? ['Terminbestätigung (sofort buchbare Leistungen)'] : []),
...(anfrage ? ['Eingang und Ablehnung von Terminanfragen (Leistungen mit Anfrage)'] : []),
...(erinnerung ? [`Erinnerung ${anzahl(e.erinnerung.stundenVorher, 'Stunde', 'Stunden')} vor dem Termin (nur wenn der Gast sie ankreuzt)`] : []),
'Absage und Verschiebung',
...(widerruf ? ['Eingangsbestätigung des Widerrufs'] : []),
])].join('\n'),
['Mails an den Betrieb (Adresse aus den Einstellungen, steht nicht in dieser Anleitung):', ...aufzaehlung([
...(sofort ? ['Neue Buchung'] : []),
...(anfrage ? ['Neue Terminanfrage'] : []),
'Absage oder Verschiebung durch den Gast',
...(widerruf ? ['Widerruf'] : []),
])].join('\n'),
'Kalender-Abo (ICS): je Person oder Platz im Portal unter Termine. Der Link ist geheim und steht nicht in dieser Anleitung.',
].join('\n\n'))
}
// ---------------------------------------------------------------- Zusammenbau
const ABSCHNITTE = [abschnittModell, abschnittLeistungen, abschnittRessourcen, abschnittRegeln, abschnittGastfelder, abschnittBenachrichtigungen]
/** Alle Abschnitte mit Ueberschrift '## Titel', durch eine Leerzeile getrennt. */
const volltext = (abschnitte) => abschnitte.map((a) => `## ${a.titel}\n${a.text}`).join('\n\n')
/** 'Projektname (Domain)' | 'Projektname' | 'Domain' | '' (Domain = liveDomain, sonst previewUrl, sonst subdomain). */
function projektText(project) {
const name = zeile(project?.projectName) || zeile(project?.subdomain)
const domain = zeile(project?.liveDomain) || zeile(project?.previewUrl) || zeile(project?.subdomain)
return name && domain && name !== domain ? `${name} (${domain})` : name || domain
}
/**
* Anleitung fuer den Einbau des Terminplaners bei einem Betrieb (Team-Sicht): rein, kein I/O, gleiche Eingabe und gleiches `now`
* ergeben gleiche Ausgabe (deepEqual). config wird beim Aufruf gelesen.
*
* @param {object} p
* @param {object} p.feature portalFeatures-Dokument der Funktion (D3b: siteKey, unlockedByPurchase; der Registereintrag
* config.features.termine wird nicht gebraucht)
* @param {object} [p.project] websiteProjects-Dokument (gelesen: projectName, subdomain, liveDomain, previewUrl)
* @param {object|null} p.einstellung gespeicherte Einstellung (termineConfig.einstellung); null = noch keine
* @param {object|null} [p.fragebogen] geparster Fragebogen oder null (D3b: Kundenwuensche zum Einbau)
* @param {number} p.version Version der Einstellungen (0 = noch nie gespeichert)
* @param {Date} p.now Zeitpunkt der Erzeugung; ein ungueltiges now wirft einen TypeError
* @param {string|null} [p.updatedAt] ISO-Zeitpunkt der letzten Aenderung der Einstellungen
* @param {{name?: string}|null} [p.kunde] gelesen wird nur der Name
* @param {boolean} [p.entwurf] D3b: Rechtstexte sind noch ENTWURF (Warnung); Standard RECHTSTEXTE_ENTWURF
* @returns {{stand: {version: number, updatedAt: string|null}, warnungen: string[], abschnitte: Array<{id: string, titel: string, text: string, kopierbar: boolean}>, text: string, kurz: string}}
* D3a: warnungen [] und kurz '' (kommen mit D3b und D4); ohne Einstellung keine Abschnitte. Die Anrede der Gaeste kommt aus einstellung.anrede.
*/
export function einbauanleitung({ feature, project, einstellung, fragebogen, version, now, updatedAt = null, kunde = null, entwurf = RECHTSTEXTE_ENTWURF }) {
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError('einbauanleitung: now fehlt')
const stand = { version, updatedAt }
if (!istObjekt(einstellung)) return { stand, warnungen: [], abschnitte: [], text: '', kurz: '' }
const ressourcen = liste(einstellung.ressourcen).filter(istObjekt)
const geaendert = updatedAt ? new Date(updatedAt) : null
const daten = {
e: einstellung,
now,
heuteTag: heute(now),
version,
geaendert: geaendert && !Number.isNaN(geaendert.getTime()) ? datumKurz(geaendert) : '',
betrieb: istObjekt(einstellung.betrieb) ? einstellung.betrieb : {},
kunde: zeile(kunde?.name),
projekt: projektText(project),
farbe: typeof einstellung.farbe === 'string' && FARBE_RE.test(einstellung.farbe) ? einstellung.farbe.toLowerCase() : VORGABE_FARBE,
portal: zeile(config.publicBaseUrl),
haltezeit: config.termine?.holdMinutes,
ressourcen,
leistungen: liste(einstellung.leistungen).filter(istObjekt),
nachId: new Map(ressourcen.map((r) => [r.id, { typ: r.typ, name: zeile(r.name) || 'ohne Namen' }])),
}
const abschnitte = ABSCHNITTE.map((baue) => baue(daten))
return { stand, warnungen: [], abschnitte, text: volltext(abschnitte), kurz: '' }
}
+482 -2
View File
@@ -1,11 +1,16 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { VORGABE_FARBE } from '../server/services/termine/einstellungen.js'
import { cssVorschlag, einbauSnippet, esc, kontrast, luminanz, textFarbeAuf } from '../server/services/termine/einbauanleitung.js'
import { config } from '../server/config.js'
import { VORGABE_FARBE, neuerFeedToken } from '../server/services/termine/einstellungen.js'
import { ausVorlage } from '../server/services/termine/vorlagen.js'
import {
cssVorschlag, einbauanleitung, einbauSnippet, esc, kontrast, kuerzeListe, luminanz, textFarbeAuf, wochenplanText,
} from '../server/services/termine/einbauanleitung.js'
// Einbau-Snippet und Farbvorschlag des Terminplaners (TP2 Task D2). Reine Funktionen ohne Appwrite und ohne config.
// Das Snippet war bis D2 in server/routes/termine.js; die Aufrufstelle und ihre Antwort-Tests stehen in termineRoutes.test.js.
// Der Generator einbauanleitung() (Task D3a, ganz unten) liest config nur beim Aufruf (Portal-Adresse), sonst ist auch er rein.
const SITE_KEY = 'abcdefghijklmnop'
const API = 'https://project.webklar.com'
@@ -204,3 +209,478 @@ test('cssVorschlag: der Hinweis nennt den Kontrast gegen Weiss, wenn der Akzent
assert.equal(cssVorschlag(farbe).hinweise.length, k < 3 ? 1 : 0, `${farbe} (${k.toFixed(3)} : 1)`)
}
})
// ================================================================ Generator einbauanleitung() 1 (Task D3a)
// Modell, Leistungen, Ressourcen, Regeln, Gastfelder, Benachrichtigungen. Rein: kein I/O, nur ausdruecklich benannte Felder.
const NOW = new Date('2026-10-05T10:00:00Z') // Montag, 12:00 Berliner Zeit
const SPAETER = new Date('2026-10-06T10:00:00Z') // einen Tag spaeter: nur die "Erzeugt am"-Zeile aendert sich
const GEHEIM = 'XYZ-GEHEIM'
const BETRIEB = {
name: 'Salon Muster',
email: 'inhaber-intern@salon-muster.example',
telefon: '0631 123456',
adresse: 'Hauptstraße 1, 67655 Kaiserslautern',
kleinunternehmer: false,
impressumUrl: 'https://salon-muster.example/impressum',
datenschutzUrl: 'https://salon-muster.example/datenschutz',
bundesland: 'RP',
}
/** Salon-Vorlage mit Farbe, zwei Personen und einem Platz, Wochenplan, Abwesenheiten, Sonderoeffnungen und einer Anfrage-Leistung. */
function fixtureEinstellung() {
const e = ausVorlage('salon', BETRIEB, NOW)
e.farbe = '#9d4b6f'
const [anna] = e.ressourcen
anna.name = 'Anna'
anna.plan = { 1: [['09:00', '12:30'], ['14:00', '18:00']], 3: [['09:00', '12:30']], 7: [['10:00', '12:00']] }
e.ressourcen.push(
{ id: 'r_ben00001', typ: 'person', name: 'Ben', aktiv: true, plan: { 2: [['08:00', '16:00']] }, feedToken: neuerFeedToken() },
{ id: 'r_stuhl001', typ: 'platz', name: 'Stuhl 2', aktiv: false, plan: {}, feedToken: neuerFeedToken() },
)
e.leistungen[0].ressourcen = [anna.id, 'r_ben00001']
e.leistungen[1].aktiv = false // Damenschnitt
e.leistungen[2].buchungsart = 'anfrage'
e.leistungen[2].preisArt = 'keiner'
e.leistungen[2].preisCent = 0
e.abwesenheiten = [
{ id: 'a_alt00001', ressourceId: anna.id, von: '2026-09-28', bis: '2026-10-02', titel: 'Urlaub vorbei' },
{ id: 'a_jetzt001', ressourceId: 'r_ben00001', von: '2026-10-05', bis: '2026-10-05', titel: 'Fortbildung' },
{ id: 'a_laeuft01', ressourceId: anna.id, von: '2026-10-01', bis: '2026-10-07', titel: 'Kur' },
{ id: 'a_zukunft1', ressourceId: null, von: '2026-12-24', bis: '2026-12-31', titel: 'Betriebsferien' },
]
e.sonderoeffnungen = { '2026-10-03': [['09:00', '12:00']], '2026-12-24': [], '2026-11-07': [['10:00', '14:00']] }
e.gastFelder = {
telefon: 'pflicht',
nachricht: 'optional',
zusatz: [
{ key: 'haare', label: 'Haarlänge', typ: 'auswahl', optionen: ['kurz', 'mittel', 'lang'], pflicht: true },
{ key: 'wunsch', label: 'Wunschfrisur', typ: 'text', optionen: [], pflicht: false },
],
}
return e
}
const PROJEKT = { projectName: 'Salon Muster Website', subdomain: 'salon-muster', liveDomain: 'salon-muster.example', previewUrl: 'https://salon-muster.project.webklar.com' }
/** Standard-Aufruf des Generators; Abweichungen ueber `zusatz`. */
function erzeuge(zusatz = {}) {
return einbauanleitung({
feature: { $id: 'f1', customerId: 'c1', projectId: 'p1', siteKey: SITE_KEY, state: 'setup' },
project: PROJEKT,
einstellung: fixtureEinstellung(),
fragebogen: null,
version: 3,
updatedAt: '2026-10-03T08:15:00.000Z',
now: NOW,
kunde: { name: 'Salon Muster GbR' },
...zusatz,
})
}
const abschnitt = (ergebnis, id) => ergebnis.abschnitte.find((a) => a.id === id)
const zeilen = (text) => text.split('\n')
test('D3a: Ergebnisform, die sechs Abschnitte in fester Reihenfolge, keiner kopierbar', () => {
const r = erzeuge()
assert.deepEqual(Object.keys(r).sort(), ['abschnitte', 'kurz', 'stand', 'text', 'warnungen'])
assert.deepEqual(r.abschnitte.map((a) => a.id), ['modell', 'leistungen', 'ressourcen', 'regeln', 'gastfelder', 'benachrichtigungen'])
for (const a of r.abschnitte) {
assert.deepEqual(Object.keys(a).sort(), ['id', 'kopierbar', 'text', 'titel'], a.id)
assert.equal(a.kopierbar, false, a.id)
assert.ok(a.titel && typeof a.titel === 'string', a.id)
assert.ok(a.text && typeof a.text === 'string', a.id)
}
assert.deepEqual(r.warnungen, [], 'Warnungen kommen mit D3b')
assert.equal(r.kurz, '', 'die Kurzfassung kommt mit D4')
// der Volltext fuehrt jeden Abschnitt mit Ueberschrift
assert.deepEqual(zeilen(r.text).filter((z) => z.startsWith('## ')), r.abschnitte.map((a) => `## ${a.titel}`))
for (const a of r.abschnitte) assert.ok(r.text.includes(a.text), a.id)
})
test('D3a: stand ist genau { version, updatedAt }, updatedAt ist ohne Angabe null', () => {
assert.deepEqual(erzeuge().stand, { version: 3, updatedAt: '2026-10-03T08:15:00.000Z' })
assert.deepEqual(erzeuge({ updatedAt: undefined }).stand, { version: 3, updatedAt: null })
assert.deepEqual(erzeuge({ version: 0, updatedAt: undefined }).stand, { version: 0, updatedAt: null })
})
test('D3a Modell: Betrieb, Kontakt, Adressen, Farbe, Anrede, Portal-Adresse, Erzeugt-am und Stand der Einstellungen', () => {
const modell = abschnitt(erzeuge(), 'modell').text
for (const erwartet of [
'Erzeugt am 05.10.2026',
'Stand der Einstellungen: Version 3, zuletzt geändert 03.10.2026',
'Kunde: Salon Muster GbR',
'Projekt: Salon Muster Website (salon-muster.example)',
'Betrieb: Salon Muster',
'Anschrift: Hauptstraße 1, 67655 Kaiserslautern',
'Telefon: 0631 123456',
'Impressum: https://salon-muster.example/impressum',
'Datenschutzerklärung: https://salon-muster.example/datenschutz',
'Farbe: #9d4b6f',
'Anrede der Gäste: Sie',
'Gäste: Privatpersonen (Verbraucher)',
`Portal-Adresse (data-api): ${config.publicBaseUrl}`,
'Umfang: 3 Personen und Plätze (2 aktiv), 4 Leistungen (3 aktiv)',
]) assert.ok(zeilen(modell).includes(erwartet), `${erwartet}\n---\n${modell}`)
})
test('D3a Modell: fehlende Angaben stehen als nicht angegeben, Du-Anrede, Projekt ohne Domain, Version 0, keine Kundenzeile', () => {
const e = fixtureEinstellung()
e.betrieb.telefon = ''
e.betrieb.adresse = ''
e.betrieb.impressumUrl = null
e.betrieb.datenschutzUrl = null
e.anrede = 'du'
e.recht = { gaesteSind: 'gemischt', freizeit: true }
const modell = abschnitt(erzeuge({ einstellung: e, project: { projectName: 'Nur Name' }, kunde: null, version: 0, updatedAt: null }), 'modell').text
const z = zeilen(modell)
for (const erwartet of [
'Betrieb: Salon Muster',
'Anschrift: nicht angegeben',
'Telefon: nicht angegeben',
'Impressum: nicht angegeben',
'Datenschutzerklärung: nicht angegeben',
'Anrede der Gäste: Du',
'Gäste: Privatpersonen und Unternehmen, Freizeitleistung',
'Projekt: Nur Name',
'Stand der Einstellungen: keine gespeicherte Version',
]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${modell}`)
assert.ok(!z.some((x) => x.startsWith('Kunde:')), 'ohne Kunde keine Kundenzeile')
})
test('D3a Modell: AGB- und Website-Adresse nur, wenn vorhanden; die Portal-Adresse wird beim Aufruf gelesen', () => {
const ohne = abschnitt(erzeuge(), 'modell').text
assert.ok(!/AGB|Website:/.test(ohne), ohne)
const e = fixtureEinstellung()
e.betrieb.agbUrl = 'https://salon-muster.example/agb'
e.betrieb.websiteUrl = 'https://salon-muster.example/'
const mit = zeilen(abschnitt(erzeuge({ einstellung: e }), 'modell').text)
assert.ok(mit.includes('AGB: https://salon-muster.example/agb'))
assert.ok(mit.includes('Website: https://salon-muster.example/'))
const vorher = config.publicBaseUrl
try {
config.publicBaseUrl = 'https://portal.test.example'
assert.ok(zeilen(abschnitt(erzeuge(), 'modell').text).includes('Portal-Adresse (data-api): https://portal.test.example'))
} finally {
config.publicBaseUrl = vorher
}
})
test('D3a Leistungen: Name, Dauer, Preis, Buchungsart, Personen; inaktive und Anfrage-Leistungen markiert', () => {
const text = abschnitt(erzeuge(), 'leistungen').text
const z = zeilen(text)
assert.equal(z[0], '4 Leistungen (3 aktiv):')
assert.ok(z.includes('- Herrenschnitt: 30 Min. (+ 5 Min. Pause danach), 28,00 €, sofort buchbar, Personen: Anna, Ben'), text)
assert.ok(z.includes('- Damenschnitt (inaktiv): 45 Min. (+ 5 Min. Pause danach), ab 45,00 €, sofort buchbar, Personen: Anna'), text)
assert.ok(z.includes('- Kinderschnitt: 30 Min. (+ 5 Min. Pause danach), kein Preis, nur als Anfrage, Personen: Anna'), text)
assert.ok(z.includes('- Färben: 120 Min. (+ 10 Min. Pause danach), ab 75,00 €, sofort buchbar, Personen: Anna'), text)
assert.equal(z.filter((x) => x.includes('(inaktiv)')).length, 1, 'nur die inaktive Leistung ist markiert')
})
test('D3a Leistungen: Plaetze heissen Plaetze, gemischt Personen und Plaetze, ohne Pause keine Klammer, unbekannte Zuordnung faellt weg', () => {
const e = fixtureEinstellung()
e.leistungen[0].ressourcen = ['r_stuhl001']
e.leistungen[1].ressourcen = [e.ressourcen[0].id, 'r_stuhl001', 'r_gibtsnicht']
e.leistungen[3].pufferNach = 0
e.leistungen[3].ressourcen = []
const z = zeilen(abschnitt(erzeuge({ einstellung: e }), 'leistungen').text)
assert.ok(z.some((x) => x.startsWith('- Herrenschnitt:') && x.endsWith('Plätze: Stuhl 2')), z.join('\n'))
assert.ok(z.some((x) => x.startsWith('- Damenschnitt (inaktiv):') && x.endsWith('Personen und Plätze: Anna, Stuhl 2')), z.join('\n'))
assert.ok(z.includes('- Färben: 120 Min., ab 75,00 €, sofort buchbar, keine Person und kein Platz zugeordnet'), z.join('\n'))
})
test('D3a Ressourcen: Wochenplan je Tag, nur kuenftige Abwesenheiten, Sonderoeffnungen ab heute', () => {
const text = abschnitt(erzeuge(), 'ressourcen').text
const z = zeilen(text)
assert.ok(z.includes('- Anna (Person)'), text)
assert.ok(z.includes(' Mo 09:00–12:30, 14:00–18:00'), text)
assert.ok(z.includes(' Mi 09:00–12:30'), text)
assert.ok(z.includes(' So 10:00–12:00'), text)
assert.ok(z.includes('- Ben (Person)') && z.includes(' Di 08:00–16:00'), text)
assert.ok(z.includes('- Stuhl 2 (Platz, inaktiv)'), text)
assert.ok(z.includes(' keine Zeiten eingetragen'), text)
// Abwesenheiten: abgelaufene fehlen, heute endende und laufende bleiben, nach Beginn sortiert
const ab = z.slice(z.indexOf('Abwesenheiten (ab heute):') + 1).filter((x) => x.startsWith('- ') && /Fortbildung|Kur|Betriebsferien|Urlaub/.test(x))
assert.deepEqual(ab, [
'- 01.10.2026–07.10.2026: Kur (Anna)',
'- 05.10.2026: Fortbildung (Ben)',
'- 24.12.2026–31.12.2026: Betriebsferien (alle)',
])
assert.ok(!text.includes('Urlaub vorbei'), 'die abgelaufene Abwesenheit steht nirgends')
// Sonderoeffnungen: der 03.10. liegt vor heute und faellt weg, der 07.11. kommt vor dem 24.12.
const so = z.slice(z.indexOf('Sonderöffnungszeiten (ab heute, für alle):') + 1).filter((x) => x.startsWith('- '))
assert.deepEqual(so.slice(-2), ['- 07.11.2026: 10:00–14:00', '- 24.12.2026: geschlossen'])
assert.ok(!text.includes('03.10.2026'))
})
test('D3a Ressourcen: ohne Abwesenheiten und Sonderoeffnungen entfallen die Ueberschriften; ohne Ressourcen steht ein Hinweis', () => {
const e = fixtureEinstellung()
e.abwesenheiten = [{ id: 'a_alt00001', ressourceId: null, von: '2026-01-01', bis: '2026-01-02', titel: 'alt' }]
e.sonderoeffnungen = { '2026-01-01': [] }
const text = abschnitt(erzeuge({ einstellung: e }), 'ressourcen').text
assert.ok(!text.includes('Abwesenheiten'), text)
assert.ok(!text.includes('Sonderöffnungszeiten'), text)
e.ressourcen = []
assert.equal(abschnitt(erzeuge({ einstellung: e }), 'ressourcen').text, 'Keine Personen oder Plätze angelegt.')
})
test('D3a Regeln: Raster, Vorlauf, Horizont, Absagefrist, Obergrenze, Storno-Text mit Fassung, Zahlung', () => {
const e = fixtureEinstellung()
const text = abschnitt(erzeuge({ einstellung: e }), 'regeln').text
const z = zeilen(text)
for (const erwartet of [
'Raster: 15 Minuten',
'Vorlauf: 2 Stunden',
'Buchungszeitraum: 60 Tage im Voraus',
'Absage durch Gäste: bis 24 Stunden vor dem Termin',
'Offene Termine je E-Mail-Adresse: höchstens 3',
'Person wählbar: ja',
'Feiertage: geschlossen (Bundesland RP)',
'Bezahlung vor Ort: bar, EC-Karte, Kreditkarte',
'Preishinweis: inkl. USt',
'Widerruf: Widerrufsbelehrung und Widerrufsfunktion werden Gästen angezeigt',
`Stornobedingungen (Fassung ${e.storno.version}):`,
]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${text}`)
assert.match(e.storno.version, /^\d{4}-\d{2}-\d{2}-\d+$/)
assert.ok(text.includes(` ${e.storno.text}`), 'der Storno-Text steht vollstaendig, eingerueckt')
})
test('D3a Regeln: andere Werte, kein Storno-Text, Unternehmen ohne Widerruf, Kleinunternehmer, keine Zahlarten', () => {
const e = fixtureEinstellung()
e.regeln = { raster: 30, vorlaufMin: 0, horizontTage: 14, absageBisStd: 0, maxOffeneJeEmail: 1, personWaehlbar: false }
e.feiertageGeschlossen = false
e.storno = { text: '', version: '2026-10-01-2' }
e.recht = { gaesteSind: 'unternehmer', freizeit: false }
e.betrieb.kleinunternehmer = true
e.zahlung = { modus: 'vorort', vorOrtArten: [] }
const z = zeilen(abschnitt(erzeuge({ einstellung: e }), 'regeln').text)
for (const erwartet of [
'Raster: 30 Minuten',
'Vorlauf: kein Vorlauf',
'Buchungszeitraum: 14 Tage im Voraus',
'Absage durch Gäste: bis zum Terminbeginn',
'Offene Termine je E-Mail-Adresse: höchstens 1',
'Person wählbar: nein',
'Feiertage: geöffnet wie im Wochenplan (Bundesland RP)',
'Bezahlung vor Ort',
'Preishinweis: kein Ausweis der USt nach § 19 UStG',
'Widerruf: keine Widerrufsbelehrung (Gäste sind keine Verbraucher oder es ist keine Leistung sichtbar)',
'Stornobedingungen: keine hinterlegt',
]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${z.join('\n')}`)
assert.ok(!z.join('\n').includes('2026-10-01-2'), 'ohne Text keine Fassung im Text')
})
test('D3a Regeln: Vorlauf in Minuten, Stunden und Tagen, Absagefrist in Tagen', () => {
const vorlauf = (minuten) => {
const e = fixtureEinstellung()
e.regeln.vorlaufMin = minuten
return zeilen(abschnitt(erzeuge({ einstellung: e }), 'regeln').text).find((x) => x.startsWith('Vorlauf:'))
}
assert.equal(vorlauf(45), 'Vorlauf: 45 Minuten')
assert.equal(vorlauf(60), 'Vorlauf: 1 Stunde')
assert.equal(vorlauf(90), 'Vorlauf: 90 Minuten')
assert.equal(vorlauf(1440), 'Vorlauf: 1 Tag')
assert.equal(vorlauf(20160), 'Vorlauf: 14 Tage')
})
test('D3a Gastfelder: Name und E-Mail immer Pflicht, Telefon und Nachricht nach Einstellung, Zusatzfelder mit Auswahl', () => {
const text = abschnitt(erzeuge(), 'gastfelder').text
const z = zeilen(text)
for (const erwartet of [
'Name: Pflicht',
'E-Mail-Adresse: Pflicht',
'Telefon: Pflicht',
'Nachricht: optional',
'Zusatzfelder (2):',
'- Haarlänge (Auswahl: kurz, mittel, lang; Pflicht)',
'- Wunschfrisur (Text; optional)',
]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${text}`)
const e = fixtureEinstellung()
e.gastFelder = { telefon: 'aus', nachricht: 'aus', zusatz: [] }
const leer = zeilen(abschnitt(erzeuge({ einstellung: e }), 'gastfelder').text)
assert.ok(leer.includes('Telefon: aus') && leer.includes('Nachricht: aus') && leer.includes('Zusatzfelder: keine'), leer.join('\n'))
})
test('D3a Benachrichtigungen: Mails an Gaeste nach Konfiguration, Mails an den Betrieb ohne Adresse, Kalender-Abo ohne Link', () => {
const text = abschnitt(erzeuge(), 'benachrichtigungen').text
const z = zeilen(text)
for (const erwartet of [
'Gäste werden mit „Sie“ angesprochen. Absender ist der Betriebsname (Salon Muster), Antworten gehen an den Betrieb.',
'Mails an Gäste:',
'- Bestätigungslink zur E-Mail-Adresse (gültig 30 Minuten)',
'- Terminbestätigung (sofort buchbare Leistungen)',
'- Eingang und Ablehnung von Terminanfragen (Leistungen mit Anfrage)',
'- Erinnerung 24 Stunden vor dem Termin (nur wenn der Gast sie ankreuzt)',
'- Absage und Verschiebung',
'- Eingangsbestätigung des Widerrufs',
'Mails an den Betrieb (Adresse aus den Einstellungen, steht nicht in dieser Anleitung):',
'- Neue Buchung',
'- Neue Terminanfrage',
'- Absage oder Verschiebung durch den Gast',
'- Widerruf',
]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${text}`)
assert.ok(text.includes('Kalender-Abo (ICS)') && text.includes('im Portal'), text)
assert.ok(!text.includes(BETRIEB.email), 'die Betriebs-E-Mail steht nicht in der Anleitung')
// die Gueltigkeit des Bestaetigungslinks wird beim Aufruf aus der Konfiguration gelesen
const vorher = config.termine.holdMinutes
try {
config.termine.holdMinutes = 45
assert.ok(zeilen(abschnitt(erzeuge(), 'benachrichtigungen').text).includes('- Bestätigungslink zur E-Mail-Adresse (gültig 45 Minuten)'))
} finally {
config.termine.holdMinutes = vorher
}
})
test('D3a Benachrichtigungen: ohne Erinnerung, ohne Anfrage-Leistung, ohne Widerruf und mit Du-Anrede entfallen die Zeilen', () => {
const e = fixtureEinstellung()
e.anrede = 'du'
e.erinnerung = { anbieten: false, stundenVorher: 24 }
e.leistungen[2].buchungsart = 'sofort'
e.recht = { gaesteSind: 'unternehmer', freizeit: false }
const text = abschnitt(erzeuge({ einstellung: e }), 'benachrichtigungen').text
const z = zeilen(text)
assert.ok(text.startsWith('Gäste werden mit „Du“ angesprochen.'), text)
for (const fehlt of ['- Eingang und Ablehnung von Terminanfragen (Leistungen mit Anfrage)', '- Neue Terminanfrage', '- Eingangsbestätigung des Widerrufs', '- Widerruf']) {
assert.ok(!z.includes(fehlt), `${fehlt}\n---\n${text}`)
}
assert.ok(!z.some((x) => x.startsWith('- Erinnerung')), text)
assert.ok(z.includes('- Neue Buchung') && z.includes('- Absage oder Verschiebung durch den Gast'), text)
// nur Anfrage-Leistungen: keine Terminbestaetigung und keine "Neue Buchung"
e.leistungen.forEach((l) => { l.buchungsart = 'anfrage' })
const nurAnfrage = zeilen(abschnitt(erzeuge({ einstellung: e }), 'benachrichtigungen').text)
assert.ok(!nurAnfrage.includes('- Terminbestätigung (sofort buchbare Leistungen)') && !nurAnfrage.includes('- Neue Buchung'))
assert.ok(nurAnfrage.includes('- Neue Terminanfrage'))
// inaktive Leistungen zaehlen nicht
const f = fixtureEinstellung()
f.leistungen[2].aktiv = false
assert.ok(!zeilen(abschnitt(erzeuge({ einstellung: f }), 'benachrichtigungen').text).includes('- Neue Terminanfrage'))
})
test('D3a feedToken: jede Ressource hat einen echten Token, das Ergebnis enthaelt weder einen Token noch das Wort feedToken', () => {
const e = fixtureEinstellung()
assert.ok(e.ressourcen.length >= 3)
for (const r of e.ressourcen) assert.match(r.feedToken, /^[A-Za-z0-9_-]{43}$/)
const json = JSON.stringify(erzeuge({ einstellung: e }))
assert.ok(!json.includes('feedToken'))
assert.ok(!json.toLowerCase().includes('feedtoken'))
for (const r of e.ressourcen) assert.ok(!json.includes(r.feedToken), `Token von ${r.name} steht im Ergebnis`)
})
test('D3a Weissliste: Zusatzfelder an Betrieb, Ressource, Leistung, Regeln und Kopf tauchen nirgends auf', () => {
const e = fixtureEinstellung()
e.geheim = GEHEIM
e.betrieb.geheim = GEHEIM
e.regeln.geheim = GEHEIM
e.gastFelder.geheim = GEHEIM
e.gastFelder.zusatz[0].geheim = GEHEIM
e.storno.geheim = GEHEIM
e.ressourcen[0].geheim = GEHEIM
e.ressourcen[0].notiz = 'NOTIZ-INTERN'
e.leistungen[0].geheim = GEHEIM
e.leistungen[0].notiz = 'NOTIZ-INTERN'
e.leistungen[0].beschreibung = 'BESCHREIBUNG-NUR-FUER-GAESTE'
e.abwesenheiten[3].geheim = GEHEIM
const json = JSON.stringify(erzeuge({
einstellung: e,
feature: { $id: 'f1', customerId: 'c1', projectId: 'p1', siteKey: SITE_KEY, geheim: GEHEIM, feedToken: GEHEIM, adminNote: GEHEIM },
project: { ...PROJEKT, geheim: GEHEIM, apiKey: GEHEIM },
kunde: { name: 'Salon Muster GbR', email: GEHEIM, geheim: GEHEIM },
fragebogen: { geheim: GEHEIM },
}))
for (const verboten of [GEHEIM, 'NOTIZ-INTERN', 'BESCHREIBUNG-NUR-FUER-GAESTE', BETRIEB.email]) assert.ok(!json.includes(verboten), verboten)
})
test('D3a Kundentexte stehen unveraendert als Text und koennen keine Abschnitts-Ueberschrift vortaeuschen', () => {
const e = fixtureEinstellung()
e.betrieb.name = 'Evil\n## Regeln\nRaster: 1'
e.ressourcen[0].name = '<img src=x onerror=alert(1)> & "Anna"'
e.leistungen[0].name = 'Schnitt\r\n## Gastfelder'
e.abwesenheiten[3].titel = 'Ferien\n## Modell'
const r = erzeuge({ einstellung: e, kunde: { name: 'Kunde\n## Leistungen' }, project: { projectName: 'Projekt\n## Ressourcen' } })
assert.equal(zeilen(r.text).filter((z) => z.startsWith('## ')).length, 6, r.text)
assert.ok(r.text.includes('<img src=x onerror=alert(1)> & "Anna"'), 'kein Maskieren: der Anzeiger setzt es als Text ein')
assert.ok(r.text.includes('Betrieb: Evil ## Regeln Raster: 1'), r.text)
assert.equal(zeilen(r.text).filter((z) => z.startsWith('Raster:')).length, 1)
})
test('D3a ist deterministisch und veraendert die Eingabe nicht', () => {
const e = fixtureEinstellung()
// Abwesenheiten absichtlich unsortiert: der Generator sortiert, darf dabei die Eingabe aber nicht umordnen
e.abwesenheiten.reverse()
const vorher = structuredClone(e)
const a = erzeuge({ einstellung: e })
assert.deepEqual(e, vorher, 'nach dem ersten Aufruf: die Einstellung bleibt unveraendert (kein Sortieren oder Filtern in place)')
const b = erzeuge({ einstellung: e })
assert.deepEqual(e, vorher, 'nach dem zweiten Aufruf ebenso')
assert.deepEqual(a, b)
})
test('D3a ein anderes now aendert nur die Erzeugt-am-Zeile und die Filterung vergangener Dinge', () => {
const normal = (r) => ({ ...r, text: r.text.replace(/^Erzeugt am .*$/m, 'ERZEUGT'), abschnitte: r.abschnitte.map((a) => ({ ...a, text: a.text.replace(/^Erzeugt am .*$/m, 'ERZEUGT') })) })
// einen Tag spaeter, ohne dass etwas ablaeuft (die Fortbildung liegt hier erst am 20.10.): nur die Erzeugt-am-Zeile unterscheidet
const spaeterEnde = fixtureEinstellung()
spaeterEnde.abwesenheiten[1].von = '2026-10-20'
spaeterEnde.abwesenheiten[1].bis = '2026-10-20'
const eins = erzeuge({ einstellung: spaeterEnde })
const zwei = erzeuge({ now: SPAETER, einstellung: spaeterEnde })
assert.notDeepEqual(eins, zwei)
assert.ok(abschnitt(eins, 'modell').text.includes('Erzeugt am 05.10.2026'))
assert.ok(abschnitt(zwei, 'modell').text.includes('Erzeugt am 06.10.2026'))
assert.deepEqual(normal(eins), normal(zwei), 'sonst bleibt alles gleich')
// mit der Fixture-Abwesenheit vom 05.10. faellt sie am Folgetag weg (bis < heute)
const a = erzeuge()
const b = erzeuge({ now: SPAETER })
assert.ok(abschnitt(a, 'ressourcen').text.includes('Fortbildung'))
assert.ok(!abschnitt(b, 'ressourcen').text.includes('Fortbildung'))
assert.ok(abschnitt(b, 'ressourcen').text.includes('Kur'), 'die laufende Kur bleibt')
// zwei Monate spaeter: vergangene Abwesenheiten und Sonderoeffnungen fehlen, sonst bleibt alles gleich
const spaet = erzeuge({ now: new Date('2026-12-20T10:00:00Z') })
const ressourcen = abschnitt(spaet, 'ressourcen').text
assert.ok(!ressourcen.includes('Kur') && !ressourcen.includes('Fortbildung') && !ressourcen.includes('07.11.2026'), ressourcen)
assert.ok(ressourcen.includes('Betriebsferien') && ressourcen.includes('24.12.2026: geschlossen'), ressourcen)
for (const id of ['leistungen', 'regeln', 'gastfelder', 'benachrichtigungen']) assert.equal(abschnitt(spaet, id).text, abschnitt(a, id).text, id)
// ein Abwesenheitstag, der in Berlin schon der naechste Tag ist, zaehlt nach Berliner Kalender (23:30 UTC = 01:30 Berlin am 06.10.)
const nachtgrenze = erzeuge({ now: new Date('2026-10-05T23:30:00Z') })
assert.ok(!abschnitt(nachtgrenze, 'ressourcen').text.includes('Fortbildung'), 'der 05.10. ist in Berlin schon vorbei')
assert.ok(abschnitt(nachtgrenze, 'modell').text.includes('Erzeugt am 06.10.2026'))
})
test('D3a ohne Einstellung wirft nichts und liefert keine Abschnitte; ein ungueltiges now wirft', () => {
const r = erzeuge({ einstellung: null, version: 0, updatedAt: null })
assert.deepEqual(r.abschnitte, [])
assert.equal(r.text, '')
assert.deepEqual(r.stand, { version: 0, updatedAt: null })
for (const now of [undefined, null, 'heute', new Date('kaputt'), 1.7e12]) {
assert.throws(() => erzeuge({ now }), TypeError, String(now))
}
})
test('D3a Hilfen: wochenplanText und kuerzeListe', () => {
assert.deepEqual(wochenplanText({ 1: [['09:00', '12:30'], ['14:00', '18:00']], 5: [['09:00', '13:00']] }), ['Mo 09:00–12:30, 14:00–18:00', 'Fr 09:00–13:00'])
assert.deepEqual(wochenplanText({ 7: [['10:00', '12:00']], 2: [] }), ['So 10:00–12:00'], 'leere Tage fehlen, Reihenfolge Montag bis Sonntag')
assert.deepEqual(wochenplanText({}), [])
assert.deepEqual(wochenplanText(undefined), [])
assert.deepEqual(wochenplanText({ 1: 'kaputt', 8: [['09:00', '10:00']], 3: [['09:00']] }), [], 'Unbrauchbares wird uebersprungen')
assert.deepEqual(kuerzeListe(['a', 'b', 'c'], 3), ['a', 'b', 'c'])
assert.deepEqual(kuerzeListe(['a', 'b', 'c', 'd', 'e'], 2), ['a', 'b', '… und 3 weitere'])
assert.deepEqual(kuerzeListe(['a', 'b', 'c'], 2), ['a', 'b', '… und 1 weitere'])
assert.deepEqual(kuerzeListe([], 5), [])
})
test('D3a Kuerzen: lange Abwesenheits- und Sonderoeffnungslisten enden mit und N weitere, Leistungen stehen vollstaendig', () => {
const e = fixtureEinstellung()
e.abwesenheiten = Array.from({ length: 30 }, (_, i) => ({
id: `a_x${String(i).padStart(7, '0')}`, ressourceId: null, von: `2027-01-${String(i + 1).padStart(2, '0')}`, bis: `2027-01-${String(i + 1).padStart(2, '0')}`, titel: `Tag ${i + 1}`,
}))
e.sonderoeffnungen = Object.fromEntries(Array.from({ length: 25 }, (_, i) => [`2027-02-${String(i + 1).padStart(2, '0')}`, [['10:00', '12:00']]]))
e.leistungen = Array.from({ length: 50 }, (_, i) => ({ ...e.leistungen[0], id: `s_l${String(i).padStart(6, '0')}`, name: `Leistung ${i + 1}` }))
const r = erzeuge({ einstellung: e })
const z = zeilen(abschnitt(r, 'ressourcen').text)
assert.equal(z.filter((x) => /^- \d\d\.01\.2027/.test(x)).length, 20)
assert.ok(z.includes('- … und 10 weitere'), z.join('\n'))
assert.equal(z.filter((x) => /^- \d\d\.02\.2027/.test(x)).length, 20)
assert.ok(z.includes('- … und 5 weitere'), z.join('\n'))
const l = zeilen(abschnitt(r, 'leistungen').text)
assert.equal(l[0], '50 Leistungen (50 aktiv):')
assert.equal(l.filter((x) => x.startsWith('- Leistung ')).length, 50)
})