Files
HealthLog/src/lib/profile/height-unit-display.ts
T
Marc-André Bombeck 1b41126fb3 feat(profile): enter and show height in the selected unit
Height is a profile column, not a MeasurementType, so the display
transform that carries the metric/imperial preference across every
reading surface never reached it. Add the adapter that closes the gap.

With imperial selected both entry surfaces render a feet + inches pair
instead of a centimetres box; with metric selected the field is what it
was. The adapter converts to canonical centimetres at the entry
boundary, so storage, the wire, the Zod bound, and every consumer of
the value stay unchanged.

Imperial entry quantises to whole inches, which map to centimetres
without a remainder, so save-then-reopen returns the typed pair. The
offered feet window rounds inward against the canonical 50-300 window
so a height the fields allow is never refused on save.

The unit select shares the Settings card with the height field, so a
flip re-expresses the in-flight draft through the new branch rather
than stranding an empty field.
2026-07-25 08:21:42 +02:00

195 lines
7.4 KiB
TypeScript

/**
* v1.32.30 — the profile subsystem's height unit adapter.
*
* v1.32.26 wired the metric/imperial preference across every reading
* surface and v1.32.27 across the target/threshold panels, but both
* resolve through `display-transform.ts`, which is keyed on
* `MeasurementType`. Height is not a measurement: it is the profile
* column `User.heightCm`, written through `PUT /api/auth/profile`. That
* is why it stayed in centimetres while everything else followed the
* preference. This module is the missing adapter.
*
* Storage and the wire never change. `User.heightCm`, the Zod bound in
* `src/lib/validations/auth.ts` (50-300 cm), the OpenAPI contract, the
* BMI math, the weight bands, the doctor report, and the AI snapshot all
* stay canonical centimetres. The adapter converts at the render
* boundary and inverts at the entry boundary, exactly like the target
* adapter does for a threshold.
*
* Imperial entry is feet PLUS inches, not decimal inches, because that
* is how a person states their height. The draft therefore carries
* three string slots and the active branch decides which ones it fills.
*
* The three rules that make the round-trip safe:
*
* 1. The imperial branch quantises to WHOLE inches at the entry
* boundary. `n` whole inches is exactly `n * 2.54` cm, a value that
* needs at most two decimals, so rounding the canonical result to
* two decimals is lossless and `cm / 2.54` lands back on `n`.
* 5 ft 11 in becomes 71 in becomes 180.34 cm becomes 71 in again.
* 2. Guardrails round INWARD, the same way the target adapter rounds
* a threshold bound. Inches always span a full foot, so the FEET
* window is the part that shrinks: its floor is ceiled and its
* ceiling is floored against the worst case of 11 extra inches.
* Every feet/inches pair the fields offer therefore inverts to a
* centimetre value inside the window the server enforces.
* 3. The metric branch is the exact identity. The draft seeds from
* `String(cm)` and returns `Number.parseFloat` of what was typed,
* with no rounding and no re-derivation, so a metric account's
* stored height is bit-for-bit unchanged by this release.
*
* A height first entered in centimetres and then read on the imperial
* branch snaps to the nearest whole inch (180 cm reads as 5 ft 11 in)
* and re-canonicalises to 180.34 cm on the next save. That single snap
* is inherent to changing the entry granularity; every save after it is
* stable.
*/
import type { UnitPreference } from "@/lib/measurements/display-transform";
/** Canonical guardrails - mirrors the Zod bound the server enforces. */
const MIN_CM = 50;
const MAX_CM = 300;
const CM_PER_INCH = 2.54;
const INCHES_PER_FOOT = 12;
/** Decimals a converted height persists with (the entry-form dialect). */
const CANONICAL_DECIMALS = 2;
/** Inward-rounded imperial equivalents of the canonical window. */
const MIN_TOTAL_INCHES = Math.ceil(MIN_CM / CM_PER_INCH);
const MAX_TOTAL_INCHES = Math.floor(MAX_CM / CM_PER_INCH);
/**
* The feet window, shrunk so that every inches value it can be paired
* with still lands inside the canonical window: the floor assumes 0
* extra inches, the ceiling assumes the full 11.
*/
const MIN_FEET = Math.ceil(MIN_TOTAL_INCHES / INCHES_PER_FOOT);
const MAX_FEET = Math.floor(
(MAX_TOTAL_INCHES - (INCHES_PER_FOOT - 1)) / INCHES_PER_FOOT,
);
/**
* The three entry slots a height form holds. Both branches keep the
* same shape so flipping the preference never reshapes form state and
* the seeded-form dirty check stays comparable. The inactive branch's
* slots stay "".
*/
export interface HeightDraft {
/** Centimetres, as typed. Metric branch only. */
cm: string;
/** Whole feet, as typed. Imperial branch only. */
feet: string;
/** Inches within the foot, as typed. Imperial branch only. */
inches: string;
}
/** The blank draft - no height set, on either branch. */
export const EMPTY_HEIGHT_DRAFT: HeightDraft = { cm: "", feet: "", inches: "" };
interface NumericBounds {
min: number;
max: number;
}
/**
* Client-side input limits in the ENTRY unit. Shared by both branches
* because a form only renders the slots its branch uses.
*/
const ENTRY_BOUNDS = {
cm: { min: MIN_CM, max: MAX_CM },
feet: { min: MIN_FEET, max: MAX_FEET },
inches: { min: 0, max: INCHES_PER_FOOT - 1 },
} as const satisfies Record<string, NumericBounds>;
export interface HeightUnitAdapter {
/** The resolved preference this adapter was built for. */
preference: UnitPreference;
/** True when the surface renders the two-part feet + inches row. */
usesFeetInches: boolean;
/** Stored centimetres to the draft the entry fields seed from. */
toDraft(cm: number | null | undefined): HeightDraft;
/**
* Entry draft to the canonical centimetres the API receives. Returns
* null for a blank or unparseable draft, which is the "no height"
* value the profile route accepts.
*/
toCanonicalCm(draft: HeightDraft): number | null;
/** Input limits for the slots this branch renders. */
bounds: typeof ENTRY_BOUNDS;
}
function roundTo(value: number, decimals: number): number {
const scale = 10 ** decimals;
return Math.round(value * scale) / scale;
}
/** Whole inches to canonical centimetres. */
function totalInchesToCm(totalInches: number): number {
return roundTo(totalInches * CM_PER_INCH, CANONICAL_DECIMALS);
}
/** Canonical centimetres to the nearest whole inch. */
function cmToTotalInches(cm: number): number {
return Math.round(cm / CM_PER_INCH);
}
/**
* Parse one draft slot. Blank reads as 0 so "5 ft" with an untouched
* inches box is 60 in; anything unparseable reads as null so the caller
* refuses the whole draft rather than persisting a half-typed number.
*/
function parseSlot(raw: string): number | null {
const trimmed = raw.trim();
if (trimmed === "") return 0;
const parsed = Number.parseFloat(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
const METRIC_ADAPTER: HeightUnitAdapter = {
preference: "metric",
usesFeetInches: false,
toDraft: (cm) =>
cm == null || !Number.isFinite(cm)
? EMPTY_HEIGHT_DRAFT
: { cm: String(cm), feet: "", inches: "" },
toCanonicalCm: (draft) => {
if (draft.cm.trim() === "") return null;
const parsed = Number.parseFloat(draft.cm);
return Number.isFinite(parsed) ? parsed : null;
},
bounds: ENTRY_BOUNDS,
};
const IMPERIAL_ADAPTER: HeightUnitAdapter = {
preference: "imperial",
usesFeetInches: true,
toDraft: (cm) => {
if (cm == null || !Number.isFinite(cm)) return EMPTY_HEIGHT_DRAFT;
const totalInches = cmToTotalInches(cm);
return {
cm: "",
feet: String(Math.floor(totalInches / INCHES_PER_FOOT)),
inches: String(totalInches % INCHES_PER_FOOT),
};
},
toCanonicalCm: (draft) => {
if (draft.feet.trim() === "" && draft.inches.trim() === "") return null;
const feet = parseSlot(draft.feet);
const inches = parseSlot(draft.inches);
if (feet === null || inches === null) return null;
// Quantise to whole inches so save-then-reopen returns the typed
// feet + inches pair instead of drifting by a fraction (rule 1).
return totalInchesToCm(Math.round(feet * INCHES_PER_FOOT + inches));
},
bounds: ENTRY_BOUNDS,
};
/** Resolve the height entry adapter for the user's unit preference. */
export function resolveHeightUnitAdapter(
preference: UnitPreference,
): HeightUnitAdapter {
return preference === "imperial" ? IMPERIAL_ADAPTER : METRIC_ADAPTER;
}