fix(insights): collapse WHOOP and computed recovery to one canonical night

The native WHOOP recovery and the computed proxy stamp the same night on
opposite clocks: WHOOP files the wake-morning instant (day D), the proxy files
noon-UTC of the day that ended (day D-1). Bucketing raw measuredAt by calendar
day leaves both alive as two competing recovery days.

Bucket each source by the user's local wake-day instead — shifting the proxy's
day-that-ended stamp forward one day to meet WHOOP's wake-morning stamp — so a
single night collapses to one canonical row while distinct nights stay
separate. Resolve the coach-nudge recovery trend and the coach-snapshot dedup
through the same path so no surface blends the two series or drops a genuine
native value on a numeric tie.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 17:49:45 +02:00
1 parent 555240cd28
commit 4eee7c1de5
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" });
+31 -8
View File
@@ -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);
}
});
+56 -12
View File
@@ -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");
+12 -3
View File
@@ -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,