# Spec: Protection Coverage & Streak (DiGA-Kernmetrik) Ersetzt die aktuell **kaputte** Streak-Anzeige (`streaks.current_days` steht fest auf 0, Profile-Page liest `me.streak`=0 + `created_at` als Datum statt des echten Streak-Starts). Neues, optimistisches Modell auf Basis des tatsächlichen **Schutz-Zustands** (VPN-Filter ODER MDM aktiv) statt eines bei jedem Slip auf 0 fallenden Streaks. ## Leitprinzip Optimistisch + motivierend. Kein Wert fällt je auf 0. Ein Cooldown/Disable wird nur zur kleinen „ungeschützt"-Scheibe, frisst nicht den Fortschritt. --- ## 1. Datenmodell — `protection_state_log` (NEU, append-only) Transitions-Log des Schutz-Zustands pro User. | Feld | Typ | Bedeutung | |---|---|---| | `id` | uuid pk | | | `userId` | uuid | | | `active` | boolean | true = Schutz AN, false = AUS — der Zustand **ab** `occurredAt` | | `source` | enum: `vpn` \| `mdm` \| `cooldown_disable` \| `client` \| `system` | woher die Transition kam | | `occurredAt` | timestamptz | Zeitpunkt des Übergangs | | `createdAt` | timestamptz default now | | Index: `(userId, occurredAt)`. **Dedup:** kein neues Event schreiben, wenn `active` == letzter bekannter Zustand des Users. > Alte `streaks` / `streak_events` / `profiles.streak` **NICHT entfernen** (andere > Consumer: coach, scores). Diese Coverage-Schicht ist additiv; nur die Profile-UI > wird umgestellt. --- ## 2. Ingestion (wann wird geloggt) - **Client meldet Übergänge:** `POST /api/protection/event` body `{ active: boolean, source: 'vpn'|'mdm' }`. Aufgerufen aus `useProtectionState`/`lib/protection`, wenn der kombinierte Schutz-Zustand an↔aus kippt. Client-seitig dedupen (nur bei echtem Wechsel). Server dedupt zusätzlich gegen letzten DB-Zustand. - **Server-seitig:** Beim Cooldown-Resolve, wo der Schutz abgeschaltet wird (`api/cooldown/status.get.ts`, dort wird `protectionDisabledAt` gesetzt) → zusätzlich `{ active:false, source:'cooldown_disable' }` ins Log appenden. - **`firstProtectionAt`** (= Tag X) = `occurredAt` des allerersten `active:true`-Events des Users. --- ## 3. Compute — `GET /api/protection/coverage` Read-time, **kein Cron**. ```json { "firstProtectionAt": "2026-05-01T10:00:00Z", // Tag X, oder null "protectedDays": 80, "unprotectedDays": 20, "currentStreakDays": 3, "longestStreakDays": 14 } ``` **Berechnungsregeln:** - Fenster: `firstProtectionAt` (auf UTC-Tagesgrenze normalisiert) → **heute** (UTC). - Aus den geordneten Transitions + `now` als Ende des aktuellen Intervalls die protected/unprotected-Intervalle rekonstruieren. - **Tages-Auflösung, großzügig:** Ein Kalendertag (UTC) zählt als **UNGESCHÜTZT** nur, wenn der Schutz an dem Tag **insgesamt > 6h aus** war. Sonst **GESCHÜTZT**. (Kurze Unterbrechungen killen den Tag nicht.) - `protectedDays` = Anzahl geschützter Tage im Fenster. - `unprotectedDays` = (Tage seit X) − `protectedDays`. - `currentStreakDays` = Anzahl **zusammenhängender** geschützter Tage, die bis heute (bzw. zum letzten Tag) durchlaufen. - `longestStreakDays` = längster je erreichter zusammenhängender geschützter Tage-Run (Rekord). - `firstProtectionAt == null` (nie Schutz aktiviert) → alle Werte 0 / null. --- ## 4. Frontend — Profile Streak-Section (ersetzt alte Logik) Daten via `GET /api/protection/coverage` (React Query). **Alte Logik entfernen** (`profile/index.tsx` Z.181-186: `currentStreak=me.streak`, `streakStartDate=created_at`, `longestDays=currentStreak`). **Layout:** 1. **Half-Donut** (Stil/Komponente wie Mail-Page `MailDistributionChart` mit `hero`): zeigt **NUR** die Verteilung `protectedDays` vs `unprotectedDays` seit Tag X (z.B. 80% / 20%). Center-Label: geschützte Tage (z.B. „127 Tage geschützt") oder Prozent — UI-Entscheidung. 2. **Progress-Bar darunter** = aktuelle Schutzphase → Rekord: - `current < record` → Bar = `currentStreakDays / longestStreakDays`, Text „Noch {record−current} Tage bis zu deinem Rekord". - `current ≥ record` → Bar voll, Text „Neuer Rekord! {current} Tage 🎉" (Rekord zieht live mit). - `record == 0` (noch kein Rekord) → sinnvoller Erst-Zustand (z.B. „Deine erste Schutzphase: {current} Tage"). **i18n:** `%{var}`-Platzhalter (lib/i18n.ts), DE + EN. --- ## 5. Scope / Guards - Backend: Prisma-Schema + **Migration** erstellen, lokal `pnpm build`-verifizieren, **NICHT pushen/deployen ohne User-GO**. - Reporting + Rendering sind Frontend-only (kein Push nötig, landen im nächsten App-Build). - Contract (Feldnamen/Endpunkte oben) ist verbindlich — Backend & UI müssen exakt dagegen bauen.