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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-25 08:21:42 +02:00
1 parent d0d880d4ba
commit 1b41126fb3
10 files changed
+417 -37

No files matched your search

+6
View File
@@ -25,6 +25,10 @@
"openTimePicker": "Uhrzeitauswahl öffnen",
"hours": "Stunden",
"minutes": "Minuten",
"feet": "ft",
"inches": "in",
"heightFeet": "Körpergröße in Fuß",
"heightInches": "Körpergröße in Zoll",
"period": "AM/PM",
"am": "AM",
"pm": "PM",
@@ -4247,6 +4251,7 @@
"displayNamePlaceholder": "Wie soll dich HealthLog ansprechen?",
"displayNameHint": "Erscheint im Dashboard-Gruß und in Berichten.",
"heightLabel": "Größe (cm)",
"heightLabelFtIn": "Größe (ft, in)",
"genderLabel": "Geschlecht",
"genderNone": "Keine Angabe",
"genderMale": "Männlich",
@@ -4794,6 +4799,7 @@
},
"username": "Nutzername",
"height": "Körpergröße (cm)",
"heightFtIn": "Körpergröße (ft, in)",
"dateOfBirth": "Geburtsdatum",
"dateOfBirthHint": "Wird für die automatische Berechnung der Blutdruck-Zielwerte benötigt.",
"gender": "Geschlecht",
+6
View File
@@ -25,6 +25,10 @@
"openTimePicker": "Open time picker",
"hours": "Hours",
"minutes": "Minutes",
"feet": "ft",
"inches": "in",
"heightFeet": "Height in feet",
"heightInches": "Height in inches",
"period": "AM/PM",
"am": "AM",
"pm": "PM",
@@ -4237,6 +4241,7 @@
"displayNamePlaceholder": "How should HealthLog address you?",
"displayNameHint": "Shown on the dashboard greeting and in reports.",
"heightLabel": "Height (cm)",
"heightLabelFtIn": "Height (ft, in)",
"genderLabel": "Gender",
"genderNone": "Prefer not to say",
"genderMale": "Male",
@@ -4784,6 +4789,7 @@
},
"username": "Username",
"height": "Height (cm)",
"heightFtIn": "Height (ft, in)",
"dateOfBirth": "Date of birth",
"dateOfBirthHint": "Used for automatic blood pressure target calculations.",
"gender": "Gender",
+6
View File
@@ -25,6 +25,10 @@
"openTimePicker": "Abrir el selector de hora",
"hours": "Horas",
"minutes": "Minutos",
"feet": "ft",
"inches": "in",
"heightFeet": "Altura en pies",
"heightInches": "Altura en pulgadas",
"period": "a. m./p. m.",
"am": "a. m.",
"pm": "p. m.",
@@ -4249,6 +4253,7 @@
"displayNamePlaceholder": "¿Cómo debería llamarte HealthLog?",
"displayNameHint": "Aparece en el saludo del panel y en los informes.",
"heightLabel": "Altura (cm)",
"heightLabelFtIn": "Altura (ft, in)",
"genderLabel": "Sexo",
"genderNone": "Prefiero no decirlo",
"genderMale": "Hombre",
@@ -4796,6 +4801,7 @@
},
"username": "Nombre de usuario",
"height": "Altura (cm)",
"heightFtIn": "Altura (ft, in)",
"dateOfBirth": "Fecha de nacimiento",
"dateOfBirthHint": "Se usa para el cálculo automático de los objetivos de presión arterial.",
"gender": "Sexo",
+6
View File
@@ -25,6 +25,10 @@
"openTimePicker": "Ouvrir le sélecteur d’heure",
"hours": "Heures",
"minutes": "Minutes",
"feet": "ft",
"inches": "in",
"heightFeet": "Taille en pieds",
"heightInches": "Taille en pouces",
"period": "AM/PM",
"am": "AM",
"pm": "PM",
@@ -4251,6 +4255,7 @@
"displayNamePlaceholder": "Comment HealthLog doit-il vous appeler ?",
"displayNameHint": "Apparaît dans le message d'accueil du tableau de bord et dans les rapports.",
"heightLabel": "Taille (cm)",
"heightLabelFtIn": "Taille (ft, in)",
"genderLabel": "Genre",
"genderNone": "Préfère ne pas le préciser",
"genderMale": "Homme",
@@ -4798,6 +4803,7 @@
},
"username": "Nom d'utilisateur",
"height": "Taille (cm)",
"heightFtIn": "Taille (ft, in)",
"dateOfBirth": "Date de naissance",
"dateOfBirthHint": "Utilisé pour le calcul automatique des objectifs de tension artérielle.",
"gender": "Sexe",
+6
View File
@@ -25,6 +25,10 @@
"openTimePicker": "Apri il selettore di orario",
"hours": "Ore",
"minutes": "Minuti",
"feet": "ft",
"inches": "in",
"heightFeet": "Altezza in piedi",
"heightInches": "Altezza in pollici",
"period": "AM/PM",
"am": "AM",
"pm": "PM",
@@ -4241,6 +4245,7 @@
"displayNamePlaceholder": "Come deve chiamarti HealthLog?",
"displayNameHint": "Compare nel saluto della dashboard e nei report.",
"heightLabel": "Altezza (cm)",
"heightLabelFtIn": "Altezza (ft, in)",
"genderLabel": "Sesso",
"genderNone": "Preferisco non specificare",
"genderMale": "Maschile",
@@ -4788,6 +4793,7 @@
},
"username": "Nome utente",
"height": "Altezza (cm)",
"heightFtIn": "Altezza (ft, in)",
"dateOfBirth": "Data di nascita",
"dateOfBirthHint": "Usato per il calcolo automatico degli obiettivi di pressione arteriosa.",
"gender": "Sesso",
+6
View File
@@ -25,6 +25,10 @@
"openTimePicker": "Otwórz wybór godziny",
"hours": "Godziny",
"minutes": "Minuty",
"feet": "ft",
"inches": "in",
"heightFeet": "Wzrost w stopach",
"heightInches": "Wzrost w calach",
"period": "AM/PM",
"am": "AM",
"pm": "PM",
@@ -4235,6 +4239,7 @@
"displayNamePlaceholder": "Jak HealthLog ma się do Ciebie zwracać?",
"displayNameHint": "Pojawia się w powitaniu na pulpicie i w raportach.",
"heightLabel": "Wzrost (cm)",
"heightLabelFtIn": "Wzrost (ft, in)",
"genderLabel": "Płeć",
"genderNone": "Wolę nie podawać",
"genderMale": "Mężczyzna",
@@ -4782,6 +4787,7 @@
},
"username": "Nazwa użytkownika",
"height": "Wzrost (cm)",
"heightFtIn": "Wzrost (ft, in)",
"dateOfBirth": "Data urodzenia",
"dateOfBirthHint": "Używane do automatycznego obliczania docelowych wartości ciśnienia.",
"gender": "Płeć",
+28 -17
View File
@@ -18,6 +18,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { HeightFieldControl } from "@/components/profile/height-field-control";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import {
EMPTY_HEIGHT_DRAFT,
resolveHeightUnitAdapter,
type HeightDraft,
} from "@/lib/profile/height-unit-display";
import { useTranslations } from "@/lib/i18n/context";
import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
@@ -56,14 +63,19 @@ import {
interface BaselineFormState {
displayName: string;
heightCm: string;
/**
* v1.32.30 — the height draft in the user's ENTRY unit (centimetres,
* or the feet + inches pair). Converted to canonical centimetres by
* the adapter right before the PUT; the wire stays cm.
*/
height: HeightDraft;
dateOfBirth: string;
gender: string;
}
const EMPTY_FORM: BaselineFormState = {
displayName: "",
heightCm: "",
height: EMPTY_HEIGHT_DRAFT,
dateOfBirth: "",
gender: "",
};
@@ -72,6 +84,8 @@ export function BaselineForm() {
const { t } = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
const { preference } = useUnitDisplay();
const heightAdapter = resolveHeightUnitAdapter(preference);
const [form, setForm] = useState<BaselineFormState>(EMPTY_FORM);
const [saving, setSaving] = useState(false);
@@ -122,10 +136,8 @@ export function BaselineForm() {
const profileBody: Record<string, unknown> = {};
if (form.displayName.trim())
profileBody.displayName = form.displayName.trim();
if (form.heightCm) {
const n = Number.parseFloat(form.heightCm);
if (Number.isFinite(n)) profileBody.heightCm = n;
}
const heightCm = heightAdapter.toCanonicalCm(form.height);
if (heightCm !== null) profileBody.heightCm = heightCm;
if (form.dateOfBirth) profileBody.dateOfBirth = form.dateOfBirth;
if (form.gender === "MALE" || form.gender === "FEMALE") {
profileBody.gender = form.gender;
@@ -187,18 +199,17 @@ export function BaselineForm() {
<div className="grid gap-4 sm:grid-cols-2">
<FieldGroup
htmlFor="ob-baseline-height"
label={t("onboarding.baseline.heightLabel")}
label={
heightAdapter.usesFeetInches
? t("onboarding.baseline.heightLabelFtIn")
: t("onboarding.baseline.heightLabel")
}
>
<Input
id="ob-baseline-height"
type="number"
inputMode="decimal"
value={form.heightCm}
onChange={(e) => patch("heightCm", e.target.value)}
placeholder="175"
min={50}
max={300}
step={0.1}
<HeightFieldControl
idPrefix="ob-baseline-height"
adapter={heightAdapter}
value={form.height}
onChange={(next) => patch("height", next)}
autoComplete="off"
/>
</FieldGroup>
@@ -0,0 +1,113 @@
"use client";
/**
* v1.32.30 — the height entry control, shared by the onboarding baseline
* step and the Settings profile card.
*
* The two surfaces own their own label (they sit in different i18n
* namespaces and different card shells), but the control below is the
* one place that decides what a height field looks like under a
* preference. Metric renders the single centimetres box the app has
* always rendered; imperial renders the feet + inches pair, because a
* single decimal-inches box is not how anyone states a height.
*
* The first input always carries the bare `idPrefix`, so a surrounding
* `<Label htmlFor={idPrefix}>` resolves on both branches. Every limit
* comes from the adapter, which rounds them inward against the server's
* canonical window — see `src/lib/profile/height-unit-display.ts`.
*/
import { Input } from "@/components/ui/input";
import { useTranslations } from "@/lib/i18n/context";
import type {
HeightDraft,
HeightUnitAdapter,
} from "@/lib/profile/height-unit-display";
interface HeightFieldControlProps {
/** Id of the first input; the inches box appends `-inches`. */
idPrefix: string;
adapter: HeightUnitAdapter;
value: HeightDraft;
onChange: (next: HeightDraft) => void;
autoComplete?: string;
enterKeyHint?: "next";
}
export function HeightFieldControl({
idPrefix,
adapter,
value,
onChange,
autoComplete,
enterKeyHint,
}: HeightFieldControlProps) {
const { t } = useTranslations();
const { bounds } = adapter;
if (!adapter.usesFeetInches) {
return (
<Input
id={idPrefix}
type="number"
inputMode="decimal"
enterKeyHint={enterKeyHint}
value={value.cm}
onChange={(e) => onChange({ ...value, cm: e.target.value })}
placeholder="175"
min={bounds.cm.min}
max={bounds.cm.max}
step={0.1}
autoComplete={autoComplete}
/>
);
}
return (
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-2">
<Input
id={idPrefix}
type="number"
inputMode="numeric"
enterKeyHint={enterKeyHint}
value={value.feet}
onChange={(e) => onChange({ ...value, feet: e.target.value })}
placeholder="5"
min={bounds.feet.min}
max={bounds.feet.max}
step={1}
autoComplete={autoComplete}
aria-label={t("common.heightFeet")}
/>
<span
aria-hidden="true"
className="text-muted-foreground shrink-0 text-xs"
>
{t("common.feet")}
</span>
</div>
<div className="flex items-center gap-2">
<Input
id={`${idPrefix}-inches`}
type="number"
inputMode="numeric"
enterKeyHint={enterKeyHint}
value={value.inches}
onChange={(e) => onChange({ ...value, inches: e.target.value })}
placeholder="9"
min={bounds.inches.min}
max={bounds.inches.max}
step={1}
autoComplete={autoComplete}
aria-label={t("common.heightInches")}
/>
<span
aria-hidden="true"
className="text-muted-foreground shrink-0 text-xs"
>
{t("common.inches")}
</span>
</div>
</div>
);
}
@@ -15,8 +15,15 @@ import { DateField } from "@/components/ui/date-field";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { PasswordInput } from "@/components/ui/password-input";
import { HeightFieldControl } from "@/components/profile/height-field-control";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import {
EMPTY_HEIGHT_DRAFT,
resolveHeightUnitAdapter,
type HeightDraft,
} from "@/lib/profile/height-unit-display";
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
import { useTranslations } from "@/lib/i18n/context";
import { AboutMeSection } from "@/components/settings/about-me-section";
@@ -46,6 +53,11 @@ export function AccountSection() {
// the SSR HTML when this boundary hydrates after `/api/auth/me`
// settled (React #418 family).
const mounted = useMounted();
// v1.32.30 — height follows the metric/imperial preference like every
// other value the app shows. Storage stays canonical centimetres; the
// adapter owns both directions of the conversion.
const { preference } = useUnitDisplay();
const heightAdapter = resolveHeightUnitAdapter(preference);
const router = useRouter();
const navigate = useCallback(
(target: string) => router.push(target),
@@ -53,7 +65,7 @@ export function AccountSection() {
);
const [email, setEmail] = useState("");
const [heightCm, setHeightCm] = useState("");
const [height, setHeight] = useState<HeightDraft>(EMPTY_HEIGHT_DRAFT);
const [dateOfBirth, setDateOfBirth] = useState("");
const [gender, setGender] = useState<string>("");
const [timezone, setTimezone] = useState<string>("Europe/Berlin");
@@ -63,7 +75,7 @@ export function AccountSection() {
const [insuranceNumber, setInsuranceNumber] = useState("");
const [profileSeed, setProfileSeed] = useState({
email: "",
heightCm: "",
height: EMPTY_HEIGHT_DRAFT,
dateOfBirth: "",
gender: "",
timezone: "Europe/Berlin",
@@ -108,7 +120,7 @@ export function AccountSection() {
if (user && user.id !== seededUserId) {
const seed = {
email: user.email ?? "",
heightCm: user.heightCm?.toString() ?? "",
height: heightAdapter.toDraft(user.heightCm),
dateOfBirth: user.dateOfBirth
? new Date(user.dateOfBirth).toISOString().slice(0, 10)
: "",
@@ -120,7 +132,7 @@ export function AccountSection() {
};
setSeededUserId(user.id);
setEmail(seed.email);
setHeightCm(seed.heightCm);
setHeight(seed.height);
setDateOfBirth(seed.dateOfBirth);
setGender(seed.gender);
setTimezone(seed.timezone);
@@ -130,11 +142,28 @@ export function AccountSection() {
setProfileSeed(seed);
}
// The unit select sits on this very card, so the preference can flip
// while the form is open. Re-express the height draft through the new
// branch (round-tripped via canonical centimetres) instead of
// stranding an empty field; every other slot keeps its in-flight edit.
// Functional updaters so this composes with the seed block above when
// both run in the same render.
const [seededPreference, setSeededPreference] = useState(preference);
if (preference !== seededPreference) {
const previous = resolveHeightUnitAdapter(seededPreference);
setSeededPreference(preference);
setHeight((prev) => heightAdapter.toDraft(previous.toCanonicalCm(prev)));
setProfileSeed((prev) => ({
...prev,
height: heightAdapter.toDraft(previous.toCanonicalCm(prev.height)),
}));
}
const profileDismissal = useSeededFormDismissal({
seed: profileSeed,
value: {
email,
heightCm,
height,
dateOfBirth,
gender,
timezone,
@@ -154,7 +183,7 @@ export function AccountSection() {
setSaveMsgType(null);
const savedProfile = {
email: email.trim(),
heightCm,
height,
dateOfBirth,
gender,
timezone,
@@ -173,9 +202,7 @@ export function AccountSection() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: savedProfile.email || null,
heightCm: savedProfile.heightCm
? parseFloat(savedProfile.heightCm)
: null,
heightCm: heightAdapter.toCanonicalCm(savedProfile.height),
dateOfBirth: savedProfile.dateOfBirth || null,
gender: savedProfile.gender || null,
fullName: savedProfile.fullName || null,
@@ -355,18 +382,17 @@ export function AccountSection() {
</p>
</div>
<div className="space-y-2">
<Label htmlFor="height">{t("settings.height")}</Label>
<Input
id="height"
type="number"
inputMode="decimal"
<Label htmlFor="height">
{heightAdapter.usesFeetInches
? t("settings.heightFtIn")
: t("settings.height")}
</Label>
<HeightFieldControl
idPrefix="height"
adapter={heightAdapter}
value={height}
onChange={setHeight}
enterKeyHint="next"
value={heightCm}
onChange={(e) => setHeightCm(e.target.value)}
placeholder="175"
min={50}
max={300}
step={0.1}
/>
</div>
</div>
+194
View File
@@ -0,0 +1,194 @@
/**
* 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;
}