diff --git a/apps/rebreak-native/app/profile/index.tsx b/apps/rebreak-native/app/profile/index.tsx
index 1b15992..7f061f8 100644
--- a/apps/rebreak-native/app/profile/index.tsx
+++ b/apps/rebreak-native/app/profile/index.tsx
@@ -22,6 +22,7 @@ import {
useCooldownHistoryFull,
useSosInsights,
useDemographics,
+ useProtectionCoverage,
} from '../../hooks/useProfileData';
import { apiFetch } from '../../lib/api';
import { untrackSelf, retrackSelf } from '../../hooks/useOnlineUsers';
@@ -65,11 +66,6 @@ function formatMemberSince(isoString: string | undefined): string {
return d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
}
-function formatStreakStartDate(isoString: string | undefined): string {
- if (!isoString) return '';
- const d = new Date(isoString);
- return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' });
-}
function mapHelpedBy(helpedBy: {
breathing: number;
@@ -155,6 +151,7 @@ export default function ProfileScreen() {
const { cooldownHistory } = useCooldownHistory();
const { rawCooldowns } = useCooldownHistoryFull();
const { sosInsights } = useSosInsights();
+ const { coverage } = useProtectionCoverage();
const {
demographics: serverDemographics,
withdrawnAt,
@@ -178,14 +175,7 @@ export default function ProfileScreen() {
provider,
};
- const currentStreak = me?.streak ?? 0;
- // TODO(backend): longestDays + streakStartDate fehlen in /api/auth/me.
- // Backend-Agent: Profile-Tabelle braucht longestStreak:Int + streakStartedAt:DateTime.
- // Tracking: streakStartedAt wird bei jedem Streak-Reset auf NOW() gesetzt.
- const longestDays = currentStreak;
- const streakStartDate = formatStreakStartDate(me?.created_at);
-
- const showDigaBanner = currentStreak >= 30 && !bannerDismissed;
+ const showDigaBanner = (coverage?.currentStreakDays ?? 0) >= 30 && !bannerDismissed;
const demoComplete = !withdrawnAt && isDemographicsComplete(demographics);
function scrollToDemographics() {
@@ -266,9 +256,7 @@ export default function ProfileScreen() {
) : null}
diff --git a/apps/rebreak-native/components/profile/StreakSection.tsx b/apps/rebreak-native/components/profile/StreakSection.tsx
index a525129..e65fd77 100644
--- a/apps/rebreak-native/components/profile/StreakSection.tsx
+++ b/apps/rebreak-native/components/profile/StreakSection.tsx
@@ -2,8 +2,10 @@ import { View, Text } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useColors } from '../../lib/theme';
+import { HalfDonut } from '../common/HalfDonut';
import { CooldownPatternAnalysis } from './CooldownPatternAnalysis';
import type { BackendCooldownEntry } from '../../hooks/useProfileData';
+import type { ProtectionCoverageData } from '../../hooks/useProfileData';
export type CooldownEntry = {
id: string;
@@ -15,9 +17,7 @@ export type CooldownEntry = {
};
type Props = {
- currentDays: number;
- longestDays: number;
- startDate: string;
+ coverage: ProtectionCoverageData | null;
cooldowns: CooldownEntry[];
rawCooldowns: BackendCooldownEntry[] | null;
};
@@ -25,6 +25,9 @@ type Props = {
const WEEKS = 8;
const MAX_BAR_HEIGHT = 28;
const MIN_BAR_HEIGHT = 2;
+const DONUT_WIDTH = 180;
+const PROTECTED_COLOR = '#22c55e';
+const UNPROTECTED_COLOR = '#e5e5e5';
function getMondayOfWeek(date: Date): Date {
const d = new Date(date);
@@ -79,7 +82,7 @@ function formatAvg(totalCount: number, language: string): string {
return avg.toFixed(1);
}
-export function StreakSection({ currentDays, longestDays, startDate, cooldowns, rawCooldowns }: Props) {
+export function StreakSection({ coverage, cooldowns, rawCooldowns }: Props) {
const colors = useColors();
const { t, i18n } = useTranslation();
const lang = i18n.language ?? 'de';
@@ -104,6 +107,30 @@ export function StreakSection({ currentDays, longestDays, startDate, cooldowns,
? t('profile.cooldown.avg_last', { avg: avgStr, date: lastDate })
: null;
+ const hasData = coverage !== null && coverage.firstProtectionAt !== null;
+
+ const donutSegments = hasData
+ ? [
+ { value: Math.max(coverage!.protectedDays, 1), color: PROTECTED_COLOR },
+ { value: Math.max(coverage!.unprotectedDays, 0), color: UNPROTECTED_COLOR },
+ ]
+ : [{ value: 1, color: UNPROTECTED_COLOR }];
+
+ const current = coverage?.currentStreakDays ?? 0;
+ const record = coverage?.longestStreakDays ?? 0;
+ const progressRatio = record > 0 ? Math.min(current / record, 1) : 1;
+ const isNewRecord = current >= record && record > 0;
+ const isFirstPhase = record === 0;
+
+ let streakLabel: string;
+ if (isFirstPhase) {
+ streakLabel = t('profile.streak_first_phase', { days: current });
+ } else if (isNewRecord) {
+ streakLabel = t('profile.streak_new_record', { days: current });
+ } else {
+ streakLabel = t('profile.streak_to_record', { days: record - current });
+ }
+
return (
-
+
-
-
- {currentDays}
-
-
- {t('profile.streak_days_protected')}
-
-
-
- {t('profile.streak_since', { date: startDate })}
-
-
- {t('profile.streak_longest', { days: longestDays })}
-
+ {hasData ? (
+ <>
+
+
+
+
+
+
+ {coverage!.protectedDays} {t('profile.streak_days_protected')}
+
+
+
+
+
+ {coverage!.unprotectedDays} {t('profile.coverage_unprotected_label')}
+
+
+
+
+
+
+
+
+
+ {t('profile.streak_phase_label')}
+
+
+
+
+
+
+
+
+ {streakLabel}
+
+
+ >
+ ) : (
+
+
+
+ {t('profile.coverage_no_data')}
+
+
+ {t('profile.coverage_no_data_hint')}
+
+
+ )}
diff --git a/apps/rebreak-native/hooks/useProfileData.ts b/apps/rebreak-native/hooks/useProfileData.ts
index b23d376..0414242 100644
--- a/apps/rebreak-native/hooks/useProfileData.ts
+++ b/apps/rebreak-native/hooks/useProfileData.ts
@@ -168,6 +168,21 @@ type DemographicsResponse = Demographics & {
withdrawnAt: string | null;
};
+export type ProtectionCoverageData = {
+ firstProtectionAt: string | null;
+ protectedDays: number;
+ unprotectedDays: number;
+ currentStreakDays: number;
+ longestStreakDays: number;
+};
+
+export function useProtectionCoverage() {
+ const { data, loading, error, reload } = useFetchOnce(
+ '/api/protection/coverage',
+ );
+ return { coverage: data, loading, error, reload };
+}
+
export function useDemographics() {
const { data, loading, error, reload } = useFetchOnce(
'/api/profile/me/demographics',
diff --git a/apps/rebreak-native/hooks/useProtectionState.ts b/apps/rebreak-native/hooks/useProtectionState.ts
index 4846878..9979ba8 100644
--- a/apps/rebreak-native/hooks/useProtectionState.ts
+++ b/apps/rebreak-native/hooks/useProtectionState.ts
@@ -13,6 +13,15 @@ import type { WebContentFilterResult } from '../modules/rebreak-protection';
const POLL_MS_ACTIVE_COOLDOWN = 5_000;
const POLL_MS_NORMAL = 30_000;
+function isProtectionActive(phase: string): boolean {
+ return phase === 'active' || phase === 'cooldownActive' || phase === 'cooldownPending';
+}
+
+function resolveEventSource(state: ProtectionState): 'vpn' | 'mdm' {
+ if (state.layers.nefilterActive === true || state.mdmManaged) return 'mdm';
+ return 'vpn';
+}
+
type UseProtectionStateReturn = {
state: ProtectionState | null;
loading: boolean;
@@ -62,6 +71,7 @@ export function useProtectionState(): UseProtectionStateReturn {
const pollTimer = useRef | null>(null);
const tickTimer = useRef | null>(null);
const prevCooldownActiveRef = useRef(null);
+ const lastReportedActiveRef = useRef(null);
// Verhindert Mehrfach-Alert wenn fetchState + AppState-Listener beide kurz
// hintereinander applyCooldownDisableIfElapsed → true sehen.
const cooldownDisabledNoticeShownRef = useRef(false);
@@ -200,6 +210,17 @@ export function useProtectionState(): UseProtectionStateReturn {
return () => sub?.remove();
}, [fetchState]);
+ // Report protection-state transitions to the coverage log.
+ // Fires only on genuine active↔inactive flips; deduped via ref.
+ useEffect(() => {
+ if (state === null) return;
+ const active = isProtectionActive(state.phase);
+ if (lastReportedActiveRef.current === active) return;
+ lastReportedActiveRef.current = active;
+ const source = resolveEventSource(state);
+ apiFetch('/api/protection/event', { method: 'POST', body: { active, source } }).catch(() => {});
+ }, [state]);
+
// ─── Public Actions ────────────────────────────────────────────────
const activate = useCallback(async () => {
diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json
index afd4ceb..c50cd69 100644
--- a/apps/rebreak-native/locales/de.json
+++ b/apps/rebreak-native/locales/de.json
@@ -1198,10 +1198,18 @@
"crop_confirm": "Übernehmen",
"crop_hint": "Bewege und zoome das Bild um den gewünschten Ausschnitt zu wählen.",
"crop_reset": "Zurücksetzen",
- "streak_section_label": "STREAK",
+ "streak_section_label": "SCHUTZ-ABDECKUNG",
"streak_days_protected": "Tage geschützt",
"streak_since": "seit %{date}",
"streak_longest": "Längste Streak: %{days} Tage",
+ "coverage_center_label": "Tage geschützt",
+ "coverage_unprotected_label": "ungeschützt",
+ "coverage_no_data": "Noch keine Schutz-Daten",
+ "coverage_no_data_hint": "Aktiviere den Schutz um deine Abdeckung zu verfolgen.",
+ "streak_phase_label": "AKTUELLE SCHUTZPHASE",
+ "streak_to_record": "Noch %{days} Tage bis zu deinem Rekord",
+ "streak_new_record": "Neuer Rekord! %{days} Tage",
+ "streak_first_phase": "Deine erste Schutzphase: %{days} Tage",
"cooldown": {
"heading": "COOLDOWN-VERLAUF",
"window_label": "letzte %{weeks}W",
diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json
index 57043fc..d0a18a3 100644
--- a/apps/rebreak-native/locales/en.json
+++ b/apps/rebreak-native/locales/en.json
@@ -1196,10 +1196,18 @@
"crop_confirm": "Apply",
"crop_hint": "Move and zoom the image to select the desired crop area.",
"crop_reset": "Reset",
- "streak_section_label": "STREAK",
+ "streak_section_label": "PROTECTION COVERAGE",
"streak_days_protected": "days protected",
"streak_since": "since %{date}",
"streak_longest": "Longest streak: %{days} days",
+ "coverage_center_label": "days protected",
+ "coverage_unprotected_label": "unprotected",
+ "coverage_no_data": "No protection data yet",
+ "coverage_no_data_hint": "Activate protection to start tracking your coverage.",
+ "streak_phase_label": "CURRENT PROTECTION PHASE",
+ "streak_to_record": "%{days} days to your record",
+ "streak_new_record": "New record! %{days} days",
+ "streak_first_phase": "Your first protection phase: %{days} days",
"cooldown": {
"heading": "COOLDOWN HISTORY",
"window_label": "last %{weeks}W",
diff --git a/backend/prisma/migrations/20260606_protection_state_log/migration.sql b/backend/prisma/migrations/20260606_protection_state_log/migration.sql
new file mode 100644
index 0000000..611deca
--- /dev/null
+++ b/backend/prisma/migrations/20260606_protection_state_log/migration.sql
@@ -0,0 +1,33 @@
+-- Migration: protection_state_log
+-- Adds append-only protection-state transition log (DiGA-Kernmetrik).
+--
+-- Purpose:
+-- Replaces the broken streaks.current_days=0 metric with a factual,
+-- optimistic coverage model based on actual VPN/MDM protection state.
+-- Drives GET /api/protection/coverage → protectedDays, currentStreakDays,
+-- longestStreakDays, etc.
+--
+-- Design decisions:
+-- - Append-only: never UPDATE rows, only INSERT new transitions.
+-- - Dedup enforced at application layer (server + client both deduplicate).
+-- - source column is VARCHAR (not enum) for forward-compatibility.
+-- - Index on (user_id, occurred_at) covers the primary query pattern:
+-- all events for a user ordered by time.
+--
+-- Non-breaking: purely additive, no existing columns modified.
+--
+-- Deploy: pnpm prisma migrate deploy (on server via GitHub Actions pipeline)
+
+CREATE TABLE "rebreak"."protection_state_log" (
+ "id" UUID NOT NULL DEFAULT gen_random_uuid(),
+ "user_id" UUID NOT NULL,
+ "active" BOOLEAN NOT NULL,
+ "source" VARCHAR(64) NOT NULL,
+ "occurred_at" TIMESTAMPTZ NOT NULL,
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
+
+ CONSTRAINT "protection_state_log_pkey" PRIMARY KEY ("id")
+);
+
+CREATE INDEX "protection_state_log_user_id_occurred_at_idx"
+ ON "rebreak"."protection_state_log" ("user_id", "occurred_at");
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index 22731a2..62f33b5 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -1108,6 +1108,34 @@ model UserDevice {
/// RebreakMagic Pairing — Native-App generiert 6-stelligen Code, Mac-App tauscht
/// gegen MagicSession-Token. Code ist single-use, läuft nach 10min ab.
+// ─── Protection State Log (DiGA-Kernmetrik, additiv) ─────────────────────────
+//
+// Append-only Transitions-Log des Schutz-Zustands pro User.
+// Ein Eintrag beschreibt ab `occurredAt` den neuen aktiven Zustand.
+// Dedup: kein neues Event, wenn active == letztem bekanntem Zustand des Users.
+//
+// Source-Values:
+// 'vpn' — VPN-Filter-App meldet Aktivierung/Deaktivierung
+// 'mdm' — MDM-Profil-Status-Wechsel
+// 'cooldown_disable' — Server: Cooldown abgelaufen, Schutz automatisch AUS
+// 'client' — Generischer Client-Event
+// 'system' — Server-seitig (Migration, Seed, etc.)
+//
+// Nicht-entfernen: Alte streaks/streak_events/profiles.streak bleiben (coach, scores).
+model ProtectionStateLog {
+ id String @id @default(uuid()) @db.Uuid
+ userId String @map("user_id") @db.Uuid
+ /// true = Schutz AN ab occurredAt, false = Schutz AUS ab occurredAt
+ active Boolean
+ source String // 'vpn' | 'mdm' | 'cooldown_disable' | 'client' | 'system'
+ occurredAt DateTime @map("occurred_at") @db.Timestamptz(6)
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
+
+ @@index([userId, occurredAt])
+ @@map("protection_state_log")
+ @@schema("rebreak")
+}
+
model MagicPairingCode {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
diff --git a/backend/server/api/cooldown/status.get.ts b/backend/server/api/cooldown/status.get.ts
index 8c9b026..2870078 100644
--- a/backend/server/api/cooldown/status.get.ts
+++ b/backend/server/api/cooldown/status.get.ts
@@ -2,6 +2,7 @@ import { requireUser } from "../../utils/auth";
import { getActiveCooldown, resolveCooldown } from "../../db/cooldown";
import { signCooldownToken } from "../../utils/cooldownToken";
import { usePrisma } from "../../utils/prisma";
+import { appendProtectionEventDeduped } from "../../db/protectionStateLog";
/** GET /api/cooldown/status — Current cooldown state for the authenticated user. */
export default defineEventHandler(async (event) => {
@@ -59,11 +60,16 @@ export default defineEventHandler(async (event) => {
// Reactivation (Sucht-Recovery-Pattern: einfach an, schwer aus, sehr schwer
// wieder zurück an Auto-Magic). User muss explizit reaktivieren.
const db = usePrisma();
+ const disabledAt = new Date();
await db.profile.update({
where: { id: user.id },
- data: { protectionDisabledAt: new Date() },
+ data: { protectionDisabledAt: disabledAt },
}).catch(() => {});
+ // Protection-state log: record the server-triggered disable so coverage
+ // metrics reflect this gap. Fire-and-forget — never block the response.
+ appendProtectionEventDeduped(user.id, false, "cooldown_disable", disabledAt).catch(() => {});
+
const token = await signCooldownToken(
user.id,
cooldown.tokenJti,
diff --git a/backend/server/api/protection/coverage.get.ts b/backend/server/api/protection/coverage.get.ts
new file mode 100644
index 0000000..19fee32
--- /dev/null
+++ b/backend/server/api/protection/coverage.get.ts
@@ -0,0 +1,25 @@
+import { requireUser } from "../../utils/auth";
+import { computeProtectionCoverage } from "../../db/protectionStateLog";
+
+/**
+ * GET /api/protection/coverage
+ *
+ * Returns protection coverage and streak metrics computed read-time from
+ * the protection_state_log table (no cron, no materialized state).
+ *
+ * Response shape (spec §3):
+ * {
+ * firstProtectionAt: string | null, // ISO-8601, null = never activated
+ * protectedDays: number,
+ * unprotectedDays: number,
+ * currentStreakDays: number,
+ * longestStreakDays: number,
+ * }
+ *
+ * All values are 0 / null when the user has never activated protection.
+ */
+export default defineEventHandler(async (event) => {
+ const user = await requireUser(event);
+ const coverage = await computeProtectionCoverage(user.id);
+ return { success: true, data: coverage };
+});
diff --git a/backend/server/api/protection/event.post.ts b/backend/server/api/protection/event.post.ts
new file mode 100644
index 0000000..5f14a01
--- /dev/null
+++ b/backend/server/api/protection/event.post.ts
@@ -0,0 +1,37 @@
+import { requireUser } from "../../utils/auth";
+import {
+ appendProtectionEventDeduped,
+ type ProtectionSource,
+} from "../../db/protectionStateLog";
+
+const VALID_SOURCES: ProtectionSource[] = ["vpn", "mdm", "client"];
+
+/**
+ * POST /api/protection/event
+ *
+ * Body: { active: boolean, source: 'vpn' | 'mdm' | 'client' }
+ *
+ * Called from the native app (useProtectionState / lib/protection) when the
+ * combined protection state transitions on↔off. The client deduplicates
+ * locally (only fires on real transitions); the server deduplicates again
+ * against the last DB row for the user.
+ *
+ * Returns { success: true, written: true } if a new row was written,
+ * { success: true, written: false } if deduplicated (state unchanged).
+ */
+export default defineEventHandler(async (event) => {
+ const user = await requireUser(event);
+ const body = await readBody(event);
+
+ if (typeof body?.active !== "boolean") {
+ throw createError({ statusCode: 400, message: "active (boolean) required" });
+ }
+
+ const source: ProtectionSource = VALID_SOURCES.includes(body.source)
+ ? (body.source as ProtectionSource)
+ : "client";
+
+ const row = await appendProtectionEventDeduped(user.id, body.active, source);
+
+ return { success: true, written: row !== null };
+});
diff --git a/backend/server/db/protectionStateLog.ts b/backend/server/db/protectionStateLog.ts
new file mode 100644
index 0000000..abdf035
--- /dev/null
+++ b/backend/server/db/protectionStateLog.ts
@@ -0,0 +1,217 @@
+import { usePrisma } from "../utils/prisma";
+
+// ─── Types ────────────────────────────────────────────────────────────────────
+
+export type ProtectionSource =
+ | "vpn"
+ | "mdm"
+ | "cooldown_disable"
+ | "client"
+ | "system";
+
+// ─── Ingestion ────────────────────────────────────────────────────────────────
+
+/**
+ * Returns the most recent ProtectionStateLog entry for a user, or null if
+ * no events have been recorded yet.
+ */
+export async function getLastProtectionEvent(userId: string) {
+ const db = usePrisma();
+ return db.protectionStateLog.findFirst({
+ where: { userId },
+ orderBy: { occurredAt: "desc" },
+ });
+}
+
+/**
+ * Appends a new protection-state transition for a user.
+ * Caller is responsible for deduplication (check getLastProtectionEvent first).
+ */
+export async function appendProtectionEvent(
+ userId: string,
+ active: boolean,
+ source: ProtectionSource,
+ occurredAt?: Date,
+) {
+ const db = usePrisma();
+ return db.protectionStateLog.create({
+ data: {
+ userId,
+ active,
+ source,
+ occurredAt: occurredAt ?? new Date(),
+ },
+ });
+}
+
+/**
+ * Writes a protection-state transition only if it differs from the last
+ * known state for this user. Returns the new row if written, null if deduped.
+ */
+export async function appendProtectionEventDeduped(
+ userId: string,
+ active: boolean,
+ source: ProtectionSource,
+ occurredAt?: Date,
+): Promise<{ id: string } | null> {
+ const last = await getLastProtectionEvent(userId);
+ if (last && last.active === active) {
+ // No state change — skip write.
+ return null;
+ }
+ return appendProtectionEvent(userId, active, source, occurredAt);
+}
+
+// ─── Coverage Compute ─────────────────────────────────────────────────────────
+
+export interface ProtectionCoverage {
+ firstProtectionAt: string | null; // ISO-8601 or null
+ protectedDays: number;
+ unprotectedDays: number;
+ currentStreakDays: number;
+ longestStreakDays: number;
+}
+
+/**
+ * Reads all protection events for a user and computes the coverage/streak
+ * metrics at read-time (no cron needed).
+ *
+ * Algorithm summary:
+ * 1. Fetch all events ordered by occurredAt asc.
+ * 2. firstProtectionAt = occurredAt of first active:true event.
+ * If none → return all-zero response.
+ * 3. Build intervals: [event[i].occurredAt, event[i+1].occurredAt) for each
+ * segment, last segment ends at now().
+ * 4. For each UTC calendar day in [firstProtectionAt..today], compute total
+ * unprotected minutes. If > 6h (360min) → day counts as UNPROTECTED.
+ * 5. currentStreakDays = consecutive protected days running up to today.
+ * 6. longestStreakDays = longest such run in the full history.
+ */
+export async function computeProtectionCoverage(
+ userId: string,
+): Promise {
+ const db = usePrisma();
+
+ const events = await db.protectionStateLog.findMany({
+ where: { userId },
+ orderBy: { occurredAt: "asc" },
+ select: { active: true, occurredAt: true },
+ });
+
+ // Find first active:true event.
+ const firstActiveEvent = events.find((e) => e.active);
+ if (!firstActiveEvent) {
+ return {
+ firstProtectionAt: null,
+ protectedDays: 0,
+ unprotectedDays: 0,
+ currentStreakDays: 0,
+ longestStreakDays: 0,
+ };
+ }
+
+ const firstProtectionAt = firstActiveEvent.occurredAt;
+ const now = new Date();
+
+ // ─── Build timeline of (state, from, to) intervals ───────────────────────
+ // Each event transitions the state FROM that moment forward.
+ // We start from the first active event (ignore any earlier inactive events
+ // before the user ever activated protection — they don't count in the window).
+ //
+ // The state at firstProtectionAt is "active:true" (that's the first event).
+ // We replay all events from that point on.
+
+ // Find the index of the first active event.
+ const startIdx = events.indexOf(firstActiveEvent);
+ const relevantEvents = events.slice(startIdx);
+
+ interface Interval {
+ active: boolean;
+ from: Date;
+ to: Date;
+ }
+
+ const intervals: Interval[] = [];
+ for (let i = 0; i < relevantEvents.length; i++) {
+ const from = relevantEvents[i].occurredAt;
+ const to =
+ i + 1 < relevantEvents.length ? relevantEvents[i + 1].occurredAt : now;
+ intervals.push({ active: relevantEvents[i].active, from, to });
+ }
+
+ // ─── Enumerate UTC calendar days from firstProtectionAt to today ─────────
+ // Normalise to UTC date boundaries.
+ const startDay = utcDayStart(firstProtectionAt);
+ const todayDay = utcDayStart(now);
+
+ // Map each day to total unprotected minutes within that day.
+ const days: boolean[] = []; // true = protected, false = unprotected
+
+ for (
+ let day = new Date(startDay);
+ day <= todayDay;
+ day = new Date(day.getTime() + 86400_000)
+ ) {
+ const dayEnd = new Date(day.getTime() + 86400_000);
+ let unprotectedMs = 0;
+
+ for (const iv of intervals) {
+ if (iv.active) continue; // Protected interval, doesn't add unprotected time.
+
+ // Overlap of this interval with [day, dayEnd)
+ const overlapStart = Math.max(iv.from.getTime(), day.getTime());
+ const overlapEnd = Math.min(iv.to.getTime(), dayEnd.getTime());
+ if (overlapEnd > overlapStart) {
+ unprotectedMs += overlapEnd - overlapStart;
+ }
+ }
+
+ const unprotectedMinutes = unprotectedMs / 60_000;
+ // Day is UNPROTECTED only if total unprotected time > 6h (360 min).
+ days.push(unprotectedMinutes <= 360);
+ }
+
+ // ─── Compute metrics ─────────────────────────────────────────────────────
+ const protectedDays = days.filter(Boolean).length;
+ const totalDays = days.length;
+ const unprotectedDays = totalDays - protectedDays;
+
+ // currentStreakDays: consecutive protected days from the END of the array.
+ let currentStreakDays = 0;
+ for (let i = days.length - 1; i >= 0; i--) {
+ if (days[i]) {
+ currentStreakDays++;
+ } else {
+ break;
+ }
+ }
+
+ // longestStreakDays: longest consecutive run of protected days.
+ let longestStreakDays = 0;
+ let runLength = 0;
+ for (const d of days) {
+ if (d) {
+ runLength++;
+ if (runLength > longestStreakDays) longestStreakDays = runLength;
+ } else {
+ runLength = 0;
+ }
+ }
+
+ return {
+ firstProtectionAt: firstProtectionAt.toISOString(),
+ protectedDays,
+ unprotectedDays,
+ currentStreakDays,
+ longestStreakDays,
+ };
+}
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+/** Returns midnight UTC for the given date (floor to day boundary). */
+function utcDayStart(date: Date): Date {
+ return new Date(
+ Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()),
+ );
+}
diff --git a/docs/marketing/onepager-behoerden.md b/docs/marketing/onepager-behoerden.md
new file mode 100644
index 0000000..774e4a9
--- /dev/null
+++ b/docs/marketing/onepager-behoerden.md
@@ -0,0 +1,78 @@
+# Rebreak — Spielerschutz dort, wo OASIS strukturell nicht greift
+
+**Positionspapier für Regulierungs- und Aufsichtsbehörden**
+(GGL, BfArM, BZgA, Landesstellen)
+
+---
+
+## Ausgangslage
+
+Das deutsche Spielersperrsystem OASIS ist ein wirksames Instrument des
+Spielerschutzes: Anfang 2026 rund 367.000 aktive Sperren, über 5 Mrd.
+Abfragen 2025, betrieben durch das Regierungspräsidium Darmstadt unter
+Aufsicht der GGL. Der ganz überwiegende Teil sind Selbstsperren (~96 %) –
+ein Beleg für die Schutzmotivation der Betroffenen selbst.
+
+**Die strukturelle Lücke:** OASIS verpflichtet ausschließlich in Deutschland
+lizenzierte Anbieter. Im nicht-lizenzierten Online-Markt – nach Schätzungen
+rund 60 % des Online-Glücksspiels (Regulus Partners, 2024), Volumen im
+Milliardenbereich – greift die Sperre nicht. Gesperrte Spieler:innen
+verlagern sich erfahrungsgemäß genau dorthin. Der Schutz endet faktisch an
+der Grenze des lizenzierten Marktes.
+
+## Beitrag von Rebreak
+
+Rebreak ist eine deutsche Anwendung, die **am Endgerät** ansetzt und damit
+eine Schutzebene bietet, die das anbieterseitige Sperrsystem nicht erreichen
+kann:
+
+| Schutzebene | OASIS | Rebreak |
+|---|---|---|
+| Lizenzierte Anbieter | ✅ verpflichtend | ✅ (ergänzend) |
+| Nicht-lizenzierte / Offshore-Anbieter | ❌ kein Zugriff | ✅ geräteseitige Domain-Sperrung |
+| Begleitung im akuten Suchtdruck | ❌ | ✅ KI-Coach + Eskalation an Fachhilfe |
+| Verbleib im Versorgungssystem | indirekt | ✅ Anbindung an Fachstellen angestrebt |
+
+Rebreak versteht sich **nicht als Konkurrenz, sondern als Komplement** zum
+staatlichen Spielerschutz: Es adressiert genau die Verlagerung in den
+nicht-lizenzierten Markt, die das regulatorische Hauptproblem der
+Kanalisierung darstellt.
+
+## Datenschutz – ausdrücklich KEINE Datenbank-Kopplung
+
+Wir betonen bewusst: Rebreak strebt **keine** Anbindung an die OASIS-Datenbank
+oder einen Abgleich von Sperrlisten an. Eine solche Kopplung wäre
+datenschutzrechtlich (Art. 9 DSGVO, besondere Kategorien personenbezogener
+Daten) hochproblematisch und ist nicht unser Ziel. Rebreak arbeitet
+geräteseitig und anonym (Nutzer:innen sind nur über Spitznamen sichtbar,
+keine Klarnamen). Die Zusammenarbeit, die wir suchen, ist **fachlich und
+inhaltlich**, nicht datentechnisch.
+
+## DiGA- und Versorgungsperspektive
+
+Rebreak verfolgt die Aufnahme ins DiGA-Verzeichnis (BfArM, §139e SGB V) als
+digitale Begleitanwendung. Eine Erstattung durch die GKV würde Spielerschutz
+erstmals in die Regelversorgung überführen – niedrigschwellig und
+flächendeckend. Bislang existiert in Deutschland keine DiGA für
+Glücksspielsucht.
+
+## Relevanz für die GlüStV-Evaluierung 2026
+
+Die laufende Evaluierung des Glücksspielstaatsvertrags adressiert die
+Wirksamkeit von Spielerschutz und Kanalisierung. Geräteseitige Schutzlösungen
+wie Rebreak schließen eine benannte Lücke und können einen Beitrag zur
+Diskussion über zeitgemäßen, technologiegestützten Spielerschutz leisten. Wir
+bieten an, hierzu eine ausführliche Stellungnahme bereitzustellen.
+
+## Was wir anbieten / suchen
+
+- Fachlicher Austausch zur Einordnung in den Spielerschutz-Werkzeugkasten
+- Möglichkeit zur Stellungnahme im Rahmen der GlüStV-Evaluierung 2026
+- Mittelfristig: Prüfung von Pilot-/Kooperationsformen (eskalationsstufig,
+ ohne Datenbank-Kopplung)
+
+## Kontakt
+
+**[Name], Gründer Rebreak**
+[E-Mail] · [Telefon] · rebreak.org
+Träger: [Raynis GmbH] · [Anschrift]
diff --git a/docs/marketing/onepager-fachstellen.md b/docs/marketing/onepager-fachstellen.md
new file mode 100644
index 0000000..40dfe9e
--- /dev/null
+++ b/docs/marketing/onepager-fachstellen.md
@@ -0,0 +1,95 @@
+# Rebreak — Digitale Unterstützung bei Glücksspielsucht
+
+**Für Suchtberatungsstellen, Fachkliniken und Selbsthilfe**
+
+---
+
+## Das Problem
+
+Glücksspielsucht ist eine anerkannte Verhaltensstörung mit hoher Rückfallquote.
+Zwischen den Beratungsterminen sind Betroffene mit dem Suchtdruck allein – oft
+nachts, mobil, in genau den Momenten, in denen das Smartphone den schnellsten
+Weg zurück ins Spiel bietet.
+
+Das staatliche Spielersperrsystem OASIS schützt wirksam vor lizenzierten
+Anbietern (Anfang 2026 rund 367.000 aktive Sperren). Es greift jedoch
+strukturell **nicht** im nicht-lizenzierten Online-Glücksspiel, das in
+Deutschland einen erheblichen Marktanteil hat (Schätzungen um 60 %, Regulus
+Partners 2024). Genau dorthin verlagern sich viele Betroffene nach einer
+Sperrung – außerhalb der Reichweite von Beratung und Schutzsystemen.
+
+## Die Lösung: Rebreak
+
+Rebreak ist eine deutsche Smartphone-Anwendung, die Betroffene **zwischen den
+Terminen** begleitet und den digitalen Rückweg ins Spiel erschwert. Sie
+versteht sich als **Ergänzung** der Beratung und Therapie, nicht als deren
+Ersatz.
+
+**Wirkmechanismus – drei Ebenen:**
+
+1. **Technischer Schutz.** Ein geräteweiter Filter blockiert den Zugang zu
+ Glücksspiel-Angeboten – auch zu nicht-lizenzierten Offshore-Seiten
+ (Domain-basierte Sperrung, kontinuierlich gepflegte Sperrliste). Ergänzend
+ ein Schutz vor Werbe- und Köder-Mails von Anbietern.
+2. **Begleitung im Suchtdruck.** Ein KI-gestützter Coach steht rund um die Uhr
+ für akute Druckmomente zur Verfügung – mit deeskalierenden, nicht
+ wertenden Impulsen und der klaren Weiterleitung an menschliche Hilfe bei
+ Krisen.
+3. **Motivation & Struktur.** Fortschritts-Tracking (spielfreie Tage),
+ Bewältigungs-Werkzeuge und eine anonyme Community stärken Selbstwirksamkeit
+ und Dranbleiben.
+
+## Zielgruppe
+
+Erwachsene mit problematischem oder pathologischem Glücksspielverhalten, die
+sich in Beratung/Behandlung befinden oder den Einstieg suchen – sowie deren
+begleitende Fachkräfte als Empfehlende.
+
+## Datenschutz & Ethik – unsere Haltung
+
+Wir behandeln Daten von Suchterkrankten als das, was sie sind: besonders
+schützenswerte Gesundheitsdaten (Art. 9 DSGVO).
+
+- **Anonymität by Design.** Nutzer:innen sind ausschließlich über einen
+ selbst gewählten Spitznamen sichtbar – nie über Klarnamen oder E-Mail.
+ Das schützt vor dem Stigma, das mit Glücksspielsucht verbunden ist.
+- **Datensparsamkeit.** Es werden nur Daten verarbeitet, die für die Funktion
+ notwendig sind. Hosting in Deutschland/EU.
+- **Kein Verkauf, keine Weitergabe** von Nutzungsdaten an Dritte.
+- **Schutz vor Schaden.** Krisen-Eskalation verweist verlässlich an
+ professionelle Hilfe (u. a. BZgA-Beratungstelefon 0800 1 37 27 00).
+
+## DiGA-Ambition
+
+Rebreak verfolgt den Weg zur Aufnahme ins Verzeichnis der Digitalen
+Gesundheitsanwendungen (DiGA) beim BfArM – als digitale Begleitanwendung zur
+Unterstützung bei Glücksspielstörung. Ziel ist die Erstattung durch die
+gesetzlichen Krankenkassen, damit der Zugang nicht an den Finanzen der
+Betroffenen scheitert. Bisher existiert keine DiGA für Glücksspielsucht;
+Rebreak möchte diese Versorgungslücke schließen. Vorbild ist die bereits
+gelistete Alkohol-DiGA *vorvida*.
+
+*Hinweis: Eine DiGA-Listung erfordert eine Wirksamkeitsstudie. Genau dafür
+suchen wir den Austausch und die Begleitung durch die Fachpraxis.*
+
+## Stand der Entwicklung
+
+Die Anwendung ist technisch weit fortgeschritten und funktionsfähig (iOS und
+Android). Als nächster Schritt ist eine **begleitete, geschlossene Testphase
+mit Fachstellen** vorgesehen – mit Schutzkonzept, Einwilligung und
+Krisen-Eskalationspfad.
+
+## Was wir suchen
+
+- Fachlicher Austausch: Passt der Ansatz zur Versorgungsrealität?
+- Pilot-Beratungsstellen für eine begleitete Testphase
+- Hinweise zu Studienpartnern für den DiGA-Weg
+
+## Kontakt
+
+**[Name], Gründer Rebreak**
+[E-Mail] · [Telefon] · rebreak.org
+Träger: [Raynis GmbH] · [Anschrift]
+
+*Rebreak ist eine Versorgungs-Ergänzung. In akuten Krisen wenden Sie sich bitte
+an das BZgA-Beratungstelefon (0800 1 37 27 00) oder im Notfall an die 112.*
diff --git a/docs/specs/protection-coverage-streak.md b/docs/specs/protection-coverage-streak.md
new file mode 100644
index 0000000..f25f36c
--- /dev/null
+++ b/docs/specs/protection-coverage-streak.md
@@ -0,0 +1,92 @@
+# 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.