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>
215 lines
5.5 KiB
TypeScript
215 lines
5.5 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import { apiFetch } from '../lib/api';
|
|
import type { CooldownEntry } from '../components/profile/StreakSection';
|
|
import type { ApprovedDomain } from '../components/profile/ApprovedDomainsList';
|
|
|
|
export type SocialStats = {
|
|
postsCount: number;
|
|
followersCount: number;
|
|
};
|
|
|
|
export type ApprovedDomainsData = {
|
|
count: number;
|
|
list: ApprovedDomain[];
|
|
};
|
|
|
|
export type CooldownHistoryData = {
|
|
items: CooldownEntry[];
|
|
nextCursor: string | null;
|
|
};
|
|
|
|
export type SosInsightsData = {
|
|
last30Days: { sessions: number; overcome: number; overcomeRate: number };
|
|
helpedBy: { breathing: number; game: number; talk: number; other: number };
|
|
topEmotion: string | null;
|
|
};
|
|
|
|
export type BackendCooldownEntry = {
|
|
id: string;
|
|
startedAt: string;
|
|
cooldownEndsAt: string;
|
|
durationMinutes: number;
|
|
status: 'active' | 'resolved' | 'cancelled';
|
|
resolvedAt: string | null;
|
|
cancelledAt: string | null;
|
|
reason: string | null;
|
|
};
|
|
|
|
function formatDuration(minutes: number): string {
|
|
if (minutes < 60) return `${minutes}min`;
|
|
const h = Math.round(minutes / 60);
|
|
return `${h}h`;
|
|
}
|
|
|
|
function formatStartedAt(isoString: string): string {
|
|
const d = new Date(isoString);
|
|
const day = String(d.getDate()).padStart(2, '0');
|
|
const month = String(d.getMonth() + 1).padStart(2, '0');
|
|
return `${day}.${month}.`;
|
|
}
|
|
|
|
function mapCooldownEntry(raw: BackendCooldownEntry): CooldownEntry {
|
|
return {
|
|
id: raw.id,
|
|
startedAt: formatStartedAt(raw.startedAt),
|
|
rawStartedAt: raw.startedAt,
|
|
durationLabel: formatDuration(raw.durationMinutes),
|
|
status: raw.status,
|
|
reason: raw.reason,
|
|
};
|
|
}
|
|
|
|
function useFetchOnce<T>(
|
|
url: string,
|
|
): { data: T | null; loading: boolean; error: boolean; reload: () => void } {
|
|
const [data, setData] = useState<T | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(false);
|
|
const [version, setVersion] = useState(0);
|
|
|
|
useEffect(() => {
|
|
if (!url) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
setError(false);
|
|
apiFetch<T>(url)
|
|
.then((res) => {
|
|
if (cancelled) return;
|
|
setData(res);
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setError(true);
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoading(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [url, version]);
|
|
|
|
const reload = useCallback(() => setVersion((v) => v + 1), []);
|
|
return { data, loading, error, reload };
|
|
}
|
|
|
|
export function useSocialStats(userId: string | undefined) {
|
|
const url = userId ? `/api/social/profile/${userId}` : '';
|
|
const { data, loading, error, reload } = useFetchOnce<{
|
|
postsCount: number;
|
|
followersCount: number;
|
|
}>(url);
|
|
|
|
return {
|
|
stats: data
|
|
? ({ postsCount: data.postsCount, followersCount: data.followersCount } as SocialStats)
|
|
: null,
|
|
loading,
|
|
error,
|
|
reload,
|
|
};
|
|
}
|
|
|
|
export function useApprovedDomains() {
|
|
const { data, loading, error, reload } = useFetchOnce<ApprovedDomainsData>(
|
|
'/api/profile/me/approved-domains',
|
|
);
|
|
return { domains: data, loading, error, reload };
|
|
}
|
|
|
|
export function useCooldownHistory() {
|
|
const { data, loading, error, reload } = useFetchOnce<{
|
|
items: BackendCooldownEntry[];
|
|
nextCursor: string | null;
|
|
}>('/api/profile/me/cooldown-history?limit=20');
|
|
|
|
const mapped: CooldownHistoryData | null = data
|
|
? {
|
|
items: data.items.map(mapCooldownEntry),
|
|
nextCursor: data.nextCursor,
|
|
}
|
|
: null;
|
|
|
|
return { cooldownHistory: mapped, loading, error, reload };
|
|
}
|
|
|
|
export function useCooldownHistoryFull() {
|
|
const { data, loading, error, reload } = useFetchOnce<{
|
|
items: BackendCooldownEntry[];
|
|
nextCursor: string | null;
|
|
}>('/api/profile/me/cooldown-history?limit=100');
|
|
|
|
return { rawCooldowns: data?.items ?? null, loading, error, reload };
|
|
}
|
|
|
|
export function useSosInsights() {
|
|
const { data, loading, error, reload } = useFetchOnce<SosInsightsData>(
|
|
'/api/profile/me/sos-insights',
|
|
);
|
|
return { sosInsights: data, loading, error, reload };
|
|
}
|
|
|
|
export type Demographics = {
|
|
birthYear: number | null;
|
|
gender: string | null;
|
|
maritalStatus: string | null;
|
|
employmentStatus: string | null;
|
|
shiftWork: boolean | null;
|
|
industry: string | null;
|
|
jobTenure: string | null;
|
|
bundesland: string | null;
|
|
city: string | null;
|
|
};
|
|
|
|
type DemographicsResponse = Demographics & {
|
|
consentAt: 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() {
|
|
const { data, loading, error, reload } = useFetchOnce<DemographicsResponse>(
|
|
'/api/profile/me/demographics',
|
|
);
|
|
|
|
const demographics: Demographics | null = data
|
|
? {
|
|
birthYear: data.birthYear,
|
|
gender: data.gender,
|
|
maritalStatus: data.maritalStatus,
|
|
employmentStatus: data.employmentStatus,
|
|
shiftWork: data.shiftWork,
|
|
industry: data.industry,
|
|
jobTenure: data.jobTenure,
|
|
bundesland: data.bundesland,
|
|
city: data.city,
|
|
}
|
|
: null;
|
|
|
|
return {
|
|
demographics,
|
|
consentAt: data?.consentAt ?? null,
|
|
withdrawnAt: data?.withdrawnAt ?? null,
|
|
loading,
|
|
error,
|
|
reload,
|
|
};
|
|
}
|
|
|