From ef420f9e2a264b6db857579e86afbcacfb352137 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:46:16 +0200 Subject: [PATCH] fix(insights): draw the blood-pressure zones from the record's resolved band The blood-pressure chart rebuilt its target zones in the browser from the signed-in account's date of birth. A band the person had set themselves never reached the chart, and inside a shared or managed record the zones followed the viewer's age instead of the record's. The targets payload already resolves for the record on screen. Its blood- pressure card now honours the user's own band too (labelled Custom, like the weight card), and the chart reads its zones from that card, so the chart and the reference panel under it name one band. --- src/app/insights/blood-pressure/page.tsx | 16 ++-- src/lib/analytics/effective-range.ts | 20 ++++ src/lib/queries/use-bp-target-band.ts | 61 ++++++++++++ .../__tests__/bp-target-override.test.ts | 96 +++++++++++++++++++ src/lib/targets/build-response.ts | 12 +++ src/lib/targets/vitals-builder.ts | 16 +++- 6 files changed, 211 insertions(+), 10 deletions(-) create mode 100644 src/lib/queries/use-bp-target-band.ts create mode 100644 src/lib/targets/__tests__/bp-target-override.test.ts diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index d2ad8102e..98df95c45 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -18,7 +18,7 @@ import { MetricStatStrip } from "@/components/insights/metric-stat-strip"; import { CoachReadStrip } from "@/components/insights/derived/coach-read-strip"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; import { SubPageShell } from "@/components/insights/sub-page-shell"; -import { getBpTargets } from "@/lib/analytics/bp-targets"; +import { useBpTargetBand } from "@/lib/queries/use-bp-target-band"; /** * v1.4.25 W4 — `/insights/blood-pressure`. @@ -26,7 +26,8 @@ import { getBpTargets } from "@/lib/analytics/bp-targets"; * Routed Blood-Pressure sub-page. The mother page hosts the overview * (hero + briefing + advisor); this page concentrates on BP-only depth: * the BP chart with its chart-cog overlays, the per-section AI - * assessment, and the chart's target zones tied to the user's age. + * assessment, and the chart's target zones — the band the server resolved + * for the record on screen (the user's own target, else the age default). * * v1.4.27 F17 — when the user has zero BP observations, the page * short-circuits to an empty-state CTA pointing at @@ -57,6 +58,7 @@ export default function InsightsBlutdruckPage() { // series at once: the single chart reports per-type visible-range stats and // each strip column reads its own half. const { statsByType, onVisibleStats } = useChartDomainStats(); + const bpTargets = useBpTargetBand(); if (isEmpty) { return ( @@ -82,13 +84,11 @@ export default function InsightsBlutdruckPage() { ); } - const bpTargets = - user?.dateOfBirth != null ? getBpTargets(new Date(user.dateOfBirth)) : null; const bpTargetZones = bpTargets ? [ { - min: bpTargets.sysLow, - max: bpTargets.sysHigh, + min: bpTargets.systolic.min, + max: bpTargets.systolic.max, color: "var(--chart-3)", opacity: 0.21, label: t("charts.systolic"), @@ -96,8 +96,8 @@ export default function InsightsBlutdruckPage() { lineOpacity: 0.24, }, { - min: bpTargets.diaLow, - max: bpTargets.diaHigh, + min: bpTargets.diastolic.min, + max: bpTargets.diastolic.max, color: "var(--info)", opacity: 0.21, label: t("charts.diastolic"), diff --git a/src/lib/analytics/effective-range.ts b/src/lib/analytics/effective-range.ts index 0c46b89fc..16fdd2587 100644 --- a/src/lib/analytics/effective-range.ts +++ b/src/lib/analytics/effective-range.ts @@ -371,6 +371,26 @@ export function resolveEffectiveBpTargets( }; } +/** + * The user's own blood-pressure band, or `null` when they have not set one. + * + * `resolveEffectiveBpTargets` answers "which band applies" and falls back to + * the age default; this answers "did the person pick it", which is what a + * surface needs to label the band as their own rather than as the guideline. + * A one-sided override (systolic only) still counts: the other half is the + * age default, exactly as `resolveEffectiveBpTargets` fills it. + */ +export function resolveBpTargetOverride( + profile: UserProfileForRange, + thresholdsJson: unknown, +): BpTargets | null { + const hasOverride = + resolveThresholdOverride(thresholdsJson, "BLOOD_PRESSURE_SYS") != null || + resolveThresholdOverride(thresholdsJson, "BLOOD_PRESSURE_DIA") != null; + if (!hasOverride) return null; + return resolveEffectiveBpTargets(profile, thresholdsJson); +} + /** * Convenience: resolve every supported metric at once. Useful for /targets * and the insight generator. diff --git a/src/lib/queries/use-bp-target-band.ts b/src/lib/queries/use-bp-target-band.ts new file mode 100644 index 000000000..3daa9d477 --- /dev/null +++ b/src/lib/queries/use-bp-target-band.ts @@ -0,0 +1,61 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { useAuth } from "@/hooks/use-auth"; + +interface TargetRange { + min: number; + max: number; +} + +/** The slice of `GET /api/insights/targets` the blood-pressure band needs. */ +interface BpTargetsSlice { + targets: ReadonlyArray<{ type: string; range: TargetRange | null }>; + bpDiastolic: { range: TargetRange | null }; +} + +export interface BpTargetBand { + systolic: TargetRange; + diastolic: TargetRange; +} + +/** + * The blood-pressure target band for the record on screen, as the server + * resolved it. + * + * The chart used to rebuild the band in the browser from the signed-in + * account's date of birth. That lost a band the person had set themselves, + * and inside a shared or managed record it used the viewer's age rather than + * the record's. `/api/insights/targets` already resolves the band for the + * record being viewed, with the user's own target winning over the age + * default, and the reference panel under the chart reads the same payload — + * so the zones and the panel name one band. + * + * Shares the `insightsTargets()` cache entry with the panel; the payload + * shape is the route's, so both readers hold the same object. + */ +export function useBpTargetBand(): BpTargetBand | null { + const { isAuthenticated } = useAuth(); + const { data } = useQuery({ + queryKey: queryKeys.insightsTargets(), + queryFn: () => apiGet("/api/insights/targets"), + enabled: isAuthenticated, + }); + return selectBpTargetBand(data ?? null); +} + +/** Pure projection, exported for the unit test. */ +export function selectBpTargetBand( + data: BpTargetsSlice | null, +): BpTargetBand | null { + if (!data) return null; + const systolic = + data.targets.find((entry) => entry.type === "BLOOD_PRESSURE")?.range ?? + null; + const diastolic = data.bpDiastolic?.range ?? null; + if (!systolic || !diastolic) return null; + return { systolic, diastolic }; +} diff --git a/src/lib/targets/__tests__/bp-target-override.test.ts b/src/lib/targets/__tests__/bp-target-override.test.ts new file mode 100644 index 000000000..1da20a698 --- /dev/null +++ b/src/lib/targets/__tests__/bp-target-override.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; + +import { resolveBpTargetOverride } from "@/lib/analytics/effective-range"; +import { selectBpTargetBand } from "@/lib/queries/use-bp-target-band"; +import { buildVitalTargets } from "../vitals-builder"; + +const NOW = new Date("2026-07-21T12:00:00.000Z"); + +// `restingPulseProxy` is listed so the literal stays valid whichever shape of +// the input the builder carries; the assertion keeps the extra key legal. +const base = { + recentMeasurements: [], + restingPulseProxy: [], + latestByType: {}, + average30ByType: {}, + heightCm: 178, + age: 40, + gender: null, + timezone: "UTC", + now: NOW, + weightTargetOverride: null, +} as unknown as Parameters[0]; + +const PROFILE_40 = { + heightCm: 178, + dateOfBirth: new Date("1986-03-01T12:00:00.000Z"), + gender: null, +}; + +describe("blood-pressure target: the user's own band", () => { + it("is null when no blood-pressure threshold is stored", () => { + expect(resolveBpTargetOverride(PROFILE_40, null)).toBeNull(); + expect( + resolveBpTargetOverride(PROFILE_40, { WEIGHT: { min: 70, max: 75 } }), + ).toBeNull(); + }); + + it("carries the stored band, filling the other half from the age default", () => { + expect( + resolveBpTargetOverride(PROFILE_40, { + BLOOD_PRESSURE_SYS: { min: 110, max: 135 }, + }), + ).toEqual({ sysLow: 110, sysHigh: 135, diaLow: 70, diaHigh: 79 }); + }); + + it("replaces the age band on the card and labels it as the user's", () => { + const card = buildVitalTargets({ + ...base, + bpTargetOverride: { sysLow: 110, sysHigh: 135, diaLow: 65, diaHigh: 85 }, + }); + const bp = card.targets.find((t) => t.type === "BLOOD_PRESSURE"); + expect(bp?.range).toEqual({ min: 110, max: 135 }); + expect(bp?.source).toBe("Custom"); + expect(card.bpRange).toEqual({ + sysLow: 110, + sysHigh: 135, + diaLow: 65, + diaHigh: 85, + }); + }); + + it("keeps the ESH age band when none is set", () => { + const bp = buildVitalTargets(base).targets.find( + (t) => t.type === "BLOOD_PRESSURE", + ); + expect(bp?.range).toEqual({ min: 120, max: 129 }); + expect(bp?.source).toBe("ESH 2023"); + }); +}); + +describe("selectBpTargetBand — the chart reads the server's band", () => { + it("projects the systolic card range and the diastolic range", () => { + expect( + selectBpTargetBand({ + targets: [ + { type: "WEIGHT", range: { min: 70, max: 75 } }, + { type: "BLOOD_PRESSURE", range: { min: 110, max: 135 } }, + ], + bpDiastolic: { range: { min: 65, max: 85 } }, + }), + ).toEqual({ + systolic: { min: 110, max: 135 }, + diastolic: { min: 65, max: 85 }, + }); + }); + + it("draws no band when the server resolved none", () => { + expect(selectBpTargetBand(null)).toBeNull(); + expect( + selectBpTargetBand({ + targets: [{ type: "BLOOD_PRESSURE", range: null }], + bpDiastolic: { range: null }, + }), + ).toBeNull(); + }); +}); diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts index 6addbc725..7421b13b2 100644 --- a/src/lib/targets/build-response.ts +++ b/src/lib/targets/build-response.ts @@ -17,6 +17,7 @@ import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import pLimit from "p-limit"; import type { MeasurementType } from "@/generated/prisma/client"; import { + resolveBpTargetOverride, resolveWeightTargetOverride, type ThresholdOverridesJson, } from "@/lib/analytics/effective-range"; @@ -277,6 +278,17 @@ export async function buildTargetsResponse(user: AuthedUser) { // set one, so the reference panel and the weight chart above it name the // same band. weightTargetOverride: resolveWeightTargetOverride(dbUser?.thresholdsJson), + // The blood-pressure card likewise names the user's own band when they + // set one. The insights page draws its chart zones from this same range, + // so the chart and the card below it cannot disagree. + bpTargetOverride: resolveBpTargetOverride( + { + heightCm, + dateOfBirth: dbUser?.dateOfBirth ?? null, + gender: dbUser?.gender ?? null, + }, + dbUser?.thresholdsJson, + ), }); const sleepTarget = buildSleepTarget({ sleepStageRows, diff --git a/src/lib/targets/vitals-builder.ts b/src/lib/targets/vitals-builder.ts index 05c6eccaf..58a4ebfd1 100644 --- a/src/lib/targets/vitals-builder.ts +++ b/src/lib/targets/vitals-builder.ts @@ -54,6 +54,15 @@ interface VitalTargetsInput { * always treated a user override. `null` keeps the height-derived band. */ weightTargetOverride: { min: number; max: number } | null; + /** + * The user's own blood-pressure band from `User.thresholdsJson` + * (`resolveBpTargetOverride`). When present it replaces the age-based ESH + * band on the card, its in-target share and its seven-day strip, and the + * source reads "Custom" — the same rule as the weight override above. The + * ESH classification of the latest reading stays guideline-derived: it + * names a category, not a goal. Absent or `null` keeps the age band. + */ + bpTargetOverride?: BloodPressureTargetRange | null; } export interface VitalTargetsResult { @@ -148,6 +157,7 @@ export function buildVitalTargets({ timezone, now, weightTargetOverride, + bpTargetOverride = null, }: VitalTargetsInput): VitalTargetsResult { const targets: TargetItem[] = []; const consistencyClock = { timezone, now }; @@ -191,7 +201,9 @@ export function buildVitalTargets({ }), }); - const bpRange = age != null ? getBpTargetsByAge(age, referenceSex) : null; + const bpRange = + bpTargetOverride ?? + (age != null ? getBpTargetsByAge(age, referenceSex) : null); const bpPairsByDay = buildBpPairsByDay(recentMeasurements, bpRange, timezone); let bpClassification: TargetItem["classification"] = null; if ( @@ -216,7 +228,7 @@ export function buildVitalTargets({ unit: "mmHg", range: bpRange ? { min: bpRange.sysLow, max: bpRange.sysHigh } : null, classification: bpClassification, - source: "ESH 2023", + source: bpTargetOverride ? "Custom" : "ESH 2023", ...rollupFromDayMap({ dayBandByKey: bpPairsByDay, ...consistencyClock,