From a7fcd6d249a283a5465a2b2265c58aa266143344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 23:27:14 +0200 Subject: [PATCH] fix(targets): show the weight target the user set, not the WHO band The targets reference panel graded weight against the WHO BMI band even for an account that had entered its own target, so the panel under the weight chart contradicted the chart above it. An override now wins and the card's source reads Custom, the same posture the glucose builder has always taken with a user-pinned band. The BMI classification stays WHO-derived: naming which category a reading falls in is a reference fact, not a goal. --- src/lib/targets/__tests__/builders.test.ts | 37 ++++++++++++++++++++++ src/lib/targets/build-response.ts | 9 +++++- src/lib/targets/vitals-builder.ts | 16 ++++++++-- 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/lib/targets/__tests__/builders.test.ts b/src/lib/targets/__tests__/builders.test.ts index cc59fe3db..efb34f1ff 100644 --- a/src/lib/targets/__tests__/builders.test.ts +++ b/src/lib/targets/__tests__/builders.test.ts @@ -20,6 +20,7 @@ describe("target section builders", () => { gender: null, timezone: TZ, now: NOW, + weightTargetOverride: null, }); expect(result.targets.map((target) => target.type)).toEqual([ @@ -163,3 +164,39 @@ describe("target section builders", () => { }); }); }); + +/** + * v1.34 — the weight card grades against the user's own target when one is set, + * so the `/targets` reference panel and the weight chart above it name the same + * band. The panel used to show the WHO BMI band unconditionally. + */ +describe("buildVitalTargets — user weight target", () => { + const base = { + recentMeasurements: [], + latestByType: {}, + average30ByType: {}, + heightCm: 178, + age: 40, + gender: null, + timezone: TZ, + now: NOW, + }; + + it("shows the WHO BMI band when no target is set", () => { + const weight = buildVitalTargets({ + ...base, + weightTargetOverride: null, + }).targets.find((t) => t.type === "WEIGHT"); + expect(weight?.source).toBe("WHO BMI"); + expect(weight?.range).not.toEqual({ min: 74, max: 78 }); + }); + + it("shows the user's own band, labelled as theirs, when one is set", () => { + const weight = buildVitalTargets({ + ...base, + weightTargetOverride: { min: 74, max: 78 }, + }).targets.find((t) => t.type === "WEIGHT"); + expect(weight?.range).toEqual({ min: 74, max: 78 }); + expect(weight?.source).toBe("Custom"); + }); +}); diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts index 43efa954d..6fd625efe 100644 --- a/src/lib/targets/build-response.ts +++ b/src/lib/targets/build-response.ts @@ -16,7 +16,10 @@ import { SCHEDULE_COMPLIANCE_SELECT } from "@/lib/analytics/compliance"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import pLimit from "p-limit"; import type { MeasurementType } from "@/generated/prisma/client"; -import type { ThresholdOverridesJson } from "@/lib/analytics/effective-range"; +import { + resolveWeightTargetOverride, + type ThresholdOverridesJson, +} from "@/lib/analytics/effective-range"; import { resolveGlucoseUnit } from "@/lib/glucose"; import { toProfileSex } from "@/lib/profile/sex"; import { @@ -264,6 +267,10 @@ export async function buildTargetsResponse(user: AuthedUser) { gender, timezone, now, + // v1.34 — the weight card grades against the user's OWN target when they + // set one, so the reference panel and the weight chart above it name the + // same band. + weightTargetOverride: resolveWeightTargetOverride(dbUser?.thresholdsJson), }); const sleepTarget = buildSleepTarget({ sleepStageRows, diff --git a/src/lib/targets/vitals-builder.ts b/src/lib/targets/vitals-builder.ts index 8dc1feb06..05c6eccaf 100644 --- a/src/lib/targets/vitals-builder.ts +++ b/src/lib/targets/vitals-builder.ts @@ -47,6 +47,13 @@ interface VitalTargetsInput { gender: ProfileSex; timezone: string; now: Date; + /** + * v1.34 — the user's own weight band from `User.thresholdsJson`, in kg. + * When present it replaces the WHO BMI band on the weight card and the + * card's source reads "Custom", matching how the glucose builder has + * always treated a user override. `null` keeps the height-derived band. + */ + weightTargetOverride: { min: number; max: number } | null; } export interface VitalTargetsResult { @@ -140,6 +147,7 @@ export function buildVitalTargets({ gender, timezone, now, + weightTargetOverride, }: VitalTargetsInput): VitalTargetsResult { const targets: TargetItem[] = []; const consistencyClock = { timezone, now }; @@ -148,7 +156,11 @@ export function buildVitalTargets({ // answers a null with a neutral value, never with one of the two sides. const referenceSex = binaryReferenceSex(gender); - const weightRange = heightCm ? getWeightRange(heightCm) : null; + // v1.34 — a user-set target outranks the WHO BMI band. The BMI + // classification below stays WHO-derived either way: it names which BMI + // category a reading falls in, which is a reference fact and not a goal. + const weightRange = + weightTargetOverride ?? (heightCm ? getWeightRange(heightCm) : null); let weightClassification: TargetItem["classification"] = null; if (latestByType.WEIGHT != null && heightCm) { const heightM = heightCm / 100; @@ -169,7 +181,7 @@ export function buildVitalTargets({ unit: "kg", range: weightRange, classification: weightClassification, - source: "WHO BMI", + source: weightTargetOverride ? "Custom" : "WHO BMI", ...rollupConsistency({ events: recentMeasurements.filter( (measurement) => measurement.type === "WEIGHT",