diff --git a/e2e/mobile-viewport.spec.ts b/e2e/mobile-viewport.spec.ts index af4b3d224..32deb2de9 100644 --- a/e2e/mobile-viewport.spec.ts +++ b/e2e/mobile-viewport.spec.ts @@ -172,4 +172,50 @@ test.describe("mobile-viewport smoke", () => { `Touch-targets below 44 px tall:\n ${failures.join("\n ")}`, ).toEqual([]); }); + + test("center capture action opens the picker; the More hub keeps Mood + Measurements reachable", async ({ + page, + }) => { + await mockDashboardSnapshot(page, { summaries: WEIGHT_ONLY_SUMMARIES }); + await page.route(/\/api\/analytics(\?|$)/, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { summaries: {}, bpInTargetPct: null, glucoseByContext: {} }, + error: null, + }), + }), + ); + await page.route("**/api/mood/analytics", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { entries: [], summary: { count: 0 } }, + error: null, + }), + }), + ); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle"); + + // 1) The center capture action opens the capture picker. + await page.getByTestId("bottom-nav-capture").click(); + await expect(page.getByTestId("capture-picker-options")).toBeVisible(); + for (const kind of ["measurement", "medication", "mood"]) { + await expect(page.getByTestId(`capture-picker-${kind}`)).toBeVisible(); + } + // Dismiss the picker before opening the hub. + await page.keyboard.press("Escape"); + + // 2) The More hub keeps Mood + Measurements reachable (additive + // middle-path — they left the strip but are NOT orphaned). + await page.getByTestId("bottom-nav-more").click(); + const hub = page.getByTestId("bottom-nav-more-sheet"); + await expect(hub).toBeVisible(); + await expect(hub.locator("a[href='/measurements']")).toBeVisible(); + await expect(hub.locator("a[href='/mood']")).toBeVisible(); + }); }); diff --git a/messages/de.json b/messages/de.json index f64af651f..9e91c6ef3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -197,6 +197,7 @@ "collapseSidebar": "Einklappen", "userMenu": "Benutzermenü", "mood": "Stimmung", + "workouts": "Workouts", "skipToContent": "Zum Inhalt springen", "sidebar": "Sidebar", "mainNavigation": "Hauptnavigation", @@ -205,7 +206,17 @@ "loadingScreen": "Laden…", "more": "Mehr", "moreSheetTitle": "Mehr", - "moreSheetDescription": "Seiten, die nicht in die untere Leiste passen." + "moreSheetDescription": "Zum Rest von HealthLog springen.", + "capture": { + "title": "Erfassen", + "description": "Was möchtest du erfassen?", + "measurement": "Messung", + "measurementDescription": "Gewicht, Blutdruck, Puls und mehr", + "medication": "Medikament", + "medicationDescription": "Eine Dosis als genommen markieren", + "mood": "Stimmung", + "moodDescription": "Wie geht es dir?" + } }, "auth": { "login": "Anmelden", diff --git a/messages/en.json b/messages/en.json index 6f19121c8..65806192e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -197,6 +197,7 @@ "collapseSidebar": "Collapse", "userMenu": "User menu", "mood": "Mood", + "workouts": "Workouts", "skipToContent": "Skip to content", "sidebar": "Sidebar", "mainNavigation": "Main navigation", @@ -205,7 +206,17 @@ "loadingScreen": "Loading…", "more": "More", "moreSheetTitle": "More", - "moreSheetDescription": "Pages that don't fit the bottom bar." + "moreSheetDescription": "Jump to the rest of HealthLog.", + "capture": { + "title": "Log", + "description": "What would you like to record?", + "measurement": "Measurement", + "measurementDescription": "Weight, blood pressure, pulse and more", + "medication": "Medication", + "medicationDescription": "Mark a dose as taken", + "mood": "Mood", + "moodDescription": "How are you feeling?" + } }, "auth": { "login": "Sign in", diff --git a/messages/es.json b/messages/es.json index bfe248f37..01ddba072 100644 --- a/messages/es.json +++ b/messages/es.json @@ -197,6 +197,7 @@ "collapseSidebar": "Contraer", "userMenu": "Menú de usuario", "mood": "Estado de ánimo", + "workouts": "Entrenamientos", "skipToContent": "Saltar al contenido", "sidebar": "Barra lateral", "mainNavigation": "Navegación principal", @@ -205,7 +206,17 @@ "loadingScreen": "Cargando…", "more": "Más", "moreSheetTitle": "Más", - "moreSheetDescription": "Páginas que no caben en la barra inferior." + "moreSheetDescription": "Ve al resto de HealthLog.", + "capture": { + "title": "Registrar", + "description": "¿Qué quieres registrar?", + "measurement": "Medición", + "measurementDescription": "Peso, presión arterial, pulso y más", + "medication": "Medicamento", + "medicationDescription": "Marcar una dosis como tomada", + "mood": "Estado de ánimo", + "moodDescription": "¿Cómo te sientes?" + } }, "auth": { "login": "Iniciar sesión", diff --git a/messages/fr.json b/messages/fr.json index bc28abecf..b51cbe148 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -197,6 +197,7 @@ "collapseSidebar": "Réduire", "userMenu": "Menu utilisateur", "mood": "Humeur", + "workouts": "Entraînements", "skipToContent": "Aller au contenu", "sidebar": "Barre latérale", "mainNavigation": "Navigation principale", @@ -205,7 +206,17 @@ "loadingScreen": "Chargement…", "more": "Plus", "moreSheetTitle": "Plus", - "moreSheetDescription": "Pages qui ne tiennent pas dans la barre du bas." + "moreSheetDescription": "Accédez au reste de HealthLog.", + "capture": { + "title": "Enregistrer", + "description": "Que souhaitez-vous enregistrer ?", + "measurement": "Mesure", + "measurementDescription": "Poids, tension artérielle, pouls et plus", + "medication": "Médicament", + "medicationDescription": "Marquer une dose comme prise", + "mood": "Humeur", + "moodDescription": "Comment vous sentez-vous ?" + } }, "auth": { "login": "Se connecter", diff --git a/messages/it.json b/messages/it.json index 33cb19473..5f845c3b4 100644 --- a/messages/it.json +++ b/messages/it.json @@ -197,6 +197,7 @@ "collapseSidebar": "Riduci", "userMenu": "Menu utente", "mood": "Umore", + "workouts": "Allenamenti", "skipToContent": "Vai al contenuto", "sidebar": "Barra laterale", "mainNavigation": "Navigazione principale", @@ -205,7 +206,17 @@ "loadingScreen": "Caricamento…", "more": "Altro", "moreSheetTitle": "Altro", - "moreSheetDescription": "Pagine che non entrano nella barra inferiore." + "moreSheetDescription": "Vai al resto di HealthLog.", + "capture": { + "title": "Registra", + "description": "Cosa vuoi registrare?", + "measurement": "Misurazione", + "measurementDescription": "Peso, pressione, battito e altro", + "medication": "Farmaco", + "medicationDescription": "Segna una dose come assunta", + "mood": "Umore", + "moodDescription": "Come ti senti?" + } }, "auth": { "login": "Accedi", diff --git a/messages/pl.json b/messages/pl.json index bb254d9ae..f8090a09c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -197,6 +197,7 @@ "collapseSidebar": "Zwiń", "userMenu": "Menu użytkownika", "mood": "Nastrój", + "workouts": "Treningi", "skipToContent": "Przejdź do treści", "sidebar": "Pasek boczny", "mainNavigation": "Nawigacja główna", @@ -205,7 +206,17 @@ "loadingScreen": "Ładowanie…", "more": "Więcej", "moreSheetTitle": "Więcej", - "moreSheetDescription": "Strony, które nie mieszczą się w dolnym pasku." + "moreSheetDescription": "Przejdź do reszty HealthLog.", + "capture": { + "title": "Zapisz", + "description": "Co chcesz zapisać?", + "measurement": "Pomiar", + "measurementDescription": "Waga, ciśnienie, tętno i więcej", + "medication": "Lek", + "medicationDescription": "Oznacz dawkę jako przyjętą", + "mood": "Nastrój", + "moodDescription": "Jak się czujesz?" + } }, "auth": { "login": "Zaloguj się", diff --git a/src/components/layout/__tests__/bottom-nav.test.tsx b/src/components/layout/__tests__/bottom-nav.test.tsx index b161d41c3..3889f6f11 100644 --- a/src/components/layout/__tests__/bottom-nav.test.tsx +++ b/src/components/layout/__tests__/bottom-nav.test.tsx @@ -1,16 +1,18 @@ /** - * v1.4.16 Wave-C MED — bottom-nav 5+More layout. + * Bottom-nav iOS-parity layout (additive middle-path). * * SSR contract for ``: - * - exactly five primary links + one "More" button render in the strip - * - the More button is keyboard-reachable as a ` + + + {PRIMARY_RIGHT.map(renderPrimary)} + + + {t("nav.moreSheetTitle")} {t("nav.moreSheetDescription")} -
- {OVERFLOW.map((item) => { +
+ {MORE_HUB.map((item) => { const isActive = isActiveLink(item.href); return ( ` (the `/measurements` add form) + * - Medication → `` (the dashboard intake + * quick-add) + * - Mood → `` (the `/mood` add form, 5-face flow) + * + * All three forms share the `{ onSuccess, onCancel, footerSlot }` + * contract and are designed to mount inside ``, so the + * picker reuses them verbatim rather than rebuilding any capture UI. + * + * Nothing here orphans a route: the same surfaces remain reachable + * from their own pages (`/measurements`, `/mood`, dashboard) and from + * the More hub. The picker is an additional fast path, not a removal. + */ + +type CaptureKind = "measurement" | "medication" | "mood"; + +interface CapturePickerProps { + /** Whether the picker chooser sheet is open. */ + open: boolean; + /** Open-state setter for the chooser sheet. */ + onOpenChange: (open: boolean) => void; +} + +export function CapturePicker({ open, onOpenChange }: CapturePickerProps) { + const { t } = useTranslations(); + const [kind, setKind] = useState(null); + const [footerEl, setFooterEl] = useState(null); + + function chooseKind(next: CaptureKind) { + // Close the chooser first, then open the form sheet so only one + // bottom-sheet is mounted at a time (no stacked backdrops). + onOpenChange(false); + setKind(next); + } + + function closeForm() { + setKind(null); + } + + const options: ReadonlyArray<{ + kind: CaptureKind; + label: string; + description: string; + icon: typeof Activity; + }> = [ + { + kind: "measurement", + label: t("nav.capture.measurement"), + description: t("nav.capture.measurementDescription"), + icon: Activity, + }, + { + kind: "medication", + label: t("nav.capture.medication"), + description: t("nav.capture.medicationDescription"), + icon: Pill, + }, + { + kind: "mood", + label: t("nav.capture.mood"), + description: t("nav.capture.moodDescription"), + icon: Waves, + }, + ]; + + const formTitle = + kind === "measurement" + ? t("measurements.addMeasurement") + : kind === "medication" + ? t("nav.capture.medication") + : t("mood.addEntry"); + + return ( + <> + {/* The capture-kind chooser. */} + +
+ {options.map((opt) => ( + + ))} +
+
+ + {/* The chosen capture surface, reusing the existing form. */} + { + if (!next) closeForm(); + }} + title={formTitle} + footer={
} + > + {kind === "measurement" && ( + + )} + {kind === "medication" && ( + + )} + {kind === "mood" && ( + + )} + + + ); +} diff --git a/src/components/settings/__tests__/unit-preference-card.test.tsx b/src/components/settings/__tests__/unit-preference-card.test.tsx deleted file mode 100644 index cbc19421f..000000000 --- a/src/components/settings/__tests__/unit-preference-card.test.tsx +++ /dev/null @@ -1,195 +0,0 @@ -/** - * v1.7.0 — Settings → Display metric/imperial control contract. - * - * The card is a two-option segmented control that reads the current - * value from `useAuth().unitPreference` and PATCHes - * `/api/auth/me/unit-preference` on change, invalidating - * `queryKeys.authMe()` so the chart display transforms re-render. - * - * Test strategy mirrors `` and the project's - * SSR-only convention (no `@testing-library/react`): real - * `useMutation` + `QueryClient`, a spied `useAuth`, and a stubbed - * `fetch`. The render pass pins the current-value SSR shape and the - * interactive / disabled state of the two segments; the mutation - * contract (endpoint + method + body) is pinned against the same - * `fetch` stub the click handler issues. - */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -import { I18nProvider } from "@/lib/i18n/context"; -import type { AuthUser } from "@/hooks/use-auth"; - -const authSpy = vi.fn< - () => { user: AuthUser | null; isAuthenticated: boolean } ->(() => ({ user: buildUser("metric"), isAuthenticated: true })); -vi.mock("@/hooks/use-auth", async () => { - const actual = - await vi.importActual( - "@/hooks/use-auth", - ); - return { ...actual, useAuth: () => authSpy() }; -}); - -function buildUser(unitPreference: "metric" | "imperial"): AuthUser { - return { - id: "user-1", - username: "user", - email: null, - role: "USER", - heightCm: null, - dateOfBirth: null, - gender: null, - timezone: "Europe/Berlin", - onboardingCompletedAt: null, - onboardingTourCompleted: true, - avatarUrl: null, - glucoseUnit: null, - unitPreference, - disableCoach: false, - fullName: null, - insurerName: null, - insurerIkNumber: null, - insuranceNumber: null, - }; -} - -import { UnitPreferenceCard } from "../unit-preference-card"; - -let fetchMock: ReturnType; - -function makeFetch() { - return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { - const body = init?.body ? JSON.parse(init.body as string) : {}; - return new Response( - JSON.stringify({ data: { unitPreference: body.unitPreference } }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - }); -} - -beforeEach(() => { - authSpy.mockClear(); - authSpy.mockImplementation(() => ({ - user: buildUser("metric"), - isAuthenticated: true, - })); - fetchMock = makeFetch(); - vi.stubGlobal("fetch", fetchMock); -}); - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -function render(): string { - const client = new QueryClient({ - defaultOptions: { queries: { retry: 0 } }, - }); - return renderToStaticMarkup( - - - - - , - ); -} - -// React renders the boolean `disabled` attribute as `disabled=""`. The -// Tailwind `disabled:opacity-50` class string also contains the word -// "disabled", so callers match the attribute form specifically. The -// helper isolates a single button's opening tag so attribute order -// (which React does not guarantee) doesn't make assertions brittle. -function segmentMarkup(html: string, key: "metric" | "imperial"): string { - const m = html.match( - new RegExp(`]*settings-unit-preference-${key}[^>]*>`), - ); - expect(m).not.toBeNull(); - return m![0]; -} - -describe("Settings — UnitPreferenceCard", () => { - it("renders the metric option as selected for a default user", () => { - const html = render(); - expect(html).toContain('data-testid="settings-unit-preference-control"'); - // The metric radio carries aria-checked="true"; imperial false. - expect(segmentMarkup(html, "metric")).toContain('aria-checked="true"'); - expect(segmentMarkup(html, "imperial")).toContain('aria-checked="false"'); - }); - - it("reflects an imperial user's stored preference", () => { - authSpy.mockImplementation(() => ({ - user: buildUser("imperial"), - isAuthenticated: true, - })); - const html = render(); - expect(segmentMarkup(html, "imperial")).toContain('aria-checked="true"'); - expect(segmentMarkup(html, "metric")).toContain('aria-checked="false"'); - }); - - it("renders both segments as enabled radios for an authenticated user", () => { - const html = render(); - // Both segments are interactive (no `disabled=""` attribute) so the - // user can switch between them; the click handler PATCHes the - // endpoint with the chosen value. - expect(segmentMarkup(html, "metric")).not.toContain('disabled=""'); - expect(segmentMarkup(html, "imperial")).not.toContain('disabled=""'); - expect(html).toContain("Metric"); - expect(html).toContain("Imperial"); - }); - - it("applies a roving tabindex — selected segment is the tab stop", () => { - // Roving tabindex: the radiogroup is a single tab stop. The selected - // (metric) option carries tabindex=0; the unselected (imperial) option - // carries tabindex=-1 so Tab skips it and Arrow keys move within the - // group instead. Arrow-key navigation itself is exercised at the - // `rovingRadioNextIndex` unit level (SSR can't dispatch keydown). - const html = render(); - expect(segmentMarkup(html, "metric")).toContain('tabindex="0"'); - expect(segmentMarkup(html, "imperial")).toContain('tabindex="-1"'); - }); - - it("moves the tab stop to the imperial segment when it is selected", () => { - authSpy.mockImplementation(() => ({ - user: buildUser("imperial"), - isAuthenticated: true, - })); - const html = render(); - expect(segmentMarkup(html, "imperial")).toContain('tabindex="0"'); - expect(segmentMarkup(html, "metric")).toContain('tabindex="-1"'); - }); - - it("disables both segments when unauthenticated", () => { - authSpy.mockImplementation(() => ({ user: null, isAuthenticated: false })); - const html = renderToStaticMarkup( - - - - - , - ); - expect(segmentMarkup(html, "metric")).toContain('disabled=""'); - expect(segmentMarkup(html, "imperial")).toContain('disabled=""'); - }); - - it("targets the unit-preference endpoint with a PATCH body", async () => { - // SSR can't dispatch a click, so pin the mutation contract against - // the same stub the handler issues — endpoint, method, and the - // field-by-field body shape the route's Zod schema accepts. - await fetch("/api/auth/me/unit-preference", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ unitPreference: "imperial" }), - }); - expect(fetchMock).toHaveBeenCalledTimes(1); - const [url, init] = fetchMock.mock.calls[0]!; - expect(url).toBe("/api/auth/me/unit-preference"); - expect(init?.method).toBe("PATCH"); - expect(JSON.parse(init?.body as string)).toEqual({ - unitPreference: "imperial", - }); - }); -}); diff --git a/src/components/settings/__tests__/unit-preference-select.test.tsx b/src/components/settings/__tests__/unit-preference-select.test.tsx new file mode 100644 index 000000000..b26a55da7 --- /dev/null +++ b/src/components/settings/__tests__/unit-preference-select.test.tsx @@ -0,0 +1,152 @@ +/** + * v1.12.x — Settings → Profile unit-system dropdown contract. + * + * The unit system moved from a standalone card into a `` + * in the Profile form beside the timezone picker. The control reads the + * current value from `useAuth().unitPreference` and PATCHes + * `/api/auth/me/unit-preference` on change, invalidating + * `queryKeys.authMe()` so the chart display transforms re-render. + * + * Test strategy mirrors the project's SSR-only convention (no + * `@testing-library/react`): real `useMutation` + `QueryClient`, a + * spied `useAuth`, and a stubbed `fetch`. The render pass pins the + * selected option SSR shape and the disabled state; the mutation + * contract (endpoint + method + body) is pinned against the same + * `fetch` stub the change handler issues. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { AuthUser } from "@/hooks/use-auth"; + +const authSpy = vi.fn< + () => { user: AuthUser | null; isAuthenticated: boolean } +>(() => ({ user: buildUser("metric"), isAuthenticated: true })); +vi.mock("@/hooks/use-auth", async () => { + const actual = + await vi.importActual( + "@/hooks/use-auth", + ); + return { ...actual, useAuth: () => authSpy() }; +}); + +function buildUser(unitPreference: "metric" | "imperial"): AuthUser { + return { + id: "user-1", + username: "user", + email: null, + role: "USER", + heightCm: null, + dateOfBirth: null, + gender: null, + timezone: "Europe/Berlin", + onboardingCompletedAt: null, + onboardingTourCompleted: true, + avatarUrl: null, + glucoseUnit: null, + unitPreference, + disableCoach: false, + fullName: null, + insurerName: null, + insurerIkNumber: null, + insuranceNumber: null, + }; +} + +import { UnitPreferenceSelect } from "../unit-preference-select"; + +let fetchMock: ReturnType; + +function makeFetch() { + return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + const body = init?.body ? JSON.parse(init.body as string) : {}; + return new Response( + JSON.stringify({ data: { unitPreference: body.unitPreference } }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }); +} + +beforeEach(() => { + authSpy.mockClear(); + authSpy.mockImplementation(() => ({ + user: buildUser("metric"), + isAuthenticated: true, + })); + fetchMock = makeFetch(); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function render(isAuthenticated = true): string { + const client = new QueryClient({ + defaultOptions: { queries: { retry: 0 } }, + }); + return renderToStaticMarkup( + + + + + , + ); +} + +describe("Settings — UnitPreferenceSelect", () => { + it("renders a labeled native select with both options", () => { + const html = render(); + expect(html).toContain('data-testid="settings-unit-preference-select"'); + expect(html).toContain('value="metric"'); + expect(html).toContain('value="imperial"'); + expect(html).toContain("Metric"); + expect(html).toContain("Imperial"); + }); + + it("selects the current value for a metric user", () => { + const html = render(); + // React renders the controlled select value via the option's + // `selected` attribute under SSR. + expect(html).toMatch(/]*value="metric"[^>]*>/); + expect(html).toContain('value="metric"'); + }); + + it("reflects an imperial user's stored preference", () => { + authSpy.mockImplementation(() => ({ + user: buildUser("imperial"), + isAuthenticated: true, + })); + const html = render(); + expect(html).toContain('value="imperial"'); + }); + + it("disables the select when unauthenticated", () => { + const html = render(false); + const select = html.match( + /]*settings-unit-preference-select[^>]*>/, + ); + expect(select).not.toBeNull(); + expect(select![0]).toContain("disabled"); + }); + + it("targets the unit-preference endpoint with a PATCH body", async () => { + // SSR can't dispatch a change event, so pin the mutation contract + // against the same stub the handler issues — endpoint, method, and + // the field-by-field body shape the route's Zod schema accepts. + await fetch("/api/auth/me/unit-preference", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ unitPreference: "imperial" }), + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe("/api/auth/me/unit-preference"); + expect(init?.method).toBe("PATCH"); + expect(JSON.parse(init?.body as string)).toEqual({ + unitPreference: "imperial", + }); + }); +}); diff --git a/src/components/settings/account-section.tsx b/src/components/settings/account-section.tsx index 44b8c3f6a..926f7ca09 100644 --- a/src/components/settings/account-section.tsx +++ b/src/components/settings/account-section.tsx @@ -53,7 +53,7 @@ import { restartOnboardingTour } from "@/lib/onboarding/tour-restart"; import { describePasskeyError } from "@/lib/passkey-errors"; import { queryKeys } from "@/lib/query-keys"; import { TimezonePicker } from "@/components/settings/timezone-picker"; -import { UnitPreferenceCard } from "@/components/settings/unit-preference-card"; +import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select"; import { InjectionSitesCard } from "@/components/settings/injection-sites-card"; import { detectBrowserTimezone, DEFAULT_TIMEZONE } from "@/lib/tz/format"; @@ -506,7 +506,14 @@ export function AccountSection() {
- + {/* Timezone + unit system share one grid row — both are + personal display preferences (like language above). The + unit dropdown PATCHes its own endpoint on change; the + timezone saves through the form's submit handler. */} +
+ + +
{/* v1.7.0 — optional patient-identity fields surfaced on the health-record export cover + FHIR Patient. All optional; @@ -585,11 +592,10 @@ export function AccountSection() {
- {/* Unit system + global injection-site exclusions live with the - account profile: both are personal preferences that apply - app-wide, not dashboard-layout controls. */} - - + {/* Global injection-site exclusions live with the account + profile — a personal preference that applies app-wide. The + unit system moved into the profile form above, beside the + timezone dropdown. */} {/* Passkeys card */} diff --git a/src/components/settings/unit-preference-card.tsx b/src/components/settings/unit-preference-card.tsx deleted file mode 100644 index d825d8d45..000000000 --- a/src/components/settings/unit-preference-card.tsx +++ /dev/null @@ -1,189 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { Ruler } from "lucide-react"; - -import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { cn } from "@/lib/utils"; -import { useAuth } from "@/hooks/use-auth"; -import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group"; -import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; - -type UnitPreference = "metric" | "imperial"; - -/** - * v1.7.0 — Settings → Display metric/imperial control. - * - * The W7 backend (`GET/PATCH /api/auth/me/unit-preference`) and the - * `useAuth().unitPreference` field shipped without a visible toggle. - * This card is the follow-up surface: a two-option segmented control - * that reads the current value from `useAuth()` and PATCHes the - * endpoint on change. The mutation invalidates `queryKeys.authMe()` - * so every chart display transform that keys off `unitPreference` - * (km/h vs mph, km vs mi) re-renders on the next /me refetch — no - * manual reload. - */ -export function UnitPreferenceCard({ - isAuthenticated, -}: { - isAuthenticated: boolean; -}) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const { user } = useAuth(); - - const [optimistic, setOptimistic] = useState(null); - const [msg, setMsg] = useState(null); - const [msgType, setMsgType] = useState<"success" | "error" | null>(null); - - const clearTimerRef = useRef | null>(null); - - useEffect(() => { - return () => { - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - clearTimerRef.current = null; - } - }; - }, []); - - function scheduleClear() { - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - } - clearTimerRef.current = setTimeout(() => { - clearTimerRef.current = null; - setMsg(null); - setMsgType(null); - }, 3000); - } - - const value: UnitPreference = optimistic ?? user?.unitPreference ?? "metric"; - - const mutation = useMutation({ - mutationFn: async (next: UnitPreference) => { - const res = await fetch("/api/auth/me/unit-preference", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ unitPreference: next }), - }); - if (!res.ok) { - const json = (await res.json().catch(() => ({}))) as { - error?: string; - }; - throw new Error(json.error ?? `HTTP ${res.status}`); - } - return next; - }, - onSuccess: () => { - setMsg(t("settings.dashboard.units.saved")); - setMsgType("success"); - // Refresh `useAuth().unitPreference` so the chart display - // transforms re-render on the next /api/auth/me refetch. - queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); - // Keep the dedicated read in sync for any future consumer. - queryClient.invalidateQueries({ - queryKey: queryKeys.userUnitPreference(), - }); - setOptimistic(null); - scheduleClear(); - }, - onError: (err) => { - setOptimistic(null); - setMsg( - err instanceof Error - ? err.message - : t("settings.dashboard.units.saveError"), - ); - setMsgType("error"); - scheduleClear(); - }, - }); - - function handleSelect(next: UnitPreference) { - if (next === value || mutation.isPending || !isAuthenticated) return; - setOptimistic(next); - setMsg(null); - setMsgType(null); - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - clearTimerRef.current = null; - } - mutation.mutate(next); - } - - const options: ReadonlyArray<{ key: UnitPreference; label: string }> = [ - { key: "metric", label: t("settings.dashboard.units.metric") }, - { key: "imperial", label: t("settings.dashboard.units.imperial") }, - ]; - - const optionsDisabled = !isAuthenticated || mutation.isPending; - const { getRadioProps } = useRovingRadioGroup({ - count: options.length, - selectedIndex: options.findIndex((o) => o.key === value), - onSelect: (index) => handleSelect(options[index]!.key), - isDisabled: () => optionsDisabled, - }); - - return ( -
- - {options.map((opt, index) => { - const selected = value === opt.key; - return ( - - ); - })} - - } - /> - {msg && ( -

- {msg} -

- )} -
- ); -} diff --git a/src/components/settings/unit-preference-select.tsx b/src/components/settings/unit-preference-select.tsx new file mode 100644 index 000000000..fa76cab1e --- /dev/null +++ b/src/components/settings/unit-preference-select.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +type UnitPreference = "metric" | "imperial"; + +/** + * v1.12.x — unit system (metric/imperial) as a Profile dropdown. + * + * The metric/imperial preference is a personal setting like language + * and timezone, so it lives in the Profile form beside the timezone + * picker rather than in a standalone card (it replaces the retired + * ``). Persistence is unchanged: a change PATCHes + * `/api/auth/me/unit-preference` immediately and invalidates + * `queryKeys.authMe()` + `queryKeys.userUnitPreference()` so every chart + * display transform (km/h vs mph, km vs mi) re-renders on the next /me + * refetch — no manual reload, no contract change. + */ +export function UnitPreferenceSelect({ + isAuthenticated, + id = "unit-preference", +}: { + isAuthenticated: boolean; + id?: string; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const { user } = useAuth(); + + const [optimistic, setOptimistic] = useState(null); + const [msg, setMsg] = useState(null); + const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + const clearTimerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (clearTimerRef.current !== null) { + clearTimeout(clearTimerRef.current); + clearTimerRef.current = null; + } + }; + }, []); + + function scheduleClear() { + if (clearTimerRef.current !== null) { + clearTimeout(clearTimerRef.current); + } + clearTimerRef.current = setTimeout(() => { + clearTimerRef.current = null; + setMsg(null); + setMsgType(null); + }, 3000); + } + + const value: UnitPreference = optimistic ?? user?.unitPreference ?? "metric"; + + const mutation = useMutation({ + mutationFn: async (next: UnitPreference) => { + const res = await fetch("/api/auth/me/unit-preference", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ unitPreference: next }), + }); + if (!res.ok) { + const json = (await res.json().catch(() => ({}))) as { + error?: string; + }; + throw new Error(json.error ?? `HTTP ${res.status}`); + } + return next; + }, + onSuccess: () => { + setMsg(t("settings.dashboard.units.saved")); + setMsgType("success"); + queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); + queryClient.invalidateQueries({ + queryKey: queryKeys.userUnitPreference(), + }); + setOptimistic(null); + scheduleClear(); + }, + onError: (err) => { + setOptimistic(null); + setMsg( + err instanceof Error + ? err.message + : t("settings.dashboard.units.saveError"), + ); + setMsgType("error"); + scheduleClear(); + }, + }); + + function handleSelect(next: UnitPreference) { + if (next === value || mutation.isPending || !isAuthenticated) return; + setOptimistic(next); + setMsg(null); + setMsgType(null); + if (clearTimerRef.current !== null) { + clearTimeout(clearTimerRef.current); + clearTimerRef.current = null; + } + mutation.mutate(next); + } + + return ( +
+ + handleSelect(e.target.value as UnitPreference)} + > + + + +

+ {msg ?? t("settings.dashboard.units.description")} +

+
+ ); +}