mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
488cc09ae9
commit
280bf42382
15 files changed
+716
-491
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
+12
-1
@@ -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",
|
||||
|
||||
+12
-1
@@ -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",
|
||||
|
||||
+12
-1
@@ -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",
|
||||
|
||||
+12
-1
@@ -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",
|
||||
|
||||
+12
-1
@@ -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",
|
||||
|
||||
+12
-1
@@ -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ę",
|
||||
|
||||
@@ -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 `<BottomNav>`:
|
||||
* - exactly five primary links + one "More" button render in the strip
|
||||
* - the More button is keyboard-reachable as a `<button>` with
|
||||
* `aria-haspopup="dialog"`
|
||||
* - Achievements lives in the More sheet (closed by default under SSR),
|
||||
* not in the always-visible strip
|
||||
* - WCAG 2.5.5 floor: every entry is `min-h-11 min-w-11`
|
||||
* - the mobile bar is Home · Meds · Log(center) · Insights · More
|
||||
* - the center "Log" slot is a capture ACTION (a `<button>` with
|
||||
* `aria-haspopup="dialog"`), not a navigation link
|
||||
* - the More button opens a hub (closed by default under SSR)
|
||||
* - the More hub is a real hub: Measurements + Mood (which left the
|
||||
* always-visible strip) stay reachable here, alongside Workouts,
|
||||
* Achievements, Notifications and Settings — nothing is orphaned
|
||||
* - WCAG 2.5.5 floor: every strip entry is `min-h-11 min-w-11`
|
||||
*
|
||||
* v1.8.6 — the Targets (Zielwerte) page is deprecated and dropped from
|
||||
* the menu, so it no longer appears in the strip or the overflow set.
|
||||
* the menu, so it no longer appears in the strip or the hub.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -33,20 +35,23 @@ function render() {
|
||||
);
|
||||
}
|
||||
|
||||
describe("<BottomNav> 5+More layout", () => {
|
||||
it("renders the five primary anchors", () => {
|
||||
describe("<BottomNav> iOS-parity layout", () => {
|
||||
it("renders the flanking primary anchors (Home, Meds, Insights)", () => {
|
||||
const html = render();
|
||||
for (const href of [
|
||||
"/",
|
||||
"/measurements",
|
||||
"/mood",
|
||||
"/medications",
|
||||
"/insights",
|
||||
]) {
|
||||
for (const href of ["/", "/medications", "/insights"]) {
|
||||
expect(html).toContain(`href="${href}"`);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the center capture action as a dialog-opening button, not a link", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="bottom-nav-capture"');
|
||||
// The capture button opening tag carries aria-haspopup="dialog".
|
||||
const capture = html.match(/<button[^>]*bottom-nav-capture[^>]*>/);
|
||||
expect(capture).not.toBeNull();
|
||||
expect(capture![0]).toMatch(/aria-haspopup="dialog"/);
|
||||
});
|
||||
|
||||
it("renders an overflow More button via aria-haspopup", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="bottom-nav-more"');
|
||||
@@ -54,28 +59,36 @@ describe("<BottomNav> 5+More layout", () => {
|
||||
expect(html).toMatch(/aria-expanded="false"/);
|
||||
});
|
||||
|
||||
it("each primary entry meets the 44 px tap-target floor (min-h-11 min-w-11)", () => {
|
||||
it("does not render Measurements or Mood as always-visible strip links", () => {
|
||||
// Measurements + Mood left the always-visible strip when the center
|
||||
// capture action took the middle slot; they live in the More hub
|
||||
// (rendered on open — exercised in e2e) plus the capture picker.
|
||||
// The closed-hub SSR markup must therefore carry neither as a strip
|
||||
// anchor, and the capture/More affordances replace them.
|
||||
const html = render();
|
||||
// Count `min-h-11 min-w-11` occurrences. Six is the expected floor
|
||||
// (five anchors + the More button); we don't hard-pin because a
|
||||
// future entry would also satisfy the contract.
|
||||
expect(html).not.toContain('href="/measurements"');
|
||||
expect(html).not.toContain('href="/mood"');
|
||||
// The replacements that keep those surfaces reachable are present.
|
||||
expect(html).toContain('data-testid="bottom-nav-capture"');
|
||||
expect(html).toContain('data-testid="bottom-nav-more"');
|
||||
});
|
||||
|
||||
it("each strip entry meets the 44 px tap-target floor (min-h-11 min-w-11)", () => {
|
||||
const html = render();
|
||||
// Five flanking strip slots (Home, Meds, capture button, Insights,
|
||||
// More) all carry `min-h-11 min-w-11`. We don't hard-pin the count
|
||||
// because a future entry would also satisfy the contract.
|
||||
const matches = html.match(/min-h-11 min-w-11/g) ?? [];
|
||||
expect(matches.length).toBeGreaterThanOrEqual(6);
|
||||
expect(matches.length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
|
||||
it("no longer lists the deprecated /targets entry (v1.8.6)", () => {
|
||||
// Targets was dropped from the OVERFLOW set; the strip never carried
|
||||
// it. The closed-sheet SSR render must therefore contain no /targets
|
||||
// link — a regression net against the entry creeping back into the
|
||||
// primary strip or the overflow set.
|
||||
const html = render();
|
||||
expect(html).not.toContain('href="/targets"');
|
||||
});
|
||||
|
||||
it("More button carries an accessible label", () => {
|
||||
const html = render();
|
||||
// Attribute order isn't guaranteed by React's renderer, so match
|
||||
// both possible directions instead of pinning the order.
|
||||
const hasLabelFirst =
|
||||
/aria-label="More"[^>]*data-testid="bottom-nav-more"/.test(html);
|
||||
const hasTestidFirst =
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
Dumbbell,
|
||||
Home,
|
||||
Lightbulb,
|
||||
MoreHorizontal,
|
||||
Pill,
|
||||
Plus,
|
||||
Settings,
|
||||
Trophy,
|
||||
Waves,
|
||||
} from "lucide-react";
|
||||
@@ -19,6 +23,7 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { CapturePicker } from "@/components/layout/capture-picker";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
@@ -28,36 +33,48 @@ interface NavLink {
|
||||
icon: typeof Home;
|
||||
}
|
||||
|
||||
// v1.4.16 Wave-C MED — bottom-nav was rendering all 7 destinations
|
||||
// edge-to-edge on a 375 px viewport, which collapsed each touch
|
||||
// target to ~50 px wide and made the WCAG 2.5.5 44 px floor only
|
||||
// just-pass on iPhone-13-mini-class screens. The mobile audit
|
||||
// recommended 5+More — so the four most-used surfaces stay in the
|
||||
// strip (Home, Measurements, Mood, Medications) plus Insights as
|
||||
// the fifth, and Achievements moves into a "More" sheet.
|
||||
// v1.12.x — iOS-parity bottom bar (additive middle-path).
|
||||
//
|
||||
// Order is preserved relative to the v1.4.15 bottom-nav so muscle
|
||||
// memory keeps working for the four core actions.
|
||||
// The mobile bar is a 5-slot shape: Home · Meds · Log(center) ·
|
||||
// Insights · More — mirroring the native client. The center slot is a
|
||||
// capture ACTION (not a destination): it opens a picker that routes to
|
||||
// the existing measurement / medication / mood quick-entry surfaces
|
||||
// (see `<CapturePicker>`).
|
||||
//
|
||||
// v1.9.0 — the standalone Targets (Zielwerte) page is retired. Target
|
||||
// editing lives inline on the Insights metric pages (the per-metric
|
||||
// reference panel mounts the target editor), so there is no nav entry.
|
||||
const PRIMARY: ReadonlyArray<NavLink> = [
|
||||
// PRIMARY_LEFT / PRIMARY_RIGHT are the two anchor pairs that flank the
|
||||
// center action. The previous strip (Home · Measurements · Mood ·
|
||||
// Medications · Insights) carried Measurements and Mood inline; those
|
||||
// stay fully reachable — through the center capture picker AND the More
|
||||
// hub below — so nothing is orphaned. On desktop the sidebar still
|
||||
// lists every destination.
|
||||
const PRIMARY_LEFT: ReadonlyArray<NavLink> = [
|
||||
{ href: "/", tKey: "nav.dashboard", icon: Home },
|
||||
{ href: "/measurements", tKey: "nav.measurements", icon: Activity },
|
||||
{ href: "/mood", tKey: "nav.mood", icon: Waves },
|
||||
{ href: "/medications", tKey: "nav.medications", icon: Pill },
|
||||
];
|
||||
|
||||
const PRIMARY_RIGHT: ReadonlyArray<NavLink> = [
|
||||
{ href: "/insights", tKey: "nav.insights", icon: Lightbulb },
|
||||
];
|
||||
|
||||
const OVERFLOW: ReadonlyArray<NavLink> = [
|
||||
// The "More" hub — a real hub of the remaining top-level destinations,
|
||||
// not just an overflow bucket. Measurements and Mood live here (they
|
||||
// left the always-visible strip when the center capture action took the
|
||||
// middle slot), alongside Workouts, Achievements, Notifications and
|
||||
// Settings. Every entry is an existing top-level route.
|
||||
const MORE_HUB: ReadonlyArray<NavLink> = [
|
||||
{ href: "/measurements", tKey: "nav.measurements", icon: Activity },
|
||||
{ href: "/mood", tKey: "nav.mood", icon: Waves },
|
||||
{ href: "/insights/workouts", tKey: "nav.workouts", icon: Dumbbell },
|
||||
{ href: "/achievements", tKey: "nav.achievements", icon: Trophy },
|
||||
{ href: "/notifications", tKey: "nav.notifications", icon: Bell },
|
||||
{ href: "/settings/account", tKey: "nav.settings", icon: Settings },
|
||||
];
|
||||
|
||||
export function BottomNav() {
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslations();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [captureOpen, setCaptureOpen] = useState(false);
|
||||
|
||||
function isActiveLink(href: string) {
|
||||
return href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||
@@ -65,84 +82,95 @@ export function BottomNav() {
|
||||
|
||||
// The "More" entry is treated as active when any of its children
|
||||
// is the current page — gives the user a clue that they've drilled
|
||||
// into a sub-route via overflow.
|
||||
const overflowActive = OVERFLOW.some((item) => isActiveLink(item.href));
|
||||
// into a sub-route via the hub.
|
||||
const moreActive = MORE_HUB.some((item) => isActiveLink(item.href));
|
||||
|
||||
function renderPrimary(item: NavLink) {
|
||||
const isActive = isActiveLink(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-label={t(item.tKey)}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
// Touch target sized to WCAG 2.5.5 (44×44 CSS px). The
|
||||
// outer min-h-11 min-w-11 is the actual hit area; the icon
|
||||
// stays visually centered at 20px so the design doesn't shift.
|
||||
className={cn(
|
||||
"relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2",
|
||||
isActive
|
||||
? "text-primary"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
{isActive && (
|
||||
<span className="bg-primary absolute bottom-1.5 h-1 w-1 rounded-full" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* v1.4.33 F14 — bar height and safe-area defence so the bar can
|
||||
never overlap the last viewport line of scrollable page content.
|
||||
- `min-h-16` floors the bar at 64px even if the inner flex row
|
||||
collapses (e.g. a future variant without one of the icons).
|
||||
- `pb-[env(safe-area-inset-bottom)]` reserves the home-indicator
|
||||
inset on iOS / iPadOS PWA installs.
|
||||
- `bg-card` (solid, not the previous `bg-card/80`) plus
|
||||
`border-t` give the bar a clean visual edge so any page
|
||||
content that scrolls under the bar is properly occluded
|
||||
rather than bleeding through a translucent surface. Page-
|
||||
level pb-* padding owners (auth-shell + per-page shells)
|
||||
still reserve room above the bar; this change hardens the
|
||||
bar itself so the visual contract holds even when a page
|
||||
shell forgets the bottom-padding contract.
|
||||
*/}
|
||||
`min-h-16` floors the bar at 64px; `pb-[env(safe-area-inset-bottom)]`
|
||||
reserves the home-indicator inset on iOS / iPadOS PWA installs;
|
||||
`bg-card` + `border-t` give the bar a clean visual edge so page
|
||||
content that scrolls under the bar is occluded, not bleeding
|
||||
through. */}
|
||||
<nav
|
||||
aria-label={t("nav.mobileNavigation")}
|
||||
className="bg-card border-border fixed bottom-0 left-0 z-50 min-h-16 w-full border-t pb-[env(safe-area-inset-bottom)] backdrop-blur-md md:hidden"
|
||||
>
|
||||
<div className="mx-auto flex h-16 max-w-lg items-stretch justify-around px-1">
|
||||
{PRIMARY.map((item) => {
|
||||
const isActive = isActiveLink(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-label={t(item.tKey)}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
// Touch target sized to WCAG 2.5.5 (44×44 CSS px). The
|
||||
// outer min-h-11 min-w-11 is the actual hit area; the icon
|
||||
// stays visually centered at 20px so the design doesn't shift.
|
||||
//
|
||||
// v1.4.34 IW-G — keyboard users get a visible ring on
|
||||
// focus-visible, matching the sidebar treatment from
|
||||
// v1.4.33. Without it the bottom-nav had no focus
|
||||
// indicator at all on Tab navigation.
|
||||
className={cn(
|
||||
"relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2",
|
||||
isActive
|
||||
? "text-primary"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
{isActive && (
|
||||
<span className="bg-primary absolute bottom-1.5 h-1 w-1 rounded-full" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{PRIMARY_LEFT.map(renderPrimary)}
|
||||
|
||||
{/* Center capture action — a labeled button (NOT a link) that
|
||||
opens the capture picker. Visually elevated so it reads as
|
||||
the primary CTA. */}
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("nav.capture.title")}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={captureOpen}
|
||||
onClick={() => setCaptureOpen(true)}
|
||||
data-testid="bottom-nav-capture"
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring/50 flex min-h-11 min-w-11 items-center justify-center rounded-full shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Plus className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{PRIMARY_RIGHT.map(renderPrimary)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("nav.more")}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={moreOpen}
|
||||
aria-current={overflowActive ? "page" : undefined}
|
||||
aria-current={moreActive ? "page" : undefined}
|
||||
onClick={() => setMoreOpen(true)}
|
||||
className={cn(
|
||||
"relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2",
|
||||
overflowActive
|
||||
moreActive
|
||||
? "text-primary"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
data-testid="bottom-nav-more"
|
||||
>
|
||||
<MoreHorizontal className="h-5 w-5" />
|
||||
{overflowActive && (
|
||||
{moreActive && (
|
||||
<span className="bg-primary absolute bottom-1.5 h-1 w-1 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<CapturePicker open={captureOpen} onOpenChange={setCaptureOpen} />
|
||||
|
||||
<Sheet open={moreOpen} onOpenChange={setMoreOpen}>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
@@ -156,8 +184,8 @@ export function BottomNav() {
|
||||
<SheetTitle>{t("nav.moreSheetTitle")}</SheetTitle>
|
||||
<SheetDescription>{t("nav.moreSheetDescription")}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="grid grid-cols-1 gap-2 px-4 pb-4">
|
||||
{OVERFLOW.map((item) => {
|
||||
<div className="grid grid-cols-2 gap-2 px-4 pb-4">
|
||||
{MORE_HUB.map((item) => {
|
||||
const isActive = isActiveLink(item.href);
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Activity, Pill, Waves } from "lucide-react";
|
||||
|
||||
import { MeasurementForm } from "@/components/measurements/measurement-form";
|
||||
import { MoodForm } from "@/components/mood/mood-form";
|
||||
import { MedicationIntakeQuickAdd } from "@/components/dashboard/medication-intake-quick-add";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* The center "Log" capture action (iOS parity — the bottom bar's
|
||||
* middle slot is a capture CTA, not a destination). It opens a small
|
||||
* picker that routes to one of three existing quick-entry surfaces:
|
||||
*
|
||||
* - Measurement → `<MeasurementForm>` (the `/measurements` add form)
|
||||
* - Medication → `<MedicationIntakeQuickAdd>` (the dashboard intake
|
||||
* quick-add)
|
||||
* - Mood → `<MoodForm>` (the `/mood` add form, 5-face flow)
|
||||
*
|
||||
* All three forms share the `{ onSuccess, onCancel, footerSlot }`
|
||||
* contract and are designed to mount inside `<ResponsiveSheet>`, 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<CaptureKind | null>(null);
|
||||
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(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. */}
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("nav.capture.title")}
|
||||
description={t("nav.capture.description")}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-2" data-testid="capture-picker-options">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.kind}
|
||||
type="button"
|
||||
data-testid={`capture-picker-${opt.kind}`}
|
||||
onClick={() => chooseKind(opt.kind)}
|
||||
className={cn(
|
||||
"border-border hover:bg-accent/40 focus-visible:ring-ring/50 flex min-h-14 items-center gap-3 rounded-lg border px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2",
|
||||
)}
|
||||
>
|
||||
<span className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-full">
|
||||
<opt.icon className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium">{opt.label}</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{opt.description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
|
||||
{/* The chosen capture surface, reusing the existing form. */}
|
||||
<ResponsiveSheet
|
||||
open={kind !== null}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) closeForm();
|
||||
}}
|
||||
title={formTitle}
|
||||
footer={<div ref={setFooterEl} className="flex w-full" />}
|
||||
>
|
||||
{kind === "measurement" && (
|
||||
<MeasurementForm
|
||||
onSuccess={closeForm}
|
||||
onCancel={closeForm}
|
||||
footerSlot={footerEl}
|
||||
/>
|
||||
)}
|
||||
{kind === "medication" && (
|
||||
<MedicationIntakeQuickAdd
|
||||
onSuccess={closeForm}
|
||||
onCancel={closeForm}
|
||||
footerSlot={footerEl}
|
||||
/>
|
||||
)}
|
||||
{kind === "mood" && (
|
||||
<MoodForm
|
||||
onSuccess={closeForm}
|
||||
onCancel={closeForm}
|
||||
footerSlot={footerEl}
|
||||
/>
|
||||
)}
|
||||
</ResponsiveSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user