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>
93 lines
4.5 KiB
Markdown
93 lines
4.5 KiB
Markdown
# 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.
|