rebreak-monorepo/backend/server/db/protectionStateLog.ts
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

218 lines
7.2 KiB
TypeScript

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