mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
merge(nav): top-level Vorsorge home, shared utility destinations with parity guard
This commit is contained in:
commit
eab86b21d5
17 files changed
+469
-178
No files matched your search
+3
-1
@@ -250,7 +250,8 @@
|
||||
},
|
||||
"cycle": "Zyklus",
|
||||
"labs": "Laborwerte",
|
||||
"recovery": "Erholung"
|
||||
"recovery": "Erholung",
|
||||
"vorsorge": "Vorsorge"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Anmelden",
|
||||
@@ -3863,6 +3864,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",
|
||||
@@ -3863,6 +3864,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",
|
||||
@@ -3863,6 +3864,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",
|
||||
@@ -3863,6 +3864,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",
|
||||
@@ -3863,6 +3864,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ę",
|
||||
@@ -3863,6 +3864,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."
|
||||
|
||||
@@ -30,8 +30,10 @@ import {
|
||||
import { SettingsShell } from "@/components/settings/settings-shell";
|
||||
|
||||
/**
|
||||
* Dynamic settings section route. Each of the eight `SETTINGS_SECTION_SLUGS`
|
||||
* is pre-rendered at build via `generateStaticParams()` so the URLs are
|
||||
* Dynamic settings section route. Each of the `SETTINGS_SECTION_SLUGS`
|
||||
* (16 today — 12 nav-visible sections plus the four routable-but-hidden
|
||||
* Layout child editors) is pre-rendered at build via
|
||||
* `generateStaticParams()` so the URLs are
|
||||
* statically known to Next.js, while the `dynamicParams = false` flag below
|
||||
* tells the router to 404 (instead of attempting on-demand rendering) for any
|
||||
* slug not in the list — which is exactly what `notFound()` would do at
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { DashboardLayoutSection } from "@/components/settings/dashboard-layout-section";
|
||||
import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export function DashboardSection() {
|
||||
@@ -11,7 +12,11 @@ export function DashboardSection() {
|
||||
aria-labelledby="settings-section-dashboard-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<header className="space-y-2">
|
||||
<SettingsHubBackLink
|
||||
href="/settings/layout"
|
||||
labelKey="settings.sections.layout.backToHub"
|
||||
/>
|
||||
<h1 id="settings-section-dashboard-title" className="sr-only">
|
||||
{t("settings.sections.dashboard.title")}
|
||||
</h1>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { InsightsOverviewArrangeSection } from "@/components/settings/insights-overview-arrange-section";
|
||||
import { InsightsPillOrderSection } from "@/components/settings/insights-pill-order-section";
|
||||
import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
@@ -23,7 +24,11 @@ export function InsightsSection() {
|
||||
aria-labelledby="settings-section-insights-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<header className="space-y-2">
|
||||
<SettingsHubBackLink
|
||||
href="/settings/layout"
|
||||
labelKey="settings.sections.layout.backToHub"
|
||||
/>
|
||||
<h1 id="settings-section-insights-title" className="sr-only">
|
||||
{t("settings.sections.insights.title")}
|
||||
</h1>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ReorderMedication,
|
||||
} from "@/components/medications/medication-order-editor";
|
||||
import { MedicationViewToggle } from "@/components/medications/medication-view-toggle";
|
||||
import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { applyMedicationOrder } from "@/lib/medications/medication-order";
|
||||
@@ -68,7 +69,11 @@ export function MedicationsSection() {
|
||||
aria-labelledby="settings-section-medications-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<header className="space-y-2">
|
||||
<SettingsHubBackLink
|
||||
href="/settings/layout"
|
||||
labelKey="settings.sections.layout.backToHub"
|
||||
/>
|
||||
<h1 id="settings-section-medications-title" className="sr-only">
|
||||
{t("settings.sections.medications.title")}
|
||||
</h1>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ArchivedTagsCard } from "@/components/mood/manage/archived-tags-card";
|
||||
import { TagGroupsCard } from "@/components/mood/manage/tag-groups-card";
|
||||
import { TagManagerCard } from "@/components/mood/manage/tag-manager-card";
|
||||
import { useMoodTagManage } from "@/components/mood/manage/use-mood-tag-manage";
|
||||
import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
@@ -44,7 +45,11 @@ export function MoodSection() {
|
||||
aria-labelledby="settings-section-mood-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<header className="space-y-2">
|
||||
<SettingsHubBackLink
|
||||
href="/settings/layout"
|
||||
labelKey="settings.sections.layout.backToHub"
|
||||
/>
|
||||
<h1 id="settings-section-mood-title" className="sr-only">
|
||||
{t("settings.sections.mood.title")}
|
||||
</h1>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.17.1 (N-5) — the up-affordance for a hub child editor.
|
||||
*
|
||||
* The four Layout personalization editors (dashboard / insights /
|
||||
* medications / mood) are reached through the "Layout & Personalization"
|
||||
* hub (or a page-header cog), and the left rail highlights the hub while
|
||||
* the body shows the child. The return path existed only implicitly (tap
|
||||
* the highlighted rail entry); this is the explicit "← back to hub" link
|
||||
* at the top of the child, so the hub → child → hub loop reads clearly,
|
||||
* especially on mobile where the rail collapses to a chip strip.
|
||||
*/
|
||||
export function SettingsHubBackLink({
|
||||
href,
|
||||
labelKey,
|
||||
}: {
|
||||
href: string;
|
||||
labelKey: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground -mx-1 inline-flex w-fit items-center gap-1 rounded-md px-1 py-0.5 text-xs font-medium transition-colors",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
{t(labelKey)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user