import { MY_PROFILE_PATH, patientHomeDestinations, type PatientDestination, type SidebarMenuItem } from "../patientNav.tsx"; /** * The patient home's "Everything else" - nine doors in patient words, design spec ยง7. * * WHAT THIS REPLACES AND WHY. The first build rendered the navigation tree flattened to its leaves * - about forty rows, in our vocabulary, with *Vision Prescriptions* carrying the same weight as * *Messages*. That is the same "the bottom does not get read" mechanism the ranked queue was designed * around, so the escape hatch reproduced the problem it existed to solve. * * BUT ITS TWO REASONS FOR READING THE NAV ARRAY WERE RIGHT, AND BOTH SURVIVE HERE: * 1. DRIFT - a destination added later would silently never appear in a hand-written list, and the * promise "every part of the product is reachable" would quietly become false. * 2. ADOLESCENT FILTERING - `ADOLESCENT_18_PLUS_NAV_IDS` removes destinations before the nav is * rendered. An authored string cannot be filtered by a filter it never passed through. * * **The label is authored; its visibility is DERIVED.** A door owns routes and renders only when at * least one of them survives. Never render this list from a static array of strings. * * CLAIMS ARE KEYED ON THE ROUTE, NEVER THE NAV ID - measured, not stylistic: **`care-teams` is used * as an id TWICE** (`DashboardPage.tsx:880` the clinical Care Teams leaf, `:1063` the top-level * Collaboration Teams). They are different destinations. Keyed on id, one door would silently claim * the other's page - and the flat list this replaces was rendering both under one React key. */ export type PatientHomeDoor = { id: string; /** What the patient sees. Authored, in their words - never a resource type or our section name. */ label: string; /** * The quiet line under the label, for a door whose label cannot say what is behind it. * * ONE DOOR NEEDS IT. "My visits & results" is thirty-four kinds of record, and a patient hunting for * a lab result has no way to know from the label that this is where it lives. The approved overview * deck writes it out under that row and under no other - a note on every row would be a second list * to read, which is the failure the forty-row dump this replaced already demonstrated. */ note?: string; /** Exact routes this door is responsible for. */ owns: string[]; /** * Where tapping the door GOES, when that is not simply its first surviving destination. * * WHY THIS EXISTS, and it is a defect between two pieces of our own code rather than a design * choice. `App.tsx` says of the record index, in as many words: *"on the PATIENT'S OWN chart the * index is the record itself, not a redirect into one leaf of it. **This is what the patient home's * 'My visits & results' door opens onto**"*. It was not. `to` resolved to `owned[0]`, and the only * destination the nav publishes for that door is the appointments page - so the door opened on a * bookings list and the record's own Visits and Test results sections were never on it. * Ed's 2026-08-21 run: *"'My visits & results' contains no results."* * * NOT ADDED TO `DECLARED_ROUTES`, deliberately. A declared route can keep a door ALIVE with no * surviving destination behind it, which is why that set holds exactly one thing the adolescent * filter cannot touch. `landsOn` only decides WHERE a door that already survived goes - visibility * stays derived, so a filtered-out record still removes the door rather than pointing at an empty one. */ landsOn?: string; /** * Everything under this path, unless a door claims it exactly. This is what keeps the record's ~34 * leaves behind one door without listing them: a clinical page added later is reachable the day it * exists. An exact claim always beats a prefix, which is how Insurance keeps its own door while * living under the record's path. */ ownsPrefix?: string; }; /** The order is the spec's, and it is the order a patient scans. */ export const PATIENT_HOME_DOORS: PatientHomeDoor[] = [ { id: "messages", label: "Messages", owns: ["/dashboard/messages"] }, { id: "records", // TITLE CASE, AND IT STAYS. The overview deck writes this row in sentence case, but the name // itself is settled across four surfaces - this door, the nav item, the breadcrumb and the page's // own heading - by Spencer on 2026-08-25: *"my visits & results say 'My Visits & Results'."* // Changing it here alone is how a patient taps one name and arrives at another. label: "My Visits & Results", note: "Notes, lab results, medications, conditions", // The record INDEX is owned explicitly, not just by the prefix below that prefix carries a // trailing slash, so it never matched `/dashboard/patient` itself. Invisible until gave the // drawer group a route of its own and the index became a destination the coverage test could see. // The door already lands on it, so it plainly owns it. // Records brought in from another health system join this record, so their connections open behind this door too. owns: ["/dashboard/my-appointments", "/dashboard/patient", "/dashboard/my-records/connections"], ownsPrefix: "/dashboard/patient/", // Spencer, 2026-08-21: *"if it says my visits and results then labs and imaging // should be there since they are results."* The record index is where they are - its Test results // section owns clinical-results, imaging-studies, personal-readings and media. The label was never // over-promising; the door was opening on the wrong page. landsOn: "/dashboard/patient", }, { id: "billing", label: "Bills & payments", owns: ["/dashboard/my-invoices"] }, { id: "insurance", label: "Insurance", owns: ["/dashboard/patient/insurance"] }, // `my-history` - "who has seen my record" - sits with the patient's own account rather than with // the clinical record: it answers a question about THEM, not about their care. `settings` likewise; // the coverage test against the real nav is what turned it up, unplaced. // - `/dashboard/profile` IS THE REAL PAGE, and this door was claiming only the declared // one. `/dashboard/patient/profile` only draws the profile inside the record shell; // `/dashboard/profile` is the router path that actually renders `ProfilePage`. So the door owned the // label and not the destination, and the coverage test was right to say nobody placed it. // It went unclaimed the moment it acquired a menu item. Before that the only way in was the // sidebar avatar - an unlabelled affordance the two-line header removes for a patient anyway - so // the route existed without ever being a listed destination. Giving it a door is the same // reasoning that gave it a menu item, applied to the other surface. { id: "details", label: "My details", // Lands on MY_PROFILE_PATH, the page the drawer's "Edit my profile" opens. It used to land on // `/dashboard/patient/profile`, the same page drawn inside the record shell and titled "My Visits & // Results". That path stays owned here so its crumb and door still agree if anyone arrives on it. owns: [ MY_PROFILE_PATH, "/dashboard/patient/profile", "/dashboard/my-history", "/dashboard/settings", ], }, { id: "practices", label: "My practices", owns: ["/dashboard/providers"] }, // `Related persons` `Family & caregivers` (Spencer, 2026-08-13): FHIR vocabulary was sitting on // a patient's screen. The phrase a patient recognises is the one they would use themselves. { id: "family", label: "Family & caregivers", owns: ["/dashboard/related-persons"] }, // Still the product's own words, and the harder rename. It must NOT become "care team" - // collaboration teams are deliberately not the clinical FHIR CareTeam, and borrowing that name // merges two different things in a patient's head. { id: "collaboration", label: "Collaboration teams", owns: ["/dashboard/collaboration-teams"] }, // Last, because it is the last resort: the portal itself is broken. A problem is reported from the // help widget and followed here, and a reader with no way back to their report telephones the // practice, which cannot fix the software. DERIVED rather than stated here: the nav row is gated on // the roles the server's routes admit, and this door follows that gate instead of restating it. { id: "support", label: "Help & support", owns: ["/dashboard/my-support"] }, ]; /** * Routes reachable from a control that is always on screen. These count as claimants in the * coverage test and that is NOT optional: booking left the drawer because the header button covers * it, so a test that only knew about doors would fail on exactly the change Spencer asked for, and * the next person would "fix" it by putting the row back. * * BOOKING IS NO LONGER IN THIS LIST BECAUSE IT IS NO LONGER A ROUTE. The header button now opens a * dialog hosted at the portal frame; `/dashboard/book-appointment` survives only as a redirect for * anyone who bookmarked it. A control with no route of its own cannot be a claimant for one. */ export const STANDING_UTILITY_ROUTES = ["/dashboard/messages"]; /** * The doors whose route the drawer nav does not publish. * * TWO, AND BOTH ARE OUTSIDE THE ADOLESCENT FILTER. That is the whole justification for the set, so * check it before adding a third: `ADOLESCENT_18_PLUS_NAV_IDS` is `messages` and * `collaboration-teams`, and a declared route for either would silently keep a door alive for a minor * that the filter had just removed. * * - the patient's own profile - see PROFILE_SECTION in the chart nav. * - INSURANCE. Spencer, 2026-08-29: *"the insurance page in the mockups are correct, point everything * at that insurance page."* The page and its routes exist; what it lost was its way in. The approved * navigation deck takes it out of the DRAWER - *"now that insurance is three fields on the profile * instead of a destination of its own"* - and the approved overview deck still lists it here, which is * consistent: it is a door on the home, not a row in the drawer. Without this line the door resolved * to nothing and vanished from the one list that was still meant to carry it. */ const DECLARED_ROUTES = new Set([MY_PROFILE_PATH, "/dashboard/patient/insurance"]); const claimsExactly = (door: PatientHomeDoor, route: string) => door.owns.includes(route); /** * Which door owns a route: an exact claim first, then the longest prefix. Deterministic on purpose - * "some door probably has it" is how a page ends up behind two doors or none. */ export const doorFor = (route: string): PatientHomeDoor | undefined => { const exact = PATIENT_HOME_DOORS.find((door) => claimsExactly(door, route)); if (exact) return exact; return PATIENT_HOME_DOORS.filter((door) => door.ownsPrefix && route.startsWith(door.ownsPrefix)).sort( (a, b) => (b.ownsPrefix?.length ?? 0) - (a.ownsPrefix?.length ?? 0), )[0]; }; export type ResolvedDoor = PatientHomeDoor & { /** Where tapping the door goes: its first surviving destination. */ to: string; destinations: PatientDestination[]; /** Present only where the nav itself carries one. See the note in `resolvePatientHomeDoors`. */ unreadCount?: number; }; /** * Resolves the authored doors against the nav the sidebar was actually given. * * COUNTS. Only the count the nav already carries (unread messages) is rendered. The spec's other * badges - an unpaid total, how many practices - are real, but they need data this list does not have, * and D3's binding constraint is that **the strip, the rows and the peer counts read ONE selector**. * Inventing a second counting path here would ship the defect the design exists to prevent, inside the * feature meant to prevent it. They arrive with the account summary band, which owns that selector. */ export const resolvePatientHomeDoors = (navItems: SidebarMenuItem[]): ResolvedDoor[] => { const destinations = patientHomeDestinations(navItems); const byDoor = new Map(); for (const destination of destinations) { const door = doorFor(destination.to); if (!door) continue; byDoor.set(door.id, [...(byDoor.get(door.id) ?? []), destination]); } // No nav at all - outside the dashboard shell, or not resolved yet. Nothing renders, including the // declared-route door below: a list of labels against a nav we have not been given is a guess. if (destinations.length === 0) return []; return PATIENT_HOME_DOORS.flatMap((door) => { const owned = byDoor.get(door.id) ?? []; const declared = door.owns.find((route) => DECLARED_ROUTES.has(route)); // A DECLARED ROUTE MAY KEEP A DOOR ALIVE ONLY BECAUSE EVERYTHING IN `DECLARED_ROUTES` IS OUTSIDE // THE ADOLESCENT FILTER - the patient's own profile and insurance. That is the whole justification, // so adding a filterable route to that set would silently bypass the gate. Without this, // "My details" would vanish the day the nav stopped carrying my-history or settings, and the // patient's own account would be back to hamburger-only - the thing the spec calls out. if (owned.length === 0 && !declared) return []; const counted = owned.find((destination) => destination.showUnread && (destination.unreadCount ?? 0) > 0); return [ { ...door, // Prefer the declared route where there is one: "My details" must open onto the profile, not // onto whichever of its other pages happens to sort first. `landsOn` is the same idea for a // door whose landing page is not one the nav publishes at all - see its note on the type. to: declared ?? door.landsOn ?? owned[0]!.to, destinations: owned, unreadCount: counted?.unreadCount, }, ]; }); }; /** * Every destination the nav offers that no door and no standing utility can reach. Must be empty - * this is the drift guarantee the forty-row list gave, kept without the forty rows. */ export const unclaimedDestinations = (navItems: SidebarMenuItem[]): PatientDestination[] => { const utilities = new Set(STANDING_UTILITY_ROUTES); return patientHomeDestinations(navItems).filter( (destination) => !doorFor(destination.to) && !utilities.has(destination.to), ); };