# Kundenansicht neu, P4 Funktionsmodule: Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Die gemeinsamen Funktionsmodule (Terminplaner, News, Webshop, Buchungskalender, Öffnungszeiten live, Analyse) bekommen ihr weißes Aussehen unter `.kn` (`public/neu/module.css`) und verhalten sich bei `modell === 'abo'` ohne Preise und Bestellseiten: Einschalten statt Kaufen, Reiter in Etage 2. Die alte Ansicht verhält sich weiter exakt wie heute. **Architecture:** Jede Verzweigung hängt an `modell === 'abo'` in der Antwort von `GET /api/funktionen`, die alle sechs Module beim Start laden. Den Einschalt-Ablauf von P3 (`zeigeEinschalten`) bekommen die Module als Init-Option `einschalten`; die gemeinsame Seite `renderEinschaltSeite` in `funktion-bestellen.js` zeigt Zusammenfassung und offene Punkte und mountet ihn. Die Reiter von Terminplaner, Webshop, Buchungskalender und Analyse zeichnet im Abo-Modell die Etage von P1; die Module melden sie per `kn:reiter` (Kurzlabels, wo nötig). `module.css` ersetzt `termine.css`, `shop.css`, `buchungen.css`, `zeiten.css`, `funktionen.css` (News) und `analyse.css` vollständig; ein Prüftest sichert Klassen-Abdeckung, Scope `.kn`, Palette und Verbote. **Tech Stack:** Vanilla-JS-ES-Module ohne Build (`public/`), Tests mit `node --test` und `test/support/fakeDom.js` (nur `h()`/`textContent`), CSS, Sichtprüfung nur mit dem Werkzeug aus P0. **Spec:** `/home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht/docs/superpowers/specs/2026-10-15-kundenansicht-neu-design.md` (Abschnitte 5.1, 5.4–5.6, C2, C9, C12, C15) **Übersicht/Verträge:** `/home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht/docs/superpowers/plans/2026-10-16-kundenansicht-00-uebersicht.md` (gilt wörtlich) **Worktree und Branch:** `/home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui`, Branch `kn/ui`. Start nach P3 (Tag `kn-p3`), Ende mit Tag `kn-p4`. Alle Pfade unten sind relativ zu diesem Worktree. ## Global Constraints Alle Global Constraints und Verträge V1–V13 der Übersicht gelten (Aussehen, „du“, keine Preise für Funktionen, Hashes, Rechtlich fest, Testbefehle mit Speicherbremse, Commits mit Zeile `Co-Authored-By: Claude Opus 5.5 `, Sicherheitsblock, kein Push). Paketeigene Zusätze: - **Alte Ansicht unverändert.** Verzweigt wird nur mit `antwort.modell === 'abo'` (strikt, ohne trim oder lowercase). `public/dashboard.html`, `app.js` und alle alten CSS-Dateien bleiben unberührt. Bestehende Testdateien ändert dieses Paket nur an den zwei Pins der Dateiübersicht. - **Quelle von `modell`.** Alle sechs Module laden beim Start `GET /api/funktionen` und lesen `modell` und `kundenart` dort. Die Unter-Reiter-Module (`termine-tab-*`, `shop-tab-*`, `buchungen-tab-*`, `zeiten-editor.js`, `analyse-cards.js`) brauchen kein `modell` und bleiben unverändert. - **Pins, die neue Importzeilen verbieten** (Erweiterung nur über benannte Importe einer bestehenden Importzeile): - `funktionen.js`: `termineView.test.js:315-320`, `shopWiring.test.js:40-42`, `termineFragebogenView.test.js:1033-1034` (genau zwei absolute Importe, ein relativer, kein `import(`, kein Wort `funktion-bestellen`). - `funktion-bestellen.js`: `termineFragebogenView.test.js:994-1034` (nur `./termine-dom.js` und `./termine-view.js`, kein `import(`, kein Wort „zahlungspflichtig“, „details“, „summary“, kein `innerHTML`, `href` nur über `sicherLink`). - `termine-fragebogen.js`: `termineFragebogenView.test.js:3072-3086` (acht Importe fest, kein `href`, kein `PATCH`). - `zeiten.js`, `zeiten-bestellen.js`: `zeitenBestellen.test.js:841-865`, `zeitenView.test.js:390-399` (Importlisten fest, kein `import(`). - `termine.js`: `termineView.test.js:373-394, 450-453` (nur relative statische Importe, je einmal `location.hash` und `history.replaceState`, einmal `api: {`, kein Text „Wartungs-Abo“, „gekündigt“, `'vertraege'`). Neue `termine*.js`-Dateien sind verboten (`termineView.test.js:324-329`). - Die `history.replaceState`-Aufrufe der Rückkehr-Query in `termine.js`, `shop.js`, `zeiten.js`, `funktionen.js` bleiben (P1 stempelt den Verlauf danach neu). - **Init-Optionen.** `initTermine`, `initNews`, `initShop`, `initBuchungen`, `initZeiten` nehmen zusätzlich `einschalten` (VF1). News und Analyse lesen die Projektwahl auch aus `root.dataset.wunschProjekt` (V2) und entfernen das Attribut. - **Reiter (P1, Vertragsfrage 5).** Im Abo-Modell zeichnen Terminplaner, Webshop, Buchungskalender und Analyse keine eigene Leiste, sondern melden `kn:reiter` (Spec 5.2 und 5.4: gemeinsame Mechanik „statt der vier eigenen Kopien“, Unterseiten-Reiter immer unten in Etage 2). Die Analyse meldet Übersicht, Heatmap und Einstellungen aus der Closure `mountCockpit` in `analyse.js` (VF2). - **Neue Dateien außerhalb der Zuständigkeitstabelle:** `public/fn-reiter.js`, `test/support/modulAbo.js`, `test/kundenansichtNeuModul*.test.js`. `public/neu/module.css` legt P1 als Stub an; ab P4 gehört die Datei P4. - **Texte im Abo-Modell** stehen wörtlich im Abschnitt „Texte“. Kein Preis, kein „Zahlungspflichtig“, kein „Behalten“, Du-Form. - **CSS:** jede Regel unter `.kn`; Farben nur aus der Palette der Übersicht plus die drei Zettelfarben der News; Fokus `outline: 2px solid #111`; keine Verläufe, kein Glas, kein Dunkelmodus, keine Animationen außer `transition` bis 150 ms; `geraete.css` bleibt unberührt (Geräterahmen der Heatmap sind Abbildungen). Inhaltsfarben der Kunden (Widget-Akzentfarbe, Banner-Farbwahl der Analyse, Zettelfarben) bleiben. - Die Annahmen der Vertragsfragen VF1–VF6 (Ende des Dokuments) gelten, bis der Controller anders entscheidet. ## Review Focus 1. **Alte Ansicht bricht:** `modell` fehlt, ist leer oder falsch geschrieben (`'Abo'`, `'abo '`): Bestellseite mit Preis, „Behalten“, Unternehmer-Haken, eigene Reiterleiste mit langen Labels bleiben wie heute. Tests: Task 1 und 3 bis 8 (je „ohne modell“), Task 15 (Bestand-Tests). 2. **Preis- und Zahlungswörter im Abo-Modell:** Reste aus alten Pfaden (`priceLabel`, `ustHinweis`, „Zahlung fortsetzen“, „Für 30 € behalten“, „299 €“) auf Einschalt-, Anfrage- und Statusflächen. Tests: `KEIN_PREIS` in Task 2 und 4 bis 8. 3. **Reiterleiste doppelt oder fehlend:** Im Abo-Modell erscheint sie in Modul und Etage zugleich oder in keinem von beiden; eine Ansicht ohne Reiter lässt die Reiter der vorigen stehen; `bei(key)` wechselt nicht. Tests: Task 1 (alle drei Module), Task 8 (Analyse, auch leere Liste), Task 3 und 5 (leere Liste). 4. **Privatkunde oder leere Kundenart im Webshop:** das Formular erscheint trotzdem, oder ein aktiver Bestands-Shop verschwindet. Privat und leer bekommen Hinweis plus Chat, nie das Formular; `state !== 'off'` bleibt bedienbar. Test: Task 5. 5. **Dunkle Reste im CSS:** eine vergessene Klasse behält Weiß-auf-Dunkel und wird auf Weiß unleserlich. Tests: Abdeckung je Präfix in Task 9–13, Gates in Task 14. ## Dateiübersicht | Datei | Aufgabe | Task | |---|---|---| | `public/fn-reiter.js` (neu) | `reiterEintraege`, `meldeReiter`, `KURZLABELS` | 1 | | `public/termine.js`, `public/shop.js`, `public/buchungen.js` | `modell` lesen, im Abo-Modell Reiter melden (`:49-56`, `:100-106`, `:322-394`; `:295-360`; `:39-41`, `:155-205`) | 1 | | `public/funktion-bestellen.js` | `renderEinschaltSeite` (nach `:123`), Blöcke aus `baueSeite` (`:146-170`) herausziehen, `statusKarte` (`:306`, `:315`) mit `modell` | 2 | | `public/termine.js`, `public/termine-fragebogen.js` | `einschalten`, `fertig` im Kontext, letzter Schritt (`:27-29`, `:80`, `:90`, `:98`, `:101-104`) | 3 | | `public/funktionen.js` | News: Einschalt-Seite (`:474-515`), Projektwahl aus `dataset` | 4 | | `public/shop.js`, `public/shop-anfrage.js` | Anfrage ohne Unternehmer, Satz, Privat-Hinweis, Einschalten (`:64-74`, `:195`, `:253`; `:37`, `:67-68`, `:115-120`) | 5 | | `public/buchungen.js`, `public/buchungen-anfrage.js` | Anfrage ohne Preis, Einschalten (`:32-41`, `:124-130`; `:11`, `:20`, `:94`) | 6 | | `public/zeiten.js`, `public/zeiten-bestellen.js` | Einschalten, kein Behalten (`:55`, `:62-73`, `:132`, `:161`, `:218-226`; `:105`, `:289`) | 7 | | `public/analyse.js` | Aktivierung mit Linkzeile und Zielgruppen-Häkchen (`:22-49`, `:76-112`); Reiter als `kn:reiter` melden (`:22-49`, `mountCockpit` `:174-328`) | 8 | | `public/neu/module.css` (Stub von P1) | alle Modul-Klassen unter `.kn` | 9–13 | | `public/neu/bilder/analyse.webp` (P3, nur dieses Bild neu erzeugt) | Analyse in neuer Optik (P3 VF2) | 14 | | `test/support/modulAbo.js` (neu) | Testhilfe | 1 | | `test/kundenansichtNeuModul{Reiter,Einschalten,Termine,News,Shop,Buchungen,Zeiten,Analyse,Css}.test.js` (neu) | je Task der Namen | 1–9 | | `test/shopWiring.test.js:31` (Pin) | Regex der Signatur `initShop(root, { projectId, einschalten } = {})` | 5 | | `test/unterkunftWiring.test.js:130` (Pin) | Regex der Signatur `initBuchungen(root, { projectId, einschalten } = {})` | 6 | | Werkzeug `/root/reports/kundenansicht-neu-2026-10-15/werkzeug/` (eigenes Git): `views.mjs`, `gates.mjs`, `fixtures/module.mjs` (neu, 17 Fälle), `fixtures/endpunkte-p4.mjs` (neu, Analyse-Endpunkte), Einträge in `fixtures/faelle.mjs`, `fixtures/index.mjs`, `fixtures/endpunkte.mjs`, `vertrag-endpunkte.mjs` | Ansichten, Gate `p4`, Fälle | 14 | ## Texte (Abo-Modell, wörtlich) | Kürzel | Datei | Wortlaut | |---|---|---| | E1 | `funktion-bestellen.js`, `termine-fragebogen.js` | Überschrift der Seite mit Zusammenfassung und letzter Eintrag der Fortschrittsleiste: `Prüfen und einschalten` | | E2 | `funktion-bestellen.js` | bei offenen Punkten: `Du kannst einschalten, sobald die offenen Punkte erledigt sind.` | | E4 | `funktion-bestellen.js`, `funktionen.js` | ohne Funktion `einschalten`: `Das Einschalten ist gerade nicht verfügbar. Bitte lade die Seite neu oder schreib uns im Chat.` | | E5 | `funktion-bestellen.js`, `zeiten-bestellen.js` | Faktenzeile der Statuskarte: `Eingeschaltet am` (statt `Bestellt am`) | | F1 | `termine-fragebogen.js` | `Du siehst den Fragebogen in der Kundenansicht. Angaben kannst du ändern, Schritte bestätigen und einschalten kann nur der Kunde selbst.` | | S1 | `shop-anfrage.js`, `buchungen-anfrage.js` | erster Stichpunkt statt der Preiszeile: `Im Wartungs-Abo enthalten.`; letzter Stichpunkt: `Wir sehen uns deine Anfrage kurz an. Erst nach unserer Zusage kannst du einschalten.` | | S2 | `shop-anfrage.js` | Satz über „Anfrage senden“: `Mit „Anfrage senden“ bestätigst du, dass du keine ausgeschlossenen Waren verkaufst (` + `AUSGESCHLOSSEN` + `).` | | S3 | `shop.js` | Privat: Titel `Der Webshop ist für Betriebe`, Text `Für Privatpersonen gibt es den Webshop leider nicht. Stimmt das nicht? Schreib uns im Chat.`, dazu der Chat-Knopf | | B1 | `shop.js`, `buchungen.js` | Gruppe der Zusammenfassung: Titel `Deine Anfrage` (in `shop.js` schon so) | | Z2 | `zeiten-bestellen.js` | Abo-Ende in `setup`/`active`: `Dein Wartungs-Abo ist beendet. Öffnungszeiten live läuft noch bis und schaltet sich dann ab.` | | Z3 | `zeiten-bestellen.js` | abgeschaltet mit Daten: `Deine Eingaben bleiben[ bis ] gespeichert. Mit einem neuen Wartungs-Abo schaltet sich Öffnungszeiten live wieder ein.` | | A1 | `analyse.js` | Linkzeile: `Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung ).`, beide Wörter sind Links (`legal.lb`, `legal.avv`) | | A2 | `analyse.js` | Zielgruppen-Zeile mit Häkchen: `Meine Website richtet sich nicht gezielt an Kinder unter 16 Jahren.` | | A3 | `analyse.js` | bei `reason === 'kein_abo'`: `Die Analyse ist im Hosting- und im Wartungs-Abo enthalten.` plus der Link `ZU_VERTRAEGE` | ## Tasks ### Task 1: Reiter im Abo-Modell (`kn:reiter`, C2) und Testhilfe **Files:** - Create: `public/fn-reiter.js`, `test/support/modulAbo.js` - Modify: `public/termine.js` (`initTermine` `:49-56`, `ladeFunktionen` `:100-106`, `zeichneAnsicht`, `zeigeReiter` `:322-394`), `public/shop.js` (`zeichneAnsicht`, `zeigeReiter` `:295-360`), `public/buchungen.js` (`starte` `:39-41`, `zeichneAnsicht`, `zeigeReiter` `:155-205`) - Test: `test/kundenansichtNeuModulReiter.test.js` **Interfaces:** - Consumes: Ereignis `kn:reiter` der Etage (P1): `CustomEvent` mit `bubbles: true` und `detail: { eintraege: [{ key, label, kurz? }], aktiv, bei(key) }`; eine leere Liste heißt „keine Reiter“, dann füllt „Zurück“ die Zeile. - Produces `public/fn-reiter.js` (keine Importe): ```js export const KURZLABELS // { termine: { team: 'Team' }, shop: { uebersicht: 'Start', rechtliches: 'Recht' }, buchungen: { unterkuenfte: 'Preise' } }; die Analyse (Übersicht, Heatmap, Einstellungen) braucht keine Kurzlabels und hat deshalb keinen Eintrag, läuft aber durch dieselbe Funktion export function reiterEintraege(modul, reiterListe) // [{ key, label, … }] → [{ key, label, kurz? }]; kurz nur mit Eintrag in KURZLABELS, andere Felder fallen weg export function meldeReiter(el, { eintraege, aktiv = '', bei = () => {} }) // sendet kn:reiter aus el; ohne CustomEvent oder ohne el.dispatchEvent: false, sonst true ``` - Produces `test/support/modulAbo.js` (alle späteren Tasks nutzen es): ```js export const mitDom // (fn) => async () => { fx = installFakeDom(); setzt globalThis.CustomEvent auf eine fakeDom-taugliche Klasse (type, detail, bubbles, target, ergebnisse, preventDefault, stopPropagation); try { await fn(fx); await fx.ruhe() } finally { fx.restore(); CustomEvent zurück } } export function hoereAufKn(fx) // { reiter: [detail …] } – lauscht am document.body auf kn:reiter export function routen(fx, tabelle) // ersetzt fetch; Schlüssel 'METHODE /pfad?query'; Wert: { status?, body } | Array (der Reihe nach, letzter gilt weiter) | (aufruf) => Wert; unbekannt: 404 und Eintrag in fx.unbekannt; jeder Aufruf in fx.fetchLog export function einschaltenAttrappe() // { fn, aufrufe: [{ wurzel, key, projectId, viewAs, onFertig }], fertig(i = 0, ergebnis = {}) → ruft aufrufe[i].onFertig }; fn zeichnet nichts export const KEIN_PREIS // /€|einmalig|Preis|Zahlungspflichtig|Bestellseite|Behalten/ export function htmlWurzel(stageSelektor) // für innerHTML-Module: { root, stage, kn }; root.dataset ist {}; root.querySelector(stageSelektor) liefert stage, root.querySelector('.an') die Attrappe `an` (addEventListener merkt die Handler in an.handlers, querySelector → null, querySelectorAll → []), sonst null; stage.els['#id'] erzeugt Attrappen { innerHTML, disabled, checked, dataset, handlers, textContent }; stage.setAttribute/getAttribute speichern in stage.attribute; root.dispatchEvent(ev) legt ev.detail der kn:reiter-Ereignisse in kn.reiter ab und gibt true zurück (die Analyse meldet ihre Reiter an root) export async function ladeFunktionenModul() // public/funktionen.js laden, alle statischen Importe auf die Dateien in public/ umbiegen (Muster funktionenZeitenTile.test.js:12-18) ``` - Verhalten der drei Module bei `modell === 'abo'`: keine eigene Leiste (kein `role="tablist"`), das Panel trägt `aria-label` statt `aria-labelledby`; jeder Reiterwechsel meldet `meldeReiter(root, { eintraege: reiterEintraege(modul, REITER), aktiv: key, bei: waehleReiter })`; jedes Zeichnen ohne Reiter (Fragebogen, Info-, Anfrage- und Statuskarten, Vorlagenwahl) meldet die leere Liste vor dem Zeichnen. Termine und Buchungen lesen dazu `modell` in `s.modell` (Shop: `s.funktionen.modell`). Ohne `modell` ändert sich nichts. Die Analyse (Task 8) meldet ebenso aus der Closure `mountCockpit` in `analyse.js`, mit denselben Hilfen und Regeln (leere Liste vor jedem Zeichnen ohne Reiter, `bei` = `pickTab`); Verdrahtung und Test stehen dort, weil sie `modell` erst dort liest und mit `htmlWurzel` arbeitet. - [ ] **Step 1: Voraussetzungen prüfen** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git tag --list 'kn-p*' && git status --short && free -m && ls public/neu` Expected: Tags `kn-p0` bis `kn-p3`, `git status` leer, „available“ mindestens 2000 MB, `public/neu` enthält `dashboard.html`, `reiter.js`, `etage.js`, `funktion-einschalten.js` und den Stub `module.css`. Fehlt etwas davon: stoppen und dem Controller melden. - [ ] **Step 2: Failing test `test/kundenansichtNeuModulReiter.test.js`** ```js test('KURZLABELS: genau diese Einträge (C2)', () => { assert.deepEqual(KURZLABELS, { termine: { team: 'Team' }, shop: { uebersicht: 'Start', rechtliches: 'Recht' }, buchungen: { unterkuenfte: 'Preise' } }) }) test('reiterEintraege: Kurzlabel nur mit Eintrag, fremde Felder fallen weg', () => { const liste = [{ key: 'termine', label: 'Termine', render() {} }, { key: 'team', label: 'Team & Plätze', render() {} }] assert.deepEqual(reiterEintraege('termine', liste), [{ key: 'termine', label: 'Termine' }, { key: 'team', label: 'Team & Plätze', kurz: 'Team' }]) assert.deepEqual(reiterEintraege('unbekannt', liste.slice(1)), [{ key: 'team', label: 'Team & Plätze' }]) assert.deepEqual(reiterEintraege('analyse', [{ key: 'heatmap', label: 'Heatmap' }]), [{ key: 'heatmap', label: 'Heatmap' }], 'Analyse: kein Kurzlabel nötig') }) test('meldeReiter: das Ereignis steigt auf und trägt Einträge, aktiv und bei; ohne Element tut es nichts', mitDom(async (fx) => { const k = hoereAufKn(fx); const el = h('div'); document.body.append(el); const bei = () => {} assert.equal(meldeReiter(el, { eintraege: [{ key: 'a', label: 'A' }], aktiv: 'a', bei }), true) assert.deepEqual(k.reiter, [{ eintraege: [{ key: 'a', label: 'A' }], aktiv: 'a', bei }]) assert.equal(meldeReiter(null, { eintraege: [] }), false) })) ``` Dazu eine Tabelle `FAELLE` (je Modul `init`, optional `vorher` = `vergissAuswahl`, `tabelle(modell)` für den Zustand `active`, erwartete Labels `alt` und Kurzlabels `kurz`) und zwei Tests je Modul. Fixtures: ein Projekt `p1` mit `features..state = 'active'`, `modell` nur im Körper, wenn gesetzt. - `termine`: `GET /api/funktionen` mit `termine: { verfuegbar: true }`, `GET /api/termine/einstellungen?projectId=p1` mit `{ state: 'active', einstellung: {}, fehlt: [] }`; `alt` `['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen']`, `kurz` `['Termine', 'Leistungen', 'Team', 'Einstellungen']` - `shop`: `shop: { verfuegbar: true }`, `features: { shop: {} }`, `GET /api/shop/status?projectId=p1` mit `{ verfuegbar: true, state: 'active', anfrage: null }`; `alt` `['Übersicht', 'Artikel', 'Bestellungen', 'Einstellungen', 'Rechtliches']`, `kurz` `['Start', 'Artikel', 'Bestellungen', 'Einstellungen', 'Recht']` - `buchungen`: `unterkunft: { verfuegbar: true }`, `GET /api/unterkunft/status?projectId=p1` mit `{ state: 'active' }`; `alt` `['Anfragen', 'Kalender', 'Unterkünfte & Preise', 'Rechnungen', 'Einstellungen']`, `kurz` `['Anfragen', 'Kalender', 'Preise', 'Rechnungen', 'Einstellungen']` ```js for (const f of FAELLE) { test(`${f.name}, Abo: keine eigene Leiste, kn:reiter mit Kurzlabels, bei() wechselt`, mitDom(async (fx) => { const k = hoereAufKn(fx); const root = await oeffne(fx, f, 'abo') assert.equal(fx.finde(root, '[role="tablist"]'), null) const erst = k.reiter.at(-1) assert.deepEqual(erst.eintraege.map((e) => e.label), f.alt) assert.deepEqual(erst.eintraege.map((e) => e.kurz ?? e.label), f.kurz) const andere = erst.eintraege.find((e) => e.key !== erst.aktiv).key erst.bei(andere); await fx.ruhe() assert.equal(k.reiter.at(-1).aktiv, andere) })) test(`${f.name}, ohne modell (fehlt, „Abo“): eigene Leiste mit langen Labels, kein Ereignis`, mitDom(async (fx) => { for (const modell of [undefined, 'Abo']) { const k = hoereAufKn(fx); const root = await oeffne(fx, f, modell) assert.deepEqual(fx.alle(root, '[role="tab"]').map((t) => fx.text(t)), f.alt) assert.equal(k.reiter.length, 0) } })) } ``` `oeffne(fx, f, modell)` ruft `f.vorher?.()`, `routen(fx, f.tabelle(modell))`, hängt eine Wurzel an den Body, ruft `f.init(root, { projectId: 'p1' })` und `fx.ruhe()`. Zwei weitere Tests: (a) `REITER.map((r) => r.label)` von `shop.js` und `buchungen.js` bleiben unverändert (die Pins `buchungenUi.test.js:53`, `shopView.test.js:373`); (b) Shop im Abo-Modell im Zustand `off` (`features.shop.state` und `status.state` `'off'`, `kundenart: 'unternehmen'`) meldet zuletzt die leere Liste: `assert.deepEqual(k.reiter.at(-1).eintraege, [])`. - [ ] **Step 3: Run test to verify it fails** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/kundenansichtNeuModulReiter.test.js` Expected: FAIL mit „Cannot find module“ (`fn-reiter.js` und `modulAbo.js` fehlen). - [ ] **Step 4: Implement `public/fn-reiter.js` und `test/support/modulAbo.js` nach den Signaturen oben** `routen` folgt dem Helfer in `termineFragebogenView.test.js:3115-3127`. `htmlWurzel` und `ladeFunktionenModul` folgen `funktionenTiles.test.js:84-104` und `funktionenZeitenTile.test.js:12-18`; die Attrappen `an`, `setAttribute` und `dispatchEvent` für die Analyse erweitern `fakeEl` und `fakeStage` aus `funktionenTiles.test.js:405-415`. `meldeReiter` baut `new CustomEvent('kn:reiter', { bubbles: true, detail })`. - [ ] **Step 5: Reiter in den drei Modulen verdrahten** In `termine.js`, `shop.js`, `buchungen.js`: neue Importzeile `import { meldeReiter, reiterEintraege } from './fn-reiter.js'`. Die Literale von `REITER` bleiben unverändert (Pin `termineView.test.js:364`). Verhalten wie im Abschnitt „Verhalten der drei Module“; im Abo-Modell werden Knöpfe und Leiste nicht gebaut (in `termine.js` lässt der vorhandene `filter(Boolean)` eine fehlende Leiste weg, in `shop.js` und `buchungen.js` die Leiste nicht in `replaceChildren` aufnehmen). - [ ] **Step 6: Run test to verify it passes** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/kundenansichtNeuModulReiter.test.js test/termineView.test.js test/shopView.test.js test/buchungenUi.test.js` Expected: PASS (neue und bestehende Tests). - [ ] **Step 7: Commit** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/fn-reiter.js test/support/modulAbo.js test/kundenansichtNeuModulReiter.test.js public/termine.js public/shop.js public/buchungen.js && git commit -m "Module: im Abo-Modell Reiter mit Kurzlabels an die Etage melden (C2)" -m "Co-Authored-By: Claude Opus 5.5 " ``` ### Task 2: Einschalt-Seite im Abo-Modell (`funktion-bestellen.js`) **Files:** - Modify: `public/funktion-bestellen.js` (neue Funktion nach `:123`; Gruppen- und Offen-Block aus `baueSeite` `:146-170` in zwei Hilfsfunktionen derselben Datei ziehen; `statusKarte` `:306`, `:315`) - Test: `test/kundenansichtNeuModulEinschalten.test.js` **Interfaces:** - Consumes: `h`, `fehlerKarte` aus `./termine-dom.js`, `zusammenfassung` aus `./termine-view.js` (beide schon importiert); `einschalten` = `zeigeEinschalten(platz, { key, projectId, onFertig, … })` aus `public/neu/funktion-einschalten.js` (P3, VF1). P3 lädt die Liste selbst, zeichnet Bestätigung und Knopf und ruft `onFertig({ feature, ticketWoid })` nach 200 und `onFertig({ feature: null, ticketWoid: null })` nach 409 `bereits_eingeschaltet`; alle anderen Fehler zeigt P3 selbst. - Produces: ```js export function renderEinschaltSeite(root, { ctx = {}, daten = {}, zumSchritt, key, zusammenfassung: gruppenFunktion, einschalten, nachEinschalten } = {}) // → Element section.kb-panel.tm-bestellen.tm-einschalten[data-key] // daten: wie bei renderBestellen (einstellung, fragebogen, branchen, offen, kundenansicht): Quelle von Zusammenfassung und offenen Punkten // einschalten(platz, { key, projectId: ctx.projectId, viewAs, onFertig }) mit viewAs = daten.kundenansicht === true || ctx.kundenansicht === true // onFertig ist eine Closure mit Merker: nur der erste Aufruf wirkt und nur, wenn ctx.aktuell?.() nicht false ist; sie ruft nachEinschalten() (die Ansicht zeichnet sich neu) // statusKarte({ ..., modell }): modell === 'abo' → Faktenzeile „Eingeschaltet am“ (E5) ``` Aufbau: gibt es Gruppen (`gruppenFunktion(daten.einstellung, daten.fragebogen, daten.branchen)`, bei `key === 'termine'` ohne eigene Funktion die `zusammenfassung` aus `termine-view.js`) oder offene Punkte (`daten.offen`), steht oben `h3` E1, dann die Gruppen mit „Ändern“ und die offenen Punkte mit „Zum Schritt“. Bei offenen Punkten folgt der Hinweis E2 und `einschalten` wird nicht gerufen. Sonst steht der Platz `div.tm-einschalten-platz[data-einschalten=key]` da, in den `einschalten` genau einmal zeichnet; fehlt `einschalten`, steht dort die `fehlerKarte` mit E4. - [ ] **Step 1: Failing test `test/kundenansichtNeuModulEinschalten.test.js`** ```js function seite(optionen) { const wurzel = h('div'); document.body.append(wurzel) renderEinschaltSeite(wurzel, { ctx: { projectId: 'p1' }, daten: { offen: [], kundenansicht: false }, key: 'shop', ...optionen }) return wurzel } const GRUPPEN = () => [{ schritt: 'anfrage', titel: 'Deine Anfrage', zeilen: ['Was du verkaufst: Kleidung'] }] test('mountet den Ablauf genau einmal in den Platz, mit key, projectId und viewAs; ohne Haken, Preis und Überschrift', mitDom(async (fx) => { const a = einschaltenAttrappe(); const wurzel = seite({ einschalten: a.fn }) assert.equal(a.aufrufe.length, 1) assert.deepEqual([a.aufrufe[0].key, a.aufrufe[0].projectId, a.aufrufe[0].viewAs], ['shop', 'p1', false]) assert.ok(a.aufrufe[0].wurzel === fx.finde(wurzel, '[data-einschalten="shop"]')) assert.equal(fx.finde(wurzel, 'h3'), null) assert.equal(fx.alle(wurzel, 'input').length, 0) assert.doesNotMatch(fx.text(wurzel), KEIN_PREIS) })) test('Zusammenfassung mit E1; offene Punkte sperren den Ablauf (E2); „Ändern“ und „Zum Schritt“ rufen zumSchritt', mitDom(async (fx) => { const a = einschaltenAttrappe(); const schritte = [] const wurzel = seite({ einschalten: a.fn, zusammenfassung: GRUPPEN, zumSchritt: (k) => schritte.push(k), daten: { offen: [{ schritt: 'zeiten', text: 'Bitte wähle das Bundesland.' }], kundenansicht: false } }) assert.equal(fx.text(fx.finde(wurzel, 'h3')), 'Prüfen und einschalten') assert.equal(a.aufrufe.length, 0) assert.match(fx.text(wurzel), /Du kannst einschalten, sobald die offenen Punkte erledigt sind\./) await fx.klick(fx.finde(wurzel, '[data-aktion="zum-schritt"][data-schritt="zeiten"]')) await fx.klick(fx.finde(wurzel, '[data-aktion="aendern"][data-schritt="anfrage"]')) assert.deepEqual(schritte, ['zeiten', 'anfrage']) })) test('Kundenansicht: der Ablauf bekommt viewAs true', mitDom(async () => { const a = einschaltenAttrappe(); seite({ einschalten: a.fn, daten: { offen: [], kundenansicht: true } }) assert.equal(a.aufrufe[0].viewAs, true) })) test('onFertig: höchstens einmal, nicht nach ctx.aktuell() === false', mitDom(async (fx) => { let n = 0 const a = einschaltenAttrappe(); seite({ einschalten: a.fn, nachEinschalten: () => { n += 1 } }) a.fertig(0); a.fertig(0); await fx.ruhe() assert.equal(n, 1) const b = einschaltenAttrappe(); seite({ einschalten: b.fn, ctx: { projectId: 'p1', aktuell: () => false }, nachEinschalten: () => { n += 1 } }) b.fertig(0); await fx.ruhe() assert.equal(n, 1) })) test('ohne Funktion einschalten: Fehlerkarte E4 statt Absturz', mitDom(async (fx) => { const wurzel = seite({}) assert.match(fx.text(wurzel), /Das Einschalten ist gerade nicht verfügbar\. Bitte lade die Seite neu oder schreib uns im Chat\./) assert.ok(fx.finde(wurzel, '[role="alert"]')) })) test('statusKarte: Abo-Modell „Eingeschaltet am“, sonst „Bestellt am“', mitDom(async (fx) => { const args = { projekt: { projectId: 'p1' }, bestellung: { state: 'setup', orderedAt: '2026-10-16T09:00:00.000Z', ticketWoid: '100150' } } assert.match(fx.text(statusKarte({ ...args, modell: 'abo' })), /Eingeschaltet am\s*16\.10\.2026/) assert.match(fx.text(statusKarte(args)), /Bestellt am\s*16\.10\.2026/) })) ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/kundenansichtNeuModulEinschalten.test.js` Expected: FAIL mit „renderEinschaltSeite is not a function“. - [ ] **Step 3: Gruppen- und Offen-Block herausziehen (Refactor ohne Änderung der Ausgabe)** In `baueSeite` die Erzeugung von `gruppen` (`:150-159`) und `offenBlock` (`:161-170`) in zwei Funktionen derselben Datei verschieben, die `baueSeite` und `renderEinschaltSeite` benutzen. Danach sofort: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/termineFragebogenView.test.js test/funktionBestellenKey.test.js` Expected: PASS wie vor dem Schritt. - [ ] **Step 4: Implement `renderEinschaltSeite` und `statusKarte({ modell })`** Keine neuen Importe, kein `href`, keine Wörter aus der Pin-Liste (`details`, `summary`, „zahlungspflichtig“). - [ ] **Step 5: Run tests to verify they pass** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/kundenansichtNeuModulEinschalten.test.js test/termineFragebogenView.test.js test/funktionBestellenKey.test.js` Expected: PASS (neue und bestehende Tests). - [ ] **Step 6: Commit** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/funktion-bestellen.js test/kundenansichtNeuModulEinschalten.test.js && git commit -m "Funktionsmodule: gemeinsame Einschalt-Seite für das Abo-Modell" -m "Co-Authored-By: Claude Opus 5.5 " ``` ### Task 3: Terminplaner im Abo-Modell **Files:** - Modify: `public/termine.js` (`initTermine` `:49-56`, `baueCtx` `:322-350`, `statusKarte`-Aufruf `:245-252`), `public/termine-fragebogen.js` (Import `:24`, `TITEL` `:27`, `KUNDENANSICHT` `:29`, `:80`, `:90`, `:98`, `bestellseite` `:101-104`) - Test: `test/kundenansichtNeuModulTermine.test.js` **Interfaces:** - Consumes: `renderEinschaltSeite` (Task 2), `s.modell` und `meldeReiter` (Task 1), Testhilfen; V5-Felder `modell`, `kundenart`. - Produces: `initTermine(root, { projectId, einschalten } = {})`; `s.einschalten` in der Sitzung; `baueCtx` liefert zusätzlich `einschalten: s.einschalten` und `fertig: () => ladeNeu(s)`; der Fragebogen liest `ctx.s?.modell === 'abo'`. - [ ] **Step 1: Failing test `test/kundenansichtNeuModulTermine.test.js`** Kopf: `STAND = bestellbarerStand({ now: new Date('2026-10-16T08:00:00.000Z') })` aus `test/support/fragebogenStand.js` (alle Schritte bestätigt, der Start ist also der letzte Schritt); `daten(extra)` = `{ state: 'off', einstellung: STAND.einstellung, fragebogen: STAND.fragebogen, version: 1, branchen: [], kundenansicht: false, abo: { ok: true }, offen: [], bestellung: null, ...extra }`; `liste(modell, state = 'off', feature = {})` = Antwort von `GET /api/funktionen` mit einem Projekt `p1` (`features.termine = { state, siteKey: 'abcdefghijklmnop', ...feature }`), `termine: { verfuegbar: true, bestellbar: true, grund: null, preis: '149 €', einrichtung: 'meist in 1–2 Wochen', hinweis: null }` und `modell` nur, wenn gesetzt; `BESTELLUNG` = `{ preis: '149 €', preisHinweis: 'einmalig, ohne Umsatzsteuer gemäß § 19 UStG', einrichtung: '', belehrung: null, dokumente: [], consent: { version: 'v-test', knopf: 'Zahlungspflichtig bestellen · 149 €', items: [{ id: 'beginn', text: 'Ich verlange den sofortigen Beginn.', haken: true }] } }`; `EINSTELLUNGEN` = `'GET /api/termine/einstellungen?projectId=p1'`; `oeffne(fx, tabelle, optionen)` hängt eine Wurzel an den Body, ruft `routen`, `initTermine(wurzel, { projectId: 'p1', ...optionen })` und `fx.ruhe()`. ```js test('Abo, alles bestätigt: letzter Schritt E1 mountet den Ablauf, ohne Haken, Preisblock und Checkout; keine Reiter', mitDom(async (fx) => { const a = einschaltenAttrappe(); const k = hoereAufKn(fx) const wurzel = await oeffne(fx, { 'GET /api/funktionen': liste('abo'), [EINSTELLUNGEN]: { body: daten() } }, { einschalten: a.fn }) assert.equal(fx.text(fx.finde(wurzel, '.tm-fb-bestellen h3')), 'Prüfen und einschalten') assert.match(fx.text(fx.finde(wurzel, '.tm-schritt[aria-current="step"]')), /Prüfen und einschalten/) assert.deepEqual(a.aufrufe.map((x) => [x.key, x.projectId, x.viewAs]), [['termine', 'p1', false]]) assert.equal(fx.alle(wurzel, 'input[type="checkbox"]').length, 0, 'auch kein Haken „gesundheit“') for (const sel of ['[data-aktion="bestellen"]', '.tm-bestellen-preis', '.tm-rechtstext', '.tm-bestellen-zustimmung']) assert.equal(fx.finde(wurzel, sel), null, sel) assert.equal(fx.fetchLog.filter((x) => x.method === 'POST').length, 0) assert.deepEqual(k.reiter.at(-1).eintraege, []) })) test('Abo, offene Punkte: kein Ablauf, Hinweis E2, „Zum Schritt“ führt zurück', mitDom(async (fx) => { const a = einschaltenAttrappe() const offen = [{ schritt: 'zeiten', text: 'Bitte wähle das Bundesland deines Betriebs.' }] const wurzel = await oeffne(fx, { 'GET /api/funktionen': liste('abo'), [EINSTELLUNGEN]: { body: daten({ offen }) } }, { einschalten: a.fn }) assert.equal(a.aufrufe.length, 0) assert.match(fx.text(wurzel), /Du kannst einschalten, sobald die offenen Punkte erledigt sind\./) await fx.klick(fx.finde(wurzel, '[data-aktion="zum-schritt"][data-schritt="zeiten"]')) assert.equal(fx.finde(wurzel, '.tm-schritt[aria-current="step"]').getAttribute('data-schritt'), 'zeiten') })) test('Abo: nach dem Einschalten lädt die Ansicht neu und zeigt die Statuskarte „In Einrichtung“ mit „Eingeschaltet am“', mitDom(async (fx) => { const a = einschaltenAttrappe(); let n = 0 const wurzel = await oeffne(fx, { 'GET /api/funktionen': () => (n === 0 ? liste('abo') : liste('abo', 'setup', { orderedAt: '2026-10-16T09:00:00.000Z', ticketWoid: '100150' })), [EINSTELLUNGEN]: () => ({ body: n === 0 ? daten() : daten({ state: 'setup' }) }), }, { einschalten: a.fn }) n = 1; a.fertig(0); await fx.ruhe() const karte = fx.finde(wurzel, '.tm-statuskarte[data-state="setup"]') assert.match(fx.text(karte), /Eingeschaltet am\s*16\.10\.2026/) assert.doesNotMatch(fx.text(karte), /Bestellt am|Zahlung/) })) test('Abo: ohne Funktion einschalten steht E4; in der Kundenansicht steht F1 und der Ablauf bekommt viewAs true', mitDom(async (fx) => { const ohne = await oeffne(fx, { 'GET /api/funktionen': liste('abo'), [EINSTELLUNGEN]: { body: daten() } }) assert.match(fx.text(ohne), /Das Einschalten ist gerade nicht verfügbar\. Bitte lade die Seite neu oder schreib uns im Chat\./) const a = einschaltenAttrappe() const ansicht = await oeffne(fx, { 'GET /api/funktionen': liste('abo'), [EINSTELLUNGEN]: { body: daten({ kundenansicht: true }) } }, { einschalten: a.fn }) assert.match(fx.text(ansicht), /Du siehst den Fragebogen in der Kundenansicht\. Angaben kannst du ändern, Schritte bestätigen und einschalten kann nur der Kunde selbst\./) assert.equal(a.aufrufe[0].viewAs, true) })) test('Ohne modell (fehlt, leer, „Abo“, „abo “): unverändert Bestellseite mit Knopf des Registers, Ablauf wird nie gerufen', mitDom(async (fx) => { for (const modell of [undefined, '', 'Abo', 'abo ']) { const a = einschaltenAttrappe() const wurzel = await oeffne(fx, { 'GET /api/funktionen': liste(modell), [EINSTELLUNGEN]: { body: daten({ bestellung: BESTELLUNG }) } }, { einschalten: a.fn }) assert.equal(a.aufrufe.length, 0, JSON.stringify(modell)) assert.equal(fx.text(fx.finde(wurzel, '[data-aktion="bestellen"]')), 'Zahlungspflichtig bestellen · 149 €') assert.equal(fx.text(fx.finde(wurzel, '.tm-fb-bestellen h3')), 'Prüfen und bestellen') } })) ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/kundenansichtNeuModulTermine.test.js` Expected: FAIL (der letzte Schritt zeigt die Bestellseite, `a.aufrufe` ist leer). - [ ] **Step 3: Implement `termine.js`** `initTermine(root, { projectId, einschalten } = {})`; `einschalten` in `s.einschalten`; `baueCtx` ergänzt `einschalten` und `fertig: () => ladeNeu(s)`; der `statusKarte`-Aufruf übergibt `modell: s.modell`. Kein neues `location.hash`, `replaceState` oder `api: {`. - [ ] **Step 4: Implement `termine-fragebogen.js`** Import `{ renderBestellen, renderEinschaltSeite }` aus `./funktion-bestellen.js` (nur die benannten Importe erweitern). `bestellseite()` ruft bei `ctx.s?.modell === 'abo'` `renderEinschaltSeite(seite, { ctx, daten: ctx.daten, zumSchritt: (key) => zeige(key), key: 'termine', einschalten: ctx.einschalten, nachEinschalten: () => ctx.fertig() })`, sonst wie bisher `renderBestellen`. Titel der Leiste und des Zählers (`:90`, `:98`) und der Hinweis `:80` folgen E1 und F1 über zwei neue Konstanten `TITEL_ABO` und `KUNDENANSICHT_ABO`; die Map `TITEL` bleibt. - [ ] **Step 5: Run tests to verify they pass** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/kundenansichtNeuModulTermine.test.js test/termineView.test.js test/termineFragebogenView.test.js` Expected: PASS (neue und bestehende Tests). - [ ] **Step 6: Commit** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/termine.js public/termine-fragebogen.js test/kundenansichtNeuModulTermine.test.js && git commit -m "Terminplaner: im Abo-Modell Einschalten statt Bestellen, ohne Häkchen" -m "Co-Authored-By: Claude Opus 5.5 " ``` ### Task 4: News-Pinnwand im Abo-Modell **Files:** - Modify: `public/funktionen.js` (`initNews` `:474-512`, `renderBooking` `:515`) - Test: `test/kundenansichtNeuModulNews.test.js` **Interfaces:** - Consumes: `htmlWurzel('#news-stage')`, `ladeFunktionenModul`, `einschaltenAttrappe`, `KEIN_PREIS` (Task 1). - Produces: `initNews(root, { projectId, einschalten } = {})`; im Zustand `off` und `modell === 'abo'` besteht die Seite nur aus dem Platz `div#news-einschalten`, in den `einschalten(platz, { key: 'news', projectId, viewAs: false, onFertig })` zeichnet (die Überschrift „News-Pinnwand einschalten“ kommt von P3); `onFertig` lädt `initNews(root, { einschalten })` einmal neu; `root.dataset.wunschProjekt` wählt das Projekt und wird entfernt. Keine neuen Importe (Pins `termineView.test.js:315-320`). - [ ] **Step 1: Failing test `test/kundenansichtNeuModulNews.test.js`** Kopf: ```js const { initNews } = await ladeFunktionenModul() globalThis.window = { location: { hash: '#news', pathname: '/dashboard.html', search: '', href: '' }, history: { replaceState() {} } } after(() => { delete globalThis.window }) const projekt = (id, name) => ({ projectId: id, projectName: name, features: { news: { state: 'off' } } }) async function lauf({ modell, projects = [projekt('p1', 'Muster')], dataset = {}, projectId = 'p1', einschalten } = {}) { const calls = [] globalThis.fetch = async (url) => { calls.push(String(url)); return { ok: true, status: 200, json: async () => ({ ...(modell ? { modell } : {}), projects, features: { news: { priceLabel: '30 €' } }, consentText: 'Alt', setupEstimate: 'meist in 1–2 Wochen', ustHinweis: 'ohne Umsatzsteuer gemäß § 19 UStG' }) } } const { root, stage } = htmlWurzel('#news-stage') Object.assign(root.dataset, dataset) await initNews(root, { projectId, einschalten }) return { root, stage, calls } } test('Abo: Seite ohne Preis, Haken und Checkout; der Ablauf bekommt den Platz mit key news', async () => { const a = einschaltenAttrappe(); const { stage } = await lauf({ modell: 'abo', einschalten: a.fn }) assert.doesNotMatch(stage.innerHTML, KEIN_PREIS) assert.doesNotMatch(stage.innerHTML, /news-consent|news-order|30 €/) assert.deepEqual([a.aufrufe[0].key, a.aufrufe[0].projectId, a.aufrufe[0].viewAs], ['news', 'p1', false]) assert.ok(a.aufrufe[0].wurzel === stage.els['#news-einschalten']) }) test('Abo: nach onFertig lädt die Pinnwand neu', async () => { const a = einschaltenAttrappe(); const { calls } = await lauf({ modell: 'abo', einschalten: a.fn }) a.fertig(0); await new Promise((r) => setImmediate(r)) assert.equal(calls.filter((u) => u === '/api/funktionen').length, 2) }) test('Abo ohne Funktion einschalten: Satz E4 im Platz', async () => { const { stage } = await lauf({ modell: 'abo' }) assert.match(stage.els['#news-einschalten'].textContent, /Das Einschalten ist gerade nicht verfügbar\. Bitte lade die Seite neu oder schreib uns im Chat\./) }) test('Ohne modell (fehlt, „Abo“): unverändert Preis, Haken und Bestellknopf; der Ablauf wird nie gerufen', async () => { for (const modell of [undefined, 'Abo']) { const a = einschaltenAttrappe(); const { stage } = await lauf({ modell, einschalten: a.fn }) assert.match(stage.innerHTML, /30 €<\/strong>/) assert.match(stage.innerHTML, /id="news-order" disabled>Zahlungspflichtig bestellen · 30 € { const a = einschaltenAttrappe() const { root } = await lauf({ modell: 'abo', projects: [projekt('p1', 'Muster'), projekt('p2', 'Muster 2')], dataset: { wunschProjekt: 'p2' }, projectId: '', einschalten: a.fn }) assert.equal(a.aufrufe[0].projectId, 'p2') assert.equal(root.dataset.wunschProjekt, undefined) }) ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/kundenansichtNeuModulNews.test.js` Expected: FAIL (die Seite zeigt „30 €“ und `news-order`). - [ ] **Step 3: Implement in `funktionen.js`** `initNews(root, { projectId, einschalten } = {})`: Wunsch = `projectId || root.dataset?.wunschProjekt || ''`, Attribut entfernen (`root.dataset` kann in Attrappen fehlen), `selection.projectId` setzen. `modell` aus der Antwort mitlesen. Bei `modell === 'abo'` und Zustand `off` statt `renderBooking` eine neue Funktion `renderBookingAbo(stage, project, { einschalten, neuLaden })`: `stage.innerHTML = '
'`; ist `einschalten` eine Funktion, wird sie mit dem Platz und `{ key: 'news', projectId, viewAs: false, onFertig }` aufgerufen (`onFertig` ruft `neuLaden()` einmal; `neuLaden` ist `() => initNews(root, { einschalten })`), sonst steht E4 im Platz (`textContent`). Kein Import, kein `import(`. - [ ] **Step 4: Run tests to verify they pass** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/kundenansichtNeuModulNews.test.js test/funktionenTiles.test.js test/funktionenZeitenTile.test.js test/funktionenRecht.test.js test/shopWiring.test.js` Expected: PASS. - [ ] **Step 5: Commit** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/funktionen.js test/kundenansichtNeuModulNews.test.js && git commit -m "News: im Abo-Modell Einschalten statt Bestellen, Projektwahl über dataset" -m "Co-Authored-By: Claude Opus 5.5 " ``` ### Task 5: Webshop im Abo-Modell **Files:** - Modify: `public/shop.js` (`initShop` `:64`, `starte` `:69-74`, `zeigeAnfrage` `:195`, `zeigeBestellseite` `:253`, Import `:18`), `public/shop-anfrage.js` (`renderAnfrageFormular` `:37`, `:53-54`, `:67-68`, `:91-92`, `:115-120`) - Test: `test/kundenansichtNeuModulShop.test.js`; Pin `test/shopWiring.test.js:31` **Interfaces:** - Consumes: `renderEinschaltSeite` (Task 2), `meldeReiter` (Task 1), Testhilfen; V5-Felder `modell`, `kundenart` der Antwort von `GET /api/funktionen`. - Produces: `initShop(root, { projectId, einschalten } = {})`; `renderAnfrageFormular(root, { ..., modell })`. Im Abo-Modell sendet der Körper `anfrage` ohne `unternehmer` und mit `keineAusgeschlossenen: true`. Kundenart: nur `'unternehmen'` darf anfragen, alles andere (`'privat'`, leer, fehlend) gilt als privat (E4). - [ ] **Step 1: Pin anpassen und failing test** In `test/shopWiring.test.js:31` die Regex auf `/export function initShop\(root, \{ projectId, einschalten \} = \{\}\)/` ändern. Test `test/kundenansichtNeuModulShop.test.js`, Kopf: ```js const ANFRAGE = { warenart: 'kleidung', beschreibung: 'Handgemachte Mützen und Schals.', artikelzahl: 'bis20', versand: true, abholung: false, unternehmer: true, keineAusgeschlossenen: true, nachricht: '' } const funk = (state, kopf = {}) => ({ projects: [{ projectId: 'p1', projectName: 'Mein Laden', features: { shop: { state } } }], features: { shop: { priceLabel: '299 €', consent: { version: 'v', knopf: 'Zahlungspflichtig bestellen · 299 €', items: [] } } }, ustHinweis: 'einmalig, ohne Umsatzsteuer gemäß § 19 UStG', legal: { shop: {} }, shop: { verfuegbar: true }, ...kopf }) async function oeffne(fx, { kopf = {}, state = 'off', anfrage = null, einschalten } = {}) { vergissAuswahl() routen(fx, { 'GET /api/funktionen': { body: funk(state, kopf) }, 'GET /api/shop/status?projectId=p1': { body: { verfuegbar: true, state, ticketWoid: null, anfrage } }, 'POST /api/shop/anfrage': { body: { anfrage: {} } } }) const root = h('div'); document.body.append(root) await initShop(root, { projectId: 'p1', einschalten }) await fx.ruhe() return root } const ZUGESAGT = { status: 'angenommen', anfrage: ANFRAGE, at: '2026-10-05T10:30:00.000Z' } ``` ```js test('Abo, Betrieb: Anfrage ohne Unternehmer-Haken, Satz S2 über dem Knopf, kein Preis; Körper ohne unternehmer', mitDom(async (fx) => { const root = await oeffne(fx, { kopf: { modell: 'abo', kundenart: 'unternehmen' } }) assert.equal(fx.finde(root, '[data-feld="unternehmer"]'), null) assert.equal(fx.finde(root, '[data-feld="keineAusgeschlossenen"]'), null) const satz = fx.finde(root, '[data-satz="ausgeschlossen"]') assert.equal(fx.text(satz), `Mit „Anfrage senden“ bestätigst du, dass du keine ausgeschlossenen Waren verkaufst (${AUSGESCHLOSSEN}).`) const knoten = fx.alle(root, '*') assert.ok(knoten.indexOf(satz) < knoten.indexOf(fx.finde(root, '[data-aktion="anfrage-senden"]')), 'Satz steht über dem Knopf') assert.match(fx.text(fx.finde(root, '.sh-erklaerung')), /Im Wartungs-Abo enthalten\./) assert.doesNotMatch(fx.text(fx.finde(root, '.sh-erklaerung')), KEIN_PREIS) await fx.setze(fx.finde(root, '[data-feld="warenart"]'), 'kleidung') await fx.setze(fx.finde(root, '[data-feld="beschreibung"]'), 'Handgemachte Mützen und Schals.') await fx.setze(fx.finde(root, '[data-feld="artikelzahl"]'), 'bis20') await fx.klick(fx.finde(root, '[data-feld="versand"]')) await fx.klick(fx.finde(root, '[data-aktion="anfrage-senden"]')) assert.deepEqual(fx.fetchLog.find((a) => a.method === 'POST').body, { projectId: 'p1', anfrage: { warenart: 'kleidung', beschreibung: 'Handgemachte Mützen und Schals.', artikelzahl: 'bis20', versand: true, abholung: false, keineAusgeschlossenen: true, nachricht: '' } }) })) test('Abo, privat oder Kundenart leer/fehlend: Hinweis S3 mit Chat, nie das Formular, kein POST, leere Reiterliste', mitDom(async (fx) => { const k = hoereAufKn(fx) for (const kundenart of ['privat', '', undefined]) { const root = await oeffne(fx, { kopf: { modell: 'abo', kundenart } }) assert.equal(fx.finde(root, 'form'), null, String(kundenart)) assert.match(fx.text(root), /Der Webshop ist für Betriebe/) assert.match(fx.text(root), /Für Privatpersonen gibt es den Webshop leider nicht\. Stimmt das nicht\? Schreib uns im Chat\./) assert.ok(fx.finde(root, 'button[data-view="chat"]')) } assert.equal(fx.fetchLog.filter((a) => a.method === 'POST').length, 0) assert.deepEqual(k.reiter.at(-1).eintraege, []) })) test('Abo, privat mit bestehendem Shop (active): die fünf Reiter bleiben', mitDom(async (fx) => { const k = hoereAufKn(fx) await oeffne(fx, { kopf: { modell: 'abo', kundenart: 'privat' }, state: 'active' }) assert.equal(k.reiter.at(-1).eintraege.length, 5) })) test('Abo, Anfrage angenommen: Gruppe B1 und Ablauf key shop statt Bestellseite; onFertig lädt neu', mitDom(async (fx) => { const a = einschaltenAttrappe() const root = await oeffne(fx, { kopf: { modell: 'abo', kundenart: 'unternehmen' }, anfrage: ZUGESAGT, einschalten: a.fn }) assert.equal(a.aufrufe[0].key, 'shop') assert.equal(fx.text(fx.finde(root, '.tm-bestellen-gruppe h4')), 'Deine Anfrage') assert.equal(fx.finde(root, '[data-aktion="bestellen"]'), null) a.fertig(0); await fx.ruhe() assert.equal(fx.fetchLog.filter((x) => x.url === '/api/shop/status?projectId=p1').length, 2) })) test('Ohne modell (fehlt, „Abo“): Formular mit Unternehmer-Haken und 299 €, nach der Zusage die Bestellseite, der Ablauf wird nie gerufen', mitDom(async (fx) => { for (const modell of [undefined, 'Abo']) { const a = einschaltenAttrappe(); const kopf = modell ? { modell } : {} const formular = await oeffne(fx, { kopf, einschalten: a.fn }) assert.ok(fx.finde(formular, '[data-feld="unternehmer"]')) assert.match(fx.text(formular), /299 € einmalig/) const zugesagt = await oeffne(fx, { kopf, anfrage: ZUGESAGT, einschalten: a.fn }) assert.ok(fx.finde(zugesagt, '[data-aktion="bestellen"]')) assert.equal(a.aufrufe.length, 0) } })) ``` (`AUSGESCHLOSSEN` kommt aus `../public/shop-anfrage.js`.) - [ ] **Step 2: Run test to verify it fails** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/kundenansichtNeuModulShop.test.js` Expected: FAIL (`[data-feld="unternehmer"]` ist da). - [ ] **Step 3: Implement `shop-anfrage.js`** Neue Option `modell`. Bei `'abo'`: Stichpunkte S1 statt Preiszeile, kein `ustHinweis`-Absatz, kein Feld `unternehmer`, statt des Haken-Feldes `keineAusgeschlossenen` ein `p.sh-hinweis[data-satz="ausgeschlossen"]` mit S2 direkt vor dem Absende-Knopf; der Körper lässt `unternehmer` weg und sendet `keineAusgeschlossenen: true`. Ohne `modell` bleibt alles wie bisher. - [ ] **Step 4: Implement `shop.js`** `initShop(root, { projectId, einschalten } = {})` reicht `einschalten` an `starte` (auch im Wiederholen-Rückruf) und legt es in `s.einschalten`. `zeigeAnfrage`: bei `s.funktionen?.modell === 'abo'` und `s.funktionen.kundenart !== 'unternehmen'` die `infoKarte` mit S3 und Chat statt Formular. Das Formular bekommt `modell: s.funktionen?.modell`. `zeigeBestellseite`: im Abo-Modell `renderEinschaltSeite(wurzel, { ctx, daten, key: 'shop', zusammenfassung: zusammenfassungVon(daten.anfrage), einschalten: s.einschalten, nachEinschalten: () => ladeNeu(s) })` (der Gruppentitel B1 steht in `zusammenfassungVon` schon). Reiter wie in Task 1. Import `renderEinschaltSeite` zur bestehenden Importzeile von `./funktion-bestellen.js` hinzufügen. - [ ] **Step 5: Run tests to verify they pass** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/kundenansichtNeuModulShop.test.js test/shopView.test.js test/shopWiring.test.js` Expected: PASS. - [ ] **Step 6: Commit** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/shop.js public/shop-anfrage.js test/kundenansichtNeuModulShop.test.js test/shopWiring.test.js && git commit -m "Webshop: im Abo-Modell Anfrage ohne Unternehmer-Haken, Privat-Hinweis, Einschalten" -m "Co-Authored-By: Claude Opus 5.5 " ``` ### Task 6: Buchungskalender im Abo-Modell **Files:** - Modify: `public/buchungen.js` (`initBuchungen` `:32`, `starte` `:39-41`, `zeigeAnfrage` `:124-130`, Import `:8`), `public/buchungen-anfrage.js` (`anfrageListe` `:20`, `renderAnfrageFormular` `:33`, `:94-97`) - Test: `test/kundenansichtNeuModulBuchungen.test.js`; Pin `test/unterkunftWiring.test.js:130` **Interfaces:** - Consumes: `renderEinschaltSeite` (Task 2), `s.modell` und `meldeReiter` (Task 1), Testhilfen. - Produces: `initBuchungen(root, { projectId, einschalten } = {})`; `renderAnfrageFormular(root, { ..., modell })`; neuer Export `anfrageZeilen(a)` in `buchungen-anfrage.js` → `[{ name, wert }]` (Zeilen der Liste, wie `shop-anfrage.js`; `[]` ohne Objekt); `anfrageListe` nutzt sie. - [ ] **Step 1: Pin anpassen und failing test** `test/unterkunftWiring.test.js:130` auf `/export function initBuchungen\(root, \{ projectId, einschalten \} = \{\}\)/` ändern. Test `test/kundenansichtNeuModulBuchungen.test.js`, Kopf: ```js const ANFRAGE = { art: 'ferienwohnung', einheiten: 2, portale: ['booking'], nachricht: '' } const ZUGESAGT = { status: 'angenommen', anfrage: ANFRAGE, at: '2026-10-05T10:30:00.000Z' } async function oeffne(fx, { kopf = {}, anfrage = null, einschalten } = {}) { vergissAuswahl() routen(fx, { 'GET /api/funktionen': { body: { projects: [{ projectId: 'p1', projectName: 'Haus am See', features: { unterkunft: { state: 'off' } } }], unterkunft: { verfuegbar: true }, ...kopf } }, 'GET /api/unterkunft/status?projectId=p1': { body: { state: 'off', anfrage } }, 'POST /api/unterkunft/anfrage-funktion?projectId=p1': { status: 201, body: { anfrage: {} } }, }) const root = h('div'); document.body.append(root) await initBuchungen(root, { projectId: 'p1', einschalten }) await fx.ruhe() return root } ``` ```js test('Abo: Anfrageformular nennt S1, kein Preis, sendet den Körper unverändert', mitDom(async (fx) => { const root = await oeffne(fx, { kopf: { modell: 'abo' } }) const text = fx.text(fx.finde(root, '.sh-erklaerung')) assert.match(text, /Im Wartungs-Abo enthalten\./) assert.match(text, /Erst nach unserer Zusage kannst du einschalten\./) assert.doesNotMatch(text, KEIN_PREIS) await fx.setze(fx.finde(root, '[data-feld="art"]'), 'ferienwohnung') await fx.setze(fx.finde(root, '[data-feld="einheiten"]'), '2') await fx.klick(fx.finde(root, '[data-feld="portale.booking"]')) await fx.klick(fx.finde(root, '[data-aktion="anfrage-senden"]')) assert.deepEqual(fx.fetchLog.find((x) => x.method === 'POST').body, ANFRAGE) })) test('Abo, angenommen: Gruppe B1 und Ablauf key unterkunft; kein Haken, kein Wort „Rechnung“; onFertig lädt neu', mitDom(async (fx) => { const a = einschaltenAttrappe() const root = await oeffne(fx, { kopf: { modell: 'abo' }, anfrage: ZUGESAGT, einschalten: a.fn }) assert.equal(a.aufrufe[0].key, 'unterkunft') assert.equal(fx.text(fx.finde(root, '.tm-bestellen-gruppe h4')), 'Deine Anfrage') assert.match(fx.text(root), /Art der Unterkunft: Ferienwohnung oder Ferienhaus/) assert.equal(fx.alle(root, 'input[type="checkbox"]').length, 0) assert.doesNotMatch(fx.text(root), /Rechnung/) a.fertig(0); await fx.ruhe() assert.equal(fx.fetchLog.filter((x) => x.url === '/api/unterkunft/status?projectId=p1').length, 2) })) test('Ohne modell (fehlt, „Abo“): Formular mit „199 € einmalig“, angenommen zeigt „Wir melden uns bei dir“, der Ablauf wird nie gerufen', mitDom(async (fx) => { for (const modell of [undefined, 'Abo']) { const a = einschaltenAttrappe(); const kopf = modell ? { modell } : {} assert.match(fx.text(await oeffne(fx, { kopf, einschalten: a.fn })), /199 € einmalig für den Einbau in deine Website\./) assert.match(fx.text(await oeffne(fx, { kopf, anfrage: ZUGESAGT, einschalten: a.fn })), /Wir melden uns bei dir mit den nächsten Schritten zur Bestellung\./) assert.equal(a.aufrufe.length, 0) } })) ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/kundenansichtNeuModulBuchungen.test.js` Expected: FAIL. - [ ] **Step 3: Implement** `buchungen-anfrage.js`: `anfrageZeilen` herausziehen; im Abo-Modell erster und letzter Stichpunkt S1 statt `${preis} einmalig …`. `buchungen.js`: `einschalten` in die Sitzung; `zeigeAnfrage` zeigt bei `angenommen` im Abo-Modell `renderEinschaltSeite(wurzel, { ctx: { projectId, aktuell }, daten: { offen: [], kundenansicht: false }, key: 'unterkunft', zusammenfassung: () => [{ schritt: 'anfrage', titel: 'Deine Anfrage', zeilen: anfrageZeilen(ansicht.anfrage).map(({ name, wert }) => `${name}: ${wert}`) }], einschalten: s.einschalten, nachEinschalten: () => zeigeProjekt(s) })`. Das Rechnungsauftrag-Häkchen bleibt allein in `buchungen-tab-einstellungen.js` (unverändert). Import `renderEinschaltSeite` zur bestehenden Importzeile von `./funktion-bestellen.js` hinzufügen. - [ ] **Step 4: Run tests to verify they pass** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/kundenansichtNeuModulBuchungen.test.js test/buchungenUi.test.js test/unterkunftWiring.test.js` Expected: PASS. - [ ] **Step 5: Commit** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/buchungen.js public/buchungen-anfrage.js test/kundenansichtNeuModulBuchungen.test.js test/unterkunftWiring.test.js && git commit -m "Buchungskalender: im Abo-Modell Anfrage ohne Preis und Einschalten nach der Zusage" -m "Co-Authored-By: Claude Opus 5.5 " ``` ### Task 7: Öffnungszeiten live im Abo-Modell **Files:** - Modify: `public/zeiten.js` (`behaltenMoeglich` `:55`, `initZeiten` `:62-73`, `ladeListe` `:132`, `baueCtx` `:161`, `zeichneProjekt` `:173-232`), `public/zeiten-bestellen.js` (Import `:23`, neue Funktion nach `:105`, `zeitenStatusKarte` `:289`) - Test: `test/kundenansichtNeuModulZeiten.test.js` **Interfaces:** - Consumes: `renderEinschaltSeite` (Task 2; nur der benannte Import in der bestehenden Zeile `:23`), Testhilfen. - Produces: `initZeiten(root, { projectId, einschalten } = {})`; `s.modell`, `s.einschalten` (auch in `ladeListe`); `baueCtx` liefert `einschalten`; `renderZeitenEinschalten(root, { ctx, projekt })` (Export von `zeiten-bestellen.js`; ruft `renderEinschaltSeite` mit `key: 'zeiten'`, `daten: { offen: [], kundenansicht: ctx.kundenansicht }`, `einschalten: ctx.einschalten`, `nachEinschalten: () => ctx.fertig({ danke: 'eingeschaltet' })`; die Überschrift „Öffnungszeiten live einschalten“ kommt von P3); `zeitenStatusKarte({ ..., modell })`. Im Abo-Modell gibt es keinen Behalten-Weg: weder Knopf noch `wunschWeg='behalten'` noch `zeigeBehalten`. - [ ] **Step 1: Failing test `test/kundenansichtNeuModulZeiten.test.js`** Kopf: ```js const EDITOR = { state: 'active', daten: null, version: 0, kundenansicht: false, vorschau: null, feiertage: [], googleText: '' } // Antwort von GET /api/zeiten/p1 const feature = (state, extra = {}) => ({ state, siteKey: null, ticketWoid: null, orderedAt: null, activatedAt: null, invoiceUrl: null, disabledReason: null, disabledAt: null, loeschungAm: null, unlockedBy: state === 'off' ? null : 'abo', aboEndeAt: null, einbau: null, ...extra }) const liste = (modell, state = 'off', extra = {}, angebot = {}) => ({ body: { ...(modell ? { modell } : {}), projects: [{ projectId: 'p1', projectName: 'Website p1', liveUrl: '', features: { news: { state: 'off' }, zeiten: feature(state, extra) }, zeitenStatus: null, zeitenLeer: false }], features: { zeiten: { priceLabel: '30 €', consent: { version: 'v', knopf: 'Zahlungspflichtig bestellen · 30 €', items: [] }, leistungsbeschreibung: { version: '2026-10-05', url: '/vertragsunterlagen/zeiten-leistungsbeschreibung.html' } } }, ustHinweis: 'ohne Umsatzsteuer gemäß § 19 UStG', zeiten: { verfuegbar: true, inklusive: true, grund: null, preis: '30 €', einschalten: { consent: { version: 'v', knopf: 'Einschalten', items: [] }, leistungsbeschreibung: { version: '2026-10-05', url: '/vertragsunterlagen/zeiten-leistungsbeschreibung.html' } }, belehrung: 'Belehrung', ...angebot } } }) async function oeffne(fx, antwort, { einschalten, dataset = {} } = {}) { routen(fx, { 'GET /api/funktionen': antwort, 'GET /api/zeiten/p1': { body: EDITOR } }) const root = h('div'); document.body.append(root) Object.assign(root.dataset, dataset) await initZeiten(root, { projectId: 'p1', einschalten }); await fx.ruhe() return root } const ALT_ENDE = { aboEndeAt: '2026-09-01T00:00:00.000Z' }, KEIN_ABO = { inklusive: false, grund: 'kein_abo' } ``` ```js test('Abo, off: Einschalt-Seite mit key zeiten, ohne Haken, Preis und Behalten; nach onFertig die Danke-Zeile', mitDom(async (fx) => { const a = einschaltenAttrappe(); let n = 0 const root = await oeffne(fx, () => (n === 0 ? liste('abo') : liste('abo', 'setup')), { einschalten: a.fn }) assert.deepEqual([a.aufrufe[0].key, a.aufrufe[0].projectId], ['zeiten', 'p1']) assert.equal(fx.alle(root, 'input[type="checkbox"]').length, 0) assert.equal(fx.finde(root, '[data-aktion="behalten"], [data-aktion="bestellen"], [data-aktion="einschalten"]'), null) assert.doesNotMatch(fx.text(root), KEIN_PREIS) n = 1; a.fertig(0); await fx.ruhe() assert.match(fx.text(fx.finde(root, '.zt-status')), /Öffnungszeiten live ist eingeschaltet\./) })) test('Abo: Abo-Ende in active nennt die Frist (Z2) und „Eingeschaltet am“, ohne Preis und ohne Behalten-Knopf', mitDom(async (fx) => { const karte = zeitenStatusKarte({ projekt: { projectId: 'p1', zeitenLeer: false }, feature: { state: 'active', orderedAt: '2026-08-01T10:00:00.000Z', ...ALT_ENDE }, angebot: { ...KEIN_ABO, preis: '30 €' }, modell: 'abo' }, {}) assert.match(fx.text(karte), /Dein Wartungs-Abo ist beendet\. Öffnungszeiten live läuft noch bis 01\.10\.2026 und schaltet sich dann ab\./) assert.match(fx.text(karte), /Eingeschaltet am\s*01\.08\.2026/) assert.equal(fx.finde(karte, '[data-aktion="behalten"]'), null) assert.doesNotMatch(fx.text(karte), KEIN_PREIS) })) test('Abo: abgeschaltet mit Daten (Z3), ohne Behalten-Knopf', mitDom(async (fx) => { const karte = zeitenStatusKarte({ projekt: { projectId: 'p1', zeitenLeer: false }, feature: { state: 'disabled', disabledReason: 'abo_ende', loeschungAm: '2099-12-31' }, angebot: { ...KEIN_ABO, preis: '30 €' }, modell: 'abo' }, {}) assert.match(fx.text(karte), /Deine Eingaben bleiben bis 31\.12\.2099 gespeichert\. Mit einem neuen Wartungs-Abo schaltet sich Öffnungszeiten live wieder ein\./) assert.equal(fx.finde(karte, '[data-aktion="behalten"]'), null) assert.doesNotMatch(fx.text(karte), KEIN_PREIS) })) test('wunschWeg „behalten“: im Abo-Modell ohne Wirkung, ohne modell die Behalten-Seite und der Knopf „Für 30 € behalten“', mitDom(async (fx) => { const wunsch = { dataset: { wunschWeg: 'behalten' } } const abo = await oeffne(fx, liste('abo', 'active', ALT_ENDE, KEIN_ABO), wunsch) assert.equal(fx.finde(abo, '[data-variante="behalten"]'), null) assert.equal(fx.finde(abo, '[data-aktion="behalten"]'), null) const alt = await oeffne(fx, liste(undefined, 'active', ALT_ENDE, KEIN_ABO), wunsch) assert.ok(fx.finde(alt, '[data-variante="behalten"]')) const karte = zeitenStatusKarte({ projekt: { projectId: 'p1', zeitenLeer: false }, feature: { state: 'active', ...ALT_ENDE }, angebot: { ...KEIN_ABO, preis: '30 €' } }, {}) assert.equal(fx.text(fx.finde(karte, '[data-aktion="behalten"]')), 'Für 30 € behalten') })) ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/kundenansichtNeuModulZeiten.test.js` Expected: FAIL. - [ ] **Step 3: Implement `zeiten-bestellen.js`** `renderZeitenEinschalten` nach `renderZeitenBestellen`. `zeitenStatusKarte`: bei `modell === 'abo'` der Block „Abo-Ende“ (`frist && lage === 'bezahlen'`) als Info-Box mit Z2 (Frist aus `behaltenFrist`), der Block „abgeschaltet mit Daten“ (`daten && lage === 'bezahlen'`) als Info-Box mit Z3 (`tagText`); die Fälle `lage === 'inklusive' | 'abo'` bleiben; Faktenzeile E5. Keine Preis-, Behalten- oder Rechnungs-Elemente. - [ ] **Step 4: Implement `zeiten.js`** `initZeiten(root, { projectId, einschalten } = {})`; `s.modell` (auch in `ladeListe`), `s.einschalten`; `baueCtx` ergänzt `einschalten`; `zeichneProjekt`: Zustand `off` im Abo-Modell ruft `renderZeitenEinschalten(kopf, { ctx, projekt })`; der Aufruf von `zeitenStatusKarte` übergibt `modell`; `behaltenMoeglich(projekt, angebot, modell)` liefert im Abo-Modell `false`. Keine neue Importzeile: `renderZeitenEinschalten` kommt in die benannten Importe von `./zeiten-bestellen.js`. - [ ] **Step 5: Run tests to verify they pass** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/kundenansichtNeuModulZeiten.test.js test/zeitenView.test.js test/zeitenBestellen.test.js test/zeitenEditor.test.js` Expected: PASS. - [ ] **Step 6: Commit** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/zeiten.js public/zeiten-bestellen.js test/kundenansichtNeuModulZeiten.test.js && git commit -m "Öffnungszeiten live: im Abo-Modell Einschalten und kein Behalten-Weg" -m "Co-Authored-By: Claude Opus 5.5 " ``` ### Task 8: Analyse im Abo-Modell **Files:** - Modify: `public/analyse.js` (`initAnalyse` `:22-49`, `renderAnalyseActivation` `:76-112`, `mountCockpit` `:174-328`; neue relative Importzeile `./fn-reiter.js`) - Test: `test/kundenansichtNeuModulAnalyse.test.js` **Interfaces:** - Consumes: `htmlWurzel('#an-stage')` (mit `an`, `setAttribute`, `kn.reiter`), `KEIN_PREIS`, `meldeReiter`, `reiterEintraege` (Task 1); Antwortfeld `modell`. - Produces: `renderAnalyseActivation(stage, project, reload, { modell } = {})` (vierter Parameter optional); im Abo-Modell Linkzeile A1 statt des langen Häkchen-Satzes, ein Häkchen `#an-zielgruppe` mit A2 (sperrt den Knopf `#an-on` wie `#an-avv`, zusätzlich bei `controller.ok !== true`); Text A3 bei `kein_abo`. Der Körper von `POST /api/funktionen/analyse/activate` ist im Abo-Modell `{ projectId, accentColor, consent: { accepted: true, avvVersion }, audienceConfirmed: true }` (VF4; der heutige Server ignoriert das Feld). `root.dataset.wunschProjekt` wählt das Projekt (V2) und wird entfernt. - **Reiter in der Etage** (Spec 5.2 und 5.4, VF2): Im Abo-Modell zeichnet `initAnalyse` die Leiste `tabsHtml` nicht (kein `role="tablist"`, kein `.an-tabbtn`); das Panel `#an-stage` trägt `role="tabpanel"` und `aria-label` mit dem Label des aktiven Reiters statt `aria-labelledby`. `mountCockpit({ …, abo, root })` meldet in `showTab()` nach `paintTabs()` `meldeReiter(root, { eintraege: reiterEintraege('analyse', ANALYSE_REITER), aktiv: tabNow(), bei: pickTab })` mit `ANALYSE_REITER = [{ key: 'uebersicht', label: 'Übersicht' }, { key: 'heatmap', label: 'Heatmap' }, { key: 'einstellungen', label: 'Einstellungen' }]` (Reihenfolge und Wortlaut wie `tabsHtml`; `paintTabs` setzt im Abo-Modell `aria-label` statt `aria-labelledby`). Jeder Zweig ohne Cockpit (Einschalten, abgeschaltet, nicht verfügbar, unbekannt, keine Website) meldet vor dem Zeichnen die leere Liste, damit die Etage nicht die Reiter der vorigen Ansicht behält; das Cockpit ersetzt sie gleich danach. Die Kopfleiste (Website wählen, Zeitraum) bleibt im Inhalt, `analyse-cards.js` bleibt unverändert. Ohne `modell === 'abo'` bleibt alles wie heute: Leiste `tabsHtml`, `aria-labelledby`, kein Ereignis. - [ ] **Step 1: Failing test `test/kundenansichtNeuModulAnalyse.test.js`** Kopf (nach `funktionenTiles.test.js:405-428`): ```js import { analyseSel } from '../public/analyse-view.js' const { initAnalyse } = await import('../public/analyse.js') globalThis.document = { querySelectorAll: () => [] } // pickTab ruft hideChartTip after(() => { delete globalThis.document }) const LEGAL = { avv: '/rechtliches/avv-website-analyse-2026-10-02.html', lb: '/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html' } const CTRL = { ok: true, company: 'Muster GmbH', address: 'Weg 1, 12345 Ort', email: 'info@muster.de' } const offProject = (id = 'p1', analyse = {}) => ({ projectId: id, projectName: `Muster ${id}`, analyse: { state: 'off', eligible: true, accentColor: '#111111', avvVersion: '2026-10-02', legal: LEGAL, controller: CTRL, ...analyse } }) const aktivProjekt = (id = 'p1') => offProject(id, { state: 'active' }) const KEIN_ABO = { eligible: false, reason: 'kein_abo', reasonText: 'Alter Satz des Servers' } async function lauf({ modell, projects = [offProject()], dataset = {}, projectId = 'p1', tab = 'uebersicht' } = {}) { const posts = [] globalThis.fetch = async (url, opts) => { if (opts?.method === 'POST') posts.push({ url: String(url), body: JSON.parse(opts.body) }) if (String(url).startsWith('/api/funktionen/analyse/stats')) return { ok: false, status: 503, json: async () => ({}) } // das Cockpit zeigt die Fehlerkarte; mehr braucht der Reitertest nicht return { ok: true, status: 200, json: async () => ({ ...(modell ? { modell } : {}), projects }) } } const { root, stage, kn } = htmlWurzel('#an-stage') Object.assign(root.dataset, dataset) analyseSel.tab = tab await initAnalyse(root, { projectId }) return { root, stage, posts, kn } } ``` ```js test('Abo: Linkzeile A1 mit beiden Links, ein Häkchen mit A2, kein langer Zustimmungssatz, keine Preise', async () => { const { stage } = await lauf({ modell: 'abo' }) const html = stage.innerHTML assert.match(html, /Es gelten ]*>Leistungsbeschreibung<\/a> und ]*>Auftragsverarbeitung<\/a> \(Fassung 2026-10-02\)\./) assert.ok(html.includes(`href="${LEGAL.lb}"`) && html.includes(`href="${LEGAL.avv}"`)) assert.match(html, /Meine Website richtet sich nicht gezielt an Kinder unter 16 Jahren\./) assert.doesNotMatch(html, /Ich beauftrage WEBklar|id="an-avv"/) assert.doesNotMatch(html, KEIN_PREIS) }) test('Abo: Knopf gesperrt ohne Zielgruppen-Häkchen und bei unvollständigem Verantwortlichen; Körper mit audienceConfirmed', async () => { const { stage, posts } = await lauf({ modell: 'abo' }) const box = stage.els['#an-zielgruppe']; const knopf = stage.els['#an-on'] box.checked = true; box.handlers.change(); assert.equal(knopf.disabled, false) box.checked = false; box.handlers.change(); assert.equal(knopf.disabled, true) box.checked = true; box.handlers.change(); await knopf.handlers.click() assert.deepEqual(posts, [{ url: '/api/funktionen/analyse/activate', body: { projectId: 'p1', accentColor: '#111111', consent: { accepted: true, avvVersion: '2026-10-02' }, audienceConfirmed: true } }]) const ohne = await lauf({ modell: 'abo', projects: [offProject('p1', { controller: { ok: false, company: '', address: '', email: '', missing: ['firma'] } })] }) ohne.stage.els['#an-zielgruppe'].checked = true; ohne.stage.els['#an-zielgruppe'].handlers.change() assert.equal(ohne.stage.els['#an-on'].disabled, true) }) test('Abo: kein_abo nennt Hosting- und Wartungs-Abo mit Link zu Verträge (A3), nicht den Satz des Servers', async () => { const { stage } = await lauf({ modell: 'abo', projects: [offProject('p1', KEIN_ABO)] }) assert.match(stage.innerHTML, /Die Analyse ist im Hosting- und im Wartungs-Abo enthalten\./) assert.match(stage.innerHTML, /data-view="vertraege"/) assert.doesNotMatch(stage.innerHTML, /Alter Satz des Servers/) }) test('Ohne modell (fehlt, „Abo“): der lange Zustimmungssatz mit #an-avv und bei kein_abo der Satz des Servers', async () => { for (const modell of [undefined, 'Abo']) { const a = await lauf({ modell }) assert.match(a.stage.innerHTML, /id="an-avv"/) assert.match(a.stage.innerHTML, /Ich beauftrage WEBklar/) const b = await lauf({ modell, projects: [offProject('p1', KEIN_ABO)] }) assert.match(b.stage.innerHTML, /Alter Satz des Servers/) } }) test('wunschProjekt aus dem Behälter wählt das Projekt und wird entfernt', async () => { const { root, stage } = await lauf({ modell: 'abo', projects: [offProject('p1'), offProject('p2')], dataset: { wunschProjekt: 'p2' }, projectId: '' }) assert.match(stage.innerHTML, /Analyse für Muster p2 einschalten/) assert.equal(root.dataset.wunschProjekt, undefined) }) test('Abo, aktive Analyse: keine eigene Leiste, kn:reiter mit den drei Reitern, Panel mit aria-label; bei() wechselt und meldet den neuen aktiven Reiter', async () => { assert.equal(typeof CustomEvent, 'function', 'meldeReiter braucht CustomEvent (Node 20: global)') const { root, stage, kn } = await lauf({ modell: 'abo', projects: [aktivProjekt()] }) assert.doesNotMatch(root.innerHTML, /role="tablist"|an-tabbtn|aria-labelledby/) assert.match(root.innerHTML, /id="an-stage" role="tabpanel" aria-label="Übersicht"/) const erst = kn.reiter.at(-1) assert.deepEqual(erst.eintraege, [{ key: 'uebersicht', label: 'Übersicht' }, { key: 'heatmap', label: 'Heatmap' }, { key: 'einstellungen', label: 'Einstellungen' }]) assert.equal(erst.aktiv, 'uebersicht') erst.bei('heatmap'); await new Promise((r) => setImmediate(r)) assert.equal(kn.reiter.at(-1).aktiv, 'heatmap') assert.equal(stage.attribute['aria-label'], 'Heatmap') assert.equal(analyseSel.tab, 'heatmap') }) test('Abo ohne Cockpit (Einschalten, abgeschaltet, nicht verfügbar, keine Website): leere Reiterliste, „Zurück“ füllt die Zeile', async () => { for (const projects of [[offProject()], [offProject('p1', { state: 'disabled' })], [offProject('p1', KEIN_ABO)], []]) { const { kn } = await lauf({ modell: 'abo', projects }) assert.deepEqual(kn.reiter.at(-1)?.eintraege, [], JSON.stringify(projects.map((p) => p.analyse.state))) } }) test('Ohne modell (fehlt, „Abo“): eigene Leiste mit denselben drei Labels, aria-labelledby, kein kn:reiter', async () => { for (const modell of [undefined, 'Abo']) { const { root, kn } = await lauf({ modell, projects: [aktivProjekt()] }) assert.deepEqual([...root.innerHTML.matchAll(/role="tab"[^>]*>([^<]+) m[1]), ['Übersicht', 'Heatmap', 'Einstellungen']) assert.match(root.innerHTML, /role="tablist"/) assert.match(root.innerHTML, /id="an-stage" role="tabpanel" aria-labelledby="an-tab-uebersicht"/) assert.equal(kn.reiter.length, 0) } }) ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/kundenansichtNeuModulAnalyse.test.js` Expected: FAIL. - [ ] **Step 3: Implement in `analyse.js`** `initAnalyse(root, { projectId } = {})` liest `root.dataset?.wunschProjekt` und `modell` und reicht `{ modell }` an `renderAnalyseActivation`; die Texte A1–A3 über kleine Konstanten; die Abbildung im Abo-Modell ersetzt nur den `