From 1b41126fb3b79611b0e3398bef006cf4ef46c00d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 08:21:42 +0200 Subject: [PATCH] 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. --- messages/de.json | 6 + messages/en.json | 6 + messages/es.json | 6 + messages/fr.json | 6 + messages/it.json | 6 + messages/pl.json | 6 + src/components/onboarding/baseline-form.tsx | 45 ++-- .../profile/height-field-control.tsx | 113 ++++++++++ .../settings/account-section/index.tsx | 66 ++++-- src/lib/profile/height-unit-display.ts | 194 ++++++++++++++++++ 10 files changed, 417 insertions(+), 37 deletions(-) create mode 100644 src/components/profile/height-field-control.tsx create mode 100644 src/lib/profile/height-unit-display.ts diff --git a/messages/de.json b/messages/de.json index 2a1b33f10..b7b6373a8 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index de6e07aa0..eb739414e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 8d6bbbc5c..5799a20cf 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index afa537f60..e2c6159af 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index d29e1ff10..ada7a6ab9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 390c23307..309fb99b2 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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ć", diff --git a/src/components/onboarding/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx index 8a1c4e6fc..86916172e 100644 --- a/src/components/onboarding/baseline-form.tsx +++ b/src/components/onboarding/baseline-form.tsx @@ -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(EMPTY_FORM); const [saving, setSaving] = useState(false); @@ -122,10 +136,8 @@ export function BaselineForm() { const profileBody: Record = {}; 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() {
- patch("heightCm", e.target.value)} - placeholder="175" - min={50} - max={300} - step={0.1} + patch("height", next)} autoComplete="off" /> diff --git a/src/components/profile/height-field-control.tsx b/src/components/profile/height-field-control.tsx new file mode 100644 index 000000000..50313a3f3 --- /dev/null +++ b/src/components/profile/height-field-control.tsx @@ -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 + * `
- - + {heightAdapter.usesFeetInches + ? t("settings.heightFtIn") + : t("settings.height")} + + setHeightCm(e.target.value)} - placeholder="175" - min={50} - max={300} - step={0.1} />
diff --git a/src/lib/profile/height-unit-display.ts b/src/lib/profile/height-unit-display.ts new file mode 100644 index 000000000..ad21c920c --- /dev/null +++ b/src/lib/profile/height-unit-display.ts @@ -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; + +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; +}