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 1/3] 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; +} From 7a0e2cb5b2cc9f24be7e215cec0a1d849a9c0f1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 08:21:51 +0200 Subject: [PATCH 2/3] test(profile): cover the height round-trip, the converted bounds, and both entry surfaces Sweep every feet + inches pair the fields can offer and every whole-inch height across the canonical range, asserting the value comes back unchanged and lands inside the server's window. Assert the metric branch with toBe so a re-derivation of a stored height cannot slip through. Render both entry surfaces under each preference: the metric case keeps the single centimetres box with the canonical guardrails, the imperial case shows the feet + inches pair seeded from the stored value. Extend the structural guard so a surface that drops the adapter, hardcodes a centimetre guardrail, or hardcodes a unit label trips a tripwire. --- .../unit-preference-display-guard.test.ts | 55 +++++ .../__tests__/height-entry-surfaces.test.tsx | 133 +++++++++++ .../__tests__/height-unit-display.test.ts | 219 ++++++++++++++++++ 3 files changed, 407 insertions(+) create mode 100644 src/components/profile/__tests__/height-entry-surfaces.test.tsx create mode 100644 src/lib/profile/__tests__/height-unit-display.test.ts diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts index 0a7c638ec..10e0598d7 100644 --- a/src/__tests__/unit-preference-display-guard.test.ts +++ b/src/__tests__/unit-preference-display-guard.test.ts @@ -46,6 +46,27 @@ const TARGET_SURFACES = [ join(SRC, "components", "settings", "thresholds-editor-section.tsx"), ]; +/** + * v1.32.30 — the profile height surfaces. Height is not a + * `MeasurementType`, so `display-transform.ts` never reached it and it + * stayed in centimetres for two releases after everything else moved. + * These three are the coupled entry unit: the two forms that write + * `User.heightCm` and the control they share. A partial wire here is + * the same failure the target block guards against — a converted field + * over a canonical save silently rewrites the stored height. + */ +const HEIGHT_SURFACES = [ + join(SRC, "components", "onboarding", "baseline-form.tsx"), + join(SRC, "components", "settings", "account-section", "index.tsx"), +]; + +const HEIGHT_CONTROL = join( + SRC, + "components", + "profile", + "height-field-control.tsx", +); + /** * Swept surfaces: the dashboard client, every insights sub-page, and the * target/threshold surfaces. @@ -124,6 +145,40 @@ describe("unit-preference display guard", () => { expect(offenders).toEqual([]); }); + it("every height entry surface resolves through the profile adapter", () => { + // Same tripwire as the target block: drop the hook or the adapter + // from one form and it falls back to centimetres while its sibling + // shows feet and inches. + const offenders: string[] = []; + for (const file of HEIGHT_SURFACES) { + const src = readFileSync(file, "utf8"); + if (!src.includes("useUnitDisplay()")) { + offenders.push(`${file}: no preference hook`); + } + if (!src.includes("resolveHeightUnitAdapter")) { + offenders.push(`${file}: no height adapter`); + } + if (!src.includes("HeightFieldControl")) { + offenders.push(`${file}: hand-rolled height input`); + } + // The canonical centimetre guardrails must come from the + // adapter's inward-rounded bounds, never from a literal. + if (/min=\{50\}|max=\{300\}/.test(src)) { + offenders.push(`${file}: hardcoded centimetre guardrail`); + } + } + expect(offenders).toEqual([]); + }); + + it("the height control announces its units from the bundle", () => { + const src = readFileSync(HEIGHT_CONTROL, "utf8"); + // A quoted "cm" / "ft" / "in" here is a hardcoded unit label; every + // unit string on this control resolves through `t(...)`. + expect(src).not.toMatch(/["'](cm|ft|in)["']/); + expect(src).toContain('t("common.feet")'); + expect(src).toContain('t("common.inches")'); + }); + it("every transformed type carries both a metric and an imperial branch", () => { for (const type of TRANSFORMED_TYPES) { const metric = getDisplayTransform(type, "metric"); diff --git a/src/components/profile/__tests__/height-entry-surfaces.test.tsx b/src/components/profile/__tests__/height-entry-surfaces.test.tsx new file mode 100644 index 000000000..97af6e93a --- /dev/null +++ b/src/components/profile/__tests__/height-entry-surfaces.test.tsx @@ -0,0 +1,133 @@ +/** + * v1.32.30 — the two surfaces that ENTER a height: the onboarding + * baseline step and the Settings profile card. + * + * Vitest runs in the Node environment here (no jsdom), so this asserts + * the SSR markup — which is exactly where the failure this release + * closes lived: an imperial account being handed a box labelled "cm". + * + * Mutation check: swap either surface's label back to the unconditional + * `t("settings.height")` / `t("onboarding.baseline.heightLabel")` and + * the "(ft, in)" assertions go red; drop the adapter and the feet + + * inches inputs disappear with them. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +let unitPreference: "metric" | "imperial" = "metric"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/settings/account", + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: null, isLoading: false, refetch: vi.fn() }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), +})); + +vi.mock("sonner", () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})); + +vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { + id: "u1", + username: "testuser", + email: "user@example.com", + heightCm: 180.34, + dateOfBirth: "1990-01-01", + gender: "MALE", + role: "USER", + unitPreference, + }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { BaselineForm } from "@/components/onboarding/baseline-form"; +import { AccountSection } from "@/components/settings/account-section"; + +function renderUnder( + preference: "metric" | "imperial", + node: React.ReactElement, +): string { + unitPreference = preference; + try { + return renderToStaticMarkup( + {node}, + ); + } finally { + unitPreference = "metric"; + } +} + +const SURFACES = [ + { + name: "onboarding baseline step", + node: , + firstInputId: "ob-baseline-height", + metricLabel: "Height (cm)", + }, + { + name: "Settings profile card", + node: , + firstInputId: "height", + metricLabel: "Height (cm)", + }, +] as const; + +describe.each(SURFACES)("$name — height entry", (surface) => { + it("keeps the single centimetres box for a metric account", () => { + const html = renderUnder("metric", surface.node); + expect(html).toContain(surface.metricLabel); + expect(html).not.toContain("Height (ft, in)"); + expect(html).toContain(`id="${surface.firstInputId}"`); + expect(html).not.toContain(`id="${surface.firstInputId}-inches"`); + // The canonical guardrails, unconverted, on the height box itself. + expect(html).toMatch( + new RegExp( + `id="${surface.firstInputId}"[^>]*min="50"[^>]*max="300"`, + "u", + ), + ); + }); + + it("shows feet + inches for an imperial account", () => { + const html = renderUnder("imperial", surface.node); + expect(html).toContain("Height (ft, in)"); + expect(html).not.toContain(surface.metricLabel); + expect(html).toContain(`id="${surface.firstInputId}"`); + expect(html).toContain(`id="${surface.firstInputId}-inches"`); + expect(html).toContain('aria-label="Height in feet"'); + expect(html).toContain('aria-label="Height in inches"'); + // The unit suffixes come from the bundle, not from a hardcoded + // literal in the surface. + expect(html).toContain(">ft"); + expect(html).toContain(">in"); + // The canonical 50/300 centimetre guardrails must not survive onto + // a feet field. + expect(html).not.toContain('max="300"'); + }); + + it("seeds the imperial fields from the stored centimetres", () => { + const html = renderUnder("imperial", surface.node); + // 180.34 cm is exactly 71 in. + if (surface.firstInputId === "height") { + expect(html).toMatch(/id="height"[^>]*value="5"/u); + expect(html).toMatch(/id="height-inches"[^>]*value="11"/u); + } else { + // The onboarding step starts blank by design — it is a fresh + // profile, not an edit of an existing one. + expect(html).toMatch(/id="ob-baseline-height"[^>]*value=""/u); + } + }); +}); diff --git a/src/lib/profile/__tests__/height-unit-display.test.ts b/src/lib/profile/__tests__/height-unit-display.test.ts new file mode 100644 index 000000000..53d3cbeab --- /dev/null +++ b/src/lib/profile/__tests__/height-unit-display.test.ts @@ -0,0 +1,219 @@ +/** + * v1.32.30 — the height entry adapter. + * + * Two properties carry the whole feature and both are swept, not + * sampled: + * + * 1. Round-trip stability. Every feet + inches pair the imperial + * fields can offer must survive save-then-reopen unchanged, or a + * user's height creeps by a fraction on every visit to Settings. + * 2. Inward-rounded bounds. Every value inside the client's own + * limits must invert to a centimetre value the server's 50-300 + * Zod bound accepts, or an imperial user is handed a field that + * 422s. + * + * The metric branch is asserted with `toBe` (identity, no rounding + * drift), because "we quietly re-derived every metric account's stored + * height" is the expensive way to get this wrong. + */ +import { describe, expect, it } from "vitest"; + +import { + EMPTY_HEIGHT_DRAFT, + resolveHeightUnitAdapter, +} from "@/lib/profile/height-unit-display"; + +/** The canonical window `src/lib/validations/auth.ts` enforces. */ +const MIN_CM = 50; +const MAX_CM = 300; + +const metric = resolveHeightUnitAdapter("metric"); +const imperial = resolveHeightUnitAdapter("imperial"); + +describe("height unit adapter — metric branch", () => { + it("renders a single centimetres field, not the feet + inches pair", () => { + expect(metric.usesFeetInches).toBe(false); + expect(metric.preference).toBe("metric"); + }); + + it("seeds the draft as the exact stored number, unrounded", () => { + for (const cm of [50, 160, 175.5, 180, 180.34, 199.99, 300]) { + expect(metric.toDraft(cm)).toEqual({ + cm: String(cm), + feet: "", + inches: "", + }); + } + }); + + it("returns the typed centimetres untouched (identity, no drift)", () => { + for (const typed of ["50", "160", "175.5", "180", "180.34", "300"]) { + expect(metric.toCanonicalCm({ ...EMPTY_HEIGHT_DRAFT, cm: typed })).toBe( + Number.parseFloat(typed), + ); + } + }); + + it("round-trips every stored centimetre value byte-for-byte", () => { + for (let cm = MIN_CM; cm <= MAX_CM; cm += 0.5) { + expect(metric.toCanonicalCm(metric.toDraft(cm))).toBe(cm); + } + }); + + it("reads a blank or unparseable field as no height", () => { + expect(metric.toCanonicalCm(EMPTY_HEIGHT_DRAFT)).toBeNull(); + expect( + metric.toCanonicalCm({ cm: " ", feet: "", inches: "" }), + ).toBeNull(); + expect( + metric.toCanonicalCm({ cm: "abc", feet: "", inches: "" }), + ).toBeNull(); + expect(metric.toDraft(null)).toEqual(EMPTY_HEIGHT_DRAFT); + expect(metric.toDraft(undefined)).toEqual(EMPTY_HEIGHT_DRAFT); + }); + + it("offers the canonical guardrails unconverted", () => { + expect(metric.bounds.cm).toEqual({ min: MIN_CM, max: MAX_CM }); + }); +}); + +describe("height unit adapter — imperial branch", () => { + it("renders the two-part feet + inches row", () => { + expect(imperial.usesFeetInches).toBe(true); + expect(imperial.preference).toBe("imperial"); + }); + + it("converts the worked example both ways", () => { + const cm = imperial.toCanonicalCm({ cm: "", feet: "5", inches: "11" }); + expect(cm).toBe(180.34); + expect(imperial.toDraft(cm)).toEqual({ cm: "", feet: "5", inches: "11" }); + }); + + it("round-trips EVERY feet + inches pair the fields can offer", () => { + const { feet, inches } = imperial.bounds; + let checked = 0; + for (let ft = feet.min; ft <= feet.max; ft += 1) { + for (let inch = inches.min; inch <= inches.max; inch += 1) { + const draft = { cm: "", feet: String(ft), inches: String(inch) }; + const cm = imperial.toCanonicalCm(draft); + expect(cm).not.toBeNull(); + expect(imperial.toDraft(cm)).toEqual(draft); + checked += 1; + } + } + // A silently empty sweep is a green void. + expect(checked).toBeGreaterThan(60); + }); + + it("round-trips every whole-inch height across the plausible range", () => { + // Independent of the offered rectangle: the full canonical window + // expressed in whole inches, which is what a height synced from a + // wearable can land on. + for (let totalInches = 20; totalInches <= 118; totalInches += 1) { + const draft = { + cm: "", + feet: String(Math.floor(totalInches / 12)), + inches: String(totalInches % 12), + }; + const cm = imperial.toCanonicalCm(draft); + expect(cm).not.toBeNull(); + expect(cm).toBeGreaterThanOrEqual(MIN_CM); + expect(cm).toBeLessThanOrEqual(MAX_CM); + expect(imperial.toDraft(cm)).toEqual(draft); + } + }); + + it("keeps every value inside the offered limits acceptable to the server", () => { + const { feet, inches } = imperial.bounds; + const corners = [ + { feet: feet.min, inches: inches.min }, + { feet: feet.min, inches: inches.max }, + { feet: feet.max, inches: inches.min }, + { feet: feet.max, inches: inches.max }, + ]; + for (const corner of corners) { + const cm = imperial.toCanonicalCm({ + cm: "", + feet: String(corner.feet), + inches: String(corner.inches), + }); + expect(cm).not.toBeNull(); + expect(cm).toBeGreaterThanOrEqual(MIN_CM); + expect(cm).toBeLessThanOrEqual(MAX_CM); + } + }); + + it("rounds the offered window INWARD, never outward", () => { + // The naive rounding: 300 cm is 118.11 in, so a feet ceiling of 9 + // would let 9 ft 11 in (119 in = 302.26 cm) through the client and + // straight into a server 422. + const { feet, inches } = imperial.bounds; + expect(inches).toEqual({ min: 0, max: 11 }); + const justOverTheCeiling = imperial.toCanonicalCm({ + cm: "", + feet: String(feet.max + 1), + inches: "11", + }); + expect(justOverTheCeiling).toBeGreaterThan(MAX_CM); + }); + + it("rejects a genuinely out-of-range height as out of range", () => { + const tooTall = imperial.toCanonicalCm({ + cm: "", + feet: "12", + inches: "0", + }); + expect(tooTall).toBe(365.76); + expect(tooTall).toBeGreaterThan(MAX_CM); + const tooShort = imperial.toCanonicalCm({ cm: "", feet: "1", inches: "0" }); + expect(tooShort).toBeLessThan(MIN_CM); + }); + + it("treats an untouched inches box as zero, not as no height", () => { + expect(imperial.toCanonicalCm({ cm: "", feet: "6", inches: "" })).toBe( + 182.88, + ); + expect(imperial.toCanonicalCm({ cm: "", feet: "", inches: "8" })).toBe( + 20.32, + ); + }); + + it("reads a blank or unparseable pair as no height", () => { + expect(imperial.toCanonicalCm(EMPTY_HEIGHT_DRAFT)).toBeNull(); + expect( + imperial.toCanonicalCm({ cm: "", feet: " ", inches: " " }), + ).toBeNull(); + expect( + imperial.toCanonicalCm({ cm: "", feet: "abc", inches: "3" }), + ).toBeNull(); + expect(imperial.toDraft(null)).toEqual(EMPTY_HEIGHT_DRAFT); + }); + + it("quantises a fractional inch so the round-trip still holds", () => { + const cm = imperial.toCanonicalCm({ cm: "", feet: "5", inches: "11.5" }); + expect(cm).toBe(182.88); // 72 in + expect(imperial.toDraft(cm)).toEqual({ cm: "", feet: "6", inches: "0" }); + expect(imperial.toCanonicalCm(imperial.toDraft(cm))).toBe(cm); + }); + + it("snaps a centimetre-entered height to the nearest whole inch", () => { + // A metric account that flips to imperial: 180 cm reads as 5 ft 11 + // in, and the next save re-canonicalises to 180.34. One snap, then + // stable forever. + expect(imperial.toDraft(180)).toEqual({ cm: "", feet: "5", inches: "11" }); + const resaved = imperial.toCanonicalCm(imperial.toDraft(180)); + expect(resaved).toBe(180.34); + expect(imperial.toDraft(resaved)).toEqual({ + cm: "", + feet: "5", + inches: "11", + }); + }); +}); + +describe("height unit adapter — resolution", () => { + it("falls back to the metric branch for anything but imperial", () => { + expect(resolveHeightUnitAdapter("metric")).toBe(metric); + expect(resolveHeightUnitAdapter("imperial")).toBe(imperial); + }); +}); From 41c2213fe685ef0454135605588cf41b6a93058d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 08:21:57 +0200 Subject: [PATCH 3/3] =?UTF-8?q?chore(release):=20v1.32.30=20=E2=80=94=20he?= =?UTF-8?q?ight=20is=20entered=20and=20shown=20in=20the=20unit=20you=20use?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a980c3a12..a313eb314 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ ## [Unreleased] +## [1.32.30] — 2026-07-25 + +Height was the last value still asked for in centimetres regardless of the +setting. Both previous releases closed with a note saying so. It sat outside +the fix because height is not a measurement at all: it is a field on your +profile, so the conversion the reading surfaces go through never reached it. + +- **Height is entered in the unit you use.** With imperial selected, the + profile card and the onboarding step ask for feet and inches in two boxes, + the way a person actually states a height. With metric selected the single + centimetre box is exactly what it was. +- **The value you type comes back.** Feet and inches are kept to the nearest + whole inch, which converts to centimetres without a remainder, so saving + 5 ft 11 in and reopening the page shows 5 ft 11 in again. The limits the + fields offer are the imperial equivalents of the ones the server enforces, + rounded inward, so a height you are allowed to type is never refused on save. +- **Storage is unchanged.** Height is still kept in centimetres, the API still + sends and receives centimetres, and BMI and the weight bands still work from + that same value. A metric account's stored height is untouched by this + release. +- **Switching the unit keeps what you were typing.** The unit selector sits on + the same card as the height field, so flipping it re-expresses whatever is in + the field rather than clearing it. + +With this the preference reported in #627 reaches every value the app shows you +or asks you for. + ## [1.32.29] — 2026-07-25 Fitbit re-scores a night hours after you wake up: blocks get split, merged or diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0e84cb9ff..8a5654572 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.32.29 + version: 1.32.30 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index ece582df5..ea37d0e0c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.32.29", + "version": "1.32.30", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index ca9eabcfc..88628ec5e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.32.29"; + /* @sw-version-fallback */ "v1.32.30"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`