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).
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 20:00:39 +01:00
1 parent 46b1cd6f34
commit 02856e7f37
10 files changed
+63 -30

No files matched your search

@@ -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"];
@@ -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({
+2 -1
View File
@@ -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;
@@ -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)",
]);
});
});
+5 -1
View File
@@ -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" },
+2 -13
View File
@@ -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.
@@ -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;
+2 -1
View File
@@ -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;
@@ -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);
}
});
+3 -3
View File
@@ -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(