From aa6e617da0cae49dc303d1762c14934bb4940929 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 21:00:10 +0100 Subject: [PATCH] fix(insights): grade blood pressure and pulse against the own target Someone whose doctor agreed a target of 140/90 entered it on /targets; the chart and the dashboard tile followed it, but the BP and pulse status texts, the general status, the comprehensive overview, the insight-card alert and `extractFeatures` (and through it the comprehensive text, the daily briefing and the Coach) still graded every reading against the age default. The text under the chart could contradict the chart. All six places now resolve the band the way the charts do since #1261: `resolveEffectiveBpTargets` for blood pressure, the PULSE override for resting pulse, the default only where no target is set. The resolved band rides in each snapshot's targets block, which is part of the input hash, so the next generation follows a changed target. The Health Score keeps its clinical targets on purpose. --- src/__tests__/baselines/test-as-never.json | 4 +- .../baselines/version-stamp-comments.json | 6 +- src/app/api/insights/cards/route.ts | 21 +++-- .../__tests__/blood-pressure-status.test.ts | 86 +++++++++++++------ src/lib/insights/__tests__/features.test.ts | 33 +++++++ .../insights/__tests__/pulse-status.test.ts | 28 ++++-- src/lib/insights/blood-pressure-status.ts | 14 ++- .../insights/comprehensive-overview/build.ts | 22 +++-- src/lib/insights/features.ts | 16 ++-- src/lib/insights/general-status.ts | 13 ++- src/lib/insights/pulse-status.ts | 22 +++-- 11 files changed, 197 insertions(+), 68 deletions(-) diff --git a/src/__tests__/baselines/test-as-never.json b/src/__tests__/baselines/test-as-never.json index 9500eeb7d..ef945bea3 100644 --- a/src/__tests__/baselines/test-as-never.json +++ b/src/__tests__/baselines/test-as-never.json @@ -1,7 +1,7 @@ { "$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.", "count": 523, - "total": 5464, + "total": 5459, "entries": { "__tests__/api/auth/login/mfa-branch.test.ts": 10, "__tests__/api/auth/mfa/verify.test.ts": 9, @@ -385,7 +385,7 @@ "lib/import/__tests__/record-import-route.test.ts": 1, "lib/insights/__tests__/background-reasoning-wire.test.ts": 2, "lib/insights/__tests__/biomarker-status.test.ts": 23, - "lib/insights/__tests__/blood-pressure-status.test.ts": 32, + "lib/insights/__tests__/blood-pressure-status.test.ts": 27, "lib/insights/__tests__/bmi-status.test.ts": 21, "lib/insights/__tests__/briefing-provider-budget.test.ts": 2, "lib/insights/__tests__/comprehensive-briefing-reroll.test.ts": 1, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index fe190cc51..be9a1f451 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2889, - "total": 9212, + "total": 9210, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -291,7 +291,7 @@ "app/api/insights/bmi-status/route.ts": 2, "app/api/insights/breathing-screening/route.ts": 2, "app/api/insights/cards/__tests__/route.test.ts": 2, - "app/api/insights/cards/route.ts": 9, + "app/api/insights/cards/route.ts": 8, "app/api/insights/chat/__tests__/dialog-harness.ts": 1, "app/api/insights/chat/__tests__/golden-transcripts.test.ts": 1, "app/api/insights/chat/__tests__/route-activity.test.ts": 1, @@ -2203,7 +2203,7 @@ "lib/insights/exclude-filter.ts": 1, "lib/insights/feature-blocks.ts": 5, "lib/insights/feature-cache.ts": 2, - "lib/insights/features.ts": 34, + "lib/insights/features.ts": 33, "lib/insights/general-status.ts": 5, "lib/insights/glp1-plateau.ts": 2, "lib/insights/graded-series.ts": 3, diff --git a/src/app/api/insights/cards/route.ts b/src/app/api/insights/cards/route.ts index 04779c3ba..87cd8ee77 100644 --- a/src/app/api/insights/cards/route.ts +++ b/src/app/api/insights/cards/route.ts @@ -19,7 +19,7 @@ import { prisma } from "@/lib/db"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; import { readDayMeanSeries } from "@/lib/insights/derived/baseline"; -import { getBpTargets } from "@/lib/analytics/bp-targets"; +import { resolveEffectiveBpTargets } from "@/lib/analytics/effective-range"; import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target"; import { generateAlerts, @@ -72,6 +72,17 @@ interface InsightCard { export const dynamic = "force-dynamic"; +/** + * The profile fields BMI and the BP band read: the person's own BP target + * where one is set, the age default otherwise. + */ +const PROFILE_SELECT = { + heightCm: true, + dateOfBirth: true, + gender: true, + thresholdsJson: true, +} as const; + export const GET = apiHandler(async () => { // v1.37.0 — MANAGE-level read: computed over the whole record, with no // provider anywhere on the path. @@ -85,7 +96,7 @@ export const GET = apiHandler(async () => { await Promise.all([ prisma.user.findUnique({ where: { id: user.id }, - select: { heightCm: true, dateOfBirth: true }, + select: PROFILE_SELECT, }), // WEIGHT + BP are manual entries (low volume, and BP needs exact- // timestamp pairing below for `bpPctInTarget`), so a bounded raw read @@ -168,14 +179,14 @@ export const GET = apiHandler(async () => { } let bpPctInTarget: number | null = null; - const bpTargets = getBpTargets(dbUser?.dateOfBirth ?? null); + const bpTargets = + dbUser && resolveEffectiveBpTargets(dbUser, dbUser.thresholdsJson); if (bpTargets) { const sysData = byType("BLOOD_PRESSURE_SYS"); const diaData = byType("BLOOD_PRESSURE_DIA"); const pairs = pairByTimestamp(sysData, diaData, 5 * 60_000); if (pairs.length > 0) { - // v1.4.16 — one-sided ceiling semantics with hypotension - // floor. See lib/analytics/bp-in-target.ts. + // One-sided ceiling with a hypotension floor (`bp-in-target.ts`). const inTarget = pairs.filter((p) => isBpReadingInTarget(p.a, p.b, bpTargets), ).length; diff --git a/src/lib/insights/__tests__/blood-pressure-status.test.ts b/src/lib/insights/__tests__/blood-pressure-status.test.ts index 43891fcb2..872190a88 100644 --- a/src/lib/insights/__tests__/blood-pressure-status.test.ts +++ b/src/lib/insights/__tests__/blood-pressure-status.test.ts @@ -99,6 +99,28 @@ beforeEach(() => { vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never); }); +/** The record row and the readings one generation reads, nothing else on file. */ +function seed( + records: Array<{ type: string; value: number; measuredAt: Date }>, + user: Record = { dateOfBirth: null }, +) { + vi.mocked(prisma.user.findUnique).mockResolvedValue(user as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); +} + +/** The snapshot the model was handed, parsed out of the user prompt. */ +async function snapshotFor() { + const captured: { userPrompt: string | null } = { userPrompt: null }; + stubCompletion('{"summary":"OK"}', captured); + await generateBloodPressureStatusForUser("user-1", { locale: "en" }); + return JSON.parse(captured.userPrompt!.match(/\{[\s\S]*\}/)![0]); +} + describe("generateBloodPressureStatusForUser — graded payload", () => { it("emits a graded {recent, weekly, monthly} per-metric series, not the full daily array", async () => { const now = new Date(); @@ -121,23 +143,8 @@ describe("generateBloodPressureStatusForUser — graded payload", () => { }); } - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - dateOfBirth: null, - } as never); - vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never); - vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( - [] as never, - ); - vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - - const captured: { userPrompt: string | null } = { userPrompt: null }; - stubCompletion('{"summary":"OK"}', captured); - - await generateBloodPressureStatusForUser("user-1", { locale: "en" }); - - const match = captured.userPrompt!.match(/\{[\s\S]*\}/); - const snapshot = JSON.parse(match![0]); + seed(records); + const snapshot = await snapshotFor(); const sys = snapshot.bloodPressure.systolic.series; expect(sys).toHaveProperty("recent"); @@ -170,18 +177,10 @@ describe("generateBloodPressureStatusForUser — graded payload", () => { describe("generateBloodPressureStatusForUser — timeout/error never persists", () => { it("serves the fallback without writing a cache row on timeout", async () => { - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - dateOfBirth: null, - } as never); - vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + seed([ { type: "BLOOD_PRESSURE_SYS", value: 132, measuredAt: new Date() }, { type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: new Date() }, - ] as never); - vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( - [] as never, - ); - vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + ]); vi.mocked(runStatusCompletion).mockResolvedValue({ kind: "timeout", @@ -346,3 +345,36 @@ describe("generateBloodPressureStatusForUser — no rate without an expectation" expect(names).toContain("Amlodipine"); }); }); + +describe("generateBloodPressureStatusForUser — the person's own target", () => { + const readings = [1, 2, 3].flatMap((daysAgo) => { + const measuredAt = new Date(Date.now() - daysAgo * dayMs); + return [ + { type: "BLOOD_PRESSURE_SYS", value: 135, measuredAt }, + { type: "BLOOD_PRESSURE_DIA", value: 85, measuredAt }, + ]; + }); + + it("hands the model the own target and the share of days inside it", async () => { + seed(readings, { + heightCm: 180, + dateOfBirth: new Date("1980-01-01"), + gender: "MALE", + thresholdsJson: { + BLOOD_PRESSURE_SYS: { min: 110, max: 140 }, + BLOOD_PRESSURE_DIA: { min: 70, max: 90 }, + }, + }); + const targets = (await snapshotFor()).bloodPressure.targets; + expect(targets.systolic).toEqual({ min: 110, max: 140 }); + expect(targets.diastolic).toEqual({ min: 70, max: 90 }); + expect(targets.inTargetPctLast30DailyPoints).toBe(100); + }); + + it("falls back to the age default without one", async () => { + seed(readings, { dateOfBirth: new Date("1980-01-01") }); + const targets = (await snapshotFor()).bloodPressure.targets; + expect(targets.systolic.max).toBeLessThan(135); + expect(targets.inTargetPctLast30DailyPoints).toBe(0); + }); +}); diff --git a/src/lib/insights/__tests__/features.test.ts b/src/lib/insights/__tests__/features.test.ts index 0a1b36456..fbdd0fdd6 100644 --- a/src/lib/insights/__tests__/features.test.ts +++ b/src/lib/insights/__tests__/features.test.ts @@ -422,6 +422,39 @@ describe("extractFeatures — weight target direction (#1006)", () => { }); }); +describe("extractFeatures — blood pressure against the person's own target", () => { + // 135/85 on three days: above the age default's ceiling, inside a target + // the person's doctor agreed (up to 140/90). + const bpRows = [1, 2, 3].flatMap((daysAgo) => { + const measuredAt = new Date(Date.now() - daysAgo * DAY_MS); + return [ + { ...measurementRow("BLOOD_PRESSURE_SYS", 135, daysAgo), measuredAt }, + { ...measurementRow("BLOOD_PRESSURE_DIA", 85, daysAgo), measuredAt }, + ]; + }); + + it("counts readings inside the own target as in target", async () => { + prismaMock.user.findUnique.mockResolvedValue({ + heightCm: 180, + dateOfBirth: new Date("1980-01-01"), + gender: "MALE", + thresholdsJson: { + BLOOD_PRESSURE_SYS: { min: 110, max: 140 }, + BLOOD_PRESSURE_DIA: { min: 70, max: 90 }, + }, + }); + prismaMock.measurement.findMany.mockResolvedValue(bpRows); + const f = await extractFeatures("user-1", false); + expect(f.bloodPressure?.pctInTarget).toBe(100); + }); + + it("grades against the age default when no target is stored", async () => { + prismaMock.measurement.findMany.mockResolvedValue(bpRows); + const f = await extractFeatures("user-1", false); + expect(f.bloodPressure?.pctInTarget).toBe(0); + }); +}); + describe("extractFeatures — v1.22 labs briefing block", () => { it("leaves the labs out of the briefing with the labs module off", async () => { labsModule.on = false; diff --git a/src/lib/insights/__tests__/pulse-status.test.ts b/src/lib/insights/__tests__/pulse-status.test.ts index e593ba9b8..9489df5de 100644 --- a/src/lib/insights/__tests__/pulse-status.test.ts +++ b/src/lib/insights/__tests__/pulse-status.test.ts @@ -152,7 +152,10 @@ describe("generatePulseStatusForUser — graded payload", () => { }); describe("generatePulseStatusForUser — A2 resting-target in-target %", () => { - it("scores RESTING_HEART_RATE against the resting band, ignoring workout PULSE", async () => { + /** A clean resting 72 bpm for 30 days beside a workout-polluted PULSE. */ + async function restingSnapshot( + user: Record = { dateOfBirth: null, gender: null }, + ) { const now = new Date(); // PULSE polluted with a heavy workout burst (would tank the in-target // % if scored against the resting band). @@ -181,10 +184,7 @@ describe("generatePulseStatusForUser — A2 resting-target in-target %", () => { }); } - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - dateOfBirth: null, - gender: null, - } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(user as never); // pulse-status reads PULSE first, then RESTING_HEART_RATE. The latest // 365 PULSE readings reach back less than 30 days here, so the PULSE // read takes the 30-day window as a second query. @@ -199,14 +199,28 @@ describe("generatePulseStatusForUser — A2 resting-target in-target %", () => { stubCompletion('{"summary":"OK"}', captured); await generatePulseStatusForUser("user-resting", { locale: "en" }); + return JSON.parse(captured.userPrompt!.match(/\{[\s\S]*\}/)![0]); + } - const match = captured.userPrompt!.match(/\{[\s\S]*\}/); - const snapshot = JSON.parse(match![0]); + it("scores RESTING_HEART_RATE against the resting band, ignoring workout PULSE", async () => { + const snapshot = await restingSnapshot(); // Resting 72 sits inside the 60-100 band → 100 % in target, NOT the // ~0 % the workout-polluted PULSE stream would have produced. expect(snapshot.pulse.target.inTargetPctLast30DailyPoints).toBe(100); }); + it("scores the resting series against the person's own target where one is set", async () => { + const snapshot = await restingSnapshot({ + heightCm: null, + dateOfBirth: null, + gender: null, + thresholdsJson: { PULSE: { min: 50, max: 65 } }, + }); + expect(snapshot.pulse.target).toMatchObject({ greenMin: 50, greenMax: 65 }); + // Resting 72 is above the own ceiling of 65 on every day. + expect(snapshot.pulse.target.inTargetPctLast30DailyPoints).toBe(0); + }); + it("falls back to a low-percentile PULSE proxy when no resting rows exist", async () => { const now = new Date(); // Each day: mostly resting reads ~72 + a workout burst ~150. The diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index 566e9cc3f..50ba6fc31 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -5,7 +5,8 @@ import { getBloodPressureUserPrompt, } from "@/lib/ai/prompts/blood-pressure"; import { computeSteadyRun } from "@/lib/insights/assessment-context"; -import { getBpTargets } from "@/lib/analytics/bp-targets"; +import type { BpTargets } from "@/lib/analytics/bp-targets"; +import { resolveEffectiveBpTargets } from "@/lib/analytics/effective-range"; import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target"; import { significantPearsonCorrelation, @@ -131,14 +132,21 @@ async function buildBloodPressureSnapshot( const user = await prisma.user.findUnique({ where: { id: userId }, select: { + heightCm: true, dateOfBirth: true, + gender: true, + thresholdsJson: true, }, }); const { measurements, now, weightSeries, sysSeries, diaSeries } = await readBpChannels(userId, userTz); - const bpTargets = getBpTargets(user?.dateOfBirth ?? null); + // The person's own target where one is set, the age default otherwise: + // the same band the chart and the dashboard tile paint. + const bpTargets = user + ? resolveEffectiveBpTargets(user, user.thresholdsJson) + : null; const { pairedBloodPressure, bpInTargetPctLast30DailyPoints } = pairBloodPressure(sysSeries.daily, diaSeries.daily, bpTargets, now, userTz); @@ -387,7 +395,7 @@ async function readBpChannels(userId: string, userTz: string) { function pairBloodPressure( sysDaily: DailyBucket[], diaDaily: DailyBucket[], - bpTargets: ReturnType, + bpTargets: BpTargets | null, now: Date, userTz: string, ) { diff --git a/src/lib/insights/comprehensive-overview/build.ts b/src/lib/insights/comprehensive-overview/build.ts index a96e8b751..212cba021 100644 --- a/src/lib/insights/comprehensive-overview/build.ts +++ b/src/lib/insights/comprehensive-overview/build.ts @@ -14,7 +14,7 @@ import { prisma } from "@/lib/db"; import { probeProviderPresence } from "@/lib/ai/provider"; import type { DataSummary } from "@/lib/analytics/trends"; import { summarize } from "@/lib/analytics/trends"; -import { getBpTargets } from "@/lib/analytics/bp-targets"; +import { resolveEffectiveBpTargets } from "@/lib/analytics/effective-range"; import { computeBpInTargetPct } from "@/lib/analytics/bp-in-target"; import { classifyBMI, @@ -43,10 +43,18 @@ export type ComprehensiveOverviewBody = Awaited< ReturnType >; -/** BMI and the 30-day BP classification against the record's profile. */ +/** + * BMI, the 30-day BP classification, and the BP band: the person's own target + * where one is set, the age default otherwise. + */ function classifyProfile( summaries: Record, - dbUser: { heightCm: number | null; dateOfBirth: Date | null } | null, + dbUser: { + heightCm: number | null; + dateOfBirth: Date | null; + gender: string | null; + thresholdsJson: unknown; + } | null, ) { let bmi: number | null = null; let bmiClassification = null; @@ -57,7 +65,9 @@ function classifyProfile( } let bpClassification = null; - const bpTargets = getBpTargets(dbUser?.dateOfBirth ?? null); + const bpTargets = dbUser + ? resolveEffectiveBpTargets(dbUser, dbUser.thresholdsJson) + : null; if ( summaries.BLOOD_PRESSURE_SYS?.avg30 && summaries.BLOOD_PRESSURE_DIA?.avg30 @@ -75,12 +85,14 @@ export async function buildComprehensiveResponse(user: OverviewRecord) { const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); const userTz = user.timezone || DEFAULT_TIMEZONE; - // Height + DOB drive BMI + BP targets. + // Height drives BMI; the BP band is the own target or the age default. const dbUser = await prisma.user.findUnique({ where: { id: userId }, select: { heightCm: true, dateOfBirth: true, + gender: true, + thresholdsJson: true, }, }); diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 2ac921ee6..83d0dd81d 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -32,7 +32,6 @@ import { type SleepStageRow, } from "@/lib/analytics/sleep-night"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; -import { getBpTargets } from "@/lib/analytics/bp-targets"; import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target"; import { READING_SPREAD } from "@/lib/stats/spread"; import { @@ -65,7 +64,10 @@ import type { RollupGranularity, } from "@/generated/prisma/client"; -import { resolveWeightTargetOverride } from "@/lib/analytics/effective-range"; +import { + resolveEffectiveBpTargets, + resolveWeightTargetOverride, +} from "@/lib/analytics/effective-range"; import { buildWeightTargetFeature, type WeightTargetFeature, @@ -1174,7 +1176,8 @@ export async function extractFeatures( ? await resolveUserTimezone(userId) : DEFAULT_TIMEZONE; - const bpTargets = getBpTargets(user?.dateOfBirth ?? null); + const bpTargets = + user && resolveEffectiveBpTargets(user, user.thresholdsJson); // Compute overall data span const oldestMeasurement = @@ -1287,11 +1290,8 @@ export async function extractFeatures( const sysVal = sysByTime.get(dia.measuredAt.getTime()); if (sysVal === undefined) continue; pairedCount++; - // v1.4.16 — one-sided ceiling semantics with hypotension - // floor. See lib/analytics/bp-in-target.ts. - if (isBpReadingInTarget(sysVal, dia.value, bpTargets)) { - inTargetCount++; - } + // One-sided ceiling with a hypotension floor (`bp-in-target.ts`). + if (isBpReadingInTarget(sysVal, dia.value, bpTargets)) inTargetCount++; } pctInTarget = pairedCount > 0 diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index 2857bcb24..0a585e720 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -5,7 +5,8 @@ import { getGeneralStatusSystemPrompt, getGeneralStatusUserPrompt, } from "@/lib/ai/prompts/general-status"; -import { getBpTargets } from "@/lib/analytics/bp-targets"; +import type { BpTargets } from "@/lib/analytics/bp-targets"; +import { resolveEffectiveBpTargets } from "@/lib/analytics/effective-range"; import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target"; import { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks"; import { measurementTypeEnum } from "@/lib/validations/measurement"; @@ -71,7 +72,10 @@ async function buildGeneralSnapshot( const user = await prisma.user.findUnique({ where: { id: userId }, select: { + heightCm: true, dateOfBirth: true, + gender: true, + thresholdsJson: true, }, }); @@ -148,7 +152,10 @@ async function buildGeneralSnapshot( ); const moodMean = moodSummary?.mean ?? null; - const bpTargets = getBpTargets(user?.dateOfBirth ?? null); + // The person's own target where one is set, the age default otherwise. + const bpTargets = user + ? resolveEffectiveBpTargets(user, user.thresholdsJson) + : null; const bpInTargetLast30Days = bpTargets ? bpInTargetLast30DailyPoints(dailyByType, bpTargets) : null; @@ -314,7 +321,7 @@ async function readAdherenceSeries(userId: string, now: Date, userTz: string) { */ function bpInTargetLast30DailyPoints( dailyByType: ReadonlyMap>, - bpTargets: NonNullable>, + bpTargets: BpTargets, ): number | null { const sysDaily = dailyByType.get("BLOOD_PRESSURE_SYS")?.daily ?? []; const diaMap = new Map( diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index 31ee348d6..fca511739 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -10,6 +10,10 @@ import { getAgeFromDateOfBirth, getPersonalizedPulseTarget, } from "@/lib/analytics/pulse-targets"; +import { + getEffectiveRange, + resolveThresholdOverride, +} from "@/lib/analytics/effective-range"; import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; import { getNoKeyPulseStatusText } from "@/lib/insights/no-key-fallbacks"; import { applyPayloadBudget } from "@/lib/insights/bucket-series"; @@ -73,8 +77,10 @@ async function buildPulseSnapshot( const user = await prisma.user.findUnique({ where: { id: userId }, select: { + heightCm: true, dateOfBirth: true, gender: true, + thresholdsJson: true, }, }); @@ -150,11 +156,17 @@ async function buildPulseSnapshot( ); const moodMean = moodSummary?.mean ?? null; - const pulseAge = getAgeFromDateOfBirth(user?.dateOfBirth ?? null); - const pulseTarget = getPersonalizedPulseTarget( - pulseAge, - binaryReferenceSex(user?.gender), - ); + // The person's own resting-pulse target where one is set, the age and sex + // default otherwise. + const ownPulse = resolveThresholdOverride(user?.thresholdsJson, "PULSE"); + const pulseTarget = + (user && ownPulse + ? getEffectiveRange("PULSE", user, { PULSE: ownPulse }).range + : null) ?? + getPersonalizedPulseTarget( + getAgeFromDateOfBirth(user?.dateOfBirth ?? null), + binaryReferenceSex(user?.gender), + ); const inTargetPctLast30DailyPoints = restingInTargetPct({ restingMeasurements,