mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge pull request #633 from MBombeck/release/v1.32.30
release/v1.32.30 — height is entered and shown in the unit you use
This commit is contained in:
17 files changed
+854
-40
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ć",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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/*`
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -18,6 +18,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { HeightFieldControl } from "@/components/profile/height-field-control";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import {
|
||||
EMPTY_HEIGHT_DRAFT,
|
||||
resolveHeightUnitAdapter,
|
||||
type HeightDraft,
|
||||
} from "@/lib/profile/height-unit-display";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
@@ -56,14 +63,19 @@ import {
|
||||
|
||||
interface BaselineFormState {
|
||||
displayName: string;
|
||||
heightCm: string;
|
||||
/**
|
||||
* v1.32.30 — the height draft in the user's ENTRY unit (centimetres,
|
||||
* or the feet + inches pair). Converted to canonical centimetres by
|
||||
* the adapter right before the PUT; the wire stays cm.
|
||||
*/
|
||||
height: HeightDraft;
|
||||
dateOfBirth: string;
|
||||
gender: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: BaselineFormState = {
|
||||
displayName: "",
|
||||
heightCm: "",
|
||||
height: EMPTY_HEIGHT_DRAFT,
|
||||
dateOfBirth: "",
|
||||
gender: "",
|
||||
};
|
||||
@@ -72,6 +84,8 @@ export function BaselineForm() {
|
||||
const { t } = useTranslations();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { preference } = useUnitDisplay();
|
||||
const heightAdapter = resolveHeightUnitAdapter(preference);
|
||||
|
||||
const [form, setForm] = useState<BaselineFormState>(EMPTY_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -122,10 +136,8 @@ export function BaselineForm() {
|
||||
const profileBody: Record<string, unknown> = {};
|
||||
if (form.displayName.trim())
|
||||
profileBody.displayName = form.displayName.trim();
|
||||
if (form.heightCm) {
|
||||
const n = Number.parseFloat(form.heightCm);
|
||||
if (Number.isFinite(n)) profileBody.heightCm = n;
|
||||
}
|
||||
const heightCm = heightAdapter.toCanonicalCm(form.height);
|
||||
if (heightCm !== null) profileBody.heightCm = heightCm;
|
||||
if (form.dateOfBirth) profileBody.dateOfBirth = form.dateOfBirth;
|
||||
if (form.gender === "MALE" || form.gender === "FEMALE") {
|
||||
profileBody.gender = form.gender;
|
||||
@@ -187,18 +199,17 @@ export function BaselineForm() {
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FieldGroup
|
||||
htmlFor="ob-baseline-height"
|
||||
label={t("onboarding.baseline.heightLabel")}
|
||||
label={
|
||||
heightAdapter.usesFeetInches
|
||||
? t("onboarding.baseline.heightLabelFtIn")
|
||||
: t("onboarding.baseline.heightLabel")
|
||||
}
|
||||
>
|
||||
<Input
|
||||
id="ob-baseline-height"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={form.heightCm}
|
||||
onChange={(e) => patch("heightCm", e.target.value)}
|
||||
placeholder="175"
|
||||
min={50}
|
||||
max={300}
|
||||
step={0.1}
|
||||
<HeightFieldControl
|
||||
idPrefix="ob-baseline-height"
|
||||
adapter={heightAdapter}
|
||||
value={form.height}
|
||||
onChange={(next) => patch("height", next)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
@@ -0,0 +1,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(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
} finally {
|
||||
unitPreference = "metric";
|
||||
}
|
||||
}
|
||||
|
||||
const SURFACES = [
|
||||
{
|
||||
name: "onboarding baseline step",
|
||||
node: <BaselineForm />,
|
||||
firstInputId: "ob-baseline-height",
|
||||
metricLabel: "Height (cm)",
|
||||
},
|
||||
{
|
||||
name: "Settings profile card",
|
||||
node: <AccountSection />,
|
||||
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</span>");
|
||||
expect(html).toContain(">in</span>");
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.32.30 — the height entry control, shared by the onboarding baseline
|
||||
* step and the Settings profile card.
|
||||
*
|
||||
* The two surfaces own their own label (they sit in different i18n
|
||||
* namespaces and different card shells), but the control below is the
|
||||
* one place that decides what a height field looks like under a
|
||||
* preference. Metric renders the single centimetres box the app has
|
||||
* always rendered; imperial renders the feet + inches pair, because a
|
||||
* single decimal-inches box is not how anyone states a height.
|
||||
*
|
||||
* The first input always carries the bare `idPrefix`, so a surrounding
|
||||
* `<Label htmlFor={idPrefix}>` resolves on both branches. Every limit
|
||||
* comes from the adapter, which rounds them inward against the server's
|
||||
* canonical window — see `src/lib/profile/height-unit-display.ts`.
|
||||
*/
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type {
|
||||
HeightDraft,
|
||||
HeightUnitAdapter,
|
||||
} from "@/lib/profile/height-unit-display";
|
||||
|
||||
interface HeightFieldControlProps {
|
||||
/** Id of the first input; the inches box appends `-inches`. */
|
||||
idPrefix: string;
|
||||
adapter: HeightUnitAdapter;
|
||||
value: HeightDraft;
|
||||
onChange: (next: HeightDraft) => void;
|
||||
autoComplete?: string;
|
||||
enterKeyHint?: "next";
|
||||
}
|
||||
|
||||
export function HeightFieldControl({
|
||||
idPrefix,
|
||||
adapter,
|
||||
value,
|
||||
onChange,
|
||||
autoComplete,
|
||||
enterKeyHint,
|
||||
}: HeightFieldControlProps) {
|
||||
const { t } = useTranslations();
|
||||
const { bounds } = adapter;
|
||||
|
||||
if (!adapter.usesFeetInches) {
|
||||
return (
|
||||
<Input
|
||||
id={idPrefix}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
enterKeyHint={enterKeyHint}
|
||||
value={value.cm}
|
||||
onChange={(e) => onChange({ ...value, cm: e.target.value })}
|
||||
placeholder="175"
|
||||
min={bounds.cm.min}
|
||||
max={bounds.cm.max}
|
||||
step={0.1}
|
||||
autoComplete={autoComplete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id={idPrefix}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
enterKeyHint={enterKeyHint}
|
||||
value={value.feet}
|
||||
onChange={(e) => onChange({ ...value, feet: e.target.value })}
|
||||
placeholder="5"
|
||||
min={bounds.feet.min}
|
||||
max={bounds.feet.max}
|
||||
step={1}
|
||||
autoComplete={autoComplete}
|
||||
aria-label={t("common.heightFeet")}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground shrink-0 text-xs"
|
||||
>
|
||||
{t("common.feet")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id={`${idPrefix}-inches`}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
enterKeyHint={enterKeyHint}
|
||||
value={value.inches}
|
||||
onChange={(e) => onChange({ ...value, inches: e.target.value })}
|
||||
placeholder="9"
|
||||
min={bounds.inches.min}
|
||||
max={bounds.inches.max}
|
||||
step={1}
|
||||
autoComplete={autoComplete}
|
||||
aria-label={t("common.heightInches")}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground shrink-0 text-xs"
|
||||
>
|
||||
{t("common.inches")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,8 +15,15 @@ import { DateField } from "@/components/ui/date-field";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { HeightFieldControl } from "@/components/profile/height-field-control";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import {
|
||||
EMPTY_HEIGHT_DRAFT,
|
||||
resolveHeightUnitAdapter,
|
||||
type HeightDraft,
|
||||
} from "@/lib/profile/height-unit-display";
|
||||
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { AboutMeSection } from "@/components/settings/about-me-section";
|
||||
@@ -46,6 +53,11 @@ export function AccountSection() {
|
||||
// the SSR HTML when this boundary hydrates after `/api/auth/me`
|
||||
// settled (React #418 family).
|
||||
const mounted = useMounted();
|
||||
// v1.32.30 — height follows the metric/imperial preference like every
|
||||
// other value the app shows. Storage stays canonical centimetres; the
|
||||
// adapter owns both directions of the conversion.
|
||||
const { preference } = useUnitDisplay();
|
||||
const heightAdapter = resolveHeightUnitAdapter(preference);
|
||||
const router = useRouter();
|
||||
const navigate = useCallback(
|
||||
(target: string) => router.push(target),
|
||||
@@ -53,7 +65,7 @@ export function AccountSection() {
|
||||
);
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [heightCm, setHeightCm] = useState("");
|
||||
const [height, setHeight] = useState<HeightDraft>(EMPTY_HEIGHT_DRAFT);
|
||||
const [dateOfBirth, setDateOfBirth] = useState("");
|
||||
const [gender, setGender] = useState<string>("");
|
||||
const [timezone, setTimezone] = useState<string>("Europe/Berlin");
|
||||
@@ -63,7 +75,7 @@ export function AccountSection() {
|
||||
const [insuranceNumber, setInsuranceNumber] = useState("");
|
||||
const [profileSeed, setProfileSeed] = useState({
|
||||
email: "",
|
||||
heightCm: "",
|
||||
height: EMPTY_HEIGHT_DRAFT,
|
||||
dateOfBirth: "",
|
||||
gender: "",
|
||||
timezone: "Europe/Berlin",
|
||||
@@ -108,7 +120,7 @@ export function AccountSection() {
|
||||
if (user && user.id !== seededUserId) {
|
||||
const seed = {
|
||||
email: user.email ?? "",
|
||||
heightCm: user.heightCm?.toString() ?? "",
|
||||
height: heightAdapter.toDraft(user.heightCm),
|
||||
dateOfBirth: user.dateOfBirth
|
||||
? new Date(user.dateOfBirth).toISOString().slice(0, 10)
|
||||
: "",
|
||||
@@ -120,7 +132,7 @@ export function AccountSection() {
|
||||
};
|
||||
setSeededUserId(user.id);
|
||||
setEmail(seed.email);
|
||||
setHeightCm(seed.heightCm);
|
||||
setHeight(seed.height);
|
||||
setDateOfBirth(seed.dateOfBirth);
|
||||
setGender(seed.gender);
|
||||
setTimezone(seed.timezone);
|
||||
@@ -130,11 +142,28 @@ export function AccountSection() {
|
||||
setProfileSeed(seed);
|
||||
}
|
||||
|
||||
// The unit select sits on this very card, so the preference can flip
|
||||
// while the form is open. Re-express the height draft through the new
|
||||
// branch (round-tripped via canonical centimetres) instead of
|
||||
// stranding an empty field; every other slot keeps its in-flight edit.
|
||||
// Functional updaters so this composes with the seed block above when
|
||||
// both run in the same render.
|
||||
const [seededPreference, setSeededPreference] = useState(preference);
|
||||
if (preference !== seededPreference) {
|
||||
const previous = resolveHeightUnitAdapter(seededPreference);
|
||||
setSeededPreference(preference);
|
||||
setHeight((prev) => heightAdapter.toDraft(previous.toCanonicalCm(prev)));
|
||||
setProfileSeed((prev) => ({
|
||||
...prev,
|
||||
height: heightAdapter.toDraft(previous.toCanonicalCm(prev.height)),
|
||||
}));
|
||||
}
|
||||
|
||||
const profileDismissal = useSeededFormDismissal({
|
||||
seed: profileSeed,
|
||||
value: {
|
||||
email,
|
||||
heightCm,
|
||||
height,
|
||||
dateOfBirth,
|
||||
gender,
|
||||
timezone,
|
||||
@@ -154,7 +183,7 @@ export function AccountSection() {
|
||||
setSaveMsgType(null);
|
||||
const savedProfile = {
|
||||
email: email.trim(),
|
||||
heightCm,
|
||||
height,
|
||||
dateOfBirth,
|
||||
gender,
|
||||
timezone,
|
||||
@@ -173,9 +202,7 @@ export function AccountSection() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: savedProfile.email || null,
|
||||
heightCm: savedProfile.heightCm
|
||||
? parseFloat(savedProfile.heightCm)
|
||||
: null,
|
||||
heightCm: heightAdapter.toCanonicalCm(savedProfile.height),
|
||||
dateOfBirth: savedProfile.dateOfBirth || null,
|
||||
gender: savedProfile.gender || null,
|
||||
fullName: savedProfile.fullName || null,
|
||||
@@ -355,18 +382,17 @@ export function AccountSection() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="height">{t("settings.height")}</Label>
|
||||
<Input
|
||||
id="height"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
<Label htmlFor="height">
|
||||
{heightAdapter.usesFeetInches
|
||||
? t("settings.heightFtIn")
|
||||
: t("settings.height")}
|
||||
</Label>
|
||||
<HeightFieldControl
|
||||
idPrefix="height"
|
||||
adapter={heightAdapter}
|
||||
value={height}
|
||||
onChange={setHeight}
|
||||
enterKeyHint="next"
|
||||
value={heightCm}
|
||||
onChange={(e) => setHeightCm(e.target.value)}
|
||||
placeholder="175"
|
||||
min={50}
|
||||
max={300}
|
||||
step={0.1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* v1.32.30 — the profile subsystem's height unit adapter.
|
||||
*
|
||||
* v1.32.26 wired the metric/imperial preference across every reading
|
||||
* surface and v1.32.27 across the target/threshold panels, but both
|
||||
* resolve through `display-transform.ts`, which is keyed on
|
||||
* `MeasurementType`. Height is not a measurement: it is the profile
|
||||
* column `User.heightCm`, written through `PUT /api/auth/profile`. That
|
||||
* is why it stayed in centimetres while everything else followed the
|
||||
* preference. This module is the missing adapter.
|
||||
*
|
||||
* Storage and the wire never change. `User.heightCm`, the Zod bound in
|
||||
* `src/lib/validations/auth.ts` (50-300 cm), the OpenAPI contract, the
|
||||
* BMI math, the weight bands, the doctor report, and the AI snapshot all
|
||||
* stay canonical centimetres. The adapter converts at the render
|
||||
* boundary and inverts at the entry boundary, exactly like the target
|
||||
* adapter does for a threshold.
|
||||
*
|
||||
* Imperial entry is feet PLUS inches, not decimal inches, because that
|
||||
* is how a person states their height. The draft therefore carries
|
||||
* three string slots and the active branch decides which ones it fills.
|
||||
*
|
||||
* The three rules that make the round-trip safe:
|
||||
*
|
||||
* 1. The imperial branch quantises to WHOLE inches at the entry
|
||||
* boundary. `n` whole inches is exactly `n * 2.54` cm, a value that
|
||||
* needs at most two decimals, so rounding the canonical result to
|
||||
* two decimals is lossless and `cm / 2.54` lands back on `n`.
|
||||
* 5 ft 11 in becomes 71 in becomes 180.34 cm becomes 71 in again.
|
||||
* 2. Guardrails round INWARD, the same way the target adapter rounds
|
||||
* a threshold bound. Inches always span a full foot, so the FEET
|
||||
* window is the part that shrinks: its floor is ceiled and its
|
||||
* ceiling is floored against the worst case of 11 extra inches.
|
||||
* Every feet/inches pair the fields offer therefore inverts to a
|
||||
* centimetre value inside the window the server enforces.
|
||||
* 3. The metric branch is the exact identity. The draft seeds from
|
||||
* `String(cm)` and returns `Number.parseFloat` of what was typed,
|
||||
* with no rounding and no re-derivation, so a metric account's
|
||||
* stored height is bit-for-bit unchanged by this release.
|
||||
*
|
||||
* A height first entered in centimetres and then read on the imperial
|
||||
* branch snaps to the nearest whole inch (180 cm reads as 5 ft 11 in)
|
||||
* and re-canonicalises to 180.34 cm on the next save. That single snap
|
||||
* is inherent to changing the entry granularity; every save after it is
|
||||
* stable.
|
||||
*/
|
||||
import type { UnitPreference } from "@/lib/measurements/display-transform";
|
||||
|
||||
/** Canonical guardrails - mirrors the Zod bound the server enforces. */
|
||||
const MIN_CM = 50;
|
||||
const MAX_CM = 300;
|
||||
|
||||
const CM_PER_INCH = 2.54;
|
||||
const INCHES_PER_FOOT = 12;
|
||||
|
||||
/** Decimals a converted height persists with (the entry-form dialect). */
|
||||
const CANONICAL_DECIMALS = 2;
|
||||
|
||||
/** Inward-rounded imperial equivalents of the canonical window. */
|
||||
const MIN_TOTAL_INCHES = Math.ceil(MIN_CM / CM_PER_INCH);
|
||||
const MAX_TOTAL_INCHES = Math.floor(MAX_CM / CM_PER_INCH);
|
||||
|
||||
/**
|
||||
* The feet window, shrunk so that every inches value it can be paired
|
||||
* with still lands inside the canonical window: the floor assumes 0
|
||||
* extra inches, the ceiling assumes the full 11.
|
||||
*/
|
||||
const MIN_FEET = Math.ceil(MIN_TOTAL_INCHES / INCHES_PER_FOOT);
|
||||
const MAX_FEET = Math.floor(
|
||||
(MAX_TOTAL_INCHES - (INCHES_PER_FOOT - 1)) / INCHES_PER_FOOT,
|
||||
);
|
||||
|
||||
/**
|
||||
* The three entry slots a height form holds. Both branches keep the
|
||||
* same shape so flipping the preference never reshapes form state and
|
||||
* the seeded-form dirty check stays comparable. The inactive branch's
|
||||
* slots stay "".
|
||||
*/
|
||||
export interface HeightDraft {
|
||||
/** Centimetres, as typed. Metric branch only. */
|
||||
cm: string;
|
||||
/** Whole feet, as typed. Imperial branch only. */
|
||||
feet: string;
|
||||
/** Inches within the foot, as typed. Imperial branch only. */
|
||||
inches: string;
|
||||
}
|
||||
|
||||
/** The blank draft - no height set, on either branch. */
|
||||
export const EMPTY_HEIGHT_DRAFT: HeightDraft = { cm: "", feet: "", inches: "" };
|
||||
|
||||
interface NumericBounds {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side input limits in the ENTRY unit. Shared by both branches
|
||||
* because a form only renders the slots its branch uses.
|
||||
*/
|
||||
const ENTRY_BOUNDS = {
|
||||
cm: { min: MIN_CM, max: MAX_CM },
|
||||
feet: { min: MIN_FEET, max: MAX_FEET },
|
||||
inches: { min: 0, max: INCHES_PER_FOOT - 1 },
|
||||
} as const satisfies Record<string, NumericBounds>;
|
||||
|
||||
export interface HeightUnitAdapter {
|
||||
/** The resolved preference this adapter was built for. */
|
||||
preference: UnitPreference;
|
||||
/** True when the surface renders the two-part feet + inches row. */
|
||||
usesFeetInches: boolean;
|
||||
/** Stored centimetres to the draft the entry fields seed from. */
|
||||
toDraft(cm: number | null | undefined): HeightDraft;
|
||||
/**
|
||||
* Entry draft to the canonical centimetres the API receives. Returns
|
||||
* null for a blank or unparseable draft, which is the "no height"
|
||||
* value the profile route accepts.
|
||||
*/
|
||||
toCanonicalCm(draft: HeightDraft): number | null;
|
||||
/** Input limits for the slots this branch renders. */
|
||||
bounds: typeof ENTRY_BOUNDS;
|
||||
}
|
||||
|
||||
function roundTo(value: number, decimals: number): number {
|
||||
const scale = 10 ** decimals;
|
||||
return Math.round(value * scale) / scale;
|
||||
}
|
||||
|
||||
/** Whole inches to canonical centimetres. */
|
||||
function totalInchesToCm(totalInches: number): number {
|
||||
return roundTo(totalInches * CM_PER_INCH, CANONICAL_DECIMALS);
|
||||
}
|
||||
|
||||
/** Canonical centimetres to the nearest whole inch. */
|
||||
function cmToTotalInches(cm: number): number {
|
||||
return Math.round(cm / CM_PER_INCH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one draft slot. Blank reads as 0 so "5 ft" with an untouched
|
||||
* inches box is 60 in; anything unparseable reads as null so the caller
|
||||
* refuses the whole draft rather than persisting a half-typed number.
|
||||
*/
|
||||
function parseSlot(raw: string): number | null {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === "") return 0;
|
||||
const parsed = Number.parseFloat(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
const METRIC_ADAPTER: HeightUnitAdapter = {
|
||||
preference: "metric",
|
||||
usesFeetInches: false,
|
||||
toDraft: (cm) =>
|
||||
cm == null || !Number.isFinite(cm)
|
||||
? EMPTY_HEIGHT_DRAFT
|
||||
: { cm: String(cm), feet: "", inches: "" },
|
||||
toCanonicalCm: (draft) => {
|
||||
if (draft.cm.trim() === "") return null;
|
||||
const parsed = Number.parseFloat(draft.cm);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
},
|
||||
bounds: ENTRY_BOUNDS,
|
||||
};
|
||||
|
||||
const IMPERIAL_ADAPTER: HeightUnitAdapter = {
|
||||
preference: "imperial",
|
||||
usesFeetInches: true,
|
||||
toDraft: (cm) => {
|
||||
if (cm == null || !Number.isFinite(cm)) return EMPTY_HEIGHT_DRAFT;
|
||||
const totalInches = cmToTotalInches(cm);
|
||||
return {
|
||||
cm: "",
|
||||
feet: String(Math.floor(totalInches / INCHES_PER_FOOT)),
|
||||
inches: String(totalInches % INCHES_PER_FOOT),
|
||||
};
|
||||
},
|
||||
toCanonicalCm: (draft) => {
|
||||
if (draft.feet.trim() === "" && draft.inches.trim() === "") return null;
|
||||
const feet = parseSlot(draft.feet);
|
||||
const inches = parseSlot(draft.inches);
|
||||
if (feet === null || inches === null) return null;
|
||||
// Quantise to whole inches so save-then-reopen returns the typed
|
||||
// feet + inches pair instead of drifting by a fraction (rule 1).
|
||||
return totalInchesToCm(Math.round(feet * INCHES_PER_FOOT + inches));
|
||||
},
|
||||
bounds: ENTRY_BOUNDS,
|
||||
};
|
||||
|
||||
/** Resolve the height entry adapter for the user's unit preference. */
|
||||
export function resolveHeightUnitAdapter(
|
||||
preference: UnitPreference,
|
||||
): HeightUnitAdapter {
|
||||
return preference === "imperial" ? IMPERIAL_ADAPTER : METRIC_ADAPTER;
|
||||
}
|
||||
Reference in new issue
Block a user