From 39c879ca6079652c6353ab8b6fe0fb9e5950a1db Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 17:33:47 +0000 Subject: [PATCH 1/3] fix(shell): side menu Reminders asks before leaving an unsaved page The work side menu and the two-pane strip went to My Day with router.push, which skipped the shell leave guard the safety plan registers. They now call confirmShellNavigation() first. The side counts loader lives in one leaf module instead of two copies. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KHkbt7EPd1iS6ryTjtnzq2 --- .../clinical-dashboard/ClinicalSidebar.tsx | 14 +------- .../work-frame/lazy-work-side-counts.tsx | 15 ++++++++ .../work-frame/lazy-work-side-nav.tsx | 12 ++----- .../work-frame/two-pane-side-strip.tsx | 7 +++- src/components/work-frame/work-side-menu.tsx | 6 +++- tests/side-menu-leave-guard.dom.test.tsx | 34 +++++++++++++++++++ 6 files changed, 64 insertions(+), 24 deletions(-) create mode 100644 src/components/work-frame/lazy-work-side-counts.tsx create mode 100644 tests/side-menu-leave-guard.dom.test.tsx diff --git a/src/components/clinical-dashboard/ClinicalSidebar.tsx b/src/components/clinical-dashboard/ClinicalSidebar.tsx index eadd8378cc..a75b744383 100644 --- a/src/components/clinical-dashboard/ClinicalSidebar.tsx +++ b/src/components/clinical-dashboard/ClinicalSidebar.tsx @@ -1,6 +1,5 @@ "use client"; -import dynamic from "next/dynamic"; import { useEffect, useRef, @@ -61,6 +60,7 @@ import { ClinicalMenuWorkAreas, clinicalMenuTabId, } from "@/components/clinical-dashboard/clinical-menu-drawer"; +import { LazyWorkSideCounts } from "@/components/work-frame/lazy-work-side-counts"; import { useWorkSideCounts } from "@/components/work-frame/work-frame-store"; import { useNewWorkMode } from "@/components/work-mode-launch/work-mode-launch-provider"; import { needsYouBellVisibleForAuth } from "@/lib/needs-you/homes"; @@ -68,18 +68,6 @@ import type { ModeMenuSideId } from "@/lib/phone-mode-groups"; import { useAuthSession } from "@/lib/supabase/client"; import type { WorkAreaId } from "@/lib/work-frame/areas"; -// The counts reader is loaded straight from its own file. Going through -// work-frame/lazy-work-side-nav would pull in the two-pane menu, which imports -// this file back and makes an import cycle. -const LazyCountsReader = dynamic( - () => import("@/components/work-frame/work-side-counts-reader").then((module) => module.WorkSideCountsReader), - { ssr: false }, -); - -function LazyWorkSideCounts({ active }: { readonly active: boolean }) { - return active ? : null; -} - export type SidebarIdentity = { displayName: string; initials: string; diff --git a/src/components/work-frame/lazy-work-side-counts.tsx b/src/components/work-frame/lazy-work-side-counts.tsx new file mode 100644 index 0000000000..ec2d0fe9c1 --- /dev/null +++ b/src/components/work-frame/lazy-work-side-counts.tsx @@ -0,0 +1,15 @@ +"use client"; + +import dynamic from "next/dynamic"; + +// Its own leaf module: the clinical sidebar imports it, so it must not pull in the +// side menus (two-pane-side-menu imports the clinical sidebar back). +const LazyCounts = dynamic( + () => import("@/components/work-frame/work-side-counts-reader").then((module) => module.WorkSideCountsReader), + { ssr: false }, +); + +/** The side menu and rail's counts, read only while one of them is on screen. */ +export function LazyWorkSideCounts({ active }: { readonly active: boolean }) { + return active ? : null; +} diff --git a/src/components/work-frame/lazy-work-side-nav.tsx b/src/components/work-frame/lazy-work-side-nav.tsx index 478a398ecd..f789f0b325 100644 --- a/src/components/work-frame/lazy-work-side-nav.tsx +++ b/src/components/work-frame/lazy-work-side-nav.tsx @@ -3,6 +3,8 @@ import dynamic from "next/dynamic"; import { useState, type ComponentProps } from "react"; +import { LazyWorkSideCounts } from "@/components/work-frame/lazy-work-side-counts"; + const loadWorkSideMenu = () => import("@/components/work-frame/work-side-menu").then((module) => module.WorkSideMenu); const LazyMenu = dynamic(loadWorkSideMenu, { ssr: false }); @@ -10,11 +12,6 @@ const loadTwoPaneSideMenu = () => import("@/components/work-frame/two-pane-side-menu").then((module) => module.TwoPaneSideMenu); const LazyTwoPane = dynamic(loadTwoPaneSideMenu, { ssr: false }); -const LazyCounts = dynamic( - () => import("@/components/work-frame/work-side-counts-reader").then((module) => module.WorkSideCountsReader), - { ssr: false }, -); - /** Warms the side menu's chunk when the menu button is about to be pressed. */ export function prefetchWorkSideMenu() { void loadWorkSideMenu(); @@ -42,10 +39,7 @@ export function LazyTwoPaneSideMenu(props: ComponentProps) { return opened ? : null; } -/** The side menu and rail's counts, read only while one of them is on screen. */ -export function LazyWorkSideCounts({ active }: { readonly active: boolean }) { - return active ? : null; -} +export { LazyWorkSideCounts }; /** * The two-pane side menu with its own counts reader, for a page that has no diff --git a/src/components/work-frame/two-pane-side-strip.tsx b/src/components/work-frame/two-pane-side-strip.tsx index 837804a4e0..07958b51c8 100644 --- a/src/components/work-frame/two-pane-side-strip.tsx +++ b/src/components/work-frame/two-pane-side-strip.tsx @@ -30,6 +30,7 @@ import { appModeIcons } from "@/lib/app-mode-icons"; import { BRAND_NAME } from "@/lib/brand"; import type { ModeMenuSideId } from "@/lib/phone-mode-groups"; import type { ThemePreference } from "@/lib/theme"; +import { confirmShellNavigation } from "@/lib/navigation-guards"; /** * The strip shared by the two-pane side menu and its tablet rail. On a phone the strip lives inside the menu. From 768 px the same @@ -64,7 +65,11 @@ export function useThemeCycle() { export function useOpenReminders(): () => void { const router = useRouter(); return () => { - if (!workFrameActionHandler("my-day-reminders")) router.push("/my-day"); + if (!workFrameActionHandler("my-day-reminders")) { + // An unsaved page (the safety plan) may keep the reader where they are. + if (!confirmShellNavigation()) return; + router.push("/my-day"); + } requestWorkFrameAction("my-day-reminders"); }; } diff --git a/src/components/work-frame/work-side-menu.tsx b/src/components/work-frame/work-side-menu.tsx index ec64cde378..c399463311 100644 --- a/src/components/work-frame/work-side-menu.tsx +++ b/src/components/work-frame/work-side-menu.tsx @@ -19,6 +19,7 @@ import { appModeIcons } from "@/lib/app-mode-icons"; import { BRAND_NAME } from "@/lib/brand"; import type { ModeMenuSideId } from "@/lib/phone-mode-groups"; import { WORK_AREAS, type WorkAreaId } from "@/lib/work-frame/areas"; +import { confirmShellNavigation } from "@/lib/navigation-guards"; import { WORK_SIDE_AREAS, WORK_SIDE_NOTIFICATIONS_HREF, @@ -85,7 +86,10 @@ export function WorkSideMenu({ const openReminders = () => { close(); // Only a page that offers Reminders can open it; anywhere else goes to My Day first. - if (!workFrameActionHandler("my-day-reminders")) router.push("/my-day"); + if (!workFrameActionHandler("my-day-reminders")) { + if (!confirmShellNavigation()) return; + router.push("/my-day"); + } requestWorkFrameAction("my-day-reminders"); }; diff --git a/tests/side-menu-leave-guard.dom.test.tsx b/tests/side-menu-leave-guard.dom.test.tsx new file mode 100644 index 0000000000..cbd7e6d9d9 --- /dev/null +++ b/tests/side-menu-leave-guard.dom.test.tsx @@ -0,0 +1,34 @@ +/** @vitest-environment jsdom */ + +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { useOpenReminders } from "@/components/work-frame/two-pane-side-strip"; +import { registerNavigationGuard } from "@/lib/navigation-guards"; + +const router = vi.hoisted(() => ({ push: vi.fn() })); + +vi.mock("next/navigation", () => ({ + useRouter: () => router, + usePathname: () => "/psychiatry/safety-plan", +})); + +afterEach(() => { + router.push.mockReset(); +}); + +describe("side menu Reminders shortcut", () => { + it("stays on a page with unsaved work when the reader says stay", () => { + const remove = registerNavigationGuard(() => false); + const { result } = renderHook(() => useOpenReminders()); + act(() => result.current()); + expect(router.push).not.toHaveBeenCalled(); + remove(); + }); + + it("goes to My Day when nothing holds the reader", () => { + const { result } = renderHook(() => useOpenReminders()); + act(() => result.current()); + expect(router.push).toHaveBeenCalledWith("/my-day"); + }); +}); From 30b74c87ec87b5d83362c7ade42b6ff1eade2cb1 Mon Sep 17 00:00:00 2001 From: "qodo-code-review[bot]" <151058649+qodo-code-review[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:40:08 +0000 Subject: [PATCH 2/3] fix: Keep menus open when Reminders navigation is declined --- src/components/work-frame/two-pane-side-menu.tsx | 2 +- src/components/work-frame/two-pane-side-strip.tsx | 8 +++++--- src/components/work-frame/work-side-menu.tsx | 9 ++++----- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/components/work-frame/two-pane-side-menu.tsx b/src/components/work-frame/two-pane-side-menu.tsx index 27c5f281be..c372f6dae3 100644 --- a/src/components/work-frame/two-pane-side-menu.tsx +++ b/src/components/work-frame/two-pane-side-menu.tsx @@ -239,8 +239,8 @@ export function TwoPaneSideMenu({ const visibleAreas = workAvailable ? WORK_SIDE_AREAS.filter((entry) => routeVisible(entry.href)) : []; const openReminders = () => { + if (!runReminders()) return; close(); - runReminders(); }; const needle = find.trim().toLowerCase(); diff --git a/src/components/work-frame/two-pane-side-strip.tsx b/src/components/work-frame/two-pane-side-strip.tsx index 07958b51c8..c87409a4dc 100644 --- a/src/components/work-frame/two-pane-side-strip.tsx +++ b/src/components/work-frame/two-pane-side-strip.tsx @@ -62,15 +62,17 @@ export function useThemeCycle() { } /** Opens Reminders. Only a page that offers it can open it; anywhere else goes to My Day first. */ -export function useOpenReminders(): () => void { +export function useOpenReminders(): () => boolean { const router = useRouter(); return () => { - if (!workFrameActionHandler("my-day-reminders")) { + const handled = workFrameActionHandler("my-day-reminders"); + if (!handled) { // An unsaved page (the safety plan) may keep the reader where they are. - if (!confirmShellNavigation()) return; + if (!confirmShellNavigation()) return false; router.push("/my-day"); } requestWorkFrameAction("my-day-reminders"); + return true; }; } diff --git a/src/components/work-frame/work-side-menu.tsx b/src/components/work-frame/work-side-menu.tsx index c399463311..8195b875a4 100644 --- a/src/components/work-frame/work-side-menu.tsx +++ b/src/components/work-frame/work-side-menu.tsx @@ -84,12 +84,11 @@ export function WorkSideMenu({ const close = () => onOpenChange(false); const openReminders = () => { - close(); // Only a page that offers Reminders can open it; anywhere else goes to My Day first. - if (!workFrameActionHandler("my-day-reminders")) { - if (!confirmShellNavigation()) return; - router.push("/my-day"); - } + const handled = workFrameActionHandler("my-day-reminders"); + if (!handled && !confirmShellNavigation()) return; + close(); + if (!handled) router.push("/my-day"); requestWorkFrameAction("my-day-reminders"); }; From 040a7b35c7bf4c37c4e898f1e5078a199f73d5e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 17:43:02 +0000 Subject: [PATCH 3/3] chore: place side-menu leave-guard test in App experience Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KHkbt7EPd1iS6ryTjtnzq2 --- docs/organisation/systems/app-experience.json | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/organisation/systems/app-experience.json b/docs/organisation/systems/app-experience.json index 094c56139b..2cfa472d0f 100644 --- a/docs/organisation/systems/app-experience.json +++ b/docs/organisation/systems/app-experience.json @@ -341,6 +341,7 @@ "tests/services-tap-floor.test.ts", "tests/settings-*", "tests/shared-*", + "tests/side-menu-leave-guard.dom.test.tsx", "tests/sidebar-*", "tests/signed-*", "tests/smart-search-intent.test.ts",