mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(nav): give Vorsorge a nav home and fold the utility tail into one model
Vorsorge (preventive-care) was a top-level tracking surface reachable only three taps deep through Settings → Reminders, while peer surfaces (Labs, Recovery) already had a top-level nav home. Add it to the one shared destination model in the clinical spine, so it renders on the desktop sidebar and the mobile More hub like every other feature, with a nav.vorsorge key across all six locales. Bug Report, Settings and Notifications were still a second hand-curated list on each bar — the exact drift the one-model contract set out to kill, just pushed down a level. Promote them to a shared NAV_UTILITY_DESTINATIONS list both bars consume: the sidebar footer + avatar menu and the bottom-nav More-hub tail now read from one source and can no longer disagree. The More hub itself is now computed by mobileMoreHubDestinations() so the headline invariant (hub == feature list minus primary slots, plus the utility tail) is a tested model function rather than inline bar logic. Add a sidebar utility-parity guard and a mobile More-hub invariant test.
This commit is contained in:
1 parent
a27247f93c
commit
6a994cd1c3
11 files changed
+402
-172
No files matched your search
+3
-1
@@ -250,7 +250,8 @@
|
||||
},
|
||||
"cycle": "Zyklus",
|
||||
"labs": "Laborwerte",
|
||||
"recovery": "Erholung"
|
||||
"recovery": "Erholung",
|
||||
"vorsorge": "Vorsorge"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Anmelden",
|
||||
@@ -3862,6 +3863,7 @@
|
||||
"layout": {
|
||||
"title": "Layout & Personalisierung",
|
||||
"description": "Lege fest, wie deine App aufgebaut ist — ein Ort für Dashboard, Insights, Medikamente und Stimmung.",
|
||||
"backToHub": "Zurück zu Layout & Personalisierung",
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"description": "Kachel-Layout und Reihenfolge."
|
||||
|
||||
+3
-1
@@ -250,7 +250,8 @@
|
||||
},
|
||||
"cycle": "Cycle",
|
||||
"labs": "Labs",
|
||||
"recovery": "Recovery"
|
||||
"recovery": "Recovery",
|
||||
"vorsorge": "Preventive Care"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Sign in",
|
||||
@@ -3862,6 +3863,7 @@
|
||||
"layout": {
|
||||
"title": "Layout & Personalization",
|
||||
"description": "Arrange how your app is laid out — one home for the dashboard, insights, medications, and mood.",
|
||||
"backToHub": "Back to Layout & Personalization",
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"description": "Tile layout and order."
|
||||
|
||||
+3
-1
@@ -250,7 +250,8 @@
|
||||
},
|
||||
"cycle": "Ciclo",
|
||||
"labs": "Análisis",
|
||||
"recovery": "Recuperación"
|
||||
"recovery": "Recuperación",
|
||||
"vorsorge": "Prevención"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Iniciar sesión",
|
||||
@@ -3862,6 +3863,7 @@
|
||||
"layout": {
|
||||
"title": "Diseño y personalización",
|
||||
"description": "Organiza cómo está dispuesta tu app — un único lugar para el panel, los análisis, los medicamentos y el ánimo.",
|
||||
"backToHub": "Volver a Diseño y personalización",
|
||||
"dashboard": {
|
||||
"title": "Panel",
|
||||
"description": "Diseño y orden de los mosaicos."
|
||||
|
||||
+3
-1
@@ -250,7 +250,8 @@
|
||||
},
|
||||
"cycle": "Cycle",
|
||||
"labs": "Analyses",
|
||||
"recovery": "Récupération"
|
||||
"recovery": "Récupération",
|
||||
"vorsorge": "Prévention"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Se connecter",
|
||||
@@ -3862,6 +3863,7 @@
|
||||
"layout": {
|
||||
"title": "Disposition et personnalisation",
|
||||
"description": "Organisez la disposition de votre application — un seul endroit pour le tableau de bord, les analyses, les médicaments et l’humeur.",
|
||||
"backToHub": "Retour à Disposition et personnalisation",
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
"description": "Disposition et ordre des tuiles."
|
||||
|
||||
+3
-1
@@ -250,7 +250,8 @@
|
||||
},
|
||||
"cycle": "Ciclo",
|
||||
"labs": "Esami",
|
||||
"recovery": "Recupero"
|
||||
"recovery": "Recupero",
|
||||
"vorsorge": "Prevenzione"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Accedi",
|
||||
@@ -3862,6 +3863,7 @@
|
||||
"layout": {
|
||||
"title": "Layout e personalizzazione",
|
||||
"description": "Organizza la disposizione della tua app — un unico posto per cruscotto, approfondimenti, farmaci e umore.",
|
||||
"backToHub": "Torna a Layout e personalizzazione",
|
||||
"dashboard": {
|
||||
"title": "Cruscotto",
|
||||
"description": "Layout e ordine dei riquadri."
|
||||
|
||||
+3
-1
@@ -250,7 +250,8 @@
|
||||
},
|
||||
"cycle": "Cykl",
|
||||
"labs": "Wyniki badań",
|
||||
"recovery": "Regeneracja"
|
||||
"recovery": "Regeneracja",
|
||||
"vorsorge": "Profilaktyka"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Zaloguj się",
|
||||
@@ -3862,6 +3863,7 @@
|
||||
"layout": {
|
||||
"title": "Układ i personalizacja",
|
||||
"description": "Ustal, jak rozłożona jest Twoja aplikacja — jedno miejsce dla pulpitu, analiz, leków i nastroju.",
|
||||
"backToHub": "Powrót do Układ i personalizacja",
|
||||
"dashboard": {
|
||||
"title": "Pulpit",
|
||||
"description": "Układ i kolejność kafelków."
|
||||
|
||||
@@ -10,9 +10,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
BOTTOM_NAV_PRIMARY_SLOT_HREFS,
|
||||
NAV_DESTINATIONS,
|
||||
NAV_UTILITY_DESTINATIONS,
|
||||
isNavDestinationActive,
|
||||
mobileMoreHubDestinations,
|
||||
visibleNavDestinations,
|
||||
visibleUtilityDestinations,
|
||||
} from "../nav-model";
|
||||
|
||||
describe("nav-model destination list", () => {
|
||||
@@ -47,6 +51,51 @@ describe("nav-model destination list", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("gives Vorsorge a top-level nav home in the clinical spine (N-3)", () => {
|
||||
const hrefs = NAV_DESTINATIONS.map((d) => d.href);
|
||||
expect(hrefs).toContain("/vorsorge");
|
||||
const vorsorge = NAV_DESTINATIONS.find((d) => d.href === "/vorsorge");
|
||||
expect(vorsorge?.tKey).toBe("nav.vorsorge");
|
||||
// Peer to Labs / Recovery: sits in the clinical spine, before Insights.
|
||||
expect(hrefs.indexOf("/labs")).toBeLessThan(hrefs.indexOf("/vorsorge"));
|
||||
expect(hrefs.indexOf("/vorsorge")).toBeLessThan(hrefs.indexOf("/insights"));
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("nav-model utility tail (N-1 — one shared list)", () => {
|
||||
it("every utility destination carries a nav-namespace i18n key", () => {
|
||||
for (const d of NAV_UTILITY_DESTINATIONS) {
|
||||
expect(d.tKey.startsWith("nav.")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("lists the shared utility surfaces (Bug Report, Settings, Notifications)", () => {
|
||||
const hrefs = NAV_UTILITY_DESTINATIONS.map((d) => d.href);
|
||||
expect(hrefs).toContain("/bugreport");
|
||||
expect(hrefs).toContain("/settings/account");
|
||||
expect(hrefs).toContain("/notifications");
|
||||
});
|
||||
|
||||
it("drops the bug-report entry unless the operator flag is on", () => {
|
||||
const off = visibleUtilityDestinations(false).map((d) => d.href);
|
||||
const on = visibleUtilityDestinations(true).map((d) => d.href);
|
||||
expect(off).not.toContain("/bugreport");
|
||||
expect(on).toContain("/bugreport");
|
||||
// Non-gated entries are present either way.
|
||||
expect(off).toContain("/settings/account");
|
||||
expect(off).toContain("/notifications");
|
||||
});
|
||||
|
||||
it("keeps a stable order (Bug Report → Settings → Notifications)", () => {
|
||||
const hrefs = visibleUtilityDestinations(true).map((d) => d.href);
|
||||
expect(hrefs.indexOf("/bugreport")).toBeLessThan(
|
||||
hrefs.indexOf("/settings/account"),
|
||||
);
|
||||
expect(hrefs.indexOf("/settings/account")).toBeLessThan(
|
||||
hrefs.indexOf("/notifications"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("visibleNavDestinations cycle gate", () => {
|
||||
@@ -60,6 +109,62 @@ describe("visibleNavDestinations cycle gate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => {
|
||||
it("is exactly the visible feature destinations minus the primary slots, then the utility tail", () => {
|
||||
const opts = { cycleTrackingEnabled: false, bugReportEnabled: false };
|
||||
const hub = mobileMoreHubDestinations(opts).map((d) => d.href);
|
||||
|
||||
const expectedFeatures = visibleNavDestinations(opts.cycleTrackingEnabled)
|
||||
.map((d) => d.href)
|
||||
.filter((href) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(href));
|
||||
const expectedTail = visibleUtilityDestinations(
|
||||
opts.bugReportEnabled,
|
||||
).map((d) => d.href);
|
||||
|
||||
expect(hub).toEqual([...expectedFeatures, ...expectedTail]);
|
||||
});
|
||||
|
||||
it("never contains a primary slot (Home / Meds / Insights)", () => {
|
||||
const hub = mobileMoreHubDestinations({
|
||||
cycleTrackingEnabled: true,
|
||||
bugReportEnabled: true,
|
||||
}).map((d) => d.href);
|
||||
for (const slot of BOTTOM_NAV_PRIMARY_SLOT_HREFS) {
|
||||
expect(hub).not.toContain(slot);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the feature destinations and the utility tail reachable in the hub", () => {
|
||||
const hub = mobileMoreHubDestinations({
|
||||
cycleTrackingEnabled: false,
|
||||
bugReportEnabled: true,
|
||||
}).map((d) => d.href);
|
||||
// Feature surfaces that left the always-visible strip stay reachable.
|
||||
expect(hub).toContain("/measurements");
|
||||
expect(hub).toContain("/mood");
|
||||
expect(hub).toContain("/vorsorge");
|
||||
// The shared utility tail rides at the end.
|
||||
expect(hub).toContain("/bugreport");
|
||||
expect(hub).toContain("/settings/account");
|
||||
expect(hub).toContain("/notifications");
|
||||
});
|
||||
|
||||
it("gates Cycle and Bug Report by the same flags the sidebar uses", () => {
|
||||
const off = mobileMoreHubDestinations({
|
||||
cycleTrackingEnabled: false,
|
||||
bugReportEnabled: false,
|
||||
}).map((d) => d.href);
|
||||
const on = mobileMoreHubDestinations({
|
||||
cycleTrackingEnabled: true,
|
||||
bugReportEnabled: true,
|
||||
}).map((d) => d.href);
|
||||
expect(off).not.toContain("/cycle");
|
||||
expect(off).not.toContain("/bugreport");
|
||||
expect(on).toContain("/cycle");
|
||||
expect(on).toContain("/bugreport");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNavDestinationActive most-specific resolution", () => {
|
||||
it("matches the dashboard only on an exact path", () => {
|
||||
expect(isNavDestinationActive("/", "/")).toBe(true);
|
||||
|
||||
@@ -51,6 +51,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { SidebarNav } from "../sidebar-nav";
|
||||
import { ADMIN_SECTIONS } from "@/components/admin/admin-shell";
|
||||
import { visibleUtilityDestinations } from "../nav-model";
|
||||
|
||||
function render({
|
||||
pathname = "/",
|
||||
@@ -154,6 +155,37 @@ describe("<SidebarNav> unified destination model (v1.17.1 F-1 / F-3)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SidebarNav> shared utility tail (v1.17.1 N-1 parity guard)", () => {
|
||||
// The footer renders the shared utility tail EXCEPT Notifications, which
|
||||
// lives in the avatar dropdown (a Radix menu that is collapsed — and so
|
||||
// not in the SSR markup — until opened). The footer-visible utilities are
|
||||
// therefore the shared list minus `/notifications`. Asserting they all
|
||||
// render straight from the shared list is the parity net that keeps the
|
||||
// sidebar footer from drifting away from the mobile More-hub tail.
|
||||
const footerUtilities = (bugReportEnabled: boolean) =>
|
||||
visibleUtilityDestinations(bugReportEnabled).filter(
|
||||
(d) => d.href !== "/notifications",
|
||||
);
|
||||
|
||||
it("renders every footer utility destination straight from the shared list", () => {
|
||||
const html = render({ bugReportEnabled: true });
|
||||
for (const dest of footerUtilities(true)) {
|
||||
expect(html).toContain(`href="${dest.href}"`);
|
||||
}
|
||||
// Bug Report and Settings are the footer utilities; both are present.
|
||||
expect(html).toContain('href="/bugreport"');
|
||||
expect(html).toContain('href="/settings/account"');
|
||||
});
|
||||
|
||||
it("drops the bug-report utility entry when the operator flag is off", () => {
|
||||
const html = render({ bugReportEnabled: false });
|
||||
for (const dest of footerUtilities(false)) {
|
||||
expect(html).toContain(`href="${dest.href}"`);
|
||||
}
|
||||
expect(html).not.toContain('href="/bugreport"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SidebarNav> admin entry mirrors Settings (no sub-item expansion)", () => {
|
||||
// v1.4.16 A1: the maintainer reported the global sidebar expanding admin
|
||||
// sub-items on `/admin/*` was unwanted UX — the in-shell `<AdminShell>`
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bell,
|
||||
Bug,
|
||||
Home,
|
||||
Lightbulb,
|
||||
MoreHorizontal,
|
||||
Pill,
|
||||
Plus,
|
||||
Settings,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -17,7 +14,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medications";
|
||||
import {
|
||||
isNavDestinationActive,
|
||||
visibleNavDestinations,
|
||||
mobileMoreHubDestinations,
|
||||
} from "@/components/layout/nav-model";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useAppSettings } from "@/components/app-settings-provider";
|
||||
@@ -50,11 +47,11 @@ interface NavLink {
|
||||
// v1.17.1 (F-1) — the two always-visible flanking anchors and the "More"
|
||||
// hub are now BOTH derived from the one shared destination model
|
||||
// (`nav-model.ts`), the same ordered list the desktop sidebar renders.
|
||||
// The bar keeps its ergonomic 5-slot shape, but every destination that
|
||||
// isn't a primary slot falls into the hub in the model's order — so the
|
||||
// two surfaces tell one story instead of two hand-curated ones that drift.
|
||||
const PRIMARY_SLOT_HREFS = ["/", "/medications", "/insights"] as const;
|
||||
|
||||
// The bar keeps its ergonomic 5-slot shape, but the hub is computed by the
|
||||
// model's `mobileMoreHubDestinations()` (feature list minus the primary
|
||||
// slots, plus the shared utility tail) — so the two surfaces tell one story
|
||||
// instead of two hand-curated ones that drift, and the headline invariant
|
||||
// is a tested model function rather than inline bar logic.
|
||||
const PRIMARY_LEFT: ReadonlyArray<NavLink> = [
|
||||
{ href: "/", tKey: "nav.dashboard", icon: Home },
|
||||
{ href: "/medications", tKey: "nav.medications", icon: Pill },
|
||||
@@ -64,29 +61,6 @@ const PRIMARY_RIGHT: ReadonlyArray<NavLink> = [
|
||||
{ href: "/insights", tKey: "nav.insights", icon: Lightbulb },
|
||||
];
|
||||
|
||||
// Mobile-only hub conveniences that have no main-list home on desktop
|
||||
// (the sidebar reaches them through its footer + avatar menu). They sit
|
||||
// at the tail of the hub, after every shared destination.
|
||||
const NOTIFICATIONS_HUB_ITEM: NavLink = {
|
||||
href: "/notifications",
|
||||
tKey: "nav.notifications",
|
||||
icon: Bell,
|
||||
};
|
||||
const SETTINGS_HUB_ITEM: NavLink = {
|
||||
href: "/settings/account",
|
||||
tKey: "nav.settings",
|
||||
icon: Settings,
|
||||
};
|
||||
|
||||
// The bug-report entry, appended under the same `bugReportEnabled`
|
||||
// operator flag that gates the desktop sidebar entry. Pre-splice the
|
||||
// route was desktop-only — a phone user had no path to `/bugreport`.
|
||||
const BUGREPORT_HUB_ITEM: NavLink = {
|
||||
href: "/bugreport",
|
||||
tKey: "nav.bugreport",
|
||||
icon: Bug,
|
||||
};
|
||||
|
||||
export function BottomNav() {
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslations();
|
||||
@@ -99,19 +73,18 @@ export function BottomNav() {
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [captureOpen, setCaptureOpen] = useState(false);
|
||||
|
||||
// v1.17.1 (F-1) — the More hub is every shared destination that isn't an
|
||||
// always-visible primary slot, in the model's order, plus the mobile-only
|
||||
// Notifications / Settings conveniences (and Bug Report behind its flag).
|
||||
// Cycle is filtered by the same account gate the sidebar uses, so the two
|
||||
// surfaces gate it identically.
|
||||
const moreHub = useMemo<ReadonlyArray<NavLink>>(() => {
|
||||
const shared = visibleNavDestinations(user?.cycleTrackingEnabled)
|
||||
.filter((d) => !PRIMARY_SLOT_HREFS.includes(d.href as never))
|
||||
.map((d) => ({ href: d.href, tKey: d.tKey, icon: d.icon }));
|
||||
const tail: NavLink[] = [NOTIFICATIONS_HUB_ITEM, SETTINGS_HUB_ITEM];
|
||||
if (bugReportEnabled) tail.unshift(BUGREPORT_HUB_ITEM);
|
||||
return [...shared, ...tail];
|
||||
}, [user?.cycleTrackingEnabled, bugReportEnabled]);
|
||||
// v1.17.1 (F-1) — the More hub is the model-computed hub: every visible
|
||||
// feature destination that isn't a primary slot, plus the shared utility
|
||||
// tail. Cycle + Bug Report are gated by the same flags the sidebar uses,
|
||||
// so the two surfaces gate identically and cannot drift.
|
||||
const moreHub = useMemo<ReadonlyArray<NavLink>>(
|
||||
() =>
|
||||
mobileMoreHubDestinations({
|
||||
cycleTrackingEnabled: user?.cycleTrackingEnabled,
|
||||
bugReportEnabled,
|
||||
}),
|
||||
[user?.cycleTrackingEnabled, bugReportEnabled],
|
||||
);
|
||||
|
||||
function isActiveLink(href: string) {
|
||||
return isNavDestinationActive(href, pathname);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
Bug,
|
||||
Droplets,
|
||||
Dumbbell,
|
||||
FlaskConical,
|
||||
@@ -8,6 +10,8 @@ import {
|
||||
Lightbulb,
|
||||
MessagesSquare,
|
||||
Pill,
|
||||
Settings,
|
||||
Stethoscope,
|
||||
Trophy,
|
||||
Waves,
|
||||
type LucideIcon,
|
||||
@@ -77,6 +81,18 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
|
||||
icon: FlaskConical,
|
||||
tourId: "nav-labs",
|
||||
},
|
||||
// v1.17.1 — Vorsorge (preventive-care) gets a top-level nav home in the
|
||||
// clinical spine, peer to Labs and Recovery. It is a first-class tracking
|
||||
// surface ("wann muss ich was wo machen"), not pure configuration, so it
|
||||
// belongs in the model both bars render — not buried three taps deep under
|
||||
// Settings → Reminders. The Reminders hub still links to it; this is the
|
||||
// direct front door so the customer knows where it lives.
|
||||
{
|
||||
href: "/vorsorge",
|
||||
tKey: "nav.vorsorge",
|
||||
icon: Stethoscope,
|
||||
tourId: "nav-vorsorge",
|
||||
},
|
||||
{
|
||||
href: "/insights/workouts",
|
||||
tKey: "nav.workouts",
|
||||
@@ -118,6 +134,54 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* v1.17.1 (F-1 residue) — the shared UTILITY tail.
|
||||
*
|
||||
* Bug Report, Settings and Notifications are reachable on both bars but are
|
||||
* not feature destinations: on desktop they live in the sidebar footer +
|
||||
* avatar menu, on mobile at the tail of the "More" hub. They used to be a
|
||||
* second hand-curated list on each bar — the exact drift the one-model
|
||||
* contract above set out to kill, just pushed down a level. This list is
|
||||
* the single source both bars consume for the tail, so the two surfaces
|
||||
* can no longer disagree on which utility links exist or in which order.
|
||||
*
|
||||
* Order is the footer/hub order: Bug Report (gated) → Settings →
|
||||
* Notifications. `bugReportGated` entries drop when the operator flag is
|
||||
* off, exactly as the feature list drops cycle-gated entries.
|
||||
*
|
||||
* Admin is intentionally NOT here: it is a role-gated, desktop-sidebar-only
|
||||
* surface (the mobile bar never exposes it), so it is not a shared tail
|
||||
* destination and stays local to the sidebar.
|
||||
*/
|
||||
export interface NavUtilityDestination {
|
||||
href: string;
|
||||
/** i18n key under the `nav.*` namespace. */
|
||||
tKey: string;
|
||||
icon: LucideIcon;
|
||||
/** Drop the entry unless the operator's bug-report flag is on. */
|
||||
bugReportGated?: boolean;
|
||||
}
|
||||
|
||||
export const NAV_UTILITY_DESTINATIONS: ReadonlyArray<NavUtilityDestination> = [
|
||||
{ href: "/bugreport", tKey: "nav.bugreport", icon: Bug, bugReportGated: true },
|
||||
{ href: "/settings/account", tKey: "nav.settings", icon: Settings },
|
||||
{ href: "/notifications", tKey: "nav.notifications", icon: Bell },
|
||||
];
|
||||
|
||||
/**
|
||||
* The utility tail visible to this account — drops the bug-report entry
|
||||
* when the operator flag is off. Both bars consume this for their tail
|
||||
* (the sidebar footer + avatar menu, the bottom-nav More hub) so the two
|
||||
* surfaces share one definition of the utility links.
|
||||
*/
|
||||
export function visibleUtilityDestinations(
|
||||
bugReportEnabled: boolean | undefined,
|
||||
): NavUtilityDestination[] {
|
||||
return NAV_UTILITY_DESTINATIONS.filter(
|
||||
(d) => !d.bugReportGated || bugReportEnabled === true,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The ordered destinations visible to this account — drops cycle-gated
|
||||
* entries when the flag is off. Both bars start from this.
|
||||
@@ -130,6 +194,48 @@ export function visibleNavDestinations(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The mobile bottom-nav's three always-visible primary slots
|
||||
* (Home · Meds · Insights). Every other feature destination falls into the
|
||||
* "More" hub. Kept here (not in the bar) so the headline F-1 invariant —
|
||||
* the hub is the shared feature list minus the primary slots, plus the
|
||||
* shared utility tail — is a tested model function, not inline bar logic.
|
||||
*/
|
||||
export const BOTTOM_NAV_PRIMARY_SLOT_HREFS: ReadonlyArray<string> = [
|
||||
"/",
|
||||
"/medications",
|
||||
"/insights",
|
||||
];
|
||||
|
||||
export interface MobileMoreHubEntry {
|
||||
href: string;
|
||||
tKey: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ordered "More" hub for the mobile bottom-nav: every visible feature
|
||||
* destination that isn't a primary slot, in model order, followed by the
|
||||
* shared utility tail (Bug Report behind its flag, Settings, Notifications).
|
||||
* The desktop sidebar renders the same feature list inline and consumes the
|
||||
* same utility tail in its footer + avatar menu, so the two bars cannot
|
||||
* drift into two hand-curated lists.
|
||||
*/
|
||||
export function mobileMoreHubDestinations(opts: {
|
||||
cycleTrackingEnabled: boolean | undefined;
|
||||
bugReportEnabled: boolean | undefined;
|
||||
}): MobileMoreHubEntry[] {
|
||||
const features = visibleNavDestinations(opts.cycleTrackingEnabled)
|
||||
.filter((d) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(d.href))
|
||||
.map((d) => ({ href: d.href, tKey: d.tKey, icon: d.icon }));
|
||||
const tail = visibleUtilityDestinations(opts.bugReportEnabled).map((d) => ({
|
||||
href: d.href,
|
||||
tKey: d.tKey,
|
||||
icon: d.icon,
|
||||
}));
|
||||
return [...features, ...tail];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `href` is the active nav destination for the current `pathname`,
|
||||
* resolved against the full destination set so the most-specific entry
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Bell,
|
||||
Bug,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
LogOut,
|
||||
@@ -21,6 +20,7 @@ import { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medicatio
|
||||
import {
|
||||
isNavDestinationActive,
|
||||
visibleNavDestinations,
|
||||
visibleUtilityDestinations,
|
||||
} from "@/components/layout/nav-model";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
@@ -266,6 +266,118 @@ export function SidebarNav() {
|
||||
});
|
||||
}
|
||||
|
||||
// v1.17.1 (F-1 residue) — the sidebar footer utility links derive from
|
||||
// the SAME shared list the mobile More-hub tail consumes, so the two
|
||||
// surfaces can no longer drift into two hand-curated utility lists.
|
||||
// Notifications is surfaced in the avatar menu (not the footer), so the
|
||||
// footer takes every utility entry except `/notifications`; Admin is the
|
||||
// role-gated, sidebar-only surface and is inserted separately below.
|
||||
const footerUtilityItems = useMemo(
|
||||
() =>
|
||||
visibleUtilityDestinations(bugReportEnabled).filter(
|
||||
(d) => d.href !== "/notifications",
|
||||
),
|
||||
[bugReportEnabled],
|
||||
);
|
||||
|
||||
function isUtilityActive(href: string) {
|
||||
// Settings matches the whole `/settings/*` shell; the rest match exact.
|
||||
return href === "/settings/account"
|
||||
? pathname.startsWith("/settings")
|
||||
: pathname === href;
|
||||
}
|
||||
|
||||
function renderUtilityLink(
|
||||
item: { href: string; tKey: string; icon: typeof Settings },
|
||||
) {
|
||||
const Icon = item.icon;
|
||||
const isActive = isUtilityActive(item.href);
|
||||
const tourId = item.href === "/settings/account" ? "nav-settings" : undefined;
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Tooltip key={item.href}>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-tour-id={tourId}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-lg p-2.5 transition-colors",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{t(item.tKey)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-tour-id={tourId}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{t(item.tKey)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function renderAdminLink() {
|
||||
if (!isAdmin) return null;
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="/admin"
|
||||
aria-current={onAdminPage ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-lg p-2.5 transition-colors",
|
||||
onAdminPage
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{t("nav.admin")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
href="/admin"
|
||||
aria-current={onAdminPage ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
onAdminPage
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
{t("nav.admin")}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function renderCollapseToggle(className?: string) {
|
||||
return (
|
||||
<Tooltip>
|
||||
@@ -402,127 +514,17 @@ export function SidebarNav() {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Bottom utility links */}
|
||||
{/* Bottom utility links — the shared utility tail (minus
|
||||
Notifications, which lives in the avatar menu) with the
|
||||
role-gated Admin entry inserted before Settings. */}
|
||||
<div className={cn("space-y-1 pb-1", collapsed ? "px-1.5" : "px-3")}>
|
||||
{collapsed ? (
|
||||
<>
|
||||
{bugReportEnabled && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="/bugreport"
|
||||
aria-current={
|
||||
pathname === "/bugreport" ? "page" : undefined
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-lg p-2.5 transition-colors",
|
||||
pathname === "/bugreport"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<Bug className="h-4 w-4" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{t("nav.bugreport")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="/admin"
|
||||
aria-current={onAdminPage ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-lg p-2.5 transition-colors",
|
||||
onAdminPage
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{t("nav.admin")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="/settings/account"
|
||||
aria-current={
|
||||
pathname.startsWith("/settings") ? "page" : undefined
|
||||
}
|
||||
data-tour-id="nav-settings"
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-lg p-2.5 transition-colors",
|
||||
pathname.startsWith("/settings")
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{t("nav.settings")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{bugReportEnabled && (
|
||||
<Link
|
||||
href="/bugreport"
|
||||
aria-current={pathname === "/bugreport" ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
pathname === "/bugreport"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<Bug className="h-4 w-4" />
|
||||
{t("nav.bugreport")}
|
||||
</Link>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/admin"
|
||||
aria-current={onAdminPage ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
onAdminPage
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
{t("nav.admin")}
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href="/settings/account"
|
||||
aria-current={
|
||||
pathname.startsWith("/settings") ? "page" : undefined
|
||||
}
|
||||
data-tour-id="nav-settings"
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
pathname.startsWith("/settings")
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
{t("nav.settings")}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{footerUtilityItems
|
||||
.filter((item) => item.href !== "/settings/account")
|
||||
.map((item) => renderUtilityLink(item))}
|
||||
{renderAdminLink()}
|
||||
{footerUtilityItems
|
||||
.filter((item) => item.href === "/settings/account")
|
||||
.map((item) => renderUtilityLink(item))}
|
||||
</div>
|
||||
|
||||
<SidebarUserSection collapsed={collapsed} />
|
||||
|
||||
Reference in new issue
Block a user