From 90106d88c892ec09eb92cb2485addf98f6468215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 10:23:14 +0200 Subject: [PATCH] fix(dashboard): shade blood pressure, pulse and body fat from the own target The dashboard bands took only the weight target into account. Blood pressure, resting pulse and body fat were resolved from age and sex alone, so a target set on /targets left the chart zones and the tile ranges on the standard band. buildDashboardBands now reads the stored targets blob and routes every override through getEffectiveRange, the same resolver the targets page and the doctor report use. A metric without an override keeps exactly the band it drew before. The snapshot's targetBands carry the resolved values, so a native client reading them needs no change; the field descriptions now say the own target wins. Refs #1261 --- docs/api/openapi.yaml | 18 +- src/app/page-client.tsx | 18 +- src/lib/dashboard/__tests__/snapshot.test.ts | 24 +++ .../dashboard/__tests__/target-bands.test.ts | 144 ++++++++++++-- .../__tests__/viewer-band-profile.test.ts | 4 +- src/lib/dashboard/bands.ts | 179 +++++++++--------- src/lib/dashboard/snapshot.ts | 9 +- src/lib/openapi/routes/insights/schemas.ts | 22 ++- .../dashboard-own-target-bands.test.ts | 133 +++++++++++++ 9 files changed, 413 insertions(+), 138 deletions(-) create mode 100644 tests/integration/dashboard-own-target-bands.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6cc0471ec..ace2b65e6 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -63558,7 +63558,8 @@ components: - diaHigh additionalProperties: false - type: "null" - description: Personalised blood-pressure target numbers. Null when the profile has no date of birth. + description: "Blood-pressure target numbers: the user's own target (`/api/user/thresholds`) where set, else the + age-banded default. Null when there is neither a target nor a date of birth." bpSysRange: anyOf: - type: object @@ -63578,7 +63579,8 @@ components: - orangeMax additionalProperties: false - type: "null" - description: Systolic traffic-light range. Null when the profile has no date of birth. + description: Systolic traffic-light range, the user's own target winning over the age default. Null when there is + neither a target nor a date of birth. bpDiaRange: anyOf: - type: object @@ -63598,7 +63600,8 @@ components: - orangeMax additionalProperties: false - type: "null" - description: Diastolic traffic-light range. Null when the profile has no date of birth. + description: Diastolic traffic-light range, the user's own target winning over the age default. Null when there is + neither a target nor a date of birth. pulseDisplayRange: type: object properties: @@ -63616,7 +63619,8 @@ components: - orangeMin - orangeMax additionalProperties: false - description: Resting-pulse display range. Always present — falls back to the AHA reference range with no date of birth. + description: "Resting-pulse display range: the user's own target where set, else the age / sex percentile band (the AHA + reference range with no date of birth). Always present." pulseBands: type: array items: @@ -63637,7 +63641,7 @@ components: - max - color additionalProperties: false - description: Resting-pulse chart bands. Always present — AHA fallback with no date of birth. + description: Resting-pulse chart bands, drawn from the same range as `pulseDisplayRange`. Always present. bodyFatRange: type: object properties: @@ -63649,7 +63653,7 @@ components: - min - max additionalProperties: false - description: Gender-aware body-fat target range. Always present. + description: "Body-fat target range: the user's own target where set, else the gender-aware default. Always present." bodyFatBands: type: array items: @@ -63670,7 +63674,7 @@ components: - max - color additionalProperties: false - description: Body-fat chart bands. Always present. + description: Body-fat chart bands, drawn from the same range as `bodyFatRange`. Always present. weightRange: anyOf: - type: object diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 02c00fe5d..5e7e0bb45 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -378,10 +378,10 @@ export default function DashboardPageClient({ analyticsThickQuery.data, ]); - // v1.34 — the user's own weight target, for the legacy (snapshot-disabled) - // band fallback only. In snapshot mode the server resolves the same override - // into `targetBands` and this query never fires; the fallback would otherwise - // keep shading the height-derived band for someone who has set a target. + // The user's own targets, for the legacy (snapshot-disabled) band fallback + // only. In snapshot mode the server resolves the same overrides into + // `targetBands` and this query never fires; the fallback would otherwise + // keep shading the default band for someone who has set a target. const { data: thresholdsData } = useQuery({ queryKey: queryKeys.userThresholds(), queryFn: async () => { @@ -754,9 +754,9 @@ export default function DashboardPageClient({ // in the snapshot steady state) before this. Keyed on the `user` object // identity so the React Compiler can preserve the memo (a narrower // property list trips `preserve-manual-memoization`). - // v1.34 — the weight target rides the same memo. In snapshot mode + // v1.34 — the user's own targets ride the same memo. In snapshot mode // `thresholdsData` is never fetched, so the client copy falls back to the - // height-derived band for the frames before `serverBands` lands — exactly + // default bands for the frames before `serverBands` lands — exactly // as it already did for every other profile-derived number. // v1.36.x — the profile this fallback reads is the CALLER's, and under a // switch the caller is the delegate. `viewerBandProfile` is where that is @@ -771,9 +771,7 @@ export default function DashboardPageClient({ dateOfBirth: user?.dateOfBirth ? new Date(user.dateOfBirth) : null, gender: toProfileSex(user?.gender), heightCm: user?.heightCm ?? null, - weightTargetOverride: resolveWeightTargetOverride( - thresholdsData?.overrides ?? null, - ), + thresholdsJson: thresholdsData?.overrides ?? null, }, inSharedRecord, ), @@ -791,7 +789,7 @@ export default function DashboardPageClient({ const weightTrend = (snapshotEnabled ? snapshotQuery.data?.tiles.weightTrend : undefined) ?? resolveWeightTrend( - clientBandProfile.weightTargetOverride, + resolveWeightTargetOverride(clientBandProfile.thresholdsJson), weightTrendReferenceKg(w), ); // v1.43 — every other tile's direction comes resolved from the server diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 85b07d2eb..3325fc8bc 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -1269,6 +1269,30 @@ describe("buildDashboardSnapshot — module gating (v1.18.0)", () => { expect(snap.targetBands.weightRange?.greenMax).toBe(78); }); + it("an own blood-pressure, pulse and body-fat target reach the dashboard bands", async () => { + const snap = await buildDashboardSnapshot( + fakePrisma, + baseUser({ + thresholdsJson: { + BLOOD_PRESSURE_SYS: { min: 115, max: 125 }, + BLOOD_PRESSURE_DIA: { min: 70, max: 78 }, + PULSE: { min: 48, max: 58 }, + BODY_FAT: { min: 12, max: 16 }, + }, + }), + { modules: () => Promise.resolve(moduleMap()) }, + ); + expect(snap.targetBands.bpTargets).toEqual({ + sysLow: 115, + sysHigh: 125, + diaLow: 70, + diaHigh: 78, + }); + expect(snap.targetBands.pulseDisplayRange.greenMin).toBe(48); + expect(snap.targetBands.pulseDisplayRange.greenMax).toBe(58); + expect(snap.targetBands.bodyFatRange).toEqual({ min: 12, max: 16 }); + }); + it("sleep disabled: tile hidden + SLEEP_DURATION stripped from summaries", async () => { const snap = await buildDashboardSnapshot(fakePrisma, baseUser(), { modules: () => Promise.resolve(moduleMap({ sleep: false })), diff --git a/src/lib/dashboard/__tests__/target-bands.test.ts b/src/lib/dashboard/__tests__/target-bands.test.ts index 06e51dca2..a60b3eb36 100644 --- a/src/lib/dashboard/__tests__/target-bands.test.ts +++ b/src/lib/dashboard/__tests__/target-bands.test.ts @@ -105,28 +105,28 @@ describe("buildTargetBands — server/client parity", () => { dateOfBirth: Date | null; gender: "MALE" | "FEMALE" | null; heightCm: number | null; - weightTargetOverride: { min: number; max: number } | null; + thresholdsJson: unknown; }> = [ { name: "male 180cm with DOB", dateOfBirth: new Date("1985-06-15T00:00:00.000Z"), gender: "MALE", heightCm: 180, - weightTargetOverride: null, + thresholdsJson: null, }, { name: "female 165cm with DOB", dateOfBirth: new Date("1970-01-01T00:00:00.000Z"), gender: "FEMALE", heightCm: 165, - weightTargetOverride: null, + thresholdsJson: null, }, { name: "no-profile user (null DOB / gender / height)", dateOfBirth: null, gender: null, heightCm: null, - weightTargetOverride: null, + thresholdsJson: null, }, ]; @@ -146,20 +146,20 @@ describe("buildTargetBands — server/client parity", () => { dateOfBirth: new Date("1985-06-15T00:00:00.000Z"), gender: "OTHER", heightCm: 180, - weightTargetOverride: null, + thresholdsJson: null, }); const unanswered = buildTargetBands({ dateOfBirth: new Date("1985-06-15T00:00:00.000Z"), gender: null, heightCm: 180, - weightTargetOverride: null, + thresholdsJson: null, }); expect(other).toEqual(unanswered); const male = buildTargetBands({ dateOfBirth: new Date("1985-06-15T00:00:00.000Z"), gender: "MALE", heightCm: 180, - weightTargetOverride: null, + thresholdsJson: null, }); expect(other.bodyFatBands).not.toEqual(male.bodyFatBands); }); @@ -169,7 +169,7 @@ describe("buildTargetBands — server/client parity", () => { dateOfBirth: null, gender: null, heightCm: null, - weightTargetOverride: null, + thresholdsJson: null, }); expect(bands.bpTargets).toBeNull(); expect(bands.bpSysRange).toBeNull(); @@ -198,14 +198,14 @@ describe("buildTargetBands — user weight target", () => { it("replaces the height-derived band with the user's own", () => { const withTarget = buildTargetBands({ ...profile, - weightTargetOverride: { min: 74, max: 78 }, + thresholdsJson: { WEIGHT: { min: 74, max: 78 } }, }); expect(withTarget.weightRange?.greenMin).toBe(74); expect(withTarget.weightRange?.greenMax).toBe(78); const heightDerived = buildTargetBands({ ...profile, - weightTargetOverride: null, + thresholdsJson: null, }); expect(heightDerived.weightRange).not.toEqual(withTarget.weightRange); // The chart bands follow the same range, so tile and chart agree. @@ -221,7 +221,7 @@ describe("buildTargetBands — user weight target", () => { dateOfBirth: null, gender: null, heightCm: null, - weightTargetOverride: { min: 60, max: 65 }, + thresholdsJson: { WEIGHT: { min: 60, max: 65 } }, }); expect(bands.weightRange?.greenMin).toBe(60); expect(bands.weightBands?.length).toBeGreaterThan(0); @@ -230,14 +230,132 @@ describe("buildTargetBands — user weight target", () => { it("leaves every non-weight band untouched", () => { const withTarget = buildTargetBands({ ...profile, - weightTargetOverride: { min: 74, max: 78 }, + thresholdsJson: { WEIGHT: { min: 74, max: 78 } }, }); const without = buildTargetBands({ ...profile, - weightTargetOverride: null, + thresholdsJson: null, }); expect(withTarget.bpTargets).toEqual(without.bpTargets); expect(withTarget.pulseBands).toEqual(without.pulseBands); expect(withTarget.bodyFatBands).toEqual(without.bodyFatBands); }); }); + +/** + * The other metrics the dashboard shades take the person's own target the + * same way weight does. A blood-pressure, resting-pulse or body-fat target set + * on `/targets` used to leave the chart on the age / sex default, so the band + * under the line contradicted the band the person had just entered. + */ +describe("buildTargetBands — the user's own targets on every shaded metric", () => { + const profile = { + dateOfBirth: new Date("1985-06-15T00:00:00.000Z"), + gender: "MALE" as const, + heightCm: 180, + }; + const without = buildTargetBands({ ...profile, thresholdsJson: null }); + const greenOf = (bands: Array<{ min: number; max: number; color: string }>) => + bands.find((band) => band.color === "var(--success)"); + + it("draws the blood-pressure zones and tile ranges from the own band", () => { + const bands = buildTargetBands({ + ...profile, + thresholdsJson: { + BLOOD_PRESSURE_SYS: { min: 115, max: 125 }, + BLOOD_PRESSURE_DIA: { min: 70, max: 78 }, + }, + }); + expect(bands.bpTargets).toEqual({ + sysLow: 115, + sysHigh: 125, + diaLow: 70, + diaHigh: 78, + }); + expect(bands.bpTargets).not.toEqual(without.bpTargets); + expect(bands.bpSysRange).toEqual( + expect.objectContaining({ greenMin: 115, greenMax: 125 }), + ); + expect(bands.bpDiaRange).toEqual( + expect.objectContaining({ greenMin: 70, greenMax: 78 }), + ); + }); + + it("fills a one-sided blood-pressure target with the age default", () => { + const bands = buildTargetBands({ + ...profile, + thresholdsJson: { BLOOD_PRESSURE_SYS: { min: 110, max: 128 } }, + }); + expect(bands.bpTargets?.sysLow).toBe(110); + expect(bands.bpTargets?.sysHigh).toBe(128); + expect(bands.bpTargets?.diaLow).toBe(without.bpTargets?.diaLow); + expect(bands.bpTargets?.diaHigh).toBe(without.bpTargets?.diaHigh); + expect(bands.bpDiaRange).toEqual(without.bpDiaRange); + }); + + it("draws a blood-pressure band from a target even without a date of birth", () => { + const bands = buildTargetBands({ + dateOfBirth: null, + gender: null, + heightCm: null, + thresholdsJson: { + BLOOD_PRESSURE_SYS: { min: 115, max: 125 }, + BLOOD_PRESSURE_DIA: { min: 70, max: 78 }, + }, + }); + expect(bands.bpTargets).toEqual({ + sysLow: 115, + sysHigh: 125, + diaLow: 70, + diaHigh: 78, + }); + }); + + it("draws the resting-pulse bands and display range from the own band", () => { + const bands = buildTargetBands({ + ...profile, + thresholdsJson: { PULSE: { min: 48, max: 58 } }, + }); + expect(bands.pulseDisplayRange).toEqual( + expect.objectContaining({ greenMin: 48, greenMax: 58 }), + ); + expect(greenOf(bands.pulseBands)).toEqual( + expect.objectContaining({ min: 48, max: 58 }), + ); + expect(bands.pulseBands).not.toEqual(without.pulseBands); + }); + + it("draws the body-fat bands and range from the own band", () => { + const bands = buildTargetBands({ + ...profile, + thresholdsJson: { BODY_FAT: { min: 12, max: 16 } }, + }); + expect(bands.bodyFatRange).toEqual({ min: 12, max: 16 }); + expect(greenOf(bands.bodyFatBands)).toEqual( + expect.objectContaining({ min: 12, max: 16 }), + ); + expect(bands.bodyFatBands).not.toEqual(without.bodyFatBands); + }); + + it("ignores a malformed target and keeps the default band", () => { + const bands = buildTargetBands({ + ...profile, + thresholdsJson: { + BLOOD_PRESSURE_SYS: { min: 130, max: 120 }, + PULSE: { min: "fast", max: 60 }, + BODY_FAT: null, + }, + }); + expect(bands).toEqual(without); + }); + + it("leaves the other metrics' bands alone", () => { + const bands = buildTargetBands({ + ...profile, + thresholdsJson: { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } }, + }); + expect(bands.pulseBands).toEqual(without.pulseBands); + expect(bands.bodyFatBands).toEqual(without.bodyFatBands); + expect(bands.weightBands).toEqual(without.weightBands); + }); +}); diff --git a/src/lib/dashboard/__tests__/viewer-band-profile.test.ts b/src/lib/dashboard/__tests__/viewer-band-profile.test.ts index 0cd9a8fd3..ac465af0a 100644 --- a/src/lib/dashboard/__tests__/viewer-band-profile.test.ts +++ b/src/lib/dashboard/__tests__/viewer-band-profile.test.ts @@ -30,7 +30,7 @@ const DELEGATE_PROFILE = { dateOfBirth: new Date("1996-04-02T00:00:00Z"), gender: "FEMALE" as const, heightCm: 165, - weightTargetOverride: { min: 55, max: 62 }, + thresholdsJson: { WEIGHT: { min: 55, max: 62 } }, }; describe("viewerBandProfile", () => { @@ -45,7 +45,7 @@ describe("viewerBandProfile", () => { dateOfBirth: null, gender: null, heightCm: null, - weightTargetOverride: null, + thresholdsJson: null, }); }); }); diff --git a/src/lib/dashboard/bands.ts b/src/lib/dashboard/bands.ts index c56aa49a3..1bfe1649d 100644 --- a/src/lib/dashboard/bands.ts +++ b/src/lib/dashboard/bands.ts @@ -15,7 +15,7 @@ * so the band/target math lives in exactly one place. `buildTargetBands` * is kept as an alias for the snapshot-era import sites. */ -import { getBpTargets, type BpTargets } from "@/lib/analytics/bp-targets"; +import type { BpTargets } from "@/lib/analytics/bp-targets"; import { buildBandsFromTrafficRange, buildTrafficLightBands, @@ -30,16 +30,27 @@ import { getPersonalizedPulseTarget, } from "@/lib/analytics/pulse-targets"; import { binaryReferenceSex, type ProfileSex } from "@/lib/profile/sex"; -import { getEffectiveRange } from "@/lib/analytics/effective-range"; +import { + getEffectiveRange, + resolveEffectiveBpTargets, + resolveThresholdOverride, + type ThresholdMetric, +} from "@/lib/analytics/effective-range"; export interface DashboardTargetBands { - /** Personalised BP target numbers (null when no DOB). */ + /** + * BP target numbers: the user's own band where they set one, else the age + * default. Null when neither exists (no target and no DOB). + */ bpTargets: BpTargets | null; - /** Systolic traffic range (null when no DOB). */ + /** Systolic traffic range, same precedence (null without target or DOB). */ bpSysRange: TrafficRange | null; - /** Diastolic traffic range (null when no DOB). */ + /** Diastolic traffic range, same precedence (null without target or DOB). */ bpDiaRange: TrafficRange | null; - /** Resting-pulse display range (always present — AHA fallback). */ + /** + * Resting-pulse display range: the user's own target, else the + * age / sex percentile band (AHA fallback). Always present. + */ pulseDisplayRange: { greenMin: number; greenMax: number; @@ -48,7 +59,7 @@ export interface DashboardTargetBands { }; /** Resting-pulse chart bands (always present — AHA fallback). */ pulseBands: ValueBand[]; - /** Body-fat target range (gender-aware; always present). */ + /** Body-fat target range: own target, else gender-aware. Always present. */ bodyFatRange: { min: number; max: number }; /** Body-fat chart bands (always present). */ bodyFatBands: ValueBand[]; @@ -66,110 +77,92 @@ export interface DashboardTargetBands { * DB read. The snapshot builder and the page fallback both call this so * they produce byte-identical numbers. * - * v1.34 — `weightTargetOverride` is the user's OWN weight band as stored in - * `User.thresholdsJson.WEIGHT`, in kg. When present it replaces the - * height-derived WHO band on both the weight range and the weight chart bands: - * a user who has answered "this is my target" must not keep reading a shaded - * zone that ignores the answer. `null` keeps the height-derived band. Required - * rather than optional so every call site is named by the compiler. + * `thresholdsJson` is the user's own targets blob as stored in + * `User.thresholdsJson` (`null` when there is none). Every metric the dashboard + * shades and that accepts a personal target reads it: weight, blood pressure, + * resting pulse and body fat. A target the person set replaces the computed + * default on both the tile's range and the chart's bands, so a shaded zone + * never ignores an answer given on `/targets`. Each override goes through + * `getEffectiveRange`, so its orange wings match the `/targets` page and the + * doctor report rather than a second convention. A metric without an override + * keeps exactly the band it drew before. Required rather than optional so + * every call site is named by the compiler. + * + * v1.34 introduced this for weight alone; the other three kept the age / sex + * default regardless of the person's own target until v1.44.1. */ export function buildDashboardBands(profile: { dateOfBirth: Date | null; gender: ProfileSex; heightCm: number | null; - weightTargetOverride: { min: number; max: number } | null; + thresholdsJson: unknown; }): DashboardTargetBands { - const bpTargets = profile.dateOfBirth - ? getBpTargets(profile.dateOfBirth) - : null; + const rangeProfile = { + heightCm: profile.heightCm, + dateOfBirth: profile.dateOfBirth, + gender: profile.gender, + }; + const ownTarget = (metric: ThresholdMetric) => + resolveThresholdOverride(profile.thresholdsJson, metric); + const ownRange = (metric: ThresholdMetric): TrafficRange | null => { + const override = ownTarget(metric); + return override + ? getEffectiveRange(metric, rangeProfile, { [metric]: override }).range + : null; + }; + + const bpTargets = resolveEffectiveBpTargets( + rangeProfile, + profile.thresholdsJson, + ); const pulseAge = getAgeFromDateOfBirth(profile.dateOfBirth); // The percentile and body-fat tables carry two sexes and no third row; // both helpers answer a null with a neutral midpoint rather than a side. const referenceSex = binaryReferenceSex(profile.gender); - const pulseTarget = getPersonalizedPulseTarget(pulseAge, referenceSex); - const bodyFatRange = getBodyFatTargetRange(referenceSex); - // v1.34 — an explicit target outranks the height-derived WHO band. Without - // this the dashboard kept shading a band the user had already answered, and - // the answer lived on `/targets` where nothing else could see it. The - // override is routed through `getEffectiveRange` rather than widened here, - // so the orange wings match the `/targets` page and the doctor report - // exactly instead of inventing a second convention. No height and no target - // → no band at all, unchanged. - const weightRange = profile.weightTargetOverride - ? getEffectiveRange( - "WEIGHT", - { - heightCm: profile.heightCm, - dateOfBirth: profile.dateOfBirth, - gender: profile.gender, - }, - { WEIGHT: profile.weightTargetOverride }, - ).range - : profile.heightCm - ? buildWeightRangeFromHeight(profile.heightCm) - : null; + const defaultPulse = getPersonalizedPulseTarget(pulseAge, referenceSex); + const pulseRange: TrafficRange = ownRange("PULSE") ?? { + greenMin: defaultPulse.greenMin, + greenMax: defaultPulse.greenMax, + orangeMin: defaultPulse.orangeMin, + orangeMax: defaultPulse.orangeMax, + }; + const ownBodyFat = ownRange("BODY_FAT"); + const bodyFatRange = ownBodyFat + ? { min: ownBodyFat.greenMin, max: ownBodyFat.greenMax } + : getBodyFatTargetRange(referenceSex); + // No height and no target → no weight band at all, unchanged. + const weightRange = + ownRange("WEIGHT") ?? + (profile.heightCm ? buildWeightRangeFromHeight(profile.heightCm) : null); const weightBands = weightRange ? buildBandsFromTrafficRange(weightRange, { lowerBound: 30, upperBound: 250, }) : null; - const bpSysRange = bpTargets - ? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh) - : null; - const bpDiaRange = bpTargets - ? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh) - : null; - const pulseBands = [ - { - min: 30, - max: pulseTarget.orangeMin, - color: "var(--destructive)", - opacity: 0.16, - }, - { - min: pulseTarget.orangeMin, - max: pulseTarget.greenMin, - color: "var(--warning)", - opacity: 0.18, - }, - { - min: pulseTarget.greenMin, - max: pulseTarget.greenMax, - color: "var(--success)", - opacity: 0.2, - }, - { - min: pulseTarget.greenMax, - max: pulseTarget.orangeMax, - color: "var(--warning)", - opacity: 0.18, - }, - { - min: pulseTarget.orangeMax, - max: 220, - color: "var(--destructive)", - opacity: 0.16, - }, - ].filter((band) => band.max > band.min); - const bodyFatBands = buildTrafficLightBands( - bodyFatRange.min, - bodyFatRange.max, - { - lowerBound: 2, - upperBound: 55, - }, - ); + // The tile colours a reading against the same band the chart shades: the + // person's own range with its effective wings, else the age default's. + const bpSysRange = + ownRange("BLOOD_PRESSURE_SYS") ?? + (bpTargets ? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh) : null); + const bpDiaRange = + ownRange("BLOOD_PRESSURE_DIA") ?? + (bpTargets ? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh) : null); + const pulseBands = buildBandsFromTrafficRange(pulseRange, { + lowerBound: 30, + upperBound: 220, + }); + const bodyFatBands = ownBodyFat + ? buildBandsFromTrafficRange(ownBodyFat, { lowerBound: 2, upperBound: 55 }) + : buildTrafficLightBands(bodyFatRange.min, bodyFatRange.max, { + lowerBound: 2, + upperBound: 55, + }); return { bpTargets, bpSysRange, bpDiaRange, - pulseDisplayRange: { - greenMin: pulseTarget.greenMin, - greenMax: pulseTarget.greenMax, - orangeMin: pulseTarget.orangeMin, - orangeMax: pulseTarget.orangeMax, - }, + pulseDisplayRange: pulseRange, pulseBands, bodyFatRange, bodyFatBands, @@ -187,7 +180,7 @@ export interface BandProfile { dateOfBirth: Date | null; gender: ProfileSex; heightCm: number | null; - weightTargetOverride: { min: number; max: number } | null; + thresholdsJson: unknown; } /** @@ -220,6 +213,6 @@ export function viewerBandProfile( dateOfBirth: null, gender: null, heightCm: null, - weightTargetOverride: null, + thresholdsJson: null, }; } diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 29541fa39..e7a5f1502 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -1489,10 +1489,11 @@ export async function buildDashboardSnapshot( dateOfBirth: user.dateOfBirth, gender, heightCm: user.heightCm ?? null, - // v1.34 — the user's own weight target, when they set one, replaces the - // height-derived band on the tile + the chart. Resolved off the same - // `thresholdsJson` blob the health score reads. - weightTargetOverride, + // The user's own targets, where set, replace the computed default on + // every tile and chart band (weight, blood pressure, resting pulse, + // body fat). Resolved off the same `thresholdsJson` blob the health + // score and the tile directions read. + thresholdsJson: user.thresholdsJson, }), tiles: { summaries: slim.summaries, diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 615b428cd..a088d8968 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -1186,8 +1186,8 @@ export const dashboardSnapshotResponse = z "Latest reading per chartable metric, keyed by the iOS `MetricKind` raw value (the non-obvious raws: `oxygenSaturation`, `totalBodyWater`, `heartRateVariability`, `bodyMassIndex`, `walkingAsymmetryPercentage`, `walkingDoubleSupportPercentage`, `environmentalAudioExposure`, `headphoneAudioExposure`, `activeEnergyBurned`). Each entry carries `value`, `measuredAt` (ISO8601), and the canonical `unit`. Types the user has never logged are omitted.", ), // v1.18.6 — server-computed band / target math (audit finding #3), - // resolved from the user's own profile facts (DOB / gender / height / - // weight-target override). The dashboard reads these instead of + // resolved from the user's own profile facts (DOB / gender / height) and + // their own targets. The dashboard reads these instead of // recomputing client-side; always present (every sub-field falls back // to a neutral default rather than being omitted). targetBands: z @@ -1201,7 +1201,7 @@ export const dashboardSnapshotResponse = z }) .nullable() .describe( - "Personalised blood-pressure target numbers. Null when the profile has no date of birth.", + "Blood-pressure target numbers: the user's own target (`/api/user/thresholds`) where set, else the age-banded default. Null when there is neither a target nor a date of birth.", ), bpSysRange: z .object({ @@ -1212,7 +1212,7 @@ export const dashboardSnapshotResponse = z }) .nullable() .describe( - "Systolic traffic-light range. Null when the profile has no date of birth.", + "Systolic traffic-light range, the user's own target winning over the age default. Null when there is neither a target nor a date of birth.", ), bpDiaRange: z .object({ @@ -1223,7 +1223,7 @@ export const dashboardSnapshotResponse = z }) .nullable() .describe( - "Diastolic traffic-light range. Null when the profile has no date of birth.", + "Diastolic traffic-light range, the user's own target winning over the age default. Null when there is neither a target nor a date of birth.", ), pulseDisplayRange: z .object({ @@ -1233,7 +1233,7 @@ export const dashboardSnapshotResponse = z orangeMax: z.number(), }) .describe( - "Resting-pulse display range. Always present — falls back to the AHA reference range with no date of birth.", + "Resting-pulse display range: the user's own target where set, else the age / sex percentile band (the AHA reference range with no date of birth). Always present.", ), pulseBands: z .array( @@ -1246,11 +1246,13 @@ export const dashboardSnapshotResponse = z }), ) .describe( - "Resting-pulse chart bands. Always present — AHA fallback with no date of birth.", + "Resting-pulse chart bands, drawn from the same range as `pulseDisplayRange`. Always present.", ), bodyFatRange: z .object({ min: z.number(), max: z.number() }) - .describe("Gender-aware body-fat target range. Always present."), + .describe( + "Body-fat target range: the user's own target where set, else the gender-aware default. Always present.", + ), bodyFatBands: z .array( z.object({ @@ -1261,7 +1263,9 @@ export const dashboardSnapshotResponse = z strokeOpacity: z.number().optional(), }), ) - .describe("Body-fat chart bands. Always present."), + .describe( + "Body-fat chart bands, drawn from the same range as `bodyFatRange`. Always present.", + ), weightRange: z .object({ greenMin: z.number(), diff --git a/tests/integration/dashboard-own-target-bands.test.ts b/tests/integration/dashboard-own-target-bands.test.ts new file mode 100644 index 000000000..bc0c7994b --- /dev/null +++ b/tests/integration/dashboard-own-target-bands.test.ts @@ -0,0 +1,133 @@ +/** + * A target the person sets on `/targets` reaches the dashboard's chart bands. + * + * The snapshot resolved the blood-pressure, resting-pulse and body-fat bands + * from the profile alone (age, sex) and took only the weight target into + * account, so the zones under the blood-pressure chart stayed on the age + * default after the person had entered their own band. The resolved bands + * are what the web dashboard draws and what a native client reads, so the + * proof runs through the real routes: write the target through + * `PUT /api/user/thresholds`, read `GET /api/dashboard/snapshot` back from + * the same Postgres, with the snapshot's cache cell in between. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +async function seedUser(): Promise { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: "own-target-bands", + email: "own-target-bands@example.test", + role: "USER", + timezone: "Europe/Berlin", + dateOfBirth: new Date("1960-03-01T00:00:00.000Z"), + gender: "MALE", + heightCm: 178, + }, + }); + const session = await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 60 * 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); +} + +interface Bands { + bpTargets: { + sysLow: number; + sysHigh: number; + diaLow: number; + diaHigh: number; + } | null; + pulseDisplayRange: { greenMin: number; greenMax: number }; + bodyFatRange: { min: number; max: number }; +} + +async function readBands(): Promise { + const { GET } = await import("@/app/api/dashboard/snapshot/route"); + const res = await GET(); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { targetBands: Bands } }; + return body.data.targetBands; +} + +async function putTargets(payload: unknown): Promise { + const { PUT } = await import("@/app/api/user/thresholds/route"); + const res = await PUT( + new NextRequest("http://localhost/api/user/thresholds", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }), + ); + expect(res.status).toBe(200); +} + +describe("dashboard bands follow the person's own targets (real Postgres)", () => { + it("replaces the age default once a target is saved", async () => { + await seedUser(); + + const before = await readBands(); + // The premise: an age default exists and differs from the target below. + expect(before.bpTargets).not.toBeNull(); + expect(before.bpTargets?.sysHigh).not.toBe(150); + + await putTargets({ + BLOOD_PRESSURE_SYS: { min: 120, max: 150 }, + BLOOD_PRESSURE_DIA: { min: 75, max: 95 }, + PULSE: { min: 48, max: 58 }, + BODY_FAT: { min: 12, max: 16 }, + }); + + // Read again: the save evicts the cached snapshot, so this is a rebuild + // against the stored blob, not the cell from the first read. + const after = await readBands(); + expect(after.bpTargets).toEqual({ + sysLow: 120, + sysHigh: 150, + diaLow: 75, + diaHigh: 95, + }); + expect(after.pulseDisplayRange).toEqual( + expect.objectContaining({ greenMin: 48, greenMax: 58 }), + ); + expect(after.bodyFatRange).toEqual({ min: 12, max: 16 }); + }); +});