merge(nav): top-level Vorsorge home, shared utility destinations with parity guard

This commit is contained in:
Marc-André Bombeck committed 2026-06-15 01:24:50 +02:00
commit eab86b21d5
17 files changed
+469 -178

No files matched your search

+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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."
+4 -2
View File
@@ -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>`
+18 -45
View File
@@ -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);
+106
View File
@@ -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
+123 -121
View File
@@ -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>
+6 -1
View File
@@ -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 -1
View File
@@ -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>
);
}