Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-10-16-kundenansicht-04-funktionen-rahmen.md

1170 lines
99 KiB
Markdown
Raw Permalink 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, P3 Funktionen-Rahmen: 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:** Der Bereich „Funktionen“ der neuen Kundenansicht zeigt die Reiter „Meine“ und „Entdecken“ aus dem Katalog des Abo-Modells, schaltet Funktionen mit einer kurzen Bestätigungsseite ein und führt gebuchte Funktionen in ihre Etage 2.
**Architecture:** Zwei ES-Module unter `public/neu/`, ohne Build. `ansicht-funktionen.js` (Stub aus P1) hält das Modell (Meine/Entdecken) und `zeigeFunktionen(wurzel, { route, me, nav })` (V2, ohne Zähler). Die Zeile „Rechnung offen“ lädt die Ansicht selbst (`GET /api/invoices` über `abh.api`, Filter und Zeile aus `rechnung-offen.js` von P2); sie hängt nicht am Zähler der Hülle, denn P1 ruft jede Ansicht nur mit `{ route, me, nav }` und liefert in `stand()` nur `chat` und `vertraege`. `funktion-einschalten.js` (neu) hält die Einschalt-Logik (Vorprüfung, Fristen, Fehlercodes) und die Bestätigungsseite. Die Seite erscheint innerhalb von Funktionen als Overlay-Zustand des P1-Routers (`oeffneOverlay`): Die Zurück-Geste schließt sie, es braucht keinen eigenen Hash. Die Funktionsetagen (`#termine?projekt=<id>`) öffnet die Hülle von P1 (`huelle.js`, `FUNKTIONS_MODULE`, setzt `dataset.wunschProjekt`); P3 liefert nur das Ziel. Für die Module exportiert `funktion-einschalten.js` den Ablauf `zeigeEinschalten(platz, { key, projectId, onFertig })` (Vorschlag von P4, VF1). Alle Abhängigkeiten (`api`, `geheZu`, Overlay, Zeit, Speicher) sind über den Parameter `abh` einspeisbar, damit die Tests ohne Browser laufen. C2 (Kurzlabels, scrollende Reiterzeile in Etage 2) liegt bei P1 und P4.
**Tech Stack:** Vanilla-JS (ES-Module), `h()` aus `public/neu/dom.js` (kein `innerHTML`), `baueReiter` aus `public/neu/reiter.js`, `node --test` mit `test/support/fakeDom.js`, sharp (vorhanden, `package.json`) für die Bilder, Sichtprüfung nur über das Werkzeug aus P0.
**Spec:** `docs/superpowers/specs/2026-10-15-kundenansicht-neu-design.md` (5.3, 5.4, 5.5, C1, C2, C9, E6, E13, E14, E17).
**Ü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` (rebased auf den Hauptzweig nach dem Server-Merge). Start nach P2 und Server-Merge. Tag am Ende: `kn-p3`.
**Abgleich mit den anderen Plänen** (Stand beim Schreiben): P0 (`01`), P1 (`02`), P2 (`03`), P4 (`05`), P5 (`06`) liegen vor und sind eingearbeitet (Katalogfeld `consent`, `baueReiter`, Overlay, Werkzeug-Form, `rechnung-offen.js`). Offen bleibt VF1 bis VF5 am Ende.
## Global Constraints
Alle Global Constraints der Übersicht gelten (Aussehen, „du“, keine Preise für Funktionen, Hashes, Kundenart, Tests, Commits, Sicherheitsblock, Prod-Daten). Zusätze für P3:
- **Keine Server-Logik, keine Änderung an fremden Dateien.** `dashboard.html`, `huelle.js`, `router.js` gehören P1. P3 ändert nur die Stubs `ansicht-funktionen.js` und `funktionen.css` und legt `funktion-einschalten.js` und die Bilder an.
- **Fremdtext** (Katalog, Websitenamen, Servermeldungen) nur über `h()`/`textContent`; in den zwei Modulen kommen `innerHTML`, `insertAdjacentHTML`, `eval` nicht vor.
- **Importe:** nur relative, statische (`./dom.js`, `./api.js`, `./router.js`, `./reiter.js`, `./rechnung-offen.js`); P1 und P2 halten sie unter Node importierbar. Kein Zugriff auf `document`, `window`, `sessionStorage` beim Import.
- **Kundenart:** Alles außer `'unternehmen'` gilt als `privat` (E4). Der Client sendet nie `kundenart`.
- **Texte** stehen als Festwerte in `TEXTE` (Task 1) und in den Tests wörtlich. Kein Preis, kein `€`, keine Sie-Anrede.
- **Hash:** nur die Query `projekt=<projectId>` an Etagen (so liest sie P1 schon), keine neuen Hash-Namen.
- **CSS:** Klassen `kn-fn-*`, Farben als `var(--kn-…, Fallback)` mit den Werten der Übersicht als Fallback, Radien nur 6/12 px (Pillen 999 px), keine Schatten außer dem Ring, kein Gewicht 800.
- **Bilder:** `public/neu/bilder/<key>.webp`, 960×600, höchstens 120 KB (122880 Byte), aus Testdaten, keine Fremdbilder.
- **Befehle:** Einzeldatei `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/<datei>.test.js`, mehrere Dateien nur mit `--test-concurrency=1`. Werkzeug: `WZ=/root/reports/kundenansicht-neu-2026-10-15/werkzeug` (Ausgabe in einen eigenen Scratch-Ordner, `TMPDIR` dorthin), davor `free -m` (über 2000 MB verfügbar). Die Kurzform `$WT` in Step 0 von Task 1 gilt nur innerhalb dieses Blocks.
## Review Focus
Die fünf Fehlerfälle, die die Spec nicht ausspricht und die im Betrieb am ehesten auftreten:
1. **Doppeltes oder widersprüchliches Einschalten:** zwei Taps, schon eingeschaltet, Netzfehler mitten im Senden, Fassung veraltet. Erwartung: höchstens ein POST je Klick-Folge, klare Meldung, nach Netzfehler erneut möglich. Tests: Task 4 („zweiter Tap“, „Netzfehler“, „weitere 409-Codes“, „fassung_veraltet“).
2. **Privatkunde an der Fristgrenze:** Fristende am letzten Tag 23:59 Berliner Zeit, Sommer- und Winterzeit, leere Kundenart, Betrieb mit Frist. Erwartung: Häkchen nur für privat (leer zählt als privat) bis zum Fristende, danach keines; ohne Häkchen kein POST. Tests: Task 1 („tagNachFrist“, „beginnLage“), Task 3 („Privatkunde in der Frist“, „Häkchen nie für Betrieb“), Task 4 („beginn_noetig“).
3. **Abo-Lagen:** kein Abo, Hosting bei Wartungs-Funktion, gekündigt, noch nicht aktiv, `sessionStorage` nicht beschreibbar. Erwartung: jeweils der richtige Ort oder Text; der Wechsel geht auch ohne Speicher. Tests: Task 1 („einschaltWeg“, „fehlerAktion“), Task 4 („navigiere“), Task 5 („Einschalten-Tap“), Task 6 („wechselFunktion“).
4. **Mehrere Websites:** Zeile je Funktion und Website, Entdecken solange eine Website fehlt, Website-Auswahl, die richtige Website beim Öffnen. Erwartung: nie die falsche Website. Tests: Task 2 („mehrere Websites“, „Kandidaten“), Task 5 („Meine“, „Zwei Websites“).
5. **Fremdtext und unvollständige Antworten:** HTML im Funktionsnamen, fehlendes Register `consent`, fehlendes Bild, keine Websites, `verfuegbar: false`, Analyse `unknown`, langsame ältere Antwort, ausgefallener Rechnungsabruf. Erwartung: nichts bricht, nichts wird als HTML gelesen, ein fehlendes Register sperrt das Absenden, ohne Rechnungsantwort fehlt nur die Zeile „Rechnung offen“. Tests: Task 2 („Analyse“, „C9“), Task 3 („Fremdtext“, „Register fehlt“), Task 5 („veraltete Antwort“, „Fremdtext und fehlendes Bild“, „keine Websites“, „Rechnungszeile oben“).
---
## Dateiübersicht
| Datei | Aufgabe | Task |
|---|---|---|
| `public/neu/funktion-einschalten.js` (neu) | Einschalt-Logik rein, Bestätigungsseite, Senden, Navigation, `zeigeEinschalten` | 1, 3, 4 |
| `public/neu/ansicht-funktionen.js` (Stub aus P1, ändern) | Modell Meine/Entdecken, Ansicht, Bestätigung als Overlay, Wechsel-Rückkehr | 2, 5, 6 |
| `public/neu/funktionen.css` (Stub aus P1, ändern) | Aussehen der beiden Module | 7 |
| `public/neu/bilder/{news,termine,shop,unterkunft,zeiten}.webp` (neu; `analyse.webp` kommt mit P4, VF2) | Bilder für „Entdecken“ | 8 |
| `test/support/knFunktionenDaten.js` (neu) | Testdaten nach V5 | 1 |
| `test/knEinschaltenLogik.test.js`, `knFunktionenModell.test.js`, `knEinschaltenSeite.test.js`, `knFunktionenAnsicht.test.js`, `knFunktionenStatik.test.js` (neu) | Tests | 1, 2, 3+4, 5+6, 7+8 |
| Werkzeug (eigenes Git): `fixtures/endpunkte-p3.mjs` (+ ein Import in `fixtures/endpunkte.mjs`, Einträge in `vertrag-endpunkte.mjs`), `views.mjs`, `gates.mjs`, `bilder/erzeuge.mjs`, `bilder/seiten/*.html` | Bildquellen, Ansichten, Gate `p3` | 8, 9 |
**Pins:** Keine bestehende Testdatei und kein Pin wird geändert. Belege: `test/adminPanelGone.test.js:32-65` liest `public/` rekursiv, sperrt aber nur `admin.html`, `kalkulation.html`, `/api/portal-admin` (kommen in P3 nicht vor). Die Quelltext-Pins lesen `public/` flach und filtern auf alte Namen (`termineView.test.js:324,397,418`, `shopView.test.js:560`, `shopWiring.test.js:49`, `zeitenView.test.js:391`, `zeitenBestellen.test.js:845`, `termineFragebogenView.test.js:1023`); `public/neu/` fällt nicht hinein. P1s `knHuelleSeite.test.js` prüft von den Stubs nur die Exportnamen. Task 10 lässt die Nachbarn als Gegenprobe laufen.
---
### Task 1: Einschalt-Logik (rein) und Testdaten
**Files:**
- Create: `public/neu/funktion-einschalten.js` (in diesem Task nur reine Exporte)
- Create: `test/support/knFunktionenDaten.js`
- Test: `test/knEinschaltenLogik.test.js`
**Interfaces:**
- Consumes: Antwortform von `GET /api/funktionen` (V5, gebaut in P5 Task 11): `modell`, `kundenart`, `abo`, `katalog[]` mit `consent`, `projects[]` mit `features.<shop|unterkunft>.anfrageStatus`.
- Produces (alle in `funktion-einschalten.js`):
```js
export const ETAGE_FUER // { news:'news', termine:'termine', shop:'webshop', unterkunft:'buchungen', zeiten:'zeiten', analyse:'analyse' }
export const TEXTE // Festtexte, siehe Step 3
export function consentVon(daten, key) // → katalog[key].consent | null (einzige Stelle für das Register)
export function anfrageStatusVon(projekt, key) // → projekt.features[key].anfrageStatus | '' (einzige Stelle)
export function einschaltWeg({ eintrag, projekt, abo, kundenart })
// → { art:'seite' } | { art:'etage', etage } | { art:'bestellen', produkt } | { art:'wechsel' } | { art:'meldung', text }
export function tagNachFrist(wert) // Zeitpunkt → Berliner Folgetag 'JJJJ-MM-TT' | ''
export function datumKurz(tag) // 'JJJJ-MM-TT' → 'TT.MM.' | ''
export function beginnLage({ kundenart, abo, jetzt, serverAb = '' }) // → { noetig, ab }
export function einschaltKoerper({ projectId, consent, beginnGesetzt }) // → { projectId, zustimmung: { version, haken } }
export function linkzeile(text, unterlagen) // → [{ text, href? }]
export function sichereAdresse(url) // → url | ''
export function fehlerAktion(fehler, { eintrag }) // fehler = { status, code?, message, body }
// → { art:'wechsel' } | { art:'bestellen', produkt } | { art:'etage', etage } | { art:'beginn', ab } | { art:'neu-laden', text } | { art:'schon' } | { art:'meldung', text }
```
Testdaten-Baustein `test/support/knFunktionenDaten.js` (Körper schreibt der Implementierer):
```js
export function katalogEintrag(key, edit = {}) // Standardwerte je key, edit überschreibt Felder
export function feature(state = 'off', extra = {}) // { state, siteKey:null, ticketWoid:null, activatedAt:null, disabledReason:null, loeschungAm:null, ...extra }
export function projekt(projectId, projectName, features = {}, analyse = { state: 'off' }) // { projectId, projectName, liveUrl:'', features:{ news, termine, shop, unterkunft, zeiten: feature('off'), ...features }, analyse }
export function daten({ abo = {}, kundenart = 'unternehmen', projekte, katalogEdit = {} } = {})
// → { modell:'abo', kundenart, abo:{ produkt:'wartung', laeuft:true, gekuendigt:false, widerrufsfristBis:null, ...abo }, katalog:[6 Einträge, katalogEdit[key] darübergelegt], projects: projekte ?? [projekt('p1','Bäckerei Sonnenschein')] }
```
Standardwerte `katalogEintrag` (Reihenfolge news, termine, shop, unterkunft, zeiten, analyse): Namen `News-Pinnwand`, `Terminplaner`, `Webshop`, `Buchungskalender`, `Öffnungszeiten live`, `Analyse`; `enthaltenIn: 'wartung'`, nur analyse `'hosting'`; `ablauf: 'fragebogen'` (termine), `'anfrage'` (shop, unterkunft), sonst `'direkt'`; `nurBetrieb: true` nur shop; `einrichtung: 'meist in 1–2 Wochen'` (termine, unterkunft), `'meist in 2–3 Wochen'` (shop), sonst `'meist in wenigen Tagen'`; `verfuegbar: true`; `beschreibung: 'Beschreibung <Name>.'`; `bild: '/neu/bilder/<key>.webp'`; `unterlagen: { leistungsbeschreibung: { version:'2026-10-05', url:'/rechtliches/leistungsbeschreibung-<key>-2026-10-05.html' }, avv: { version:'2026-10-05', url:'/rechtliches/avv-<key>-2026-10-05.html' } }`; `consent: { version:'2026-10-20', knopf:'Einschalten', items:[{ id:'einschalten-zeile', text:'Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung 2026-10-05).', haken:false }, { id:'beginn', text:'Bitte beginnt sofort mit der Einrichtung. Mir ist klar: Mit der vollständigen Einrichtung erlischt mein Widerrufsrecht, und wenn ich vorher widerrufe, zahle ich den bis dahin erbrachten Teil.', haken:true, nurPrivatInFrist:true }] }`.
- [ ] **Step 0: Voraussetzungen prüfen** (nur lesen)
Run:
```bash
WT=/home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui
git -C $WT log --oneline -3 && git -C $WT tag --list 'kn-p*' && command grep -a -n "^export" $WT/public/neu/dom.js $WT/public/neu/api.js $WT/public/neu/router.js $WT/public/neu/reiter.js $WT/public/neu/rechnung-offen.js
command grep -a -n "katalog" $WT/server/routes/funktionen.js | head -5
```
Expected: `kn/ui` trägt `kn-p1`, `kn-p2` und den Server-Merge; die Exporte `h`, `leeren`, `api`, `geheZu`, `oeffneOverlay`, `beendeOverlay`, `baueReiter`, `offeneRechnungen`, `rechnungOffenZeile` sind da; `routes/funktionen.js` kennt `katalog`. Weicht ein Name oder die Antwortform ab, ändert sich nur die eine Stelle (`consentVon`, `anfrageStatusVon`, die Importzeile); Befund dem Controller melden. Fehlt etwas Größeres, stoppen.
- [ ] **Step 1: Failing Tests schreiben** in `test/knEinschaltenLogik.test.js`
```js
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { ETAGE_FUER, consentVon, anfrageStatusVon, einschaltWeg, tagNachFrist, datumKurz, beginnLage, einschaltKoerper, linkzeile, sichereAdresse, fehlerAktion } from '../public/neu/funktion-einschalten.js'
import { daten, katalogEintrag, projekt, feature } from './support/knFunktionenDaten.js'
const abo = (produkt, extra = {}) => ({ produkt, laeuft: produkt !== null, gekuendigt: false, widerrufsfristBis: null, ...extra })
const angenommen = (key) => projekt('p1', 'B', { [key]: feature('off', { anfrageStatus: 'angenommen' }) })
const GEKUENDIGT = 'Dein Abo ist gekündigt. Solange die Kündigung läuft, kannst du nichts Neues einschalten.'
const NICHT_AKTIV = 'Dein Abo ist noch nicht aktiv. Sobald die erste Zahlung da ist, kannst du einschalten.'
const NUR_BETRIEB = 'Webshop gibt es für Betriebe. Stimmt das nicht? Schreib uns im Chat.'
test('Etagen-Tabelle und Zugriffsfunktionen', () => {
assert.deepEqual(ETAGE_FUER, { news: 'news', termine: 'termine', shop: 'webshop', unterkunft: 'buchungen', zeiten: 'zeiten', analyse: 'analyse' })
assert.equal(consentVon(daten(), 'news').version, '2026-10-20')
assert.equal(consentVon(daten({ katalogEdit: { analyse: { consent: null } } }), 'analyse'), null)
assert.equal(consentVon(daten(), 'gibtsnicht'), null)
assert.equal(anfrageStatusVon(angenommen('shop'), 'shop'), 'angenommen')
assert.equal(anfrageStatusVon(projekt('p1', 'B'), 'shop'), '')
})
test('einschaltWeg: Vorprüfung in der Reihenfolge des Servers (V5)', () => {
const faelle = [ // [erwartet, key, abo, kundenart, projekt, Katalogänderung]
[{ art: 'meldung', text: 'Diese Funktion ist für dich gerade nicht verfügbar.' }, 'news', abo('wartung'), 'unternehmen', undefined, { verfuegbar: false }],
[{ art: 'meldung', text: GEKUENDIGT }, 'news', abo('wartung', { gekuendigt: true })],
[{ art: 'bestellen', produkt: 'wartung' }, 'news', abo(null)],
[{ art: 'bestellen', produkt: 'hosting' }, 'analyse', abo(null)],
[{ art: 'meldung', text: NICHT_AKTIV }, 'news', abo('wartung', { laeuft: false })],
[{ art: 'wechsel' }, 'news', abo('hosting')],
[{ art: 'etage', etage: 'analyse' }, 'analyse', abo('hosting')],
[{ art: 'meldung', text: NUR_BETRIEB }, 'shop', abo('wartung'), 'privat'],
[{ art: 'meldung', text: NUR_BETRIEB }, 'shop', abo('wartung'), ''], // leere Kundenart zählt als privat (E4)
[{ art: 'seite' }, 'news', abo('wartung')],
[{ art: 'seite' }, 'zeiten', abo('wartung')],
[{ art: 'etage', etage: 'termine' }, 'termine', abo('wartung')],
[{ art: 'etage', etage: 'webshop' }, 'shop', abo('wartung')],
[{ art: 'etage', etage: 'buchungen' }, 'unterkunft', abo('wartung')],
[{ art: 'seite' }, 'shop', abo('wartung'), 'unternehmen', angenommen('shop')],
[{ art: 'seite' }, 'unterkunft', abo('wartung'), 'unternehmen', angenommen('unterkunft')],
]
for (const [erwartet, key, a, kundenart = 'unternehmen', p = projekt('p1', 'B'), edit = {}] of faelle) {
assert.deepEqual(einschaltWeg({ eintrag: katalogEintrag(key, edit), projekt: p, abo: a, kundenart }), erwartet, `${key} ${JSON.stringify(a)} ${kundenart}`)
}
})
test('tagNachFrist und datumKurz: Berliner Folgetag, Sommer- und Winterzeit', () => {
assert.equal(tagNachFrist('2026-10-30T22:59:59.999Z'), '2026-10-31') // 23:59:59 MEZ
assert.equal(tagNachFrist('2026-08-14T21:59:59.999Z'), '2026-08-15') // 23:59:59 MESZ
assert.equal(tagNachFrist('2026-12-31T22:59:59.999Z'), '2027-01-01')
assert.deepEqual([tagNachFrist(null), tagNachFrist('kein Datum'), datumKurz('2026-10-31'), datumKurz('')], ['', '', '31.10.', ''])
})
test('beginnLage: Häkchen nur für privat bis zum Fristende; die Antwort des Servers gewinnt', () => {
const jetzt = new Date('2026-10-20T10:00:00Z')
const frist = '2026-10-30T22:59:59.999Z'
const lage = (kundenart, widerrufsfristBis, extra = {}) => beginnLage({ kundenart, abo: { widerrufsfristBis }, jetzt, ...extra })
assert.deepEqual(lage('privat', frist), { noetig: true, ab: '2026-10-31' })
assert.deepEqual(lage('', frist), { noetig: true, ab: '2026-10-31' })
assert.deepEqual(lage('unternehmen', frist), { noetig: false, ab: '' })
assert.deepEqual(lage('privat', null), { noetig: false, ab: '' })
assert.equal(lage('privat', frist, { jetzt: new Date(frist) }).noetig, true) // genau am Ende noch Frist
assert.equal(lage('privat', frist, { jetzt: new Date('2026-10-30T23:00:00.000Z') }).noetig, false)
assert.deepEqual(lage('unternehmen', null, { serverAb: '2026-11-02T23:00:00.000Z' }), { noetig: true, ab: '2026-11-03' }) // Form von P5: Berliner Mitternacht
assert.equal(lage('privat', null, { serverAb: '2026-11-03' }).ab, '2026-11-03')
})
test('einschaltKoerper: Fassung aus dem Register, Haken nur "beginn", nie die Kundenart', () => {
const c = consentVon(daten(), 'news')
assert.deepEqual(einschaltKoerper({ projectId: 'p1', consent: c, beginnGesetzt: false }), { projectId: 'p1', zustimmung: { version: '2026-10-20', haken: [] } })
const mit = einschaltKoerper({ projectId: 'p1', consent: c, beginnGesetzt: true })
assert.deepEqual(mit.zustimmung.haken, ['beginn'])
assert.equal('kundenart' in mit, false)
})
test('sichereAdresse: nur https und Pfade unter /rechtliches/ oder /vertragsunterlagen/', () => {
for (const gut of ['/rechtliches/avv-2026-10-05.html', '/vertragsunterlagen/funktionen-leistungsbeschreibung.html', 'https://example.org/a.pdf']) assert.equal(sichereAdresse(gut), gut)
for (const schlecht of ['javascript:alert(1)', 'http://example.org/', '//example.org/x', '/rechtliches/../geheim', '/rechtliches/%2e%2e/x', 'https://user:pw@example.org/', '/api/abo', '/rechtliches/a b', '', null, 42]) assert.equal(sichereAdresse(schlecht), '', String(schlecht))
})
test('linkzeile: die zwei Wörter werden Links; unsichere oder fehlende Adressen nicht', () => {
const text = 'Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung 2026-10-05).'
const u = katalogEintrag('termine').unterlagen
assert.deepEqual(linkzeile(text, u), [
{ text: 'Es gelten ' }, { text: 'Leistungsbeschreibung', href: u.leistungsbeschreibung.url }, { text: ' und ' },
{ text: 'Auftragsverarbeitung', href: u.avv.url }, { text: ' (Fassung 2026-10-05).' },
])
assert.deepEqual(linkzeile('Es gilt die Leistungsbeschreibung (Fassung 2026-10-04).', { leistungsbeschreibung: u.leistungsbeschreibung, avv: null }), [
{ text: 'Es gilt die ' }, { text: 'Leistungsbeschreibung', href: u.leistungsbeschreibung.url }, { text: ' (Fassung 2026-10-04).' },
])
assert.deepEqual(linkzeile(text, { leistungsbeschreibung: { url: 'javascript:alert(1)' }, avv: null }), [{ text }])
assert.deepEqual(linkzeile('Es gelten Leistungsbeschreibung (Fassung 2026-10-05).', u), [
{ text: 'Es gelten ' }, { text: 'Leistungsbeschreibung', href: u.leistungsbeschreibung.url }, { text: ' (Fassung 2026-10-05). ' }, { text: 'Auftragsverarbeitung', href: u.avv.url },
])
})
test('fehlerAktion: alle 409-Codes aus V5 und die Randfälle', () => {
const f = (status, code, body = {}, message = 'Servertext') => ({ status, code, message, body: { code, ...body } })
const AB = '2026-11-02T23:00:00.000Z'
const faelle = [ // [Fehler, Funktion, erwartete Aktion]
[f(409, 'nicht_verfuegbar'), 'termine', { art: 'meldung', text: 'Diese Funktion ist für dich gerade nicht verfügbar.' }],
[f(409, 'abo_fehlt', { grund: 'falsches_abo', wechsel: true }), 'termine', { art: 'wechsel' }],
[f(409, 'abo_fehlt', { grund: 'kein_abo' }), 'termine', { art: 'bestellen', produkt: 'wartung' }],
[f(409, 'abo_fehlt', { grund: 'kein_abo' }), 'analyse', { art: 'bestellen', produkt: 'hosting' }],
[f(409, 'abo_fehlt', { grund: 'gekuendigt' }), 'termine', { art: 'meldung', text: GEKUENDIGT }],
[f(409, 'abo_fehlt', { grund: 'nicht_aktiv' }), 'termine', { art: 'meldung', text: NICHT_AKTIV }],
[f(409, 'abo_fehlt', { grund: 'falsches_abo' }), 'termine', { art: 'meldung', text: 'Dein Abo enthält diese Funktion nicht.' }],
[f(409, 'nur_betrieb'), 'shop', { art: 'meldung', text: NUR_BETRIEB }],
[f(409, 'bestellung_offen', { offen: [{ schritt: 'zeiten', text: 'x' }] }), 'termine', { art: 'etage', etage: 'termine' }],
[f(409, 'bestellung_offen', { offen: ['Anfrage'] }), 'shop', { art: 'etage', etage: 'webshop' }],
[f(409, 'beginn_noetig', { ab: AB }), 'termine', { art: 'beginn', ab: AB }],
[f(409, 'bereits_eingeschaltet'), 'termine', { art: 'schon' }],
[f(409, 'fassung_veraltet'), 'termine', { art: 'neu-laden', text: 'Die Bedingungen wurden aktualisiert. Bitte lies die Zeile noch einmal.' }],
[f(409, 'bestellung_laeuft'), 'termine', { art: 'meldung', text: 'Servertext' }], // weitere Codes aus P5: Text des Servers
[f(409, 'abgeschaltet'), 'termine', { art: 'meldung', text: 'Servertext' }],
[f(503, 'unterlagen_fehlen'), 'termine', { art: 'meldung', text: 'Servertext' }],
[f(403, 'view_blocked'), 'termine', { art: 'meldung', text: 'Servertext' }],
[{ status: 0, message: 'Keine Verbindung. Bitte versuch es gleich noch einmal.', body: {} }, 'termine', { art: 'meldung', text: 'Keine Verbindung. Bitte versuch es gleich noch einmal.' }],
[{ status: 500, message: '', body: {} }, 'termine', { art: 'meldung', text: 'Das Einschalten hat gerade nicht geklappt. Versuch es gleich noch einmal oder schreib uns im Chat.' }],
]
for (const [fehler, key, erwartet] of faelle) assert.deepEqual(fehlerAktion(fehler, { eintrag: katalogEintrag(key) }), erwartet, `${fehler.status} ${fehler.code}`)
})
```
- [ ] **Step 2: Fehlschlag prüfen.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knEinschaltenLogik.test.js`. Expected: FAIL (`ERR_MODULE_NOT_FOUND` für `funktion-einschalten.js` bzw. `knFunktionenDaten.js`).
- [ ] **Step 3: `public/neu/funktion-einschalten.js` und den Testdaten-Baustein anlegen** (Signaturen oben)
`TEXTE` (Texte wörtlich wie in den Tests): `stundenkonto: 'Die Einrichtung geht von deinem Stundenkonto ab, auch wenn das Guthaben noch nicht reicht. Extrakosten entstehen keine.'`, `nichtVerfuegbar`, `gekuendigt`, `nichtAktiv`, `falschesAbo`, `fassungVeraltet`, `allgemein`, `nurBetrieb: (name) => \`${name} gibt es für Betriebe. Stimmt das nicht? Schreib uns im Chat.\``, `registerFehlt: 'Einschalten ist gerade nicht möglich. Schreib uns im Chat.'`, `kundenansicht: 'In der Kundenansicht kann nichts eingeschaltet werden.'`.
- `einschaltWeg` prüft in der Reihenfolge der Tabelle im Test: verfügbar, gekündigt, kein Abo (`bestellen` mit `eintrag.enthaltenIn`), nicht aktiv, Hosting bei Wartungs-Funktion (`wechsel`), `nurBetrieb` bei `kundenart !== 'unternehmen'`; danach Analyse und `ablauf: 'fragebogen'` → Etage, `ablauf: 'anfrage'` ohne `angenommen` → Etage, sonst `seite`. Die Analyse hat einen eigenen Endpunkt und geht nie über `POST …/einschalten`. Die Vorprüfung spiegelt `aboDeckt` (V5); bei Abweichung entscheidet der Server per 409.
- `berlinTag(wert)` (nicht exportiert): Zeitpunkt oder `JJJJ-MM-TT` → Berliner Kalendertag über `toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })`, unlesbar → `''`; `tagNachFrist` = `berlinTag` plus ein Tag über `Date.UTC`. `beginnLage`: `noetig`, wenn privat und `Date.parse(abo.widerrufsfristBis) >= jetzt.getTime()` (`ab = tagNachFrist`), oder wenn `serverAb` nicht leer ist (`ab = berlinTag(serverAb)`).
- `linkzeile`: erstes Vorkommen des Wortes → Link, wenn die Unterlage existiert und `sichereAdresse` nicht leer ist; fehlt das Wort bei einer sicheren Unterlage, hängt ihr Link mit einem Leerzeichen hinten an (zwei Links mit ` · ` dazwischen); benachbarte Text-Segmente werden zusammengefasst. `sichereAdresse`: `https://` mit Rechnername ohne Zugangsdaten, oder Pfad unter `/rechtliches/` bzw. `/vertragsunterlagen/` ohne `..`, `%2e`, Leerraum, Steuerzeichen, Rückwärtsschrägstrich.
- `fehlerAktion`: `abo_fehlt` mit `body.wechsel === true` → Wechsel, sonst nach `body.grund`; `bestellung_offen` → Etage `ETAGE_FUER[eintrag.key]`; `beginn_noetig` → `ab` unverändert aus `body.ab`; alles ohne eigene Zeile im Test (auch `status 0`) → `{ art:'meldung', text: fehler.message || TEXTE.allgemein }`.
- [ ] **Step 4: Test laufen lassen.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knEinschaltenLogik.test.js`. Expected: PASS.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/funktion-einschalten.js test/support/knFunktionenDaten.js test/knEinschaltenLogik.test.js && git commit -m "Funktionen neu: Einschalt-Logik (Vorprüfung, Fristen, 409-Codes) und Testdaten" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 2: Modell für „Meine“ und „Entdecken“ (rein)
**Files:**
- Modify: `public/neu/ansicht-funktionen.js` (Stub aus P1; hier nur reine Exporte ergänzen, der Stub-Export `zeigeFunktionen` bleibt bis Task 5)
- Test: `test/knFunktionenModell.test.js`
**Interfaces:**
- Consumes: `anfrageStatusVon`, `ETAGE_FUER` aus `./funktion-einschalten.js` (Task 1), Testdaten (Task 1).
- Produces:
```js
export function meineZeilen(daten)
// → [{ key, name, projectId, projektName, zustand, anfrageStatus, pille:{ text, ton }, status, zeile2 }]
// Reihenfolge: Katalog, dann Website. ton: 'gruen'|'blau'|'orange'|'violett'|'rot'|'grau'. zeile2 = Websitename bei mehreren Websites, sonst status.
export function entdeckenEintraege(daten) // → [{ eintrag, kandidaten: [{ projectId, projektName }] }] in Katalogreihenfolge
export function zeilenWeg(zeile) // → { art:'seite' } | { art:'etage', etage } (Tipp auf eine Zeile in Meine)
export function datumDe(wert) // Zeitpunkt oder 'JJJJ-MM-TT' → 'TT.MM.JJJJ' (Europe/Berlin) | ''
```
Regeln: Katalogeinträge mit `verfuegbar !== true` fehlen überall (C9). Eine Funktion steht für eine Website in Meine, wenn `state !== 'off'`, oder bei `ablauf === 'fragebogen'` ein Entwurf vorliegt (`state === 'off'` und `siteKey`, wie `funktionen.js:148`), oder bei `ablauf === 'anfrage'` der `anfrageStatus` `offen`, `angenommen` oder `abgelehnt` ist (`zurueckgezogen` zählt nicht). Analyse: Zeile nur bei `analyse.state` `active` oder `disabled`. Entdecken zeigt einen Eintrag, solange eine Website die Funktion nicht hat oder es keine Website gibt; `kandidaten` sind die Websites ohne die Funktion, bei der Analyse nur mit `analyse.state === 'off'`.
- [ ] **Step 1: Failing Tests schreiben** in `test/knFunktionenModell.test.js`
```js
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { meineZeilen, entdeckenEintraege, zeilenWeg } from '../public/neu/ansicht-funktionen.js'
import { daten, projekt, feature } from './support/knFunktionenDaten.js'
test('Meine: alles außer off, dazu Entwurf und Anfrage; zurückgezogen zählt nicht', () => {
const d = daten({ projekte: [projekt('p1', 'Bäckerei', {
news: feature('active', { activatedAt: '2026-10-05T08:00:00.000Z' }),
termine: feature('off', { siteKey: 'abcdefghijklmnop' }),
shop: feature('off', { anfrageStatus: 'offen' }),
unterkunft: feature('off', { anfrageStatus: 'zurueckgezogen' }),
zeiten: feature('disabled', { disabledReason: 'abo_ende', loeschungAm: '2026-11-20' }),
})] })
const z = meineZeilen(d)
assert.deepEqual(z.map((r) => [r.key, r.pille.text, r.pille.ton]), [['news', 'Aktiv', 'gruen'], ['termine', 'Entwurf', 'grau'], ['shop', 'Angefragt', 'violett'], ['zeiten', 'Abgeschaltet', 'grau']])
assert.deepEqual(z.map((r) => r.status), ['Aktiv seit 05.10.2026', 'Fragebogen fortsetzen', 'Wir prüfen deine Anfrage', 'Dein Abo ist beendet. Deine Daten bleiben bis 20.11.2026 gespeichert.'])
assert.equal(z[0].zeile2, z[0].status)
})
test('Status je Zustand: Pille, Ton, Zeile', () => {
const zeile = (key, f) => meineZeilen(daten({ projekte: [projekt('p1', 'B', { [key]: f })] })).find((r) => r.key === key)
const faelle = [
['news', feature('setup', { ticketWoid: '100201' }), 'In Einrichtung', 'blau', 'Einrichtung läuft · Ticket #100201'],
['news', feature('setup'), 'In Einrichtung', 'blau', 'Einrichtung läuft'],
['news', feature('ordered'), 'Zahlung offen', 'orange', 'Wir warten auf deine Zahlung'],
['news', feature('active'), 'Aktiv', 'gruen', 'Läuft auf deiner Website'],
['news', feature('disabled'), 'Abgeschaltet', 'grau', 'Schreib uns im Chat, wenn du die Funktion wieder möchtest.'],
['news', feature('disabled', { disabledReason: 'abo_ende' }), 'Abgeschaltet', 'grau', 'Dein Abo ist beendet.'],
['shop', feature('off', { anfrageStatus: 'angenommen' }), 'Angenommen', 'blau', 'Jetzt einschalten'],
['shop', feature('off', { anfrageStatus: 'abgelehnt' }), 'Abgelehnt', 'rot', 'Schreib uns im Chat'],
]
for (const [key, f, pille, ton, status] of faelle) {
const r = zeile(key, f)
assert.deepEqual([r.pille.text, r.pille.ton, r.status], [pille, ton, status], `${key} ${f.state}`)
}
})
test('mehrere Websites: eine Zeile je Funktion und Website, zweite Zeile ist der Websitename', () => {
const d = daten({ projekte: [projekt('p1', 'Bäckerei', { termine: feature('active') }), projekt('p2', 'Café', { termine: feature('setup'), news: feature('active') })] })
assert.deepEqual(meineZeilen(d).map((r) => [r.key, r.projectId, r.zeile2]), [['news', 'p2', 'Café'], ['termine', 'p1', 'Bäckerei'], ['termine', 'p2', 'Café']])
})
test('Entdecken: Kandidaten sind die Websites ohne die Funktion; eine einzelne Website mit aktiver Funktion blendet sie aus', () => {
const d = daten({ projekte: [projekt('p1', 'Bäckerei', { news: feature('active') }), projekt('p2', 'Café')] })
assert.deepEqual(entdeckenEintraege(d).find((e) => e.eintrag.key === 'news').kandidaten, [{ projectId: 'p2', projektName: 'Café' }])
assert.equal(entdeckenEintraege(daten({ projekte: [projekt('p1', 'B', { news: feature('active') })] })).some((e) => e.eintrag.key === 'news'), false)
})
test('C9: verfuegbar:false fehlt in Meine und Entdecken; ohne Websites zeigt Entdecken alles ohne Kandidaten', () => {
const d = daten({ katalogEdit: { shop: { verfuegbar: false } }, projekte: [projekt('p1', 'B', { shop: feature('active') }, { state: 'active' })] })
assert.deepEqual(meineZeilen(d).map((r) => r.key), ['analyse'])
assert.deepEqual(entdeckenEintraege(d).map((e) => e.eintrag.key), ['news', 'termine', 'unterkunft', 'zeiten'])
const leer = daten({ projekte: [] })
assert.deepEqual(meineZeilen(leer), [])
assert.deepEqual(entdeckenEintraege(leer).map((e) => e.kandidaten.length), [0, 0, 0, 0, 0, 0])
})
test('Analyse: Zeile nur bei active/disabled; unknown und comingSoon sind kein Kandidat', () => {
const zeilen = (analyse) => meineZeilen(daten({ projekte: [projekt('p1', 'B', {}, analyse)] })).filter((r) => r.key === 'analyse').map((r) => r.pille.text)
assert.deepEqual([zeilen({ state: 'active' }), zeilen({ state: 'disabled' }), zeilen({ state: 'off' }), zeilen({ state: 'unknown' }), zeilen({ comingSoon: true })], [['Aktiv'], ['Gesperrt'], [], [], []])
const kand = (analyse) => entdeckenEintraege(daten({ projekte: [projekt('p1', 'B', {}, analyse)] })).find((e) => e.eintrag.key === 'analyse')?.kandidaten.length ?? -1
assert.deepEqual([kand({ state: 'off' }), kand({ state: 'unknown' }), kand({ comingSoon: true }), kand({ state: 'active' })], [1, 0, 0, -1])
})
test('zeilenWeg: angenommene Anfrage geht zur Bestätigung, alles andere in die Etage', () => {
const z = meineZeilen(daten({ projekte: [projekt('p1', 'B', { shop: feature('off', { anfrageStatus: 'angenommen' }), termine: feature('active') })] }))
assert.deepEqual(z.map(zeilenWeg), [{ art: 'etage', etage: 'termine' }, { art: 'seite' }])
})
```
- [ ] **Step 2: Fehlschlag prüfen.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knFunktionenModell.test.js`. Expected: FAIL („does not provide an export named ‚meineZeilen‘“).
- [ ] **Step 3: Reine Funktionen in `public/neu/ansicht-funktionen.js` ergänzen** (Signaturen und Regeln oben)
Festtexte wörtlich wie in den Tests. `Aktiv seit <datumDe>` nur mit `activatedAt`, `Einrichtung läuft · Ticket #<woid>` nur mit `ticketWoid`. Analyse `active`: Pille „Aktiv“, Zeile „Läuft auf deiner Website“; `disabled`: Pille „Gesperrt“ (grau), Zeile „Schreib uns im Chat“. Bei `disabled` mit `disabledReason === 'abo_ende'` lautet die Zeile `Dein Abo ist beendet.` plus, wenn `loeschungAm` da ist, ` Deine Daten bleiben bis TT.MM.JJJJ gespeichert.`. `datumDe` nutzt `toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })`; ein `JJJJ-MM-TT`-Wert wird als Tag um 12:00 UTC gelesen.
- [ ] **Step 4: Test laufen lassen.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knFunktionenModell.test.js`. Expected: PASS.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/ansicht-funktionen.js test/knFunktionenModell.test.js && git commit -m "Funktionen neu: Modell für Meine und Entdecken (mehrere Websites, Anfragestatus)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 3: Bestätigungsseite zeichnen
**Files:**
- Modify: `public/neu/funktion-einschalten.js`
- Test: `test/knEinschaltenSeite.test.js` (Teil 1)
**Interfaces:**
- Consumes: Task 1 (`consentVon`, `beginnLage`, `linkzeile`, `TEXTE`, `datumKurz`); `h`, `leeren` aus `./dom.js` (V2).
- Produces (das Senden folgt in Task 4):
```js
export function renderEinschalten(behaelter, { key, projectId, daten, kundenartWirksam = daten?.kundenart, viewAs = false, imModul = false, jetzt = () => new Date(), api, geheZu, speicher, bei = {} })
// daten = Antwort von GET /api/funktionen (modell 'abo'); api(pfad, { method, body }) wirft Fehler mit status/code/message/body
// bei = { fertig(antwort|null), weiter(weg), abbrechen(), neuLaden() → Promise<daten> }, alles optional (Standards in Task 4)
// imModul: true, wenn ein Funktionsmodul die Seite einbettet (kein „Abbrechen“; bestellung_offen zeigt den Servertext)
// → HTMLElement (die Seite, in behaelter gezeichnet)
```
Aufbau (Selektoren, auf die Tests und Werkzeug bauen): `section.kn-fn-einschalten[data-key][data-projekt]`; `h2` = `<Name> einschalten`; `dl.kn-fn-fakten` mit „Website“ (nur bei mehr als einer Website in `daten.projects`), „Leistung“ = `Einrichtung durch WEBklar`, „Dauer“ = `eintrag.einrichtung`, „Laufzeit“ = `läuft, solange dein Wartungs-Abo läuft` (bzw. `Hosting-Abo` nach `daten.abo.produkt`); `p.kn-fn-konto` = `TEXTE.stundenkonto`; `p.kn-fn-recht` = Linkzeile aus dem Register-Item `einschalten-zeile` (Links mit `target="_blank" rel="noopener noreferrer"`); `label.kn-fn-haken > input[type=checkbox][data-haken="beginn"]` nur wenn `beginnLage(...).noetig` (Text aus dem Item `beginn`); `p[role=alert][data-meldung]`; `button[data-aktion="einschalten"]` (Text `Einschalten`, bei nötigem und fehlendem Häkchen `Einschalten ab TT.MM.`, gesperrt) und, wenn nicht `imModul`, `button[data-aktion="abbrechen"]` (Text `Abbrechen`).
- [ ] **Step 1: Failing Tests schreiben** in `test/knEinschaltenSeite.test.js` (Teil 1)
```js
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { installFakeDom } from './support/fakeDom.js'
import { renderEinschalten } from '../public/neu/funktion-einschalten.js'
import { daten, projekt } from './support/knFunktionenDaten.js'
const JETZT = () => new Date('2026-10-20T10:00:00Z')
const FRIST = '2026-10-30T22:59:59.999Z'
const OK = { feature: { state: 'setup' }, ticketWoid: '100301' }
const mit = (fn) => async () => { const fx = installFakeDom(); try { await fn(fx) } finally { fx.restore() } }
function seite(fx, { antwort = () => OK, ...opt } = {}) {
const b = fx.document.createElement('div'); fx.document.body.append(b)
const aufrufe = []; const rueck = []
const api = async (pfad, o = {}) => { const a = { pfad, method: o.method ?? 'GET', body: o.body }; aufrufe.push(a); return antwort(a) }
const bei = { fertig: (a) => rueck.push(['fertig', a]), weiter: (w) => rueck.push(['weiter', w]), abbrechen: () => rueck.push(['abbrechen']) }
const el = renderEinschalten(b, { key: 'news', projectId: 'p1', daten: daten(), kundenartWirksam: 'unternehmen', jetzt: JETZT, api, bei, ...opt })
return { b, el, aufrufe, rueck }
}
const knopf = (fx, b) => fx.finde(b, '[data-aktion="einschalten"]')
const haken = (fx, b) => fx.alle(b, 'input[type="checkbox"]')
test('Betrieb: Fakten, Stundenkonto-Satz, Linkzeile, Knopf, kein Häkchen, kein Preis', mit((fx) => {
const { b } = seite(fx)
const t = fx.text(b)
assert.match(t, /News-Pinnwand einschalten/)
assert.match(t, /Leistung.*Einrichtung durch WEBklar/)
assert.match(t, /Dauer.*meist in wenigen Tagen/)
assert.match(t, /Laufzeit.*läuft, solange dein Wartungs-Abo läuft/)
assert.match(t, /Die Einrichtung geht von deinem Stundenkonto ab, auch wenn das Guthaben noch nicht reicht\. Extrakosten entstehen keine\./)
assert.match(t, /Es gelten Leistungsbeschreibung und Auftragsverarbeitung \(Fassung 2026-10-05\)\./)
assert.deepEqual(fx.alle(b, 'a').map((a) => a.getAttribute('href')), ['/rechtliches/leistungsbeschreibung-news-2026-10-05.html', '/rechtliches/avv-news-2026-10-05.html'])
assert.equal(haken(fx, b).length, 0)
assert.equal(knopf(fx, b).textContent, 'Einschalten')
assert.ok(fx.finde(b, '[data-aktion="abbrechen"]'))
assert.doesNotMatch(t, /€|Euro/)
}))
test('Privatkunde in der Frist: Häkchen sichtbar, Knopf nennt das Datum und bleibt gesperrt, bis das Häkchen sitzt', mit(async (fx) => {
const { b } = seite(fx, { kundenartWirksam: 'privat', daten: daten({ abo: { widerrufsfristBis: FRIST } }) })
const h1 = fx.finde(b, 'input[data-haken="beginn"]')
assert.equal(h1.checked, false)
assert.match(fx.text(b), /Bitte beginnt sofort mit der Einrichtung\./)
assert.deepEqual([knopf(fx, b).textContent, knopf(fx, b).disabled], ['Einschalten ab 31.10.', true])
await fx.klick(h1)
assert.deepEqual([knopf(fx, b).textContent, knopf(fx, b).disabled], ['Einschalten', false])
await fx.klick(h1)
assert.equal(knopf(fx, b).disabled, true)
}))
test('Häkchen nie für Betrieb, nie nach Fristende; leere Kundenart zählt als privat', mit((fx) => {
const d = daten({ abo: { widerrufsfristBis: FRIST } })
assert.equal(haken(fx, seite(fx, { daten: d, kundenartWirksam: 'unternehmen' }).b).length, 0)
assert.equal(haken(fx, seite(fx, { daten: d, kundenartWirksam: 'privat', jetzt: () => new Date('2026-10-31T09:00:00Z') }).b).length, 0)
assert.equal(haken(fx, seite(fx, { daten: d, kundenartWirksam: '' }).b).length, 1)
}))
test('Register fehlt: kein Absenden, freundlicher Text', mit((fx) => {
const { b } = seite(fx, { daten: daten({ katalogEdit: { news: { consent: null } } }) })
assert.match(fx.text(b), /Einschalten ist gerade nicht möglich\. Schreib uns im Chat\./)
assert.equal(knopf(fx, b), null)
}))
test('Kundenansicht (viewAs): Knopf gesperrt mit Hinweis', mit((fx) => {
const { b } = seite(fx, { viewAs: true })
assert.equal(knopf(fx, b).disabled, true)
assert.match(fx.text(b), /In der Kundenansicht kann nichts eingeschaltet werden\./)
}))
test('Fremdtext: HTML in Funktions- und Websitenamen nur als Text; Websitezeile erst ab zwei Websites', mit((fx) => {
const d = daten({ katalogEdit: { news: { name: '<img src=x onerror=alert(1)>' } }, projekte: [projekt('p1', '<b>Café</b>'), projekt('p2', 'Kiosk')] })
const { b } = seite(fx, { daten: d })
assert.equal(fx.alle(b, 'img').length + fx.alle(b, 'b').length, 0)
assert.match(fx.text(b), /<img src=x onerror=alert\(1\)> einschalten/)
assert.match(fx.text(b), /Website.*<b>Café<\/b>/)
assert.doesNotMatch(fx.text(seite(fx).b), /Website/)
}))
```
- [ ] **Step 2: Fehlschlag prüfen.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knEinschaltenSeite.test.js`. Expected: FAIL (`renderEinschalten` nicht exportiert).
- [ ] **Step 3: `renderEinschalten` zeichnen** in `public/neu/funktion-einschalten.js` (Aufbau und Zustand von Häkchen und Knopf; Senden folgt in Task 4)
Der Knopf ist gesperrt bei `viewAs`, bei nötigem und fehlendem Häkchen und während des Sendens; das `change`-Ereignis des Häkchens aktualisiert Knopf und Beschriftung. Fehlt das Register (`consentVon` null, das Item `einschalten-zeile` fehlt, oder `beginn` ist nötig und das Item fehlt), zeichnet die Seite statt Linkzeile und Knopf `TEXTE.registerFehlt` und einen Knopf „Im Chat schreiben“ (`geheZu('#chat')`). Der Websitename für „Website“ kommt aus `daten.projects` zum `projectId`.
- [ ] **Step 4: Test laufen lassen.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knEinschaltenSeite.test.js`. Expected: PASS.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/funktion-einschalten.js test/knEinschaltenSeite.test.js && git commit -m "Funktionen neu: Bestätigungsseite zum Einschalten (Linkzeile, Häkchen nur für privat in der Frist)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 4: Einschalten senden, Fehlerfolgen, Navigation, `zeigeEinschalten`
**Files:**
- Modify: `public/neu/funktion-einschalten.js`
- Test: `test/knEinschaltenSeite.test.js` (Teil 2; Importkopf um `navigiere, erfolgText, merkeMeldung, nimmMeldung, zeigeEinschalten` erweitern)
**Interfaces:**
- Consumes: Task 1 (`einschaltKoerper`, `fehlerAktion`, `ETAGE_FUER`, `TEXTE`), Task 3 (`renderEinschalten`), Standards `api` aus `./api.js` und `geheZu` aus `./router.js`.
- Produces:
```js
export async function navigiere(weg, ziel, { geheZu, speicher } = {})
// weg = Ergebnis von einschaltWeg/fehlerAktion ('etage'|'bestellen'|'wechsel'), ziel = { key, projectId }; 'seite' behandelt die Ansicht selbst
// etage → geheZu(`#${weg.etage}?projekt=${projectId}`)
// bestellen → geheZu(`#bestellen-${weg.produkt}`)
// wechsel → speicher.setItem('kn-wechsel-funktion', JSON.stringify({ key, projectId })) in try/catch, dann geheZu('#wechsel') (V11)
export function erfolgText(name, antwort) // antwort null → "<Name> ist schon eingeschaltet."
export function merkeMeldung(text) // merkt genau eine Meldung für den nächsten Aufbau von Meine/Entdecken
export function nimmMeldung() // → Text | '' und vergisst ihn
export async function zeigeEinschalten(platz, { key, projectId, onFertig, daten, kundenartWirksam, viewAs = false, api, geheZu, speicher, jetzt })
// Vertrag mit P4 (VF1): lädt GET /api/funktionen, wenn daten fehlt, zeichnet die Seite mit imModul: true;
// onFertig({ feature, ticketWoid }) nach 200, onFertig({ feature: null, ticketWoid: null }) nach 409 bereits_eingeschaltet; alle anderen Fehler zeigt die Seite selbst
```
Standards in `renderEinschalten`: `bei.fertig` = `merkeMeldung(erfolgText(name, antwort))` und `geheZu('#funktionen', { ersetzen: true })`; `bei.weiter` = `navigiere`; `bei.abbrechen` = `geheZu('#entdecken', { ersetzen: true })`; `bei.neuLaden` = `api('/api/funktionen')`. Senden: `POST /api/funktionen/<key>/einschalten` mit dem Körper aus `einschaltKoerper`.
- [ ] **Step 1: Failing Tests ergänzen** in `test/knEinschaltenSeite.test.js`
```js
const fehler = (status, code, body = {}, message = 'Servertext') => Object.assign(new Error(message), { status, code, body: { code, ...body } })
const posts = (s) => s.aufrufe.filter((a) => a.method === 'POST')
test('Erfolg: genau ein POST mit dem Körper aus dem Register, ohne Kundenart; fertig bekommt die Antwort', mit(async (fx) => {
const s = seite(fx)
await fx.klick(knopf(fx, s.b))
assert.deepEqual(posts(s), [{ pfad: '/api/funktionen/news/einschalten', method: 'POST', body: { projectId: 'p1', zustimmung: { version: '2026-10-20', haken: [] } } }])
assert.deepEqual(s.rueck, [['fertig', OK]])
}))
test('zweiter Tap: höchstens ein POST', mit(async (fx) => {
const s = seite(fx)
await Promise.all([fx.klick(knopf(fx, s.b)), fx.klick(knopf(fx, s.b))])
assert.equal(posts(s).length, 1)
}))
test('Privatkunde: ohne Häkchen geht nichts raus, mit Häkchen ["beginn"]', mit(async (fx) => {
const s = seite(fx, { kundenartWirksam: 'privat', daten: daten({ abo: { widerrufsfristBis: FRIST } }) })
await fx.klick(knopf(fx, s.b))
assert.equal(s.aufrufe.length, 0)
await fx.klick(fx.finde(s.b, 'input[data-haken="beginn"]'))
await fx.klick(knopf(fx, s.b))
assert.deepEqual(posts(s)[0].body.zustimmung.haken, ['beginn'])
}))
test('beginn_noetig vom Server: Häkchen erscheint, Knopf nennt das Datum, zweiter Versuch mit Häkchen', mit(async (fx) => {
let n = 0
const s = seite(fx, { antwort: () => { if (++n === 1) throw fehler(409, 'beginn_noetig', { ab: '2026-11-02T23:00:00.000Z' }); return OK } })
await fx.klick(knopf(fx, s.b))
assert.deepEqual([knopf(fx, s.b).textContent, knopf(fx, s.b).disabled], ['Einschalten ab 03.11.', true])
await fx.klick(fx.finde(s.b, 'input[data-haken="beginn"]'))
await fx.klick(knopf(fx, s.b))
assert.deepEqual(posts(s).map((p) => p.body.zustimmung.haken), [[], ['beginn']])
}))
test('weitere 409-Codes führen an den richtigen Ort', mit(async (fx) => {
const faelle = [
[fehler(409, 'abo_fehlt', { grund: 'falsches_abo', wechsel: true }), ['weiter', { art: 'wechsel' }]],
[fehler(409, 'abo_fehlt', { grund: 'kein_abo' }), ['weiter', { art: 'bestellen', produkt: 'wartung' }]],
[fehler(409, 'bestellung_offen', { offen: [] }), ['weiter', { art: 'etage', etage: 'news' }]],
[fehler(409, 'bereits_eingeschaltet'), ['fertig', null]],
]
for (const [f, erwartet] of faelle) {
const s = seite(fx, { antwort: () => { throw f } })
await fx.klick(knopf(fx, s.b))
assert.deepEqual(s.rueck, [erwartet], f.code)
}
}))
test('fassung_veraltet: lädt neu, zeigt die Zeile erneut und sendet mit der neuen Fassung', mit(async (fx) => {
const neu = daten(); neu.katalog[0].consent.version = '2026-11-01'
let n = 0
const s = seite(fx, { antwort: () => { if (++n === 1) throw fehler(409, 'fassung_veraltet'); return OK }, bei: { neuLaden: async () => neu, fertig: () => {} } })
await fx.klick(knopf(fx, s.b))
assert.match(fx.text(s.b), /Die Bedingungen wurden aktualisiert\. Bitte lies die Zeile noch einmal\./)
await fx.klick(knopf(fx, s.b))
assert.equal(posts(s).at(-1).body.zustimmung.version, '2026-11-01')
}))
test('Netzfehler: Meldung, Knopf wieder frei, zweiter Versuch geht durch', mit(async (fx) => {
let n = 0
const s = seite(fx, { antwort: () => { if (++n === 1) throw Object.assign(new Error('Keine Verbindung. Bitte versuch es gleich noch einmal.'), { status: 0, body: {} }); return OK } })
await fx.klick(knopf(fx, s.b))
assert.match(fx.text(fx.finde(s.b, '[data-meldung]')), /Keine Verbindung/)
assert.equal(knopf(fx, s.b).disabled, false)
await fx.klick(knopf(fx, s.b))
assert.equal(posts(s).length, 2)
assert.deepEqual(s.rueck, [['fertig', OK]])
}))
test('zeigeEinschalten (Vertrag mit P4): lädt die Liste, kein Abbrechen, onFertig nach 200 und nach bereits_eingeschaltet, Servertext bei bestellung_offen', mit(async (fx) => {
const lauf = async (antwort) => {
const platz = fx.document.createElement('div'); fx.document.body.append(platz)
const log = { onFertig: [], gegangen: [], pfade: [] }
const api = async (pfad, o = {}) => { log.pfade.push(`${o.method ?? 'GET'} ${pfad}`); if (!o.method) return daten(); return antwort() }
await zeigeEinschalten(platz, { key: 'news', projectId: 'p1', onFertig: (a) => log.onFertig.push(a), api, geheZu: (h) => log.gegangen.push(h), jetzt: JETZT })
assert.equal(fx.finde(platz, '[data-aktion="abbrechen"]'), null)
await fx.klick(knopf(fx, platz))
return { platz, log }
}
const ok = await lauf(() => OK)
assert.deepEqual([ok.log.pfade, ok.log.onFertig], [['GET /api/funktionen', 'POST /api/funktionen/news/einschalten'], [OK]])
const schon = await lauf(() => { throw fehler(409, 'bereits_eingeschaltet') })
assert.deepEqual(schon.log.onFertig, [{ feature: null, ticketWoid: null }])
const offen = await lauf(() => { throw fehler(409, 'bestellung_offen', { offen: [] }, 'Es fehlen noch Angaben.') })
assert.match(fx.text(offen.platz), /Es fehlen noch Angaben\./)
assert.deepEqual([offen.log.gegangen, offen.log.onFertig], [[], []])
}))
test('navigiere: Ziele je Weg; der Wechsel merkt die Funktion und geht auch ohne beschreibbaren Speicher', async () => {
const gegangen = []; const geheZu = (hash, o) => { gegangen.push(o ? [hash, o] : [hash]) }
const gespeichert = {}; const speicher = { setItem: (k, v) => { gespeichert[k] = v } }
const ziel = { key: 'termine', projectId: 'p1' }
await navigiere({ art: 'etage', etage: 'webshop' }, { key: 'shop', projectId: 'p2' }, { geheZu, speicher })
await navigiere({ art: 'bestellen', produkt: 'hosting' }, { key: 'analyse', projectId: 'p1' }, { geheZu, speicher })
assert.deepEqual(gegangen, [['#webshop?projekt=p2'], ['#bestellen-hosting']])
gegangen.length = 0
await navigiere({ art: 'wechsel' }, ziel, { geheZu, speicher })
assert.deepEqual(JSON.parse(gespeichert['kn-wechsel-funktion']), ziel)
await navigiere({ art: 'wechsel' }, ziel, { geheZu, speicher: { setItem() { throw new Error('voll') } } })
assert.deepEqual(gegangen, [['#wechsel'], ['#wechsel']])
})
test('erfolgText und Meldung: eine Meldung, einmal gelesen', () => {
assert.equal(erfolgText('News-Pinnwand', { ticketWoid: '100301' }), 'News-Pinnwand ist eingeschaltet. Die Einrichtung läuft jetzt. Ticket #100301.')
assert.equal(erfolgText('News-Pinnwand', { ticketWoid: null }), 'News-Pinnwand ist eingeschaltet. Die Einrichtung läuft jetzt.')
assert.equal(erfolgText('News-Pinnwand', null), 'News-Pinnwand ist schon eingeschaltet.')
merkeMeldung('x'); assert.equal(nimmMeldung(), 'x'); assert.equal(nimmMeldung(), '')
})
```
- [ ] **Step 2: Fehlschlag prüfen.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knEinschaltenSeite.test.js`. Expected: FAIL (Senden nicht verdrahtet; `navigiere`, `zeigeEinschalten` fehlen).
- [ ] **Step 3: Senden und Navigation implementieren** in `public/neu/funktion-einschalten.js`
Ablauf des Klicks: (1) bei gesperrtem Knopf nichts; (2) Knopf sperren, Meldung leeren; (3) `POST` mit `einschaltKoerper`; (4) Erfolg → `bei.fertig(antwort)`; (5) Fehler → `fehlerAktion(fehler, { eintrag })`: `wechsel`/`bestellen` → `bei.weiter(aktion)`; `etage` → `bei.weiter(aktion)`, bei `imModul` stattdessen der Servertext in `[data-meldung]`; `schon` → `bei.fertig(null)`; `beginn` → `serverAb` merken und die Seite neu zeichnen (Häkchen sichtbar, Knopf gesperrt mit Datum); `neu-laden` → `bei.neuLaden()` und neu zeichnen mit der Meldung `aktion.text`; `meldung` → Text in `[data-meldung]`, Knopf frei. Ein Fehler beim Neuladen zeigt `TEXTE.allgemein`. `zeigeEinschalten` übersetzt `bei.fertig(antwort)` zu `onFertig(antwort ?? { feature: null, ticketWoid: null })`. Fehlende Optionen: `api` aus `./api.js`, `geheZu` aus `./router.js`, `speicher` = try/catch-Zugriff auf `globalThis.sessionStorage` erst beim Aufruf.
- [ ] **Step 4: Test laufen lassen.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knEinschaltenSeite.test.js`. Expected: PASS (Task 3 und 4).
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/funktion-einschalten.js test/knEinschaltenSeite.test.js && git commit -m "Funktionen neu: Einschalten senden, 409-Folgen, Wechsel mit gemerkter Funktion, zeigeEinschalten für die Module" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 5: Ansicht mit Reitern, Meine, Entdecken, Website-Auswahl und Rechnungszeile
**Files:**
- Modify: `public/neu/ansicht-funktionen.js`
- Test: `test/knFunktionenAnsicht.test.js` (Teil 1)
**Interfaces:**
- Consumes: Task 1 bis 4; `h`, `leeren` (V2); `api`, `geheZu`, `oeffneOverlay`, `beendeOverlay` (P1) über `abh`; `baueReiter({ eintraege:[{ key, label, kurz? }], aktiv, art = 'unterstrich', ariaLabel, bei(key) }) → HTMLElement` aus `./reiter.js` (P1 Task 5, dieselbe Form wie P2 Task 16: das Element selbst, nicht `{ el }`; am Element `setzeAktiv(key)`, `setzeAnzeige`, `messen`); `offeneRechnungen(antwort) → Rechnung[]` (nur `Number(balance) > 0`) und `rechnungOffenZeile(offen) → <p class="kn-rechnung-offen"> | null` aus `./rechnung-offen.js` (P2 Task 3). Nicht genutzt: `ladeOffeneRechnungen()` (nimmt keine `api` entgegen, im Test nicht einspeisbar) und `zaehler.stand().rechnungen` (gibt es nicht, P1 Task 7).
- Produces:
```js
export async function zeigeFunktionen(wurzel, { route, me, nav }, abh = {}) // V2, genau die Form von P1 Task 11
// abh: { api, geheZu, oeffneOverlay, beendeOverlay, speicher, jetzt }, jeder Eintrag optional (Standards: ./api.js, ./router.js, sessionStorage, new Date())
export function vergissMerker() // setzt die Merker des Moduls zurück (nur für Tests)
```
Aufbau (Selektoren): oben `p.kn-rechnung-offen` (nur mit offenen Rechnungen, die die Ansicht selbst über `GET /api/invoices` lädt, in Meine und in Entdecken); die Reiterleiste `baueReiter({ eintraege: [{ key:'meine', label:'Meine' }, { key:'entdecken', label:'Entdecken' }], aktiv: route.reiter ?? 'meine', art:'unterstrich', ariaLabel:'Funktionen', bei })` (liefert direkt das Element mit `button[role=tab]`); Meine: `ul.kn-fn-liste > li.kn-fn-zeile[data-fn][data-projekt] > button.kn-fn-zeile-knopf` mit `.kn-fn-name`, `.kn-fn-zeile2`, `.kn-fn-pille.kn-fn-pille--<ton>`; Entdecken: `section.kn-fn-eintrag[data-fn]` mit `img.kn-fn-bild` (`src` = `eintrag.bild`, `alt` = `Beispiel: <Name>`, `width=960 height=600 loading=lazy`), `h3`, Beschreibung, `p.kn-fn-enthalten` (`Im Wartungs-Abo enthalten` / `Im Hosting-Abo enthalten` nach `enthaltenIn`), `button[data-aktion="einschalten"]`, `div[role=group][aria-label="Für welche Website?"]` mit einem Knopf je Kandidat (nur bei mehr als einem Kandidaten), `p[role=alert]` für Meldungen. Meldung nach Erfolg: `p[role=status]` aus `nimmMeldung()`. Texte: `Wird geladen …`; `Die Funktionen konnten gerade nicht geladen werden.` mit `button[data-aktion="nochmal"]` („Nochmal versuchen“, ruft `zeigeFunktionen` mit denselben Argumenten erneut); `modell !== 'abo'` → `Die Funktionen sind gerade nicht verfügbar. Lade die Seite neu.`; Meine leer und ausdrücklich gewählt → `Du hast noch keine Funktion eingeschaltet.` mit Knopf `Entdecken`; Entdecken ohne Websites → `Sobald deine Website angelegt ist, kannst du Funktionen einschalten.` statt Knöpfe; Entdecken ohne Einträge → `Du hast schon alles eingeschaltet, was es gibt.`
- [ ] **Step 0: Signaturen von `baueReiter` und `rechnung-offen.js` lesen** (nur lesen)
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && sed -n 1,60p public/neu/reiter.js && command grep -a -n "^export" public/neu/rechnung-offen.js`
Expected: `baueReiter({ eintraege: [{ key, label, kurz? }], aktiv, art, ariaLabel, bei(key) })` liefert ein `HTMLElement`; `offeneRechnungen` und `rechnungOffenZeile` sind exportiert. Weicht eine Form ab, wird nur die interne Funktion `funktionenReiter(aktiv, bei)` bzw. `ladeRechnungen(api)` in `ansicht-funktionen.js` angepasst; die Tests unten fragen `[role="tab"]` und `.kn-rechnung-offen` ab. Befund dem Controller melden.
- [ ] **Step 1: Failing Tests schreiben** in `test/knFunktionenAnsicht.test.js` (Teil 1)
```js
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { installFakeDom } from './support/fakeDom.js'
import { zeigeFunktionen, vergissMerker } from '../public/neu/ansicht-funktionen.js'
import { daten, projekt, feature } from './support/knFunktionenDaten.js'
const JETZT = () => new Date('2026-10-20T10:00:00Z')
const MEINE = { bereich: 'funktionen', reiter: 'meine', query: '' }
const ENTDECKEN = { bereich: 'funktionen', reiter: 'entdecken', query: '' }
const mit = (fn) => async () => { vergissMerker(); const fx = installFakeDom(); try { await fn(fx) } finally { fx.restore() } }
function umgebung(fx, { d = daten(), abo, api, post = { feature: { state: 'setup' }, ticketWoid: '100301' }, me = {} } = {}) {
const wurzel = fx.document.createElement('div'); fx.document.body.append(wurzel)
const u = { wurzel, d, rechnungen: [], rechnungenFehler: false, gegangen: [], aufrufe: [], gespeichert: {}, overlay: null }
const basis = async (pfad, o = {}) => {
u.aufrufe.push([o.method ?? 'GET', pfad])
if (o.method === 'POST') return post
if (pfad === '/api/funktionen') return u.d
if (pfad === '/api/invoices') { if (u.rechnungenFehler) throw Object.assign(new Error('x'), { status: 500, body: {} }); return { invoices: u.rechnungen, incomplete: false } }
if (pfad === '/api/abo') { if (abo instanceof Error) throw abo; return abo ?? { subscription: null } }
throw new Error(`unerwartet ${pfad}`)
}
const abh = {
api: api ?? basis, jetzt: JETZT, speicher: { setItem: (k, v) => { u.gespeichert[k] = v } },
geheZu: (hash, o) => { u.gegangen.push(o ? [hash, o] : [hash]) },
oeffneOverlay: ({ schliesse }) => { u.overlay = schliesse },
beendeOverlay: () => { const s = u.overlay; u.overlay = null; s?.() },
}
u.zeige = (route, ctx = {}) => zeigeFunktionen(wurzel, { route, me: { customer: { kundenartWirksam: 'unternehmen' }, viewAs: false, ...me }, nav: 'tab', ...ctx }, abh)
return u
}
const tab = (fx, u, text) => fx.alle(u.wurzel, '[role="tab"]').find((t) => t.textContent === text)
test('Meine: Liste mit Name, zweiter Zeile und Pille; Tipp öffnet die Etage mit der Website der Zeile, eine angenommene Anfrage die Bestätigung', mit(async (fx) => {
const u = umgebung(fx, { d: daten({ projekte: [projekt('p1', 'Bäckerei', { termine: feature('active'), shop: feature('off', { anfrageStatus: 'angenommen' }) }), projekt('p2', 'Café', { termine: feature('active') })] }) })
await u.zeige(MEINE)
const zeilen = fx.alle(u.wurzel, 'li[data-fn]')
assert.deepEqual(zeilen.map((z) => [z.getAttribute('data-fn'), z.getAttribute('data-projekt')]), [['termine', 'p1'], ['termine', 'p2'], ['shop', 'p1']])
assert.match(fx.text(zeilen[1]), /Terminplaner.*Café.*Aktiv/)
await fx.klick(fx.finde(zeilen[0], 'button'))
await fx.klick(fx.finde(zeilen[1], 'button'))
assert.deepEqual(u.gegangen, [['#termine?projekt=p1'], ['#termine?projekt=p2']])
await fx.klick(fx.finde(zeilen[2], 'button'))
assert.ok(fx.finde(u.wurzel, 'section.kn-fn-einschalten[data-key="shop"][data-projekt="p1"]'))
}))
test('Meine leer: der erste Aufruf springt ersetzend zu Entdecken, ein ausdrücklich gewähltes Meine zeigt den Leerzustand', mit(async (fx) => {
const u = umgebung(fx)
await u.zeige(MEINE)
assert.deepEqual(u.gegangen, [['#entdecken', { ersetzen: true }]])
await u.zeige(ENTDECKEN)
u.gegangen.length = 0
await fx.klick(tab(fx, u, 'Meine'))
assert.deepEqual(u.gegangen, [['#funktionen', { ersetzen: true }]])
await u.zeige(MEINE) // so ruft der Router danach auf
assert.equal(u.gegangen.length, 1)
assert.match(fx.text(u.wurzel), /Du hast noch keine Funktion eingeschaltet\./)
}))
test('Rechnungszeile oben: die Ansicht lädt GET /api/invoices selbst (ohne Zähler), nur balance > 0, in Meine und Entdecken; fällt der Abruf aus, bleibt die Liste', mit(async (fx) => {
const u = umgebung(fx, { d: daten({ projekte: [projekt('p1', 'B', { news: feature('active') })] }) })
await u.zeige(MEINE)
assert.equal(fx.finde(u.wurzel, '.kn-rechnung-offen'), null)
assert.ok(u.aufrufe.some(([m, p]) => m === 'GET' && p === '/api/invoices'))
u.rechnungen = [{ id: 'r0', number: 'RE-2026-0040', balance: 0, portalUrl: null }, { id: 'r1', number: 'RE-2026-0042', balance: 50, portalUrl: 'https://invoice.stripe.com/i/x' }]
await u.zeige(MEINE)
assert.match(fx.text(fx.finde(u.wurzel, '.kn-rechnung-offen')), /^Rechnung offen · RE-2026-0042 · 50,00\s€ · Zahlen$/)
assert.ok(fx.finde(u.wurzel, 'li[data-fn]'))
await u.zeige(ENTDECKEN)
assert.ok(fx.finde(u.wurzel, '.kn-rechnung-offen'))
u.rechnungenFehler = true
await u.zeige(MEINE)
assert.equal(fx.finde(u.wurzel, '.kn-rechnung-offen'), null)
assert.ok(fx.finde(u.wurzel, 'li[data-fn]'))
}))
test('Entdecken: Bild, Name, Beschreibung, "Im … Abo enthalten", Knopf; Katalogpreise erscheinen nie', mit(async (fx) => {
const u = umgebung(fx, { d: daten({ katalogEdit: { termine: { priceLabel: '149 €' } } }) })
await u.zeige(ENTDECKEN)
const e = fx.alle(u.wurzel, 'section[data-fn]')
assert.deepEqual(e.map((x) => x.getAttribute('data-fn')), ['news', 'termine', 'shop', 'unterkunft', 'zeiten', 'analyse'])
assert.equal(fx.finde(e[1], 'img').getAttribute('src'), '/neu/bilder/termine.webp')
assert.match(fx.text(e[1]), /Terminplaner.*Beschreibung Terminplaner\..*Im Wartungs-Abo enthalten/)
assert.match(fx.text(e[5]), /Im Hosting-Abo enthalten/)
assert.equal(fx.finde(e[1], '[data-aktion="einschalten"]').textContent, 'Einschalten')
assert.doesNotMatch(fx.text(u.wurzel), /€|Euro/)
}))
test('Einschalten-Tap (eine Website) geht je Abo-Lage an den richtigen Ort', mit(async (fx) => {
const faelle = [ // [Abo, Funktion, Sprung ('seite' = Bestätigung ohne Sprung)]
[{ produkt: 'wartung' }, 'news', 'seite'],
[{ produkt: 'wartung' }, 'termine', ['#termine?projekt=p1']],
[{ produkt: 'wartung' }, 'shop', ['#webshop?projekt=p1']],
[{ produkt: 'hosting' }, 'analyse', ['#analyse?projekt=p1']],
[{ produkt: 'hosting' }, 'zeiten', ['#wechsel']],
[{ produkt: null, laeuft: false }, 'zeiten', ['#bestellen-wartung']],
[{ produkt: null, laeuft: false }, 'analyse', ['#bestellen-hosting']],
]
for (const [abo, key, sprung] of faelle) {
const u = umgebung(fx, { d: daten({ abo }) })
await u.zeige(ENTDECKEN)
await fx.klick(fx.finde(u.wurzel, `section[data-fn="${key}"] [data-aktion="einschalten"]`))
if (sprung === 'seite') { assert.deepEqual(u.gegangen, []); assert.ok(fx.finde(u.wurzel, `section.kn-fn-einschalten[data-key="${key}"]`)); continue }
assert.deepEqual(u.gegangen, [sprung], `${abo.produkt} ${key}`)
if (sprung[0] === '#wechsel') assert.deepEqual(JSON.parse(u.gespeichert['kn-wechsel-funktion']), { key, projectId: 'p1' })
}
}))
test('Privatkunde: Webshop zeigt den Hinweis statt des Knopfes; ohne Websites steht ein Hinweis statt der Knöpfe', mit(async (fx) => {
const u = umgebung(fx, { d: daten({ kundenart: 'privat' }), me: { customer: { kundenartWirksam: 'privat' } } })
await u.zeige(ENTDECKEN)
const shop = fx.finde(u.wurzel, 'section[data-fn="shop"]')
assert.match(fx.text(shop), /Webshop gibt es für Betriebe\. Stimmt das nicht\? Schreib uns im Chat\./)
assert.equal(fx.finde(shop, '[data-aktion="einschalten"]'), null)
const leer = umgebung(fx, { d: daten({ projekte: [] }) })
await leer.zeige(ENTDECKEN)
assert.match(fx.text(leer.wurzel), /Sobald deine Website angelegt ist, kannst du Funktionen einschalten\./)
assert.equal(fx.alle(leer.wurzel, '[data-aktion="einschalten"]').length, 0)
}))
test('Zwei Websites: Einschalten fragt "Für welche Website?" und bietet nur Websites ohne die Funktion', mit(async (fx) => {
const u = umgebung(fx, { d: daten({ projekte: [projekt('p1', 'Bäckerei', { news: feature('active') }), projekt('p2', 'Café'), projekt('p3', 'Kiosk')] }) })
await u.zeige(ENTDECKEN)
const news = fx.finde(u.wurzel, 'section[data-fn="news"]')
await fx.klick(fx.finde(news, '[data-aktion="einschalten"]'))
const wahl = fx.finde(news, '[role="group"]')
assert.equal(wahl.getAttribute('aria-label'), 'Für welche Website?')
assert.deepEqual(fx.alle(wahl, 'button').map((b) => b.textContent), ['Café', 'Kiosk'])
await fx.klick(fx.alle(wahl, 'button')[1])
assert.ok(fx.finde(u.wurzel, 'section.kn-fn-einschalten[data-key="news"][data-projekt="p3"]'))
}))
test('Laden und Fehler: erst "Wird geladen …", dann Text und "Nochmal versuchen"; Antwort ohne Abo-Modell', mit(async (fx) => {
let los; const warten = new Promise((r) => { los = r }); let n = 0
const u = umgebung(fx, { api: async (pfad) => { if (pfad === '/api/invoices') return { invoices: [], incomplete: false }; if (++n === 1) { await warten; throw Object.assign(new Error('x'), { status: 500, body: {} }) } return n === 2 ? daten() : { ...daten(), modell: undefined } } })
const lauf = u.zeige(ENTDECKEN)
assert.match(fx.text(u.wurzel), /Wird geladen …/)
los(); await lauf
assert.match(fx.text(u.wurzel), /Die Funktionen konnten gerade nicht geladen werden\./)
await fx.klick(fx.finde(u.wurzel, '[data-aktion="nochmal"]'))
assert.equal(fx.alle(u.wurzel, 'section[data-fn]').length, 6)
await u.zeige(ENTDECKEN)
assert.match(fx.text(u.wurzel), /Die Funktionen sind gerade nicht verfügbar\. Lade die Seite neu\./)
}))
test('veraltete Antwort überschreibt die neuere nicht', mit(async (fx) => {
let langsam; const erster = new Promise((r) => { langsam = r }); let n = 0
const mit1 = (name) => daten({ katalogEdit: { news: { name } }, projekte: [projekt('p1', 'B', { news: feature('active') })] })
const u = umgebung(fx, { api: async (pfad) => (pfad === '/api/invoices' ? { invoices: [], incomplete: false } : ++n === 1 ? (await erster, mit1('Alt-Pinnwand')) : mit1('Neu-Pinnwand')) })
const a = u.zeige(MEINE); const b = u.zeige(MEINE)
await b; langsam(); await a
assert.match(fx.text(u.wurzel), /Neu-Pinnwand/)
assert.doesNotMatch(fx.text(u.wurzel), /Alt-Pinnwand/)
}))
test('Fremdtext und fehlendes Bild: Namen nur als Text, ohne Bild bleibt der Eintrag lesbar', mit(async (fx) => {
const u = umgebung(fx, { d: daten({ katalogEdit: { news: { name: '<b>News</b>', beschreibung: '<i>x</i>' } }, projekte: [projekt('p1', '<u>Café</u>')] }) })
await u.zeige(ENTDECKEN)
assert.equal(fx.alle(u.wurzel, 'b, i, u').length, 0)
const news = fx.finde(u.wurzel, 'section[data-fn="news"]')
await fx.sende(fx.finde(news, 'img'), 'error')
assert.equal(fx.finde(news, 'img'), null)
assert.match(fx.text(news), /<b>News<\/b>/)
}))
```
- [ ] **Step 2: Fehlschlag prüfen.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knFunktionenAnsicht.test.js`. Expected: FAIL (der Stub zeichnet nur „Funktionen (folgt)“; `vergissMerker` fehlt).
- [ ] **Step 3: `zeigeFunktionen` für Listen und Bestätigung implementieren** in `public/neu/ansicht-funktionen.js` (den Stub ersetzen)
Ablauf eines Aufrufs: (1) fehlende `abh`-Einträge mit den Standards füllen; (2) Lauf-Zähler am `wurzel` (`dataset.knFnLauf`), damit eine ältere Antwort verworfen wird; (3) `Wird geladen …` (vor dem ersten `await`); (4) `GET /api/funktionen` und parallel (`Promise.all`) die offenen Rechnungen über die interne Funktion `ladeRechnungen(api)` = `offeneRechnungen(await api('/api/invoices'))`, jeder Fehler dort → `[]` (dann fehlt nur die Zeile, die Liste bleibt); Fehler von `/api/funktionen` und `modell !== 'abo'` mit den Texten oben; (5) Weiche nach `route.reiter ?? 'meine'`: Meine ohne Zeilen und ohne ausdrückliche Wahl → `geheZu('#entdecken', { ersetzen: true })` und Ende. Den Merker `reiterGewaehlt` setzt der Tipp auf „Meine“: `bei(key)` der Reiterleiste (aus `funktionenReiter(aktiv, bei)`, dem einzigen Aufruf von `baueReiter`) ruft `geheZu('#funktionen' | '#entdecken', { ersetzen: true })` für `'meine'` bzw. `'entdecken'`; der nächste Aufruf verbraucht den Merker; (6) die Liste zeichnet eine eigene Funktion `zeichneListe(daten, offen, optionen)` (in Task 6 auch aus dem Zwischenspeicher aufrufbar; `daten` und `offen` liegen beide im Zwischenspeicher des Aufrufs): ganz oben `rechnungOffenZeile(offen)`, wenn nicht `null`, darunter die Reiterleiste; `nimmMeldung()` erst dort. Kundenart: `me?.customer?.kundenartWirksam ?? daten.kundenart`. Tipp auf eine Meine-Zeile und auf „Einschalten“: `zeilenWeg` bzw. `einschaltWeg` entscheidet; `seite` → `oeffneSeite(key, projectId)` (hier ein `renderEinschalten` direkt in `wurzel`, Task 6 ergänzt das Overlay), `etage`/`bestellen`/`wechsel` → `navigiere(weg, { key, projectId }, { geheZu, speicher })`, `meldung` → Text in `p[role=alert]` des Eintrags. Bei mehreren Kandidaten öffnet „Einschalten“ erst die Auswahlgruppe (zweiter Tipp schließt sie). `nurBetrieb` bei privat → Hinweis (`TEXTE.nurBetrieb`) statt Knopf. Der `error`-Handler des Bildes entfernt das `img`.
- [ ] **Step 4: Test laufen lassen.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knFunktionenAnsicht.test.js`. Expected: PASS.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/ansicht-funktionen.js test/knFunktionenAnsicht.test.js && git commit -m "Funktionen neu: Ansicht mit Meine und Entdecken, Website-Auswahl, Hinweise je Abo-Lage, Rechnungszeile" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 6: Bestätigungsseite als Overlay, Erfolgsmeldung, Wechsel-Rückkehr
**Files:**
- Modify: `public/neu/ansicht-funktionen.js`
- Test: `test/knFunktionenAnsicht.test.js` (Teil 2)
**Interfaces:**
- Consumes: Task 3 bis 5; `oeffneOverlay({ schliesse })` und `beendeOverlay()` aus `./router.js` (P1 Task 4, R6: das Overlay schiebt einen Verlaufseintrag, die Zurück-Geste ruft `schliesse()` ohne `zeige`, `geheZu` bei offenem Overlay ersetzt den Eintrag und ruft `schliesse()` genau einmal).
- Produces (innerhalb von `zeigeFunktionen`):
- **`oeffneSeite(key, projectId)`:** ersetzt den Inhalt von `wurzel` durch `renderEinschalten(wurzel, …)` (mit `viewAs` aus `me`, `bei.abbrechen` = `beendeOverlay`) und ruft `oeffneOverlay({ schliesse })`. `schliesse` zeichnet die Liste aus den bereits geladenen Daten und Rechnungen (kein neuer Abruf, auch nicht von `/api/invoices`; `nimmMeldung()` bleibt unangetastet) und wirkt nur, wenn die Seite noch die aktuelle Anzeige ist (Lauf-Zähler).
- **Erfolg:** `bei.fertig` (Standard aus Task 4) merkt die Meldung und ruft `geheZu('#funktionen', { ersetzen: true })`; der folgende Aufruf von `zeigeFunktionen` zeigt die Meldung als `p[role=status]` genau einmal.
- **Wechsel-Rückkehr (V11):** nach dem Laden der Daten, vor der Weiche Meine/Entdecken. Bei Abo `wartung` und `laeuft` ruft die Ansicht `GET /api/abo`; steht `subscription.wechselFunktion` `{ key, projectId }` da, ist die Funktion im Katalog verfügbar und hat die Website sie noch nicht, öffnet sie den Ablauf dieser Funktion (`einschaltWeg`: `seite` → `oeffneSeite`, `etage` → `navigiere`), pro Seitenaufruf einmal je `key:projectId` (modulweites `Set`). Fehler von `/api/abo` werden ignoriert.
- [ ] **Step 1: Failing Tests ergänzen** in `test/knFunktionenAnsicht.test.js`
```js
test('Bestätigung als Overlay: Zurück-Geste bringt die Liste ohne neuen Abruf zurück, Abbrechen ebenso', mit(async (fx) => {
const u = umgebung(fx)
await u.zeige(ENTDECKEN)
const geladen = () => u.aufrufe.filter(([m, p]) => m === 'GET' && p === '/api/funktionen').length
await fx.klick(fx.finde(u.wurzel, 'section[data-fn="news"] [data-aktion="einschalten"]'))
assert.match(fx.text(u.wurzel), /News-Pinnwand einschalten/)
assert.equal(fx.alle(u.wurzel, 'section[data-fn]').length, 0)
u.overlay() // Zurück-Geste des Browsers (der Router ruft schliesse)
assert.equal(fx.alle(u.wurzel, 'section[data-fn]').length, 6)
await fx.klick(fx.finde(u.wurzel, 'section[data-fn="news"] [data-aktion="einschalten"]'))
await fx.klick(fx.finde(u.wurzel, '[data-aktion="abbrechen"]'))
assert.equal(fx.alle(u.wurzel, 'section[data-fn]').length, 6)
assert.deepEqual([geladen(), u.gegangen], [1, []])
}))
test('Erfolg: Meldung merken, ersetzend zu Meine, Meldung genau einmal', mit(async (fx) => {
const u = umgebung(fx)
await u.zeige(ENTDECKEN)
await fx.klick(fx.finde(u.wurzel, 'section[data-fn="news"] [data-aktion="einschalten"]'))
await fx.klick(fx.finde(u.wurzel, '[data-aktion="einschalten"]'))
assert.deepEqual(u.aufrufe.filter(([m]) => m === 'POST'), [['POST', '/api/funktionen/news/einschalten']])
assert.deepEqual(u.gegangen, [['#funktionen', { ersetzen: true }]])
u.d = daten({ projekte: [projekt('p1', 'Bäckerei Sonnenschein', { news: feature('setup', { ticketWoid: '100301' }) })] })
await u.zeige(MEINE) // so ruft der Router danach auf
assert.equal(fx.text(fx.finde(u.wurzel, '[role="status"]')), 'News-Pinnwand ist eingeschaltet. Die Einrichtung läuft jetzt. Ticket #100301.')
await u.zeige(MEINE)
assert.equal(fx.finde(u.wurzel, '[role="status"]'), null)
}))
test('wechselFunktion (V11): öffnet den Ablauf der Funktion, einmal, nur mit Wartungs-Abo', mit(async (fx) => {
const aboMit = (key) => ({ subscription: { wechselFunktion: { key, projectId: 'p1' } } })
const abgefragt = (u) => u.aufrufe.some(([, p]) => p === '/api/abo')
let u = umgebung(fx, { abo: aboMit('news') })
await u.zeige(MEINE)
assert.ok(fx.finde(u.wurzel, 'section.kn-fn-einschalten[data-key="news"]')) // Bestätigung, kein Sprung
assert.deepEqual(u.gegangen, [])
await u.zeige(MEINE) // zweiter Aufruf: nicht noch einmal in die Funktion
assert.deepEqual(u.gegangen, [['#entdecken', { ersetzen: true }]])
vergissMerker()
u = umgebung(fx, { abo: aboMit('termine') }); await u.zeige(MEINE)
assert.deepEqual(u.gegangen, [['#termine?projekt=p1']]) // Fragebogen zuerst
vergissMerker()
u = umgebung(fx, { d: daten({ abo: { produkt: 'hosting' } }), abo: aboMit('news') }); await u.zeige(MEINE)
assert.equal(abgefragt(u), false)
vergissMerker()
for (const abo of [aboMit('gibtsnicht'), new Error('weg')]) {
u = umgebung(fx, { abo }); await u.zeige(MEINE)
assert.deepEqual(u.gegangen, [['#entdecken', { ersetzen: true }]]) // ungültig oder Fehler: wie ohne Wechsel
}
}))
```
- [ ] **Step 2: Fehlschlag prüfen.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knFunktionenAnsicht.test.js`. Expected: FAIL in den drei neuen Tests (kein Overlay, keine Meldung, keine Wechsel-Rückkehr).
- [ ] **Step 3: Overlay, Erfolgsmeldung und Wechsel-Rückkehr implementieren** in `public/neu/ansicht-funktionen.js`
`oeffneSeite` ersetzt den Platzhalter aus Task 5. Der Zwischenspeicher der geladenen `daten`, der offenen Rechnungen `offen` und der `route` lebt in der Closure des Aufrufs. Der Merker der Wechsel-Rückkehr ist ein modulweites `Set` aus `key:projectId`; `vergissMerker()` leert es und setzt `reiterGewaehlt` zurück.
- [ ] **Step 4: Test laufen lassen.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knFunktionenAnsicht.test.js`. Expected: PASS (Task 5 und 6).
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/ansicht-funktionen.js test/knFunktionenAnsicht.test.js && git commit -m "Funktionen neu: Bestätigung als Overlay der Zurück-Geste, Erfolgsmeldung, Rückkehr nach dem Wechsel" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 7: Aussehen (`funktionen.css`) und Quelltext-Prüfung
**Files:**
- Modify: `public/neu/funktionen.css` (Stub aus P1; `dashboard.html` bindet die Datei schon in fester Reihenfolge ein)
- Test: `test/knFunktionenStatik.test.js`
**Interfaces:**
- Consumes: alle `kn-fn-*`-Klassen aus Task 3 bis 6; die Tokens `--kn-*` aus P1 `tokens.css` (z. B. `--kn-text`, `--kn-text-2`, `--kn-text-3`, `--kn-line`, `--kn-bg-hover`, `--kn-bg-subtle`, `--kn-accent`, `--kn-danger`, `--kn-radius`, `--kn-radius-lg`, `--kn-ring`, `--kn-<farbe>-bg|fg`) mit den Werten der Übersicht als Fallback.
- Produces: eine CSS-Datei, die jede im JS benutzte `kn-fn-*`-Klasse gestaltet.
Vorgaben (Werte aus den Global Constraints der Übersicht): Liste mit Haarlinien `#ebeef1`, Hover `#f6f8fa`, Zeilenknopf `min-height: 44px`, Name `#1a1f36` Gewicht 600, zweite Zeile `#5e6678`. Pillen ohne Rand, Radius 999 px, Fläche/Schrift: grau `#ebeef1`/`#414552`, blau `#e0f2fe`/`#075985`, orange `#fcedb9`/`#a82c00`, violett `#efeafe`/`#5b21b6`, grün `#d7f7c2`/`#006908`, rot `#ffe7f2`/`#b3093c`. Primärknopf `#111` mit weißer Schrift, Zweitknopf mit Ring-Schatten `0 0 0 1px rgba(60,66,87,.16), 0 1px 1px rgba(0,0,0,.06)`, beide Radius 6 px, mindestens 44 px hoch; gesperrt: Schrift `#8a93a3`, Fläche `#f8f9fb`. Fehlertext `#c0123c`. Entdecken ohne Rahmen: Bild (16:10, Radius 12 px) über dem Text am Handy, ab `@media (min-width: 881px)` zwei Spalten. Keine Animation. Die Rechnungszeile `.kn-rechnung-offen` gehört P2.
- [ ] **Step 1: Failing Tests schreiben** in `test/knFunktionenStatik.test.js`
```js
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
const lies = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8')
const ohneKommentare = (t) => t.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
const JS = ['public/neu/ansicht-funktionen.js', 'public/neu/funktion-einschalten.js']
const css = lies('public/neu/funktionen.css')
test('funktionen.css: Pillen-Farben aus den Global Constraints', () => {
const PILLEN = { grau: ['#ebeef1', '#414552'], blau: ['#e0f2fe', '#075985'], orange: ['#fcedb9', '#a82c00'], violett: ['#efeafe', '#5b21b6'], gruen: ['#d7f7c2', '#006908'], rot: ['#ffe7f2', '#b3093c'] }
for (const [ton, [flaeche, schrift]] of Object.entries(PILLEN)) {
const regel = (css.match(new RegExp(`\\.kn-fn-pille--${ton}\\s*\\{([^}]*)\\}`)) ?? [, ''])[1].toLowerCase()
assert.ok(regel.includes(flaeche) && regel.includes(schrift), ton)
}
})
test('funktionen.css: verbotene Mittel fehlen, Radien nur 6 und 12 px, Tap-Ziel 44 px', () => {
assert.doesNotMatch(css, /gradient|backdrop-filter|glow|prefers-color-scheme\s*:\s*dark|#9d3dfe|rgba\(\s*157/i)
assert.doesNotMatch(css, /font-weight\s*:\s*(800|900)/)
for (const m of css.matchAll(/border-radius\s*:\s*([^;}]+)/g)) assert.match(m[1].trim(), /^(0|6px|12px|999px|50%)$/, m[0])
assert.match((css.match(/\.kn-fn-zeile-knopf\s*\{([^}]*)\}/) ?? [, ''])[1], /min-height\s*:\s*44px/)
assert.match(css, /@media\s*\(min-width:\s*881px\)/)
})
test('jede kn-fn-Klasse der JS-Dateien ist im CSS gestaltet', () => {
for (const datei of JS) for (const k of new Set(ohneKommentare(lies(datei)).match(/kn-fn-[a-z0-9-]+/g) ?? [])) assert.ok(css.includes(`.${k}`), `${datei}: .${k} fehlt im CSS`)
})
for (const datei of JS) {
test(`${datei}: kein innerHTML, nur relative Importe, keine Preise, keine Sie-Anrede`, () => {
const code = ohneKommentare(lies(datei))
assert.doesNotMatch(code, /innerHTML|insertAdjacentHTML|outerHTML|document\.write|\beval\(|new Function/)
for (const m of code.matchAll(/(?:from|import\()\s*'([^']+)'/g)) assert.match(m[1], /^\.{1,2}\//, m[1])
assert.doesNotMatch(code, /€|\bEuro\b/)
assert.doesNotMatch(code, /\b(Sie|Ihre[rnms]?|Ihnen|Ihr)\b/)
})
}
test('beide Module laden unter Node ohne DOM (kein Zugriff beim Import)', async () => {
assert.equal(typeof globalThis.document, 'undefined')
await import('../public/neu/ansicht-funktionen.js')
await import('../public/neu/funktion-einschalten.js')
})
```
- [ ] **Step 2: Fehlschlag prüfen.** Run: `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`. Expected: FAIL (der Stub `funktionen.css` hat nur einen Kommentar: Pillen-Regeln und Klassen fehlen).
- [ ] **Step 3: `public/neu/funktionen.css` schreiben** nach den Vorgaben oben. Jede Farbe als `var(--kn-<name>, <Wert>)`; die Pillen-Klassen heißen `.kn-fn-pille--grau|blau|orange|violett|gruen|rot`. Die Klassenliste ergibt sich aus den zwei JS-Dateien; der Test „jede kn-fn-Klasse“ meldet fehlende.
- [ ] **Step 4: Test laufen lassen.** Run: `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`. Expected: PASS.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/funktionen.css test/knFunktionenStatik.test.js && git commit -m "Funktionen neu: Aussehen (weiß, Pillen, 44-px-Zeilen) mit Quelltext-Prüfung" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 8: Bilder für „Entdecken“ aus Testdaten
**Files:**
- Create: `public/neu/bilder/{news,termine,shop,unterkunft,zeiten}.webp`
- Create (Werkzeug, eigenes Git): `$WZ/fixtures/endpunkte-p3.mjs` (+ ein Import in `fixtures/endpunkte.mjs`, Einträge in `vertrag-endpunkte.mjs` als `NICHT_PRUEFBAR` mit dem Grund „nur Bildquelle“), `$WZ/bilder/erzeuge.mjs`, `$WZ/bilder/seiten/{news,termine,shop,unterkunft,zeiten}.html`
- Test: `test/knFunktionenStatik.test.js` (Bild-Test)
**Interfaces:**
- Consumes: die echten Einbettungen `public/embed/{news,termine,shop,unterkunft,zeiten}.js` mit den Wegen `/api/public/news/<siteKey>` (`embed/news.js:168`), `/api/public/termine/v1/` (`termine.js:12`), `/api/public/shop/v1/` (`shop.js:11`), `/api/public/unterkunft/v1/` (`unterkunft.js:13`), `/api/public/zeiten/v1/<siteKey>` (`zeiten.js:45`) und den Server-Gegenstücken (`server/index.js:77,79,81,94,95`); das Werkzeug aus P0: `run-browser.sh <skript.mjs> [args…]`, `browser.mjs` (`starteBrowser`), `statik-server.mjs` (`starteStatik`), `backend.mjs` (`createBackend`), `fixtures/index.mjs`.
- Produces: fünf Dateien, 960×600, WebP, je höchstens 122880 Byte, benannt nach `katalog[].bild`. Skript: `bash $WZ/run-browser.sh $WZ/bilder/erzeuge.mjs [--ziel <Ordner>] [key …]`. `analyse.webp` entsteht erst mit P4 (VF2); das Skript meldet `übersprungen analyse`, solange P4 keine Werkzeug-Ansicht `kn-modul-analyse` liefert.
Bildquellen (Testdaten „Bäckerei Sonnenschein“, keine Fremdbilder): je Funktion eine eigene schlichte Beispiel-Website-Seite (`bilder/seiten/<key>.html`, nur eigenes HTML und CSS, keine externen Dateien) mit der echten Einbettung aus `public/embed/<key>.js` und dem Platzhalter-`siteKey` aus `public/embed/<key>-demo.html`. Die öffentlichen Endpunkte beantwortet das Fake-Backend des Werkzeugs (`endpunkte-p3.mjs`, Fall `betrieb-wartung`); die Antwortformen stammen aus `server/routes/publicNews.js:67`, `publicTermine.js`, `publicShop.js`, `publicUnterkunft.js`, `publicZeiten.js:69` und den Einbettungs-Skripten, plausibel gefüllt (drei Pinnwand-Zettel, freie Termine eines Tages, sechs Artikel, zwei Unterkünfte mit Belegung, Öffnungszeiten mit Status).
- [ ] **Step 1: Failing Test ergänzen** in `test/knFunktionenStatik.test.js` (die zwei Importzeilen oben einsortieren)
```js
import { statSync, readdirSync } from 'node:fs'
import sharp from 'sharp'
test('Bilder: fünf webp-Dateien, 960×600, höchstens 120 KB; analyse.webp, falls da, ebenso', async () => {
const PFLICHT = ['news', 'termine', 'shop', 'unterkunft', 'zeiten']
const ordner = new URL('../public/neu/bilder/', import.meta.url)
const da = readdirSync(ordner).sort()
for (const d of da) assert.ok([...PFLICHT, 'analyse'].map((k) => `${k}.webp`).includes(d), `unerwartete Datei ${d}`)
for (const key of PFLICHT) assert.ok(da.includes(`${key}.webp`), `${key}.webp fehlt`)
for (const d of da) {
const pfad = new URL(d, ordner).pathname
const meta = await sharp(pfad).metadata()
assert.deepEqual([meta.format, meta.width, meta.height], ['webp', 960, 600], d)
assert.ok(statSync(pfad).size <= 122880, `${d} größer als 120 KB`)
}
})
```
- [ ] **Step 2: Fehlschlag prüfen.** Run: `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`. Expected: FAIL (`ENOENT` für `public/neu/bilder/`).
- [ ] **Step 3: Werkzeug-Teile anlegen** (Dateien im Werkzeug, nicht im Worktree)
- `fixtures/endpunkte-p3.mjs`: `ENDPUNKTE`-Einträge (Form laut P0 Task 6) für die fünf öffentlichen Endpunkt-Gruppen; unbekannte `/api/*` bleiben 404 mit Verstoß.
- `bilder/seiten/<key>.html`: Seite „Bäckerei Sonnenschein“ (Kopf mit Name, ein Absatz Text, darunter die Einbettung), ruhige Akzentfarbe, keine Fremdschriften.
- `bilder/erzeuge.mjs`: startet über `starteStatik({ wurzel: process.env.WT + '/public' })` (`env.sh` des Werkzeugs exportiert `WT`) und genau einen `starteBrowser()` je Schlüssel einen Kontext 1200×750, Gerätefaktor 1, `reducedMotion: 'reduce'`, `setFixedTime`; `route('**/*')` wie in `shots.mjs` (same-origin `/api/` ans `createBackend`, `/bilder-seite/<key>.html` aus `bilder/seiten/`, Fremdes `abort`). Wartet, bis der Ausweichtext der Seite (`#ausweich-inline`, `.static-empty`, `.ersatz`, `#ausweich-shop`) verschwunden ist, sonst Abbruch mit Meldung (die Einbettung hat keine Daten bekommen). PNG-Screenshot (nie `fullPage`), mit `sharp` (Import aus `process.env.WT + '/node_modules/sharp/lib/index.js'`) auf Breite 960 skalieren, als WebP (Qualität 80) schreiben; Größe über 122880 Byte → Abbruch. Schreibt nur `<key>.webp` für die festen Schlüssel in den Ordner `--ziel`, der `bilder` heißen und existieren muss; löscht nichts.
- [ ] **Step 4: Bilder erzeugen und ansehen**
Run:
```bash
WZ=/root/reports/kundenansicht-neu-2026-10-15/werkzeug
export TMPDIR=<eigener Scratch-Ordner>
free -m
mkdir -p /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui/public/neu/bilder
bash "$WZ/run-browser.sh" "$WZ/bilder/erzeuge.mjs" --ziel /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui/public/neu/bilder
ls -l /dev/null
```
Expected: fünf Zeilen `ok <key>.webp <n> KB` und eine Zeile `übersprungen analyse`, Exit 0; `/dev/null` zeigt `crw-rw-rw-`. Danach jede der fünf Dateien mit dem Lesewerkzeug öffnen: Funktion deutlich sichtbar, keine Fehlermeldung, kein Ausweichtext, keine Platzhaltertexte, keine echten Kundendaten.
- [ ] **Step 5: Test laufen lassen und committen.** Run: `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`. Expected: PASS.
```bash
WZ=/root/reports/kundenansicht-neu-2026-10-15/werkzeug
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git add public/neu/bilder/news.webp public/neu/bilder/termine.webp public/neu/bilder/shop.webp public/neu/bilder/unterkunft.webp public/neu/bilder/zeiten.webp test/knFunktionenStatik.test.js && git commit -m "Funktionen neu: Bilder für Entdecken aus Testdaten (Einbettungen auf Beispiel-Website)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
git -C "$WZ" add fixtures/endpunkte-p3.mjs fixtures/endpunkte.mjs vertrag-endpunkte.mjs bilder/erzeuge.mjs bilder/seiten/news.html bilder/seiten/termine.html bilder/seiten/shop.html bilder/seiten/unterkunft.html bilder/seiten/zeiten.html && git -C "$WZ" commit -m "Werkzeug: Bildquellen und Skript für die Funktionen-Bilder" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 9: Werkzeug-Ansichten und Gate `p3` für den Funktionen-Bereich
**Files:**
- Modify (Werkzeug-Git): `$WZ/views.mjs` (Ansichten), `$WZ/gates.mjs` (`GATES.p3`), `$WZ/fixtures/endpunkte-p3.mjs` (`POST /api/funktionen/:key/einschalten`); `$WZ/strang.env` (ungetrackt) zeigt auf `dev-kn-ui`
- Modify (bei Befund): `public/neu/funktionen.css`, `public/neu/ansicht-funktionen.js`, `public/neu/funktion-einschalten.js`
**Interfaces:**
- Consumes: Werkzeug aus P0: `ANSICHTEN`-Einträge `{ id, fall, adresse, geraete, warte, schritte?(seite) }`, `GATES`, `ALWAYS_ON`, `SEITEN_PRUEFUNGEN = ['dark-surface', 'low-contrast', 'overflow-x', 'lila']`, Läufer `shots.sh <ausgabe> --only <ids> --gate <name> --geraete <liste>`, die Fälle `betrieb-wartung`, `funktionen-alle`, `mehrere-websites`, `hosting`, `ohne-abo`, `privat-frist`; `public/neu/dashboard.html` aus P1.
- Produces: die Ansichten der Tabelle (Geräte `handy`, `tablet`, `desktop`; `reducedMotion` ist die Vorgabe) und `GATES.p3 = { checks: [...ALWAYS_ON, ...SEITEN_PRUEFUNGEN], ansichten: ['kn-funktionen-*'] }`. Der Endpunkt `POST /api/funktionen/:key/einschalten` setzt das Feature der Website im `zustand` auf `setup` mit `ticketWoid: '100301'` und antwortet `{ feature, ticketWoid }`.
| id | Fall und Adresse | `warte` / Schritte | Zeigt |
|---|---|---|---|
| `kn-funktionen-meine` | `funktionen-alle`, `/dashboard.html#funktionen` | `li[data-fn]` | alle Zustände, zwei Websites, Pillen |
| `kn-funktionen-entdecken` | `betrieb-wartung`, `#entdecken` | `section[data-fn]` | Einträge mit Bild und Knopf |
| `kn-funktionen-entdecken-hosting` | `hosting`, `#entdecken` | Schritt: Tipp auf den Knopf des Terminplaners | „Im Wartungs-Abo enthalten“, danach Hash `#wechsel` |
| `kn-funktionen-entdecken-ohne-abo` | `ohne-abo`, `#entdecken` | Schritt: Tipp auf den Knopf von „Öffnungszeiten live“ | Hash `#bestellen-wartung` |
| `kn-funktionen-entdecken-privat` | `privat-frist`, `#entdecken` | `section[data-fn="shop"]` | Webshop-Hinweis statt Knopf |
| `kn-funktionen-wahl` | `mehrere-websites`, `#entdecken` | Schritt: Tipp auf den ersten Knopf „Einschalten“ | `[role=group]` „Für welche Website?“ |
| `kn-funktionen-einschalten` | `betrieb-wartung`, `#entdecken` | Schritt: Tipp auf „Öffnungszeiten live“ | Bestätigung ohne Häkchen; `page.goBack()` zeigt wieder Entdecken |
| `kn-funktionen-einschalten-privat` | `privat-frist`, `#entdecken` | Schritt wie oben | Häkchen, Knopf „Einschalten ab TT.MM.“ |
| `kn-funktionen-einschalten-fertig` | `betrieb-wartung`, `#entdecken` | Schritt: Bestätigung, Tipp auf „Einschalten“ | `[role=status]` „… ist eingeschaltet …“, in Meine die Zeile mit „In Einrichtung“ |
| `kn-funktionen-etage` | `betrieb-wartung`, `#funktionen` | Schritt: Tipp auf die Zeile „News-Pinnwand“ | Hash beginnt mit `#news?projekt=`, `#kn-blatt` sichtbar (die Hülle von P1 öffnet die Etage; die Optik des Moduls prüft P4) |
- [ ] **Step 1: Werkzeug lesen.** Run: `head -60 /root/reports/kundenansicht-neu-2026-10-15/werkzeug/README.md; git -C /root/reports/kundenansicht-neu-2026-10-15/werkzeug status --short; cat /root/reports/kundenansicht-neu-2026-10-15/werkzeug/strang.env`. Expected: Anleitung von P0, Git sauber, `strang.env` setzt `WT` auf `dev-kn-ui` (sonst anlegen). `export TMPDIR=<eigener Scratch-Ordner>` setzen (Sicherheitsblock). Weicht das README vom Plan ab, gilt das README; Befund dem Controller melden.
- [ ] **Step 2: Ansichten, Gate und Endpunkt schreiben** (Tabelle oben; die `schritte` prüfen mit `assert` die Spalte „Zeigt“, Vorbild `kn-huelle-*` aus P1 Task 13)
- [ ] **Step 3: Erster Lauf (rot ist möglich) und Sichtprüfung**
Run:
```bash
WZ=/root/reports/kundenansicht-neu-2026-10-15/werkzeug
export TMPDIR=<eigener Scratch-Ordner>
free -m
bash "$WZ/shots.sh" "$TMPDIR/kn-p3" --gate p3 --only 'kn-funktionen-*'
```
Expected: Bericht in `$TMPDIR/kn-p3/report.json`. Danach die Aufnahmen für Handy, Tablet und Desktop mit dem Lesewerkzeug ansehen und gegen die Spalte „Zeigt“ prüfen: keine Zeile unter 44 px Höhe, Pille und Name ohne Abschneiden, Entdecken ohne Rahmen und ohne Schatten, Bild im Verhältnis 16:10, Bestätigung ohne Häkchen beim Betrieb.
- [ ] **Step 4: Korrigieren und Tests wiederholen.** Befunde im Worktree beheben (CSS oder Module). 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/knEinschaltenLogik.test.js test/knFunktionenModell.test.js test/knEinschaltenSeite.test.js test/knFunktionenAnsicht.test.js test/knFunktionenStatik.test.js`. Expected: PASS.
- [ ] **Step 5: Gate grün, Commit.** Run: `export TMPDIR=<eigener Scratch-Ordner>; bash /root/reports/kundenansicht-neu-2026-10-15/werkzeug/shots.sh "$TMPDIR/kn-p3" --gate p3 --only 'kn-funktionen-*'; ls -l /dev/null`. Expected: Exit 0, keine Verstöße; `/dev/null` zeigt `crw-rw-rw-`.
```bash
WZ=/root/reports/kundenansicht-neu-2026-10-15/werkzeug
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git status --short
git add public/neu/funktionen.css && git commit -m "Funktionen neu: Korrekturen aus den Gates (Handy, Tablet, Desktop)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
git -C "$WZ" add views.mjs gates.mjs fixtures/endpunkte-p3.mjs && git -C "$WZ" commit -m "Werkzeug: Ansichten und Gate p3 für den Funktionen-Bereich" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
(Nur die in diesem Task wirklich geänderten Worktree-Dateien einzeln hinzufügen; gab es keine Korrekturen, entfällt der erste Commit.)
---
### Task 10: Selbstprüfung
**Files:**
- keine Änderung (nur Prüfung); Tag `kn-p3`
- [ ] **Step 1: Alle eigenen Tests grün.** Run (einzeln, damit ein Fehler eindeutig zuzuordnen ist):
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui
for t in knEinschaltenLogik knFunktionenModell knEinschaltenSeite knFunktionenAnsicht knFunktionenStatik; do
timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/$t.test.js || break
done
```
Expected: fünfmal PASS, keine übersprungenen Tests.
- [ ] **Step 2: Gegenprobe an den Nachbarn** (Quelltext-Pins über `public/`, Stubs, Hülle). Run:
```bash
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/adminPanelGone.test.js test/termineView.test.js test/shopView.test.js test/shopWiring.test.js test/zeitenView.test.js test/knHuelleSeite.test.js test/knHuelleStart.test.js
```
Expected: PASS. Schlägt eine Datei fehl, liegt es an einer Datei in `public/neu/` (etwa einer verbotenen Zeichenkette aus `adminPanelGone`): in P3 beheben, den Test nicht ändern.
- [ ] **Step 3: Verträge abhaken** (jeweils mit Fundstelle im Code oder Test)
- [ ] V2: `zeigeFunktionen(wurzel, { route, me, nav })` (kein `zaehler`; `command grep -a -n "zaehler" public/neu/ansicht-funktionen.js` bleibt leer); Ziel `#<etage>?projekt=<id>` (Test „Meine“); die Etage selbst öffnet P1 (Werkzeug-Ansicht `kn-funktionen-etage`)
- [ ] V4: leere oder unbekannte Kundenart gilt als privat; der Client sendet nie `kundenart` (Tests „beginnLage“, „einschaltKoerper“, „Häkchen nie für Betrieb“)
- [ ] V5: Katalogreihenfolge, `verfuegbar:false` ausgeblendet, `modell:'abo'`, alle sieben 409-Codes (Test „fehlerAktion“), Körper `{ projectId, zustimmung: { version, haken } }`, Analyse nie über `…/einschalten`
- [ ] V11: `sessionStorage['kn-wechsel-funktion']` mit `{ key, projectId }` in try/catch, danach `#wechsel`; `subscription.wechselFunktion` öffnet den Ablauf (Tests „navigiere“, „wechselFunktion“)
- [ ] Spec 5.3: Zeile „Rechnung offen“ oben in Meine und Entdecken, von der Ansicht selbst über `GET /api/invoices` geladen (Test „Rechnungszeile oben“: Abruf, nur `balance > 0`, Ausfall lässt die Liste stehen)
- [ ] Global Constraints: keine Preise, „du“, keine Karten und Verläufe (Statik-Test), Bildformat, Hashes unverändert
- [ ] VF1: `command grep -a -n "einschalten" public/neu/huelle.js` zeigt, ob die Hülle `zeigeEinschalten` als Init-Option übergibt; wenn nicht, dem Controller melden (Datei gehört P1)
- [ ] Vertragsfragen VF1 bis VF5 an den Controller übergeben
- [ ] **Step 4: Zustand prüfen.** Run:
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui
git status --short
git diff --name-only kn-p2 HEAD
git -C /root/reports/kundenansicht-neu-2026-10-15/werkzeug status --short
ls -l /dev/null
```
Expected: `git status` leer (der Symlink `node_modules` ist ignoriert); `git diff --name-only` zeigt genau die Worktree-Dateien der Dateiübersicht (`funktion-einschalten.js`, `ansicht-funktionen.js`, `funktionen.css`, fünf Bilder, `knFunktionenDaten.js`, fünf Testdateien); das Werkzeug-`git status` ist leer (`strang.env` ist ignoriert); `/dev/null` zeigt `crw-rw-rw-`.
- [ ] **Step 5: Tag setzen**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git tag kn-p3 && git log --oneline -12
```
Expected: Tag angelegt, das Log zeigt die Commits der Tasks 1 bis 9. Kein Push.
---
## Vertragsfragen an den Controller
Die Pläne der anderen Pakete liegen vor und sind eingearbeitet. Offen bleibt:
- **VF1 Verdrahtung des Einschalt-Ablaufs (P1, P4, P3).** P4 erwartet `zeigeEinschalten(platz, { key, projectId, onFertig })` als Init-Option `einschalten` an `initTermine`, `initNews`, `initShop`, `initBuchungen`, `initZeiten` (P4 VF1: „Die Hülle übergibt die Funktion“). P1 Task 11 ruft die Module aber nur als `init*(wurzel)` auf. P3 liefert die Funktion (Task 4) und darf `huelle.js` nicht ändern. Vorschlag: P1 ergänzt in `huelle.js` den Aufruf `init*(wurzel, { einschalten: (platz, o) => import('/neu/funktion-einschalten.js').then((m) => m.zeigeEinschalten(platz, o)) })` (lazy, weil P1 vor P3 liegt). Fehlt das beim Merge, bleiben die Module im Abo-Modell auf der Fehlerkarte E4 aus P4.
- **VF2 Bild der Analyse (P4).** `analyse.webp` zeigt das Analyse-Modul, dessen Optik und Testdaten erst P4 liefert. Vorschlag: P4 legt die Werkzeug-Ansicht `kn-modul-analyse` an, erzeugt die Datei mit `erzeuge.mjs analyse` und committet nur `public/neu/bilder/analyse.webp`. Bis dahin entfernt der `error`-Handler in „Entdecken“ das fehlende Bild (Test „fehlendes Bild“). Der Bild-Test von P3 akzeptiert `analyse.webp`, verlangt sie aber nicht.
- **VF3 Werkzeug (P0).** (a) `fixtures/endpunkte-p3.mjs` ergänzt `POST /api/funktionen/:key/einschalten` und die fünf öffentlichen Bild-Endpunkte; das README von P0 sieht dieses Muster vor. (b) `SEITEN_PRUEFUNGEN` heißt in P0 `dark-surface`, `low-contrast`, `overflow-x`, `lila`; P1 nimmt zusätzlich `bad-text` und `icon-svg` an. P3 folgt P0. (c) `strang.env` muss auf `dev-kn-ui` zeigen.
- **VF4 Weitere Antwortcodes der Einschalt-Route (P5).** P5 nennt zusätzlich `bestellung_laeuft`, `abgeschaltet`, 400 `zustimmung_fehlt`, 503 `unterlagen_fehlen`. P3 zeigt dazu den Text des Servers (Test „fehlerAktion“). Bitte in V5 aufnehmen (P5 VF3 fragt dasselbe).
- **VF5 Overlay (P1) und Rechnungszeile (P2).** P3 nutzt `oeffneOverlay`/`beendeOverlay` aus `router.js` für die Bestätigungsseite (P1 Task 4, R6, dort für das Konto-Sheet gedacht). Bitte bestätigen, dass `oeffneOverlay` aus Ansichten heraus gerufen werden darf und nie zwei Overlays zugleich offen sind. Die Zeile „Rechnung offen“ braucht von P1 nichts und V2 bleibt unverändert: Die Ansicht lädt `GET /api/invoices` selbst und nutzt `offeneRechnungen` und `rechnungOffenZeile` aus P2 Task 3 (wie P2 Task 6 für die Website). `ladeOffeneRechnungen()` bleibt ungenutzt, weil es keine `api` entgegennimmt; gibt P2 ihm einen optionalen Parameter `ladeOffeneRechnungen(apiFn = api)`, ersetzt P3 `ladeRechnungen` durch einen Aufruf.