diff --git a/src/components/insights/__tests__/health-score-card.test.tsx b/src/components/insights/__tests__/health-score-card.test.tsx index 0353d7719..c1f798c3e 100644 --- a/src/components/insights/__tests__/health-score-card.test.tsx +++ b/src/components/insights/__tests__/health-score-card.test.tsx @@ -359,3 +359,75 @@ describe("", () => { expect(progressMatch?.[0]).toContain('aria-valuemax="100"'); }); }); + +/** + * v1.34 — the weight row names its yardstick. Before this the score graded + * weight against a band the user never set and the card said nothing at all, + * which is why the defect survived for months. + */ +describe(" — weight yardstick disclosure", () => { + it("names the user's own target band", () => { + const html = ssr( + , + ); + const line = html.match( + /]*data-slot="health-score-card-weight-yardstick"[^>]*>([\s\S]*?)<\/p>/, + ); + expect(line).not.toBeNull(); + expect(line?.[0]).toContain('data-target-source="user"'); + expect(line?.[1]).toContain("64-70 kg"); + }); + + it("says plainly when no target graded the row", () => { + const html = ssr( + , + ); + const line = html.match( + /]*data-slot="health-score-card-weight-yardstick"[^>]*>([\s\S]*?)<\/p>/, + ); + expect(line).not.toBeNull(); + expect(line?.[0]).toContain('data-target-source="none"'); + expect(line?.[1]).toMatch(/trend/i); + }); + + it("discloses nothing when the payload carries no yardstick", () => { + // An older cached body has no `targetSource`; silence beats a guess. + const html = ssr( + , + ); + expect(html).not.toContain("health-score-card-weight-yardstick"); + }); + + it("stays quiet when the weight pillar itself has no value", () => { + const html = ssr( + , + ); + expect(html).not.toContain("health-score-card-weight-yardstick"); + }); +}); diff --git a/src/lib/analytics/__tests__/effective-range.test.ts b/src/lib/analytics/__tests__/effective-range.test.ts index 4ce9905a8..8c7e42372 100644 --- a/src/lib/analytics/__tests__/effective-range.test.ts +++ b/src/lib/analytics/__tests__/effective-range.test.ts @@ -3,6 +3,7 @@ import { getEffectiveRange, getAllEffectiveRanges, METRIC_BOUNDS, + resolveWeightTargetOverride, type UserProfileForRange, type ThresholdOverridesJson, } from "../effective-range"; @@ -224,3 +225,48 @@ describe("OXYGEN_SATURATION thresholds", () => { }); }); }); + +/** + * v1.34 — `User.thresholdsJson` is untyped JSON and v1.34 gave the weight + * target three new consumers at once (the Health Score, the dashboard bands, + * the PR direction). One narrowing beats three hand-rolled casts, and a + * malformed blob must read as "no target", never as a target of NaN. + */ +describe("resolveWeightTargetOverride", () => { + it("reads a well-formed weight override", () => { + expect( + resolveWeightTargetOverride({ WEIGHT: { min: 74, max: 78 } }), + ).toEqual({ min: 74, max: 78 }); + }); + + it("returns null for a blob with no weight entry", () => { + expect(resolveWeightTargetOverride(null)).toBeNull(); + expect(resolveWeightTargetOverride({})).toBeNull(); + expect( + resolveWeightTargetOverride({ BLOOD_PRESSURE_SYS: { min: 1, max: 2 } }), + ).toBeNull(); + }); + + it("rejects a malformed pair instead of passing it through", () => { + expect(resolveWeightTargetOverride({ WEIGHT: { min: 74 } })).toBeNull(); + expect( + resolveWeightTargetOverride({ WEIGHT: { min: "74", max: "78" } }), + ).toBeNull(); + expect( + resolveWeightTargetOverride({ WEIGHT: { min: NaN, max: 78 } }), + ).toBeNull(); + // An inverted or collapsed band is not a band. + expect( + resolveWeightTargetOverride({ WEIGHT: { min: 80, max: 70 } }), + ).toBeNull(); + expect( + resolveWeightTargetOverride({ WEIGHT: { min: 75, max: 75 } }), + ).toBeNull(); + }); + + it("survives a non-object blob", () => { + expect(resolveWeightTargetOverride("nope")).toBeNull(); + expect(resolveWeightTargetOverride(42)).toBeNull(); + expect(resolveWeightTargetOverride(undefined)).toBeNull(); + }); +}); diff --git a/src/lib/analytics/__tests__/health-score-fast-path.test.ts b/src/lib/analytics/__tests__/health-score-fast-path.test.ts index fd6d575f3..60585e8a7 100644 --- a/src/lib/analytics/__tests__/health-score-fast-path.test.ts +++ b/src/lib/analytics/__tests__/health-score-fast-path.test.ts @@ -132,9 +132,11 @@ describe("computeUserHealthScoreFastPath", () => { const result = await computeUserHealthScoreFastPath({ userId: "user-rollup", bpInTargetPct: 85, - heightCm: 178, + weightTarget: null, + weightTargetSource: "none", now, coverage, + moodEnabled: true, }); expect(result).not.toBeNull(); @@ -225,9 +227,11 @@ describe("computeUserHealthScoreFastPath", () => { await computeUserHealthScoreFastPath({ userId: "user-longwindow", bpInTargetPct: 85, - heightCm: 178, + weightTarget: null, + weightTargetSource: "none", now, coverage, + moodEnabled: true, }); const calls = ANNOTATE.mock.calls.map((c) => c[0]); @@ -276,9 +280,11 @@ describe("computeUserHealthScoreFastPath", () => { await computeUserHealthScoreFastPath({ userId: "user-no-longwindow", bpInTargetPct: 85, - heightCm: 178, + weightTarget: null, + weightTargetSource: "none", now, coverage, + moodEnabled: true, }); const calls = ANNOTATE.mock.calls.map((c) => c[0]); @@ -306,9 +312,11 @@ describe("computeUserHealthScoreFastPath", () => { await computeUserHealthScoreFastPath({ userId: "user-live-nowmy", bpInTargetPct: 80, - heightCm: 178, + weightTarget: null, + weightTargetSource: "none", now, coverage, + moodEnabled: true, }); // The live branch never issues the long-window probe — guards @@ -348,9 +356,11 @@ describe("computeUserHealthScoreFastPath", () => { const result = await computeUserHealthScoreFastPath({ userId: "user-live", bpInTargetPct: 75, - heightCm: 178, + weightTarget: null, + weightTargetSource: "none", now, coverage, + moodEnabled: true, }); expect(result).not.toBeNull(); @@ -402,9 +412,11 @@ describe("computeUserHealthScoreFastPath", () => { const result = await computeUserHealthScoreFastPath({ userId: "user-no-height", bpInTargetPct: null, - heightCm: null, + weightTarget: null, + weightTargetSource: "none", now, coverage, + moodEnabled: true, }); expect(result).not.toBeNull(); @@ -430,9 +442,11 @@ describe("computeUserHealthScoreFastPath", () => { const result = await computeUserHealthScoreFastPath({ userId: "user-empty", bpInTargetPct: null, - heightCm: null, + weightTarget: null, + weightTargetSource: "none", now: new Date("2026-05-17T12:00:00.000Z"), coverage, + moodEnabled: true, }); expect(result).toBeNull(); @@ -468,9 +482,11 @@ describe("computeUserHealthScoreFastPath", () => { const result = await computeUserHealthScoreFastPath({ userId: "user-one-mood-entry", bpInTargetPct: null, - heightCm: null, + weightTarget: null, + weightTargetSource: "none", now, coverage, + moodEnabled: true, }); expect(result).toBeNull(); @@ -500,9 +516,11 @@ describe("computeUserHealthScoreFastPath", () => { const result = await computeUserHealthScoreFastPath({ userId: "user-one-weight-reading", bpInTargetPct: null, - heightCm: null, + weightTarget: null, + weightTargetSource: "none", now, coverage, + moodEnabled: true, }); expect(result).toBeNull(); @@ -534,9 +552,11 @@ describe("computeUserHealthScoreFastPath", () => { const result = await computeUserHealthScoreFastPath({ userId: "user-multi-pillar-ineligible", bpInTargetPct: null, - heightCm: null, + weightTarget: null, + weightTargetSource: "none", now, coverage, + moodEnabled: true, }); expect(result).toBeNull(); @@ -559,9 +579,11 @@ describe("computeUserHealthScoreFastPath", () => { userId: "user-legit-zero", bpInTargetPct: 0, bpGradedScore: 0, - heightCm: null, + weightTarget: null, + weightTargetSource: "none", now, coverage, + moodEnabled: true, }); expect(result).not.toBeNull(); @@ -580,8 +602,10 @@ describe("computeUserHealthScoreFastPath", () => { await computeUserHealthScoreFastPath({ userId: "user-fresh", bpInTargetPct: null, - heightCm: null, + weightTarget: null, + weightTargetSource: "none", now: new Date("2026-05-17T12:00:00.000Z"), + moodEnabled: true, }); expect(PROBE).toHaveBeenCalledWith("user-fresh"); @@ -603,9 +627,11 @@ describe("computeUserHealthScoreFastPath", () => { userId: "user-bpdelta", bpInTargetPct: 90, bpInTargetPctPriorWeek: 70, - heightCm: null, + weightTarget: null, + weightTargetSource: "none", now: new Date("2026-05-17T12:00:00.000Z"), coverage, + moodEnabled: true, }); // With only BP data the helper still returns a score. The delta @@ -634,9 +660,11 @@ describe("computeUserHealthScoreFastPath", () => { userId: "user-bplegacy", bpInTargetPct: 80, // bpInTargetPctPriorWeek omitted — should fall back to 80. - heightCm: null, + weightTarget: null, + weightTargetSource: "none", now: new Date("2026-05-17T12:00:00.000Z"), coverage, + moodEnabled: true, }); expect(result).not.toBeNull(); @@ -715,9 +743,11 @@ describe("computeUserHealthScoreFastPath", () => { const result = await computeUserHealthScoreFastPath({ userId: "user-weekly-med", bpInTargetPct: 80, - heightCm: 178, + weightTarget: null, + weightTargetSource: "none", now, coverage, + moodEnabled: true, }); expect(result).not.toBeNull(); @@ -778,9 +808,11 @@ describe("computeUserHealthScoreFastPath", () => { const result = await computeUserHealthScoreFastPath({ userId: "user-daily-med", bpInTargetPct: 80, - heightCm: 178, + weightTarget: null, + weightTargetSource: "none", now, coverage, + moodEnabled: true, }); expect(result).not.toBeNull(); @@ -799,6 +831,10 @@ describe("computeUserHealthScoreFastPath", () => { * (or inflated) by a pillar the user can no longer see. Golden fixture: * an identical profile with strong, stable mood scored mood-on vs * mood-off must produce a different, correctly re-weighted score. + * + * v1.34 — the flag is required. It used to default to on, and the + * dashboard snapshot never passed it, so the hero ring scored a pillar + * the account had switched off while the Insights card dropped it. */ describe("module-aware mood pillar (R4)", () => { // A profile with BP + steady high mood (low variance → high @@ -811,7 +847,7 @@ describe("computeUserHealthScoreFastPath", () => { })); } - it("weights the mood pillar when moodEnabled is omitted (pre-v1.18.0 default)", async () => { + it("weights the mood pillar when moodEnabled is true", async () => { const coverage = new Map(); FULLY_COVERED.mockReturnValue(false); PROBE.mockResolvedValue(coverage); @@ -823,9 +859,11 @@ describe("computeUserHealthScoreFastPath", () => { userId: "user-mood-on", bpInTargetPct: 60, bpGradedScore: 60, - heightCm: null, + weightTarget: null, + weightTargetSource: "none", now, coverage, + moodEnabled: true, }); expect(result).not.toBeNull(); @@ -849,7 +887,8 @@ describe("computeUserHealthScoreFastPath", () => { userId: "user-mood-off", bpInTargetPct: 60, bpGradedScore: 60, - heightCm: null, + weightTarget: null, + weightTargetSource: "none", now, coverage, moodEnabled: false, @@ -888,9 +927,11 @@ describe("computeUserHealthScoreFastPath", () => { userId: "user-golden-on", bpInTargetPct: 60, bpGradedScore: 60, - heightCm: null, + weightTarget: null, + weightTargetSource: "none", now, coverage, + moodEnabled: true, }); // Mood-OFF: only BP=60 survives → score 60. @@ -899,7 +940,8 @@ describe("computeUserHealthScoreFastPath", () => { userId: "user-golden-off", bpInTargetPct: 60, bpGradedScore: 60, - heightCm: null, + weightTarget: null, + weightTargetSource: "none", now, coverage, moodEnabled: false, @@ -936,9 +978,11 @@ describe("computeUserHealthScoreFastPath", () => { bpInTargetPct: 16, // …but the graded clinical-proximity score says borderline. bpGradedScore: 55, - heightCm: null, + weightTarget: null, + weightTargetSource: "none", now, coverage, + moodEnabled: true, }); expect(result).not.toBeNull(); @@ -968,9 +1012,11 @@ describe("computeUserHealthScoreFastPath", () => { // stray graded score were supplied. bpInTargetPct: null, bpGradedScore: 80, - heightCm: null, + weightTarget: null, + weightTargetSource: "none", now, coverage, + moodEnabled: true, }); expect(result).not.toBeNull(); diff --git a/src/lib/analytics/__tests__/health-score-inputs.test.ts b/src/lib/analytics/__tests__/health-score-inputs.test.ts index 3f6b6f4e3..df9b9f4d0 100644 --- a/src/lib/analytics/__tests__/health-score-inputs.test.ts +++ b/src/lib/analytics/__tests__/health-score-inputs.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from "vitest"; -import { buildHealthScoreBpInputs } from "../health-score-inputs"; +import { + buildHealthScoreBpInputs, + buildHealthScoreWeightTarget, +} from "../health-score-inputs"; import type { BpInTargetEnvelope } from "../bp-in-target-fast-path"; function env(partial: Partial): BpInTargetEnvelope { @@ -134,3 +137,60 @@ describe("buildHealthScoreBpInputs — single Health-Score BP input builder", () expect(a.bpInTargetPct).not.toBeNull(); }); }); + +/** + * v1.34 — the weight yardstick builder. The score used to grade weight against + * `22 x height^2` +/- 2 kg, a band no user set and no surface named. The + * builder is the one place the two Health-Score callers resolve it, and its + * whole job is to refuse to hand over anything but a real user target. + */ +describe("buildHealthScoreWeightTarget — the one weight-yardstick resolver", () => { + const profile = { + heightCm: 178, + dateOfBirth: new Date("1985-06-15T00:00:00.000Z"), + gender: "MALE", + }; + + it("hands over the user's own band when they set one", () => { + const result = buildHealthScoreWeightTarget(profile, { + WEIGHT: { min: 74, max: 78 }, + }); + expect(result.weightTargetSource).toBe("user"); + expect(result.weightTarget).toEqual({ min: 74, max: 78 }); + }); + + it("refuses the evidence-based default that sits under the override", () => { + // `getEffectiveRange` resolves a height-derived WHO band for this profile. + // It is a reference band, not a goal the person chose, so it must never + // reach the score — that substitution IS the defect. + const result = buildHealthScoreWeightTarget(profile, null); + expect(result.weightTargetSource).toBe("none"); + expect(result.weightTarget).toBeNull(); + }); + + it("ignores overrides on other metrics", () => { + const result = buildHealthScoreWeightTarget(profile, { + BLOOD_PRESSURE_SYS: { min: 110, max: 130 }, + }); + expect(result.weightTargetSource).toBe("none"); + expect(result.weightTarget).toBeNull(); + }); + + it("returns no target for a profile with no height and no override", () => { + const result = buildHealthScoreWeightTarget( + { heightCm: null, dateOfBirth: null, gender: null }, + null, + ); + expect(result.weightTargetSource).toBe("none"); + expect(result.weightTarget).toBeNull(); + }); + + it("honours an override even when the profile carries no height", () => { + const result = buildHealthScoreWeightTarget( + { heightCm: null, dateOfBirth: null, gender: null }, + { WEIGHT: { min: 60, max: 65 } }, + ); + expect(result.weightTargetSource).toBe("user"); + expect(result.weightTarget).toEqual({ min: 60, max: 65 }); + }); +}); diff --git a/src/lib/analytics/__tests__/health-score-rollup-live-parity.test.ts b/src/lib/analytics/__tests__/health-score-rollup-live-parity.test.ts new file mode 100644 index 000000000..aa31e0967 --- /dev/null +++ b/src/lib/analytics/__tests__/health-score-rollup-live-parity.test.ts @@ -0,0 +1,259 @@ +/** + * F1 — rollup-vs-live golden for the Health Score. + * + * `computeUserHealthScoreFastPath` reads the weight series two ways: per-day + * MEAN buckets off `measurement_rollups` when the WEIGHT type is covered, raw + * `measurements` rows otherwise. The scoring math cannot diverge — there is one + * scorer and both branches call it — but the INPUT SERIES can, and nothing in + * the suite asserted the two branches land on the same number for the same + * underlying readings. The existing fast-path tests exercise each branch alone. + * + * This file encodes one series both ways and pins the equivalence, plus the one + * documented case where the two legitimately differ: with several readings on a + * day, the rollup branch regresses over the day's mean while the live branch + * regresses over every reading. That is a collapse, not a bug — so the second + * suite pins that the rollup branch equals the live branch fed those same daily + * means, which is the exact claim the fast-path's own comment makes. + */ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findMany: vi.fn() }, + measurementRollup: { findMany: vi.fn() }, + user: { findUnique: vi.fn() }, + moodEntry: { findMany: vi.fn() }, + medication: { findMany: vi.fn() }, + medicationIntakeEvent: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +vi.mock("@/lib/rollups/measurement-coverage", () => ({ + isFullyCovered: vi.fn(), + probeRollupCoverage: vi.fn(), +})); + +import { prisma } from "@/lib/db"; +import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; +import { computeUserHealthScoreFastPath } from "../health-score-fast-path"; + +const MEASUREMENT_FIND_MANY = prisma.measurement + .findMany as unknown as ReturnType; +const ROLLUP_FIND_MANY = prisma.measurementRollup + .findMany as unknown as ReturnType; +const MOOD_FIND_MANY = prisma.moodEntry.findMany as unknown as ReturnType< + typeof vi.fn +>; +const MEDICATION_FIND_MANY = prisma.medication + .findMany as unknown as ReturnType; +const PROBE = probeRollupCoverage as unknown as ReturnType; + +const NOW = new Date("2026-05-20T12:00:00.000Z"); + +/** A weight reading in the shared fixture vocabulary. */ +interface Reading { + /** Days before `NOW` the reading was taken. */ + daysAgo: number; + /** Hour of that day, so several readings can share a day. */ + hour: number; + kg: number; +} + +function measuredAt(r: Reading): Date { + const d = new Date(NOW.getTime() - r.daysAgo * 24 * 60 * 60 * 1000); + d.setUTCHours(r.hour, 0, 0, 0); + return d; +} + +/** UTC midnight of a reading's day — the DAY bucket key. */ +function bucketStart(r: Reading): Date { + const d = measuredAt(r); + return new Date( + Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()), + ); +} + +/** Collapse readings into the per-day MEAN buckets the rollup tier stores. */ +function toDayBuckets(readings: Reading[]) { + const byDay = new Map(); + for (const r of readings) { + const key = bucketStart(r).getTime(); + const slot = byDay.get(key) ?? { sum: 0, count: 0 }; + slot.sum += r.kg; + slot.count += 1; + byDay.set(key, slot); + } + return [...byDay.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([ms, slot]) => ({ + bucketStart: new Date(ms), + count: slot.count, + mean: slot.sum / slot.count, + minValue: slot.sum / slot.count, + maxValue: slot.sum / slot.count, + sd: null, + slope: null, + r2: null, + computedAt: NOW, + })); +} + +beforeEach(() => { + MEASUREMENT_FIND_MANY.mockReset(); + ROLLUP_FIND_MANY.mockReset(); + MOOD_FIND_MANY.mockReset(); + MEDICATION_FIND_MANY.mockReset(); + PROBE.mockReset(); + + MOOD_FIND_MANY.mockResolvedValue([]); + MEDICATION_FIND_MANY.mockResolvedValue([]); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +/** Score the readings through the ROLLUP branch (WEIGHT covered). */ +async function scoreViaRollup(readings: Reading[]) { + const coverage = new Map([["WEIGHT", true]]); + PROBE.mockResolvedValue(coverage); + const buckets = toDayBuckets(readings); + // The DAY read and the long-window (WEEK/MONTH/YEAR) read fire in parallel, + // so key the mock on the requested granularity rather than call order. + ROLLUP_FIND_MANY.mockImplementation( + async ({ where }: { where: { granularity: string } }) => + where.granularity === "DAY" ? buckets : [], + ); + // Source attribution still comes off the raw rows (2-column projection), + // then the BP-SYS attribution read. + MEASUREMENT_FIND_MANY.mockResolvedValueOnce( + readings.map((r) => ({ measuredAt: measuredAt(r), source: "MANUAL" })), + ).mockResolvedValueOnce([]); + + return computeUserHealthScoreFastPath({ + userId: "user-rollup", + bpInTargetPct: null, + weightTarget: null, + weightTargetSource: "none", + now: NOW, + coverage, + moodEnabled: true, + }); +} + +/** Score the readings through the LIVE branch (WEIGHT uncovered). */ +async function scoreViaLive(readings: Reading[]) { + const coverage = new Map([["WEIGHT", false]]); + PROBE.mockResolvedValue(coverage); + MEASUREMENT_FIND_MANY.mockResolvedValueOnce( + readings.map((r) => ({ + measuredAt: measuredAt(r), + value: r.kg, + source: "MANUAL", + })), + ).mockResolvedValueOnce([]); + + return computeUserHealthScoreFastPath({ + userId: "user-live", + bpInTargetPct: null, + weightTarget: null, + weightTargetSource: "none", + now: NOW, + coverage, + moodEnabled: true, + }); +} + +describe("Health Score — rollup / live parity on one weigh-in per day", () => { + // The canonical cadence: one weigh-in a day, the pattern the fast path's own + // comment says the equivalence rests on. + const dailyDecline: Reading[] = [ + { daysAgo: 24, hour: 7, kg: 84.0 }, + { daysAgo: 20, hour: 7, kg: 83.6 }, + { daysAgo: 16, hour: 7, kg: 83.1 }, + { daysAgo: 12, hour: 7, kg: 82.7 }, + { daysAgo: 8, hour: 7, kg: 82.2 }, + { daysAgo: 4, hour: 7, kg: 81.8 }, + { daysAgo: 1, hour: 7, kg: 81.5 }, + ]; + + it("produces the same score, band and weight pillar on both branches", async () => { + const rollup = await scoreViaRollup(dailyDecline); + const live = await scoreViaLive(dailyDecline); + + expect(rollup).not.toBeNull(); + expect(live).not.toBeNull(); + expect(rollup?.score).toBe(live?.score); + expect(rollup?.band).toBe(live?.band); + expect(rollup?.components.weight.value).toBe(live?.components.weight.value); + // The weight pillar is the whole score here (no BP, mood or medications), + // so an equal score is only meaningful if the pillar actually resolved. + expect(rollup?.components.weight.value).not.toBeNull(); + }); + + it("agrees on the week-over-week delta too", async () => { + // The delta re-runs the scorer over a 7-day-shifted window, so it exercises + // the branch's series construction a second time on a different slice. + const rollup = await scoreViaRollup(dailyDecline); + const live = await scoreViaLive(dailyDecline); + expect(rollup?.delta).toBe(live?.delta); + }); + + it("agrees on a rising series as well as a falling one", async () => { + const rise = dailyDecline.map((r) => ({ ...r, kg: 160 - r.kg })); + const rollup = await scoreViaRollup(rise); + const live = await scoreViaLive(rise); + expect(rollup?.score).toBe(live?.score); + expect(rollup?.components.weight.value).toBe(live?.components.weight.value); + }); +}); + +describe("Health Score — rollup / live on an uneven weigh-in cadence", () => { + // Three weigh-ins on the older day, one on the recent day. Per-reading + // regression weights that older day three times over; per-day-mean regression + // gives it one point. This is the shape where the two branches genuinely part + // company, so it is the shape worth pinning. + const uneven: Reading[] = [ + { daysAgo: 20, hour: 6, kg: 85.0 }, + { daysAgo: 20, hour: 12, kg: 84.0 }, + { daysAgo: 20, hour: 20, kg: 83.0 }, + { daysAgo: 2, hour: 7, kg: 83.6 }, + ]; + + /** The same readings collapsed to one per day at their daily mean. */ + const collapsed: Reading[] = toDayBuckets(uneven).map((b) => ({ + daysAgo: Math.round( + (NOW.getTime() - b.bucketStart.getTime()) / (24 * 60 * 60 * 1000), + ), + hour: 0, + kg: b.mean, + })); + + it("the two branches do differ here — the fixture is not a no-op", async () => { + // Guard the guard: if raw and collapsed ever scored the same for this + // series, the equality below would prove nothing. + const liveOnRaw = await scoreViaLive(uneven); + const liveOnMeans = await scoreViaLive(collapsed); + expect(liveOnRaw?.components.weight.value).not.toBe( + liveOnMeans?.components.weight.value, + ); + }); + + it("the rollup branch equals the live branch fed the same daily means", async () => { + // The whole difference between the branches is the mean-collapse and + // nothing else: replay the collapsed series through the LIVE branch and the + // two agree again. That is the claim the fast path's own comment makes, + // stated as a test rather than left as prose. + const rollup = await scoreViaRollup(uneven); + const liveOnMeans = await scoreViaLive(collapsed); + + expect(rollup?.score).toBe(liveOnMeans?.score); + expect(rollup?.components.weight.value).toBe( + liveOnMeans?.components.weight.value, + ); + }); +}); diff --git a/src/lib/analytics/__tests__/health-score.test.ts b/src/lib/analytics/__tests__/health-score.test.ts index 15a9499bf..2c678aa06 100644 --- a/src/lib/analytics/__tests__/health-score.test.ts +++ b/src/lib/analytics/__tests__/health-score.test.ts @@ -3,7 +3,6 @@ import { coefficientOfVariation, complianceRate, computeHealthScore, - defaultWeightTargetFromHeight, linearRegressionSlope, moodStability, weightTrendAlignment, @@ -170,16 +169,6 @@ describe("complianceRate", () => { }); }); -describe("defaultWeightTargetFromHeight", () => { - it("returns null when height is null", () => { - expect(defaultWeightTargetFromHeight(null)).toBeNull(); - }); - - it("approximates BMI-22 midpoint for 178 cm", () => { - expect(defaultWeightTargetFromHeight(178)).toBeCloseTo(69.7, 1); - }); -}); - // ── Composite scenarios ────────────────────────────────────────────── describe("computeHealthScore — strong positive case", () => { @@ -187,7 +176,8 @@ describe("computeHealthScore — strong positive case", () => { const input: HealthScoreInput = { bpInTargetRate: 90, weightSeriesLast30d: weightSeries([80, 79.5, 79, 78.5, 78]), - weightTargetKg: 78, + weightTarget: { min: 76, max: 80 }, + weightTargetSource: "user", moodEntriesLast30d: moodEntries([4, 4, 5, 4, 5, 4, 5, 4, 4]), medicationCompliance30: [95, 100, 90], }; @@ -205,7 +195,8 @@ describe("computeHealthScore — mixed case", () => { bpInTargetRate: 60, // Above the band, drifting up — alignment <50. weightSeriesLast30d: weightSeries([85, 86, 87, 88]), - weightTargetKg: 80, + weightTarget: { min: 78, max: 82 }, + weightTargetSource: "user", moodEntriesLast30d: moodEntries([2, 3, 5, 1, 4, 3]), medicationCompliance30: [100, 100], }; @@ -221,7 +212,8 @@ describe("computeHealthScore — poor case", () => { const input: HealthScoreInput = { bpInTargetRate: 20, weightSeriesLast30d: weightSeries([95, 96, 97, 98, 99]), - weightTargetKg: 80, + weightTarget: { min: 78, max: 82 }, + weightTargetSource: "user", moodEntriesLast30d: moodEntries([1, 5, 1, 5, 1, 5]), medicationCompliance30: [40, 30, 20], }; @@ -236,7 +228,8 @@ describe("computeHealthScore — null component redistribution", () => { const input: HealthScoreInput = { bpInTargetRate: null, weightSeriesLast30d: weightSeries([80, 80, 80, 80]), - weightTargetKg: 80, + weightTarget: { min: 78, max: 82 }, + weightTargetSource: "user", moodEntriesLast30d: moodEntries([5, 5, 5, 5, 5]), medicationCompliance30: [100, 100], }; @@ -254,7 +247,8 @@ describe("computeHealthScore — null component redistribution", () => { const input: HealthScoreInput = { bpInTargetRate: null, weightSeriesLast30d: weightSeries([80, 80, 80]), - weightTargetKg: 80, + weightTarget: { min: 78, max: 82 }, + weightTargetSource: "user", // <5 mood entries → moodStability returns null. moodEntriesLast30d: moodEntries([4, 4]), medicationCompliance30: [80], @@ -273,7 +267,8 @@ describe("computeHealthScore — null component redistribution", () => { const input: HealthScoreInput = { bpInTargetRate: null, weightSeriesLast30d: [], - weightTargetKg: null, + weightTarget: null, + weightTargetSource: "none", moodEntriesLast30d: [], medicationCompliance30: [73], }; @@ -287,7 +282,8 @@ describe("computeHealthScore — null component redistribution", () => { const input: HealthScoreInput = { bpInTargetRate: null, weightSeriesLast30d: [], - weightTargetKg: null, + weightTarget: null, + weightTargetSource: "none", moodEntriesLast30d: [], medicationCompliance30: [], }; @@ -305,7 +301,8 @@ describe("computeHealthScore — determinism", () => { const input: HealthScoreInput = { bpInTargetRate: 75, weightSeriesLast30d: weightSeries([82, 81, 80, 79]), - weightTargetKg: 78, + weightTarget: { min: 76, max: 80 }, + weightTargetSource: "user", moodEntriesLast30d: moodEntries([4, 5, 3, 4, 5, 4]), medicationCompliance30: [85, 90, 80], }; @@ -335,7 +332,8 @@ describe("computeHealthScore — delta vs previous week", () => { const baseInput: HealthScoreInput = { bpInTargetRate: 80, weightSeriesLast30d: weightSeries([82, 81, 80]), - weightTargetKg: 80, + weightTarget: { min: 78, max: 82 }, + weightTargetSource: "user", moodEntriesLast30d: moodEntries([5, 4, 5, 4, 5]), medicationCompliance30: [100, 100], }; @@ -377,7 +375,8 @@ describe("computeHealthScore — source attribution", () => { return { bpInTargetRate: 90, weightSeriesLast30d: weightSeries([80, 80, 80]), - weightTargetKg: 80, + weightTarget: { min: 78, max: 82 }, + weightTargetSource: "user", moodEntriesLast30d: moodEntries([5, 4, 5, 4, 5]), medicationCompliance30: [95, 100], attribution, @@ -433,7 +432,8 @@ describe("computeHealthScore — source attribution", () => { const input: HealthScoreInput = { bpInTargetRate: null, weightSeriesLast30d: [], - weightTargetKg: null, + weightTarget: null, + weightTargetSource: "none", // <5 mood entries → moodStability returns null. moodEntriesLast30d: moodEntries([4, 4]), medicationCompliance30: [], @@ -461,7 +461,8 @@ describe("computeHealthScore — source attribution", () => { const result = computeHealthScore({ bpInTargetRate: 80, weightSeriesLast30d: weightSeries([80, 80, 80]), - weightTargetKg: 80, + weightTarget: { min: 78, max: 82 }, + weightTargetSource: "user", moodEntriesLast30d: moodEntries([5, 5, 5, 5, 5]), medicationCompliance30: [100], }); @@ -480,7 +481,8 @@ describe("computeHealthScore — source attribution", () => { const result = computeHealthScore({ bpInTargetRate: 90, weightSeriesLast30d: weightSeries([80, 80, 80]), - weightTargetKg: 80, + weightTarget: { min: 78, max: 82 }, + weightTargetSource: "user", moodEntriesLast30d: moodEntries([5, 5, 5, 5, 5]), medicationCompliance30: [100], attribution: { @@ -494,3 +496,75 @@ describe("computeHealthScore — source attribution", () => { expect(result.components.bp.asOf).toBe(WINDOW_END); }); }); + +/** + * v1.34 — the weight yardstick. + * + * Until this release the pillar graded every account with a height on file + * against `22 x height^2` +/- 2 kg: a band nobody set and no surface named. + * These pin the replacement contract — the band arrives whole from the caller, + * a missing band scores the bare trend, and the result says which of the two + * happened. + */ +describe("computeHealthScore — weight yardstick", () => { + const stable = weightSeries([80, 80, 80, 80]); + + function scoreWeightOnly( + series: Array<{ date: string; kg: number }>, + target: { min: number; max: number } | null, + ) { + return computeHealthScore({ + bpInTargetRate: null, + weightSeriesLast30d: series, + weightTarget: target, + weightTargetSource: target ? "user" : "none", + moodEntriesLast30d: [], + medicationCompliance30: [], + }); + } + + it("takes the band whole — no expansion of the caller's bounds", () => { + // 80 kg sits OUTSIDE 76-79. A +/- 2 kg widening would pull it in and + // score 100; the band must be used exactly as handed over. + expect( + scoreWeightOnly(stable, { min: 76, max: 79 }).components.weight.value, + ).toBe(50); + // The same reading INSIDE the band scores the in-band 100. + expect( + scoreWeightOnly(stable, { min: 78, max: 82 }).components.weight.value, + ).toBe(100); + }); + + it("scores the bare trend when no target is set", () => { + // Stable weight, no target: the trend-only anchor, not the target-aware + // "neither" midpoint of 50, and never a fabricated band. + const result = scoreWeightOnly(stable, null); + expect(result.components.weight.value).toBe(75); + expect(result.components.weight.weight).toBeGreaterThan(0); + }); + + it("carries the resolved yardstick on the weight component", () => { + const withTarget = scoreWeightOnly(stable, { min: 78, max: 82 }); + expect(withTarget.components.weight.targetSource).toBe("user"); + expect(withTarget.components.weight.target).toEqual({ min: 78, max: 82 }); + + const without = scoreWeightOnly(stable, null); + expect(without.components.weight.targetSource).toBe("none"); + expect(without.components.weight.target).toBeNull(); + }); + + it("never reports a band when the source is `none`", () => { + // Defensive: a caller that passes a band while declaring no source must + // not have that band surface as though the user had set it. + const result = computeHealthScore({ + bpInTargetRate: null, + weightSeriesLast30d: stable, + weightTarget: { min: 78, max: 82 }, + weightTargetSource: "none", + moodEntriesLast30d: [], + medicationCompliance30: [], + }); + expect(result.components.weight.target).toBeNull(); + expect(result.components.weight.targetSource).toBe("none"); + }); +}); diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 288e2c39f..74897a21c 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -146,6 +146,7 @@ function baseUser( insightsCachedText: null, insightsCachedAt: null, dashboardWidgetsJson: null, + thresholdsJson: null, ...overrides, }; } @@ -471,7 +472,9 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => { userId: string; bpInTargetPct: number | null; bpGradedScore: number | null; - heightCm: number | null; + weightTarget: { min: number; max: number } | null; + weightTargetSource: string; + moodEnabled: boolean; coverage: unknown; }; expect(arg.userId).toBe("user-1"); @@ -480,7 +483,10 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => { // number. Previously this was last30Days (80). expect(arg.bpInTargetPct).toBe(77); expect(arg.bpGradedScore).toBe(88); - expect(arg.heightCm).toBe(180); + // v1.34 — no weight target on this fixture, so the pillar scores the bare + // trend. The BMI-22 band that used to be derived from `heightCm` is gone. + expect(arg.weightTarget).toBeNull(); + expect(arg.weightTargetSource).toBe("none"); expect(arg.coverage).toBe(coverageMap); }); @@ -998,6 +1004,40 @@ describe("buildDashboardSnapshot — module gating (v1.18.0)", () => { expect(snap.tiles.mood.summary).toBeNull(); // Core tile unaffected. expect(widget(snap, "weight")!.visible).toBe(true); + // v1.34 — the hero ring's health score drops the mood pillar too. Before + // this the snapshot never resolved the module, so a mood-off account was + // scored on a pillar the Insights card had already dropped. + const scoreCall = computeUserHealthScoreFastPath.mock.calls.at(-1)?.[0] as + { moodEnabled: boolean } | undefined; + expect(scoreCall?.moodEnabled).toBe(false); + }); + + it("mood enabled: the health score keeps its mood pillar", async () => { + await buildDashboardSnapshot(fakePrisma, baseUser(), { + modules: () => Promise.resolve(moduleMap()), + }); + const scoreCall = computeUserHealthScoreFastPath.mock.calls.at(-1)?.[0] as + { moodEnabled: boolean } | undefined; + expect(scoreCall?.moodEnabled).toBe(true); + }); + + it("an explicit weight target reaches the score and the bands", async () => { + const snap = await buildDashboardSnapshot( + fakePrisma, + baseUser({ thresholdsJson: { WEIGHT: { min: 74, max: 78 } } }), + { modules: () => Promise.resolve(moduleMap()) }, + ); + const scoreCall = computeUserHealthScoreFastPath.mock.calls.at(-1)?.[0] as + | { + weightTarget: { min: number; max: number } | null; + weightTargetSource: string; + } + | undefined; + expect(scoreCall?.weightTarget).toEqual({ min: 74, max: 78 }); + expect(scoreCall?.weightTargetSource).toBe("user"); + // The tile / chart band follows the same target. + expect(snap.targetBands.weightRange?.greenMin).toBe(74); + expect(snap.targetBands.weightRange?.greenMax).toBe(78); }); it("sleep disabled: tile hidden + SLEEP_DURATION stripped from summaries", async () => { diff --git a/src/lib/dashboard/__tests__/target-bands.test.ts b/src/lib/dashboard/__tests__/target-bands.test.ts index 5e798d75b..06e51dca2 100644 --- a/src/lib/dashboard/__tests__/target-bands.test.ts +++ b/src/lib/dashboard/__tests__/target-bands.test.ts @@ -105,24 +105,28 @@ describe("buildTargetBands — server/client parity", () => { dateOfBirth: Date | null; gender: "MALE" | "FEMALE" | null; heightCm: number | null; + weightTargetOverride: { min: number; max: number } | null; }> = [ { name: "male 180cm with DOB", dateOfBirth: new Date("1985-06-15T00:00:00.000Z"), gender: "MALE", heightCm: 180, + weightTargetOverride: null, }, { name: "female 165cm with DOB", dateOfBirth: new Date("1970-01-01T00:00:00.000Z"), gender: "FEMALE", heightCm: 165, + weightTargetOverride: null, }, { name: "no-profile user (null DOB / gender / height)", dateOfBirth: null, gender: null, heightCm: null, + weightTargetOverride: null, }, ]; @@ -142,17 +146,20 @@ describe("buildTargetBands — server/client parity", () => { dateOfBirth: new Date("1985-06-15T00:00:00.000Z"), gender: "OTHER", heightCm: 180, + weightTargetOverride: null, }); const unanswered = buildTargetBands({ dateOfBirth: new Date("1985-06-15T00:00:00.000Z"), gender: null, heightCm: 180, + weightTargetOverride: null, }); expect(other).toEqual(unanswered); const male = buildTargetBands({ dateOfBirth: new Date("1985-06-15T00:00:00.000Z"), gender: "MALE", heightCm: 180, + weightTargetOverride: null, }); expect(other.bodyFatBands).not.toEqual(male.bodyFatBands); }); @@ -162,6 +169,7 @@ describe("buildTargetBands — server/client parity", () => { dateOfBirth: null, gender: null, heightCm: null, + weightTargetOverride: null, }); expect(bands.bpTargets).toBeNull(); expect(bands.bpSysRange).toBeNull(); @@ -173,3 +181,63 @@ describe("buildTargetBands — server/client parity", () => { expect(bands.bodyFatBands.length).toBeGreaterThan(0); }); }); + +/** + * v1.34 — an explicit weight target outranks the height-derived WHO band. + * The dashboard used to shade the BMI band for someone who had already + * answered the question on `/targets`, so the answer stayed invisible on + * every surface that mattered. + */ +describe("buildTargetBands — user weight target", () => { + const profile = { + dateOfBirth: new Date("1985-06-15T00:00:00.000Z"), + gender: "MALE" as const, + heightCm: 180, + }; + + it("replaces the height-derived band with the user's own", () => { + const withTarget = buildTargetBands({ + ...profile, + weightTargetOverride: { min: 74, max: 78 }, + }); + expect(withTarget.weightRange?.greenMin).toBe(74); + expect(withTarget.weightRange?.greenMax).toBe(78); + + const heightDerived = buildTargetBands({ + ...profile, + weightTargetOverride: null, + }); + expect(heightDerived.weightRange).not.toEqual(withTarget.weightRange); + // The chart bands follow the same range, so tile and chart agree. + expect(withTarget.weightBands).not.toEqual(heightDerived.weightBands); + const green = withTarget.weightBands?.find( + (b) => b.color === "var(--success)", + ); + expect(green).toEqual(expect.objectContaining({ min: 74, max: 78 })); + }); + + it("honours a target even for a profile with no height on file", () => { + const bands = buildTargetBands({ + dateOfBirth: null, + gender: null, + heightCm: null, + weightTargetOverride: { min: 60, max: 65 }, + }); + expect(bands.weightRange?.greenMin).toBe(60); + expect(bands.weightBands?.length).toBeGreaterThan(0); + }); + + it("leaves every non-weight band untouched", () => { + const withTarget = buildTargetBands({ + ...profile, + weightTargetOverride: { min: 74, max: 78 }, + }); + const without = buildTargetBands({ + ...profile, + weightTargetOverride: null, + }); + expect(withTarget.bpTargets).toEqual(without.bpTargets); + expect(withTarget.pulseBands).toEqual(without.pulseBands); + expect(withTarget.bodyFatBands).toEqual(without.bodyFatBands); + }); +}); diff --git a/src/lib/dashboard/__tests__/verdict.test.ts b/src/lib/dashboard/__tests__/verdict.test.ts index 49e64199b..3b62ce33c 100644 --- a/src/lib/dashboard/__tests__/verdict.test.ts +++ b/src/lib/dashboard/__tests__/verdict.test.ts @@ -79,6 +79,7 @@ function baseSnapshot( dateOfBirth: null, gender: null, heightCm: null, + weightTargetOverride: null, }), tiles: { summaries: {}, @@ -395,6 +396,7 @@ describe("rung 4 — weightDrift", () => { avg7: number | null, avg30: number | null, heightCm: number | null = 180, + weightTargetOverride: { min: number; max: number } | null = null, ): DashboardSnapshot { const snap = baseSnapshot({ tiles: { @@ -408,6 +410,14 @@ describe("rung 4 — weightDrift", () => { }, }); snap.user.heightCm = heightCm; + // v1.34 — the rung reads the snapshot's RESOLVED band, so the fixture + // resolves it the same way the builder does. + snap.targetBands = buildTargetBands({ + dateOfBirth: null, + gender: null, + heightCm, + weightTargetOverride, + }); return snap; } @@ -429,7 +439,7 @@ describe("rung 4 — weightDrift", () => { expect(verdict.variant).toBe("allQuiet"); }); - it("skips entirely when no height is stored (no derivable range)", () => { + it("skips entirely when no height and no target (no band at all)", () => { const verdict = resolveDashboardVerdict( weightSnapshot(GREEN_MAX_180 + 5, GREEN_MAX_180 - 1, null), NOW, @@ -729,3 +739,55 @@ describe("rung 9 — allQuiet + full-ladder precedence", () => { expect(verdict.values).toEqual({ points: 15 }); }); }); + +/** + * v1.34 — the weight-drift rung measures against the band the user actually + * reads. It used to recompute the height-derived BMI band inline, so it kept + * nudging about drift away from a band an explicit target had replaced. + */ +describe("rung 4 — weightDrift honours an explicit target", () => { + function driftSnapshot( + avg7: number, + avg30: number, + weightTargetOverride: { min: number; max: number } | null, + ): DashboardSnapshot { + const snap = baseSnapshot({ + tiles: { + summaries: { + WEIGHT: summary({ latest: avg7, avg7, avg30, count: 20 }), + }, + lastSeenByType: { + WEIGHT: { lastSeenAt: isoHoursAgo(2), daysAgo: 0 }, + }, + mood: { summary: null, entries: [] }, + }, + }); + snap.user.heightCm = 180; + snap.targetBands = buildTargetBands({ + dateOfBirth: null, + gender: null, + heightCm: 180, + weightTargetOverride, + }); + return snap; + } + + it("stays quiet inside the user's own band even when the BMI band would fire", () => { + // 82 / 81 kg is above the height-derived green max (80.68) and drifting + // away from it, but sits inside a self-set 78-85 kg band. + expect( + resolveDashboardVerdict(driftSnapshot(82, 81, null), NOW).variant, + ).toBe("weightDrift"); + expect( + resolveDashboardVerdict(driftSnapshot(82, 81, { min: 78, max: 85 }), NOW) + .variant, + ).toBe("allQuiet"); + }); + + it("fires on drift away from the user's own band", () => { + expect( + resolveDashboardVerdict(driftSnapshot(72, 74, { min: 78, max: 85 }), NOW) + .variant, + ).toBe("weightDrift"); + }); +}); diff --git a/src/lib/personal-records/__tests__/pr-detection-worker.test.ts b/src/lib/personal-records/__tests__/pr-detection-worker.test.ts index c4377c3d0..9b3281d3d 100644 --- a/src/lib/personal-records/__tests__/pr-detection-worker.test.ts +++ b/src/lib/personal-records/__tests__/pr-detection-worker.test.ts @@ -18,7 +18,17 @@ import { __test__, detectPersonalRecordsForUser, } from "../pr-detection-worker"; -import { getPRDirection, isPRTrackable } from "../pr-direction"; +import { + getPRDirection, + isPRTrackable, + type PRDirectionContext, +} from "../pr-direction"; + +/** + * v1.34 — the fixtures below carry no weight target, so the resolver answers + * for an account that has set no goal (the historical behaviour). + */ +const NO_GOAL: PRDirectionContext = { weightGoal: null }; import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; import { measurementTypeEnum } from "@/lib/validations/measurement"; import { @@ -147,6 +157,9 @@ function makeFakePrisma(state: { // the existing day-sum assertions stay valid. timezone: "UTC", sourcePriorityJson: null, + // v1.34 — no weight target on file, so weight / BMI keep their + // historical null PR direction throughout this file's fixtures. + thresholdsJson: null, })), }, measurement: { @@ -688,7 +701,7 @@ describe("detectPersonalRecordsForUser — flags + drift guard", () => { const result = await detectPersonalRecordsForUser(USER, { prisma }); const trackable = measurementTypeEnum.options.filter((t) => - isPRTrackable(t as MeasurementType), + isPRTrackable(t as MeasurementType, NO_GOAL), ); expect(result.scanned).toBeGreaterThanOrEqual(trackable.length); }); @@ -783,7 +796,7 @@ describe("detectPersonalRecordsForUser — REG-9 cumulative day-sum (v1.4.46)", // direction lookup short-circuits null-direction types upstream; // we filter to the PR-trackable subset for the loop. const trackableCumulative = Array.from(CUMULATIVE_HK_TYPES).filter((t) => - isPRTrackable(t), + isPRTrackable(t, NO_GOAL), ); expect(trackableCumulative.length).toBeGreaterThan(0); @@ -843,7 +856,9 @@ describe("detectPersonalRecordsForUser — REG-9 cumulative day-sum (v1.4.46)", // falls is the record) the winning day is the smallest day-sum, so // the seed days at 100 hold the record over the 500 day. const expectedValue = - getPRDirection(type) === PersonalRecordDirection.MIN ? 100 : 500; + getPRDirection(type, NO_GOAL) === PersonalRecordDirection.MIN + ? 100 + : 500; expect(pr?.value, `${type} day-sum`).toBe(expectedValue); } }); diff --git a/src/lib/personal-records/__tests__/pr-direction.test.ts b/src/lib/personal-records/__tests__/pr-direction.test.ts index 480eb01a3..743a33d04 100644 --- a/src/lib/personal-records/__tests__/pr-direction.test.ts +++ b/src/lib/personal-records/__tests__/pr-direction.test.ts @@ -1,18 +1,30 @@ import { describe, expect, it } from "vitest"; -import { getPRDirection, isPRTrackable } from "../pr-direction"; +import { + getPRDirection, + isPRTrackable, + resolveWeightGoalDirection, + type PRDirectionContext, +} from "../pr-direction"; import { measurementTypeEnum } from "@/lib/validations/measurement"; import { PersonalRecordDirection, type MeasurementType, } from "@/generated/prisma/client"; +/** + * v1.34 — the resolver takes the user-goal context. `NO_GOAL` is the account + * with no weight target on file, which is the historical behaviour every + * assertion below was written against. + */ +const NO_GOAL: PRDirectionContext = { weightGoal: null }; + describe("getPRDirection", () => { // Drift guard — every canonical MeasurementType MUST land in exactly // one branch of the switch so the future detection worker never // encounters an "I have a sample but no direction" race. it("returns either MAX, MIN, or null for every MeasurementType", () => { for (const type of measurementTypeEnum.options) { - const result = getPRDirection(type as MeasurementType); + const result = getPRDirection(type as MeasurementType, NO_GOAL); expect( result === null || result === PersonalRecordDirection.MAX || @@ -36,7 +48,7 @@ describe("getPRDirection", () => { "TIME_IN_DAYLIGHT", ]; for (const t of maxMetrics) { - expect(getPRDirection(t)).toBe(PersonalRecordDirection.MAX); + expect(getPRDirection(t, NO_GOAL)).toBe(PersonalRecordDirection.MAX); } }); @@ -52,11 +64,11 @@ describe("getPRDirection", () => { "AUDIO_EXPOSURE_HEADPHONE", ]; for (const t of minMetrics) { - expect(getPRDirection(t)).toBe(PersonalRecordDirection.MIN); + expect(getPRDirection(t, NO_GOAL)).toBe(PersonalRecordDirection.MIN); } }); - it("returns null for BP, glucose, weight, sleep, and homeostatic vitals", () => { + it("returns null for BP, glucose, sleep, and homeostatic vitals", () => { const noPRMetrics: MeasurementType[] = [ "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", @@ -70,7 +82,7 @@ describe("getPRDirection", () => { "FAT_FREE_MASS", ]; for (const t of noPRMetrics) { - expect(getPRDirection(t)).toBeNull(); + expect(getPRDirection(t, NO_GOAL)).toBeNull(); } }); }); @@ -79,7 +91,74 @@ describe("isPRTrackable", () => { it("returns true exactly when getPRDirection is non-null", () => { for (const type of measurementTypeEnum.options) { const t = type as MeasurementType; - expect(isPRTrackable(t)).toBe(getPRDirection(t) !== null); + expect(isPRTrackable(t, NO_GOAL)).toBe( + getPRDirection(t, NO_GOAL) !== null, + ); } }); }); + +/** + * v1.34 — weight + BMI stopped deferring on `User.thresholdsJson`. With no + * target they answer `null` exactly as they have since v1.5.5; with one they + * answer the direction the user's own goal points in. + */ +describe("getPRDirection — user weight goal", () => { + const goalMetrics: MeasurementType[] = ["WEIGHT", "BODY_MASS_INDEX"]; + + it("stays null when no weight target is set", () => { + for (const t of goalMetrics) { + expect(getPRDirection(t, { weightGoal: null })).toBeNull(); + expect(isPRTrackable(t, { weightGoal: null })).toBe(false); + } + }); + + it("reads MIN when the user is working down toward their target", () => { + for (const t of goalMetrics) { + expect(getPRDirection(t, { weightGoal: "lower" })).toBe( + PersonalRecordDirection.MIN, + ); + } + }); + + it("reads MAX when the user is working up toward their target", () => { + for (const t of goalMetrics) { + expect(getPRDirection(t, { weightGoal: "higher" })).toBe( + PersonalRecordDirection.MAX, + ); + } + }); + + it("leaves every other metric untouched by the goal context", () => { + for (const type of measurementTypeEnum.options) { + const t = type as MeasurementType; + if (goalMetrics.includes(t)) continue; + expect(getPRDirection(t, { weightGoal: "lower" })).toBe( + getPRDirection(t, { weightGoal: null }), + ); + } + }); +}); + +describe("resolveWeightGoalDirection", () => { + const band = { min: 70, max: 75 }; + + it("returns null without a target — a goal is never inferred", () => { + expect(resolveWeightGoalDirection(null, 90)).toBeNull(); + }); + + it("returns null without a reading to place against the band", () => { + expect(resolveWeightGoalDirection(band, null)).toBeNull(); + }); + + it("returns null inside the band — the goal is already met", () => { + expect(resolveWeightGoalDirection(band, 70)).toBeNull(); + expect(resolveWeightGoalDirection(band, 72.5)).toBeNull(); + expect(resolveWeightGoalDirection(band, 75)).toBeNull(); + }); + + it("points down above the band and up below it", () => { + expect(resolveWeightGoalDirection(band, 82)).toBe("lower"); + expect(resolveWeightGoalDirection(band, 64)).toBe("higher"); + }); +}); diff --git a/tests/integration/pr-detection-end-to-end.test.ts b/tests/integration/pr-detection-end-to-end.test.ts index 2815342f9..18898955e 100644 --- a/tests/integration/pr-detection-end-to-end.test.ts +++ b/tests/integration/pr-detection-end-to-end.test.ts @@ -180,8 +180,10 @@ describe("PR detection — drift guard (real DB)", () => { // either the worker or the suppress branch via `getPRDirection`). const result = await detectPersonalRecordsForUser(TEST_USER_ID); + // v1.34 — the seeded user carries no weight target, so weight / BMI keep + // their null direction and stay out of the trackable set. const trackable = measurementTypeEnum.options.filter((t) => - isPRTrackable(t as MeasurementType), + isPRTrackable(t as MeasurementType, { weightGoal: null }), ); expect(result.scanned).toBeGreaterThanOrEqual(trackable.length); });