rebreak-monorepo/docs/specs/protection-coverage-streak.md
chahinebrini d31e45e2a8 feat(streak): protection-coverage metric (DiGA core) replacing broken streak
The old streak was non-functional: streaks.current_days was always 0 (never
computed/incremented), and the profile page read me.streak (0) + account
created_at as the "since" date — showing "0 days protected since <signup>"
for everyone. This is the DiGA key metric, so it had to be rebuilt.

New model: optimistic protection-coverage based on actual VPN/MDM protection
state, never resets to 0.
- backend: append-only protection_state_log + migration; POST /api/protection/event
  (ingestion, deduped) + GET /api/protection/coverage (read-time compute, no cron);
  server-side cooldown_disable event on cooldown resolve. Generous >6h-off/day rule.
- frontend: report protection on/off transitions (initial + flips, deduped) from
  useProtectionState; rewrote profile StreakSection → half-donut (protected vs
  unprotected) + progress bar (current streak → personal record) + empty state.
- coverage starts fresh from deploy (no historical backfill — clean data for DiGA).
- spec: docs/specs/protection-coverage-streak.md (shared contract).
- old streaks/streak_events/profiles.streak left intact (coach/scores consumers).

Also adds go-to-market one-pagers under docs/marketing/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 10:54:55 +02:00

93 lines
4.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 {recordcurrent} 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.