diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f0a04c6ec..21aacb0b77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,8 @@ on: pull_request: branches: ["**"] workflow_dispatch: + schedule: + - cron: "0 18 * * 0" permissions: contents: read @@ -61,3 +63,32 @@ jobs: - name: Chromium UI smoke run: npm run test:e2e:chromium + + release-browser-matrix: + if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/') + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: ".nvmrc" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + env: + NEXT_PUBLIC_SUPABASE_URL: https://sjrfecxgysukkwxsowpy.supabase.co + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: placeholder-ci-anon-key + + - name: Install Playwright browsers + run: npx playwright install --with-deps + + - name: Full browser UI matrix + run: npm run test:e2e diff --git a/docs/clinical-governance.md b/docs/clinical-governance.md index b3592dce07..024288a2a3 100644 --- a/docs/clinical-governance.md +++ b/docs/clinical-governance.md @@ -36,3 +36,11 @@ Use the `.github/pull_request_template.md` clinical governance section for any c - Confirm unknown or outdated source metadata is treated conservatively. - Confirm demo/synthetic content remains separated from real clinical sources. - Confirm clinical decision-support behavior changes have deployment classification and TGA SaMD impact reviewed before production use. + +## Verification Records + +### RLS & access scoping — 2026-06-28 +- Supabase **security advisors: 0 findings** for `Clinical KB Database` (`sjrfecxgysukkwxsowpy`). The linter specifically flags missing RLS / insecure policies, so a clean run confirms RLS is enabled and policy-covered across `public` tables. +- Supabase **performance advisors: INFO only** — unused indexes (expected on a low-traffic database; do not drop pre-launch) and one auth connection-strategy tip (switch to percentage-based allocation when scaling instance size). +- **Application-layer cross-owner denial** (service-role routes enforce `owner_id` scoping in code) is covered by `tests/private-access-routes.test.ts` and `tests/private-rag-access.test.ts` (unowned document detail/signed-url/rename rejected; listing and search scoped to the authenticated owner). +- **Follow-up:** add a live DB-level RLS integration test that connects as two real authenticated users via the publishable (anon) key and asserts owner B cannot read owner A's rows. This needs a seeded test project/harness and is tracked as a remaining item. diff --git a/docs/process-hardening.md b/docs/process-hardening.md index ad8ce6aabf..448874a2f0 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -7,7 +7,7 @@ This document turns the current process review into phased, durable repo practic - `npm run verify:cheap` is the default broad local gate for source/config/test changes: lint, typecheck, and unit tests. - `npm run verify:ui` is the default UI gate: Chromium Playwright smoke, stress, and accessibility media checks. - `npm run verify:release` is the release-confidence gate: lint, typecheck, unit tests, build, and the full Playwright browser project set. -- CI now installs Chromium and runs the Chromium UI gate after build. +- CI now installs Chromium and runs the Chromium UI gate after build on all branches; a gated release-browser job runs the full Playwright browser matrix on `main`, `release/*`, manual dispatch, and the weekly schedule. - `tests/ui-accessibility.spec.ts` covers reduced-motion and forced-colors dashboard usability so those modes are no longer only reviewed by inspection. - `tests/ui-tools.spec.ts` covers the `/applications` launcher at mobile and desktop sizes. - `AGENTS.md` now points future agents to these gates and to this document. @@ -36,13 +36,13 @@ This document turns the current process review into phased, durable repo practic - `npm run check:runtime` is the strict runtime gate and is now part of `npm run verify:cheap`, `npm run verify:ui`, and `npm run verify:release`; it fails outside Node 24.x or npm 11.x when run through npm. - CI runs `npm run check:runtime` after dependency install so branch verification cannot silently drift away from Node 24. - `npm run check:edge:functions` is the Deno type gate for the Supabase `indexing-v3-agent` Edge Function. -- Decide whether CI should run all Playwright browser projects on protected branches, release branches, or a scheduled workflow instead of every push. +- Tune the full-browser CI cadence if release branches or weekly schedules prove too slow or too sparse. - Add explicit review ownership for clinical source governance, outdated-source handling, incident review, and decommission decisions. - Record production-readiness outcomes in release notes whenever clinical workflow, source governance, privacy, or deployment assumptions change. ## Known limits -- Chromium UI coverage is active in CI now; Firefox and WebKit remain available through `npm run test:e2e` and `npm run verify:release`. +- Chromium UI coverage is active in CI on all branches; Firefox and WebKit run in the gated release-browser CI job and remain available locally through `npm run test:e2e` and `npm run verify:release`. - The new accessibility media smoke verifies usability and layout in reduced-motion and forced-colors modes; it is not a full WCAG audit. - The format gate intentionally ignores `.tmp-visual/` and `scratch/`; those folders are local investigation output, not release source. - Process scripts do not commit, push, deploy, mutate Supabase data, or run dependency updates. diff --git a/docs/supabase-migration-reconciliation.md b/docs/supabase-migration-reconciliation.md index 406d53d492..1fd3b2899c 100644 --- a/docs/supabase-migration-reconciliation.md +++ b/docs/supabase-migration-reconciliation.md @@ -1,6 +1,6 @@ # Supabase Migration Reconciliation -Last reviewed: 2026-06-27 +Last reviewed: 2026-06-28 Target project: Clinical KB Database (`sjrfecxgysukkwxsowpy`) @@ -12,15 +12,20 @@ Target project: Clinical KB Database (`sjrfecxgysukkwxsowpy`) ## Verified Applied -These versions are safe to mark applied in Supabase migration history: +These previously local-only versions have been verified in the live project history: - `20260625033425` - `document_strict_gate_status` exists, `repair_strict_enrichment_gate_batch(integer)` exists, service role can read/execute, and anon cannot read/execute. - `20260625033944` - `complete_strict_enrichment_job(uuid, uuid, text, text, text)` exists, service role can execute, and anon cannot execute. - `20260626000000` - duplicate index `ingestion_job_stages_doc_idx` is absent and canonical index `ingestion_job_stages_document_started_idx` exists. +- `20260626020000` - retrieval RPC performance migration is present in remote history. +- `20260626030000` - document organisation profile label constraint migration is present in remote history. +- `20260627000000` - deliberately applied as a no-op deferral for retrieval HNSW `ef_search`; hosted migrations cannot set this function GUC for this project, and the live vector RPC bodies already use session-local `set_config('hnsw.ef_search', '100', true)` where relevant. +- `20260628000000` - atomic document index generation commit RPC and committed-generation retrieval filters are present and verified in live. +- `20260628135727` - explicit `invoke_indexing_v3_agent(integer)` execute grant hardening is present and verified in live. -## Skipped +## Current Status -All other local-only migrations from `supabase migration list --linked` remain unrepaired until they are individually verified. This includes older search/retrieval/API-rate-limit migrations and the current `20260626020000` retrieval RPC performance migration. +As of this review, `npx supabase migration list --linked` shows no local-only migrations for `sjrfecxgysukkwxsowpy`. Remote migration history is aligned through `20260628135727`. ## Verification Commands @@ -28,4 +33,5 @@ All other local-only migrations from `supabase migration list --linked` remain u npx supabase migration list --linked npx supabase db advisors --linked npx supabase db query --linked "select to_regclass('public.document_strict_gate_status') as gate_view, to_regprocedure('public.repair_strict_enrichment_gate_batch(integer)') as repair_rpc, to_regprocedure('public.complete_strict_enrichment_job(uuid, uuid, text, text, text)') as complete_rpc, to_regclass('public.ingestion_job_stages_doc_idx') as duplicate_index, to_regclass('public.ingestion_job_stages_document_started_idx') as canonical_stage_index;" +npx supabase db query --linked "select to_regprocedure('public.commit_document_index_generation(uuid, uuid, text, integer, integer, integer, jsonb, jsonb, jsonb)') as commit_generation_rpc, has_function_privilege('anon', 'public.invoke_indexing_v3_agent(integer)', 'execute') as anon_can_invoke_indexing_v3_agent, has_function_privilege('service_role', 'public.invoke_indexing_v3_agent(integer)', 'execute') as service_role_can_invoke_indexing_v3_agent;" ``` diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts index fdd5b37654..7c7c2935a0 100644 --- a/src/app/api/answer/route.ts +++ b/src/app/api/answer/route.ts @@ -71,7 +71,7 @@ export async function POST(request: Request) { if (scope.documentIds?.length === 0) { return NextResponse.json({ answer: - "The selected filters did not match any indexed documents, so I cannot generate a source-backed answer for that scope.", + "The selected filters did not match any indexed documents, so I cannot generate an answer for that scope.", grounded: false, confidence: "unsupported", citations: [], @@ -97,12 +97,16 @@ export async function POST(request: Request) { relevance: answer.relevance ?? answer.smartPanel?.relevance ?? null, }); if (hasDangerSourceGovernanceWarning(warnings)) { + // Build the refusal explicitly — never spread ...answer here, or the original + // (refused) sources/smartPanel/smartApiPlan would still reach the client and + // defeat the refusal. Keep only the safe "unsupported" contract fields, matching + // the empty-scope branch above. return NextResponse.json({ - ...answer, answer: sourceGovernanceRefusalAnswer, grounded: false, confidence: "unsupported", citations: [], + sources: [], scope: { ...scope, queryMode: body.queryMode }, sourceGovernanceWarnings: warnings, }); diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index b877ee33e1..ecff1dcd3c 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -15,7 +15,7 @@ import { sourceGovernanceWarnings, } from "@/lib/source-governance"; import { createAdminClient } from "@/lib/supabase/admin"; -import { requireAuthenticatedUser } from "@/lib/supabase/auth"; +import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; export const runtime = "nodejs"; @@ -64,7 +64,9 @@ function streamErrorPayload(error: unknown) { if (error instanceof Error) { return { message: "Answer generation failed. Retry with a narrower question.", - status: 503, + // Match the non-streaming /api/answer route, which returns 500 for a + // generic answer-generation failure. + status: 500, details: { code: error.name }, }; } @@ -107,7 +109,7 @@ function streamAnswer(body: AnswerBody, ownerId?: string) { if (scope?.documentIds?.length === 0) { send("final", { answer: - "The selected filters did not match any indexed documents, so I cannot generate a source-backed answer for that scope.", + "The selected filters did not match any indexed documents, so I cannot generate an answer for that scope.", grounded: false, confidence: "unsupported", citations: [], @@ -159,12 +161,14 @@ function streamAnswer(body: AnswerBody, ownerId?: string) { relevance: answer.relevance ?? answer.smartPanel?.relevance ?? null, }); if (hasDangerSourceGovernanceWarning(warnings)) { + // Explicit refusal payload — do not spread ...answer (see /api/answer): + // the refused sources/smartPanel/smartApiPlan must not reach the client. send("final", { - ...answer, answer: sourceGovernanceRefusalAnswer, grounded: false, confidence: "unsupported", citations: [], + sources: [], scope: scope ? { ...scope, queryMode: body.queryMode } : undefined, sourceGovernanceWarnings: warnings, }); @@ -213,8 +217,8 @@ export async function POST(request: Request) { return streamAnswer(body, user.id); } catch (error) { - if (error instanceof Error && error.name === "AuthenticationError") { - return Response.json({ error: "Authentication required." }, { status: 401 }); + if (error instanceof AuthenticationError) { + return unauthorizedResponse(error); } if (error instanceof z.ZodError) { return jsonError(error, 400); diff --git a/src/app/api/eval-cases/route.ts b/src/app/api/eval-cases/route.ts index 00d52480a1..d461c416cc 100644 --- a/src/app/api/eval-cases/route.ts +++ b/src/app/api/eval-cases/route.ts @@ -4,7 +4,12 @@ import { normalizedClinicalSearchTokens } from "@/lib/clinical-search"; import { clinicalQueryModeSchema } from "@/lib/clinical-query-mode"; import { env, isDemoMode } from "@/lib/env"; import { jsonError, PublicApiError } from "@/lib/http"; -import { queryPrivacyMetadata, queryTextForStorage } from "@/lib/query-privacy"; +import { + normalizedQueryTextForStorage, + queryDerivedTokensForStorage, + queryPrivacyMetadata, + queryTextForStorage, +} from "@/lib/query-privacy"; import { searchScopeFiltersSchema } from "@/lib/search-scope"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; @@ -117,7 +122,7 @@ export async function POST(request: Request) { const supabase = createAdminClient(); const user = await requireAuthenticatedUser(request, supabase); - const normalizedQuery = parsed.data.query.toLowerCase().replace(/\s+/g, " ").trim(); + const normalizedQuery = normalizedQueryTextForStorage(parsed.data.query); const sourceChunkIds = uniqueUuidValues(parsed.data.sourceChunkIds); const citedChunkIds = uniqueUuidValues(parsed.data.citedChunkIds); const sourceFiles = uniqueValues(parsed.data.sourceFiles); @@ -149,7 +154,7 @@ export async function POST(request: Request) { miss_reason: missReason, expected_document_id: expectedDocumentId, expected_chunk_id: expectedChunkId, - candidate_aliases: normalizedClinicalSearchTokens(parsed.data.query).slice(0, 12), + candidate_aliases: queryDerivedTokensForStorage(normalizedClinicalSearchTokens(parsed.data.query).slice(0, 12)), promoted_eval_case: true, promoted_at: new Date().toISOString(), metadata: { diff --git a/src/app/api/search/interaction/route.ts b/src/app/api/search/interaction/route.ts index 40b7483994..7c283d1508 100644 --- a/src/app/api/search/interaction/route.ts +++ b/src/app/api/search/interaction/route.ts @@ -2,7 +2,12 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { normalizedClinicalSearchTokens } from "@/lib/clinical-search"; import { isDemoMode } from "@/lib/env"; -import { normalizeQueryText, queryPrivacyMetadata, queryTextForStorage } from "@/lib/query-privacy"; +import { + normalizedQueryTextForStorage, + queryDerivedTokensForStorage, + queryPrivacyMetadata, + queryTextForStorage, +} from "@/lib/query-privacy"; import { createAdminClient } from "@/lib/supabase/admin"; import * as serverAuth from "@/lib/supabase/auth"; @@ -76,14 +81,14 @@ export async function POST(request: Request) { await supabase.from("rag_query_misses").insert({ owner_id: user.id, query: queryTextForStorage(body.query), - normalized_query: normalizeQueryText(body.query), + normalized_query: normalizedQueryTextForStorage(body.query), query_class: body.queryClass ?? null, clicked_document_id: clickedDocumentId, clicked_chunk_id: clickedChunkId, top_files: safeFileName ? [safeFileName] : [], top_chunk_ids: clickedChunkId ? [clickedChunkId] : [], miss_reason: "clicked_result", - candidate_aliases: normalizedClinicalSearchTokens(body.query).slice(0, 10), + candidate_aliases: queryDerivedTokensForStorage(normalizedClinicalSearchTokens(body.query).slice(0, 10)), candidate_labels: safeTitle ? [ { diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index f3bc4b87dd..11d975fa39 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -16,7 +16,12 @@ import { consumeApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit import { clinicalQueryModeSchema, queryClassForClinicalMode, queryForClinicalMode } from "@/lib/clinical-query-mode"; import { resolveSearchScope, searchScopeFiltersSchema } from "@/lib/search-scope"; import { sourceGovernanceWarnings } from "@/lib/source-governance"; -import { normalizeQueryText, queryPrivacyMetadata, queryTextForStorage } from "@/lib/query-privacy"; +import { + normalizedQueryTextForStorage, + queryDerivedTokensForStorage, + queryPrivacyMetadata, + queryTextForStorage, +} from "@/lib/query-privacy"; import type { ChunkImage, ClinicalSourceMetadata, SearchResult } from "@/lib/types"; export const runtime = "nodejs"; @@ -86,43 +91,48 @@ function buildDocumentMatchesFromResults(results: SearchResult[], limit: number) file_name: string; bestPages: number[]; bestChunkIds: string[]; - imageCount: number; - tableCount: number; + // Track unique image ids: the same image is often hydrated onto several of + // a document's chunks, so summing per-chunk counts would inflate the badge. + imageIds: Set; + tableImageIds: Set; score: number; } >(); for (const result of results) { - const current = grouped.get(result.document_id); const score = result.hybrid_score ?? result.similarity; const page = result.page_number ?? null; const clinicalImages = result.images?.filter((image) => isClinicalImageEvidence(image)) ?? []; - const tableCount = clinicalImages.filter((image) => image.source_kind === "table_crop").length; - const imageCount = clinicalImages.length; + let current = grouped.get(result.document_id); if (!current) { - grouped.set(result.document_id, { + current = { document_id: result.document_id, title: result.title, file_name: result.file_name, - bestPages: page ? [page] : [], - bestChunkIds: [result.id], - imageCount, - tableCount, + bestPages: [], + bestChunkIds: [], + imageIds: new Set(), + tableImageIds: new Set(), score, - }); - continue; + }; + grouped.set(result.document_id, current); } current.score = Math.max(current.score, score); if (page && !current.bestPages.includes(page)) current.bestPages.push(page); if (!current.bestChunkIds.includes(result.id)) current.bestChunkIds.push(result.id); - current.imageCount += imageCount; - current.tableCount += tableCount; + for (const image of clinicalImages) { + if (!image.id) continue; + current.imageIds.add(image.id); + if (image.source_kind === "table_crop") current.tableImageIds.add(image.id); + } } return Array.from(grouped.values()) .sort((a, b) => b.score - a.score) .slice(0, limit) - .map((document) => ({ + .map(({ imageIds, tableImageIds, ...document }) => ({ ...document, + imageCount: imageIds.size, + tableCount: tableImageIds.size, labels: [], summarySnippet: null, matchReason: `Matched ${document.bestChunkIds.length} indexed passage${ @@ -337,7 +347,7 @@ function candidatePromotions(query: string, results: SearchResult[]) { confidence: label.confidence, })); return { - aliases: Array.from(new Set(queryTerms)).slice(0, 10), + aliases: queryDerivedTokensForStorage(Array.from(new Set(queryTerms)).slice(0, 10)), labels: topLabels, }; } @@ -370,7 +380,7 @@ function logWeakSearch(args: { .insert({ owner_id: args.ownerId, query: queryTextForStorage(args.query), - normalized_query: normalizeQueryText(args.query), + normalized_query: normalizedQueryTextForStorage(args.query), query_class: args.queryClass, route: args.route ?? null, retrieval_strategy: args.retrievalStrategy ?? null, @@ -478,7 +488,7 @@ function logRetrievalDiagnostics(args: { await args.supabase.from("rag_retrieval_logs").insert({ owner_id: args.ownerId, query: queryTextForStorage(args.query), - normalized_query: normalizeQueryText(args.query), + normalized_query: normalizedQueryTextForStorage(args.query), query_class: (args.telemetry.query_class as string) ?? null, retrieval_strategy: (args.telemetry.retrieval_strategy as string) ?? null, candidate_count: args.results.length, diff --git a/src/app/api/setup-status/route.ts b/src/app/api/setup-status/route.ts index ba34855b83..bf0551c94f 100644 --- a/src/app/api/setup-status/route.ts +++ b/src/app/api/setup-status/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { env, isDemoMode } from "@/lib/env"; import { localProjectRequestIdentityPayload, unsafeLocalProjectResponse } from "@/lib/local-project-guard"; import { createAdminClient } from "@/lib/supabase/admin"; +import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; import { formatSupabaseUnavailableError, isSupabaseUnavailableError, probeSupabaseHealth } from "@/lib/supabase/health"; import { checkSupabaseProjectConfig, formatSupabaseProjectCheck } from "@/lib/supabase/project"; @@ -408,11 +409,27 @@ async function readSetupStatusPayload() { } } -export async function GET(request: Request) { +async function requireProductionSetupStatusAuth(request: Request) { const identity = localProjectRequestIdentityPayload(request); - if (!identity.localServer.safeLocalOrigin) { - return unsafeLocalProjectResponse(identity); + if (process.env.NODE_ENV !== "production" || identity.localServer.currentUrl) { + return identity; } + await requireAuthenticatedUser(request, createAdminClient()); + return identity; +} - return setupStatusResponse(await readSetupStatusPayload()); +export async function GET(request: Request) { + try { + const identity = await requireProductionSetupStatusAuth(request); + if (!identity.localServer.safeLocalOrigin) { + return unsafeLocalProjectResponse(identity); + } + + return setupStatusResponse(await readSetupStatusPayload()); + } catch (error) { + if (error instanceof AuthenticationError) { + return unauthorizedResponse(error); + } + throw error; + } } diff --git a/src/app/mockups/recent-searches-bottom/page.tsx b/src/app/mockups/recent-searches-bottom/page.tsx index 2783ef3ea1..f78e9a3138 100644 --- a/src/app/mockups/recent-searches-bottom/page.tsx +++ b/src/app/mockups/recent-searches-bottom/page.tsx @@ -1,15 +1,8 @@ import { - BookOpenCheck, Clock3, FileText, Filter, - Menu, - Mic, - Moon, - Plus, Search, - Send, - Sparkles, X, } from "lucide-react"; @@ -27,107 +20,9 @@ const sourceCards = [ ["Acamprosate prescribing note", "Dose thresholds, contraindications, PBS access", "Current"], ] as const; -function HeaderMockup() { - return ( -
-
- - - - -
- - - - AK - - -
-
-
- ); -} - -function SidebarMockup() { - return ( - - ); -} - function RecentSearchRail() { return ( -
+
Recent @@ -155,44 +50,6 @@ function RecentSearchRail() { ); } -function BottomComposerMockup() { - return ( -
- -
- - - - -
-
- ); -} - function SourcePreviewGrid() { return (
@@ -219,54 +76,46 @@ function SourcePreviewGrid() { export default function RecentSearchesBottomMockupPage() { return ( -
-
- -
- -
-
-
-
-
- - - - - Ready - - - All sources - -
-

- Ask from your indexed clinical sources -

-

- Recent searches sit near the bottom composer as low-emphasis shortcuts, leaving the answer canvas - quiet until the clinician starts a new query. -

-
+
+
+
+
+
+
+ + + + + Ready + + + All sources + +
+

+ Ask from your indexed clinical sources +

+

+ Recent searches sit near the bottom composer as low-emphasis shortcuts, leaving the answer canvas quiet + until the clinician starts a new query. +

+
- + -
-
- -

- Scope and mode stay primary -

- - Recent is secondary - -
-
+
+
+ +

Scope and mode stay primary

+ + Recent is secondary +
-
- +
-
+ +
); } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 578f2c612d..68f6f29753 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -7,7 +7,6 @@ import { useRouter } from "next/navigation"; import { Activity, AlertCircle, - ArrowUpDown, BookOpen, Brain, CheckCircle2, @@ -19,8 +18,6 @@ import { FileImage, FileText, Filter, - Folder, - FolderInput, Heart, HeartHandshake, Layers, @@ -142,6 +139,8 @@ import { sourceDisplayTitle, } from "@/components/clinical-dashboard/display-text"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; +import { FavouritesHub } from "@/components/clinical-dashboard/favourites-hub"; +import { favouritePrototypeCount } from "@/components/clinical-dashboard/favourites-prototype-data"; import { MedicationPrescribingWorkspace } from "@/components/clinical-dashboard/medication-prescribing-workspace"; import { DocumentSearchResultsPanel, @@ -5331,32 +5330,61 @@ function DrawerGroupLabel({ title }: { title: string }) { } const sidebarToolItems = [ - { label: "Formulation", icon: Brain, href: "/applications" }, - { label: "DSM-5", icon: BookOpen, href: "/applications" }, + { label: "Answer", icon: Sparkles, href: "/?mode=answer" }, + { label: "Documents", icon: FileText, href: "/?mode=documents" }, { label: "Meds", icon: Pill, href: "/?mode=prescribing" }, - { label: "Diffs", icon: Search, href: "/applications" }, + { label: "Applications", icon: ListChecks, href: "/applications" }, ] as const; +type SidebarIdentity = { + displayName: string; + initials: string; + detail: string; + signedIn: boolean; +}; + +function deriveSidebarIdentity(email: string | null | undefined): SidebarIdentity { + const normalized = email?.trim(); + if (!normalized) { + return { displayName: "Guest", initials: "G", detail: "Not signed in", signedIn: false }; + } + const handle = normalized.split("@")[0] || normalized; + const parts = handle.split(/[._\-+]+/).filter(Boolean); + const initials = (parts.length >= 2 ? `${parts[0][0]}${parts[1][0]}` : handle.slice(0, 2)).toUpperCase() || "U"; + const displayName = + parts.length > 0 ? parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ") : normalized; + return { displayName, initials, detail: normalized, signedIn: true }; +} + function ClinicalSidebarContent({ recentQueries, + identity, onNewChat, onPickRecent, onOpenGuide, + onOpenSettings, onPrefetchApplications, showHeader = true, onCollapsedChange, onNavigate, }: { recentQueries: string[]; + identity: SidebarIdentity; onNewChat: () => void; onPickRecent: (query: string) => void; onOpenGuide: () => void; + onOpenSettings: () => void; onPrefetchApplications?: () => void; showHeader?: boolean; onCollapsedChange?: (collapsed: boolean) => void; onNavigate?: () => void; }) { - const visibleRecentQueries = recentQueries.slice(0, 5); + const [chatFilter, setChatFilter] = useState(""); + const normalizedChatFilter = chatFilter.trim().toLowerCase(); + const matchingRecentQueries = normalizedChatFilter + ? recentQueries.filter((recent) => recent.toLowerCase().includes(normalizedChatFilter)) + : recentQueries; + const visibleRecentQueries = matchingRecentQueries.slice(0, 5); return (
@@ -5400,6 +5428,9 @@ function ClinicalSidebarContent({ setChatFilter(event.target.value)} + aria-label="Search recent chats" className="h-11 w-full rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] pl-9 pr-3 text-sm font-medium text-[color:var(--text)] shadow-[var(--shadow-inset)] outline-none placeholder:text-[color:var(--text-soft)] focus:border-[color:var(--focus)] focus:ring-4 focus:ring-[color:var(--focus)]/20" /> @@ -5438,7 +5469,7 @@ function ClinicalSidebarContent({ textMuted, )} > - Recent chats will appear here. + {normalizedChatFilter ? "No recent chats match your search." : "Recent chats will appear here."}

)}
@@ -5494,19 +5525,28 @@ function ClinicalSidebarContent({ Guide & help -
- AK + {identity.initials} - Dr A. Khan + + {identity.displayName} + -
@@ -5518,18 +5558,22 @@ function ClinicalSidebarContent({ function ClinicalDesktopSidebar({ collapsed, recentQueries, + identity, onCollapsedChange, onNewChat, onPickRecent, onOpenGuide, + onOpenSettings, onPrefetchApplications, }: { collapsed: boolean; recentQueries: string[]; + identity: SidebarIdentity; onCollapsedChange: (collapsed: boolean) => void; onNewChat: () => void; onPickRecent: (query: string) => void; onOpenGuide: () => void; + onOpenSettings: () => void; onPrefetchApplications: () => void; }) { if (collapsed) { @@ -5550,7 +5594,13 @@ function ClinicalDesktopSidebar({ - - -
- AK +
+ {identity.initials}
); @@ -5585,10 +5639,12 @@ function ClinicalDesktopSidebar({ > @@ -5598,18 +5654,22 @@ function ClinicalDesktopSidebar({ function ClinicalMobileSidebar({ open, recentQueries, + identity, onOpenChange, onNewChat, onPickRecent, onOpenGuide, + onOpenSettings, onPrefetchApplications, }: { open: boolean; recentQueries: string[]; + identity: SidebarIdentity; onOpenChange: (open: boolean) => void; onNewChat: () => void; onPickRecent: (query: string) => void; onOpenGuide: () => void; + onOpenSettings: () => void; onPrefetchApplications: () => void; }) { return ( @@ -5625,9 +5685,11 @@ function ClinicalMobileSidebar({ onOpenChange(false)} /> @@ -5635,569 +5697,127 @@ function ClinicalMobileSidebar({ ); } -type FavouriteType = "medications" | "documents" | "sources" | "sets"; -type FavouriteTabId = "all" | FavouriteType; - -type FavouriteItem = { - id: string; - title: string; - type: Exclude; - set: string; - meta: string; - sourceMeta: string; - primaryAction: string; - icon: typeof FileText; - keywords: string; -}; - -type FavouriteSet = { - id: string; - title: string; - count: number; - meta: string; - keywords: string; -}; - -const favouriteTabs: Array<{ - id: FavouriteTabId; - label: string; - shortLabel: string; - icon: typeof FileText; -}> = [ - { id: "all", label: "All", shortLabel: "All", icon: LayoutList }, - { id: "medications", label: "Medications", shortLabel: "Meds", icon: Pill }, - { id: "documents", label: "Documents", shortLabel: "Docs", icon: FileText }, - { id: "sources", label: "Sources", shortLabel: "Sources", icon: Quote }, - { id: "sets", label: "Sets", shortLabel: "Sets", icon: Folder }, -]; - -const favouriteItems: FavouriteItem[] = [ - { - id: "acamprosate-renal-screen", - title: "Acamprosate renal screen", - type: "medications", - set: "Ward round", - meta: "Medication page · renal cautions · dose notes", - sourceMeta: "3 sources", - primaryAction: "Open", - icon: Pill, - keywords: "acamprosate renal screen medication dose safety ward round pbs", - }, - { - id: "lithium-monitoring-guideline", - title: "Lithium monitoring guideline", - type: "documents", - set: "Prescribing safety", - meta: "PDF · p.4-9 · 2 tables", - sourceMeta: "PDF", - primaryAction: "Ask", - icon: FileText, - keywords: "lithium monitoring guideline blood tests shared care renal toxicity", - }, - { - id: "clozapine-monitoring-table", - title: "Clozapine monitoring table", - type: "sources", - set: "Clozapine clinic", - meta: "Saved table · ANC monitoring", - sourceMeta: "Table", - primaryAction: "Source", - icon: Quote, - keywords: "clozapine monitoring table anc fbc neutrophil clinic", - }, - { - id: "renal-dose-search", - title: "renal dose saved search", - type: "sources", - set: "Ward round", - meta: "Saved query · medicines + documents", - sourceMeta: "Run", - primaryAction: "Run", - icon: Search, - keywords: "renal dose saved search kidney egfr medicines documents", - }, - { - id: "qt-prolongation-quote", - title: "QT prolongation quote", - type: "sources", - set: "Prescribing safety", - meta: "Source card · prescribing safety", - sourceMeta: "Quote", - primaryAction: "Copy", - icon: Quote, - keywords: "qt prolongation quote source card prescribing safety", - }, -]; - -const favouriteSets: FavouriteSet[] = [ - { - id: "ward-round", - title: "Ward round", - count: 12, - meta: "Medication pages, renal checks, forms", - keywords: "ward round acamprosate lithium renal mht forms", - }, - { - id: "prescribing-safety", - title: "Prescribing safety", - count: 9, - meta: "Dose limits, pregnancy, renal cautions", - keywords: "prescribing safety dose pregnancy renal qt interactions", - }, - { - id: "clozapine-clinic", - title: "Clozapine clinic", - count: 7, - meta: "Monitoring, ANC table, counselling", - keywords: "clozapine clinic monitoring anc table counselling", - }, -]; - -function favouriteTypeCount(type: FavouriteTabId) { - if (type === "all") return favouriteItems.length + favouriteSets.length; - if (type === "sets") return favouriteSets.length; - return favouriteItems.filter((item) => item.type === type).length; -} - -function favouriteMatchesQuery(value: { title: string; meta?: string; set?: string; keywords: string }, query: string) { - const normalized = query.trim().toLowerCase(); - if (!normalized) return true; - return [value.title, value.meta, value.set, value.keywords] - .filter(Boolean) - .join(" ") - .toLowerCase() - .includes(normalized); -} - -function FavouritesHub({ - query, - onClearQuery, - onAddFavourite, +function SettingsDialog({ + open, + onClose, + identity, + theme, + onToggleTheme, + onSignOut, + onOpenGuide, }: { - query: string; - onClearQuery: () => void; - onAddFavourite: () => void; + open: boolean; + onClose: () => void; + identity: SidebarIdentity; + theme: "light" | "dark"; + onToggleTheme: () => void; + onSignOut: () => void; + onOpenGuide: () => void; }) { - const [selectedTab, setSelectedTab] = useState("all"); - const [tabMenuOpen, setTabMenuOpen] = useState(false); - const [selectedSetId, setSelectedSetId] = useState(null); - const tabMenuRef = useRef(null); - const normalizedQuery = query.trim(); - const selectedSet = selectedSetId ? favouriteSets.find((set) => set.id === selectedSetId) : null; - const tabItems = - selectedTab === "all" || selectedTab === "sets" - ? favouriteItems - : favouriteItems.filter((item) => item.type === selectedTab); - const visibleItems = tabItems - .filter((item) => favouriteMatchesQuery(item, normalizedQuery)) - .filter((item) => !selectedSet || item.set === selectedSet.title); - const visibleSets = favouriteSets.filter((set) => favouriteMatchesQuery(set, normalizedQuery)); - const showSets = selectedTab === "all" || selectedTab === "sets"; - const showItems = selectedTab !== "sets"; - const empty = (!showItems || visibleItems.length === 0) && (!showSets || visibleSets.length === 0); - const selectedTabMeta = favouriteTabs.find((tab) => tab.id === selectedTab) ?? favouriteTabs[0]; - const selectedTabLabel = selectedTabMeta.label; - const selectedTabCount = favouriteTypeCount(selectedTab); - const SelectedTabIcon = selectedTabMeta.icon; - const itemCount = favouriteItems.length; - const setCount = favouriteSets.length; - const activeFilterCount = (normalizedQuery ? 1 : 0) + (selectedSet ? 1 : 0); - - useEffect(() => { - if (!tabMenuOpen) return undefined; - - function handlePointerDown(event: PointerEvent) { - const target = event.target; - if (!(target instanceof Node)) return; - if (!tabMenuRef.current?.contains(target)) setTabMenuOpen(false); - } - - function handleKeyDown(event: KeyboardEvent) { - if (event.key === "Escape") { - event.preventDefault(); - setTabMenuOpen(false); - } - } - - document.addEventListener("pointerdown", handlePointerDown); - document.addEventListener("keydown", handleKeyDown); - return () => { - document.removeEventListener("pointerdown", handlePointerDown); - document.removeEventListener("keydown", handleKeyDown); - }; - }, [tabMenuOpen]); + const themeOptions: Array<{ value: "light" | "dark"; label: string }> = [ + { value: "light", label: "Light" }, + { value: "dark", label: "Dark" }, + ]; return ( -
-
-
- - - -
-

- Favourites -

-

- Keep trusted notes, sources, medication pages, and clinical sets ready for reuse. -

+ +
+
+
+ +

Appearance

-
- -
- {[ - { label: "Items", value: itemCount, icon: Heart }, - { label: "Sets", value: setCount, icon: Folder }, - { label: "Active", value: activeFilterCount, icon: Filter }, - ].map((stat) => { - const Icon = stat.icon; - return ( -
-
- - {stat.label} -
-

- {stat.value} -

-
- ); - })} -
-
+

Choose the workspace theme. Saved to this browser.

+
+ {themeOptions.map((option) => { + const active = option.value === theme; + return ( + + ); + })} +
+ -
-
-
- - - {tabMenuOpen ? ( -
- {favouriteTabs.map((tab) => { - const Icon = tab.icon; - const selected = selectedTab === tab.id; - const count = favouriteTypeCount(tab.id); - return ( - - ); - })} -
- ) : null} -
- {normalizedQuery ? ( - - ) : null} - {selectedSet ? ( - - ) : null} -
-
- - -
-
- -
-
-

Saved sets

- -
-
- {visibleSets.slice(0, 3).map((set) => ( - { - setSelectedSetId(set.id); - if (selectedTab === "sets") setSelectedTab("all"); - }} - /> - ))} -
-
- -
-
-
-
-

- {selectedTab === "all" - ? "Recent favourites" - : selectedTab === "sets" - ? "Saved sets" - : `${selectedTabLabel} favourites`} -

-

- {selectedTab === "sets" ? "Open a focused clinical set." : "Open, ask, copy, or organise saved items."} -

-
- - {selectedTab === "sets" ? visibleSets.length : visibleItems.length}
- -
- {showSets && selectedTab === "sets" - ? visibleSets.map((set) => ( - { - setSelectedSetId(set.id); - setSelectedTab("all"); - }} - /> - )) - : null} - - {showItems - ? visibleItems.map((item) => ( - setSelectedTab("sets")} /> - )) - : null} - - {empty ? ( -
-
- -

No favourites match

-

- Clear the composer text or choose another tab. -

-
-
- ) : null} -
-
- - -
-
- ); -} + ) : ( +

+ Sign in from the search header to access private documents and saved reviews. +

+ )} + -function FavouriteItemRow({ item, onMoveToSet }: { item: FavouriteItem; onMoveToSet: () => void }) { - const Icon = item.icon; - return ( -
- - - -
-

{item.title}

-

{item.meta}

-
- {item.set} - - {item.sourceMeta} +
-
-
- -
- -
- ); -} - -function FavouriteSetRow({ - favouriteSet, - compact = false, - selected = false, - onSelect, -}: { - favouriteSet: FavouriteSet; - compact?: boolean; - selected?: boolean; - onSelect: () => void; -}) { - return ( - + ); } @@ -6907,6 +6527,7 @@ export function ClinicalDashboard({ const [actionNotice, setActionNotice] = useState<{ tone: "success" | "warning"; message: string } | null>(null); const [activeHash, setActiveHash] = useState("#search"); const [guideOpen, setGuideOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [documentsDrawerOpen, setDocumentsDrawerOpen] = useState(false); @@ -6942,6 +6563,9 @@ export function ClinicalDashboard({ const canRunSearch = explicitDemoMode || (hasReadyPublicSearchSetup(setupChecks) && canUsePrivateApis); const openGuide = useCallback(() => setGuideOpen(true), []); const closeGuide = useCallback(() => setGuideOpen(false), []); + const openSettings = useCallback(() => setSettingsOpen(true), []); + const closeSettings = useCallback(() => setSettingsOpen(false), []); + const sidebarIdentity = useMemo(() => deriveSidebarIdentity(auth.session?.user.email), [auth.session?.user.email]); const prefetchApplications = useCallback(() => { router.prefetch("/applications"); void import("@/components/applications-launcher-page"); @@ -8269,7 +7893,7 @@ export function ClinicalDashboard({ href: "#search", count: activeModeResultKind === "favourites" - ? favouriteItems.length + favouriteSets.length + ? favouritePrototypeCount : activeModeResultKind === "tools" ? toolCatalog.length : activeModeResultKind === "documents" @@ -8441,10 +8065,12 @@ export function ClinicalDashboard({ @@ -8834,13 +8460,24 @@ export function ClinicalDashboard({ onNavigate={navigateMobileSection} /> +
diff --git a/src/components/applications-launcher-page.tsx b/src/components/applications-launcher-page.tsx index 39ba7b6483..8c5772ceff 100644 --- a/src/components/applications-launcher-page.tsx +++ b/src/components/applications-launcher-page.tsx @@ -3,7 +3,6 @@ import Link from "next/link"; import { BookOpen, - Brain, Check, ChevronRight, ClipboardList, @@ -11,7 +10,6 @@ import { FileText, Grid2X2, Globe2, - ListChecks, Mic, Menu, MoreVertical, @@ -19,14 +17,11 @@ import { Pin, PinOff, Plus, - Puzzle, Search, Send, Settings, Sparkles, Star, - Stethoscope, - Users, X, type LucideIcon, } from "lucide-react"; @@ -43,8 +38,6 @@ import { sidebarToolTile as sidebarApplicationTile, textMuted, } from "@/components/ui-primitives"; -import { toolCatalog } from "@/lib/tools"; - type LauncherStatus = "ready" | "recent" | "review_due"; type LauncherCategory = "clinical" | "admin" | "recent"; @@ -87,110 +80,13 @@ const filterOptions = [ ] as const; const sidebarApplicationItems = [ - { label: "Formulation", icon: Brain, href: "/applications" }, - { label: "DSM-5", icon: BookOpen, href: "/applications" }, + { label: "Answer", icon: Sparkles, href: "/?mode=answer" }, + { label: "Documents", icon: FileText, href: "/?mode=documents" }, { label: "Meds", icon: Pill, href: "/?mode=prescribing" }, - { label: "Diffs", icon: Search, href: "/applications" }, + { label: "Favourites", icon: Star, href: "/?mode=favourites" }, ] as const; -function toolById(id: string) { - return toolCatalog.find((tool) => tool.id === id); -} - -function hrefForTool(id: string, fallback: string) { - return toolById(id)?.href ?? fallback; -} - -function isExternalTool(id: string) { - return toolById(id)?.target === "external"; -} - const launcherApps: LauncherApp[] = [ - { - id: "differential-diagnosis", - title: "Differential Diagnosis", - description: "Generate and explore differential diagnoses.", - detail: "Compare likely differentials, rule-outs, red flags, and competing diagnostic explanations.", - href: hrefForTool("differentials", "http://127.0.0.1:53375"), - external: isExternalTool("differentials"), - icon: Stethoscope, - category: "recent", - workflow: "Assessment", - lastUsed: "Today, 10:24 AM", - status: "recent", - sourceToolId: "differentials", - relatedIds: ["specifiers", "services", "clinical-kb-search"], - quickActions: ["Start differential", "Review red flags", "Compare rule-outs", "Open assessment history"], - recentWorkflows: [ - { title: "Chest pain differential", date: "Today, 10:24 AM" }, - { title: "Headache workup", date: "Yesterday, 3:11 PM" }, - { title: "Fatigue assessment", date: "May 10, 2025" }, - ], - }, - { - id: "specifiers", - title: "Specifiers", - description: "Add clinical specifiers and refine conditions.", - detail: "Review severity, course, qualifiers, and specifier language for a diagnosis.", - href: hrefForTool("specifiers", "http://127.0.0.1:58123"), - external: isExternalTool("specifiers"), - icon: ListChecks, - category: "clinical", - workflow: "Assessment", - lastUsed: "Yesterday, 4:15 PM", - status: "ready", - sourceToolId: "specifiers", - relatedIds: ["differential-diagnosis", "formulation", "clinical-kb-search"], - quickActions: ["Open specifier review", "Check course descriptors", "Review severity", "Browse qualifiers"], - recentWorkflows: [ - { title: "Mood episode specifiers", date: "Yesterday, 4:15 PM" }, - { title: "Anxiety course review", date: "May 11, 2025" }, - { title: "Psychosis qualifiers", date: "May 8, 2025" }, - ], - }, - { - id: "services", - title: "Services", - description: "Browse and manage clinical services.", - detail: "Find referral pathways, access points, service matching, and destination options.", - href: hrefForTool("services", "http://127.0.0.1:53174"), - external: isExternalTool("services"), - icon: Users, - category: "clinical", - workflow: "Care planning", - lastUsed: "May 12, 2025", - status: "ready", - sourceToolId: "services", - relatedIds: ["formulation", "documents", "clinical-kb-search"], - quickActions: ["Find referral pathway", "Browse service options", "Review access criteria", "Open saved pathway"], - recentWorkflows: [ - { title: "Community referral options", date: "May 12, 2025" }, - { title: "Crisis pathway review", date: "May 9, 2025" }, - { title: "Outpatient access check", date: "May 7, 2025" }, - ], - }, - { - id: "formulation", - title: "Formulation", - description: "Create and manage clinical formulations.", - detail: "Structure case formulations to support assessment, conceptualisation, care planning, and team reuse.", - href: hrefForTool("formulation", "http://localhost:53210"), - external: isExternalTool("formulation"), - icon: Puzzle, - category: "recent", - workflow: "Care planning", - lastUsed: "Today, 9:02 AM", - status: "recent", - sourceToolId: "formulation", - relatedIds: ["differential-diagnosis", "medication-prescribing", "documents", "clinical-kb-search"], - quickActions: ["Create new formulation", "Browse my formulations", "Shared with me", "Formulation templates"], - recentWorkflows: [ - { title: "Mood & anxiety formulation - Jane D.", date: "Today, 9:02 AM" }, - { title: "Complex case review - M. Smith", date: "May 12, 2025" }, - { title: "Care plan formulation - P. Johnson", date: "May 9, 2025" }, - { title: "Discharge formulation - K. Patel", date: "May 7, 2025" }, - ], - }, { id: "medication-prescribing", title: "Medication Prescribing", @@ -205,7 +101,7 @@ const launcherApps: LauncherApp[] = [ lastUsed: "May 12, 2025", status: "review_due", sourceToolId: "medications", - relatedIds: ["formulation", "documents", "clinical-kb-search"], + relatedIds: ["documents", "clinical-kb-search", "favourites"], quickActions: ["Create new prescription", "Browse formulary", "Review interactions", "Medication templates"], recentWorkflows: [ { title: "Medication review - Sam T.", date: "May 12, 2025" }, @@ -225,7 +121,7 @@ const launcherApps: LauncherApp[] = [ workflow: "Reference", lastUsed: "May 10, 2025", status: "ready", - relatedIds: ["clinical-kb-search", "favourites", "formulation"], + relatedIds: ["clinical-kb-search", "favourites", "medication-prescribing"], quickActions: ["Search documents", "Browse library", "Open source PDF", "Review indexed documents"], recentWorkflows: [ { title: "Lithium monitoring guideline", date: "May 10, 2025" }, @@ -245,7 +141,7 @@ const launcherApps: LauncherApp[] = [ workflow: "Reference", lastUsed: "Today, 8:45 AM", status: "recent", - relatedIds: ["documents", "clinical-kb-search", "formulation"], + relatedIds: ["documents", "clinical-kb-search", "medication-prescribing"], quickActions: ["Open saved items", "Manage pinned sets", "Review due favourites", "Add current answer"], recentWorkflows: [ { title: "Ward round set", date: "Today, 8:45 AM" }, @@ -265,7 +161,7 @@ const launcherApps: LauncherApp[] = [ workflow: "Reference", lastUsed: "Today, 7:30 AM", status: "ready", - relatedIds: ["documents", "favourites", "differential-diagnosis"], + relatedIds: ["documents", "favourites", "medication-prescribing"], quickActions: ["Ask clinical question", "Search indexed guidelines", "Open document scope", "Review sources"], recentWorkflows: [ { title: "Lithium monitoring search", date: "Today, 7:30 AM" }, @@ -275,18 +171,13 @@ const launcherApps: LauncherApp[] = [ }, ]; -const seedPinnedIds = ["formulation", "differential-diagnosis", "medication-prescribing"]; +const seedPinnedIds = ["clinical-kb-search", "medication-prescribing", "documents"]; const recentActivity = [ - { id: "formulation", label: "Formulation opened", date: "Today, 9:02 AM", icon: Puzzle }, - { - id: "differential-diagnosis", - label: "Differential Diagnosis launched", - date: "Today, 10:24 AM", - icon: Stethoscope, - }, + { id: "clinical-kb-search", label: "Clinical KB Search opened", date: "Today, 7:30 AM", icon: Search }, { id: "medication-prescribing", label: "Medication Prescribing reviewed", date: "May 12, 2025", icon: Pill }, { id: "documents", label: "Documents opened", date: "May 10, 2025", icon: FileText }, + { id: "favourites", label: "Favourites reviewed", date: "Today, 8:45 AM", icon: Star }, ] as const; function appById(id: string) { @@ -937,7 +828,7 @@ function ApplicationsHeader({ export function ApplicationsLauncherPage() { const [query, setQuery] = useState(""); const [activeFilter, setActiveFilter] = useState<(typeof filterOptions)[number]["id"]>("all"); - const [selectedId, setSelectedId] = useState("formulation"); + const [selectedId, setSelectedId] = useState("clinical-kb-search"); const [pinnedIds, setPinnedIds] = useState(seedPinnedIds); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [modeMenuOpen, setModeMenuOpen] = useState(false); @@ -990,8 +881,8 @@ export function ApplicationsLauncherPage() { /> -
-
+
+
@@ -1005,6 +896,37 @@ export function ApplicationsLauncherPage() {
+
+ + + + +
@@ -1120,43 +1042,6 @@ export function ApplicationsLauncherPage() { /> -
- - - - -
); } diff --git a/src/components/clinical-dashboard/favourites-hub.tsx b/src/components/clinical-dashboard/favourites-hub.tsx new file mode 100644 index 0000000000..8aa0a0742e --- /dev/null +++ b/src/components/clinical-dashboard/favourites-hub.tsx @@ -0,0 +1,513 @@ +"use client"; + +import { ArrowUpDown, ChevronDown, Filter, Folder, FolderInput, Heart, Plus, Search, X } from "lucide-react"; +import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; +import { cn, floatingControl, iconTilePremium, panelSubtle, primaryControl } from "@/components/ui-primitives"; +import { + favouriteItems, + favouriteSets, + favouriteTabs, + favouriteTypeCount, + type FavouriteItem, + type FavouriteSet, + type FavouriteTabId, +} from "@/components/clinical-dashboard/favourites-prototype-data"; + +function favouriteMatchesQuery(value: { title: string; meta?: string; set?: string; keywords: string }, query: string) { + const normalized = query.trim().toLowerCase(); + if (!normalized) return true; + return [value.title, value.meta, value.set, value.keywords] + .filter(Boolean) + .join(" ") + .toLowerCase() + .includes(normalized); +} + +export function FavouritesHub({ + query, + onClearQuery, + onAddFavourite, +}: { + query: string; + onClearQuery: () => void; + onAddFavourite: () => void; +}) { + const [selectedTab, setSelectedTab] = useState("all"); + const [tabMenuOpen, setTabMenuOpen] = useState(false); + const [selectedSetId, setSelectedSetId] = useState(null); + const tabMenuRef = useRef(null); + const tabButtonRef = useRef(null); + const tabOptionRefs = useRef>([]); + const normalizedQuery = query.trim(); + const selectedSet = selectedSetId ? favouriteSets.find((set) => set.id === selectedSetId) : null; + const tabItems = + selectedTab === "all" || selectedTab === "sets" + ? favouriteItems + : favouriteItems.filter((item) => item.type === selectedTab); + const visibleItems = tabItems + .filter((item) => favouriteMatchesQuery(item, normalizedQuery)) + .filter((item) => !selectedSet || item.set === selectedSet.title); + const visibleSets = favouriteSets.filter((set) => favouriteMatchesQuery(set, normalizedQuery)); + const showSets = selectedTab === "all" || selectedTab === "sets"; + const showItems = selectedTab !== "sets"; + const empty = (!showItems || visibleItems.length === 0) && (!showSets || visibleSets.length === 0); + const selectedTabMeta = favouriteTabs.find((tab) => tab.id === selectedTab) ?? favouriteTabs[0]; + const selectedTabLabel = selectedTabMeta.label; + const selectedTabCount = favouriteTypeCount(selectedTab); + const SelectedTabIcon = selectedTabMeta.icon; + const itemCount = favouriteItems.length; + const setCount = favouriteSets.length; + const activeFilterCount = (normalizedQuery ? 1 : 0) + (selectedSet ? 1 : 0); + const selectedTabIndex = Math.max( + 0, + favouriteTabs.findIndex((tab) => tab.id === selectedTab), + ); + + function focusFavouriteTabOption(index: number) { + const nextIndex = (index + favouriteTabs.length) % favouriteTabs.length; + tabOptionRefs.current[nextIndex]?.focus(); + } + + function openFavouriteTabMenu(focusIndex: number) { + setTabMenuOpen(true); + window.requestAnimationFrame(() => focusFavouriteTabOption(focusIndex)); + } + + function handleFavouriteTabTriggerKeyDown(event: ReactKeyboardEvent) { + if (event.key === "ArrowDown") { + event.preventDefault(); + openFavouriteTabMenu(selectedTabIndex); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + openFavouriteTabMenu(selectedTabIndex - 1); + } + } + + function handleFavouriteTabOptionKeyDown(event: ReactKeyboardEvent, index: number) { + if (event.key === "ArrowDown") { + event.preventDefault(); + focusFavouriteTabOption(index + 1); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + focusFavouriteTabOption(index - 1); + } else if (event.key === "Home") { + event.preventDefault(); + focusFavouriteTabOption(0); + } else if (event.key === "End") { + event.preventDefault(); + focusFavouriteTabOption(favouriteTabs.length - 1); + } else if (event.key === "Escape") { + event.preventDefault(); + setTabMenuOpen(false); + window.requestAnimationFrame(() => tabButtonRef.current?.focus()); + } + } + + useEffect(() => { + if (!tabMenuOpen) return undefined; + + function handlePointerDown(event: PointerEvent) { + const target = event.target; + if (!(target instanceof Node)) return; + if (!tabMenuRef.current?.contains(target)) setTabMenuOpen(false); + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + setTabMenuOpen(false); + window.requestAnimationFrame(() => tabButtonRef.current?.focus()); + } + } + + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [tabMenuOpen]); + + return ( +
+
+
+ + + +
+

+ Favourites +

+

+ Keep trusted notes, sources, medication pages, and clinical sets ready for reuse. +

+
+
+ +
+ {[ + { label: "Items", value: itemCount, icon: Heart }, + { label: "Sets", value: setCount, icon: Folder }, + { label: "Active", value: activeFilterCount, icon: Filter }, + ].map((stat) => { + const Icon = stat.icon; + return ( +
+
+ + {stat.label} +
+

+ {stat.value} +

+
+ ); + })} +
+
+ +
+
+
+ + + {tabMenuOpen ? ( +
+ {favouriteTabs.map((tab, index) => { + const Icon = tab.icon; + const selected = selectedTab === tab.id; + const count = favouriteTypeCount(tab.id); + return ( + + ); + })} +
+ ) : null} +
+ {normalizedQuery ? ( + + ) : null} + {selectedSet ? ( + + ) : null} +
+
+ + +
+
+ +
+
+

Saved sets

+ +
+
+ {visibleSets.slice(0, 3).map((set) => ( + { + setSelectedSetId(set.id); + if (selectedTab === "sets") setSelectedTab("all"); + }} + /> + ))} +
+
+ +
+
+
+
+

+ {selectedTab === "all" + ? "Recent favourites" + : selectedTab === "sets" + ? "Saved sets" + : `${selectedTabLabel} favourites`} +

+

+ {selectedTab === "sets" ? "Open a focused clinical set." : "Open, ask, copy, or organise saved items."} +

+
+ + {selectedTab === "sets" ? visibleSets.length : visibleItems.length} + +
+ +
+ {showSets && selectedTab === "sets" + ? visibleSets.map((set) => ( + { + setSelectedSetId(set.id); + setSelectedTab("all"); + }} + /> + )) + : null} + + {showItems + ? visibleItems.map((item) => ( + setSelectedTab("sets")} /> + )) + : null} + + {empty ? ( +
+
+ +

No favourites match

+

+ Clear the composer text or choose another tab. +

+
+
+ ) : null} +
+
+ + +
+
+ ); +} + +function FavouriteItemRow({ item, onMoveToSet }: { item: FavouriteItem; onMoveToSet: () => void }) { + const Icon = item.icon; + return ( +
+ + + +
+

{item.title}

+

{item.meta}

+
+ {item.set} + + {item.sourceMeta} + +
+
+
+ + +
+ +
+ ); +} + +function FavouriteSetRow({ + favouriteSet, + compact = false, + selected = false, + onSelect, +}: { + favouriteSet: FavouriteSet; + compact?: boolean; + selected?: boolean; + onSelect: () => void; +}) { + return ( + + ); +} diff --git a/src/components/clinical-dashboard/favourites-prototype-data.ts b/src/components/clinical-dashboard/favourites-prototype-data.ts new file mode 100644 index 0000000000..9ddf9d4b8c --- /dev/null +++ b/src/components/clinical-dashboard/favourites-prototype-data.ts @@ -0,0 +1,127 @@ +import { FileText, Folder, LayoutList, Pill, Quote, Search } from "lucide-react"; + +export type FavouriteType = "medications" | "documents" | "sources" | "sets"; +export type FavouriteTabId = "all" | FavouriteType; + +export type FavouriteItem = { + id: string; + title: string; + type: Exclude; + set: string; + meta: string; + sourceMeta: string; + primaryAction: string; + icon: typeof FileText; + keywords: string; +}; + +export type FavouriteSet = { + id: string; + title: string; + count: number; + meta: string; + keywords: string; +}; + +export const favouriteTabs: Array<{ + id: FavouriteTabId; + label: string; + shortLabel: string; + icon: typeof FileText; +}> = [ + { id: "all", label: "All", shortLabel: "All", icon: LayoutList }, + { id: "medications", label: "Medications", shortLabel: "Meds", icon: Pill }, + { id: "documents", label: "Documents", shortLabel: "Docs", icon: FileText }, + { id: "sources", label: "Sources", shortLabel: "Sources", icon: Quote }, + { id: "sets", label: "Sets", shortLabel: "Sets", icon: Folder }, +]; + +export const favouriteItems: FavouriteItem[] = [ + { + id: "acamprosate-renal-screen", + title: "Acamprosate renal screen", + type: "medications", + set: "Ward round", + meta: "Medication page · renal cautions · dose notes", + sourceMeta: "3 sources", + primaryAction: "Open", + icon: Pill, + keywords: "acamprosate renal screen medication dose safety ward round pbs", + }, + { + id: "lithium-monitoring-guideline", + title: "Lithium monitoring guideline", + type: "documents", + set: "Prescribing safety", + meta: "PDF · p.4-9 · 2 tables", + sourceMeta: "PDF", + primaryAction: "Ask", + icon: FileText, + keywords: "lithium monitoring guideline blood tests shared care renal toxicity", + }, + { + id: "clozapine-monitoring-table", + title: "Clozapine monitoring table", + type: "sources", + set: "Clozapine clinic", + meta: "Saved table · ANC monitoring", + sourceMeta: "Table", + primaryAction: "Source", + icon: Quote, + keywords: "clozapine monitoring table anc fbc neutrophil clinic", + }, + { + id: "renal-dose-search", + title: "renal dose saved search", + type: "sources", + set: "Ward round", + meta: "Saved query · medicines + documents", + sourceMeta: "Run", + primaryAction: "Run", + icon: Search, + keywords: "renal dose saved search kidney egfr medicines documents", + }, + { + id: "qt-prolongation-quote", + title: "QT prolongation quote", + type: "sources", + set: "Prescribing safety", + meta: "Source card · prescribing safety", + sourceMeta: "Quote", + primaryAction: "Copy", + icon: Quote, + keywords: "qt prolongation quote source card prescribing safety", + }, +]; + +export const favouriteSets: FavouriteSet[] = [ + { + id: "ward-round", + title: "Ward round", + count: 12, + meta: "Medication pages, renal checks, forms", + keywords: "ward round acamprosate lithium renal mht forms", + }, + { + id: "prescribing-safety", + title: "Prescribing safety", + count: 9, + meta: "Dose limits, pregnancy, renal cautions", + keywords: "prescribing safety dose pregnancy renal qt interactions", + }, + { + id: "clozapine-clinic", + title: "Clozapine clinic", + count: 7, + meta: "Monitoring, ANC table, counselling", + keywords: "clozapine clinic monitoring anc table counselling", + }, +]; + +export function favouriteTypeCount(type: FavouriteTabId) { + if (type === "all") return favouriteItems.length + favouriteSets.length; + if (type === "sets") return favouriteSets.length; + return favouriteItems.filter((item) => item.type === type).length; +} + +export const favouritePrototypeCount = favouriteItems.length + favouriteSets.length; diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 68089c97d8..2f2f773824 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -1,6 +1,15 @@ "use client"; -import { FormEvent, useCallback, useEffect, useMemo, useRef, useState, type Ref } from "react"; +import { + FormEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type Ref, +} from "react"; import { Activity, @@ -172,6 +181,8 @@ export function MasterSearchHeader({ const dailyActionsMenuRef = useRef(null); const firstDailyActionRef = useRef(null); const modeMenuRef = useRef(null); + const modeButtonRef = useRef(null); + const modeOptionRefs = useRef>([]); const scopeDetailsRef = useRef(null); const scopeSummaryRef = useRef(null); const scopeFilterInputRef = useRef(null); @@ -294,6 +305,51 @@ export function MasterSearchHeader({ } if ("href" in mode && mode.href) window.location.assign(mode.href); } + + const selectedModeIndex = Math.max( + 0, + visibleAppModeOptions.findIndex((mode) => mode.id === selectedAppMode.id), + ); + + function focusModeOption(index: number) { + const nextIndex = (index + visibleAppModeOptions.length) % visibleAppModeOptions.length; + modeOptionRefs.current[nextIndex]?.focus(); + } + + function openModeMenuWithFocus(index: number) { + setModeMenuOpen(true); + window.requestAnimationFrame(() => focusModeOption(index)); + } + + function handleModeTriggerKeyDown(event: ReactKeyboardEvent) { + if (event.key === "ArrowDown") { + event.preventDefault(); + openModeMenuWithFocus(selectedModeIndex); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + openModeMenuWithFocus(selectedModeIndex - 1); + } + } + + function handleModeOptionKeyDown(event: ReactKeyboardEvent, index: number) { + if (event.key === "ArrowDown") { + event.preventDefault(); + focusModeOption(index + 1); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + focusModeOption(index - 1); + } else if (event.key === "Home") { + event.preventDefault(); + focusModeOption(0); + } else if (event.key === "End") { + event.preventDefault(); + focusModeOption(visibleAppModeOptions.length - 1); + } else if (event.key === "Escape") { + event.preventDefault(); + setModeMenuOpen(false); + window.requestAnimationFrame(() => modeButtonRef.current?.focus()); + } + } const collectionOptions = useMemo(() => { const values = new Set(); for (const document of documents) { @@ -370,6 +426,7 @@ export function MasterSearchHeader({ if (event.key === "Escape") { event.preventDefault(); setModeMenuOpen(false); + window.requestAnimationFrame(() => modeButtonRef.current?.focus()); } } @@ -643,11 +700,14 @@ export function MasterSearchHeader({ )} >