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:
Marc-André Bombeck authored and GitHub committed 2026-07-25 08:38:00 +02:00
commit a1a978f5e6
17 files changed
+854 -40

No files matched your search

+27
View File
@@ -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 -1
View File
@@ -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.
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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");
+28 -17
View File
@@ -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);
});
});
+194
View File
@@ -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;
}