From dc3f305cef4ef314325b2c65933f5d4f0d852ac3 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:27:11 +0800 Subject: [PATCH 01/11] feat: add back navigation to info pages --- src/app/privacy/page.tsx | 22 ++++++----- src/app/reference/colour-coding/page.tsx | 28 ++++++++------ .../clinical-dashboard/settings-dialog.tsx | 35 +++++++++++++----- src/components/navigation-back-button.tsx | 37 +++++++++++++++++++ src/components/patient-safety-plan.tsx | 4 +- 5 files changed, 94 insertions(+), 32 deletions(-) create mode 100644 src/components/navigation-back-button.tsx diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx index 6ee555399..7557b5169 100644 --- a/src/app/privacy/page.tsx +++ b/src/app/privacy/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import type { ReactNode } from "react"; import { ClinicalBadge } from "@/components/clinical-dashboard/clinical-badge"; +import { NavigationBackButton } from "@/components/navigation-back-button"; import { cn, eyebrowText, @@ -75,15 +76,18 @@ export default function PrivacyPage() {
-
-

{privacyCopy.pageEyebrow}

-

- {privacyCopy.pageTitle} -

-

- This is draft product information based on the repository's configured behaviour. It is not legal - advice, a final privacy policy, or an assertion of governance approval. -

+
+ +
+

{privacyCopy.pageEyebrow}

+

+ {privacyCopy.pageTitle} +

+

+ This is draft product information based on the repository's configured behaviour. It is not legal + advice, a final privacy policy, or an assertion of governance approval. +

+
diff --git a/src/app/reference/colour-coding/page.tsx b/src/app/reference/colour-coding/page.tsx index d5e0a946e..d5d06bdf1 100644 --- a/src/app/reference/colour-coding/page.tsx +++ b/src/app/reference/colour-coding/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { ClinicalBadge } from "@/components/clinical-dashboard/clinical-badge"; +import { NavigationBackButton } from "@/components/navigation-back-button"; import { cn, eyebrowText, @@ -41,18 +42,21 @@ export default function ColourCodingReferencePage() { >
-
-

Reference

-

- Colour coding reference -

-

- Badges flag important content so clinical screens are faster to scan. The system uses six tones only — - meaning drives the colour, never the other way round. Danger and warning also carry an icon so they stay - distinguishable without colour. Governance lives in{" "} - docs/clinical-badge-system-guide.md; this page is generated - from src/lib/semantic-flags.ts. -

+
+ +
+

Reference

+

+ Colour coding reference +

+

+ Badges flag important content so clinical screens are faster to scan. The system uses six tones only — + meaning drives the colour, never the other way round. Danger and warning also carry an icon so they stay + distinguishable without colour. Governance lives in{" "} + docs/clinical-badge-system-guide.md; this page is generated + from src/lib/semantic-flags.ts. +

+
diff --git a/src/components/clinical-dashboard/settings-dialog.tsx b/src/components/clinical-dashboard/settings-dialog.tsx index a364be58a..f202727d1 100644 --- a/src/components/clinical-dashboard/settings-dialog.tsx +++ b/src/components/clinical-dashboard/settings-dialog.tsx @@ -2,6 +2,7 @@ import { type FormEvent, type ReactNode, type UIEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { + ArrowLeft, Bell, BookOpen, Check, @@ -259,6 +260,17 @@ export function SettingsDialog({ return () => window.cancelAnimationFrame(focusFrame); }, [emailEntryOpen]); + const backButton = ( + + ); + const closeButton = ( + ); +} diff --git a/src/components/patient-safety-plan.tsx b/src/components/patient-safety-plan.tsx index 8461bc322..cb5863532 100644 --- a/src/components/patient-safety-plan.tsx +++ b/src/components/patient-safety-plan.tsx @@ -23,6 +23,7 @@ import { } from "lucide-react"; import { useCallback, useMemo, useRef, useState } from "react"; +import { NavigationBackButton } from "@/components/navigation-back-button"; import { cn, clinicalDivider, @@ -521,7 +522,8 @@ export function PatientSafetyPlan() { {/* Tool header */}
-
+
+ From b64d30919e559ab85c79d80d8439b14c258455b8 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:17:54 +0800 Subject: [PATCH 02/11] fix: use IconButton for back nav tap target Replace legacy sm:h-11/sm:w-11 classes with IconButton size-tap, and mock next/navigation in privacy/safety-plan tests that render without App Router. Co-authored-by: Cursor --- src/components/navigation-back-button.tsx | 19 +++++++------------ .../patient-safety-plan-privacy.dom.test.tsx | 11 +++++++++++ tests/privacy-ui.test.ts | 9 ++++++++- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/components/navigation-back-button.tsx b/src/components/navigation-back-button.tsx index 76a928213..4569150e0 100644 --- a/src/components/navigation-back-button.tsx +++ b/src/components/navigation-back-button.tsx @@ -3,7 +3,7 @@ import { ArrowLeft } from "lucide-react"; import { useRouter } from "next/navigation"; -import { cn, floatingControl } from "@/components/ui-primitives"; +import { cn, floatingControl, IconButton } from "@/components/ui-primitives"; type NavigationBackButtonProps = { label?: string; @@ -15,8 +15,9 @@ export function NavigationBackButton({ label = "Go back", fallbackHref = "/", cl const router = useRouter(); return ( - + className={cn(floatingControl, "rounded-full text-[color:var(--text-muted)]", className)} + iconClassName="h-5 w-5" + /> ); } diff --git a/tests/patient-safety-plan-privacy.dom.test.tsx b/tests/patient-safety-plan-privacy.dom.test.tsx index bb2bd8d40..02bcaaad1 100644 --- a/tests/patient-safety-plan-privacy.dom.test.tsx +++ b/tests/patient-safety-plan-privacy.dom.test.tsx @@ -3,11 +3,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PatientSafetyPlan } from "@/components/patient-safety-plan"; +const router = vi.hoisted(() => ({ + back: vi.fn(), + push: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => router, +})); + describe("PatientSafetyPlan privacy contract", () => { const writeText = vi.fn(async () => undefined); beforeEach(() => { vi.clearAllMocks(); + router.back.mockReset(); + router.push.mockReset(); Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText }, diff --git a/tests/privacy-ui.test.ts b/tests/privacy-ui.test.ts index 44efb1fcb..13c1343d1 100644 --- a/tests/privacy-ui.test.ts +++ b/tests/privacy-ui.test.ts @@ -1,10 +1,17 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import PrivacyPage from "@/app/privacy/page"; import { PrivacyInputNotice } from "@/components/privacy-input-notice"; +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + back: vi.fn(), + push: vi.fn(), + }), +})); + describe("privacy UI", () => { it("renders a persistent, keyboard-reachable privacy warning and product link", () => { const markup = renderToStaticMarkup(createElement(PrivacyInputNotice)); From 3621a70b576fd0adf1a206a503ca109f9d6d7379 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 04:45:01 +0000 Subject: [PATCH 03/11] fix(nav): use explicit in-app back targets and 44px settings back Always push fallbackHref instead of history.back() so external referrers cannot eject users from Clinical KB. Settings back uses IconButton/size-tap. Addresses Codex review on PR #1121. Co-authored-by: BigSimmo --- .../clinical-dashboard/settings-dialog.tsx | 17 ++++++---- src/components/navigation-back-button.tsx | 9 ++--- tests/navigation-back-button.dom.test.tsx | 33 +++++++++++++++++++ 3 files changed, 48 insertions(+), 11 deletions(-) create mode 100644 tests/navigation-back-button.dom.test.tsx diff --git a/src/components/clinical-dashboard/settings-dialog.tsx b/src/components/clinical-dashboard/settings-dialog.tsx index f202727d1..9e557ff79 100644 --- a/src/components/clinical-dashboard/settings-dialog.tsx +++ b/src/components/clinical-dashboard/settings-dialog.tsx @@ -50,6 +50,7 @@ import { fieldControlWithIcon, fieldIcon, floatingControl, + IconButton, InlineNotice, primaryControl, toggleThumbSurface, @@ -261,14 +262,16 @@ export function SettingsDialog({ }, [emailEntryOpen]); const backButton = ( - + className={cn( + floatingControl, + "rounded-full border border-[color:var(--border)] bg-[color:var(--surface)]/70 text-[color:var(--text-muted)] shadow-[var(--shadow-inset)] hover:bg-[color:var(--surface)] hover:text-[color:var(--text-heading)] lg:border-transparent lg:bg-transparent lg:shadow-none", + )} + iconClassName="size-icon-lg" + /> ); const closeButton = ( diff --git a/src/components/navigation-back-button.tsx b/src/components/navigation-back-button.tsx index 4569150e0..a383211ff 100644 --- a/src/components/navigation-back-button.tsx +++ b/src/components/navigation-back-button.tsx @@ -11,6 +11,11 @@ type NavigationBackButtonProps = { className?: string; }; +/** + * Deterministic in-app back control. Always navigates to `fallbackHref` rather + * than `history.back()`, so deep links / external referrers cannot eject the + * user out of Clinical KB (same contract as form detail pages). + */ export function NavigationBackButton({ label = "Go back", fallbackHref = "/", className }: NavigationBackButtonProps) { const router = useRouter(); @@ -19,10 +24,6 @@ export function NavigationBackButton({ label = "Go back", fallbackHref = "/", cl label={label} icon={ArrowLeft} onClick={() => { - if (typeof window !== "undefined" && window.history.length > 1) { - router.back(); - return; - } router.push(fallbackHref); }} className={cn(floatingControl, "rounded-full text-[color:var(--text-muted)]", className)} diff --git a/tests/navigation-back-button.dom.test.tsx b/tests/navigation-back-button.dom.test.tsx new file mode 100644 index 000000000..76a4d55f4 --- /dev/null +++ b/tests/navigation-back-button.dom.test.tsx @@ -0,0 +1,33 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { NavigationBackButton } from "@/components/navigation-back-button"; + +const router = vi.hoisted(() => ({ + back: vi.fn(), + push: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => router, +})); + +beforeEach(() => { + router.back.mockReset(); + router.push.mockReset(); +}); + +describe("NavigationBackButton", () => { + it("uses the explicit in-app fallback even when browser history has prior entries", () => { + window.history.pushState({}, "", "/unrelated-route"); + window.history.pushState({}, "", "/privacy"); + expect(window.history.length).toBeGreaterThan(1); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(router.push).toHaveBeenCalledOnce(); + expect(router.push).toHaveBeenCalledWith("/"); + expect(router.back).not.toHaveBeenCalled(); + }); +}); From 91eb3ad360e3d981f705998d7ae4363ae2f96bfb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 05:04:04 +0000 Subject: [PATCH 04/11] fix(safety-plan): confirm before leaving a dirty plan Guard the header back control when entries, reasons, or a plan date have been edited so in-progress browser-only content is not discarded without confirmation. --- src/components/navigation-back-button.tsx | 13 +++++++- src/components/patient-safety-plan.tsx | 18 ++++++++++- tests/navigation-back-button.dom.test.tsx | 10 ++++++ .../patient-safety-plan-privacy.dom.test.tsx | 32 +++++++++++++++++++ 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/components/navigation-back-button.tsx b/src/components/navigation-back-button.tsx index a383211ff..251bdd67c 100644 --- a/src/components/navigation-back-button.tsx +++ b/src/components/navigation-back-button.tsx @@ -9,6 +9,11 @@ type NavigationBackButtonProps = { label?: string; fallbackHref?: string; className?: string; + /** + * Optional gate before navigation. Return `false` to cancel (for example a + * dirty-form confirmation). When omitted, navigation always proceeds. + */ + onBeforeNavigate?: () => boolean; }; /** @@ -16,7 +21,12 @@ type NavigationBackButtonProps = { * than `history.back()`, so deep links / external referrers cannot eject the * user out of Clinical KB (same contract as form detail pages). */ -export function NavigationBackButton({ label = "Go back", fallbackHref = "/", className }: NavigationBackButtonProps) { +export function NavigationBackButton({ + label = "Go back", + fallbackHref = "/", + className, + onBeforeNavigate, +}: NavigationBackButtonProps) { const router = useRouter(); return ( @@ -24,6 +34,7 @@ export function NavigationBackButton({ label = "Go back", fallbackHref = "/", cl label={label} icon={ArrowLeft} onClick={() => { + if (onBeforeNavigate && !onBeforeNavigate()) return; router.push(fallbackHref); }} className={cn(floatingControl, "rounded-full text-[color:var(--text-muted)]", className)} diff --git a/src/components/patient-safety-plan.tsx b/src/components/patient-safety-plan.tsx index cb5863532..838e38002 100644 --- a/src/components/patient-safety-plan.tsx +++ b/src/components/patient-safety-plan.tsx @@ -450,6 +450,13 @@ export function PatientSafetyPlan() { const filledSteps = useMemo(() => STEPS.filter((step) => entries[step.key].length > 0).length, [entries]); const ready = filledSteps === STEPS.length; + // Working plan content is browser-tab only and never persisted. Treat any + // entered step/reason/date as dirty so the header back control cannot discard + // an in-progress plan without an explicit confirmation. + const isDirty = useMemo( + () => Object.values(entries).some((rows) => rows.length > 0) || reasons.length > 0 || planDate.trim() !== "", + [entries, planDate, reasons], + ); const planText = useMemo(() => { const lines: string[] = [ @@ -523,7 +530,16 @@ export function PatientSafetyPlan() {
- + { + if (!isDirty) return true; + return window.confirm( + "Leave this safety plan? Your entries are only in this browser tab and will be lost.", + ); + }} + /> diff --git a/tests/navigation-back-button.dom.test.tsx b/tests/navigation-back-button.dom.test.tsx index 76a4d55f4..87a11c0e0 100644 --- a/tests/navigation-back-button.dom.test.tsx +++ b/tests/navigation-back-button.dom.test.tsx @@ -30,4 +30,14 @@ describe("NavigationBackButton", () => { expect(router.push).toHaveBeenCalledWith("/"); expect(router.back).not.toHaveBeenCalled(); }); + + it("cancels navigation when onBeforeNavigate returns false", () => { + const onBeforeNavigate = vi.fn(() => false); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(onBeforeNavigate).toHaveBeenCalledOnce(); + expect(router.push).not.toHaveBeenCalled(); + }); }); diff --git a/tests/patient-safety-plan-privacy.dom.test.tsx b/tests/patient-safety-plan-privacy.dom.test.tsx index 02bcaaad1..360307bfe 100644 --- a/tests/patient-safety-plan-privacy.dom.test.tsx +++ b/tests/patient-safety-plan-privacy.dom.test.tsx @@ -64,4 +64,36 @@ describe("PatientSafetyPlan privacy contract", () => { expect(printSpy).toHaveBeenCalledTimes(1); }); + + it("confirms before leaving a dirty safety plan via the header back control", () => { + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false); + + render(); + fireEvent.change(screen.getByLabelText("e.g. Not sleeping for a couple of nights"), { + target: { value: "Not sleeping" }, + }); + fireEvent.click(screen.getAllByRole("button", { name: "Add" })[0]); + fireEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(confirmSpy).toHaveBeenCalledOnce(); + expect(confirmSpy).toHaveBeenCalledWith(expect.stringMatching(/Leave this safety plan\?.*will be lost/i)); + expect(router.push).not.toHaveBeenCalled(); + + confirmSpy.mockReturnValue(true); + fireEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(router.push).toHaveBeenCalledOnce(); + expect(router.push).toHaveBeenCalledWith("/"); + }); + + it("navigates back without confirmation when the safety plan is empty", () => { + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(confirmSpy).not.toHaveBeenCalled(); + expect(router.push).toHaveBeenCalledOnce(); + expect(router.push).toHaveBeenCalledWith("/"); + }); }); From 259ea27bd706d5e6a320c42c605b6375cc7a8e55 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 05:36:27 +0000 Subject: [PATCH 05/11] fix(ui): stop sheet focus-restore timers from crashing CI Cancel pending focus-restore rAF/timeouts on Sheet unmount and guard document access so Vitest coverage workers cannot fail the suite with an unhandled ReferenceError after document-viewer shell tests open a sheet. --- src/components/ui/sheet.tsx | 48 ++++++++++++++++++++---- tests/document-viewer-shell.dom.test.tsx | 6 +++ tests/sheet.dom.test.tsx | 17 +++++++++ 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index f0b9eba2b..8e5c752b9 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -117,6 +117,13 @@ export function Sheet({ // Otherwise a press that begins on the panel and ends on the backdrop would // synthesize a click on the common ancestor and accidentally close the sheet. const backdropPointerDownRef = useRef(false); + // Pending focus-restore timers from the previous close. Cleared on the next + // open and on unmount so a torn-down jsdom environment cannot throw from a + // stale 50ms retry under Vitest coverage workers. + const restoreTimersRef = useRef<{ frame: number | null; timeout: ReturnType | null }>({ + frame: null, + timeout: null, + }); const titleId = useId(); const descId = useId(); const sheetId = useId(); @@ -125,6 +132,19 @@ export function Sheet({ onCloseRef.current = onClose; }, [onClose]); + useEffect(() => { + return () => { + if (restoreTimersRef.current.frame != null) { + window.cancelAnimationFrame(restoreTimersRef.current.frame); + restoreTimersRef.current.frame = null; + } + if (restoreTimersRef.current.timeout != null) { + window.clearTimeout(restoreTimersRef.current.timeout); + restoreTimersRef.current.timeout = null; + } + }; + }, []); + // Swipe-to-dismiss for the mobile bottom sheet: dragging the grip down past a // threshold closes the sheet; a shorter drag snaps back. Grip-initiated only, // so it never competes with scrolling the sheet body. Keyboard/backdrop/close @@ -216,17 +236,31 @@ export function Sheet({ window.removeEventListener("keydown", onKeyDown); popSheet(sheetId); const restoreTarget = explicitReturnElement ?? previousActiveElement; - window.requestAnimationFrame(() => { - if (!restoreTarget?.isConnected) return; + if (restoreTimersRef.current.frame != null) { + window.cancelAnimationFrame(restoreTimersRef.current.frame); + } + if (restoreTimersRef.current.timeout != null) { + window.clearTimeout(restoreTimersRef.current.timeout); + } + // Focus restore is best-effort. Under Vitest coverage workers the jsdom + // `document` can be torn down before this rAF/setTimeout pair fires; bare + // `document` access then becomes an unhandled ReferenceError that fails + // the whole suite even when every test assertion passed. + restoreTimersRef.current.frame = window.requestAnimationFrame(() => { + restoreTimersRef.current.frame = null; + if (typeof document === "undefined" || !restoreTarget?.isConnected) return; restoreTarget.focus({ preventScroll: true }); - window.setTimeout(() => { + restoreTimersRef.current.timeout = window.setTimeout(() => { + restoreTimersRef.current.timeout = null; if ( - restoreTarget.isConnected && - document.activeElement !== restoreTarget && - document.activeElement === document.body + typeof document === "undefined" || + !restoreTarget.isConnected || + document.activeElement === restoreTarget || + document.activeElement !== document.body ) { - restoreTarget.focus({ preventScroll: true }); + return; } + restoreTarget.focus({ preventScroll: true }); }, 50); }); }; diff --git a/tests/document-viewer-shell.dom.test.tsx b/tests/document-viewer-shell.dom.test.tsx index c4751bc88..ce7d151a9 100644 --- a/tests/document-viewer-shell.dom.test.tsx +++ b/tests/document-viewer-shell.dom.test.tsx @@ -143,6 +143,12 @@ describe("DocumentViewer — shell states", () => { fireEvent.click(actionsButtons[0]); expect(await screen.findByText("clozapine-titration.pdf")).toBeVisible(); + // Close the sheet before teardown so focus-restore timers settle while jsdom + // is still alive (avoids an unhandled post-test `document` ReferenceError + // under the coverage worker pool). + fireEvent.click(screen.getByRole("button", { name: "Close document actions" })); + expect(screen.queryByText("clozapine-titration.pdf")).toBeNull(); + // A supplied payload must resolve to the ready shell — neither failure shell. expect(screen.queryByText("Source unavailable")).toBeNull(); expect(screen.queryByText("Sign in required")).toBeNull(); diff --git a/tests/sheet.dom.test.tsx b/tests/sheet.dom.test.tsx index d38125411..d0cbe6ed9 100644 --- a/tests/sheet.dom.test.tsx +++ b/tests/sheet.dom.test.tsx @@ -98,4 +98,21 @@ describe("Sheet stacked-overlay coordination", () => { ); expect(document.body.style.overflow).toBe(""); }); + + it("cancels focus-restore timers on unmount so coverage teardown cannot throw", async () => { + vi.useFakeTimers(); + const onClose = vi.fn(); + const { unmount } = render( + +

Solo body

+
, + ); + + // Unmount while open: the open-effect cleanup schedules rAF + 50ms retry, + // then the unmount cleanup must cancel both before they can touch `document`. + unmount(); + await vi.runAllTimersAsync(); + expect(vi.getTimerCount()).toBe(0); + vi.useRealTimers(); + }); }); From 004e191ee4265433973346d6b610536f0553d4d2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 05:50:52 +0000 Subject: [PATCH 06/11] fix: guard safety-plan draft navigation Co-authored-by: BigSimmo --- src/components/patient-safety-plan.tsx | 42 +++++++++++++++++-- src/components/ui/sheet.tsx | 32 +++++++------- .../patient-safety-plan-privacy.dom.test.tsx | 25 +++++++++++ 3 files changed, 80 insertions(+), 19 deletions(-) diff --git a/src/components/patient-safety-plan.tsx b/src/components/patient-safety-plan.tsx index 838e38002..2670f8646 100644 --- a/src/components/patient-safety-plan.tsx +++ b/src/components/patient-safety-plan.tsx @@ -211,14 +211,20 @@ function AddRow({ primaryPlaceholder, secondaryPlaceholder, onAdd, + onDraftDirtyChange, }: { kind: StepKind; primaryPlaceholder: string; secondaryPlaceholder?: string; onAdd: (primary: string, secondary?: string) => void; + onDraftDirtyChange?: (dirty: boolean) => void; }) { const [primary, setPrimary] = useState(""); const [secondary, setSecondary] = useState(""); + const draftIsDirty = useCallback( + (nextPrimary: string, nextSecondary: string) => nextPrimary.trim() !== "" || nextSecondary.trim() !== "", + [], + ); const submit = () => { const trimmed = primary.trim(); @@ -226,13 +232,18 @@ function AddRow({ onAdd(trimmed, secondary.trim() || undefined); setPrimary(""); setSecondary(""); + onDraftDirtyChange?.(false); }; return (
setPrimary(event.target.value)} + onChange={(event) => { + const nextPrimary = event.target.value; + setPrimary(nextPrimary); + onDraftDirtyChange?.(draftIsDirty(nextPrimary, secondary)); + }} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); @@ -246,7 +257,11 @@ function AddRow({ {kind === "contact" ? ( setSecondary(event.target.value)} + onChange={(event) => { + const nextSecondary = event.target.value; + setSecondary(nextSecondary); + onDraftDirtyChange?.(draftIsDirty(primary, nextSecondary)); + }} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); @@ -310,11 +325,13 @@ function StepBuilderCard({ entries, onAdd, onRemove, + onDraftDirtyChange, }: { def: StepDef; entries: Entry[]; onAdd: (primary: string, secondary?: string) => void; onRemove: (id: string) => void; + onDraftDirtyChange: (dirty: boolean) => void; }) { const Icon = def.icon; const filled = entries.length > 0; @@ -367,6 +384,7 @@ function StepBuilderCard({ primaryPlaceholder={def.primaryPlaceholder} secondaryPlaceholder={def.secondaryPlaceholder} onAdd={onAdd} + onDraftDirtyChange={onDraftDirtyChange} />
); @@ -429,6 +447,7 @@ export function PatientSafetyPlan() { const [mobileTab, setMobileTab] = useState<"build" | "preview">("build"); const [copied, setCopied] = useState(false); const [finalised, setFinalised] = useState(false); + const [draftDirtyByRow, setDraftDirtyByRow] = useState>({}); // Per-instance id counter — avoids a module-level mutable that would persist // across remounts; ids only need to be unique within this mounted plan. @@ -448,14 +467,25 @@ export function PatientSafetyPlan() { setFinalised(false); }, []); + const setDraftDirty = useCallback((key: string, dirty: boolean) => { + setDraftDirtyByRow((prev) => { + if (prev[key] === dirty) return prev; + return { ...prev, [key]: dirty }; + }); + }, []); + const filledSteps = useMemo(() => STEPS.filter((step) => entries[step.key].length > 0).length, [entries]); const ready = filledSteps === STEPS.length; // Working plan content is browser-tab only and never persisted. Treat any // entered step/reason/date as dirty so the header back control cannot discard // an in-progress plan without an explicit confirmation. const isDirty = useMemo( - () => Object.values(entries).some((rows) => rows.length > 0) || reasons.length > 0 || planDate.trim() !== "", - [entries, planDate, reasons], + () => + Object.values(entries).some((rows) => rows.length > 0) || + reasons.length > 0 || + planDate.trim() !== "" || + Object.values(draftDirtyByRow).some(Boolean), + [draftDirtyByRow, entries, planDate, reasons], ); const planText = useMemo(() => { @@ -505,6 +535,7 @@ export function PatientSafetyPlan() { if (hasContent && !window.confirm("Replace the current plan with the example content?")) return; setEntries(SEED); setReasons(SEED_REASONS); + setDraftDirtyByRow({}); // Clear the plan date so example content cannot look like a current handover. setPlanDate(""); setFinalised(false); @@ -694,6 +725,7 @@ export function PatientSafetyPlan() { entries={entries[def.key]} onAdd={(primary, secondary) => addEntry(def.key, primary, secondary)} onRemove={(id) => removeEntry(def.key, id)} + onDraftDirtyChange={(dirty) => setDraftDirty(def.key, dirty)} /> ))} @@ -745,8 +777,10 @@ export function PatientSafetyPlan() { setDraftDirty("reason", dirty)} onAdd={(primary) => { setReasons((prev) => [...prev, { id: uid("reason"), primary }]); + setDraftDirty("reason", false); setFinalised(false); }} /> diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index 8e5c752b9..78b0f484c 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -120,7 +120,7 @@ export function Sheet({ // Pending focus-restore timers from the previous close. Cleared on the next // open and on unmount so a torn-down jsdom environment cannot throw from a // stale 50ms retry under Vitest coverage workers. - const restoreTimersRef = useRef<{ frame: number | null; timeout: ReturnType | null }>({ + const restoreTimersRef = useRef<{ frame: number | null; timeout: number | null }>({ frame: null, timeout: null, }); @@ -133,14 +133,15 @@ export function Sheet({ }, [onClose]); useEffect(() => { + const restoreTimers = restoreTimersRef.current; return () => { - if (restoreTimersRef.current.frame != null) { - window.cancelAnimationFrame(restoreTimersRef.current.frame); - restoreTimersRef.current.frame = null; + if (restoreTimers.frame != null) { + window.cancelAnimationFrame(restoreTimers.frame); + restoreTimers.frame = null; } - if (restoreTimersRef.current.timeout != null) { - window.clearTimeout(restoreTimersRef.current.timeout); - restoreTimersRef.current.timeout = null; + if (restoreTimers.timeout != null) { + window.clearTimeout(restoreTimers.timeout); + restoreTimers.timeout = null; } }; }, []); @@ -231,27 +232,28 @@ export function Sheet({ } window.addEventListener("keydown", onKeyDown); + const restoreTimers = restoreTimersRef.current; return () => { window.cancelAnimationFrame(focusFrame); window.removeEventListener("keydown", onKeyDown); popSheet(sheetId); const restoreTarget = explicitReturnElement ?? previousActiveElement; - if (restoreTimersRef.current.frame != null) { - window.cancelAnimationFrame(restoreTimersRef.current.frame); + if (restoreTimers.frame != null) { + window.cancelAnimationFrame(restoreTimers.frame); } - if (restoreTimersRef.current.timeout != null) { - window.clearTimeout(restoreTimersRef.current.timeout); + if (restoreTimers.timeout != null) { + window.clearTimeout(restoreTimers.timeout); } // Focus restore is best-effort. Under Vitest coverage workers the jsdom // `document` can be torn down before this rAF/setTimeout pair fires; bare // `document` access then becomes an unhandled ReferenceError that fails // the whole suite even when every test assertion passed. - restoreTimersRef.current.frame = window.requestAnimationFrame(() => { - restoreTimersRef.current.frame = null; + restoreTimers.frame = window.requestAnimationFrame(() => { + restoreTimers.frame = null; if (typeof document === "undefined" || !restoreTarget?.isConnected) return; restoreTarget.focus({ preventScroll: true }); - restoreTimersRef.current.timeout = window.setTimeout(() => { - restoreTimersRef.current.timeout = null; + restoreTimers.timeout = window.setTimeout(() => { + restoreTimers.timeout = null; if ( typeof document === "undefined" || !restoreTarget.isConnected || diff --git a/tests/patient-safety-plan-privacy.dom.test.tsx b/tests/patient-safety-plan-privacy.dom.test.tsx index 360307bfe..12c048408 100644 --- a/tests/patient-safety-plan-privacy.dom.test.tsx +++ b/tests/patient-safety-plan-privacy.dom.test.tsx @@ -86,6 +86,31 @@ describe("PatientSafetyPlan privacy contract", () => { expect(router.push).toHaveBeenCalledWith("/"); }); + it("confirms before leaving unadded safety-plan step text", () => { + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false); + + render(); + fireEvent.change(screen.getByLabelText("e.g. Not sleeping for a couple of nights"), { + target: { value: "Not sleeping" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(confirmSpy).toHaveBeenCalledOnce(); + expect(router.push).not.toHaveBeenCalled(); + }); + + it("confirms before leaving unadded contact detail text", () => { + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false); + + render(); + fireEvent.change(screen.getByLabelText("Name & relationship"), { target: { value: "Priya" } }); + fireEvent.change(screen.getByLabelText("Phone or how to reach them"), { target: { value: "0400 000 000" } }); + fireEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(confirmSpy).toHaveBeenCalledOnce(); + expect(router.push).not.toHaveBeenCalled(); + }); + it("navigates back without confirmation when the safety plan is empty", () => { const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); From 5884c86a605479e9f8dc2c0a5e16df6b67dbbedd Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:33:52 +0800 Subject: [PATCH 07/11] fix: preserve dirty navigation guards on teardown --- src/components/patient-safety-plan.tsx | 1 - src/components/ui/sheet.tsx | 4 ++++ tests/patient-safety-plan-privacy.dom.test.tsx | 15 +++++++++++++++ tests/sheet.dom.test.tsx | 7 +++++-- 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/components/patient-safety-plan.tsx b/src/components/patient-safety-plan.tsx index 2670f8646..4680c10b8 100644 --- a/src/components/patient-safety-plan.tsx +++ b/src/components/patient-safety-plan.tsx @@ -535,7 +535,6 @@ export function PatientSafetyPlan() { if (hasContent && !window.confirm("Replace the current plan with the example content?")) return; setEntries(SEED); setReasons(SEED_REASONS); - setDraftDirtyByRow({}); // Clear the plan date so example content cannot look like a current handover. setPlanDate(""); setFinalised(false); diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index 106a352e4..608ca03a0 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -124,6 +124,7 @@ export function Sheet({ frame: null, timeout: null, }); + const unmountingRef = useRef(false); const titleId = useId(); const descId = useId(); const sheetId = useId(); @@ -133,8 +134,10 @@ export function Sheet({ }, [onClose]); useEffect(() => { + unmountingRef.current = false; const restoreTimers = restoreTimersRef.current; return () => { + unmountingRef.current = true; if (restoreTimers.frame != null) { window.cancelAnimationFrame(restoreTimers.frame); restoreTimers.frame = null; @@ -244,6 +247,7 @@ export function Sheet({ if (restoreTimers.timeout != null) { window.clearTimeout(restoreTimers.timeout); } + if (unmountingRef.current) return; // Focus restore is best-effort. Under Vitest coverage workers the jsdom // `document` can be torn down before this rAF/setTimeout pair fires; bare // `document` access then becomes an unhandled ReferenceError that fails diff --git a/tests/patient-safety-plan-privacy.dom.test.tsx b/tests/patient-safety-plan-privacy.dom.test.tsx index 12c048408..fef72476a 100644 --- a/tests/patient-safety-plan-privacy.dom.test.tsx +++ b/tests/patient-safety-plan-privacy.dom.test.tsx @@ -111,6 +111,21 @@ describe("PatientSafetyPlan privacy contract", () => { expect(router.push).not.toHaveBeenCalled(); }); + it("keeps unadded draft text dirty after loading and clearing the example", () => { + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false); + + render(); + fireEvent.change(screen.getByLabelText("e.g. Not sleeping for a couple of nights"), { + target: { value: "Not sleeping" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Load example" })); + fireEvent.click(screen.getByRole("button", { name: "Clear all" })); + fireEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(confirmSpy).toHaveBeenCalledOnce(); + expect(router.push).not.toHaveBeenCalled(); + }); + it("navigates back without confirmation when the safety plan is empty", () => { const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); diff --git a/tests/sheet.dom.test.tsx b/tests/sheet.dom.test.tsx index d0cbe6ed9..e2aef7d25 100644 --- a/tests/sheet.dom.test.tsx +++ b/tests/sheet.dom.test.tsx @@ -107,10 +107,13 @@ describe("Sheet stacked-overlay coordination", () => {

Solo body

, ); + await vi.runAllTimersAsync(); + const restoreFrameSpy = vi.spyOn(window, "requestAnimationFrame"); - // Unmount while open: the open-effect cleanup schedules rAF + 50ms retry, - // then the unmount cleanup must cancel both before they can touch `document`. + // Unmount while open: no restore callback should be scheduled after the + // mount cleanup has started tearing down the component. unmount(); + expect(restoreFrameSpy).not.toHaveBeenCalled(); await vi.runAllTimersAsync(); expect(vi.getTimerCount()).toBe(0); vi.useRealTimers(); From b2a10590becf15c1726f0f6eaa8f4f11d9dbfc58 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:53:39 +0800 Subject: [PATCH 08/11] fix: return tool references to their mode home --- src/app/reference/colour-coding/page.tsx | 3 ++- src/components/patient-safety-plan.tsx | 3 ++- tests/navigation-back-button.dom.test.tsx | 10 ++++++++++ tests/patient-safety-plan-privacy.dom.test.tsx | 5 +++-- 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/app/reference/colour-coding/page.tsx b/src/app/reference/colour-coding/page.tsx index d5d06bdf1..8a8df31ca 100644 --- a/src/app/reference/colour-coding/page.tsx +++ b/src/app/reference/colour-coding/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { ClinicalBadge } from "@/components/clinical-dashboard/clinical-badge"; import { NavigationBackButton } from "@/components/navigation-back-button"; +import { appModeHomeHref } from "@/lib/app-modes"; import { cn, eyebrowText, @@ -43,7 +44,7 @@ export default function ColourCodingReferencePage() {
- +

Reference

diff --git a/src/components/patient-safety-plan.tsx b/src/components/patient-safety-plan.tsx index 4680c10b8..9d73f7c59 100644 --- a/src/components/patient-safety-plan.tsx +++ b/src/components/patient-safety-plan.tsx @@ -24,6 +24,7 @@ import { import { useCallback, useMemo, useRef, useState } from "react"; import { NavigationBackButton } from "@/components/navigation-back-button"; +import { appModeHomeHref } from "@/lib/app-modes"; import { cn, clinicalDivider, @@ -562,7 +563,7 @@ export function PatientSafetyPlan() {
{ if (!isDirty) return true; return window.confirm( diff --git a/tests/navigation-back-button.dom.test.tsx b/tests/navigation-back-button.dom.test.tsx index 87a11c0e0..2cd8995fe 100644 --- a/tests/navigation-back-button.dom.test.tsx +++ b/tests/navigation-back-button.dom.test.tsx @@ -2,6 +2,8 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { NavigationBackButton } from "@/components/navigation-back-button"; +import ColourCodingReferencePage from "@/app/reference/colour-coding/page"; +import { appModeHomeHref } from "@/lib/app-modes"; const router = vi.hoisted(() => ({ back: vi.fn(), @@ -40,4 +42,12 @@ describe("NavigationBackButton", () => { expect(onBeforeNavigate).toHaveBeenCalledOnce(); expect(router.push).not.toHaveBeenCalled(); }); + + it("returns the colour-coding reference to the canonical Tools home", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(router.push).toHaveBeenCalledOnce(); + expect(router.push).toHaveBeenCalledWith(appModeHomeHref("tools")); + }); }); diff --git a/tests/patient-safety-plan-privacy.dom.test.tsx b/tests/patient-safety-plan-privacy.dom.test.tsx index fef72476a..be021e784 100644 --- a/tests/patient-safety-plan-privacy.dom.test.tsx +++ b/tests/patient-safety-plan-privacy.dom.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PatientSafetyPlan } from "@/components/patient-safety-plan"; +import { appModeHomeHref } from "@/lib/app-modes"; const router = vi.hoisted(() => ({ back: vi.fn(), @@ -83,7 +84,7 @@ describe("PatientSafetyPlan privacy contract", () => { fireEvent.click(screen.getByRole("button", { name: "Go back" })); expect(router.push).toHaveBeenCalledOnce(); - expect(router.push).toHaveBeenCalledWith("/"); + expect(router.push).toHaveBeenCalledWith(appModeHomeHref("tools")); }); it("confirms before leaving unadded safety-plan step text", () => { @@ -134,6 +135,6 @@ describe("PatientSafetyPlan privacy contract", () => { expect(confirmSpy).not.toHaveBeenCalled(); expect(router.push).toHaveBeenCalledOnce(); - expect(router.push).toHaveBeenCalledWith("/"); + expect(router.push).toHaveBeenCalledWith(appModeHomeHref("tools")); }); }); From a3f057abe2cec998fb0f50d8acc916b9c1819bc5 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:03:42 +0800 Subject: [PATCH 09/11] fix: preserve privacy source navigation --- src/app/privacy/page.tsx | 7 ++++-- .../DocumentManagerPanel.tsx | 2 +- .../master-search-header.tsx | 1 + src/components/privacy-input-notice.tsx | 17 +++++++++++-- src/components/privacy-page-back-button.tsx | 14 +++++++++++ tests/navigation-back-button.dom.test.tsx | 24 +++++++++++++++++++ tests/privacy-ui.test.ts | 2 ++ 7 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 src/components/privacy-page-back-button.tsx diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx index 7557b5169..505c1a9c3 100644 --- a/src/app/privacy/page.tsx +++ b/src/app/privacy/page.tsx @@ -1,8 +1,9 @@ import type { Metadata } from "next"; -import type { ReactNode } from "react"; +import { Suspense, type ReactNode } from "react"; import { ClinicalBadge } from "@/components/clinical-dashboard/clinical-badge"; import { NavigationBackButton } from "@/components/navigation-back-button"; +import { PrivacyPageBackButton } from "@/components/privacy-page-back-button"; import { cn, eyebrowText, @@ -77,7 +78,9 @@ export default function PrivacyPage() {
- + }> + +

{privacyCopy.pageEyebrow}

diff --git a/src/components/clinical-dashboard/DocumentManagerPanel.tsx b/src/components/clinical-dashboard/DocumentManagerPanel.tsx index 0f2d28401..1af39b2b8 100644 --- a/src/components/clinical-dashboard/DocumentManagerPanel.tsx +++ b/src/components/clinical-dashboard/DocumentManagerPanel.tsx @@ -408,7 +408,7 @@ export function UploadPanel({ return (
- +