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/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/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/*` 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/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/__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/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/__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); + }); +}); 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; +}