diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index 850a8fcdfa..aa974f511f 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -223,6 +223,11 @@ "file": "src/app/auth/reset-password/page.tsx", "area": "product" }, + { + "path": "/forms/clock", + "file": "src/app/(search-app)/forms/clock/page.tsx", + "area": "product" + }, { "path": "/documents/[id]", "file": "src/app/(search-app)/documents/[id]/page.tsx", @@ -780,7 +785,7 @@ }, { "path": "/safety-plan", - "file": "src/app/safety-plan/page.tsx", + "file": "src/app/(search-app)/safety-plan/page.tsx", "area": "product" }, { diff --git a/docs/codebase-index.md b/docs/codebase-index.md index 97b7090e95..e0836481fb 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -186,7 +186,7 @@ Local task coordination lives in `.superpowers/`: ignored task briefs, review pa | `/medications/[slug]` (bare `/medications` redirects to `/?mode=prescribing`) | `src/app/(search-app)/medications/` | | `/privacy` | `src/app/privacy/page.tsx` → `privacy-quiet-signal-page.tsx` + `privacy-page-content.tsx` | | `/reference/colour-coding` | `src/app/reference/` | -| `/safety-plan` | `src/app/safety-plan/page.tsx` | +| `/safety-plan` | `src/app/(search-app)/safety-plan/page.tsx` | | `/calculators`, `/calculators/search` | `src/app/(search-app)/calculators/` | | `/services/search`, `/services/[slug]` | `src/app/(search-app)/services/` | | `/therapy-compass/search`, `/recommend`, `/compare`, `/pathways`, `/review`, `/[slug]`, `/[slug]/brief`, `/[slug]/sheet` | `src/app/(search-app)/therapy-compass/` | @@ -217,7 +217,7 @@ Local task coordination lives in `.superpowers/`: ignored task briefs, review pa | `/medications/[slug]` (bare `/medications` redirects to `/?mode=prescribing`) | `src/app/(search-app)/medications/` | | `/privacy` | `src/app/privacy/page.tsx` → `privacy-quiet-signal-page.tsx` + `privacy-page-content.tsx` | | `/reference/colour-coding` | `src/app/reference/` | -| `/safety-plan` | `src/app/safety-plan/page.tsx` | +| `/safety-plan` | `src/app/(search-app)/safety-plan/page.tsx` | | `/calculators`, `/calculators/search` | `src/app/(search-app)/calculators/` | | `/services/search`, `/services/[slug]` | `src/app/(search-app)/services/` | | `/therapy-compass/search`, `/recommend`, `/compare`, `/pathways`, `/review`, `/[slug]`, `/[slug]/brief`, `/[slug]/sheet` | `src/app/(search-app)/therapy-compass/` | @@ -580,8 +580,14 @@ Limits come only from `mhaTimers` (`src/lib/on-call/mha-timers.ts`), so a countd the signed switch is on (a passed limit is red); otherwise the owner-approved quote-only lines from the form-page Timeline. Every limit, countdown or not, carries the Act's own words and a section-labelled link to the Act (the hazard-analysis control). `/psychiatry/*` are information -pages, so the clock page has no shared composer. The page also copies the running clocks for handover (form and made-at time -only) and offers Undo after a removal. Linked from the hub's "For a shift" list on Ask and Tools. +pages, so the clock page has no shared composer. The page also copies the running clocks for handover (each form's code +and name, made-at time, and the limits the page already shows — a signed-off countdown's end as a Perth time, +otherwise the not-calculated line; never time left) and offers Undo after a removal. Linked from the hub's "For a +shift" list on Ask and Tools. The same page is also Clinical Forms' **Clock** tab at `/forms/clock` (breadcrumb +"Forms"): Forms has three tabs — Forms (`/forms/search`), Clock and Act (`/forms/act`) — registered in +`src/lib/mode-secondary-navigation.ts`; the shell draws the bar on the list, and Clock and Act (information pages) +mount it themselves through `src/components/forms/forms-mode-nav.tsx`. Both clock routes read their form list +from `src/lib/psychiatry-hub/mha-clock-forms.ts` on the server. The hub and the clock page are drawn with the flat list parts in `src/components/psychiatry/psychiatry-flat.tsx`. diff --git a/docs/design-system/ADOPTION.md b/docs/design-system/ADOPTION.md index 8667891477..da374f3543 100644 --- a/docs/design-system/ADOPTION.md +++ b/docs/design-system/ADOPTION.md @@ -367,12 +367,12 @@ product exclusions; the only route-only dispositions are the documented legacy-r surfaces. Shared shell/component roots carry their own explicit `shared-shell` disposition. Registered public components: 55 -Declared product roots: 231 +Declared product roots: 232 Roots with a literal `.ckb-v2` opt-in: 1 -Roots inheriting `.ckb-v2` from the global ``: 230 +Roots inheriting `.ckb-v2` from the global ``: 231 Production surfaces observed under v2: 14/14 Dynamic `ckb-v2` constructions: 0 -Declared production page routes: 223/223 +Declared production page routes: 224/224 Source observation and contract declaration are independent. A literal `ckb-v2` on the global `` makes every production surface inherit v2, but it does not approve that adoption. The Proof column summarizes each surface's dark, forced-colours, 320px, print and browser declarations; exact statuses and evidence paths live in the manifest. @@ -381,7 +381,7 @@ Observed v2 under a compatibility declaration fails closed. A declared v2 shell | Surface | Disposition | Routes | Roots | Declared shell | Observed shell (mount) | Proof | Baseline | | ---------------------------------- | --------------- | -----: | ----: | -------------- | -------------------------- | -------------- | -------------- | | `root-shell-and-settings` | shared-shell | 3 | 6 | v2 | v2 (inherited-global-root) | passed | not-committed | -| `catalogues-forms-and-info` | owned | 180 | 180 | v2 | v2 (inherited-global-root) | passed | not-committed | +| `catalogues-forms-and-info` | owned | 181 | 181 | v2 | v2 (inherited-global-root) | passed | not-committed | | `differentials` | owned | 7 | 7 | v2 | v2 (inherited-global-root) | passed | not-committed | | `formulation` | owned | 6 | 6 | v2 | v2 (inherited-global-root) | passed | not-committed | | `specifiers` | owned | 6 | 6 | v2 | v2 (inherited-global-root) | passed | not-committed | diff --git a/docs/design-system/adoption-contract.json b/docs/design-system/adoption-contract.json index dcf19dfb0c..bdda51cba3 100644 --- a/docs/design-system/adoption-contract.json +++ b/docs/design-system/adoption-contract.json @@ -179,6 +179,7 @@ "src/app/(search-app)/first-nations/talking/page.tsx", "src/app/(search-app)/forms/[slug]/page.tsx", "src/app/(search-app)/forms/act/page.tsx", + "src/app/(search-app)/forms/clock/page.tsx", "src/app/(search-app)/forms/page.tsx", "src/app/(search-app)/forms/search/page.tsx", "src/app/(search-app)/medications/[slug]/page.tsx", @@ -667,7 +668,7 @@ "src/app/auth/reset-password/page.tsx", "src/app/privacy/page.tsx", "src/app/reference/colour-coding/page.tsx", - "src/app/safety-plan/page.tsx" + "src/app/(search-app)/safety-plan/page.tsx" ], "routeRoots": true, "roots": [], diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index fcb4e5305b..ae498bc793 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -3357,6 +3357,7 @@ "src/app/(search-app)/first-nations/talking/page.tsx", "src/app/(search-app)/forms/[slug]/page.tsx", "src/app/(search-app)/forms/act/page.tsx", + "src/app/(search-app)/forms/clock/page.tsx", "src/app/(search-app)/forms/page.tsx", "src/app/(search-app)/forms/search/page.tsx", "src/app/(search-app)/medications/[slug]/page.tsx", @@ -4260,6 +4261,16 @@ "v2MountMode": "inherited-global-root", "sanctionedPatternsPresent": [] }, + { + "file": "src/app/(search-app)/forms/clock/page.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": [] + }, { "file": "src/app/(search-app)/forms/page.tsx", "exists": true, @@ -6089,10 +6100,10 @@ "disposition": "owned", "documentedDisposition": null, "routes": [ + "src/app/(search-app)/safety-plan/page.tsx", "src/app/auth/reset-password/page.tsx", "src/app/privacy/page.tsx", - "src/app/reference/colour-coding/page.tsx", - "src/app/safety-plan/page.tsx" + "src/app/reference/colour-coding/page.tsx" ], "routeRoots": true, "proofApplicability": "required", @@ -6138,44 +6149,44 @@ ], "roots": [ { - "file": "src/app/auth/reset-password/page.tsx", + "file": "src/app/(search-app)/safety-plan/page.tsx", "exists": true, "imports": [], "importedFamilies": [], "literalCkbV2": false, "dynamicCkbV2": false, "v2MountMode": "inherited-global-root", - "sanctionedPatternsPresent": ["PasswordRecoveryForm"] + "sanctionedPatternsPresent": ["PatientSafetyPlan"] }, { - "file": "src/app/privacy/page.tsx", + "file": "src/app/auth/reset-password/page.tsx", "exists": true, "imports": [], "importedFamilies": [], "literalCkbV2": false, "dynamicCkbV2": false, "v2MountMode": "inherited-global-root", - "sanctionedPatternsPresent": ["PrivacyPage"] + "sanctionedPatternsPresent": ["PasswordRecoveryForm"] }, { - "file": "src/app/reference/colour-coding/page.tsx", + "file": "src/app/privacy/page.tsx", "exists": true, "imports": [], "importedFamilies": [], "literalCkbV2": false, "dynamicCkbV2": false, "v2MountMode": "inherited-global-root", - "sanctionedPatternsPresent": ["ColourCodingReferencePage"] + "sanctionedPatternsPresent": ["PrivacyPage"] }, { - "file": "src/app/safety-plan/page.tsx", + "file": "src/app/reference/colour-coding/page.tsx", "exists": true, "imports": [], "importedFamilies": [], "literalCkbV2": false, "dynamicCkbV2": false, "v2MountMode": "inherited-global-root", - "sanctionedPatternsPresent": ["PatientSafetyPlan"] + "sanctionedPatternsPresent": ["ColourCodingReferencePage"] } ] }, @@ -6448,6 +6459,7 @@ "src/app/(search-app)/first-nations/talking/page.tsx", "src/app/(search-app)/forms/[slug]/page.tsx", "src/app/(search-app)/forms/act/page.tsx", + "src/app/(search-app)/forms/clock/page.tsx", "src/app/(search-app)/forms/page.tsx", "src/app/(search-app)/forms/search/page.tsx", "src/app/(search-app)/formulation/[slug]/page.tsx", @@ -6525,6 +6537,7 @@ "src/app/(search-app)/roster/swaps/page.tsx", "src/app/(search-app)/roster/team/page.tsx", "src/app/(search-app)/roster/today/page.tsx", + "src/app/(search-app)/safety-plan/page.tsx", "src/app/(search-app)/services/[slug]/page.tsx", "src/app/(search-app)/services/page.tsx", "src/app/(search-app)/services/search/page.tsx", @@ -6578,8 +6591,7 @@ "src/app/(search-app)/tools/page.tsx", "src/app/auth/reset-password/page.tsx", "src/app/privacy/page.tsx", - "src/app/reference/colour-coding/page.tsx", - "src/app/safety-plan/page.tsx" + "src/app/reference/colour-coding/page.tsx" ], "declared": [ "src/app/(display)/teaching/display/[token]/page.tsx", @@ -6673,6 +6685,7 @@ "src/app/(search-app)/first-nations/talking/page.tsx", "src/app/(search-app)/forms/[slug]/page.tsx", "src/app/(search-app)/forms/act/page.tsx", + "src/app/(search-app)/forms/clock/page.tsx", "src/app/(search-app)/forms/page.tsx", "src/app/(search-app)/forms/search/page.tsx", "src/app/(search-app)/formulation/[slug]/page.tsx", @@ -6750,6 +6763,7 @@ "src/app/(search-app)/roster/swaps/page.tsx", "src/app/(search-app)/roster/team/page.tsx", "src/app/(search-app)/roster/today/page.tsx", + "src/app/(search-app)/safety-plan/page.tsx", "src/app/(search-app)/services/[slug]/page.tsx", "src/app/(search-app)/services/page.tsx", "src/app/(search-app)/services/search/page.tsx", @@ -6803,8 +6817,7 @@ "src/app/(search-app)/tools/page.tsx", "src/app/auth/reset-password/page.tsx", "src/app/privacy/page.tsx", - "src/app/reference/colour-coding/page.tsx", - "src/app/safety-plan/page.tsx" + "src/app/reference/colour-coding/page.tsx" ], "undeclared": [], "missing": [], @@ -6812,7 +6825,7 @@ }, "adoption": { "literalCkbV2RootCount": 1, - "inheritedCkbV2RootCount": 230, + "inheritedCkbV2RootCount": 231, "dynamicCkbV2RootCount": 0, "v2MountedSurfaceCount": 14, "declaredV2SurfaceCount": 14 @@ -6821,8 +6834,8 @@ "registeredComponentCount": 55, "previewCount": 55, "productImportedComponentCount": 48, - "rootCount": 231, - "productionRouteCount": 223, - "nextUiEntryCount": 331 + "rootCount": 232, + "productionRouteCount": 224, + "nextUiEntryCount": 333 } } diff --git a/docs/organisation/systems/app-experience.json b/docs/organisation/systems/app-experience.json index 001305dc69..094c56139b 100644 --- a/docs/organisation/systems/app-experience.json +++ b/docs/organisation/systems/app-experience.json @@ -52,7 +52,6 @@ "src/app/privacy/**", "src/app/reference/**", "src/app/robots.ts", - "src/app/safety-plan/**", "src/app/sitemap.ts", "src/app/work-mode.css", "src/components/AccessibleTable.tsx", @@ -169,6 +168,7 @@ "src/lib/mode-home-composer.ts", "src/lib/mode-picker-hints.ts", "src/lib/mode-secondary-navigation.ts", + "src/lib/navigation-guards.ts", "src/lib/patient-profile-storage.ts", "src/lib/pdf-documents.ts", "src/lib/phone-mode-groups.ts", @@ -186,6 +186,7 @@ "src/lib/search-scope-filter-chips.ts", "src/lib/search-shell-props.ts", "src/lib/service-best-fit.ts", + "src/lib/service-call.ts", "src/lib/service-core-groups.ts", "src/lib/service-facets.ts", "src/lib/service-navigator-metrics.ts", @@ -224,6 +225,7 @@ "tests/audit-*", "tests/auth-panel-product-truth.dom.test.tsx", "tests/bbox-overlay.test.ts", + "tests/browse-a-to-z.dom.test.tsx", "tests/calculator-focus-return.dom.test.tsx", "tests/calculator-sheet-close.dom.test.tsx", "tests/calculators-mode.dom.test.tsx", @@ -300,6 +302,7 @@ "tests/mobile-*", "tests/mode-*", "tests/navigation-back-button.dom.test.tsx", + "tests/navigation-guards.test.ts", "tests/offline-pack-content.test.ts", "tests/page-secondary-navigation.dom.test.tsx", "tests/patient-*", diff --git a/docs/site-map.md b/docs/site-map.md index 9bfa3765c8..54d3d538a9 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -14,6 +14,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/teaching/teach` - Presenter preparation, de-identification confirmation, taught-before history and released feedback totals. Source: `src/app/(search-app)/teaching/teach/page.tsx`. - `/forms` - Compatibility redirect to the shared home at `/?mode=forms`; a submitted `?q=…&run=1` forwards to `/forms/search`. Source: `src/app/(search-app)/forms/page.tsx`. - `/auth/reset-password` - Route discovered from app directory Source: `src/app/auth/reset-password/page.tsx`. +- `/forms/clock` - Route discovered from app directory Source: `src/app/(search-app)/forms/clock/page.tsx`. - `/teaching/assessments/help` - Route discovered from app directory Source: `src/app/(search-app)/teaching/assessments/help/page.tsx`. - `/cme/programme` - The requirement targets confirmed for this year — hours required in each category — and the source document they were confirmed against. Source: `src/app/(search-app)/cme/programme/page.tsx`. - `/admin/new-job/pack` - Route discovered from app directory Source: `src/app/(search-app)/admin/new-job/pack/page.tsx`. @@ -86,7 +87,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/on-call/first-week` - First week pack for a new rotation: orientation, who is who, escalation and logins, from the hospital handbook. Source: `src/app/(search-app)/on-call/first-week/page.tsx`. - `/teaching/c/complete` - Finishes a check-in after sign-in, from the claim this browser holds. Source: `src/app/(search-app)/teaching/c/complete/page.tsx`. - `/roster/swaps` - Route discovered from app directory Source: `src/app/(search-app)/roster/swaps/page.tsx`. -- `/safety-plan` - Patient safety plan generator (Stanley-Brown six steps) — a Tools-page clinical tool. Source: `src/app/safety-plan/page.tsx`. +- `/safety-plan` - Patient safety plan generator (Stanley-Brown six steps), inside the search shell under Psychiatry; opened from the Psychiatry page's For a shift row. Source: `src/app/(search-app)/safety-plan/page.tsx`. - `/medicines` - Medicines & tools dashboard: one card each for Medication, Calculators, Tools, Factsheets and Dictionary, linking to those modes at their own addresses. A dashboard, not a redirect to the shared search home — it has no search results surface. Source: `src/app/(search-app)/medicines/page.tsx`. - `/on-call/education` - Compatibility redirect to `/teaching/week`, carrying the query string. Source: `src/app/(search-app)/on-call/education/page.tsx`. - `/admin/compliance` - The statewide requirements grouped as a health service asks for them, with the doctor's own recorded dates, what to do before the next job, and an Excel export saved on the device; dates are not verified with an issuing body. Source: `src/app/(search-app)/admin/compliance/page.tsx`. @@ -1655,7 +1656,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir | Therapy Compass | `src/app/(search-app)/therapy-compass, src/lib/therapies.ts` | | Factsheets | `src/app/(search-app)/factsheets, src/components/factsheets` | | Dictionary | `src/app/(search-app)/dictionary, src/lib/dictionary.ts` | -| Safety Plan | `src/app/safety-plan, src/components/patient-safety-plan.tsx` | +| Safety Plan | `src/app/(search-app)/safety-plan, src/components/patient-safety-plan.tsx` | | Privacy | `src/app/privacy` | | Tools | `src/app/(search-app)/tools, src/components/tools/tools-search-results-page.tsx, src/components/tools/tool-quick-actions.tsx` | | Sources | `src/app/(search-app)/sources, src/components/sources, src/lib/sources` | diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index 52842163c5..f58e3e1025 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -147,7 +147,8 @@ const routeDescriptions: Record = { "/medications/[slug]": "Medication detail.", "/privacy": "Public privacy and data-processing transparency notice; governance approval pending.", "/reference/colour-coding": "Clinical domain and category colour-coding palette reference.", - "/safety-plan": "Patient safety plan generator (Stanley-Brown six steps) — a Tools-page clinical tool.", + "/safety-plan": + "Patient safety plan generator (Stanley-Brown six steps), inside the search shell under Psychiatry; opened from the Psychiatry page's For a shift row.", "/services": "Services home and search surface.", "/services/[slug]": "Registry-backed service detail.", "/services/search": @@ -388,7 +389,7 @@ const routeOwnershipRows = [ ["Therapy Compass", "src/app/(search-app)/therapy-compass, src/lib/therapies.ts"], ["Factsheets", "src/app/(search-app)/factsheets, src/components/factsheets"], ["Dictionary", "src/app/(search-app)/dictionary, src/lib/dictionary.ts"], - ["Safety Plan", "src/app/safety-plan, src/components/patient-safety-plan.tsx"], + ["Safety Plan", "src/app/(search-app)/safety-plan, src/components/patient-safety-plan.tsx"], ["Privacy", "src/app/privacy"], [ "Tools", diff --git a/src/app/(search-app)/forms/act/page.tsx b/src/app/(search-app)/forms/act/page.tsx index 441d9861cb..d755332fa4 100644 --- a/src/app/(search-app)/forms/act/page.tsx +++ b/src/app/(search-app)/forms/act/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { loadActReferenceGroups, loadChiefPsychiatristStandards } from "@/components/forms/act-and-standards-content"; import { ActAndStandardsPage } from "@/components/forms/act-and-standards-page"; +import { FormsModeNav } from "@/components/forms/forms-mode-nav"; import { consolidatedModeSearchPath } from "@/lib/consolidated-mode-home-redirect"; import { mhaActMetadata } from "@/lib/mha-act-sections"; @@ -13,11 +14,14 @@ export const metadata: Metadata = { export default function FormsActAndStandardsRoute() { return ( - + <> + + + ); } diff --git a/src/app/(search-app)/forms/clock/page.tsx b/src/app/(search-app)/forms/clock/page.tsx new file mode 100644 index 0000000000..33eac86478 --- /dev/null +++ b/src/app/(search-app)/forms/clock/page.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from "next"; + +import { FormsModeNav } from "@/components/forms/forms-mode-nav"; +import { MhaClockPage } from "@/components/psychiatry/mha-clock-page"; +import { consolidatedModeSearchPath } from "@/lib/consolidated-mode-home-redirect"; +import { mhaClockForms } from "@/lib/psychiatry-hub/mha-clock-forms"; + +export const metadata: Metadata = { + title: "MHA clock | PsychSift", + description: "Every Mental Health Act form you are holding, with how long it has been running and its time limits.", +}; + +/** + * Clinical Forms · Clock: the same MHA clock the Psychiatry hub opens at + * /psychiatry/mha-clock, here as the Forms mode's Clock tab so the forms list, + * the clock and the Act are one tap apart. Clocks live on this device, so both + * addresses show the same ones. + */ +export default function FormsClockRoute() { + return ( + <> + + + + ); +} diff --git a/src/app/(search-app)/psychiatry/mha-clock/page.tsx b/src/app/(search-app)/psychiatry/mha-clock/page.tsx index 9cd4bf45f5..ed3cd9e4d4 100644 --- a/src/app/(search-app)/psychiatry/mha-clock/page.tsx +++ b/src/app/(search-app)/psychiatry/mha-clock/page.tsx @@ -1,8 +1,7 @@ import type { Metadata } from "next"; -import { MhaClockPage, type MhaClockForm } from "@/components/psychiatry/mha-clock-page"; -import { formCatalogDetails, formRecords } from "@/lib/forms"; -import { hasMhaTimeline } from "@/lib/mha-timeline"; +import { MhaClockPage } from "@/components/psychiatry/mha-clock-page"; +import { mhaClockForms } from "@/lib/psychiatry-hub/mha-clock-forms"; export const metadata: Metadata = { title: "MHA clock | PsychSift", @@ -10,13 +9,11 @@ export const metadata: Metadata = { }; /** - * Psychiatry · MHA clock. The form list is read here, on the server, so the forms catalogue never - * reaches the browser: only forms with time limits in the governed timeframe data are offered. + * Psychiatry · MHA clock, the hub's shortcut. The same clock is the Clinical + * Forms mode's Clock tab at /forms/clock. The form list is read here, on the + * server, so the forms catalogue never reaches the browser: only forms with time + * limits in the governed timeframe data are offered. */ export default function MhaClockRoute() { - const forms: MhaClockForm[] = formRecords.flatMap((record) => { - const code = formCatalogDetails(record)?.form; - return code && hasMhaTimeline(code) ? [{ code, title: record.title, slug: record.slug }] : []; - }); - return ; + return ; } diff --git a/src/app/(search-app)/safety-plan/error.tsx b/src/app/(search-app)/safety-plan/error.tsx new file mode 100644 index 0000000000..547d82d6d1 --- /dev/null +++ b/src/app/(search-app)/safety-plan/error.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { RouteErrorBoundary } from "@/components/route-error-boundary"; + +export default function ErrorPage({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + return ; +} diff --git a/src/app/safety-plan/page.tsx b/src/app/(search-app)/safety-plan/page.tsx similarity index 100% rename from src/app/safety-plan/page.tsx rename to src/app/(search-app)/safety-plan/page.tsx diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index f151328803..e7f74498f0 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -36,6 +36,7 @@ import { MasterSearchHeader } from "@/components/clinical-dashboard/master-searc import { PhoneFooterLayerFrame } from "@/components/clinical-dashboard/phone-footer-layer-portal"; import { PageSecondaryNavigation } from "@/components/page-secondary-navigation"; import { TWO_PANE_SIDE_MENU } from "@/lib/work-frame/side-menu-choice"; +import { confirmShellNavigation } from "@/lib/navigation-guards"; import { LazyTwoPaneSideMenu, LazyWorkSideCounts, @@ -753,6 +754,7 @@ function GlobalStandaloneSearchShellBody({ }); function navigateToMode(mode: AppModeId, options: SearchNavigationOptions = {}) { + if (!confirmShellNavigation()) return; const nextOptions = { queryMode, scopeFilters, ...options }; if (mode === "documents" && options.query?.trim()) { router.push(documentsSearchHref(nextOptions)); @@ -780,8 +782,6 @@ function GlobalStandaloneSearchShellBody({ openAccountSetup("favourites"); return; } - setLastAppMode(mode); - // Dedicated modes return to their dedicated standalone home. Remaining modes // return to the shared home. Preserve any current query as an unsubmitted draft, // but omit `run=1`; only an explicit submit may open the selected mode's @@ -798,6 +798,9 @@ function GlobalStandaloneSearchShellBody({ const destination = new URL(href, window.location.origin); const destinationSearch = destination.search.startsWith("?") ? destination.search.slice(1) : destination.search; const alreadyOnDestination = pathname === destination.pathname && searchParamString === destinationSearch; + // A page with unsaved work may ask first; re-selecting the mode already shown never leaves, so never asks. + if (!alreadyOnDestination && !confirmShellNavigation()) return; + setLastAppMode(mode); setMobileMenuOpen(false); @@ -826,6 +829,7 @@ function GlobalStandaloneSearchShellBody({ } function startNewAnswerChat(clearClinicalAsk: () => void = () => undefined) { + if (!confirmShellNavigation()) return; clearClinicalAsk(); setQuery(""); setMobileMenuOpen(false); diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index b4ba5b2e1a..141439e44b 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -158,6 +158,7 @@ import { useCommandDropdownDisplayableByPlacement } from "@/components/clinical- import type { ClinicalDocument, ClinicalQueryMode } from "@/lib/types"; import { type SearchScopeFilters } from "@/lib/search-scope"; import { standaloneModeHomeHref } from "@/lib/search-route-ownership"; +import { confirmShellNavigation } from "@/lib/navigation-guards"; // Lazy chunks; the clinical bell loads the bell alone, never AI Search. const StaffWorkHeaderControls = dynamic(() => @@ -386,6 +387,8 @@ export function MasterSearchHeader(props: MasterSearchHeaderProps) { // Hosts pass the precomputed session decision in canAccessFavourites (auth || demo). // Do not OR demoMode again here — that would reopen Favourites when props diverge. const router = useRouter(); + // Direct pushes skip link guards and beforeunload: ask any page with unsaved work first. + const pushIfLeaveConfirmed = (href: string) => confirmShellNavigation() && router.push(href); const currentPathname = usePathname(); const [, setLastAppMode] = useLastAppMode(); // The two-pane side menu (off again since 10 Oct 2026) keeps the menu button @@ -805,23 +808,23 @@ export function MasterSearchHeader(props: MasterSearchHeaderProps) { return; } if (actionId === "factsheets-browse") { - router.push(factsheetsTopicsHref); + pushIfLeaveConfirmed(factsheetsTopicsHref); return; } if (actionId === "dictionary-search") { - router.push(`/dictionary/search${trimmedQuery ? `?q=${encodeURIComponent(trimmedQuery)}` : ""}`); + pushIfLeaveConfirmed(`/dictionary/search${trimmedQuery ? `?q=${encodeURIComponent(trimmedQuery)}` : ""}`); return; } if (actionId === "dictionary-topics") { - router.push("/dictionary/topics"); + pushIfLeaveConfirmed("/dictionary/topics"); return; } if (actionId === "dictionary-compare") { - router.push("/dictionary/compare"); + pushIfLeaveConfirmed("/dictionary/compare"); return; } if (actionId === "dictionary-sources") { - router.push("/dictionary/sources"); + pushIfLeaveConfirmed("/dictionary/sources"); return; } if (actionId === "services-search") { @@ -880,7 +883,7 @@ export function MasterSearchHeader(props: MasterSearchHeaderProps) { return; } if (actionId === "calculators-browse") { - router.push("/calculators"); + pushIfLeaveConfirmed("/calculators"); return; } if (actionId === "differentials-build") { @@ -908,7 +911,7 @@ export function MasterSearchHeader(props: MasterSearchHeaderProps) { return; } if (actionId === "dsm-compare") { - router.push("/dsm/compare"); + pushIfLeaveConfirmed("/dsm/compare"); return; } if (actionId === "dsm-criteria") { @@ -921,15 +924,15 @@ export function MasterSearchHeader(props: MasterSearchHeaderProps) { return; } if (actionId === "specifiers-builder") { - router.push("/specifiers/builder"); + pushIfLeaveConfirmed("/specifiers/builder"); return; } if (actionId === "specifiers-compare") { - router.push("/specifiers/compare"); + pushIfLeaveConfirmed("/specifiers/compare"); return; } if (actionId === "specifiers-map") { - router.push("/specifiers/map"); + pushIfLeaveConfirmed("/specifiers/map"); return; } if (actionId === "formulation-search") { @@ -937,22 +940,23 @@ export function MasterSearchHeader(props: MasterSearchHeaderProps) { return; } if (actionId === "formulation-builder") { - router.push("/formulation/builder"); + pushIfLeaveConfirmed("/formulation/builder"); return; } if (actionId === "formulation-compare") { - router.push("/formulation/compare"); + pushIfLeaveConfirmed("/formulation/compare"); return; } if (actionId === "formulation-map") { - router.push("/formulation/map"); + pushIfLeaveConfirmed("/formulation/map"); return; } } function selectAppMode(mode: (typeof appModeDefinitions)[number]) { - setModeMenuOpen(false); const standaloneHref = standaloneModeHomeHref(mode.id as AppModeId); + if (standaloneHref && !confirmShellNavigation()) return; + setModeMenuOpen(false); if (standaloneHref) { // Dedicated modes navigate to their canonical standalone homes // instead of retargeting the shared-home composer. @@ -1006,7 +1010,7 @@ export function MasterSearchHeader(props: MasterSearchHeaderProps) { onSearchModeChange(mode.id); return; } - if ("href" in mode && mode.href) router.push(mode.href); + if ("href" in mode && mode.href) pushIfLeaveConfirmed(mode.href); } function selectAppModeById(modeId: string) { diff --git a/src/components/clinical-dashboard/search-results-header-band.tsx b/src/components/clinical-dashboard/search-results-header-band.tsx index 8b0c74c213..0bfb842feb 100644 --- a/src/components/clinical-dashboard/search-results-header-band.tsx +++ b/src/components/clinical-dashboard/search-results-header-band.tsx @@ -150,6 +150,7 @@ export function SearchResultsHeaderBand({ resultNoun: resultNounOverride, hideEmptyQuery = false, emptyQueryLabel, + emptyQuerySubject, catalogueDegraded = false, className, }: { @@ -228,6 +229,12 @@ export function SearchResultsHeaderBand({ hideEmptyQuery?: boolean; /** Accessible region name used when `hideEmptyQuery` omits the subject. */ emptyQueryLabel?: string; + /** + * The section name shown as the heading when nothing has been typed, in place + * of "All". A browse list is the whole section in A to Z order, not a search + * that picked something, so it is titled by what it is ("DSM diagnoses"). + */ + emptyQuerySubject?: string; /** * What this count counted, when the mode registry's noun is not specific * enough to be true. @@ -244,9 +251,14 @@ export function SearchResultsHeaderBand({ className?: string; }) { const trimmedQuery = query.trim(); - const displayQuery = trimmedQuery || "All"; - const showQuerySubject = Boolean(trimmedQuery) || !hideEmptyQuery; - const regionLabel = showQuerySubject ? `Search results for ${displayQuery}` : (emptyQueryLabel ?? "Catalogue"); + const browseSubject = trimmedQuery ? undefined : emptyQuerySubject?.trim() || undefined; + const displayQuery = trimmedQuery || browseSubject || "All"; + const showQuerySubject = Boolean(trimmedQuery) || Boolean(browseSubject) || !hideEmptyQuery; + const regionLabel = browseSubject + ? `Browse ${browseSubject}` + : showQuerySubject + ? `Search results for ${displayQuery}` + : (emptyQueryLabel ?? "Catalogue"); // `status` wins when both are passed; `loading` is the deprecated shim. const resolvedStatus: SearchResultsBandStatus = status ?? (loading ? "loading" : "ready"); // The clinical invariant, expressed once: a search that failed has no count to diff --git a/src/components/dsm/dsm-search-page.tsx b/src/components/dsm/dsm-search-page.tsx index e6d4d25505..709b961722 100644 --- a/src/components/dsm/dsm-search-page.tsx +++ b/src/components/dsm/dsm-search-page.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { useId, useMemo, useState, useSyncExternalStore } from "react"; -import { BookOpenCheck, Check, ChevronRight, CircleAlert, GitCompareArrows, SearchX } from "lucide-react"; +import { BookOpenCheck, Check, ChevronDown, ChevronRight, CircleAlert, GitCompareArrows, SearchX } from "lucide-react"; import { SearchResultsHeaderBand, @@ -29,6 +29,48 @@ import { readResultFilterValues, replaceResultFilterUrl, writeResultFilterValues const dsmCardSelected = "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] shadow-[var(--e2)]"; +/** + * Browse order with nothing typed: A to Z by title, with the generic template + * entries ("[Substance] intoxication / withdrawal …") after the named + * diagnoses. Display-only — query-ranked results never pass through here. + */ +export function compareDsmBrowseTitles(left: { title: string }, right: { title: string }) { + const leftTemplate = left.title.trimStart().startsWith("["); + const rightTemplate = right.title.trimStart().startsWith("["); + if (leftTemplate !== rightTemplate) return leftTemplate ? 1 : -1; + return left.title.localeCompare(right.title, "en", { sensitivity: "base" }); +} + +/** + * Groups browse rows by DSM chapter, in the order the catalogue lists its + * chapters, rows A to Z inside each. Chapters with no rows are left out. + */ +export function groupDsmBrowseByChapter( + categories: readonly DsmCategory[], + results: readonly DsmDiagnosisSummary[], +): Array<{ key: string; label: string; items: DsmDiagnosisSummary[] }> { + const byKey = new Map(); + for (const result of results) { + const bucket = byKey.get(result.category.key); + if (bucket) bucket.push(result); + else byKey.set(result.category.key, [result]); + } + const groups: Array<{ key: string; label: string; items: DsmDiagnosisSummary[] }> = []; + const seen = new Set(); + for (const category of categories) { + const items = byKey.get(category.key); + if (!items?.length) continue; + seen.add(category.key); + groups.push({ key: category.key, label: category.label, items: [...items].sort(compareDsmBrowseTitles) }); + } + // A record whose chapter is missing from the category list still has to show. + for (const [key, items] of byKey) { + if (seen.has(key)) continue; + groups.push({ key, label: items[0]?.category.label ?? key, items: [...items].sort(compareDsmBrowseTitles) }); + } + return groups; +} + function compareHref(slugs: string[]) { const params = new URLSearchParams({ ids: slugs.join(",") }); return `/dsm/compare?${params.toString()}`; @@ -38,10 +80,13 @@ function DsmSearchResultCard({ result, isSelected, onToggle, + compact = false, }: { result: DsmDiagnosisSummary; isSelected: boolean; onToggle: () => void; + /** Browse rows: one line on a phone (title and ICD code); summary and chapter return at `sm`. */ + compact?: boolean; }) { return (
-
-
-

- {result.title} -

-

- {result.summary} -

-
- {result.category.label} - {result.icd_code} + {compact ? ( +
+
+
+

+ {result.title} +

+ {result.icd_code} +
+

+ {result.summary} +

+
+ {result.category.label} +
+
- -
+ ) : ( +
+
+

+ {result.title} +

+

+ {result.summary} +

+
+ {result.category.label} + {result.icd_code} +
+
+ +
+ )}
); @@ -168,6 +236,13 @@ export function DsmSearchPage({ return true; }); const results = filterDiagnoses(selectedCategories, selectedSupport); + // Nothing typed: this is the whole catalogue, not a search, so it reads as the + // chapter list a psychiatrist looks things up by rather than a ranked list. + const browsing = query.trim().length === 0; + const browseGroups = browsing ? groupDsmBrowseByChapter(categories, results) : []; + // Chapters start closed so the phone opens on a chapter index; a chapter filter + // (or a list that is only one chapter) opens them. + const openChapters = selectedCategories.size > 0 || browseGroups.length === 1; const activeFilterCount = selectedCategories.size + selectedSupport.size; const selectedSet = useMemo(() => new Set(selected), [selected]); const canCompare = selected.length >= 2; @@ -277,6 +352,7 @@ export function DsmSearchPage({ -
- {results.map((result) => ( - toggleDiagnosis(result.slug)} - /> - ))} -
+ {browsing ? ( +
+ {browseGroups.map((group) => ( +
+ +

+ {group.label} +

+ + {group.items.length} + {group.items.length === 1 ? " diagnosis" : " diagnoses"} + + +
+
+ {group.items.map((result) => ( + toggleDiagnosis(result.slug)} + /> + ))} +
+
+ ))} +

+ + Reference content only. Confirm criteria, exclusions, and clinical context before use. +

+
+ ) : ( +
+ {results.map((result) => ( + toggleDiagnosis(result.slug)} + /> + ))} +
+ )}