Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-10-04-oeffnungszeiten-live.md
T

1037 lines
81 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Öffnungszeiten live – Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Die Funktion „Öffnungszeiten live“ im Kundenportal: Zeitpläne im Portal pflegen, Live-Status/Tabelle/Hinweis-Leiste/Kontakt-Leiste/JSON-LD per Skript auf der Kundenseite, Bestellung (30 €) oder Einschalten im Wartungs-Abo, Abo-Kopplung, Feiertags-Erinnerung, WOMS-Anbindung und eine Station auf der Relaunch-Seite.
**Architecture:** Eigenes Modul `server/services/zeiten/` auf dem Funktions-Register des Terminplaner-TP2-Branchs. Der Server rechnet alle Zeiten (Europe/Berlin, Feiertage) und liefert über eine öffentliche, gecachte GET-Schnittstelle fertige Texte und Wechselzeitpunkte; das abhängigkeitsfreie Skript `public/embed/zeiten.js` zeigt sie nur an. Der Preview-Runner setzt das Skript beim Deploy ein; das Team baut Platzhalter per WOMS-Ticket ein.
**Tech Stack:** Node 20 (Produktion; Host Node 24), Express, Vanilla-ES-Module ohne Build, Appwrite (`node-appwrite`), Stripe, `node:test`, Playwright-core (UI-Prüfungen), `feiertagejs` 1.5.1; WOMS: React 18 + Vite; Relaunch: React + Vite + Vitest 3.
**Spec:** `docs/superpowers/specs/2026-10-04-oeffnungszeiten-live-design.md` (Entscheidungen Z1–Z23). Anschluss-Fakten: `/root/reports/funktionen-ultra-update-2026-10-04/plan-anschluesse.md`.
## Global Constraints
- Arbeitsorte: Portal `/home/webklar/apps/kundenbereich.webklar.com/dev-zeiten` (Branch `feature/oeffnungszeiten`); WOMS eigener Worktree `/home/webklar/apps/ticket.webklar.com/dev-zeiten` auf `feature/terminplaner-tp2` (Task 31); Relaunch eigener Worktree `/home/webklar/apps/webklar.com/dev-zeiten-station` (Task 34). **Nie** in `dev-termine` (beide Repos) oder `build/` schreiben, **nie pushen**, nie `git add -A` (nur die genannten Dateien).
- Produktion läuft Node 20: keine Syntax/APIs jenseits Node 20. Runner-Code nur Node-20-Syntax ohne Abhängigkeiten.
- `server/config.js` importiert nichts statisch aus `./services/` (Test `featureRegistry.test.js` prüft per Regex); `siteFeatures.js` importiert `zeiten/*` nur per `await import()`.
- Sperren (`withLocks`) sind nicht wiederbetretbar. Schlüssel: `feature-order:<customerId>:<projectId>` (Einschalten/Checkout), `feature-admin:<projectId>` (Team, Abgleich), `zeiten:<projectId>` (Speichern), `zeiten-neubau` (Neubau anstoßen). T19 (`meldeErstattung`) nie innerhalb `feature-admin:`/`feature-paid:`.
- Werte aus der Spec, wörtlich: Preis `3000` Cent, Pläne ≤ 5, Fenster/Tag ≤ 2, Ausnahmen ≤ 100, Hinweise ≤ 20, Hinweistext ≤ 140, Plan-/Ausnahmetext ≤ 60, Knopftext ≤ 30, Starttext ≤ 200, Adresse ≤ 200, Telefon ≤ 30, Link ≤ 300, `navSelektor` ≤ 120, `daten` ≤ 32 KB, 60 Speichervorgänge/Stunde, API `max-age=60`, Speichercache 30 s als LRU ≤ 500, 120 Anfragen/Minute je IP, Skript lädt ≤ alle 10 min neu, Daten > 24 h alt ⇒ aus, Uhrabweichung > 5 min ⇒ Serverzeit, Erinnerung 1–7 Tage vorher zwischen 08:00 und 20:00 Berlin, Frist 30 Tage + Datenfrist 30 Tage, Kontakt-Leiste nur `(max-width: 768px)` und `z-index: 40`, Tippflächen ≥ 44 px, Kontrast ≥ 4,5:1.
- Texte: Kundentexte in Du-Form (Mails wie News: „Du“ groß), Rechtstexte in Sie-Form. Status-Texte ohne Anrede und **nie „24 Uhr“**. Die Schreibweise ist „WEBklar“.
- Website-Skript: keine Cookies, kein Storage, keine Anfragen an Dritte, kein `https://`-Literal, `Date` nur als `Date.now(` und `Date.parse(`, Kundentexte nur per `textContent`.
- Neue Dateien heißen `zeiten*` (nie `termine*`); Tests `test/zeiten<Thema>.test.js`, Testnamen deutsch. Einzeltest: `node --test test/<datei>.test.js`; alles: `npm test`. Bekannt wackelig/rot in der Basis: `test/previewDevRouter.test.js` (flaky), `test/analyticsContract.test.js` (Node 20).
- Commits: deutsche Betreffzeile, Abschluss `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- Abweichung von Spec 2.3 (bewusst): Die neuen `portalFeatures`-Felder legt **`scripts/zeiten-setup.mjs`** an (nicht `feature-setup.mjs`, dessen Tests genau 3 Attribute pinnen); `test/support/schema.js` hängt sie an `schemaKeys('portalFeatures')` an.
- Abweichung von Spec 7 (bewusst): Der K02/K03-Block `funktionen` ist Rechtstext mit Pins; er wird nur als Entwurf für die Rechtsrunde geschrieben (Task 33), nicht im Code geändert.
## Review Focus
1. **Feiertage über den Jahreswechsel:** Die 12-Monats-Liste im Editor und die 60-Tage-Auflösung müssen Feiertage aus zwei Kalenderjahren liefern (Ende Dezember bis Anfang Januar). → Test in Task 3 und Task 4.
2. **Telefonnummern in allen üblichen Schreibweisen:** „0171 1234567“, „+49 171 1234567“, „0049171…“, „(06 31) 12 34“ müssen dieselbe `tel:`/`wa.me`-Nummer ergeben; Buchstaben werden abgelehnt. → Test in Task 8.
3. **Kontakt-Leiste mit Teildaten:** Nur Telefon ⇒ ein Knopf; nur Adresse ⇒ nur Route; `an: true` ohne jede Angabe ⇒ keine Leiste (kein leerer Balken). → Test in Task 11 (Antwort) und Task 26 (Skript).
4. **Plan löschen, der auf der Website steht:** Ein gelöschter Plan lässt Platzhalter mit seinem Kurznamen beim Ersatz-HTML stehen; der Editor warnt vor dem Löschen („Steht dieser Plan auf deiner Website, zeigt sie dort wieder die feste Tabelle“). → Test in Task 29.
5. **Zu viele Daten:** Überschreitet `daten` 32 KB, kommt eine verständliche Feldmeldung statt eines 500/413. → Test in Task 8.
---
## Block A – Grundlagen
### Task 1: Arbeitsumgebung, Schalter, Collections und Felder
**Files:**
- Create: `server/services/zeiten/schema.js`, `server/services/zeiten/schalter.js`, `scripts/zeiten-setup.mjs`
- Modify: `server/config.js` (`collections`, neuer Block `zeiten`, `zeitenSetup`), `test/support/schema.js`
- Test: `test/zeitenSetup.test.js`, `test/zeitenSchalter.test.js`
**Interfaces:**
- Produces: `ZEITEN_COLLECTIONS` (`{ oeffnungszeiten: {attributes, indexes}, zeitenErinnerungen: {...} }` im Format von `server/services/termine/schema.js`), `ZEITEN_FEATURE_ATTRIBUTES` (Attributliste für `portalFeatures`), `zeitenModus() → 'off'|'pilot'|'on'`, `zeitenAus() → boolean`, `zeitenFuerKunde(email) → boolean`; `config.zeiten = { mode, pilotEmails, keinNeubau }`, `config.zeitenSetup`, `config.collections.oeffnungszeiten`, `config.collections.zeitenErinnerungen`; `schemaKeys('oeffnungszeiten' | 'zeitenErinnerungen')`.
- [ ] **Step 1: Abhängigkeiten installieren und Basislauf festhalten**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-zeiten && free -m && nice npm ci && (cd runner && npm test) && nice npm test 2>&1 | tail -5`
Expected: Installation ok; Basis grün bis auf die zwei bekannten Kandidaten (siehe Global Constraints). Abweichungen im Ledger notieren.
- [ ] **Step 2: Failing tests schreiben**
```js
// test/zeitenSchalter.test.js (Muster: test/termineSchalter.test.js, freshConfig)
test('Vorgabe ist aus', …) // ZEITEN_ENABLED leer ⇒ zeitenModus()==='off', zeitenAus()===true
test('pilot gilt nur für die Pilotliste', …) // mode 'pilot', pilotEmails ['a@b.de'] ⇒ zeitenFuerKunde('A@b.de')===true, ('x@y.de')===false
test('on gilt für alle', …)
test('keinNeubau kommt aus ZEITEN_KEIN_NEUBAU, Vorgabe emmas-schatzkiste', …) // deepEqual(config.zeiten.keinNeubau, ['emmas-schatzkiste'])
test('zeitenSetup: 3000 Cent einmalig', …) // config.zeitenSetup.amountCents===3000, oneTime===true, lookupKey==='webklar_zeiten_einrichtung_3000'
// test/zeitenSetup.test.js (Muster: test/termineSetup.test.js)
test('oeffnungszeiten hat die Felder aus Spec 2.1', …) // keys ⊇ customerId, projectId, featureId, version, daten, teamVersion, team, ersatzHash, updatedAt, updatedBy
test('daten fasst 32 KB JSON', …) // Größe von daten ≥ 40000
test('zeitenErinnerungen: featureId, datum, createdAt', …)
test('portalFeatures bekommt unlockedBy(8), aboEndeAt, einbau(12), behaltenSessionId(128), k24At', …)
test('--check meldet fehlende Collections und Felder, ohne zu schreiben', …)
test('schemaKeys liefert die Zeiten-Collections und die neuen portalFeatures-Felder', …)
```
- [ ] **Step 3: Run → FAIL** (`node --test test/zeitenSchalter.test.js test/zeitenSetup.test.js`, Module fehlen)
- [ ] **Step 4: Implementieren**
- `config.js`: `zeitenSetup = { key:'zeitenSetup', name:'Öffnungszeiten live – Einrichtung', amountCents:3000, lookupKey:'webklar_zeiten_einrichtung_3000', oneTime:true, priceNickname:'Öffnungszeiten-Einrichtung 30 €' }` (exportiert wie `termineSetup`); `zeiten: { mode, pilotEmails, keinNeubau }` aus `ZEITEN_ENABLED`, `ZEITEN_PILOT_EMAILS`, `ZEITEN_KEIN_NEUBAU` (Komma-Liste, Vorgabe `'emmas-schatzkiste'`), als Geschwister von `config.termine` (nicht darin: `termineSchalter.test.js` pinnt `config.termine`); `collections.oeffnungszeiten`/`zeitenErinnerungen` mit `APPWRITE_COLLECTION_…`-Variablen.
- `schalter.js`: Kopie von `termine/schalter.js` auf `config.zeiten`.
- `schema.js`: reine Daten, kein `config`-Import; `daten` String 40000, `team` String 2000, `ersatzHash` String 64, `updatedBy` String 64, `version`/`teamVersion` Integer; Index `idx_customer` auf `customerId` (Löschung), Erinnerungen Index `idx_datum`.
- `zeiten-setup.mjs`: Klon von `scripts/termine-setup.mjs` (`main({check, client, log})`), legt beide Collections und `ZEITEN_FEATURE_ATTRIBUTES` an `portalFeatures` an (idempotent).
- `test/support/schema.js`: Zweig für `ZEITEN_COLLECTIONS`; bei `portalFeatures` zusätzlich `ZEITEN_FEATURE_ATTRIBUTES`.
- [ ] **Step 5: Run → PASS**, dann `npm test` (Pins unverändert grün).
- [ ] **Step 6: Commit** `git add server/services/zeiten/schema.js server/services/zeiten/schalter.js scripts/zeiten-setup.mjs server/config.js test/support/schema.js test/zeitenSetup.test.js test/zeitenSchalter.test.js && git commit -m "Öffnungszeiten: Schalter, Collections und Felder"`
### Task 2: Register-Eintrag `zeiten` und Stripe-Katalog
**Files:**
- Modify: `server/config.js` (`features.zeiten`), `server/services/featureRegistry.js` (`STANDARD_HAKEN.zeiten`, `freiweg`), `server/services/siteFeatures.js` (`FEATURE_KEYS`), `server/services/stripeBilling.js` (`PRODUCT_METADATA`, `catalogFor`), `scripts/stripe-products.mjs` (`KEYS`, `catalogEntry`)
- Create: `server/services/zeiten/ticketDetails.js` (vorläufig, ausgebaut in Task 14)
- Test: `test/zeitenRegister.test.js`; Pins anpassen in `test/featureRegistry.test.js`, `test/siteFeatures.test.js`, `test/featureFreischaltung.test.js`, `test/funktionenTermine.test.js`, `test/funktionenRoutes.test.js`, `test/stripeProductsScript.test.js`, `test/featureMails.test.js` (läuft automatisch über alle Einträge)
**Interfaces:**
- Consumes: `zeitenFuerKunde` (Task 1).
- Produces: `featureConfig('zeiten')` mit den Feldern unten; `freiweg(feature) → { ...feature, consent: feature.consentFrei }`; `STANDARD_HAKEN.zeiten = { verfuegbar, unterlagenBereit, belehrung, freischaltbar }` (freischaltbar zunächst `async () => true`, echt in Task 17); `zeitenTicketDetails(ctx) → Promise<string>`.
- [ ] **Step 1: Failing tests schreiben**
```js
// test/zeitenRegister.test.js
test('zeiten steht im Register mit 30 € und Abo-Inklusive', …)
// const f = featureConfig('zeiten'); f.product==='zeitenSetup'; f.priceLabel==='30 €'; f.requiresWartungsAbo===false;
// f.aboInklusive===true; f.ticketFreischaltung===true; deepEqual(f.teilweiseLive, ['setup','active']); f.returnHash==='zeiten'; f.returnProjekt===true
test('Zustimmung bezahlt: zwei Haken, Knopf „Zahlungspflichtig bestellen · 30 €“, § 356 Abs. 5 Nr. 2', …)
test('Zustimmung gratis: ein Haken, Knopf „Einschalten“, kein „zahlungspflichtig“', …) // doesNotMatch(JSON.stringify(f.consentFrei), /zahlungspflichtig/i)
test('freiweg ersetzt nur consent', …) // freiweg(f).consent === f.consentFrei; freiweg(f).product === f.product
test('verfuegbar folgt ZEITEN_ENABLED', …) // mode 'off' ⇒ featureSperre('zeiten','a@b.de') === f.unavailableMessage; mode 'on' ⇒ null
test('oeffentlicheAnsicht(zeiten) hat dieselben Schlüssel wie news', …) // kein aboInklusive/consentFrei darin
test('Stripe-Katalog: zeitenSetup mit eigener Metadaten-Marke', …) // PRODUCT_METADATA.zeitenSetup === 'zeiten-einrichtung'
```
- [ ] **Step 2: Run → FAIL**
- [ ] **Step 3: Implementieren** – Eintrag in `config.features` nach `termine` (Feldsatz wie `news`, Werte):
- `label: { name:'Öffnungszeiten live', kurz:'Zeiten', keine:'keine Öffnungszeiten live' }`, `setupEstimate:'meist innerhalb weniger Tage'`.
- `consent: { version:'2026-10-04', knopf:'Zahlungspflichtig bestellen · 30 €', items:[{ id:'leistung', haken:true, text:'Ich habe die Leistungsbeschreibung „Öffnungszeiten live“ gelesen und bestelle die Einrichtung.' }, { id:'beginn', haken:true, text:'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit vollständiger Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB).' }] }`.
- `consentFrei: { version:'2026-10-04', knopf:'Einschalten', items:[{ id:'leistung', haken:true, text:'Ich habe die Leistungsbeschreibung „Öffnungszeiten live“ gelesen und schalte die Funktion ein. Sie ist in meinem Wartungs-Abo ohne Aufpreis enthalten.' }] }`.
- `leistungsbeschreibung: { version:'2026-10-04', url:'/vertragsunterlagen/zeiten-leistungsbeschreibung.html' }`, `avv:null`, `widerruf`: wie beim `termine`-Eintrag.
- `ticket.topic: (project) => \`Öffnungszeiten live einbauen – ${ticketProjektName(project)}\`.trim()`, `ticket.details: async (ctx) => (await import('./services/zeiten/ticketDetails.js')).zeitenTicketDetails(ctx)`.
- `mails: { funktion:'Öffnungszeiten live', formen:{ dein:'Deine Öffnungszeiten live', nom:'Öffnungszeiten live', akk:'Öffnungszeiten live', gen:'von Öffnungszeiten live', pron:'sie' }, knopf:'Zeiten bearbeiten', danach:'Danach kannst Du Deine Zeiten im Portal eintragen.', bereit:'Deine Öffnungszeiten stehen ab sofort live auf Deiner Seite.', preisHinweis:'einmalig, ohne Umsatzsteuer nach § 19 UStG; die Nutzung ist kostenlos, es läuft kein Abo', anleitung:false }`.
- `disabledMessage:'Öffnungszeiten live wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du die Funktion wieder möchtest.'`, `orderedConflictMessage:'Öffnungszeiten live ist für dieses Projekt schon bestellt oder eingeschaltet.'`, `unavailableMessage:'Öffnungszeiten live ist noch nicht verfügbar.'`.
- `FEATURE_KEYS` += `'zeiten'`; Stripe: `PRODUCT_METADATA.zeitenSetup = 'zeiten-einrichtung'`, `catalogFor`/`catalogEntry`/`KEYS` ergänzen.
- `ticketDetails.js`: vorläufig `minimaleTicketDetails(ctx)` aus `featureRegistry.js` zurückgeben.
- [ ] **Step 4: Pins anpassen** (Schlüssellisten um `'zeiten'`; Zählungen), Run `npm test` → PASS.
- [ ] **Step 5: Commit** `"Öffnungszeiten: Register-Eintrag und Stripe-Katalog"`
## Block B – Zeitlogik
### Task 3: Feiertage mit Namen und „bitte prüfen“
**Files:**
- Modify: `server/services/termine/feiertage.js` (nur neue Exporte)
- Test: `test/zeitenFeiertage.test.js`
**Interfaces:**
- Produces: `feiertagName(tag, land) → string|null` (wirft nie); `feiertageImZeitraum(von, bis, land) → [{ tag, name, art: 'feiertag'|'pruefen' }]` (inklusive Grenzen, sortiert, über Jahresgrenzen).
- [ ] **Step 1: Failing tests**
```js
test('Tag der Deutschen Einheit hat einen Namen', …) // feiertagName('2026-10-03','RP') === 'Tag der Deutschen Einheit'
test('normaler Tag hat keinen Namen', …) // feiertagName('2026-10-05','RP') === null
test('Heiligabend und Silvester sind „pruefen“', …) // feiertageImZeitraum('2026-12-20','2027-01-02','RP') enthält {tag:'2026-12-24',art:'pruefen'}, {tag:'2026-12-31',art:'pruefen'}, {tag:'2027-01-01',art:'feiertag'}
test('Jahreswechsel: beide Jahre werden geliefert', …) // tags sortiert, enthält 2026-12-25, 2026-12-26 und 2027-01-01
test('Mariä Himmelfahrt in BY ist „pruefen“, im SL „feiertag“', …) // '2028-08-15': BY ⇒ pruefen, SL ⇒ feiertag
test('Fronleichnam in SN und TH ist „pruefen“ (Datum aus RP)', …) // '2026-06-04' in SN ⇒ {art:'pruefen'}, RP ⇒ 'feiertag'
test('ungültiges Land wirft nicht', …) // feiertagName('2026-10-03','XX') === null
```
- [ ] **Step 2: Run → FAIL**
- [ ] **Step 3: Implementieren** über `feiertagejs.getHolidays(jahr, land)` je Jahr im Zeitraum (nicht `istFeiertag` je Tag), Namen per `holiday.translate('de')`, Datum per `holiday.dateString`. „pruefen“: 24.12., 31.12., `MARIAHIMMELFAHRT` nur für BY, Fronleichnam für SN/TH aus der RP-Liste. Ein „pruefen“-Tag, der zugleich landesweiter Feiertag ist, bleibt `feiertag`.
- [ ] **Step 4: Run → PASS**, `node --test test/termine*.test.js` unverändert grün.
- [ ] **Step 5: Commit** `"Feiertage: Namen und Prüftage im Zeitraum"`
### Task 4: Zeitlogik I – Tagesbild und Öffnungsintervalle
**Files:**
- Create: `server/services/zeiten/zeitlogik.js`
- Test: `test/zeitenZeitlogik.test.js`
**Interfaces:**
- Consumes: `termine/zeit.js` (`zuUtc`, `ausUtc`, `wochentag`, `plusTage`, `heute`), Task 3.
- Produces (alle rein, `now` immer als Parameter):
- `hauptplan(daten) → plan|null`; `planNachKurz(daten, kurz) → plan|null`
- `zuUtcOderDanach(tag, hhmm) → Date` (März-Lücke ⇒ erste existierende Minute danach, 03:00; `'24:00'` ⇒ Folgetag 00:00)
- `tagesbild(daten, plan, tag) → { tag, art: 'regulaer'|'feiertag'|'urlaub'|'sondertag', name: string|null, modus, fenster }`
- `oeffnungsIntervalle(daten, plan, vonTag, bisTag) → [{ vonMs, bisMs }]` (zusammengefügt; enthält das in `vonTag` hineinlaufende Fenster des Vortags)
- [ ] **Step 1: Failing tests** (Fixture-Helfer `plan({1:['09:00','18:00'], …})`, `daten({land:'RP', plaene, ausnahmen, feiertage})`)
```js
test('Wochenplan an einem normalen Montag', …) // tagesbild(d,p,'2026-10-05') ⇒ {art:'regulaer', modus:'zeiten', fenster:[['09:00','18:00']]}
test('Feiertag ist zu, mit Namen', …) // '2026-10-03' RP ⇒ {art:'feiertag', name:'Tag der Deutschen Einheit', modus:'zu'}
test('Feiertags-Abweichung gilt', …) // daten.feiertage['2026-10-03']={modus:'zeiten',fenster:[['10:00','14:00']]} ⇒ modus 'zeiten'
test('Prüftag folgt dem Wochenplan', …) // '2026-12-24' (Do) ⇒ art 'regulaer', Fenster des Donnerstags
test('Urlaub schlägt Feiertag, Sondertag schlägt Urlaub', …)
test('planbezogene Ausnahme schlägt globale gleicher Art', …)
test('zwei überlappende Urlaube: der später angelegte gilt', …)
test('Urlaub über den Jahreswechsel', …) // von '2026-12-28' bis '2027-01-03' ⇒ '2027-01-02' art 'urlaub'
test('Fenster über Mitternacht gehört zum Starttag', …) // Sa 18:00–02:00, So Feiertag ⇒ Intervall Sa 18:00 bis So 02:00 Berliner Zeit
test('rund um die Uhr an zwei Tagen wird ein Intervall', …) // Mo+Di 'rund' ⇒ ein Intervall Mo 00:00 bis Mi 00:00
test('März-Lücke rückt auf 03:00', …) // zuUtcOderDanach('2026-03-29','02:30').toISOString()==='2026-03-29T01:00:00.000Z'
test('Oktober: doppelte Stunde nimmt das erste Vorkommen', …) // zuUtcOderDanach('2026-10-25','02:30') === 00:30Z
test('nach Vereinbarung erzeugt kein Intervall', …)
```
- [ ] **Step 2: Run → FAIL**
- [ ] **Step 3: Implementieren** nach Spec 6 (Vorrang Sondertag > Urlaub > Feiertag > Wochenplan; planbezogen vor global; bei Gleichstand die später angelegte = höherer Index in `ausnahmen`). Kalendertage nur mit `plusTage`, nie `+86400000`.
- [ ] **Step 4: Run → PASS**
- [ ] **Step 5: Commit** `"Öffnungszeiten: Tagesbild und Öffnungsintervalle"`
### Task 5: Zeitlogik II – Status, Texte und Wechsel
**Files:**
- Modify: `server/services/zeiten/zeitlogik.js`
- Test: `test/zeitenZeitlogik.test.js` (erweitern)
**Interfaces:**
- Produces: `uhrText(hhmm) → string`; `statusZu(daten, plan, now) → { offen: true|false|null, text, kurz }`; `wechselListe(daten, plan, now) → [{ ab, tag, offen, text, kurz }]` (8 Tage; erster Eintrag `ab` = Beginn der aktuellen Minute; ein Eintrag an jeder Berliner Mitternacht, an der sich der Text ändert; `tag` = Berliner Datum bei `ab`); `tageListe(daten, plan, vonTag, anzahl) → [{ datum, art, name, modus, fenster }]`.
- [ ] **Step 1: Failing tests** (`JETZT = new Date('2026-10-05T08:00:00.000Z')` = Mo 10:00)
```js
test('uhrText', …) // '09:00'⇒'9 Uhr', '18:30'⇒'18:30 Uhr', '00:00'⇒'Mitternacht'
test('offen: schließt um 18 Uhr', …) // text==='Jetzt geöffnet · schließt um 18 Uhr', kurz==='Geöffnet', offen===true
test('Schluss um Mitternacht', …) // Fenster 18:00–00:00 ⇒ '… schließt um Mitternacht'
test('Schluss > 24 h: „bis Dienstag 14 Uhr“', …)
test('7 Tage durchgehend: „Rund um die Uhr geöffnet“', …)
test('zu: heute / morgen / Wochentag / Datum', …) // '… öffnet heute um 14 Uhr', 'morgen um 9 Uhr', 'Montag um 9 Uhr', 'am 18.08. um 9 Uhr'
test('keine Öffnung in 60 Tagen: „Derzeit geschlossen“', …)
test('Grund an geschlossenen Tagen', …) // 'Heute geschlossen (Betriebsurlaub bis 17.08.)', '(Feiertag: Tag der Deutschen Einheit)'
test('nach Vereinbarung', …) // {offen:null, text:'Heute nach Vereinbarung', kurz:'Nach Vereinbarung'}
test('nie „24 Uhr“ in irgendeinem Text', …) // für jede Viertelstunde einer Woche mit rund/24h-Plänen: doesNotMatch(text, /24 Uhr/)
test('Fenster 18–02: um 01:00 Folgetag offen', …)
test('wechselListe: erster Eintrag ist jetzt, danach Wechsel um 18:00', …)
test('wechselListe: Mitternachtseintrag wechselt „morgen“ zu „heute“', …)
test('wechselListe hat tag (Berliner Datum)', …)
test('Sommerzeit 25.10.: Fenster 02:00–06:00, Text „schließt um 6 Uhr“', …)
```
- [ ] **Step 2: Run → FAIL**
- [ ] **Step 3: Implementieren** (Texte exakt nach Spec 6, Wochentage deutsch, Datum `TT.MM.`).
- [ ] **Step 4: Run → PASS**
- [ ] **Step 5: Commit** `"Öffnungszeiten: Live-Status, Texte und Wechselzeitpunkte"`
### Task 6: Zeitlogik III – Hinweise, JSON-LD, Google-Text, Erinnerungstage
**Files:**
- Modify: `server/services/zeiten/zeitlogik.js`
- Test: `test/zeitenZeitlogik.test.js` (erweitern)
**Interfaces:**
- Produces:
- `HINWEIS_RANG = { wichtig:4, urlaub:3, aktion:2, info:1 }`; `hinweisZeitraum(h) → { ab, bis }` (`ab` = `von` 00:00 Berlin, `bis` = `bis` 24:00 Berlin, ms); `gueltigeHinweise(daten, now) → [{ art, text, knopf, ab, bis, rang }]` (jetzt gültig oder Beginn in ≤ 8 Tagen).
- `jsonLdFuer(daten, plan, now) → { openingHoursSpecification, specialOpeningHoursSpecification, neuerBlock }` (`neuerBlock` = vollständiger `{ '@context':'https://schema.org', '@type':'LocalBusiness', … }` ohne `name`/`address`/`url`; die setzt das Skript).
- `googleText(daten, plan, now) → string`.
- `erinnerungsTage(daten, now) → [{ tag, name, art }]`; `imErinnerungsfenster(now) → boolean`.
- [ ] **Step 1: Failing tests**
```js
test('Hinweis-Zeitraum in Berliner Zeit über die Zeitumstellung', …) // von '2026-10-24' bis '2026-10-25' ⇒ ab 2026-10-23T22:00Z, bis 2026-10-25T23:00Z
test('gueltigeHinweise: vergangene fehlen, künftige ≤ 8 Tage sind dabei, Rang gesetzt', …)
test('JSON-LD: Mitternachtsfenster mit closes < opens', …) // {dayOfWeek:'Saturday', opens:'18:00', closes:'02:00'}
test('JSON-LD: rund um die Uhr 00:00–23:59, nach Vereinbarung fehlt', …)
test('JSON-LD: Urlaub als Sonderzeit geschlossen 00:00–00:00 mit validFrom/validThrough', …)
test('Google-Text: Montag zuerst, „geschlossen“, Sonderzeiten danach', …) // beginnt mit 'Montag: 09:00–18:00'
test('Erinnerung: Feiertag in 1–7 Tagen, offener Wochentag', …) // JETZT Mo 28.09. ⇒ [{tag:'2026-10-03', art:'feiertag'}] nur wenn Samstag offen
test('Erinnerung: nicht bei geschlossenem Tag, Ausnahme oder gespeicherter Abweichung', …)
test('Erinnerung: Heiligabend als „pruefen“', …)
test('Erinnerungsfenster 08:00–20:00 Berlin', …) // 07:59 ⇒ false, 08:00 ⇒ true, 19:59 ⇒ true, 20:00 ⇒ false
```
- [ ] **Step 2: Run → FAIL** - [ ] **Step 3: Implementieren** - [ ] **Step 4: Run → PASS**
- [ ] **Step 5: Commit** `"Öffnungszeiten: Hinweise, JSON-LD, Google-Text und Erinnerungstage"`
### Task 7: Ersatz-HTML und Wochenplan-Hash
**Files:**
- Create: `server/services/zeiten/ersatz.js`
- Test: `test/zeitenErsatz.test.js`
**Interfaces:**
- Consumes: `hauptplan`, `planNachKurz` (Task 4).
- Produces: `ersatzHtml(daten, plan) → string` (schlichte `<table class="wkz-ersatz">` mit `<caption>` = Planname, Zeilen Montag–Sonntag; nur ASCII, Umlaute/Gedankenstrich als Entities `&auml;` `&ouml;` `&uuml;` `&szlig;` `&#8211;`); `wochenplanHash(daten) → string` (sha1 der `tage` des Hauptplans, 16 Hex-Zeichen; `''` ohne Hauptplan).
- [ ] **Step 1: Failing tests**
```js
test('Ersatz-HTML ist reines ASCII', …) // /^[\x00-\x7F]*$/.test(html)
test('Ersatz-HTML zeigt alle Wochentage', …) // enthält 'Montag', 'Dienstag', … 'Sonntag', '09:00&#8211;18:00', 'geschlossen'
test('Kundentext wird maskiert', …) // Planname '<b>' ⇒ '&lt;b&gt;'
test('Hash ändert sich nur mit dem Wochenplan des Hauptplans', …) // Ausnahme/Hinweis ändern ⇒ gleich; Montag ändern ⇒ anders
```
- [ ] **Step 2–4: FAIL → Implementieren → PASS**
- [ ] **Step 5: Commit** `"Öffnungszeiten: Ersatz-Tabelle und Wochenplan-Hash"`
## Block C – Speichern und Kundenschnittstelle
### Task 8: Prüfregeln (`normalisiereZeiten`, `normalisiereTeam`)
**Files:**
- Create: `server/services/zeiten/pruefen.js`
- Test: `test/zeitenPruefen.test.js`
**Interfaces:**
- Consumes: `BUNDESLAENDER` (`termine/feiertage.js`), `text()` aus `termine/einstellungen.js` (Muster).
- Produces: `normalisiereZeiten(eingabe, { bisher, now }) → { daten } | { fehler: { [pfad]: text } }` (Pfade mit IDs: `plaene.<id>.tage.3`, `ausnahmen.<id>.von`, `hinweise.<id>.knopf.ziel`, `kontakt.telefon`, `daten`); `normalisiereTeam(eingabe) → { team } | { fehler }`; `telefonZiffern(text) → string|null` (deutsche Nummern mit führendem `49`); `kurzname(name, vorhandene) → string`.
- [ ] **Step 1: Failing tests**
```js
test('gültiger Plan bleibt, IDs und Kurznamen bleiben unverändert', …)
test('neuer Plan bekommt Kurznamen aus dem Namen', …) // 'Küche' ⇒ 'kueche', zweites 'Küche' ⇒ 'kueche-2'
test('genau ein Hauptplan, höchstens 5 Pläne', …)
test('Fensterregeln', …) // Überschneidung, bis==von, Mitternacht nicht als letztes, ragt in Folgetag, 5-Minuten-Raster ⇒ je eigener fehler-Pfad
test('Telefon in allen Schreibweisen', …) // '0171 1234567', '+49 171 1234567', '0049 171 1234567' ⇒ '491711234567'; '(06 31) 12 34' ⇒ '496311234'; '0171 ABC' ⇒ fehler 'kontakt.telefon'
test('Link nur https ohne Benutzerdaten, höchstens 300 Zeichen', …) // 'javascript:…', 'http://…', 'https://u:p@x.de' ⇒ fehler
test('Aktion braucht Enddatum, Urlaub höchstens 366 Tage', …)
test('Längen aus der Spec', …) // Hinweistext 141 Zeichen ⇒ fehler; Starttext 201 ⇒ fehler
test('Vergangenes (> 30 Tage) wird entfernt', …) // Ausnahme bis 2026-08-01 bei now 2026-10-05 ⇒ fehlt in daten
test('zu viele Daten: verständliche Meldung statt Absturz', …) // 100 Ausnahmen mit langen Texten > 32 KB ⇒ fehler.daten === 'Zu viele Einträge – bitte lösche alte Ausnahmen oder Hinweise.'
test('WhatsApp verlangt Nummer und bestaetigtAt', …)
test('navSelektor-Regeln', …) // 'header.nav' ok; 'a{}', 'a;b', '<x>', 121 Zeichen ⇒ fehler
```
- [ ] **Step 2–4: FAIL → Implementieren (Regeln Spec 2.1) → PASS**
- [ ] **Step 5: Commit** `"Öffnungszeiten: Prüfregeln für Zeiten und Team-Einstellungen"`
### Task 9: Speicher (`speicher.js`) und Cache-Instanz
**Files:**
- Create: `server/services/zeiten/lru.js`, `server/services/zeiten/cache.js`, `server/services/zeiten/speicher.js`
- Test: `test/zeitenSpeicher.test.js`, `test/zeitenLru.test.js`
**Interfaces:**
- Consumes: Task 8, `withLocks`.
- Produces:
- `neuerLru({ max, ttlMs }) → { hole(key, nowMs), setze(key, wert, nowMs), vergiss(key), leere(), groesse() }` (echtes LRU).
- `cache.js`: `zeitenCache` (`max: 500`, `ttlMs: 30000`), `vergissZeiten(siteKey)`, `resetZeitenCacheForTests()`.
- `ladeZeiten(featureId) → Promise<{ daten, version, team, teamVersion, ersatzHash } | null>`
- `speichereZeiten({ feature, eingabe, version, by, now }) → Promise<{ daten, version }>` (wirft `err.status=400, err.fehler` bzw. `err.status=409, err.code='ZEITEN_VERALTET'`)
- `speichereTeam({ feature, eingabe, teamVersion, by }) → Promise<{ team, teamVersion }>`
- `setzeErsatzHash(feature, hash)`, `loescheZeiten(featureId)`, `loescheZeitenVonKunde(customerId) → Promise<number>`
- [ ] **Step 1: Failing tests** (Appwrite-Nachbau mit `schemaKeys('oeffnungszeiten')`)
```js
test('LRU verdrängt den ältesten, Treffer erneuern', …) // max 2: a,b,hole(a),c ⇒ b fehlt
test('LRU-Einträge verfallen nach ttl', …)
test('erstes Speichern legt das Dokument mit version 1 an', …)
test('veraltete Version ⇒ 409 ZEITEN_VERALTET', …)
test('Prüffehler ⇒ 400 mit fehler', …)
test('Speichern leert den Cache-Eintrag des siteKeys', …)
test('Team-Einstellungen haben eine eigene Version', …)
test('gleichzeitiges Speichern: genau einer gewinnt', …) // Promise.all zweier Speichervorgänge mit version 1 ⇒ ein 409
test('loescheZeitenVonKunde löscht Daten und Merker', …)
```
- [ ] **Step 2–4: FAIL → Implementieren (alle Schreibzugriffe unter `withLocks([`zeiten:${feature.projectId}`])`, Version innerhalb der Sperre prüfen) → PASS**
- [ ] **Step 5: Commit** `"Öffnungszeiten: Speicher mit Versionen und Cache"`
### Task 10: Kundenroute `/api/zeiten`
**Files:**
- Create: `server/routes/zeiten.js`
- Modify: `server/index.js` (Mount `app.use('/api/zeiten', …)` nach dem Termine-Mount), `server/middleware/kundenansichtGuard.js` (`['PUT', /^\/api\/zeiten\/[^/]+\/?$/]`), `server/services/customerViewLog.js` (`VIEW_ACTIONS` += `'zeiten_gespeichert'`)
- Test: `test/zeitenRoutes.test.js`; Pins: `test/kundenansichtGuard.test.js` (Liste, Zählung, `MOUNTS` += `/api/zeiten`), `test/customerViewLog.test.js` (elf Aktionen)
**Interfaces:**
- Consumes: Tasks 4–9, `getFeatureDoc`, `assertOwnProject`, `isKundenansicht`, `logIfKundenansicht`, `createWriteLimiter`.
- Produces:
- `GET /api/zeiten/:projectId` → `{ state, daten|null, version, kundenansicht, vorschau: {offen,text,kurz}|null, feiertage: [{tag,name,art}] (12 Monate), googleText }`.
- `PUT /api/zeiten/:projectId` `{ version, daten }` → wie GET; 400 `{ fehler }`, 409 `{ code:'ZEITEN_VERALTET', error:'Inzwischen geändert – bitte neu laden.' }`, 429 bei > 60/Stunde.
- `GET /api/zeiten/:projectId/vorschau?zeit=YYYY-MM-DDTHH:MM` → `{ offen, text, kurz }` (Berliner Wanduhrzeit, gespeicherte Daten).
- Zugriff nur bei `state ∈ {ordered, setup, active}`; `disabled` mit Daten ⇒ GET lesbar, PUT 403; sonst 403. `setzeUhrFuerTests(fn)` exportiert.
- [ ] **Step 1: Failing tests**
```js
test('fremdes Projekt ⇒ 403', …)
test('ohne Funktion (off) ⇒ 403', …)
test('GET vor dem ersten Speichern: daten null, version 0, vorschau null', …)
test('PUT speichert und liefert vorschau und googleText', …)
test('PUT mit alter Version ⇒ 409 ZEITEN_VERALTET', …)
test('PUT in der Kundenansicht ist erlaubt und wird protokolliert', …) // customerViewLog-Eintrag 'zeiten_gespeichert'
test('Kundenansicht darf whatsapp.bestaetigtAt nicht neu setzen', …) // 403
test('disabled: GET ja, PUT 403', …)
test('vorschau rechnet zur angefragten Berliner Uhrzeit', …)
test('61. Speichern in einer Stunde ⇒ 429', …)
```
- [ ] **Step 2–4: FAIL → Implementieren → PASS**, dann `npm test` (Pins).
- [ ] **Step 5: Commit** `"Öffnungszeiten: Kundenschnittstelle zum Bearbeiten"`
## Block D – Öffentliche Schnittstelle
### Task 11: Öffentliche Antwort (`oeffentlich.js`)
**Files:**
- Create: `server/services/zeiten/oeffentlich.js`
- Modify: `server/services/siteFeatures.js` (`findActiveBySiteKey(siteKey, featureKey='news', { states = ['active'] } = {})` mit `Query.equal('state', states)`)
- Test: `test/zeitenOeffentlich.test.js`, `test/zeitenSiteKey.test.js`
**Interfaces:**
- Consumes: Tasks 4–6, 9; `customerOf`, `projectName` (`siteFeatures.js`).
- Produces:
- `baueOeffentlicheAntwort({ daten, team, firma, now }) → object` (rein; Form Spec 4.1 mit `plaene[].{kurz,name,haupt,woche,tage,wechsel}`, `hinweise`, `kontakt`, `jsonld`, `firma`, `team`, `aktiv: true`, `stand`).
- `oeffentlicheZeitenAntwort(siteKey, now) → Promise<object>`: `zeitenAus()` ⇒ `{aktiv:false}`; Format `^[a-z2-7]{16}$` sonst `{aktiv:false}` **ohne** Cache/Abfrage; Cache-Treffer; `findActiveBySiteKey(siteKey, 'zeiten', { states:['setup','active'] })`; Kunde fehlt ⇒ `{aktiv:false}`; `ladeZeiten`; bauen; im Cache ablegen.
- [ ] **Step 1: Failing tests**
```js
test('findActiveBySiteKey: Vorgabe nur active, mit states auch setup', …)
test('setup liefert dasselbe wie active', …)
test('vor dem ersten Speichern: aktiv true, plaene leer, kein jsonld', …)
test('Kontakt-Leiste mit Teildaten', …) // nur telefon ⇒ {telefon:'491711234567', routeUrl:null, whatsappUrl:null}; nur adresse ⇒ nur routeUrl; an:true ohne Angaben ⇒ kontakt null
test('routeUrl und whatsappUrl', …) // routeUrl==='https://www.google.com/maps/dir/?api=1&destination=' + encodeURIComponent(adresse); whatsappUrl==='https://wa.me/491711234567?text=' + encodeURIComponent(starttext)
test('firma ohne Kundendokument fällt auf den Projektnamen, sonst leer', …)
test('Kunde gelöscht ⇒ aktiv false', …)
test('ungültiger Schlüssel: keine Abfrage, kein Cache-Eintrag', …) // aw.listCalls('portalFeatures').length === 0; zeitenCache.groesse() === 0
test('zweiter Abruf innerhalb von 30 s aus dem Cache', …)
test('keine internen IDs in der Antwort', …) // rekursiv: keine Schlüssel $id, customerId, projectId, featureId
```
- [ ] **Step 2–4: FAIL → Implementieren → PASS**, `node --test test/siteFeatures.test.js test/publicNews*.test.js test/publicTermine*.test.js` grün.
- [ ] **Step 5: Commit** `"Öffnungszeiten: öffentliche Antwort mit Cache"`
### Task 12: Route `/api/public/zeiten/v1`
**Files:**
- Create: `server/routes/publicZeiten.js`, `server/services/zeiten/limits.js`
- Modify: `server/index.js` (Mount direkt nach `app.use('/api/public/news', …)`)
- Test: `test/publicZeiten.test.js`
**Interfaces:**
- Consumes: Task 11; `zaehle`, `ipSchluessel` (`termine/limits.js`).
- Produces: `export default router`, `setzeUhrFuerTests(fn)`, `resetPublicZeitenForTests()`; `darfZeitenAnfrage(ip, now) → boolean` (`ip:zeiten:<ipSchluessel>`, 120 je 60000 ms).
- [ ] **Step 1: Failing tests** (Muster `test/publicTermine.test.js`, eigene IP je Test über `x-forwarded-for`)
```js
test('CORS * und OPTIONS 204', …)
test('nur GET/HEAD, sonst 405', …)
test('Cache-Control public, max-age=60 und Expose-Headers Date', …)
test('ETag bleibt gleich, obwohl stand sich ändert ⇒ 304', …)
test('121. Anfrage in einer Minute ⇒ 429 mit Retry-After', …)
test('Appwrite-Fehler ⇒ 503 ohne Details', …)
test('aktiv:false bei Schalter aus, unbekanntem Schlüssel, Zustand off/ordered/disabled', …)
test('unbekannter Pfad ⇒ 404', …)
```
- [ ] **Step 2–4: FAIL → Implementieren (Vorlage `publicNews.js`: CORS-Block, `sendeMitEtag` hasht `JSON.stringify({ ...body, stand: undefined })`) → PASS**
- [ ] **Step 5: Commit** `"Öffnungszeiten: öffentliche Route"`
## Block E – Freischalten, Kauf, Abo
### Task 13: Mails K24–K27 und Blöcke in K15/T16
**Files:**
- Create: `server/mail-templates/K24 Zeiten eingeschaltet.html`, `K25 Zeiten Feiertage pruefen.html`, `K26 Zeiten Abo beendet.html`, `K27 Zeiten abgeschaltet.html`; `server/services/zeiten/mails.js`
- Modify: `server/mail-templates/K15 Bestellung Funktion.html` (Block `behalten`), `server/mail-templates/T16 Funktion bestellt.html` (Blöcke `abo`, `team`), `server/services/siteFeatures.js` (`sendT16` Default-Blöcke `abo:false, team:false`; `bestellMail` Block `behalten`)
- Test: `test/zeitenMails.test.js`; Pins: `test/mailTemplates.test.js` (`ids('K', 27)`, Du-Form-Liste += K24–K27, K15/T16-Blöcke), `test/featureMails.test.js`
**Interfaces:**
- Produces: `k24Mail({ kunde, projekt, einbau, wieder, ticketNr }) → { template:'K24', values, blocks:{ angestossen, manuell, wieder } }`; `k25Mail({ kunde, projekt, tage }) → { template:'K25', values:{ …, tage: 'Sa 03.10. – Tag der Deutschen Einheit\n…' }, blocks:{ pruefen, mehrere } }`; `k26Mail({ kunde, projekt, art, frist, preis }) → { template:'K26', blocks:{ abo_ende, hosting, wechsel_widerrufen } }`; `k27Mail({ kunde, projekt, loeschdatum, preis }) → { template:'K27', … }`; `sendeZeitenMail(customer, nachricht, { bccTeam = true }) → Promise<{ sent, reason? }>` (wirft nie; ohne E-Mail `{sent:false, reason:'keine_adresse'}`; Log ohne Adresse/Namen).
- [ ] **Step 1: Failing tests**
```js
test('Vorlagen K24–K27 bestehen die allgemeinen Regeln', …) // über mailTemplates.test.js-EXPECTED automatisch
test('Builder decken genau die Platzhalter ab', …) // renderMail(id, values, blocks).problems deepEqual []
test('K24: Text je nach einbau', …) // angestossen ⇒ 'nach dem nächsten Veröffentlichen'; manuell ⇒ 'baut unser Team'
test('K25 geht ohne BCC', …) // sendeZeitenMail(c, k25Mail(...), {bccTeam:false}) ⇒ sink.sent[0].bcc fehlt
test('K26-Varianten nach art', …)
test('ohne E-Mail-Adresse: nichts senden, nichts werfen', …)
test('Log enthält weder Adresse noch Namen', …)
test('K15 mit Block behalten, T16 mit Blöcken abo/team', …)
```
- [ ] **Step 2–4: FAIL → Implementieren (Skelett K23, Du-Form, Logo; Texte nach Spec 7; Betreffs: K24 „Öffnungszeiten live ist eingeschaltet“, K25 „Feiertage stehen an – stimmen Deine Zeiten?“, K26 „Dein Wartungs-Abo ist beendet – Öffnungszeiten live behalten?“, K27 „Öffnungszeiten live ist abgeschaltet“) → PASS**, `npm test`.
- [ ] **Step 5: Commit** `"Öffnungszeiten: Mails K24 bis K27"`
### Task 14: Neubau anstoßen und Runner-Feld `zeiten`
**Files:**
- Create: `server/services/zeiten/neubau.js`, `server/services/zeiten/einbau.js`
- Modify: `server/services/previewDeploy.js` (Job: `zeiten: await buildZeiten(doc)`; Dev-Pfad: `zeiten: undefined` neben `analytics: undefined`)
- Test: `test/zeitenNeubau.test.js`; Pins: `test/previewRunner.test.js` (Job-Body mit `zeiten: null`, Dev-PUT ohne `zeiten`)
**Interfaces:**
- Produces:
- `einbau.js`: `buildZeiten(project) → Promise<{ siteKey } | null>` (wirft nie; nur bei Feature-Zustand `setup`/`active`; `null` bei Subdomain in `config.zeiten.keinNeubau`). Darf weder `neubau.js` noch `previewRebuild.js` importieren (Zyklus).
- `neubau.js`: `zeitenDeps = { rebuild: triggerRebuild }`; `stosseNeubauAn(project) → Promise<'angestossen'|'manuell'>` unter `withLocks(['zeiten-neubau'])`; `'angestossen'` nur bei `result?.queued === true`; `'manuell'` bei fehlendem Repo, Preview aus, Dev-Modus (`previewDetected` beginnt mit `dev`), Sperrliste, Fehler oder 503.
- [ ] **Step 1: Failing tests**
```js
test('buildZeiten: setup und active liefern siteKey, off/ordered/disabled null', …)
test('buildZeiten: Sperrliste ⇒ null', …)
test('buildZeiten wirft nie (Appwrite-Fehler ⇒ null)', …)
test('Neubau: queued ⇒ angestossen', …) // zeitenDeps.rebuild = async () => ({ queued:true })
test('Neubau: skipped/kein Repo/Dev/Sperrliste/503 ⇒ manuell', …)
test('Neubauten laufen nacheinander', …) // zwei gleichzeitige stosseNeubauAn ⇒ rebuild-Aufrufe überlappen nicht
```
- [ ] **Step 2–4: FAIL → Implementieren → PASS**, `node --test test/previewRunner.test.js`.
- [ ] **Step 5: Commit** `"Öffnungszeiten: Neubau anstoßen und Feld im Runner-Auftrag"`
### Task 15: Ticket-Text (`ticketDetails.js`) und Ersatz-Endpunkt für das Team
**Files:**
- Modify: `server/services/zeiten/ticketDetails.js`, `server/routes/admin/funktionenAdmin.js` (`GET /:customerId/zeiten/ersatz?projectId=&plan=` → `{ html }`, 409 bei leeren Plänen)
- Test: `test/zeitenTicket.test.js`
**Interfaces:**
- Consumes: Tasks 7, 9, 14 (`doc.einbau`).
- Produces: `zeitenTicketDetails(ctx) → Promise<string>` (≤ `TICKET_DETAILS_MAX`): siteKey, Einbau-Zeile `<script data-wk-zeiten src="https://project.webklar.com/embed/zeiten.js" data-site="<siteKey>" defer></script>`, Ergebnis `einbau` („automatisch eingesetzt“ / „bitte von Hand einsetzen“), Platzhalter-Beispiele mit Kurznamen, Hinweis „Ersatz-HTML: WOMS → Funktionen → Ersatz-HTML kopieren (erst nach dem ersten Speichern des Kunden)“, Prüfliste aus Spec 4.6.
- [ ] **Step 1: Failing tests**
```js
test('Ticket nennt siteKey, Einbau-Zeile und Prüfliste', …)
test('einbau manuell ⇒ „bitte von Hand einsetzen“', …)
test('Ticket bleibt unter TICKET_DETAILS_MAX', …)
test('Ersatz-Endpunkt: Team-JWT nötig, {html} als JSON, 409 ohne gespeicherte Zeiten', …)
```
- [ ] **Step 2–4: FAIL → Implementieren → PASS**
- [ ] **Step 5: Commit** `"Öffnungszeiten: Einbau-Ticket und Ersatz-HTML für das Team"`
### Task 16: Gratisweg „Einschalten“ und Angebot in `GET /api/funktionen`
**Files:**
- Create: `server/services/zeiten/einschalten.js`
- Modify: `server/routes/funktionen.js` (`POST /zeiten/einschalten`, Antwortfeld `zeiten`, `zeitenStatus` je Projekt; `speichereNachweis` nach `server/services/featureConsents.js` als `speichereNachweisAusAnfrage(req, …)` auslagern), `server/services/featureOrder.js` (`pruefeAboFuerEinschalten`), `server/services/siteFeatures.js` (`featureView` + `unlockedBy`, `aboEndeAt`, `einbau`, `loeschungAm` für `zeiten`)
- Test: `test/zeitenEinschalten.test.js`; Pins: `test/funktionenRoutes.test.js`, `test/kundenansichtGuard.test.js` (Einschalten bleibt in der Kundenansicht gesperrt)
**Interfaces:**
- Consumes: Tasks 2, 13, 14, 15; `freiweg`, `pruefeZustimmung`, `speichereZustimmung`, `bestellbar`, `ensureFeatureDoc`, `assertOwnProject`, `createFeatureTicket`.
- Produces:
- `pruefeAboFuerEinschalten(customerId) → Promise<void>` (frisch `bestellbar()`; `kein_abo`/`gekuendigt` ⇒ 409 Code `kein_abo`/`gekuendigt` mit Text „Ohne laufendes Wartungs-Abo kannst Du Öffnungszeiten live für einmalig 30 € bestellen.“; `nicht_aktiv` ⇒ 409 „Einschaltbar, sobald Dein Abo aktiv ist.“; Lesefehler ⇒ `Object.assign(new Error('Gerade nicht prüfbar – bitte versuch es gleich noch einmal.'), { status: 503 })`).
- `einschaltenZeiten({ customerId, projectId, by: 'kunde'|'team', staff = null, nachweis = null, now }) → Promise<{ doc, changed }>` (Spec 3.2 Schritte 1–6; Abo-Prüfung nur bei `by === 'kunde'`; `unlockedBy` = `'abo'` bzw. `'team'`; unter `feature-order:<c>:<p>`; Neubau/Ticket/Mail nach der Sperre).
- `holeZeitenNach(doc, { now }) → Promise<doc>` (Ticket fehlt in `setup` ⇒ anlegen; `unlockedBy === 'abo'` und `k24At` leer ⇒ K24, dann `k24At`).
- `GET /api/funktionen`: `zeiten: { verfuegbar, inklusive: true|false|null, grund, preis: '30 €', einschalten: { consent, leistungsbeschreibung } }`; je Projekt `features.zeiten` (erweiterte `featureView`), `zeitenStatus: { offen, kurz, text } | null`, `zeitenLeer: boolean`.
- `POST /api/funktionen/zeiten/einschalten` `{ projectId, zustimmung: { version, haken } }` → 200 `{ feature, einbau }`; viewAs ⇒ 403.
- [ ] **Step 1: Failing tests**
```js
test('Einschalten mit laufendem Abo: setup, unlockedBy abo, Nachweis ohne Sitzung', …)
test('Reihenfolge: Neubau vor Ticket, Ticket-Text nennt einbau, dann K24, dann k24At', …)
test('kein Abo / gekündigt ⇒ 409 mit Hinweis auf 30 €', …)
test('Abo nicht aktiv ⇒ 409 „sobald Dein Abo aktiv ist“', …)
test('Abo-Lesefehler ⇒ 503, nichts geschrieben', …)
test('Kundenansicht ⇒ 403', …)
test('Zustimmung fehlt oder veraltet ⇒ 400/409 wie beim Checkout', …)
test('archiviertes Projekt ⇒ 409', …)
test('zweiter Klick ist idempotent und holt Fehlendes nach', …) // erster Lauf: Ticket-Anlage scheitert (aw.failNext) ⇒ zweiter Klick legt Ticket an, kein zweites K24
test('Angebot: inklusive true/false/null je nach Abo-Lage', …)
test('Projektliste enthält zeitenStatus ohne Zusatzabfrage pro Kachel', …)
```
- [ ] **Step 2–4: FAIL → Implementieren → PASS**, `npm test`.
- [ ] **Step 5: Commit** `"Öffnungszeiten: Einschalten im Wartungs-Abo"`
### Task 17: Checkout-Sperre „inklusive“ und `unlockedBy` beim Kauf
**Files:**
- Modify: `server/services/featureOrder.js` (`pruefeInklusive(feature, customerId)`), `server/routes/funktionen.js` (Stufe nach `pruefeAbo`), `server/services/siteFeatures.js` (`markPaid`/späte Zahlung schreiben `unlockedBy: 'kauf'`; Lesehelfer `unlockedByVon(doc) = doc.unlockedBy || (doc.unlockedByPurchase ? 'kauf' : 'team')`)
- Test: `test/zeitenCheckout.test.js`
**Interfaces:**
- Produces: `pruefeInklusive(feature, customerId) → Promise<void>` (nur bei `feature.aboInklusive === true`; `bestellbar` ok oder `nicht_aktiv` ⇒ `bestellFehler(409, 'Im Wartungs-Abo bereits enthalten – einfach einschalten.', 'inklusive', { grund })`; Lesefehler ⇒ wie `pruefeAbo`); `unlockedByVon(doc) → 'kauf'|'abo'|'team'`.
- [ ] **Step 1: Failing tests**
```js
test('Checkout zeiten mit laufendem Abo ⇒ 409 inklusive', …)
test('Checkout zeiten bei Abo nicht aktiv ⇒ 409 inklusive', …)
test('Checkout zeiten ohne Abo / gekündigt ⇒ Stripe-Sitzung über 3000 Cent', …)
test('Zahlung ⇒ setup, unlockedBy kauf, Ticket, K15', …)
test('News und Termine unverändert (kein inklusive-Check)', …)
```
- [ ] **Step 2–4: FAIL → Implementieren → PASS**
- [ ] **Step 5: Commit** `"Öffnungszeiten: Bezahlen gesperrt, wenn im Abo enthalten"`
### Task 18: Freischalten nur mit Zeiten, Selbstheilung, Ersatz-Hash
**Files:**
- Modify: `server/services/featureRegistry.js` (`STANDARD_HAKEN.zeiten.freischaltbar`, neuer Haken `nachFreischaltung` in `HAKEN_NAMEN`), `server/services/siteFeatures.js` (`runActivation`: nach `activateClosedTickets` `nachFreischaltung` aufrufen; für `zeiten` in `setup` per `await import('./zeiten/einschalten.js')` `holeZeitenNach`)
- Test: `test/zeitenFreischalten.test.js`; Pins: `test/featureFreischaltung.test.js`, `test/featureRegistry.test.js` (`HAKEN_NAMEN`)
**Interfaces:**
- Produces: `zeitenFreischaltbar(doc) → Promise<boolean>` (Hauptplan gespeichert und mindestens ein Tag nicht `zu`; fail-closed); `nachFreischaltung(doc)` für `zeiten` = `setzeErsatzHash(feature, wochenplanHash(daten))`.
- [ ] **Step 1: Failing tests**
```js
test('Ticket zu, aber keine Zeiten ⇒ bleibt setup, kein K16', …)
test('Ticket zu und Zeiten gespeichert ⇒ active, K16, ersatzHash gesetzt', …)
test('Selbstheilung: setup ohne ticketId ⇒ Job legt Ticket an', …)
test('Selbstheilung: unlockedBy abo ohne k24At ⇒ Job sendet K24 genau einmal', …)
test('Lesefehler im Haken ⇒ bleibt setup', …)
```
- [ ] **Step 2–4: FAIL → Implementieren → PASS**, `npm test`.
- [ ] **Step 5: Commit** `"Öffnungszeiten: Freischalten erst mit eingetragenen Zeiten"`
### Task 19: Team – Aktivieren über den Gratis-Pfad, Einstellungen, Abschalten
**Files:**
- Create: `server/services/zeiten/abschalten.js`
- Modify: `server/routes/admin/funktionenAdmin.js` (für `zeiten`: `activate` ⇒ `einschaltenZeiten({ by:'team', staff })`; `disable` ⇒ `disable(...)` + `nachAbschaltung`; `PATCH /:customerId/zeiten/team` `{ projectId, teamVersion, navSelektor, kontaktleisteAus, hinweisleisteAus }`; `adminView` liefert für `zeiten` zusätzlich `plaene: [{kurz,name,haupt}]`, `team`, `teamVersion`, `hindernis: 'zeiten_fehlen'|null`)
- Test: `test/zeitenTeam.test.js`
**Interfaces:**
- Consumes: Tasks 9, 16.
- Produces: `nachAbschaltung(doc, { now }) → Promise<'angelegt'|'aktuell'|'fehler'>` (weicht `wochenplanHash(daten)` von `doc`-Datensatz `ersatzHash` ab ⇒ WOMS-Ticket „Ersatz-Tabelle aktualisieren – <Projekt>“ mit aktuellem Ersatz-HTML; wirft nie).
- [ ] **Step 1: Failing tests**
```js
test('Team aktiviert zeiten: setup, unlockedBy team, Neubau, Ticket, kein K24', …)
test('Team-Einstellungen mit Version; ungültiger Selektor ⇒ 400', …)
test('Abschalten durch Team: disabled Grund team, keine Löschfrist', …)
test('Abschalten mit geändertem Wochenplan ⇒ Ersatz-Ticket', …)
test('Abschalten ohne Änderung ⇒ kein Ersatz-Ticket', …)
test('adminView zeigt Pläne, team und hindernis', …)
```
- [ ] **Step 2–4: FAIL → Implementieren (Sperren: Route hält `feature-admin:<projectId>`, `einschaltenZeiten` nimmt nur `feature-order:…`) → PASS**
- [ ] **Step 5: Commit** `"Öffnungszeiten: Team-Aktionen und Ersatz-Ticket beim Abschalten"`
### Task 20: „Für 30 € behalten“
**Files:**
- Create: `server/services/zeiten/behalten.js`
- Modify: `server/services/stripeBilling.js` (`createFeatureCheckout(customer, { projectId, key, consentAt, zweck = null })`: `zweck` in `metadata` und `invoice_data.metadata`; bei `zweck === 'behalten'` Rücksprung `#zeiten?behalten=1&projekt=<id>`), `server/services/siteFeatures.js` (`handleFeatureCheckoutEvent`: Zweig `metadata.zweck === 'behalten'` **vor** `markPaid`/`adoptPaidSession`, per dynamischem Import), `server/routes/funktionen.js` (`POST /zeiten/behalten`)
- Test: `test/zeitenBehalten.test.js`
**Interfaces:**
- Produces: `starteBehalten({ customer, projectId, nachweis, now }) → Promise<{ url }>` (nur bei `aboEndeAt` gesetzt oder `disabled` mit `abo_ende` und Daten vorhanden, sonst 409; setzt `behaltenSessionId`; Zustand unverändert); `bucheBehalten(session) → Promise<{ fall: 'behalten'|'unpassend' }>` (unter `feature-admin:<projectId>`: `unlockedBy:'kauf'`, `aboEndeAt:null`, `behaltenSessionId:''`, `stripeSessionId = session.id`, aus `disabled` zurück auf `active`/`setup` + Neubau; Nachweis mit dieser Sitzung; K15 mit Block `behalten`; unpassend ⇒ T19 **nach** der Sperre).
- [ ] **Step 1: Failing tests**
```js
test('Behalten nur nach Abo-Ende, sonst 409', …)
test('während der Zahlung bleibt der Zustand (Leisten bleiben live)', …)
test('Webhook behalten: kauf, Frist weg, kein neues Ticket, K15 behalten', …)
test('Behalten aus disabled: zurück auf active und Neubau', …)
test('Sitzung passt nicht oder Daten gelöscht ⇒ T19 Erstattung fällig', …)
test('normaler Kauf-Webhook unverändert', …)
```
- [ ] **Step 2–4: FAIL → Implementieren → PASS**, `npm test`.
- [ ] **Step 5: Commit** `"Öffnungszeiten: nach Abo-Ende für 30 € behalten"`
### Task 21: Abo-Abgleich, Abschaltung nach 30 Tagen, Löschung
**Files:**
- Create: `server/services/zeiten/aboAbgleich.js`
- Modify: `server/services/siteFeatures.js` (`activate()` leert zusätzlich `aboEndeAt` und `behaltenSessionId`)
- Test: `test/zeitenAboAbgleich.test.js`
**Interfaces:**
- Consumes: Tasks 13, 14, 19; `aboMemo`, `aboLage`, `loeschzeit` (`termine/frist.js`).
- Produces: `gleicheZeitenAboAb(now) → Promise<{ geprueft, aboEnde, abgeschaltet, wiederEin, geloescht, mails, fehler }>` (wirft nie; nur `unlockedBy === 'abo'`; Schritte Spec 5: K26 mit Variante nach `art`, nach `aboEndeAt + 30 Tage` ⇒ `disabled`/`abo_ende` + K27 + `nachAbschaltung`, Rückkehr bis `aboEndeAt + 60 Tage` ⇒ zurück + Neubau aus `disabled` + K24 `wieder`, `disabled`/`abo_ende` + 30 Tage ⇒ `loescheZeiten` + `state:'off'` + `ticketId`, `einbau`, `k24At`, `aboEndeAt`, `behaltenSessionId` geleert; Lesefehler ⇒ Kunde überspringen).
- [ ] **Step 1: Failing tests** (`now` als Parameter, Tage über `plusTage`)
```js
test('Abo endet: aboEndeAt gesetzt, K26 einmal', …)
test('K26-Variante bei Wechsel zu Hosting', …)
test('Tag 29: noch aktiv; Tag 30: disabled abo_ende, K27', …)
test('Rückkehr an Tag 45: zurück auf active, Neubau, K24 wieder', …)
test('Tag 60 nach Abschaltung: Daten gelöscht, state off', …)
test('gekaufte und Team-Freischaltungen bleiben unberührt', …)
test('Abo-Lesefehler: überspringen, nie abschalten', …)
test('Grund team wird nicht gelöscht', …)
test('nach der Löschung: ticketId, einbau, k24At, ersatzHash leer; Neubestellung legt ein neues Ticket an', …)
```
- [ ] **Step 2–4: FAIL → Implementieren → PASS**
- [ ] **Step 5: Commit** `"Öffnungszeiten: Abo-Abgleich mit 30 Tagen Frist"`
### Task 22: Kunde gelöscht
**Files:**
- Modify: `server/services/customerAdmin.js` (neben dem Termine-Aufruf: `disableCustomerFeatures(customerId, 'zeiten', …)` und `loescheZeitenVonKunde(customerId)`, beides per dynamischem Import in try/catch), `server/services/retention.js` (wo Funktionen eines gelöschten Kunden abgeschaltet werden: für `zeiten` zusätzlich `loescheZeitenVonKunde`)
- Test: `test/zeitenLoeschen.test.js`
- [ ] **Step 1: Failing tests**
```js
test('Kundenlöschung schaltet zeiten ab und löscht Daten und Merker', …)
test('öffentliche Antwort danach aktiv false', …)
test('Fehler beim Löschen der Zeiten bricht die Kundenlöschung nicht ab', …)
```
- [ ] **Step 2–4: FAIL → Implementieren → PASS**, `node --test test/funktionenTermine.test.js test/analyse.test.js test/retention.test.js`.
- [ ] **Step 5: Commit** `"Öffnungszeiten: Daten bei Kundenlöschung entfernen"`
### Task 23: Feiertags-Erinnerung und Jobs
**Files:**
- Create: `server/services/zeiten/erinnerung.js`, `server/services/zeiten/jobs.js`
- Modify: `server/index.js` (Abo-Job `setTimeout` 240 s, dann `setInterval` 24 h; Erinnerung `setTimeout` 300 s, dann `setInterval` 1 h; beide `.unref()`, Termine-Blöcke nicht anfassen)
- Test: `test/zeitenErinnerung.test.js`, `test/zeitenJobs.test.js`
**Interfaces:**
- Consumes: Tasks 6, 13, 21; `ZEITEN_COLLECTIONS.zeitenErinnerungen`.
- Produces: `erinnereFeiertage(now) → Promise<{ geprueft, mails, merker, fehler }>` (nur Schalter `pilot`/`on` und freigegebene Kunden, nur `setup`/`active`, nur im Fenster; je Tag zuerst Merker `sha1(featureId|datum)` anlegen, 409 ⇒ überspringen; eine K25 je Dokument mit allen neuen Tagen, `bccTeam:false`; Merker älter als 30 Tage löschen); `zeitenAboJob(now)`, `zeitenErinnerungsJob(now)` (eigene Überlappsperre), `resetZeitenJobsForTests()`.
- [ ] **Step 1: Failing tests**
```js
test('eine Mail für zwei Feiertage im Fenster', …)
test('zweiter Lauf: keine zweite Mail (Merker)', …)
test('Neustart zwischen Merker und Mail: keine doppelte Mail', …) // Merker existiert, Mail fehlt ⇒ nichts senden
test('außerhalb 08–20 Uhr: nichts', …)
test('Schalter off: nichts', …)
test('alte Merker werden aufgeräumt', …)
test('Jobs überlappen nicht', …)
test('index.js registriert beide Jobs mit .unref()', …) // Quelltext-Pin wie termineJobs.test.js
```
- [ ] **Step 2–4: FAIL → Implementieren → PASS**, `npm test`.
- [ ] **Step 5: Commit** `"Öffnungszeiten: Feiertags-Erinnerung und Jobs"`
## Block G – Runner
### Task 24: Runner setzt das Zeiten-Skript ein
**Files:**
- Modify: `runner/src/config.js` (`zeitenPublicBase` aus `ZEITEN_PUBLIC_BASE`, Vorgabe `'https://project.webklar.com/embed/'`, gleiche Regex wie `analyticsPublicBase`, Fehlertext `ZEITEN_PUBLIC_BASE muss mit https:// beginnen und mit / enden`), `runner/src/validate.js` (Block `zeiten` + Whitelist), `runner/src/analytics-inject.js` (`injectIntoHtml(buf, tag, marker = 'data-wk-analytics')`, Marker gegen `/^data-wk-[a-z]+$/` prüfen; neu `zeitenScriptTag(zeiten, baseUrl)`, `injectZeiten(dir, zeiten, { baseUrl, onLine, fs })`), `runner/src/jobs.js` (in `publish()` nach dem Analyse-Aufruf: `if (job.zeiten) await injectZeiten(fresh, job.zeiten, { baseUrl: config.zeitenPublicBase, onLine, fs })`)
- Test: `runner/test/zeiten-inject.test.js`
**Interfaces:**
- Produces: `zeitenScriptTag({ siteKey }, baseUrl) → '<script data-wk-zeiten src="<baseUrl>zeiten.js" data-site="<siteKey>" defer></script>'`; `injectZeiten(...)` wirft `Error('Zeiten: ungültiger siteKey')` / `Error('Zeiten: ungültige öffentliche Basis-URL')`, Logzeile `Zeiten-Skript in n HTML-Datei(en) eingesetzt`, keine Datenschutzseite.
- [ ] **Step 1: Failing tests** (Helfer aus `runner/test/analytics-inject.test.js` kopieren)
```js
test('Tag steht vor </head>', …)
test('zweimal einsetzen ergibt genau einen Tag', …) // count('data-wk-zeiten') === 1
test('alter Zeiten-Tag wird ersetzt', …)
test('Analyse und Zeiten in einem Job: je ein Tag', …) // includes beider Marken, keine Reihenfolge-Prüfung
test('nur Zeiten: keine datenschutz-analyse.html', …)
test('Fehler ⇒ Job failed, alte Preview bleibt', …)
test('validateJob: gültig, null, ungültiger Schlüssel, Zusatzfelder verworfen', …)
test('loadConfig: Vorgabe, Override, ungültig wirft', …)
test('injectIntoHtml lehnt fremde Marker ab', …)
```
- [ ] **Step 2–4: FAIL → Implementieren → PASS** (`cd runner && npm test`; bestehende Analyse-Tests unverändert grün)
- [ ] **Step 5: Commit** `"Runner: Zeiten-Skript beim Veröffentlichen einsetzen"`
## Block H – Website-Skript
### Task 25: `zeiten.js` – Abruf, Status und Platzhalter
**Files:**
- Create: `public/embed/zeiten.js`, `public/embed/zeiten-demo.html` (`noindex`, Testseite mit allen drei Platzhalter-Modi)
- Test: `test/zeitenEmbedStatic.test.js`
**Interfaces:**
- Consumes: Antwort aus Task 11/12.
- Produces (reine Funktionen zwischen Marken `// @rein-anfang` … `// @rein-ende`, per `vm.runInNewContext` testbar): `waehleWechsel(wechsel, jetztMs) → eintrag|null`, `naechsterWechsel(wechsel, hinweise, jetztMs) → ms|null`, `waehleHinweis(hinweise, jetztMs) → hinweis|null`, `jetztMitServer(datumsKopf, lokalMs) → ms` (Abweichung > 5 min ⇒ Serverzeit), `tabelleDaten(plan, heuteTag) → { woche, abweichungen14 }`; `telefonLink` zwischen `// @telefon-anfang`/`// @telefon-ende` (gleiche Regel wie `public/embed/termine.js`).
- [ ] **Step 1: Failing tests**
```js
test('Quelltext: keine Cookies, kein Storage, kein fremder Host', …) // doesNotMatch(/document\.cookie|localStorage|sessionStorage|indexedDB/), doesNotMatch(/https?:\/\//)
test('Quelltext: Date nur als Date.now( und Date.parse(', …) // keine getHours/getDay/toLocale/Intl./getTimezoneOffset
test('Quelltext: Kundentexte nie per innerHTML', …)
test('Doppel-Laden-Schutz __wkZeiten', …)
test('waehleWechsel nimmt den letzten Eintrag mit ab ≤ jetzt', …)
test('naechsterWechsel berücksichtigt Hinweis-Beginn und -Ende', …)
test('waehleHinweis: höchster Rang, bei Gleichstand späterer Beginn', …)
test('jetztMitServer: 4 min Abweichung ⇒ lokal, 6 min ⇒ Server', …)
test('tabelleDaten: heutiger Tag markiert, Abweichungen der nächsten 14 Tage', …)
```
- [ ] **Step 2–4: FAIL → Implementieren → PASS.** Ablauf im Skript: siteKey/API-Basis wie `termine.js` (`SELF=document.currentScript`, `data-api`); genau ein `fetch(… , { credentials:'omit' })`; Zeitpunkt des letzten **erfolgreichen** Abrufs merken (nicht `body.stand`); Timer auf `naechsterWechsel`, Neuladen frühestens alle 10 min und bei `visibilitychange`; älter als 24 h oder Fehler ⇒ Platzhalter unverändert lassen; Platzhalter `[data-webklar-zeiten]` mit `data-modus` `tabelle|status|badge` und `data-plan` (Kurzname, unbekannt ⇒ Ersatz-HTML bleiben + `console.warn`); Light DOM, `@layer wkz`, Klassen `wkz-…`, Variablen `--wkz-…`, Tabelle mit `<caption>`, `th scope="row"`, `aria-current="date"`.
- [ ] **Step 5: Commit** `"Öffnungszeiten: Website-Skript mit Status und Tabelle"`
### Task 26: `zeiten.js` – Leisten, JSON-LD und UI-Prüfung
**Files:**
- Modify: `public/embed/zeiten.js`
- Create: `test/ui/zeitenTestServer.mjs`, `test/ui/zeiten-embed.ui.mjs`
- Test: `test/zeitenEmbedStatic.test.js` (erweitern)
**Interfaces:**
- Produces (rein, zwischen den Marken): `jsonLdEinbauen(bloecke, jsonld, seite) → { geaendert: [index], neu: object|null }` (`bloecke` = geparste `ld+json`-Inhalte, unparsbare als `null`; LocalBusiness-Typen auch in `@graph`; `seite = { name, adresse, url }` füllt `neuerBlock`); `kontaktKnoepfe(kontakt) → [{ art, href, text }]`; `istEigeneAnrufLeiste(elemente) → boolean`.
- [ ] **Step 1: Failing tests (statisch)**
```js
test('JSON-LD: vorhandener Restaurant-Knoten wird ergänzt, kein zweiter Block', …)
test('JSON-LD: @graph mit HairSalon wird ergänzt', …)
test('JSON-LD: unparsbarer Block bleibt unberührt, neuer Block kommt dazu', …)
test('JSON-LD: ohne LocalBusiness ⇒ neuerBlock mit name/url/address', …)
test('Kontakt-Knöpfe aus Teildaten', …) // nur telefon ⇒ 1 Knopf tel:; nur routeUrl ⇒ 1 Knopf Route; leer ⇒ []
test('Links aus der API werden erneut geprüft', …) // 'javascript:x' im knopf.ziel ⇒ kein Link
```
- [ ] **Step 2: Failing UI-Prüfung schreiben** (`test/ui/zeiten-embed.ui.mjs`, Muster `test/ui/embed.ui.mjs` + `termineTestServer.mjs`; Seiten vom Testserver selbst ausliefern; `ctx.clock.install`/`fastForward` für Timer). Prüfungen: `hinweisleiste_aus` (Team-Schalter ⇒ keine Leiste), `fester_header_rueckt` (Header mit `position:fixed; top:0` steht unter der Leiste, nach Scrollen wieder bei 0), `klebender_header`, `eigene_anrufleiste` (Seite mit fester `a[href^="tel:"]`-Leiste ⇒ keine Kontakt-Leiste), `kontaktleiste_nur_handy` (390 px ja, 1280 px nein; Abstandhalter vorhanden), `jsonld_ergaenzt`, `aktiv_false_ersatz_bleibt`, `unbekannter_plan`, `kein_storage` (`document.cookie===''`, `localStorage.length===0`), `doppelt_geladen`, `reduzierte_bewegung`, `ungueltiger_selektor` (kein Fehler, Leiste trotzdem da), `status_wechselt_um_18_uhr` (Uhr vorspulen).
- [ ] **Step 3: Run → FAIL** (`free -m && nice node test/ui/zeiten-embed.ui.mjs`)
- [ ] **Step 4: Implementieren** nach Spec 4.3: Hinweis-Leiste als Shadow-DOM-Host erstes Kind von `body`, schiebt den Inhalt, feste/klebende Elemente mit `top:0` (oder `team.navSelektor`, `querySelectorAll` in try/catch) per Inline-`top` um `max(0, Höhe − scrollY)` rücken (rAF beim Scrollen, beim Entfernen zurücksetzen), `scroll-padding-top`; Farben je Art (Kontrast ≥ 4,5:1), `role="region"`, `aria-label="Hinweis"`; Kontakt-Leiste nur bei `matchMedia('(max-width: 768px)')`, Shadow DOM, `z-index:40`, Abstandhalter am Ende von `body`, `scroll-padding-bottom`, `rel="noopener noreferrer"`; JSON-LD per `textContent = JSON.stringify(...)`.
- [ ] **Step 5: Run → PASS** (statisch + UI)
- [ ] **Step 6: Commit** `"Öffnungszeiten: Hinweis-Leiste, Kontakt-Leiste und JSON-LD im Website-Skript"`
## Block I – Portal-Oberfläche
### Task 27: Stand abgleichen und reine Editor-Helfer
**Files:**
- Create: `public/zeiten-view.js`
- Test: `test/zeitenView.test.js`
**Interfaces:**
- Produces: `ZEITEN_LIMITS = { plaene:5, ausnahmen:100, hinweise:20, urlaubTage:366 }`, `ZEITEN_MAX = { hinweisText:140, name:60, ausnahmeText:60, knopfText:30, starttext:200, adresse:200, telefon:30, link:300, kurz:24 }`, `MODI`, `HINWEIS_ARTEN`; `neueZeitenId(praefix: 'p'|'x'|'h') → string`; `kurzname(name, vorhandene) → string` (gleiche Regel wie Server, Task 8); `fensterPruefen(fenster, { folgetagErstes }) → string|null`; `wochenFehler(plan) → { [tagNr]: text }`; `leererPlan({ name, kurz, haupt })`; `hauptplanWechseln(daten, planId) → daten`; `planLoeschenGrund(daten, planId) → string|null`; `hinweisVorschlag(urlaub) → { art:'urlaub', text, von, bis } | null` (ab 2 Tagen; `von` = Urlaubsbeginn − 7 Tage, `bis` = Urlaubsende; Text `Betriebsurlaub vom TT.MM. bis TT.MM.`); `preisHinweisNoetig(text) → boolean` (`/%|statt/i`); `linkOk(url) → boolean`.
- [ ] **Step 1: Stand abgleichen** – `git -C ../dev-termine log --oneline -5` und `git fetch`-freier Vergleich: Hat der TP2-Branch seit 6ae0f67 neue Commits, `git rebase feature/terminplaner-tp2` (nur bei sauberem Baum), dann `npm test`. Konflikte in `public/funktionen.js`/`app.js` zugunsten von TP2 lösen und im Ledger notieren.
- [ ] **Step 2: Failing tests**
```js
test('kurzname wie auf dem Server', …) // 'Küche'⇒'kueche'; Dublette ⇒ 'kueche-2'; 30 Zeichen ⇒ 24
test('fensterPruefen: Überschneidung, gleiche Zeiten, Mitternacht nur zuletzt', …)
test('Hauptplan wechseln und Löschgründe', …) // Hauptplan ⇒ 'Erst einen anderen Plan zum Hauptplan machen.'; letzter Plan ⇒ Grund
test('hinweisVorschlag ab 2 Tagen Urlaub', …) // Urlaub 12.–26.10. ⇒ {von:'2026-10-05', bis:'2026-10-26', text:'Betriebsurlaub vom 12.10. bis 26.10.'}; 1 Tag ⇒ null
test('Preis-Hinweis bei % oder statt', …)
test('Quelltext-Pins: zeiten*.js importieren nur relativ', …)
```
- [ ] **Step 3–4: FAIL → Implementieren → PASS**
- [ ] **Step 5: Commit** `"Öffnungszeiten: Helfer für den Editor"`
### Task 28: Kachel, Ansicht und Bestellen/Einschalten
**Files:**
- Modify: `public/funktionen.js` (Kachel **inline**, keine neuen Importe: `ICONS.clock`, `zeitenTile(projects, angebot)`, `pickZeitenProject`, Knopf `data-view="zeiten" data-project`, Übergabe an `#zeiten-body.dataset.wunschProjekt`), `public/app.js` (`FEATURE_LABELS.zeiten='Öffnungszeiten'`, `VIEW_HASHES`, `viewRefreshers.zeiten` mit `import('/zeiten.js')` und Sperre gegen Neuaufbau bei ungespeicherten Änderungen, `APP_PARENT` `zeiten:'funktionen'` hinter `termine`), `public/dashboard.html` (`<section class="kb-view" id="view-zeiten">` mit `#zeiten-body`, `<link href="/zeiten.css">`)
- Create: `public/zeiten.js`, `public/zeiten-bestellen.js`, `public/zeiten.css`
- Test: `test/funktionenZeitenTile.test.js`, `test/zeitenBestellen.test.js`; Pins: `test/termineView.test.js` (`APP_PARENT`/`VIEW_HASHES`/`FEATURE_LABELS`), `test/funktionenRoutes.test.js`
**Interfaces:**
- Consumes: Task 16 (`zeiten`, `features.zeiten`, `zeitenStatus`, `zeitenLeer`), Task 17, Task 20; `bestellKoerper`, `sicherLink` (`funktion-bestellen.js`); `h`, `request`, `hakenFeld`, `infoKarte`, `fehlerKarte` (`termine-dom.js`).
- Produces: `initZeiten(root, { projectId } = {})`; `renderZeitenBestellen(root, { ctx, angebot, projekt })`; `zeitenStatusKarte({ projekt, feature, angebot, kundenansicht }) → Element`.
- [ ] **Step 1: Failing tests**
```js
// test/funktionenZeitenTile.test.js (eigener Loader: alle Importe in funktionen.js generisch umbiegen)
test('ohne verfuegbar keine Kachel', …)
test('Abo: „Im Wartungs-Abo enthalten“ und Knopf Einschalten', …)
test('Abo nicht aktiv: „sobald dein Abo aktiv ist“, kein Bezahlen', …)
test('ohne Abo: „30 € einmalig“', …)
test('setup: Text je nach einbau, Hinweis „Trag jetzt deine Zeiten ein“ bei zeitenLeer', …)
test('active: Kurzstatus aus zeitenStatus, keine Zusatzabfrage', …) // fetch-Aufrufe deepEqual ['/api/funktionen']
test('aboEndeAt: Behalten-Hinweis mit Datum', …)
test('ordered: „Zahlung wird bestätigt …“', …)
test('disabled abo_ende mit Daten: Knopf „Für 30 € wieder einschalten“', …)
test('Abo-Lesefehler (inklusive null): „Gerade nicht prüfbar – bitte später erneut“', …)
// test/zeitenBestellen.test.js (fakeDom)
test('Einschalten: Haken schaltet Knopf frei, POST /api/funktionen/zeiten/einschalten', …)
test('Bestellen: POST /api/funktionen/zeiten/checkout, Weiterleitung zu Stripe', …)
test('409 inklusive beim Bestellen ⇒ wechselt zur Einschalt-Ansicht', …)
test('Kundenansicht: Einschalten/Bestellen gesperrt mit Hinweis', …)
test('Rückkehr #zeiten?bestellt=1 und ?behalten=1 zeigt Danke-Karte', …)
```
- [ ] **Step 2–4: FAIL → Implementieren → PASS**, `npm test`; `nice node test/ui/funktionen.ui.mjs` (Zahl „Kommt bald“-Kacheln unverändert).
- [ ] **Step 5: Commit** `"Öffnungszeiten: Kachel und Bestell-/Einschaltweg im Portal"`
### Task 29: Editor I – Speichern, Zeitpläne, Feiertage, Ausnahmen
**Files:**
- Create: `public/zeiten-speichern.js`, `public/zeiten-editor.js`, `public/zeiten-plaene.js`, `public/zeiten-ausnahmen.js`
- Test: `test/zeitenEditor.test.js`
**Interfaces:**
- Consumes: Tasks 10, 27; `editorZeile`, `neuerEintrag`, `abschnitt`, `feld`, `knopfZeile`, `setzeFehler`, `zeigeFehler` (`termine-dom.js`).
- Produces: `neuerZeitenSpeicher(ctx, wurzel, neuZeichnen) → { speichere(aendere, { container }), konflikt, nochHier }` (`PUT /api/zeiten/<pid> { version, daten }`; 409 ⇒ `'Inzwischen geändert – bitte neu laden.'`; 400 ⇒ Fehler an die Felder per ID-Pfad); `renderEditor(root, ctx)`; Abschnitte `plaene(t)`, `feiertage(t)`, `ausnahmen(t)` mit `t = { ctx, sp, zeichne, wurzel }`.
- [ ] **Step 1: Failing tests (fakeDom)**
```js
test('leerer Start: „Trag deine Öffnungszeiten ein“ und Hauptplan', …)
test('Wochentag: Haken geöffnet, zwei Zeitfelder step 300, rund/Vereinbarung', …)
test('Plan hinzufügen zeigt Kurznamen, Hauptplan wechseln', …)
test('Plan löschen warnt: „Steht dieser Plan auf deiner Website, zeigt sie dort wieder die feste Tabelle.“', …)
test('Feiertage: Bundesland, Liste mit Namen, „bitte prüfen“ markiert, Hinweis Gemeinde-Feiertage', …)
test('Urlaub ≥ 2 Tage ⇒ Frage „Hinweis-Leiste dazu anlegen?“ füllt den Hinweis vor', …)
test('409 beim Speichern ⇒ Meldung und Neu-laden-Knopf', …)
test('400 ⇒ Fehler am richtigen Feld (plaene.<id>.tage.3)', …)
test('Kundenansicht: Hinweis-Box, Speichern erlaubt', …)
```
- [ ] **Step 2–4: FAIL → Implementieren → PASS**
- [ ] **Step 5: Commit** `"Öffnungszeiten: Editor für Pläne, Feiertage und Ausnahmen"`
### Task 30: Editor II – Hinweise, Kontakt, Google, Vorschau
**Files:**
- Create: `public/zeiten-leisten.js`, `public/zeiten-google.js`
- Modify: `public/zeiten-editor.js` (Vorschau oben, Regler „Uhrzeit testen“)
- Create: `test/ui/zeiten-portal.ui.mjs`
- Test: `test/zeitenEditor.test.js` (erweitern)
**Interfaces:**
- Produces: `hinweise(t)`, `kontakt(t)`, `google(t)`; Vorschau ruft `GET /api/zeiten/<pid>/vorschau?zeit=…`; Knopf „Hinweis-Leiste anlegen“ (genau dieser Text, die Relaunch-Station tippt ihn an).
- [ ] **Step 1: Failing tests**
```js
test('Hinweis: Aktion ohne Enddatum ⇒ Feldfehler; % im Text ⇒ Preis-Hinweis', …)
test('Hinweis-Knopf: nur https-Link oder Telefon', …)
test('Kontakt: WhatsApp verlangt den Datenschutz-Haken; in der Kundenansicht gesperrt', …)
test('Google: „Für Google kopieren“ nutzt googleText vom Server, Link business.google.com', …)
test('Vorschau-Regler fragt /vorschau mit Tag und Uhrzeit', …)
```
- [ ] **Step 2: UI-Prüfung schreiben** (`test/ui/zeiten-portal.ui.mjs`, Muster `termine-portal.ui.mjs` mit Fixture-JSON): `fx_kachel_zustaende`, `fx_einschalten`, `fx_editor_leer`, `fx_speichern_konflikt`, `fx_hinweis_vorschlag`, `fx_handy_390` (kein waagerechtes Scrollen), `fx_tastatur` (alle Felder per Tab erreichbar, Fokus sichtbar).
- [ ] **Step 3–4: FAIL → Implementieren → PASS** (`node --test test/zeitenEditor.test.js`, `free -m && nice node test/ui/zeiten-portal.ui.mjs`)
- [ ] **Step 5: Commit** `"Öffnungszeiten: Editor für Hinweis- und Kontakt-Leiste, Google und Vorschau"`
## Block J – WOMS
### Task 31: WOMS – Register, Texte, Protokoll
**Files (WOMS-Worktree `/home/webklar/apps/ticket.webklar.com/dev-zeiten`):**
- Vorbereitung: `git -C /home/webklar/apps/ticket.webklar.com/build worktree add ../dev-zeiten -b feature/oeffnungszeiten feature/terminplaner-tp2 && cd ../dev-zeiten && nice npm ci`
- Modify: `src/lib/funktionenView.js` (Eintrag `zeiten` hinter `termine`, `siteKeyOf` für `['termine','zeiten']`, `aktionen` für `zeiten`: `off ['activate']`, `ordered ['disable']`, `setup ['disable']`, `active ['disable']`, `disabled ['activate']` – Signatur so übernehmen, wie TP2 sie zum Zeitpunkt hat), `src/lib/viewLog.js` (`zeiten_gespeichert: 'Öffnungszeiten gespeichert'`)
- Create: `src/lib/zeitenView.js`
- Test: `tests/unit/zeitenView.test.mjs`; Pins: `tests/unit/funktionenView.test.mjs`, `tests/unit/viewLog.test.mjs`
**Interfaces:**
- Consumes: `adminView` für `zeiten` (Task 19).
- Produces: `zeitenStatusText(feature) → string` („Leisten live – Einbau läuft“ / „wartet auf die Zeiten des Kunden“ bei `hindernis === 'zeiten_fehlen'` / Abo-Ende-Hinweis mit Frist); `navSelektorFehler(text) → string|null` (Spiegel der Serverregel Task 8); `teamBody({ projectId, teamVersion, navSelektor, kontaktleisteAus, hinweisleisteAus }) → object`.
- [ ] **Step 1: TP2-Stand prüfen** – `git log --oneline -10` im neuen Worktree; sind TP2 D9/D10/F13b/F14 schon da, deren Signaturen (`aktionen(state, key, …)`, Panel-Aufbau) verwenden. Im Ledger notieren.
- [ ] **Step 2: Failing tests**
```js
test('FUNKTIONEN enthält zeiten hinter termine', …)
test('siteKeyOf liefert für zeiten den Schlüssel, für news weiter ""', …)
test('aktionen für zeiten', …)
test('zeitenStatusText: setup / wartet / Abo-Ende', …)
test('navSelektorFehler wie auf dem Server', …)
test('viewLog-Label für zeiten_gespeichert', …)
```
- [ ] **Step 3–4: FAIL → Implementieren → PASS** (`node --test tests/unit/*.test.mjs`)
- [ ] **Step 5: Commit** (im WOMS-Worktree) `"Öffnungszeiten: Register und Texte im Ticketsystem"`
### Task 32: WOMS – Panel, Team-API, Rechtliches-Karte
**Files (WOMS-Worktree):**
- Create: `src/components/customer/ZeitenPanel.jsx`
- Modify: `src/lib/teamApi.js` (`getZeitenErsatz(customerId, projectId, plan)` ⇒ `GET /funktionen/<id>/zeiten/ersatz`, `patchZeitenTeam(customerId, body)` ⇒ `PATCH /funktionen/<id>/zeiten/team`), `src/components/customer/CustomerFunktionen.jsx` (Panel unter der Zeiten-Zeile, eigene Zeile außerhalb des 5-Spalten-Grids; Aktionsbeschriftung je Aktion), `scripts/setup-legal.mjs` (Karte `{ key:'leistungsbeschreibung-oeffnungszeiten', title:'Leistungsbeschreibung Öffnungszeiten live', liveUrl:'https://project.webklar.com/vertragsunterlagen/zeiten-leistungsbeschreibung.html' }` am Ende von `DOCUMENTS`)
- Test: bestehende WOMS-Tests + Bau
- [ ] **Step 1: Failing test** – Pin für `setup-legal.mjs` (Schlüssel, Titel, `sort` lückenlos) in der bestehenden Testdatei dieses Skripts ergänzen.
- [ ] **Step 2: Implementieren** – Panel: Felder Header-Selektor (Client-Prüfung `navSelektorFehler`), Haken „Kontakt-Leiste aus“, „Hinweis-Leiste aus“, Speichern mit `teamVersion` (409 ⇒ „Inzwischen geändert – bitte neu laden“), je Plan Knopf „Ersatz-HTML kopieren“ (`navigator.clipboard.writeText(html)`; 409 ⇒ „Der Kunde hat noch keine Zeiten eingetragen“).
- [ ] **Step 3: Run** `npm test` und `nice npx vite build` → grün.
- [ ] **Step 4: Commit** (im WOMS-Worktree) `"Öffnungszeiten: Team-Panel, Ersatz-HTML und Rechtliches-Karte"`
## Block K – Rechtstexte und Einbau-Doku
### Task 33: Entwürfe für die Rechtsrunde und Einbau-Anleitung
**Files:**
- Create: `public/vertragsunterlagen/zeiten-leistungsbeschreibung.html` (Kopf-Kommentar `ENTWURF – vor Livegang Rechtsrunde`, Gliederung Spec 9.1), `docs/rechtliches/zeiten-datenschutz-6d-entwurf.md` (§ 6d und Ergänzung § 6), `docs/rechtliches/zeiten-k02-k03-entwurf.md` (vorgeschlagener Satz für den Block `funktionen`, Sie-Form, mit den heutigen Pins als Hinweis), `docs/funktionen/zeiten-einbau.md` (Einbau-Zeile, Platzhalter-Modi, `data-plan`, Ersatz-HTML, Header-Selektor, Prüfliste Spec 4.6)
- Modify: `docs/funktionen/website-datenschutz-absatz.md` (Absatz „Öffnungszeiten live“ für Variante A und B, WhatsApp und Google Maps erst nach Klick), `docs/rechtliches/` Kanzlei-Fragen (⚖ „AVV Funktionen“ per Haken?) dort ergänzen, wo TP2 das Kanzlei-Paket führt
- Test: `test/zeitenRecht.test.js`
- [ ] **Step 1: Failing tests**
```js
test('Leistungsbeschreibung ist erreichbar und als Entwurf markiert', …) // Datei existiert, enthält 'ENTWURF', '§ 19 UStG', 'ein Einbau je Website'
test('Leistungsbeschreibung nennt Haftung, Ende nach Abo und Einstellung', …)
test('Register verlinkt die Leistungsbeschreibung', …) // featureConfig('zeiten').leistungsbeschreibung.url zeigt auf die Datei
test('unterlagenBereit für zeiten ist erfüllt', …)
```
- [ ] **Step 2–4: FAIL → Texte schreiben (Spec 9, Sie-Form) → PASS**
- [ ] **Step 5: Commit** `"Öffnungszeiten: Rechtstext-Entwürfe und Einbau-Anleitung"`
## Block L – Relaunch-Station
### Task 34: Ablauf der Station (rein, getestet)
**Files (Relaunch-Worktree):**
- Vorbereitung: `git -C /home/webklar/apps/webklar.com/build worktree add ../dev-zeiten-station -b feature/zeiten-station main && ln -s ../build/node_modules ../dev-zeiten-station/node_modules`
- Create: `src/components/home/demos/baeckerei/teile.tsx` (`Logo`, `Stempel`, `Pfeil` unverändert verschoben; `BaeckereiSeite.tsx` importiert sie), `src/components/home/demos/zeiten/ablauf.ts`
- Test: `src/test/demo-zeiten.test.tsx`
**Interfaces:**
- Produces: `type ZeitenPhase = "start"|"lauf"|"tippen"|"leiste"`; `type ZeitenZustand = { phase, uhr /* Minuten ab Mo 5.10.2026 00:00 */, status: { offen, text, kurz }, leiste: boolean, klick: number, bedient: boolean, laeuft: boolean, statisch: boolean, setzeUhr: (min: number|null) => void }`; Konstanten `ZEIT_MIN=300`, `ZEIT_MAX=1320`, `ZEIT_SCHRITT=10`, `START_UHR=350`, `ENDE_UHR=1090`, `HINWEIS_TEXT="Betriebsurlaub 12.–26. Oktober"`, `ZEITEN_START`, `ZEITEN_RUNDE_MS`, `HINWEIS_ANLEGEN_PUNKT`; `statusFuer(minute) → status` (Bäckerei Mo 6–18 Uhr); `formatUhr(min) → "05:50"`; `useZeitenAblauf(laeuft, statisch) → ZeitenZustand`.
- [ ] **Step 1: Failing tests** (Muster `demo-pinnwand.test.tsx`, `vi.useFakeTimers`)
```tsx
it('statusFuer deckt jede Minute ab, nie „24“', …) // für m in ZEIT_MIN..ZEIT_MAX: text nicht /24/
it('05:50 geschlossen, 06:00 geöffnet, 18:00 geschlossen · öffnet morgen um 6 Uhr', …)
it('Ablauf: Uhr läuft, Finger tippt, Leiste erscheint', …)
it('Bedienen stoppt den Ablauf', …) // setzeUhr(700) ⇒ vi.getTimerCount() === 0, bedient true
it('statisch: Endzustand 18:10 mit Leiste, ohne Timer', …)
it('ablauf.ts importiert kein CSS und keine URL', …)
```
- [ ] **Step 2–4: FAIL → Implementieren → PASS** (`npx vitest run src/test/demo-zeiten.test.tsx`; `demo-chat-vorschau.test.tsx` mit `teile.tsx` in der Scan-Liste grün)
- [ ] **Step 5: Commit** (im Relaunch-Worktree) `"Relaunch: Ablauf der Station Öffnungszeiten live"`
### Task 35: Station einbauen (Seite, Handy, Regler, Bühne)
**Files (Relaunch-Worktree):**
- Create: `src/components/home/demos/zeiten/ZeitenSeite.tsx`, `ZeitenHandy.tsx`, `ZeitenRegler.tsx`, `zeiten.css`
- Modify: `src/components/home/demos/steuerung.tsx` (`ZeitenSteuerung` mit `aktiv`, bei `!aktiv` `setzeUhr(null)`), `src/components/home/Kundenportal.tsx` (`DemoArt` += `"zeiten"`, Eintrag nach der Pinnwand: `id:"oeffnungszeiten"`, `titel:"Öffnungszeiten live"`, `demo:"zeiten"`, `bald:true`, `adresse:"baeckerei-sonnenschein.example"`, `handy:{ name:"phone-12-zeiten", alt:"Öffnungszeiten im Kundenportal bearbeiten" }`; lazy-Importe, `VORLADEN`/`PLATZHALTER` (`"#f7f0e4"`), lokales `HINWEIS_PUNKT`, Zustand, Steuerung, Website- und Handy-Ebene, `.kp-regler`, `ZeitenKarte`, `HandyReihe`, Label „Neun Funktionen…“; aus `ablauf.ts` nur `import type`), `src/components/home/kundenportal.css` (`.kp-regler` in die Selektorlisten, `.kp-karte__regler`), Kommentare/Spec-Zählungen („acht Stationen“, „Höchstens 7 Karten“)
- Test: `src/test/demo-zeiten.test.tsx` (erweitern); Pins `src/test/relaunch-kundenportal.test.tsx` (alle Stellen aus dem Anschluss-Datenblatt „relaunch (d) 1“: `TITEL`, `UNTERSCHRIFTEN`, Zählungen 8⇒9, `.kp-bald` 1⇒2, `meldeMarke(7)`⇒`(8)` für „Als App“, `2 + 9` Bilder, Selektorlisten, `.kp-beispiel`, `.kp-karte--demo` 4⇒5, `[data-demo]` 3⇒4)
- [ ] **Step 1: Failing tests**
```tsx
it('Seite: Pille ≥ 30 pt, Leiste ≥ 24 pt, nicht unter dem iPhone', …) // Leiste links von x 1150 oder oberhalb y 410
it('nichts fokussierbar außer genau einem Regler', …)
it('Regler inaktiv: aria-hidden und inert', …)
it('Bewegung nur transform/opacity hinter no-preference', …)
it('Bühne: neun Stationen, Station 8 ist Öffnungszeiten live mit „Bald verfügbar“', …)
```
- [ ] **Step 2–4: FAIL → Implementieren → PASS** (`npm test`; `npx eslint src/components/home`)
- [ ] **Step 5: Bauen und Budget prüfen** – `npm run build && gzip -9 -c dist/assets/index-*.js | wc -c` ≤ 133 120 (≤ +0,7 KB gegenüber 127 638 B, Grenze 130 KB); neue Chunks ≤ 8 KB gz.
- [ ] **Step 6: Commit** (im Relaunch-Worktree) `"Relaunch: Station Öffnungszeiten live"`
### Task 36: Handy-Bild `phone-12-zeiten`
**Files:**
- Fixture-Kopie: `/root/reports/webklar-relaunch-2026-10-01/zeiten-station/fixture/` aus `…/funktionen-demos/fixture/` (`PORTAL` ⇒ `dev-zeiten`, Port 5329 oder frei aus 5326/5327; Profil `baeckerei` mit `features.zeiten`, `zeiten`-Angebot, GET-Mocks `/api/zeiten/:projectId` und `/vorschau` vor dem generischen `/api/`)
- Modify (Relaunch-Worktree): `scripts/relaunch-bilder.py` (`BILDER["phone-12-zeiten"]`), `docs/relaunch/assets/portal/phone-12-zeiten.webp`, `public/relaunch/portal/phone-12-zeiten-780.webp`, `HINWEIS_PUNKT` in `Kundenportal.tsx` aus der gemessenen Knopfmitte
- [ ] **Step 1: Aufnehmen** – Capture nach Muster `capture-funktionen.mjs` (402×874 @2x, feste Uhr Mo 5.10. 17:30): Funktionen → Kachel „Öffnungszeiten live“ → Editor, Knopf „Hinweis-Leiste anlegen“ sichtbar; Knopfmitte als `phone-12-hinweis-punkt.json` speichern.
- [ ] **Step 2: Umwandeln** – `webp-funktionen.mjs` um den Namen ergänzen, `python3 scripts/relaunch-bilder.py phone-12` ⇒ Handy-Bild ≤ 60 KB.
- [ ] **Step 3: Prüfen** – `npm test`, `npm run build`, Durchlauf mit `/root/reports/webklar-relaunch-2026-10-01/vorschau-chat/pruefen/kurz.mjs` (Station sichtbar, Finger trifft den Knopf).
- [ ] **Step 4: Commit** (im Relaunch-Worktree) `"Relaunch: Portal-Bild für Öffnungszeiten live"`
## Block M – Abschluss
### Task 37: Gesamtprüfung und Übergabe
- [ ] **Step 1: Alle Suiten** – Portal `npm test` und `cd runner && npm test`; UI `nice node test/ui/zeiten-embed.ui.mjs`, `nice node test/ui/zeiten-portal.ui.mjs`, `nice node test/ui/funktionen.ui.mjs`; WOMS `npm test` + `nice npx vite build`; Relaunch `npm test` + `npm run build` mit Budget. Erwartet: grün bis auf die bekannten Basis-Kandidaten.
- [ ] **Step 2: Gegen die Spec abhaken** – Abschnitte 1–12 der Spec einzeln gegen Code/Tests prüfen; Lücken als Nacharbeit-Task ergänzen.
- [ ] **Step 3: Livegang-Notiz** schreiben (`docs/superpowers/plans/2026-10-04-oeffnungszeiten-live-livegang.md`), Reihenfolge aus Spec 13.6: nach TP2-Livegang Rebase auf `main`; `node scripts/zeiten-setup.mjs` (vorher `--check`); `node scripts/stripe-products.mjs --pruefen`; `.env`/Compose: `ZEITEN_ENABLED=pilot`, `ZEITEN_PILOT_EMAILS=kenso.gri@gmail.com`, `ZEITEN_KEIN_NEUBAU=emmas-schatzkiste`, Runner `ZEITEN_PUBLIC_BASE` (Vorgabe reicht); Inhaber pusht Portal, WOMS, Relaunch (Relaunch-Station bleibt `bald:true` bis `on`); Probelauf auf `test-website`; Rechtsrunde abgeschlossen ⇒ `on`, Relaunch `bald:false`.
- [ ] **Step 4: Commit** `"Öffnungszeiten: Livegang-Notiz"`