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",