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(