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/app/(search-app)/layout.tsx b/src/app/(search-app)/layout.tsx index 25b993f48e..10fb114d7e 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 { 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"; @@ -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 46e38b256b..e55dbb078d 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, @@ -987,6 +988,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 d210a753fd..206621361f 100644 --- a/src/components/clinical-dashboard/answer-status.tsx +++ b/src/components/clinical-dashboard/answer-status.tsx @@ -16,6 +16,7 @@ import { } from "@/components/clinical-dashboard/answer-evidence-preview"; import type { VerifiedEvidencePreviewUnit } from "@/lib/answer-stream-contract"; import { useAppPreferences } from "@/components/clinical-dashboard/use-app-preferences"; +import { ModeHomeStartList, modeHomeStartListConfig } from "@/components/mode-home-start-list"; import { useModeResultsWarmup } from "@/components/clinical-dashboard/use-mode-results-warmup"; import { ModeHomeTemplate } from "@/components/mode-home-template"; import { LazyMyDayHomeCard } from "@/components/my-day/my-day-home-card-lazy"; @@ -94,6 +95,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-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(); + }} /> 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-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 new file mode 100644 index 0000000000..517dce741f --- /dev/null +++ b/src/lib/recent-pages.ts @@ -0,0 +1,428 @@ +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", "requests"]), + 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; + // 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 || segments.length !== 3) return null; + } else if (rule.notRecords?.includes(second) || segments.length !== 2) { + 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[]; 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[] { + 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 && now < cache.expiresAt) return cache.value; + let value: readonly RecentPage[] = EMPTY; + let storedCount = 0; + if (raw) { + try { + const parsed: unknown = JSON.parse(raw); + storedCount = Array.isArray(parsed) ? parsed.length : 1; + value = normaliseRecentPages(parsed, now); + } catch { + value = EMPTY; + storedCount = 1; + } + } + // 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; +} + +/** 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; + announcedTitles.clear(); + generation += 1; + 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(); +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; +} + +export const recentPageTitleAnnouncedEvent = "psychsift:recent-page-title"; + +/** 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; + announcedTitles.delete(oldestKey); + } + 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; +} + +/** 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 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 { + // 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; + announcedTitles.clear(); + generation += 1; + 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-favourites-group.dom.test.tsx b/tests/mode-home-favourites-group.dom.test.tsx new file mode 100644 index 0000000000..30d049f007 --- /dev/null +++ b/tests/mode-home-favourites-group.dom.test.tsx @@ -0,0 +1,71 @@ +/** @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(() => ({ + ready: true, + 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(); + }); + + 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/mode-home-start-list.dom.test.tsx b/tests/mode-home-start-list.dom.test.tsx new file mode 100644 index 0000000000..2a8b1f16b0 --- /dev/null +++ b/tests/mode-home-start-list.dom.test.tsx @@ -0,0 +1,83 @@ +/** @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 } from "@/lib/recent-pages"; +import { recordRecentPage } from "@/lib/recent-pages-record"; + +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..6e62585346 --- /dev/null +++ b/tests/recent-pages-menus.dom.test.tsx @@ -0,0 +1,63 @@ +/** @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, RECENT_PAGES_SHOWN } from "@/lib/recent-pages"; +import { recordRecentPage } from "@/lib/recent-pages-record"; + +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-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"], + ]); + }); +}); diff --git a/tests/recent-pages-recorder.dom.test.tsx b/tests/recent-pages-recorder.dom.test.tsx new file mode 100644 index 0000000000..9c145da858 --- /dev/null +++ b/tests/recent-pages-recorder.dom.test.tsx @@ -0,0 +1,107 @@ +/** @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", + ); + }); + + it("does not trust the title on screen when it first loads after the reader has already moved on", async () => { + // 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(); + 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"], + ]); + }); +}); diff --git a/tests/recent-pages.dom.test.ts b/tests/recent-pages.dom.test.ts new file mode 100644 index 0000000000..448e2353fa --- /dev/null +++ b/tests/recent-pages.dom.test.ts @@ -0,0 +1,203 @@ +/** @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, + recentPagesGeneration, + RECENT_PAGES_LIMIT, + RECENT_PAGES_TTL_MS, + recentPageTarget, + recentPageTitle, + announcedRecentPageTitle, +} from "@/lib/recent-pages"; +import { announceRecentPageTitle, recordRecentPage } from "@/lib/recent-pages-record"; + +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", + ]); + }); +}); + +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); + }); +}); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index e1eaa87630..aa67c49afc 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -4927,7 +4927,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");