From 3f85d17f23a05137efa6816ae3437bedb11c806d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 13:37:35 +0000 Subject: [PATCH 1/8] Recent pages across Work and Clinical, and a start list on Clinical mode homes Recent pages: a device-only list (capped at 12) of record and work area pages opened, path without query, title, mode and time. Never call notes, handover, the MHA clock, drafts, searches, First Nations, mock-ups or developer pages. Recorded only while "Save recent searches" is on, cleared with recent searches and at every account transition. Shown (last five) in the phone menu's Work and Clinical tabs, the two-pane menu and AI Search's empty state; hidden when empty. Clinical mode homes (DSM, Medication, Therapy, Documents, Differentials): Continue (last three records of the mode), the mode's Favourites (read only) and a Browse all link where a catalogue page exists, hanging below the home so the centred hero and composer keep their exact place. Shorter DSM and Medication placeholders so they are not cut off at 390 px. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Mws6schjKQbpCQqQzPMKSL --- src/app/(search-app)/layout.tsx | 2 + .../clinical-dashboard/ClinicalSidebar.tsx | 3 + .../clinical-dashboard/answer-status.tsx | 2 + .../clinical-menu-drawer.tsx | 98 ++++- .../clinical-dashboard/settings-dialog.tsx | 7 +- src/components/mode-home-favourites-group.tsx | 33 ++ src/components/mode-home-start-list.tsx | 160 ++++++++ src/components/mode-home-template.tsx | 18 +- .../recent-pages/recent-pages-recorder.tsx | 60 +++ .../recent-pages/use-recent-pages.ts | 33 ++ .../work-frame/two-pane-side-menu.css | 11 + .../work-frame/two-pane-side-menu.tsx | 61 ++- .../work-search/work-search-sheet.tsx | 30 ++ src/lib/account-scoped-browser-state.ts | 9 + src/lib/app-modes.ts | 4 +- src/lib/recent-pages.ts | 377 ++++++++++++++++++ tests/mode-home-start-list.dom.test.tsx | 82 ++++ tests/recent-pages-menus.dom.test.tsx | 62 +++ tests/recent-pages.dom.test.ts | 166 ++++++++ tests/ui-smoke.spec.ts | 2 +- 20 files changed, 1212 insertions(+), 8 deletions(-) create mode 100644 src/components/mode-home-favourites-group.tsx create mode 100644 src/components/mode-home-start-list.tsx create mode 100644 src/components/recent-pages/recent-pages-recorder.tsx create mode 100644 src/components/recent-pages/use-recent-pages.ts create mode 100644 src/lib/recent-pages.ts create mode 100644 tests/mode-home-start-list.dom.test.tsx create mode 100644 tests/recent-pages-menus.dom.test.tsx create mode 100644 tests/recent-pages.dom.test.ts diff --git a/src/app/(search-app)/layout.tsx b/src/app/(search-app)/layout.tsx index 25b993f48e..d194c19272 100644 --- a/src/app/(search-app)/layout.tsx +++ b/src/app/(search-app)/layout.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import { SharedSearchAppShell } from "@/components/clinical-dashboard/shared-search-app-shell"; import { PsychiatryVisitRecorder } from "@/components/psychiatry/psychiatry-visit-recorder"; +import { RecentPagesRecorder } from "@/components/recent-pages/recent-pages-recorder"; import { WorkModeLaunchProvider } from "@/components/work-mode-launch/work-mode-launch-provider"; import { clinicalAskModeEnabled } from "@/lib/clinical-ask/authority-registry"; import { projectClinicalAskAvailableModeIds } from "@/lib/clinical-ask/capabilities"; @@ -27,6 +28,7 @@ export default async function SearchAppLayout({ children }: { children: ReactNod {children} + ); diff --git a/src/components/clinical-dashboard/ClinicalSidebar.tsx b/src/components/clinical-dashboard/ClinicalSidebar.tsx index a75b744383..e3aad0fa84 100644 --- a/src/components/clinical-dashboard/ClinicalSidebar.tsx +++ b/src/components/clinical-dashboard/ClinicalSidebar.tsx @@ -56,6 +56,7 @@ import type { ThemePreference } from "@/lib/theme"; import { CLINICAL_MENU_PANEL_ID, ClinicalMenuBell, + ClinicalMenuRecentPages, ClinicalMenuTabs, ClinicalMenuWorkAreas, clinicalMenuTabId, @@ -988,6 +989,8 @@ export function ClinicalSidebarContent({ + {drawer ? onNavigate?.()} /> : null} +

diff --git a/src/components/clinical-dashboard/answer-status.tsx b/src/components/clinical-dashboard/answer-status.tsx index 1d978d5aa4..f89c5b5e5c 100644 --- a/src/components/clinical-dashboard/answer-status.tsx +++ b/src/components/clinical-dashboard/answer-status.tsx @@ -17,6 +17,7 @@ import { import type { VerifiedEvidencePreviewUnit } from "@/lib/answer-stream-contract"; import { AnswerSuggestionChips } from "@/components/clinical-dashboard/answer-suggestion-chips"; import { useAppPreferences } from "@/components/clinical-dashboard/use-app-preferences"; +import { ModeHomeStartList, modeHomeStartListConfig } from "@/components/mode-home-start-list"; import { ModeHomeTemplate } from "@/components/mode-home-template"; import { LazyMyDayHomeCard } from "@/components/my-day/my-day-home-card-lazy"; import { ShowAllChip } from "@/components/show-all-chip"; @@ -91,6 +92,7 @@ export function SharedHomeEmptyState({ } actionsLabel={sharedHomeEmptyState.starterActionsLabel} actions={[]} + startList={modeHomeStartListConfig(modeId) ? : undefined} footer={

{/* My Day sits under the composer, never above it: the composer diff --git a/src/components/clinical-dashboard/clinical-menu-drawer.tsx b/src/components/clinical-dashboard/clinical-menu-drawer.tsx index 5844083458..c27535ffad 100644 --- a/src/components/clinical-dashboard/clinical-menu-drawer.tsx +++ b/src/components/clinical-dashboard/clinical-menu-drawer.tsx @@ -18,11 +18,13 @@ import { Bell, BriefcaseMedical, Stethoscope, type LucideIcon } from "lucide-rea import Link from "next/link"; import { useRef, type KeyboardEvent } from "react"; -import { cn } from "@/components/ui-primitives"; +import { useRecentPages } from "@/components/recent-pages/use-recent-pages"; +import { cn, sidebarItem } from "@/components/ui-primitives"; import { rememberedWorkAreaPage, type WorkSideCountsState } from "@/components/work-frame/work-frame-store"; import { appModeIcons } from "@/lib/app-mode-icons"; import { notificationBadgeText } from "@/lib/needs-you/feed"; import type { ModeMenuSideId } from "@/lib/phone-mode-groups"; +import { RECENT_PAGE_SECTION_LABEL, RECENT_PAGES_SHOWN } from "@/lib/recent-pages"; import { WORK_AREAS, type WorkAreaId } from "@/lib/work-frame/areas"; import { WORK_SIDE_AREAS, @@ -195,6 +197,23 @@ export function ClinicalMenuWorkAreas({ counts: WorkSideCountsState | null; currentArea: WorkAreaId | null; onNavigate: () => void; +}) { + return ( + <> + + + + ); +} + +function WorkAreasNav({ + counts, + currentArea, + onNavigate, +}: { + counts: WorkSideCountsState | null; + currentArea: WorkAreaId | null; + onNavigate: () => void; }) { return ( + + ); @@ -946,6 +953,58 @@ function RecentRow({ ); } +/** + * The last few pages opened on this device, across Work and Clinical, in both + * panes. Hidden when there are none: an empty heading says nothing useful. + */ +function RecentPagesGroup({ + headingId, + pages, + onNavigate, +}: { + readonly headingId: string; + readonly pages: readonly RecentPage[]; + readonly onNavigate: () => void; +}) { + if (pages.length === 0) return null; + return ( +
+
+

Recent pages

+
+
    + {pages.map((page) => { + const Icon = appModeIcons[page.section]; + const sectionLabel = RECENT_PAGE_SECTION_LABEL[page.section]; + return ( +
  • + +
  • + ); + })} +
+
+ ); +} + function Highlight({ text, needle }: { readonly text: string; readonly needle: string }) { const at = needle ? text.toLowerCase().indexOf(needle) : -1; if (at < 0) return <>{text}; diff --git a/src/components/work-search/work-search-sheet.tsx b/src/components/work-search/work-search-sheet.tsx index 1e15a1fa65..8e6fa8b8a0 100644 --- a/src/components/work-search/work-search-sheet.tsx +++ b/src/components/work-search/work-search-sheet.tsx @@ -39,6 +39,7 @@ import { subscribeAppPreferences, } from "@/components/clinical-dashboard/use-app-preferences"; import { WorkButton, WorkEmpty } from "@/components/mode-kit/work"; +import { useRecentPages } from "@/components/recent-pages/use-recent-pages"; import { useWorkModeRouteVisible } from "@/components/work-mode-launch/work-mode-launch-provider"; import { useWorkSearchRecords } from "@/components/work-search/use-work-search-records"; import { AnswerCard } from "@/components/work-search/work-search-answer-card"; @@ -57,9 +58,11 @@ import { WorkSearchGlyph } from "@/components/work-search/work-search-glyph"; import { workFrameIcons } from "@/components/work-frame/work-frame-icons"; import { Sheet } from "@/components/ui/sheet"; import { cn } from "@/components/ui-primitives"; +import { appModeIcons } from "@/lib/app-mode-icons"; import { appModeHomeHref } from "@/lib/app-modes"; import { documentsSearchHref } from "@/lib/document-flow-routes"; import { addDaysToDate, formatPerthDay, perthDateOf } from "@/lib/perth-time"; +import { RECENT_PAGE_SECTION_LABEL, RECENT_PAGES_SHOWN } from "@/lib/recent-pages"; import { AGREEMENT_PAGE_HREF } from "@/lib/work-profile/agreement-answers"; import { answerWorkQuestion, type WorkAnswer } from "@/lib/work-search/answers"; import { workSearchAreaLabels, workSearchAreas, type WorkAreaRead, type WorkSearchArea } from "@/lib/work-search/model"; @@ -425,6 +428,8 @@ export function WorkSearchSheet({ open, onClose, currentArea, returnFocusRef }: const keepRecents = useSyncExternalStore(subscribeAppPreferences, readSaveRecentSearches, () => false); const [storedRecents, setRecents] = useState(() => (keepRecents ? recentsFor(epoch) : [])); const recents = keepRecents ? storedRecents : []; + // Pages opened lately, across Work and Clinical: paths and page titles only, never anything typed. + const recentPages = useRecentPages({ limit: RECENT_PAGES_SHOWN }); const [scrolled, setScrolled] = useState(false); const [chipsMore, setChipsMore] = useState(false); /** The query "Search for … exactly" was tapped for: one-letter-out matching is off until it changes. */ @@ -1197,6 +1202,31 @@ export function WorkSearchSheet({ open, onClose, currentArea, returnFocusRef }: )}
) : null} + {recentPages.length > 0 ? ( +
+ Recent pages +
    + {recentPages.map((page) => { + const Icon = appModeIcons[page.section]; + const sectionLabel = RECENT_PAGE_SECTION_LABEL[page.section]; + return ( + + ); + })} +
+
+ ) : null}
Try asking
    diff --git a/src/lib/account-scoped-browser-state.ts b/src/lib/account-scoped-browser-state.ts index 9deab6c3f7..4def75f271 100644 --- a/src/lib/account-scoped-browser-state.ts +++ b/src/lib/account-scoped-browser-state.ts @@ -132,6 +132,14 @@ export const PSYCHIATRY_MHA_CLOCKS_STORAGE_KEY = "psychsift:psychiatry:mha-clock * searches" is on, cleared with recent searches. */ export const MEDICINES_RECENT_STORAGE_KEY = "psychsift:medicines:recent-v1"; +/** + * localStorage — the "Recent pages" list shared by Work and Clinical (side menu, AI Search and the + * Clinical mode homes' Continue): a page's path without its query, the page's own title, its mode + * and section, and a time. Record and area pages only, never a search, a draft, call notes, a + * handover or an MHA clock; recorded only while "Save recent searches" is on, cleared with recent + * searches. + */ +export const RECENT_PAGES_STORAGE_KEY = "psychsift:recent-pages-v1"; /** * localStorage — Teaching's term tracker: the doctor's own term dates, assessment due dates, EPA * counts, learning goals and "to raise" notes. Backed up to the account in `work_backups` @@ -279,6 +287,7 @@ export function clearAccountScopedBrowserStorage(): void { removeQuietly(() => window.localStorage, PSYCHIATRY_VISITS_STORAGE_KEY); removeQuietly(() => window.localStorage, PSYCHIATRY_MHA_CLOCKS_STORAGE_KEY); removeQuietly(() => window.localStorage, MEDICINES_RECENT_STORAGE_KEY); + removeQuietly(() => window.localStorage, RECENT_PAGES_STORAGE_KEY); removeQuietly(() => window.localStorage, REMIND_ME_STORAGE_KEY); removeQuietly(() => window.localStorage, BELL_PHONE_QUEUE_STORAGE_KEY); removeQuietly(() => window.localStorage, TEACHING_TERM_TRACKER_STORAGE_KEY); diff --git a/src/lib/app-modes.ts b/src/lib/app-modes.ts index b25c92c068..797479cf5b 100644 --- a/src/lib/app-modes.ts +++ b/src/lib/app-modes.ts @@ -258,7 +258,7 @@ export const appModeDefinitions = [ href: "/dsm", search: { kind: "dsm", - placeholder: "Search DSM diagnoses or criteria...", + placeholder: "Search diagnoses...", inputAriaLabel: "Search DSM diagnoses, ICD codes, criteria, and categories", submitIdleLabel: "DSM", submitBusyLabel: "DSM", @@ -336,7 +336,7 @@ export const appModeDefinitions = [ // document corpus for dosing/threshold guidance (defaultQueryMode dose_threshold_lookup). // The medication registry joins cross-entity search via /api/search/universal instead. kind: "documents", - placeholder: "Search medication dosing or safety...", + placeholder: "Search medications...", inputAriaLabel: "Search medication dosing, safety, and monitoring guidance", submitIdleLabel: "Meds", submitBusyLabel: "Meds", diff --git a/src/lib/recent-pages.ts b/src/lib/recent-pages.ts new file mode 100644 index 0000000000..341d73b029 --- /dev/null +++ b/src/lib/recent-pages.ts @@ -0,0 +1,377 @@ +import { RECENT_PAGES_STORAGE_KEY, subscribeAccountTransition } from "@/lib/account-scoped-browser-state"; + +/** + * "Recent pages": the last few pages this reader opened, across Work and + * Clinical, so a doctor moving between On Call, a medicine and a form can get + * back to what they just looked at without searching again (site + * recommendations 12 Oct 2026, item 4). The side menu and AI Search show the + * newest five; each Clinical mode home shows its own newest three as + * "Continue" (item 3). + * + * What it holds and why that is safe: a path WITHOUT its query, the page's own + * title (a diagnosis, a medicine, "Swaps"), the mode and section it belongs + * to, and a time. Never a query, a note or anything typed. Only record pages + * and work area pages are recorded (see `recentPageTarget`); searches, drafts, + * call notes and the call log, handover, the MHA clock, safety plans, First + * Nations pages, mock-ups and developer pages never are. It stays in this + * browser for this account (cleared at sign-out and account switch, see + * `account-scoped-browser-state.ts`), is recorded only while "Save recent + * searches" is on, and is cleared with recent searches. + */ + +export type RecentPageMode = "work" | "clinical"; + +/** The section a page belongs to: a Clinical mode id or a Work area's mode id. */ +export type RecentPageSection = + | "dsm" + | "differentials" + | "therapy-compass" + | "prescribing" + | "documents" + | "specifiers" + | "formulation" + | "forms" + | "services" + | "factsheets" + | "dictionary" + | "sources" + | "psychiatry" + | "medicines" + | "my-day" + | "on-call" + | "open-shifts" + | "roster" + | "teaching" + | "cme" + | "my-work"; + +export interface RecentPage { + /** The path only: never a query string or a hash. */ + readonly path: string; + readonly title: string; + readonly mode: RecentPageMode; + readonly section: RecentPageSection; + /** Epoch milliseconds of the latest open; orders the list, never shown. */ + readonly at: number; +} + +export const RECENT_PAGES_LIMIT = 12; +/** How many the side menu and AI Search show. */ +export const RECENT_PAGES_SHOWN = 5; +/** How many a Clinical mode home's Continue shows. */ +export const RECENT_PAGES_CONTINUE_SHOWN = 3; +export const RECENT_PAGES_TTL_MS = 90 * 24 * 60 * 60 * 1000; +const recentPagesChangeEvent = "psychsift:recent-pages-change"; + +export const RECENT_PAGE_SECTION_LABEL: Readonly> = { + dsm: "DSM-5", + differentials: "Differentials", + "therapy-compass": "Therapy", + prescribing: "Medication", + documents: "Documents", + specifiers: "Specifiers", + formulation: "Formulation", + forms: "Forms", + services: "Services", + factsheets: "Factsheets", + dictionary: "Dictionary", + sources: "Sources", + psychiatry: "Psychiatry", + medicines: "Medicines", + "my-day": "My Day", + "on-call": "On Call", + "open-shifts": "Open shifts", + roster: "Roster", + teaching: "Teaching", + cme: "CPD", + "my-work": "Admin", +}; + +const WORK_SECTIONS: ReadonlySet = new Set([ + "my-day", + "on-call", + "open-shifts", + "roster", + "teaching", + "cme", + "my-work", +]); + +export function recentPageModeOf(section: RecentPageSection): RecentPageMode { + return WORK_SECTIONS.has(section) ? "work" : "clinical"; +} + +/** + * Clinical record routes: a first segment, the segments that are lists, + * searches or interactive tools under it (never recorded, because a bare path + * cannot bring their working state back), and how deep a record sits. + */ +const CLINICAL_RECORD_RULES: ReadonlyArray<{ + readonly root: string; + readonly section: RecentPageSection; + /** The second segment a record must have, when the record sits one level further in. */ + readonly via?: readonly string[]; + readonly notRecords?: readonly string[]; +}> = [ + { root: "dsm", section: "dsm", via: ["diagnoses"] }, + { root: "differentials", section: "differentials", via: ["diagnoses", "presentations"] }, + { + root: "therapy-compass", + section: "therapy-compass", + notRecords: ["search", "compare", "pathways", "recommend", "review"], + }, + { root: "medications", section: "prescribing" }, + { root: "documents", section: "documents", notRecords: ["search", "source"] }, + { root: "specifiers", section: "specifiers", notRecords: ["search", "builder", "compare", "map"] }, + { root: "formulation", section: "formulation", notRecords: ["search", "builder", "compare", "map"] }, + // The MHA clock lives under /forms/ but holds detention timers: never recorded. + { root: "forms", section: "forms", notRecords: ["search", "clock"] }, + { root: "services", section: "services", notRecords: ["search"] }, + { root: "factsheets", section: "factsheets", notRecords: ["search", "topics"] }, + { root: "dictionary", section: "dictionary", notRecords: ["search", "browse", "compare", "sources"] }, + { root: "sources", section: "sources", notRecords: ["search", "currency", "method", "publishers", "topics"] }, +]; + +/** Clinical hubs recorded as area pages (their own home, never a sub-page). */ +const CLINICAL_AREA_ROOTS: Readonly> = { + psychiatry: "psychiatry", + medicines: "medicines", +}; + +/** Work areas: the area's home and its tab pages are recorded, never anything deeper. */ +const WORK_AREA_ROOTS: Readonly> = { + "my-day": "my-day", + "on-call": "on-call", + "open-shifts": "open-shifts", + roster: "roster", + teaching: "teaching", + cme: "cme", + admin: "my-work", +}; + +/** + * Work tab pages that are never recorded: call notes and the call log, + * handover, referrals, searches, and pages built around free-text drafts or a + * person's own statements (a new CPD entry, job applications, feedback, sick + * reports, posting a shift, requests). + */ +const WORK_PRIVATE_TABS: Readonly>> = { + "on-call": new Set(["call", "handover", "refer", "referrals", "find"]), + cme: new Set(["new", "applications", "log"]), + teaching: new Set(["c", "feedback", "import", "review", "assessments"]), + roster: new Set(["sick", "join"]), + admin: new Set(["applications", "requests", "new-job", "hospital"]), + "open-shifts": new Set(["post", "log"]), + "my-day": new Set(["setup"]), +}; + +/** + * Where a path belongs, or null when it must not be recorded. Pure. A query + * or hash is dropped before anything is read, so a search's words never reach + * the list. + */ +export function recentPageTarget( + pathname: string, +): { readonly path: string; readonly mode: RecentPageMode; readonly section: RecentPageSection } | null { + const path = (pathname.split(/[?#]/)[0] ?? "").replace(/\/+$/, ""); + if (!path.startsWith("/") || path.length > 200) return null; + const segments = path.split("/").filter(Boolean); + const [root, second, third] = segments; + if (!root) return null; + // A segment that is not a plain slug (encoded text, spaces) could carry typed words: never recorded. + if (segments.some((segment) => !/^[A-Za-z0-9._~-]{1,120}$/.test(segment))) return null; + + const workSection = WORK_AREA_ROOTS[root]; + if (workSection) { + if (segments.length > 2) return null; + if (second && WORK_PRIVATE_TABS[root]?.has(second)) return null; + return { path, mode: "work", section: workSection }; + } + + const areaSection = CLINICAL_AREA_ROOTS[root]; + if (areaSection) { + return segments.length === 1 ? { path, mode: "clinical", section: areaSection } : null; + } + + const rule = CLINICAL_RECORD_RULES.find((candidate) => candidate.root === root); + if (!rule || !second) return null; + if (rule.via) { + if (!rule.via.includes(second) || !third) return null; + } else if (rule.notRecords?.includes(second)) { + return null; + } + return { path, mode: "clinical", section: rule.section }; +} + +const GENERIC_TITLES = new Set([ + "psychsift", + "document", + "documents", + "document viewer", + "loading", + "untitled", + "medication", +]); + +/** + * The page's own name from its document title ("Lithium | PsychSift", + * "Swaps | Roster | PsychSift", "CBT - Therapy"). Only a spaced separator + * splits, so "Obsessive-compulsive" survives. Null for a missing, generic or + * "not found" title. Pure. + */ +export function recentPageTitle(documentTitle: string): string | null { + const first = documentTitle.split(/\s+[|—–-]\s+/)[0]?.trim() ?? ""; + if (!first || first.length > 120) return null; + if (GENERIC_TITLES.has(first.toLowerCase())) return null; + if (/not found|^error\b|^search\b/i.test(first)) return null; + return first; +} + +function isRecentPage(value: unknown): value is RecentPage { + if (!value || typeof value !== "object") return false; + const record = value as Record; + if (typeof record.path !== "string" || typeof record.title !== "string") return false; + if (typeof record.at !== "number" || !Number.isFinite(record.at)) return false; + const target = recentPageTarget(record.path); + // The stored path must still be one this list may hold, exactly as stored, in the stored section. + return ( + target !== null && + target.path === record.path && + target.section === record.section && + target.mode === record.mode && + record.title.trim().length > 0 && + record.title.length <= 120 + ); +} + +/** Newest first, one entry per path, expired or malformed entries dropped, capped. Pure. */ +export function normaliseRecentPages(raw: unknown, now: number): RecentPage[] { + if (!Array.isArray(raw)) return []; + const seen = new Set(); + return raw + .filter(isRecentPage) + .filter((page) => now - page.at <= RECENT_PAGES_TTL_MS && page.at <= now + 60_000) + .sort((a, b) => b.at - a.at) + .filter((page) => { + if (seen.has(page.path)) return false; + seen.add(page.path); + return true; + }) + .slice(0, RECENT_PAGES_LIMIT) + .map(({ path, title, mode, section, at }) => ({ path, title, mode, section, at })); +} + +const EMPTY: readonly RecentPage[] = []; +let cache: { raw: string | null; value: readonly RecentPage[] } | null = null; + +/** The stored list, newest first. Blocked or unreadable storage reads as no history. */ +export function readRecentPages(now = Date.now()): readonly RecentPage[] { + if (typeof window === "undefined") return EMPTY; + let raw: string | null = null; + try { + raw = window.localStorage.getItem(RECENT_PAGES_STORAGE_KEY); + } catch { + return EMPTY; + } + if (cache && cache.raw === raw) return cache.value; + let value: readonly RecentPage[] = EMPTY; + if (raw) { + try { + value = normaliseRecentPages(JSON.parse(raw), now); + } catch { + value = EMPTY; + } + } + cache = { raw, value }; + return value; +} + +/** For `useSyncExternalStore`: the server and the first client frame both read no history. */ +export function readNoRecentPages(): readonly RecentPage[] { + return EMPTY; +} + +function notify() { + if (typeof window === "undefined") return; + window.dispatchEvent(new Event(recentPagesChangeEvent)); +} + +subscribeAccountTransition(() => { + cache = null; + notify(); +}); + +/** + * Record one open of `pathname` titled `title`. Anything `recentPageTarget` + * refuses is dropped here, whatever the caller checked. The caller decides + * whether recording is allowed (the "Save recent searches" switch). + */ +export function recordRecentPage(pathname: string, title: string, at: number = Date.now()): void { + if (typeof window === "undefined") return; + const target = recentPageTarget(pathname); + const name = title.trim(); + if (!target || !name || name.length > 120) return; + const page: RecentPage = { ...target, title: name, at }; + const current = readRecentPages(at); + const next = normaliseRecentPages([page, ...current.filter((existing) => existing.path !== page.path)], at); + try { + window.localStorage.setItem(RECENT_PAGES_STORAGE_KEY, JSON.stringify(next)); + } catch { + // Quota or blocked storage: losing a convenience list is not worth interrupting the reader. + return; + } + notify(); +} + +export function forgetRecentPage(path: string): void { + if (typeof window === "undefined") return; + const current = readRecentPages(); + const next = current.filter((page) => page.path !== path); + if (next.length === current.length) return; + try { + window.localStorage.setItem(RECENT_PAGES_STORAGE_KEY, JSON.stringify(next)); + } catch { + return; + } + notify(); +} + +export function clearRecentPages(): void { + if (typeof window === "undefined") return; + try { + window.localStorage.removeItem(RECENT_PAGES_STORAGE_KEY); + } catch { + // Nothing stored that we can reach; nothing to clear. + } + cache = null; + notify(); +} + +export function countRecentPages(): number { + return readRecentPages().length; +} + +export function subscribeRecentPages(listener: () => void): () => void { + if (typeof window === "undefined") return () => undefined; + const onStorage = (event: StorageEvent) => { + if (event.key === null || event.key === RECENT_PAGES_STORAGE_KEY) listener(); + }; + window.addEventListener(recentPagesChangeEvent, listener); + window.addEventListener("storage", onStorage); + return () => { + window.removeEventListener(recentPagesChangeEvent, listener); + window.removeEventListener("storage", onStorage); + }; +} + +/** The newest pages, optionally one section's only, excluding the page being looked at. Pure. */ +export function pickRecentPages( + pages: readonly RecentPage[], + { limit, section, exceptPath }: { limit: number; section?: RecentPageSection; exceptPath?: string | null }, +): RecentPage[] { + const except = exceptPath ? (exceptPath.split(/[?#]/)[0] ?? "").replace(/\/+$/, "") : null; + return pages + .filter((page) => (section ? page.section === section : true)) + .filter((page) => page.path !== except) + .slice(0, limit); +} diff --git a/tests/mode-home-start-list.dom.test.tsx b/tests/mode-home-start-list.dom.test.tsx new file mode 100644 index 0000000000..3464ab0b50 --- /dev/null +++ b/tests/mode-home-start-list.dom.test.tsx @@ -0,0 +1,82 @@ +/** @vitest-environment jsdom */ + +// Clinical mode homes' start list (site recommendations 12 Oct 2026, item 3): +// Continue, this mode's Favourites and a Browse link under the unchanged +// composer, each group hidden when empty. Answer and the other modes keep +// their home exactly as it was. + +import { act, cleanup, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { SharedHomeEmptyState } from "@/components/clinical-dashboard/answer-status"; +import { ModeHomeStartList, modeHomeStartListConfig } from "@/components/mode-home-start-list"; +import { clearRecentPages, recordRecentPage } from "@/lib/recent-pages"; + +vi.mock("next/navigation", () => ({ + usePathname: () => "/", + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn(), prefetch: vi.fn() }), + useSearchParams: () => new URLSearchParams(), +})); + +afterEach(() => { + cleanup(); + clearRecentPages(); + window.localStorage.clear(); +}); + +describe("mode home start list", () => { + it("is offered on DSM, Medication, Therapy, Documents and Differentials only", () => { + for (const modeId of ["dsm", "prescribing", "therapy-compass", "documents", "differentials"] as const) { + expect(modeHomeStartListConfig(modeId), modeId).not.toBeNull(); + } + for (const modeId of ["answer", "psychiatry", "services", "forms", "on-call"] as const) { + expect(modeHomeStartListConfig(modeId), modeId).toBeNull(); + } + }); + + it("shows a first visit only the Browse link, and nothing at all where there is no catalogue page", () => { + render(); + expect(screen.queryByTestId("mode-home-continue")).toBeNull(); + expect(screen.getByTestId("mode-home-browse").getAttribute("href")).toBe("/dsm/search"); + cleanup(); + + render(); + expect(screen.queryByRole("link")).toBeNull(); + }); + + it("continues with the last three records of this mode, newest first", () => { + const now = Date.now(); + recordRecentPage("/dsm/diagnoses/a", "Disorder A", now - 5); + recordRecentPage("/medications/lithium", "Lithium", now - 4); + recordRecentPage("/dsm/diagnoses/b", "Disorder B", now - 3); + recordRecentPage("/dsm/diagnoses/c", "Disorder C", now - 2); + recordRecentPage("/dsm/diagnoses/d", "Disorder D", now - 1); + + render(); + const group = screen.getByTestId("mode-home-continue"); + const links = within(group).getAllByRole("link"); + expect(links.map((link) => link.textContent)).toEqual(["Disorder D", "Disorder C", "Disorder B"]); + expect(links[0]?.getAttribute("href")).toBe("/dsm/diagnoses/d"); + }); + + it("updates when a page is opened in another part of the app", () => { + render(); + expect(screen.queryByTestId("mode-home-continue")).toBeNull(); + act(() => recordRecentPage("/medications/lithium", "Lithium")); + expect(within(screen.getByTestId("mode-home-continue")).getByRole("link", { name: "Lithium" })).toBeTruthy(); + }); + + it("hangs under the shared home without joining its flow, and leaves the Answer home alone", () => { + render(); + const home = screen.getByTestId("shared-home-empty-state"); + expect(home.className).toContain("relative"); + const hanging = home.querySelector(".mode-home-start"); + expect(hanging?.className).toContain("absolute"); + expect(hanging?.className).toContain("top-full"); + expect(within(home).getByTestId("mode-home-start-list")).toBeTruthy(); + cleanup(); + + render(); + expect(screen.getByTestId("shared-home-empty-state").querySelector(".mode-home-start")).toBeNull(); + }); +}); diff --git a/tests/recent-pages-menus.dom.test.tsx b/tests/recent-pages-menus.dom.test.tsx new file mode 100644 index 0000000000..dfe0d6efdc --- /dev/null +++ b/tests/recent-pages-menus.dom.test.tsx @@ -0,0 +1,62 @@ +/** @vitest-environment jsdom */ + +// "Recent pages" in the phone menu's Work and Clinical tabs (and the two-pane +// menu kept for when it returns): the last five pages, hidden when empty, the +// page on screen left out. + +import { cleanup, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ClinicalMenuRecentPages, ClinicalMenuWorkAreas } from "@/components/clinical-dashboard/clinical-menu-drawer"; +import { clearRecentPages, recordRecentPage, RECENT_PAGES_SHOWN } from "@/lib/recent-pages"; + +vi.mock("next/navigation", () => ({ + usePathname: () => "/roster", + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn(), prefetch: vi.fn() }), + useSearchParams: () => new URLSearchParams(), +})); + +afterEach(() => { + cleanup(); + clearRecentPages(); + window.localStorage.clear(); +}); + +describe("Recent pages in the phone menu", () => { + it("is hidden when nothing has been opened", () => { + render(); + expect(screen.queryByText("Recent pages")).toBeNull(); + }); + + it("lists the newest pages across Work and Clinical, without the page on screen", () => { + const now = Date.now(); + recordRecentPage("/roster", "Roster", now - 10); + for (let index = 0; index < 7; index += 1) { + recordRecentPage(`/medications/med-${index}`, `Med ${index}`, now - 9 + index); + } + recordRecentPage("/on-call", "On Call", now); + + const onNavigate = vi.fn(); + render(); + const group = screen.getByTestId("clinical-menu-recent-pages-clinical"); + const links = within(group).getAllByRole("link"); + expect(links).toHaveLength(RECENT_PAGES_SHOWN); + expect(links[0]?.getAttribute("href")).toBe("/on-call"); + expect(links.some((link) => link.getAttribute("href") === "/roster")).toBe(false); + expect(within(group).getAllByText("Medication").length).toBeGreaterThan(0); + links[0]?.click(); + expect(onNavigate).toHaveBeenCalled(); + }); + + it("sits under the work areas on the Work tab", () => { + recordRecentPage("/medications/lithium", "Lithium"); + render(); + expect(screen.getByTestId("clinical-menu-work")).toBeTruthy(); + const group = screen.getByTestId("clinical-menu-recent-pages-work"); + expect( + within(group) + .getByRole("link", { name: /Lithium/ }) + .getAttribute("href"), + ).toBe("/medications/lithium"); + }); +}); diff --git a/tests/recent-pages.dom.test.ts b/tests/recent-pages.dom.test.ts new file mode 100644 index 0000000000..d69e762cbe --- /dev/null +++ b/tests/recent-pages.dom.test.ts @@ -0,0 +1,166 @@ +/** @vitest-environment jsdom */ + +// "Recent pages" (site recommendations 12 Oct 2026, item 4): one device-only +// list across Work and Clinical. Paths without queries and page titles only; +// record and work area pages only; never call notes, handover, the MHA clock, +// drafts, searches, mock-ups or developer pages. + +import { afterEach, describe, expect, it } from "vitest"; + +import { clearAccountScopedBrowserStorage, RECENT_PAGES_STORAGE_KEY } from "@/lib/account-scoped-browser-state"; +import { + clearRecentPages, + countRecentPages, + forgetRecentPage, + normaliseRecentPages, + pickRecentPages, + readRecentPages, + RECENT_PAGES_LIMIT, + RECENT_PAGES_TTL_MS, + recentPageTarget, + recentPageTitle, + recordRecentPage, +} from "@/lib/recent-pages"; + +afterEach(() => { + clearRecentPages(); + window.localStorage.clear(); +}); + +describe("which pages Recent pages may hold", () => { + it("records Clinical record pages in their mode's section", () => { + expect(recentPageTarget("/dsm/diagnoses/major-depressive-disorder")).toEqual({ + path: "/dsm/diagnoses/major-depressive-disorder", + mode: "clinical", + section: "dsm", + }); + expect(recentPageTarget("/medications/lithium-carbonate-ir-sr")?.section).toBe("prescribing"); + expect(recentPageTarget("/therapy-compass/acceptance-and-commitment-therapy-act")?.section).toBe("therapy-compass"); + expect(recentPageTarget("/differentials/diagnoses/akathisia")?.section).toBe("differentials"); + expect(recentPageTarget("/documents/abc-123")?.section).toBe("documents"); + expect(recentPageTarget("/forms/form-1a")?.section).toBe("forms"); + }); + + it("records work area pages and their tabs, but nothing deeper", () => { + expect(recentPageTarget("/roster")).toEqual({ path: "/roster", mode: "work", section: "roster" }); + expect(recentPageTarget("/roster/swaps")?.mode).toBe("work"); + expect(recentPageTarget("/admin")?.section).toBe("my-work"); + expect(recentPageTarget("/roster/rotations/round-1")).toBeNull(); + expect(recentPageTarget("/teaching/session/abc")).toBeNull(); + }); + + it("never records call notes, handover, the MHA clock, drafts, searches, mock-ups or developer pages", () => { + for (const path of [ + "/on-call/call", + "/on-call/handover", + "/on-call/referrals", + "/forms/clock", + "/psychiatry/mha-clock", + "/cme/new", + "/teaching/feedback", + "/teaching/c/secret-token", + "/open-shifts/post", + "/safety-plan", + "/first-nations", + "/first-nations/contacts", + "/mockups/anything", + "/developer", + "/dsm/search", + "/therapy-compass/search", + "/differentials/presentations", + "/specifiers/builder", + "/", + "/dsm", + ]) { + expect(recentPageTarget(path), path).toBeNull(); + } + }); + + it("drops a query or hash, and refuses a path segment that could carry typed words", () => { + expect(recentPageTarget("/medications/sertraline?q=dose%20in%20pregnancy#top")?.path).toBe( + "/medications/sertraline", + ); + expect(recentPageTarget("/medications/John%20Smith")).toBeNull(); + expect(recentPageTarget("/medications/a b")).toBeNull(); + }); + + it("reads the page's own name from its title and refuses generic ones", () => { + expect(recentPageTitle("Major depressive disorder | DSM-5 Diagnosis | PsychSift")).toBe( + "Major depressive disorder", + ); + expect(recentPageTitle("Swaps | Roster | PsychSift")).toBe("Swaps"); + expect(recentPageTitle("Obsessive-compulsive disorder - Therapy")).toBe("Obsessive-compulsive disorder"); + expect(recentPageTitle("PsychSift")).toBeNull(); + expect(recentPageTitle("Medication | PsychSift")).toBeNull(); + expect(recentPageTitle("Therapy not found - Therapy")).toBeNull(); + expect(recentPageTitle("Search DSM diagnoses | PsychSift")).toBeNull(); + }); +}); + +describe("the stored list", () => { + it("keeps one entry per path, newest first, capped", () => { + const now = Date.now(); + recordRecentPage("/roster", "Roster", now - 3000); + recordRecentPage("/medications/lithium", "Lithium", now - 2000); + recordRecentPage("/roster", "Roster", now - 1000); + expect(readRecentPages(now).map((page) => page.path)).toEqual(["/roster", "/medications/lithium"]); + + for (let index = 0; index < RECENT_PAGES_LIMIT + 4; index += 1) { + recordRecentPage(`/medications/med-${index}`, `Med ${index}`, now + index); + } + expect(countRecentPages()).toBe(RECENT_PAGES_LIMIT); + }); + + it("refuses anything recentPageTarget refuses, whatever the caller checked", () => { + recordRecentPage("/on-call/handover", "Handover"); + recordRecentPage("/forms/clock", "MHA clock"); + expect(countRecentPages()).toBe(0); + }); + + it("stores only path, title, mode, section and time, and drops malformed, expired or rewritten entries", () => { + const now = Date.now(); + const kept = normaliseRecentPages( + [ + { path: "/roster", title: "Roster", mode: "work", section: "roster", at: now, note: "extra" }, + { path: "/on-call/call", title: "Call", mode: "work", section: "on-call", at: now }, + { path: "/roster/swaps", title: "Swaps", mode: "clinical", section: "roster", at: now }, + { + path: "/medications/old", + title: "Old", + mode: "clinical", + section: "prescribing", + at: now - RECENT_PAGES_TTL_MS - 1, + }, + { path: "/medications/blank", title: " ", mode: "clinical", section: "prescribing", at: now }, + "nonsense", + ], + now, + ); + expect(kept).toEqual([{ path: "/roster", title: "Roster", mode: "work", section: "roster", at: now }]); + }); + + it("forgets one page, clears all, and is cleared at an account transition", () => { + recordRecentPage("/roster", "Roster"); + recordRecentPage("/medications/lithium", "Lithium"); + forgetRecentPage("/roster"); + expect(readRecentPages().map((page) => page.path)).toEqual(["/medications/lithium"]); + + clearAccountScopedBrowserStorage(); + expect(window.localStorage.getItem(RECENT_PAGES_STORAGE_KEY)).toBeNull(); + expect(countRecentPages()).toBe(0); + }); + + it("picks the newest of one section, leaving out the page on screen", () => { + const now = Date.now(); + recordRecentPage("/dsm/diagnoses/a", "A", now - 4); + recordRecentPage("/roster", "Roster", now - 3); + recordRecentPage("/dsm/diagnoses/b", "B", now - 2); + recordRecentPage("/dsm/diagnoses/c", "C", now - 1); + const pages = readRecentPages(now); + expect(pickRecentPages(pages, { limit: 3, section: "dsm" }).map((page) => page.title)).toEqual(["C", "B", "A"]); + expect(pickRecentPages(pages, { limit: 2, exceptPath: "/dsm/diagnoses/c/" }).map((page) => page.title)).toEqual([ + "B", + "Roster", + ]); + }); +}); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 0fca33317a..45863b4fb8 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -4785,7 +4785,7 @@ test.describe("PsychSift UI smoke coverage", () => { const globalSearchInput = page.locator('[data-testid="global-search-input"]:visible').first(); await expect(page.getByRole("button", { name: "Mode Medication" })).toBeVisible({ timeout: 30_000 }); - await expect(globalSearchInput).toHaveAttribute("placeholder", "Search medication dosing or safety..."); + await expect(globalSearchInput).toHaveAttribute("placeholder", "Search medications..."); await expect(globalSearchInput).toHaveValue("acamprosate renal dose"); const acamprosateResult = page.getByTestId("medication-result-acamprosate-desktop"); From b006b210083cbff4b322ecff5704be70b031d8a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 14:08:46 +0000 Subject: [PATCH 2/8] Recent pages: record documents by their own title, never save a stale title, lighter favourites - Document pages hand the title already loaded for the viewer to the recorder (no new data read), since their tab title is only "PsychSift". - The recorder captures the tab title as a navigation starts and records nothing while it is unchanged, so a new path is never saved under the previous page's name (a cold load is exempt). - Mode home Favourites read only that mode's saved slugs: no service or form registry request, and the therapy catalogue loads on the Therapy home only. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Mws6schjKQbpCQqQzPMKSL --- src/app/(search-app)/documents/[id]/page.tsx | 26 +++--- src/components/mode-home-favourites-group.tsx | 73 +++++++++++++-- .../recent-pages/recent-page-title.tsx | 17 ++++ .../recent-pages/recent-pages-recorder.tsx | 60 ++++++++++--- src/lib/recent-pages.ts | 24 +++++ tests/mode-home-favourites-group.dom.test.tsx | 60 +++++++++++++ tests/recent-pages-recorder.dom.test.tsx | 88 +++++++++++++++++++ 7 files changed, 317 insertions(+), 31 deletions(-) create mode 100644 src/components/recent-pages/recent-page-title.tsx create mode 100644 tests/mode-home-favourites-group.dom.test.tsx create mode 100644 tests/recent-pages-recorder.dom.test.tsx diff --git a/src/app/(search-app)/documents/[id]/page.tsx b/src/app/(search-app)/documents/[id]/page.tsx index 2adedd964f..1ed6466c93 100644 --- a/src/app/(search-app)/documents/[id]/page.tsx +++ b/src/app/(search-app)/documents/[id]/page.tsx @@ -8,6 +8,7 @@ import { } from "@/lib/document-detail"; import type { DocumentDetailPayload } from "@/lib/document-detail-contract"; import { PublicApiError } from "@/lib/http"; +import { RecentPageTitle } from "@/components/recent-pages/recent-page-title"; export default async function DocumentPage({ params, @@ -41,15 +42,20 @@ export default async function DocumentPage({ } return ( - + <> + {/* The viewer's tab title does not name the document, so Recent pages is + handed the title already loaded for the viewer (no extra read). */} + + + ); } diff --git a/src/components/mode-home-favourites-group.tsx b/src/components/mode-home-favourites-group.tsx index e12b5c40b5..5f9b753b5a 100644 --- a/src/components/mode-home-favourites-group.tsx +++ b/src/components/mode-home-favourites-group.tsx @@ -1,17 +1,22 @@ "use client"; +import { useEffect, useState } from "react"; + +import { useAccountData } from "@/components/account-data-provider"; import { ModeHomeStartGroup, ModeHomeStartRow } from "@/components/mode-home-start-list"; -import { useSavedRegistryFavourites } from "@/components/clinical-dashboard/use-saved-registry-favourites"; import { appModeIcons } from "@/lib/app-mode-icons"; import type { AppModeId } from "@/lib/app-modes"; /** How many favourites a mode home lists; the Favourites page holds the rest. */ const FAVOURITES_SHOWN = 3; +type FavouriteRow = { readonly slug: string; readonly title: string; readonly href: string }; + /** - * This mode's Favourites on its home, read from the account's saved records - * (the same read the Favourites page makes; nothing here writes). Hidden when - * there are none, while they load, and for a signed-out reader. + * This mode's Favourites on its home. Reads only this mode's saved slugs from + * the account data already loaded for the app (nothing here writes, and no + * service or form registry is fetched). Hidden when there are none, while they + * load, and for a signed-out reader. */ export function ModeHomeFavouritesGroup({ modeId, @@ -20,13 +25,63 @@ export function ModeHomeFavouritesGroup({ modeId: AppModeId; favouriteType: "differentials" | "therapies"; }) { - const { items } = useSavedRegistryFavourites(); - const shown = items.filter((item) => item.type === favouriteType).slice(0, FAVOURITES_SHOWN); - if (shown.length === 0) return null; + return favouriteType === "therapies" ? ( + + ) : ( + + ); +} + +/** Differential favourites are named from their slug, exactly as the Favourites page names them. */ +function DifferentialFavourites({ modeId }: { modeId: AppModeId }) { + const slugs = useAccountData().favourites.differential.slice(0, FAVOURITES_SHOWN); + const rows = slugs.map((slug) => ({ + slug, + title: slug + .split("-") + .filter(Boolean) + .map((word) => word[0]?.toUpperCase() + word.slice(1)) + .join(" "), + href: `/differentials/diagnoses/${encodeURIComponent(slug)}`, + })); + return ; +} + +/** Therapy favourites take their names from the therapy catalogue, loaded only on the Therapy home and only when some are saved. */ +function TherapyFavourites({ modeId }: { modeId: AppModeId }) { + const saved = useAccountData().favourites.therapy; + const key = saved.slice(0, FAVOURITES_SHOWN).join("\u0000"); + const [loaded, setLoaded] = useState<{ key: string; rows: FavouriteRow[] }>({ key: "", rows: [] }); + + useEffect(() => { + if (!key) return undefined; + let active = true; + void import("@/lib/therapies") + .then(({ findTherapyRecord }) => { + if (!active) return; + const rows = key.split("\u0000").flatMap((slug) => { + const therapy = findTherapyRecord(slug); + return therapy ? [{ slug, title: therapy.name, href: `/therapy-compass/${therapy.slug}` }] : []; + }); + setLoaded({ key, rows }); + }) + .catch(() => { + // The group stays hidden; the Favourites page still lists them. + }); + return () => { + active = false; + }; + }, [key]); + + return ; +} + +function FavouritesRows({ modeId, rows }: { modeId: AppModeId; rows: readonly FavouriteRow[] }) { + if (rows.length === 0) return null; return ( - {shown.map((item) => ( - + {rows.map((row) => ( + ))} ); diff --git a/src/components/recent-pages/recent-page-title.tsx b/src/components/recent-pages/recent-page-title.tsx new file mode 100644 index 0000000000..919d5c17a4 --- /dev/null +++ b/src/components/recent-pages/recent-page-title.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { useEffect } from "react"; + +import { announceRecentPageTitle } from "@/lib/recent-pages"; + +/** + * Hands a page's own name to Recent pages when its tab title does not carry it + * (a document's viewer is titled "PsychSift"). The name is one the page already + * has on screen: nothing extra is read or fetched for it. Renders nothing. + */ +export function RecentPageTitle({ path, title }: { readonly path: string; readonly title: string | null | undefined }) { + useEffect(() => { + if (title) announceRecentPageTitle(path, title); + }, [path, title]); + return null; +} diff --git a/src/components/recent-pages/recent-pages-recorder.tsx b/src/components/recent-pages/recent-pages-recorder.tsx index 787781c897..503e610a7d 100644 --- a/src/components/recent-pages/recent-pages-recorder.tsx +++ b/src/components/recent-pages/recent-pages-recorder.tsx @@ -1,10 +1,16 @@ "use client"; import { usePathname } from "next/navigation"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { mayRecordRecentSearches, useAppPreferences } from "@/components/clinical-dashboard/use-app-preferences"; -import { recentPageTarget, recentPageTitle, recordRecentPage } from "@/lib/recent-pages"; +import { + announcedRecentPageTitle, + recentPageTarget, + recentPageTitle, + recentPageTitleAnnouncedEvent, + recordRecentPage, +} from "@/lib/recent-pages"; /** How long after a navigation the page title may still settle. */ const TITLE_WATCH_MS = 8_000; @@ -12,24 +18,49 @@ const TITLE_WATCH_MS = 8_000; /** * Notes, on this device, each record or work area page the reader opens, for * the "Recent pages" list (side menu, AI Search, Clinical mode homes). Renders - * nothing. Paths the list may not hold (`recentPageTarget`) render nothing at - * all, so the preference bootstrap only runs where something may be recorded. + * nothing. Paths the list may not hold (`recentPageTarget`) mount nothing + * more, so the preference bootstrap only runs where something may be recorded. * - * The page's name is read from its title, which a client navigation can set a - * moment after the path changes, so the title is watched briefly and the entry - * updated if it changes. Nothing is written while "Save recent searches" is off. + * The page's name is a title the page handed over itself + * (`announceRecentPageTitle`, for pages such as a document whose tab title does + * not name it) or else its document title, which a client navigation can set + * a moment after the path changes. So the title is watched briefly, and while + * it still reads as the page the reader came from, nothing is written: the + * new path is never saved under the old page's name. */ export function RecentPagesRecorder() { const pathname = usePathname(); + // The document title as the navigation to this path began, captured while + // rendering the new path (before the new page's title is committed). Null on + // a cold load, where the title in the document is already this page's own. + const [navigation, setNavigation] = useState<{ path: string | null; startTitle: string | null }>(() => ({ + path: pathname, + startTitle: null, + })); + if (navigation.path !== pathname) { + setNavigation({ path: pathname, startTitle: typeof document === "undefined" ? null : document.title }); + } if (!pathname || !recentPageTarget(pathname)) return null; - return ; + return ; } function samePath(a: string, b: string): boolean { return a.replace(/\/+$/, "") === b.replace(/\/+$/, ""); } -function RecordRecentPage({ pathname }: { readonly pathname: string }) { +/** + * The title to record for this open, or null while there is none yet. Exported + * for tests. A handed-over title wins; otherwise a document title still equal + * to the one the navigation started from belongs to the previous page. + */ +export function recentPageTitleForOpen(pathname: string, startTitle: string | null): string | null { + const announced = announcedRecentPageTitle(pathname); + if (announced) return announced; + if (startTitle !== null && document.title === startTitle) return null; + return recentPageTitle(document.title); +} + +function RecordRecentPage({ pathname, startTitle }: { readonly pathname: string; readonly startTitle: string | null }) { const { canRecordRecentSearches } = useAppPreferences(); useEffect(() => { @@ -40,7 +71,7 @@ function RecordRecentPage({ pathname }: { readonly pathname: string }) { let lastTitle: string | null = null; const record = () => { if (!mayRecordRecentSearches() || !samePath(window.location.pathname, pathname)) return; - const title = recentPageTitle(document.title); + const title = recentPageTitleForOpen(pathname, startTitle); if (!title || title === lastTitle) return; lastTitle = title; recordRecentPage(pathname, title, at); @@ -48,13 +79,18 @@ function RecordRecentPage({ pathname }: { readonly pathname: string }) { const first = window.setTimeout(record, 400); const observer = new MutationObserver(record); observer.observe(document.head, { subtree: true, childList: true, characterData: true }); - const stop = window.setTimeout(() => observer.disconnect(), TITLE_WATCH_MS); + window.addEventListener(recentPageTitleAnnouncedEvent, record); + const stop = window.setTimeout(() => { + observer.disconnect(); + window.removeEventListener(recentPageTitleAnnouncedEvent, record); + }, TITLE_WATCH_MS); return () => { window.clearTimeout(first); window.clearTimeout(stop); observer.disconnect(); + window.removeEventListener(recentPageTitleAnnouncedEvent, record); }; - }, [pathname, canRecordRecentSearches]); + }, [pathname, startTitle, canRecordRecentSearches]); return null; } diff --git a/src/lib/recent-pages.ts b/src/lib/recent-pages.ts index 341d73b029..5b5a7a2ff8 100644 --- a/src/lib/recent-pages.ts +++ b/src/lib/recent-pages.ts @@ -298,9 +298,33 @@ function notify() { subscribeAccountTransition(() => { cache = null; + announcedTitles.clear(); notify(); }); +/** + * Titles a page hands over itself, by path, for pages whose document title + * does not name the record (a document's viewer is titled "PsychSift"). Held in + * memory only, until the recorder reads them; never stored apart from the list. + */ +const announcedTitles = new Map(); +export const recentPageTitleAnnouncedEvent = "psychsift:recent-page-title"; + +/** A page names itself for Recent pages. Ignored for any path the list may not hold. */ +export function announceRecentPageTitle(pathname: string, title: string): void { + const target = recentPageTarget(pathname); + const name = title.trim(); + if (!target || !name || name.length > 120) return; + announcedTitles.set(target.path, name); + if (typeof window !== "undefined") window.dispatchEvent(new Event(recentPageTitleAnnouncedEvent)); +} + +/** The title a page handed over for this path, if any. */ +export function announcedRecentPageTitle(pathname: string): string | null { + const target = recentPageTarget(pathname); + return target ? (announcedTitles.get(target.path) ?? null) : null; +} + /** * Record one open of `pathname` titled `title`. Anything `recentPageTarget` * refuses is dropped here, whatever the caller checked. The caller decides diff --git a/tests/mode-home-favourites-group.dom.test.tsx b/tests/mode-home-favourites-group.dom.test.tsx new file mode 100644 index 0000000000..8ce4b61bfd --- /dev/null +++ b/tests/mode-home-favourites-group.dom.test.tsx @@ -0,0 +1,60 @@ +/** @vitest-environment jsdom */ + +// A mode home's Favourites read only that mode's saved slugs: no service or +// form registry request, and the therapy catalogue loads on the Therapy home only. + +import { cleanup, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ModeHomeFavouritesGroup } from "@/components/mode-home-favourites-group"; + +const saved = vi.hoisted(() => ({ + favourites: { service: ["crisis-line"], form: ["form-1a"], differential: ["akathisia"], therapy: ["cbt"] }, +})); +const therapyLookups = vi.hoisted(() => ({ count: 0 })); + +vi.mock("@/components/account-data-provider", () => ({ + useAccountData: () => saved, + useOptionalAccountData: () => saved, +})); + +vi.mock("@/lib/therapies", () => ({ + findTherapyRecord: (slug: string) => { + therapyLookups.count += 1; + return slug === "cbt" ? { slug: "cbt", name: "Cognitive behavioural therapy" } : undefined; + }, +})); + +vi.mock("next/navigation", () => ({ + usePathname: () => "/", + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn(), prefetch: vi.fn() }), + useSearchParams: () => new URLSearchParams(), +})); + +afterEach(() => { + cleanup(); + therapyLookups.count = 0; + vi.restoreAllMocks(); +}); + +describe("mode home favourites", () => { + it("lists differential favourites without fetching anything or loading therapies", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + render(); + const link = within(screen.getByTestId("mode-home-favourites")).getByRole("link", { name: "Akathisia" }); + expect(link.getAttribute("href")).toBe("/differentials/diagnoses/akathisia"); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(therapyLookups.count).toBe(0); + }); + + it("names therapy favourites from the therapy catalogue on the Therapy home", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + render(); + await waitFor(() => expect(screen.getByRole("link", { name: "Cognitive behavioural therapy" })).toBeTruthy()); + expect(screen.getByRole("link", { name: "Cognitive behavioural therapy" }).getAttribute("href")).toBe( + "/therapy-compass/cbt", + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/recent-pages-recorder.dom.test.tsx b/tests/recent-pages-recorder.dom.test.tsx new file mode 100644 index 0000000000..0fa020d312 --- /dev/null +++ b/tests/recent-pages-recorder.dom.test.tsx @@ -0,0 +1,88 @@ +/** @vitest-environment jsdom */ + +// The Recent pages recorder: a document page is recorded under the title it +// hands over (its tab title is only "PsychSift"), and a client navigation never +// saves the previous page's title under the new path while the route loads. + +import { act, cleanup, render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { RecentPageTitle } from "@/components/recent-pages/recent-page-title"; +import { RecentPagesRecorder } from "@/components/recent-pages/recent-pages-recorder"; +import { clearRecentPages, readRecentPages } from "@/lib/recent-pages"; + +const navigation = vi.hoisted(() => ({ pathname: "/roster" })); + +vi.mock("next/navigation", () => ({ + usePathname: () => navigation.pathname, + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn(), prefetch: vi.fn() }), + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock("@/components/clinical-dashboard/use-app-preferences", () => ({ + useAppPreferences: () => ({ canRecordRecentSearches: true }), + mayRecordRecentSearches: () => true, +})); + +function goTo(path: string) { + navigation.pathname = path; + window.history.replaceState(null, "", path); +} + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + clearRecentPages(); + window.localStorage.clear(); + document.title = ""; +}); + +describe("Recent pages recorder", () => { + it("records a document page under the title the page hands over", () => { + goTo("/documents/doc-123"); + document.title = "PsychSift"; + render( + <> + + + , + ); + act(() => { + vi.advanceTimersByTime(500); + }); + expect(readRecentPages().map((page) => [page.path, page.title, page.section])).toEqual([ + ["/documents/doc-123", "Clozapine monitoring guideline", "documents"], + ]); + }); + + it("never saves the previous page's title under the new path while the route is loading", async () => { + goTo("/roster"); + document.title = "Roster | PsychSift"; + const view = render(); + act(() => { + vi.advanceTimersByTime(500); + }); + expect(readRecentPages().map((page) => page.title)).toEqual(["Roster"]); + + // Client navigation: the path changes first; the tab still shows Roster. + goTo("/dsm/diagnoses/panic-disorder"); + view.rerender(); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(readRecentPages().find((page) => page.path === "/dsm/diagnoses/panic-disorder")).toBeUndefined(); + + // The new page's title arrives. + await act(async () => { + document.title = "Panic disorder | DSM-5 Diagnosis | PsychSift"; + await Promise.resolve(); + }); + expect(readRecentPages().find((page) => page.path === "/dsm/diagnoses/panic-disorder")?.title).toBe( + "Panic disorder", + ); + }); +}); From 2b91d60175f7b98db1ef99d369fdade402e4cf5f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 16:12:12 +0000 Subject: [PATCH 3/8] fix(recent-pages): honour Save recent searches everywhere, never keep patient-like titles, expire and clear reliably - Recent pages hide when Save recent searches is off, and Work profile's toggle and Clear now clear them - titles that could name a patient are never kept or announced - entries are dropped and removed from the device once 90 days old, even if nothing else is written - a clear or account change stops a page still settling its title from writing itself back - roster requests and anything below a record are never recorded - mode-home favourites stay hidden while an account's favourites load Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Mws6schjKQbpCQqQzPMKSL --- src/components/mode-home-favourites-group.tsx | 4 ++ .../recent-pages/recent-pages-recorder.tsx | 4 ++ .../recent-pages/use-recent-pages.ts | 12 +++- .../work-profile/work-profile-panels.tsx | 11 ++- src/lib/recent-pages.ts | 67 ++++++++++++++++--- tests/mode-home-favourites-group.dom.test.tsx | 11 +++ tests/recent-pages.dom.test.ts | 38 +++++++++++ 7 files changed, 132 insertions(+), 15 deletions(-) diff --git a/src/components/mode-home-favourites-group.tsx b/src/components/mode-home-favourites-group.tsx index 5f9b753b5a..d8788871e4 100644 --- a/src/components/mode-home-favourites-group.tsx +++ b/src/components/mode-home-favourites-group.tsx @@ -25,6 +25,10 @@ export function ModeHomeFavouritesGroup({ modeId: AppModeId; favouriteType: "differentials" | "therapies"; }) { + // While an account's favourites are loading (including straight after a switch + // of account), the previous account's list is still in memory: show nothing. + const { ready } = useAccountData(); + if (!ready) return null; return favouriteType === "therapies" ? ( ) : ( diff --git a/src/components/recent-pages/recent-pages-recorder.tsx b/src/components/recent-pages/recent-pages-recorder.tsx index 503e610a7d..e79bb47991 100644 --- a/src/components/recent-pages/recent-pages-recorder.tsx +++ b/src/components/recent-pages/recent-pages-recorder.tsx @@ -8,6 +8,7 @@ import { announcedRecentPageTitle, recentPageTarget, recentPageTitle, + recentPagesGeneration, recentPageTitleAnnouncedEvent, recordRecentPage, } from "@/lib/recent-pages"; @@ -68,8 +69,11 @@ function RecordRecentPage({ pathname, startTitle }: { readonly pathname: string; // A page opened with a search in its address is a search, not a page to come back to. if (/[?&](q|query)=/.test(window.location.search)) return undefined; const at = Date.now(); + // A clear or a change of account after this open began ends it: nothing is written back. + const startGeneration = recentPagesGeneration(); let lastTitle: string | null = null; const record = () => { + if (recentPagesGeneration() !== startGeneration) return; if (!mayRecordRecentSearches() || !samePath(window.location.pathname, pathname)) return; const title = recentPageTitleForOpen(pathname, startTitle); if (!title || title === lastTitle) return; diff --git a/src/components/recent-pages/use-recent-pages.ts b/src/components/recent-pages/use-recent-pages.ts index 9498b4103d..bd1e49b5b4 100644 --- a/src/components/recent-pages/use-recent-pages.ts +++ b/src/components/recent-pages/use-recent-pages.ts @@ -3,6 +3,7 @@ import { usePathname } from "next/navigation"; import { useMemo, useSyncExternalStore } from "react"; +import { readAppPreferences, subscribeAppPreferences } from "@/components/clinical-dashboard/use-app-preferences"; import { pickRecentPages, readNoRecentPages, @@ -12,6 +13,10 @@ import { type RecentPageSection, } from "@/lib/recent-pages"; +const NO_PAGES: readonly RecentPage[] = []; +const readSaveRecentSearches = () => readAppPreferences().saveRecentSearches; +const readNotAllowed = () => false; + /** * The newest "Recent pages" on this device, leaving out the page being looked * at. The server and the first client frame read none, so the groups that show @@ -25,9 +30,12 @@ export function useRecentPages({ readonly section?: RecentPageSection; }): readonly RecentPage[] { const pages = useSyncExternalStore(subscribeRecentPages, readRecentPages, readNoRecentPages); + // "Save recent searches" off hides the list however it was turned off (Settings, + // Work profile or another device), as AI Search's own recents do. + const allowed = useSyncExternalStore(subscribeAppPreferences, readSaveRecentSearches, readNotAllowed); const pathname = usePathname(); return useMemo( - () => pickRecentPages(pages, { limit, section, exceptPath: pathname }), - [pages, limit, section, pathname], + () => (allowed ? pickRecentPages(pages, { limit, section, exceptPath: pathname }) : NO_PAGES), + [allowed, pages, limit, section, pathname], ); } diff --git a/src/components/work-profile/work-profile-panels.tsx b/src/components/work-profile/work-profile-panels.tsx index 245da0061c..1d0a8dba58 100644 --- a/src/components/work-profile/work-profile-panels.tsx +++ b/src/components/work-profile/work-profile-panels.tsx @@ -33,6 +33,7 @@ import { import type { WorkProfileData, WorkProfilePreferences } from "@/components/work-profile/use-work-profile-data"; import { NewWorkModeOnly } from "@/components/work-mode-launch/work-mode-launch-provider"; import { JURISDICTION_OPTIONS, workStageLabel } from "@/lib/account-preferences"; +import { clearRecentPages, countRecentPages } from "@/lib/recent-pages"; import { clearRecentQueries, countRecentQueries } from "@/lib/recent-query-storage"; import { countAllRecents, forgetAllRecents } from "@/lib/work-search/memory"; import { FATIGUE_RULE_SET } from "@/lib/roster/fatigue-rules-source"; @@ -402,9 +403,10 @@ export function PrivacyPanel({ const clearSearches = () => { // Clinical search's recent queries and AI Search's Recent (kept in this tab's memory) together. - const count = countRecentQueries() + countAllRecents(); + const count = countRecentQueries() + countAllRecents() + countRecentPages(); clearRecentQueries(); forgetAllRecents(); + clearRecentPages(); setCleared(count > 0 ? "Recent searches cleared" : "No recent searches to clear"); }; @@ -432,7 +434,12 @@ export function PrivacyPanel({ title="Save recent searches" subtitle="Kept only in this browser tab, never on your account" enabled={preferences.saveRecentSearches} - onToggle={() => setPreference("saveRecentSearches", !preferences.saveRecentSearches)} + onToggle={() => { + const next = !preferences.saveRecentSearches; + setPreference("saveRecentSearches", next); + // Turning saving off also forgets the pages already kept, as Settings does. + if (!next) clearRecentPages(); + }} /> >> = { "on-call": new Set(["call", "handover", "refer", "referrals", "find"]), cme: new Set(["new", "applications", "log"]), teaching: new Set(["c", "feedback", "import", "review", "assessments"]), - roster: new Set(["sick", "join"]), + roster: new Set(["sick", "join", "requests"]), admin: new Set(["applications", "requests", "new-job", "hospital"]), "open-shifts": new Set(["post", "log"]), "my-day": new Set(["setup"]), @@ -195,9 +196,11 @@ export function recentPageTarget( const rule = CLINICAL_RECORD_RULES.find((candidate) => candidate.root === root); if (!rule || !second) return null; + // A record sits at one exact depth: anything below it (a therapy's brief or + // patient sheet, whose choices live in the query) cannot be brought back by a bare path. if (rule.via) { - if (!rule.via.includes(second) || !third) return null; - } else if (rule.notRecords?.includes(second)) { + if (!rule.via.includes(second) || !third || segments.length !== 3) return null; + } else if (rule.notRecords?.includes(second) || segments.length !== 2) { return null; } return { path, mode: "clinical", section: rule.section }; @@ -262,7 +265,7 @@ export function normaliseRecentPages(raw: unknown, now: number): RecentPage[] { } const EMPTY: readonly RecentPage[] = []; -let cache: { raw: string | null; value: readonly RecentPage[] } | null = null; +let cache: { raw: string | null; value: readonly RecentPage[]; expiresAt: number } | null = null; /** The stored list, newest first. Blocked or unreadable storage reads as no history. */ export function readRecentPages(now = Date.now()): readonly RecentPage[] { @@ -273,16 +276,32 @@ export function readRecentPages(now = Date.now()): readonly RecentPage[] { } catch { return EMPTY; } - if (cache && cache.raw === raw) return cache.value; + if (cache && cache.raw === raw && now < cache.expiresAt) return cache.value; let value: readonly RecentPage[] = EMPTY; + let storedCount = 0; if (raw) { try { - value = normaliseRecentPages(JSON.parse(raw), now); + const parsed: unknown = JSON.parse(raw); + storedCount = Array.isArray(parsed) ? parsed.length : 1; + value = normaliseRecentPages(parsed, now); } catch { value = EMPTY; + storedCount = 1; } } - cache = { raw, value }; + // Expired or unreadable entries are removed from the device, not only hidden. + if (raw && value.length < storedCount) { + try { + if (value.length === 0) window.localStorage.removeItem(RECENT_PAGES_STORAGE_KEY); + else window.localStorage.setItem(RECENT_PAGES_STORAGE_KEY, JSON.stringify(value)); + raw = value.length === 0 ? null : JSON.stringify(value); + } catch { + // Blocked storage: the list read is still filtered. + } + } + // The cached list holds only until its oldest entry expires. + const oldest = value.reduce((min, page) => Math.min(min, page.at), Infinity); + cache = { raw, value, expiresAt: oldest === Infinity ? Infinity : oldest + RECENT_PAGES_TTL_MS + 1 }; return value; } @@ -299,6 +318,7 @@ function notify() { subscribeAccountTransition(() => { cache = null; announcedTitles.clear(); + generation += 1; notify(); }); @@ -308,14 +328,37 @@ subscribeAccountTransition(() => { * memory only, until the recorder reads them; never stored apart from the list. */ const announcedTitles = new Map(); +const ANNOUNCED_TITLES_KEPT = 20; + +/** + * Bumped whenever the list is cleared or the account changes, so a page still + * settling its title from before then can never write itself back. + */ +let generation = 0; +export function recentPagesGeneration(): number { + return generation; +} + +/** A title that could name a patient is never kept (a document title is free text someone typed). */ +function safeRecentTitle(title: string): string | null { + const name = title.trim(); + if (!name || name.length > 120 || looksLikePatientDetail(name)) return null; + return name; +} export const recentPageTitleAnnouncedEvent = "psychsift:recent-page-title"; /** A page names itself for Recent pages. Ignored for any path the list may not hold. */ export function announceRecentPageTitle(pathname: string, title: string): void { const target = recentPageTarget(pathname); - const name = title.trim(); - if (!target || !name || name.length > 120) return; + const name = safeRecentTitle(title); + if (!target || !name) return; + announcedTitles.delete(target.path); announcedTitles.set(target.path, name); + while (announcedTitles.size > ANNOUNCED_TITLES_KEPT) { + const oldestKey = announcedTitles.keys().next().value; + if (oldestKey === undefined) break; + announcedTitles.delete(oldestKey); + } if (typeof window !== "undefined") window.dispatchEvent(new Event(recentPageTitleAnnouncedEvent)); } @@ -333,8 +376,8 @@ export function announcedRecentPageTitle(pathname: string): string | null { export function recordRecentPage(pathname: string, title: string, at: number = Date.now()): void { if (typeof window === "undefined") return; const target = recentPageTarget(pathname); - const name = title.trim(); - if (!target || !name || name.length > 120) return; + const name = safeRecentTitle(title); + if (!target || !name) return; const page: RecentPage = { ...target, title: name, at }; const current = readRecentPages(at); const next = normaliseRecentPages([page, ...current.filter((existing) => existing.path !== page.path)], at); @@ -368,6 +411,8 @@ export function clearRecentPages(): void { // Nothing stored that we can reach; nothing to clear. } cache = null; + announcedTitles.clear(); + generation += 1; notify(); } diff --git a/tests/mode-home-favourites-group.dom.test.tsx b/tests/mode-home-favourites-group.dom.test.tsx index 8ce4b61bfd..30d049f007 100644 --- a/tests/mode-home-favourites-group.dom.test.tsx +++ b/tests/mode-home-favourites-group.dom.test.tsx @@ -9,6 +9,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { ModeHomeFavouritesGroup } from "@/components/mode-home-favourites-group"; const saved = vi.hoisted(() => ({ + ready: true, favourites: { service: ["crisis-line"], form: ["form-1a"], differential: ["akathisia"], therapy: ["cbt"] }, })); const therapyLookups = vi.hoisted(() => ({ count: 0 })); @@ -57,4 +58,14 @@ describe("mode home favourites", () => { ); expect(fetchSpy).not.toHaveBeenCalled(); }); + + it("shows nothing while the account's favourites are loading, as after a change of account", () => { + saved.ready = false; + try { + render(); + expect(screen.queryByTestId("mode-home-favourites")).toBeNull(); + } finally { + saved.ready = true; + } + }); }); diff --git a/tests/recent-pages.dom.test.ts b/tests/recent-pages.dom.test.ts index d69e762cbe..b7b8f72827 100644 --- a/tests/recent-pages.dom.test.ts +++ b/tests/recent-pages.dom.test.ts @@ -15,11 +15,14 @@ import { normaliseRecentPages, pickRecentPages, readRecentPages, + recentPagesGeneration, RECENT_PAGES_LIMIT, RECENT_PAGES_TTL_MS, recentPageTarget, recentPageTitle, recordRecentPage, + announceRecentPageTitle, + announcedRecentPageTitle, } from "@/lib/recent-pages"; afterEach(() => { @@ -164,3 +167,38 @@ describe("the stored list", () => { ]); }); }); + +describe("privacy guards (review fixes)", () => { + it("never keeps a title that could name a patient", () => { + recordRecentPage("/documents/doc-1", "Discharge summary Mr Testpatient"); + expect(readRecentPages()).toEqual([]); + announceRecentPageTitle("/documents/doc-2", "Jane Citizen MRN 1234567"); + expect(announcedRecentPageTitle("/documents/doc-2")).toBeNull(); + recordRecentPage("/documents/doc-3", "Lithium monitoring guideline"); + expect(readRecentPages().map((page) => page.title)).toEqual(["Lithium monitoring guideline"]); + }); + + it("never records roster requests or anything below a record", () => { + expect(recentPageTarget("/roster/requests")).toBeNull(); + expect(recentPageTarget("/therapy-compass/cbt/brief")).toBeNull(); + expect(recentPageTarget("/therapy-compass/cbt/sheet")).toBeNull(); + expect(recentPageTarget("/dsm/diagnoses/major-depressive-disorder/extra")).toBeNull(); + expect(recentPageTarget("/therapy-compass/cbt")).not.toBeNull(); + }); + + it("drops entries that expire while cached, and removes them from the device", () => { + const at = Date.now(); + recordRecentPage("/medications/lithium", "Lithium", at); + expect(readRecentPages(at)).toHaveLength(1); + expect(readRecentPages(at + RECENT_PAGES_TTL_MS + 10)).toEqual([]); + expect(window.localStorage.getItem(RECENT_PAGES_STORAGE_KEY)).toBeNull(); + }); + + it("moves to a new generation on clear and at an account transition", () => { + const before = recentPagesGeneration(); + clearRecentPages(); + expect(recentPagesGeneration()).toBe(before + 1); + clearAccountScopedBrowserStorage(); + expect(recentPagesGeneration()).toBeGreaterThan(before + 1); + }); +}); From d947fcbd468312bc232c51eee9f96fcb5066d7fa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 16:23:04 +0000 Subject: [PATCH 4/8] Mode home start list: move the shared row and group into their own file to end an import cycle Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Mws6schjKQbpCQqQzPMKSL --- src/components/mode-home-favourites-group.tsx | 2 +- src/components/mode-home-start-list.tsx | 48 +---------------- src/components/mode-home-start-parts.tsx | 53 +++++++++++++++++++ 3 files changed, 56 insertions(+), 47 deletions(-) create mode 100644 src/components/mode-home-start-parts.tsx diff --git a/src/components/mode-home-favourites-group.tsx b/src/components/mode-home-favourites-group.tsx index d8788871e4..dd17238b3e 100644 --- a/src/components/mode-home-favourites-group.tsx +++ b/src/components/mode-home-favourites-group.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { useAccountData } from "@/components/account-data-provider"; -import { ModeHomeStartGroup, ModeHomeStartRow } from "@/components/mode-home-start-list"; +import { ModeHomeStartGroup, ModeHomeStartRow } from "@/components/mode-home-start-parts"; import { appModeIcons } from "@/lib/app-mode-icons"; import type { AppModeId } from "@/lib/app-modes"; diff --git a/src/components/mode-home-start-list.tsx b/src/components/mode-home-start-list.tsx index 53c726d0d2..b1eb2fabf8 100644 --- a/src/components/mode-home-start-list.tsx +++ b/src/components/mode-home-start-list.tsx @@ -1,13 +1,12 @@ "use client"; -import { ChevronRight, LayoutList, type LucideIcon } from "lucide-react"; +import { ChevronRight, LayoutList } from "lucide-react"; import dynamic from "next/dynamic"; import Link from "next/link"; -import { type ReactNode } from "react"; import { useOptionalAccountData } from "@/components/account-data-provider"; import { useRecentPages } from "@/components/recent-pages/use-recent-pages"; -import { cn, eyebrowText } from "@/components/ui-primitives"; +import { ModeHomeStartGroup, ModeHomeStartRow } from "@/components/mode-home-start-parts"; import { appModeIcons } from "@/lib/app-mode-icons"; import type { AppModeId } from "@/lib/app-modes"; import { RECENT_PAGES_CONTINUE_SHOWN, type RecentPageSection } from "@/lib/recent-pages"; @@ -63,49 +62,6 @@ const ModeHomeFavouritesGroup = dynamic( { ssr: false }, ); -const rowClass = - "group grid min-h-12 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 px-3 py-2 text-left text-[color:var(--text-heading)] transition-colors hover:bg-[color:var(--surface-subtle)] focus-visible:relative focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[color:var(--focus)] motion-reduce:transition-none"; - -/** One row: the record's icon, its own title and a chevron. 48 px tall at least. */ -export function ModeHomeStartRow({ href, icon: Icon, title }: { href: string; icon: LucideIcon; title: string }) { - return ( -
  • - -
  • - ); -} - -/** A labelled group: a quiet heading over a flat hairline card. */ -export function ModeHomeStartGroup({ - id, - title, - testId, - children, -}: { - id: string; - title: string; - testId: string; - children: ReactNode; -}) { - return ( -
    -

    - {title} -

    -
      - {children} -
    -
    - ); -} - function ContinueGroup({ modeId, section }: { modeId: StartListMode; section: RecentPageSection }) { const pages = useRecentPages({ limit: RECENT_PAGES_CONTINUE_SHOWN, section }); if (pages.length === 0) return null; diff --git a/src/components/mode-home-start-parts.tsx b/src/components/mode-home-start-parts.tsx new file mode 100644 index 0000000000..6e613cf0f8 --- /dev/null +++ b/src/components/mode-home-start-parts.tsx @@ -0,0 +1,53 @@ +import { ChevronRight, type LucideIcon } from "lucide-react"; +import Link from "next/link"; +import { type ReactNode } from "react"; + +import { cn, eyebrowText } from "@/components/ui-primitives"; + +/* + * The row and group a Clinical mode home's start list is built from. Kept apart from the start + * list itself so the lazily loaded favourites group can use them without an import cycle. + */ + +const rowClass = + "group grid min-h-12 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 px-3 py-2 text-left text-[color:var(--text-heading)] transition-colors hover:bg-[color:var(--surface-subtle)] focus-visible:relative focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[color:var(--focus)] motion-reduce:transition-none"; + +/** One row: the record's icon, its own title and a chevron. 48 px tall at least. */ +export function ModeHomeStartRow({ href, icon: Icon, title }: { href: string; icon: LucideIcon; title: string }) { + return ( +
  • + +
  • + ); +} + +/** A labelled group: a quiet heading over a flat hairline card. */ +export function ModeHomeStartGroup({ + id, + title, + testId, + children, +}: { + id: string; + title: string; + testId: string; + children: ReactNode; +}) { + return ( +
    +

    + {title} +

    +
      + {children} +
    +
    + ); +} From 78f272a687e76d07f62d12500442ac34db05a568 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:00:17 +0000 Subject: [PATCH 5/8] Recent pages: load the recorder after the page and keep the patient-detail check off every page's first download Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Mws6schjKQbpCQqQzPMKSL --- src/app/(search-app)/layout.tsx | 4 +-- .../recent-pages/recent-page-title.tsx | 2 +- .../recent-pages-recorder-deferred.tsx | 12 +++++++ .../recent-pages/recent-pages-recorder.tsx | 2 +- src/lib/recent-pages-record.ts | 36 +++++++++++++++++++ src/lib/recent-pages.ts | 34 +++++------------- tests/mode-home-start-list.dom.test.tsx | 3 +- tests/recent-pages-menus.dom.test.tsx | 3 +- tests/recent-pages.dom.test.ts | 3 +- 9 files changed, 65 insertions(+), 34 deletions(-) create mode 100644 src/components/recent-pages/recent-pages-recorder-deferred.tsx create mode 100644 src/lib/recent-pages-record.ts diff --git a/src/app/(search-app)/layout.tsx b/src/app/(search-app)/layout.tsx index d194c19272..10fb114d7e 100644 --- a/src/app/(search-app)/layout.tsx +++ b/src/app/(search-app)/layout.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from "react"; import { SharedSearchAppShell } from "@/components/clinical-dashboard/shared-search-app-shell"; import { PsychiatryVisitRecorder } from "@/components/psychiatry/psychiatry-visit-recorder"; -import { RecentPagesRecorder } from "@/components/recent-pages/recent-pages-recorder"; +import { RecentPagesRecorderDeferred } from "@/components/recent-pages/recent-pages-recorder-deferred"; import { WorkModeLaunchProvider } from "@/components/work-mode-launch/work-mode-launch-provider"; import { clinicalAskModeEnabled } from "@/lib/clinical-ask/authority-registry"; import { projectClinicalAskAvailableModeIds } from "@/lib/clinical-ask/capabilities"; @@ -28,7 +28,7 @@ export default async function SearchAppLayout({ children }: { children: ReactNod {children} - + ); diff --git a/src/components/recent-pages/recent-page-title.tsx b/src/components/recent-pages/recent-page-title.tsx index 919d5c17a4..06240d44b4 100644 --- a/src/components/recent-pages/recent-page-title.tsx +++ b/src/components/recent-pages/recent-page-title.tsx @@ -2,7 +2,7 @@ import { useEffect } from "react"; -import { announceRecentPageTitle } from "@/lib/recent-pages"; +import { announceRecentPageTitle } from "@/lib/recent-pages-record"; /** * Hands a page's own name to Recent pages when its tab title does not carry it diff --git a/src/components/recent-pages/recent-pages-recorder-deferred.tsx b/src/components/recent-pages/recent-pages-recorder-deferred.tsx new file mode 100644 index 0000000000..f783b92bd7 --- /dev/null +++ b/src/components/recent-pages/recent-pages-recorder-deferred.tsx @@ -0,0 +1,12 @@ +"use client"; + +import dynamic from "next/dynamic"; + +/** + * The Recent pages recorder draws nothing and only writes after a page settles, + * so it loads just after the page instead of adding to every page's first download. + */ +export const RecentPagesRecorderDeferred = dynamic( + () => import("@/components/recent-pages/recent-pages-recorder").then((m) => m.RecentPagesRecorder), + { ssr: false }, +); diff --git a/src/components/recent-pages/recent-pages-recorder.tsx b/src/components/recent-pages/recent-pages-recorder.tsx index e79bb47991..cf0b811b7a 100644 --- a/src/components/recent-pages/recent-pages-recorder.tsx +++ b/src/components/recent-pages/recent-pages-recorder.tsx @@ -10,8 +10,8 @@ import { recentPageTitle, recentPagesGeneration, recentPageTitleAnnouncedEvent, - recordRecentPage, } from "@/lib/recent-pages"; +import { recordRecentPage } from "@/lib/recent-pages-record"; /** How long after a navigation the page title may still settle. */ const TITLE_WATCH_MS = 8_000; diff --git a/src/lib/recent-pages-record.ts b/src/lib/recent-pages-record.ts new file mode 100644 index 0000000000..4295feee4f --- /dev/null +++ b/src/lib/recent-pages-record.ts @@ -0,0 +1,36 @@ +import { holdAnnouncedRecentPageTitle, recentPageTarget, storeRecentPage, type RecentPage } from "@/lib/recent-pages"; +import { looksLikePatientDetail } from "@/lib/work-text/patient-detail-check"; + +/* + * Writing to Recent pages. Kept apart from the list itself so the menus that only + * read it do not carry the patient-detail check in every page's first download. + */ + +/** A title that could name a patient is never kept (a document title is free text someone typed). */ +function safeRecentTitle(title: string): string | null { + const name = title.trim(); + if (!name || name.length > 120 || looksLikePatientDetail(name)) return null; + return name; +} + +/** A page names itself for Recent pages. Ignored for any path the list may not hold. */ +export function announceRecentPageTitle(pathname: string, title: string): void { + const target = recentPageTarget(pathname); + const name = safeRecentTitle(title); + if (!target || !name) return; + holdAnnouncedRecentPageTitle(target.path, name); +} + +/** + * Record one open of `pathname` titled `title`. Anything `recentPageTarget` + * refuses is dropped here, whatever the caller checked. The caller decides + * whether recording is allowed (the "Save recent searches" switch). + */ +export function recordRecentPage(pathname: string, title: string, at: number = Date.now()): void { + if (typeof window === "undefined") return; + const target = recentPageTarget(pathname); + const name = safeRecentTitle(title); + if (!target || !name) return; + const page: RecentPage = { ...target, title: name, at }; + storeRecentPage(page); +} diff --git a/src/lib/recent-pages.ts b/src/lib/recent-pages.ts index ba95146145..517dce741f 100644 --- a/src/lib/recent-pages.ts +++ b/src/lib/recent-pages.ts @@ -1,5 +1,4 @@ import { RECENT_PAGES_STORAGE_KEY, subscribeAccountTransition } from "@/lib/account-scoped-browser-state"; -import { looksLikePatientDetail } from "@/lib/work-text/patient-detail-check"; /** * "Recent pages": the last few pages this reader opened, across Work and @@ -339,21 +338,12 @@ export function recentPagesGeneration(): number { return generation; } -/** A title that could name a patient is never kept (a document title is free text someone typed). */ -function safeRecentTitle(title: string): string | null { - const name = title.trim(); - if (!name || name.length > 120 || looksLikePatientDetail(name)) return null; - return name; -} export const recentPageTitleAnnouncedEvent = "psychsift:recent-page-title"; -/** A page names itself for Recent pages. Ignored for any path the list may not hold. */ -export function announceRecentPageTitle(pathname: string, title: string): void { - const target = recentPageTarget(pathname); - const name = safeRecentTitle(title); - if (!target || !name) return; - announcedTitles.delete(target.path); - announcedTitles.set(target.path, name); +/** For the recording module only: hold a title a page announced for itself. */ +export function holdAnnouncedRecentPageTitle(path: string, name: string): void { + announcedTitles.delete(path); + announcedTitles.set(path, name); while (announcedTitles.size > ANNOUNCED_TITLES_KEPT) { const oldestKey = announcedTitles.keys().next().value; if (oldestKey === undefined) break; @@ -368,19 +358,11 @@ export function announcedRecentPageTitle(pathname: string): string | null { return target ? (announcedTitles.get(target.path) ?? null) : null; } -/** - * Record one open of `pathname` titled `title`. Anything `recentPageTarget` - * refuses is dropped here, whatever the caller checked. The caller decides - * whether recording is allowed (the "Save recent searches" switch). - */ -export function recordRecentPage(pathname: string, title: string, at: number = Date.now()): void { +/** For the recording module only: put one already-checked page at the top of the list. */ +export function storeRecentPage(page: RecentPage): void { if (typeof window === "undefined") return; - const target = recentPageTarget(pathname); - const name = safeRecentTitle(title); - if (!target || !name) return; - const page: RecentPage = { ...target, title: name, at }; - const current = readRecentPages(at); - const next = normaliseRecentPages([page, ...current.filter((existing) => existing.path !== page.path)], at); + const current = readRecentPages(page.at); + const next = normaliseRecentPages([page, ...current.filter((existing) => existing.path !== page.path)], page.at); try { window.localStorage.setItem(RECENT_PAGES_STORAGE_KEY, JSON.stringify(next)); } catch { diff --git a/tests/mode-home-start-list.dom.test.tsx b/tests/mode-home-start-list.dom.test.tsx index 3464ab0b50..2a8b1f16b0 100644 --- a/tests/mode-home-start-list.dom.test.tsx +++ b/tests/mode-home-start-list.dom.test.tsx @@ -10,7 +10,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { SharedHomeEmptyState } from "@/components/clinical-dashboard/answer-status"; import { ModeHomeStartList, modeHomeStartListConfig } from "@/components/mode-home-start-list"; -import { clearRecentPages, recordRecentPage } from "@/lib/recent-pages"; +import { clearRecentPages } from "@/lib/recent-pages"; +import { recordRecentPage } from "@/lib/recent-pages-record"; vi.mock("next/navigation", () => ({ usePathname: () => "/", diff --git a/tests/recent-pages-menus.dom.test.tsx b/tests/recent-pages-menus.dom.test.tsx index dfe0d6efdc..6e62585346 100644 --- a/tests/recent-pages-menus.dom.test.tsx +++ b/tests/recent-pages-menus.dom.test.tsx @@ -8,7 +8,8 @@ import { cleanup, render, screen, within } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ClinicalMenuRecentPages, ClinicalMenuWorkAreas } from "@/components/clinical-dashboard/clinical-menu-drawer"; -import { clearRecentPages, recordRecentPage, RECENT_PAGES_SHOWN } from "@/lib/recent-pages"; +import { clearRecentPages, RECENT_PAGES_SHOWN } from "@/lib/recent-pages"; +import { recordRecentPage } from "@/lib/recent-pages-record"; vi.mock("next/navigation", () => ({ usePathname: () => "/roster", diff --git a/tests/recent-pages.dom.test.ts b/tests/recent-pages.dom.test.ts index b7b8f72827..448e2353fa 100644 --- a/tests/recent-pages.dom.test.ts +++ b/tests/recent-pages.dom.test.ts @@ -20,10 +20,9 @@ import { RECENT_PAGES_TTL_MS, recentPageTarget, recentPageTitle, - recordRecentPage, - announceRecentPageTitle, announcedRecentPageTitle, } from "@/lib/recent-pages"; +import { announceRecentPageTitle, recordRecentPage } from "@/lib/recent-pages-record"; afterEach(() => { clearRecentPages(); From dcc4c09642850e8655bb400f208c456ddc09b747 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:13:22 +0000 Subject: [PATCH 6/8] Recent pages: a recorder that loads after the reader moved on waits for the new page's own title Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Mws6schjKQbpCQqQzPMKSL --- .../recent-pages/recent-pages-recorder.tsx | 23 ++++++++++++++++++- tests/recent-pages-recorder.dom.test.tsx | 22 ++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/components/recent-pages/recent-pages-recorder.tsx b/src/components/recent-pages/recent-pages-recorder.tsx index cf0b811b7a..eab9768010 100644 --- a/src/components/recent-pages/recent-pages-recorder.tsx +++ b/src/components/recent-pages/recent-pages-recorder.tsx @@ -34,9 +34,12 @@ export function RecentPagesRecorder() { // The document title as the navigation to this path began, captured while // rendering the new path (before the new page's title is committed). Null on // a cold load, where the title in the document is already this page's own. + // This recorder loads after the page, so a reader may already have moved on + // by the time it first renders; then the title on screen may still be the + // previous page's, and is treated as such (see `firstStartTitle`). const [navigation, setNavigation] = useState<{ path: string | null; startTitle: string | null }>(() => ({ path: pathname, - startTitle: null, + startTitle: firstStartTitle(pathname), })); if (navigation.path !== pathname) { setNavigation({ path: pathname, startTitle: typeof document === "undefined" ? null : document.title }); @@ -45,6 +48,24 @@ export function RecentPagesRecorder() { return ; } +/** + * The start title for the recorder's first render. Null when this is still the + * page the document was loaded on (a cold load: its title is its own). When a + * client navigation happened before this recorder loaded, the current title is + * held as the start title, so it is only recorded once the page sets a new one; + * at worst a quick visit goes unrecorded, never mislabelled. + */ +function firstStartTitle(pathname: string | null): string | null { + if (typeof document === "undefined" || !pathname) return null; + try { + const entry = performance.getEntriesByType("navigation")[0]; + if (!entry || samePath(new URL(entry.name).pathname, pathname)) return null; + } catch { + return null; + } + return document.title; +} + function samePath(a: string, b: string): boolean { return a.replace(/\/+$/, "") === b.replace(/\/+$/, ""); } diff --git a/tests/recent-pages-recorder.dom.test.tsx b/tests/recent-pages-recorder.dom.test.tsx index 0fa020d312..d063abf98e 100644 --- a/tests/recent-pages-recorder.dom.test.tsx +++ b/tests/recent-pages-recorder.dom.test.tsx @@ -85,4 +85,26 @@ describe("Recent pages recorder", () => { "Panic disorder", ); }); + it("does not trust the title on screen when it first loads after the reader has already moved on", async () => { + // The document was loaded on /roster; the reader moved to a diagnosis before this recorder loaded. + vi.spyOn(performance, "getEntriesByType").mockReturnValue([ + { name: "http://localhost/roster", type: "navigate" } as unknown as PerformanceEntry, + ]); + goTo("/dsm/diagnoses/panic-disorder"); + document.title = "Roster | PsychSift"; + render(); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(readRecentPages()).toEqual([]); + + await act(async () => { + document.title = "Panic disorder | DSM-5 Diagnosis | PsychSift"; + await Promise.resolve(); + }); + expect(readRecentPages().map((page) => [page.path, page.title])).toEqual([ + ["/dsm/diagnoses/panic-disorder", "Panic disorder"], + ]); + vi.restoreAllMocks(); + }); }); From b61c6724fe5b1b07919d88b035d9532d311eb04c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:19:00 +0000 Subject: [PATCH 7/8] Recent pages: the small eager wrapper notes any navigation before the recorder loads, so a return visit is never saved under another page's title Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Mws6schjKQbpCQqQzPMKSL --- .../recent-pages-recorder-deferred.tsx | 17 ++++++++++- .../recent-pages/recent-pages-recorder.tsx | 29 +++++-------------- tests/recent-pages-recorder.dom.test.tsx | 9 ++---- 3 files changed, 26 insertions(+), 29 deletions(-) diff --git a/src/components/recent-pages/recent-pages-recorder-deferred.tsx b/src/components/recent-pages/recent-pages-recorder-deferred.tsx index f783b92bd7..b48df3375f 100644 --- a/src/components/recent-pages/recent-pages-recorder-deferred.tsx +++ b/src/components/recent-pages/recent-pages-recorder-deferred.tsx @@ -1,12 +1,27 @@ "use client"; import dynamic from "next/dynamic"; +import { usePathname } from "next/navigation"; +import { useState } from "react"; /** * The Recent pages recorder draws nothing and only writes after a page settles, * so it loads just after the page instead of adding to every page's first download. */ -export const RecentPagesRecorderDeferred = dynamic( +const RecentPagesRecorder = dynamic( () => import("@/components/recent-pages/recent-pages-recorder").then((m) => m.RecentPagesRecorder), { ssr: false }, ); + +/** + * Mounted with the page. It only notes whether the reader navigated before the + * recorder arrived, so the recorder never takes a title still on screen from an + * earlier page as the name of the one it first sees. + */ +export function RecentPagesRecorderDeferred() { + const pathname = usePathname(); + const [firstPathname] = useState(pathname); + const [navigated, setNavigated] = useState(false); + if (!navigated && pathname !== firstPathname) setNavigated(true); + return ; +} diff --git a/src/components/recent-pages/recent-pages-recorder.tsx b/src/components/recent-pages/recent-pages-recorder.tsx index eab9768010..01f075775f 100644 --- a/src/components/recent-pages/recent-pages-recorder.tsx +++ b/src/components/recent-pages/recent-pages-recorder.tsx @@ -29,17 +29,20 @@ const TITLE_WATCH_MS = 8_000; * it still reads as the page the reader came from, nothing is written: the * new path is never saved under the old page's name. */ -export function RecentPagesRecorder() { +export function RecentPagesRecorder({ navigatedBeforeLoad = false }: { readonly navigatedBeforeLoad?: boolean }) { const pathname = usePathname(); // The document title as the navigation to this path began, captured while // rendering the new path (before the new page's title is committed). Null on // a cold load, where the title in the document is already this page's own. // This recorder loads after the page, so a reader may already have moved on - // by the time it first renders; then the title on screen may still be the - // previous page's, and is treated as such (see `firstStartTitle`). + // by the time it first renders (`navigatedBeforeLoad`, tracked by the small + // eager wrapper). Then the title on screen may be a previous page's, even when + // the reader has come back to the page the document opened on, so it is held + // as the start title and only a new title is recorded. At worst a quick visit + // goes unrecorded, never mislabelled. const [navigation, setNavigation] = useState<{ path: string | null; startTitle: string | null }>(() => ({ path: pathname, - startTitle: firstStartTitle(pathname), + startTitle: navigatedBeforeLoad && typeof document !== "undefined" ? document.title : null, })); if (navigation.path !== pathname) { setNavigation({ path: pathname, startTitle: typeof document === "undefined" ? null : document.title }); @@ -48,24 +51,6 @@ export function RecentPagesRecorder() { return ; } -/** - * The start title for the recorder's first render. Null when this is still the - * page the document was loaded on (a cold load: its title is its own). When a - * client navigation happened before this recorder loaded, the current title is - * held as the start title, so it is only recorded once the page sets a new one; - * at worst a quick visit goes unrecorded, never mislabelled. - */ -function firstStartTitle(pathname: string | null): string | null { - if (typeof document === "undefined" || !pathname) return null; - try { - const entry = performance.getEntriesByType("navigation")[0]; - if (!entry || samePath(new URL(entry.name).pathname, pathname)) return null; - } catch { - return null; - } - return document.title; -} - function samePath(a: string, b: string): boolean { return a.replace(/\/+$/, "") === b.replace(/\/+$/, ""); } diff --git a/tests/recent-pages-recorder.dom.test.tsx b/tests/recent-pages-recorder.dom.test.tsx index d063abf98e..9c145da858 100644 --- a/tests/recent-pages-recorder.dom.test.tsx +++ b/tests/recent-pages-recorder.dom.test.tsx @@ -85,14 +85,12 @@ describe("Recent pages recorder", () => { "Panic disorder", ); }); + it("does not trust the title on screen when it first loads after the reader has already moved on", async () => { - // The document was loaded on /roster; the reader moved to a diagnosis before this recorder loaded. - vi.spyOn(performance, "getEntriesByType").mockReturnValue([ - { name: "http://localhost/roster", type: "navigate" } as unknown as PerformanceEntry, - ]); + // The reader moved to a diagnosis before this recorder loaded; the tab still shows Roster. goTo("/dsm/diagnoses/panic-disorder"); document.title = "Roster | PsychSift"; - render(); + render(); act(() => { vi.advanceTimersByTime(1000); }); @@ -105,6 +103,5 @@ describe("Recent pages recorder", () => { expect(readRecentPages().map((page) => [page.path, page.title])).toEqual([ ["/dsm/diagnoses/panic-disorder", "Panic disorder"], ]); - vi.restoreAllMocks(); }); }); From 7752fb67419b615b9e170cf80114cdaf0bd292a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:25:13 +0000 Subject: [PATCH 8/8] Recent pages: test the eager wrapper hands a late-loading recorder the fact that the reader moved on Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Mws6schjKQbpCQqQzPMKSL --- ...ecent-pages-recorder-deferred.dom.test.tsx | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 tests/recent-pages-recorder-deferred.dom.test.tsx diff --git a/tests/recent-pages-recorder-deferred.dom.test.tsx b/tests/recent-pages-recorder-deferred.dom.test.tsx new file mode 100644 index 0000000000..dbd08e3510 --- /dev/null +++ b/tests/recent-pages-recorder-deferred.dom.test.tsx @@ -0,0 +1,84 @@ +/** @vitest-environment jsdom */ + +// The eager wrapper around the lazily loaded Recent pages recorder: if the +// reader moves to another page before the recorder arrives, the recorder must +// not save the title still on screen from the earlier page under the new path. + +import { act, cleanup, render } from "@testing-library/react"; +import type { ComponentType } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { clearRecentPages, readRecentPages } from "@/lib/recent-pages"; + +const navigation = vi.hoisted(() => ({ pathname: "/roster" })); +const loader = vi.hoisted(() => ({ + ready: false, + Recorder: null as ComponentType<{ navigatedBeforeLoad?: boolean }> | null, +})); + +vi.mock("next/navigation", () => ({ + usePathname: () => navigation.pathname, + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn(), prefetch: vi.fn() }), + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock("@/components/clinical-dashboard/use-app-preferences", () => ({ + useAppPreferences: () => ({ canRecordRecentSearches: true }), + mayRecordRecentSearches: () => true, +})); + +// Stands in for next/dynamic: renders nothing until the test says the chunk has arrived. +vi.mock("next/dynamic", () => ({ + default: () => + function LazyRecorder(props: { navigatedBeforeLoad?: boolean }) { + const Recorder = loader.Recorder; + return loader.ready && Recorder ? : null; + }, +})); + +import { RecentPagesRecorderDeferred } from "@/components/recent-pages/recent-pages-recorder-deferred"; + +function goTo(path: string) { + navigation.pathname = path; + window.history.replaceState(null, "", path); +} + +beforeEach(async () => { + vi.useFakeTimers(); + loader.ready = false; + loader.Recorder = (await import("@/components/recent-pages/recent-pages-recorder")).RecentPagesRecorder; +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + clearRecentPages(); + window.localStorage.clear(); + document.title = ""; +}); + +describe("Recent pages recorder wrapper", () => { + it("tells a late-loading recorder that the reader moved on, so the old title is not saved", async () => { + goTo("/roster"); + document.title = "Roster | PsychSift"; + const view = render(); + + // The reader opens a diagnosis before the recorder arrives; the tab still shows Roster. + goTo("/dsm/diagnoses/panic-disorder"); + view.rerender(); + loader.ready = true; + view.rerender(); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(readRecentPages()).toEqual([]); + + await act(async () => { + document.title = "Panic disorder | DSM-5 Diagnosis | PsychSift"; + await Promise.resolve(); + }); + expect(readRecentPages().map((page) => [page.path, page.title])).toEqual([ + ["/dsm/diagnoses/panic-disorder", "Panic disorder"], + ]); + }); +});