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