diff --git a/.gitleaksignore b/.gitleaksignore index 12d83edba6..bb76d93f3c 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -67,3 +67,15 @@ a394d76631c7ea3405037baf58091b1c4c47fa3f:tests/codex-cloud-setup.test.ts:generic 14535ffc5f05d559f6bccac84099f9e6874bba6a:.gitleaksignore:generic-api-key:49 # Reviewed false positive: a localhost example URL with a dummy API key in a removed handoff document. 92097e53e096bc33c8b23f73874981b2e3214af3:docs/ed-care-plans/claude-build-handover-2026-08-21.md:generic-api-key:360 +# Reviewed false positive: the exported browser-storage key name for the Therapy +# compare set. It is a `localStorage` key, visible in any browser's devtools and +# never a credential; `generic-api-key` matched it only because the identifier +# ends in "Key" and the hyphenated slug measures 3.99 Shannon entropy. Its five +# siblings (answer-thread, recent-queries, saved-*, search-pins) are the same +# shape and sit at 3.72-3.78, just under the rule's threshold, so this is a +# margin artefact rather than a new class of finding. Rewording the slug to duck +# the threshold was rejected: it is tuning an unverifiable number (gitleaks is +# not installed locally) and would leave a worse name behind. The value is not +# quoted in this comment, because a previous allowlist entry that quoted its own +# pattern tripped the same rule (see line 49 above). +9b34a0149759b2c2f1e8ed5d37f02ba1ac35cf39:src/lib/therapy-compare-memory.ts:generic-api-key:24 diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index ade3508593..b4d10ccfce 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -1,8 +1,8 @@ { "version": "repo-awareness-snapshot-v1", "captured_revision": { - "sha": "6cd6ba816d2eef8a4642529701ed7d7656c09510", - "committed_at": "2026-08-26T10:14:41+08:00" + "sha": "ecae1752e52dd2cd363bfaaafe2491df24769606", + "committed_at": "2026-08-26T04:01:20+00:00" }, "routes": { "modes": [ @@ -3665,6 +3665,14 @@ }, "review_state": { "records": [ + { + "date": "2026-08-26", + "ref": "claude/therapy-compare-tray", + "head": "9b34a0149759b2c2f1e8ed5d37f02ba1ac35cf39", + "scope": "therapy compare tray: phone dock addon, add-in-place, stacked comparison, device memory", + "outcome": "built and verified; verify:cheap exit 0 (876 files / 10547 tests), verify:phone-chrome escalated to full Chromium 521 passed", + "checks": "verify:cheap, verify:phone-chrome (full verify:ui), lint, typecheck" + }, { "date": "2026-08-26", "ref": "codex/medication-risk-highlights", @@ -24611,8 +24619,8 @@ } ], "counts": { - "records": 2618, - "refs": 1597 + "records": 2619, + "refs": 1598 } } } diff --git a/docs/branch-review-records/e9e97c01e7bcec0aa0ee1416a488f8890818ce71281b5d5841a814a017eca245.record.md b/docs/branch-review-records/e9e97c01e7bcec0aa0ee1416a488f8890818ce71281b5d5841a814a017eca245.record.md new file mode 100644 index 0000000000..331ec71a2c --- /dev/null +++ b/docs/branch-review-records/e9e97c01e7bcec0aa0ee1416a488f8890818ce71281b5d5841a814a017eca245.record.md @@ -0,0 +1 @@ +| 2026-08-26 | claude/therapy-compare-tray | 9b34a0149759b2c2f1e8ed5d37f02ba1ac35cf39 | therapy compare tray: phone dock addon, add-in-place, stacked comparison, device memory | built and verified; verify:cheap exit 0 (876 files / 10547 tests), verify:phone-chrome escalated to full Chromium 521 passed | verify:cheap, verify:phone-chrome (full verify:ui), lint, typecheck | diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index 78ff4a48f2..ccb12bfa62 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -974,7 +974,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `AnswerFooter` | answer | yes | yes | inherited-global-root | yes | no | 1 | | `AsyncButton` | controls | yes | yes | inherited-global-root | yes | no | 4 | | `Breadcrumb` | layout | yes | yes | inherited-global-root | yes | no | 1 | -| `Button` | controls | yes | yes | inherited-global-root | yes | no | 14 | +| `Button` | controls | yes | yes | inherited-global-root | yes | no | 16 | | `Checkbox` | controls | yes | yes | no | yes | no | 0 | | `Chip` | controls | yes | yes | inherited-global-root | yes | no | 5 | | `ChoiceChip` | controls | yes | yes | inherited-global-root | yes | no | 4 | @@ -1010,7 +1010,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `SearchField` | controls | yes | yes | no | yes | no | 0 | | `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 8 | | `Select` | controls | yes | yes | inherited-global-root | yes | no | 2 | -| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 29 | +| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 30 | | `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 6 | | `SourceDesignationBadge` | source | yes | yes | inherited-global-root | yes | no | 4 | | `SourceProvenance` | source | yes | yes | inherited-global-root | yes | no | 1 | diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index cca0213fdd..c4a91631fa 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -281,6 +281,7 @@ "src/components/AccessibleTable.tsx", "src/components/clinical-dashboard/signed-image.tsx", "src/components/privacy-quiet-signal-page.tsx", + "src/components/therapy-compass/record/compare-action.tsx", "src/components/therapy-compass/screens/brief-screen.tsx", "src/components/therapy-compass/screens/compare-screen.tsx", "src/components/therapy-compass/screens/other-screen.tsx", @@ -288,6 +289,7 @@ "src/components/therapy-compass/screens/recommend-screen.tsx", "src/components/therapy-compass/screens/search-screen.tsx", "src/components/therapy-compass/therapy-card.tsx", + "src/components/therapy-compass/therapy-compare-tray.tsx", "src/components/therapy-compass/workspace.tsx", "src/components/tools/tools-search-results-page.tsx", "src/components/ui/confirm-dialog.tsx", @@ -297,6 +299,7 @@ "src/components/AccessibleTable.tsx", "src/components/clinical-dashboard/signed-image.tsx", "src/components/privacy-quiet-signal-page.tsx", + "src/components/therapy-compass/record/compare-action.tsx", "src/components/therapy-compass/screens/brief-screen.tsx", "src/components/therapy-compass/screens/compare-screen.tsx", "src/components/therapy-compass/screens/other-screen.tsx", @@ -304,6 +307,7 @@ "src/components/therapy-compass/screens/recommend-screen.tsx", "src/components/therapy-compass/screens/search-screen.tsx", "src/components/therapy-compass/therapy-card.tsx", + "src/components/therapy-compass/therapy-compare-tray.tsx", "src/components/therapy-compass/workspace.tsx", "src/components/tools/tools-search-results-page.tsx", "src/components/ui/confirm-dialog.tsx", @@ -1660,6 +1664,7 @@ "src/components/in-page-nav/in-page-nav-header.tsx", "src/components/mode-nav/mode-nav.tsx", "src/components/therapy-compass/record/key-facts.tsx", + "src/components/therapy-compass/therapy-compare-tray.tsx", "src/components/tools/tools-search-results-page.tsx", "src/components/ui/confirm-dialog.tsx", "src/components/ward-management/ward-management-navigation.tsx" @@ -1692,6 +1697,7 @@ "src/components/in-page-nav/in-page-nav-header.tsx", "src/components/mode-nav/mode-nav.tsx", "src/components/therapy-compass/record/key-facts.tsx", + "src/components/therapy-compass/therapy-compare-tray.tsx", "src/components/tools/tools-search-results-page.tsx", "src/components/ui/confirm-dialog.tsx" ], diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index ed460c667c..ab0f5b3cef 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -764,12 +764,13 @@ floating element: it portals into a slot rendered _inside_ the dock's `
` z-index, safe-area padding and scroll-hide transform. There is no bottom-offset arithmetic and no second scroll listener anywhere in an addon. -Two claimants exist, and they are mutually exclusive by surface: +Three claimants exist, and they are mutually exclusive by surface: -| Addon kind | Slot id | Claimed by | -| ----------------------- | ----------------------------------------- | ----------------------------------------------------------- | -| `differentials-compare` | `differentials-mobile-compare-addon-slot` | Differentials submitted search / `/differentials/diagnoses` | -| `patient-details` | `patient-details-addon-slot` | Prescribing submitted search (dashboard-owned) | +| Addon kind | Slot id | Claimed by | +| ----------------------- | ----------------------------------------- | ----------------------------------------------------------------------------- | +| `differentials-compare` | `differentials-mobile-compare-addon-slot` | Differentials submitted search / `/differentials/diagnoses` | +| `patient-details` | `patient-details-addon-slot` | Prescribing submitted search (dashboard-owned) | +| `therapy-compare` | `therapy-compare-addon-slot` | Therapy Compass dock routes, **and only while the URL carries a compare set** | Rules: @@ -788,13 +789,27 @@ Rules: opens a blank band at the bottom. `/medications` is a standalone mode home with the composer in the hero and no dock at all; `/medications/[slug]` already opens the patient sheet from its own nav header, so neither claims the addon. +- **An addon that can be empty must gate its claim on being non-empty.** The + therapy compare tray renders nothing until something is in the comparison, so + claiming the slot on every therapy route would reserve a tray-sized band under + a row that is not there. The shell therefore reads the set out of the URL + (`readTherapyCompareSlugCount`) and claims only when it is non-empty — the + claim and the render have to agree, in both directions. +- **Keep an addon exactly one row tall.** The clearance is a static token, so a + dock that grows covers page content by exactly its own growth. Anything that + needs more room opens a bottom `Sheet` instead of a taller bar; that is why + the compare tray's expanded state is a sheet and the Patient details panel is + one too. - **Gate the portal at 639px**, matching `.phone-footer-layer`'s `sm:fixed`. The two Compare bars gate at 1023px, which between 640–1023px portals into a slot on a form that is not fixed. Do not copy that. Coverage: `tests/phone-dock-addon-contract.test.ts` (registry, exclusivity, CSS/TS -value parity), `tests/patient-details-dock-action.dom.test.tsx` (portal target, -breakpoint, sheet wiring). +value parity, therapy route/claim gating), `tests/patient-details-dock-action.dom.test.tsx` +(portal target, breakpoint, sheet wiring), `tests/therapy-compare-tray.dom.test.tsx` +(portal target, breakpoint, empty-set silence, sheet wiring), +`tests/ui-therapy-nav-scroll.spec.ts` (the tray hides with the composer and +releases its reserve to `0rem`). ## Motion & Animation Preferences (#S4K1GA) diff --git a/src/app/globals.css b/src/app/globals.css index 9ef6a51e89..fdb6e82d0f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -584,10 +584,20 @@ clears roughly 3.5rem less than the Compare bar. */ --phone-dock-patient-details-clearance: 9rem; --phone-dock-patient-details-compact-clearance: 8.75rem; + /* Therapy compare tray: one row + 0.5rem gap, same geometry as Patient + details. The tray is required to stay exactly one row tall (its expanded + state is a sheet), which is what lets a single static number stay correct. */ + --phone-dock-therapy-compare-clearance: 9rem; + --phone-dock-therapy-compare-compact-clearance: 8.75rem; --patient-details-fab-gap: 0.5rem; --patient-details-fab-padding: 0.3125rem 0.5rem 0.3125rem 1rem; --patient-details-fab-count-size: 1.625rem; --patient-details-fab-count-padding: 0.375rem; + --therapy-compare-tray-gap: 0.5rem; + --therapy-compare-tray-pip-gap: 0.125rem; + --therapy-compare-tray-pip-size: 1.5rem; + --therapy-compare-tray-padding: 0.25rem 0.5rem; + --therapy-compare-tray-summary-inset: 0.25rem; /* Radius tokens are the single source of truth in @theme above (they also generate the rounded-* utilities); do not redefine them here or var() and the utilities drift apart. */ @@ -1459,6 +1469,101 @@ summary::-webkit-details-marker { font-weight: 800; } +/* Therapy Compass compare tray: the row above the search pill that says what is + in the comparison. Same in-flow trick as the two rows above, so scroll-hide + carries it with the composer. + + It must stay ONE row tall at every state — the dock's content clearance is a + static token, so a dock that grows covers page content by exactly its growth. + The expanded state is a bottom sheet for that reason, not a taller bar. + + Interaction states are NOT redeclared here: the summary row carries + `interactiveRowBase` and the actions are the shared `Button`, so hover, focus + and disabled behaviour come from the design system, per COMPONENTS.md 9.1. */ +.therapy-compare-tray { + position: relative; + z-index: var(--z-raised, 10); + display: flex; + width: 100%; + align-items: center; + gap: var(--therapy-compare-tray-gap); + margin-bottom: var(--therapy-compare-tray-gap); + border-radius: var(--radius-lg); + border: 1px solid var(--border-strong); + background: color-mix(in srgb, var(--surface) 94%, transparent); + padding: var(--therapy-compare-tray-padding); + box-shadow: var(--e3); +} + +.therapy-compare-tray__summary { + min-width: 0; + flex: 1 1 auto; + gap: var(--therapy-compare-tray-gap); + border-radius: var(--radius-md); + padding-inline: var(--therapy-compare-tray-summary-inset); +} + +.therapy-compare-tray__pips { + display: flex; + flex-shrink: 0; + gap: var(--therapy-compare-tray-pip-gap); +} + +.therapy-compare-tray__pip { + display: grid; + height: var(--therapy-compare-tray-pip-size); + width: var(--therapy-compare-tray-pip-size); + place-items: center; + border: 1px dashed var(--border-strong); + border-radius: var(--radius-pill, 9999px); + color: var(--text-muted); + font-size: 0.6875rem; + font-weight: 900; + transition: transform var(--duration-moderate) var(--ease-out-soft); +} + +.therapy-compare-tray__pip[data-filled="true"] { + border-color: transparent; + background: var(--clinical-accent); + color: var(--clinical-accent-contrast); +} + +/* The arriving slot pulses once. Derived from the set, so it fires whichever + control added the therapy. */ +.therapy-compare-tray__pip[data-landed="true"] { + transform: scale(1.25); +} + +@media (prefers-reduced-motion: reduce) { + .therapy-compare-tray__pip { + transition: none; + } + + .therapy-compare-tray__pip[data-landed="true"] { + transform: none; + } +} + +.therapy-compare-tray__names { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-heading); + font-size: 0.8125rem; + font-weight: 800; +} + +.therapy-compare-tray__count { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-muted); + font-size: 0.6875rem; + font-weight: 700; +} + /* Bottom-docked composer: hint row + upward dropdown grow above the pill. */ .answer-footer-search-edge { overflow: visible; @@ -2794,6 +2899,17 @@ summary::-webkit-details-marker { height: max(var(--phone-dock-patient-details-compact-clearance), calc(var(--safe-area-bottom) + 7.5rem)); } + /* Therapy Compass: the compare tray sits above the search pill in the dock. + Heights track --phone-dock-therapy-compare-* (same as content reserve). */ + .answer-footer-search-dock[data-footer-addon="therapy-compare"] .answer-footer-search-backdrop { + height: max(var(--phone-dock-therapy-compare-clearance), calc(var(--safe-area-bottom) + 7.75rem)); + } + + .answer-footer-search-dock.document-mobile-search-compact[data-footer-addon="therapy-compare"] + .answer-footer-search-backdrop { + height: max(var(--phone-dock-therapy-compare-compact-clearance), calc(var(--safe-area-bottom) + 7.5rem)); + } + .document-mobile-search-pill { min-height: 3.6rem; border-color: var(--border-strong); @@ -2884,6 +3000,13 @@ summary::-webkit-details-marker { transform: translateY(calc(100% + 0.5rem + var(--safe-area-bottom))); } + /* Therapy compare tray: shell dock (.document-mobile-search-edge), one row, + so the same 0.5rem overshoot as Patient details. Without it the tray's top + border can leave a subpixel peep at the viewport edge while hidden. */ + .answer-footer-search-dock.document-mobile-search-edge.answer-footer-search-edge[data-footer-addon="therapy-compare"][data-scroll-hidden="true"] { + transform: translateY(calc(100% + 0.5rem + var(--safe-area-bottom))); + } + /* Document viewer owns its floating composer (not the shared dock). Lift it with the same --keyboard-height signal so overlay/visual keyboards do not cover the focused input on document detail/source routes. */ @@ -4840,12 +4963,6 @@ html.theme-transitioning *:after { } @media (max-width: 640px) { - [data-therapy-scroll-sm] { - overflow-x: auto !important; - overscroll-behavior-inline: contain; - -webkit-overflow-scrolling: touch; - scrollbar-width: thin; - } .therapy-pathway-list { border-right: 0 !important; border-bottom: 1px solid var(--border); diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 25ebe75b25..7a92dc9425 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -105,10 +105,12 @@ import { DesktopComposerPortalSlot } from "@/components/desktop-composer-portal- import { desktopPageComposerSlotId, differentialsMobileCompareAddonSlotId, + therapyCompareAddonSlotId, modeHomeComposerReservePendingValue, modeHomeDesktopComposerSlotId, } from "@/lib/mode-home-composer"; import { readSearchNavigationContext, type SearchNavigationOptions } from "@/lib/search-navigation-context"; +import { isTherapyPhoneDockRoute, readTherapyCompareSlugCount } from "@/lib/therapy-compass-navigation"; import { isAlwaysStandaloneShellPath, isDashboardOwnedModeHomePath, @@ -448,6 +450,14 @@ function GlobalStandaloneSearchShellBody({ // `/differentials` is absent on purpose: it redirects to the shared home, so a // branch naming it can never be true and would only read as live ownership. (pathname === "/differentials/diagnoses" || pathname === "/differentials/search"); + // The therapy compare tray docks above the phone search pill. The claim is + // gated on the URL actually carrying a set, not merely on being on a therapy + // route: the dock reserve inflates on CLAIM, so claiming with an empty tray + // would open a blank band under a row that renders nothing. + const therapyCompareAddonActive = + searchMode === "therapy-compass" && + isTherapyPhoneDockRoute(pathname) && + readTherapyCompareSlugCount(searchParams) > 0; const clinicalAskMode = isClinicalAskModeId(searchMode) ? searchMode : null; // No shell-owned route claims the Patient details dock addon. `/medications` // is a standalone mode home (composer in the hero, no dock to portal into), @@ -509,6 +519,7 @@ function GlobalStandaloneSearchShellBody({ heroOwnsPhoneComposer, searchMode, differentialsCompareAddonActive, + therapyCompareAddonActive, }), ); @@ -935,9 +946,19 @@ function GlobalStandaloneSearchShellBody({ // maximum screen space (mode homes and result views alike). mobileBottomSearchVariant="compact" mobileBottomSearchAddonSlotId={ - differentialsCompareAddonActive ? differentialsMobileCompareAddonSlotId : undefined + differentialsCompareAddonActive + ? differentialsMobileCompareAddonSlotId + : therapyCompareAddonActive + ? therapyCompareAddonSlotId + : undefined + } + mobileBottomSearchAddonKind={ + differentialsCompareAddonActive + ? "differentials-compare" + : therapyCompareAddonActive + ? "therapy-compare" + : undefined } - mobileBottomSearchAddonKind={differentialsCompareAddonActive ? "differentials-compare" : undefined} desktopSearchPlacement={desktopSearchPlacement === "hero" && isStandaloneModeHome ? "hero" : "default"} showPhoneSuggestionTickerOnHome={isStandaloneModeHome || (pathname === "/" && !hasSubmittedModeSearch)} searchComposerVisible={shouldShowSearchComposer} diff --git a/src/components/clinical-dashboard/mobile-composer-reserve.ts b/src/components/clinical-dashboard/mobile-composer-reserve.ts index 8c1bb6aa2e..04185227a5 100644 --- a/src/components/clinical-dashboard/mobile-composer-reserve.ts +++ b/src/components/clinical-dashboard/mobile-composer-reserve.ts @@ -34,6 +34,18 @@ export const mobileComposerDifferentialsCompareReserve = */ export const mobileComposerPatientDetailsReserve = "calc(9rem + var(--safe-area-bottom) + var(--keyboard-height, 0px))"; +/** + * Therapy Compass: compare tray + compact search pill. + * + * Same geometry as the Patient details pill — one ~3rem row plus the 0.5rem gap + * above the composer — because the tray is required to stay exactly one row + * tall. Its expanded state is a sheet, not a taller dock, precisely so this + * single static number can keep being correct. Keep the rem figure equal to + * --phone-dock-therapy-compare-clearance in globals.css; + * tests/mobile-composer-reserve.test.ts pins the pair. + */ +export const mobileComposerTherapyCompareReserve = "calc(9rem + var(--safe-area-bottom) + var(--keyboard-height, 0px))"; + // Every phone dock is the compact single-row pill (mode homes and result views // alike); only the answer dock with a follow-up chip row is taller. The answer // values are derived from the dock constants so the pairs cannot silently @@ -49,6 +61,7 @@ export const mobileComposerVisibleReserve = { dashboardDock: dashboardCompactSingleRowReserve, differentialsCompare: mobileComposerDifferentialsCompareReserve, patientDetails: mobileComposerPatientDetailsReserve, + therapyCompare: mobileComposerTherapyCompareReserve, } as const; export function resolveMobileComposerReserve(bottomComposerHidden: boolean, visibleReserve: string): string { @@ -85,6 +98,7 @@ export function resolveDashboardVisibleMobileComposerReserve(input: { hasAnswerFollowUps: boolean; differentialsCompareAddonActive: boolean; patientDetailsAddonActive?: boolean; + therapyCompareAddonActive?: boolean; /** Hero owns the phone composer (no fixed bottom dock) — match shell idle pad. */ heroOwnsPhoneComposer?: boolean; }): string { @@ -105,6 +119,9 @@ export function resolveDashboardVisibleMobileComposerReserve(input: { if (input.patientDetailsAddonActive) { return mobileComposerVisibleReserve.patientDetails; } + if (input.therapyCompareAddonActive) { + return mobileComposerVisibleReserve.therapyCompare; + } return mobileComposerVisibleReserve.dashboardDock; } @@ -118,6 +135,7 @@ export function resolveShellVisibleMobileComposerReserve(input: { searchMode: string; differentialsCompareAddonActive: boolean; patientDetailsAddonActive?: boolean; + therapyCompareAddonActive?: boolean; }): string { if (!input.shouldShowSearchComposer) { // Page-owned composers (DocumentViewer) manage their own dock @@ -133,5 +151,6 @@ export function resolveShellVisibleMobileComposerReserve(input: { if (input.searchMode === "answer") return mobileComposerVisibleReserve.shellAnswer; if (input.differentialsCompareAddonActive) return mobileComposerVisibleReserve.differentialsCompare; if (input.patientDetailsAddonActive) return mobileComposerVisibleReserve.patientDetails; + if (input.therapyCompareAddonActive) return mobileComposerVisibleReserve.therapyCompare; return mobileComposerVisibleReserve.shellDock; } diff --git a/src/components/therapy-compass/bindings.tsx b/src/components/therapy-compass/bindings.tsx index 51eee12e22..0ee981060e 100644 --- a/src/components/therapy-compass/bindings.tsx +++ b/src/components/therapy-compass/bindings.tsx @@ -1,6 +1,15 @@ "use client"; -import { createContext, useContext, useEffect, useMemo, useState, useDeferredValue, type ReactNode } from "react"; +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, + useDeferredValue, + type ReactNode, +} from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { @@ -15,6 +24,7 @@ import { type TherapySheetSection, type TherapyWorkspaceState, } from "@/lib/therapy-compass-navigation"; +import { readTherapyCompareMemory, writeTherapyCompareMemory } from "@/lib/therapy-compare-memory"; import { useTherapyData } from "./data/use-therapy-data"; import { THERAPY_CATALOGUE_SUMMARY } from "./data/generated-assets"; @@ -90,7 +100,6 @@ export type TcBindings = { // ---- compare -------------------------------------------------------- compareSlugs: string[]; compareTherapies: Therapy[]; - toggleCompare: (slug: string) => void; // add/remove + navigate addCompare: (slug: string) => void; replaceCompareSlugs: (slugs: readonly string[]) => void; removeCompare: (slug: string) => void; @@ -485,15 +494,6 @@ export function TcProvider({ children }: { children: ReactNode }) { compareSlugs, compareTherapies, - toggleCompare: (slug) => { - const next = compareSlugs.includes(slug) - ? compareSlugs.filter((value) => value !== slug) - : compareSlugs.length >= THERAPY_MAX_COMPARE - ? compareSlugs - : [...compareSlugs, slug]; - setCompareSlugs(next); - pushWorkspace(therapyScreenHref("compare"), { compareSlugs: next }); - }, addCompare: (slug) => { const next = compareSlugs.includes(slug) || compareSlugs.length >= THERAPY_MAX_COMPARE @@ -678,6 +678,40 @@ export function TcProvider({ children }: { children: ReactNode }) { sheetClinician, ]); + // ---- Device memory for the compare set ------------------------------- + // + // The URL stays the source of truth. Memory only fills the gap when you + // arrive with no `ids` at all, so an interrupted comparison is not lost. Both + // effects run after mount and never during render, so server HTML, the first + // client render and hydration stay identical whatever is in storage. + // + // "Decided" is a ref, not state, and that ordering is load-bearing: effects in + // one commit run in declaration order, so the restore below always resolves + // before the mirror reads it, without a second render to carry a flag. + const compareRestoreDecided = useRef(false); + + useEffect(() => { + if (compareRestoreDecided.current) return; + // A shared link always wins, and can be decided without the catalogue. + if (workspaceFromUrl.compareSlugs.length > 0) { + compareRestoreDecided.current = true; + return; + } + // Restoring needs the catalogue: a slug from an older data generation must + // be dropped rather than resurrected as a therapy that no longer exists. + if (therapies.length === 0) return; + compareRestoreDecided.current = true; + const remembered = readTherapyCompareMemory().filter((slug) => bySlug.has(slug)); + if (remembered.length > 0) value.replaceCompareSlugs(remembered); + }, [workspaceFromUrl.compareSlugs.length, therapies.length, bySlug, value]); + + useEffect(() => { + // Writing before the restore has decided would clobber the remembered set + // with the empty one this tab started from. + if (!compareRestoreDecided.current) return; + writeTherapyCompareMemory(compareSlugs); + }, [compareSlugs]); + return {children}; } diff --git a/src/components/therapy-compass/record/compare-action.tsx b/src/components/therapy-compass/record/compare-action.tsx new file mode 100644 index 0000000000..5fcb1cbbff --- /dev/null +++ b/src/components/therapy-compass/record/compare-action.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useId, useState } from "react"; +import { Scale } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { ignoreUnavailableActivation } from "@/components/ui-primitives"; +import { THERAPY_MAX_COMPARE } from "@/lib/therapy-compass-navigation"; + +import { useTcBindings } from "../bindings"; +import type { Therapy } from "../data/types"; + +/** + * "Add to compare" on a therapy record. + * + * This lives in the page body rather than in the chrome, and that is deliberate. + * The compare tray docks above the phone search pill, and record pages have no + * pill — claiming a dock slot there would open a blank band at the bottom of the + * page (`docs/search-chrome-behaviour.md`, "reserve inflates on claim"). So the + * record keeps the design's headline move — add it while you are reading it — + * as ordinary page content, and introduces no second bottom owner. + * + * The status line is not decoration. On these routes the tray is not on screen, + * so without it an add is completely silent: the reader taps, nothing visible + * changes, and the only feedback is a URL they are not looking at. + */ +export function TherapyCompareAction({ therapy }: { therapy: Therapy }) { + const b = useTcBindings(); + const fullNoteId = useId(); + const [notice, setNotice] = useState(null); + + const inCompare = b.isInCompare(therapy.slug); + const count = b.compareSlugs.length; + const full = !inCompare && count >= THERAPY_MAX_COMPARE; + + function announce(next: number, verb: string) { + setNotice(`${verb} — ${next} of ${THERAPY_MAX_COMPARE} selected to compare.`); + } + + return ( +
+
+ + {count >= 2 ? ( + + ) : null} +
+ {full ? ( + + The comparison already holds {THERAPY_MAX_COMPARE} therapies. Remove one before adding another. + + ) : null} +

+ {notice} +

+
+ ); +} diff --git a/src/components/therapy-compass/screens/brief-screen.tsx b/src/components/therapy-compass/screens/brief-screen.tsx index a2e870178e..04d5e3cc0b 100644 --- a/src/components/therapy-compass/screens/brief-screen.tsx +++ b/src/components/therapy-compass/screens/brief-screen.tsx @@ -17,6 +17,7 @@ import { InteractiveRow } from "@/components/ui/interactive-row"; import { parseSteps, summarise } from "../data/select"; import { LoadingState } from "../ui"; import { useClipboard } from "../use-clipboard"; +import { TherapyCompareAction } from "../record/compare-action"; import { TherapySaveNotice } from "../record/save-notice"; import { useTherapyFavourite } from "../use-therapy-favourite"; import { TherapyRecordNavHeader } from "../therapy-record-nav-header"; @@ -110,6 +111,10 @@ export function BriefScreen() { } /> +
+ +
+ - {items.length} of 4 selected + {items.length} of {THERAPY_MAX_COMPARE} selected } actions={ @@ -208,13 +208,13 @@ export function CompareScreen() { { id: "all", label: "All fields" }, ]} > - {/* table */} + {/* Phones read the same rows stacked; see TherapyCompareStack below. */}
@@ -283,6 +283,7 @@ export function CompareScreen() {
Therapy comparison by clinical field
+
+ b.open(slug)} /> diff --git a/src/components/therapy-compass/screens/sheets-screen.tsx b/src/components/therapy-compass/screens/sheets-screen.tsx index 458b111be5..82de055b06 100644 --- a/src/components/therapy-compass/screens/sheets-screen.tsx +++ b/src/components/therapy-compass/screens/sheets-screen.tsx @@ -17,6 +17,7 @@ import { parseSteps, searchTherapies } from "../data/select"; import { LoadingState } from "../ui"; import { InteractiveRow, interactiveRowBase } from "@/components/ui/interactive-row"; import { TherapyRecordNavHeader } from "../therapy-record-nav-header"; +import { TherapyCompareAction } from "../record/compare-action"; import { TherapySaveNotice } from "../record/save-notice"; import { useTherapyFavourite } from "../use-therapy-favourite"; @@ -63,6 +64,10 @@ export function SheetsScreen() { /> +
+ +
+
{/* BUILDER */}
diff --git a/src/components/therapy-compass/therapy-card.tsx b/src/components/therapy-compass/therapy-card.tsx index e53b2b4628..ddc228d695 100644 --- a/src/components/therapy-compass/therapy-card.tsx +++ b/src/components/therapy-compass/therapy-card.tsx @@ -17,6 +17,7 @@ import { import { cardSurface } from "@/components/card-recipes"; import { Button } from "@/components/ui/button"; import { cn, ignoreUnavailableActivation } from "@/components/ui-primitives"; +import { THERAPY_MAX_COMPARE } from "@/lib/therapy-compass-navigation"; import { useTcBindings } from "./bindings"; import { cardPreviewText, prioritiseTherapyTags, summarise } from "./data/select"; @@ -51,6 +52,9 @@ export function ResultCard({ const sheetUnavailableId = useId(); const { notice, saved, toggleFavourite } = useTherapyFavourite(therapy.slug); const inCompare = b.isInCompare(therapy.slug); + // A full tray keeps its tab stop and states why, per the wiring convention. + const compareFull = !inCompare && b.compareSlugs.length >= THERAPY_MAX_COMPARE; + const compareFullId = `${sheetUnavailableId}-compare-full`; const rankingQuery = query ?? b.search.query; const subtitle = cardPreviewText(therapy.clinicalSummary, { exclude: therapy.name }) || @@ -181,13 +185,33 @@ export function ResultCard({ block className={cn(cardActionButton, controlPressed)} icon={Scale} - onClick={() => b.toggleCompare(therapy.slug)} + // Adding no longer navigates: the tray above the composer is where the + // set is now assembled, so this control fills it and leaves the reader + // exactly where they were. The label moved with the behaviour — the + // old "Compare" promised a destination it no longer goes to. + onClick={ + compareFull + ? ignoreUnavailableActivation + : inCompare + ? () => b.removeCompare(therapy.slug) + : () => b.addCompare(therapy.slug) + } aria-pressed={inCompare} - aria-label={inCompare ? "In compare" : "Compare"} + aria-disabled={compareFull ? true : undefined} + aria-describedby={compareFull ? compareFullId : undefined} + title={compareFull ? `Compare holds ${THERAPY_MAX_COMPARE} therapies — remove one first` : undefined} + aria-label={inCompare ? "In compare tray" : compareFull ? "Compare tray full" : "Add to compare"} > - {inCompare ? "In compare" : "Compare"} - {inCompare ? "Added" : "Compare"} + + {inCompare ? "In compare tray" : compareFull ? "Tray full" : "Add to compare"} + + {inCompare ? "In tray" : compareFull ? "Full" : "Add"} + {compareFull ? ( + + The comparison already holds {THERAPY_MAX_COMPARE} therapies. Remove one before adding another. + + ) : null} + +
, + host, + )} + setSheetOpen(false)} + title="Compare tray" + description={`${count} of ${THERAPY_MAX_COMPARE} therapies selected.`} + mobilePlacement="bottom" + testId="therapy-compare-tray-sheet" + > +
    + {items.map((therapy, index) => ( +
  • + + + {therapy.name} + + +
  • + ))} +
+ {count === 1 ? ( +

+ Add one more to compare. Up to {THERAPY_MAX_COMPARE}. +

+ ) : null} + +
+ + ); +} + +/** + * Which slot just gained a therapy, or null. + * + * Derived from the set itself rather than reported by whatever added it, so the + * pip animates whichever control was used — a result card, a record button, a + * restored set — without any of them needing to know the tray exists. + */ +function useArrivalSlot(slugs: readonly string[]): number | null { + const previous = useRef(slugs); + const [landed, setLanded] = useState(null); + + useEffect(() => { + const grew = slugs.length > previous.current.length; + previous.current = slugs; + if (!grew) return; + setLanded(slugs.length - 1); + const timer = window.setTimeout(() => setLanded(null), 400); + return () => window.clearTimeout(timer); + }, [slugs]); + + return landed; +} + +/** + * The tray is one line wide, so it shows the record's own abbreviation where it + * has a short one ("CBT", "EMDR") and the full name otherwise. Truncation is + * left to CSS — cutting a therapy name mid-word in JS reads as a data error. + */ +function shortTherapyName(therapy: Therapy | undefined): string { + if (!therapy) return ""; + const alias = therapy.aliases.map((value) => value.trim()).find((value) => value.length > 0 && value.length <= 10); + return alias ?? therapy.name; +} diff --git a/src/components/therapy-compass/therapy-record-nav-header.tsx b/src/components/therapy-compass/therapy-record-nav-header.tsx index cfec0c0565..8ca050a732 100644 --- a/src/components/therapy-compass/therapy-record-nav-header.tsx +++ b/src/components/therapy-compass/therapy-record-nav-header.tsx @@ -102,10 +102,11 @@ export function TherapyRecordNavHeader({ if (destination === "overview") b.open(therapy.slug); else if (destination === "sheet") b.openSheet(therapy.slug); else if (destination === "brief") b.openBrief(therapy.slug); - // `toggleCompare` adds the therapy and navigates; once it is already in the - // set, toggling would remove it, so an established member just navigates. - else if (inCompare) b.goCompare(); - else b.toggleCompare(therapy.slug); + // Pure navigation, both branches. This slot used to add the therapy when it + // was not in the set and navigate when it was — one control with two + // different meanings depending on state the reader could not see. Adding + // now belongs to the record's own "Add to compare" button. + else b.goCompare(); } return ( diff --git a/src/components/therapy-compass/workspace.tsx b/src/components/therapy-compass/workspace.tsx index 9ec1b6e3c7..5714ed803d 100644 --- a/src/components/therapy-compass/workspace.tsx +++ b/src/components/therapy-compass/workspace.tsx @@ -11,6 +11,7 @@ import { cn, pageContainer } from "@/components/ui-primitives"; import { isInformationPage } from "@/lib/information-pages"; import { TcProvider, useTcBindings } from "./bindings"; +import { TherapyCompareTray } from "./therapy-compare-tray"; function TherapyCompassFooter() { return ( @@ -105,6 +106,10 @@ export function TherapyCompassWorkspace({ children }: { children: ReactNode }) { )}
+ {/* Mounted once for the whole mode. The tray decides for itself whether a + dock slot exists to portal into, so record routes (which have no phone + composer) simply get nothing. */} + ); } diff --git a/src/lib/mode-home-composer.ts b/src/lib/mode-home-composer.ts index f1080023b6..8dadaec034 100644 --- a/src/lib/mode-home-composer.ts +++ b/src/lib/mode-home-composer.ts @@ -33,20 +33,25 @@ export const differentialsMobileCompareAddonSlotId = "differentials-mobile-compa /** Phone search-composer slot for the medication surfaces' Patient details pill. */ export const patientDetailsAddonSlotId = "patient-details-addon-slot"; +/** Phone search-composer slot for the Therapy Compass compare tray. */ +export const therapyCompareAddonSlotId = "therapy-compare-addon-slot"; + /** * Which page-owned action currently occupies the phone dock's addon slot. * * The dock carries exactly ONE addon at a time — `data-footer-addon` is a single * attribute value, and the scrim height, hide transform, and content reserve are * all keyed off it. The two claimants are mutually exclusive by surface - * (differentials vs medications/prescribing) and `tests/phone-dock-addon-contract.test.ts` - * pins that, so the slot never has to hold two. + * (differentials vs medications/prescribing vs therapy) and + * `tests/phone-dock-addon-contract.test.ts` pins that, so the slot never has to + * hold two. */ -export type PhoneDockAddonKind = "differentials-compare" | "patient-details"; +export type PhoneDockAddonKind = "differentials-compare" | "patient-details" | "therapy-compare"; export const phoneDockAddonSlotId: Record = { "differentials-compare": differentialsMobileCompareAddonSlotId, "patient-details": patientDetailsAddonSlotId, + "therapy-compare": therapyCompareAddonSlotId, }; /** diff --git a/src/lib/therapy-compare-memory.ts b/src/lib/therapy-compare-memory.ts new file mode 100644 index 0000000000..5df64af403 --- /dev/null +++ b/src/lib/therapy-compare-memory.ts @@ -0,0 +1,90 @@ +import { THERAPY_MAX_COMPARE } from "@/lib/therapy-compass-navigation"; + +/** + * Device memory for the Therapy compare set. + * + * The URL (`?ids=`) remains the source of truth — it is what a shared link + * carries and what `readTherapyWorkspaceState` validates. This module is a + * strictly additive convenience: when you arrive at Therapy with no `ids` in + * the URL, the set you last had is restored so an interrupted comparison is not + * lost. A URL that carries `ids` always wins, so a shared link can never be + * overwritten by whatever happens to be on the reader's device. + * + * Two deliberate non-goals: + * + * - **No cross-tab sync.** `search-pins` and `use-sidebar-pins` subscribe to + * the `storage` event because many components read them live. A compare set + * is bound to the address bar, so a remote write would rewrite *this* tab's + * URL underneath the reader mid-comparison. One consumer, no subscription. + * - **No catalogue validation here.** Storage is synchronous and the 205-record + * catalogue is fetched, so the caller drops unknown slugs at restore time + * when it actually has the records. This module owns shape only. + */ + +export const therapyCompareMemoryStorageKey = "clinical-kb-therapy-compare-v1"; + +// Browser storage can be unavailable (private mode, blocked site data) or full. +// The set is a convenience, so degrade to this tab's session rather than throw. +let inMemorySlugs: string[] | null = null; + +/** + * Shape-normalize a stored value: strings only, trimmed, no blanks, no + * duplicates, capped at the same ceiling the URL parser applies. Mirrors the + * rule `uniqueNonEmpty` applies in `therapy-compass-navigation.ts` so a value + * cannot survive here that the URL would reject. + */ +export function normalizeTherapyCompareSlugs(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const normalized: string[] = []; + for (const candidate of value) { + if (typeof candidate !== "string") continue; + const trimmed = candidate.trim(); + if (!trimmed || normalized.includes(trimmed)) continue; + normalized.push(trimmed); + if (normalized.length >= THERAPY_MAX_COMPARE) break; + } + return normalized; +} + +export function readTherapyCompareMemory(storage?: Pick): string[] { + let raw: string | null; + try { + const target = storage ?? (typeof window === "undefined" ? null : window.localStorage); + if (!target) return inMemorySlugs ? [...inMemorySlugs] : []; + raw = target.getItem(therapyCompareMemoryStorageKey); + } catch { + return inMemorySlugs ? [...inMemorySlugs] : []; + } + + if (!raw) return []; + try { + return normalizeTherapyCompareSlugs(JSON.parse(raw)); + } catch { + return []; + } +} + +/** + * Persist the set. An empty array is written, not skipped: an empty tray is a + * real preference, and skipping it would mean "Empty" is undone by a reload. + */ +export function writeTherapyCompareMemory(slugs: readonly string[], storage?: Pick): string[] { + const normalized = normalizeTherapyCompareSlugs([...slugs]); + try { + const target = storage ?? (typeof window === "undefined" ? null : window.localStorage); + if (!target) { + inMemorySlugs = [...normalized]; + return normalized; + } + target.setItem(therapyCompareMemoryStorageKey, JSON.stringify(normalized)); + inMemorySlugs = null; + } catch { + inMemorySlugs = [...normalized]; + } + return normalized; +} + +/** Test-only: drop tab session memory so cases do not leak sets into each other. */ +export function resetTherapyCompareMemoryForTests() { + inMemorySlugs = null; +} diff --git a/src/lib/therapy-compass-navigation.ts b/src/lib/therapy-compass-navigation.ts index ecd92544d9..2be1d81022 100644 --- a/src/lib/therapy-compass-navigation.ts +++ b/src/lib/therapy-compass-navigation.ts @@ -109,6 +109,40 @@ export function resolveTherapyRoute(pathname: string): { screen: string; slug: s return { screen, slug: safeDecodePathSegment(first) }; } +/** + * Therapy routes that render the shared phone bottom dock, and therefore have a + * slot the compare tray can dock into. + * + * `/therapy-compass/recommend` is absent because it hides the shell composer + * (it owns an in-flow clinical-situation composer instead), and the record + * routes are absent because `isInformationPage` hides the composer there. The + * reserve inflates when a route CLAIMS the addon slot, not when the tray + * renders, so claiming a route with no dock opens a blank band at the bottom of + * the page — this list is what stops that. + */ +export const THERAPY_PHONE_DOCK_ROUTES = [ + THERAPY_COMPASS_BASE, + `${THERAPY_COMPASS_BASE}/search`, + `${THERAPY_COMPASS_BASE}/compare`, + `${THERAPY_COMPASS_BASE}/pathways`, + `${THERAPY_COMPASS_BASE}/review`, +] as const; + +export function isTherapyPhoneDockRoute(pathname: string): boolean { + return (THERAPY_PHONE_DOCK_ROUTES as readonly string[]).includes(pathname); +} + +/** + * How many therapies the URL currently selects for comparison. + * + * The shell needs this — and only this — to decide whether the compare tray has + * anything to dock, without pulling the whole workspace parser onto every + * render of every route. + */ +export function readTherapyCompareSlugCount(params: Pick): number { + return uniqueNonEmpty((params.get("ids") ?? "").split(","), THERAPY_MAX_COMPARE).length; +} + export function therapyScreenHref(screen: string): string { return screen === "home" ? THERAPY_COMPASS_BASE : `${THERAPY_COMPASS_BASE}/${screen}`; } diff --git a/tests/helpers/style-contracts.ts b/tests/helpers/style-contracts.ts index 7338ce7acb..187ab6886d 100644 --- a/tests/helpers/style-contracts.ts +++ b/tests/helpers/style-contracts.ts @@ -405,6 +405,20 @@ export const STYLE_CONTRACT_EXEMPTIONS: Readonly> = { "patient-details-fab__button--active": "patient details dock pill, populated state — no effect contract yet (#094)", "patient-details-fab__count": "patient details count badge — no effect contract yet (#094)", "medication-mobile-result": "prescribing phone results — no effect contract yet (#094)", + + // Therapy compare tray. Phone-only (it portals into the dock's addon slot + // below 639px), so it is not reachable at the desktop viewport + // `ui-style-contract.spec.ts` drives — the same reason the Patient details + // pill above is exempt rather than contracted. What is covered: + // `tests/therapy-compare-tray.dom.test.tsx` pins the portal target, + // breakpoint, empty-set silence and sheet wiring, and + // `tests/ui-therapy-nav-scroll.spec.ts` proves in Chromium that the row hides + // with the composer and releases its reserve. None of that is a computed-style + // assertion, so these stay exemptions, not contracts. + "therapy-compare-tray": "therapy compare tray row — phone-only; no effect contract yet (#094)", + "therapy-compare-tray__pip": "therapy compare tray slot pip — phone-only; no effect contract yet (#094)", + "therapy-compare-tray__names": "therapy compare tray name line — phone-only; no effect contract yet (#094)", + "therapy-compare-tray__count": "therapy compare tray count line — phone-only; no effect contract yet (#094)", "search-band-count": "count weight/colour; the zero-result state needs a deterministic empty fixture first", "search-band-rule": "gradient divider — forced-colors fallback covered by ui-accessibility", diff --git a/tests/phone-dock-addon-contract.test.ts b/tests/phone-dock-addon-contract.test.ts index f776c0f43b..0adab75f3a 100644 --- a/tests/phone-dock-addon-contract.test.ts +++ b/tests/phone-dock-addon-contract.test.ts @@ -17,6 +17,7 @@ import { phoneDockAddonSlotId, differentialsMobileCompareAddonSlotId, patientDetailsAddonSlotId, + therapyCompareAddonSlotId, type PhoneDockAddonKind, } from "@/lib/mode-home-composer"; import { @@ -24,6 +25,11 @@ import { resolveDashboardVisibleMobileComposerReserve, resolveShellVisibleMobileComposerReserve, } from "@/components/clinical-dashboard/mobile-composer-reserve"; +import { + isTherapyPhoneDockRoute, + readTherapyCompareSlugCount, + THERAPY_MAX_COMPARE, +} from "@/lib/therapy-compass-navigation"; function read(relativePath: string): string { return readFileSync(path.resolve(process.cwd(), relativePath), "utf8"); @@ -34,12 +40,12 @@ const dashboard = read("src/components/ClinicalDashboard.tsx"); const shell = read("src/components/clinical-dashboard/global-search-shell.tsx"); const header = read("src/components/clinical-dashboard/master-search-header.tsx"); -const ADDON_KINDS: PhoneDockAddonKind[] = ["differentials-compare", "patient-details"]; +const ADDON_KINDS: PhoneDockAddonKind[] = ["differentials-compare", "patient-details", "therapy-compare"]; describe("phone dock addon registry", () => { it("maps every addon kind to a distinct slot id", () => { const ids = ADDON_KINDS.map((kind) => phoneDockAddonSlotId[kind]); - expect(ids).toEqual([differentialsMobileCompareAddonSlotId, patientDetailsAddonSlotId]); + expect(ids).toEqual([differentialsMobileCompareAddonSlotId, patientDetailsAddonSlotId, therapyCompareAddonSlotId]); expect(new Set(ids).size).toBe(ids.length); }); @@ -56,8 +62,9 @@ describe("addon kinds are mutually exclusive", () => { expect(dashboard).toMatch(/patientDetailsAddonActive\s*=\s*\n?\s*searchMode === "prescribing"/); }); - it("leaves the shell with a single claimant", () => { + it("keys the shell's two claimants on different modes", () => { expect(shell).toMatch(/differentialsCompareAddonActive\s*=\s*\n?\s*searchMode === "differentials"/); + expect(shell).toMatch(/therapyCompareAddonActive\s*=\s*\n?\s*searchMode === "therapy-compass"/); // No shell route claims the patient-details addon: `/medications` is a // standalone mode home with no dock, and `/medications/[slug]` already opens // the same sheet from its nav header. Claiming it here would inflate the @@ -91,6 +98,35 @@ describe("every addon kind carries its dock wiring", () => { }); }); +describe("the therapy tray only claims a dock that exists and has something in it", () => { + it("restricts the claim to therapy routes that render the phone dock", () => { + // `/therapy-compass/recommend` hides the shell composer and every record + // route is an information page, so neither has a slot to portal into. + // Claiming one would inflate the reserve for a row that never mounts. + expect(isTherapyPhoneDockRoute("/therapy-compass")).toBe(true); + expect(isTherapyPhoneDockRoute("/therapy-compass/search")).toBe(true); + expect(isTherapyPhoneDockRoute("/therapy-compass/compare")).toBe(true); + expect(isTherapyPhoneDockRoute("/therapy-compass/pathways")).toBe(true); + expect(isTherapyPhoneDockRoute("/therapy-compass/review")).toBe(true); + expect(isTherapyPhoneDockRoute("/therapy-compass/recommend")).toBe(false); + expect(isTherapyPhoneDockRoute("/therapy-compass/cognitive-behavioural-therapy-cbt")).toBe(false); + expect(isTherapyPhoneDockRoute("/therapy-compass/cognitive-behavioural-therapy-cbt/brief")).toBe(false); + }); + + it("requires the URL to carry a compare set before claiming the slot", () => { + // The reserve inflates on claim, not on render. An empty tray renders + // nothing, so a claim without `ids` is a blank band by construction. + expect(shell).toMatch(/readTherapyCompareSlugCount\(searchParams\) > 0/); + expect(readTherapyCompareSlugCount(new URLSearchParams("q=trauma&run=1"))).toBe(0); + expect(readTherapyCompareSlugCount(new URLSearchParams("ids=cbt,act"))).toBe(2); + expect(readTherapyCompareSlugCount(new URLSearchParams("ids=,,"))).toBe(0); + }); + + it("caps the counted set at the comparison ceiling", () => { + expect(readTherapyCompareSlugCount(new URLSearchParams("ids=a,b,c,d,e,f"))).toBe(THERAPY_MAX_COMPARE); + }); +}); + describe("reserve resolvers honour the patient-details addon", () => { it("returns the patient-details reserve on the dashboard", () => { expect( @@ -127,6 +163,29 @@ describe("reserve resolvers honour the patient-details addon", () => { ).toBe(mobileComposerVisibleReserve.differentialsCompare); }); + it("returns the therapy-compare reserve in the shell", () => { + expect( + resolveShellVisibleMobileComposerReserve({ + shouldShowSearchComposer: true, + heroOwnsPhoneComposer: false, + searchMode: "therapy-compass", + differentialsCompareAddonActive: false, + therapyCompareAddonActive: true, + }), + ).toBe(mobileComposerVisibleReserve.therapyCompare); + }); + + it("returns the therapy-compare reserve on the dashboard", () => { + expect( + resolveDashboardVisibleMobileComposerReserve({ + searchMode: "therapy-compass", + hasAnswerFollowUps: false, + differentialsCompareAddonActive: false, + therapyCompareAddonActive: true, + }), + ).toBe(mobileComposerVisibleReserve.therapyCompare); + }); + it("keeps the standalone mode home on the idle pad — no dock, no addon reserve", () => { // `/medications` is a standalone mode home: the composer lives in the hero, // so reserving dock-sized space there would open a blank bottom band. @@ -146,6 +205,7 @@ describe("CSS and TS reserve values stay in step", () => { it.each([ ["differentials-compare", mobileComposerVisibleReserve.differentialsCompare], ["patient-details", mobileComposerVisibleReserve.patientDetails], + ["therapy-compare", mobileComposerVisibleReserve.therapyCompare], ] as const)("%s clearance token matches the TS constant", (kind, reserve) => { const token = new RegExp(`--phone-dock-${kind}-clearance:\\s*([\\d.]+)rem`).exec(globalsCss); expect(token, `missing --phone-dock-${kind}-clearance`).not.toBeNull(); diff --git a/tests/recommend-screen.dom.test.tsx b/tests/recommend-screen.dom.test.tsx index 8a724c944a..79759ac2aa 100644 --- a/tests/recommend-screen.dom.test.tsx +++ b/tests/recommend-screen.dom.test.tsx @@ -16,7 +16,9 @@ const bindings = vi.hoisted(() => ({ open: vi.fn(), openSheet: vi.fn(), isInCompare: () => false, - toggleCompare: vi.fn(), + compareSlugs: [], + addCompare: vi.fn(), + removeCompare: vi.fn(), search: { query: "", tags: [] as string[] }, recommendations: [] as Array<{ therapy: Therapy; score: number; reasons: string[] }>, })); diff --git a/tests/therapy-compare-add-in-place.dom.test.tsx b/tests/therapy-compare-add-in-place.dom.test.tsx new file mode 100644 index 0000000000..c45005fa9d --- /dev/null +++ b/tests/therapy-compare-add-in-place.dom.test.tsx @@ -0,0 +1,136 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { Therapy } from "@/components/therapy-compass/data/types"; +import { THERAPY_MAX_COMPARE } from "@/lib/therapy-compass-navigation"; + +const tc = vi.hoisted(() => ({ + compareSlugs: [] as string[], + addCompare: vi.fn(), + removeCompare: vi.fn(), + goCompare: vi.fn(), + open: vi.fn(), + openSheet: vi.fn(), +})); + +vi.mock("@/components/therapy-compass/bindings", () => ({ + useTcBindings: () => ({ + search: { query: "CBT", tags: [] }, + compareSlugs: tc.compareSlugs, + isInCompare: (slug: string) => tc.compareSlugs.includes(slug), + addCompare: tc.addCompare, + removeCompare: tc.removeCompare, + goCompare: tc.goCompare, + open: tc.open, + openSheet: tc.openSheet, + }), +})); + +vi.mock("@/components/account-data-provider", () => ({ + useAccountData: () => ({ isAuthenticated: false, isSaved: () => false, setFavourite: vi.fn() }), +})); + +import { TherapyCompareAction } from "@/components/therapy-compass/record/compare-action"; +import { ResultCard } from "@/components/therapy-compass/therapy-card"; + +const therapy = { + slug: "cognitive-behavioural-therapy", + name: "Cognitive behavioural therapy", + aliases: ["CBT"], + tags: ["CBT"], + category: "Behavioural", + reviewStatus: "needs_review", + briefInterventionAvailable: true, + patientSheetAvailable: true, +} as Therapy; + +beforeEach(() => { + tc.compareSlugs = []; +}); + +afterEach(() => { + tc.addCompare.mockReset(); + tc.removeCompare.mockReset(); + tc.goCompare.mockReset(); +}); + +describe("Adding a therapy to the comparison", () => { + describe("from a search-result card", () => { + it("adds in place and does not navigate to the comparison", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Add to compare" })); + + expect(tc.addCompare).toHaveBeenCalledWith(therapy.slug); + expect(tc.goCompare).not.toHaveBeenCalled(); + }); + + it("says the therapy is in the tray, and removes it on a second tap", async () => { + tc.compareSlugs = [therapy.slug]; + const user = userEvent.setup(); + render(); + + const control = screen.getByRole("button", { name: "In compare tray" }); + expect(control).toHaveAttribute("aria-pressed", "true"); + await user.click(control); + + expect(tc.removeCompare).toHaveBeenCalledWith(therapy.slug); + }); + + it("states why a full tray cannot take another, and keeps the control reachable", async () => { + tc.compareSlugs = ["a", "b", "c", "d"].slice(0, THERAPY_MAX_COMPARE); + const user = userEvent.setup(); + render(); + + const control = screen.getByRole("button", { name: "Compare tray full" }); + // `aria-disabled`, never native `disabled`: the reason has to stay reachable. + expect(control).toHaveAttribute("aria-disabled", "true"); + expect(control).not.toBeDisabled(); + expect(control).toHaveAttribute("title", expect.stringContaining("remove one first")); + + await user.click(control); + expect(tc.addCompare).not.toHaveBeenCalled(); + }); + }); + + describe("from a therapy record", () => { + it("announces the add, because no tray is on screen to show it", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Add to compare" })); + + expect(tc.addCompare).toHaveBeenCalledWith(therapy.slug); + expect(screen.getByRole("status")).toHaveTextContent( + `Added to compare — 1 of ${THERAPY_MAX_COMPARE} selected to compare.`, + ); + }); + + it("announces a removal too", async () => { + tc.compareSlugs = [therapy.slug, "other"]; + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "In compare tray" })); + + expect(tc.removeCompare).toHaveBeenCalledWith(therapy.slug); + expect(screen.getByRole("status")).toHaveTextContent( + `Removed from compare — 1 of ${THERAPY_MAX_COMPARE} selected to compare.`, + ); + }); + + it("offers the comparison only once there is something to compare", async () => { + const user = userEvent.setup(); + const { rerender } = render(); + expect(screen.queryByRole("button", { name: /^Compare \d/ })).toBeNull(); + + tc.compareSlugs = [therapy.slug, "other"]; + rerender(); + await user.click(screen.getByRole("button", { name: "Compare 2" })); + + expect(tc.goCompare).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/tests/therapy-compare-memory.dom.test.tsx b/tests/therapy-compare-memory.dom.test.tsx new file mode 100644 index 0000000000..f4025d29d3 --- /dev/null +++ b/tests/therapy-compare-memory.dom.test.tsx @@ -0,0 +1,166 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { TcProvider, useTcBindings } from "@/components/therapy-compass/bindings"; +import { + resetTherapyCompareMemoryForTests, + therapyCompareMemoryStorageKey, + writeTherapyCompareMemory, +} from "@/lib/therapy-compare-memory"; + +const navState = vi.hoisted(() => ({ + pathname: "/therapy-compass/search", + search: "q=trauma&run=1", + push: vi.fn(), + replace: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + usePathname: () => navState.pathname, + useSearchParams: () => new URLSearchParams(navState.search), + useRouter: () => ({ push: navState.push, replace: navState.replace, prefetch: () => {} }), +})); + +function therapy(slug: string) { + return { + slug, + name: slug.toUpperCase(), + category: "Standard Talking Therapies", + tags: [], + aliases: [], + warnings: [], + sources: [], + patientSheetTemplates: [], + clinicianScripts: [], + reviewChecklist: null, + reviewStatus: "needs_review", + patientSheetAvailable: false, + briefInterventionAvailable: false, + }; +} + +const CATALOGUE = [therapy("cbt"), therapy("act"), therapy("emdr")]; + +function stubCatalogue(therapies: unknown[] = CATALOGUE) { + vi.stubGlobal( + "fetch", + vi.fn((url: string) => { + const body = String(url).includes("pathways") || String(url).includes("reference") ? [] : therapies; + return Promise.resolve({ ok: true, status: 200, json: vi.fn().mockResolvedValue(body) }); + }), + ); +} + +function CompareProbe() { + const b = useTcBindings(); + return
{b.compareSlugs.join(",")}
; +} + +function renderProvider() { + return render( + + + , + ); +} + +/** The `ids` the provider asked the router to write, if any. */ +function replacedIds(): string | null { + for (const call of navState.replace.mock.calls) { + const href = String(call[0]); + const query = href.includes("?") ? href.slice(href.indexOf("?") + 1) : ""; + const ids = new URLSearchParams(query).get("ids"); + if (ids) return ids; + } + return null; +} + +beforeEach(() => { + window.localStorage.clear(); + resetTherapyCompareMemoryForTests(); + navState.pathname = "/therapy-compass/search"; + navState.search = "q=trauma&run=1"; +}); + +afterEach(() => { + vi.unstubAllGlobals(); + navState.push.mockReset(); + navState.replace.mockReset(); +}); + +describe("Therapy compare set — device memory", () => { + it("restores the remembered set when the URL carries no ids", async () => { + writeTherapyCompareMemory(["cbt", "act"]); + stubCatalogue(); + renderProvider(); + + await waitFor(() => expect(screen.getByTestId("set")).toHaveTextContent("cbt,act")); + expect(replacedIds()).toBe("cbt,act"); + }); + + it("lets a shared link win over whatever this device remembers", async () => { + writeTherapyCompareMemory(["cbt", "act"]); + navState.search = "q=trauma&run=1&ids=emdr"; + stubCatalogue(); + renderProvider(); + + await waitFor(() => expect(screen.getByTestId("set")).toHaveTextContent("emdr")); + // Nothing may rewrite `ids` away from what the link asked for. + expect(replacedIds() === null || replacedIds() === "emdr").toBe(true); + expect(screen.getByTestId("set")).not.toHaveTextContent("cbt"); + }); + + it("drops a remembered slug that is no longer in the catalogue", async () => { + writeTherapyCompareMemory(["cbt", "retired-therapy"]); + stubCatalogue(); + renderProvider(); + + await waitFor(() => expect(screen.getByTestId("set")).toHaveTextContent("cbt")); + expect(screen.getByTestId("set")).not.toHaveTextContent("retired-therapy"); + }); + + it("restores nothing when no remembered slug survives catalogue validation", async () => { + writeTherapyCompareMemory(["retired-one", "retired-two"]); + stubCatalogue(); + renderProvider(); + + await waitFor(() => expect(screen.getByTestId("set")).toBeInTheDocument()); + expect(screen.getByTestId("set")).toHaveTextContent(""); + expect(replacedIds()).toBeNull(); + }); + + it("does not restore before the catalogue has loaded", () => { + writeTherapyCompareMemory(["cbt", "act"]); + vi.stubGlobal( + "fetch", + vi.fn(() => new Promise(() => {})), + ); + renderProvider(); + + expect(screen.getByTestId("set")).toHaveTextContent(""); + expect(replacedIds()).toBeNull(); + }); + + it("mirrors the live set into storage once the restore has decided", async () => { + navState.search = "q=trauma&run=1&ids=emdr"; + stubCatalogue(); + renderProvider(); + + await waitFor(() => + expect(JSON.parse(window.localStorage.getItem(therapyCompareMemoryStorageKey) ?? "null")).toEqual(["emdr"]), + ); + }); + + it("never writes storage before the restore has decided", () => { + writeTherapyCompareMemory(["cbt", "act"]); + vi.stubGlobal( + "fetch", + vi.fn(() => new Promise(() => {})), + ); + renderProvider(); + + // The remembered set must survive a mount that never finished loading — + // writing the empty starting set here is what would silently destroy it. + expect(JSON.parse(window.localStorage.getItem(therapyCompareMemoryStorageKey) ?? "null")).toEqual(["cbt", "act"]); + }); +}); diff --git a/tests/therapy-compare-memory.test.ts b/tests/therapy-compare-memory.test.ts new file mode 100644 index 0000000000..e29074be7c --- /dev/null +++ b/tests/therapy-compare-memory.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { THERAPY_MAX_COMPARE } from "@/lib/therapy-compass-navigation"; +import { + normalizeTherapyCompareSlugs, + readTherapyCompareMemory, + resetTherapyCompareMemoryForTests, + therapyCompareMemoryStorageKey, + writeTherapyCompareMemory, +} from "@/lib/therapy-compare-memory"; + +function memoryStorage(initial: Record = {}) { + const store = new Map(Object.entries(initial)); + return { + store, + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value); + }, + }; +} + +function throwingStorage() { + return { + getItem: () => { + throw new Error("site data blocked"); + }, + setItem: () => { + throw new Error("quota exceeded"); + }, + }; +} + +afterEach(() => { + resetTherapyCompareMemoryForTests(); +}); + +describe("therapy compare memory", () => { + describe("normalization", () => { + it("keeps only trimmed, unique, non-empty strings", () => { + expect(normalizeTherapyCompareSlugs([" cbt ", "cbt", "", " ", "act"])).toEqual(["cbt", "act"]); + }); + + it("drops non-string entries rather than coercing them", () => { + expect(normalizeTherapyCompareSlugs(["cbt", 7, null, undefined, { slug: "act" }, ["act"]])).toEqual(["cbt"]); + }); + + it("caps at the same ceiling the URL parser applies", () => { + const slugs = ["a", "b", "c", "d", "e", "f"]; + expect(normalizeTherapyCompareSlugs(slugs)).toHaveLength(THERAPY_MAX_COMPARE); + expect(normalizeTherapyCompareSlugs(slugs)).toEqual(["a", "b", "c", "d"]); + }); + + it("returns an empty list for anything that is not an array", () => { + for (const value of [null, undefined, "cbt", 3, { 0: "cbt" }]) { + expect(normalizeTherapyCompareSlugs(value)).toEqual([]); + } + }); + }); + + describe("read and write", () => { + it("round-trips a set through injected storage", () => { + const storage = memoryStorage(); + expect(writeTherapyCompareMemory(["cbt", "act"], storage)).toEqual(["cbt", "act"]); + expect(readTherapyCompareMemory(storage)).toEqual(["cbt", "act"]); + }); + + it("normalizes on write, so a malformed set cannot be stored", () => { + const storage = memoryStorage(); + writeTherapyCompareMemory(["cbt", "cbt", " act ", "", "d", "e", "f"], storage); + expect(JSON.parse(storage.store.get(therapyCompareMemoryStorageKey) ?? "[]")).toEqual(["cbt", "act", "d", "e"]); + }); + + it("persists an empty set, so Empty survives a reload", () => { + const storage = memoryStorage(); + writeTherapyCompareMemory(["cbt", "act"], storage); + writeTherapyCompareMemory([], storage); + expect(storage.store.get(therapyCompareMemoryStorageKey)).toBe("[]"); + expect(readTherapyCompareMemory(storage)).toEqual([]); + }); + + it("returns an empty set when nothing has been stored", () => { + expect(readTherapyCompareMemory(memoryStorage())).toEqual([]); + }); + + it("returns an empty set rather than throwing on malformed JSON", () => { + const storage = memoryStorage({ [therapyCompareMemoryStorageKey]: "{not json" }); + expect(readTherapyCompareMemory(storage)).toEqual([]); + }); + + it("normalizes on read, so a hand-edited store cannot exceed the cap", () => { + const storage = memoryStorage({ + [therapyCompareMemoryStorageKey]: JSON.stringify(["a", "a", "b", "c", "d", "e"]), + }); + expect(readTherapyCompareMemory(storage)).toEqual(["a", "b", "c", "d"]); + }); + }); + + describe("when browser storage is unavailable", () => { + it("degrades to this session instead of throwing", () => { + const storage = throwingStorage(); + expect(() => writeTherapyCompareMemory(["cbt", "act"], storage)).not.toThrow(); + expect(readTherapyCompareMemory(storage)).toEqual(["cbt", "act"]); + }); + + it("does not leak the session fallback between tests", () => { + writeTherapyCompareMemory(["cbt"], throwingStorage()); + resetTherapyCompareMemoryForTests(); + expect(readTherapyCompareMemory(throwingStorage())).toEqual([]); + }); + }); +}); diff --git a/tests/therapy-compare-phone-layout.dom.test.tsx b/tests/therapy-compare-phone-layout.dom.test.tsx new file mode 100644 index 0000000000..a7db951d0a --- /dev/null +++ b/tests/therapy-compare-phone-layout.dom.test.tsx @@ -0,0 +1,147 @@ +// The comparison has two presentations of one `rows` memo: the wide table from +// `md` up, and a stacked per-field layout below it. jsdom applies no Tailwind, +// so BOTH render here — every query has to say which one it means. + +import { render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { TcProvider } from "@/components/therapy-compass/bindings"; +import { CompareScreen } from "@/components/therapy-compass/screens/compare-screen"; + +const navigation = vi.hoisted(() => ({ + pathname: "/therapy-compass/compare", + search: "ids=alpha,beta&comparison=all", +})); + +vi.mock("next/navigation", () => ({ + usePathname: () => navigation.pathname, + useSearchParams: () => new URLSearchParams(navigation.search), + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }), +})); + +vi.mock("@/components/account-data-provider", () => ({ + useAccountData: () => ({ isAuthenticated: false, isSaved: () => false, setFavourite: vi.fn(async () => true) }), +})); + +const therapy = vi.hoisted(() => (slug: string, name: string, caution: string) => ({ + slug, + name, + category: "Skills based", + clinicalSummary: `${name} summary`, + bestUsedFor: `${name} best fit`, + indications: "Anxiety", + contraindicationsOrCautions: caution, + deliverySteps: "1. Orient the patient", + patientExplanation: "A practical therapy skill.", + sourceNotes: null, + targetSymptoms: "Anxiety", + patientPopulation: "Adults", + setting: "Outpatient", + sessionLength: "50 minutes", + timeRequired: "5 minutes", + complexity: "Low", + mechanism: "Skills practice", + briefVersion: null, + fifteenMinuteVersion: null, + fullSessionVersion: null, + homework: null, + materials: null, + commonPitfalls: null, + alternatives: null, + relatedTherapies: null, + evidenceLevel: "Review source", + evidenceNotes: null, + limitations: null, + references: null, + reviewStatus: "reviewed", + confidenceLevel: null, + contentOrigin: null, + patientSheetAvailable: true, + briefInterventionAvailable: true, + sourceCompleteness: null, + indexCompleteness: null, + reviewCompleteness: null, + tags: ["Anxiety"], + warnings: [], + aliases: [], + sources: [], + patientSheetTemplates: [], + clinicianScripts: [], + reviewChecklist: null, +})); + +vi.mock("@/components/therapy-compass/data/use-therapy-data", () => ({ + useTherapyData: () => ({ + data: { + therapies: [ + therapy("alpha", "Alpha therapy", "Alpha caution."), + therapy("beta", "Beta therapy", "Beta caution."), + ], + pathways: [], + reference: { categories: [], tags: [], measures: [] }, + }, + loading: false, + error: null, + retry: vi.fn(), + }), +})); + +function renderCompare() { + return render( + + + , + ); +} + +afterEach(() => { + navigation.search = "ids=alpha,beta&comparison=all"; +}); + +describe("Therapy comparison on a phone", () => { + it("forks at md, so the 720px table never has to scroll sideways on a phone", () => { + renderCompare(); + + // The table is `min-w-[720px]`; at 640–767px it would still scroll, which is + // why the fork is `md` rather than `sm`. + expect(screen.getByTestId("therapy-compare-table").className).toContain("hidden"); + expect(screen.getByTestId("therapy-compare-table").className).toContain("md:block"); + expect(screen.getByTestId("therapy-compare-stack").className).toContain("md:hidden"); + }); + + it("keeps each field label with its values instead of scrolling them apart", () => { + renderCompare(); + const stack = within(screen.getByTestId("therapy-compare-stack")); + + // One card per field, and every selected therapy appears inside that card — + // the label cannot leave the values it labels. + const caution = stack.getByRole("region", { name: "When not to use" }); + expect(within(caution).getByText("Alpha therapy")).toBeInTheDocument(); + expect(within(caution).getByText("Beta therapy")).toBeInTheDocument(); + expect(within(caution).getByText("Alpha caution.")).toBeInTheDocument(); + expect(within(caution).getByText("Beta caution.")).toBeInTheDocument(); + + const fit = stack.getByRole("region", { name: "Best fit" }); + expect(within(fit).getByText("Alpha therapy best fit")).toBeInTheDocument(); + }); + + it("shows the phone layout the same rows the tab filter chose", () => { + navigation.search = "ids=alpha,beta&comparison=priorities"; + renderCompare(); + const stack = within(screen.getByTestId("therapy-compare-stack")); + + expect(stack.getByRole("region", { name: "When not to use" })).toBeInTheDocument(); + expect(stack.getByRole("region", { name: "Evidence level" })).toBeInTheDocument(); + // "Time required" is not a priority row, so neither presentation shows it. + expect(stack.queryByRole("region", { name: "Time required" })).toBeNull(); + expect(within(screen.getByTestId("therapy-compare-table")).queryByText("Time required")).toBeNull(); + }); + + it("draws no comparison at all — and no empty slots — below two therapies", () => { + navigation.search = "ids=alpha"; + renderCompare(); + + expect(screen.queryByTestId("therapy-compare-stack")).toBeNull(); + expect(screen.queryByTestId("therapy-compare-table")).toBeNull(); + }); +}); diff --git a/tests/therapy-compare-tray.dom.test.tsx b/tests/therapy-compare-tray.dom.test.tsx new file mode 100644 index 0000000000..834a56b409 --- /dev/null +++ b/tests/therapy-compare-tray.dom.test.tsx @@ -0,0 +1,165 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { installMatchMediaStub } from "./setup/jsdom.setup"; + +import type { Therapy } from "@/components/therapy-compass/data/types"; +import { therapyCompareAddonSlotId } from "@/lib/mode-home-composer"; +import { THERAPY_MAX_COMPARE } from "@/lib/therapy-compass-navigation"; + +const tc = vi.hoisted(() => ({ + compareSlugs: [] as string[], + compareTherapies: [] as Therapy[], + removeCompare: vi.fn(), + clearCompare: vi.fn(), + goCompare: vi.fn(), +})); + +vi.mock("@/components/therapy-compass/bindings", () => ({ + useTcBindings: () => ({ + compareSlugs: tc.compareSlugs, + compareTherapies: tc.compareTherapies, + removeCompare: tc.removeCompare, + clearCompare: tc.clearCompare, + goCompare: tc.goCompare, + }), +})); + +import { TherapyCompareTray } from "@/components/therapy-compass/therapy-compare-tray"; + +function therapy(slug: string, name: string, aliases: string[] = []): Therapy { + return { slug, name, aliases, tags: [], category: "Behavioural" } as unknown as Therapy; +} + +const CBT = therapy("cbt", "Cognitive behavioural therapy", ["CBT"]); +const ACT = therapy("act", "Acceptance and commitment therapy", ["ACT"]); + +function select(...items: Therapy[]) { + tc.compareTherapies = items; + tc.compareSlugs = items.map((item) => item.slug); +} + +/** The dock renders the addon slot inside its own form; stand one in for it. */ +function mountDockSlot() { + const slot = document.createElement("div"); + slot.id = therapyCompareAddonSlotId; + document.body.append(slot); + return slot; +} + +beforeEach(() => { + // The tray gates on the phone breakpoint the dock itself uses (639px). + installMatchMediaStub(true); + select(); +}); + +afterEach(() => { + document.getElementById(therapyCompareAddonSlotId)?.remove(); + tc.removeCompare.mockReset(); + tc.clearCompare.mockReset(); + tc.goCompare.mockReset(); +}); + +describe("Therapy compare tray", () => { + it("does not exist until something is in it", () => { + mountDockSlot(); + render(); + + expect(screen.queryByTestId("therapy-compare-tray")).toBeNull(); + }); + + it("renders nothing above the phone breakpoint, where there is no dock to sit in", () => { + installMatchMediaStub(false); + mountDockSlot(); + select(CBT, ACT); + render(); + + expect(screen.queryByTestId("therapy-compare-tray")).toBeNull(); + }); + + it("docks inside the composer's addon slot rather than positioning itself", async () => { + const slot = mountDockSlot(); + select(CBT); + render(); + + const tray = await screen.findByTestId("therapy-compare-tray"); + // Inheriting the dock's fixed position, z-index, safe-area and scroll-hide + // transform is the entire reason this is a portal and not new chrome. + expect(slot.contains(tray)).toBe(true); + }); + + it("holds Compare back until there are two, and says why", async () => { + mountDockSlot(); + select(CBT); + render(); + + const compare = await screen.findByTestId("therapy-compare-tray-compare"); + expect(compare).toHaveAttribute("aria-disabled", "true"); + expect(compare).not.toBeDisabled(); + expect(compare).toHaveAttribute("title", "Add one more therapy to compare"); + + await userEvent.click(compare); + expect(tc.goCompare).not.toHaveBeenCalled(); + }); + + it("opens the comparison once two are selected", async () => { + mountDockSlot(); + select(CBT, ACT); + render(); + + const compare = await screen.findByTestId("therapy-compare-tray-compare"); + expect(compare).not.toHaveAttribute("aria-disabled"); + await userEvent.click(compare); + + expect(tc.goCompare).toHaveBeenCalledTimes(1); + }); + + it("shows the record's abbreviation where it has one, and counts the set", async () => { + mountDockSlot(); + select(CBT, ACT); + render(); + + const tray = await screen.findByTestId("therapy-compare-tray"); + expect(tray).toHaveTextContent("CBT · ACT"); + expect(tray).toHaveTextContent(`2 of ${THERAPY_MAX_COMPARE} selected`); + expect(screen.getByTestId("therapy-compare-tray-open")).toHaveAttribute( + "aria-label", + `Compare tray, 2 of ${THERAPY_MAX_COMPARE} selected`, + ); + }); + + it("removes and empties from the sheet, not from the one-row bar", async () => { + mountDockSlot(); + select(CBT, ACT); + render(); + + await userEvent.click(await screen.findByTestId("therapy-compare-tray-open")); + await screen.findByTestId("therapy-compare-tray-sheet"); + + await userEvent.click(await screen.findByRole("button", { name: `Remove ${CBT.name} from the comparison` })); + expect(tc.removeCompare).toHaveBeenCalledWith(CBT.slug); + // Two were selected, so one removal leaves a tray — the sheet stays put. + expect(screen.getByTestId("therapy-compare-tray-sheet")).toBeInTheDocument(); + + // Emptying does not: the sheet belongs to a tray that is about to stop + // existing, so leaving it open would be a dialog over nothing. + await userEvent.click(await screen.findByRole("button", { name: "Empty the tray" })); + expect(tc.clearCompare).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.queryByTestId("therapy-compare-tray-sheet")).toBeNull()); + }); + + it("closes the sheet when the tray it belongs to stops existing", async () => { + mountDockSlot(); + select(CBT, ACT); + const { rerender } = render(); + + await userEvent.click(await screen.findByTestId("therapy-compare-tray-open")); + expect(await screen.findByTestId("therapy-compare-tray-sheet")).toBeInTheDocument(); + + select(); + rerender(); + + await waitFor(() => expect(screen.queryByTestId("therapy-compare-tray-sheet")).toBeNull()); + }); +}); diff --git a/tests/therapy-compass-compare-url.dom.test.tsx b/tests/therapy-compass-compare-url.dom.test.tsx index 451c876c57..f6897ea7ea 100644 --- a/tests/therapy-compass-compare-url.dom.test.tsx +++ b/tests/therapy-compass-compare-url.dom.test.tsx @@ -26,14 +26,18 @@ afterEach(() => { function CompareProbe() { const bindings = useTcBindings(); return ( - ); } describe("Therapy comparison URL identity", () => { - it("serializes the exact selected therapy slug while preserving search context", () => { + // Adding deliberately no longer navigates. The compare set is assembled from + // wherever the therapies are — a result list, a record — and the reader stays + // put; only the address bar changes, so the set is still shareable and still + // survives a reload. This test asserts that changed contract on purpose. + it("serializes the exact selected therapy slug without leaving the page", () => { vi.stubGlobal( "fetch", vi.fn(() => new Promise(() => {})), @@ -46,9 +50,10 @@ describe("Therapy comparison URL identity", () => { fireEvent.click(screen.getByRole("button", { name: "Compare exact therapy" })); - expect(navState.push).toHaveBeenCalledTimes(1); - const destination = new URL(String(navState.push.mock.calls[0]?.[0]), "http://localhost"); - expect(destination.pathname).toBe("/therapy-compass/compare"); + expect(navState.push).not.toHaveBeenCalled(); + const written = navState.replace.mock.calls.at(-1)?.[0]; + const destination = new URL(String(written), "http://localhost"); + expect(destination.pathname).toBe("/therapy-compass/search"); expect(destination.searchParams.get("q")).toBe("CBT"); expect(destination.searchParams.get("run")).toBe("1"); expect(destination.searchParams.get("ids")).toBe("cognitive-behavioural-therapy-cbt"); diff --git a/tests/therapy-compass-responsive-contract.test.ts b/tests/therapy-compass-responsive-contract.test.ts index 1e4fe906c1..069bf54793 100644 --- a/tests/therapy-compass-responsive-contract.test.ts +++ b/tests/therapy-compass-responsive-contract.test.ts @@ -132,10 +132,14 @@ describe("Therapy Compass responsive contract", () => { expect(workspaceSource).not.toContain("sm:px-10"); }); - it("keeps phone reflow and comparison scroll residuals in globals.css", () => { + it("keeps phone reflow residuals in globals.css", () => { expect(globalsSource).toMatch(/@media \(max-width: 640px\)/); - expect(globalsSource).toContain("overflow-x: auto !important;"); - expect(globalsSource).toContain("[data-therapy-scroll-sm]"); + expect(globalsSource).toContain(".therapy-pathway-list"); + // `[data-therapy-scroll-sm]` was the phone horizontal-scroll enabler for the + // comparison table. Phones no longer render that table at all — they get the + // stacked per-field layout below `md` — so the rule and the attribute were + // removed together rather than left as a rule nothing can match. + expect(globalsSource).not.toContain("[data-therapy-scroll-sm]"); }); it("marks every fixed screen/card grid for phone reflow without changing its desktop template", () => { @@ -167,7 +171,15 @@ describe("Therapy Compass responsive contract", () => { expect(responsiveStackCount(compareSource)).toBeGreaterThanOrEqual(1); expect(compareSource).toContain(" ({ isInCompare: () => false, open: vi.fn(), openSheet: vi.fn(), - toggleCompare: vi.fn(), + compareSlugs: [], + addCompare: vi.fn(), + removeCompare: vi.fn(), }), })); diff --git a/tests/therapy-favourite.dom.test.tsx b/tests/therapy-favourite.dom.test.tsx index 07ed719a67..043e0f04e6 100644 --- a/tests/therapy-favourite.dom.test.tsx +++ b/tests/therapy-favourite.dom.test.tsx @@ -23,7 +23,9 @@ vi.mock("@/components/therapy-compass/bindings", () => ({ isInCompare: () => false, open: vi.fn(), openSheet: vi.fn(), - toggleCompare: vi.fn(), + compareSlugs: [], + addCompare: vi.fn(), + removeCompare: vi.fn(), }), })); diff --git a/tests/therapy-record-nav.dom.test.tsx b/tests/therapy-record-nav.dom.test.tsx index 647d2a77c8..39c30c7a76 100644 --- a/tests/therapy-record-nav.dom.test.tsx +++ b/tests/therapy-record-nav.dom.test.tsx @@ -13,7 +13,6 @@ const bindings = vi.hoisted(() => ({ open: vi.fn(), openSheet: vi.fn(), openBrief: vi.fn(), - toggleCompare: vi.fn(), goCompare: vi.fn(), removeCompare: vi.fn(), inCompare: false, @@ -24,7 +23,6 @@ vi.mock("@/components/therapy-compass/bindings", () => ({ open: bindings.open, openSheet: bindings.openSheet, openBrief: bindings.openBrief, - toggleCompare: bindings.toggleCompare, goCompare: bindings.goCompare, removeCompare: bindings.removeCompare, isInCompare: () => bindings.inCompare, @@ -110,27 +108,29 @@ describe("therapy record header", () => { expect(bindings.openBrief).toHaveBeenCalledWith(therapy.slug); }); - it("adds the therapy to the comparison before opening it, but only once", async () => { + // Every other slot in this rail is a destination, and this one now is too. + // It used to add the therapy when it was absent from the set and navigate + // when it was present — the same tap doing two different things depending on + // state the reader could not see. Adding moved to the record's own button. + it("navigates to the comparison whether or not the therapy is in it", async () => { const user = userEvent.setup(); const { unmount } = renderHeader(); await user.click( within(screen.getByTestId("therapy-detail-section-rail")).getByRole("button", { name: /^Compare/ }), ); - expect(bindings.toggleCompare).toHaveBeenCalledWith(therapy.slug); - expect(bindings.goCompare).not.toHaveBeenCalled(); + expect(bindings.goCompare).toHaveBeenCalledTimes(1); + expect(bindings.removeCompare).not.toHaveBeenCalled(); unmount(); - // Already in the comparison: toggling again would remove it, so an - // established member just navigates. bindings.inCompare = true; - bindings.toggleCompare.mockReset(); + bindings.goCompare.mockReset(); renderHeader(); await user.click( within(screen.getByTestId("therapy-detail-section-rail")).getByRole("button", { name: /^Compare/ }), ); - expect(bindings.goCompare).toHaveBeenCalled(); - expect(bindings.toggleCompare).not.toHaveBeenCalled(); + expect(bindings.goCompare).toHaveBeenCalledTimes(1); + expect(bindings.removeCompare).not.toHaveBeenCalled(); }); it("does not re-navigate when the active destination is selected", async () => { diff --git a/tests/ui-route-coverage.spec.ts b/tests/ui-route-coverage.spec.ts index c119ecb01f..1109050049 100644 --- a/tests/ui-route-coverage.spec.ts +++ b/tests/ui-route-coverage.spec.ts @@ -387,7 +387,7 @@ test.describe("previously uncovered production routes", () => { await page.setViewportSize({ width: 390, height: 844 }); await page.emulateMedia({ reducedMotion: "reduce" }); const compare = card.locator("[data-therapy-result-actions] button").nth(1); - await expect(compare).toHaveAccessibleName("Compare"); + await expect(compare).toHaveAccessibleName("Add to compare"); await compare.focus(); const focusStyle = await compare.evaluate((element) => { const style = getComputedStyle(element); @@ -404,13 +404,18 @@ test.describe("previously uncovered production routes", () => { await expectNoHorizontalOverflow(page); await expect(card.locator("[data-therapy-result-actions] button")).toHaveCount(3); + // Adding deliberately keeps the reader where they are. The set moves into + // the URL (so it is still shareable and survives a reload) and into the tray + // above the composer; the page does not change. await compare.focus(); await page.keyboard.press("Space"); - await expect(page).toHaveURL(/\/therapy-compass\/compare(?:\?.*)?$/); - const comparisonUrl = new URL(page.url()); - expect(comparisonUrl.searchParams.get("q")).toBe("CBT"); - expect(comparisonUrl.searchParams.get("ids")).toBeTruthy(); - await expect(page.getByRole("heading", { name: "Therapy Comparison", level: 1 })).toBeVisible(); + await expect(compare).toHaveAccessibleName("In compare tray"); + await expect(page).toHaveURL(/\/therapy-compass\/search/); + const stayedPut = new URL(page.url()); + expect(stayedPut.searchParams.get("q")).toBe("CBT"); + expect(stayedPut.searchParams.get("ids")).toBeTruthy(); + await expect(page.getByTestId("therapy-compare-tray")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Therapy Comparison", level: 1 })).toHaveCount(0); }); // `/dsm` redirects onto the shared home, so the route this proves is the shared diff --git a/tests/ui-therapy-nav-scroll.spec.ts b/tests/ui-therapy-nav-scroll.spec.ts index aeb4609bc9..1838d8a87f 100644 --- a/tests/ui-therapy-nav-scroll.spec.ts +++ b/tests/ui-therapy-nav-scroll.spec.ts @@ -54,6 +54,22 @@ async function gotoTherapyCompare(page: Page) { await page.waitForTimeout(700); } +/** Two real therapy slugs, enough to make the tray offer a comparison. */ +const COMPARE_SLUGS = ["cognitive-behavioural-therapy-cbt", "acceptance-and-commitment-therapy-act"]; + +async function readTrayGeometry(page: Page) { + return page.evaluate(() => { + const tray = document.querySelector('[data-testid="therapy-compare-tray"]'); + const main = document.querySelector("#main-content"); + const rect = tray?.getBoundingClientRect(); + return { + trayTop: rect?.top ?? Number.POSITIVE_INFINITY, + trayBottom: rect?.bottom ?? Number.POSITIVE_INFINITY, + reserve: main ? getComputedStyle(main).getPropertyValue("--mobile-composer-reserve").trim() : "", + }; + }); +} + test.beforeEach(async ({ page }) => { await blockExternalRequests(page); await installTherapyFixtures(page); @@ -126,3 +142,59 @@ test("phone Therapy mode nav hides and returns with the universal header", async expect(box!.y).toBeGreaterThanOrEqual(0); expect(box!.height).toBeGreaterThan(8); }); + +/** + * The compare tray docks INSIDE the phone search dock's form, so it inherits the + * dock's fixed position and — the clause most likely to be broken by a later + * edit — its scroll-hide transform. If it ever became independently fixed + * chrome, it would stay pinned over the page after the composer scrolled away, + * and the content reserve would no longer describe what is on screen. + */ +test("phone compare tray hides with the composer and releases its reserve", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.setViewportSize(phoneViewport); + await page.goto(`/therapy-compass/search?q=CBT&run=1&ids=${COMPARE_SLUGS.join(",")}`, { + waitUntil: "domcontentloaded", + }); + await expect(page.locator("#main-content").first()).toBeVisible({ timeout: 15_000 }); + await page.addStyleTag({ + content: ":root{--safe-area-top:59px !important;--safe-area-bottom:34px !important;}", + }); + + const tray = page.getByTestId("therapy-compare-tray"); + const dock = page.locator(".answer-footer-search-dock"); + await expect(tray).toBeVisible({ timeout: 15_000 }); + + // Docked, not free-floating: the tray is a descendant of the composer's form. + await expect + .poll(async () => tray.evaluate((node) => Boolean(node.closest(".answer-footer-search-dock")))) + .toBe(true); + await expect(dock).toHaveAttribute("data-footer-addon", "therapy-compare"); + + const visible = await readTrayGeometry(page); + expect(visible.trayBottom).toBeLessThanOrEqual(phoneViewport.height + 1); + expect(visible.reserve, "a claimed dock reserves room for the tray").not.toBe("0rem"); + + await appendPrimaryScrollSpacer(page, { heightPx: 2400, testId: "therapy-tray-hide-scroll-spacer" }); + for (const offset of [40, 80, 120, 160, 220, 300]) { + await scrollPrimarySurface(page, offset); + } + + await expect(dock).toHaveAttribute("data-scroll-hidden", "true", { timeout: 5_000 }); + await expect + .poll(async () => (await readTrayGeometry(page)).trayTop, { timeout: 5_000 }) + .toBeGreaterThanOrEqual(phoneViewport.height - 1); + // Hidden means zero reserve — never the safe-area inset, which on iOS Safari + // recreates a toolbar-sized blank band under the content. + await expect.poll(async () => (await readTrayGeometry(page)).reserve, { timeout: 5_000 }).toBe("0rem"); + + for (const offset of [220, 140, 60, 0]) { + await scrollPrimarySurface(page, offset); + } + + await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true", { timeout: 5_000 }); + await expect(tray).toBeVisible(); + const returned = await readTrayGeometry(page); + expect(returned.trayBottom).toBeLessThanOrEqual(phoneViewport.height + 1); + expect(returned.reserve).not.toBe("0rem"); +});