chahinebrini d31e45e2a8 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>
2026-06-06 10:54:55 +02:00

401 lines
12 KiB
TypeScript

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;
startedAt: string;
rawStartedAt: string;
durationLabel: string;
status: 'active' | 'resolved' | 'cancelled';
reason: string | null;
};
type Props = {
coverage: ProtectionCoverageData | null;
cooldowns: CooldownEntry[];
rawCooldowns: BackendCooldownEntry[] | null;
};
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);
const day = d.getDay();
const diff = (day === 0 ? -6 : 1 - day);
d.setDate(d.getDate() + diff);
d.setHours(0, 0, 0, 0);
return d;
}
function buildWeekBuckets(cooldowns: CooldownEntry[]): number[] {
const now = new Date();
const currentWeekMonday = getMondayOfWeek(now);
const buckets: number[] = Array(WEEKS).fill(0);
for (const c of cooldowns) {
if (!c.rawStartedAt) continue;
const started = new Date(c.rawStartedAt);
const weekMonday = getMondayOfWeek(started);
const diffMs = currentWeekMonday.getTime() - weekMonday.getTime();
const diffWeeks = Math.round(diffMs / (7 * 24 * 60 * 60 * 1000));
if (diffWeeks >= 0 && diffWeeks < WEEKS) {
const bucketIndex = WEEKS - 1 - diffWeeks;
buckets[bucketIndex]++;
}
}
return buckets;
}
function formatLastDate(cooldowns: CooldownEntry[], language: string): string {
if (cooldowns.length === 0) return '';
const sorted = [...cooldowns].sort(
(a, b) => new Date(b.rawStartedAt).getTime() - new Date(a.rawStartedAt).getTime(),
);
const latest = new Date(sorted[0].rawStartedAt);
if (language === 'de') {
const day = String(latest.getDate()).padStart(2, '0');
const month = String(latest.getMonth() + 1).padStart(2, '0');
return `${day}.${month}.`;
}
return latest.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
function formatAvg(totalCount: number, language: string): string {
if (totalCount === 0) return '0';
const avg = WEEKS / totalCount;
if (language === 'de') {
return avg.toFixed(1).replace('.', ',');
}
return avg.toFixed(1);
}
export function StreakSection({ coverage, cooldowns, rawCooldowns }: Props) {
const colors = useColors();
const { t, i18n } = useTranslation();
const lang = i18n.language ?? 'de';
const buckets = buildWeekBuckets(cooldowns);
const maxCount = Math.max(...buckets, 1);
const totalInWindow = buckets.reduce((s, v) => s + v, 0);
const cooldownsInWindow = totalInWindow;
const lastDate = cooldowns.length > 0 ? formatLastDate(cooldowns, lang) : null;
const avgStr = formatAvg(cooldownsInWindow, lang);
const countLabel =
cooldownsInWindow === 0
? t('profile.cooldown.none')
: cooldownsInWindow === 1
? t('profile.cooldown.count_one', { weeks: WEEKS })
: t('profile.cooldown.count_other', { n: cooldownsInWindow, weeks: WEEKS });
const avgLabel =
cooldownsInWindow > 0 && lastDate
? t('profile.cooldown.avg_last', { avg: avgStr, date: lastDate })
: null;
const hasData = coverage !== null && coverage.firstProtectionAt !== null;
const donutSegments = hasData
? [
{ value: Math.max(coverage!.protectedDays, 1), color: PROTECTED_COLOR },
{ value: Math.max(coverage!.unprotectedDays, 0), color: UNPROTECTED_COLOR },
]
: [{ value: 1, color: UNPROTECTED_COLOR }];
const current = coverage?.currentStreakDays ?? 0;
const record = coverage?.longestStreakDays ?? 0;
const progressRatio = record > 0 ? Math.min(current / record, 1) : 1;
const isNewRecord = current >= record && record > 0;
const isFirstPhase = record === 0;
let streakLabel: string;
if (isFirstPhase) {
streakLabel = t('profile.streak_first_phase', { days: current });
} else if (isNewRecord) {
streakLabel = t('profile.streak_new_record', { days: current });
} else {
streakLabel = t('profile.streak_to_record', { days: record - current });
}
return (
<View style={{ marginHorizontal: 16, marginTop: 24 }}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: 10,
}}
>
<Ionicons name="shield-checkmark-outline" size={14} color={colors.textMuted} />
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.8,
}}
>
{t('profile.streak_section_label')}
</Text>
</View>
<View
style={{
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 14,
padding: 16,
}}
>
{hasData ? (
<>
<View
style={{
flexDirection: 'row',
alignItems: 'flex-end',
gap: 20,
}}
>
<HalfDonut
segments={donutSegments}
centerValue={coverage!.protectedDays}
centerLabel={t('profile.coverage_center_label')}
width={DONUT_WIDTH}
/>
<View style={{ flex: 1, gap: 8, paddingBottom: 8 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: PROTECTED_COLOR }} />
<Text style={{ fontSize: 12, fontFamily: 'Nunito_600SemiBold', color: colors.text }}>
{coverage!.protectedDays} {t('profile.streak_days_protected')}
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: UNPROTECTED_COLOR, borderWidth: 1, borderColor: colors.border }} />
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{coverage!.unprotectedDays} {t('profile.coverage_unprotected_label')}
</Text>
</View>
</View>
</View>
<View style={{ marginTop: 16 }}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: 8,
}}
>
<Ionicons name="flame-outline" size={13} color={colors.textMuted} />
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.8,
}}
>
{t('profile.streak_phase_label')}
</Text>
</View>
<View
style={{
height: 8,
backgroundColor: colors.surfaceElevated,
borderRadius: 4,
overflow: 'hidden',
}}
>
<View
style={{
height: 8,
width: `${Math.round(progressRatio * 100)}%`,
backgroundColor: isNewRecord ? '#f59e0b' : PROTECTED_COLOR,
borderRadius: 4,
}}
/>
</View>
<Text
style={{
marginTop: 6,
fontSize: 12,
color: isNewRecord ? colors.warning : colors.textMuted,
fontFamily: isNewRecord ? 'Nunito_700Bold' : 'Nunito_400Regular',
}}
>
{streakLabel}
</Text>
</View>
</>
) : (
<View style={{ alignItems: 'center', paddingVertical: 12 }}>
<Ionicons name="shield-outline" size={32} color={colors.border} />
<Text
style={{
marginTop: 8,
fontSize: 14,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
textAlign: 'center',
}}
>
{t('profile.coverage_no_data')}
</Text>
<Text
style={{
marginTop: 4,
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
textAlign: 'center',
}}
>
{t('profile.coverage_no_data_hint')}
</Text>
</View>
)}
</View>
<View style={{ marginTop: 16 }}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
paddingHorizontal: 2,
}}
>
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.8,
}}
>
{t('profile.cooldown.heading')}
</Text>
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{t('profile.cooldown.window_label', { weeks: WEEKS })}
</Text>
</View>
<View
style={{
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 14,
padding: 16,
}}
>
<View
style={{
flexDirection: 'row',
alignItems: 'flex-end',
justifyContent: 'space-between',
height: MAX_BAR_HEIGHT + 20,
marginBottom: 6,
}}
>
{buckets.map((count, i) => {
const isEmpty = count === 0;
const barHeight = isEmpty
? MIN_BAR_HEIGHT
: Math.max(
MIN_BAR_HEIGHT,
Math.min(count, 5) / Math.min(maxCount, 5) * MAX_BAR_HEIGHT,
);
return (
<View key={i} style={{ alignItems: 'center', flex: 1 }}>
<View
style={{
width: 10,
height: barHeight,
borderRadius: 3,
backgroundColor: isEmpty ? colors.border : colors.brandOrange,
}}
/>
</View>
);
})}
</View>
<View
style={{
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 12,
}}
>
{buckets.map((_, i) => (
<View key={i} style={{ flex: 1, alignItems: 'center' }}>
<Text
style={{
fontSize: 9,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{t('profile.cooldown.week_label', { n: i + 1 })}
</Text>
</View>
))}
</View>
<Text
style={{
fontSize: 12,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
}}
>
{countLabel}
</Text>
{avgLabel ? (
<Text
style={{
marginTop: 2,
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{avgLabel}
</Text>
) : null}
</View>
<CooldownPatternAnalysis rawCooldowns={rawCooldowns} />
</View>
</View>
);
}