diff --git a/docs/branch-review-records/ccf620aa5c3871b3787e29f50511d79a9a9ba43bc3591b8d0e1bd117ed930ea5.record.md b/docs/branch-review-records/ccf620aa5c3871b3787e29f50511d79a9a9ba43bc3591b8d0e1bd117ed930ea5.record.md new file mode 100644 index 0000000000..07da5c66ee --- /dev/null +++ b/docs/branch-review-records/ccf620aa5c3871b3787e29f50511d79a9a9ba43bc3591b8d0e1bd117ed930ea5.record.md @@ -0,0 +1 @@ +| 2026-08-15 | claude/favourites-hybrid-164 | 02672320db5a0b5b57b589e8d19d19c8c66483d4 | Favourites search-led workspace: retire marketing H1 and library nav, live in-place filter, dashboard band (#164) | Shipped; no P0-P2 findings | verify:pr-local all 10 selected gates passed — unit suite 607 files / 6584 passed, 4 skipped; check:design-system-contract passed with edgeOwnershipConflicts 25 to 24 from the deleted nav module; UI delegated to CI Production UI — chromium-1194 vs pinned 1234 (#255/#312) | diff --git a/docs/outstanding-issues-inbox/0a052268-97e1-4d4a-9ad7-033003aa486c.json b/docs/outstanding-issues-inbox/0a052268-97e1-4d4a-9ad7-033003aa486c.json new file mode 100644 index 0000000000..166bba5ad1 --- /dev/null +++ b/docs/outstanding-issues-inbox/0a052268-97e1-4d4a-9ad7-033003aa486c.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "id": "0a052268-97e1-4d4a-9ad7-033003aa486c", + "createdOn": "2026-08-15", + "action": "add", + "payload": { + "pri": "P2", + "type": "task", + "summary": "Favourites Continue and Recent are driven by hard-coded demo timestamps; real saved items have no last-opened data", + "detail": "Surfaced while shipping #164 (PR #1983), which made both surfaces prominent. src/components/clinical-dashboard/favourites-command-library-page.tsx derives 'most recently used' from lastUsedScore(item.lastUsed), and item.lastUsed comes from lastUsedByItemId — a hard-coded five-entry literal keyed to demo slugs ('Today 08:44', 'Yesterday 16:12', ...). Anything else, including every real registry favourite, falls back to the literal string 'Saved', which lastUsedScore buckets at 1000. pinnedItemIds is likewise a hard-coded two-item Set. The consequence after #164: for a signed-in user with real favourites, the Continue card and the Recent panel are effectively arbitrary — every item ties at the same score and the order is whatever the source array happened to be. Note that recentQueries in the shell is search-query history, not viewed-item history, so it cannot back this. Next: add a per-favourite last-opened timestamp. Cheapest is a client-side recents store keyed by favourite id written on open; the durable version is a column on the account favourites record so it survives a device change, which is a schema plus /api/account/favourites change and needs the usual migration review. Either way, pinning should stop being a hard-coded id set. Stop: do not fabricate a timestamp at render time from anything other than a recorded open event — an invented 'last used' on a clinical reference list is worse than an honest absence.", + "source": "session 2026-08-15; PR #1983; favourites-command-library-page.tsx lastUsedByItemId/pinnedItemIds" + } +} diff --git a/docs/outstanding-issues-inbox/5cf244c3-1b68-4214-8097-767b11a49e13.json b/docs/outstanding-issues-inbox/5cf244c3-1b68-4214-8097-767b11a49e13.json new file mode 100644 index 0000000000..1f1ddc4661 --- /dev/null +++ b/docs/outstanding-issues-inbox/5cf244c3-1b68-4214-8097-767b11a49e13.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "id": "5cf244c3-1b68-4214-8097-767b11a49e13", + "createdOn": "2026-08-15", + "action": "done", + "payload": { + "id": "#164", + "outcome": "CLOSED 2026-08-15 by PR #1983. /favourites is one dashboard+search workspace; no ModeHome was reintroduced and no separate Favourites home route exists. DELIVERED: the 'Favourites command library' marketing H1, its heart icon tile and its explanatory subtitle are retired for a plain Favourites heading with the item count beside it as non-heading text; the desktop FavouritesSidebar plus the phone FavouritesMobileQuickViews and FavouritesMobileBrowseRail collapse into one chip rail carrying sets, Pinned/Source-backed and types with counts, after which favourites-library-nav.tsx had no callers and was deleted (529 lines, and it paid down one gate-8 edge conflict as a side effect: 25 to 24); the empty query shows Continue then Recent and Your sets side by side, a typed query demotes that band to a collapsed disclosure and filters the table in place with an 'N matches for ...' header; and the redundant filter computation is gone — filteredItems and the table's own tableRows were derived independently from identical inputs, so the band count and the table count were two answers to one question, and the page now derives rows once and passes them down. IN-PLACE FILTERING IS REAL, not just same-surface: the page reads the shared composer's live draft via useSearchCommand seeded from the route's submitted ?q=, the same pattern tools-search-results-page already uses, so typing filters without navigating and without a second input and the one-composer contract is untouched. NOT DONE, deliberately and worth knowing: Continue and Recent still render from the existing derivation, where lastUsedByItemId and pinnedItemIds are hard-coded five-entry literals for demo slugs and real registry items fall back to the literal string 'Saved'. Direction B leads with both surfaces, so they want a genuine per-item last-opened timestamp; that is a data-layer change and building it here would have silently rescoped this row. Captured as its own row. Verification: verify:pr-local all ten selected gates passed, unit suite 607 files / 6584 passed; UI proof delegated to CI Production UI (chromium-1194 vs pinned 1234, #255/#312)." + } +} diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index 517d1f57d2..bb7c10199c 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -5,10 +5,12 @@ import { useRouter } from "next/navigation"; import { ChevronDown, ChevronsRight, + Clock, Copy, ExternalLink, FileText, Folder, + FolderPlus, Heart, MoreVertical, Pill, @@ -20,15 +22,9 @@ import { X, type LucideIcon, } from "lucide-react"; -import { useMemo, useRef, useState } from "react"; +import { useMemo, useRef, useState, useSyncExternalStore } from "react"; -import { - FavouritesMobileBrowseRail, - FavouritesMobileQuickViews, - FavouritesSidebar, - useFavouritesNavCollapsed, - type FavouritesViewMode, -} from "@/components/clinical-dashboard/favourites-library-nav"; +import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context"; import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; import { useDismissableLayer } from "@/components/use-dismissable-layer"; import { cn, EmptyState, ignoreUnavailableActivation } from "@/components/ui-primitives"; @@ -53,7 +49,9 @@ import { useAuthSession } from "@/lib/supabase/client"; type FavouriteType = "Medication" | "Document" | "Table" | "Saved search" | "Source" | "Service" | "Form" | "Differential"; -type ViewMode = FavouritesViewMode; +// Previously imported from `favourites-library-nav`, which this redesign +// retired along with the sidebar and the two phone rails it exported. +type ViewMode = "all" | "source-backed" | "pinned" | "recent"; type SortMode = "last-used" | "title" | "type"; type FavouriteItem = { @@ -81,6 +79,15 @@ type FavouriteSet = { const focusRing = "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; +// How many rows the Recent card shows before "View all" takes over. Small on +// purpose: this is a glance surface sitting above the real table, not a second +// copy of it. +const recentPreviewLimit = 3; + +function subscribeNoop() { + return () => {}; +} + const typeAppearance: Record = { Medication: { kind: "information", tone: "accent" }, Document: { kind: "category", tone: "document" }, @@ -601,11 +608,252 @@ function FavouritesEmptyMatches() { ); } +/** + * One horizontal rail replacing three separate navigation surfaces: the + * desktop `FavouritesSidebar`, the phone `FavouritesMobileQuickViews`, and the + * phone `FavouritesMobileBrowseRail`. Direction B (ledger #164) collapses sets, + * quick views and types into a single row of chips, so the same control reads + * identically at every width instead of three components disagreeing about + * what "browse" means. + * + * Each chip toggles its own dimension, so a set and a type still compose the + * way the sidebar allowed. "All" is the only chip that clears every dimension, + * and it reads as pressed exactly when nothing else is. + */ +function FavouritesFilterRail({ + items, + sets, + selectedSetId, + selectedTypeId, + viewMode, + onSelectSet, + onSelectType, + onSelectViewMode, + onClearAll, +}: { + items: FavouriteItem[]; + sets: FavouriteSet[]; + selectedSetId: string | null; + selectedTypeId: string; + viewMode: ViewMode; + onSelectSet: (id: string | null) => void; + onSelectType: (id: string) => void; + onSelectViewMode: (mode: ViewMode) => void; + onClearAll: () => void; +}) { + const typeChips = favouriteTabs + .filter((tab) => tab.id !== "all" && tab.id !== "sets") + .map((tab) => ({ id: tab.id, label: tab.label, count: items.filter((item) => item.tabId === tab.id).length })) + .filter((tab) => tab.count > 0); + const pinnedCount = items.filter((item) => item.pinned === true).length; + const sourceBackedCount = items.filter((item) => isSourceBacked(item)).length; + const nothingActive = selectedSetId === null && selectedTypeId === "all" && viewMode === "all"; + + return ( + + ); +} + +function FilterRailChip({ + label, + count, + pressed, + onClick, +}: { + label: string; + count: number | null; + pressed: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +/** + * The empty-query dashboard band. Both cards read from the same derived data + * the table does — no separate store — so they cannot drift from it. + */ +function FavouritesDashboardBand({ + recentItems, + sets, + onSelectSet, + onShowRecent, +}: { + recentItems: FavouriteItem[]; + sets: FavouriteSet[]; + onSelectSet: (id: string) => void; + onShowRecent: () => void; +}) { + return ( +
+
+
+

+ + Recent +

+ +
+
    + {recentItems.map((item) => ( +
  • + + {item.type} + + + {item.title} + + {item.set} · {item.lastUsed} + + + + Open + +
  • + ))} +
+
+ +
+
+

+ + Your sets +

+
+ {sets.length === 0 ? ( +

+ Saved items group into sets as you add them. +

+ ) : ( +
    + {sets.map((set) => ( +
  • + +
  • + ))} +
+ )} +
+
+ ); +} + function FavouritesTable({ items, + rows: tableRows, searchTerm, - selectedTypeId, - selectedSet, viewMode, sortMode, selectedItemId, @@ -613,26 +861,18 @@ function FavouritesTable({ onSelectItem, }: { items: FavouriteItem[]; + // The filtered/sorted rows are computed once by the page and passed in. + // They used to be derived here as well, from the same inputs, so the band's + // match count and the table's own count were two independent answers to one + // question — the redundant half of the "dual search" ledger #164 names. + rows: FavouriteItem[]; searchTerm: string; - selectedTypeId: string; - selectedSet: FavouriteSet | null; viewMode: ViewMode; sortMode: SortMode; selectedItemId: string | null; onSortModeChange: (value: SortMode) => void; onSelectItem: (id: string) => void; }) { - const tableRows = useMemo(() => { - const rows = filterAndSortItems(items, { - searchTerm, - selectedTypeId, - selectedSet, - viewMode, - sortMode, - }); - return rows; - }, [items, searchTerm, selectedSet, selectedTypeId, viewMode, sortMode]); - // With the item workspace open (only at 2xl), the middle column narrows sharply. // Drop the leading icon and the secondary Evidence column there so titles keep // room instead of collapsing to a couple of characters. @@ -644,11 +884,24 @@ function FavouritesTable({ return (
-

- - {tableRows.length} - {tableRows.length === 1 ? "item" : "items"} - {tableRows.length !== items.length ? ` of ${items.length}` : ""} +

+ {searchTerm.trim() ? ( + <> + + {tableRows.length} + {tableRows.length === 1 ? "match for" : "matches for"} + + “{searchTerm.trim()}” + + + ) : ( + <> + + {tableRows.length} + {tableRows.length === 1 ? "item" : "items"} + {tableRows.length !== items.length ? ` of ${items.length}` : ""} + + )}