feat(nav): add the center capture action and More hub, move unit preference into the profile

Reshape the mobile bottom bar to the five-slot shape the native client
uses: Home · Meds · Log · Insights · More. The center "Log" slot is a
capture action rather than a destination — it opens a picker that routes
to the existing measurement, medication-intake and mood quick-entry
sheets (those forms already share a mount contract, so the picker reuses
them verbatim). Expand the former overflow into a real "More" hub that
holds the rest of the top-level destinations: Measurements, Mood,
Workouts, Achievements, Notifications and Settings.

The change is additive — Measurements and Mood leave the always-visible
strip but stay reachable through the More hub and the capture picker, so
nothing loses its route. On desktop the sidebar still lists every
destination.

Move the metric/imperial unit system from its standalone card into a
dropdown in the Profile form beside the timezone picker — it is a
display preference like language and timezone. Persistence is unchanged:
the dropdown PATCHes /api/auth/me/unit-preference on change and
invalidates the same query keys, so chart display transforms re-render
without a reload. Retire the now-unused card.
This commit is contained in:
Marc-André Bombeck committed 2026-06-05 00:08:07 +02:00
1 parent 488cc09ae9
commit 280bf42382
15 files changed
+716 -491

No files matched your search

@@ -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 `<DisableCoachCard>` 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<typeof import("@/hooks/use-auth")>(
"@/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<typeof vi.fn>;
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(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<UnitPreferenceCard isAuthenticated />
</I18nProvider>
</QueryClientProvider>,
);
}
// 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(`<button[^>]*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(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: 0 } } })}
>
<I18nProvider initialLocale="en">
<UnitPreferenceCard isAuthenticated={false} />
</I18nProvider>
</QueryClientProvider>,
);
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",
});
});
});
@@ -0,0 +1,152 @@
/**
* v1.12.x — Settings → Profile unit-system dropdown contract.
*
* The unit system moved from a standalone card into a `<NativeSelect>`
* 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<typeof import("@/hooks/use-auth")>(
"@/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<typeof vi.fn>;
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(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<UnitPreferenceSelect isAuthenticated={isAuthenticated} />
</I18nProvider>
</QueryClientProvider>,
);
}
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(/<option[^>]*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(
/<select[^>]*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",
});
});
});
+13 -7
View File
@@ -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() {
</div>
</div>
<TimezonePicker value={timezone} onChange={setTimezone} />
{/* 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. */}
<div className="grid gap-4 sm:grid-cols-2">
<TimezonePicker value={timezone} onChange={setTimezone} />
<UnitPreferenceSelect isAuthenticated={isAuthenticated} />
</div>
{/* 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() {
</form>
</div>
{/* Unit system + global injection-site exclusions live with the
account profile: both are personal preferences that apply
app-wide, not dashboard-layout controls. */}
<UnitPreferenceCard isAuthenticated={isAuthenticated} />
{/* 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. */}
<InjectionSitesCard isAuthenticated={isAuthenticated} />
{/* Passkeys card */}
@@ -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<UnitPreference | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<section
aria-labelledby="settings-units-title"
data-testid="settings-unit-preference-card"
className="bg-card rounded-lg border p-4 sm:p-6"
>
<SettingsCardHeader
icon={Ruler}
title={t("settings.dashboard.units.title")}
titleId="settings-units-title"
description={t("settings.dashboard.units.description")}
status={
<div
role="radiogroup"
aria-label={t("settings.dashboard.units.title")}
data-testid="settings-unit-preference-control"
className="bg-muted inline-flex rounded-lg p-1"
>
{options.map((opt, index) => {
const selected = value === opt.key;
return (
<button
key={opt.key}
type="button"
role="radio"
aria-checked={selected}
data-testid={`settings-unit-preference-${opt.key}`}
disabled={optionsDisabled}
onClick={() => handleSelect(opt.key)}
{...getRadioProps(index)}
className={cn(
"min-h-11 rounded-md px-4 text-sm font-medium transition-colors focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:opacity-50 sm:min-h-9",
selected
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{opt.label}
</button>
);
})}
</div>
}
/>
{msg && (
<p
role="status"
aria-live="polite"
className={
msgType === "error"
? "text-destructive mt-3 text-sm"
: "text-muted-foreground mt-3 text-sm"
}
>
{msg}
</p>
)}
</section>
);
}
@@ -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
* `<UnitPreferenceCard>`). 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<UnitPreference | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div className="space-y-2">
<Label htmlFor={id}>{t("settings.dashboard.units.title")}</Label>
<NativeSelect
id={id}
data-testid="settings-unit-preference-select"
value={value}
disabled={!isAuthenticated || mutation.isPending}
onChange={(e) => handleSelect(e.target.value as UnitPreference)}
>
<option value="metric">{t("settings.dashboard.units.metric")}</option>
<option value="imperial">
{t("settings.dashboard.units.imperial")}
</option>
</NativeSelect>
<p
role="status"
aria-live="polite"
className={
msgType === "error"
? "text-destructive text-xs"
: "text-muted-foreground text-xs"
}
>
{msg ?? t("settings.dashboard.units.description")}
</p>
</div>
);
}