From 8e7c9d65463ab37bc6e6cd658ec8091b4260db18 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:59:13 +0800 Subject: [PATCH 01/12] feat(calculators): add first-class mode --- ...0660337191337c4097bc1831ccd34960.record.md | 1 + docs/search-chrome-behaviour.md | 6 +- docs/site-map.md | 1 + scripts/generate-site-map.ts | 1 + src/app/(search-app)/calculators/page.tsx | 39 +- .../calculators/calculator-filters.ts | 94 +++ .../calculators/calculator-fixtures.ts | 10 + src/components/calculators/home-page.tsx | 58 ++ src/components/calculators/index.ts | 1 + src/components/calculators/search-page.tsx | 752 ++++++------------ .../clinical-dashboard/ClinicalSidebar.tsx | 1 + .../global-search-shell.tsx | 4 +- .../master-search-header.tsx | 4 +- .../mobile-composer-reserve.ts | 9 +- .../universal-search-command-surface.tsx | 3 +- src/lib/app-mode-icons.ts | 2 + src/lib/app-modes.ts | 27 + src/lib/mode-secondary-navigation.ts | 3 +- src/lib/search-command-surface.ts | 17 + src/lib/search-route-ownership.ts | 2 + src/lib/search-shell-props.ts | 3 +- src/lib/ui-copy.ts | 3 + src/lib/universal-search-mode-context.ts | 3 + tests/app-modes.test.ts | 20 + tests/calculators-mode.dom.test.tsx | 240 ++++++ tests/header-scroll-hide-contract.test.ts | 12 +- tests/helpers/phone-scroll.ts | 11 +- tests/mobile-chrome-paint-contract.test.ts | 14 +- tests/mobile-composer-reserve.test.ts | 9 +- tests/mode-secondary-navigation.test.ts | 11 +- tests/search-command-surface.test.ts | 7 + tests/search-route-ownership.test.ts | 2 + tests/search-shell-props.test.ts | 6 +- tests/ui-overlay-css-contract.test.ts | 2 +- tests/ui-phone-scroll-page-owned.spec.ts | 394 +-------- tests/ui-universal-search.spec.ts | 15 + 36 files changed, 883 insertions(+), 904 deletions(-) create mode 100644 docs/branch-review-records/7b17944b9be99b8dfecf5a608e68fcee0660337191337c4097bc1831ccd34960.record.md create mode 100644 src/components/calculators/calculator-filters.ts create mode 100644 src/components/calculators/home-page.tsx create mode 100644 tests/calculators-mode.dom.test.tsx diff --git a/docs/branch-review-records/7b17944b9be99b8dfecf5a608e68fcee0660337191337c4097bc1831ccd34960.record.md b/docs/branch-review-records/7b17944b9be99b8dfecf5a608e68fcee0660337191337c4097bc1831ccd34960.record.md new file mode 100644 index 000000000..05625cdf9 --- /dev/null +++ b/docs/branch-review-records/7b17944b9be99b8dfecf5a608e68fcee0660337191337c4097bc1831ccd34960.record.md @@ -0,0 +1 @@ +| 2026-08-14 | codex/calculators-mode | 563ce4195512b9e623df6ba98762f4a3dc1b9e8e | calculators first-class mode | P1: calculator composer invokes universal-search API despite local-only boundary | source diff review; local verification evidence inspected; verify:pr-local dry-run; provider checks not run | diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index b96f9094a..5c78024f0 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -12,7 +12,7 @@ This repo uses one shared search experience across the global shell, dashboard r | Document detail/source routes | `DocumentViewer` floating composer | `DocumentViewer` content padding | | Document section navigation | Header row disclosure (phone sheet) + rail index card at `lg` | None — adds no chrome and no reserve | | Record page breadcrumb header | Same header row without the disclosure or track; view mode inline from `sm` | None — portals into the phone collapse row, sticky at `sm+` | -| Calculators (`/calculators`) | Page-owned composer (desktop top + phone bottom dock) | Calculators page pad; shell reserve stays `0` | +| Calculators (`/calculators`) | In-flow hero composer at home; shared compact dock after submission | Page content at home; shell reserve for submitted results | | Info/detail pages with no composer | No fixed composer | Idle shell padding only | ## Default in-page navigation template @@ -320,7 +320,7 @@ in-page navigation work defaults to the DocumentViewer template above. 6. Header and footer chrome that share the same scroll signal should hide/reveal symmetrically for the surfaces that actually hide. **Collapse motion (tablet and desktop only):** when the top bar is hidden, `chrome-safe-area-top` and the controls both release to `0rem` so underlying content paints to the physical viewport edge. **Overlay motion (every phone route on both hosts, no exception):** the stack translates instead; `chrome-safe-area-top` stays inside the translated layer at a stable height, and the content-facing `--phone-overlay-chrome-h` clearance is constant across hide/reveal — zeroing it on hide would reintroduce the layout shift overlay exists to remove. The visible phone header still owns `var(--safe-area-top)`; tablet/desktop top-bar chrome keeps its pinned inset. While visible that spacer is the top of the header, so it paints `var(--surface)` — the bar's own opaque phone colour — never `var(--background)`: the page colour there reads as a status-bar band above the bar, the seam overlay-strategy answer mode never shows because its header pads the inset itself. Keep it opaque so the sm+ pinned inset still hides scrolled content. Top-bar hide/reveal is cross-breakpoint; the search field belongs to page flow and scrolls away naturally on tablets and desktops; the bottom search dock is phone-only. Hidden bottom dock reserve stays `0rem` (invariant 4). Read "Scroll hide/reveal" below before changing either. 7. Do not add page-local dock-sized `pb-[calc(...safe-area...)]` under a shell-owned dock. Put clearance in the shared reserve or the page-owned composer, never both. 8. `GlobalSearchShell` uses an inner `mobile-composer-reserve-pad` so phone padding contributes to scroll height; do not move phone shell clearance back to scrollport padding without a browser proof. -9. Page-owned fixed phone composers follow the same release contract: calculators use the shared footer backdrop; DocumentViewer keeps its floating pill but synchronizes transform, opacity, pointer release, and its own zero-reserve content padding. In-flow hero composers remain free of fixed-footer glass. +9. Page-owned fixed phone composers follow the same release contract: DocumentViewer keeps its floating pill but synchronizes transform, opacity, pointer release, and its own zero-reserve content padding. In-flow hero composers remain free of fixed-footer glass; Calculators uses the shared shell dock after submission. 10. Keep collapse-budget policy geometry-aware: an in-flow collapsing phone header needs enough remaining runway to absorb controls + released top safe-area + dock clearance, while a fixed overlay that only releases bottom reserve may hide when its post-collapse range retains the top reveal band plus deliberate hide intent _and_ the current offset already fits that post-collapse range (no material near-bottom clamp). Do not use synthetic page padding to make the stricter gate pass. 11. Detect reserve-transition clamps from geometry, not a wider pixel tolerance: if the scroll range shrinks and the previous offset no longer fits inside the new maximum, rebase that frame as layout feedback. Once the range stabilizes, the same upward movement must reveal normally. 12. Standalone mode-home detection (`isStandaloneModeHomePath`) is pathname-only. Do not gate hero vs dock on a React `searchMode` that can update before the router pathname lands — that one-frame mismatch animates reserve padding and reads as a choppy screen resize. @@ -576,7 +576,7 @@ Rules that keep this working: - **Rebase the reporter on geometry switches.** Pass `resetKey` when the host changes the scrollport under it (`ClinicalDashboard` passes `searchMode`, which swaps `
`'s header reserve); otherwise the carried-over offset spends the first post-switch scroll on a spurious hide or reveal. Shared mode-home shells should also reset on `pathname` so collapsed chrome/scroll offset does not carry across modes. - **Do not carry composer focus into submitted result views.** Focus pins both chrome edges for keyboard safety. `GlobalSearchShell` must not pass `focus: true` with `run: true`, must gate `queryInputAutoFocus` on `!hasSubmittedModeSearch`, and both hosts must blur the dock input when the active result owner scrolls so hide-on-scroll can reclaim the header and bottom dock. -Coverage: `tests/header-scroll-hide-contract.test.ts` (wiring), `tests/use-hide-on-scroll.test.ts` (decision logic), `tests/ui-chrome-scroll.spec.ts` (tablet/desktop page-flow search plus top-bar hide/reveal), `tests/ui-phone-scroll.spec.ts` (shared shell header hide/reveal, per-mode top-edge release, collapse owner), `tests/ui-phone-scroll-routes.spec.ts` (per-route phone scroll sweep), `tests/ui-phone-scroll-page-owned.spec.ts` (document-viewer composer, standalone frame-owned footers, calculators dock, Services canvas) — the three share `tests/helpers/phone-scroll.ts`, and `tests/playwright-project-isolation.test.ts` asserts every sibling is collected by the required browser projects, `tests/ui-therapy-nav-scroll.spec.ts` (Therapy section nav hide/reveal with the top bar). +Coverage: `tests/header-scroll-hide-contract.test.ts` (wiring), `tests/use-hide-on-scroll.test.ts` (decision logic), `tests/ui-chrome-scroll.spec.ts` (tablet/desktop page-flow search plus top-bar hide/reveal), `tests/ui-phone-scroll.spec.ts` (shared shell header hide/reveal, per-mode top-edge release, collapse owner), `tests/ui-phone-scroll-routes.spec.ts` (per-route phone scroll sweep, including submitted calculator results), `tests/ui-phone-scroll-page-owned.spec.ts` (document-viewer composer, standalone frame-owned footers, Services canvas) — the three share `tests/helpers/phone-scroll.ts`, and `tests/playwright-project-isolation.test.ts` asserts every sibling is collected by the required browser projects, `tests/ui-therapy-nav-scroll.spec.ts` (Therapy section nav hide/reveal with the top bar). Run `npm run verify:phone-chrome` for phone-chrome work. For executable changes its classifier checks installed/lock parity first, runs focused static contracts and only the browser/PWA owners and route journeys implicated by the changed files, then escalates to `npm run verify:ui` automatically for shared chrome foundations. Documentation-only scopes run only documentation guards. Use `-- --dry-run` to inspect the plan, `-- --files ` for an explicit scope, and `-- --full=always|never` only for a deliberate override. diff --git a/docs/site-map.md b/docs/site-map.md index 55ea7e389..60502f3a7 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -59,6 +59,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/formulation` - Formulation mode. Search kind: `formulation`. Query example: `/formulation?q=I+keep+going+over+it&focus=1&run=1`. - `/medications` - Medication mode. Search kind: `documents`. Query example: `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1`. - `/tools` - Tools mode. Search kind: `tools`. Query example: `/tools?q=medications&focus=1&run=1`. +- `/calculators` - Calculators mode. Search kind: `calculators`. Query example: `/calculators?q=PHQ-9&focus=1&run=1`. - `/therapy-compass` - Therapy mode. Search kind: `tools`. Query example: `/therapy-compass?q=behavioural+activation&focus=1&run=1`. - `/factsheets` - Factsheets mode. Search kind: `tools`. Query example: `/factsheets/search?q=sertraline&focus=1&run=1`. diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index 3ed43b080..7e2a7acbe 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -277,6 +277,7 @@ function renderModeRoutes() { formulation: appModeHomeHref("formulation", { query: "I keep going over it", focus: true, run: true }), prescribing: appModeHomeHref("prescribing", { query: "acamprosate renal dose", focus: true, run: true }), tools: appModeHomeHref("tools", { query: "medications", focus: true, run: true }), + calculators: appModeHomeHref("calculators", { query: "PHQ-9", focus: true, run: true }), "therapy-compass": appModeHomeHref("therapy-compass", { query: "behavioural activation", focus: true, run: true }), factsheets: appModeHomeHref("factsheets", { query: "sertraline", focus: true, run: true }), }; diff --git a/src/app/(search-app)/calculators/page.tsx b/src/app/(search-app)/calculators/page.tsx index cb6ca9895..802403f7a 100644 --- a/src/app/(search-app)/calculators/page.tsx +++ b/src/app/(search-app)/calculators/page.tsx @@ -1,12 +1,45 @@ import type { Metadata } from "next"; +import { redirect } from "next/navigation"; -import { CalculatorsSearchPage } from "@/components/calculators"; +import { CalculatorsHomePage, CalculatorsSearchPage } from "@/components/calculators"; export const metadata: Metadata = { title: "Calculators - Clinical KB", description: "Psychiatry clinical decision calculators and rating scales with source-cited scoring guidance.", }; -export default function CalculatorsRoute() { - return ; +type CalculatorsSearchParams = Promise<{ [key: string]: string | string[] | undefined }>; + +function readFirstSearchParam(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} + +function toURLSearchParams(params: Awaited) { + const normalized = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (Array.isArray(value)) value.forEach((item) => normalized.append(key, item)); + else if (value !== undefined) normalized.set(key, value); + } + return normalized; +} + +export default async function CalculatorsRoute({ searchParams }: { searchParams: CalculatorsSearchParams }) { + const resolvedSearchParams = await searchParams; + const hasSubmittedSearch = readFirstSearchParam(resolvedSearchParams.run) === "1"; + const primaryQuery = readFirstSearchParam(resolvedSearchParams.q)?.trim(); + const legacyQuery = readFirstSearchParam(resolvedSearchParams.query)?.trim(); + const query = primaryQuery || legacyQuery; + + if (resolvedSearchParams.query !== undefined) { + const canonicalSearchParams = toURLSearchParams(resolvedSearchParams); + if (query) canonicalSearchParams.set("q", query); + else canonicalSearchParams.delete("q"); + canonicalSearchParams.delete("query"); + const suffix = canonicalSearchParams.toString(); + redirect(suffix ? `/calculators?${suffix}` : "/calculators"); + } + + if (!hasSubmittedSearch || !query) return ; + + return ; } diff --git a/src/components/calculators/calculator-filters.ts b/src/components/calculators/calculator-filters.ts new file mode 100644 index 000000000..efdd4f77e --- /dev/null +++ b/src/components/calculators/calculator-filters.ts @@ -0,0 +1,94 @@ +import { domainLabels, type CalculatorDomain, type CalculatorFixture } from "./calculator-fixtures"; +import type { DerivedCalculator } from "./calculator-ui"; + +export type CalculatorProgressFilter = "all" | "not-started" | "in-progress" | "completed"; +export type CalculatorTimeFilter = "all" | "quick" | "standard" | "extended"; + +export type CalculatorFilterState = { + domains: ReadonlySet; + progress: CalculatorProgressFilter; + time: CalculatorTimeFilter; +}; + +export type CalculatorFilterRecord = { + calc: CalculatorFixture; + derived: DerivedCalculator; +}; + +export function normalizeCalculatorQuery(query: string) { + return query.trim().toLowerCase(); +} + +export function calculatorMatchesQuery(calc: CalculatorFixture, query: string) { + const normalized = normalizeCalculatorQuery(query); + if (!normalized) return true; + const haystack = [calc.abbrev, calc.name, calc.indication, calc.summary, domainLabels[calc.domain]] + .join(" ") + .toLowerCase(); + return haystack.includes(normalized) || calc.items.some((item) => item.text.toLowerCase().includes(normalized)); +} + +export function calculatorMatchesProgress(derived: DerivedCalculator, progress: CalculatorProgressFilter) { + if (progress === "all") return true; + if (progress === "not-started") return !derived.started; + if (progress === "completed") return derived.complete; + return derived.started && !derived.complete; +} + +export function calculatorMatchesTime(calc: CalculatorFixture, time: CalculatorTimeFilter) { + if (time === "all") return true; + if (time === "quick") return calc.timeEstimateMinutes.max <= 2; + if (time === "standard") return calc.timeEstimateMinutes.max >= 3 && calc.timeEstimateMinutes.max <= 4; + return calc.timeEstimateMinutes.max >= 5; +} + +export function calculatorMatchesFilters( + record: CalculatorFilterRecord, + query: string, + filters: CalculatorFilterState, +) { + return ( + calculatorMatchesQuery(record.calc, query) && + (filters.domains.size === 0 || filters.domains.has(record.calc.domain)) && + calculatorMatchesProgress(record.derived, filters.progress) && + calculatorMatchesTime(record.calc, filters.time) + ); +} + +export function filterCalculatorRecords( + records: readonly CalculatorFilterRecord[], + query: string, + filters: CalculatorFilterState, +) { + return records.filter((record) => calculatorMatchesFilters(record, query, filters)); +} + +export function calculatorDomainCandidateCount( + records: readonly CalculatorFilterRecord[], + query: string, + filters: CalculatorFilterState, + candidate: CalculatorDomain, +) { + const domains = new Set(filters.domains); + if (domains.has(candidate)) domains.delete(candidate); + else domains.add(candidate); + return filterCalculatorRecords(records, query, { ...filters, domains }).length; +} + +export function calculatorProgressCandidateCount( + records: readonly CalculatorFilterRecord[], + query: string, + filters: CalculatorFilterState, + candidate: CalculatorProgressFilter, +) { + return filterCalculatorRecords(records, query, { ...filters, progress: candidate }).length; +} + +export function calculatorTimeCandidateCount( + records: readonly CalculatorFilterRecord[], + query: string, + filters: CalculatorFilterState, + candidate: CalculatorTimeFilter, +) { + return filterCalculatorRecords(records, query, { ...filters, time: candidate }).length; +} diff --git a/src/components/calculators/calculator-fixtures.ts b/src/components/calculators/calculator-fixtures.ts index c940c7415..c1eb34f7f 100644 --- a/src/components/calculators/calculator-fixtures.ts +++ b/src/components/calculators/calculator-fixtures.ts @@ -61,6 +61,8 @@ export type CalculatorFixture = { /** Question stem shown above the items. */ stem?: string; timeEstimate: string; + /** Numeric range used by the local completion-time filter. */ + timeEstimateMinutes: { min: number; max: number }; minScore: number; maxScore: number; items: CalculatorItem[]; @@ -121,6 +123,7 @@ export const calculators: CalculatorFixture[] = [ summary: "9-item depression severity score with treatment-action bands.", stem: "Over the last 2 weeks, how often have you been bothered by:", timeEstimate: "2–3 min", + timeEstimateMinutes: { min: 2, max: 3 }, minScore: 0, maxScore: 27, scoringNote: "Sum of 9 items (0–3 each). Severity bands map to stepped treatment actions.", @@ -199,6 +202,7 @@ export const calculators: CalculatorFixture[] = [ summary: "7-item anxiety severity score; also performs well for panic and social anxiety.", stem: "Over the last 2 weeks, how often have you been bothered by:", timeEstimate: "1–2 min", + timeEstimateMinutes: { min: 1, max: 2 }, minScore: 0, maxScore: 21, scoringNote: "Sum of 7 items (0–3 each). ≥10 is the usual cut-point for probable GAD.", @@ -242,6 +246,7 @@ export const calculators: CalculatorFixture[] = [ summary: "10-item distress measure scored 10–50, widely used across Australian primary care.", stem: "In the past 4 weeks, about how often did you feel:", timeEstimate: "2–3 min", + timeEstimateMinutes: { min: 2, max: 3 }, minScore: 10, maxScore: 50, scoringNote: "Sum of 10 items (1–5 each); range 10–50. Higher scores indicate greater distress.", @@ -293,6 +298,7 @@ export const calculators: CalculatorFixture[] = [ summary: "13 lifetime hypomanic symptoms plus co-occurrence and impairment criteria.", stem: "Has there ever been a period of time when you were not your usual self and you…", timeEstimate: "3–4 min", + timeEstimateMinutes: { min: 3, max: 4 }, minScore: 0, maxScore: 13, scoringNote: @@ -399,6 +405,7 @@ export const calculators: CalculatorFixture[] = [ indication: "Rapid 4-question lifetime screen for problem drinking in adults.", summary: "Four yes/no questions; two or more positives is a clinically significant screen.", timeEstimate: "under 1 min", + timeEstimateMinutes: { min: 0, max: 1 }, minScore: 0, maxScore: 4, scoringNote: "1 point per “yes”. ≥2 is a positive screen; follow with AUDIT and drinking history.", @@ -440,6 +447,7 @@ export const calculators: CalculatorFixture[] = [ indication: "Brief consumption screen for hazardous drinking — the first three AUDIT items.", summary: "3 consumption questions scored 0–4 each; sex-specific positive thresholds.", timeEstimate: "1 min", + timeEstimateMinutes: { min: 1, max: 1 }, minScore: 0, maxScore: 12, scoringNote: "Positive screen at ≥3 for women and ≥4 for men. Higher totals track hazard severity.", @@ -509,6 +517,7 @@ export const calculators: CalculatorFixture[] = [ indication: "Structure a rapid inventory of static suicide-risk factors as an adjunct to clinical assessment.", summary: "10 yes/no risk factors, one point each; supports — never replaces — clinical judgement.", timeEstimate: "1–2 min", + timeEstimateMinutes: { min: 1, max: 2 }, minScore: 0, maxScore: 10, scoringNote: "1 point per factor present. Bands are indicative only; act on clinical concern at any score.", @@ -566,6 +575,7 @@ export const calculators: CalculatorFixture[] = [ indication: "Grade the severity of obsessive-compulsive symptoms and track treatment response.", summary: "10 severity items (0–4) across obsessions and compulsions; total 0–40.", timeEstimate: "5–8 min", + timeEstimateMinutes: { min: 5, max: 8 }, minScore: 0, maxScore: 40, scoringNote: "Sum of 10 items. Items 1–5 grade obsessions, 6–10 compulsions; subscales can be reported separately.", diff --git a/src/components/calculators/home-page.tsx b/src/components/calculators/home-page.tsx new file mode 100644 index 000000000..3c06703e8 --- /dev/null +++ b/src/components/calculators/home-page.tsx @@ -0,0 +1,58 @@ +import { Calculator, CloudRain, GlassWater, Search, Zap } from "lucide-react"; + +import { ModeHomeMain, ModeHomeTemplate, ModeHomeVerificationFooter } from "@/components/mode-home-template"; +import { appModeHomeHref } from "@/lib/app-modes"; +import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; + +const calculatorQuickLinks = ["PHQ-9", "GAD-7", "K10", "MDQ", "AUDIT-C"] as const; + +function calculatorSearchHref(query: string) { + return appModeHomeHref("calculators", { query, run: true }); +} + +export function CalculatorsHomePage() { + return ( + + ({ + label, + href: calculatorSearchHref(label), + icon: Search, + }))} + footer={ + + } + /> + + ); +} diff --git a/src/components/calculators/index.ts b/src/components/calculators/index.ts index 759635cac..c9aeaf4e5 100644 --- a/src/components/calculators/index.ts +++ b/src/components/calculators/index.ts @@ -2,6 +2,7 @@ export { CalculatorsBedsideSheetMockup } from "./bedside-sheet"; export { CalculatorsClinicalConsoleMockup } from "./clinical-console"; export { CalculatorsDirectoryGridMockup } from "./directory-grid"; export { CalculatorsGuidedFlowMockup } from "./guided-flow"; +export { CalculatorsHomePage } from "./home-page"; export { CalculatorsPopupSheetMockup } from "./calculator-sheet"; export { CalculatorsSearchPage } from "./search-page"; export { CalculatorsSearchDetailMockup } from "./search-detail"; diff --git a/src/components/calculators/search-page.tsx b/src/components/calculators/search-page.tsx index c6bb6f4ea..6599ba091 100644 --- a/src/components/calculators/search-page.tsx +++ b/src/components/calculators/search-page.tsx @@ -1,49 +1,44 @@ "use client"; -import { - ArrowRight, - Calculator, - Clock3, - History, - Info, - LayoutGrid, - ListChecks, - Plus, - Rows3, - Search, - Send, - Sigma, - SlidersHorizontal, - X, -} from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowRight, Clock3, History, Info, LayoutGrid, ListChecks, Rows3, Search, Sigma } from "lucide-react"; +import { useEffect, useId, useMemo, useState, useSyncExternalStore } from "react"; -import { AnswerSuggestionChips } from "@/components/clinical-dashboard/answer-suggestion-chips"; import { - PhoneFooterLayerPortal, - usePhoneFooterLayerScrollHidden, -} from "@/components/clinical-dashboard/phone-footer-layer-portal"; + ResultFilterSheet, + ResultFilterTrigger, + resultFilterFacetGroup, + resultFilterGroup, +} from "@/components/clinical-dashboard/result-filter-control"; +import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context"; import { SearchResultsLayout } from "@/components/clinical-dashboard/search-results-layout"; -import { useHideOnScroll, useReserveTransitionMarker } from "@/components/clinical-dashboard/use-hide-on-scroll"; -import { PrivacyInputNotice } from "@/components/privacy-input-notice"; import { - chatComposerInput, - chatComposerShellBase, - chatSendButton, - cn, - eyebrowText, - ignoreUnavailableActivation, -} from "@/components/ui-primitives"; + SearchResultsEmptyState, + SearchResultsHeaderBand, + type AppliedFilterChip, +} from "@/components/clinical-dashboard/search-results-header-band"; +import { cn, eyebrowText } from "@/components/ui-primitives"; +import { appModeHomeHref } from "@/lib/app-modes"; +import { + calculatorDomainCandidateCount, + calculatorProgressCandidateCount, + calculatorTimeCandidateCount, + filterCalculatorRecords, + normalizeCalculatorQuery, + type CalculatorFilterState, + type CalculatorProgressFilter, + type CalculatorTimeFilter, +} from "./calculator-filters"; import { calculators, - domainIcons, domainLabels, domainOrder, plannedCalculators, type CalculatorDomain, type CalculatorFixture, } from "./calculator-fixtures"; +import { CalculatorSheet } from "./calculator-sheet"; import { MetaPill, SeverityPill, @@ -54,12 +49,30 @@ import { type AnswerMap, type DerivedCalculator, } from "./calculator-ui"; -import { CalculatorSheet } from "./calculator-sheet"; -type DomainFilter = CalculatorDomain | "all"; type SessionAnswers = Record; type Density = "comfortable" | "compact"; +const subscribeNoop = () => () => undefined; + +const progressOptions: ReadonlyArray<{ value: CalculatorProgressFilter; label: string }> = [ + { value: "all", label: "Any" }, + { value: "not-started", label: "Not started" }, + { value: "in-progress", label: "In progress" }, + { value: "completed", label: "Completed" }, +]; + +const timeOptions: ReadonlyArray<{ value: CalculatorTimeFilter; label: string }> = [ + { value: "all", label: "Any" }, + { value: "quick", label: "2 minutes or less" }, + { value: "standard", label: "3–4 minutes" }, + { value: "extended", label: "5+ minutes" }, +]; + +function optionLabel(options: ReadonlyArray<{ value: Value; label: string }>, value: Value) { + return options.find((option) => option.value === value)?.label ?? value; +} + /** Match context: name / indication hit, or the first matching item text. */ function matchContext(calc: CalculatorFixture, query: string): string | null { if (!query) return null; @@ -67,126 +80,6 @@ function matchContext(calc: CalculatorFixture, query: string): string | null { return item ? item.text : null; } -function matches(calc: CalculatorFixture, query: string): boolean { - if (!query) return true; - const haystack = [calc.abbrev, calc.name, calc.indication, calc.summary, domainLabels[calc.domain]] - .join(" ") - .toLowerCase(); - return haystack.includes(query) || calc.items.some((item) => item.text.toLowerCase().includes(query)); -} - -/* ---------- universal-style search composer (top on desktop, docked bottom on phones) ---------- */ - -// Example searches shown in the composer prompt row; each filters the list. -const promptExamples = ["depression", "anxiety", "drinking", "bipolar", "suicide"]; - -/** - * The calculators search composer, matching the app's universal composer: a - * leading "+" (new search), the query input with an inline clear, and the teal - * send button. `variant="full"` adds the Smart-search hint, prompt chips, and - * privacy notice (desktop header); `variant="compact"` shows the pill plus the - * privacy line only (phone bottom dock). - */ -function CalculatorComposer({ - query, - onQuery, - onReset, - onSubmit, - variant, -}: { - query: string; - onQuery: (value: string) => void; - onReset: () => void; - onSubmit: () => void; - variant: "full" | "compact"; -}) { - return ( -
- {variant === "full" ? ( -
- Smart search - - - Try “depression severity” in Calculators. - -
- ) : null} - -
{ - event.preventDefault(); - onSubmit(); - }} - className={cn(chatComposerShellBase, "answer-footer-search-pill relative z-10 w-full")} - > - - - - -
- ); -} - -/* ---------- home-page-style calculator tile ---------- */ - function CalculatorTile({ calc, derived, @@ -262,144 +155,40 @@ function CalculatorTile({ ); } -/* ---------- results header band (count + eyebrow + controls) ---------- */ - -function ResultsHeaderBand({ - count, - query, - density, - onDensity, -}: { - count: number; - query: string; - density: Density; - onDensity: (next: Density) => void; -}) { +function DensityControl({ density, onDensity }: { density: Density; onDensity: (next: Density) => void }) { return ( -
-
-
- - {count} - -
-

- Clinical calculators -

-

- {count} {count === 1 ? "calculator" : "calculators"} -

-

- {query ? ( - <> - Matching “{query}”. Open one to score - it and see next actions. - - ) : ( - "Validated psychiatry scores. Open one to score it and see score-linked next actions." - )} -

-
-
-
- {( - [ - ["comfortable", LayoutGrid, "Comfortable"], - ["compact", Rows3, "Compact"], - ] as const - ).map(([value, DensityIcon, label]) => { - const active = density === value; - return ( - - ); - })} -
-
+
+ {( + [ + ["comfortable", LayoutGrid, "Comfortable"], + ["compact", Rows3, "Compact"], + ] as const + ).map(([value, Icon, label]) => ( + + ))}
); } -/* ---------- right rail ---------- */ - -function DomainNav({ - domain, - counts, - onSelect, -}: { - domain: DomainFilter; - counts: Record; - onSelect: (next: DomainFilter) => void; -}) { - const rows: { id: DomainFilter; label: string; icon: typeof Calculator }[] = [ - { id: "all", label: "All calculators", icon: Calculator }, - ...domainOrder.map((entry) => ({ - id: entry as DomainFilter, - label: domainLabels[entry], - icon: domainIcons[entry], - })), - ]; - - return ( -
-

Browse by domain

-
- {rows.map((row) => { - const active = domain === row.id; - const RowIcon = row.icon; - const count = row.id === "all" ? calculators.length : (counts[row.id] ?? 0); - return ( - - ); - })} -
-
- ); -} - function ContinuePanel({ inProgress, onOpen, @@ -463,45 +252,48 @@ function AboutPanel() { ); } -/* ---------- page ---------- */ - -const filterChips: { id: DomainFilter; label: string }[] = [ - { id: "all", label: "All" }, - ...domainOrder.map((domain) => ({ id: domain as DomainFilter, label: domainLabels[domain] })), -]; - -export function CalculatorsSearchPage() { - const [query, setQuery] = useState(""); - const [domain, setDomain] = useState("all"); +export function CalculatorsSearchPage({ initialQuery = "" }: { initialQuery?: string }) { + const router = useRouter(); + const searchCommand = useSearchCommand(); + const hydrated = useSyncExternalStore( + subscribeNoop, + () => true, + () => false, + ); + const query = hydrated ? (searchCommand?.query ?? initialQuery) : initialQuery; + const normalizedQuery = normalizeCalculatorQuery(query); + const filterPanelId = useId(); + const [filterOpen, setFilterOpen] = useState(false); + const [selectedDomains, setSelectedDomains] = useState>(new Set()); + const [progress, setProgress] = useState("all"); + const [time, setTime] = useState("all"); const [density, setDensity] = useState("comfortable"); const [session, setSession] = useState({}); const [openId, setOpenId] = useState(null); - const trimmed = query.trim().toLowerCase(); - - const domainCounts = useMemo(() => { - const counts: Record = {}; - for (const calc of calculators) counts[calc.domain] = (counts[calc.domain] ?? 0) + 1; - return counts; - }, []); - - const results = useMemo( + const records = useMemo( () => - calculators - .filter((calc) => (domain === "all" || calc.domain === domain) && matches(calc, trimmed)) - .map((calc) => ({ calc, context: matchContext(calc, trimmed) })), - [domain, trimmed], + calculators.map((calc) => ({ + calc, + derived: deriveCalculator(calc, session[calc.id] ?? {}), + })), + [session], ); - - const inProgress = useMemo( + const filters = useMemo( + () => ({ domains: selectedDomains, progress, time }), + [progress, selectedDomains, time], + ); + const results = useMemo( () => - calculators - .map((calc) => ({ calc, derived: deriveCalculator(calc, session[calc.id] ?? {}) })) - .filter((entry) => entry.derived.started), - [session], + filterCalculatorRecords(records, query, filters).map((record) => ({ + ...record, + context: matchContext(record.calc, normalizedQuery), + })), + [filters, normalizedQuery, query, records], ); - + const inProgress = useMemo(() => records.filter((record) => record.derived.started), [records]); const activeCalc = openId ? calculators.find((calc) => calc.id === openId) : undefined; + const activeFilterCount = selectedDomains.size + (progress === "all" ? 0 : 1) + (time === "all" ? 0 : 1); useEffect(() => { if (!activeCalc) return; @@ -516,224 +308,192 @@ export function CalculatorsSearchPage() { }; }, [activeCalc]); - // The viewport frame owns the authoritative hide decision for its header and - // every portaled footer. Reuse that signal so slower hydration/RAF scheduling - // cannot let independently evaluated header and calculator reporters split. - // Keep local reporters only for the shell-less fallback used by isolated - // renders; browser phones scroll the document while standalone mode scrolls - // #main-content. - const frameScrollHidden = usePhoneFooterLayerScrollHidden(); - const useLocalScrollFallback = frameScrollHidden === undefined; - const scrollContainerRef = useRef(null); - useEffect(() => { - scrollContainerRef.current = document.querySelector("#main-content"); - }, []); - const innerFooterHidden = useHideOnScroll({ - containerRef: scrollContainerRef, - disabled: !useLocalScrollFallback, + function toggleDomain(domain: CalculatorDomain) { + setSelectedDomains((current) => { + const next = new Set(current); + if (next.has(domain)) next.delete(domain); + else next.add(domain); + return next; + }); + } + + function clearFilters() { + setSelectedDomains(new Set()); + setProgress("all"); + setTime("all"); + } + + const domainGroup = resultFilterFacetGroup({ + id: "clinical-domain", + label: "Clinical domain", + selected: selectedDomains, + options: domainOrder.map((domain) => { + const count = calculatorDomainCandidateCount(records, query, filters, domain); + return { + value: domain, + label: domainLabels[domain], + hint: String(count), + disabled: count === 0 && !selectedDomains.has(domain), + }; + }), + onToggle: toggleDomain, }); - const documentFooterHidden = useHideOnScroll({ - documentCollapseRootRef: scrollContainerRef, - disabled: !useLocalScrollFallback, + const progressGroup = resultFilterGroup({ + id: "session-progress", + label: "Session progress", + value: progress, + options: progressOptions.map((option) => { + const count = calculatorProgressCandidateCount(records, query, filters, option.value); + return { + ...option, + hint: String(count), + disabled: count === 0 && progress !== option.value, + }; + }), + onChange: setProgress, + note: "one only", + }); + const timeGroup = resultFilterGroup({ + id: "completion-time", + label: "Completion time", + value: time, + options: timeOptions.map((option) => { + const count = calculatorTimeCandidateCount(records, query, filters, option.value); + return { + ...option, + hint: String(count), + disabled: count === 0 && time !== option.value, + }; + }), + onChange: setTime, + note: "one only", }); - const footerHidden = frameScrollHidden ?? (innerFooterHidden || documentFooterHidden); - // Keep the phone dock visible while focused so scroll-hide cannot slide a - // focused input off-screen or mark it aria-hidden while still tabbable. - const [dockFocused, setDockFocused] = useState(false); - const dockHidden = footerHidden && !dockFocused; - const reserveTransitioning = useReserveTransitionMarker(dockHidden, activeCalc); - useEffect(() => { - if (!activeCalc) return; - // Submitting a focused dock input unmounts the dock before React is - // guaranteed to dispatch blur. Clear the latch after teardown so the dock - // can resume hide-on-scroll when the calculator sheet closes. This reset - // must survive a fast close: cancelling the microtask during effect cleanup - // can otherwise leave the remounted dock permanently focus-pinned. - queueMicrotask(() => { - setDockFocused(false); - }); - }, [activeCalc]); - - const compact = density === "compact"; - - const submitSearch = () => { - if (results.length === 1) setOpenId(results[0].calc.id); - }; - const resetSearch = () => { - setQuery(""); - setDomain("all"); - }; + const appliedFilters: AppliedFilterChip[] = [ + ...domainOrder + .filter((domain) => selectedDomains.has(domain)) + .map((domain) => ({ + id: `domain-${domain}`, + label: `Clinical domain: ${domainLabels[domain]}`, + onRemove: () => toggleDomain(domain), + })), + ...(progress === "all" + ? [] + : [ + { + id: `progress-${progress}`, + label: `Session progress: ${optionLabel(progressOptions, progress)}`, + onRemove: () => setProgress("all"), + }, + ]), + ...(time === "all" + ? [] + : [ + { + id: `time-${time}`, + label: `Completion time: ${optionLabel(timeOptions, time)}`, + onRemove: () => setTime("all"), + }, + ]), + ]; return ( <> - {/* Desktop: universal-style composer at the top, matching the site-wide - search header. Phones get the docked bottom composer below. */} -
- -
- -
-
- {filterChips.map((chip) => { - const active = domain === chip.id; - return ( - - ); - })} -
- - - Advanced filters are coming soon. - -
-
- } - summary={ - + <> + 0 ? clearFilters : undefined} + mobileControlsPlacement="inline" + mobileControls={ + setFilterOpen((current) => !current)} + /> + } + utilityControls={ + + setFilterOpen((current) => !current)} + /> + + + } + /> + setFilterOpen(false)} + panelId={filterPanelId} + testId="calculators-filter-sheet" + title="Filter calculators" + description="Choose any clinical domains, then narrow by session progress and completion time." + groups={[domainGroup, progressGroup, timeGroup]} + onClearAll={activeFilterCount > 0 ? clearFilters : undefined} + footerNote={`${results.length} showing`} + chromeResetKey={query} + /> + } sidebar={ <> - } sidebarMobile={ -
+
+
} > {results.length ? ( -
- {results.map(({ calc, context }) => ( +
+ {results.map(({ calc, derived, context }) => ( setOpenId(calc.id)} /> ))}
) : ( -
- - -

- No calculators match “{query.trim()}”. -

-

- Try a symptom (“hopeless”, “drinking”, “worry”) or clear the filters. -

- -
+ 0 ? clearFilters : undefined} + onClearSearch={() => router.push(appModeHomeHref("calculators", { focus: true }))} + onTryExample={(example) => router.push(appModeHomeHref("calculators", { query: example, run: true }))} + /> )} - {/* Phones: composer docks at the bottom, matching the site-wide composer - placement, and slides away on scroll-down in lockstep with the header. - Hidden while a calculator sheet is open. */} - {activeCalc ? null : ( - -
setDockFocused(true)} - onBlurCapture={(event) => { - if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDockFocused(false); - }} - className={cn( - "phone-footer-layer answer-footer-search-dock answer-footer-search-edge inset-x-0 bottom-0 z-40 px-3 pb-[calc(0.75rem+var(--safe-area-bottom))] pt-3 transition-[transform,opacity] motion-reduce:transition-none sm:hidden", - dockHidden - ? "pointer-events-none duration-[var(--duration-slow)] ease-[var(--ease-chrome-hide)]" - : "duration-[var(--duration-moderate)] ease-[var(--ease-chrome-reveal)]", - )} - aria-hidden={dockHidden} - inert={dockHidden || undefined} - > - - - )} - {activeCalc ? ( setSession((prev) => ({ ...prev, [activeCalc.id]: next }))} + onAnswersChange={(next) => setSession((current) => ({ ...current, [activeCalc.id]: next }))} onClose={() => setOpenId(null)} onOpenCalculator={setOpenId} /> diff --git a/src/components/clinical-dashboard/ClinicalSidebar.tsx b/src/components/clinical-dashboard/ClinicalSidebar.tsx index e5d2012b3..a47ccba15 100644 --- a/src/components/clinical-dashboard/ClinicalSidebar.tsx +++ b/src/components/clinical-dashboard/ClinicalSidebar.tsx @@ -84,6 +84,7 @@ const sidebarMoreModeIds = [ "dsm", "specifiers", "formulation", + "calculators", "therapy-compass", ] as const satisfies readonly AppModeId[]; diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 888704d62..ef5999d15 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -856,7 +856,9 @@ function GlobalStandaloneSearchShellBody({ onAsk={submitSearch} onClearQuery={() => { setQuery(""); - if (isStandaloneModeHome) navigateToMode(searchMode, { focus: true }); + if (isStandaloneModeHome || searchMode === "calculators") { + navigateToMode(searchMode, { focus: true }); + } }} onClearScope={() => undefined} onQueryModeChange={setQueryMode} diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 471929f01..22badf5d9 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -113,8 +113,8 @@ const phoneModeGroups = [ { id: "care", label: "Care", - hint: "Medication, tools, therapy", - modeIds: ["prescribing", "tools", "therapy-compass", "factsheets"], + hint: "Medication, calculators, tools, therapy", + modeIds: ["prescribing", "calculators", "tools", "therapy-compass", "factsheets"], }, ] as const satisfies ReadonlyArray<{ id: string; diff --git a/src/components/clinical-dashboard/mobile-composer-reserve.ts b/src/components/clinical-dashboard/mobile-composer-reserve.ts index 037b21780..326655f79 100644 --- a/src/components/clinical-dashboard/mobile-composer-reserve.ts +++ b/src/components/clinical-dashboard/mobile-composer-reserve.ts @@ -63,14 +63,9 @@ export function isDocumentViewerOwnedRoute(pathname: string): boolean { return pathname !== "/documents/search"; } -/** Calculators owns its desktop top + phone bottom search composer. */ -export function isCalculatorsOwnedRoute(pathname: string): boolean { - return pathname === "/calculators" || pathname.startsWith("/calculators/"); -} - /** Routes that own a floating/page composer so the shell keeps only a zero pad. */ export function isPageOwnedComposerRoute(pathname: string): boolean { - return isDocumentViewerOwnedRoute(pathname) || isCalculatorsOwnedRoute(pathname); + return isDocumentViewerOwnedRoute(pathname); } /* @@ -124,7 +119,7 @@ export function resolveShellVisibleMobileComposerReserve(input: { patientDetailsAddonActive?: boolean; }): string { if (!input.shouldShowSearchComposer) { - // Page-owned composers (DocumentViewer, Calculators) manage their own dock + // Page-owned composers (DocumentViewer) manage their own dock // clearance; the shell keeps only the hidden-size pad. const pageOwned = input.pageOwnedComposerRoute ?? input.documentViewerOwnedRoute ?? false; return pageOwned ? mobileComposerHiddenReserve : mobileComposerIdleReserve; diff --git a/src/components/clinical-dashboard/universal-search-command-surface.tsx b/src/components/clinical-dashboard/universal-search-command-surface.tsx index 86c05f0fd..0f0f0c2e0 100644 --- a/src/components/clinical-dashboard/universal-search-command-surface.tsx +++ b/src/components/clinical-dashboard/universal-search-command-surface.tsx @@ -32,6 +32,7 @@ import { commandDropdownCanDisplay, commandDropdownMinimumWidthMediaQuery, commandDropdownPointerMediaQuery, + commandSurfaceRemoteSearchEnabled, differentialRedFlagTerms, filteredSuggestions, isFormCodeQuery, @@ -489,7 +490,7 @@ export function UniversalSearchCommandSurface({ // the palette surfaces every entity type, ordered by the server's intent-aware domainOrder. const universal = useUniversalSearch({ query: trimmedQuery, - enabled: dropdownOpen && dropdownDisplayable && Boolean(config), + enabled: dropdownOpen && dropdownDisplayable && commandSurfaceRemoteSearchEnabled(modeId), contextMode: modeId, }); const savedRegistryFavourites = useSavedRegistryFavourites().items; diff --git a/src/lib/app-mode-icons.ts b/src/lib/app-mode-icons.ts index 090584c44..0f91559cc 100644 --- a/src/lib/app-mode-icons.ts +++ b/src/lib/app-mode-icons.ts @@ -2,6 +2,7 @@ import { BookOpenCheck, BookOpenText, BrainCircuit, + Calculator, Compass, FileSignature, FileText, @@ -30,6 +31,7 @@ export const appModeIcons: Record = { formulation: Network, prescribing: Pill, tools: Wrench, + calculators: Calculator, "therapy-compass": Compass, factsheets: BookOpenText, }; diff --git a/src/lib/app-modes.ts b/src/lib/app-modes.ts index 704ad37b7..1c74249d6 100644 --- a/src/lib/app-modes.ts +++ b/src/lib/app-modes.ts @@ -14,6 +14,7 @@ export const appModeIds = [ "formulation", "prescribing", "tools", + "calculators", "therapy-compass", "factsheets", ] as const; @@ -31,6 +32,7 @@ export type AppModeSearchKind = | "dsm" | "specifiers" | "formulation" + | "calculators" | "tools"; export type AppModeResultKind = AppModeSearchKind; @@ -336,6 +338,29 @@ export const appModeDefinitions = [ badgeLabel: null, }, }, + { + id: "calculators", + label: "Calculators", + description: "Source-cited psychiatry scores and clinical decision calculators", + href: "/calculators", + search: { + kind: "calculators", + placeholder: "Search calculators by scale, symptom, or indication...", + inputAriaLabel: "Search clinical calculators by scale, symptom, or indication", + submitIdleLabel: "Calculate", + submitBusyLabel: "Calculate", + submitAriaLabel: "Search clinical calculators", + emptyTitle: "Search clinical calculators", + readyTitle: "Find a clinical calculator", + progressLabel: "Searching the local calculator catalogue.", + resultKind: "calculators", + resultHeading: "Calculator matches", + resultsSurface: "results-band", + statusLabel: "Calculators", + nextStep: "Open a calculator to score it and review next actions", + badgeLabel: null, + }, + }, { id: "therapy-compass", label: "Therapy", @@ -429,6 +454,7 @@ const namespaceIsolatedModes = new Set([ "therapy-compass", "factsheets", "tools", + "calculators", ]); export function appModeHomeHref(modeId: AppModeId, options: SearchNavigationOptions = {}) { @@ -523,6 +549,7 @@ export function isSearchableAppMode(modeId: string): modeId is SearchableAppMode kind === "dsm" || kind === "specifiers" || kind === "formulation" || + kind === "calculators" || kind === "tools" ); } diff --git a/src/lib/mode-secondary-navigation.ts b/src/lib/mode-secondary-navigation.ts index 48b959ab4..ac51ae962 100644 --- a/src/lib/mode-secondary-navigation.ts +++ b/src/lib/mode-secondary-navigation.ts @@ -57,10 +57,11 @@ export const modeSecondaryNavigationRegistry = { ], prescribing: [], tools: [], + calculators: [], // Inert: `PageSecondaryNavigation` early-returns on `/therapy-compass*`, and // the mode's live destination list is `useTherapyNavItems` in // `src/components/therapy-compass/nav.tsx`, which feeds the shared `ModeNav`. - // Kept because this registry is a 13-mode contract, not because it renders. + // Kept because this registry is a 14-mode contract, not because it renders. // Editing these entries changes nothing a user sees. "therapy-compass": [ { id: "search", label: "Search", action: "therapy-search" }, diff --git a/src/lib/search-command-surface.ts b/src/lib/search-command-surface.ts index 7c5c69098..506782033 100644 --- a/src/lib/search-command-surface.ts +++ b/src/lib/search-command-surface.ts @@ -9,6 +9,8 @@ export type SearchCommandSurfaceConfig = { examples: string[]; suggestions: CommandSuggestion[]; crossModes: AppModeId[]; + /** Defaults to true. Set false when a mode's search contract is entirely local. */ + remoteSearchEnabled?: boolean; }; export type CommandSurfacePlacement = "bottom-dock" | "inline"; @@ -141,12 +143,27 @@ const searchCommandSurfaceByMode: Partial([ "therapy-compass", "factsheets", "tools", + "calculators", ]); /** @@ -34,6 +35,7 @@ const standaloneModeHomePaths = new Set([ "/factsheets", "/therapy-compass", "/tools", + "/calculators", // Documents and Medication gained real homes when `/` became the single shared // home for every mode. Like the others they own an in-flow hero composer. "/documents", diff --git a/src/lib/search-shell-props.ts b/src/lib/search-shell-props.ts index 08b29bf50..3abf405a0 100644 --- a/src/lib/search-shell-props.ts +++ b/src/lib/search-shell-props.ts @@ -70,9 +70,8 @@ export function searchShellPropsForPathname(pathname: string): SearchShellPathPr return { initialMode: "tools", desktopSearchPlacement: "hero" }; } - // Calculators owns its search composer (desktop top + phone bottom dock). if (pathname.startsWith("/calculators")) { - return { initialMode: "tools", searchComposerVisible: false }; + return { initialMode: "calculators", desktopSearchPlacement: "hero" }; } if (pathname.startsWith("/therapy-compass")) { diff --git a/src/lib/ui-copy.ts b/src/lib/ui-copy.ts index 2d26a6abc..fec7bfe77 100644 --- a/src/lib/ui-copy.ts +++ b/src/lib/ui-copy.ts @@ -52,6 +52,9 @@ export const sharedHomePresentation = { tools: { title: "Clinical Tools", }, + calculators: { + title: "Clinical Calculators", + }, "therapy-compass": { title: "Therapy Compass", }, diff --git a/src/lib/universal-search-mode-context.ts b/src/lib/universal-search-mode-context.ts index f921eb953..d90dcbe2b 100644 --- a/src/lib/universal-search-mode-context.ts +++ b/src/lib/universal-search-mode-context.ts @@ -13,6 +13,9 @@ const preferredDomainsByMode: Record { expect(universalSearchModeForDomain("therapies")).toBe("therapy-compass"); expect(universalSearchPreferredDomains("favourites")).toEqual([]); expect(universalSearchPreferredDomains("factsheets")).toEqual([]); + expect(universalSearchPreferredDomains("calculators")).toEqual([]); }); it("requires every mode to declare its search behavior and copy", () => { @@ -77,6 +78,17 @@ describe("app mode search contract", () => { expect(config.placeholder.toLowerCase()).toContain("tools"); }); + it("keeps calculators local and searchable as a first-class mode", () => { + const config = appModeSearchConfig("calculators"); + const mode = appModeDefinitions.find((definition) => definition.id === "calculators"); + + expect(isSearchableAppMode("calculators")).toBe(true); + expect(mode?.href).toBe("/calculators"); + expect(config.kind).toBe("calculators"); + expect(config.resultKind).toBe("calculators"); + expect(config.resultsSurface).toBe("results-band"); + }); + it("keeps services searchable through the shared dashboard composer", () => { const config = appModeSearchConfig("services"); const mode = appModeDefinitions.find((definition) => definition.id === "services"); @@ -236,6 +248,9 @@ describe("app mode search contract", () => { expect(appModeHomeHref("tools", { query: " medications ", run: true, focus: true })).toBe( "/tools?q=medications&focus=1&run=1", ); + expect(appModeHomeHref("calculators", { query: " PHQ-9 ", run: true, focus: true })).toBe( + "/calculators?q=PHQ-9&focus=1&run=1", + ); }); it("keeps active search context while routing from the shared composer", () => { @@ -270,6 +285,7 @@ describe("app mode search contract", () => { "formulation", "prescribing", "tools", + "calculators", "therapy-compass", "factsheets", ]), @@ -291,6 +307,7 @@ describe("app mode search contract", () => { expect(isAppModeVisible("formulation", "production")).toBe(true); expect(isAppModeVisible("prescribing", "production")).toBe(true); expect(isAppModeVisible("tools", "production")).toBe(true); + expect(isAppModeVisible("calculators", "production")).toBe(true); expect(isAppModeVisible("therapy-compass", "production")).toBe(true); expect(isAppModeVisible("factsheets", "production")).toBe(true); expect(productionModes).not.toContain("evidence"); @@ -303,6 +320,7 @@ describe("app mode search contract", () => { expect(productionModes).toContain("formulation"); expect(productionModes).toContain("prescribing"); expect(productionModes).toContain("tools"); + expect(productionModes).toContain("calculators"); expect(productionModes).toContain("therapy-compass"); expect(productionModes).toContain("factsheets"); expect(developmentModes).toEqual( @@ -318,6 +336,7 @@ describe("app mode search contract", () => { "formulation", "prescribing", "tools", + "calculators", "therapy-compass", "factsheets", ]), @@ -393,6 +412,7 @@ describe("app mode search contract", () => { "therapy-compass": "/therapy-compass?q=clozapine&run=1", // Tools has no search route by design: it filters its launcher in place. tools: "/tools?q=clozapine&run=1", + calculators: "/calculators?q=clozapine&run=1", }); }); diff --git a/tests/calculators-mode.dom.test.tsx b/tests/calculators-mode.dom.test.tsx new file mode 100644 index 000000000..9aabe09ca --- /dev/null +++ b/tests/calculators-mode.dom.test.tsx @@ -0,0 +1,240 @@ +/** @vitest-environment jsdom */ + +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +const navigation = vi.hoisted(() => ({ + push: vi.fn(), + redirect: vi.fn(() => { + throw new Error("NEXT_REDIRECT"); + }), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: navigation.push }), + redirect: navigation.redirect, +})); + +import CalculatorsRoute from "@/app/(search-app)/calculators/page"; +import { + calculatorDomainCandidateCount, + calculatorProgressCandidateCount, + calculatorTimeCandidateCount, + filterCalculatorRecords, + type CalculatorFilterRecord, + type CalculatorFilterState, +} from "@/components/calculators/calculator-filters"; +import { calculators, type CalculatorFixture } from "@/components/calculators/calculator-fixtures"; +import { CalculatorsHomePage } from "@/components/calculators/home-page"; +import { CalculatorsSearchPage } from "@/components/calculators/search-page"; +import { deriveCalculator, type AnswerMap } from "@/components/calculators/calculator-ui"; +import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; + +function completeAnswers(calc: CalculatorFixture): AnswerMap { + return Object.fromEntries(calc.items.map((item) => [item.id, 0])); +} + +function recordsWithProgress(): CalculatorFilterRecord[] { + return calculators.map((calc) => { + const answers = calc.id === "gad7" ? completeAnswers(calc) : calc.id === "phq9" ? { p1: 1 } : {}; + return { calc, derived: deriveCalculator(calc, answers) }; + }); +} + +const emptyFilters = (): CalculatorFilterState => ({ + domains: new Set(), + progress: "all", + time: "all", +}); + +describe("calculator mode routing", () => { + it("renders the shared-composer home until a non-empty search is submitted", async () => { + const home = await CalculatorsRoute({ searchParams: Promise.resolve({}) }); + const emptyRun = await CalculatorsRoute({ searchParams: Promise.resolve({ run: "1", q: " " }) }); + const results = await CalculatorsRoute({ + searchParams: Promise.resolve({ run: "1", q: " depression " }), + }); + + expect(home.type).toBe(CalculatorsHomePage); + expect(emptyRun.type).toBe(CalculatorsHomePage); + expect(results.type).toBe(CalculatorsSearchPage); + expect(results.props.initialQuery).toBe("depression"); + }); + + it("normalizes the legacy query parameter to the canonical q URL", async () => { + navigation.redirect.mockClear(); + await expect( + CalculatorsRoute({ + searchParams: Promise.resolve({ run: "1", query: "PHQ-9", focus: "1" }), + }), + ).rejects.toThrow("NEXT_REDIRECT"); + expect(navigation.redirect).toHaveBeenCalledWith("/calculators?run=1&focus=1&q=PHQ-9"); + }); + + it("removes empty and redundant legacy query parameters", async () => { + navigation.redirect.mockClear(); + await expect(CalculatorsRoute({ searchParams: Promise.resolve({ query: " " }) })).rejects.toThrow( + "NEXT_REDIRECT", + ); + expect(navigation.redirect).toHaveBeenLastCalledWith("/calculators"); + + await expect(CalculatorsRoute({ searchParams: Promise.resolve({ q: "GAD-7", query: "" }) })).rejects.toThrow( + "NEXT_REDIRECT", + ); + expect(navigation.redirect).toHaveBeenLastCalledWith("/calculators?q=GAD-7"); + }); + + it("mounts the universal hero composer slot and canonical starter searches", () => { + const { container } = render(); + + expect(screen.getByRole("heading", { level: 1, name: "Clinical Calculators" })).toBeVisible(); + expect(container.querySelector(".mode-home-composer-slot")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Depression severity/ })).toHaveAttribute( + "href", + "/calculators?q=depression&run=1", + ); + expect(screen.getByRole("link", { name: "PHQ-9" })).toHaveAttribute("href", "/calculators?q=PHQ-9&run=1"); + }); +}); + +describe("calculator filter predicates", () => { + it("applies OR within domains and AND across domain, progress, time, and query", () => { + const records = recordsWithProgress(); + const filters: CalculatorFilterState = { + domains: new Set(["mood", "anxiety"]), + progress: "completed", + time: "quick", + }; + + expect(filterCalculatorRecords(records, "anxiety", filters).map(({ calc }) => calc.id)).toEqual(["gad7"]); + expect(filterCalculatorRecords(records, "depression", filters)).toEqual([]); + }); + + it("keeps not-started, in-progress, and completed boundaries exclusive", () => { + const records = recordsWithProgress(); + const states = ["not-started", "in-progress", "completed"] as const; + const ids = Object.fromEntries( + states.map((progress) => [ + progress, + filterCalculatorRecords(records, "", { ...emptyFilters(), progress }).map(({ calc }) => calc.id), + ]), + ); + + expect(ids["in-progress"]).toEqual(["phq9"]); + expect(ids.completed).toEqual(["gad7"]); + expect(ids["not-started"]).not.toContain("phq9"); + expect(ids["not-started"]).not.toContain("gad7"); + expect(new Set(Object.values(ids).flat()).size).toBe(calculators.length); + }); + + it("buckets completion time by the fixture's numeric maximum", () => { + const records = recordsWithProgress(); + const idsFor = (time: "quick" | "standard" | "extended") => + filterCalculatorRecords(records, "", { ...emptyFilters(), time }).map(({ calc }) => calc.id); + + expect(idsFor("quick")).toEqual(["gad7", "cage", "auditc", "sadpersons"]); + expect(idsFor("standard")).toEqual(["phq9", "k10", "mdq"]); + expect(idsFor("extended")).toEqual(["ybocs"]); + }); + + it("derives candidate counts from the same predicates", () => { + const records = recordsWithProgress(); + const filters: CalculatorFilterState = { + domains: new Set(["mood"]), + progress: "all", + time: "all", + }; + + expect(calculatorDomainCandidateCount(records, "", filters, "anxiety")).toBe(4); + expect(calculatorProgressCandidateCount(records, "", filters, "in-progress")).toBe(1); + expect(calculatorTimeCandidateCount(records, "", filters, "quick")).toBe(0); + }); +}); + +describe("calculator results surface", () => { + it("uses the shared results band and filter sheet without a page-owned composer", async () => { + const user = userEvent.setup(); + const { container } = render(); + + expect(screen.getByRole("heading", { level: 1, name: "depression" })).toBeVisible(); + expect(screen.getByRole("status")).toHaveTextContent("2 calculators"); + expect(container.querySelector('[data-testid="calculators-phone-dock"]')).toBeNull(); + + await user.click(screen.getByTestId("calculators-filter-trigger-phone")); + const sheet = screen.getByTestId("calculators-filter-sheet"); + expect(within(sheet).getByRole("group", { name: "Clinical domain" })).toBeVisible(); + expect(within(sheet).getByRole("radiogroup", { name: "Session progress" })).toBeVisible(); + expect(within(sheet).getByRole("radiogroup", { name: "Completion time" })).toBeVisible(); + }); + + it("labels applied filters, removes one chip, and clears filters without clearing the query", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("calculators-filter-trigger-phone")); + const sheet = screen.getByTestId("calculators-filter-sheet"); + await user.click(within(sheet).getByRole("button", { name: /Mood/ })); + expect(screen.getByRole("button", { name: "Remove Clinical domain: Mood filter" })).toBeVisible(); + expect(screen.getByRole("heading", { level: 1, name: "depression" })).toBeVisible(); + + await user.click(screen.getByRole("button", { name: "Remove Clinical domain: Mood filter" })); + expect(screen.queryByRole("button", { name: "Remove Clinical domain: Mood filter" })).toBeNull(); + + await user.click(within(sheet).getByRole("button", { name: /Mood/ })); + await user.click(screen.getByTestId("calculators-filter-sheet-clear")); + expect(screen.queryByRole("button", { name: "Remove Clinical domain: Mood filter" })).toBeNull(); + expect(screen.getByRole("heading", { level: 1, name: "depression" })).toBeVisible(); + }); + + it("distinguishes filtered and query-only empty recovery", async () => { + const user = userEvent.setup(); + const { rerender } = render( + + + , + ); + + await user.click(screen.getByTestId("calculators-filter-trigger-phone")); + await user.click(within(screen.getByTestId("calculators-filter-sheet")).getByRole("button", { name: /Mood/ })); + rerender( + + + , + ); + expect(screen.getByText("No calculators match the selected filter")).toBeVisible(); + expect(screen.getByTestId("search-results-empty-remove-filter")).toBeVisible(); + + await user.click(screen.getByTestId("calculators-filter-sheet-clear")); + rerender( + + + , + ); + expect(screen.getByText("No matches for “no-such-calculator”")).toBeVisible(); + await user.click(screen.getByTestId("search-results-empty-clear-search")); + expect(navigation.push).toHaveBeenCalledWith("/calculators?focus=1"); + }); + + it("opens, updates, completes, closes, and resumes a scoring session", async () => { + const user = userEvent.setup(); + Element.prototype.scrollTo = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: /^Open PHQ-9/ })); + let dialog = screen.getByRole("dialog", { name: "PHQ-9 calculator" }); + const firstItem = within(dialog).getByRole("group", { name: "Little interest or pleasure in doing things" }); + await user.click(within(firstItem).getByRole("button", { name: "Several days (1 point)" })); + expect(within(dialog).getAllByText("1 of 9 answered")[0]).toBeVisible(); + + for (const option of within(dialog).getAllByRole("button", { name: "Not at all (0 points)" })) { + await user.click(option); + } + expect(within(dialog).getAllByText("9 of 9 answered")[0]).toBeVisible(); + + await user.click(within(dialog).getByRole("button", { name: /^Close$/ })); + await user.click(screen.getAllByRole("button", { name: /^Open PHQ-9/ })[0]); + dialog = screen.getByRole("dialog", { name: "PHQ-9 calculator" }); + expect(within(dialog).getAllByText("9 of 9 answered")[0]).toBeVisible(); + }); +}); diff --git a/tests/header-scroll-hide-contract.test.ts b/tests/header-scroll-hide-contract.test.ts index 3ce7fc070..2e17cfa37 100644 --- a/tests/header-scroll-hide-contract.test.ts +++ b/tests/header-scroll-hide-contract.test.ts @@ -436,7 +436,7 @@ describe("shared header hide/reveal wiring", () => { it("uses one adaptive phone footer positioning owner", () => { expect(headerSource).toContain("phone-footer-layer"); expect(documentViewerSource).toContain("phone-footer-layer document-viewer-composer"); - expect(calculatorSearchSource).toContain("phone-footer-layer answer-footer-search-dock"); + expect(calculatorSearchSource).not.toContain("phone-footer-layer"); }); it("exposes one stable diagnostic contract across each phone chrome owner", () => { @@ -464,20 +464,18 @@ describe("shared header hide/reveal wiring", () => { expect(phoneFooterPortalSource).toContain("export function PhoneFooterLayerFrame"); expect(shellSource).toContain(""); + expect(calculatorSearchSource).not.toContain(""); expect(documentViewerSource).toContain(""); expect(differentialPresentationSource).toContain(""); expect(differentialPresentationSource).toContain('data-testid="differential-presentation-phone-footer"'); }); - it("shares the frame's authoritative scroll decision with the calculator footer", () => { + it("keeps calculator results on the shell's authoritative scroll decision", () => { expect(phoneFooterPortalSource).toContain("export function usePhoneFooterLayerScrollHidden"); expect(shellSource).toContain("scrollHidden={chromeScrollHide.hidden}"); expect(dashboardSource).toContain("scrollHidden={chromeScrollHidden}"); - expect(calculatorSearchSource).toContain("const frameScrollHidden = usePhoneFooterLayerScrollHidden()"); - expect(calculatorSearchSource).toContain( - "const footerHidden = frameScrollHidden ?? (innerFooterHidden || documentFooterHidden)", - ); + expect(calculatorSearchSource).not.toContain("usePhoneFooterLayerScrollHidden"); + expect(calculatorSearchSource).not.toContain("useHideOnScroll"); }); it("releases the phone top safe-area with hidden chrome while retaining the wide inset", () => { diff --git a/tests/helpers/phone-scroll.ts b/tests/helpers/phone-scroll.ts index 20fe66f36..317a66a3b 100644 --- a/tests/helpers/phone-scroll.ts +++ b/tests/helpers/phone-scroll.ts @@ -36,6 +36,7 @@ export const modeHomeRoutes = [ "/formulation", "/dsm", "/tools", + "/calculators", "/differentials", "/specifiers", "/factsheets", @@ -52,6 +53,7 @@ export const longRoutes = [ "/formulation/worry", "/formulation/builder?mechanism=rumination&template=5Ps", "/documents/search", + "/calculators?q=depression&run=1", // Demo-corpus document detail: DocumentViewer owns its composer here, and its // scroll container binding has its own failure mode (stale #main-content). "/documents/11111111-1111-4111-8111-111111111111?page=1", @@ -69,6 +71,7 @@ export const appModeHeaderRoutes = [ { mode: "Formulation", route: "/formulation" }, { mode: "Medication", route: "/?mode=prescribing" }, { mode: "Tools", route: "/tools" }, + { mode: "Calculators", route: "/calculators" }, { mode: "Therapy", route: "/therapy-compass" }, { mode: "Factsheets", route: "/factsheets" }, ]; @@ -122,14 +125,6 @@ export const pageOwnedHeaderRoutes = [ ]; export const standalonePageOwnedFooterRoutes = [ - { - name: "calculator composer", - route: "/calculators", - selector: '[data-testid="calculators-phone-dock"]', - focusSelector: 'input[aria-label="Search calculators"]', - reserveSelector: '[data-testid="calculators-search-page"]', - flushBottom: true, - }, { name: "document composer", route: "/documents/11111111-1111-4111-8111-111111111111?page=1", diff --git a/tests/mobile-chrome-paint-contract.test.ts b/tests/mobile-chrome-paint-contract.test.ts index 50ef4249b..2306b6bfc 100644 --- a/tests/mobile-chrome-paint-contract.test.ts +++ b/tests/mobile-chrome-paint-contract.test.ts @@ -57,15 +57,11 @@ describe("mobile chrome paint baseline", () => { } }); - it("registers the calculator page reserve with the shared hide and transition contracts", () => { - expect(calculatorsSource).toContain("useReserveTransitionMarker(dockHidden, activeCalc)"); - expect(calculatorsSource).toContain("if (!activeCalc) return;"); - expect(calculatorsSource).toContain("queueMicrotask(() =>"); - expect(calculatorsSource).toContain("setDockFocused(false)"); - expect(calculatorsSource).not.toContain("if (!cancelled) setDockFocused(false)"); - expect(calculatorsSource).toContain('reserveOwner="calculator"'); - expect(calculatorsSource).toContain('reserveHiddenPad="0rem"'); - expect(calculatorsSource).toContain("reserveTransitioning={reserveTransitioning}"); + it("keeps calculator results free of a second phone dock and page reserve", () => { + expect(calculatorsSource).not.toContain("useReserveTransitionMarker"); + expect(calculatorsSource).not.toContain('reserveOwner="calculator"'); + expect(calculatorsSource).not.toContain('data-testid="calculators-phone-dock"'); + expect(calculatorsSource).not.toContain("PhoneFooterLayerPortal"); expect(globalStylesSource).toContain('[data-reserve-owner][data-reserve-transitioning="true"]'); expect(scrollHideSource).toContain('querySelectorAll("[data-reserve-owner]")'); expect(scrollHideSource).toContain("dataset.reserveHiddenPad"); diff --git a/tests/mobile-composer-reserve.test.ts b/tests/mobile-composer-reserve.test.ts index f2756d62d..91d5ecd7b 100644 --- a/tests/mobile-composer-reserve.test.ts +++ b/tests/mobile-composer-reserve.test.ts @@ -4,7 +4,6 @@ import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { - isCalculatorsOwnedRoute, isDocumentViewerOwnedRoute, isPageOwnedComposerRoute, mobileComposerDifferentialsCompareReserve, @@ -205,11 +204,9 @@ describe("mobile composer reserve contract", () => { expect(isDocumentViewerOwnedRoute("/forms")).toBe(false); }); - it("classifies calculators as a page-owned composer route", () => { - expect(isCalculatorsOwnedRoute("/calculators")).toBe(true); - expect(isCalculatorsOwnedRoute("/calculators/phq-9")).toBe(true); - expect(isCalculatorsOwnedRoute("/tools")).toBe(false); - expect(isPageOwnedComposerRoute("/calculators")).toBe(true); + it("keeps calculators shell-owned and document viewers page-owned", () => { + expect(isPageOwnedComposerRoute("/calculators")).toBe(false); + expect(isPageOwnedComposerRoute("/calculators/phq-9")).toBe(false); expect(isPageOwnedComposerRoute("/documents/source")).toBe(true); expect(isPageOwnedComposerRoute("/tools")).toBe(false); expect( diff --git a/tests/mode-secondary-navigation.test.ts b/tests/mode-secondary-navigation.test.ts index 597617e00..bb5af111a 100644 --- a/tests/mode-secondary-navigation.test.ts +++ b/tests/mode-secondary-navigation.test.ts @@ -11,7 +11,7 @@ import { routedModeSecondaryNavigationCount, } from "@/lib/mode-secondary-navigation"; -/** Seven modes intentionally register no destinations at all — see `emptyRegistryModes`. */ +/** Eight modes intentionally register no destinations at all — see `emptyRegistryModes`. */ const expectedLabels: Record = { answer: [], documents: [], @@ -24,6 +24,7 @@ const expectedLabels: Record = { formulation: ["Find", "Build", "Compare", "Map"], prescribing: [], tools: [], + calculators: [], "therapy-compass": ["Search", "Recommend", "Compare", "Pathways", "Brief Intervention", "Patient Sheets"], factsheets: ["Topics", "Search"], }; @@ -40,6 +41,7 @@ const cleanLandingPath: Record = { formulation: "/formulation", prescribing: "/medications", tools: "/tools", + calculators: "/calculators", "therapy-compass": "/therapy-compass", factsheets: "/factsheets", }; @@ -59,12 +61,13 @@ const emptyRegistryModes = [ "favourites", "prescribing", "tools", + "calculators", ] as const satisfies readonly AppModeId[]; describe("mode secondary navigation registry", () => { - it("covers all 13 modes with the approved destinations and no Home item", () => { + it("covers all 14 modes with the approved destinations and no Home item", () => { expect(Object.keys(modeSecondaryNavigationRegistry).sort()).toEqual([...appModeIds].sort()); - expect(appModeIds).toHaveLength(13); + expect(appModeIds).toHaveLength(14); for (const modeId of appModeIds) { const labels = modeSecondaryNavigationRegistry[modeId].map((item) => item.label); @@ -73,7 +76,7 @@ describe("mode secondary navigation registry", () => { } }); - it("registers no destinations at all for the seven single-surface modes", () => { + it("registers no destinations at all for the eight single-surface modes", () => { // Empty is a real answer, pinned rather than left incidental: a future edit // that re-adds a lone focus-the-composer button should have to argue with // this test rather than slip back in. diff --git a/tests/search-command-surface.test.ts b/tests/search-command-surface.test.ts index b2d351ba3..86bef4ef4 100644 --- a/tests/search-command-surface.test.ts +++ b/tests/search-command-surface.test.ts @@ -6,6 +6,7 @@ import { commandDropdownCanDisplay, commandDropdownMinimumWidthMediaQuery, commandDropdownPointerMediaQuery, + commandSurfaceRemoteSearchEnabled, differentialRedFlagTerms, filteredSuggestions, isFormCodeQuery, @@ -46,6 +47,12 @@ describe("search command surface", () => { expect(specifiers?.crossModes).toContain("formulation"); }); + it("keeps calculator suggestions local while preserving remote typeahead elsewhere", () => { + expect(commandSurfaceRemoteSearchEnabled("calculators")).toBe(false); + expect(commandSurfaceRemoteSearchEnabled("documents")).toBe(true); + expect(commandSurfaceRemoteSearchEnabled("therapy-compass")).toBe(false); + }); + it("detects form code queries", () => { expect(isFormCodeQuery("form 3A")).toBe(true); expect(isFormCodeQuery("form 12")).toBe(true); diff --git a/tests/search-route-ownership.test.ts b/tests/search-route-ownership.test.ts index 5bc195271..2b6aa0352 100644 --- a/tests/search-route-ownership.test.ts +++ b/tests/search-route-ownership.test.ts @@ -23,6 +23,7 @@ describe("shared-search route ownership", () => { "formulation", "therapy-compass", "tools", + "calculators", ] as const) { expect(shouldRenderDashboardSearch({ hasSubmittedSearch: true, mode, pathname: `/${mode}` })).toBe(false); } @@ -65,6 +66,7 @@ describe("shared-search route ownership", () => { "/factsheets", "/therapy-compass", "/tools", + "/calculators", "/documents", "/medications", ]) { diff --git a/tests/search-shell-props.test.ts b/tests/search-shell-props.test.ts index 9f4d247dc..08299a4bd 100644 --- a/tests/search-shell-props.test.ts +++ b/tests/search-shell-props.test.ts @@ -35,10 +35,10 @@ describe("searchShellPropsForPathname", () => { expect(searchShellPropsForPathname("/")).toEqual({ initialMode: "answer" }); }); - it("hides the shell composer on calculators (page-owned)", () => { + it("assigns calculator home and results search to the shared shell", () => { expect(searchShellPropsForPathname("/calculators")).toEqual({ - initialMode: "tools", - searchComposerVisible: false, + initialMode: "calculators", + desktopSearchPlacement: "hero", }); }); }); diff --git a/tests/ui-overlay-css-contract.test.ts b/tests/ui-overlay-css-contract.test.ts index 20bd27e86..1fa4b81e7 100644 --- a/tests/ui-overlay-css-contract.test.ts +++ b/tests/ui-overlay-css-contract.test.ts @@ -215,7 +215,7 @@ describe("overlay and global CSS contracts", () => { expect(browserMediaBlock).toContain("var(--mobile-composer-reserve, 0rem)"); expect(masterSearchHeaderSource).toContain("phone-footer-layer"); expect(documentViewerSource).toContain("phone-footer-layer document-viewer-composer"); - expect(calculatorSearchSource).toContain("phone-footer-layer answer-footer-search-dock"); + expect(calculatorSearchSource).not.toContain("phone-footer-layer"); expect(differentialPresentationSource).toContain("phone-footer-layer inset-x-0 bottom-0"); expect(differentialPresentationSource).not.toContain('className="fixed inset-x-0 bottom-0'); expect(globalSearchShellSource).toContain("phone-viewport-shell"); diff --git a/tests/ui-phone-scroll-page-owned.spec.ts b/tests/ui-phone-scroll-page-owned.spec.ts index b147b8e62..baffbede3 100644 --- a/tests/ui-phone-scroll-page-owned.spec.ts +++ b/tests/ui-phone-scroll-page-owned.spec.ts @@ -17,7 +17,7 @@ import { expectSingleSettledOwner } from "./playwright-settlement"; /** * Page-owned phone chrome: the document viewer's own composer, the standalone - * frame-owned footers, the calculators dock, and the Services result canvas. + * frame-owned footers and the Services result canvas. * * These surfaces own their composer instead of using the shell's, so their * hide/reveal and reserve behaviour is independent of the shared header swept in @@ -837,10 +837,20 @@ test("Services results keep a continuous browser viewport after shared chrome re ); }); -test("calculator results stay inside narrow phone viewports", async ({ page }) => { - for (const width of [320, 390]) { - await page.setViewportSize({ width, height: 844 }); - await gotoPhoneSurface(page, "/calculators", 112); +test("calculator results stay usable across the responsive and accessibility matrix", async ({ page }) => { + const viewports = [ + { width: 320, height: 740 }, + { width: 390, height: 844 }, + { width: 639, height: 900 }, + { width: 768, height: 1024 }, + { width: 1440, height: 900 }, + { width: 1920, height: 1080 }, + ] as const; + + for (const { width, height } of viewports) { + await page.emulateMedia({ colorScheme: "light", forcedColors: "none", reducedMotion: "no-preference" }); + await page.setViewportSize({ width, height }); + await gotoPhoneSurface(page, "/calculators?q=depression&run=1", 112); const pageSurface = page.getByTestId("calculators-search-page"); const geometry = await pageSurface.evaluate((element) => ({ @@ -851,10 +861,10 @@ test("calculator results stay inside narrow phone viewports", async ({ page }) = geometry.clientWidth + 1, ); - for (const control of [ - page.getByRole("button", { name: "Filters" }), - page.getByRole("button", { name: /^Open PHQ-9/ }), - ]) { + const filterTrigger = page.getByTestId( + width < 640 ? "calculators-filter-trigger-phone" : "calculators-filter-trigger-desktop", + ); + for (const control of [filterTrigger, page.getByRole("button", { name: /^Open PHQ-9/ })]) { const box = await control.boundingBox(); expect(box, `${width}px calculator control should be rendered`).not.toBeNull(); expect(box!.x, `${width}px calculator control starts inside the viewport`).toBeGreaterThanOrEqual(0); @@ -862,351 +872,35 @@ test("calculator results stay inside narrow phone viewports", async ({ page }) = width + 1, ); } - } -}); - -test("calculators page-owned phone dock uses localized glass and releases its reserve when hidden", async ({ - page, -}) => { - await page.emulateMedia({ reducedMotion: "no-preference" }); - await page.setViewportSize(phoneViewport); - await gotoPhoneSurface(page, "/calculators", 112); - - const dock = page.getByTestId("calculators-phone-dock"); - const pageSurface = page.getByTestId("calculators-search-page"); - await expect(dock).toBeVisible(); - - const visible = await dock.evaluate((element) => { - const backdrop = element.querySelector(".answer-footer-search-backdrop"); - const dockRect = element.getBoundingClientRect(); - const backdropStyle = backdrop ? getComputedStyle(backdrop) : null; - return { - background: getComputedStyle(element).backgroundColor, - left: dockRect.left, - right: dockRect.right, - bottom: dockRect.bottom, - backdropDisplay: backdropStyle?.display ?? "missing", - backdropPosition: backdropStyle?.position ?? "missing", - backdropPaint: backdropStyle?.backgroundImage ?? "", - backdropHasTranslucentStop: /transparent|\/\s*(?:0?\.)\d+/.test(backdropStyle?.backgroundImage ?? ""), - }; - }); - expect(visible.background).toBe("rgba(0, 0, 0, 0)"); - expect(visible.left).toBeCloseTo(0, 0); - expect(visible.right).toBeCloseTo(phoneViewport.width, 0); - expect(visible.bottom).toBeCloseTo(phoneViewport.height, 0); - expect(visible.backdropDisplay).toBe("block"); - expect(visible.backdropPosition).toBe("absolute"); - expect(visible.backdropPaint).toContain("gradient"); - expect(visible.backdropHasTranslucentStop).toBe(true); - await expect - .poll(async () => - Number.parseFloat(await pageSurface.evaluate((element) => getComputedStyle(element).paddingBottom)), - ) - .toBeGreaterThan(112); - - // This journey owns the visible/hidden paint contract. Give it explicit - // runway so the separate near-bottom tests remain the sole owner of the - // intentional anti-clamp behavior on naturally short calculator pages. - await addPhoneScrollRunway(page); - const geometry = await readGeometry(page); - await dragScrollBy(page, Math.min(Math.max(geometry.maxOffset, 500), 900), 24); - await expect(dock).toHaveAttribute("data-scroll-hidden", "true"); - await page.waitForTimeout(300); - - const hidden = await dock.evaluate((element) => { - const rect = element.getBoundingClientRect(); - return { opacity: getComputedStyle(element).opacity, top: rect.top, viewportHeight: window.innerHeight }; - }); - expect(hidden.opacity).toBe("0"); - expect(hidden.top).toBeGreaterThanOrEqual(hidden.viewportHeight - 1); - await expect - .poll(async () => - Number.parseFloat(await pageSurface.evaluate((element) => getComputedStyle(element).paddingBottom)), - ) - .toBeLessThanOrEqual(1); - - await dragScrollBy(page, -48, 8); - await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); -}); - -test("calculator dock clears its focus pin after a focused submit opens and closes a sheet", async ({ page }) => { - await page.setViewportSize(phoneViewport); - await gotoPhoneSurface(page, "/calculators", 112); - - const dock = page.getByTestId("calculators-phone-dock"); - const input = dock.getByRole("searchbox", { name: "Search calculators" }); - await input.fill("PHQ-9"); - await input.press("Enter"); - await expect(page.getByRole("button", { name: "Close", exact: true })).toBeVisible(); - await page.getByRole("button", { name: "Close", exact: true }).click(); - await expect(dock).toBeVisible(); - await expect(input).not.toBeFocused(); - - await addPhoneScrollRunway(page); - await dragScrollUntilHidden(page, 900, 24); - await expect(dock).toHaveAttribute("data-scroll-hidden", "true"); -}); - -test("page-owned focus clearance places a below-fold calculator control above the visible dock", async ({ page }) => { - await page.setViewportSize(phoneViewport); - await gotoPhoneSurface(page, "/calculators", 112); - await expect(page.getByTestId("calculators-phone-dock")).toBeVisible(); - - const geometry = await page.evaluate(() => { - const owner = document.querySelector('[data-reserve-owner="calculator"]'); - const dock = document.querySelector('[data-testid="calculators-phone-dock"]'); - if (!owner || !dock) throw new Error("calculator focus-clearance owners were not rendered"); - const runway = document.createElement("div"); - runway.style.height = "1400px"; - runway.setAttribute("aria-hidden", "true"); - const target = document.createElement("button"); - target.type = "button"; - target.textContent = "Focus clearance probe"; - target.dataset.testid = "phone-focus-clearance-probe"; - const tail = document.createElement("div"); - tail.style.height = "400px"; - tail.setAttribute("aria-hidden", "true"); - owner.append(runway, target, tail); - window.scrollTo(0, 0); - target.focus({ preventScroll: true }); - target.scrollIntoView({ block: "nearest" }); - const targetRect = target.getBoundingClientRect(); - const dockRect = dock.getBoundingClientRect(); - return { - targetBottom: targetRect.bottom, - dockTop: dockRect.top, - scrollMarginBottom: Number.parseFloat(getComputedStyle(target).scrollMarginBottom), - dockHidden: dock.getAttribute("data-scroll-hidden") === "true", - }; - }); - - expect(geometry.dockHidden, "measure before hide-on-scroll can release the visible dock").toBe(false); - expect(geometry.scrollMarginBottom, "the page-owned reserve must reach focused descendants").toBeGreaterThan(100); - expect(geometry.targetBottom, "focused content must land above the visible page-owned dock").toBeLessThanOrEqual( - geometry.dockTop + 1, - ); -}); -// Renamed 2026-07-30: the old title said "96px of near-bottom runway", but 96 is -// `collapseRunwaySlack`, which only exists on the in-flow branch of -// `computeScrollHideUpdate`. Reserve-only overlay has no slack term at all — its -// near-bottom refusal is `offset <= postCollapseMaxOffset + bottomClampTolerance`. -// One test covers both owners, so the title names the behaviour rather than one -// motion's constant. No flake-ledger or allowlist entry referenced the old name. -test("calculator combined chrome refuses a near-bottom hide that would clamp the reader", async ({ page }) => { - await page.emulateMedia({ reducedMotion: "no-preference" }); - await page.setViewportSize(phoneViewport); - await gotoPhoneSurface(page, "/calculators", 112); - await addPhoneScrollRunway(page); - - const { frames, diag } = await page.evaluate(async () => { - const main = document.getElementById("main-content"); - const dock = document.querySelector('[data-testid="calculators-phone-dock"]'); - const reserve = document.querySelector('[data-testid="calculators-search-page"]'); - if (!main || !dock || !reserve) throw new Error("calculator reserve geometry was not rendered"); - const mainOwnsScroll = - /^(?:auto|scroll|overlay)$/.test(getComputedStyle(main).overflowY) && main.scrollHeight > main.clientHeight + 1; - const scrollOwner = mainOwnsScroll ? main : (document.scrollingElement ?? document.documentElement); - const read = () => ({ - dockHidden: dock.getAttribute("data-scroll-hidden") === "true", - headerHidden: - document.querySelector('[data-testid="universal-header-collapse"]')?.getAttribute("data-scroll-hidden") === - "true", - scrollTop: scrollOwner.scrollTop, - maxOffset: scrollOwner.scrollHeight - scrollOwner.clientHeight, - }); - const collapse = document.querySelector('[data-testid="universal-header-collapse"]'); - const safeArea = document.querySelector('[data-testid="chrome-safe-area-top"]'); - // Match `readChromeCollapseMetrics`: overlay charges zero top release, so - // the near-bottom gate is reserve-only. Including the still-present header - // height here overstates the budget and scrolls into a zone where - // reserve-only policy *allows* hide — the opposite of this regression guard. - const phoneOverlayMotion = collapse?.dataset.phoneMotion === "overlay"; - const headerRelease = - !phoneOverlayMotion && collapse && getComputedStyle(collapse).display === "grid" - ? collapse.getBoundingClientRect().height - : 0; - const safeAreaRelease = headerRelease > 0 && safeArea ? safeArea.getBoundingClientRect().height : 0; - const reserveRelease = Math.max(0, Number.parseFloat(getComputedStyle(reserve).paddingBottom) || 0); - const collapseBudget = headerRelease + safeAreaRelease + reserveRelease; - // Both target offsets are derived from `computeScrollHideUpdate`'s own - // refusal clauses (use-hide-on-scroll.ts) rather than from a chosen number, - // because the two motions refuse for genuinely different reasons: - // - // in-flow refuse while runwayAfterCollapse <= revealIntentDistance - // + collapseRunwaySlack (12 + 96) - // reserve-only refuse while offset > postCollapseMaxOffset - // + bottomClampTolerance - // - // Mirrored constants — keep in step with use-hide-on-scroll.ts. - const revealIntentDistance = 12; - const collapseRunwaySlack = 96; - const bottomClampTolerance = 1; - const maxOffset = Math.max(0, scrollOwner.scrollHeight - scrollOwner.clientHeight); - const postCollapseMaxOffset = Math.max(0, maxOffset - collapseBudget); - // Reserve-only: land strictly inside the refusal band but not at the very - // bottom, so this stays a boundary case rather than the trivial one. - const overlayTarget = Math.min( - maxOffset, - postCollapseMaxOffset + bottomClampTolerance + Math.max(1, Math.round(reserveRelease / 3)), - ); - const inFlowTarget = Math.max(0, maxOffset - collapseBudget - collapseRunwaySlack); - scrollOwner.scrollTop = phoneOverlayMotion ? overlayTarget : inFlowTarget; - // Capture the offset we actually landed on BEFORE dispatching, and never - // re-read it afterwards. If the policy regresses and the hide is wrongly - // allowed, the reserve collapses, maxOffset shrinks and the browser clamps - // scrollTop — so a post-loop read reports the offset after the bug rather - // than the offset under test. Asserting the band against that clamped value - // made a policy regression surface as "the test setup is wrong", which is - // the opposite of a useful failure. Found by removing the refusal clause and - // watching this test fail for the wrong reason (2026-07-30). - const targetScrollTop = scrollOwner.scrollTop; - (mainOwnsScroll ? main : window).dispatchEvent(new Event("scroll", { bubbles: true })); - const frames = [read()]; - for (let index = 0; index < 18; index += 1) { - await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); - frames.push(read()); - } - return { - frames, - diag: { - phoneOverlayMotion, - reserveRelease, - collapseBudget, - maxOffset, - postCollapseMaxOffset, - targetScrollTop, - // The other reserve-only clause. If the post-collapse range were shorter - // than this, hide would be refused for being too short overall and the - // near-bottom clause would never be reached — the test would pass - // without testing anything. - minimumRangeForHide: 8 + 24, - inFlowRunwayAfterCollapse: maxOffset - targetScrollTop - collapseBudget, - inFlowRefusalCeiling: revealIntentDistance + collapseRunwaySlack, - }, - }; - }); - - // Non-vacuity first. "Chrome stayed visible" is only evidence of a refusal if - // the scroll position actually sat inside the refusal band AND the other - // clauses were satisfied — otherwise chrome stays visible for an unrelated - // reason and this test silently guards nothing. Added 2026-07-30: the overlay - // branch previously used chosen offsets, so nothing checked that it landed - // anywhere meaningful. - expect(diag.collapseBudget, "chrome must have geometry to release, or nothing can be refused").toBeGreaterThan(1); - if (diag.phoneOverlayMotion) { - expect( - diag.postCollapseMaxOffset, - "post-collapse range must clear the short-range clause, so the near-bottom clause is what refuses", - ).toBeGreaterThanOrEqual(diag.minimumRangeForHide); - expect(diag.targetScrollTop, "reserve-only offset must sit inside the near-bottom refusal band").toBeGreaterThan( - diag.postCollapseMaxOffset + 1, - ); - } else { - expect( - diag.inFlowRunwayAfterCollapse, - "in-flow runway after collapse must sit inside the slack refusal band", - ).toBeLessThanOrEqual(diag.inFlowRefusalCeiling); - } - - expect(frames.every((frame) => !frame.headerHidden && !frame.dockHidden)).toBe(true); - for (let index = 1; index < frames.length; index += 1) { - expect(frames[index].scrollTop, "rejected near-bottom collapse cannot clamp scrollTop").toBeCloseTo( - frames[0].scrollTop, - 0, + await filterTrigger.click(); + const filterSheet = page.getByTestId("calculators-filter-sheet"); + await expect(filterSheet).toBeVisible(); + const filterGeometry = await filterSheet.evaluate((element) => ({ + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + right: element.getBoundingClientRect().right, + })); + expect(filterGeometry.scrollWidth, `${width}px calculator filters must not overflow`).toBeLessThanOrEqual( + filterGeometry.clientWidth + 1, ); - expect(frames[index].maxOffset, "rejected near-bottom collapse keeps the scroll range stable").toBeCloseTo( - frames[0].maxOffset, - 0, + expect(filterGeometry.right, `${width}px calculator filters stay inside the viewport`).toBeLessThanOrEqual( + width + 1, ); + await page.keyboard.press("Escape"); + await expect(filterSheet).toBeHidden(); + await expect(filterTrigger).toBeFocused(); } -}); -test("calculator reserve and dock hide and reveal monotonically with sufficient near-bottom runway", async ({ - page, -}) => { - await page.emulateMedia({ reducedMotion: "no-preference" }); - await page.setViewportSize(phoneViewport); - await gotoPhoneSurface(page, "/calculators", 112); - await addPhoneScrollRunway(page); - - const frames = await page.evaluate(async () => { - const main = document.getElementById("main-content"); - const dock = document.querySelector('[data-testid="calculators-phone-dock"]'); - const reserve = document.querySelector('[data-testid="calculators-search-page"]'); - if (!main || !dock || !reserve) throw new Error("calculator reserve geometry was not rendered"); - const mainOwnsScroll = - /^(?:auto|scroll|overlay)$/.test(getComputedStyle(main).overflowY) && main.scrollHeight > main.clientHeight + 1; - const scrollOwner = mainOwnsScroll ? main : (document.scrollingElement ?? document.documentElement); - const read = () => ({ - hidden: dock.getAttribute("data-scroll-hidden") === "true", - dockTop: dock.getBoundingClientRect().top, - paddingBottom: Number.parseFloat(getComputedStyle(reserve).paddingBottom), - scrollTop: scrollOwner.scrollTop, - maxOffset: scrollOwner.scrollHeight - scrollOwner.clientHeight, - scrollAnchor: getComputedStyle(scrollOwner).overflowAnchor, - transitionDuration: getComputedStyle(reserve).transitionDuration, - }); - const collapse = document.querySelector('[data-testid="universal-header-collapse"]'); - const safeArea = document.querySelector('[data-testid="chrome-safe-area-top"]'); - const collapseBudget = - (collapse?.getBoundingClientRect().height ?? 0) + - (safeArea?.getBoundingClientRect().height ?? 0) + - Number.parseFloat(getComputedStyle(reserve).paddingBottom); - const targetOffset = Math.max(0, scrollOwner.scrollHeight - scrollOwner.clientHeight - collapseBudget - 256); - scrollOwner.scrollTop = Math.max(0, targetOffset - 24); - (mainOwnsScroll ? main : window).dispatchEvent(new Event("scroll", { bubbles: true })); - await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); - scrollOwner.scrollTop = targetOffset; - (mainOwnsScroll ? main : window).dispatchEvent(new Event("scroll", { bubbles: true })); - const hiding = [read()]; - for (let index = 0; index < 18; index += 1) { - await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); - hiding.push(read()); - } - scrollOwner.scrollTop -= 12; - (mainOwnsScroll ? main : window).dispatchEvent(new Event("scroll", { bubbles: true })); - const reveal = [read()]; - for (let index = 0; index < 16; index += 1) { - await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); - reveal.push(read()); - } - return { hiding, reveal }; - }); - - const firstHidden = frames.hiding.findIndex((frame) => frame.hidden); - expect(firstHidden, "sufficient-runway downward intent hides the calculator dock").toBeGreaterThan(-1); - const hiding = frames.hiding.slice(firstHidden); - expect(hiding.some((frame) => frame.transitionDuration.includes("0.24s"))).toBe(true); - expect( - hiding.some((frame) => frame.scrollAnchor === "none"), - "page-owned reserve transition disables anchoring", - ).toBe(true); - for (let index = 1; index < hiding.length; index += 1) { - expect(hiding[index].paddingBottom, "calculator reserve never reverses during hide").toBeLessThanOrEqual( - hiding[index - 1].paddingBottom + 1, - ); - expect(hiding[index].dockTop, "dock never reverses during hide").toBeGreaterThanOrEqual( - hiding[index - 1].dockTop - 1, - ); - expect( - hiding[index].scrollTop, - `scroll remains monotonic during hide at frame ${index}: ${JSON.stringify({ previous: hiding[index - 1], current: hiding[index] })}`, - ).toBeGreaterThanOrEqual(hiding[index - 1].scrollTop - 1); - } - const firstRevealed = frames.reveal.findIndex((frame) => !frame.hidden); - expect(firstRevealed, "upward intent reveals the calculator dock").toBeGreaterThan(-1); - const revealing = frames.reveal.slice(firstRevealed); - for (let index = 1; index < revealing.length; index += 1) { - expect(revealing[index].paddingBottom, "calculator reserve never reverses during reveal").toBeGreaterThanOrEqual( - revealing[index - 1].paddingBottom - 1, - ); - expect(revealing[index].dockTop, "dock never reverses during reveal").toBeLessThanOrEqual( - revealing[index - 1].dockTop + 1, - ); - expect(revealing[index].scrollTop, "scroll remains monotonic during reveal").toBeLessThanOrEqual( - revealing[index - 1].scrollTop + 1, - ); + await page.setViewportSize({ width: 390, height: 844 }); + for (const media of [ + { colorScheme: "dark" as const, forcedColors: "none" as const, reducedMotion: "no-preference" as const }, + { colorScheme: "light" as const, forcedColors: "none" as const, reducedMotion: "reduce" as const }, + { colorScheme: "light" as const, forcedColors: "active" as const, reducedMotion: "no-preference" as const }, + ]) { + await page.emulateMedia(media); + await gotoPhoneSurface(page, "/calculators?q=depression&run=1", 112); + await expect(page.getByTestId("calculators-search-page")).toBeVisible(); + await expect(page.getByTestId("calculators-filter-trigger-phone")).toBeVisible(); } }); diff --git a/tests/ui-universal-search.spec.ts b/tests/ui-universal-search.spec.ts index 2d40d2379..6ee42b12b 100644 --- a/tests/ui-universal-search.spec.ts +++ b/tests/ui-universal-search.spec.ts @@ -146,6 +146,21 @@ async function openComposer(page: Page, href = "/?mode=documents&focus=1") { test.beforeEach(stubZeroTouchPoints); test.describe("universal search typeahead", () => { + test("keeps calculator command suggestions local", async ({ page }) => { + let universalRequestCount = 0; + await page.route(/\/api\/search\/universal(?:\?.*)?$/, async (route) => { + universalRequestCount += 1; + await route.fulfill({ status: 204 }); + }); + + const input = await openComposer(page, "/calculators?focus=1"); + await input.fill("depression"); + await expect(page.getByRole("option", { name: /depression severity.*PHQ-9/i })).toBeVisible(); + await page.waitForTimeout(500); + + expect(universalRequestCount).toBe(0); + }); + test("shows grouped cross-entity results while typing", async ({ page }) => { await mockUniversalSearch(page); const input = await openComposer(page); From 91f6ef6c0233f8369c43527a0aa9f5b053c3d1f3 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:02:38 +0800 Subject: [PATCH 02/12] docs(review): record calculators mode review --- ...852133f97afa60a07d554e02ec2055fd40acb890720fbcdd8c3.record.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/branch-review-records/3c872a41535a5852133f97afa60a07d554e02ec2055fd40acb890720fbcdd8c3.record.md diff --git a/docs/branch-review-records/3c872a41535a5852133f97afa60a07d554e02ec2055fd40acb890720fbcdd8c3.record.md b/docs/branch-review-records/3c872a41535a5852133f97afa60a07d554e02ec2055fd40acb890720fbcdd8c3.record.md new file mode 100644 index 000000000..5d0c4169b --- /dev/null +++ b/docs/branch-review-records/3c872a41535a5852133f97afa60a07d554e02ec2055fd40acb890720fbcdd8c3.record.md @@ -0,0 +1 @@ +| 2026-08-14 | codex/calculators-mode | 8e7c9d65463ab37bc6e6cd658ec8091b4260db18 | calculators first-class mode | Clean after resolving local-only typeahead and legacy URL normalization | 20/20 focused unit/DOM; typecheck; lint; prior verify:ui 433/433; targeted browser request-interception queued then not run due coordinator contention | From 4db080a14365a3facd0c2b662a3ba93601a2fca4 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:50:03 +0800 Subject: [PATCH 03/12] fix(calculators): keep shared search local and scoped --- .../master-search-header.tsx | 9 +++++- .../clinical-dashboard/mode-action-popup.tsx | 12 +++++++ tests/mode-menu-prefetch.dom.test.tsx | 32 +++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 22badf5d9..56c66f729 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -374,6 +374,7 @@ export function MasterSearchHeader({ selectedSearch.kind === "forms" || selectedSearch.kind === "services" || selectedSearch.kind === "tools" || + selectedSearch.kind === "calculators" || selectedSearch.kind === "favourites" || selectedSearch.kind === "specifiers" || selectedSearch.kind === "formulation" || @@ -657,7 +658,9 @@ export function MasterSearchHeader({ ? "formulation" : searchMode === "tools" ? "tools" - : searchMode === "factsheets" + : searchMode === "calculators" + ? "calculators" + : searchMode === "factsheets" ? "factsheets" : "answer"; const actionMenuItems = modeActionItemsFor(actionMenuSetId); @@ -814,6 +817,10 @@ export function MasterSearchHeader({ onSearchModeChange("tools"); return; } + if (actionId === "calculators-browse") { + router.push("/calculators"); + return; + } if (actionId === "differentials-build") { onSearchModeChange("differentials"); onQueryChange(trimmedQuery || "acute confusion differential diagnosis"); diff --git a/src/components/clinical-dashboard/mode-action-popup.tsx b/src/components/clinical-dashboard/mode-action-popup.tsx index 0da42b6f8..dbe4a78a7 100644 --- a/src/components/clinical-dashboard/mode-action-popup.tsx +++ b/src/components/clinical-dashboard/mode-action-popup.tsx @@ -19,6 +19,7 @@ import { BookOpenText, TriangleAlert, CalendarDays, + Calculator, Check, ChevronDown, ChevronRight, @@ -57,6 +58,7 @@ export type ModeActionSetId = | "forms" | "favourites" | "tools" + | "calculators" | "differentials" | "dsm" | "specifiers" @@ -123,6 +125,7 @@ export type ModeActionId = | "medication-access" | "tools-browse" | "tools-new" + | "calculators-browse" | "differentials-build" | "differentials-criteria" | "differentials-documents" @@ -227,6 +230,15 @@ const modeActionSets = { }, { id: "tools-new", label: "New answer", description: "Clear the current thread", icon: Sparkles }, ], + calculators: [ + { + id: "calculators-browse", + label: "Browse calculators", + description: "Open the calculator catalogue", + icon: Calculator, + primary: true, + }, + ], differentials: [ { id: "differentials-build", diff --git a/tests/mode-menu-prefetch.dom.test.tsx b/tests/mode-menu-prefetch.dom.test.tsx index 0efbf9a96..8f40a1a34 100644 --- a/tests/mode-menu-prefetch.dom.test.tsx +++ b/tests/mode-menu-prefetch.dom.test.tsx @@ -87,6 +87,38 @@ describe("mode menu destination prefetch", () => { router.prefetch.mockReset(); }); + it("keeps calculator submission enabled when document data is unavailable", async () => { + const user = userEvent.setup(); + const onAsk = vi.fn(); + + render( + , + ); + + const submit = screen.getByRole("button", { name: "Search clinical calculators" }); + expect(submit).toBeEnabled(); + await user.click(submit); + expect(onAsk).toHaveBeenCalledTimes(1); + }); + + it("shows calculator-specific actions instead of Answer actions", async () => { + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole("button", { name: "Open calculators options" })); + + const actions = await screen.findByRole("menu", { name: "Useful actions" }); + expect(within(actions).getByRole("menuitem", { name: "Browse calculators" })).toBeVisible(); + expect(within(actions).queryByRole("menuitem", { name: "New question" })).toBeNull(); + expect(within(actions).queryByRole("menuitem", { name: "Add document" })).toBeNull(); + }); + it("prefetches the shared-home selection URL when the user points at a mode", async () => { const user = userEvent.setup(); const documents = guestModeHomes().find((mode) => mode.id === "documents"); From 805b84b6f506bc69c8e6f0242a81ad2030f98ca0 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:14:32 +0800 Subject: [PATCH 04/12] docs(review): record final calculators base sync --- ...9170cc5f4dc265e6b1e647dac6fb84d348f6b12fea7f87e7968.record.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/branch-review-records/1bcd7481c03879170cc5f4dc265e6b1e647dac6fb84d348f6b12fea7f87e7968.record.md diff --git a/docs/branch-review-records/1bcd7481c03879170cc5f4dc265e6b1e647dac6fb84d348f6b12fea7f87e7968.record.md b/docs/branch-review-records/1bcd7481c03879170cc5f4dc265e6b1e647dac6fb84d348f6b12fea7f87e7968.record.md new file mode 100644 index 000000000..ce510798d --- /dev/null +++ b/docs/branch-review-records/1bcd7481c03879170cc5f4dc265e6b1e647dac6fb84d348f6b12fea7f87e7968.record.md @@ -0,0 +1 @@ +| 2026-08-15 | codex/calculators-mode | 1938a6bc5f1845c67c85584ded78b3be1c8a961a | Calculators first-class mode | Merged the latest required base after the final freshness check; calculator local-search and action-menu P2 corrections remain intact. | git diff --check; ledger inbox/outstanding-issues/branch-ledger/discipline guards; reconciled search-chrome contract assertion; focused Vitest remains unavailable because this isolated worktree has no node_modules | From 9a431b5231d6b3430bdbb2c09d193dd578d1ed3e Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:34:19 +0800 Subject: [PATCH 05/12] docs: record calculators base update --- ...fb166c1c4267644135f54ba5f04f0c8c06a38fc88ddc42c85e6.record.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/branch-review-records/711ddbc0e2119fb166c1c4267644135f54ba5f04f0c8c06a38fc88ddc42c85e6.record.md diff --git a/docs/branch-review-records/711ddbc0e2119fb166c1c4267644135f54ba5f04f0c8c06a38fc88ddc42c85e6.record.md b/docs/branch-review-records/711ddbc0e2119fb166c1c4267644135f54ba5f04f0c8c06a38fc88ddc42c85e6.record.md new file mode 100644 index 000000000..8dc6e9d4f --- /dev/null +++ b/docs/branch-review-records/711ddbc0e2119fb166c1c4267644135f54ba5f04f0c8c06a38fc88ddc42c85e6.record.md @@ -0,0 +1 @@ +| 2026-08-15 | codex/calculators-mode | 39423d88bced6494ad2eed30f43fb859ab6abefb | Calculators mode: final current-base merge | Merged latest required base after prior focused review; no conflicts or new confirmed P0-P2 findings | git diff --check; ledger-inbox; outstanding-issues; branch-review-ledger; ledger-write-discipline; calculator registration assertion | From d42b1393c6185def0470f1eb421f1923b3e917ff Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:00:52 +0800 Subject: [PATCH 06/12] docs: record calculators base sync --- ...1dbdbfb1360d45e46027a5b3debdb029af744345900ca5acdc1.record.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/branch-review-records/4dbb7f7d041801dbdbfb1360d45e46027a5b3debdb029af744345900ca5acdc1.record.md diff --git a/docs/branch-review-records/4dbb7f7d041801dbdbfb1360d45e46027a5b3debdb029af744345900ca5acdc1.record.md b/docs/branch-review-records/4dbb7f7d041801dbdbfb1360d45e46027a5b3debdb029af744345900ca5acdc1.record.md new file mode 100644 index 000000000..4fa0ca268 --- /dev/null +++ b/docs/branch-review-records/4dbb7f7d041801dbdbfb1360d45e46027a5b3debdb029af744345900ca5acdc1.record.md @@ -0,0 +1 @@ +| 2026-08-15 | codex/calculators-mode | d8fde21e65ce010b72bc56f1e57334cdfac764c5 | required base sync through main 17402395 | Approved — required main update merged; prior calculator-mode review remains applicable with no PR-path conflict | git diff --check; ci-change-scope self-test; ledger/inbox/outstanding/discipline guards passed | From 4e6478f922f8cfede2759095ecb35e6da81e10e1 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:10:51 +0800 Subject: [PATCH 07/12] docs: record calculators latest base sync --- ...b7149b664c65dc98e4b7e10477e73f22ca8c1ccfe02d4b84f50.record.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/branch-review-records/1f2f64dc3b528b7149b664c65dc98e4b7e10477e73f22ca8c1ccfe02d4b84f50.record.md diff --git a/docs/branch-review-records/1f2f64dc3b528b7149b664c65dc98e4b7e10477e73f22ca8c1ccfe02d4b84f50.record.md b/docs/branch-review-records/1f2f64dc3b528b7149b664c65dc98e4b7e10477e73f22ca8c1ccfe02d4b84f50.record.md new file mode 100644 index 000000000..3022e100c --- /dev/null +++ b/docs/branch-review-records/1f2f64dc3b528b7149b664c65dc98e4b7e10477e73f22ca8c1ccfe02d4b84f50.record.md @@ -0,0 +1 @@ +| 2026-08-15 | codex/calculators-mode | bbf2207102a0b40b5ba048e8e9a04f37add9bf2c | required base sync through main 3824095 | Approved — required main update merged after calculator command follow-ups; no PR-path conflict | git diff --check; ci-change-scope self-test; ledger/inbox/outstanding/discipline guards passed | From 6ff4aca7e5d43fb5be8a313cde57a994bbfee9f0 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:22:59 +0800 Subject: [PATCH 08/12] docs(ledger): record calculator CI fixes --- ...92b04d348313e763ff4a51012c6de6ec22028f0f2b600adeaa7.record.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/branch-review-records/24336297cd7a192b04d348313e763ff4a51012c6de6ec22028f0f2b600adeaa7.record.md diff --git a/docs/branch-review-records/24336297cd7a192b04d348313e763ff4a51012c6de6ec22028f0f2b600adeaa7.record.md b/docs/branch-review-records/24336297cd7a192b04d348313e763ff4a51012c6de6ec22028f0f2b600adeaa7.record.md new file mode 100644 index 000000000..bd396c8cb --- /dev/null +++ b/docs/branch-review-records/24336297cd7a192b04d348313e763ff4a51012c6de6ec22028f0f2b600adeaa7.record.md @@ -0,0 +1 @@ +| 2026-08-15 | codex/calculators-mode | 3b896be3f5f6ea67df10c991257aeb05b41af619 | Fix calculator regression tests from exact-head CI and merge main d301d8f4 | fixed | git diff --check; static ledger guards; focused tests blocked without node_modules | From 58df709d2839f06f571540443b1f056b05981913 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:45:45 +0800 Subject: [PATCH 09/12] fix(calculators): repair suggestion regression test --- src/components/clinical-dashboard/master-search-header.tsx | 4 ++-- tests/mode-menu-prefetch.dom.test.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index aa47d8153..021e13312 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -663,8 +663,8 @@ export function MasterSearchHeader({ : searchMode === "calculators" ? "calculators" : searchMode === "factsheets" - ? "factsheets" - : "answer"; + ? "factsheets" + : "answer"; const actionMenuItems = modeActionItemsFor(actionMenuSetId); const actionMenuButtonLabel = `Open ${selectedAppMode.label.toLowerCase()} options`; diff --git a/tests/mode-menu-prefetch.dom.test.tsx b/tests/mode-menu-prefetch.dom.test.tsx index 033042ddc..1a12cf09d 100644 --- a/tests/mode-menu-prefetch.dom.test.tsx +++ b/tests/mode-menu-prefetch.dom.test.tsx @@ -136,7 +136,7 @@ describe("mode menu destination prefetch", () => { const input = screen.getByTestId("global-search-input"); await user.clear(input); await user.type(input, "depression"); - await user.click(await screen.findByRole("option", { name: "depression severity" })); + await user.click(await screen.findByRole("option", { name: /depression severity.*PHQ-9/i })); expect(onAsk).toHaveBeenCalledWith("depression severity"); } finally { From 011ac3065a0aaa026d0aaf473701b0e9e8c554c6 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:45:58 +0800 Subject: [PATCH 10/12] docs(ledger): record calculator CI follow-up --- ...a7a9ee8b0daf3b933bb51b159fedb9b0e5fce759cb470f06252.record.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/branch-review-records/35ae4a75db629a7a9ee8b0daf3b933bb51b159fedb9b0e5fce759cb470f06252.record.md diff --git a/docs/branch-review-records/35ae4a75db629a7a9ee8b0daf3b933bb51b159fedb9b0e5fce759cb470f06252.record.md b/docs/branch-review-records/35ae4a75db629a7a9ee8b0daf3b933bb51b159fedb9b0e5fce759cb470f06252.record.md new file mode 100644 index 000000000..0a36ee596 --- /dev/null +++ b/docs/branch-review-records/35ae4a75db629a7a9ee8b0daf3b933bb51b159fedb9b0e5fce759cb470f06252.record.md @@ -0,0 +1 @@ +| 2026-08-15 | codex/calculators-mode | 1ec9eb1028bf29ac633857846fe69c91249a64c7 | calculator suggestion regression test accessibility and formatting follow-up | Fixed the focused calculator suggestion test to match the full accessible option label; corrected formatter-required nested ternary indentation. Targeted Vitest unavailable because this isolated worktree has no node_modules/vitest. | git diff --check; ledger-inbox; outstanding-issues; branch-review-ledger; ledger-write-discipline; ci-change-scope --self-test | From ec869347b0ade65c222430e3dfaa7ed015c39dca Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:03:11 +0800 Subject: [PATCH 11/12] style(calculators): align search chrome table --- docs/search-chrome-behaviour.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index 39e179d06..df06a54db 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -4,17 +4,17 @@ This repo uses one shared search experience across the global shell, dashboard r ## Page ownership model -| Page state | Composer placement | Reserve owner | -| ------------------------------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -| Answer home / standalone mode homes | In-flow hero composer on phones and larger breakpoints | Page content; no fixed phone dock reserve | -| Tools directory (`/tools`, no submitted query) | In-flow composer above the all-tools results directory | Page content; no fixed phone dock reserve | -| Submitted/search-result views | Compact bottom dock on phones; in normal page flow on tablets and desktops | Shell/dashboard `--mobile-composer-reserve` on phones; page content on desktop | -| Answer result view | Overlaid glass header plus answer composer dock | Dashboard `#main-content` top/bottom reserves | -| Document detail/source routes | `DocumentViewer` floating composer | `DocumentViewer` content padding | -| Document section navigation | Header row disclosure (phone sheet) + rail index card at `lg` | None — adds no chrome and no reserve | -| Record page breadcrumb header | Same header row without the disclosure or track; view mode inline from `sm` | None — portals into the phone collapse row, sticky at `sm+` | -| Calculators (`/calculators`) | In-flow hero composer at home; shared compact dock after submission | Page content at home; shell reserve for submitted results | -| Info/detail pages with no composer | No fixed composer | Idle shell padding only | +| Page state | Composer placement | Reserve owner | +| ---------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| Answer home / standalone mode homes | In-flow hero composer on phones and larger breakpoints | Page content; no fixed phone dock reserve | +| Tools directory (`/tools`, no submitted query) | In-flow composer above the all-tools results directory | Page content; no fixed phone dock reserve | +| Submitted/search-result views | Compact bottom dock on phones; in normal page flow on tablets and desktops | Shell/dashboard `--mobile-composer-reserve` on phones; page content on desktop | +| Answer result view | Overlaid glass header plus answer composer dock | Dashboard `#main-content` top/bottom reserves | +| Document detail/source routes | `DocumentViewer` floating composer | `DocumentViewer` content padding | +| Document section navigation | Header row disclosure (phone sheet) + rail index card at `lg` | None — adds no chrome and no reserve | +| Record page breadcrumb header | Same header row without the disclosure or track; view mode inline from `sm` | None — portals into the phone collapse row, sticky at `sm+` | +| Calculators (`/calculators`) | In-flow hero composer at home; shared compact dock after submission | Page content at home; shell reserve for submitted results | +| Info/detail pages with no composer | No fixed composer | Idle shell padding only | ## Default in-page navigation template From 1618f53110f4456b25e5e5335b90e135d703135e Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:05:19 +0800 Subject: [PATCH 12/12] docs(ledger): record calculator search-chrome formatter follow-up --- ...6fb39e8fd09c38c3cc169be8ad8f8459b70b86140a155a88967.record.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/branch-review-records/5c0ac2f4e15f46fb39e8fd09c38c3cc169be8ad8f8459b70b86140a155a88967.record.md diff --git a/docs/branch-review-records/5c0ac2f4e15f46fb39e8fd09c38c3cc169be8ad8f8459b70b86140a155a88967.record.md b/docs/branch-review-records/5c0ac2f4e15f46fb39e8fd09c38c3cc169be8ad8f8459b70b86140a155a88967.record.md new file mode 100644 index 000000000..19f50bb0e --- /dev/null +++ b/docs/branch-review-records/5c0ac2f4e15f46fb39e8fd09c38c3cc169be8ad8f8459b70b86140a155a88967.record.md @@ -0,0 +1 @@ +| 2026-08-15 | codex/calculators-mode | ff3dfa4e32e63ccf288438a6371608b42847c18e | calculator search-chrome documentation formatter follow-up | Aligned the search-chrome ownership table to the new longest mode label after the changed-file formatter reported Markdown table drift. | git diff --check; ledger-inbox; outstanding-issues; branch-review-ledger; ledger-write-discipline; ci-change-scope --self-test |