From d31e45e2a8fa43dc2278cd51587891189fec96c9 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Sat, 6 Jun 2026 10:54:55 +0200 Subject: [PATCH] feat(streak): protection-coverage metric (DiGA core) replacing broken streak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 " 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 --- apps/rebreak-native/app/profile/index.tsx | 20 +- .../components/profile/StreakSection.tsx | 187 +++++++++++---- apps/rebreak-native/hooks/useProfileData.ts | 15 ++ .../hooks/useProtectionState.ts | 21 ++ apps/rebreak-native/locales/de.json | 10 +- apps/rebreak-native/locales/en.json | 10 +- .../migration.sql | 33 +++ backend/prisma/schema.prisma | 28 +++ backend/server/api/cooldown/status.get.ts | 8 +- backend/server/api/protection/coverage.get.ts | 25 ++ backend/server/api/protection/event.post.ts | 37 +++ backend/server/db/protectionStateLog.ts | 217 ++++++++++++++++++ docs/marketing/onepager-behoerden.md | 78 +++++++ docs/marketing/onepager-fachstellen.md | 95 ++++++++ docs/specs/protection-coverage-streak.md | 92 ++++++++ 15 files changed, 812 insertions(+), 64 deletions(-) create mode 100644 backend/prisma/migrations/20260606_protection_state_log/migration.sql create mode 100644 backend/server/api/protection/coverage.get.ts create mode 100644 backend/server/api/protection/event.post.ts create mode 100644 backend/server/db/protectionStateLog.ts create mode 100644 docs/marketing/onepager-behoerden.md create mode 100644 docs/marketing/onepager-fachstellen.md create mode 100644 docs/specs/protection-coverage-streak.md 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.