From 0b5b92ce8485f6a6a19717e52390a2688fd96880 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 18 Aug 2026 02:10:24 +0800 Subject: [PATCH 1/7] feat(ui): implement filter density tiers, mobile route deduplication, favourites timestamps, and answer notice (#309, #281, #339, #165) --- docs/filter-contract.md | 18 +- src/components/DocumentViewer.tsx | 5 +- .../clinical-dashboard/answer-status.tsx | 13 +- .../favourites-command-library-page.tsx | 21 +- .../result-filter-control.tsx | 57 +++-- .../document-viewer/document-rail-panels.tsx | 2 +- .../favourites/favourites-storage.ts | 194 ++++++++++++++++++ src/components/search/ResultFilterSheet.tsx | 21 ++ tests/favourites.test.ts | 79 +++++++ tests/filter-contract.dom.test.tsx | 163 +++++++++++++++ tests/filter-contract.test.ts | 31 +++ 11 files changed, 565 insertions(+), 39 deletions(-) create mode 100644 src/components/favourites/favourites-storage.ts create mode 100644 src/components/search/ResultFilterSheet.tsx create mode 100644 tests/favourites.test.ts create mode 100644 tests/filter-contract.dom.test.tsx create mode 100644 tests/filter-contract.test.ts diff --git a/docs/filter-contract.md b/docs/filter-contract.md index 52d89db67c..ec26f06e2b 100644 --- a/docs/filter-contract.md +++ b/docs/filter-contract.md @@ -116,17 +116,17 @@ corpus of that size, and it stays. ## 5. Density is a function of option count -Facet groups only. Two states, not three: the shared renderer uses the same threshold for its -find-a-filter field and collapse-by-default disclosures. +Facet groups only. Density scales with option and group volume across three tiers: -| Options | Renderer | -| --------------------------- | ---------------------------------------------------------------------------------------- | -| ≤ 3 groups and ≤ 20 options | chips, single row where they fit (unchanged from before this section) | -| > 3 groups, or > 20 options | chips plus find-a-filter and collapse-by-default, every group behind a disclosure header | +| Options / Groups | Renderer | +| --------------------------- | -------------------------------------------------------------------------------------- | +| ≤ 5 options | chips, single row / wrapping chips | +| 6–20 options | dense full-width vertical list with right-aligned count column and group headings | +| > 3 groups, or > 20 options | list/chips plus find-a-filter and collapse-by-default, every group behind a disclosure | -`ResultFilterSheet` computes the threshold once across all facet groups. The option-count limb -catches a small number of very large groups, while the group-count limb covers services' six -facet groups. Below the threshold every group renders as before. +`ResultFilterSheet` computes the threshold across facet groups. Facet groups containing 6–20 options +render as compact full-width rows with a right-aligned count column for fast scanning. When a sheet +exceeds 3 groups or 20 total options, it additionally adds find-a-filter and collapse-by-default chrome. Collapse rules, when they apply: groups start collapsed; a group holding a selection opens itself; an explicit user collapse beats that; an active needle forces every matched group open diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index 9731ad7725..4b8d0b91e0 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -1393,7 +1393,10 @@ export function DocumentViewer({ a phone reader sees the clinical priorities digest before scrolling past the PDF. */} {readyDocument ? ( -
+
)} - {/* No privacy link here: the composer's PrivacyInputNotice is the - single site-wide notice, so the hero footer must not repeat it. */} - {/* Pre-query copy must describe what the search does, not assert that - every indexed source is verified/current (PT-06): validation status - varies per document and is surfaced on the results themselves. */} + {modeId === "answer" ? ( + + ) : null}
} /> diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index 5b1a72668a..e9b3e82167 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -1106,9 +1106,18 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: status: favouritesHookStatus, refetch: refetchFavouritesRegistry, } = useSavedRegistryFavourites(); + const lastOpenedMap = useSyncExternalStore( + subscribeFavouritesStorage, + loadFavouriteLastOpened, + loadFavouriteLastOpened, + ); + const pinnedIds = useSyncExternalStore(subscribeFavouritesStorage, loadFavouritePinnedIds, loadFavouritePinnedIds); const items = useMemo( - () => [...(demoMode ? prototypeFavouriteItems : []), ...savedRegistryFavourites].map(toCommandItem), - [demoMode, savedRegistryFavourites], + () => + [...(demoMode ? prototypeFavouriteItems : []), ...savedRegistryFavourites].map((item) => + toCommandItem(item, lastOpenedMap, pinnedIds), + ), + [demoMode, savedRegistryFavourites, lastOpenedMap, pinnedIds], ); // Demo prototypes live outside the hook. If they are the only items while a // registry/account read failed, keep their honest nonzero count but mark it @@ -1162,7 +1171,7 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: const recentItems = useMemo( () => [...items] - .sort((first, second) => lastUsedScore(second.lastUsed) - lastUsedScore(first.lastUsed)) + .sort((first, second) => lastOpenedScore(second.lastUsed) - lastOpenedScore(first.lastUsed)) .slice(0, recentPreviewLimit), [items], ); @@ -1517,7 +1526,10 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: sortMode={sortMode} selectedItemId={selectedItemId} onSortModeChange={setSortMode} - onSelectItem={setSelectedItemId} + onSelectItem={(id) => { + if (id) recordFavouriteOpened(id); + setSelectedItemId(id); + }} /> )} @@ -1548,6 +1560,7 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: recordFavouriteOpened(item.id)} aria-label={`Open ${item.title}`} className={cn( "inline-flex min-h-tap shrink-0 items-center rounded-lg border border-[color:var(--border)] px-2.5 text-xs font-bold text-[color:var(--text)] hover:bg-[color:var(--surface-subtle)] sm:min-h-9", diff --git a/src/components/clinical-dashboard/result-filter-control.tsx b/src/components/clinical-dashboard/result-filter-control.tsx index 71fbd46246..e9ede86aeb 100644 --- a/src/components/clinical-dashboard/result-filter-control.tsx +++ b/src/components/clinical-dashboard/result-filter-control.tsx @@ -479,7 +479,12 @@ export function ResultFilterFacetChips({ const panelId = idPrefix; const groupLabelId = `${panelId}-${group.id}-label`; const visibleOptions = options ?? group.options; - const renderOptions = (items: ReadonlyArray>) => + const isDenseList = + !group.optionSections && + ((group.options.length >= 6 && group.options.length <= 20) || + (visibleOptions.length >= 6 && visibleOptions.length <= 20)); + + const renderOptions = (items: ReadonlyArray>, isDense: boolean = isDenseList) => items.map((option) => { const selected = group.selected.has(option.value); const deadEnd = Boolean(option.disabled) && !selected; @@ -497,7 +502,9 @@ export function ResultFilterFacetChips({ group.onToggle(option.value); }} className={cn( - "inline-flex min-h-tap max-w-full items-center gap-1.5 rounded-md border px-2.5 text-2xs font-semibold shadow-[var(--shadow-inset)] transition motion-reduce:transition-none sm:min-h-10 sm:gap-1 sm:px-2", + isDense + ? "flex min-h-tap w-full min-w-0 items-center justify-between gap-2.5 rounded-lg border px-3 py-2 text-left text-xs font-semibold shadow-[var(--shadow-inset)] transition motion-reduce:transition-none sm:min-h-9 sm:py-1.5" + : "inline-flex min-h-tap max-w-full items-center gap-1.5 rounded-md border px-2.5 text-2xs font-semibold shadow-[var(--shadow-inset)] transition motion-reduce:transition-none sm:min-h-10 sm:gap-1 sm:px-2", "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]", selected ? "border-[color:var(--clinical-accent)]/35 bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]" @@ -506,19 +513,25 @@ export function ResultFilterFacetChips({ : "border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)] hover:border-[color:var(--border-strong)] hover:text-[color:var(--text)]", )} > - - {selected ? - {option.label} - {option.hint ? {option.hint} : null} +
+ + {selected ? + {option.label} +
+ {option.hint ? ( + + {option.hint} + + ) : null} {deadEnd ? ( No matches with your current filters. @@ -583,12 +596,18 @@ export function ResultFilterFacetChips({ hidden={disclosure ? !disclosure.open : false} role="group" aria-labelledby={groupLabelId} - className={cn("pb-2.5", group.optionSections ? "grid gap-3" : "flex flex-wrap gap-2 sm:gap-1.5")} + className={cn( + "pb-2.5", + group.optionSections ? "grid gap-3" : isDenseList ? "grid gap-1" : "flex flex-wrap gap-2 sm:gap-1.5", + )} > {group.optionSections ? group.optionSections.map((section) => { const sectionOptions = visibleOptions.filter((option) => section.optionValues.includes(option.value)); if (sectionOptions.length === 0) return null; + const sectionDense = + (section.optionValues.length >= 6 && section.optionValues.length <= 20) || + (sectionOptions.length >= 6 && sectionOptions.length <= 20); return (
@@ -599,11 +618,13 @@ export function ResultFilterFacetChips({

) : null}
-
{renderOptions(sectionOptions)}
+
+ {renderOptions(sectionOptions, sectionDense)} +
); }) - : renderOptions(visibleOptions)} + : renderOptions(visibleOptions, isDenseList)}
); diff --git a/src/components/document-viewer/document-rail-panels.tsx b/src/components/document-viewer/document-rail-panels.tsx index 3918810d69..d89bde41d9 100644 --- a/src/components/document-viewer/document-rail-panels.tsx +++ b/src/components/document-viewer/document-rail-panels.tsx @@ -138,7 +138,7 @@ export function DocumentViewerRail({ data-testid="high-yield-summary" className={cn( panel, - "group min-w-0 scroll-mt-[var(--document-anchor-offset,6rem)] source-print md:col-span-2 lg:col-span-1", + "group min-w-0 max-sm:hidden scroll-mt-[var(--document-anchor-offset,6rem)] source-print md:col-span-2 lg:col-span-1", )} > { + const now = Date.now(); + const dayMs = 24 * 60 * 60 * 1000; + return { + "acamprosate-renal-screen": now - 15 * 60 * 1000, // 15 mins ago (today) + "lithium-monitoring-guideline": now - 35 * 60 * 1000, // 35 mins ago (today) + "renal-dose-search": now - 55 * 60 * 1000, // 55 mins ago (today) + "clozapine-monitoring-table": now - dayMs - 2 * 60 * 60 * 1000, // yesterday + "qt-prolongation-quote": now - 3 * dayMs, // earlier this week + }; +} + +let inMemoryLastOpened: Record | null = null; +let inMemoryPinned: Set | null = null; +const listeners = new Set<() => void>(); + +function notifyListeners() { + for (const listener of listeners) { + try { + listener(); + } catch { + // Ignore listener errors + } + } +} + +export function subscribeFavouritesStorage(listener: () => void): () => void { + listeners.add(listener); + if (typeof window !== "undefined") { + const handleStorage = (event: StorageEvent) => { + if ( + event.key === DATABASE_FAVOURITES_LAST_OPENED_STORAGE_KEY || + event.key === DATABASE_FAVOURITES_PINNED_STORAGE_KEY + ) { + inMemoryLastOpened = null; + inMemoryPinned = null; + notifyListeners(); + } + }; + window.addEventListener("storage", handleStorage); + return () => { + listeners.delete(listener); + window.removeEventListener("storage", handleStorage); + }; + } + return () => { + listeners.delete(listener); + }; +} + +export function loadFavouriteLastOpened(): Record { + if (typeof window === "undefined") { + return getDefaultInitialTimestamps(); + } + if (inMemoryLastOpened) return inMemoryLastOpened; + + try { + const raw = localStorage.getItem(DATABASE_FAVOURITES_LAST_OPENED_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (typeof parsed === "object" && parsed !== null) { + const result: Record = { ...getDefaultInitialTimestamps(), ...parsed }; + inMemoryLastOpened = result; + return result; + } + } + } catch { + // Fallback on JSON parse / storage errors + } + + const fallback = getDefaultInitialTimestamps(); + inMemoryLastOpened = fallback; + return fallback; +} + +export function recordFavouriteOpened(itemId: string, timestamp: number = Date.now()): Record { + const current: Record = { ...loadFavouriteLastOpened(), [itemId]: timestamp }; + inMemoryLastOpened = current; + if (typeof window !== "undefined") { + try { + localStorage.setItem(DATABASE_FAVOURITES_LAST_OPENED_STORAGE_KEY, JSON.stringify(current)); + } catch { + // Ignore storage write errors (e.g. quota) + } + } + notifyListeners(); + return current; +} + +export function loadFavouritePinnedIds(): Set { + if (typeof window === "undefined") { + return new Set(DEFAULT_PINNED_ITEM_IDS); + } + if (inMemoryPinned) return inMemoryPinned; + + try { + const raw = localStorage.getItem(DATABASE_FAVOURITES_PINNED_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + const result = new Set(parsed.filter((id): id is string => typeof id === "string")); + inMemoryPinned = result; + return result; + } + } + } catch { + // Fallback on JSON parse / storage errors + } + + const fallback = new Set(DEFAULT_PINNED_ITEM_IDS); + inMemoryPinned = fallback; + return fallback; +} + +export function toggleFavouritePinnedId(itemId: string): Set { + const current = new Set(loadFavouritePinnedIds()); + if (current.has(itemId)) { + current.delete(itemId); + } else { + current.add(itemId); + } + inMemoryPinned = current; + if (typeof window !== "undefined") { + try { + localStorage.setItem(DATABASE_FAVOURITES_PINNED_STORAGE_KEY, JSON.stringify(Array.from(current))); + } catch { + // Ignore storage write errors + } + } + notifyListeners(); + return current; +} + +const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +export function formatLastOpened(timestampOrLabel: number | string | undefined): string { + if (timestampOrLabel === undefined || timestampOrLabel === null) { + return "Saved"; + } + if (typeof timestampOrLabel === "string") { + return timestampOrLabel; + } + + const date = new Date(timestampOrLabel); + if (Number.isNaN(date.getTime())) { + return "Saved"; + } + + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const itemDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + const diffDays = Math.round((today.getTime() - itemDate.getTime()) / (24 * 60 * 60 * 1000)); + + const hours = String(date.getHours()).padStart(2, "0"); + const mins = String(date.getMinutes()).padStart(2, "0"); + const timeStr = `${hours}:${mins}`; + + if (diffDays === 0) { + return `Today ${timeStr}`; + } + if (diffDays === 1) { + return `Yesterday ${timeStr}`; + } + if (diffDays >= 2 && diffDays <= 6) { + return `${dayNames[date.getDay()]} ${timeStr}`; + } + return `${date.getDate()} ${monthNames[date.getMonth()]}`; +} + +export function lastOpenedScore(lastUsed: string | number | undefined): number { + if (typeof lastUsed === "number") { + return lastUsed; + } + if (!lastUsed) return 0; + + const lower = lastUsed.toLowerCase(); + if (lower.startsWith("today")) { + const timeMatch = lastUsed.match(/(\d{1,2}):(\d{2})/); + if (timeMatch) return 1_000_000_000_000 + Number(timeMatch[1]) * 60 + Number(timeMatch[2]); + return 1_000_000_000_000; + } + if (lower.startsWith("yesterday")) return 500_000_000_000; + if (dayNames.some((d) => lower.startsWith(d.toLowerCase()))) return 100_000_000_000; + return 1_000; +} diff --git a/src/components/search/ResultFilterSheet.tsx b/src/components/search/ResultFilterSheet.tsx new file mode 100644 index 0000000000..56d3025c1c --- /dev/null +++ b/src/components/search/ResultFilterSheet.tsx @@ -0,0 +1,21 @@ +"use client"; + +export { + ResultFilterSheet, + ResultFilterTrigger, + ResultFilterFacetChips, + ResultFilterScopeSelector, + resultFilterGroup, + resultFilterFacetGroup, + isFacetGroup, + type ResultFilterOption, + type ResultFilterOptionSection, + type ResultFilterGroupKind, + type ResultFilterLensGroup, + type ResultFilterFacetGroup, + type ResultFilterGroup, + type ResultFilterScopeOption, + type ResultFilterScopeConfig, + type ResultFilterSummary, + type ResultFilterSecondaryAction, +} from "@/components/clinical-dashboard/result-filter-control"; diff --git a/tests/favourites.test.ts b/tests/favourites.test.ts new file mode 100644 index 0000000000..f69622aaa3 --- /dev/null +++ b/tests/favourites.test.ts @@ -0,0 +1,79 @@ +/** @vitest-environment jsdom */ + +import { beforeEach, describe, expect, it } from "vitest"; + +import { + DATABASE_FAVOURITES_LAST_OPENED_STORAGE_KEY, + DATABASE_FAVOURITES_PINNED_STORAGE_KEY, + formatLastOpened, + lastOpenedScore, + loadFavouriteLastOpened, + loadFavouritePinnedIds, + recordFavouriteOpened, + toggleFavouritePinnedId, +} from "@/components/favourites/favourites-storage"; + +describe("favourites storage, timestamps and pinning", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("loads default seed timestamps and records real timestamp when item is opened", () => { + const initial = loadFavouriteLastOpened(); + expect(initial["acamprosate-renal-screen"]).toBeDefined(); + expect(typeof initial["acamprosate-renal-screen"]).toBe("number"); + + const customTime = Date.now() + 5000; + recordFavouriteOpened("test-item-1", customTime); + + const updated = loadFavouriteLastOpened(); + expect(updated["test-item-1"]).toBe(customTime); + + const storedRaw = localStorage.getItem(DATABASE_FAVOURITES_LAST_OPENED_STORAGE_KEY); + expect(storedRaw).not.toBeNull(); + const parsed = JSON.parse(storedRaw!); + expect(parsed["test-item-1"]).toBe(customTime); + }); + + it("loads default pinned IDs and allows toggling pinning state with localStorage persistence", () => { + const initialPinned = loadFavouritePinnedIds(); + expect(initialPinned.has("acamprosate-renal-screen")).toBe(true); + expect(initialPinned.has("custom-unpinned-id")).toBe(false); + + toggleFavouritePinnedId("custom-unpinned-id"); + const updated = loadFavouritePinnedIds(); + expect(updated.has("custom-unpinned-id")).toBe(true); + + const storedRaw = localStorage.getItem(DATABASE_FAVOURITES_PINNED_STORAGE_KEY); + expect(storedRaw).not.toBeNull(); + const parsed = JSON.parse(storedRaw!); + expect(parsed).toContain("custom-unpinned-id"); + + toggleFavouritePinnedId("custom-unpinned-id"); + const reverted = loadFavouritePinnedIds(); + expect(reverted.has("custom-unpinned-id")).toBe(false); + }); + + it("formats timestamps into human-readable relative strings", () => { + const now = Date.now(); + const formattedNow = formatLastOpened(now); + expect(formattedNow).toMatch(/^Today \d{2}:\d{2}$/); + + const yesterday = now - 24 * 60 * 60 * 1000; + const formattedYesterday = formatLastOpened(yesterday); + expect(formattedYesterday).toMatch(/^Yesterday \d{2}:\d{2}$/); + + expect(formatLastOpened(undefined)).toBe("Saved"); + expect(formatLastOpened("Today 08:44")).toBe("Today 08:44"); + }); + + it("computes sort scores correctly prioritizing recent timestamps", () => { + const t1 = Date.now(); + const t2 = t1 - 10000; + + expect(lastOpenedScore(t1)).toBeGreaterThan(lastOpenedScore(t2)); + expect(lastOpenedScore("Today 10:00")).toBeGreaterThan(lastOpenedScore("Yesterday 10:00")); + expect(lastOpenedScore("Yesterday 10:00")).toBeGreaterThan(lastOpenedScore("Mon 10:00")); + expect(lastOpenedScore("Saved")).toBe(1000); + }); +}); diff --git a/tests/filter-contract.dom.test.tsx b/tests/filter-contract.dom.test.tsx new file mode 100644 index 0000000000..d8f58fae4b --- /dev/null +++ b/tests/filter-contract.dom.test.tsx @@ -0,0 +1,163 @@ +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + ResultFilterSheet, + resultFilterFacetGroup, + resultFilterGroup, + type ResultFilterOption, +} from "@/components/search/ResultFilterSheet"; + +afterEach(() => { + cleanup(); +}); + +describe("filter contract and density rendering", () => { + it("renders facet groups with <= 5 options as compact wrapping chips", () => { + const onToggle = vi.fn(); + const group = resultFilterFacetGroup({ + id: "small-facet", + label: "Category", + selected: new Set(["a"]), + options: [ + { value: "a", label: "Option A", hint: "3" }, + { value: "b", label: "Option B", hint: "5" }, + { value: "c", label: "Option C", hint: "2" }, + ], + onToggle, + }); + + render( + , + ); + + const buttonA = screen.getByRole("button", { name: /^Option A/ }); + expect(buttonA).toHaveAttribute("aria-pressed", "true"); + expect(buttonA.className).toContain("inline-flex"); + expect(buttonA.className.split(/\s+/)).not.toContain("w-full"); + expect(buttonA.className).toContain("min-h-tap"); + }); + + it("renders facet groups with 6–20 options as dense full-width vertical list with right-aligned counts", async () => { + const user = userEvent.setup(); + const onToggle = vi.fn(); + const options: ResultFilterOption[] = Array.from({ length: 9 }, (_, i) => ({ + value: `option-${i + 1}`, + label: `Domain ${i + 1}`, + hint: `${(i + 1) * 2}`, + })); + + const group = resultFilterFacetGroup({ + id: "dense-domains", + label: "Domains", + selected: new Set(["option-2"]), + options, + onToggle, + }); + + render( + , + ); + + const groupEl = screen.getByRole("group", { name: "Domains" }); + expect(groupEl).toBeInTheDocument(); + expect(groupEl.className).toContain("grid"); + + const buttons = within(groupEl).getAllByRole("button"); + expect(buttons).toHaveLength(9); + + const firstButton = buttons[0]; + expect(firstButton.className).toContain("w-full"); + expect(firstButton.className).toContain("justify-between"); + expect(firstButton.className).toContain("min-h-tap"); + expect(firstButton).toHaveAttribute("aria-pressed", "false"); + + const secondButton = buttons[1]; + expect(secondButton).toHaveAttribute("aria-pressed", "true"); + + await user.click(firstButton); + expect(onToggle).toHaveBeenCalledWith("option-1"); + }); + + it("adds find-a-filter and disclosure header when total facet options exceed 20 or facet groups exceed 3", () => { + const onToggle = vi.fn(); + const groups = Array.from({ length: 4 }, (_, groupIndex) => + resultFilterFacetGroup({ + id: `facet-group-${groupIndex + 1}`, + label: `Group ${groupIndex + 1}`, + selected: new Set(), + options: [ + { value: `g${groupIndex}-1`, label: `Item 1`, hint: "1" }, + { value: `g${groupIndex}-2`, label: `Item 2`, hint: "2" }, + ], + onToggle, + }), + ); + + render( + , + ); + + expect(screen.getByTestId("super-dense-panel-find")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Group 1/ })).toHaveAttribute("aria-expanded", "false"); + }); + + it("handles roving radio selection for lens groups", () => { + const onChange = vi.fn(); + const lensGroup = resultFilterGroup({ + id: "view-lens", + label: "View", + value: "all", + options: [ + { value: "all", label: "All items" }, + { value: "presentations", label: "Presentations" }, + { value: "diagnoses", label: "Diagnoses" }, + ], + onChange, + }); + + render( + , + ); + + const radioAll = screen.getByRole("radio", { name: "All items" }); + expect(radioAll).toHaveAttribute("aria-checked", "true"); + expect(radioAll).toHaveAttribute("tabindex", "0"); + + const radioPres = screen.getByRole("radio", { name: "Presentations" }); + expect(radioPres).toHaveAttribute("aria-checked", "false"); + expect(radioPres).toHaveAttribute("tabindex", "-1"); + + fireEvent.click(radioPres); + expect(onChange).toHaveBeenCalledWith("presentations"); + }); +}); diff --git a/tests/filter-contract.test.ts b/tests/filter-contract.test.ts new file mode 100644 index 0000000000..05368331a6 --- /dev/null +++ b/tests/filter-contract.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +function source(path: string) { + return readFileSync(resolve(process.cwd(), path), "utf8"); +} + +describe("filter contract structural verification", () => { + it("exports ResultFilterSheet from src/components/search/ResultFilterSheet.tsx", () => { + const filterExport = source("src/components/search/ResultFilterSheet.tsx"); + expect(filterExport).toContain("ResultFilterSheet"); + expect(filterExport).toContain("ResultFilterTrigger"); + expect(filterExport).toContain("ResultFilterFacetChips"); + }); + + it("implements density tier detection in result-filter-control.tsx", () => { + const control = source("src/components/clinical-dashboard/result-filter-control.tsx"); + expect(control).toContain("isDenseList"); + expect(control).toContain("group.options.length >= 6"); + expect(control).toContain("group.options.length <= 20"); + expect(control).toContain("min-h-tap"); + }); + + it("documents density tiers in docs/filter-contract.md", () => { + const doc = source("docs/filter-contract.md"); + expect(doc).toContain("6–20 options"); + expect(doc).toContain("dense full-width vertical list"); + expect(doc).toContain("≤ 5 options"); + }); +}); From c132a10ce1d3a0d473a45d4024b868ce5c297744 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 18 Aug 2026 03:44:35 +0800 Subject: [PATCH 2/7] fix(ui): repair CI blockers on filter density tiers (PR #2074) --- .../favourites-command-library-page.tsx | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index e9b3e82167..895a2568b3 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -35,6 +35,14 @@ import { type FavouriteItem as PrototypeFavouriteItem, } from "@/components/clinical-dashboard/favourites-prototype-data"; import { useSavedRegistryFavourites } from "@/components/clinical-dashboard/use-saved-registry-favourites"; +import { + formatLastOpened, + lastOpenedScore, + loadFavouriteLastOpened, + loadFavouritePinnedIds, + recordFavouriteOpened, + subscribeFavouritesStorage, +} from "@/components/favourites/favourites-storage"; import { SearchResultsEmptyState, SearchResultsHeaderBand, @@ -112,8 +120,6 @@ const lastUsedByItemId: Record = { "qt-prolongation-quote": "Mon 11:03", }; -const pinnedItemIds = new Set(["acamprosate-renal-screen", "lithium-monitoring-guideline"]); - const typeByPrototypeType: Record = { medications: "Medication", documents: "Document", @@ -186,7 +192,11 @@ async function copyFavouriteCitation(item: FavouriteItem): Promise { } } -function toCommandItem(item: PrototypeFavouriteItem): FavouriteItem { +function toCommandItem( + item: PrototypeFavouriteItem, + lastOpenedMap: Record, + pinnedIds: ReadonlySet, +): FavouriteItem { const type = item.type === "sources" && item.primaryAction === "Run" ? "Saved search" @@ -199,11 +209,14 @@ function toCommandItem(item: PrototypeFavouriteItem): FavouriteItem { tabId: item.type, set: item.set || (item.type === "services" ? "Saved services" : item.type === "forms" ? "Saved forms" : "Unsorted"), evidence: item.sourceMeta, - lastUsed: lastUsedByItemId[item.id] ?? "Saved", + lastUsed: + lastOpenedMap[item.id] !== undefined + ? formatLastOpened(lastOpenedMap[item.id]) + : (lastUsedByItemId[item.id] ?? "Saved"), action: item.primaryAction, href: item.href, icon: item.icon ?? fallbackIconByType[item.type], - pinned: pinnedItemIds.has(item.id), + pinned: pinnedIds.has(item.id), }; } From 3a6c20239d72d9c59a871216dc165cdbccc9f277 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 18 Aug 2026 06:03:55 +0800 Subject: [PATCH 3/7] fix(ui): keep document-viewer retry panel ids unique under density tiers (PR #2074) --- src/components/DocumentViewer.tsx | 2 +- src/components/document-viewer/use-section-spy.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index 4b8d0b91e0..9a53f23c75 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -1394,7 +1394,7 @@ export function DocumentViewer({ past the PDF. */} {readyDocument ? (
= { "source-evidence": ["source-evidence-rail"], + // The rail's document-profile disclosure owns the canonical anchor (the spy + // treats it as an exclusive-accordion member), while the in-flow clinical + // summary card above the PDF is its phone/tablet copy. + "source-summary": ["source-summary-card"], }; /** From a98c42e2e3424b0e3f0fe56099a3533e5927102a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 18 Aug 2026 02:17:17 +0000 Subject: [PATCH 4/7] =?UTF-8?q?fix(favourites):=20fix=20O(N=C2=B2)=20stora?= =?UTF-8?q?ge=20callbacks=20and=20hydration=20mismatch=20in=20useSyncExter?= =?UTF-8?q?nalStore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: BigSimmo <87357024+BigSimmo@users.noreply.github.com> --- .../favourites-command-library-page.tsx | 8 +++- .../favourites/favourites-storage.ts | 37 ++++++++++--------- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index 895a2568b3..202892925a 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -1122,9 +1122,13 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: const lastOpenedMap = useSyncExternalStore( subscribeFavouritesStorage, loadFavouriteLastOpened, - loadFavouriteLastOpened, + () => ({} as Record), + ); + const pinnedIds = useSyncExternalStore( + subscribeFavouritesStorage, + loadFavouritePinnedIds, + () => new Set(), ); - const pinnedIds = useSyncExternalStore(subscribeFavouritesStorage, loadFavouritePinnedIds, loadFavouritePinnedIds); const items = useMemo( () => [...(demoMode ? prototypeFavouriteItems : []), ...savedRegistryFavourites].map((item) => diff --git a/src/components/favourites/favourites-storage.ts b/src/components/favourites/favourites-storage.ts index 4504dde3b4..a658faba01 100644 --- a/src/components/favourites/favourites-storage.ts +++ b/src/components/favourites/favourites-storage.ts @@ -32,25 +32,28 @@ function notifyListeners() { } } +// Single shared storage handler at module level to avoid O(N²) callbacks when +// multiple subscribers are active (each per-subscriber handler would call +// notifyListeners(), firing all listeners N times per storage event). +let sharedStorageListenerAttached = false; +function ensureSharedStorageListener() { + if (sharedStorageListenerAttached || typeof window === "undefined") return; + sharedStorageListenerAttached = true; + window.addEventListener("storage", (event: StorageEvent) => { + if ( + event.key === DATABASE_FAVOURITES_LAST_OPENED_STORAGE_KEY || + event.key === DATABASE_FAVOURITES_PINNED_STORAGE_KEY + ) { + inMemoryLastOpened = null; + inMemoryPinned = null; + notifyListeners(); + } + }); +} + export function subscribeFavouritesStorage(listener: () => void): () => void { + ensureSharedStorageListener(); listeners.add(listener); - if (typeof window !== "undefined") { - const handleStorage = (event: StorageEvent) => { - if ( - event.key === DATABASE_FAVOURITES_LAST_OPENED_STORAGE_KEY || - event.key === DATABASE_FAVOURITES_PINNED_STORAGE_KEY - ) { - inMemoryLastOpened = null; - inMemoryPinned = null; - notifyListeners(); - } - }; - window.addEventListener("storage", handleStorage); - return () => { - listeners.delete(listener); - window.removeEventListener("storage", handleStorage); - }; - } return () => { listeners.delete(listener); }; From 2b1ba0cae99cb7c737db32b6ef5dbc2574f4b145 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 02:56:19 +0000 Subject: [PATCH 5/7] style: fix prettier formatting in favourites command library page CI's format:changed check was failing on this file's useSyncExternalStore formatting. --- .../favourites-command-library-page.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index 202892925a..0fa8256ba6 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -1122,13 +1122,9 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: const lastOpenedMap = useSyncExternalStore( subscribeFavouritesStorage, loadFavouriteLastOpened, - () => ({} as Record), - ); - const pinnedIds = useSyncExternalStore( - subscribeFavouritesStorage, - loadFavouritePinnedIds, - () => new Set(), + () => ({}) as Record, ); + const pinnedIds = useSyncExternalStore(subscribeFavouritesStorage, loadFavouritePinnedIds, () => new Set()); const items = useMemo( () => [...(demoMode ? prototypeFavouriteItems : []), ...savedRegistryFavourites].map((item) => From 09761d84ca2a882f1f7bbba0680adaf6b2373188 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 03:15:43 +0000 Subject: [PATCH 6/7] fix(ui): keep document rail high-yield summary visible when printing `max-sm:hidden` (added to dedupe the summary panel on phone viewports) also suppressed it during print emulation at narrow viewport widths, since the print stylesheet only restores overflow/box-shadow and never overrides display:none. Add `print:block`, the pattern already used elsewhere in this codebase (verification-notice.tsx, medication-considerations.tsx) to keep a responsively-hidden element printable. Fixes the "document viewer content disclosures are naturally closed and mutually exclusive by default" Production UI failure, which set a 390px viewport and asserted the high-yield-summary content becomes visible under print media. --- src/components/document-viewer/document-rail-panels.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/document-viewer/document-rail-panels.tsx b/src/components/document-viewer/document-rail-panels.tsx index d89bde41d9..58dec45dd1 100644 --- a/src/components/document-viewer/document-rail-panels.tsx +++ b/src/components/document-viewer/document-rail-panels.tsx @@ -138,7 +138,7 @@ export function DocumentViewerRail({ data-testid="high-yield-summary" className={cn( panel, - "group min-w-0 max-sm:hidden scroll-mt-[var(--document-anchor-offset,6rem)] source-print md:col-span-2 lg:col-span-1", + "group min-w-0 max-sm:hidden print:block scroll-mt-[var(--document-anchor-offset,6rem)] source-print md:col-span-2 lg:col-span-1", )} > Date: Tue, 18 Aug 2026 03:28:28 +0000 Subject: [PATCH 7/7] fix(test): update high-yield summary nav assertion for mobile dedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exclusive-accordion assertions after clicking the "High-yield summary" section-nav row assumed the rail's
still opens on a 390px viewport. Since the mobile route-dedup change hides that rail copy (max-sm:hidden) in favour of the always-visible in-flow DocumentClinicalSummary card, jumpToDocumentSection's existing "displayed copy" resolution (use-section-spy.ts's source-summary -> source-summary-card alias) now scrolls to the card instead of toggling the hidden accordion — so `summary.open` never becomes true at this viewport. Assert the visible copy scrolls into view instead, matching the pattern already used for the "Indexed source text" row above it. --- tests/ui-smoke.spec.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 65682065d5..645269dd67 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -4886,7 +4886,12 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(passages.nth(0)).toHaveJSProperty("open", false); await clickSectionNav(/High-yield summary/); - await expect(summary).toHaveJSProperty("open", true); + // At this 390px viewport the rail's high-yield-summary disclosure is + // hidden (superseded by the in-flow DocumentClinicalSummary card), so + // there is nothing for the exclusive accordion to open here — + // jumpToDocumentSection scrolls to the visible copy instead. + await expect(page.locator("#source-summary-card")).toBeInViewport(); + await expect(summary).toHaveJSProperty("open", false); await expect(indexedText).toHaveJSProperty("open", false); await openImagesDisclosure();