Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-10-16-kundenansicht-05-funktionsmodule.md
T

1223 lines
109 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <noreply@anthropic.com>`, 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 <TT.MM.JJJJ> und schaltet sich dann ab.` |
| Z3 | `zeiten-bestellen.js` | abgeschaltet mit Daten: `Deine Eingaben bleiben[ bis <TT.MM.JJJJ>] gespeichert. Mit einem neuen Wartungs-Abo schaltet sich Öffnungszeiten live wieder ein.` |
| A1 | `analyse.js` | Linkzeile: `Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung <avvVersion>).`, 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.<schlüssel>.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 <noreply@anthropic.com>"
```
### 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 <noreply@anthropic.com>"
```
### 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 <noreply@anthropic.com>"
```
### 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, /<strong>30 €<\/strong>/)
assert.match(stage.innerHTML, /id="news-order" disabled>Zahlungspflichtig bestellen · 30 €</)
assert.equal(a.aufrufe.length, 0)
}
})
test('wunschProjekt aus dem Behälter wählt das Projekt und wird entfernt', async () => {
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 = '<div id="news-einschalten"></div>'`; 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 <noreply@anthropic.com>"
```
### 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 <noreply@anthropic.com>"
```
### 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 <noreply@anthropic.com>"
```
### 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 <noreply@anthropic.com>"
```
### 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 <a href="[^"]*"[^>]*>Leistungsbeschreibung<\/a> und <a href="[^"]*"[^>]*>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"[^>]*>([^<]+)</g)].map((m) => 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 `<label class="fn-consent">`-Block, den Körper der Aktivierung und den `kein_abo`-Satz, alles andere (Farbwahl, Banner-Vorschau, Verantwortlicher) bleibt. `a.legal` bleibt die Quelle der Links (Pin `avvPage.test.js:28-34`), kein `confirm(`.
Reiter: neue Importzeile `import { meldeReiter, reiterEintraege } from './fn-reiter.js'` und die Konstante `ANALYSE_REITER` neben `TABS`. `initAnalyse` liest `abo = modell === 'abo'`, meldet bei `abo` vor dem Zeichnen die leere Liste (jeder Zweig, auch die frühe Rückkehr ohne nutzbare Website), lässt `tabsHtml` weg und setzt das Panel-Attribut. `mountCockpit` bekommt `abo` und `root`; `showTab()` ruft nach `paintTabs()` eine Closure `melde()` (`abo && meldeReiter(root, …)`, `bei: pickTab`). Klick- und Tastatur-Handler bleiben unverändert: der Teaser „Heatmap“ der Übersicht ruft ebenfalls `pickTab`, `showTab()` meldet dann den neuen aktiven Reiter an die Etage.
- [ ] **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/kundenansichtNeuModulAnalyse.test.js test/analyseView.test.js test/avvPage.test.js test/funktionenTiles.test.js`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/analyse.js test/kundenansichtNeuModulAnalyse.test.js && git commit -m "Analyse: im Abo-Modell Linkzeile und Zielgruppen-Häkchen, Hosting berechtigt, Reiter in der Etage" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 9: `module.css` Gerüst, Prüftest und gemeinsame Bausteine
**Files:**
- Modify: `public/neu/module.css` (Stub von P1)
- Test: `test/kundenansichtNeuModulCss.test.js`
**Interfaces:**
- Consumes: `public/neu/tokens.css` und die übrigen `public/neu/*.css` (P1–P3, nur lesen); alte CSS-Dateien in `public/` als Klassenquelle.
- Produces: `module.css` mit Abschnitt 0 (Alt-Klassen, Fokus, Handy, reduzierte Bewegung). Der Prüftest hat die Liste `FERTIG` (Präfixe, für die schon die Abdeckung gilt); Task 10–13 hängen ihre Präfixe an.
**Gestaltungsregeln** (gelten für Task 9–13; alle Selektoren beginnen mit `.kn `):
| Altes Muster | Neu |
|---|---|
| Karte (`.kb-panel`, `*-abschnitt`, `*-info`, `fn-booking`) | Abschnitt: ohne Fläche, Rahmen, Radius; `padding: 20px 0`; ab dem zweiten Abschnitt obere Haarlinie `#ebeef1`; `h3` 15px/600 |
| Hinweis (`tm-box`, `sh-box`, `-info`, `-warn`) | Zeile `#f8f9fb`, Radius 6px, 14px, ohne Rahmen; `-warn`: Fläche `#fcedb9`, Schrift `#a82c00` |
| Meldung (`tm-meldung-ok/-fehler`, `sh-meldung-*`) | grüne Pille `#d7f7c2`/`#006908`, rote `#ffe7f2`/`#b3093c`; Feldfehler Schrift `#c0123c` |
| Reiterleisten (`an-tabs`, `tm-reiterleiste`, `sh-reiterleiste`) | Unterstrich-Reiter: Flex, `overflow-x: auto`, untere Haarlinie; Reiter 14px/600 `#5e6678`, aktiv `#111` mit 2px Unterstrich `#111`; Handy: 44px hoch. Die Leisten von Terminplaner, Webshop, Buchungskalender und Analyse zeichnet im Abo-Modell die Etage; ihre Klassen bleiben trotzdem gestaltet (Abdeckungstest, die alte Ansicht nutzt sie weiter) |
| Segment, Chip (`tm-seg`, `tm-chip`) | Ring-Schatten, aktiv Fläche `#111` und Schrift `#fff` |
| `.kb-btn-primary`, `.abo-btn` | Fläche `#111`, Schrift `#fff`, Radius 6px, 36px (Handy 44px), `:disabled` 40 % Deckkraft |
| `.kb-btn-outline`, `.kb-btn` | Fläche `#fff`, Ring-Schatten `0 0 0 1px rgba(60, 66, 87, .16), 0 1px 1px rgba(0, 0, 0, .06)`, hover `#f6f8fa` |
| Pillen (`tm-pille-*`, `sh-pille-*`, `fn-pill-*`) | ohne Rand: `wait`/`setup` orange (`#fcedb9`/`#a82c00`), `ok`/`on` grün, `off` grau (`#ebeef1`/`#414552`), `bad` rot, Info blau (`#e0f2fe`/`#075985`) |
| Felder (input, select, textarea) | Fläche `#fff`, Ring-Schatten, Radius 6px, 36px (Handy 44px); Fokus siehe Abschnitt 0 |
| Listen und Kacheln (`tm-eintrag`, `sh-artikel`, `bk-zeile`) | Zeilen mit Haarlinie, kein Kasten; Hover `#f6f8fa` |
| Rückfrage (`tm-frage-box`, `sh-frage-box`) | Fläche `#f8f9fb`, Radius 12px, kein Rahmen |
| Belegt/frei/angefragt/gesperrt (Kalender) | belegt `#111` mit `#fff`, angefragt orange Pille, gesperrt grau Pille, frei `#fff` mit Haarlinie |
- [ ] **Step 1: Failing test `test/kundenansichtNeuModulCss.test.js`**
Hilfen im Testfile: `lies(datei)` (liest aus `public/`), `regeln(css): { selektor, koerper }[]` (Kommentare entfernt, verschachtelte `@media`-Regeln flach, `@keyframes` ohne Schritte), `selektoren(css): string[]` (Kommalisten aufgespalten, Klammer-bewusst für `:is()` und `:not()`), `klassen(css, praefix): Set<string>` (aus den Selektoren), `neuCss()` = Inhalt aller `public/neu/*.css`, `MODUL()` = `public/neu/module.css`, `jsModule()` = Inhalt aller `public/*.js` und `public/neu/*.js`.
```js
const FERTIG = [] // Präfixe mit geprüfter Abdeckung; Task 10–13 ergänzen sie
const UNGENUTZT = new Set([]) // alte Klassen, die in keiner JS-Datei vorkommen; der Test prüft das
const ALT = { tm: 'termine.css', sh: 'shop.css', bk: 'buchungen.css', zt: 'zeiten.css', an: 'analyse.css', hs: 'analyse.css', nw: 'funktionen.css' }
const FN_NEWS = ['fn-project', 'fn-booking', 'fn-price', 'fn-estimate', 'fn-desc-link', 'fn-consent', 'fn-thanks', 'fn-status', 'fn-facts', 'fn-pill', 'fn-pill-setup']
const ALT_KLASSEN = ['kb-panel', 'kb-btn', 'kb-btn-primary', 'kb-btn-outline', 'kb-empty', 'kb-link', 'kb-checks', 'abo-actions', 'abo-btn', 'hidden', 'muted', 'error']
const PALETTE = new Set(['#fff', '#ffffff', '#1a1f36', '#5e6678', '#8a93a3', '#ebeef1', '#d8dde3', '#f6f8fa', '#f8f9fb', '#111', '#111111', '#414552', '#e0f2fe', '#075985', '#fcedb9', '#a82c00', '#efeafe', '#5b21b6', '#d7f7c2', '#006908', '#ffe7f2', '#b3093c', '#c0123c'])
const ZETTEL = /#fff6d6|#ddeffb|#ffe1d9/i
const RING = new Set(['60,66,87,.16', '60,66,87,0.16', '0,0,0,.06', '0,0,0,0.06'])
const HANDY = '@media (max-width: 880px), (pointer: coarse) and (max-height: 500px)'
test('module.css: jede Regel steht unter .kn und hängt nicht an der alten Seite', () => {
for (const s of selektoren(MODUL())) assert.match(s, /^\.kn(\s|[.:#[>~+]|$)/, s)
assert.doesNotMatch(MODUL(), /#view-|--kb-|\.kb-page/)
})
test('module.css: Verbote (Lila, Teal, Verlauf, Glas, Dunkel, Gewicht 800, Animation)', () => {
assert.doesNotMatch(MODUL(), /#9d3dfe|#d4b4ff|157,\s*61,\s*254|#2dd4bf|#5eead4|gradient|backdrop-filter|color-scheme:\s*dark|prefers-color-scheme|font-weight:\s*(800|900)|@keyframes/)
})
test('module.css: Farben nur aus der Palette der Übersicht; Zettelfarben nur in .nw-paper-*; rgba nur der Ring-Schatten', () => {
for (const { selektor, koerper } of regeln(MODUL())) {
for (const [, hex] of koerper.matchAll(/(#[0-9a-fA-F]{3,8})\b/g)) assert.ok(PALETTE.has(hex.toLowerCase()) || (ZETTEL.test(hex) && /\.nw-paper-/.test(selektor)), `${selektor}: ${hex}`)
for (const [, wert] of koerper.matchAll(/rgba?\(([^)]*)\)/g)) assert.ok(RING.has(wert.replace(/\s/g, '')), `${selektor}: rgba(${wert})`)
}
})
test('module.css: Fokus schwarz, Handy-Regeln wörtlich mit 44px, reduzierte Bewegung', () => {
const css = MODUL()
assert.match(css, /:focus-visible[^{]*\{[^}]*outline:\s*2px solid (#111|var\(--kn-[a-z-]+\))/)
assert.ok(css.includes(HANDY) && /min-height:\s*44px/.test(css.slice(css.indexOf(HANDY))))
assert.match(css, /prefers-reduced-motion:\s*reduce/)
})
test('module.css: jedes var(--kn-…) ist in public/neu/*.css definiert', () => {
const definiert = new Set([...neuCss().matchAll(/(--kn-[a-z0-9-]+)\s*:/g)].map((m) => m[1]))
for (const [, name] of MODUL().matchAll(/var\((--kn-[a-z0-9-]+)/g)) assert.ok(definiert.has(name), name)
})
test('Alt-Klassen der Module sind unter .kn gestaltet (in einer Datei von public/neu)', () => {
const sel = selektoren(neuCss()).filter((s) => s.startsWith('.kn'))
for (const k of ALT_KLASSEN) assert.ok(sel.some((s) => new RegExp(`\\.${k}(?![a-z0-9-])`).test(s)), k)
})
test('UNGENUTZT enthält nur Klassen, die kein JS nennt', () => {
for (const k of UNGENUTZT) assert.ok(!jsModule().includes(k), k)
})
for (const praefix of FERTIG) test(`Abdeckung ${praefix}: jede Klasse der alten CSS-Datei ist unter .kn gestaltet`, () => {
const alt = praefix === 'fn' ? FN_NEWS : [...klassen(lies(ALT[praefix]), praefix)]
const neu = klassen(neuCss(), praefix)
assert.deepEqual(alt.filter((k) => !neu.has(k) && !UNGENUTZT.has(k)).sort(), [])
})
```
Für das Präfix `fn` gilt nur die Liste `FN_NEWS`; die Kachel-Klassen (`fn-tile*`, `fn-tiles`, `fn-count`, `fn-ico`) gehören P3. In `ALT` steht kein Eintrag für `fn`.
- [ ] **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/kundenansichtNeuModulCss.test.js`
Expected: FAIL (Fokus-Regel, Handy-Block und Alt-Klassen fehlen im Stub von P1).
- [ ] **Step 3: Abschnitt 0 in `public/neu/module.css` schreiben**
Kopfkommentar mit der Abschnittsliste (0 gemeinsame Bausteine, 1 Terminplaner, 2 Webshop und Buchungen, 3 Öffnungszeiten, 4 News, 5 Analyse). Abschnitt 0: die Alt-Klassen aus `ALT_KLASSEN` nach den Gestaltungsregeln, soweit noch nicht in einer anderen Datei von `public/neu/` unter `.kn` definiert (`command grep -a -n "kb-panel" public/neu/*.css` je Klasse; keine Doppeldefinition, VF3); Fokus-Regel für `button, input, select, textarea, summary, a` mit `outline: 2px solid #111; outline-offset: 2px`; der Block `HANDY` mit `min-height: 44px` für Knöpfe und Felder; `@media (prefers-reduced-motion: reduce)` setzt `transition: none`. Token-Namen (`var(--kn-…)`) nur aus `tokens.css`.
- [ ] **Step 4: 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/kundenansichtNeuModulCss.test.js`
Expected: PASS (Abdeckungstests laufen noch nicht, `FERTIG` ist leer).
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/module.css test/kundenansichtNeuModulCss.test.js && git commit -m "module.css: Gerüst, Prüftest und gemeinsame Bausteine unter .kn" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 10: `module.css` Terminplaner (`tm-`)
**Files:**
- Modify: `public/neu/module.css` (Abschnitt 1), `test/kundenansichtNeuModulCss.test.js` (`FERTIG`)
**Interfaces:**
- Consumes: Gestaltungsregeln aus Task 9; alte Klassenquelle `public/termine.css` (auch die `tm-`-Bausteine von Shop, Buchungen und Öffnungszeiten).
- Produces: Abdeckung `tm`.
- [ ] **Step 1: Präfix in den Test aufnehmen**
In `test/kundenansichtNeuModulCss.test.js` `FERTIG = ['tm']`.
- [ ] **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/kundenansichtNeuModulCss.test.js`
Expected: FAIL; „Abdeckung tm“ listet die fehlenden Klassen.
- [ ] **Step 3: Abschnitt 1 schreiben**
Alle `tm-`-Klassen der Liste. Entscheidungen, die die Regeltabelle nicht abdeckt:
- Fortschrittsleiste (`tm-schritte`, `tm-schritt*`): Zahlenkreis 28px, erledigt `#111` mit `#fff`, aktuell Ring `#111`; am Handy zeigt `tm-schritt-stand` den Zähler („Schritt n von 8“) und `tm-schritt-titel` ist nur noch für Screenreader da (visuell versteckt).
- Bestellseite (`tm-bestellen*`) und `tm-einschalten`: Abschnitte ohne Kasten; `tm-rechtstext` bleibt ein scrollbarer Bereich (`max-height`, `overflow-y: auto`, `white-space: pre-wrap`, Fläche `#f8f9fb`); `tm-einschalten-platz` ohne eigenes Aussehen (P3 gestaltet den Ablauf).
- Statuskarte (`tm-statuskarte`): Abschnitt, Pille oben, Fakten als zweispaltige Liste (`dt` `#5e6678`).
- Terminliste und Editoren (`tm-eintrag`, `tm-editor-box`, `tm-formular-box`): Zeilen mit Haarlinie, Editor klappt darunter ohne Rahmen auf.
- `tm-sr` bleibt als Screenreader-Klasse unverändert.
- [ ] **Step 4: 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/kundenansichtNeuModulCss.test.js`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/module.css test/kundenansichtNeuModulCss.test.js && git commit -m "module.css: Terminplaner und gemeinsame Bestellbausteine (tm-)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 11: `module.css` Webshop und Buchungskalender (`sh-`, `bk-`)
**Files:**
- Modify: `public/neu/module.css` (Abschnitt 2), `test/kundenansichtNeuModulCss.test.js`
**Interfaces:**
- Consumes: Gestaltungsregeln; Klassenquellen `public/shop.css`, `public/buchungen.css`.
- Produces: Abdeckung `sh` und `bk`.
- [ ] **Step 1: `FERTIG = ['tm', 'sh', 'bk']`**
- [ ] **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/kundenansichtNeuModulCss.test.js`
Expected: FAIL; „Abdeckung sh“ und „Abdeckung bk“ listen die fehlenden Klassen.
- [ ] **Step 3: Abschnitt 2 schreiben**
- Shop: `sh-reiterleiste` und `sh-reiter` wie `an-tabs` (der Buchungskalender nutzt sie mit); Anfrage (`sh-anfrage`, `sh-form`, `sh-erklaerung`, `sh-gruppe`): Formular einspaltig, Stichpunkte der Erklärung `#5e6678`; `sh-hinweis[data-satz]` (Satz über dem Knopf) 14px `#1a1f36` mit Abstand 12px zum Knopf; Zahlen `tabular-nums`.
- Buchungen: `bk-zeile` als Listenzeile mit Haarlinie; `bk-posten-*` am Handy zwei Spalten (wie die alte Regel); Kalender nach der Zeile „belegt/frei/angefragt/gesperrt“ der Tabelle.
- Artikelbilder und Vorschauen behalten ihre Seitenverhältnisse, der Hintergrund ist `#f6f8fa`.
- [ ] **Step 4: 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/kundenansichtNeuModulCss.test.js`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/module.css test/kundenansichtNeuModulCss.test.js && git commit -m "module.css: Webshop und Buchungskalender (sh-, bk-)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 12: `module.css` Öffnungszeiten und News (`zt-`, `fn-`, `nw-`)
**Files:**
- Modify: `public/neu/module.css` (Abschnitte 3 und 4), `test/kundenansichtNeuModulCss.test.js`
**Interfaces:**
- Consumes: Gestaltungsregeln; Klassenquellen `public/zeiten.css`, `public/funktionen.css` (nur `FN_NEWS` und `nw-`).
- Produces: Abdeckung `zt`, `fn` (Liste `FN_NEWS`), `nw`.
- [ ] **Step 1: `FERTIG = ['tm', 'sh', 'bk', 'zt', 'fn', 'nw']`**
- [ ] **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/kundenansichtNeuModulCss.test.js`
Expected: FAIL; „Abdeckung zt“, „fn“, „nw“ listen die fehlenden Klassen.
- [ ] **Step 3: Abschnitte 3 und 4 schreiben**
- Öffnungszeiten (`zt-*`): Editor als Tageszeilen mit Haarlinie, `zt-hinweis` und `zt-status` als Abschnitt; Zeitfelder 44px am Handy.
- News: `fn-booking`, `fn-status`, `fn-facts` als Abschnitt; `fn-thanks` grüne Pille; `fn-pill-setup` orange Pille; `nw-`-Formular einspaltig mit Haarlinien. Vorschau-Zettel (`nw-paper`, `nw-polaroid`) behalten Drehung und Papierfarben (`.nw-paper-sun|sky|coral|white` setzen `--paper` auf `#fff6d6`, `#ddeffb`, `#ffe1d9`, `#fff`); der Schatten wird der Ring-Schatten; Auswahl der Zettelfarbe (`nw-sw[aria-pressed='true']`) mit Außenlinie `#111`.
- [ ] **Step 4: 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/kundenansichtNeuModulCss.test.js`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/module.css test/kundenansichtNeuModulCss.test.js && git commit -m "module.css: Öffnungszeiten und News (zt-, fn-, nw-)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 13: `module.css` Analyse (`an-`, `hs-`)
**Files:**
- Modify: `public/neu/module.css` (Abschnitt 5), `test/kundenansichtNeuModulCss.test.js`
**Interfaces:**
- Consumes: Gestaltungsregeln; Klassenquelle `public/analyse.css`; `analyse-cards.js` (Klassenvertrag im Kopf der Datei).
- Produces: Abdeckung `an` und `hs`. Die Analyse bleibt über `.an` mit eigenen Variablen gebaut; sie werden hell belegt.
- [ ] **Step 1: `FERTIG = ['tm', 'sh', 'bk', 'zt', 'fn', 'nw', 'an', 'hs']`**
- [ ] **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/kundenansichtNeuModulCss.test.js`
Expected: FAIL; „Abdeckung an“ und „hs“ listen die fehlenden Klassen.
- [ ] **Step 3: Abschnitt 5 schreiben**
`.kn .an` belegt die Variablen neu: `--an-fg: #1a1f36`, `--an-fg2` und `--an-muted: #5e6678`, `--an-faint: #8a93a3` (nur für Nebensächliches), `--an-line: #ebeef1`, `--an-line2: #d8dde3`, `--an-card: #fff`, `--an-acc` und `--an-acc-strong: #111`, `--an-acc-soft: #f6f8fa`, `--an-on-acc: #fff`, `--an-up-bg: #d7f7c2` (Schrift `#006908`), `--an-down: #b3093c`, `--an-down-bg: #ffe7f2`, `--an-prev: #8a93a3`, `--an-warn: #a82c00`, `--an-bar-fill: #ebeef1`. Diagramme: Linie und Punkte `#111`, Fläche `#f6f8fa`, Vergleichslinie `#8a93a3` gestrichelt, Balken `#111` auf `#ebeef1`. Die Banner-Vorschau (`an-bn*`) bildet die Kundenseite ab: Fläche `#fff` mit Ring-Schatten; ihre Knopffarbe kommt inline aus dem Kunden-Akzent. Heatmap-Rahmen (`hs-*`) bekommen Fläche `#f6f8fa`; `geraete.css` bleibt unberührt. Keine `color-scheme: dark`, kein Verlauf in `--an-bar-fill`.
- [ ] **Step 4: 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/kundenansichtNeuModulCss.test.js`
Expected: PASS (alle Abdeckungen).
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/module.css test/kundenansichtNeuModulCss.test.js && git commit -m "module.css: Analyse hell (an-, hs-)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 14: Sichtprüfung mit dem Werkzeug aus P0 und Bild der Analyse
**Files:**
- Modify (Werkzeug-Git, `KW` = `/root/reports/kundenansicht-neu-2026-10-15/werkzeug`): `views.mjs`, `gates.mjs`, `fixtures/faelle.mjs` und `fixtures/index.mjs` (nur Einträge für die neuen Fälle), `fixtures/endpunkte.mjs` (ein Import), `vertrag-endpunkte.mjs` (Einträge der zwei Analyse-Endpunkte); Create: `fixtures/module.mjs` (17 Fälle), `fixtures/endpunkte-p4.mjs`. Nicht angefasst werden `test/backend.test.mjs` (Pin „Fälle in fester Reihenfolge“) und `vorschau/boot.js` (VF5).
- Modify (Portal-Worktree, bei Befund): `public/neu/module.css`; neu erzeugt: `public/neu/bilder/analyse.webp`
**Interfaces:**
- Consumes (P0, P1, P3): Ansichtsfelder `{ id, fall, adresse, geraete, warte, schritte?(seite), bewegung?, ignorieren? }`; `GATES.p<N> = { checks, ansichten }` mit `ALWAYS_ON`, `SEITEN_PRUEFUNGEN = ['dark-surface', 'low-contrast', 'overflow-x', 'lila']`; Geräte `handy` 390×844, `tablet` 820×1180, `desktop` 1440×900; Läufer `KN_STRANG_ENV=<KW>/tmp/strang-ui.env <KW>/shots.sh <Ausgabe> --gate p4`; die Fälle `betrieb-wartung` und `privat-frist` und die Modul-Endpunkte `termine`, `shop`, `unterkunft`, `zeiten`, `news` (Formen laut Karte `woms-vorschau.md` B4); Bild-Skript `bash <KW>/run-browser.sh <KW>/bilder/erzeuge.mjs --ziel <Ordner> [key …]` (P3 Task 8), das für `analyse` die Ansicht `kn-modul-analyse` braucht. Einen Fall `bilder` gibt es in keinem Plan: P3 arbeitet mit `betrieb-wartung`, die Analyse-Fälle legt dieser Task an. Auch die Endpunkte der aktiven Analyse (`analyse.stats`, `analyse.heatmap-link`) fehlen in P0 und P3 und kommen hier dazu.
- Produces: `GATES.p4 = { checks: [...ALWAYS_ON, ...SEITEN_PRUEFUNGEN], ansichten: ['kn-modul-*'] }` grün; 17 neue Fälle `m-*` hinter den vierzehn von P0 in `FALL_NAMEN`; 20 Ansichten, je auf Handy, Tablet und Desktop:
| id (`kn-modul-…`) | Fall, Adresse | Zeigt und prüft |
|---|---|---|
| `termine-einschalten` | `m-termine-fragebogen`, `#termine?projekt=p1` | letzter Schritt „Prüfen und einschalten“; keine Leiste im Inhalt; Etage ohne Reiter, „Zurück“ füllt die Zeile |
| `termine-aktiv` | `m-termine-aktiv`, `#termine?projekt=p1` | Etage mit vier Reitern, Handy „Team“, Desktop „Team & Plätze“; die Zeile scrollt bei 320 px, kein seitlicher Überlauf der Seite |
| `termine-setup`, `termine-aus` | `m-termine-setup`, `m-termine-aus` | Statuskarte „Eingeschaltet am“; Hinweis zum Abo-Ende |
| `shop-anfrage`, `shop-privat`, `shop-angenommen`, `shop-aktiv` | `m-shop-anfrage`, `privat-frist`, `m-shop-angenommen`, `m-shop-aktiv` | Satz über „Anfrage senden“; Privat-Hinweis mit Chat; Zusammenfassung und Ablauf; fünf Reiter, Handy „Start“ und „Recht“ |
| `buchungen-anfrage`, `buchungen-angenommen`, `buchungen-aktiv` | `m-buchungen-anfrage`, `m-buchungen-angenommen`, `m-buchungen-aktiv` | kein Preis; fünf Reiter, Handy „Preise“ |
| `zeiten-einschalten`, `zeiten-aktiv`, `zeiten-abo-ende`, `zeiten-abgeschaltet` | `m-zeiten-aus`, `m-zeiten-aktiv`, `m-zeiten-abo-ende`, `m-zeiten-abgeschaltet` | Ablauf ohne Haken; Z2 ohne Behalten-Knopf; Z3 |
| `news-einschalten`, `news-editor` | `m-news-aus`, `betrieb-wartung` | Platz des Ablaufs; Editor mit Zettelvorschau in den drei Zettelfarben |
| `analyse-aktivieren`, `analyse`, `analyse-heatmap` | `m-analyse-aus`, `m-analyse-aktiv`, `m-analyse-aktiv` | Linkzeile und ein Häkchen; Übersicht (Quelle des Bildes für P3) mit Diagramm schwarz auf Weiß und Etage mit drei Reitern (Handy und Desktop „Übersicht“, „Heatmap“, „Einstellungen“), keine Leiste im Inhalt; Heatmap (`schritte`: Tipp auf den Etagen-Reiter „Heatmap“): Reiter aktiv, Geräterahmen |
Jede Ansicht prüft zusätzlich: `getComputedStyle(document.body).backgroundColor` ist `rgb(255, 255, 255)`, im Modul-Inhalt kein Element mit `role="tablist"` (auch nicht in der Analyse), Tippflächen der Knöpfe am Handy mindestens 44 px hoch.
- [ ] **Step 1: Fälle, Ansichten und Gate anlegen**
`fixtures/module.mjs` liefert die 17 Fälle `m-*` (aus `betrieb-wartung` abgeleitet, nur der Modul-Endpunkt und `GET /api/funktionen` mit `modell: 'abo'` ändern sich): die 16 der Tabelle und `m-analyse-aktiv` (Analyse `active` im Projekt, mit Besucher- und Heatmap-Daten: Statistik über 30 Tage mit Reihe, Seiten, Herkunft, Ländern, Geräten und Kontakten in der Form von `stats()` in `test/analyseCards.test.js:17-40`). Die Namen werden an `FALL_NAMEN` angehängt (17 hinter den vierzehn von P0), weil `views.test` aus P0 `FALL_NAMEN.includes(fall)` verlangt; `fixtures/faelle.mjs` und `fixtures/index.mjs` bekommen dafür je einen Import. `fixtures/endpunkte-p4.mjs` legt `analyse.stats` (`GET /api/funktionen/analyse/stats`, Antwort aus `daten` des Falls) und `analyse.heatmap-link` (`POST /api/funktionen/analyse/heatmap-link`, `server/routes/funktionen.js:519` und `:540`) an; `heatmap-link` antwortet mit `{ url: '/vorschau/demo-baeckerei.html?__wk_heat=x&__wk_embed=1' }` (same-origin, sonst meldet das Gate `external-request`). In `vertrag-endpunkte.mjs` stehen beide als `NICHT_PRUEFBAR` mit dem Grund „Analytics-Dienst läuft nicht über die Testnachbauten“, wie bei P3. `views.mjs` bekommt die 20 Ansichten, `gates.mjs` das Gate `p4`. Ausgangslage: `printf 'export WT=/home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui\n' > /root/reports/kundenansicht-neu-2026-10-15/werkzeug/tmp/strang-ui.env`, falls P1 sie nicht schon angelegt hat. Keine eigene Browser-Instanz.
Danach die Werkzeug-Tests, ohne dass Pin oder `boot.js` angefasst wurden: `cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/backend.test.mjs test/vertrag.test.mjs test/gates.test.mjs test/vorschau-bau.test.mjs` Expected: PASS. Schlägt der Pin „Fälle in fester Reihenfolge“ oder der `boot.js`-Test („alle vierzehn Fälle genannt“) nur wegen der angehängten Namen fehl: nicht selbst ändern, stoppen und VF5 dem Controller melden.
- [ ] **Step 2: Erster Lauf**
Run: `free -m && cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && KN_STRANG_ENV=/root/reports/kundenansicht-neu-2026-10-15/werkzeug/tmp/strang-ui.env /root/reports/kundenansicht-neu-2026-10-15/werkzeug/shots.sh /root/reports/kundenansicht-neu-2026-10-15/werkzeug/tmp/shots-p4 --gate p4`
Expected: „available“ über 2000 MB; der Lauf listet Befunde (`dark-surface`, `low-contrast`, `overflow-x`, `lila`, `unknown-api`, `step-failed`), solange `module.css` nicht fertig geprüft ist. Danach die Aufnahmen für Handy und Desktop mit dem Read-Werkzeug ansehen: kein Lila oder Türkis, Haarlinien statt Kästen, Reiterzeile in der Etage.
- [ ] **Step 3: Befunde beheben**
CSS-Befunde in `public/neu/module.css` (Abschnitt der Klasse), Fixture-Befunde im Werkzeug. Danach `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/kundenansichtNeuModulCss.test.js` Expected: PASS.
- [ ] **Step 4: Gate erneut laufen lassen**
Run: wie Step 2.
Expected: `0 Befunde`, Exit 0, für alle 20 Ansichten und alle Geräte.
- [ ] **Step 5: Bild der Analyse neu erzeugen (P3 VF2)**
`erzeuge.mjs analyse` nimmt die Ansicht `kn-modul-analyse` mit dem Fall `m-analyse-aktiv` (Step 1).
Run: `bash /root/reports/kundenansicht-neu-2026-10-15/werkzeug/run-browser.sh /root/reports/kundenansicht-neu-2026-10-15/werkzeug/bilder/erzeuge.mjs --ziel /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui/public/neu/bilder analyse && ls -l /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui/public/neu/bilder/analyse.webp`
Expected: Die Datei liegt da, kleiner als 122880 Byte (960×600 prüft P3s `test/knFunktionenStatik.test.js`: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knFunktionenStatik.test.js` PASS). Weicht die Form des Skripts im P3-Plan ab, gilt die Form dort.
- [ ] **Step 6: Commit**
```bash
git -C /root/reports/kundenansicht-neu-2026-10-15/werkzeug add views.mjs gates.mjs fixtures/module.mjs fixtures/endpunkte-p4.mjs fixtures/endpunkte.mjs fixtures/faelle.mjs fixtures/index.mjs vertrag-endpunkte.mjs && git -C /root/reports/kundenansicht-neu-2026-10-15/werkzeug commit -m "Ansichten, Fälle und Gate p4 der Funktionsmodule"
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/module.css public/neu/bilder/analyse.webp && git commit -m "module.css: Korrekturen aus der Sichtprüfung, Bild der Analyse in neuer Optik" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
Gab es keine CSS-Befunde, steht im zweiten Commit nur das Bild.
### Task 15: Selbstprüfung
**Files:** keine Änderungen.
**Interfaces:** Consumes alle Ergebnisse aus Task 1–14. Produces den Tag `kn-p4`.
- [ ] **Step 1: Speicher prüfen**
Run: `free -m`
Expected: „available“ mindestens 2000 MB, sonst warten und dem Controller melden.
- [ ] **Step 2: Alle neuen Testdateien**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 1800 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/kundenansichtNeuModulReiter.test.js test/kundenansichtNeuModulEinschalten.test.js test/kundenansichtNeuModulTermine.test.js test/kundenansichtNeuModulNews.test.js test/kundenansichtNeuModulShop.test.js test/kundenansichtNeuModulBuchungen.test.js test/kundenansichtNeuModulZeiten.test.js test/kundenansichtNeuModulAnalyse.test.js test/kundenansichtNeuModulCss.test.js`
Expected: alle Tests PASS, 0 fail.
- [ ] **Step 3: Bestehende Modul-Tests**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 2400 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/termineView.test.js test/termineFragebogenView.test.js test/funktionBestellenKey.test.js test/shopView.test.js test/shopWiring.test.js test/buchungenUi.test.js test/unterkunftWiring.test.js test/zeitenView.test.js test/zeitenBestellen.test.js test/zeitenEditor.test.js test/funktionenTiles.test.js test/funktionenZeitenTile.test.js test/funktionenRecht.test.js test/analyseView.test.js test/avvPage.test.js`
Expected: alle PASS, 0 fail. Die Pins `shopWiring.test.js:31` und `unterkunftWiring.test.js:130` sind die einzigen geänderten Zeilen bestehender Tests.
- [ ] **Step 4: Verträge abhaken**
- [ ] V2: `initNews` und `initAnalyse` lesen `root.dataset.wunschProjekt` (Task 4, 8); die Module behalten ihre Klassen (Task 9–13, Abdeckungstest).
- [ ] V5: Shop-Anfrage ohne `unternehmer`, mit `keineAusgeschlossenen: true` (Task 5); Terminplaner ohne Häkchen `gesundheit`, Einschalten statt Checkout (Task 3); Analyse über `analyse/activate` (Task 8).
- [ ] P1 Vertragsfrage 5: Reiter von Terminplaner, Webshop, Buchungskalender als `kn:reiter` mit `kurz`, leere Liste ohne Reiter (Task 1, 3, 5); Reiter der Analyse ebenso, ohne eigene Leiste (Task 8, Gate `p4` ohne Ausnahme).
- [ ] Global Constraints: keine Preise (`KEIN_PREIS` in Task 2 und 4–8), Du-Form (Pins der Bestandstests), Medienabfrage und 44px (Task 9), Palette (Task 9).
- [ ] Vertragsfragen VF1–VF6 stehen dem Controller zur Entscheidung; die Annahmen sind in den Tasks umgesetzt.
- [ ] **Step 5: Alte Ansicht und Dateiliste prüfen**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git diff --stat kn-p3 HEAD -- public/dashboard.html public/app.js public/login.html public/termine.css public/shop.css public/buchungen.css public/zeiten.css public/funktionen.css public/analyse.css public/geraete.css public/kundenbereich.css`
Expected: leere Ausgabe.
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git diff --name-only kn-p3 HEAD | sort`
Expected genau diese 26 Zeilen: `public/analyse.js`, `public/buchungen-anfrage.js`, `public/buchungen.js`, `public/fn-reiter.js`, `public/funktion-bestellen.js`, `public/funktionen.js`, `public/neu/bilder/analyse.webp`, `public/neu/module.css`, `public/shop-anfrage.js`, `public/shop.js`, `public/termine-fragebogen.js`, `public/termine.js`, `public/zeiten-bestellen.js`, `public/zeiten.js`, `test/kundenansichtNeuModulAnalyse.test.js`, `test/kundenansichtNeuModulBuchungen.test.js`, `test/kundenansichtNeuModulCss.test.js`, `test/kundenansichtNeuModulEinschalten.test.js`, `test/kundenansichtNeuModulNews.test.js`, `test/kundenansichtNeuModulReiter.test.js`, `test/kundenansichtNeuModulShop.test.js`, `test/kundenansichtNeuModulTermine.test.js`, `test/kundenansichtNeuModulZeiten.test.js`, `test/shopWiring.test.js`, `test/support/modulAbo.js`, `test/unterkunftWiring.test.js`.
- [ ] **Step 6: Einbindung, Sauberkeit, Tag**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && command grep -a -n "module.css" public/neu/dashboard.html; command grep -a -n "einschalten" public/neu/huelle.js; git status --short; ls -l /dev/null`
Expected: eine Zeile mit `<link rel="stylesheet" href="/neu/module.css">`; mindestens eine Zeile in `huelle.js`, die `einschalten` an die Module übergibt (fehlt sie: nicht selbst ändern, VF1 melden); `git status --short` leer; `/dev/null` zeigt `crw-rw-rw-`.
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git tag kn-p4 && git -C /root/reports/kundenansicht-neu-2026-10-15/werkzeug tag kn-p4`
Expected: kein Fehler; `git tag --list 'kn-p*'` nennt `kn-p4`.
## Vertragsfragen an den Controller
**VF1 Einschalt-Ablauf (P1, P3, P4).** P3 liefert `zeigeEinschalten(platz, { key, projectId, onFertig, daten?, viewAs?, … })` (P3 Task 4) und erwartet die Übergabe als Init-Option `einschalten` an `initTermine`, `initNews`, `initShop`, `initBuchungen`, `initZeiten`; P4 nutzt genau diese Form (`einschalten(platz, { key, projectId, viewAs, onFertig })`). P1 Task 11 ruft die Module bisher nur als `init*(wurzel)` auf. Vorschlag wie in P3 VF1: P1 ergänzt in `huelle.js` `init*(wurzel, { einschalten: (platz, o) => import('/neu/funktion-einschalten.js').then((m) => m.zeigeEinschalten(platz, o)) })`. Ohne das zeigen die Module im Abo-Modell die Fehlerkarte E4 (Task 15 prüft es). Grund für die Übergabe statt `import()` in den Modulen: Pins verbieten dort neue Importe und `import(` (Datei:Zeile in den Global Constraints), und die gemeinsamen Module sollen keine `/neu/`-Datei kennen.
**VF2 Reiter (P1 und P4).** P4 setzt P1s Vertragsfrage 5 um: Im Abo-Modell zeichnen Terminplaner, Webshop, Buchungskalender und Analyse keine eigene Leiste, sondern melden `kn:reiter` (leere Liste ohne Reiter). Spec 5.4 verlangt die gemeinsame Mechanik „statt der vier eigenen Kopien“, Spec 5.2 und C2 verlangen Unterseiten-Reiter immer unten in Etage 2; eine Analyse mit Reitern im Inhalt und nur „Zurück“ in der Etage würde das Muster „unten Ebene, oben Ansicht“ brechen. Die Analyse meldet deshalb aus der Closure `mountCockpit` (`bei: pickTab`), und der Test läuft mit den Attrappen von `htmlWurzel`, die Statistik-Anfrage antwortet 503 (Task 8). Die frühere Ausnahme „die Analyse behält ihre Leiste“ ist gestrichen, ebenso „außer `analyse`“ im Gate `p4`. `kn:titel` sendet P4 nicht (der Titel kommt aus der Routentabelle von P1). Die Kurzlabels (Team, Start, Recht, Preise) sind Vorschläge; die Analyse braucht keine (Übersicht, Heatmap, Einstellungen); die Vorschau prüft sie (C2).
**VF3 Alt-Klassen.** `kb-panel`, `kb-btn`, `kb-btn-primary`, `kb-btn-outline`, `kb-empty`, `kb-link`, `kb-checks`, `abo-actions`, `abo-btn`, `hidden`, `muted`, `error` stehen heute in `kundenbereich.css` und `login.css`, die die neue Seite nicht lädt; in den Plänen von P0 bis P3 definiert sie niemand. Vorschlag: `module.css` Abschnitt 0 definiert sie, außer eine Datei von `public/neu/` hat es schon getan (Task 9 prüft per `grep`; der Prüftest akzeptiert jede Datei dort). P2 prüft für seine 1:1-Seiten, ob es dieselben Klassen nutzt, und stützt sich dann auf Abschnitt 0.
**VF4 Analyse-Aktivierung im Abo-Modell (P5).** P4 sendet im Abo-Modell zusätzlich `audienceConfirmed: true` (P5 Vertragsfrage 8 plant dafür den Fehlercode `zielgruppe_fehlt`); der heutige Server ignoriert das Feld. Der Nachweis in `featureConsents` (Zeilen A1 und A2, Knopftext `Analyse einschalten`, Kundenart) bleibt Sache von P5.
**VF5 Werkzeug (P0, P3, P4).** Task 14 nimmt die Form aus P0, P1 und P3 an (`views.mjs`, `gates.mjs` mit `GATES.p4`, `shots.sh … --gate p4`, `erzeuge.mjs`) und ergänzt 17 Fälle `m-*` (16 Modul-Fälle und `m-analyse-aktiv`) in einer neuen Datei `fixtures/module.mjs`, eingetragen in `fixtures/faelle.mjs` und `fixtures/index.mjs` (Dateien von P0, im Werkzeug-Git), dazu die Analyse-Endpunkte in `fixtures/endpunkte-p4.mjs` (Konvention aus P0 Vertragsfrage 5). Außerdem erzeugt P4 `public/neu/bilder/analyse.webp` neu (P3 VF2; sonst Besitz von P3).
**Regel: Pin und `boot.js` bleiben unberührt.** P4 ändert weder den Pin „Fälle in fester Reihenfolge“ in `KW/test/backend.test.mjs` noch `KW/vorschau/boot.js`. Das setzt voraus, dass P0 (und P7, das ebenfalls Fälle anhängt) den Pin auf die vierzehn Basisnamen begrenzt und `boot.js` die Fallliste nicht fest einträgt; sonst schlagen `backend.test.mjs` und `vorschau-bau.test.mjs` nach dem Anhängen der 17 Namen fehl, und Task 14 Step 1 stoppt und meldet es. Bitte alles bestätigen und die Regel in P0 und P7 gleich ziehen.
**VF6 Hinweis für P10.** Der Abdeckungstest `kundenansichtNeuModulCss.test.js` liest die alten CSS-Dateien als Klassenquelle. Löscht P10 sie, ersetzt es die Quelle durch den Stand der Klassenliste (Ausgabe von `klassen(...)` je Präfix, einmal als `test/support/modulKlassen.json` abgelegt) und passt `FN_NEWS` an. Die Alt-Zweige der Module (Bestellseiten, „Behalten“, lange Reiterleisten) entfernt P10 erst nach der Umschaltung.