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; +}