import { MY_PROFILE_PATH, patientHomeDestinations, type PatientDestination, type SidebarMenuItem } from "../../patientNav.tsx"; /** * The patient's own record, in the patient's own words.. * * THE DEFECT THIS CLOSES. `PatientChartSidebar` renders only when `!isPatient`, so a STAFF member * looking at a chart gets in-page navigation and **the patient whose chart it is gets none**. Every * record section - conditions, medicines, results, documents - was reachable only through the * hamburger, which is exactly what design ruling D6 exists to forbid, for exactly the cohort it names * (senior, tech-averse, and the people least likely to read a hamburger as a menu). * * NOT the staff sidebar moved across. The staff chart nav is built for a clinician scanning a whole * patient; this is a person looking for their own blood test. * NOT the 36 FHIR resource types listed out. That is the forty-row problem relocated one level down. * * THE CONSTRUCTION, and it is the same rule lands for the home's door list: **the label is * authored, its visibility is DERIVED.** Each section owns a set of nav ids and renders only when at * least one of them survives the nav's own filtering. A hand-written list could not be filtered by a * filter it never passed through - and `ADOLESCENT_18_PLUS_NAV_IDS` removes destinations from a * minor's portal before the nav is ever rendered. */ export type PatientRecordSection = { id: string; /** What the patient sees. Authored - never a resource type. */ label: string; /** Nav item ids this section OFFERS as rows. Every record destination belongs to exactly one section. */ owns: string[]; /** * Ids this section is accountable for but does NOT offer, because another surface is where the * patient meets them. Kept claimed so that every destination still belongs to exactly one section * and none can go missing silently - the alternative is an unowned id, which reads the same as one * nobody noticed. */ alsoClaims?: string[]; /** * Words a patient would actually type. The spec's example is the test: *"nobody looks for a blood * test under Diagnostics"* - so search has to match the thing, not our filing system. */ keywords?: string[]; }; /** * The ORDER is the display order, and it is not alphabetical: the things a patient looks for most * are first. Alphabetical would open on "Care plans" and bury "Test results" in the middle. */ export const PATIENT_RECORD_SECTIONS: PatientRecordSection[] = [ // RETIRED 2026-08-26 Spencer: *"fold in the two leftovers then retire those two."* // // `Visits` held `encounters` and `episodes-of-care`. Every visit is now a row in the stream that // OPENS onto what it produced, and a course of care is an event beside them. A destination listing // the same encounters flat, with nothing to open, was the thing the stream replaced. // The nav ids are NOT deleted they are claimed below so the coverage test still accounts for // every destination, which is what stops one going missing silently. { id: "results", label: "Test results", owns: [ "clinical-results", "personal-readings", "imaging-studies", "media", "body-structures", ], // CLAIMED, NOT OFFERED. These were folded in here when `Visits` was retired, and offering them // as rows put "Visits" and "Courses of care" underneath a heading that says "Test results" - on // the record page and, once the drawer was built from these sections, in the drawer too. They are // the flat lists the stream replaced: a visit is a row on the record index that opens onto what // it produced, so that index is where a patient meets one. The routes stay mounted. alsoClaims: ["encounters", "episodes-of-care"], keywords: ["blood test", "lab", "labs", "bloods", "x-ray", "xray", "scan", "mri", "ct", "ultrasound", "imaging", "reading", "blood pressure", "weight"], }, { id: "medicines", label: "Medicines", owns: [ "medication-statements", "medication-requests", "medication-administrations", "medication-dispenses", "immunizations", "immunization-evaluations", "immunization-recommendations", ], keywords: ["medication", "drug", "prescription", "pills", "tablets", "refill", "pharmacy"], }, // RETIRED 2026-08-26, same ruling. `Vaccinations` held the shots themselves now on the `Now` // lens and the recommendations, which are what "what is due" reads. Its ids move to `medicines`, // the nearest surviving section, so nothing is unclaimed. { id: "conditions-allergies", label: "Conditions & allergies", owns: ["conditions", "allergy-intolerances", "adverse-events", "detected-issues"], keywords: ["diagnosis", "diagnoses", "allergy", "allergies", "reaction", "problem", "illness"], }, { // - THE CLINICAL NOTE IS OUT OF "Conditions & allergies", and that filing was the card's // own worked example of what is wrong with organising a record by FHIR resource type: *"why // Tuesday's note is filed under Conditions & allergies"*. A note is not a condition and it is not // an allergy; it is what somebody wrote about a visit. // // A NOTE'S REAL HOME IS THE VISIT THAT PRODUCED IT, and that now exists - the visit container // opens onto it. But the destination is NOT retired, because a note written from the chart // OUTSIDE a visit carries no encounter, and that is routine rather than rare: `encounterState` is // set in exactly two places, and the card is explicit that a note recorded from a chart opened // directly is correctly left unstamped. Retiring this destination would make every one of those // unreachable - and clinical notes are the patient's to read BY DEFAULT, so losing one is not a // tidying-up. It gets an honest label instead of somebody else's. id: "notes", label: "Notes from your visits", owns: ["clinical-impressions"], keywords: ["note", "notes", "wrote", "assessment", "impression", "summary", "what the doctor said"], }, { id: "procedures", label: "Procedures & surgery", owns: ["procedures"], keywords: ["operation", "surgery", "biopsy", "treatment"], }, { id: "family-history", label: "Family health history", owns: ["family-history"], keywords: ["family", "inherited", "hereditary", "relatives"], }, { id: "care-plans", label: "Care plans & goals", owns: ["care-plans", "goals", "care-teams", "outside-providers", "nutrition-orders", "risk-assessments", "vision-prescriptions"], keywords: ["plan", "goal", "diet", "nutrition", "glasses", "vision", "risk", "care team"], }, { id: "devices", label: "Devices & equipment", owns: ["devices", "device-use-statements", "device-requests"], keywords: ["implant", "pacemaker", "monitor", "equipment", "wheelchair", "hearing aid"], }, { id: "documents", label: "Forms & documents", owns: ["documents", "questionnaire-responses"], keywords: ["form", "questionnaire", "paperwork", "letter", "upload", "consent"], }, // "Things to do" IS GONE FROM THE RECORD - Spencer, 2026-08-25: *"Tasks should NOT be under My // visits & results, they should be under their own page."* Tasks is a top-level destination in the // patient's nav, so listing it here as well would put it back inside the record it was taken out of, // and give the same screen two routes in with two different names. // AND INSURANCE IS GONE FROM THE RECORD FOR THE SAME REASON, 2026-08-30. The rebuild that made // medications and care plans their own pages also settled that "insurance stops pretending to be part // of the record", and Spencer's 08-29 ruling gave it a door of its own on the patient home. It kept its // entry here, so `getBreadcrumbConfig` ended up naming `/dashboard/patient/insurance` TWICE - a literal // parented to the dashboard, and a generated one parented to the record - and the literal silently won. // That is the two-vocabularies defect the note above describes, in its second instance: the same screen // with two routes in and two different names. The literal is the correct one; this entry was the leftover. { id: "access", label: "My record activity", owns: ["my-history"], // "seen" and "viewed" stay in the keywords deliberately. The label moved because it did not cover // changes or visits; the words a patient TYPES when looking for it did not move with it. keywords: ["access", "history", "audit", "who", "viewed", "seen", "privacy", "activity"], }, ]; /** * The patient's own account details. DECLARED rather than derived, and that is a deliberate * exception worth knowing about: **`profile` is not in the patient sidebar at all** - the route * `/dashboard/patient/profile` exists and nothing in the nav points at it, which is why the design * spec calls out "where the profile went". Safe to declare because it is the patient's OWN record and * is not one of the destinations the adolescent filter can remove; anything the filter touches must * stay derived. */ export const PROFILE_SECTION = { id: "details", label: "My details", // The page the drawer opens, not the chart's copy of it at `/dashboard/patient/profile`. to: MY_PROFILE_PATH, keywords: ["profile", "address", "phone", "name", "date of birth", "contact"], }; export type ResolvedRecordSection = PatientRecordSection & { destinations: PatientDestination[] }; /** * Every record destination the patient nav actually offers, keyed by nav id. * * THE NAV'S CHILDREN WHEN THERE ARE ANY, THE AUTHORED LIST WHEN THERE ARE NOT - and the fallback is * the whole reason the record page has anything on it at all. * * This read the sidebar's `my-records` children and nothing else. Then the record redesign made the * record ONE ROW and deliberately removed those children - so this returned an EMPTY map, every * section resolved to zero destinations, and the browse list rendered `null`. All 33 record * destinations lost their only door, silently, and the sidebar comment announcing the change says the * opposite: "the facet list on the record page is generated from the same PATIENT_RECORD_SECTIONS * table the children were". It was generated from the children, so emptying them emptied it. * * NOT MERGED, CHOSEN. If the nav does supply children, those win outright - the adolescent filter * removes destinations by dropping them from the nav, and merging the authored list back in would * quietly re-offer whatever it had just hidden. Falling back only when there is nothing to fall back * FROM keeps that filter exactly as sharp as it was. */ const recordDestinations = ( navItems: SidebarMenuItem[], authored: PatientDestination[] = [], ): Map => { const myRecords = navItems.find((item) => item.id === "my-records"); const fromNav = myRecords?.children?.length ? patientHomeDestinations(myRecords.children) : []; /* * AN EMPTY NAV AND A NAV WITH NO RECORD CHILDREN ARE DIFFERENT FACTS, and only one of them may fall * back. Outside the dashboard shell `usePatientNav` returns an empty array - there is no patient in * context at all, and rendering a record navigation there would offer a way around somebody's chart * from a page that has no chart. Inside the shell the array is full (Overview, Tasks, Appointments * and the rest) and it is only `my-records.children` that is gone. * * So the published nav is what licenses the fallback. Collapsing the two would have re-broken the * guard that keeps this component off pages it does not belong on. */ const destinations = fromNav.length > 0 ? fromNav : (navItems.length > 0 ? authored : []); return new Map(destinations.map((destination) => [destination.id, destination])); }; /** * Resolves the authored sections against the nav the sidebar was actually given. * * A SECTION WITH NOTHING BEHIND IT DOES NOT RENDER. That is what keeps an authored label honest: * remove a destination from the nav - as the adolescent filter does - and the section carrying it * disappears with it rather than becoming a label pointing at nothing. */ export const resolvePatientRecordSections = ( navItems: SidebarMenuItem[], authored: PatientDestination[] = [], ): ResolvedRecordSection[] => { const available = recordDestinations(navItems, authored); return PATIENT_RECORD_SECTIONS.map((section) => ({ ...section, destinations: section.owns .map((id) => available.get(id)) .filter((destination): destination is PatientDestination => Boolean(destination)), })).filter((section) => section.destinations.length > 0); }; /** * What this record page is called, for its own heading. / finding B20. * * THE DEFECT: ten of the fifteen record sections render no heading of their own, so **eleven pages * announce only "My Records"** to a screen reader. The breadcrumb carries the section - but a breadcrumb * is navigation, not a title, and a user navigating by heading never reaches it. * * RESOLVED FROM THE SAME NAV DATA THE NAVIGATION USES, and that is the whole point rather than * convenience. A hand-written routetitle map is a second source for the page's name, and the two drift: * this codebase has already paid for that with a breadcrumb, a tab and a card header disagreeing about one * set. **Here the heading IS the nav's authored label**, so they cannot say different things. * It also inherits the adolescent filter for free - a destination the nav removed resolves to no title * at all, rather than to a heading for a page that is not offered. * * Returns `undefined` rather than a fallback. A page whose title cannot be resolved gets NO heading, * which is exactly today's behaviour; inventing one from the URL would put a slug on screen. */ export const patientRecordPageTitle = ( navItems: SidebarMenuItem[], pathname: string, authored: PatientDestination[] = [], ): string | undefined => { const path = pathname.replace(/\/+$/, ""); let best: PatientDestination | undefined; // Takes the same fallback the browse list does, or the headings would have gone silent alongside // the navigation when the nav's children were removed - and for the same reason. for (const destination of recordDestinations(navItems, authored).values()) { const to = destination.to.replace(/\/+$/, ""); // Prefix, not equality - a leaf route can carry an id (`.../conditions/abc`) and still be that // section. LONGEST WINS, or a parent group's own route would out-match its child on every page // beneath it and every leaf would be titled with its parent. if (path !== to && !path.startsWith(to + "/")) continue; if (!best || to.length > best.to.replace(/\/+$/, "").length) best = destination; } return best?.label; }; /** * Does this section answer what the patient typed? Matches the authored label, the words a patient * would use, and the destination labels underneath - so "blood test" finds Test results even though * the product files it under Clinical Results. */ export const sectionMatchesQuery = (section: ResolvedRecordSection, query: string): boolean => { const needle = query.trim().toLowerCase(); if (!needle) return true; const haystack = [ section.label, ...(section.keywords ?? []), ...section.destinations.map((destination) => destination.label), ]; return haystack.some((candidate) => candidate.toLowerCase().includes(needle)); };