From c9a81097a9a5cf74b038a011833b7ea13952555d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 09:18:38 +0000 Subject: [PATCH 01/28] feat(browse): section-named A to Z lists with nothing typed; DSM by chapter Audit item 6 (display-only). With no query, DSM, Specifiers, Formulation, Therapy and Forms titled their list "All" and badged cards "Top match" / "Why matched", which read as if the app had picked something for a patient. - Results band: new emptyQuerySubject prop shows the section name ("DSM diagnoses", "Specifiers", "Formulation mechanisms", "Therapies", "Forms") as the heading instead of "All" when nothing is typed. - Specifiers, Formulation, Therapy: empty-query lists sorted A to Z in the UI component; no Top match / Best match; Therapy cards label the first column "Best used for" instead of "Why matched" while browsing. - Forms: register headings read "Forms" / "A to Z by form code" (register keeps its existing form-code order). - DSM: browse groups by chapter in catalogue order as collapsible
headings with counts; rows A to Z with "[...]" generic templates last; compact one-line rows on a phone (title + ICD code); reference-only note kept, and shown on phones too. - Typed queries keep today's ranked behaviour exactly. No search or ranking library changed. Tests: new tests/browse-a-to-z.dom.test.tsx and tests/therapy-browse-a-to-z.dom.test.tsx; forms register listing and the therapy accessibility journey updated for the new headings. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../search-results-header-band.tsx | 18 +- src/components/dsm/dsm-search-page.tsx | 179 ++++++++++++++--- .../forms/forms-search-results-page.tsx | 7 +- .../formulation/formulation-home-page.tsx | 23 ++- .../specifiers/specifiers-home-page.tsx | 25 ++- .../therapy-compass/screens/search-screen.tsx | 20 +- .../therapy-compass/therapy-card.tsx | 15 +- tests/browse-a-to-z.dom.test.tsx | 182 ++++++++++++++++++ ...forms-search-register-listing.dom.test.tsx | 9 +- tests/therapy-browse-a-to-z.dom.test.tsx | 107 ++++++++++ tests/ui-accessibility.spec.ts | 5 +- 11 files changed, 541 insertions(+), 49 deletions(-) create mode 100644 tests/browse-a-to-z.dom.test.tsx create mode 100644 tests/therapy-browse-a-to-z.dom.test.tsx diff --git a/src/components/clinical-dashboard/search-results-header-band.tsx b/src/components/clinical-dashboard/search-results-header-band.tsx index 8b0c74c213..0bfb842feb 100644 --- a/src/components/clinical-dashboard/search-results-header-band.tsx +++ b/src/components/clinical-dashboard/search-results-header-band.tsx @@ -150,6 +150,7 @@ export function SearchResultsHeaderBand({ resultNoun: resultNounOverride, hideEmptyQuery = false, emptyQueryLabel, + emptyQuerySubject, catalogueDegraded = false, className, }: { @@ -228,6 +229,12 @@ export function SearchResultsHeaderBand({ hideEmptyQuery?: boolean; /** Accessible region name used when `hideEmptyQuery` omits the subject. */ emptyQueryLabel?: string; + /** + * The section name shown as the heading when nothing has been typed, in place + * of "All". A browse list is the whole section in A to Z order, not a search + * that picked something, so it is titled by what it is ("DSM diagnoses"). + */ + emptyQuerySubject?: string; /** * What this count counted, when the mode registry's noun is not specific * enough to be true. @@ -244,9 +251,14 @@ export function SearchResultsHeaderBand({ className?: string; }) { const trimmedQuery = query.trim(); - const displayQuery = trimmedQuery || "All"; - const showQuerySubject = Boolean(trimmedQuery) || !hideEmptyQuery; - const regionLabel = showQuerySubject ? `Search results for ${displayQuery}` : (emptyQueryLabel ?? "Catalogue"); + const browseSubject = trimmedQuery ? undefined : emptyQuerySubject?.trim() || undefined; + const displayQuery = trimmedQuery || browseSubject || "All"; + const showQuerySubject = Boolean(trimmedQuery) || Boolean(browseSubject) || !hideEmptyQuery; + const regionLabel = browseSubject + ? `Browse ${browseSubject}` + : showQuerySubject + ? `Search results for ${displayQuery}` + : (emptyQueryLabel ?? "Catalogue"); // `status` wins when both are passed; `loading` is the deprecated shim. const resolvedStatus: SearchResultsBandStatus = status ?? (loading ? "loading" : "ready"); // The clinical invariant, expressed once: a search that failed has no count to diff --git a/src/components/dsm/dsm-search-page.tsx b/src/components/dsm/dsm-search-page.tsx index e6d4d25505..709b961722 100644 --- a/src/components/dsm/dsm-search-page.tsx +++ b/src/components/dsm/dsm-search-page.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { useId, useMemo, useState, useSyncExternalStore } from "react"; -import { BookOpenCheck, Check, ChevronRight, CircleAlert, GitCompareArrows, SearchX } from "lucide-react"; +import { BookOpenCheck, Check, ChevronDown, ChevronRight, CircleAlert, GitCompareArrows, SearchX } from "lucide-react"; import { SearchResultsHeaderBand, @@ -29,6 +29,48 @@ import { readResultFilterValues, replaceResultFilterUrl, writeResultFilterValues const dsmCardSelected = "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] shadow-[var(--e2)]"; +/** + * Browse order with nothing typed: A to Z by title, with the generic template + * entries ("[Substance] intoxication / withdrawal …") after the named + * diagnoses. Display-only — query-ranked results never pass through here. + */ +export function compareDsmBrowseTitles(left: { title: string }, right: { title: string }) { + const leftTemplate = left.title.trimStart().startsWith("["); + const rightTemplate = right.title.trimStart().startsWith("["); + if (leftTemplate !== rightTemplate) return leftTemplate ? 1 : -1; + return left.title.localeCompare(right.title, "en", { sensitivity: "base" }); +} + +/** + * Groups browse rows by DSM chapter, in the order the catalogue lists its + * chapters, rows A to Z inside each. Chapters with no rows are left out. + */ +export function groupDsmBrowseByChapter( + categories: readonly DsmCategory[], + results: readonly DsmDiagnosisSummary[], +): Array<{ key: string; label: string; items: DsmDiagnosisSummary[] }> { + const byKey = new Map(); + for (const result of results) { + const bucket = byKey.get(result.category.key); + if (bucket) bucket.push(result); + else byKey.set(result.category.key, [result]); + } + const groups: Array<{ key: string; label: string; items: DsmDiagnosisSummary[] }> = []; + const seen = new Set(); + for (const category of categories) { + const items = byKey.get(category.key); + if (!items?.length) continue; + seen.add(category.key); + groups.push({ key: category.key, label: category.label, items: [...items].sort(compareDsmBrowseTitles) }); + } + // A record whose chapter is missing from the category list still has to show. + for (const [key, items] of byKey) { + if (seen.has(key)) continue; + groups.push({ key, label: items[0]?.category.label ?? key, items: [...items].sort(compareDsmBrowseTitles) }); + } + return groups; +} + function compareHref(slugs: string[]) { const params = new URLSearchParams({ ids: slugs.join(",") }); return `/dsm/compare?${params.toString()}`; @@ -38,10 +80,13 @@ function DsmSearchResultCard({ result, isSelected, onToggle, + compact = false, }: { result: DsmDiagnosisSummary; isSelected: boolean; onToggle: () => void; + /** Browse rows: one line on a phone (title and ICD code); summary and chapter return at `sm`. */ + compact?: boolean; }) { return (
-
-
-

- {result.title} -

-

- {result.summary} -

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

+ {result.title} +

+ {result.icd_code} +
+

+ {result.summary} +

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

+ {result.title} +

+

+ {result.summary} +

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

+ {group.label} +

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

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

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