From 594fbd82b7e9d9f847f636b43caf3dae1995bdef Mon Sep 17 00:00:00 2001 From: Sriharsha Mopidevi Date: Wed, 18 Mar 2026 15:51:46 -0400 Subject: [PATCH 1/2] feat: metadata search feature & implement tests --- package-lock.json | 26 + package.json | 2 + src/app/AppLayout.tsx | 2 +- src/app/search/page.tsx | 147 ++++ src/components/Header.tsx | 5 + .../dashboard/ResearchTab/index.tsx | 216 +++--- src/components/search/EntityBadge.tsx | 25 + src/components/search/FacetSidebar.tsx | 533 +++++++++++++++ src/components/search/ResultCard.tsx | 232 +++++++ src/components/search/ResultsList.tsx | 94 +++ src/components/search/SearchBar.tsx | 99 +++ src/components/search/TierBadge.tsx | 14 + src/components/search/VisitDetailDrawer.tsx | 644 ++++++++++++++++++ .../search/__tests__/FacetSidebar.test.tsx | 169 +++++ .../search/__tests__/ResultCard.test.tsx | 152 +++++ .../search/__tests__/ResultsList.test.tsx | 118 ++++ .../search/__tests__/SearchBar.test.tsx | 100 +++ .../search/__tests__/TierBadge.test.tsx | 46 ++ src/constants/demographicLabels.ts | 27 + src/hooks/__tests__/useSearch.test.ts | 202 ++++++ src/hooks/index.ts | 1 + src/hooks/useFilterOptions.ts | 8 +- src/hooks/useSearch.ts | 158 +++++ src/interfaces/index.ts | 1 + src/interfaces/search.ts | 104 +++ 25 files changed, 3017 insertions(+), 108 deletions(-) create mode 100644 src/app/search/page.tsx create mode 100644 src/components/search/EntityBadge.tsx create mode 100644 src/components/search/FacetSidebar.tsx create mode 100644 src/components/search/ResultCard.tsx create mode 100644 src/components/search/ResultsList.tsx create mode 100644 src/components/search/SearchBar.tsx create mode 100644 src/components/search/TierBadge.tsx create mode 100644 src/components/search/VisitDetailDrawer.tsx create mode 100644 src/components/search/__tests__/FacetSidebar.test.tsx create mode 100644 src/components/search/__tests__/ResultCard.test.tsx create mode 100644 src/components/search/__tests__/ResultsList.test.tsx create mode 100644 src/components/search/__tests__/SearchBar.test.tsx create mode 100644 src/components/search/__tests__/TierBadge.test.tsx create mode 100644 src/constants/demographicLabels.ts create mode 100644 src/hooks/__tests__/useSearch.test.ts create mode 100644 src/hooks/useSearch.ts create mode 100644 src/interfaces/search.ts diff --git a/package-lock.json b/package-lock.json index f34e828..1c2c5d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,11 +16,13 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.13", + "@types/dompurify": "^3.0.5", "@types/video.js": "^7.3.58", "axios": "^1.10.0", "d3": "^7.9.0", "date-fns": "^3.6.0", "date-fns-tz": "^3.1.3", + "dompurify": "^3.3.3", "js-cookie": "^3.0.5", "next": "16.1.6", "next-themes": "^0.4.3", @@ -3878,6 +3880,15 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4073,6 +4084,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, "node_modules/@types/video.js": { "version": "7.3.58", "resolved": "https://registry.npmjs.org/@types/video.js/-/video.js-7.3.58.tgz", @@ -7801,6 +7818,15 @@ "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/package.json b/package.json index c8e1334..551d223 100644 --- a/package.json +++ b/package.json @@ -25,11 +25,13 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.13", + "@types/dompurify": "^3.0.5", "@types/video.js": "^7.3.58", "axios": "^1.10.0", "d3": "^7.9.0", "date-fns": "^3.6.0", "date-fns-tz": "^3.1.3", + "dompurify": "^3.3.3", "js-cookie": "^3.0.5", "next": "16.1.6", "next-themes": "^0.4.3", diff --git a/src/app/AppLayout.tsx b/src/app/AppLayout.tsx index d4b6a08..d7abb49 100644 --- a/src/app/AppLayout.tsx +++ b/src/app/AppLayout.tsx @@ -13,7 +13,7 @@ const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => { const { isAuthenticated, isLoading, isLoggingOut } = useAuth(); // Routes that should use authenticated layout - const authenticatedRoutes = ['/dashboard', '/profile']; + const authenticatedRoutes = ['/dashboard', '/profile', '/search']; const shouldUseAuthLayout = authenticatedRoutes.some((route) => pathname.startsWith(route)); // Show loading state while checking authentication or during logout diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx new file mode 100644 index 0000000..cf34643 --- /dev/null +++ b/src/app/search/page.tsx @@ -0,0 +1,147 @@ +'use client'; + +import React, { Suspense, useEffect, useState } from 'react'; +import { Box, Container, HStack, Spinner, Center, Text } from '@chakra-ui/react'; +import { useRouter } from 'next/navigation'; +import { useAuth } from '@/contexts/AuthContext'; +import { useEncounterSearch } from '@/hooks'; +import { EncounterSearchFilters, EncounterSearchHit } from '@/interfaces/search'; +import SearchBar from '@/components/search/SearchBar'; +import FacetSidebar from '@/components/search/FacetSidebar'; +import ResultsList from '@/components/search/ResultsList'; +import PaginationControls from '@/components/dashboard/ResearchTab/PaginationControls'; +import VisitDetailDrawer from '@/components/search/VisitDetailDrawer'; + +function SearchLoading() { + return ( +
+ +
+ ); +} + +export default function SearchPage() { + return ( + }> + + + ); +} + +function SearchContent() { + const { isAuthenticated, isLoading: authLoading } = useAuth(); + const router = useRouter(); + + const { + results, + loading, + error, + pagination, + aggregations, + query, + setQuery, + setFilters, + setPage, + } = useEncounterSearch(); + + const [localFilters, setLocalFilters] = useState({}); + const [selectedVisit, setSelectedVisit] = useState(null); + + useEffect(() => { + if (!authLoading && !isAuthenticated) { + router.push('/login'); + } + }, [isAuthenticated, authLoading, router]); + + if (authLoading) { + return ( +
+ +
+ ); + } + + if (!isAuthenticated) { + return null; + } + + const hasSearched = query.trim().length > 0 || Object.keys(localFilters).length > 0; + + const activeFilterCount = Object.values(localFilters).filter((v) => + Array.isArray(v) ? v.length > 0 : v !== undefined && v !== null + ).length; + + function handleFilterChange(filters: EncounterSearchFilters) { + setLocalFilters(filters); + setFilters(filters); + } + + return ( + + {/* Hero search bar */} + + + + Encounter Search + + + {error && ( + + + {error} + + + )} + + + + {/* Main layout */} + + + + + + + + + + {hasSearched && !loading && pagination.totalCount > 0 && ( + + + + )} + + + + + setSelectedVisit(null)} + /> + + ); +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index ec273fd..0647aed 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -140,6 +140,7 @@ const Header: React.FC = ({ showLinks = true, logo = { width: 200, Dashboard Dataset + Search {/* Avatar Menu */} @@ -222,6 +223,10 @@ const Header: React.FC = ({ showLinks = true, logo = { width: 200, Dataset + + Search + + {/* Mobile Auth Section */} {showAuthenticatedMenu ? ( diff --git a/src/components/dashboard/ResearchTab/index.tsx b/src/components/dashboard/ResearchTab/index.tsx index 9a52eb6..07d4494 100644 --- a/src/components/dashboard/ResearchTab/index.tsx +++ b/src/components/dashboard/ResearchTab/index.tsx @@ -43,6 +43,7 @@ export default function ResearchTab({ hasValidTier, onCohortCreated }: ResearchT filterOptions, loading: filterOptionsLoading, error: filterOptionsError, + refetch: refetchFilterOptions, } = useFilterOptions(isAuthenticated); const { @@ -157,115 +158,124 @@ export default function ResearchTab({ hasValidTier, onCohortCreated }: ResearchT return ; } + // Show error state if filter options failed to load + if (!filterOptions) { + return ( +
+ + Failed to load research data. + + +
+ ); + } + return ( {/* Main Content Area */} - {filterOptions && ( - - {/* Filter Sidebar */} - + + {/* Filter Sidebar */} + - {/* Data Table */} - - - - - Research Visits - - {filterSummary && pagination.totalCount > 0 && ( - <> - Showing {(pagination.currentPage - 1) * DEFAULT_PAGE_SIZE + 1}- - {Math.min( - pagination.currentPage * DEFAULT_PAGE_SIZE, - pagination.totalCount - )}{' '} - of {pagination.totalCount} visits - {filterSummary.activeFilters > 0 && ( - - ({filterSummary.activeFilters}{' '} - {filterSummary.activeFilters === 1 ? 'filter' : 'filters'} active) - - )} - - )} - - - - - {!hasValidTier ? ( -
- - - - - - Request Data Access - - - Your account has been created, but you don't have data access yet. - Please reach out to our team to get started. - - - observerproject@pennmedicine.upenn.edu - - -
- ) : visitsLoading ? ( - - - {Array.from({ length: 5 }).map((_, i) => ( - - - - - - - - - ))} - - - ) : visits.length > 0 ? ( - <> - - - - ) : ( - - - No visits match your current filters. Try adjusting your search criteria. + {/* Data Table */} + + + + + Research Visits + + {filterSummary && pagination.totalCount > 0 && ( + <> + Showing {(pagination.currentPage - 1) * DEFAULT_PAGE_SIZE + 1}- + {Math.min(pagination.currentPage * DEFAULT_PAGE_SIZE, pagination.totalCount)}{' '} + of {pagination.totalCount} visits + {filterSummary.activeFilters > 0 && ( + + ({filterSummary.activeFilters}{' '} + {filterSummary.activeFilters === 1 ? 'filter' : 'filters'} active) + + )} + + )} + + + + + {!hasValidTier ? ( +
+ + + + + + Request Data Access + + + Your account has been created, but you don't have data access yet. Please + reach out to our team to get started. - {filterSummary && filterSummary.activeFilters > 0 && ( - - )} + + observerproject@pennmedicine.upenn.edu + - )} - - - - - )} +
+ ) : visitsLoading ? ( + + + {Array.from({ length: 5 }).map((_, i) => ( + + + + + + + + + ))} + + + ) : visits.length > 0 ? ( + <> + + + + ) : ( + + + No visits match your current filters. Try adjusting your search criteria. + + {filterSummary && filterSummary.activeFilters > 0 && ( + + )} + + )} +
+
+
+
{/* Create Cohort Dialog */} {filterSummary && ( diff --git a/src/components/search/EntityBadge.tsx b/src/components/search/EntityBadge.tsx new file mode 100644 index 0000000..2ccb755 --- /dev/null +++ b/src/components/search/EntityBadge.tsx @@ -0,0 +1,25 @@ +'use client'; + +import React from 'react'; +import { Badge } from '@chakra-ui/react'; + +const LABEL_COLORS: Record = { + MEDICATION: 'blue', + PROBLEM: 'red', + PROCEDURE: 'purple', + TEST: 'orange', +}; + +interface EntityBadgeProps { + label: string; + text: string; +} + +export default function EntityBadge({ label, text }: EntityBadgeProps) { + const color = LABEL_COLORS[label] ?? 'gray'; + return ( + + {text} + + ); +} diff --git a/src/components/search/FacetSidebar.tsx b/src/components/search/FacetSidebar.tsx new file mode 100644 index 0000000..cfb1c01 --- /dev/null +++ b/src/components/search/FacetSidebar.tsx @@ -0,0 +1,533 @@ +'use client'; + +import React from 'react'; +import { + Box, + VStack, + Text, + Input, + Checkbox, + Collapsible, + HStack, + Badge, + Card, +} from '@chakra-ui/react'; +import { + FaCalendar, + FaUsers, + FaUserMd, + FaVideo, + FaFileMedical, + FaChevronDown, + FaHospital, +} from 'react-icons/fa'; +import { EncounterSearchFilters, SearchAggregations } from '@/interfaces/search'; +import { + GENDER_LABELS, + RACE_LABELS, + ETHNICITY_LABELS, + labelDemographic, +} from '@/constants/demographicLabels'; + +interface FacetSidebarProps { + filters: EncounterSearchFilters; + aggregations: SearchAggregations | null; + onChange: (filters: EncounterSearchFilters) => void; +} + +function SectionHeader({ + icon, + title, + activeCount, + iconColor, +}: { + icon: React.ReactNode; + title: string; + activeCount: number; + iconColor: string; +}) { + return ( + + + {icon} + + {title} + + + + {activeCount > 0 && ( + + {activeCount} + + )} + + + + + + ); +} + +function AggCheckboxList({ + buckets, + selected, + onToggle, + labelMap, +}: { + buckets: { key: string; count: number }[]; + selected: string[] | undefined; + onToggle: (value: string) => void; + labelMap?: Record; +}) { + const validBuckets = buckets.filter((b) => b.key !== ''); + if (!validBuckets.length) { + return null; + } + return ( + + {validBuckets.map((b) => ( + onToggle(b.key)} + size="sm" + > + + + + + + {labelMap ? (labelDemographic(labelMap, b.key) ?? b.key) : b.key} + + + {b.count} + + + + + ))} + + ); +} + +function CollapsibleSection({ + children, + activeBg, + activeHoverBg, + activeBorderColor, + isActive, + header, +}: { + children: React.ReactNode; + activeBg: string; + activeHoverBg: string; + activeBorderColor: string; + isActive: boolean; + header: React.ReactNode; +}) { + return ( + + + {header} + + + + {children} + + + + ); +} + +export default function FacetSidebar({ filters, aggregations, onChange }: FacetSidebarProps) { + function update(patch: Partial) { + onChange({ ...filters, ...patch }); + } + + function toggleArrayValue(field: keyof EncounterSearchFilters, value: string) { + const current = (filters[field] as string[] | undefined) ?? []; + const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value]; + update({ [field]: next.length ? next : undefined }); + } + + function toggleBool(field: keyof EncounterSearchFilters, current?: boolean) { + update({ [field]: current === true ? undefined : true }); + } + + const departmentBuckets = aggregations?.departments ?? []; + const patientGenderBuckets = aggregations?.patient_genders ?? []; + const patientRaceBuckets = aggregations?.patient_races ?? []; + const patientEthnicityBuckets = aggregations?.patient_ethnicities ?? []; + const providerGenderBuckets = aggregations?.provider_genders ?? []; + const providerRaceBuckets = aggregations?.provider_races ?? []; + const providerEthnicityBuckets = aggregations?.provider_ethnicities ?? []; + const noteTypeBuckets = aggregations?.note_types ?? []; + + const departmentActiveCount = filters.department?.length ?? 0; + const dateActiveCount = [filters.date_from, filters.date_to].filter(Boolean).length; + const demographicActiveCount = [ + ...(filters.patient_gender ?? []), + ...(filters.patient_race ?? []), + ...(filters.patient_ethnicity ?? []), + ...(filters.provider_gender ?? []), + ...(filters.provider_race ?? []), + ...(filters.provider_ethnicity ?? []), + ].length; + const multimodalActiveCount = [ + filters.has_transcript, + filters.has_audio, + filters.has_provider_view, + filters.has_patient_view, + filters.has_room_view, + ].filter(Boolean).length; + const clinicalActiveCount = + (filters.has_notes ? 1 : 0) + + (filters.note_types?.length ?? 0) + + (filters.drug_names?.length ?? 0) + + (filters.icd_codes?.length ?? 0) + + (filters.cpt_codes?.length ?? 0); + + return ( + + + + + Filters + + + + + 0} + header={ + } + title="Date Range" + activeCount={dateActiveCount} + iconColor="blue.500" + /> + } + > + + + + From + + update({ date_from: e.target.value || undefined })} + borderColor="gray.300" + _focus={{ borderColor: 'blue.500' }} + /> + + + + To + + update({ date_to: e.target.value || undefined })} + borderColor="gray.300" + _focus={{ borderColor: 'blue.500' }} + /> + + + + + 0} + header={ + } + title="Department" + activeCount={departmentActiveCount} + iconColor="orange.500" + /> + } + > + + toggleArrayValue('department', val)} + /> + + + + 0} + header={ + } + title="Demographics" + activeCount={demographicActiveCount} + iconColor="green.500" + /> + } + > + + {patientGenderBuckets.length > 0 && ( + + + + + + + Patient Gender + + + toggleArrayValue('patient_gender', v)} + labelMap={GENDER_LABELS} + /> + + )} + {patientRaceBuckets.length > 0 && ( + + + Patient Race + + toggleArrayValue('patient_race', v)} + labelMap={RACE_LABELS} + /> + + )} + {patientEthnicityBuckets.length > 0 && ( + + + Patient Ethnicity + + toggleArrayValue('patient_ethnicity', v)} + labelMap={ETHNICITY_LABELS} + /> + + )} + {providerGenderBuckets.length > 0 && ( + + + + + + + Provider Gender + + + toggleArrayValue('provider_gender', v)} + labelMap={GENDER_LABELS} + /> + + )} + {providerRaceBuckets.length > 0 && ( + + + Provider Race + + toggleArrayValue('provider_race', v)} + labelMap={RACE_LABELS} + /> + + )} + {providerEthnicityBuckets.length > 0 && ( + + + Provider Ethnicity + + toggleArrayValue('provider_ethnicity', v)} + labelMap={ETHNICITY_LABELS} + /> + + )} + + + + 0} + header={ + } + title="Multimodal Data" + activeCount={multimodalActiveCount} + iconColor="teal.500" + /> + } + > + + {( + [ + { field: 'has_transcript', label: 'Transcript' }, + { field: 'has_audio', label: 'Audio' }, + { field: 'has_provider_view', label: 'Provider View' }, + { field: 'has_patient_view', label: 'Patient View' }, + { field: 'has_room_view', label: 'Room View' }, + ] as { field: keyof EncounterSearchFilters; label: string }[] + ).map(({ field, label }) => ( + toggleBool(field, filters[field] as boolean | undefined)} + size="sm" + > + + + + {label} + + + ))} + + + + 0} + header={ + } + title="Clinical Data" + activeCount={clinicalActiveCount} + iconColor="purple.500" + /> + } + > + + toggleBool('has_notes', filters.has_notes)} + size="sm" + > + + + + Has Clinical Notes + + + + {noteTypeBuckets.length > 0 && ( + + + Note Types + + toggleArrayValue('note_types', v)} + /> + + )} + + + + Drug Name + + { + const val = e.target.value.trim(); + update({ drug_names: val ? [val] : undefined }); + }} + borderColor="gray.300" + _focus={{ borderColor: 'purple.500' }} + /> + + + + + ICD Code + + { + const val = e.target.value.trim(); + update({ icd_codes: val ? [val] : undefined }); + }} + borderColor="gray.300" + _focus={{ borderColor: 'purple.500' }} + /> + + + + + CPT Code / Procedure + + { + const val = e.target.value.trim(); + update({ cpt_codes: val ? [val] : undefined }); + }} + borderColor="gray.300" + _focus={{ borderColor: 'purple.500' }} + /> + + + + + + + ); +} diff --git a/src/components/search/ResultCard.tsx b/src/components/search/ResultCard.tsx new file mode 100644 index 0000000..e5d8c44 --- /dev/null +++ b/src/components/search/ResultCard.tsx @@ -0,0 +1,232 @@ +'use client'; + +import { Box, Card, HStack, VStack, Text, Badge } from '@chakra-ui/react'; +import { EncounterSearchHit } from '@/interfaces/search'; +import { COLORS } from '@/constants/colors'; +import { + GENDER_LABELS, + RACE_LABELS, + ETHNICITY_LABELS, + labelDemographic, +} from '@/constants/demographicLabels'; +import DOMPurify from 'dompurify'; +import { TierBadge } from './TierBadge'; + +interface ResultCardProps { + hit: EncounterSearchHit; + onSelect?: (hit: EncounterSearchHit) => void; +} + +function sanitizeHighlight(html: string): string { + const stripped = html.replace(/<(?!\/?mark\b)[^>]+>/gi, ''); + return DOMPurify.sanitize(stripped, { ALLOWED_TAGS: ['mark'], ALLOWED_ATTR: [] }); +} + +export default function ResultCard({ hit, onSelect }: ResultCardProps) { + const visitDate = hit.visit_date + ? new Date(`${hit.visit_date}T00:00:00`).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + : null; + + const uniqueIcds = [...new Set(hit.icd_codes)]; + const visibleIcds = uniqueIcds.slice(0, 3); + const extraIcds = uniqueIcds.length - 3; + + const icdHighlight = hit.highlights?.['icd_codes.text']?.[0]; + const drugHighlight = hit.highlights?.['drug_names.text']?.[0]; + + // Only show active multimodal capabilities + const activeCapabilities: string[] = []; + if (hit.has_transcript) { + activeCapabilities.push('Transcript'); + } + if (hit.has_audio) { + activeCapabilities.push('Audio'); + } + if (hit.has_provider_view) { + activeCapabilities.push('Provider View'); + } + if (hit.has_patient_view) { + activeCapabilities.push('Patient View'); + } + if (hit.has_room_view) { + activeCapabilities.push('Room View'); + } + + return ( + onSelect?.(hit)} + _hover={{ + borderColor: 'gray.400', + shadow: 'md', + transform: COLORS.animation.cardHoverLift, + }} + transition="all 0.2s" + > + + + {/* Header: demographics + tier + date */} + + + {/* Patient */} + + + Person: + + {hit.patient_gender && ( + + {labelDemographic(GENDER_LABELS, hit.patient_gender)} + + )} + {hit.patient_race && ( + + {labelDemographic(RACE_LABELS, hit.patient_race)} + + )} + {hit.patient_ethnicity && ( + + {labelDemographic(ETHNICITY_LABELS, hit.patient_ethnicity)} + + )} + {hit.patient_year_of_birth && ( + + b. {hit.patient_year_of_birth} + + )} + + {/* Provider */} + {(hit.provider_gender || hit.provider_race) && ( + + + Provider: + + {hit.provider_gender && ( + + {labelDemographic(GENDER_LABELS, hit.provider_gender)} + + )} + {hit.provider_race && ( + + {labelDemographic(RACE_LABELS, hit.provider_race)} + + )} + {hit.provider_year_of_birth && ( + + b. {hit.provider_year_of_birth} + + )} + + )} + + + + {hit.encounter_id && ( + + #{hit.encounter_id} + + )} + {visitDate && ( + + {visitDate} + + )} + {hit.department && ( + + {hit.department} + + )} + + + + {/* Active multimodal capabilities */} + {activeCapabilities.length > 0 && ( + + {activeCapabilities.map((cap) => ( + + {cap} + + ))} + + )} + + {/* Match source */} + {hit.matched_in && hit.matched_in.length > 0 && ( + + + Matched in: + + {hit.matched_in.map((source) => ( + + {source} + + ))} + + )} + + {/* Clinical summary */} + {(hit.drug_count > 0 || hit.note_count > 0 || uniqueIcds.length > 0) && ( + + {hit.drug_count > 0 && + (drugHighlight ? ( + + + + ) : ( + + {hit.drug_count} drug{hit.drug_count !== 1 ? 's' : ''} + + ))} + {hit.note_count > 0 && ( + + {hit.note_count} note{hit.note_count !== 1 ? 's' : ''} + + )} + {icdHighlight ? ( + + + + + + ) : ( + visibleIcds.map((code) => ( + + + {code} + + + )) + )} + {extraIcds > 0 && ( + + +{extraIcds} more + + )} + + )} + + + + ); +} diff --git a/src/components/search/ResultsList.tsx b/src/components/search/ResultsList.tsx new file mode 100644 index 0000000..830068a --- /dev/null +++ b/src/components/search/ResultsList.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { VStack, Text, Box, Skeleton, Stack, HStack, Icon } from '@chakra-ui/react'; +import { HiSearch } from 'react-icons/hi'; +import { EncounterSearchHit, EncounterSearchSort } from '@/interfaces/search'; +import ResultCard from './ResultCard'; + +interface ResultsListProps { + results: EncounterSearchHit[]; + loading: boolean; + totalCount: number; + hasSearched: boolean; + sort?: EncounterSearchSort; + onSelect?: (hit: EncounterSearchHit) => void; +} + +function LoadingSkeleton() { + return ( + + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + ); +} + +export default function ResultsList({ + results, + loading, + totalCount, + hasSearched, + sort, + onSelect, +}: ResultsListProps) { + const sortLabel = sort?.visit_date + ? `sorted by date (${sort.visit_date === 'asc' ? 'oldest first' : 'newest first'})` + : sort?.tier_level + ? `sorted by tier (${sort.tier_level})` + : 'sorted by relevance'; + if (loading) { + return ; + } + + if (!hasSearched) { + return ( + + + + + + Search encounters + + + Enter a query or apply filters to find encounters. + + + ); + } + + if (results.length === 0) { + return ( + + + + + + No encounters found + + + Try adjusting your search query or filters. + + + ); + } + + return ( + + + + + {totalCount.toLocaleString()} + {' '} + encounter{totalCount !== 1 ? 's' : ''} found + + + {sortLabel} + + + {results.map((hit) => ( + + ))} + + ); +} diff --git a/src/components/search/SearchBar.tsx b/src/components/search/SearchBar.tsx new file mode 100644 index 0000000..d155ff0 --- /dev/null +++ b/src/components/search/SearchBar.tsx @@ -0,0 +1,99 @@ +'use client'; + +import React from 'react'; +import { Box, Input, IconButton, HStack, Text, Badge } from '@chakra-ui/react'; +import { HiSearch, HiX } from 'react-icons/hi'; + +interface SearchBarProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + totalCount?: number; + hasSearched?: boolean; + activeFilterCount?: number; +} + +export default function SearchBar({ + value, + onChange, + placeholder = 'Search by ICD code, drug name, procedure, diagnosis…', + totalCount, + hasSearched, + activeFilterCount, +}: SearchBarProps) { + return ( + + + + + + + onChange(e.target.value)} + placeholder={placeholder} + pl={10} + pr={value ? 10 : 4} + size="lg" + borderColor="gray.300" + bg="gray.50" + _hover={{ borderColor: 'gray.400', bg: 'white' }} + _focus={{ + borderColor: 'blue.500', + bg: 'white', + boxShadow: '0 0 0 1px var(--chakra-colors-blue-500)', + }} + transition="all 0.15s" + /> + {value && ( + onChange('')} + zIndex={1} + > + + + )} + + {hasSearched && totalCount !== undefined && ( + + {totalCount.toLocaleString()} results + + )} + + {hasSearched && activeFilterCount !== undefined && activeFilterCount > 0 && ( + + + Active filters: + + + {activeFilterCount} + + + )} + + ); +} diff --git a/src/components/search/TierBadge.tsx b/src/components/search/TierBadge.tsx new file mode 100644 index 0000000..9c6e827 --- /dev/null +++ b/src/components/search/TierBadge.tsx @@ -0,0 +1,14 @@ +import { Badge } from '@chakra-ui/react'; +import { COLORS } from '@/constants/colors'; + +export function TierBadge({ tier }: { tier: number }) { + if (tier < 1 || tier > 5) { + return null; + } + const palette = (COLORS.tier as Record)[tier] ?? 'gray'; + return ( + + Tier {tier} + + ); +} diff --git a/src/components/search/VisitDetailDrawer.tsx b/src/components/search/VisitDetailDrawer.tsx new file mode 100644 index 0000000..bfec274 --- /dev/null +++ b/src/components/search/VisitDetailDrawer.tsx @@ -0,0 +1,644 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Box, VStack, HStack, Text, Badge, Spinner, Center, Tabs } from '@chakra-ui/react'; +import { + DialogRoot, + DialogContent, + DialogHeader, + DialogBody, + DialogCloseTrigger, + DialogTitle, +} from '@/components/ui/dialog'; +import { apiClient } from '@/lib/apiClient'; +import { TierBadge } from './TierBadge'; + +interface VisitInfo { + id: number; + visit_start_date: string | null; + visit_source_value: string | null; + visit_source_id: number | null; + department: string | null; + tier_level: number; + person_id: number | null; + provider_id: number | null; +} + +interface Condition { + id: number; + concept_code: string | null; + condition_source_value: string | null; + is_primary_dx: boolean; +} + +interface Drug { + id: number; + description: string | null; + drug_ordering_date: string | null; + quantity: number | null; +} + +interface Procedure { + id: number; + name: string | null; + description: string | null; + procedure_ordering_date: string | null; +} + +interface Note { + id: number; + note_date: string | null; + note_type: string | null; + note_text: string | null; +} + +interface Observation { + id: number; + file_type: string | null; + observation_date: string | null; +} + +interface Measurement { + id: number; + bp_systolic: number | null; + bp_diastolic: number | null; + weight_lb: number | null; + height: number | null; + pulse: number | null; + phys_spo2: number | null; +} + +interface DataSection { + count: number; + results: T[]; +} + +interface VisitDetailResponse { + visit: VisitInfo; + conditions: DataSection; + drugs: DataSection; + procedures: DataSection; + notes: DataSection; + observations: DataSection; + measurements: DataSection; +} + +interface VisitDetail { + visit: VisitInfo; + conditions: Condition[]; + conditionCount: number; + drugs: Drug[]; + drugCount: number; + procedures: Procedure[]; + procedureCount: number; + notes: Note[]; + noteCount: number; + observations: Observation[]; + observationCount: number; + measurements: Measurement[]; + measurementCount: number; +} + +interface VisitDetailDrawerProps { + visitSourceId: number | null; + isOpen: boolean; + onClose: () => void; +} + +function EmptyRow({ label }: { label: string }) { + return ( +
+ + No {label} recorded + +
+ ); +} + +function formatDate(dateStr: string | null, long = false) { + if (!dateStr) { + return '—'; + } + return new Date(dateStr).toLocaleDateString( + 'en-US', + long + ? { year: 'numeric', month: 'long', day: 'numeric' } + : { year: 'numeric', month: 'short', day: 'numeric' } + ); +} + +export default function VisitDetailDrawer({ + visitSourceId, + isOpen, + onClose, +}: VisitDetailDrawerProps) { + type ResultState = + | { key: null } + | { key: number; status: 'error'; message: string } + | { key: number; status: 'success'; data: VisitDetail }; + + const [result, setResult] = useState({ key: null }); + + const fetchKey = isOpen && visitSourceId !== null ? visitSourceId : null; + + useEffect(() => { + if (fetchKey === null) { + return; + } + + let cancelled = false; + + apiClient + .get(`/research/private/visits/${fetchKey}/detail-data/`) + .then((res) => { + if (cancelled) { + return; + } + const d = res.data; + setResult({ + key: fetchKey, + status: 'success', + data: { + visit: d.visit, + conditions: d.conditions.results, + conditionCount: d.conditions.count, + drugs: d.drugs.results, + drugCount: d.drugs.count, + procedures: d.procedures.results, + procedureCount: d.procedures.count, + notes: d.notes.results, + noteCount: d.notes.count, + observations: d.observations.results, + observationCount: d.observations.count, + measurements: d.measurements.results, + measurementCount: d.measurements.count, + }, + }); + }) + .catch(() => { + if (!cancelled) { + setResult({ key: fetchKey, status: 'error', message: 'Failed to load visit details.' }); + } + }); + + return () => { + cancelled = true; + }; + }, [fetchKey]); + + const loading = fetchKey !== null && result.key !== fetchKey; + const settled = !loading && result.key !== null && result.key === fetchKey; + const error = settled && 'message' in result ? result.message : null; + const detail = settled && 'data' in result ? result.data : null; + + return ( + { + if (!e.open) { + onClose(); + } + }} + size="xl" + > + + {/* Header */} + + + + + Visit Detail + + {detail && ( + + + + + Visit ID + + + {detail.visit.id} + + + {detail.visit.visit_source_value && ( + + + Type + + + {detail.visit.visit_source_value} + + + )} + {detail.visit.visit_start_date && ( + + + Date + + + {formatDate(detail.visit.visit_start_date)} + + + )} + {detail.visit.department && ( + + {detail.visit.department} + + )} + + )} + + + + + + + {loading && ( +
+ + + + Loading visit data… + + +
+ )} + + {error && ( +
+ {error} +
+ )} + + {detail && !loading && ( + + + + Conditions + {detail.conditionCount > 0 && ( + + {detail.conditionCount} + + )} + + + Drugs + {detail.drugCount > 0 && ( + + {detail.drugCount} + + )} + + + Procedures + {detail.procedureCount > 0 && ( + + {detail.procedureCount} + + )} + + + Notes + {detail.noteCount > 0 && ( + + {detail.noteCount} + + )} + + + Observations + {detail.observationCount > 0 && ( + + {detail.observationCount} + + )} + + + Vitals + {detail.measurementCount > 0 && ( + + {detail.measurementCount} + + )} + + + + {/* Conditions */} + + {detail.conditions.length === 0 ? ( + + ) : ( + + {detail.conditions.map((c) => ( + + {c.concept_code && ( + + {c.concept_code} + + )} + {c.is_primary_dx && ( + + Primary + + )} + + {c.condition_source_value ?? '—'} + + + ))} + + )} + + + {/* Drugs */} + + {detail.drugs.length === 0 ? ( + + ) : ( + + {detail.drugs.map((d) => ( + + + {d.description ?? '—'} + + + {d.quantity !== null && ( + + qty: {d.quantity} + + )} + {d.drug_ordering_date && ( + + {d.drug_ordering_date} + + )} + + + ))} + + )} + + + {/* Procedures */} + + {detail.procedures.length === 0 ? ( + + ) : ( + + {detail.procedures.map((p) => ( + + + + {p.name ?? '—'} + + {p.procedure_ordering_date && ( + + {p.procedure_ordering_date} + + )} + + {p.description && ( + + {p.description} + + )} + + ))} + + )} + + + {/* Notes */} + + {detail.notes.length === 0 ? ( + + ) : ( + + {detail.notes.map((n) => ( + + + {n.note_type && ( + + {n.note_type} + + )} + {n.note_date && ( + + {n.note_date} + + )} + + + {n.note_text ?? '—'} + + + ))} + + )} + + + {/* Observations */} + + {detail.observations.length === 0 ? ( + + ) : ( + + {detail.observations.map((o) => ( + + {o.file_type && ( + + {o.file_type} + + )} + {o.observation_date && ( + + {o.observation_date} + + )} + + ))} + + )} + + + {/* Vitals */} + + {detail.measurements.length === 0 ? ( + + ) : ( + + {detail.measurements.map((m) => ( + + + {m.bp_systolic !== null && m.bp_diastolic !== null && ( + + + Blood Pressure + + + {m.bp_systolic}/{m.bp_diastolic} + + + mmHg + + + )} + {m.pulse !== null && ( + + + Pulse + + + {m.pulse} + + + bpm + + + )} + {m.weight_lb !== null && ( + + + Weight + + + {m.weight_lb} + + + lb + + + )} + {m.height !== null && ( + + + Height + + + {m.height} + + + in + + + )} + {m.phys_spo2 !== null && ( + + + SpO2 + + + {m.phys_spo2} + + + % + + + )} + + + ))} + + )} + + + )} +
+
+
+ ); +} diff --git a/src/components/search/__tests__/FacetSidebar.test.tsx b/src/components/search/__tests__/FacetSidebar.test.tsx new file mode 100644 index 0000000..b78bf35 --- /dev/null +++ b/src/components/search/__tests__/FacetSidebar.test.tsx @@ -0,0 +1,169 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Provider } from '@/components/ui/provider'; +import FacetSidebar from '../FacetSidebar'; +import { EncounterSearchFilters, SearchAggregations } from '@/interfaces/search'; + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}); + +global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); + +function renderWithProvider(ui: React.ReactElement) { + return render({ui}); +} + +const emptyAggregations: SearchAggregations = { + departments: [], + patient_genders: [], + patient_races: [], + patient_ethnicities: [], + provider_genders: [], + provider_races: [], + provider_ethnicities: [], + tier_distribution: [], + note_types: [], + file_types: [], +}; + +const aggregationsWithData: SearchAggregations = { + ...emptyAggregations, + departments: [{ key: 'Internal Medicine', count: 15 }], + patient_genders: [ + { key: 'F', count: 47 }, + { key: 'M', count: 40 }, + ], + patient_races: [ + { key: 'B', count: 42 }, + { key: 'W', count: 34 }, + ], + note_types: [{ key: 'Progress Notes', count: 20 }], +}; + +describe('FacetSidebar', () => { + it('renders section headers', () => { + renderWithProvider( + + ); + expect(screen.getByText('Date Range')).toBeInTheDocument(); + expect(screen.getByText('Department')).toBeInTheDocument(); + expect(screen.getByText('Demographics')).toBeInTheDocument(); + expect(screen.getByText('Multimodal Data')).toBeInTheDocument(); + expect(screen.getByText('Clinical Data')).toBeInTheDocument(); + }); + + it('renders date range inputs', () => { + renderWithProvider( + + ); + expect(screen.getByText('From')).toBeInTheDocument(); + expect(screen.getByText('To')).toBeInTheDocument(); + }); + + it('renders patient gender checkboxes from aggregations', () => { + renderWithProvider( + + ); + expect(screen.getByText('Female')).toBeInTheDocument(); + expect(screen.getByText('Male')).toBeInTheDocument(); + }); + + it('does not render gender checkboxes when aggregations empty', () => { + renderWithProvider( + + ); + expect(screen.queryByText('Female')).not.toBeInTheDocument(); + expect(screen.queryByText('Male')).not.toBeInTheDocument(); + }); + + it('calls onChange with patient_gender filter when checkbox clicked', async () => { + const onChange = jest.fn(); + renderWithProvider( + + ); + await userEvent.click(screen.getByText('Female')); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ patient_gender: ['F'] })); + }); + + it('removes filter when checked item is clicked again', async () => { + const onChange = jest.fn(); + renderWithProvider( + + ); + await userEvent.click(screen.getByText('Female')); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ patient_gender: undefined })); + }); + + it('renders multimodal checkboxes', () => { + renderWithProvider( + + ); + expect(screen.getByText('Transcript')).toBeInTheDocument(); + expect(screen.getByText('Audio')).toBeInTheDocument(); + }); + + it('calls onChange with has_transcript=true when Transcript checkbox clicked', async () => { + const onChange = jest.fn(); + renderWithProvider( + + ); + await userEvent.click(screen.getByText('Transcript')); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ has_transcript: true })); + }); + + it('updates drug_names filter when drug name input changed', () => { + const onChange = jest.fn(); + renderWithProvider( + + ); + fireEvent.change(screen.getByPlaceholderText('e.g. metformin'), { + target: { value: 'lisinopril' }, + }); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ drug_names: ['lisinopril'] })); + }); + + it('renders department checkboxes from aggregations', () => { + renderWithProvider( + + ); + expect(screen.getByText('Internal Medicine')).toBeInTheDocument(); + }); + + it('calls onChange with department filter when checkbox clicked', async () => { + const onChange = jest.fn(); + renderWithProvider( + + ); + await userEvent.click(screen.getByText('Internal Medicine')); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ department: ['Internal Medicine'] }) + ); + }); + + it('renders note types from aggregations', () => { + renderWithProvider( + + ); + expect(screen.getByText('Progress Notes')).toBeInTheDocument(); + }); +}); diff --git a/src/components/search/__tests__/ResultCard.test.tsx b/src/components/search/__tests__/ResultCard.test.tsx new file mode 100644 index 0000000..31ec7a2 --- /dev/null +++ b/src/components/search/__tests__/ResultCard.test.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Provider } from '@/components/ui/provider'; +import ResultCard from '../ResultCard'; +import { EncounterSearchHit } from '@/interfaces/search'; + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}); + +global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); + +function renderWithProvider(ui: React.ReactElement) { + return render({ui}); +} + +const baseHit: EncounterSearchHit = { + encounter_id: '42', + visit_source_value: 'clinic', + visit_source_id: 1, + visit_date: '2024-03-15', + department: 'Internal Medicine', + patient_gender: 'M', + patient_race: 'B', + patient_ethnicity: 'NH', + patient_year_of_birth: 1965, + provider_gender: 'F', + provider_race: 'W', + provider_ethnicity: 'NH', + provider_year_of_birth: 1980, + has_transcript: true, + has_audio: false, + has_provider_view: false, + has_patient_view: false, + has_room_view: false, + file_types: ['transcript'], + icd_codes: ['Z00.00', 'I10', 'Z00.00'], // duplicate to test dedup + cpt_codes: [], + drug_names: ['metformin'], + drug_count: 1, + has_notes: true, + note_types: ['Progress Notes'], + note_count: 2, + tier_level: 1, + highlights: {}, + matched_in: [], +}; + +describe('ResultCard', () => { + it('renders patient gender label "Male" for code "M"', () => { + renderWithProvider(); + expect(screen.getByText('Male')).toBeInTheDocument(); + }); + + it('renders patient race label "Black" for code "B"', () => { + renderWithProvider(); + expect(screen.getByText('Black')).toBeInTheDocument(); + }); + + it('renders patient ethnicity label "Not Hispanic" for code "NH"', () => { + renderWithProvider(); + // patient ethnicity NH → Not Hispanic + const badges = screen.getAllByText('Not Hispanic'); + expect(badges.length).toBeGreaterThanOrEqual(1); + }); + + it('renders tier badge', () => { + renderWithProvider(); + expect(screen.getByText('Tier 1')).toBeInTheDocument(); + }); + + it('renders visit date formatted', () => { + renderWithProvider(); + expect(screen.getByText(/Mar 15, 2024/i)).toBeInTheDocument(); + }); + + it('deduplicates ICD codes — shows Z00.00 once and I10', () => { + renderWithProvider(); + const z00 = screen.getAllByText('Z00.00'); + expect(z00).toHaveLength(1); + expect(screen.getByText('I10')).toBeInTheDocument(); + }); + + it('shows Transcript badge when has_transcript is true', () => { + renderWithProvider(); + expect(screen.getByText('Transcript')).toBeInTheDocument(); + }); + + it('does not show Audio badge when has_audio is false', () => { + renderWithProvider(); + expect(screen.queryByText('Audio')).not.toBeInTheDocument(); + }); + + it('shows no multimodal badges when all flags false', () => { + const hit: EncounterSearchHit = { + ...baseHit, + has_transcript: false, + has_audio: false, + has_provider_view: false, + has_patient_view: false, + has_room_view: false, + }; + renderWithProvider(); + expect(screen.queryByText('Transcript')).not.toBeInTheDocument(); + expect(screen.queryByText('Audio')).not.toBeInTheDocument(); + }); + + it('calls onSelect with the hit when clicked', () => { + const onSelect = jest.fn(); + renderWithProvider(); + fireEvent.click(screen.getByText('Tier 1').closest('[data-testid]') ?? document.body); + // Click the card itself + const card = screen.getByText('Male').closest('div[class]')!; + fireEvent.click(card); + expect(onSelect).toHaveBeenCalledWith(baseHit); + }); + + it('renders department badge when department is present', () => { + renderWithProvider(); + expect(screen.getByText('Internal Medicine')).toBeInTheDocument(); + }); + + it('does not render department badge when department is null', () => { + const hit: EncounterSearchHit = { ...baseHit, department: null }; + renderWithProvider(); + expect(screen.queryByText('Internal Medicine')).not.toBeInTheDocument(); + }); + + it('shows drug count badge', () => { + renderWithProvider(); + expect(screen.getByText('1 drug')).toBeInTheDocument(); + }); + + it('shows note count badge', () => { + renderWithProvider(); + expect(screen.getByText('2 notes')).toBeInTheDocument(); + }); +}); diff --git a/src/components/search/__tests__/ResultsList.test.tsx b/src/components/search/__tests__/ResultsList.test.tsx new file mode 100644 index 0000000..55e86ab --- /dev/null +++ b/src/components/search/__tests__/ResultsList.test.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { Provider } from '@/components/ui/provider'; +import ResultsList from '../ResultsList'; +import { EncounterSearchHit } from '@/interfaces/search'; + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}); + +global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); + +function renderWithProvider(ui: React.ReactElement) { + return render({ui}); +} + +const makeHit = (id: string): EncounterSearchHit => ({ + encounter_id: id, + visit_source_value: 'clinic', + visit_source_id: 1, + visit_date: '2024-01-01', + department: null, + patient_gender: 'F', + patient_race: 'W', + patient_ethnicity: 'NH', + patient_year_of_birth: 1970, + provider_gender: 'M', + provider_race: 'W', + provider_ethnicity: 'NH', + provider_year_of_birth: 1975, + has_transcript: false, + has_audio: false, + has_provider_view: false, + has_patient_view: false, + has_room_view: false, + file_types: [], + icd_codes: [], + cpt_codes: [], + drug_names: [], + drug_count: 0, + has_notes: false, + note_types: [], + note_count: 0, + tier_level: 1, + highlights: {}, + matched_in: [], +}); + +describe('ResultsList', () => { + it('shows empty state when hasSearched is false', () => { + renderWithProvider( + + ); + expect(screen.getByText('Search encounters')).toBeInTheDocument(); + }); + + it('shows loading skeletons when loading=true', () => { + renderWithProvider( + + ); + // Skeleton elements rendered — no results header + expect(screen.queryByText(/encounters found/i)).not.toBeInTheDocument(); + }); + + it('shows no encounters found when hasSearched and results empty', () => { + renderWithProvider( + + ); + expect(screen.getByText('No encounters found')).toBeInTheDocument(); + }); + + it('renders result count header when results present', () => { + const hits = [makeHit('1'), makeHit('2')]; + renderWithProvider( + + ); + expect(screen.getByText('42')).toBeInTheDocument(); + expect(screen.getByText(/encounters found/i)).toBeInTheDocument(); + }); + + it('shows singular "encounter found" for count=1', () => { + renderWithProvider( + + ); + expect(screen.getByText(/encounter found/i)).toBeInTheDocument(); + expect(screen.queryByText(/encounters found/i)).not.toBeInTheDocument(); + }); + + it('renders a ResultCard for each hit', () => { + const hits = [makeHit('1'), makeHit('2'), makeHit('3')]; + renderWithProvider( + + ); + // Each card shows Tier 1 badge + expect(screen.getAllByText('Tier 1')).toHaveLength(3); + }); + + it('shows "sorted by relevance" label', () => { + renderWithProvider( + + ); + expect(screen.getByText('sorted by relevance')).toBeInTheDocument(); + }); +}); diff --git a/src/components/search/__tests__/SearchBar.test.tsx b/src/components/search/__tests__/SearchBar.test.tsx new file mode 100644 index 0000000..d96db1a --- /dev/null +++ b/src/components/search/__tests__/SearchBar.test.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Provider } from '@/components/ui/provider'; +import SearchBar from '../SearchBar'; + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}); + +global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); + +function renderWithProvider(ui: React.ReactElement) { + return render({ui}); +} + +describe('SearchBar', () => { + it('renders the input', () => { + renderWithProvider(); + expect(screen.getByPlaceholderText(/Search by ICD code/i)).toBeInTheDocument(); + }); + + it('calls onChange when user types', () => { + const onChange = jest.fn(); + renderWithProvider(); + fireEvent.change(screen.getByPlaceholderText(/Search by ICD code/i), { + target: { value: 'metformin' }, + }); + expect(onChange).toHaveBeenCalledWith('metformin'); + }); + + it('shows clear button when value is non-empty', () => { + renderWithProvider(); + expect(screen.getByLabelText('Clear search')).toBeInTheDocument(); + }); + + it('clear button calls onChange with empty string', () => { + const onChange = jest.fn(); + renderWithProvider(); + fireEvent.click(screen.getByLabelText('Clear search')); + expect(onChange).toHaveBeenCalledWith(''); + }); + + it('does not show clear button when value is empty', () => { + renderWithProvider(); + expect(screen.queryByLabelText('Clear search')).not.toBeInTheDocument(); + }); + + it('shows result count badge when hasSearched=true and totalCount provided', () => { + renderWithProvider(); + expect(screen.getByText(/87/)).toBeInTheDocument(); + }); + + it('does not show result count badge when hasSearched=false', () => { + renderWithProvider( + + ); + expect(screen.queryByText(/87/)).not.toBeInTheDocument(); + }); + + it('shows active filter count when hasSearched=true and activeFilterCount > 0', () => { + renderWithProvider( + + ); + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.getByText(/Active filters/i)).toBeInTheDocument(); + }); + + it('does not show active filter count when activeFilterCount=0', () => { + renderWithProvider( + + ); + expect(screen.queryByText(/Active filters/i)).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/search/__tests__/TierBadge.test.tsx b/src/components/search/__tests__/TierBadge.test.tsx new file mode 100644 index 0000000..22f2219 --- /dev/null +++ b/src/components/search/__tests__/TierBadge.test.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { Provider } from '@/components/ui/provider'; +import { TierBadge } from '../TierBadge'; + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}); + +global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); + +function renderWithProvider(ui: React.ReactElement) { + return render({ui}); +} + +describe('TierBadge', () => { + it('renders tier 1 with correct label', () => { + renderWithProvider(); + expect(screen.getByText('Tier 1')).toBeInTheDocument(); + }); + + it('renders tier 5 with correct label', () => { + renderWithProvider(); + expect(screen.getByText('Tier 5')).toBeInTheDocument(); + }); + + it('renders nothing for out-of-range tier', () => { + const { container } = renderWithProvider(); + expect(container.querySelector('[class]')).toBeNull(); + expect(screen.queryByText(/Tier/)).not.toBeInTheDocument(); + }); +}); diff --git a/src/constants/demographicLabels.ts b/src/constants/demographicLabels.ts new file mode 100644 index 0000000..504882a --- /dev/null +++ b/src/constants/demographicLabels.ts @@ -0,0 +1,27 @@ +export const GENDER_LABELS: Record = { + M: 'Male', + F: 'Female', + UN: 'Unknown', +}; + +export const RACE_LABELS: Record = { + W: 'White', + B: 'Black', + A: 'Asian', + AI: 'American Indian', + O: 'Other', + UN: 'Unknown', +}; + +export const ETHNICITY_LABELS: Record = { + H: 'Hispanic', + NH: 'Not Hispanic', + UN: 'Unknown', +}; + +export function labelDemographic(map: Record, value: string | null): string | null { + if (!value) { + return null; + } + return map[value] ?? value; +} diff --git a/src/hooks/__tests__/useSearch.test.ts b/src/hooks/__tests__/useSearch.test.ts new file mode 100644 index 0000000..e25e087 --- /dev/null +++ b/src/hooks/__tests__/useSearch.test.ts @@ -0,0 +1,202 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { useEncounterSearch } from '../useSearch'; +import { apiClient } from '@/lib/apiClient'; +import { EncounterSearchResponse } from '@/interfaces/search'; + +jest.mock('@/lib/apiClient', () => ({ + apiClient: { + post: jest.fn(), + }, +})); + +jest.mock('@/lib/logger', () => ({ + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn() }, +})); + +const mockApiClient = apiClient as jest.Mocked; + +const mockHit = { + encounter_id: '1', + visit_source_value: 'clinic', + visit_source_id: 1, + visit_date: '2024-01-15', + department: 'Internal Medicine', + patient_gender: 'M', + patient_race: 'B', + patient_ethnicity: 'NH', + patient_year_of_birth: 1965, + provider_gender: 'F', + provider_race: 'W', + provider_ethnicity: 'NH', + provider_year_of_birth: 1980, + has_transcript: true, + has_audio: false, + has_provider_view: false, + has_patient_view: false, + has_room_view: false, + file_types: ['transcript'], + icd_codes: ['Z00.00'], + cpt_codes: [], + drug_names: ['metformin'], + drug_count: 1, + has_notes: true, + note_types: ['Progress Notes'], + note_count: 2, + tier_level: 1, + highlights: {}, + matched_in: [], +}; + +const mockResponse: EncounterSearchResponse = { + count: 42, + page: 1, + page_size: 20, + next: 'http://example.com/api?page=2', + previous: null, + results: [mockHit], + aggregations: { + departments: [], + patient_genders: [{ key: 'M', count: 30 }], + patient_races: [], + patient_ethnicities: [], + provider_genders: [], + provider_races: [], + provider_ethnicities: [], + tier_distribution: [], + note_types: [], + file_types: [], + }, +}; + +describe('useEncounterSearch', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('starts with empty state and does not fetch on mount', () => { + const { result } = renderHook(() => useEncounterSearch()); + expect(result.current.results).toEqual([]); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + // aggregations start as DEFAULT_AGGREGATIONS (empty arrays), not null + expect(result.current.aggregations).not.toBeNull(); + expect(result.current.pagination.totalCount).toBe(0); + expect(mockApiClient.post).not.toHaveBeenCalled(); + }); + + async function triggerSearch( + result: ReturnType, unknown>>['result'], + query: string + ) { + act(() => { + result.current.setQuery(query); + }); + await act(async () => { + jest.advanceTimersByTime(350); + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + } + + it('fetches when query is set', async () => { + mockApiClient.post.mockResolvedValue({ data: mockResponse }); + const { result } = renderHook(() => useEncounterSearch()); + + await triggerSearch(result, 'metformin'); + + expect(mockApiClient.post).toHaveBeenCalledWith( + '/search/private/encounters/', + expect.objectContaining({ query: 'metformin' }) + ); + expect(result.current.results).toEqual([mockHit]); + }); + + it('sets pagination correctly after fetch', async () => { + mockApiClient.post.mockResolvedValue({ data: mockResponse }); + const { result } = renderHook(() => useEncounterSearch()); + + await triggerSearch(result, 'test'); + + expect(result.current.pagination.totalCount).toBe(42); + expect(result.current.pagination.hasNext).toBe(true); + expect(result.current.pagination.hasPrevious).toBe(false); + expect(result.current.pagination.currentPage).toBe(1); + }); + + it('sets aggregations after fetch', async () => { + mockApiClient.post.mockResolvedValue({ data: mockResponse }); + const { result } = renderHook(() => useEncounterSearch()); + + await triggerSearch(result, 'test'); + + expect(result.current.aggregations?.patient_genders).toEqual([{ key: 'M', count: 30 }]); + }); + + it('sets error on API failure', async () => { + mockApiClient.post.mockRejectedValue(new Error('Network error')); + const { result } = renderHook(() => useEncounterSearch()); + + act(() => { + result.current.setQuery('test'); + }); + await act(async () => { + jest.advanceTimersByTime(350); + }); + await waitFor(() => expect(result.current.error).not.toBeNull()); + expect(result.current.loading).toBe(false); + }); + + it('does not fetch when query is empty and no filters', async () => { + const { result } = renderHook(() => useEncounterSearch()); + + act(() => { + result.current.setQuery(' '); + }); + await act(async () => { + jest.advanceTimersByTime(350); + }); + + expect(mockApiClient.post).not.toHaveBeenCalled(); + }); + + it('fetches when filters are set with empty query', async () => { + mockApiClient.post.mockResolvedValue({ data: mockResponse }); + const { result } = renderHook(() => useEncounterSearch()); + + act(() => { + result.current.setFilters({ has_transcript: true }); + }); + await act(async () => { + jest.advanceTimersByTime(350); + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(mockApiClient.post).toHaveBeenCalled(); + }); + + it('resets to page 1 when filters change', async () => { + mockApiClient.post.mockResolvedValue({ data: mockResponse }); + const { result } = renderHook(() => useEncounterSearch()); + + await triggerSearch(result, 'test'); + + act(() => { + result.current.setPage(3); + }); + await waitFor(() => expect(result.current.pagination.currentPage).toBe(3)); + + act(() => { + result.current.setFilters({ patient_gender: ['M'] }); + }); + await act(async () => { + jest.advanceTimersByTime(350); + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.pagination.currentPage).toBe(1); + }); +}); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 9c0bc94..2bd49cf 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -6,3 +6,4 @@ export { useDatasetExplorer } from './useDatasetExplorer'; export { useDebounce } from './useDebounce'; export { useFilterOptions } from './useFilterOptions'; export { useVisitSearch } from './useVisitSearch'; +export { useEncounterSearch } from './useSearch'; diff --git a/src/hooks/useFilterOptions.ts b/src/hooks/useFilterOptions.ts index f92e818..d327ac8 100644 --- a/src/hooks/useFilterOptions.ts +++ b/src/hooks/useFilterOptions.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { apiClient } from '@/lib/apiClient'; import { FilterOptions } from '@/interfaces/research'; import { logger } from '@/lib/logger'; @@ -20,7 +20,7 @@ export function useFilterOptions(enabled: boolean = true): UseFilterOptionsRetur const [loading, setLoading] = useState(enabled); const [error, setError] = useState(null); - const fetchFilterOptions = async () => { + const fetchFilterOptions = useCallback(async () => { try { setLoading(true); setError(null); @@ -33,13 +33,13 @@ export function useFilterOptions(enabled: boolean = true): UseFilterOptionsRetur } finally { setLoading(false); } - }; + }, []); useEffect(() => { if (enabled) { fetchFilterOptions(); } - }, [enabled]); + }, [enabled, fetchFilterOptions]); return { filterOptions, diff --git a/src/hooks/useSearch.ts b/src/hooks/useSearch.ts new file mode 100644 index 0000000..3a695a1 --- /dev/null +++ b/src/hooks/useSearch.ts @@ -0,0 +1,158 @@ +import { useState, useEffect, useCallback } from 'react'; +import { apiClient } from '@/lib/apiClient'; +import { + EncounterSearchFilters, + EncounterSearchSort, + EncounterSearchHit, + EncounterSearchResponse, + SearchAggregations, +} from '@/interfaces/search'; +import { logger } from '@/lib/logger'; +import { useDebounce } from './useDebounce'; + +interface UseEncounterSearchReturn { + results: EncounterSearchHit[]; + loading: boolean; + error: string | null; + pagination: { + currentPage: number; + totalCount: number; + hasNext: boolean; + hasPrevious: boolean; + }; + aggregations: SearchAggregations | null; + query: string; + sort: EncounterSearchSort; + setQuery: (query: string) => void; + setFilters: (filters: EncounterSearchFilters) => void; + setSort: (sort: EncounterSearchSort) => void; + setPage: (page: number) => void; +} + +const DEFAULT_AGGREGATIONS: SearchAggregations = { + departments: [], + patient_genders: [], + patient_races: [], + patient_ethnicities: [], + provider_genders: [], + provider_races: [], + provider_ethnicities: [], + tier_distribution: [], + note_types: [], + file_types: [], +}; + +const DEFAULT_SORT: EncounterSearchSort = { _score: 'desc' }; + +/** + * Hook for encounter search using the Observer Elasticsearch API. + * - No auto-fetch on empty query + empty filters (shows empty state on page load) + * - Query/filter changes are debounced 300ms + * - Sort and page changes are immediate + */ +export function useEncounterSearch(): UseEncounterSearchReturn { + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [aggregations, setAggregations] = useState(null); + + const [query, setQuery] = useState(''); + const [filters, setFilters] = useState({}); + const [sort, setSort] = useState(DEFAULT_SORT); + const [page, setPage] = useState(1); + + const [totalCount, setTotalCount] = useState(0); + const [hasNext, setHasNext] = useState(false); + const [hasPrevious, setHasPrevious] = useState(false); + + const debouncedQuery = useDebounce(query, 300); + const debouncedFilters = useDebounce(filters, 300); + + // Reset to page 1 when debounced query, filters, or sort change + useEffect(() => { + setPage(1); + }, [debouncedQuery, debouncedFilters, sort]); + + useEffect(() => { + const hasQuery = debouncedQuery.trim().length > 0; + const hasFilters = Object.keys(debouncedFilters).length > 0; + + if (!hasQuery && !hasFilters) { + setResults([]); + setTotalCount(0); + setHasNext(false); + setHasPrevious(false); + setAggregations(DEFAULT_AGGREGATIONS); + return; + } + + let cancelled = false; + + const fetchResults = async () => { + try { + setLoading(true); + setError(null); + + const response = await apiClient.post( + '/search/private/encounters/', + { + query: debouncedQuery, + filters: debouncedFilters, + sort, + page, + page_size: 20, + } + ); + + if (cancelled) { + return; + } + + setResults(response.data.results); + setTotalCount(response.data.count); + setHasNext(response.data.next !== null); + setHasPrevious(response.data.previous !== null); + setAggregations(response.data.aggregations); + } catch (err) { + if (cancelled) { + return; + } + setError('Search failed. Please try again.'); + logger.error('Encounter search error:', err); + setResults([]); + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + fetchResults(); + + return () => { + cancelled = true; + }; + }, [debouncedQuery, debouncedFilters, sort, page]); + + const handleSetQuery = useCallback((q: string) => { + setQuery(q); + }, []); + + const handleSetFilters = useCallback((f: EncounterSearchFilters) => { + setFilters(f); + }, []); + + return { + results, + loading, + error, + pagination: { currentPage: page, totalCount, hasNext, hasPrevious }, + aggregations, + query, + sort, + setQuery: handleSetQuery, + setFilters: handleSetFilters, + setSort, + setPage, + }; +} diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index b251605..ad9f8f3 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -6,6 +6,7 @@ export * from './patient'; export * from './department'; export * from './mmd'; export * from './research'; +export * from './search'; // Explicit re-exports to avoid naming conflicts export type { diff --git a/src/interfaces/search.ts b/src/interfaces/search.ts new file mode 100644 index 0000000..5399792 --- /dev/null +++ b/src/interfaces/search.ts @@ -0,0 +1,104 @@ +/** + * TypeScript interfaces for the Observer encounter search API. + * All fields sourced from research DB (de-identified OMOP dataset) — no PHI. + */ + +export interface EncounterSearchFilters { + department?: string[]; + date_from?: string; + date_to?: string; + icd_codes?: string[]; + cpt_codes?: string[]; + drug_names?: string[]; + note_types?: string[]; + patient_gender?: string[]; + patient_race?: string[]; + patient_ethnicity?: string[]; + provider_gender?: string[]; + provider_race?: string[]; + provider_ethnicity?: string[]; + has_transcript?: boolean; + has_audio?: boolean; + has_provider_view?: boolean; + has_patient_view?: boolean; + has_room_view?: boolean; + has_notes?: boolean; +} + +export type EncounterSearchSort = Partial< + Record<'visit_date' | '_score' | 'tier_level', 'asc' | 'desc'> +>; + +export interface EncounterSearchRequest { + query?: string; + filters?: EncounterSearchFilters; + page?: number; + page_size?: number; + sort?: EncounterSearchSort; + search_type?: 'keyword' | 'semantic'; +} + +export interface EncounterSearchHit { + encounter_id: string; + visit_source_value: string | null; + visit_source_id: number | null; + visit_date: string | null; + department: string | null; + // Patient demographics (de-identified — no name) + patient_gender: string | null; + patient_race: string | null; + patient_ethnicity: string | null; + patient_year_of_birth: number | null; + // Provider demographics (de-identified — no name) + provider_gender: string | null; + provider_race: string | null; + provider_ethnicity: string | null; + provider_year_of_birth: number | null; + // Multimodal flags + has_transcript: boolean; + has_audio: boolean; + has_provider_view: boolean; + has_patient_view: boolean; + has_room_view: boolean; + file_types: string[]; + // Clinical data + icd_codes: string[]; + cpt_codes: string[]; + drug_names: string[]; + drug_count: number; + has_notes: boolean; + note_types: string[]; + note_count: number; + // Access control + tier_level: number; + highlights: Record; + matched_in: string[]; +} + +export interface SearchAggregationBucket { + key: string; + count: number; +} + +export interface SearchAggregations { + departments: SearchAggregationBucket[]; + patient_genders: SearchAggregationBucket[]; + patient_races: SearchAggregationBucket[]; + patient_ethnicities: SearchAggregationBucket[]; + provider_genders: SearchAggregationBucket[]; + provider_races: SearchAggregationBucket[]; + provider_ethnicities: SearchAggregationBucket[]; + tier_distribution: SearchAggregationBucket[]; + note_types: SearchAggregationBucket[]; + file_types: SearchAggregationBucket[]; +} + +export interface EncounterSearchResponse { + count: number; + page: number; + page_size: number; + next: string | null; + previous: string | null; + results: EncounterSearchHit[]; + aggregations: SearchAggregations; +} From bd6ffaf16824e5ff5e2f39f80bd41c12a17372b6 Mon Sep 17 00:00:00 2001 From: Sriharsha Mopidevi Date: Fri, 27 Mar 2026 14:16:46 -0400 Subject: [PATCH 2/2] feat: Add Save Search Cohort Dialog and enhance cohort management --- src/app/search/page.tsx | 124 ++++- .../dashboard/CohortTab/CohortCard.tsx | 151 ++++-- .../CohortTab/CreateCohortDialog.tsx | 1 + src/components/search/ResultCard.tsx | 319 +++++++------ src/components/search/ResultsList.tsx | 15 +- .../search/SaveSearchCohortDialog.tsx | 219 +++++++++ src/interfaces/cohort.ts | 52 +- src/lib/utils/__tests__/cohortStorage.test.ts | 451 +++++++----------- src/lib/utils/cohortStorage.ts | 107 ++--- 9 files changed, 901 insertions(+), 538 deletions(-) create mode 100644 src/components/search/SaveSearchCohortDialog.tsx diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index cf34643..1f46cf0 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -1,16 +1,20 @@ 'use client'; import React, { Suspense, useEffect, useState } from 'react'; -import { Box, Container, HStack, Spinner, Center, Text } from '@chakra-ui/react'; +import { Box, Button, Container, HStack, Spinner, Center, Text } from '@chakra-ui/react'; +import { FaSave, FaCheckSquare, FaTimes } from 'react-icons/fa'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; import { useEncounterSearch } from '@/hooks'; import { EncounterSearchFilters, EncounterSearchHit } from '@/interfaces/search'; +import { Cohort } from '@/interfaces/cohort'; import SearchBar from '@/components/search/SearchBar'; import FacetSidebar from '@/components/search/FacetSidebar'; import ResultsList from '@/components/search/ResultsList'; import PaginationControls from '@/components/dashboard/ResearchTab/PaginationControls'; import VisitDetailDrawer from '@/components/search/VisitDetailDrawer'; +import SaveSearchCohortDialog, { SaveCohortMode } from '@/components/search/SaveSearchCohortDialog'; +import { toaster } from '@/components/ui/toaster'; function SearchLoading() { return ( @@ -47,6 +51,14 @@ function SearchContent() { const [localFilters, setLocalFilters] = useState({}); const [selectedVisit, setSelectedVisit] = useState(null); + // --- Cohort save dialog --- + const [saveCohortOpen, setSaveCohortOpen] = useState(false); + const [saveCohortMode, setSaveCohortMode] = useState('search'); + + // --- Selection mode --- + const [selectionMode, setSelectionMode] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + useEffect(() => { if (!authLoading && !isAuthenticated) { router.push('/login'); @@ -76,6 +88,46 @@ function SearchContent() { setFilters(filters); } + function handleToggleSelect(encounterId: string) { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(encounterId)) { + next.delete(encounterId); + } else { + next.add(encounterId); + } + return next; + }); + } + + function handleSaveSearch() { + setSaveCohortMode('search'); + setSaveCohortOpen(true); + } + + function handleSaveSelected() { + setSaveCohortMode('selected'); + setSaveCohortOpen(true); + } + + function handleCohortSaved(cohort: Cohort) { + toaster.create({ + title: 'Cohort saved', + description: `"${cohort.name}" created with ${cohort.visitCount.toLocaleString()} encounters.`, + type: 'success', + duration: 4000, + }); + setSelectionMode(false); + setSelectedIds(new Set()); + } + + function exitSelectionMode() { + setSelectionMode(false); + setSelectedIds(new Set()); + } + + const showToolbar = hasSearched && !loading && pagination.totalCount > 0; + return ( {/* Hero search bar */} @@ -113,12 +165,69 @@ function SearchContent() { + {/* Cohort toolbar */} + {showToolbar && ( + + {!selectionMode ? ( + <> + + + + ) : ( + <> + + + {selectedIds.size > 0 && ( + + {selectedIds.size} encounter{selectedIds.size !== 1 ? 's' : ''} selected + + )} + + )} + + )} + {hasSearched && !loading && pagination.totalCount > 0 && ( @@ -142,6 +251,17 @@ function SearchContent() { isOpen={!!selectedVisit} onClose={() => setSelectedVisit(null)} /> + + setSaveCohortOpen(false)} + onSaved={handleCohortSaved} + mode={saveCohortMode} + filters={localFilters} + query={query} + totalCount={pagination.totalCount} + encounterIds={saveCohortMode === 'selected' ? Array.from(selectedIds) : undefined} + /> ); } diff --git a/src/components/dashboard/CohortTab/CohortCard.tsx b/src/components/dashboard/CohortTab/CohortCard.tsx index 46de7d3..7c768b8 100644 --- a/src/components/dashboard/CohortTab/CohortCard.tsx +++ b/src/components/dashboard/CohortTab/CohortCard.tsx @@ -2,8 +2,10 @@ import React, { useMemo } from 'react'; import { Card, VStack, HStack, Text, Button, Badge, Box } from '@chakra-ui/react'; -import { FaTrash, FaEye, FaPencilAlt } from 'react-icons/fa'; -import { Cohort, getDetailedFilterSummary } from '@/interfaces/cohort'; +import { FaTrash, FaEye, FaPencilAlt, FaSearch } from 'react-icons/fa'; +import { Cohort, getDetailedFilterSummary, getSearchFilterCount } from '@/interfaces/cohort'; +import { VisitSearchFilters } from '@/interfaces/research'; +import { EncounterSearchFilters } from '@/interfaces/search'; import { COLORS } from '@/constants/colors'; import { COHORT_NAME_MAX_LENGTH } from '@/lib/utils/cohortValidation'; import { formatVisitSource, expandDemographic, formatDateForDisplay } from '@/lib/utils/utils'; @@ -211,9 +213,91 @@ interface CohortCardProps { onDelete: (cohortId: string) => void; } +/** + * Simple summary for search-source cohorts (flat EncounterSearchFilters). + */ +function SearchFilterSummary({ + filters, + encounterIds, + searchQuery, +}: { + filters: EncounterSearchFilters; + encounterIds?: string[] | null; + searchQuery?: string; +}) { + if (encounterIds && encounterIds.length > 0) { + return ( + + + {encounterIds.length} selected encounter{encounterIds.length !== 1 ? 's' : ''} + + + ); + } + + const activeCount = getSearchFilterCount(filters); + const badges: React.ReactNode[] = []; + + if (searchQuery) { + badges.push( + + query: {searchQuery.length > 20 ? `${searchQuery.slice(0, 20)}...` : searchQuery} + + ); + } + if (filters.department && filters.department.length > 0) { + badges.push( + + {filters.department.join(', ')} + + ); + } + if (filters.date_from || filters.date_to) { + const label = + filters.date_from && filters.date_to + ? `${filters.date_from} – ${filters.date_to}` + : filters.date_from + ? `from ${filters.date_from}` + : `to ${filters.date_to}`; + badges.push( + + {label} + + ); + } + if (filters.patient_gender && filters.patient_gender.length > 0) { + badges.push( + + {filters.patient_gender.join(', ')} + + ); + } + + return ( + + {badges.length > 0 ? ( + + {badges} + + ) : ( + + {activeCount > 0 + ? `${activeCount} filter${activeCount !== 1 ? 's' : ''} applied` + : 'No filters'} + + )} + + ); +} + export default function CohortCard({ cohort, onView, onRename, onDelete }: CohortCardProps) { - // Memoize detailed filter summary calculation - const filterDetails = useMemo(() => getDetailedFilterSummary(cohort.filters), [cohort.filters]); + const isSearchSource = cohort.source === 'search'; + + // Only compute research filter details for research-source cohorts + const filterDetails = useMemo( + () => (isSearchSource ? null : getDetailedFilterSummary(cohort.filters as VisitSearchFilters)), + [cohort.filters, isSearchSource] + ); // Truncate name for display if too long const displayName = useMemo(() => { @@ -277,31 +361,40 @@ export default function CohortCard({ cohort, onView, onRename, onDelete }: Cohor
- {/* Filter Summary - Always show all categories with labels */} - - {/* Visit Filters */} - - - {/* Patient Demographic Filters */} - - - {/* Provider Demographic Filters */} - - - {/* TODO: Add Clinical Filters section when clinical filtering is implemented */} - + {/* Filter Summary */} + {isSearchSource ? ( + + + + + Search + + + + + ) : filterDetails ? ( + + + + + + ) : null} {/* Actions */} diff --git a/src/components/dashboard/CohortTab/CreateCohortDialog.tsx b/src/components/dashboard/CohortTab/CreateCohortDialog.tsx index 863a48c..14fea7d 100644 --- a/src/components/dashboard/CohortTab/CreateCohortDialog.tsx +++ b/src/components/dashboard/CohortTab/CreateCohortDialog.tsx @@ -54,6 +54,7 @@ export default function CreateCohortDialog({ await onSave({ name: name.trim(), description: description.trim() || undefined, + source: 'research', filters, visitCount, }); diff --git a/src/components/search/ResultCard.tsx b/src/components/search/ResultCard.tsx index e5d8c44..b55a301 100644 --- a/src/components/search/ResultCard.tsx +++ b/src/components/search/ResultCard.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Box, Card, HStack, VStack, Text, Badge } from '@chakra-ui/react'; +import { Box, Card, HStack, VStack, Text, Badge, Checkbox } from '@chakra-ui/react'; import { EncounterSearchHit } from '@/interfaces/search'; import { COLORS } from '@/constants/colors'; import { @@ -15,6 +15,9 @@ import { TierBadge } from './TierBadge'; interface ResultCardProps { hit: EncounterSearchHit; onSelect?: (hit: EncounterSearchHit) => void; + selectionMode?: boolean; + isSelected?: boolean; + onToggleSelect?: (encounterId: string) => void; } function sanitizeHighlight(html: string): string { @@ -22,7 +25,13 @@ function sanitizeHighlight(html: string): string { return DOMPurify.sanitize(stripped, { ALLOWED_TAGS: ['mark'], ALLOWED_ATTR: [] }); } -export default function ResultCard({ hit, onSelect }: ResultCardProps) { +export default function ResultCard({ + hit, + onSelect, + selectionMode, + isSelected, + onToggleSelect, +}: ResultCardProps) { const visitDate = hit.visit_date ? new Date(`${hit.visit_date}T00:00:00`).toLocaleDateString('en-US', { year: 'numeric', @@ -56,176 +65,208 @@ export default function ResultCard({ hit, onSelect }: ResultCardProps) { activeCapabilities.push('Room View'); } + const handleCardClick = () => { + if (selectionMode) { + onToggleSelect?.(hit.encounter_id); + } else { + onSelect?.(hit); + } + }; + return ( onSelect?.(hit)} + onClick={handleCardClick} _hover={{ - borderColor: 'gray.400', + borderColor: isSelected ? 'blue.500' : 'gray.400', shadow: 'md', transform: COLORS.animation.cardHoverLift, }} transition="all 0.2s" > - - {/* Header: demographics + tier + date */} - - - {/* Patient */} - - - Person: - - {hit.patient_gender && ( - - {labelDemographic(GENDER_LABELS, hit.patient_gender)} - - )} - {hit.patient_race && ( - - {labelDemographic(RACE_LABELS, hit.patient_race)} - - )} - {hit.patient_ethnicity && ( - - {labelDemographic(ETHNICITY_LABELS, hit.patient_ethnicity)} - - )} - {hit.patient_year_of_birth && ( - - b. {hit.patient_year_of_birth} - - )} - - {/* Provider */} - {(hit.provider_gender || hit.provider_race) && ( + + {selectionMode && ( + + onToggleSelect?.(hit.encounter_id)} + onClick={(e: React.MouseEvent) => e.stopPropagation()} + colorPalette="blue" + size="md" + > + + + + + )} + + {/* Header: demographics + tier + date */} + + + {/* Patient */} - Provider: + Person: - {hit.provider_gender && ( - - {labelDemographic(GENDER_LABELS, hit.provider_gender)} + {hit.patient_gender && ( + + {labelDemographic(GENDER_LABELS, hit.patient_gender)} )} - {hit.provider_race && ( - - {labelDemographic(RACE_LABELS, hit.provider_race)} + {hit.patient_race && ( + + {labelDemographic(RACE_LABELS, hit.patient_race)} )} - {hit.provider_year_of_birth && ( + {hit.patient_ethnicity && ( + + {labelDemographic(ETHNICITY_LABELS, hit.patient_ethnicity)} + + )} + {hit.patient_year_of_birth && ( - b. {hit.provider_year_of_birth} + b. {hit.patient_year_of_birth} )} - )} - - - - {hit.encounter_id && ( - - #{hit.encounter_id} - - )} - {visitDate && ( - - {visitDate} - - )} - {hit.department && ( - - {hit.department} - - )} - - - - {/* Active multimodal capabilities */} - {activeCapabilities.length > 0 && ( - - {activeCapabilities.map((cap) => ( - - {cap} - - ))} + {/* Provider */} + {(hit.provider_gender || hit.provider_race) && ( + + + Provider: + + {hit.provider_gender && ( + + {labelDemographic(GENDER_LABELS, hit.provider_gender)} + + )} + {hit.provider_race && ( + + {labelDemographic(RACE_LABELS, hit.provider_race)} + + )} + {hit.provider_year_of_birth && ( + + b. {hit.provider_year_of_birth} + + )} + + )} + + + + {hit.encounter_id && ( + + #{hit.encounter_id} + + )} + {visitDate && ( + + {visitDate} + + )} + {hit.department && ( + + {hit.department} + + )} + - )} - {/* Match source */} - {hit.matched_in && hit.matched_in.length > 0 && ( - - - Matched in: - - {hit.matched_in.map((source) => ( - - {source} - - ))} - - )} + {/* Active multimodal capabilities */} + {activeCapabilities.length > 0 && ( + + {activeCapabilities.map((cap) => ( + + {cap} + + ))} + + )} - {/* Clinical summary */} - {(hit.drug_count > 0 || hit.note_count > 0 || uniqueIcds.length > 0) && ( - - {hit.drug_count > 0 && - (drugHighlight ? ( - - + {/* Match source */} + {hit.matched_in && hit.matched_in.length > 0 && ( + + + Matched in: + + {hit.matched_in.map((source) => ( + + {source} - ) : ( + ))} + + )} + + {/* Clinical summary */} + {(hit.drug_count > 0 || hit.note_count > 0 || uniqueIcds.length > 0) && ( + + {hit.drug_count > 0 && + (drugHighlight ? ( + + + + ) : ( + + {hit.drug_count} drug{hit.drug_count !== 1 ? 's' : ''} + + ))} + {hit.note_count > 0 && ( - {hit.drug_count} drug{hit.drug_count !== 1 ? 's' : ''} + {hit.note_count} note{hit.note_count !== 1 ? 's' : ''} - ))} - {hit.note_count > 0 && ( - - {hit.note_count} note{hit.note_count !== 1 ? 's' : ''} - - )} - {icdHighlight ? ( - - - - - - ) : ( - visibleIcds.map((code) => ( - + )} + {icdHighlight ? ( + - {code} + - )) - )} - {extraIcds > 0 && ( - - +{extraIcds} more - - )} - - )} - + ) : ( + visibleIcds.map((code) => ( + + + {code} + + + )) + )} + {extraIcds > 0 && ( + + +{extraIcds} more + + )} + + )} + + ); diff --git a/src/components/search/ResultsList.tsx b/src/components/search/ResultsList.tsx index 830068a..b9a2c54 100644 --- a/src/components/search/ResultsList.tsx +++ b/src/components/search/ResultsList.tsx @@ -12,6 +12,9 @@ interface ResultsListProps { hasSearched: boolean; sort?: EncounterSearchSort; onSelect?: (hit: EncounterSearchHit) => void; + selectionMode?: boolean; + selectedIds?: Set; + onToggleSelect?: (encounterId: string) => void; } function LoadingSkeleton() { @@ -31,6 +34,9 @@ export default function ResultsList({ hasSearched, sort, onSelect, + selectionMode, + selectedIds, + onToggleSelect, }: ResultsListProps) { const sortLabel = sort?.visit_date ? `sorted by date (${sort.visit_date === 'asc' ? 'oldest first' : 'newest first'})` @@ -87,7 +93,14 @@ export default function ResultsList({ {results.map((hit) => ( - + ))} ); diff --git a/src/components/search/SaveSearchCohortDialog.tsx b/src/components/search/SaveSearchCohortDialog.tsx new file mode 100644 index 0000000..86be424 --- /dev/null +++ b/src/components/search/SaveSearchCohortDialog.tsx @@ -0,0 +1,219 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { Button, Input, Textarea, VStack, Text, Badge, HStack } from '@chakra-ui/react'; +import { + DialogRoot, + DialogContent, + DialogHeader, + DialogBody, + DialogFooter, + DialogTitle, + DialogCloseTrigger, +} from '@/components/ui/dialog'; +import { Field } from '@/components/ui/field'; +import { CohortCreateRequest, Cohort } from '@/interfaces/cohort'; +import { EncounterSearchFilters } from '@/interfaces/search'; +import { validateCohortName, COHORT_NAME_MAX_LENGTH } from '@/lib/utils/cohortValidation'; +import { createCohort, getCohorts } from '@/lib/utils/cohortStorage'; + +export type SaveCohortMode = 'search' | 'selected'; + +interface SaveSearchCohortDialogProps { + isOpen: boolean; + onClose: () => void; + onSaved: (cohort: Cohort) => void; + mode: SaveCohortMode; + filters: EncounterSearchFilters; + query: string; + totalCount: number; + encounterIds?: string[]; +} + +export default function SaveSearchCohortDialog({ + isOpen, + onClose, + onSaved, + mode, + filters, + query, + totalCount, + encounterIds, +}: SaveSearchCohortDialogProps) { + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [existingCohorts, setExistingCohorts] = useState([]); + + useEffect(() => { + if (isOpen) { + getCohorts().then(setExistingCohorts); + } + }, [isOpen]); + + const count = mode === 'selected' ? (encounterIds?.length ?? 0) : totalCount; + + const handleSave = async () => { + const validationError = validateCohortName(name, existingCohorts); + if (validationError) { + setError(validationError); + return; + } + + try { + setLoading(true); + setError(null); + + const request: CohortCreateRequest = { + name: name.trim(), + description: description.trim() || undefined, + source: 'search', + filters: mode === 'search' ? filters : undefined, + encounterIds: mode === 'selected' ? encounterIds : undefined, + searchQuery: query || undefined, + visitCount: count, + }; + + const cohort = await createCohort(request); + setName(''); + setDescription(''); + onSaved(cohort); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create cohort'); + } finally { + setLoading(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !loading && name.trim()) { + handleSave(); + } + }; + + const handleClose = () => { + if (!loading) { + setName(''); + setDescription(''); + setError(null); + onClose(); + } + }; + + return ( + !e.open && handleClose()}> + + + + Save as Cohort + + + + + + + {/* Context */} + + + {mode === 'selected' ? ( + <> + Saving {count.toLocaleString()} selected encounter + {count !== 1 ? 's' : ''} + + ) : ( + <> + Saving search with {count.toLocaleString()} encounter + {count !== 1 ? 's' : ''} + + )} + + {query && ( + + query: {query.length > 30 ? `${query.slice(0, 30)}...` : query} + + )} + + + {/* Name */} + + { + setName(e.target.value); + setError(null); + }} + onKeyDown={handleKeyDown} + placeholder="e.g., Diabetic patients with transcript" + disabled={loading} + maxLength={COHORT_NAME_MAX_LENGTH} + variant="outline" + padding={2} + border="1px solid" + borderColor="gray.300" + _hover={{ borderColor: 'gray.400' }} + _focus={{ borderColor: 'blue.500' }} + /> + + + {/* Description */} + +