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>
218 lines
7.2 KiB
TypeScript
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()),
|
|
);
|
|
}
|