import React, { createContext, useContext } from "react"; /** * The patient portal's navigation, published so that MORE THAN ONE SURFACE CAN RENDER IT. * * / design decision D6 - "nothing important lives ONLY behind an icon". The patient home * carries an "Everything else" list so a patient who never opens the hamburger can still reach every * part of the product. That list MUST be the same array the sidebar renders, never a second * hand-written one, for two reasons and the second is a safety one: * * 1. DRIFT - a destination added to the sidebar later would not appear in the home list, and the * guarantee ("every part of the product") would be false with nothing failing. This codebase's * most common defect is a surface deriving its own answer to a question another surface already * answers (claude-4's UI pass named it as one of the three biggest root causes). * * 2. ADOLESCENT FILTERING - `patientMenuItems` is filtered by `ADOLESCENT_18_PLUS_NAV_IDS` * before it reaches the sidebar. A hand-rolled home list would NOT inherit that filter and * would surface messaging and collaboration teams to a minor whose own portal deliberately * hides them. Consuming the filtered array makes that impossible rather than remembered. * * The provider carries the array DashboardPage already computed. Nothing is moved and no logic is * duplicated - this is a publication point, not a second source. */ /** * The patient's own profile page. The drawer's "Edit my profile", the home's "My details" door and * the record's "My details" row all open it, so they read this one string. The chart's * `/dashboard/patient/profile` is a second rendering of the same page inside the record shell, * titled as the record, and nothing should link to it. */ export const MY_PROFILE_PATH = "/dashboard/profile"; export type SidebarMenuItem = { id: string; label: string; icon: React.ReactNode; to?: string; onClick?: () => void; activeMatch?: "exact" | "prefix"; activePaths?: string[]; /** Routes under `activePaths` that belong to another group, so this one stays unlit there. */ exceptPaths?: string[]; unreadCount?: number; showUnread?: boolean; /** Some reads behind the count failed: it is a floor, not an exact number. */ unreadIncomplete?: boolean; isLogout?: boolean; children?: SidebarMenuItem[]; }; /** One reachable place in the patient portal, as the home list renders it. */ export type PatientDestination = { id: string; label: string; to: string; /** The nearest ancestor group's label ("Clinical Data"), or undefined for a top-level row. */ section?: string; unreadCount?: number; showUnread?: boolean; }; /** * The one item the home list must NOT carry. Log Out is a control, not a destination - and it is * not a hypothetical: claude-4's nav sweep signed itself out by walking `.dashboard-nav-item` * indiscriminately, because Log Out looks exactly like every other row. A list whose stated purpose * is "tap anything here to go somewhere" must not contain the one row that ends the session. */ const isLogoutItem = (item: SidebarMenuItem) => item.isLogout === true || item.to === "#"; /** The home page itself. Offering a row that reloads the page you are on is noise, not navigation. */ const HOME_ITEM_ID = "overview"; /** * Flattens the patient nav into every place a patient can actually go. * * A GROUP (no `to`, has children) contributes no row of its own - it has no landing page, so a row * for it would either dead-end or navigate somewhere arbitrary. Its label becomes the section * heading above its own destinations instead, which is what makes a 40-item list readable. * * The patient's own chart has NO in-page navigation: `PatientChartSidebar` is rendered only on * the STAFF side (`!isPatient`), so a patient landing on "My Records" cannot reach Conditions or * Medications from there. That is precisely why this flattens all the way to the leaves rather than * stopping at the top level and trusting the destination to carry the patient onwards. */ export const patientHomeDestinations = ( items: SidebarMenuItem[], section?: string, ): PatientDestination[] => items.flatMap((item) => { if (isLogoutItem(item)) return []; if (!section && item.id === HOME_ITEM_ID) return []; if (item.to) { return [ { id: item.id, label: item.label, to: item.to, section, unreadCount: item.unreadCount, showUnread: item.showUnread, }, ]; } return item.children?.length ? patientHomeDestinations(item.children, item.label) : []; }); const PatientNavContext = createContext([]); export const PatientNavProvider = ({ items, children, }: { items: SidebarMenuItem[]; children: React.ReactNode; }) => {children}; /** * Returns the patient nav, or an empty array outside the dashboard shell. * * Deliberately does NOT throw when unmounted. The same rule landed on: an optional * refinement must never be able to take down a working surface. A missing provider costs the * "Everything else" list, not the patient's home page. */ export const usePatientNav = (): SidebarMenuItem[] => useContext(PatientNavContext);