diff --git a/server/services/termine/einbauanleitung.js b/server/services/termine/einbauanleitung.js index d13541b..fa91905 100644 --- a/server/services/termine/einbauanleitung.js +++ b/server/services/termine/einbauanleitung.js @@ -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: '' } +} diff --git a/test/termineEinbauanleitung.test.js b/test/termineEinbauanleitung.test.js index 005fc79..115c8c2 100644 --- a/test/termineEinbauanleitung.test.js +++ b/test/termineEinbauanleitung.test.js @@ -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 = ' & "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(' & "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) +})