Öffnungszeiten live: Design (Entscheidungen Z1–Z23 des Inhabers, Prüfung gegen den Code eingearbeitet)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 22:52:38 +00:00
1 parent 6ae0f67457
commit 360d218d4e
1 file changed
+585
@@ -0,0 +1,585 @@
# Öffnungszeiten live (Funktion im Kundenportal) – Design
Stand: 2026-10-04 · Status: vom Inhaber im Chat freigegeben (Abschnitte 1–5), Spec zur Durchsicht
Branch `feature/oeffnungszeiten` (Worktree `dev-zeiten`), aufgesetzt auf `feature/terminplaner-tp2` (6ae0f67).
WOMS: eigener Worktree auf `feature/terminplaner-tp2` des WOMS-Repos (6f03712), Branch `feature/oeffnungszeiten`.
Ideenfindung und Bewertung: `/root/reports/funktionen-ultra-update-2026-10-04/ideen.md` (21 Ideen, 3 Jurys),
Code-Fakten: `/root/reports/funktionen-ultra-update-2026-10-04/design-fakten.md`; Spec-Prüfung gegen den Code
(19 Punkte) eingearbeitet.
Keine Rechtsberatung – Texte gehen vor Livegang in die Rechtsrunde (Abschnitt 9).
## Ziel
Die „Killer-Funktion“ des Ultra-Updates im Funktionen-Tab. Der Kunde trägt seine Öffnungszeiten **einmal**
im Portal ein. Seine Website zeigt daraufhin überall dieselben Zeiten und einen Live-Status („Jetzt geöffnet ·
schließt um 18 Uhr“). Feiertage des Bundeslands sind schon eingerechnet, Betriebsurlaub und Sondertage
ebenso. Dazu kommen eine zeitgesteuerte Hinweis-Leiste oben auf der Seite, eine Kontakt-Leiste auf dem Handy
(Anrufen, Route, WhatsApp) und Google-Daten im Seitencode. Eine Mail erinnert vor Feiertagen, und ein Knopf
gibt die Zeiten für das Google-Unternehmensprofil aus. Alles läuft auf dem eigenen Server, ohne
Google-Cloud-Projekt und ohne Cookies.
## Entscheidungen (vom Inhaber)
| # | Entscheidung |
|---|---|
| Z1 | Eine einzelne Killer-Funktion statt eines Pakets: **Öffnungszeiten live** (mit Hinweis-Leiste). |
| Z2 | Umfang V1: Kern (Wochenplan mit bis zu 2 Fenstern/Tag, Live-Status, Feiertage des Bundeslands, Ausnahmen) **plus** Hinweis-Leiste, mobile Kontakt-Leiste, Feiertags-Erinnerung mit Google-Hilfe, JSON-LD. |
| Z3 | Google nur ohne Google Cloud. Automatisches Schreiben ins Unternehmensprofil braucht die Business-Profile-API (Cloud-Projekt, Zugangsantrag mit bis zu 14 Tagen Prüfung, vorher 0 Anfragen/Minute, Profil ≥ 60 Tage bestätigt mit Website). Deshalb V1 als Umweg: Kopieren in Googles Reihenfolge + Link zur Profilverwaltung + Erinnerung. Echter Abgleich ist eine mögliche spätere Stufe. |
| Z4 | Preis: **30 € einmalig** für jeden Kunden; mit **laufendem Wartungs-Abo ohne Aufpreis enthalten**. |
| Z5 | Einbau **hybrid**: Hinweis-Leiste, Kontakt-Leiste und JSON-LD kommen über das Skript, das der Runner beim Deploy einsetzt; Zeiten-Tabelle/Status/Badge setzt das Team per WOMS-Ticket an die richtige Stelle und ersetzt die fest eingetragenen Zeiten. |
| Z6 | **Mehrere benannte Zeitpläne** je Website; der Hauptplan steuert Live-Status und Leisten. |
| Z7 | Abo-Kunden **schalten selbst ein**; der Klick legt das Einbau-Ticket an. |
| Z8 | Launch-Zugabe: nur eine **Live-Station auf der Relaunch-Seite**, keine Ankündigungsmail, kein Portal-Banner. |
| Z9 | Ansatz A: **eigenes Modul** (eigene Collection, eigene öffentliche Schnittstelle, eigenes Skript), kein allgemeiner Inhalts-Baukasten, nicht aus den Terminplaner-Zeiten abgeleitet. |
| Z10 | Gebaut **auf dem TP2-Branch** (Funktions-Register `config.features`); live zusammen mit oder direkt nach TP2. |
| Z11 | Abo endet bei Gratis-Freischaltung: Mail mit „für 30 € behalten“, nach **30 Tagen** ohne Zahlung abgeschaltet, Daten noch 30 Tage, auf der Seite bleibt die Ersatz-Tabelle. |
Vom Inhaber in den Abschnitten 1–5 mitgetragene Festlegungen, die ich vorgeschlagen habe:
| # | Festlegung |
|---|---|
| Z12 | Bezahlen ist gesperrt, solange ein Wartungs-Abo läuft und nicht gekündigt ist; ein gekündigtes Abo führt auf den 30-€-Weg. |
| Z13 | Der Editor ist ab Bestellen/Einschalten offen und wird nie gesperrt. |
| Z14 | Hinweis-Leiste **schiebt den Inhalt** (liegt nicht darüber); kein Schließen-Knopf (keine Speicherung im Browser). |
| Z15 | Kontakt-Leiste bleibt automatisch aus, wenn die Seite schon eine eigene feste Anruf-Leiste hat. |
| Z16 | JSON-LD erzeugt das Skript im Browser: vorhandener LocalBusiness-Knoten wird ergänzt, sonst ein kleiner Block angelegt. Kein Neubau je Zeitenänderung. |
| Z17 | In jedem Platzhalter bleibt eine **statische Ersatz-Tabelle** stehen (ohne JavaScript, nach Abschaltung). Sie zeigt den Stand beim Einbau; Pflege siehe 4.5. |
| Z18 | Erinnerung **7 Tage** vorher, eine Mail für alle Tage im Fenster; Heiligabend, Silvester und landesweit geführte, aber örtlich nicht überall geltende Feiertage als „bitte prüfen“. |
| Z19 | „Im Wartungs-Abo enthalten“ ist ein **freiwilliges Extra**, keine Änderung der Abo-Bedingungen. |
| Z20 | Im Preis (und im Abo ohne Anrechnung aufs Stundenkontingent) ist **ein Einbau je Website** enthalten; Sonderwünsche nach Angebot/Stunden. |
| Z21 | Relaunch-Station: 4. Eintrag der Funktionen-Gruppe, „Bald verfügbar“ bis zum Portal-Livegang, kein Preis, Uhr-Regler bedienbar mit Automatiklauf. |
| Z22 | Schalter `ZEITEN_ENABLED=off|pilot|on` mit Pilotliste `ZEITEN_PILOT_EMAILS` wie beim Terminplaner. |
| Z23 | „Route“ öffnet Google Maps (`https://www.google.com/maps/dir/?api=1&destination=…`, erst beim Klick). |
## 1. Bausteine
1. **Register-Eintrag `zeiten`** in `config.features` (TP2): Katalogpreis `zeitenSetup` (3000 Cent, einmalig),
`requiresWartungsAbo: false`, `ticketFreischaltung: true`, neu `aboInklusive: true`, `consentFrei`
(Zustimmung für den Gratisweg), `teilweiseLive: ['setup', 'active']`; Eintrag in `STANDARD_HAKEN`
(`featureRegistry.js`) mit `verfuegbar` (Schalter/Pilot) und `freischaltbar` (4.4).
2. **Collections** `oeffnungszeiten` und `zeitenErinnerungen`; neue Felder an `portalFeatures`.
3. **Portal-Server:** Dienst `server/services/zeiten/` (Zeitlogik, Speichern, öffentliche Antwort, Freischalten,
Abgleich, Erinnerung), Kundenrouten `server/routes/zeiten.js`, öffentliche Route
`server/routes/publicZeiten.js`, Gratisweg `POST /api/funktionen/zeiten/einschalten`, Behalten-Checkout.
4. **Portal-Oberfläche:** Kachel im Funktionen-Tab (`zeitenAngebot` in `GET /api/funktionen`) und
Editor-Ansicht `public/zeiten*.js`.
5. **Website-Skript** `public/embed/zeiten.js` (ohne Abhängigkeiten).
6. **Runner:** zweite Einsetzung neben der Analyse, Feld `zeiten` im Build-Auftrag.
7. **WOMS:** Funktionen-Register, Team-Aktionen und -Einstellungen, Protokoll-Label, Rechtliches-Karte.
8. **Mails** K24–K27, Register-Texte für K15/K16/T16, Team-Ticket „Ersatz-Tabelle aktualisieren“.
9. **Rechtstexte** (Abschnitt 9) und **Relaunch-Station** (Abschnitt 10, eigenes Repo).
## 2. Daten
### 2.1 `oeffnungszeiten` (neu)
Dokument-ID = ID des zugehörigen `portalFeatures`-Dokuments (TP2-Konvention, `featureDocId(projectId,
'zeiten')`). Felder: `customerId`, `projectId`, `featureId`, `version` (Integer), `daten` (JSON-String,
≤ 32 KB), `teamVersion` (Integer), `team` (JSON-String, nur über WOMS änderbar), `ersatzHash` (String, Hash des
Hauptplan-Wochenplans beim letzten Ersatz-HTML), `updatedAt`, `updatedBy` (`kunde` | `team:<staffId>`).
Rechte wie die übrigen Funktions-Collections (nur Server-Key, Label-Team). Alle Schreibzugriffe laufen unter
`withLocks(['zeiten:<projectId>'])`; Versionen werden innerhalb der Sperre geprüft und hochgezählt.
`daten`:
```
{
land: 'RP', // Bundesland-Kürzel (16); Vorgabe RP
plaene: [{ id, kurz, name, haupt: true|false, // kurz = unveränderlicher Kurzname [a-z0-9-]{1,24}, im Editor sichtbar
tage: { 1..7: { modus: 'zu'|'zeiten'|'rund'|'vereinbarung',
fenster: [['09:00','12:30'], ['13:30','18:00']] } } }],
feiertage: { 'YYYY-MM-DD': { modus, fenster } }, // nur Abweichungen von der Vorgabe
ausnahmen: [{ id, art: 'urlaub'|'sondertag', von, bis, text, modus, fenster, plan: null|planId }],
hinweise: [{ id, art: 'info'|'urlaub'|'aktion'|'wichtig', text, knopf: null|{ art: 'link'|'anruf', ziel, text },
von, bis }], // von/bis = Kalendertage (YYYY-MM-DD), Berliner Zeit
kontakt: { an: bool, telefon, adresse, whatsapp: null|{ nummer, starttext, bestaetigtAt } }
}
```
`team`: `{ navSelektor: null|string, kontaktleisteAus: bool, hinweisleisteAus: bool }`.
**Grenzen und Prüfregeln** (Server, Fehler je Feld):
- Höchstens 5 Pläne, genau ein Hauptplan, der letzte Plan ist nicht löschbar.
- Zeiten `HH:MM` in 5-Minuten-Schritten; je Tag ≤ 2 Fenster ohne Überschneidung; `bis == von` ungültig;
`bis < von` (über Mitternacht) nur beim letzten Fenster des Tages und nur, wenn es nicht in das erste
Fenster des Folgetags hineinragt.
- Ausnahmen ≤ 100, Hinweise ≤ 20; Urlaub `von ≤ bis`, höchstens 366 Tage; „Aktion“ braucht `bis`.
Vergangene Ausnahmen/Hinweise und vergangene `feiertage`-Schlüssel (> 30 Tage) entfernt der Server beim
Speichern.
- Längen: Hinweistext ≤ 140, Plan-/Ausnahmetext ≤ 60, Knopftext ≤ 30, Starttext ≤ 200, Adresse ≤ 200,
Telefon ≤ 30, Link ≤ 300 Zeichen.
- `knopf.ziel` bei `link`: `new URL` muss gelingen, nur `https:`, ohne Benutzer/Passwort; bei `anruf`: nur
Ziffern, Leerzeichen, `+`, `/`, `-`. Telefon und WhatsApp-Nummer werden auf Ziffern (mit führendem `49`
für deutsche Nummern) normalisiert.
- `navSelektor` (nur Team): ≤ 120 Zeichen, nur `[A-Za-z0-9 #.\-_>:\[\]="']`, kein `<`, `{`, `}`, `;`.
- Schreib-Limit 60 Speichervorgänge/Stunde je Kunde (`writeLimiter`).
### 2.2 `zeitenErinnerungen` (neu)
Dokument-ID = sha1(`featureId|datum`) gekürzt; Felder `featureId`, `datum`, `createdAt`. Anlegen mit 409 =
„schon erinnert“. Der Tagesjob löscht Merker, deren Datum > 30 Tage zurückliegt.
### 2.3 `portalFeatures` (Erweiterung, Schema-Skript `feature-setup.mjs`)
- `unlockedBy` (String 8): `kauf` | `abo` | `team`. `unlockedByPurchase` bleibt und wird abgeleitet
(`kauf` ⇒ true, sonst false).
- `aboEndeAt` (Datetime): Zeitpunkt, an dem der Abgleich das Ende des Abos erkannt hat.
- `einbau` (String 12): `angestossen` | `manuell` (Ergebnis des Neubau-Anstoßes, 4.6).
- `behaltenSessionId` (String 128): offene Behalten-Zahlung (3.3).
- `k24At` (Datetime): Marker der K24 (Selbstheilung, 3.2).
- `featureView` liefert zusätzlich `unlockedBy`, `aboEndeAt`, `einbau`, `loeschungAm` (auch für `zeiten`).
## 3. Portal
### 3.1 Kachel „Öffnungszeiten live“
`GET /api/funktionen` liefert `zeitenAngebot` je Projekt (analog `termineAngebot`), mit
`bestellbar: null` bei einem Abo-Lesefehler (Kachel zeigt dann „Gerade nicht prüfbar – bitte später erneut“).
- **off, Wartungs-Abo läuft, nicht gekündigt, `active`:** „Im Wartungs-Abo enthalten“ · Haken
Leistungsbeschreibung · Knopf **„Einschalten“** (3.2).
- **off, Wartungs-Abo läuft, nicht gekündigt, aber nicht `active` (Probezeit, Zahlung offen):** „Im
Wartungs-Abo enthalten – einschaltbar, sobald dein Abo aktiv ist“; kein Bezahlen.
- **off, sonst** (kein Wartungs-Abo, Hosting-Abo, Kündigung vorgemerkt): „30 € einmalig“ · Bestellseite aus
dem Register (Haken Leistungsbeschreibung und Beginn-Verlangen) · **„Zahlungspflichtig bestellen · 30 €“**.
- **ordered:** „Zahlung wird bestätigt …“.
- **setup:** je nach `einbau`: „Hinweis- und Kontakt-Leiste erscheinen nach dem nächsten Veröffentlichen
(meist in wenigen Minuten)“ bzw. „Unser Team baut die Funktion ein (Ticket #…)“; bei leeren Plänen
zusätzlich „Trag jetzt deine Zeiten ein – erst dann kann das Team die Tabelle einbauen“ ·
**„Zeiten bearbeiten“**.
- **active:** Kurzstatus der Website („Jetzt geöffnet · schließt um 18 Uhr“) · **„Zeiten bearbeiten“**.
- **active/setup mit `aboEndeAt`:** zusätzlich „Dein Wartungs-Abo ist beendet – behalte die Funktion bis
<Datum> für einmalig 30 €“ · Knopf zum Behalten-Checkout (3.3).
- **disabled:** Grundtext aus `disabledMessage`; bei `abo_ende` bis zur Löschung Knopf
„Für 30 € wieder einschalten“ (3.3).
Bei `ZEITEN_ENABLED=off` erscheint die Kachel nicht; bei `pilot` nur für die Pilotliste.
### 3.2 Gratisweg `POST /api/funktionen/zeiten/einschalten`
Vorbild `activateAnalyse` (`server/services/analyse.js`). Stufen in dieser Reihenfolge, gleiche Fehlertexte
wie der Checkout: Kundenansicht (viewAs ⇒ 403, Mitarbeiter schalten nicht für den Kunden ein) → Zustimmung
(`consentFrei`, Fassung) → eigenes, nicht archiviertes Projekt → Schalter/Pilot → Unterlagen bereit. Dann unter
der Sperre `feature-order`:
1. Abo frisch prüfen: `bestellbar()` direkt (ohne Memo). `kein_abo`/`gekuendigt` ⇒ 409 mit Hinweis auf den
30-€-Weg; `nicht_aktiv` ⇒ 409 „sobald dein Abo aktiv ist“; Lesefehler ⇒ 503, nie stillschweigend erlaubt.
2. Steht das Dokument schon auf `setup`/`active` mit `unlockedBy: 'abo'` ⇒ **idempotent**: fehlende Schritte
(Ticket, K24) nachholen, 200 mit aktuellem Zustand. Andere Zustände außer `off` ⇒ 409
`orderedConflictMessage`.
3. Zustimmung speichern (`speichereZustimmung` ohne `stripeSessionId`).
4. Feature-Dokument auf `setup`, `unlockedBy: 'abo'`.
5. Neubau anstoßen (4.6) und `einbau` setzen.
6. Einbau-Ticket anlegen (Register-Text mit Ergebnis aus Schritt 5), dann K24 (Text nach `einbau`), dann
`k24At` – jeweils erst nach Erfolg.
**Selbstheilung:** Der 5-Minuten-Job (`runActivation`) legt für `zeiten` in `setup` ohne `ticketId` das
Ticket nach und sendet K24 nach, wenn `unlockedBy: 'abo'` und `k24At` leer ist. Ein erneuter Klick heilt
ebenso (Schritt 2).
### 3.3 Bezahlweg und Behalten
**Neubestellung** über den TP2-Checkout je Funktion; der Register-Eintrag liefert Preis, Zustimmung, Ticket-
und Mailtexte. Zusatz: Wartungs-Abo läuft und ist nicht gekündigt (`bestellbar` ok oder Grund `nicht_aktiv`)
⇒ 409 `inklusive` („Im Wartungs-Abo bereits enthalten – einfach einschalten“).
**Behalten** (neuer Modus, nicht über `markOrdered`/`markPaid` der Neubestellung, weil diese bei
`setup`/`active`/`disabled` mit 409 bzw. Anomalie antworten):
- `POST /api/funktionen/zeiten/behalten` (eigener Kunde, kein viewAs; nur wenn `aboEndeAt` gesetzt oder
`disabled` mit `abo_ende` und Daten vorhanden): gleiche Zustimmung wie die Neubestellung, Stripe Checkout
mit Metadata `zweck: 'behalten'`, `behaltenSessionId` am Dokument. **Der Zustand bleibt unverändert**
(Leisten bleiben während der Zahlung live).
- Webhook-Zweig `zweck: 'behalten'` unter `feature-admin:<projectId>`: Session passt zu
`behaltenSessionId` ⇒ `unlockedBy: 'kauf'`, `aboEndeAt` leer, `behaltenSessionId` leer; war das Dokument
`disabled`, zurück auf `active` (Ticket war geschlossen) bzw. `setup`, Neubau anstoßen (4.6).
**Kein neues Einbau-Ticket.** K15 mit Behalten-Wortlaut. Passt die Session nicht (schon behalten, Daten
gelöscht) ⇒ T19 „Erstattung fällig“ wie bei anderen Anomalien.
### 3.4 Editor (Ansicht `#zeiten`)
Oben eine **Live-Vorschau**: „So steht es gerade auf deiner Seite: *Jetzt geöffnet · schließt um 18 Uhr*“
(dieselbe Serverlogik) und ein Regler **„Uhrzeit testen“** (Tag + Uhrzeit, `GET /api/zeiten/:projectId/
vorschau?zeit=…`). Vor dem ersten Speichern: leerer Hauptplan und „Trag deine Öffnungszeiten ein“.
Darunter sechs Bereiche:
1. **Zeitpläne:** Hauptplan „Öffnungszeiten“ (Kurzname `oeffnungszeiten`), „Plan hinzufügen“ (Name, der
Kurzname wird einmalig daraus gebildet und angezeigt), Hauptplan wechseln, Plan löschen (nicht den letzten,
nicht den Hauptplan). Je Wochentag: Haken „geöffnet“, zwei `<input type="time" step="300">`-Paare,
Auswahl „rund um die Uhr“ / „nach Vereinbarung“.
2. **Feiertage:** Bundesland-Auswahl; Liste der Feiertage der nächsten 12 Monate mit Namen; je Eintrag
„geschlossen“ (Vorgabe) oder eigene Zeiten. „Bitte prüfen“ (Vorgabe: Wochenplan, nicht „geschlossen“):
Heiligabend, Silvester sowie Feiertage, die `feiertagejs` landesweit führt, die aber nur örtlich gelten
(Mariä Himmelfahrt in BY, Fronleichnam in SN und TH). Hinweis: Gemeinde-Feiertage sind nicht enthalten.
3. **Ausnahmen:** Betriebsurlaub (von–bis, Text) und Sondertag (Datum, geschlossen oder Zeiten); Geltung
„alle Pläne“ (Vorgabe) oder ein Plan; Liste kommend/vergangen. Beim Speichern eines Urlaubs ≥ 2 Tage:
„Hinweis-Leiste dazu anlegen?“ (füllt Text und Zeitraum vor; sichtbar ab 7 Tage vor Beginn bis Urlaubsende).
4. **Hinweis-Leiste:** Text, Art (Info/Urlaub/Aktion/Wichtig mit festen Farben), optionaler Knopf
(Link `https://…` oder Anrufen), Zeitraum von–bis (Kalendertage); „Aktion“ verlangt ein Enddatum. Enthält
der Text „%“ oder „statt“: Hinweis zu Preisangaben (Endpreise, bei Waren niedrigster Preis der letzten
30 Tage). Liste geplanter Hinweise.
5. **Kontakt-Leiste (Handy):** Ein/Aus; Telefon; Adresse für „Route“; WhatsApp optional mit Nummer,
Starttext und Pflicht-Haken „Ich habe den WhatsApp-Hinweis in meine Datenschutzerklärung aufgenommen“
(Zeitpunkt in `bestaetigtAt`; in der Kundenansicht setzen Mitarbeiter den Haken **nicht** – 403 wie
`routes/termine.js`).
6. **Google:** Knopf **„Für Google kopieren“** (Zwischenablage: Montag–Sonntag im Format
„Montag: 09:00–12:30, 13:30–18:00“, „geschlossen“, danach Sonderzeiten der nächsten 60 Tage) und Link
„Google-Profil öffnen“ (`https://business.google.com/`). Text: Die Google-Daten im Seitencode laufen
automatisch.
Speichern: `PUT /api/zeiten/:projectId` mit `version`; veraltete Version ⇒ 409 „Inzwischen geändert – bitte
neu laden“. Mitarbeiter können über die Kundenansicht speichern; Protokoll
`VIEW_ACTIONS += 'zeiten_gespeichert'` (Test-Pin und WOMS-`viewLog.js`-Label anpassen).
## 4. Website
### 4.1 Öffentliche Schnittstelle `GET /api/public/zeiten/v1/:siteKey`
CORS `*`, nur GET, `Cache-Control: public, max-age=60`, weiches ETag über den Body **ohne** `stand`,
Speichercache 30 s je siteKey als **LRU mit höchstens 500 Einträgen** (auch für „unbekannt“), 120
Anfragen/Minute je IP, `Access-Control-Expose-Headers: Date`. siteKey-Format wird vor jedem Appwrite-Zugriff
geprüft. Unbekannter Schlüssel, Schalter aus, Kunde gelöscht oder Zustand nicht in `setup`/`active` ⇒
`{ aktiv: false }`. Keine internen IDs, keine Kundendaten außer den veröffentlichten Kontaktangaben.
Antwort (gekürzt):
```
{ aktiv: true, stand: <ISO>,
plaene: [{ kurz, name, haupt,
woche: [{ tag: 1, modus, fenster }],
tage: [{ datum, art: 'regulaer'|'feiertag'|'urlaub'|'sondertag', name, modus, fenster }], // 60 Tage
wechsel: [{ ab: <epoch-ms>, offen: true|false|null, text, kurz }] }], // 8 Tage
hinweise: [{ art, text, knopf, ab: <epoch-ms>, bis: <epoch-ms>, rang }], // aktuell gültige + Beginn in 8 Tagen
kontakt: null|{ telefon, routeUrl, whatsappUrl },
jsonld: { openingHoursSpecification: [...], specialOpeningHoursSpecification: [...] },
firma: { name, adresse },
team: { navSelektor, kontaktleisteAus, hinweisleisteAus } }
```
- `hinweise[].ab/bis` rechnet der Server aus den Kalendertagen in Berliner Zeit (Beginn 00:00, Ende 24:00).
Der Browser zeigt zu jedem Zeitpunkt den gültigen Hinweis mit dem höchsten `rang`
(Wichtig > Urlaub > Aktion > Info; bei Gleichstand späterer Beginn).
- `firma.name` = Firmenname des Kunden; fehlt das Kundendokument, Projektname, sonst leer (nie 500).
`firma.adresse` = `kontakt.adresse`.
- Solange keine Zeiten gespeichert sind, ist `plaene` leer: kein Status, kein JSON-LD, Platzhalter behalten
ihr Ersatz-HTML; Leisten erscheinen nur, wenn eingerichtet.
- `whatsappUrl` = `https://wa.me/<ziffern>?text=<encodeURIComponent(starttext)>`; `routeUrl` nach Z23.
### 4.2 Skript `zeiten.js`
IIFE ohne Abhängigkeiten, Schutz gegen doppeltes Laden (`window.__wkZeiten`). siteKey aus `data-site` des
eigenen Script-Tags oder aus `[data-webklar-zeiten]`; API-Basis aus `data-api` oder der Herkunft des Skripts.
**Ein** Abruf je Seite für alle Teile. Der Browser rechnet keine Feiertage und keine Zeitzonen: Er wählt aus
`wechsel` den letzten Eintrag mit `ab ≤ jetzt`, stellt einen Timer auf den nächsten Wechsel (Statuswechsel
oder Hinweis-Beginn/-Ende), lädt höchstens alle 10 Minuten und bei `visibilitychange` neu. „Jetzt“ =
`Date.now()`; weicht die Uhr laut `Date`-Header um mehr als 5 Minuten ab, rechnet das Skript mit der
Serverzeit. Schlägt der Abruf fehl oder sind die Daten älter als 24 h: Leisten aus, Platzhalter behalten ihr
Ersatz-HTML. Keine Cookies, kein Storage, keine Anfragen an Dritte; Kundentexte nur über `textContent`;
Links aus der API werden im Browser erneut geprüft (`https:`/`tel:`), `rel="noopener noreferrer"`;
`querySelectorAll(navSelektor)` in try/catch.
### 4.3 Teile auf der Seite
1. **Hinweis-Leiste:** Shadow-DOM-Host als erstes Kind von `body`, schiebt den Inhalt. Feste/klebende
Elemente mit `top: 0` (oder `team.navSelektor`) werden, solange die Leiste sichtbar ist, um
`max(0, Leistenhöhe − scrollY)` nach unten gerückt (Inline-`top`, beim Entfernen zurückgesetzt);
`scroll-padding-top` wird um die Höhe erhöht. Farben je Art mit Kontrast ≥ 4,5:1; `role="region"`,
`aria-label="Hinweis"`. Aus bei `team.hinweisleisteAus`.
2. **Kontakt-Leiste** (nur bei `matchMedia('(max-width: 768px)')`): Shadow DOM, unten fest, `z-index: 40`,
Abstandhalter am Ende von `body` in Leistenhöhe, `scroll-padding-bottom`; Knöpfe Anrufen (`tel:`), Route,
WhatsApp – erst der Klick öffnet den fremden Dienst. Aus, wenn `team.kontaktleisteAus` oder die Seite
schon ein festes Element am unteren Rand mit `a[href^="tel:"]` hat.
3. **JSON-LD:** Gibt es einen `application/ld+json`-Block mit einem Knoten vom Typ LocalBusiness oder einem
Untertyp (auch in `@graph`), ersetzt das Skript dort nur `openingHoursSpecification` und
`specialOpeningHoursSpecification` (Text des Blocks per `JSON.stringify` neu, unparsbare Blöcke bleiben
unberührt); sonst legt es einen Block `{ @type: LocalBusiness, name, address?, url, … }` an. Regeln:
Fenster über Mitternacht mit `closes < opens`; „rund um die Uhr“ 00:00–23:59; „nach Vereinbarung“ wird
ausgelassen; Sonderzeiten der nächsten 60 Tage mit `validFrom`/`validThrough`, geschlossen als
`00:00`–`00:00`.
4. **Platzhalter** (vom Team gesetzt; das Skript füllt sie ab `setup`, damit das Team vor dem Schließen des
Tickets prüfen kann):
`<div data-webklar-zeiten data-modus="tabelle|status|badge" data-plan="<kurz>">Ersatz-HTML</div>`.
- *Tabelle:* echtes `<table>` mit Caption, Zeilenköpfe `th scope="row"`, heutiger Tag mit
`aria-current="date"`, darunter „Abweichungen in den nächsten 14 Tagen“.
- *Status:* Punkt + Text („Jetzt geöffnet · schließt um 18 Uhr“), kein `aria-live`.
- *Badge:* Kurzform („Geöffnet“ / „Geschlossen“ / „Nach Vereinbarung“).
Light DOM mit Präfix `wkz-`, `@layer wkz`, Variablen `--wkz-*`; erbt Schrift und Farben der Seite.
Unbekannter `data-plan`: Platzhalter bleibt beim Ersatz-HTML, Konsole-Warnung (kein stilles Umschalten
auf den Hauptplan). Bei `aktiv: false` bleibt das Ersatz-HTML unverändert stehen.
Barrierefreiheit: Tippflächen ≥ 44 px, sichtbarer Fokus, `prefers-reduced-motion` (keine Einblend-Animation).
### 4.4 Freischalten (Ticket schließen ⇒ `active`)
Der vorhandene 5-Minuten-Job setzt `active`, wenn das Ticket geschlossen ist **und** `freischaltbar`
(Register-Haken) erfüllt ist: Hauptplan gespeichert und mindestens ein Tag nicht „zu“. Sonst bleibt `setup`,
WOMS zeigt „wartet auf die Zeiten des Kunden“. Bei `active`: K16, `ersatzHash` setzen.
### 4.5 Ersatz-HTML
`GET /api/team/funktionen/:customerId/zeiten/ersatz?projectId=…&plan=<kurz>` liefert eine schlichte
Wochentabelle des Plans als ASCII mit Entities (`&auml;`, `&#8211;`); bei leeren Plänen 409. Das Team setzt sie
beim Einbau in jeden Platzhalter. Ändert sich der Wochenplan des Hauptplans später, bleibt das Ersatz-HTML
zunächst stehen (mit Skript unsichtbar). Wird die Funktion **abgeschaltet** und weicht der aktuelle Wochenplan
von `ersatzHash` ab, legt der Abschalt-Pfad ein WOMS-Ticket „Ersatz-Tabelle aktualisieren“ mit dem aktuellen
Ersatz-HTML an (gehört zum enthaltenen Einbau). K27 sagt dem Kunden, dass ab jetzt wieder feste Zeiten auf der
Seite stehen. Statisches Vorbefüllen durch den Runner bei jedem Deploy bleibt V1.1.
### 4.6 Einbau des Skripts
- **Seite läuft über den Runner:** `previewDeploy` liefert im Auftrag `zeiten: { siteKey } | null` über
`buildZeiten(project)` (nur bei `setup`/`active`; nie im Dev-Pfad – dort wie `analytics` auf `undefined`;
`null` für Projekte auf der Sperrliste; `isPublic` nicht nötig; wirft nie). Der Runner prüft das Feld
(Whitelist in `validate.js`, siteKey-Regex) und setzt in derselben Runde wie die Analyse vor `</head>` jeder
HTML-Datei ein:
`<script data-wk-zeiten src="<ZEITEN_PUBLIC_BASE>zeiten.js" data-site="<siteKey>" defer></script>`
(neue Runner-Variable `ZEITEN_PUBLIC_BASE`, Vorgabe `https://project.webklar.com/embed/`;
`injectIntoHtml` wird auf eine Marke je Tag verallgemeinert, heute fest `data-wk-analytics`).
- **Neubau anstoßen** bei jedem Übergang in einen ausgebenden Zustand (`setup`/`active`) aus `off`, `ordered`
oder `disabled` (Gratisweg, Zahlung, Team-Aktivierung, Abo-Rückkehr, Behalten nach Abschaltung):
`triggerRebuild` (Muster `analyseDeps.rebuild`), **serialisiert** über eine Portal-weite Sperre
`zeiten-neubau` (höchstens ein angestoßener Neubau gleichzeitig). Ergebnis in `einbau`: `angestossen`
(Auftrag angenommen) oder `manuell` (kein Repo, Preview aus, Dev-Modus, Sperrliste, Fehler oder Runner
ausgelastet). Änderungen an Zeiten/Leisten brauchen **keinen** Neubau. Beim Abschalten kein Pflicht-Neubau;
die API antwortet `aktiv: false`, das Tag verschwindet beim nächsten Deploy.
- **Sperrliste** `ZEITEN_KEIN_NEUBAU` (Subdomains; Vorgabe: Emmas Seiten, deren Dateien teilweise nicht in
Git liegen): weder angestoßener Neubau noch Einsetzen bei normalen Deploys (`buildZeiten` ⇒ `null`); das
Team setzt die Zeile von Hand ins Repo.
- **Seite liegt woanders** oder `einbau: manuell`: Das Ticket enthält die Einbau-Zeile; das Team setzt sie ein.
- **Ticket-Inhalt** (Register `ticket.details`): siteKey, Einbau-Zeile, Ergebnis `einbau`, Beispiele der
Platzhalter mit Plan-Kurznamen, Link auf das Ersatz-HTML (nach dem ersten Speichern des Kunden), Prüfliste:
feste Zeiten im Repo suchen und ersetzen (`grep -ril "öffnungszeit"` u. Ä., auch Footer, Kontaktseite,
eigene Status-Skripte), Header-Überlappung der Hinweis-Leiste prüfen (sonst `navSelektor`), eigene
Anruf-Leiste vorhanden?, vorhandenes JSON-LD, Datenschutz-Absatz auf der Website ergänzt (Abschnitt 9).
## 5. Zustände und Abläufe
```
off ──Checkout──▶ ordered ──Zahlung──▶ setup ──Ticket zu + freischaltbar──▶ active
├──Einschalten (Abo)───────────────▶ setup
└──Team „Aktivieren ohne Zahlung“───▶ setup
setup/active ──Abo-Ende + 30 Tage ohne Zahlung (nur abo) / Team „Abschalten“──▶ disabled
disabled (abo_ende) ──30 Tage──▶ Daten gelöscht, state off
```
- Die öffentliche Schnittstelle liefert in `setup` und `active` dasselbe (`teilweiseLive`);
`findActiveBySiteKey` bekommt eine Zustandsliste. `active` heißt „Einbau abgeschlossen“ (Ticket zu, K16).
- **Team** (WOMS): „Aktivieren ohne Zahlung“ läuft für `zeiten` über denselben Pfad wie der Gratisweg
(`setup`, `unlockedBy: 'team'`, Neubau, Ticket; Zustimmung entfällt, Aktion protokolliert) statt über
`adminActivate` (setzt heute direkt `active`). „Abschalten“ ⇒ `disabled`, Grund `team`, ohne Löschfrist.
`activate()` und jede Wiederherstellung räumen `aboEndeAt` und `behaltenSessionId` ab; eine Neubestellung
nach Datenlöschung setzt `ticketId`, `einbau`, `k24At`, `ersatzHash` zurück.
- **Abo-Abgleich** (eigener Tagesjob, nicht an `TERMINE_ENABLED` gekoppelt), nur `unlockedBy === 'abo'`,
Abo-Lage über `aboLage()` (Fehler ⇒ Kunde überspringen, nie abschalten):
1. Abo läuft nicht mehr und `aboEndeAt` leer ⇒ `aboEndeAt = jetzt`, **K26** (Variante nach `art`:
beendet / Wechsel zu Hosting / Wechsel widerrufen; Behalten-Link, Frist = `aboEndeAt` + 30 Tage).
2. `aboEndeAt` + 30 Tage erreicht ⇒ `disabled`, `disabledReason: 'abo_ende'`, **K27**, ggf. Ticket
„Ersatz-Tabelle aktualisieren“ (4.5).
3. Abo läuft wieder, solange die Daten noch da sind (also bis `aboEndeAt` + 60 Tage) ⇒ `aboEndeAt` leer,
Zustand zurück (`active`, wenn das Ticket geschlossen ist, sonst `setup`), Neubau bei Rückkehr aus
`disabled`, K24-Variante „wieder eingeschaltet“.
4. `disabled` mit `abo_ende` + 30 Tage ⇒ `daten`, `team` und Merker gelöscht, Feature-Dokument
`state: 'off'`. Bei Grund `team` keine automatische Löschung.
K16 kann nach K26 noch kommen (Ticket wird während der Frist geschlossen); das ist gewollt.
- **Kunde gelöscht** (`customerAdmin.js`, Löschpfad): `zeiten` wird wie `termine` abgeschaltet;
`oeffnungszeiten`, `zeitenErinnerungen` und der Cache-Eintrag werden gelöscht. `retention.js` bekommt
`zeiten` (Löschfristen wie oben).
## 6. Zeitlogik (Server, `server/services/zeiten/zeitlogik.js`)
Reine Funktionen auf Basis von `termine/zeit.js` (Europe/Berlin über `Intl`) und einem erweiterten
`termine/feiertage.js` (neu: `feiertagName(tag, land)` und `feiertageImZeitraum(von, bis, land)` über
`feiertagejs.getHolidays(jahr, land)` und die Methode `translate('de')` der Feiertags-Objekte). Der Browser
rechnet nichts davon nach.
- **Tagesauflösung** je Plan und Datum, Vorrang (spezifisch vor allgemein):
Sondertag > Betriebsurlaub > Feiertag (Abweichung oder Vorgabe) > Wochenplan. Bei gleicher Art schlägt eine
planbezogene Ausnahme eine für alle Pläne; zwei gleichrangige überlappende Ausnahmen: die später angelegte.
Bewusst anders als `slots.js` (dort schlägt Abwesenheit die Sonderöffnung), weil ein einzelner geöffneter
Tag im Urlaub hier gewollt ist.
- **Fenster** gehören zum Starttag; `bis < von` heißt „bis zum Folgetag“. Ein über Mitternacht laufendes
Fenster folgt der Regel seines **Starttags**, auch wenn der Folgetag Feiertag, Urlaub oder geschlossen ist
(Bar hat Samstag 18–02 offen, Sonntag ist Feiertag ⇒ bis 02:00 offen). Der Status zu einem Zeitpunkt prüft
die Fenster des Tages **und** das über Mitternacht laufende Fenster des Vortags. Aneinander grenzende Fenster
(auch über Tage, z. B. zweimal „rund um die Uhr“) werden vor der Textbildung zusammengefügt.
- **Sommerzeit:** Fenster sind Wanduhrzeiten. `zuUtc` liefert in der März-Lücke `null`; die Zeitlogik
rückt dann selbst auf die erste existierende Minute danach vor (03:00). In der doppelten Oktober-Stunde gilt
das erste Vorkommen (Verhalten von `zuUtc`).
- **Texte** (ohne Anrede):
- offen: „Jetzt geöffnet · schließt um 18 Uhr“ / „… um 18:30 Uhr“; Schluss um 00:00 des Folgetags:
„… schließt um Mitternacht“; Schluss mehr als 24 h entfernt: „Jetzt geöffnet · bis Dienstag 14 Uhr“;
durchgehend ≥ 7 Tage: „Rund um die Uhr geöffnet“. Nie „24 Uhr“.
- zu: „Geschlossen · öffnet heute um 14 Uhr“ / „morgen um 9 Uhr“ / „Montag um 9 Uhr“ (≤ 6 Tage) /
„am 18.08. um 9 Uhr“; keine Öffnung in 60 Tagen: „Derzeit geschlossen“. Grund an geschlossenen Tagen:
„Heute geschlossen (Betriebsurlaub bis 17.08.)“, „(Feiertag: Tag der Deutschen Einheit)“.
- nach Vereinbarung: „Heute nach Vereinbarung“ (`offen: null`).
- kurz: „Geöffnet“ / „Geschlossen“ / „Nach Vereinbarung“.
- **`wechsel`**: alle Statuswechsel der nächsten 8 Tage ab Beginn der aktuellen Minute; zusätzlich ein Eintrag
zu jeder Tagesgrenze, an der sich der Text ändert („morgen“ → „heute“).
## 7. Erinnerung und Mails
**Feiertags-Erinnerung** (Job in `server/index.js`, stündlich, nur 08:00–20:00 Berliner Zeit,
Überlappsperre, nur bei Schalter `pilot`/`on` und nur für freigegebene Kunden): Für jedes Dokument in
`setup`/`active` alle Tage in 1–7 Tagen, die ein Feiertag des Landes oder ein „bitte prüfen“-Tag (3.4) sind.
Nicht erinnert wird, wenn der Tag im Hauptplan ohnehin geschlossen ist, eine Ausnahme ihn abdeckt oder der
Kunde für den Tag schon eine Abweichung gespeichert hat. Für die übrigen Tage: erst Merker anlegen (409 ⇒
überspringen), dann **eine** Mail **K25** mit allen neuen Tagen („Stimmen deine Zeiten an diesen Tagen?“,
Knopf zum Editor, Hinweis „bei Google auch eintragen“). Lieber einmal zu wenig als doppelt; kein BCC ans Team.
**Mails** (im Design der Mailvorlagen vom 27.09., Texte in die Mailrunde des Inhabers):
| ID | Anlass |
|---|---|
| K15/K16/T16 | Wie gehabt, Texte aus dem Register (Kauf, bereit, Team); T16 beim Gratis-/Team-Weg mit Zusatz „im Wartungs-Abo enthalten“ bzw. „vom Team freigeschaltet“; K15 mit Behalten-Wortlaut (3.3). |
| K24 (neu) | Im Abo eingeschaltet; Text nach `einbau` („Leisten erscheinen nach dem nächsten Veröffentlichen“ / „das Team baut sie ein“), Tabelle folgt per Ticket; Variante „wieder eingeschaltet“. |
| K25 (neu) | Feiertags-Erinnerung. |
| K26 (neu) | Wartungs-Abo beendet (Varianten nach `art`): Funktion läuft noch bis <Datum>, „für einmalig 30 € behalten“. |
| K27 (neu) | Abgeschaltet: Leisten sind weg, auf der Seite stehen wieder feste Zeiten; Daten noch 30 Tage, Wiedereinschalten für 30 €. |
Mail-Pin-Test (K01–K27) und der Block `funktionen` in K02/K03 werden angepasst.
## 8. WOMS (eigener Worktree auf WOMS `feature/terminplaner-tp2`)
- `src/lib/funktionenView.js`: Eintrag `zeiten` (Name, Zustandstexte, „Leisten live – Einbau läuft“ bzw.
„wartet auf die Zeiten des Kunden“ für `setup`, Abo-Ende-Hinweis mit Frist, `loeschungAm`), `siteKeyOf` um
`zeiten` erweitert, Test-Pin angepasst.
- Funktionen-Panel: Aktionen „Aktivieren ohne Zahlung“ / „Abschalten“; Team-Einstellungen `navSelektor`,
`kontaktleisteAus`, `hinweisleisteAus` (`PATCH /api/team/funktionen/:customerId/zeiten/team` mit
`teamVersion`); Knopf „Ersatz-HTML kopieren“ je Plan.
- `viewLog.js`: Label für `zeiten_gespeichert`.
- Rechtliches: Karte „Leistungsbeschreibung Öffnungszeiten live“ über `scripts/setup-legal.mjs` (nach den
Terminplaner-Karten aus TP2 F14; setzt TP2 Block F voraus).
## 9. Rechtstexte
Grundsatz Z19: freiwilliges Extra, Abo-Bedingungen bleiben unverändert (kein § 15-Verfahren, kein P10-Risiko).
1. **Leistungsbeschreibung „Öffnungszeiten live“** (eigenständig, Gerüst der Analyse-Leistungsbeschreibung):
Leistung; automatischer Einbau nur bei WEBklar-Hosting, sonst durch das Team; Preis 30 € ohne
Umsatzsteuer nach § 19 UStG, mit laufendem Wartungs-Abo ohne Aufpreis und ohne Anrechnung aufs
Stundenkontingent („gehört nicht zum Leistungsumfang der Abo-Bedingungen; Leistungsbeschreibung B Nr. 3
gilt dafür nicht; der Abo-Preis ändert sich nicht“); ein Einbau je Website inkl. Aktualisierung der
Ersatz-Tabelle beim Abschalten, Sonderwünsche nach Angebot/Stunden; nicht enthalten: Übertragung zu
Google, Gemeinde-Feiertage; Pflichten des Kunden (Zeiten, Feiertage und Hinweistexte prüfen,
Preise/Rabatte rechtmäßig angeben, Datenschutz-Absatz auf der Website); **Haftung** (Angaben vom Kunden,
Live-Anzeige als Hilfsmittel, Softwarefehler inkl. Feiertagsdaten nur im Rahmen der üblichen Grenzen –
spiegelt Abo § 13 Abs. 1 und 2, damit Kunden ohne Abo abgedeckt sind); Ende der Leistung (Abo-Ende nach Z11)
und Einstellung/Änderung nach Vorbild Analyse Nr. 8.
2. **Zustimmung:** bezahlt mit Beginn-Verlangen (§ 356 Abs. 5 Nr. 2 BGB, Belehrung über TP2-Baustein F3);
gratis nur Haken für die Leistungsbeschreibung, Knopf „Einschalten“; Nachweis in beiden Fällen.
3. **Datenschutz Portal:** neuer § 6d (gespeicherte Angaben, Erinnerungsmail, Art. 6 Abs. 1 lit. b,
Löschfristen); § 6 um „Öffnungszeiten“ im Kundenauftrag erweitert.
4. **Absatz für die Kundenseite** (`docs/funktionen/website-datenschutz-absatz.md`, Varianten A/B):
Abruf von project.webklar.com (IP 14 Tage im Server-Log, keine Cookies, nichts im Browser gespeichert);
WhatsApp (Meta) und Google Maps erst nach Klick.
5. **AVV:** mit Abo reicht Anlage 1 (Server-Protokolle); ohne Abo/bei Fremdhosting gleiche Lücke wie bei
der News-Pinnwand ⇒ Frage ins Kanzlei-Paket (⚖ „AVV Funktionen“ per Haken?).
6. **Mitkorrektur News** (TP2 F2): „inkl. USt.“ → § 19 UStG, § 356 Abs. 4 → Abs. 5.
Reihenfolge der Rechtsrunde (gemeinsam mit TP2): gemeinsame Bausteine (TP2 Block F) → Leistungsbeschreibung →
Datenschutz § 6d/§ 6 und Website-Absatz → Oberflächen- und Mailtexte (Mailrunde) → ⚖-Fragen ans Kanzlei-Paket
→ Inhaber lädt Fassungen in WOMS „Rechtliches“ hoch → Schalter `on`.
## 10. Relaunch-Station (Repo webklar.com, eigener Branch ab `main`)
`main` des Relaunch-Repos ist live (Push = Deploy) – Arbeit auf eigenem Branch im eigenen Worktree, der Inhaber
pusht.
- `Kundenportal.tsx` `FUNKTIONEN`: 4. Eintrag nach der News-Pinnwand, `demo: 'zeiten'`, `bald: true`
(bis Portal-Livegang), kein Preis. Indizes 0–6 bleiben, „Als App“ rückt auf 8.
- Neuer Ordner `src/components/home/demos/zeiten/`: `ablauf.ts`, `ZeitenSeite.tsx`, `ZeitenHandy.tsx`,
`ZeitenRegler.tsx`, `zeiten.css`. Gemeinsame Bäckerei-Teile (`Logo`, `Pfeil`, `Stempel`) nach
`baeckerei/teile.tsx`; eigenes Wurzelelement ohne `.bk-inhalt` (sonst greift `--kp-v`).
- **MacBook:** Bäckerei Sonnenschein mit großer Status-Pille (≥ 30 pt) und Hinweis-Leiste
„Betriebsurlaub 12.–26. Oktober“ (≥ 24 pt, nicht unter dem iPhone). Demo-Datum Mo 5. Okt. 2026 (liegt im
7-Tage-Vorlauf des Urlaubs).
- **Handy:** Portal-Editor-Shot `phone-12-zeiten` (Fixture-Kopie mit Portal aus `dev-zeiten`, Profil
`baeckerei` mit `features.zeiten`, fester Uhrzeit, freier Port aus 5326/5327/5329), Finger tippt
„Hinweis-Leiste anlegen“, danach gleitet die Leiste auf der Seite ein.
- **Uhr-Regler:** echtes `<input type="range">` mit Label, Tippfläche ≥ 44 px; Automatiklauf
Mo 05:50 → 18:10 („Jetzt geöffnet“ → „Geschlossen · öffnet morgen um 6 Uhr“), danach Hinweis-Leiste; stoppt
bei Bedienung; bei reduzierter Bewegung fester Endzustand.
- Animation nur über `transform`/`opacity`; keine `https://` in Demo-Dateien; kein „24 h“; kein
Google-Ranking-Versprechen; WhatsApp nur als Text/Piktogramm ohne Logo.
- Budget: Haupt-JS ≤ +0,7 KB gz (Grenze 130 KB), neue Chunks ≤ 8 KB gz, Handy-Bild ≤ 60 KB.
- Tests: neuer `demo-zeiten.test.tsx` (Endzustand, nichts fokussierbar außer Regler, Status zu jeder Uhrzeit);
`relaunch-kundenportal.test.tsx`: `TITEL`, Karten-Obergrenze, `ziele`-Länge, Bilderzahl („2 + n“),
Handy-Bildzählung und `meldeMarke`-Indizes („Als App“ 7 → 8) aus der Stationsliste ableiten bzw. anpassen;
Spec-Text „höchstens 7 Karten“ anpassen.
- Das Handy-Bild entsteht erst, wenn der Portal-Editor steht.
## 11. Fehlerfälle
| Fall | Verhalten |
|---|---|
| API nicht erreichbar / Daten > 24 h alt | Leisten aus, Platzhalter zeigen Ersatz-HTML. |
| Browser-Uhr falsch (> 5 min laut `Date`) | Serverzeit wird verwendet. |
| Gleichzeitiges Speichern (Kunde + Team) | 409, Editor lädt neu und zeigt die neue Fassung. |
| Neubau nicht möglich / Sperrliste / Fremdhosting | `einbau: manuell`, Ticket enthält Einbau-Zeile; Kachel und K24 sagen „das Team baut ein“. |
| Ticket-Anlage oder K24 scheitert beim Gratisweg | Zustand bleibt `setup`; 5-Minuten-Job bzw. erneuter Klick holt nach. |
| Ticket geschlossen, Kunde hat keine Zeiten eingetragen | bleibt `setup`, WOMS „wartet auf die Zeiten des Kunden“. |
| Abo endet während `setup` | Abgleich wie 5 (Frist läuft unabhängig vom Einbau). |
| Behalten-Zahlung nach Datenlöschung | T19 „Erstattung fällig“. |
| Abo-Lesefehler | Einschalten 503, Kachel „gerade nicht prüfbar“, Abgleich überspringt den Kunden. |
| Hauptplan löschen | nicht möglich; erst anderen Plan zum Hauptplan machen. |
| Platzhalter mit unbekanntem `data-plan` | Ersatz-HTML bleibt, Konsole-Warnung. |
| Skript zweimal eingebunden (Runner + manuell) | zweiter Lauf beendet sich sofort. |
| Beliebige siteKeys (Abtasten) | Format-Prüfung, LRU-Cache mit Obergrenze, Rate-Limit. |
| Kunde gelöscht | API `aktiv: false`, Daten gelöscht (5). |
| Projekt archiviert | Funktion läuft weiter, solange `setup`/`active` (wie News); Neubestellung gesperrt. |
## 12. Tests
- **Zeitlogik** (`test/zeitenZeitlogik.test.js`): 29.03.2026 (Fenster 02:00–04:00, Öffnung 02:30 ⇒ 03:00),
25.10.2026 (Fenster 02:00–06:00, „schließt um 02:30“); Fenster 18:00–02:00 (Status um 01:00 am Folgetag;
Feiertag/Urlaub am Folgetag ⇒ trotzdem offen bis 02:00; Sondertag am Folgetag); Mo+Di rund um die Uhr (kein
„24 Uhr“, Wechsel am Ende von Dienstag); Feiertag an geschlossenem Tag; Feiertag mit Abweichung; „bitte
prüfen“-Tage (Heiligabend, Mariä Himmelfahrt BY); Urlaub über Feiertag; Sondertag im Urlaub (Sondertag
gewinnt); überlappende Urlaube; Urlaub über den Jahreswechsel; „nach Vereinbarung“ (Status `null`, nicht im
JSON-LD); mehrere Pläne mit planbezogenen Ausnahmen; Hauptplan wechseln; Texte
„heute/morgen/Wochentag/Datum/Derzeit geschlossen/Mitternacht“; `wechsel` über 8 Tage; Hinweis-`ab/bis`
über Sommerzeitgrenzen.
- **Prüfregeln:** Fenster-Regeln, Längen, Link-/Telefon-/Selektor-Regeln, Aufräumen vergangener Einträge.
- **Abläufe:** Gratisweg (alle Stufen, `kein_abo`/`gekuendigt`/`nicht_aktiv`, Lesefehler 503, Doppelklick
idempotent, Selbstheilung Ticket/K24 im Job), Bezahlweg mit 409 `inklusive`, Team-Aktivierung über den
Gratis-Pfad, Ausgabe in `setup` wie in `active`, leere Pläne, `freischaltbar`-Gate, Neubau bei jedem
Übergang in einen ausgebenden Zustand und Serialisierung, Abo-Abgleich (K26-Varianten, 30 Tage, K27, Ticket
Ersatz-Tabelle nur bei Abweichung, Rückkehr bis Tag 60, Löschung nur bei `abo_ende`), Behalten (Zustand
bleibt während der Zahlung, kein neues Ticket, Session-Abgleich, T19 nach Löschung), Kunde gelöscht,
Erinnerung genau einmal (Neustart zwischen Merker und Mail, Bundeslandwechsel, mehrere Tage in einer Mail,
keine Mail bei geschlossenem Tag/Ausnahme/Abweichung).
- **Schnittstelle:** `aktiv:false`-Fälle, ETag/304 trotz `stand`, LRU-Grenze, Rate-Limit, keine internen IDs,
`firma` ohne Kundendokument.
- **Runner:** Einsetzen beider Tags in einem Durchlauf, idempotent, Whitelist, Dev-Pfad ohne `zeiten`,
Sperrliste ohne Einsetzen.
- **Skript** (Playwright, Testseiten): fester Header (Rücken), klebender Header, eigene Anruf-Leiste
(Kontakt-Leiste aus), vorhandenes JSON-LD (ergänzt, kein Duplikat, unparsbarer Block unberührt), kein JSON-LD
(Block neu), `aktiv:false` (Ersatz-HTML bleibt), unbekannter `data-plan`, keine Cookies/kein Storage,
doppeltes Laden, Handy- und Desktop-Breite, reduzierte Bewegung, ungültiger `navSelektor`.
- **Editor** (UI-Test mit Fixture): Pläne mit Kurznamen, Fenster über Mitternacht, Validierungsfehler je Feld,
Urlaub ⇒ Hinweis-Vorschlag, „Für Google kopieren“, Vorschau-Regler, Kundenansicht ohne WhatsApp-Haken.
- **WOMS:** Register-Pin, Zustandstexte, Team-Einstellungen, viewLog-Label.
- Bestehende Suiten bleiben grün (Portal `npm test` inkl. Mail-Pin und `customerViewLog`-Pin, WOMS `npm test`
+ `vite build`, Relaunch-Tests).
## 13. Reihenfolge und Livegang
1. Spec (diese Datei) → Durchsicht durch den Inhaber.
2. Umsetzungsplan (`docs/superpowers/plans/2026-10-04-oeffnungszeiten-live.md`) → Freigabe.
3. Umsetzung in `dev-zeiten` (Portal) und einem WOMS-Worktree auf `feature/terminplaner-tp2` mit
Sonnet-Agenten; regelmäßig auf die TP2-Branches rebasen. Die TP2-Worktrees `dev-termine` werden nicht
angefasst.
4. Relaunch-Station auf eigenem Branch im Relaunch-Repo.
5. Rechtsrunde gemeinsam mit TP2 (Abschnitt 9).
6. Livegang nach TP2: Rebase auf `main` (mit TP2), Schema-Skripte (`feature-setup.mjs`, neue Collections),
Stripe-Katalog prüfen (`scripts/stripe-products.mjs --pruefen`, `PRODUCT_METADATA`), Runner-Variable
`ZEITEN_PUBLIC_BASE` und Sperrliste setzen, Inhaber pusht, `ZEITEN_ENABLED=pilot` (Testkonto), Probelauf auf
einer Testseite, dann `on`; Relaunch-Station `bald: false`.
## 14. Nicht im Umfang (V1)
Automatischer Abgleich mit dem Google-Unternehmensprofil (API/Cloud); statisches Vorbefüllen der Platzhalter
durch den Runner bei jedem Deploy (V1.1); Gemeinde-Feiertage; Schließen-Knopf der Hinweis-Leiste; Übernahme
der Zeiten in den Terminplaner; mehrere Hinweise gleichzeitig; Mehrsprachigkeit; Ankündigungsmail/Portal-Banner
(Z8).