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 }); + }); +});