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",
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-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 837804a4e0..c87409a4dc 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
@@ -61,11 +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")) router.push("/my-day");
+ const handled = workFrameActionHandler("my-day-reminders");
+ if (!handled) {
+ // An unsaved page (the safety plan) may keep the reader where they are.
+ 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 ec64cde378..8195b875a4 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,
@@ -83,9 +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")) 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");
};
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");
+ });
+});