Files
HealthLog/src/lib/daily/milestones.ts
T
Marc-André Bombeck 8b7bb7914e feat(daily): milestones — the calm reward layer
Add the durable-states Milestone primitive and a milestone card to the
daily digest. A milestone marks a state REACHED, never days maintained:
a metric settled back inside its own range, a run of weeks in range, or
a fresh personal best. It is deliberately not a streak — the type carries
a reached-date and no running counter, so nothing here can be tuned into
a flame, and a lapse is invisible rather than mourned.

The primitive is a thin wrapper over engines that already exist: the pure
detectStreak band engine (return-to-range + sustained-in-range) and the
PersonalRecord detection output (new bests). Reached-once needs no new
storage — a milestone's reached-date is a stable fact, so it surfaces only
on the day it was reached and never resurfaces the next day.

The digest emits one calm success card per day when a milestone is freshly
reached, below an overdue dose and above ambient items, with a single
action into the metric's insight. Module- and data-gated: a disabled
insights account or a day with nothing reached shows nothing. Copy carries
no streak, loss, or failure vocabulary, in all six locales.
2026-07-17 08:10:58 +02:00

227 lines
8.3 KiB
TypeScript

/**
* P2 — `Milestone`, the durable-states reward primitive.
*
* A milestone is "a state REACHED" — a durable achievement the record now
* carries: a metric that settled back inside the user's own range, a run of
* weeks in range, a fresh personal best. It is deliberately NOT a streak.
*
* The distinction is written into the type on purpose: a milestone carries a
* `sinceDate` (the day the durable state was reached) and NOTHING that counts
* days still being maintained — no `currentRun`, no `daysHeld`, no counter a
* future tuning pass could quietly turn into a flame. A missed day is invisible
* here; there is no "you broke it" state to reach, and none is representable.
*
* This module is a THIN wrapper over the engines that already exist — it adds
* no second detection framework:
* - `return_to_baseline` + `sustained_in_range` fold a `StreakResult` from
* the pure `detectStreak` engine (`@/lib/insights/streak-detector`).
* - `record_first` folds a `PersonalRecord` row (the `pr-detection` infra).
*
* Reached-once WITHOUT new storage: a milestone's `sinceDate` is a stable
* historical fact. `selectFreshMilestone` admits it only on the day it was
* reached (`sinceDate === todayKey`); the next day it is no longer fresh and
* never resurfaces. No "which milestones were shown" table is needed — the
* reached-date IS the once-guarantee.
*
* Pure: no DB, no clock, no network. Day keys are UTC-ISO (`YYYY-MM-DD`), the
* SAME space `readDayMeanSeries` and the rollup tier already emit, so a
* milestone's reach-day and "today" compare in one consistent space.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import type { ServerTranslator } from "@/lib/i18n/server-translator";
import { MIN_IN_RUN, type StreakResult } from "@/lib/insights/streak-detector";
/** Closed set of durable-state kinds. Grows by PR, never with a counter field. */
export const MILESTONE_KINDS = [
"record_first",
"return_to_baseline",
"sustained_in_range",
] as const;
export type MilestoneKind = (typeof MILESTONE_KINDS)[number];
/**
* One durable state reached. `sinceDate` is the UTC-ISO day the state was
* reached; `copyKey` is the BASE i18n key under `daily.milestone.*` (the card
* appends `.title` / `.body`). There is intentionally no numeric run field.
*/
export interface Milestone {
kind: MilestoneKind;
metricType: MeasurementType;
/** UTC-ISO (`YYYY-MM-DD`) day the durable state was REACHED. */
sinceDate: string;
/** Base i18n key under `daily.milestone.*`; copy celebrates arrival. */
copyKey: string;
}
/**
* The vitals a milestone can celebrate — mirrors `score-narrative`'s
* `RETURN_SALIENT_TYPES`. All four are core vitals with no toggleable module,
* so a milestone here never leaks a disabled-module surface.
*/
export const MILESTONE_SALIENT_TYPES: readonly MeasurementType[] = [
"RESTING_HEART_RATE",
"HEART_RATE_VARIABILITY",
"RESPIRATORY_RATE",
"WEIGHT",
] as const;
/**
* Durable sustained-in-range states, in WEEKS. A CLOSED, bounded set: reaching
* week 4 is the last one. There is deliberately no escalation beyond it and no
* running day count — each threshold is a one-time state, so nothing here can
* be tuned into an ever-climbing streak.
*/
export const SUSTAINED_WEEK_THRESHOLDS = [1, 2, 3, 4] as const;
/** Per-metric localised-name key + its insight deep-link. */
const METRIC_NAME_KEY: Partial<Record<MeasurementType, string>> = {
RESTING_HEART_RATE: "daily.milestone.metric.restingHeartRate",
HEART_RATE_VARIABILITY: "daily.milestone.metric.heartRateVariability",
RESPIRATORY_RATE: "daily.milestone.metric.respiratoryRate",
WEIGHT: "daily.milestone.metric.weight",
};
const METRIC_HREF: Partial<Record<MeasurementType, string>> = {
RESTING_HEART_RATE: "/insights/resting-pulse",
HEART_RATE_VARIABILITY: "/insights/hrv",
RESPIRATORY_RATE: "/insights/respiratory-rate",
WEIGHT: "/insights/weight",
};
/** The insight surface a milestone's action deep-links to. */
export function milestoneHref(milestone: Milestone): string {
return METRIC_HREF[milestone.metricType] ?? "/insights";
}
// ── pure UTC day-key arithmetic ─────────────────────────────────────────────
function dayKeyToSerial(key: string): number {
const [year, month, day] = key.split("-").map(Number);
return Date.UTC(year, month - 1, day) / 86_400_000;
}
function serialToDayKey(serial: number): string {
return new Date(serial * 86_400_000).toISOString().slice(0, 10);
}
/** Shift a UTC-ISO day key by a whole number of days. */
function shiftDayKey(key: string, deltaDays: number): string {
return serialToDayKey(dayKeyToSerial(key) + deltaDays);
}
/**
* Fold a `StreakResult` into the durable milestones it represents. Reuses the
* `detectStreak` engine's output verbatim — this adds no detection logic.
*
* `return_to_baseline`: the metric settled BACK inside its own range. The
* return is reached the moment the in-band run first holds `MIN_IN_RUN` days,
* so the reach-day is that many days back from the latest reading. This only
* ever narrates the calm ARRIVAL back inside the range — there is no message
* for leaving it.
*
* `sustained_in_range`: one durable state per week boundary the in-band run has
* passed, each stamped with the day it was reached. Emitted only while the
* metric IS currently in-band; a lapse simply stops producing NEW milestones —
* it is never marked, never mourned.
*/
export function milestonesFromStreak(
metricType: MeasurementType,
result: StreakResult,
latestDayKey: string,
): Milestone[] {
const out: Milestone[] = [];
if (result.returnEvent) {
const reachedDay = shiftDayKey(
latestDayKey,
-(result.returnEvent.daysInside - MIN_IN_RUN),
);
out.push({
kind: "return_to_baseline",
metricType,
sinceDate: reachedDay,
copyKey: "daily.milestone.returnToBaseline",
});
}
if (result.inBand && result.latestPlacement === "in") {
for (const weeks of SUSTAINED_WEEK_THRESHOLDS) {
const days = weeks * 7;
if (result.streakDays < days) continue;
const reachedDay = shiftDayKey(latestDayKey, -(result.streakDays - days));
out.push({
kind: "sustained_in_range",
metricType,
sinceDate: reachedDay,
copyKey: `daily.milestone.sustainedInRange.week${weeks}`,
});
}
}
return out;
}
/** Fold a personal-record row (its achieved day) into a `record_first` milestone. */
export function milestoneFromRecord(
metricType: MeasurementType,
achievedDayKey: string,
): Milestone {
return {
kind: "record_first",
metricType,
sinceDate: achievedDayKey,
copyKey: "daily.milestone.record",
};
}
/**
* Priority when several milestones land on the same day: a new personal best
* first (the brightest arrival), then a return to range (closes a worry), then
* a sustained state. The one/day cap picks the top of this order.
*/
const KIND_PRIORITY: Record<MilestoneKind, number> = {
record_first: 0,
return_to_baseline: 1,
sustained_in_range: 2,
};
/**
* The reached-once gate: admit only milestones REACHED today, and only ONE —
* the most meaningful. A milestone reached on an earlier day is a settled fact,
* not fresh news, so it never resurfaces; a day with no reached state yields
* `null` (an empty rail entry, never an invented one).
*/
export function selectFreshMilestone(
candidates: Milestone[],
todayKey: string,
): Milestone | null {
const fresh = candidates.filter((m) => m.sinceDate === todayKey);
if (fresh.length === 0) return null;
fresh.sort(
(a, b) =>
KIND_PRIORITY[a.kind] - KIND_PRIORITY[b.kind] ||
(a.metricType < b.metricType ? -1 : a.metricType > b.metricType ? 1 : 0),
);
return fresh[0];
}
type Translate = ServerTranslator["t"];
/**
* Resolve a milestone's already-localised card copy. The metric name is
* interpolated so one copy key serves every salient vital. Copy celebrates the
* arrival at a durable state — never a maintained count, never a loss.
*/
export function milestoneCopy(
milestone: Milestone,
t: Translate,
): { title: string; body: string } {
const metric = t(
METRIC_NAME_KEY[milestone.metricType] ?? "daily.milestone.metric.generic",
);
return {
title: t(`${milestone.copyKey}.title`, { metric }),
body: t(`${milestone.copyKey}.body`, { metric }),
};
}