mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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.
This commit is contained in:
1 parent
1427125b7e
commit
8b7bb7914e
11 files changed
+908
-6
No files matched your search
@@ -13,6 +13,8 @@ import {
|
||||
type DailyDigestCoachPlan,
|
||||
type DailyDigestInput,
|
||||
} from "@/lib/daily/digest";
|
||||
import type { Milestone } from "@/lib/daily/milestones";
|
||||
import type { PriorityItem } from "@/lib/daily/priority-item";
|
||||
|
||||
const t = getServerTranslator("en").t;
|
||||
const NOW = new Date("2026-07-16T09:00:00.000Z");
|
||||
@@ -413,3 +415,69 @@ describe("buildDailyDigest — coach check-in (S3)", () => {
|
||||
expect(d.worthALook[0].kind).toBe("dose_window");
|
||||
});
|
||||
});
|
||||
|
||||
const milestone = (
|
||||
d: ReturnType<typeof buildDailyDigest>,
|
||||
): PriorityItem | undefined => d.worthALook.find((i) => i.kind === "milestone");
|
||||
|
||||
const RECORD_MILESTONE: Milestone = {
|
||||
kind: "record_first",
|
||||
metricType: "RESTING_HEART_RATE",
|
||||
sinceDate: "2026-07-16",
|
||||
copyKey: "daily.milestone.record",
|
||||
};
|
||||
|
||||
describe("S12 — the milestone reward card", () => {
|
||||
it("emits ONE calm success card when a milestone was freshly reached", () => {
|
||||
const d = buildDailyDigest(input({ milestone: RECORD_MILESTONE }), t);
|
||||
const item = milestone(d);
|
||||
expect(item).toBeDefined();
|
||||
expect(item?.status).toBe("success");
|
||||
expect(item?.title.length).toBeGreaterThan(0);
|
||||
expect(item?.body?.length).toBeGreaterThan(0);
|
||||
// Single calm action deep-linking into the metric's insight.
|
||||
expect(item?.actions).toHaveLength(1);
|
||||
expect(item?.actions[0].intent).toBe("milestone.view");
|
||||
expect(item?.actions[0].href).toBe("/insights/resting-pulse");
|
||||
// One per day — never two milestone cards.
|
||||
expect(d.worthALook.filter((i) => i.kind === "milestone")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows nothing when no milestone was reached today (data-gated)", () => {
|
||||
expect(
|
||||
milestone(buildDailyDigest(input({ milestone: null }), t)),
|
||||
).toBeUndefined();
|
||||
expect(milestone(buildDailyDigest(input(), t))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is suppressed when the insights module is off (module-gated)", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({ milestone: RECORD_MILESTONE, modules: { insights: false } }),
|
||||
t,
|
||||
);
|
||||
expect(milestone(d)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sits just below an overdue dose and above ambient items", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
milestone: RECORD_MILESTONE,
|
||||
medsToday: meds({ nextDueOverdue: true, nextDueMedicationName: "X" }),
|
||||
syncIssues: [{ integration: "withings", state: "error_reauth" }],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(d.worthALook[0].kind).toBe("dose_window");
|
||||
expect(d.worthALook[1].kind).toBe("milestone");
|
||||
expect(d.worthALook[2].kind).toBe("sync_issue");
|
||||
});
|
||||
|
||||
it("carries no streak / loss vocabulary in its copy", () => {
|
||||
const item = milestone(
|
||||
buildDailyDigest(input({ milestone: RECORD_MILESTONE }), t),
|
||||
);
|
||||
const forbidden = /streak|flame|broke|broken|lost|missed|fail/i;
|
||||
expect(item?.title).not.toMatch(forbidden);
|
||||
expect(item?.body ?? "").not.toMatch(forbidden);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { detectStreak, type StreakPoint } from "@/lib/insights/streak-detector";
|
||||
import {
|
||||
MILESTONE_KINDS,
|
||||
milestoneCopy,
|
||||
milestoneFromRecord,
|
||||
milestoneHref,
|
||||
milestonesFromStreak,
|
||||
selectFreshMilestone,
|
||||
SUSTAINED_WEEK_THRESHOLDS,
|
||||
type Milestone,
|
||||
} from "@/lib/daily/milestones";
|
||||
|
||||
const t = getServerTranslator("en").t;
|
||||
|
||||
/** Build a consecutive-day UTC series ending at `endDay` (values[last] === endDay). */
|
||||
function seriesEndingAt(values: number[], endDay: string): StreakPoint[] {
|
||||
const endSerial =
|
||||
Date.UTC(...(dayParts(endDay) as [number, number, number])) / 86_400_000;
|
||||
return values.map((value, i) => ({
|
||||
day: keyFromSerial(endSerial - (values.length - 1 - i)),
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
function dayParts(key: string): number[] {
|
||||
const [y, m, d] = key.split("-").map(Number);
|
||||
return [y, m - 1, d];
|
||||
}
|
||||
|
||||
function keyFromSerial(serial: number): string {
|
||||
return new Date(serial * 86_400_000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function shift(day: string, delta: number): string {
|
||||
const serial =
|
||||
Date.UTC(...(dayParts(day) as [number, number, number])) / 86_400_000;
|
||||
return keyFromSerial(serial + delta);
|
||||
}
|
||||
|
||||
const TODAY = "2026-07-16";
|
||||
|
||||
describe("milestonesFromStreak — folds the real detectStreak engine", () => {
|
||||
it("emits a fresh sustained_in_range milestone the day a week boundary is reached", () => {
|
||||
// Exactly 7 consecutive in-band days ending today → the 1-week durable
|
||||
// state is REACHED today. Uses the real detectStreak result, not a stub.
|
||||
const series = seriesEndingAt([60, 60, 60, 60, 60, 60, 60], TODAY);
|
||||
const result = detectStreak(series);
|
||||
expect(result.inBand).toBe(true);
|
||||
expect(result.streakDays).toBe(7);
|
||||
|
||||
const milestones = milestonesFromStreak(
|
||||
"RESTING_HEART_RATE",
|
||||
result,
|
||||
TODAY,
|
||||
);
|
||||
const week1 = milestones.find((m) => m.copyKey.endsWith("week1"));
|
||||
expect(week1).toBeDefined();
|
||||
expect(week1?.kind).toBe("sustained_in_range");
|
||||
expect(week1?.sinceDate).toBe(TODAY); // reached today → fresh
|
||||
|
||||
// Reached-once: the same durable state is NOT fresh the next day.
|
||||
expect(selectFreshMilestone(milestones, TODAY)?.copyKey).toContain("week1");
|
||||
expect(selectFreshMilestone(milestones, shift(TODAY, 1))).toBeNull();
|
||||
});
|
||||
|
||||
it("does not re-fire a week boundary reached on an earlier day", () => {
|
||||
// 8 in-band days: the 1-week state was reached YESTERDAY (streak 8 > 7).
|
||||
const series = seriesEndingAt([60, 60, 60, 60, 60, 60, 60, 60], TODAY);
|
||||
const result = detectStreak(series);
|
||||
expect(result.streakDays).toBe(8);
|
||||
|
||||
const milestones = milestonesFromStreak(
|
||||
"RESTING_HEART_RATE",
|
||||
result,
|
||||
TODAY,
|
||||
);
|
||||
const week1 = milestones.find((m) => m.copyKey.endsWith("week1"));
|
||||
expect(week1?.sinceDate).toBe(shift(TODAY, -1)); // reached yesterday
|
||||
expect(selectFreshMilestone(milestones, TODAY)).toBeNull(); // not fresh today
|
||||
});
|
||||
|
||||
it("emits a fresh return_to_baseline the day the return settles (daysInside === MIN_IN_RUN)", () => {
|
||||
// Prior out-of-band run (90s) then a settled 2-day in-band run ending today.
|
||||
const series = seriesEndingAt(
|
||||
[60, 60, 60, 60, 60, 90, 90, 90, 60, 60],
|
||||
TODAY,
|
||||
);
|
||||
const result = detectStreak(series);
|
||||
expect(result.returnEvent).toBeDefined();
|
||||
expect(result.returnEvent?.daysInside).toBe(2);
|
||||
|
||||
const milestones = milestonesFromStreak(
|
||||
"RESTING_HEART_RATE",
|
||||
result,
|
||||
TODAY,
|
||||
);
|
||||
const ret = milestones.find((m) => m.kind === "return_to_baseline");
|
||||
expect(ret).toBeDefined();
|
||||
expect(ret?.sinceDate).toBe(TODAY);
|
||||
expect(selectFreshMilestone(milestones, TODAY)?.kind).toBe(
|
||||
"return_to_baseline",
|
||||
);
|
||||
// Reached-once: gone the next day.
|
||||
expect(selectFreshMilestone(milestones, shift(TODAY, 1))).toBeNull();
|
||||
});
|
||||
|
||||
it("emits NOTHING while the metric is currently out of band — no 'broken' state", () => {
|
||||
// Latest placement is out-of-band (a lapse). A lapse is invisible: no
|
||||
// milestone at all, and certainly no negative one.
|
||||
const series = seriesEndingAt([60, 60, 60, 60, 60, 60, 60, 90, 90], TODAY);
|
||||
const result = detectStreak(series);
|
||||
expect(result.inBand).toBe(false);
|
||||
expect(milestonesFromStreak("RESTING_HEART_RATE", result, TODAY)).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("omits quietly for an empty series (no band, no guess)", () => {
|
||||
const result = detectStreak([]);
|
||||
expect(result.latestPlacement).toBeNull();
|
||||
expect(milestonesFromStreak("WEIGHT", result, TODAY)).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits no milestone from an in-band run shorter than a week", () => {
|
||||
// A short in-band run (3 days) has reached no durable week boundary yet.
|
||||
const series = seriesEndingAt([60, 60, 60], TODAY);
|
||||
const result = detectStreak(series);
|
||||
expect(result.inBand).toBe(true);
|
||||
expect(result.streakDays).toBe(3);
|
||||
expect(milestonesFromStreak("WEIGHT", result, TODAY)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("milestoneFromRecord — folds a PersonalRecord achieved day", () => {
|
||||
it("is fresh only on the day the record was achieved", () => {
|
||||
const m = milestoneFromRecord("HEART_RATE_VARIABILITY", TODAY);
|
||||
expect(m.kind).toBe("record_first");
|
||||
expect(selectFreshMilestone([m], TODAY)?.kind).toBe("record_first");
|
||||
expect(selectFreshMilestone([m], shift(TODAY, 1))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectFreshMilestone — reached-once, one per day, most meaningful", () => {
|
||||
it("prefers a personal best, then a return, then a sustained state", () => {
|
||||
const record: Milestone = milestoneFromRecord("RESTING_HEART_RATE", TODAY);
|
||||
const ret: Milestone = {
|
||||
kind: "return_to_baseline",
|
||||
metricType: "WEIGHT",
|
||||
sinceDate: TODAY,
|
||||
copyKey: "daily.milestone.returnToBaseline",
|
||||
};
|
||||
const sustained: Milestone = {
|
||||
kind: "sustained_in_range",
|
||||
metricType: "WEIGHT",
|
||||
sinceDate: TODAY,
|
||||
copyKey: "daily.milestone.sustainedInRange.week1",
|
||||
};
|
||||
expect(selectFreshMilestone([sustained, ret, record], TODAY)?.kind).toBe(
|
||||
"record_first",
|
||||
);
|
||||
expect(selectFreshMilestone([sustained, ret], TODAY)?.kind).toBe(
|
||||
"return_to_baseline",
|
||||
);
|
||||
expect(selectFreshMilestone([sustained], TODAY)?.kind).toBe(
|
||||
"sustained_in_range",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when no candidate was reached today", () => {
|
||||
const stale: Milestone = milestoneFromRecord("WEIGHT", shift(TODAY, -3));
|
||||
expect(selectFreshMilestone([stale], TODAY)).toBeNull();
|
||||
expect(selectFreshMilestone([], TODAY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("copy is calm and never negative", () => {
|
||||
const cases: Milestone[] = [
|
||||
milestoneFromRecord("RESTING_HEART_RATE", TODAY),
|
||||
{
|
||||
kind: "return_to_baseline",
|
||||
metricType: "RESTING_HEART_RATE",
|
||||
sinceDate: TODAY,
|
||||
copyKey: "daily.milestone.returnToBaseline",
|
||||
},
|
||||
...SUSTAINED_WEEK_THRESHOLDS.map((w): Milestone => ({
|
||||
kind: "sustained_in_range",
|
||||
metricType: "RESTING_HEART_RATE",
|
||||
sinceDate: TODAY,
|
||||
copyKey: `daily.milestone.sustainedInRange.week${w}`,
|
||||
})),
|
||||
];
|
||||
|
||||
it("resolves a real title + body with the metric name interpolated, no leftover placeholder", () => {
|
||||
for (const m of cases) {
|
||||
const { title, body } = milestoneCopy(m, t);
|
||||
expect(title.length).toBeGreaterThan(0);
|
||||
expect(body.length).toBeGreaterThan(0);
|
||||
expect(title).not.toContain("{metric}");
|
||||
expect(body).not.toContain("{metric}");
|
||||
expect(body.toLowerCase()).toContain("resting heart rate");
|
||||
}
|
||||
});
|
||||
|
||||
it("uses no streak / loss / failure vocabulary anywhere", () => {
|
||||
const forbidden =
|
||||
/streak|flame|broke|broken|lost|don'?t lose|missed|fail|penalt/i;
|
||||
for (const m of cases) {
|
||||
const { title, body } = milestoneCopy(m, t);
|
||||
expect(title).not.toMatch(forbidden);
|
||||
expect(body).not.toMatch(forbidden);
|
||||
}
|
||||
});
|
||||
|
||||
it("every milestone deep-links to an insight surface", () => {
|
||||
for (const m of cases) {
|
||||
expect(milestoneHref(m)).toMatch(/^\/insights/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the type carries no running counter", () => {
|
||||
it("a Milestone has only kind / metricType / sinceDate / copyKey", () => {
|
||||
const m = milestoneFromRecord("WEIGHT", TODAY);
|
||||
expect(Object.keys(m).sort()).toEqual([
|
||||
"copyKey",
|
||||
"kind",
|
||||
"metricType",
|
||||
"sinceDate",
|
||||
]);
|
||||
});
|
||||
|
||||
it("exposes exactly the three durable kinds", () => {
|
||||
expect([...MILESTONE_KINDS]).toEqual([
|
||||
"record_first",
|
||||
"return_to_baseline",
|
||||
"sustained_in_range",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
MAX_PRIORITY_ACTIONS,
|
||||
type PriorityItem,
|
||||
} from "@/lib/daily/priority-item";
|
||||
import {
|
||||
milestoneCopy,
|
||||
milestoneHref,
|
||||
type Milestone,
|
||||
} from "@/lib/daily/milestones";
|
||||
|
||||
/** At most three rail items — a glance, never a wall (§2.5, never padded). */
|
||||
export const MAX_WORTH_A_LOOK = 3;
|
||||
@@ -135,6 +140,13 @@ export interface DailyDigestInput {
|
||||
preventiveDue: DailyDigestPreventiveDue[];
|
||||
/** Standing coach plans (active + reviewed) — check-in candidates (§2.3). */
|
||||
coachPlans: DailyDigestCoachPlan[];
|
||||
/**
|
||||
* S12 — the single freshly-reached durable milestone for today, or null. The
|
||||
* IO seam gathers candidates from the existing streak / personal-record
|
||||
* engines and applies the reached-once gate (`selectFreshMilestone`), so the
|
||||
* builder only ever sees a milestone worth celebrating today.
|
||||
*/
|
||||
milestone?: Milestone | null;
|
||||
}
|
||||
|
||||
export interface DailyDigest {
|
||||
@@ -256,6 +268,38 @@ function buildPreventiveCareItem(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* S12 — the calm reward card. Emits ONE `milestone` PriorityItem when a durable
|
||||
* state was reached TODAY (the IO seam already applied the reached-once gate).
|
||||
* Gated on the `insights` module — the daily narrative layer that hosts it.
|
||||
* Celebratory-but-quiet: `success` status, a single "view" action into the
|
||||
* metric's insight, and copy that marks arrival at a state, never a maintained
|
||||
* count. Shown the day reached and never again — never a "you broke it" note.
|
||||
*/
|
||||
function buildMilestoneItem(
|
||||
milestone: Milestone | null | undefined,
|
||||
modules: DigestModuleMap,
|
||||
t: Translate,
|
||||
): PriorityItem | null {
|
||||
if (!milestone) return null;
|
||||
if (!moduleEnabled(modules, "insights")) return null;
|
||||
const { title, body } = milestoneCopy(milestone, t);
|
||||
return {
|
||||
kind: "milestone",
|
||||
title,
|
||||
body,
|
||||
status: "success",
|
||||
actions: [
|
||||
{
|
||||
labelKey: "daily.action.viewMilestone",
|
||||
intent: "milestone.view",
|
||||
href: milestoneHref(milestone),
|
||||
},
|
||||
],
|
||||
moduleKey: "insights",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The instant a plan's check-in came due (§2.3). A coach-pinned `reviewDate`
|
||||
* wins. Otherwise, a `reviewed` plan lost its `reviewDate` to the daily sweep
|
||||
@@ -384,6 +428,11 @@ export function buildDailyDigest(
|
||||
const worthALook: PriorityItem[] = [];
|
||||
const dose = buildDoseWindowItem(input.medsToday, input.modules, t);
|
||||
if (dose) worthALook.push(dose);
|
||||
// A freshly-reached milestone is rare and one-per-day — surface it ahead of
|
||||
// the ambient sync / check-in items so the calm reward is not buried, but
|
||||
// below an overdue dose (the one genuinely time-critical daily action).
|
||||
const milestone = buildMilestoneItem(input.milestone, input.modules, t);
|
||||
if (milestone) worthALook.push(milestone);
|
||||
worthALook.push(...buildSyncIssueItems(input.syncIssues, t));
|
||||
const checkin = buildCoachCheckinItem(
|
||||
input.coachPlans,
|
||||
|
||||
@@ -30,6 +30,16 @@ import {
|
||||
type DailyDigestScore,
|
||||
type DailyDigestSyncIssue,
|
||||
} from "@/lib/daily/digest";
|
||||
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
|
||||
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
|
||||
import { detectStreak, type StreakPoint } from "@/lib/insights/streak-detector";
|
||||
import {
|
||||
MILESTONE_SALIENT_TYPES,
|
||||
milestoneFromRecord,
|
||||
milestonesFromStreak,
|
||||
selectFreshMilestone,
|
||||
type Milestone,
|
||||
} from "@/lib/daily/milestones";
|
||||
|
||||
/** Integration states that mean "your action is needed to keep data flowing". */
|
||||
const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const;
|
||||
@@ -43,6 +53,85 @@ const CHECKIN_PLAN_STATES = ["active", "reviewed"] as const;
|
||||
/** Cap on plans scanned for the (one/day) check-in candidate. */
|
||||
const CHECKIN_PLAN_READ_LIMIT = 50;
|
||||
|
||||
/** Trailing window the milestone streak read uses (matches score-narrative). */
|
||||
const MILESTONE_STREAK_WINDOW_DAYS = 30;
|
||||
|
||||
/** How far back to scan for a just-set personal best (a couple of days is ample). */
|
||||
const MILESTONE_RECORD_LOOKBACK_MS = 2 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** UTC-ISO day key — the space the streak series + rollup tier already emit. */
|
||||
function utcDayKey(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* S12 — gather today's single freshly-reached milestone (or null) from the
|
||||
* engines that ALREADY exist. Two cheap, fail-soft reads reused verbatim:
|
||||
* - `detectStreak` over the salient vitals' day-mean series (the exact
|
||||
* `score-narrative` pattern) → return-to-range + sustained-in-range states;
|
||||
* - a bounded `PersonalRecord` scan (the `pr-detection` output) → new bests.
|
||||
* The reached-once gate (`selectFreshMilestone`) admits at most one, only on the
|
||||
* day it was reached. Any failure leaves the reward quiet rather than guessing.
|
||||
*/
|
||||
async function gatherFreshMilestone(
|
||||
userId: string,
|
||||
now: Date,
|
||||
): Promise<Milestone | null> {
|
||||
const todayKey = utcDayKey(now);
|
||||
const candidates: Milestone[] = [];
|
||||
|
||||
const coverage = await probeRollupCoverage(userId).catch(() => null);
|
||||
if (coverage) {
|
||||
const perMetric = await Promise.all(
|
||||
MILESTONE_SALIENT_TYPES.map(async (type) => {
|
||||
try {
|
||||
const { points } = await readDayMeanSeries(
|
||||
userId,
|
||||
type,
|
||||
MILESTONE_STREAK_WINDOW_DAYS,
|
||||
now,
|
||||
coverage,
|
||||
);
|
||||
if (points.length === 0) return [] as Milestone[];
|
||||
const series: StreakPoint[] = points.map((p) => ({
|
||||
day: p.day,
|
||||
value: p.mean,
|
||||
}));
|
||||
const latestDayKey = points[points.length - 1].day;
|
||||
return milestonesFromStreak(type, detectStreak(series), latestDayKey);
|
||||
} catch {
|
||||
return [] as Milestone[];
|
||||
}
|
||||
}),
|
||||
);
|
||||
candidates.push(...perMetric.flat());
|
||||
}
|
||||
|
||||
try {
|
||||
const records = await prisma.personalRecord.findMany({
|
||||
where: {
|
||||
userId,
|
||||
metricType: { in: [...MILESTONE_SALIENT_TYPES] },
|
||||
achievedAt: {
|
||||
gte: new Date(now.getTime() - MILESTONE_RECORD_LOOKBACK_MS),
|
||||
},
|
||||
},
|
||||
select: { metricType: true, achievedAt: true },
|
||||
orderBy: { achievedAt: "desc" },
|
||||
take: 10,
|
||||
});
|
||||
for (const record of records) {
|
||||
candidates.push(
|
||||
milestoneFromRecord(record.metricType, utcDayKey(record.achievedAt)),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// A read hiccup just leaves the reward quiet — never a fabricated one.
|
||||
}
|
||||
|
||||
return selectFreshMilestone(candidates, todayKey);
|
||||
}
|
||||
|
||||
interface CoachPlanRow {
|
||||
id: string;
|
||||
status: string;
|
||||
@@ -172,6 +261,14 @@ export async function loadDailyDigest(
|
||||
? planRows.map((row) => toCoachPlanCandidate(row as CoachPlanRow))
|
||||
: [];
|
||||
|
||||
// S12 — the calm reward layer. Only gather when the insights module (the
|
||||
// narrative layer that hosts the milestone card) is on; the builder gates on
|
||||
// it too, so a disabled account never surfaces one either way.
|
||||
const insightsEnabled = modules.insights !== false;
|
||||
const milestone = insightsEnabled
|
||||
? await gatherFreshMilestone(user.id, now)
|
||||
: null;
|
||||
|
||||
const { t } = getServerTranslator(resolveLocale(locale));
|
||||
|
||||
const digest = buildDailyDigest(
|
||||
@@ -186,6 +283,7 @@ export async function loadDailyDigest(
|
||||
syncIssues,
|
||||
preventiveDue,
|
||||
coachPlans,
|
||||
milestone,
|
||||
},
|
||||
t,
|
||||
);
|
||||
@@ -200,6 +298,9 @@ export async function loadDailyDigest(
|
||||
daily_digest_has_checkin: digest.worthALook.some(
|
||||
(i) => i.kind === "coach_checkin",
|
||||
),
|
||||
daily_digest_has_milestone: digest.worthALook.some(
|
||||
(i) => i.kind === "milestone",
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 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 }),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user