diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index d95e2a6d3..a8b1b6796 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -331,8 +331,8 @@ "lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 261, "lib/strava/sync.ts :: runStravaSync": 219, "lib/support/report.ts :: collectSupportReportFacts": 332, - "lib/targets/build-response.ts :: buildTargetsResponse": 330, - "lib/targets/vitals-builder.ts :: buildVitalTargets": 324, + "lib/targets/build-response.ts :: buildTargetsResponse": 332, + "lib/targets/vitals-builder.ts :: buildVitalTargets": 326, "lib/telegram-webhook-handlers.ts :: handleCallback": 582, "lib/telegram-webhook-handlers.ts :: handleTextMessage": 322, "lib/withings/sync-activity.ts :: syncUserActivity": 377, diff --git a/src/app/insights/bmi/__tests__/page.test.tsx b/src/app/insights/bmi/__tests__/page.test.tsx index b0f342bc4..9c65f7420 100644 --- a/src/app/insights/bmi/__tests__/page.test.tsx +++ b/src/app/insights/bmi/__tests__/page.test.tsx @@ -23,6 +23,7 @@ const state = { viewerHeight: null as number | null, inSharedRecord: true, recordHeight: 118 as number | null, + bmiRange: { min: 18.5, max: 24.9 } as { min: number; max: number } | null, }; vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ @@ -41,7 +42,10 @@ vi.mock("@tanstack/react-query", () => ({ data: enabled === false ? undefined - : { profile: { heightCm: state.recordHeight } }, + : { + targets: [{ type: "BMI", range: state.bmiRange }], + profile: { heightCm: state.recordHeight }, + }, }), })); @@ -61,6 +65,7 @@ describe("/insights/bmi height gate", () => { state.viewerHeight = null; state.inSharedRecord = true; state.recordHeight = 118; + state.bmiRange = { min: 18.5, max: 24.9 }; }); it("reads the record's height inside somebody else's record", () => { @@ -77,4 +82,20 @@ describe("/insights/bmi height gate", () => { state.viewerHeight = null; expect(asksForHeight()).toBe(true); }); + + it("draws the adult bands only where the record's BMI range is named", () => { + const chartBands = () => { + renderToStaticMarkup(); + const { children } = shellProps.mock.calls.at(-1)![0] as { + children: Array<{ props?: { valueBands?: unknown } }>; + }; + return children.find( + (child) => child?.props && "valueBands" in child.props, + )?.props?.valueBands; + }; + expect(chartBands()).toBeDefined(); + // A child's record: the server names no BMI range. + state.bmiRange = null; + expect(chartBands()).toBeUndefined(); + }); }); diff --git a/src/app/insights/bmi/page.tsx b/src/app/insights/bmi/page.tsx index cc6db7390..04dc43c69 100644 --- a/src/app/insights/bmi/page.tsx +++ b/src/app/insights/bmi/page.tsx @@ -56,15 +56,21 @@ export default function InsightsBmiPage() { const { data: targets } = useQuery({ queryKey: queryKeys.insightsTargets(), queryFn: () => - apiGet<{ profile?: { heightCm: number | null } }>( - "/api/insights/targets", - ), - enabled: isAuthenticated && inSharedRecord, + apiGet<{ + targets: ReadonlyArray<{ type: string; range: unknown }>; + profile?: { heightCm: number | null }; + }>("/api/insights/targets"), + enabled: isAuthenticated, }); const heightCm = inSharedRecord ? (targets?.profile?.heightCm ?? null) : (user?.heightCm ?? null); const heightKnown = !inSharedRecord || targets !== undefined; + // The adult bands only where the server names the BMI range: it names none + // for a child's record, which they do not describe. + const bmiBands = targets?.targets.find((entry) => entry.type === "BMI")?.range + ? BMI_BANDS + : undefined; // v1.4.27 — BMI is derived from WEIGHT. When no weight readings // exist yet, the existing "set your height" branch can never compute @@ -140,7 +146,7 @@ export default function InsightsBmiPage() { colors={["var(--chart-5)"]} unit="kg/m²" valueMode="bmi" - valueBands={BMI_BANDS} + valueBands={bmiBands} compareBaseline={compareBaseline} userTimezone={user?.timezone} showDataTable diff --git a/src/app/insights/weight/__tests__/weight-band.test.ts b/src/app/insights/weight/__tests__/weight-band.test.ts index 0a4da57a5..a716b6a27 100644 --- a/src/app/insights/weight/__tests__/weight-band.test.ts +++ b/src/app/insights/weight/__tests__/weight-band.test.ts @@ -12,11 +12,22 @@ describe("selectWeightBandRange", () => { it("builds the WHO band from the record's height", () => { const range = selectWeightBandRange({ targets: [ - { type: "WEIGHT", range: { min: 25.8, max: 34.7 }, source: "WHO BMI" }, + { type: "WEIGHT", range: { min: 59.9, max: 81 }, source: "WHO BMI" }, ], - profile: { heightCm: 118 }, + profile: { heightCm: 180 }, }); - expect(range).toEqual(buildWeightRangeFromHeight(118)); + expect(range).toEqual(buildWeightRangeFromHeight(180)); + }); + + it("paints no adult band behind a child's weights", () => { + // A 118 cm child: the server names no WHO range for a record under 18, + // where the adult band read a normal 21 kg as underweight. + expect( + selectWeightBandRange({ + targets: [{ type: "WEIGHT", range: null, source: "WHO BMI" }], + profile: { heightCm: 118 }, + }), + ).toBeNull(); }); it("paints the record's own target with the override wings", () => { diff --git a/src/app/insights/weight/weight-band.ts b/src/app/insights/weight/weight-band.ts index 0c645f8c6..2f0756a0d 100644 --- a/src/app/insights/weight/weight-band.ts +++ b/src/app/insights/weight/weight-band.ts @@ -24,14 +24,16 @@ export interface WeightTargetsSlice { * record's own target and height, and the reference panel under the chart * reads the same payload. The record's own target (`source: "Custom"`) gets * the wings `getEffectiveRange` gives every override; otherwise the band is - * the height-derived WHO one, and with neither there is none. + * the height-derived WHO one. Where the server names no range there is none: + * no target and no height, or a child's record, which the adult WHO band does + * not describe. */ export function selectWeightBandRange( data: WeightTargetsSlice | null, ): TrafficRange | null { - if (!data) return null; - const weight = data.targets.find((entry) => entry.type === "WEIGHT"); - if (weight?.source === "Custom" && weight.range) { + const weight = data?.targets.find((entry) => entry.type === "WEIGHT"); + if (!data || !weight?.range) return null; + if (weight.source === "Custom") { return getEffectiveRange( "WEIGHT", { heightCm: null, dateOfBirth: null, gender: null }, diff --git a/src/lib/targets/__tests__/builders.test.ts b/src/lib/targets/__tests__/builders.test.ts index 9a765e531..9c7a2fcef 100644 --- a/src/lib/targets/__tests__/builders.test.ts +++ b/src/lib/targets/__tests__/builders.test.ts @@ -274,3 +274,50 @@ describe("buildVitalTargets — user weight target", () => { expect(weight?.source).toBe("Custom"); }); }); + +describe("buildVitalTargets — a child's record", () => { + // 118 cm and 21 kg: a BMI of about 15, normal for a six-year-old and red + // under the adult cut-offs. + const child = { + recentMeasurements: [], + restingPulseProxy: [], + latestByType: { WEIGHT: 21 }, + average30ByType: { WEIGHT: 21 }, + heightCm: 118, + age: 6, + gender: null, + timezone: TZ, + now: NOW, + weightTargetOverride: null, + adultBands: false, + }; + const card = ( + targets: ReturnType["targets"], + type: string, + ) => targets.find((t) => t.type === type); + + it("keeps the values and names no adult band or category", () => { + const { targets } = buildVitalTargets(child); + expect(card(targets, "WEIGHT")).toMatchObject({ + current: 21, + range: null, + classification: null, + }); + expect(card(targets, "BMI")).toMatchObject({ + current: 15.1, + range: null, + classification: null, + }); + }); + + it("keeps the record's own weight target", () => { + const { targets } = buildVitalTargets({ + ...child, + weightTargetOverride: { min: 19, max: 24 }, + }); + expect(card(targets, "WEIGHT")).toMatchObject({ + range: { min: 19, max: 24 }, + source: "Custom", + }); + }); +}); diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts index 04928a600..f7f1a2022 100644 --- a/src/lib/targets/build-response.ts +++ b/src/lib/targets/build-response.ts @@ -116,6 +116,7 @@ export async function buildTargetsResponse(user: AuthedUser) { select: { heightCm: true, dateOfBirth: true, + managedProfileAt: true, gender: true, glucoseUnit: true, hasDiabetes: true, @@ -274,6 +275,7 @@ export async function buildTargetsResponse(user: AuthedUser) { average30ByType, heightCm, age, + adultBands: age != null ? age >= 18 : dbUser?.managedProfileAt == null, gender, timezone, now, diff --git a/src/lib/targets/vitals-builder.ts b/src/lib/targets/vitals-builder.ts index f792643b4..9bed9428a 100644 --- a/src/lib/targets/vitals-builder.ts +++ b/src/lib/targets/vitals-builder.ts @@ -74,6 +74,13 @@ interface VitalTargetsInput { * names a category, not a goal. Absent or `null` keeps the age band. */ bpTargetOverride?: BloodPressureTargetRange | null; + /** + * Whether the adult WHO weight and BMI cut-offs apply. No band here knows a + * child's age yet, so for a record under 18, or a managed one with no date + * of birth, the weight card keeps only the record's own target, the BMI + * card names no band, and neither names a category. Absent means they do. + */ + adultBands?: boolean; } export interface VitalTargetsResult { @@ -170,6 +177,7 @@ export function buildVitalTargets({ now, weightTargetOverride, bpTargetOverride = null, + adultBands = true, }: VitalTargetsInput): VitalTargetsResult { const targets: TargetItem[] = []; const consistencyClock = { timezone, now }; @@ -181,10 +189,11 @@ export function buildVitalTargets({ // 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 whoHeightCm = adultBands ? heightCm : null; const weightRange = - weightTargetOverride ?? (heightCm ? getWeightRange(heightCm) : null); + weightTargetOverride ?? (whoHeightCm ? getWeightRange(whoHeightCm) : null); let weightClassification: TargetItem["classification"] = null; - if (latestByType.WEIGHT != null && heightCm) { + if (latestByType.WEIGHT != null && whoHeightCm) { const heightM = heightCm / 100; const classification = classifyBMI( latestByType.WEIGHT / (heightM * heightM), @@ -389,14 +398,14 @@ export function buildVitalTargets({ ? Math.round((averageWeight / heightSquared) * 10) / 10 : null; let bmiClassification: TargetItem["classification"] = null; - if (currentBmi != null) { + if (currentBmi != null && adultBands) { const classification = classifyBMI(currentBmi); bmiClassification = { category: classification.category, color: classification.color, }; } - const bmiRange = { min: 18.5, max: 24.9 }; + const bmiRange = adultBands ? { min: 18.5, max: 24.9 } : null; targets.push({ type: "BMI", label: "BMI",