{/* R20 — the avatar + name in the expanded footer routes straight
into the account settings; the kebab to the right still opens
the rest of the user menu. */}
{avatarUrl && }
{getInitials(user.username)}
{user.username}
{user.email && (
{user.email}
)}
{dropdownContent}
);
}
export function SidebarNav() {
const pathname = usePathname();
const { t } = useTranslations();
const { user } = useAuth();
const queryClient = useQueryClient();
// v1.16.7 — hover / focus intent on the medications link starts the
// list request before the navigation commits, so the due-time cells
// hydrate from a warm cache instead of serialising behind the chunk.
const medsIntent = medicationsPrefetchIntentProps(queryClient);
const isAdmin = user?.role === "ADMIN";
// Match `/admin` exactly or any `/admin/...` sub-route for active-link
// styling. Plain `startsWith("/admin")` would also flip for a
// hypothetical future `/administrative` page, which is not the same
// semantic surface. The Admin entry mirrors Settings: a single link
// with no sub-item expansion in the global sidebar — ``
// renders its own per-section nav inside the page itself.
const onAdminPage = pathname === "/admin" || pathname.startsWith("/admin/");
// v1.17.1 (F-1) — the sidebar renders the one shared destination model
// (`nav-model.ts`), the same ordered list the mobile bottom-nav derives
// its "More" hub from. v1.18.0 — entries are filtered by the account's
// resolved module map (mood, cycle, labs, coach, achievements …); cycle +
// coach are delegated server-side and already reflected in that map.
// The module map rides the client-only `/api/auth/me` query, so it is not
// settled on SSR or the first client paint. Gating the filter behind
// `useMounted()` keeps SSR and first paint identical (core destinations
// only) and stops a disabled module's entry from flickering in for one
// frame before the query resolves; once mounted the real map applies.
const mounted = useMounted();
const visibleNavItems = useMemo(
() => visibleNavDestinations(user?.modules, mounted),
[user?.modules, mounted],
);
// Collapsed = the user's stored choice, or — with no stored choice — a
// viewport default: tablet widths (md–lg, e.g. an iPad held upright)
// fall back to the icon rail so the content column keeps its room; a
// 256 px sidebar squeezes a 768 px viewport down to a 512 px column.
// Everything is gated on `mounted` (same pattern as the module filter
// above): SSR and the hydration render both paint the expanded shell,
// the stored pref / viewport default applies on the first client render
// after hydration settles. Branching earlier — localStorage or
// matchMedia inside the initial render — is a React #418 hydration
// mismatch, because the sidebar is only CSS-hidden below `md` and still
// hydrates its DOM there.
const tabletOrBelow = useIsMobile("lg");
const [collapsedPref, setCollapsedPref] = useState(() => {
if (typeof window === "undefined") return null;
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored === null ? null : stored === "true";
} catch {
return null;
}
});
const collapsed = mounted ? (collapsedPref ?? tabletOrBelow) : false;
function toggleCollapsed() {
const next = !collapsed;
setCollapsedPref(next);
try {
localStorage.setItem(STORAGE_KEY, String(next));
} catch {
// Ignore storage errors
}
}
// 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().filter((d) => d.href !== "/notifications"),
[],
);
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 (
{t(item.tKey)}
);
}
return (
{t(item.tKey)}
);
}
function renderAdminLink() {
if (!isAdmin) return null;
if (collapsed) {
return (
{t("nav.admin")}
);
}
return (
{t("nav.admin")}
);
}
function renderCollapseToggle(className?: string) {
return (
{t("nav.collapseSidebar")}
);
}
return (
);
}