# 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=`) ö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=` 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/.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/.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..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 .'`; `bild: '/neu/bilder/.webp'`; `unterlagen: { leistungsbeschreibung: { version:'2026-10-05', url:'/rechtliches/leistungsbeschreibung--2026-10-05.html' }, avv: { version:'2026-10-05', url:'/rechtliches/avv--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 " ``` --- ### 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 ` nur mit `activatedAt`, `Einrichtung läuft · Ticket #` 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 " ``` --- ### 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 }, 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` = ` 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: '' } }, projekte: [projekt('p1', 'Café'), 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), / einschalten/) assert.match(fx.text(b), /Website.*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 " ``` --- ### 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 → " 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//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 " ``` --- ### 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) →

| 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--`; Entdecken: `section.kn-fn-eintrag[data-fn]` mit `img.kn-fn-bild` (`src` = `eintrag.bild`, `alt` = `Beispiel: `, `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: 'News', beschreibung: 'x' } }, projekte: [projekt('p1', 'Café')] }) }) 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), /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 " ``` --- ### 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 " ``` --- ### 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--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-, )`; 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 " ``` --- ### 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/` (`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/` (`zeiten.js:45`) und den Server-Gegenstücken (`server/index.js:77,79,81,94,95`); das Werkzeug aus P0: `run-browser.sh [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 ] [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/.html`, nur eigenes HTML und CSS, keine externen Dateien) mit der echten Einbettung aus `public/embed/.js` und dem Platzhalter-`siteKey` aus `public/embed/-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/.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/.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 `.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= 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 .webp 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 " 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 " ``` --- ### 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 --only --gate --geraete `, 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=` 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= 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=; 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 " 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 " ``` (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 `#?projekt=` (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.