From 385f768d765e95785426b722a35d83508282357a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 23:19:34 +0000 Subject: [PATCH] =?UTF-8?q?=C3=96ffnungszeiten=20live:=20Umsetzungsplan=20?= =?UTF-8?q?(37=20Tasks=20in=20Bl=C3=B6cken=20A=20bis=20M)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-04-oeffnungszeiten-live.md | 1036 +++++++++++++++++ 1 file changed, 1036 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-04-oeffnungszeiten-live.md diff --git a/docs/superpowers/plans/2026-10-04-oeffnungszeiten-live.md b/docs/superpowers/plans/2026-10-04-oeffnungszeiten-live.md new file mode 100644 index 0000000..ed64f67 --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-oeffnungszeiten-live.md @@ -0,0 +1,1036 @@ +# Ö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::` (Einschalten/Checkout), `feature-admin:` (Team, Abgleich), `zeiten:` (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.test.js`, Testnamen deutsch. Einzeltest: `node --test test/.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 `. +- 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`. + +- [ ] **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 `` mit `
` = Planname, Zeilen Montag–Sonntag; nur ASCII, Umlaute/Gedankenstrich als Entities `ä` `ö` `ü` `ß` `–`); `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–18:00', 'geschlossen' +test('Kundentext wird maskiert', …) // Planname '' ⇒ '<b>' +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..tage.3`, `ausnahmen..von`, `hinweise..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', '', 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` + +- [ ] **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`: `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:`, 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` (≤ `TICKET_DETAILS_MAX`): siteKey, Einbau-Zeile ``, 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` (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::

`; Neubau/Ticket/Mail nach der Sperre). + - `holeZeitenNach(doc, { now }) → Promise` (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` (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` (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 – “ 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:`, `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=`), `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:`: `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) → ''`; `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 ', …) +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 `

`, `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` (`
` mit `#zeiten-body`, ``) +- 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/ { 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..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//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//zeiten/ersatz`, `patchZeitenTeam(customerId, body)` ⇒ `PATCH /funktionen//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"`