Shortcuts
@@ -615,6 +620,8 @@ export function TwoPaneSideMenu({
) : null}
+
+
>
);
@@ -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 (
+ -
+
+
+ {page.title}
+ {sectionLabel !== page.title ? (
+
+ ,
+ {sectionLabel}
+
+ ) : null}
+
+
+ );
+ })}
+
+
+ );
+}
+
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 (
+ }
+ area={page.mode === "work" ? page.section : undefined}
+ tone={page.mode === "work" ? undefined : "neutral"}
+ href={page.path}
+ onNavigate={() => close(true)}
+ sub={sectionLabel !== page.title ? sectionLabel : undefined}
+ weight="semibold"
+ >
+ {page.title}
+
+ );
+ })}
+
+
+ ) : 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");