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

4.5 KiB
Raw Blame History

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.

{
  "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.