diff --git a/src/lib/targets/__tests__/target-verdict-basis.test.ts b/src/lib/targets/__tests__/target-verdict-basis.test.ts new file mode 100644 index 000000000..bd423a5bd --- /dev/null +++ b/src/lib/targets/__tests__/target-verdict-basis.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); + +import { targetVerdictReference, withTargetVerdicts } from "../build-response"; +import { resolveSummaryVerdicts } from "../trend-verdict"; +import type { TargetItem } from "../types"; + +const OWN_BP = { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } }; +const CTX = { + profile: { dateOfBirth: null, gender: null, heightCm: null }, + thresholdsJson: OWN_BP, +}; + +function item( + type: string, + current: number | null, + average30: number | null = null, +): TargetItem { + return { + type, + label: type, + current, + average30, + trend: "up", + unit: "", + range: null, + classification: null, + source: "", + } as TargetItem; +} + +/** + * The target tile and the dashboard tile judge the same change, so they are + * placed with the same value: the seven-day mean, else the latest reading, + * from the dashboard's summary slice. + */ +describe("target verdict basis", () => { + it("places a target with the dashboard's seven-day mean, not its last reading", () => { + // Last reading 118 (inside the own band), seven-day mean 132 (above it). + const summaries = { BLOOD_PRESSURE_SYS: { avg7: 132, latest: 118 } }; + const [bp] = withTargetVerdicts( + [item("BLOOD_PRESSURE", 118, 121)], + CTX, + summaries, + ); + const dashboard = resolveSummaryVerdicts(summaries, CTX); + expect(bp!.direction).toEqual(dashboard.BLOOD_PRESSURE_SYS); + expect(bp!.direction?.targetPosition).toBe("above"); + }); + + it("follows the resting heart rate for the pulse target when it exists", () => { + expect( + targetVerdictReference( + item("PULSE", 70), + { RESTING_HEART_RATE: { avg7: 52, latest: 50 } }, + undefined, + ), + ).toBe(52); + expect( + targetVerdictReference( + item("PULSE", 70), + { PULSE: { avg7: null, latest: 64 } }, + undefined, + ), + ).toBe(64); + }); + + it("judges BMI by the weight summary", () => { + expect( + targetVerdictReference( + item("BMI", 24), + { WEIGHT: { avg7: 80, latest: 81 } }, + undefined, + ), + ).toBe(80); + }); + + it("falls back to the item's own value without a summary", () => { + expect( + targetVerdictReference(item("SLEEP_DURATION", 7), {}, undefined), + ).toBe(420); + expect( + targetVerdictReference(item("BMI", 24), {}, item("WEIGHT", null, 79)), + ).toBe(79); + expect( + targetVerdictReference(item("MOOD_SCORE", null), {}, undefined), + ).toBe(null); + }); +}); diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts index 4ce2e7d19..73a0e162f 100644 --- a/src/lib/targets/build-response.ts +++ b/src/lib/targets/build-response.ts @@ -31,6 +31,7 @@ import { readMoodDayRollups, } from "@/lib/rollups/mood-rollups"; import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read"; +import { computeSummariesSlice } from "@/lib/analytics/summaries-slice"; import { buildGlucoseTargets } from "./glucose-builder"; import { readGlucoseTargetSummaries } from "./glucose-read"; import { buildMedicationTarget } from "./medication-builder"; @@ -112,6 +113,7 @@ export async function buildTargetsResponse(user: AuthedUser) { latestMoodEntry, glucoseSummaries, restingPulseProxy, + dashboardSummaries, ] = await Promise.all([ limit(() => prisma.user.findUnique({ @@ -247,6 +249,11 @@ export async function buildTargetsResponse(user: AuthedUser) { timeZone: timezone, }), ), + // The verdict is placed with the value the dashboard tile places it + // with (the seven-day mean, else the latest reading), read from the same + // summary slice, so a target and its tile never disagree on which way a + // change counts. + limit(() => computeSummariesSlice(userId)), ]); // Rollup coverage fallback stays an explicit repository read here rather @@ -369,14 +376,18 @@ export async function buildTargetsResponse(user: AuthedUser) { // v1.43 (ios #116 item 3) — every item carries which way its change // counts, judged with the same rule the dashboard tiles use. - const verdictTargets = withTargetVerdicts(targets, { - profile: { - heightCm, - dateOfBirth: dbUser?.dateOfBirth ?? null, - gender: dbUser?.gender ?? null, + const verdictTargets = withTargetVerdicts( + targets, + { + profile: { + heightCm, + dateOfBirth: dbUser?.dateOfBirth ?? null, + gender: dbUser?.gender ?? null, + }, + thresholdsJson: dbUser?.thresholdsJson ?? null, }, - thresholdsJson: dbUser?.thresholdsJson ?? null, - }); + dashboardSummaries.summaries, + ); targets.splice(0, targets.length, ...verdictTargets); const pageSummary = buildTargetPageSummary(targets); @@ -412,28 +423,70 @@ export async function buildTargetsResponse(user: AuthedUser) { } /** - * Lay the verdict over each target item. The reference is the item's latest - * value, else its thirty-day average, in the unit the item is served in: - * sleep is served in hours and judged in minutes, and BMI is judged by the - * weight it is derived from. + * The dashboard summary each target item's verdict is placed with: the key + * its dashboard tile reads its direction under. Blood pressure is judged by + * its systolic side, BMI by the weight it is derived from, and the resting + * pulse by the resting heart rate where the account records one (the tile + * shows that series then). + */ +function summaryKeysFor(type: string): string[] { + switch (type) { + case "BLOOD_PRESSURE": + return ["BLOOD_PRESSURE_SYS"]; + case "BMI": + return ["WEIGHT"]; + case "PULSE": + return ["RESTING_HEART_RATE", "PULSE"]; + default: + return [type]; + } +} + +type VerdictSummaries = Readonly< + Record +>; + +/** + * The value a target's verdict is placed with, in the stored unit: the same + * basis as the dashboard tile (`resolveSummaryVerdicts`), the seven-day mean + * else the latest reading. Only when the account has no summary for the type + * does the item's own value stand in (latest, else thirty-day average; sleep + * is served in hours and stored in minutes). + */ +export function targetVerdictReference( + item: Pick, + summaries: VerdictSummaries, + weightItem: Pick | undefined, +): number | null { + for (const key of summaryKeysFor(item.type)) { + const summary = summaries[key]; + const value = summary ? (summary.avg7 ?? summary.latest) : null; + if (value !== null && value !== undefined) return value; + } + const own = + item.type === "BMI" + ? weightItem + ? (weightItem.current ?? weightItem.average30) + : null + : (item.current ?? item.average30); + if (own === null) return null; + return item.type === "SLEEP_DURATION" ? own * 60 : own; +} + +/** + * Lay the verdict over each target item, placed with the dashboard's basis + * (`targetVerdictReference`). */ export function withTargetVerdicts( targets: readonly TargetItem[], ctx: Omit, + summaries: VerdictSummaries, ): TargetItem[] { const weight = targets.find((item) => item.type === "WEIGHT"); - const weightReference = weight ? (weight.current ?? weight.average30) : null; return targets.map((item) => { - const own = item.current ?? item.average30; - const reference = - item.type === "BMI" - ? weightReference - : own !== null && item.type === "SLEEP_DURATION" - ? own * 60 - : own; const direction = resolveTrendVerdict(item.type, { ...ctx, - referenceValue: reference, + referenceValue: targetVerdictReference(item, summaries, weight), }); return { ...item,