"use client";
import { useMemo, useRef, useState } from "react";
import {
Bell,
Keyboard,
LogOut,
Monitor,
Moon,
MoreVertical,
PanelLeftClose,
PanelLeftOpen,
Settings,
Shield,
Sun,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { AccountSwitcherMenuItems } from "@/components/layout/account-switcher-menu";
import { openShortcutsHelp } from "@/components/layout/global-shortcuts";
import { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medications";
import {
isNavDestinationActive,
isSettingsUtilityDestination,
visibleNavDestinations,
visibleUtilityDestinations,
} from "@/components/layout/nav-model";
import {
RAIL_ACCOUNT_BAND_CLASS,
RAIL_BAND_INSET,
RAIL_WIDTH_COLLAPSED,
RAIL_WIDTH_EXPANDED,
SHELL_HEADER_BAND,
} from "@/components/layout/shell-metrics";
import {
RailNavList,
useNavOverflows,
} from "@/components/layout/sidebar-nav-scroll";
import { cn } from "@/lib/utils";
import { Logo } from "@/components/ui/logo";
import { useAuth, useLogout } from "@/hooks/use-auth";
import { accountName, nameInitials } from "@/lib/account-name";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { useNavModules } from "@/hooks/use-nav-modules";
import { useMounted } from "@/hooks/use-mounted";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useTheme } from "@/components/providers";
import { useTranslations } from "@/lib/i18n/context";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const SIDEBAR_COLLAPSED_STORAGE_KEY = "healthlog-sidebar-collapsed";
/**
* The expanded sidebar's content width: `w-64` minus the rail's 1 px
* `border-r`, so the column fills the expanded rail exactly. Applied as a
* floor while expanded, it keeps the content at its final layout during the
* width transition (see the `sidebar-column` comment in the render).
*/
export const SIDEBAR_EXPANDED_CONTENT_WIDTH = "min-w-[calc(16rem-1px)]";
/**
* The stored collapse choice: `true` / `false`, or `null` when this browser
* has none (or storage is unavailable), which lets the viewport default decide.
*/
export function readSidebarCollapsedPref(): boolean | null {
if (typeof window === "undefined") return null;
try {
const stored = localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY);
return stored === null ? null : stored === "true";
} catch {
return null;
}
}
export function writeSidebarCollapsedPref(collapsed: boolean): void {
try {
localStorage.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, String(collapsed));
} catch {
// Private mode or blocked storage: the choice lasts for this page only.
}
}
/**
* The sidebar footer's name-and-avatar block.
*
* A link into the account settings normally, and a plain block while this
* browser is acting on somebody else's record. The block names the person at
* the keyboard, and `/settings/account` inside a record is either refused (an
* adult share) or the RECORD's profile card (a managed profile) — a door on
* one's own name into somebody else's settings would mislabel both. A record
* that has Settings pages reaches them through the footer Settings entry,
* which is labelled as Settings and lands where the shell opens.
*/
function FooterIdentity({
sharedRecord,
label,
children,
}: {
sharedRecord: boolean;
label: string;
children: React.ReactNode;
}) {
const className =
"flex min-w-0 flex-1 items-center gap-3 rounded-md transition-colors";
if (sharedRecord) {
return
{children}
;
}
return (
{/* The purpose leads the accessible name; the visible name and email
follow it, so the link is named by what it shows (WCAG 2.5.3). */}
{label}
{children}
);
}
function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
const { user } = useAuth();
const logout = useLogout();
const { theme, setTheme } = useTheme();
const { t } = useTranslations();
const avatarUrl = user?.avatarUrl ?? null;
// v1.36.0 — while this browser is acting on somebody else's record the
// account surfaces are unreachable, so the avatar stops being a door into
// them. It still names the person at the keyboard, which is exactly what a
// switched session needs it to do.
const sharedRecord = user?.accountAccess?.active != null;
if (!user) return null;
const name = accountName(user);
const themeIcon =
theme === "system" ? (
) : theme === "dark" ? (
) : (
);
const identityBlock = (
<>
{avatarUrl && }
{nameInitials(name)}
{name}
{user.email && (
{user.email}
)}
>
);
const dropdownContent = (
{/* The user header is the direct route into the account
settings. Clicking the avatar / name jumps straight to
`/settings/account` rather than making the user hunt for a
separate "Settings" entry. */}
{sharedRecord ? (
{identityBlock}
) : (
{identityBlock}
)}
{!sharedRecord && (
{t("nav.notifications")}
)}
{/* v1.42 — the keyboard shortcuts, findable without knowing that `?`
lists them. Desktop only, like this menu: the phone's account menu
has no keyboard to describe. */}
openShortcutsHelp()}
className="cursor-pointer"
>
{t("shortcuts.menuEntry")}
?
{/* The about section lives at the end of the settings shell nav;
the avatar menu stays focused on account-level actions. */}
{themeIcon}
{t("nav.theme")} setTheme("system")}>
{t("nav.themeSystem")}
{theme === "system" && (
✓
)}
setTheme("dark")}>
{t("nav.themeDark")}
{theme === "dark" && (
✓
)}
setTheme("light")}>
{t("nav.themeLight")}
{theme === "light" && (
✓
)}
{/* v1.36.0 — the records this account may open. Renders nothing when
nobody has shared one, so the menu is unchanged for every account
that does not use sharing. */}
logout.mutate()}
className="text-destructive focus:text-destructive focus:bg-destructive/10 cursor-pointer"
>
{t("nav.logout")}
);
if (collapsed) {
return (
{/* 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 && }
{nameInitials(name)}
{name}
{user.email && (
{user.email}
)}
{dropdownContent}
);
}
/**
* Height-based density for the rail. A 13-inch laptop at 100 % zoom leaves
* the browser roughly 700–860 px, and the full destination list plus the
* footer rows did not fit: the list scrolled, and on a classic-scrollbar
* platform (Windows) the rail grew a native scrollbar with arrow buttons.
* On short windows the rows close their gaps first, then step their height
* down; tall windows keep the roomy default. Desktop-only by construction —
* the rail is hidden below `md`, so the phone layout never sees these steps.
*
* Steps (rows are full-width, so the click target is the whole row):
* - default: 40 px rows, 4 px apart
* - max-height 940 px: gaps close (40 px pitch), the list padding tightens
* (the account band keeps the logo band's height, `RAIL_ACCOUNT_BAND`)
* - max-height 820 px: 36 px rows
* - max-height 740 px: 32 px rows — the one step under 40 px, taken only
* where the alternative is rows hidden behind a scroll edge
*/
// Disjoint height ranges, not nested max-heights: two arbitrary media
// variants on one property cascade in stylesheet order, not by how narrow
// the query is, so a nested pair let the 36 px step win on a 720 px window.
// The height steps apply to a mouse only. Under a finger (a touch tablet)
// the rows keep the 44 px touch floor and the list scrolls behind its fades
// instead. A phone held sideways never sees the rail: it keeps the phone
// shell (`shell-mobile`).
const RAIL_ROW_HEIGHT =
"min-h-10 pointer-coarse:min-h-11 [@media(hover:hover)_and_(pointer:fine)_and_(740px void;
}) {
const { t } = useTranslations();
const Icon = collapsed ? PanelLeftOpen : PanelLeftClose;
const actionLabel = t(
collapsed ? "nav.expandSidebarLabel" : "nav.collapseSidebarLabel",
);
const button = (
);
if (!collapsed) return button;
return (
{button}
{actionLabel}
);
}
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 — 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();
// v1.36.0 — while this browser is acting on somebody else's record, the nav
// shows only what sharing covers. The server refuses the rest regardless;
// dropping the entries spares a delegate a click that ends in a 403.
const {
inSharedRecord: sharedRecord,
sections,
recordKind,
level,
} = useRecordCapabilities();
// The sections the grant may manage, bound from the server-resolved entry.
// The Settings entry needs them: a record-content page is offered only when
// the grant manages the section its forms write.
const manageableDomains = user?.accountAccess?.active?.manageableDomains;
// The Coach entry follows the `coach` AI capability, like every other
// Coach entry point; the rest of the map is `modules` unchanged.
const navModules = useNavModules();
const visibleNavItems = useMemo(
() => visibleNavDestinations(navModules, mounted, sharedRecord, sections),
[navModules, mounted, sharedRecord, sections],
);
// 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(
readSidebarCollapsedPref,
);
const collapsed = mounted ? (collapsedPref ?? tabletOrBelow) : false;
const navRef = useRef(null);
const navEdges = useNavOverflows(navRef);
const navOverflows = navEdges.overflows;
function toggleCollapsed() {
const next = !collapsed;
setCollapsedPref(next);
writeSidebarCollapsedPref(next);
}
// v1.17.1 (F-1 residue) — the sidebar footer utility links derive from
// the SAME shared list the mobile user menu 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.
//
// Under a switch the list answers for the record on screen: a Settings
// entry exactly when the Settings shell lists a section for it, pointing at
// the first one (see `visibleUtilityDestinations`).
const footerUtilityItems = useMemo(
() =>
visibleUtilityDestinations({
record: sharedRecord
? { recordKind, level, manageableDomains: manageableDomains ?? [] }
: null,
}).filter((d) => d.href !== "/notifications"),
[sharedRecord, recordKind, level, manageableDomains],
);
function isUtilityActive(href: string) {
// Settings matches the whole `/settings/*` shell; the rest match exact.
return isSettingsUtilityDestination({ href })
? 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 isSettings = isSettingsUtilityDestination(item);
const tourId = isSettingsUtilityDestination(item)
? "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")}
);
}
return (
);
}