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