diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 393a14fca..03457014f 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -714,6 +714,7 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (Option A wave verdict — no code change; #1040 merged as cde6c5c) | canary run 29827012719 (#61, main cde6c5c) vs banked #60 (29800029819) | OPTION A WAVE ADOPTED — FIRST FULLY-GREEN 44-CASE CANARY IN PROGRAM HISTORY (Blocking failures: None). (1) Option A payoff EXCEEDED: citation_failure_rate 0.0227→0; the neuroleptic-side-effect-escalation case flipped from wrong-doc→failed-generation→1-citation-fallback to **strong route, successful gpt-5.6-sol generation, passed in 15.4s with no fallback marker** — the rescued S3 retrieval fixed generation itself, not just the citation count; expected_source_hit 0.6364→0.6591. (2) Golden held exactly as the blast-radius analysis promised: 36/36 PASS, content_recall 1.0, mrr@10 0.8921 BYTE-IDENTICAL to the pre-wave baseline (zero ordering movement — no golden case fires the predicate), irrelevant@10 0.1083→0.0917 (slightly better). (3) Parity payoff PARTIAL: monitoring targeting 1/5→2/5 (olanzapine-lai flipped — previously called a retrieval-depth residual; quetiapine-dose also flipped on the dose side); lithium-range (232ch) + metabolic (73ch, byte-identical answer to #60) did NOT flip despite offline-proven fixes — their live chunk sets evidently contain no admissible schedule sentence even under the widened gate → reclassified as retrieval-depth/live-content residuals joining adhd; below the ≥3/5 target but strictly improved, no regression anywhere. Dose 2/5 vs 2/4: same passing count, applicable set grew (new quality-metformin-renal-dosing miss = eval-set churn, not regression). (4) No-worse EXCEEDED: relevance 0.5333→0.6 (the two-step watch-item slide FULLY REVERSED to the #58 level), targeting_rate 0.6667→0.6957, fail_closed 0.9 held, readability/artifact_leaks 1.0, route ceilings 0, grounded 1.0, unsupported_correct 1.0, numeric 0, p95 22.8s, red_result 3/3. Adoption per the measured-gain rule: primary goal achieved, three case flips, relevance recovered, zero regressions. Residual queue: monitoring retrieval-depth trio (lithium-range/metabolic/adhd), E-3d H2 discards, weekly ANSWER_CASE_LIMIT 8→44 raise now unblocked (gate would be green), comparison-class coverage. Wave spend +~$2-4 → Phase E + Option A total ~$12-20 of ≤$20. | Evidence: run #61 job log read (Threshold Status: None; Answer Metrics; neuroleptic diagnostics row; targeting metric_rates + 6-miss list; golden 36 PASS lines + summary). Revert drill NOT triggered. | | 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (PR: I9 weekly coverage raise) | see PR head | ADDENDUM 5 post-green item I9 (plan-authorized "after reds fixed"): weekly scheduled canary ANSWER_CASE_LIMIT default 8→44 — the Sunday 18:00 UTC cron now guards the FULL answer-quality case set instead of the first 8 (both #57 blocking reds historically lived OUTSIDE the first 8, leaving the weekly gate blind to them). Unblocked by run #61 proving the citation gate green on the full 44. Cost: est +$1-2/week (user-authorized in the plan). Contract test pin updated in lockstep (eval-canary-workflow.test.ts). Dispatch shapes unchanged (input override still wins); operational-risk diff, plain-revert rollback. | check:github-actions PASS; check:ci-scope PASS; check:gate-manifest PASS (20/20); eval-canary-workflow contract 4/4; prettier clean; no provider calls | | 2026-07-21 | claude/database-governance-audit-10b6ed (PR #1051: source-governance audit — safe subset) | cee396730 | Governance-metadata observability + UI display + provenance flow test; no ranking/retrieval/generation surface touched. | IMPLEMENTED + handed off (not a review of prior work). Resolved audit #1 (logger.warn on unrecognized enum values; return value unchanged), #2 (review_due_source added to frontendVisibleWarningCodes → answer-level badge; warning-severity, no refusal impact), #9 (source_metadata retained on safety-finding citations + governance pill in SafetyFindingsListContent), #13 (new tests/provenance-flow.test.ts: DB-normalize→governance→client payload sources+safety citations→render policy). Deferred #4/5/6/8/10 (RAG-protected ranking/selection/LLM-context/cache — need live eval-canary+approval), #11/#5 flag debt (D5/D4), #3 (is_public schema/RLS), #7 (conflict-detection scope), #12 (canary automation). Rebased onto origin/main (was 18 behind; conflict-free — none of the 18 commits touched the 8 files). PR-policy CI green (confirmed no ragRankingPatterns match). | verify:pr-local exit 0 (351 files/3129 tests, production build, client-bundle secret scan, offline RAG fixtures 36/36); typecheck + lint + prettier green. verify:ui NOT run locally: pre-existing globals.css Tailwind/Turbopack dev-compile error (git-clean, unrelated; prod build passed) — CI Production UI job covers it. check:production-readiness deferred (offline env/config validator; PR changes no env/secret/config inputs; secretless worktree). No provider calls. | +| 2026-07-24 | codex/add-back-arrows-to-all-information-pages (PR #1121) | c89f33144 | Run PR sweep: CI fix + threads + drift | Before: PR required green, 1 unresolved stale safety-plan draft thread, branch behind main. After: merged origin/main cleanly; existing AddRow draft-dirty fix verified; thread resolved via GraphQL; reply mutation 403 noted in commit c89f3314. | node scripts/run-vitest.mjs run --reporter=dot tests/patient-safety-plan-privacy.dom.test.tsx PASS (7/7); git diff --check PASS; no Supabase/OpenAI/live eval gates run. | | 2026-07-24 | codex/reindex-agent-enrichment-guard (PR #1143) | f82cf9cfa | Run PR sweep: CI fix + threads + drift | Before: PR required green, 1 unresolved reindex/agent-claim serialization thread, branch behind main. After: remote branch already had atomic RPC fix 2bb0470d plus merge-main d2d57d7c; validated pushed head, resolved thread via GraphQL; reply mutation 403 noted in commit f82cf9cf. | node scripts/run-vitest.mjs run --reporter=dot tests/reindex-enrichment-lease.test.ts tests/private-access-routes.test.ts tests/supabase-schema.test.ts tests/drift-detection.test.ts PASS (218/218); npm run check:migration-role PASS; npm run typecheck PASS; npm run check:production-readiness PASS with non-blocking warnings; no live eval gates run. | ## 2026-07-24 — work search chrome behaviour review 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..9e557ff79 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, @@ -49,6 +50,7 @@ import { fieldControlWithIcon, fieldIcon, floatingControl, + IconButton, InlineNotice, primaryControl, toggleThumbSurface, @@ -259,6 +261,19 @@ export function SettingsDialog({ return () => window.cancelAnimationFrame(focusFrame); }, [emailEntryOpen]); + const backButton = ( + + ); + const closeButton = (
); @@ -428,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. @@ -447,8 +467,26 @@ 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() !== "" || + Object.values(draftDirtyByRow).some(Boolean), + [draftDirtyByRow, entries, planDate, reasons], + ); const planText = useMemo(() => { const lines: string[] = [ @@ -497,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); @@ -521,7 +560,17 @@ export function PatientSafetyPlan() { {/* Tool header */}
-
+
+ { + if (!isDirty) return true; + return window.confirm( + "Leave this safety plan? Your entries are only in this browser tab and will be lost.", + ); + }} + /> @@ -676,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)} /> ))} @@ -727,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 12f2ee969..106a352e4 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: number | null }>({ + frame: null, + timeout: null, + }); const titleId = useId(); const descId = useId(); const sheetId = useId(); @@ -125,6 +132,20 @@ export function Sheet({ onCloseRef.current = onClose; }, [onClose]); + useEffect(() => { + const restoreTimers = restoreTimersRef.current; + return () => { + if (restoreTimers.frame != null) { + window.cancelAnimationFrame(restoreTimers.frame); + restoreTimers.frame = null; + } + if (restoreTimers.timeout != null) { + window.clearTimeout(restoreTimers.timeout); + restoreTimers.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 @@ -211,23 +232,38 @@ 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; - window.requestAnimationFrame(() => { - if (!restoreTarget?.isConnected) return; + if (restoreTimers.frame != null) { + window.cancelAnimationFrame(restoreTimers.frame); + } + 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. + restoreTimers.frame = window.requestAnimationFrame(() => { + restoreTimers.frame = null; + if (typeof document === "undefined" || !restoreTarget?.isConnected) return; restoreTarget.focus({ preventScroll: true }); - window.setTimeout(() => { + restoreTimers.timeout = window.setTimeout(() => { + restoreTimers.timeout = null; if (typeof document === "undefined") return; 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/navigation-back-button.dom.test.tsx b/tests/navigation-back-button.dom.test.tsx new file mode 100644 index 000000000..87a11c0e0 --- /dev/null +++ b/tests/navigation-back-button.dom.test.tsx @@ -0,0 +1,43 @@ +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(); + }); + + 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 bb2bd8d40..12c048408 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 }, @@ -53,4 +64,61 @@ 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("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); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(confirmSpy).not.toHaveBeenCalled(); + expect(router.push).toHaveBeenCalledOnce(); + expect(router.push).toHaveBeenCalledWith("/"); + }); }); 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)); 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(); + }); });