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>
4.5 KiB
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.streakNICHT 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/eventbody{ active: boolean, source: 'vpn'|'mdm' }. Aufgerufen aususeProtectionState/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 wirdprotectionDisabledAtgesetzt) → zusätzlich{ active:false, source:'cooldown_disable' }ins Log appenden. firstProtectionAt(= Tag X) =occurredAtdes allererstenactive: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 +
nowals 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:
- Half-Donut (Stil/Komponente wie Mail-Page
MailDistributionChartmithero): zeigt NUR die VerteilungprotectedDaysvsunprotectedDaysseit Tag X (z.B. 80% / 20%). Center-Label: geschützte Tage (z.B. „127 Tage geschützt") oder Prozent — UI-Entscheidung. - 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.