Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/design-system/COMPONENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -1001,7 +1001,7 @@ This generated snapshot is a local source-derived inventory. It does not assert
| `SearchField` | controls | yes | yes | no | yes | no | 0 |
| `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 9 |
| `Select` | controls | yes | yes | inherited-global-root | yes | no | 2 |
| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 27 |
| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 26 |
| `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 6 |
| `SourceDesignationBadge` | source | yes | yes | inherited-global-root | yes | no | 1 |
| `SourceProvenance` | source | yes | yes | inherited-global-root | yes | no | 1 |
Expand Down
4 changes: 1 addition & 3 deletions docs/design-system/adoption-manifest.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -1513,7 +1513,6 @@
"src/components/in-page-nav/in-page-nav-header.tsx",
"src/components/mode-nav/mode-nav.tsx",
"src/components/services/service-group-nav.tsx",
"src/components/therapy-compass/filter-sheet.tsx",
"src/components/ui/confirm-dialog.tsx"
],
"productImportFiles": [
Expand DownExpand Up@@ -1542,8 +1541,7 @@
"src/components/forms/forms-search-results-page.tsx",
"src/components/in-page-nav/in-page-nav-header.tsx",
"src/components/mode-nav/mode-nav.tsx",
"src/components/services/service-group-nav.tsx",
"src/components/therapy-compass/filter-sheet.tsx"
"src/components/services/service-group-nav.tsx"
],
"designSync": {
"listedInSourceMap": true,
Expand Down
16 changes: 11 additions & 5 deletions docs/filter-contract.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -145,10 +145,11 @@ rather than showing an empty heading.
- **`footerNote` counts what the filters actually govern.** Specifiers currently reports
`results.length + catalogueMatches.length` while the groups narrow only `results` — the sheet
claims to scope a list it half controls. A mode must not report a total its filters cannot move.
- **`onClearAll` never touches the query.** Therapy-compass's clear wipes the search box; the
shared sheet's does not. Clearing filters and clearing a search are different intentions.
- **One trigger component.** `ResultFilterTrigger`. Therapy-compass re-implements it with a
different icon, a hardcoded test id and a different label-hiding breakpoint.
- **`onClearAll` never touches the query.** Clearing filters and clearing a search are different
intentions. Therapy-compass now uses the shared sheet's filter-only clear; the composer's
explicit "Clear search" action remains responsible for deleting the query.
- **One trigger component.** `ResultFilterTrigger`. Therapy-compass now uses the shared trigger
at the phone breakpoint and the shared facet chips on desktop.
- **Tap targets are `min-h-tap` (48px) on phone.** Do not relax to 44px for generic WCAG
guidance; it reintroduces a known `ui-smoke` flake.

Expand DownExpand Up@@ -183,6 +184,11 @@ Contract first, then one PR per mode:
`src/lib/service-facets.ts`), a URL round-trip alongside `q`/`group`, and the scope segment
(section 4). Services is also the first mode dense enough (5 facet groups) to exercise the
`> 3 groups` chrome added to the shared sheet for this — see section 5.
4. **Documents last** — port its needle and collapse up into the shared component as the
4. **Therapy-compass** — converge runtime use of the bespoke phone-only filter sheet and trigger
onto `ResultFilterSheet`, `ResultFilterTrigger`, and `ResultFilterFacetChips`. Topics are OR
within their group. Review status and handout availability are independent one-option groups
that AND with Topics and with each other. Option counts and filtering share
`matchesTopics`/`matchesAvailability`, and Clear filters preserves the query.
5. **Documents last** — port its needle and collapse up into the shared component as the
`> 20` tier, then converge. It is the largest surface and should move once the contract is
proven elsewhere.
19 changes: 19 additions & 0 deletions src/components/therapy-compass/bindings.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,6 +112,13 @@ export type TcBindings = {
// ---- search ---------------------------------------------------------
search: SearchOptions;
searchResults: Therapy[];
/**
* Query-only matches — same query, tags/reviewedOnly/briefOnly reset to
* `EMPTY_SEARCH`. The base for a facet option's count ("how many would I
* have if I ticked this as well" — docs/filter-contract.md section 3),
* which must never be narrowed by the very selection it is counting.
*/
queryMatches: Therapy[];
setQuery: (q: string) => void;
submitQuery: (q: string) => void; // set query + go search
toggleTag: (tag: string) => void;
Expand DownExpand Up@@ -321,6 +328,16 @@ export function TcProvider({ children }: { children: ReactNode }) {
// match aria-pressed state without waiting for useDeferredValue.
return searchTherapies(therapies, { ...search, query: deferredSearch.query });
}, [therapies, deferredSearch.query, search]);
// Same query-deferral shape as `searchResults`, tags/reviewedOnly/briefOnly
// reset — so a facet count never lags behind or races ahead of the query
// typing the reader can see.
const queryMatches = useMemo(() => {
const liveQuery = search.query.trim();
const deferredQuery = deferredSearch.query.trim();
if (!liveQuery) return searchTherapies(therapies, EMPTY_SEARCH);
if (!deferredQuery) return [];
return searchTherapies(therapies, { ...EMPTY_SEARCH, query: deferredSearch.query });
}, [therapies, deferredSearch.query, search.query]);
const compareTherapies = useMemo(
() => compareSlugs.map((sl) => bySlug.get(sl)).filter((t): t is Therapy => Boolean(t)),
[compareSlugs, bySlug],
Expand DownExpand Up@@ -422,6 +439,7 @@ export function TcProvider({ children }: { children: ReactNode }) {

search,
searchResults,
queryMatches,
setQuery: (q) => patchSearch({ query: q }),
submitQuery: (q) => {
patchSearch({ query: q });
Expand DownExpand Up@@ -538,6 +556,7 @@ export function TcProvider({ children }: { children: ReactNode }) {
relatedForSelected,
search,
searchResults,
queryMatches,
compareSlugs,
compareTherapies,
recQuery,
Expand Down
39 changes: 34 additions & 5 deletions src/components/therapy-compass/data/select.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -124,7 +124,7 @@ export function complexityLabel(complexity: string | null): string {

export type SearchOptions = {
query: string;
tags: string[]; // therapy must carry ALL selected tags
tags: string[]; // therapy must carry ANY selected tag (OR within the group — see matchesTopics)
briefOnly: boolean;
sheetOnly: boolean;
reviewedOnly: boolean;
Expand All@@ -138,6 +138,36 @@ export const EMPTY_SEARCH: SearchOptions = {
reviewedOnly: false,
};

/**
* Topics facet predicate — OR within the group, matching every other adopted
* facet in the app (docs/filter-contract.md section 1). Picking both CBT and
* DBT means "either", not "a therapy tagged with both": AND-within-group is
* the exact defect class already fixed for document tags, where it made a
* second selection within one group a dead affordance instead of a widen.
*
* Exported so the filter sheet's option counts (`searchTherapies`'s own
* `hint` predicate) and the real filter run through one function — the count
* must be produced by the same predicate as the filter, or the two drift
* apart the moment the combination rule changes (section 3).
*/
export function matchesTopics(therapy: Therapy, topics: ReadonlySet<string>): boolean {
if (topics.size === 0) return true;
const wanted = [...topics].map(lc);
return therapy.tags.some((tag) => wanted.includes(lc(tag)));
}

/**
* Availability facet predicate — reviewed status and brief-intervention
* availability are independent constraints that AND together (they are two
* separate one-option facet groups, not options inside one group), and each
* ANDs against Topics in turn. Neither combines with the other by OR.
*/
export function matchesAvailability(therapy: Therapy, reviewedOnly: boolean, briefOnly: boolean): boolean {
if (reviewedOnly && therapy.reviewStatus !== "reviewed") return false;
if (briefOnly && !therapy.briefInterventionAvailable) return false;
return true;
}

function scoreTherapy(t: Therapy, q: string): number {
if (!q) return 1;
const name = lc(t.name);
Expand All@@ -158,13 +188,12 @@ function scoreTherapy(t: Therapy, q: string): number {

export function searchTherapies(therapies: Therapy[], opts: SearchOptions): Therapy[] {
const q = opts.query.trim().toLowerCase();
const wantTags = opts.tags.map(lc);
const topics = new Set(opts.tags);
const scored = therapies
.filter((t) => {
if (opts.briefOnly && !t.briefInterventionAvailable) return false;
if (!matchesAvailability(t, opts.reviewedOnly, opts.briefOnly)) return false;
if (opts.sheetOnly && !t.patientSheetAvailable) return false;
if (opts.reviewedOnly && t.reviewStatus !== "reviewed") return false;
if (wantTags.length && !wantTags.every((wt) => t.tags.some((tag) => lc(tag) === wt))) return false;
if (!matchesTopics(t, topics)) return false;
return scoreTherapy(t, q) > 0;
})
.map((t) => ({ t, s: scoreTherapy(t, q) }));
Expand Down
175 changes: 0 additions & 175 deletions src/components/therapy-compass/filter-sheet.tsx

This file was deleted.

Loading
Loading