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>
This commit is contained in:
chahinebrini 2026-06-06 10:54:55 +02:00
parent ac1d33afb8
commit d31e45e2a8
15 changed files with 812 additions and 64 deletions

View File

@ -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}
<StreakSection
currentDays={currentStreak}
longestDays={longestDays}
startDate={streakStartDate}
coverage={coverage ?? null}
cooldowns={cooldownHistory?.items ?? EMPTY_COOLDOWNS}
rawCooldowns={rawCooldowns}
/>

View File

@ -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 (
<View style={{ marginHorizontal: 16, marginTop: 24 }}>
<View
@ -114,7 +141,7 @@ export function StreakSection({ currentDays, longestDays, startDate, cooldowns,
marginBottom: 10,
}}
>
<Ionicons name="flame-outline" size={14} color={colors.textMuted} />
<Ionicons name="shield-checkmark-outline" size={14} color={colors.textMuted} />
<Text
style={{
fontSize: 11,
@ -136,46 +163,116 @@ export function StreakSection({ currentDays, longestDays, startDate, cooldowns,
padding: 16,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 8 }}>
<Text
style={{
fontSize: 36,
color: colors.text,
fontFamily: 'Nunito_800ExtraBold',
}}
>
{currentDays}
</Text>
<Text
style={{
fontSize: 14,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
}}
>
{t('profile.streak_days_protected')}
</Text>
</View>
<Text
style={{
marginTop: 2,
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{t('profile.streak_since', { date: startDate })}
</Text>
<Text
style={{
marginTop: 8,
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{t('profile.streak_longest', { days: longestDays })}
</Text>
{hasData ? (
<>
<View
style={{
flexDirection: 'row',
alignItems: 'flex-end',
gap: 20,
}}
>
<HalfDonut
segments={donutSegments}
centerValue={coverage!.protectedDays}
centerLabel={t('profile.coverage_center_label')}
width={DONUT_WIDTH}
/>
<View style={{ flex: 1, gap: 8, paddingBottom: 8 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: PROTECTED_COLOR }} />
<Text style={{ fontSize: 12, fontFamily: 'Nunito_600SemiBold', color: colors.text }}>
{coverage!.protectedDays} {t('profile.streak_days_protected')}
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: UNPROTECTED_COLOR, borderWidth: 1, borderColor: colors.border }} />
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{coverage!.unprotectedDays} {t('profile.coverage_unprotected_label')}
</Text>
</View>
</View>
</View>
<View style={{ marginTop: 16 }}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: 8,
}}
>
<Ionicons name="flame-outline" size={13} color={colors.textMuted} />
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.8,
}}
>
{t('profile.streak_phase_label')}
</Text>
</View>
<View
style={{
height: 8,
backgroundColor: colors.surfaceElevated,
borderRadius: 4,
overflow: 'hidden',
}}
>
<View
style={{
height: 8,
width: `${Math.round(progressRatio * 100)}%`,
backgroundColor: isNewRecord ? '#f59e0b' : PROTECTED_COLOR,
borderRadius: 4,
}}
/>
</View>
<Text
style={{
marginTop: 6,
fontSize: 12,
color: isNewRecord ? colors.warning : colors.textMuted,
fontFamily: isNewRecord ? 'Nunito_700Bold' : 'Nunito_400Regular',
}}
>
{streakLabel}
</Text>
</View>
</>
) : (
<View style={{ alignItems: 'center', paddingVertical: 12 }}>
<Ionicons name="shield-outline" size={32} color={colors.border} />
<Text
style={{
marginTop: 8,
fontSize: 14,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
textAlign: 'center',
}}
>
{t('profile.coverage_no_data')}
</Text>
<Text
style={{
marginTop: 4,
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
textAlign: 'center',
}}
>
{t('profile.coverage_no_data_hint')}
</Text>
</View>
)}
</View>
<View style={{ marginTop: 16 }}>

View File

@ -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<ProtectionCoverageData>(
'/api/protection/coverage',
);
return { coverage: data, loading, error, reload };
}
export function useDemographics() {
const { data, loading, error, reload } = useFetchOnce<DemographicsResponse>(
'/api/profile/me/demographics',

View File

@ -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<ReturnType<typeof setInterval> | null>(null);
const tickTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const prevCooldownActiveRef = useRef<boolean | null>(null);
const lastReportedActiveRef = useRef<boolean | null>(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 () => {

View File

@ -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",

View File

@ -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",

View File

@ -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");

View File

@ -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

View File

@ -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,

View File

@ -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 };
});

View File

@ -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 onoff. 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 };
});

View File

@ -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<ProtectionCoverage> {
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()),
);
}

View File

@ -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]

View File

@ -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.*

View File

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