feat(profile): enter and show height in the selected unit

Height is a profile column, not a MeasurementType, so the display
transform that carries the metric/imperial preference across every
reading surface never reached it. Add the adapter that closes the gap.

With imperial selected both entry surfaces render a feet + inches pair
instead of a centimetres box; with metric selected the field is what it
was. The adapter converts to canonical centimetres at the entry
boundary, so storage, the wire, the Zod bound, and every consumer of
the value stay unchanged.

Imperial entry quantises to whole inches, which map to centimetres
without a remainder, so save-then-reopen returns the typed pair. The
offered feet window rounds inward against the canonical 50-300 window
so a height the fields allow is never refused on save.

The unit select shares the Settings card with the height field, so a
flip re-expresses the in-flight draft through the new branch rather
than stranding an empty field.
This commit is contained in:
Marc-André Bombeck committed 2026-07-25 08:21:42 +02:00
1 parent d0d880d4ba
commit 1b41126fb3
10 files changed
+417 -37

No files matched your search

+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,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>