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,