mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(insights): collapse recovery by physiological night, not calendar day
WHOOP stamps a recovery on the wake morning while the computed proxy files the same night under the day that ended, a systematic off-by-one that left both rows alive as two competing recovery days once the native series was read. Resolve recovery by a source-aware wake-day key in the user's timezone — the computed proxy's day is shifted forward to meet WHOOP's wake morning — so one night collapses to one canonical row without re-dating the stored stamp and without a backfill. Thread the same resolution through the wellness reader, the coach nudge trend, and the coach snapshot, which had each read recovery raw.
This commit is contained in:
commit
5831143e26
8 files changed
+295
-48
No files matched your search
@@ -5,10 +5,21 @@ vi.mock("@/lib/insights/derived", async (importOriginal) => {
|
||||
return { ...actual, computeDerivedMetric: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { measurement: { findFirst: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { computeDerivedMetric } from "@/lib/insights/derived";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { buildDerivedSnapshotBlock } from "../derived-snapshot";
|
||||
|
||||
const compute = computeDerivedMetric as unknown as ReturnType<typeof vi.fn>;
|
||||
const whoopFindFirst = prisma.measurement
|
||||
.findFirst as unknown as ReturnType<typeof vi.fn>;
|
||||
/** Make the WHOOP-native recovery probe report present / absent. */
|
||||
function setWhoopNativePresent(present: boolean) {
|
||||
whoopFindFirst.mockResolvedValue(present ? { id: "whoop-1" } : null);
|
||||
}
|
||||
const PROFILE = { ageYears: 40, sex: "MALE" as const, heightCm: 180 };
|
||||
const NOW = new Date("2026-06-02T08:00:00Z");
|
||||
|
||||
@@ -28,7 +39,13 @@ const insufficient = {
|
||||
reason: "no_score_in_window",
|
||||
};
|
||||
|
||||
beforeEach(() => compute.mockReset());
|
||||
beforeEach(() => {
|
||||
compute.mockReset();
|
||||
whoopFindFirst.mockReset();
|
||||
// Default: no WHOOP-native recovery row in the window. Tests that exercise
|
||||
// the native-present path flip this explicitly.
|
||||
setWhoopNativePresent(false);
|
||||
});
|
||||
|
||||
describe("buildDerivedSnapshotBlock", () => {
|
||||
it("returns null when every metric is insufficient", async () => {
|
||||
@@ -38,6 +55,9 @@ describe("buildDerivedSnapshotBlock", () => {
|
||||
});
|
||||
|
||||
it("emits compact value + band + confidence per ok metric, omits insufficient", async () => {
|
||||
// A WHOOP-native recovery is present, so the recovery line survives
|
||||
// alongside readiness (the dedup only drops the COMPUTED proxy).
|
||||
setWhoopNativePresent(true);
|
||||
compute.mockImplementation(async (args?: { metric?: string }) => {
|
||||
if (args?.metric === "READINESS") return ok({ score: 64, band: "yellow" });
|
||||
if (args?.metric === "RECOVERY_SCORE") return ok({ score: 72, band: "green" });
|
||||
@@ -61,9 +81,11 @@ describe("buildDerivedSnapshotBlock", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops RECOVERY_SCORE when it mirrors READINESS (COMPUTED proxy, no native)", async () => {
|
||||
// With no WHOOP-native recovery, the COMPUTED proxy IS the readiness blend
|
||||
// verbatim — feeding both labels hands the model the same number twice.
|
||||
it("drops RECOVERY_SCORE when it is the COMPUTED proxy (no WHOOP-native row)", async () => {
|
||||
// No WHOOP-native recovery in the window → the resolved recovery IS the
|
||||
// COMPUTED proxy, which equals readiness. Feeding both hands the model the
|
||||
// same number twice, so the recovery line is dropped.
|
||||
setWhoopNativePresent(false);
|
||||
compute.mockImplementation(async (args?: { metric?: string }) => {
|
||||
if (args?.metric === "READINESS") return ok({ score: 64, band: "yellow" });
|
||||
if (args?.metric === "RECOVERY_SCORE") return ok({ score: 64, band: "yellow" });
|
||||
@@ -72,11 +94,26 @@ describe("buildDerivedSnapshotBlock", () => {
|
||||
const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW);
|
||||
expect(block).not.toBeNull();
|
||||
expect(block!.READINESS).toBeDefined();
|
||||
// The redundant proxy is removed — readiness once, not twice.
|
||||
expect(block!.RECOVERY_SCORE).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps RECOVERY_SCORE when the WHOOP-native value differs from READINESS", async () => {
|
||||
it("keeps a WHOOP-native RECOVERY_SCORE that coincidentally equals READINESS (L4)", async () => {
|
||||
// A genuine WHOOP recovery happens to match readiness on the nose. The old
|
||||
// numeric-equality drop would silence the device's ground-truth number; the
|
||||
// source-aware drop keeps it because a WHOOP-native row exists.
|
||||
setWhoopNativePresent(true);
|
||||
compute.mockImplementation(async (args?: { metric?: string }) => {
|
||||
if (args?.metric === "READINESS") return ok({ score: 64, band: "yellow" });
|
||||
if (args?.metric === "RECOVERY_SCORE") return ok({ score: 64, band: "yellow" });
|
||||
return insufficient;
|
||||
});
|
||||
const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW);
|
||||
expect(block!.READINESS?.value).toBe(64);
|
||||
expect(block!.RECOVERY_SCORE?.value).toBe(64);
|
||||
});
|
||||
|
||||
it("keeps RECOVERY_SCORE when a WHOOP-native value differs from READINESS", async () => {
|
||||
setWhoopNativePresent(true);
|
||||
compute.mockImplementation(async (args?: { metric?: string }) => {
|
||||
if (args?.metric === "READINESS") return ok({ score: 64, band: "yellow" });
|
||||
if (args?.metric === "RECOVERY_SCORE") return ok({ score: 80, band: "green" });
|
||||
|
||||
@@ -20,6 +20,14 @@ import {
|
||||
type DerivedMetricId,
|
||||
type BaselineProfile,
|
||||
} from "@/lib/insights/derived";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Trailing window the recovery dedup probes for a WHOOP-native row — matches
|
||||
* the wellness reader's default trend window so "is the resolved recovery the
|
||||
* COMPUTED proxy?" is answered against the same set of days.
|
||||
*/
|
||||
const RECOVERY_DEDUP_WINDOW_DAYS = 14;
|
||||
|
||||
/** The high-signal derived metrics worth a Coach prompt slot. The vitals
|
||||
* baseline is omitted — the per-vital aggregate block already carries those
|
||||
@@ -127,14 +135,29 @@ export async function buildDerivedSnapshotBlock(
|
||||
// Recovery dedup (one number, one engine). The COMPUTED recovery proxy IS the
|
||||
// readiness blend verbatim, so when no WHOOP-native row exists the resolved
|
||||
// recovery equals readiness — feeding both labels would hand the model the
|
||||
// identical score under two names. Drop the redundant recovery line in that
|
||||
// case; a WHOOP-native recovery resolves to a DIFFERENT number and both stay.
|
||||
if (
|
||||
block.READINESS !== undefined &&
|
||||
block.RECOVERY_SCORE !== undefined &&
|
||||
block.RECOVERY_SCORE.value === block.READINESS.value
|
||||
) {
|
||||
delete block.RECOVERY_SCORE;
|
||||
// identical score under two names. Drop the redundant recovery line ONLY when
|
||||
// the resolved recovery is that COMPUTED proxy (no WHOOP-native row present).
|
||||
// A genuine WHOOP recovery stays even if it coincidentally equals readiness —
|
||||
// a numeric tie must never silence the device's ground-truth number.
|
||||
if (block.READINESS !== undefined && block.RECOVERY_SCORE !== undefined) {
|
||||
const whoopRecovery = await prisma.measurement.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
type: "RECOVERY_SCORE",
|
||||
source: "WHOOP",
|
||||
deletedAt: null,
|
||||
measuredAt: {
|
||||
gte: new Date(
|
||||
now.getTime() - RECOVERY_DEDUP_WINDOW_DAYS * 24 * 60 * 60 * 1000,
|
||||
),
|
||||
lte: now,
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (whoopRecovery === null) {
|
||||
delete block.RECOVERY_SCORE;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(block).length > 0 ? block : null;
|
||||
|
||||
@@ -8,11 +8,21 @@ function row(iso: string, value: number, source: string): RecoveryRow {
|
||||
return { value, measuredAt: new Date(iso), source: source as never };
|
||||
}
|
||||
|
||||
// All stamps below use REALISTIC source clocks for one physiological night:
|
||||
// - WHOOP stamps the wake-morning instant (`updated_at`).
|
||||
// - COMPUTED stamps noon-UTC of the day-that-ended (`scoreDayKey = run − 1d`),
|
||||
// so for the SAME night it lands one calendar day BEFORE the WHOOP row.
|
||||
// Europe/Berlin (the resolver default) is UTC+2 in June, so a noon-UTC stamp
|
||||
// reads as the same local day and a ~06:00-UTC wake stamp reads as the wake day.
|
||||
describe("resolveCanonicalRecovery", () => {
|
||||
it("prefers the WHOOP-native row over the COMPUTED proxy for the same day", () => {
|
||||
it("collapses the same night's WHOOP + COMPUTED rows to the WHOOP value", () => {
|
||||
// Night of Jun 01 → wake Jun 02. WHOOP scores Jun 02 morning; the COMPUTED
|
||||
// proxy for that readiness is filed under Jun 01 (the day that ended). With
|
||||
// realistic stamps the two land on DIFFERENT calendar days — the resolver's
|
||||
// source-aware wake-day key must still fold them into ONE canonical night.
|
||||
const rows = [
|
||||
row("2026-06-01T12:00:00Z", 50, "COMPUTED"),
|
||||
row("2026-06-01T06:00:00Z", 80, "WHOOP"),
|
||||
row("2026-06-01T12:00:00Z", 50, "COMPUTED"), // day-that-ended stamp
|
||||
row("2026-06-02T06:00:00Z", 80, "WHOOP"), // wake-morning stamp
|
||||
];
|
||||
const resolved = resolveCanonicalRecovery(rows);
|
||||
expect(resolved).toHaveLength(1);
|
||||
@@ -20,7 +30,7 @@ describe("resolveCanonicalRecovery", () => {
|
||||
expect(resolved[0].value).toBe(80);
|
||||
});
|
||||
|
||||
it("falls back to COMPUTED when no WHOOP row exists for the day", () => {
|
||||
it("falls back to COMPUTED when no WHOOP row exists for the night", () => {
|
||||
const rows = [row("2026-06-01T12:00:00Z", 55, "COMPUTED")];
|
||||
const resolved = resolveCanonicalRecovery(rows);
|
||||
expect(resolved).toHaveLength(1);
|
||||
@@ -28,31 +38,49 @@ describe("resolveCanonicalRecovery", () => {
|
||||
expect(resolved[0].value).toBe(55);
|
||||
});
|
||||
|
||||
it("resolves per day — WHOOP one day, COMPUTED another", () => {
|
||||
it("keeps a genuinely SEPARATE next night separate", () => {
|
||||
// Two nights: Jun 01→02 (WHOOP, wake Jun 02) and Jun 02→03 (COMPUTED proxy
|
||||
// filed under Jun 02). Despite both touching the Jun 02 calendar day on raw
|
||||
// stamps, they are DIFFERENT nights and must stay two canonical rows.
|
||||
const rows = [
|
||||
row("2026-06-02T12:00:00Z", 51, "COMPUTED"),
|
||||
row("2026-06-01T12:00:00Z", 49, "COMPUTED"),
|
||||
row("2026-06-01T06:00:00Z", 70, "WHOOP"),
|
||||
row("2026-06-02T12:00:00Z", 51, "COMPUTED"), // night Jun02→03, wake Jun03
|
||||
row("2026-06-02T06:00:00Z", 70, "WHOOP"), // night Jun01→02, wake Jun02
|
||||
];
|
||||
const resolved = resolveCanonicalRecovery(rows);
|
||||
// Sorted desc by measuredAt: 06-02 COMPUTED first, then 06-01 WHOOP.
|
||||
expect(resolved).toHaveLength(2);
|
||||
expect(resolved[0].source).toBe("COMPUTED");
|
||||
expect(resolved[0].value).toBe(51);
|
||||
expect(resolved[1].source).toBe("WHOOP");
|
||||
expect(resolved[1].value).toBe(70);
|
||||
// Sorted desc by measuredAt: the Jun02-noon COMPUTED first, then the
|
||||
// Jun02-06:00 WHOOP. Both survive — no night was merged away.
|
||||
expect(resolved.map((r) => r.value).sort()).toEqual([51, 70]);
|
||||
expect(resolved.map((r) => r.source).sort()).toEqual(["COMPUTED", "WHOOP"]);
|
||||
});
|
||||
|
||||
it("keeps the latest same-source row within a day", () => {
|
||||
it("keeps the latest same-source row within a night", () => {
|
||||
const rows = [
|
||||
row("2026-06-01T06:00:00Z", 60, "WHOOP"),
|
||||
row("2026-06-01T18:00:00Z", 75, "WHOOP"),
|
||||
row("2026-06-02T06:00:00Z", 60, "WHOOP"),
|
||||
row("2026-06-02T08:00:00Z", 75, "WHOOP"), // a morning re-score
|
||||
];
|
||||
const resolved = resolveCanonicalRecovery(rows);
|
||||
expect(resolved).toHaveLength(1);
|
||||
expect(resolved[0].value).toBe(75);
|
||||
});
|
||||
|
||||
it("buckets by the user's local wake-day, not UTC", () => {
|
||||
// A WHOOP wake stamp just after local midnight in a far-east zone. Read in
|
||||
// UTC it would file under the previous calendar day; in the user's zone it
|
||||
// is the wake day, matching the COMPUTED proxy's forward-shifted anchor.
|
||||
const rows = [
|
||||
// Pacific/Auckland (UTC+12 in June): 2026-06-02T13:00:00Z = 2026-06-03 01:00 local.
|
||||
row("2026-06-02T13:00:00Z", 82, "WHOOP"),
|
||||
// COMPUTED proxy for the same wake (Jun 03 local): filed Jun 02 noon UTC,
|
||||
// which is Jun 03 00:00 local → shifted forward to Jun 03 local wake day.
|
||||
row("2026-06-02T00:00:00Z", 40, "COMPUTED"),
|
||||
];
|
||||
const resolved = resolveCanonicalRecovery(rows, "Pacific/Auckland");
|
||||
expect(resolved).toHaveLength(1);
|
||||
expect(resolved[0].source).toBe("WHOOP");
|
||||
expect(resolved[0].value).toBe(82);
|
||||
});
|
||||
|
||||
it("returns an empty list for no rows", () => {
|
||||
expect(resolveCanonicalRecovery([])).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
strainTrimpCache: { findUnique: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -19,11 +20,16 @@ const NOW = new Date("2026-06-02T08:00:00Z");
|
||||
const findMany = prisma.measurement.findMany as ReturnType<typeof vi.fn>;
|
||||
const cacheFindUnique = prisma.strainTrimpCache
|
||||
.findUnique as ReturnType<typeof vi.fn>;
|
||||
const userFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
findMany.mockReset();
|
||||
cacheFindUnique.mockReset();
|
||||
cacheFindUnique.mockResolvedValue(null);
|
||||
// The recovery resolver buckets by the user's local wake-day; the reader
|
||||
// loads the zone when none is passed. Default the cohort to Europe/Berlin.
|
||||
userFindUnique.mockReset();
|
||||
userFindUnique.mockResolvedValue({ timezone: "Europe/Berlin" });
|
||||
});
|
||||
|
||||
describe("bandWellnessScore", () => {
|
||||
@@ -135,11 +141,13 @@ describe("computeWellnessScore", () => {
|
||||
});
|
||||
|
||||
it("RECOVERY reads BOTH sources and resolves the WHOOP-native row when present", async () => {
|
||||
// Same day written by both the COMPUTED proxy AND the WHOOP-native sync.
|
||||
// The native row is canonical → the tile shows 80, not the proxy's 50.
|
||||
// ONE night written by both engines on their realistic clocks: the WHOOP
|
||||
// wake-morning stamp (Jun 02) and the COMPUTED proxy filed under the
|
||||
// day-that-ended (Jun 01). The native row is canonical → the tile shows 80,
|
||||
// not the proxy's 50, and the off-by-one does NOT spawn two recovery days.
|
||||
findMany.mockResolvedValue([
|
||||
{ value: 50, measuredAt: new Date("2026-06-02T12:00:00Z"), source: "COMPUTED" },
|
||||
{ value: 80, measuredAt: new Date("2026-06-02T06:00:00Z"), source: "WHOOP" },
|
||||
{ value: 50, measuredAt: new Date("2026-06-01T12:00:00Z"), source: "COMPUTED" },
|
||||
]);
|
||||
const r = await computeWellnessScore("RECOVERY_SCORE", "u1", PROFILE, {
|
||||
now: NOW,
|
||||
@@ -151,6 +159,8 @@ describe("computeWellnessScore", () => {
|
||||
if (r.status === "ok") {
|
||||
expect((r.value as WellnessScoreValue).score).toBe(80);
|
||||
expect((r.value as WellnessScoreValue).band).toBe("green");
|
||||
// One night, one canonical row — the off-by-one collapsed.
|
||||
expect((r.value as WellnessScoreValue).daysInWindow).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -14,11 +14,35 @@
|
||||
* doctor PDF, the iOS feed — must resolve to the SAME canonical row per day so
|
||||
* none of them shows the proxy and the native value as two competing series.
|
||||
*
|
||||
* Pure resolution: group rows by their calendar day and, per day, keep the
|
||||
* WHOOP row when one exists, else the COMPUTED one. No DB access here — the
|
||||
* caller does the bounded read and passes rows in.
|
||||
* Pure resolution: group rows by the physiological NIGHT they describe and,
|
||||
* per night, keep the WHOOP row when one exists, else the COMPUTED one. No DB
|
||||
* access here — the caller does the bounded read and passes rows in.
|
||||
*
|
||||
* THE DAY-ANCHOR PROBLEM (v1.16.16). The two sources stamp the SAME night on
|
||||
* opposite clocks:
|
||||
* - WHOOP stamps `measuredAt = updated_at` — the MORNING instant WHOOP
|
||||
* finished scoring the night → the WAKE day D.
|
||||
* - COMPUTED stamps `measuredAt = noon-UTC of scoreDayKey` where
|
||||
* `scoreDayKey = (run morning) − 1 day` → the day-that-ENDED, D−1
|
||||
* (`src/lib/insights/score-row.ts`). The 04:45 Europe/Berlin cron
|
||||
* scores last night's signals but files them under the prior day.
|
||||
* So for ONE night the WHOOP row lands on day D and the COMPUTED proxy on day
|
||||
* D−1 — a systematic off-by-one. Bucketing raw `measuredAt` by calendar day
|
||||
* therefore leaves BOTH alive as two competing recovery days.
|
||||
*
|
||||
* The fix is a SOURCE-AWARE wake-day key: the COMPUTED proxy's day stamp is
|
||||
* shifted forward one day (the readiness of the day that ended IS the readiness
|
||||
* you wake with the next morning), and both keys are read in the user's LOCAL
|
||||
* timezone so a near-midnight / late re-score can't split a single night. The
|
||||
* COMPUTED proxy's own stored stamp (shared with stress / strain) is NOT
|
||||
* touched — the shift is a pure read-time alignment, so no ingest change and no
|
||||
* backfill are needed and two genuinely different nights stay separate.
|
||||
*/
|
||||
import type { MeasurementSource } from "@/generated/prisma/client";
|
||||
import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz";
|
||||
import { resolveUserTimezone } from "@/lib/measurements/consolidation-base";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** A `RECOVERY_SCORE` row as read for resolution. */
|
||||
export interface RecoveryRow {
|
||||
@@ -41,23 +65,43 @@ function rankOf(source: MeasurementSource): number {
|
||||
return RECOVERY_SOURCE_RANK[source] ?? Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
/** The UTC calendar-day key a recovery row is filed under. */
|
||||
function dayKeyOf(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
/**
|
||||
* The local WAKE-day key a recovery row describes. WHOOP's stamp already IS the
|
||||
* wake morning, so its local day is the night's wake day directly. The COMPUTED
|
||||
* proxy stamps the day-that-ended (`scoreDayKey = run − 1 day`), so its day is
|
||||
* shifted forward by one before the local-day read — aligning it onto the same
|
||||
* wake day the WHOOP row for that night carries. Reading the key in the user's
|
||||
* timezone keeps a near-midnight or late re-score on the same night.
|
||||
*/
|
||||
function wakeDayKeyOf(d: Date, source: MeasurementSource, tz: string): string {
|
||||
const anchor =
|
||||
source === "COMPUTED" ? new Date(d.getTime() + MS_PER_DAY) : d;
|
||||
return dayKeyForUserTz(anchor, tz);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a mixed-source `RECOVERY_SCORE` set to ONE canonical row per day:
|
||||
* the WHOOP-native row wins when present, else the COMPUTED proxy. Within a
|
||||
* source the latest `measuredAt` wins (a same-day re-score). Returns the
|
||||
* Collapse a mixed-source `RECOVERY_SCORE` set to ONE canonical row per
|
||||
* physiological NIGHT: the WHOOP-native row wins when present, else the
|
||||
* COMPUTED proxy. Within a source the latest `measuredAt` wins (a same-night
|
||||
* re-score). Rows are bucketed by their local WAKE-day (the COMPUTED proxy's
|
||||
* day-that-ended stamp is shifted forward one day to meet WHOOP's wake-morning
|
||||
* stamp) so a single night never splits into two competing days. Returns the
|
||||
* canonical rows in the same shape, sorted by `measuredAt` DESCENDING so the
|
||||
* first element is the most recent canonical day — matching the order the
|
||||
* first element is the most recent canonical night — matching the order the
|
||||
* wellness reader already expects.
|
||||
*
|
||||
* `timezone` is the user's IANA zone (falls back to `Europe/Berlin` when
|
||||
* null/empty); the wake-day key is read in it so the bucket boundary tracks the
|
||||
* user's midnight, not UTC's.
|
||||
*/
|
||||
export function resolveCanonicalRecovery(rows: RecoveryRow[]): RecoveryRow[] {
|
||||
export function resolveCanonicalRecovery(
|
||||
rows: RecoveryRow[],
|
||||
timezone?: string | null,
|
||||
): RecoveryRow[] {
|
||||
const tz = resolveUserTimezone(timezone ?? null);
|
||||
const byDay = new Map<string, RecoveryRow>();
|
||||
for (const row of rows) {
|
||||
const key = dayKeyOf(row.measuredAt);
|
||||
const key = wakeDayKeyOf(row.measuredAt, row.source, tz);
|
||||
const incumbent = byDay.get(key);
|
||||
if (incumbent === undefined) {
|
||||
byDay.set(key, row);
|
||||
|
||||
@@ -90,6 +90,13 @@ export interface WellnessScoreOpts {
|
||||
/** Trailing window for the trend mean (days). Defaults to 14. */
|
||||
windowDays?: number;
|
||||
now?: Date;
|
||||
/**
|
||||
* The user's IANA timezone, used only by the RECOVERY canonical-night
|
||||
* resolver to bucket WHOOP + COMPUTED rows by the local wake-day. When
|
||||
* omitted, the reader loads it from the user row (falling back to
|
||||
* `Europe/Berlin`). STRESS / STRAIN ignore it.
|
||||
*/
|
||||
timezone?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,6 +139,17 @@ export async function computeWellnessScore(
|
||||
// wins over COMPUTED). Non-recovery sets are already single-source, so the
|
||||
// resolver is recovery-only — the tile, doctor PDF, and iOS feed all read the
|
||||
// SAME canonical value.
|
||||
let timezone = opts.timezone ?? null;
|
||||
if (isRecovery && timezone === null) {
|
||||
// The canonical-night resolver buckets by the user's local wake-day, so the
|
||||
// off-by-one between WHOOP's wake-morning stamp and the COMPUTED proxy's
|
||||
// day-that-ended stamp collapses onto one night. Load the zone once.
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { timezone: true },
|
||||
});
|
||||
timezone = user?.timezone ?? null;
|
||||
}
|
||||
const rows = isRecovery
|
||||
? resolveCanonicalRecovery(
|
||||
rawRows.map((r) => ({
|
||||
@@ -139,6 +157,7 @@ export async function computeWellnessScore(
|
||||
measuredAt: r.measuredAt,
|
||||
source: r.source,
|
||||
})),
|
||||
timezone,
|
||||
)
|
||||
: rawRows;
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
evaluateSelfContextTrigger,
|
||||
evaluateSleepDebtTrigger,
|
||||
evaluateWeightTrigger,
|
||||
findTriggerForUser,
|
||||
runCoachNudgeTick,
|
||||
} from "../coach-nudge";
|
||||
import { getAssistantFlags } from "@/lib/feature-flags";
|
||||
@@ -165,6 +166,82 @@ describe("evaluateScoreTrigger", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("findTriggerForUser RECOVERY_SCORE canonical resolution", () => {
|
||||
const NOW = new Date("2026-06-15T08:00:00Z");
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
// Vitals-only so the recovery branch is the only one with data; no height /
|
||||
// dob / gender → the WEIGHT range can't resolve and that branch is skipped.
|
||||
const VITALS_ONLY = { medication: false, vitals: true, routine: false };
|
||||
const baseUser = {
|
||||
id: "u-recovery",
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
thresholdsJson: null,
|
||||
timezone: "Europe/Berlin",
|
||||
};
|
||||
|
||||
/** A prisma stub serving ONLY the supplied RECOVERY_SCORE rows; every other
|
||||
* measurement read comes back empty so no other vitals trigger fires. */
|
||||
function recoveryPrisma(recoveryRows: unknown[]) {
|
||||
return {
|
||||
measurement: {
|
||||
findMany: vi.fn(async (args: { where: { type?: string } }) =>
|
||||
args.where.type === "RECOVERY_SCORE" ? recoveryRows : [],
|
||||
),
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
/** A recovery row on a source's realistic clock. */
|
||||
function rec(iso: string, value: number, source: "WHOOP" | "COMPUTED") {
|
||||
return { value, measuredAt: new Date(iso), source };
|
||||
}
|
||||
|
||||
it("does not fire when the canonical WHOOP series is stable but stray COMPUTED proxies dip", async () => {
|
||||
// Healthy, flat WHOOP-native recovery across both windows (~70). For the
|
||||
// SAME nights a low COMPUTED proxy (~40) also sits in the table. Blending
|
||||
// both — the pre-fix behaviour — would drag the recent mean down enough to
|
||||
// trip the drop trigger. Canonical resolution keeps only the WHOOP rows.
|
||||
const rows: unknown[] = [];
|
||||
for (let d = 1; d <= 12; d++) {
|
||||
const day = new Date(NOW.getTime() - d * DAY);
|
||||
const iso = day.toISOString().slice(0, 10);
|
||||
rows.push(rec(`${iso}T06:00:00Z`, 70, "WHOOP"));
|
||||
// COMPUTED proxy for the same night, filed one day earlier at noon UTC.
|
||||
const prevIso = new Date(day.getTime() - DAY).toISOString().slice(0, 10);
|
||||
rows.push(rec(`${prevIso}T12:00:00Z`, 40, "COMPUTED"));
|
||||
}
|
||||
const trigger = await findTriggerForUser(
|
||||
recoveryPrisma(rows),
|
||||
baseUser,
|
||||
NOW,
|
||||
VITALS_ONLY,
|
||||
);
|
||||
expect(trigger).not.toBe("score");
|
||||
});
|
||||
|
||||
it("still fires on a genuine WHOOP-native week-over-week recovery drop", async () => {
|
||||
// No COMPUTED rows — a real native collapse from ~72 to ~50 must trigger.
|
||||
const rows: unknown[] = [];
|
||||
for (let d = 1; d <= 6; d++) {
|
||||
const day = new Date(NOW.getTime() - d * DAY).toISOString().slice(0, 10);
|
||||
rows.push(rec(`${day}T06:00:00Z`, 50, "WHOOP"));
|
||||
}
|
||||
for (let d = 8; d <= 13; d++) {
|
||||
const day = new Date(NOW.getTime() - d * DAY).toISOString().slice(0, 10);
|
||||
rows.push(rec(`${day}T06:00:00Z`, 72, "WHOOP"));
|
||||
}
|
||||
const trigger = await findTriggerForUser(
|
||||
recoveryPrisma(rows),
|
||||
baseUser,
|
||||
NOW,
|
||||
VITALS_ONLY,
|
||||
);
|
||||
expect(trigger).toBe("score");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCoachNudgePayload", () => {
|
||||
it("resolves the German payload", () => {
|
||||
const { title, body } = buildCoachNudgePayload("bp", "de");
|
||||
|
||||
@@ -77,6 +77,7 @@ import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { defaultLocale, locales } from "@/lib/i18n/config";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve";
|
||||
// Trend thresholds shared with the dashboard hero's verdict resolver
|
||||
// live in a client-safe leaf module; re-exported below so server-side
|
||||
// imports keep their path.
|
||||
@@ -434,6 +435,7 @@ export async function findTriggerForUser(
|
||||
dateOfBirth: Date | null;
|
||||
gender: string | null;
|
||||
thresholdsJson: unknown;
|
||||
timezone?: string | null;
|
||||
},
|
||||
now: Date,
|
||||
groups: Record<CoachNudgeTriggerGroup, boolean> = {
|
||||
@@ -497,16 +499,22 @@ export async function findTriggerForUser(
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Recovery score falling sharply week-over-week.
|
||||
const scores = await prisma.measurement.findMany({
|
||||
// 3) Recovery score falling sharply week-over-week. RECOVERY_SCORE is
|
||||
// written by TWO sources (WHOOP-native + the COMPUTED proxy); reading
|
||||
// the raw rows would blend both series into the trend windows and let a
|
||||
// single night appear twice. Resolve to the ONE canonical row per night
|
||||
// (WHOOP wins when present) before the recent/prior split — the same
|
||||
// "one number, one engine" rule the wellness tile applies.
|
||||
const scoreRows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: "RECOVERY_SCORE",
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: fourteenDaysAgo, lte: now },
|
||||
},
|
||||
select: { value: true, measuredAt: true },
|
||||
select: { value: true, measuredAt: true, source: true },
|
||||
});
|
||||
const scores = resolveCanonicalRecovery(scoreRows, user.timezone ?? null);
|
||||
const recent = scores
|
||||
.filter((m) => m.measuredAt >= sevenDaysAgo)
|
||||
.map((m) => m.value);
|
||||
@@ -691,6 +699,7 @@ export async function runCoachNudgeTick(
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
thresholdsJson: true,
|
||||
timezone: true,
|
||||
aiProvider: true,
|
||||
aiProviderChain: true,
|
||||
aiAnthropicKeyEncrypted: true,
|
||||
|
||||
Reference in new issue
Block a user