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:
parent
ac1d33afb8
commit
d31e45e2a8
@ -22,6 +22,7 @@ import {
|
|||||||
useCooldownHistoryFull,
|
useCooldownHistoryFull,
|
||||||
useSosInsights,
|
useSosInsights,
|
||||||
useDemographics,
|
useDemographics,
|
||||||
|
useProtectionCoverage,
|
||||||
} from '../../hooks/useProfileData';
|
} from '../../hooks/useProfileData';
|
||||||
import { apiFetch } from '../../lib/api';
|
import { apiFetch } from '../../lib/api';
|
||||||
import { untrackSelf, retrackSelf } from '../../hooks/useOnlineUsers';
|
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' });
|
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: {
|
function mapHelpedBy(helpedBy: {
|
||||||
breathing: number;
|
breathing: number;
|
||||||
@ -155,6 +151,7 @@ export default function ProfileScreen() {
|
|||||||
const { cooldownHistory } = useCooldownHistory();
|
const { cooldownHistory } = useCooldownHistory();
|
||||||
const { rawCooldowns } = useCooldownHistoryFull();
|
const { rawCooldowns } = useCooldownHistoryFull();
|
||||||
const { sosInsights } = useSosInsights();
|
const { sosInsights } = useSosInsights();
|
||||||
|
const { coverage } = useProtectionCoverage();
|
||||||
const {
|
const {
|
||||||
demographics: serverDemographics,
|
demographics: serverDemographics,
|
||||||
withdrawnAt,
|
withdrawnAt,
|
||||||
@ -178,14 +175,7 @@ export default function ProfileScreen() {
|
|||||||
provider,
|
provider,
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentStreak = me?.streak ?? 0;
|
const showDigaBanner = (coverage?.currentStreakDays ?? 0) >= 30 && !bannerDismissed;
|
||||||
// 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 demoComplete = !withdrawnAt && isDemographicsComplete(demographics);
|
const demoComplete = !withdrawnAt && isDemographicsComplete(demographics);
|
||||||
|
|
||||||
function scrollToDemographics() {
|
function scrollToDemographics() {
|
||||||
@ -266,9 +256,7 @@ export default function ProfileScreen() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<StreakSection
|
<StreakSection
|
||||||
currentDays={currentStreak}
|
coverage={coverage ?? null}
|
||||||
longestDays={longestDays}
|
|
||||||
startDate={streakStartDate}
|
|
||||||
cooldowns={cooldownHistory?.items ?? EMPTY_COOLDOWNS}
|
cooldowns={cooldownHistory?.items ?? EMPTY_COOLDOWNS}
|
||||||
rawCooldowns={rawCooldowns}
|
rawCooldowns={rawCooldowns}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -2,8 +2,10 @@ import { View, Text } from 'react-native';
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useColors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
|
import { HalfDonut } from '../common/HalfDonut';
|
||||||
import { CooldownPatternAnalysis } from './CooldownPatternAnalysis';
|
import { CooldownPatternAnalysis } from './CooldownPatternAnalysis';
|
||||||
import type { BackendCooldownEntry } from '../../hooks/useProfileData';
|
import type { BackendCooldownEntry } from '../../hooks/useProfileData';
|
||||||
|
import type { ProtectionCoverageData } from '../../hooks/useProfileData';
|
||||||
|
|
||||||
export type CooldownEntry = {
|
export type CooldownEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -15,9 +17,7 @@ export type CooldownEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
currentDays: number;
|
coverage: ProtectionCoverageData | null;
|
||||||
longestDays: number;
|
|
||||||
startDate: string;
|
|
||||||
cooldowns: CooldownEntry[];
|
cooldowns: CooldownEntry[];
|
||||||
rawCooldowns: BackendCooldownEntry[] | null;
|
rawCooldowns: BackendCooldownEntry[] | null;
|
||||||
};
|
};
|
||||||
@ -25,6 +25,9 @@ type Props = {
|
|||||||
const WEEKS = 8;
|
const WEEKS = 8;
|
||||||
const MAX_BAR_HEIGHT = 28;
|
const MAX_BAR_HEIGHT = 28;
|
||||||
const MIN_BAR_HEIGHT = 2;
|
const MIN_BAR_HEIGHT = 2;
|
||||||
|
const DONUT_WIDTH = 180;
|
||||||
|
const PROTECTED_COLOR = '#22c55e';
|
||||||
|
const UNPROTECTED_COLOR = '#e5e5e5';
|
||||||
|
|
||||||
function getMondayOfWeek(date: Date): Date {
|
function getMondayOfWeek(date: Date): Date {
|
||||||
const d = new Date(date);
|
const d = new Date(date);
|
||||||
@ -79,7 +82,7 @@ function formatAvg(totalCount: number, language: string): string {
|
|||||||
return avg.toFixed(1);
|
return avg.toFixed(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StreakSection({ currentDays, longestDays, startDate, cooldowns, rawCooldowns }: Props) {
|
export function StreakSection({ coverage, cooldowns, rawCooldowns }: Props) {
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const lang = i18n.language ?? 'de';
|
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 })
|
? t('profile.cooldown.avg_last', { avg: avgStr, date: lastDate })
|
||||||
: null;
|
: 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 (
|
return (
|
||||||
<View style={{ marginHorizontal: 16, marginTop: 24 }}>
|
<View style={{ marginHorizontal: 16, marginTop: 24 }}>
|
||||||
<View
|
<View
|
||||||
@ -114,7 +141,7 @@ export function StreakSection({ currentDays, longestDays, startDate, cooldowns,
|
|||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name="flame-outline" size={14} color={colors.textMuted} />
|
<Ionicons name="shield-checkmark-outline" size={14} color={colors.textMuted} />
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
@ -136,47 +163,117 @@ export function StreakSection({ currentDays, longestDays, startDate, cooldowns,
|
|||||||
padding: 16,
|
padding: 16,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 8 }}>
|
{hasData ? (
|
||||||
<Text
|
<>
|
||||||
|
<View
|
||||||
style={{
|
style={{
|
||||||
fontSize: 36,
|
flexDirection: 'row',
|
||||||
color: colors.text,
|
alignItems: 'flex-end',
|
||||||
fontFamily: 'Nunito_800ExtraBold',
|
gap: 20,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{currentDays}
|
<HalfDonut
|
||||||
</Text>
|
segments={donutSegments}
|
||||||
<Text
|
centerValue={coverage!.protectedDays}
|
||||||
style={{
|
centerLabel={t('profile.coverage_center_label')}
|
||||||
fontSize: 14,
|
width={DONUT_WIDTH}
|
||||||
color: colors.text,
|
/>
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
<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 }} />
|
||||||
{t('profile.streak_days_protected')}
|
<Text style={{ fontSize: 12, fontFamily: 'Nunito_600SemiBold', color: colors.text }}>
|
||||||
|
{coverage!.protectedDays} {t('profile.streak_days_protected')}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text
|
<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={{
|
style={{
|
||||||
marginTop: 2,
|
flexDirection: 'row',
|
||||||
fontSize: 12,
|
alignItems: 'center',
|
||||||
color: colors.textMuted,
|
gap: 8,
|
||||||
fontFamily: 'Nunito_400Regular',
|
marginBottom: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('profile.streak_since', { date: startDate })}
|
<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>
|
</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
|
<Text
|
||||||
style={{
|
style={{
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('profile.coverage_no_data')}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 4,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: colors.textMuted,
|
color: colors.textMuted,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
textAlign: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('profile.streak_longest', { days: longestDays })}
|
{t('profile.coverage_no_data_hint')}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={{ marginTop: 16 }}>
|
<View style={{ marginTop: 16 }}>
|
||||||
<View
|
<View
|
||||||
|
|||||||
@ -168,6 +168,21 @@ type DemographicsResponse = Demographics & {
|
|||||||
withdrawnAt: string | null;
|
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() {
|
export function useDemographics() {
|
||||||
const { data, loading, error, reload } = useFetchOnce<DemographicsResponse>(
|
const { data, loading, error, reload } = useFetchOnce<DemographicsResponse>(
|
||||||
'/api/profile/me/demographics',
|
'/api/profile/me/demographics',
|
||||||
|
|||||||
@ -13,6 +13,15 @@ import type { WebContentFilterResult } from '../modules/rebreak-protection';
|
|||||||
const POLL_MS_ACTIVE_COOLDOWN = 5_000;
|
const POLL_MS_ACTIVE_COOLDOWN = 5_000;
|
||||||
const POLL_MS_NORMAL = 30_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 = {
|
type UseProtectionStateReturn = {
|
||||||
state: ProtectionState | null;
|
state: ProtectionState | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@ -62,6 +71,7 @@ export function useProtectionState(): UseProtectionStateReturn {
|
|||||||
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const tickTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
const tickTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const prevCooldownActiveRef = useRef<boolean | null>(null);
|
const prevCooldownActiveRef = useRef<boolean | null>(null);
|
||||||
|
const lastReportedActiveRef = useRef<boolean | null>(null);
|
||||||
// Verhindert Mehrfach-Alert wenn fetchState + AppState-Listener beide kurz
|
// Verhindert Mehrfach-Alert wenn fetchState + AppState-Listener beide kurz
|
||||||
// hintereinander applyCooldownDisableIfElapsed → true sehen.
|
// hintereinander applyCooldownDisableIfElapsed → true sehen.
|
||||||
const cooldownDisabledNoticeShownRef = useRef(false);
|
const cooldownDisabledNoticeShownRef = useRef(false);
|
||||||
@ -200,6 +210,17 @@ export function useProtectionState(): UseProtectionStateReturn {
|
|||||||
return () => sub?.remove();
|
return () => sub?.remove();
|
||||||
}, [fetchState]);
|
}, [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 ────────────────────────────────────────────────
|
// ─── Public Actions ────────────────────────────────────────────────
|
||||||
|
|
||||||
const activate = useCallback(async () => {
|
const activate = useCallback(async () => {
|
||||||
|
|||||||
@ -1198,10 +1198,18 @@
|
|||||||
"crop_confirm": "Übernehmen",
|
"crop_confirm": "Übernehmen",
|
||||||
"crop_hint": "Bewege und zoome das Bild um den gewünschten Ausschnitt zu wählen.",
|
"crop_hint": "Bewege und zoome das Bild um den gewünschten Ausschnitt zu wählen.",
|
||||||
"crop_reset": "Zurücksetzen",
|
"crop_reset": "Zurücksetzen",
|
||||||
"streak_section_label": "STREAK",
|
"streak_section_label": "SCHUTZ-ABDECKUNG",
|
||||||
"streak_days_protected": "Tage geschützt",
|
"streak_days_protected": "Tage geschützt",
|
||||||
"streak_since": "seit %{date}",
|
"streak_since": "seit %{date}",
|
||||||
"streak_longest": "Längste Streak: %{days} Tage",
|
"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": {
|
"cooldown": {
|
||||||
"heading": "COOLDOWN-VERLAUF",
|
"heading": "COOLDOWN-VERLAUF",
|
||||||
"window_label": "letzte %{weeks}W",
|
"window_label": "letzte %{weeks}W",
|
||||||
|
|||||||
@ -1196,10 +1196,18 @@
|
|||||||
"crop_confirm": "Apply",
|
"crop_confirm": "Apply",
|
||||||
"crop_hint": "Move and zoom the image to select the desired crop area.",
|
"crop_hint": "Move and zoom the image to select the desired crop area.",
|
||||||
"crop_reset": "Reset",
|
"crop_reset": "Reset",
|
||||||
"streak_section_label": "STREAK",
|
"streak_section_label": "PROTECTION COVERAGE",
|
||||||
"streak_days_protected": "days protected",
|
"streak_days_protected": "days protected",
|
||||||
"streak_since": "since %{date}",
|
"streak_since": "since %{date}",
|
||||||
"streak_longest": "Longest streak: %{days} days",
|
"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": {
|
"cooldown": {
|
||||||
"heading": "COOLDOWN HISTORY",
|
"heading": "COOLDOWN HISTORY",
|
||||||
"window_label": "last %{weeks}W",
|
"window_label": "last %{weeks}W",
|
||||||
|
|||||||
@ -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");
|
||||||
@ -1108,6 +1108,34 @@ model UserDevice {
|
|||||||
|
|
||||||
/// RebreakMagic Pairing — Native-App generiert 6-stelligen Code, Mac-App tauscht
|
/// RebreakMagic Pairing — Native-App generiert 6-stelligen Code, Mac-App tauscht
|
||||||
/// gegen MagicSession-Token. Code ist single-use, läuft nach 10min ab.
|
/// 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 {
|
model MagicPairingCode {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
userId String @map("user_id") @db.Uuid
|
userId String @map("user_id") @db.Uuid
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { requireUser } from "../../utils/auth";
|
|||||||
import { getActiveCooldown, resolveCooldown } from "../../db/cooldown";
|
import { getActiveCooldown, resolveCooldown } from "../../db/cooldown";
|
||||||
import { signCooldownToken } from "../../utils/cooldownToken";
|
import { signCooldownToken } from "../../utils/cooldownToken";
|
||||||
import { usePrisma } from "../../utils/prisma";
|
import { usePrisma } from "../../utils/prisma";
|
||||||
|
import { appendProtectionEventDeduped } from "../../db/protectionStateLog";
|
||||||
|
|
||||||
/** GET /api/cooldown/status — Current cooldown state for the authenticated user. */
|
/** GET /api/cooldown/status — Current cooldown state for the authenticated user. */
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
@ -59,11 +60,16 @@ export default defineEventHandler(async (event) => {
|
|||||||
// Reactivation (Sucht-Recovery-Pattern: einfach an, schwer aus, sehr schwer
|
// Reactivation (Sucht-Recovery-Pattern: einfach an, schwer aus, sehr schwer
|
||||||
// wieder zurück an Auto-Magic). User muss explizit reaktivieren.
|
// wieder zurück an Auto-Magic). User muss explizit reaktivieren.
|
||||||
const db = usePrisma();
|
const db = usePrisma();
|
||||||
|
const disabledAt = new Date();
|
||||||
await db.profile.update({
|
await db.profile.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { protectionDisabledAt: new Date() },
|
data: { protectionDisabledAt: disabledAt },
|
||||||
}).catch(() => {});
|
}).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(
|
const token = await signCooldownToken(
|
||||||
user.id,
|
user.id,
|
||||||
cooldown.tokenJti,
|
cooldown.tokenJti,
|
||||||
|
|||||||
25
backend/server/api/protection/coverage.get.ts
Normal file
25
backend/server/api/protection/coverage.get.ts
Normal 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 };
|
||||||
|
});
|
||||||
37
backend/server/api/protection/event.post.ts
Normal file
37
backend/server/api/protection/event.post.ts
Normal 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 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 };
|
||||||
|
});
|
||||||
217
backend/server/db/protectionStateLog.ts
Normal file
217
backend/server/db/protectionStateLog.ts
Normal 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()),
|
||||||
|
);
|
||||||
|
}
|
||||||
78
docs/marketing/onepager-behoerden.md
Normal file
78
docs/marketing/onepager-behoerden.md
Normal 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]
|
||||||
95
docs/marketing/onepager-fachstellen.md
Normal file
95
docs/marketing/onepager-fachstellen.md
Normal 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.*
|
||||||
92
docs/specs/protection-coverage-streak.md
Normal file
92
docs/specs/protection-coverage-streak.md
Normal 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 {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.
|
||||||
Loading…
x
Reference in New Issue
Block a user