From 02856e7f37585fd05f60117ab7edd2db19ef3576 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:00:39 +0100 Subject: [PATCH] fix(targets): accept a child's weight target below 30 kg A weight target was refused below 30 kg on every save path (the own targets editor and a managed record's settings parse the same schema), while a weight reading is accepted from 1 kg. A guardian setting 20 to 23 kg for a child got only "please check your entries". The target floor is now the measurement floor. The weight bands on the dashboard and the weight page started their low red zone at a hard-coded 30 kg, so a target below it lost that zone; the edge now follows the same floor. Bands are clipped to the chart's own domain, so an adult's chart looks the same. `buildWeightBandsFromHeight` had no caller left and goes; the band builder's 30/250 defaults, which every caller overrode, go with it. The unit-adapter test keeps its inward-rounding check at the new floor (1 kg is 2.2046 lb, offered as 2.3). --- .../display-transform-rounding-guard.test.ts | 5 +-- .../user/thresholds/__tests__/route.test.ts | 2 +- src/app/insights/weight/page.tsx | 3 +- .../__tests__/effective-range.test.ts | 36 +++++++++++++++++++ src/lib/analytics/effective-range.ts | 6 +++- src/lib/analytics/value-bands.ts | 15 ++------ .../dashboard/__tests__/target-bands.test.ts | 7 ++-- src/lib/dashboard/bands.ts | 3 +- .../__tests__/target-unit-display.test.ts | 10 +++--- src/lib/targets/target-unit-display.ts | 6 ++-- 10 files changed, 63 insertions(+), 30 deletions(-) diff --git a/src/__tests__/display-transform-rounding-guard.test.ts b/src/__tests__/display-transform-rounding-guard.test.ts index f6d32f37a..8d0a4a5fd 100644 --- a/src/__tests__/display-transform-rounding-guard.test.ts +++ b/src/__tests__/display-transform-rounding-guard.test.ts @@ -45,8 +45,9 @@ const REGISTRY = "lib/measurements/display-transform.ts"; /** * The one module allowed to import the unrounded escape hatch. The target * adapter needs the unrounded pound edge of a canonical guardrail so it can - * round it INWARD — rounding to the nearest tenth first gives 66.1 lb for the - * 30 kg floor, which inverts to 29.98 kg and the server rejects it. + * round it INWARD — rounding to the nearest tenth first can put the pound + * edge outside the kilogram window, and the server rejects what converts + * back from it. */ const UNROUNDED_IMPORTERS = ["lib/targets/target-unit-display.ts"]; diff --git a/src/app/api/user/thresholds/__tests__/route.test.ts b/src/app/api/user/thresholds/__tests__/route.test.ts index c7865e32f..6a3468a1e 100644 --- a/src/app/api/user/thresholds/__tests__/route.test.ts +++ b/src/app/api/user/thresholds/__tests__/route.test.ts @@ -96,7 +96,7 @@ beforeEach(() => { describe("PUT /api/user/thresholds — 422 multi-issue (v1.4.43 W6)", () => { it("surfaces TWO simultaneous validation errors", async () => { - // WEIGHT bounds are min=30, max=300. Sending min > max + a value + // WEIGHT bounds are min=1, max=300. Sending min > max + a value // out of bounds on PULSE forces two range errors. const res = await PUT( putReq({ diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index b6685072a..75101098f 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -23,6 +23,7 @@ import { MetricCorrelationCard } from "@/components/insights/metric-correlation- import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; import { SubPageShell } from "@/components/insights/sub-page-shell"; +import { METRIC_BOUNDS } from "@/lib/analytics/effective-range"; import { buildBandsFromTrafficRange } from "@/lib/analytics/value-bands"; import { selectWeightBandRange, type WeightTargetsSlice } from "./weight-band"; @@ -131,7 +132,7 @@ export default function InsightsGewichtPage() { const weightRange = selectWeightBandRange(targets ?? null); const weightBands = weightRange ? buildBandsFromTrafficRange(weightRange, { - lowerBound: 30, + lowerBound: METRIC_BOUNDS.WEIGHT.min, upperBound: 250, }) : undefined; diff --git a/src/lib/analytics/__tests__/effective-range.test.ts b/src/lib/analytics/__tests__/effective-range.test.ts index 863f00cd0..2650d4f93 100644 --- a/src/lib/analytics/__tests__/effective-range.test.ts +++ b/src/lib/analytics/__tests__/effective-range.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from "vitest"; +import { thresholdsUpdateSchema } from "@/lib/validations/thresholds"; +import { buildBandsFromTrafficRange } from "@/lib/analytics/value-bands"; import { getEffectiveRange, getAllEffectiveRanges, @@ -313,3 +315,37 @@ describe("resolveWeightTargetOverride", () => { expect(resolveWeightTargetOverride(undefined)).toBeNull(); }); }); + +describe("a child's weight target", () => { + // A guardian keeping a record for a six-year-old sets 20 to 23 kg. The old + // 30 kg floor refused it with nothing but "please check your entries", and + // the chart's hard-coded 30 kg lower edge dropped the low red zone. + const child = { WEIGHT: { min: 20, max: 23 } }; + + it("is accepted by the same schema both save paths parse", () => { + expect(thresholdsUpdateSchema.safeParse(child).success).toBe(true); + expect( + thresholdsUpdateSchema.safeParse({ WEIGHT: { min: 0.5, max: 3 } }) + .success, + ).toBe(false); + }); + + it("paints every band below and above the target", () => { + const range = getEffectiveRange( + "WEIGHT", + { heightCm: 118, dateOfBirth: null, gender: null }, + child, + ).range!; + const bands = buildBandsFromTrafficRange(range, { + lowerBound: METRIC_BOUNDS.WEIGHT.min, + upperBound: 250, + }); + expect(bands.map((band) => band.color)).toEqual([ + "var(--destructive)", + "var(--warning)", + "var(--success)", + "var(--warning)", + "var(--destructive)", + ]); + }); +}); diff --git a/src/lib/analytics/effective-range.ts b/src/lib/analytics/effective-range.ts index 38f02113b..0e00c6a87 100644 --- a/src/lib/analytics/effective-range.ts +++ b/src/lib/analytics/effective-range.ts @@ -15,6 +15,7 @@ * consistently. */ +import { VALUE_RANGES } from "@/lib/measurements/value-ranges"; import { binaryReferenceSex } from "@/lib/profile/sex"; import { getBpTargets, type BpTargets } from "./bp-targets"; import { @@ -65,7 +66,10 @@ export const METRIC_BOUNDS: Record< ThresholdMetric, { min: number; max: number; unit: string } > = { - WEIGHT: { min: 30, max: 300, unit: "kg" }, + // The floor is the measurement floor: a target refused for a weight the + // record can hold (a child's 20 kg under a 30 kg floor) left a guardian + // with nothing but "please check your entries". + WEIGHT: { min: VALUE_RANGES.WEIGHT.min, max: 300, unit: "kg" }, BLOOD_PRESSURE_SYS: { min: 80, max: 220, unit: "mmHg" }, BLOOD_PRESSURE_DIA: { min: 40, max: 140, unit: "mmHg" }, PULSE: { min: 30, max: 220, unit: "bpm" }, diff --git a/src/lib/analytics/value-bands.ts b/src/lib/analytics/value-bands.ts index 4f7cf68db..2d34febd8 100644 --- a/src/lib/analytics/value-bands.ts +++ b/src/lib/analytics/value-bands.ts @@ -100,10 +100,9 @@ export function buildWeightRangeFromHeight(heightCm: number): TrafficRange { */ export function buildBandsFromTrafficRange( range: TrafficRange, - options: { lowerBound?: number; upperBound?: number } = {}, + options: { lowerBound: number; upperBound: number }, ): ValueBand[] { - const lowerBound = options.lowerBound ?? 30; - const upperBound = options.upperBound ?? 250; + const { lowerBound, upperBound } = options; return [ { @@ -139,16 +138,6 @@ export function buildBandsFromTrafficRange( ].filter((band) => band.max > band.min); } -export function buildWeightBandsFromHeight( - heightCm: number, - options: { lowerBound?: number; upperBound?: number } = {}, -): ValueBand[] { - return buildBandsFromTrafficRange( - buildWeightRangeFromHeight(heightCm), - options, - ); -} - /** * Body-fat target range — the "green" band shown on the dashboard / * targets page / chart value-bands. diff --git a/src/lib/dashboard/__tests__/target-bands.test.ts b/src/lib/dashboard/__tests__/target-bands.test.ts index a60b3eb36..1883e175d 100644 --- a/src/lib/dashboard/__tests__/target-bands.test.ts +++ b/src/lib/dashboard/__tests__/target-bands.test.ts @@ -10,10 +10,11 @@ import { describe, it, expect } from "vitest"; import { buildTargetBands } from "../snapshot"; import { getBpTargets } from "@/lib/analytics/bp-targets"; +import { METRIC_BOUNDS } from "@/lib/analytics/effective-range"; import { buildTrafficLightBands, buildTrafficRange, - buildWeightBandsFromHeight, + buildBandsFromTrafficRange, buildWeightRangeFromHeight, getBodyFatTargetRange, } from "@/lib/analytics/value-bands"; @@ -38,8 +39,8 @@ function clientBands(profile: { ? buildWeightRangeFromHeight(profile.heightCm) : null; const weightBands = profile.heightCm - ? buildWeightBandsFromHeight(profile.heightCm, { - lowerBound: 30, + ? buildBandsFromTrafficRange(buildWeightRangeFromHeight(profile.heightCm), { + lowerBound: METRIC_BOUNDS.WEIGHT.min, upperBound: 250, }) : null; diff --git a/src/lib/dashboard/bands.ts b/src/lib/dashboard/bands.ts index 1bfe1649d..5f03dacab 100644 --- a/src/lib/dashboard/bands.ts +++ b/src/lib/dashboard/bands.ts @@ -32,6 +32,7 @@ import { import { binaryReferenceSex, type ProfileSex } from "@/lib/profile/sex"; import { getEffectiveRange, + METRIC_BOUNDS, resolveEffectiveBpTargets, resolveThresholdOverride, type ThresholdMetric, @@ -136,7 +137,7 @@ export function buildDashboardBands(profile: { (profile.heightCm ? buildWeightRangeFromHeight(profile.heightCm) : null); const weightBands = weightRange ? buildBandsFromTrafficRange(weightRange, { - lowerBound: 30, + lowerBound: METRIC_BOUNDS.WEIGHT.min, upperBound: 250, }) : null; diff --git a/src/lib/targets/__tests__/target-unit-display.test.ts b/src/lib/targets/__tests__/target-unit-display.test.ts index 3d2fa1522..590ce8063 100644 --- a/src/lib/targets/__tests__/target-unit-display.test.ts +++ b/src/lib/targets/__tests__/target-unit-display.test.ts @@ -28,7 +28,7 @@ describe("resolveTargetUnitAdapter — metric account (identity)", () => { expect(units.toCanonical(value)).toBe(value); expect(units.toDisplayDelta(value)).toBe(value); } - expect(units.bounds(METRIC_BOUNDS.WEIGHT)).toEqual({ min: 30, max: 300 }); + expect(units.bounds(METRIC_BOUNDS.WEIGHT)).toEqual({ min: 1, max: 300 }); expect(units.step).toBe(0.1); }); @@ -115,9 +115,9 @@ describe("resolveTargetUnitAdapter — imperial weight class", () => { it("rounds guardrails inward so a value typed at the limit still saves", () => { const bounds = units.bounds(METRIC_BOUNDS.WEIGHT); - // A naive round of the 30 kg floor gives 66.1 lb, which inverts to - // 29.98 kg and the server rejects it. The ceiling keeps it legal. - expect(bounds.min).toBe(66.2); + // The 1 kg floor is 2.2046 lb: the floor rounds up to 2.3, never down, + // so a value typed at the limit converts back inside the window. + expect(bounds.min).toBe(2.3); expect(bounds.max).toBe(661.3); expect(units.toCanonical(bounds.min)).toBeGreaterThanOrEqual( METRIC_BOUNDS.WEIGHT.min, @@ -144,7 +144,7 @@ describe("resolveTargetUnitAdapter — imperial weight class", () => { it("still rejects a genuinely out-of-range value", () => { const bounds = units.bounds(METRIC_BOUNDS.WEIGHT); - for (const typed of [12, 66.1, 661.4, 900]) { + for (const typed of [0.5, 2.2, 661.4, 900]) { expect(typed < bounds.min || typed > bounds.max).toBe(true); } }); diff --git a/src/lib/targets/target-unit-display.ts b/src/lib/targets/target-unit-display.ts index 0afb78a7d..e1d3fd6c6 100644 --- a/src/lib/targets/target-unit-display.ts +++ b/src/lib/targets/target-unit-display.ts @@ -27,7 +27,7 @@ * 68.04 kg, not 68.03885550000001. * 3. Rules 1 + 2 compose back to the typed number: 150 lb → 68.04 kg * → 150 lb. Save-then-reopen is stable. - * 4. Guardrails round INWARD (min up, max down). A naive round of the + * 4. Guardrails round INWARD (min up, max down). A naive round of a * 30 kg floor gives 66.1 lb, which inverts to 29.98 kg and the * server rejects it. Ceiling it to 66.2 lb inverts to 30.03 kg and * passes. @@ -205,8 +205,8 @@ export function resolveTargetUnitAdapter( applyDisplayTransformDelta(canonicalDelta, transform), // A guardrail is not a rendered value — it is the window a typed // number is checked against, and it has to round INWARD (rule 4). - // That needs the UNROUNDED edge: the shared helper would round 30 kg - // to 66.1 lb first and the ceiling below would then be a no-op on an + // That needs the UNROUNDED edge: the shared helper would round a 30 kg + // floor to 66.1 lb first and the ceiling below would then be a no-op on an // edge that already inverts to 29.98 kg. bounds: (canonical) => ({ min: ceilTo(