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..1f46cf0
--- /dev/null
+++ b/src/app/search/page.tsx
@@ -0,0 +1,267 @@
+'use client';
+
+import React, { Suspense, useEffect, useState } from '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 (
+
+
+
+ );
+}
+
+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);
+
+ // --- 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');
+ }
+ }, [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);
+ }
+
+ 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 */}
+
+
+
+ Encounter Search
+
+
+ {error && (
+
+
+ {error}
+
+
+ )}
+
+
+
+ {/* Main layout */}
+
+
+
+
+
+
+
+ {/* Cohort toolbar */}
+ {showToolbar && (
+
+ {!selectionMode ? (
+ <>
+
+
+ Save Search as Cohort
+
+ setSelectionMode(true)}
+ >
+
+ Select Encounters
+
+ >
+ ) : (
+ <>
+
+
+ Save {selectedIds.size} Selected
+
+
+
+ Cancel
+
+ {selectedIds.size > 0 && (
+
+ {selectedIds.size} encounter{selectedIds.size !== 1 ? 's' : ''} selected
+
+ )}
+ >
+ )}
+
+ )}
+
+
+
+ {hasSearched && !loading && pagination.totalCount > 0 && (
+
+
+
+ )}
+
+
+
+
+ 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/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/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/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.
+
+ Retry
+
+
+
+ );
+ }
+
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 && (
-
- Clear Filters
-
- )}
+
+ 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 && (
+
+ Clear Filters
+
+ )}
+
+ )}
+
+
+
+
{/* 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..b55a301
--- /dev/null
+++ b/src/components/search/ResultCard.tsx
@@ -0,0 +1,273 @@
+'use client';
+
+import { Box, Card, HStack, VStack, Text, Badge, Checkbox } 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;
+ selectionMode?: boolean;
+ isSelected?: boolean;
+ onToggleSelect?: (encounterId: string) => 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,
+ selectionMode,
+ isSelected,
+ onToggleSelect,
+}: 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');
+ }
+
+ const handleCardClick = () => {
+ if (selectionMode) {
+ onToggleSelect?.(hit.encounter_id);
+ } else {
+ onSelect?.(hit);
+ }
+ };
+
+ return (
+
+
+
+ {selectionMode && (
+
+ onToggleSelect?.(hit.encounter_id)}
+ onClick={(e: React.MouseEvent) => e.stopPropagation()}
+ colorPalette="blue"
+ size="md"
+ >
+
+
+
+
+ )}
+
+ {/* 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..b9a2c54
--- /dev/null
+++ b/src/components/search/ResultsList.tsx
@@ -0,0 +1,107 @@
+'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;
+ selectionMode?: boolean;
+ selectedIds?: Set;
+ onToggleSelect?: (encounterId: string) => void;
+}
+
+function LoadingSkeleton() {
+ return (
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+export default function ResultsList({
+ results,
+ loading,
+ totalCount,
+ hasSearched,
+ sort,
+ onSelect,
+ selectionMode,
+ selectedIds,
+ onToggleSelect,
+}: 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/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 */}
+
+
+
+
+
+
+
+ Cancel
+
+
+ Save Cohort
+
+
+
+
+ );
+}
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/cohort.ts b/src/interfaces/cohort.ts
index 532bd86..c1de0b0 100644
--- a/src/interfaces/cohort.ts
+++ b/src/interfaces/cohort.ts
@@ -1,32 +1,46 @@
/**
- * Cohort-related TypeScript interfaces
- * Designed for future server-side API integration
+ * Cohort-related TypeScript interfaces.
+ * Supports two cohort sources:
+ * - "research": Dashboard research tab (nested VisitSearchFilters)
+ * - "search": Encounter Search page (flat EncounterSearchFilters)
*/
import { VisitSearchFilters } from './research';
+import { EncounterSearchFilters } from './search';
+
+export type CohortSource = 'research' | 'search';
+
+export type CohortFilters = VisitSearchFilters | EncounterSearchFilters;
export interface Cohort {
id: string;
name: string;
description?: string;
- filters: VisitSearchFilters;
+ source: CohortSource;
+ filters: CohortFilters;
+ encounterIds?: string[] | null;
+ encounterIdCount?: number | null;
+ searchQuery?: string;
visitCount: number;
- createdAt: string; // ISO date string for serialization
- updatedAt: string; // ISO date string
- userId?: number; // For server-side ownership
+ createdAt: string;
+ updatedAt: string;
+ userId?: number;
}
export interface CohortCreateRequest {
name: string;
description?: string;
- filters: VisitSearchFilters;
+ source: CohortSource;
+ filters?: CohortFilters;
+ encounterIds?: string[];
+ searchQuery?: string;
visitCount: number;
}
export interface CohortUpdateRequest {
name?: string;
description?: string;
- filters?: VisitSearchFilters;
+ filters?: CohortFilters;
}
export interface CohortListResponse {
@@ -98,7 +112,27 @@ function hasNonEmptyFilterValues(obj: unknown): boolean {
}
/**
- * Calculates filter summary from VisitSearchFilters
+ * Counts active filters in a flat EncounterSearchFilters object.
+ */
+export function getSearchFilterCount(filters: EncounterSearchFilters | null | undefined): number {
+ if (!filters) {
+ return 0;
+ }
+ let count = 0;
+ for (const [, value] of Object.entries(filters)) {
+ if (Array.isArray(value) && value.length > 0) {
+ count++;
+ } else if (typeof value === 'boolean' && value) {
+ count++;
+ } else if (typeof value === 'string' && value) {
+ count++;
+ }
+ }
+ return count;
+}
+
+/**
+ * Calculates filter summary from VisitSearchFilters (research source).
*/
export function getCohortFilterSummary(
filters: VisitSearchFilters | null | undefined
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;
+}
diff --git a/src/lib/utils/__tests__/cohortStorage.test.ts b/src/lib/utils/__tests__/cohortStorage.test.ts
index f80fafb..b7437ed 100644
--- a/src/lib/utils/__tests__/cohortStorage.test.ts
+++ b/src/lib/utils/__tests__/cohortStorage.test.ts
@@ -32,6 +32,48 @@ jest.mock('../../logger', () => ({
},
}));
+/**
+ * Helper to build a mock API cohort response with sensible defaults.
+ */
+function mockApiCohort(overrides: Record = {}) {
+ return {
+ id: 1,
+ name: 'Test Cohort',
+ description: 'Test description',
+ source: 'research',
+ filters: { visit: {}, person_demographics: {}, provider_demographics: {}, clinical: {} },
+ encounter_ids: null,
+ encounter_id_count: null,
+ search_query: '',
+ visit_count: 50,
+ user_id: undefined,
+ created_at: '2024-01-15T10:00:00Z',
+ updated_at: '2024-01-15T10:00:00Z',
+ ...overrides,
+ };
+}
+
+/**
+ * Expected frontend Cohort shape from the helper above.
+ */
+function expectedCohort(overrides: Record = {}) {
+ return {
+ id: '1',
+ name: 'Test Cohort',
+ description: 'Test description',
+ source: 'research',
+ filters: { visit: {}, person_demographics: {}, provider_demographics: {}, clinical: {} },
+ encounterIds: null,
+ encounterIdCount: null,
+ searchQuery: '',
+ visitCount: 50,
+ userId: undefined,
+ createdAt: '2024-01-15T10:00:00Z',
+ updatedAt: '2024-01-15T10:00:00Z',
+ ...overrides,
+ };
+}
+
describe('cohortStorage', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -39,50 +81,20 @@ describe('cohortStorage', () => {
describe('getCohorts', () => {
it('should fetch and transform cohorts successfully', async () => {
- const mockApiResponse = {
- data: {
- cohorts: [
- {
- id: 1,
- name: 'Test Cohort',
- description: 'Test description',
- filters: {
- visit: {},
- person_demographics: {},
- provider_demographics: {},
- clinical: {},
- },
- visit_count: 50,
- created_at: '2024-01-15T10:00:00Z',
- updated_at: '2024-01-15T10:00:00Z',
- },
- ],
- count: 1,
- },
- };
-
- (apiClient.get as jest.Mock).mockResolvedValueOnce(mockApiResponse);
+ (apiClient.get as jest.Mock).mockResolvedValueOnce({
+ data: { cohorts: [mockApiCohort()], count: 1 },
+ });
const result = await getCohorts();
expect(apiClient.get).toHaveBeenCalledWith('/accounts/cohorts/');
expect(result).toHaveLength(1);
- expect(result[0]).toEqual({
- id: '1',
- name: 'Test Cohort',
- description: 'Test description',
- filters: { visit: {}, person_demographics: {}, provider_demographics: {}, clinical: {} },
- visitCount: 50,
- createdAt: '2024-01-15T10:00:00Z',
- updatedAt: '2024-01-15T10:00:00Z',
- });
+ expect(result[0]).toEqual(expectedCohort());
});
it('should return empty array on error', async () => {
(apiClient.get as jest.Mock).mockRejectedValueOnce(new Error('Network error'));
-
const result = await getCohorts();
-
expect(result).toEqual([]);
});
@@ -90,95 +102,66 @@ describe('cohortStorage', () => {
(apiClient.get as jest.Mock).mockResolvedValueOnce({
data: { cohorts: [], count: 0 },
});
-
const result = await getCohorts();
-
expect(result).toEqual([]);
});
it('should transform snake_case to camelCase', async () => {
- const mockApiResponse = {
+ (apiClient.get as jest.Mock).mockResolvedValueOnce({
data: {
cohorts: [
- {
+ mockApiCohort({
id: 2,
- name: 'Cohort 2',
- description: '',
- filters: {},
visit_count: 100,
created_at: '2024-01-16T12:00:00Z',
updated_at: '2024-01-16T12:00:00Z',
- },
+ }),
],
count: 1,
},
- };
-
- (apiClient.get as jest.Mock).mockResolvedValueOnce(mockApiResponse);
+ });
const result = await getCohorts();
-
expect(result[0].visitCount).toBe(100);
expect(result[0].createdAt).toBe('2024-01-16T12:00:00Z');
- expect(result[0].updatedAt).toBe('2024-01-16T12:00:00Z');
});
});
describe('getCohort', () => {
it('should fetch and transform a single cohort', async () => {
- const mockApiResponse = {
- data: {
- id: 1,
+ (apiClient.get as jest.Mock).mockResolvedValueOnce({
+ data: mockApiCohort({
name: 'Single Cohort',
description: 'Description',
filters: { visit: { visit_type: ['Inpatient'] } },
visit_count: 75,
- created_at: '2024-01-15T10:00:00Z',
- updated_at: '2024-01-15T10:00:00Z',
- },
- };
-
- (apiClient.get as jest.Mock).mockResolvedValueOnce(mockApiResponse);
+ }),
+ });
const result = await getCohort('1');
expect(apiClient.get).toHaveBeenCalledWith('/accounts/cohorts/1/');
- expect(result).toEqual({
- id: '1',
- name: 'Single Cohort',
- description: 'Description',
- filters: { visit: { visit_type: ['Inpatient'] } },
- visitCount: 75,
- createdAt: '2024-01-15T10:00:00Z',
- updatedAt: '2024-01-15T10:00:00Z',
- });
+ expect(result).toEqual(
+ expectedCohort({
+ name: 'Single Cohort',
+ description: 'Description',
+ filters: { visit: { visit_type: ['Inpatient'] } },
+ visitCount: 75,
+ })
+ );
});
it('should return null on error', async () => {
(apiClient.get as jest.Mock).mockRejectedValueOnce(new Error('Not found'));
-
const result = await getCohort('999');
-
expect(result).toBeNull();
});
it('should handle empty description', async () => {
- const mockApiResponse = {
- data: {
- id: 1,
- name: 'No Description Cohort',
- description: null,
- filters: {},
- visit_count: 0,
- created_at: '2024-01-15T10:00:00Z',
- updated_at: '2024-01-15T10:00:00Z',
- },
- };
-
- (apiClient.get as jest.Mock).mockResolvedValueOnce(mockApiResponse);
-
+ (apiClient.get as jest.Mock).mockResolvedValueOnce({
+ data: mockApiCohort({ description: null }),
+ });
const result = await getCohort('1');
-
expect(result?.description).toBe('');
});
});
@@ -188,155 +171,149 @@ describe('cohortStorage', () => {
const requestData: CohortCreateRequest = {
name: 'New Cohort',
description: 'New description',
+ source: 'research',
filters: { visit: {}, person_demographics: {}, provider_demographics: {}, clinical: {} },
visitCount: 25,
};
- const mockApiResponse = {
- data: {
+ (apiClient.post as jest.Mock).mockResolvedValueOnce({
+ data: mockApiCohort({
id: 3,
name: 'New Cohort',
description: 'New description',
- filters: { visit: {}, person_demographics: {}, provider_demographics: {}, clinical: {} },
visit_count: 25,
created_at: '2024-01-17T14:00:00Z',
updated_at: '2024-01-17T14:00:00Z',
- },
- };
-
- (apiClient.post as jest.Mock).mockResolvedValueOnce(mockApiResponse);
+ }),
+ });
const result = await createCohort(requestData);
expect(apiClient.post).toHaveBeenCalledWith('/accounts/cohorts/', {
name: 'New Cohort',
description: 'New description',
+ source: 'research',
filters: { visit: {}, person_demographics: {}, provider_demographics: {}, clinical: {} },
visit_count: 25,
});
- expect(result).toEqual({
- id: '3',
- name: 'New Cohort',
- description: 'New description',
- filters: { visit: {}, person_demographics: {}, provider_demographics: {}, clinical: {} },
- visitCount: 25,
- createdAt: '2024-01-17T14:00:00Z',
- updatedAt: '2024-01-17T14:00:00Z',
+ expect(result).toEqual(
+ expectedCohort({
+ id: '3',
+ name: 'New Cohort',
+ description: 'New description',
+ visitCount: 25,
+ createdAt: '2024-01-17T14:00:00Z',
+ updatedAt: '2024-01-17T14:00:00Z',
+ })
+ );
+ });
+
+ it('should create search-source cohort with encounter_ids', async () => {
+ const requestData: CohortCreateRequest = {
+ name: 'Selected Encounters',
+ source: 'search',
+ encounterIds: ['1', '2', '3'],
+ searchQuery: 'diabetes',
+ visitCount: 3,
+ };
+
+ (apiClient.post as jest.Mock).mockResolvedValueOnce({
+ data: mockApiCohort({
+ id: 10,
+ name: 'Selected Encounters',
+ source: 'search',
+ filters: {},
+ encounter_ids: ['1', '2', '3'],
+ search_query: 'diabetes',
+ visit_count: 3,
+ }),
});
+
+ const result = await createCohort(requestData);
+
+ expect(apiClient.post).toHaveBeenCalledWith(
+ '/accounts/cohorts/',
+ expect.objectContaining({
+ source: 'search',
+ encounter_ids: ['1', '2', '3'],
+ search_query: 'diabetes',
+ })
+ );
+
+ expect(result.source).toBe('search');
+ expect(result.encounterIds).toEqual(['1', '2', '3']);
+ expect(result.searchQuery).toBe('diabetes');
});
it('should handle empty description', async () => {
const requestData: CohortCreateRequest = {
name: 'Minimal Cohort',
+ source: 'research',
filters: {},
visitCount: 0,
};
- const mockApiResponse = {
- data: {
+ (apiClient.post as jest.Mock).mockResolvedValueOnce({
+ data: mockApiCohort({
id: 4,
name: 'Minimal Cohort',
description: '',
filters: {},
visit_count: 0,
- created_at: '2024-01-17T14:00:00Z',
- updated_at: '2024-01-17T14:00:00Z',
- },
- };
-
- (apiClient.post as jest.Mock).mockResolvedValueOnce(mockApiResponse);
-
- const result = await createCohort(requestData);
-
- expect(apiClient.post).toHaveBeenCalledWith('/accounts/cohorts/', {
- name: 'Minimal Cohort',
- description: '',
- filters: {},
- visit_count: 0,
+ }),
});
+ const result = await createCohort(requestData);
expect(result.description).toBe('');
});
it('should throw error on API failure', async () => {
const requestData: CohortCreateRequest = {
name: 'Fail Cohort',
+ source: 'research',
filters: {},
visitCount: 0,
};
- const apiError = new Error('Validation failed');
- (apiClient.post as jest.Mock).mockRejectedValueOnce(apiError);
-
+ (apiClient.post as jest.Mock).mockRejectedValueOnce(new Error('Validation failed'));
await expect(createCohort(requestData)).rejects.toThrow('Validation failed');
});
it('should throw generic error for non-Error objects', async () => {
const requestData: CohortCreateRequest = {
name: 'Fail Cohort',
+ source: 'research',
filters: {},
visitCount: 0,
};
(apiClient.post as jest.Mock).mockRejectedValueOnce('String error');
-
await expect(createCohort(requestData)).rejects.toThrow('Failed to create cohort');
});
});
describe('updateCohort', () => {
it('should update cohort with partial data', async () => {
- const updates = {
- name: 'Updated Name',
- };
-
- const mockApiResponse = {
- data: {
- id: 1,
- name: 'Updated Name',
- description: 'Original description',
- filters: {},
- visit_count: 50,
- created_at: '2024-01-15T10:00:00Z',
- updated_at: '2024-01-18T10:00:00Z',
- },
- };
-
- (apiClient.patch as jest.Mock).mockResolvedValueOnce(mockApiResponse);
+ (apiClient.patch as jest.Mock).mockResolvedValueOnce({
+ data: mockApiCohort({ name: 'Updated Name', updated_at: '2024-01-18T10:00:00Z' }),
+ });
- const result = await updateCohort('1', updates);
+ const result = await updateCohort('1', { name: 'Updated Name' });
expect(apiClient.patch).toHaveBeenCalledWith('/accounts/cohorts/1/', {
name: 'Updated Name',
});
-
expect(result.name).toBe('Updated Name');
expect(result.updatedAt).toBe('2024-01-18T10:00:00Z');
});
it('should transform camelCase to snake_case in request', async () => {
- const updates = {
- name: 'New Name',
- description: 'New Desc',
- visitCount: 100,
- };
-
- const mockApiResponse = {
- data: {
- id: 1,
- name: 'New Name',
- description: 'New Desc',
- filters: {},
- visit_count: 100,
- created_at: '2024-01-15T10:00:00Z',
- updated_at: '2024-01-18T10:00:00Z',
- },
- };
-
- (apiClient.patch as jest.Mock).mockResolvedValueOnce(mockApiResponse);
+ (apiClient.patch as jest.Mock).mockResolvedValueOnce({
+ data: mockApiCohort({ name: 'New Name', description: 'New Desc', visit_count: 100 }),
+ });
- await updateCohort('1', updates);
+ await updateCohort('1', { name: 'New Name', description: 'New Desc', visitCount: 100 });
expect(apiClient.patch).toHaveBeenCalledWith('/accounts/cohorts/1/', {
name: 'New Name',
@@ -347,13 +324,11 @@ describe('cohortStorage', () => {
it('should throw error on API failure', async () => {
(apiClient.patch as jest.Mock).mockRejectedValueOnce(new Error('Update failed'));
-
await expect(updateCohort('1', { name: 'Fail' })).rejects.toThrow('Update failed');
});
it('should throw generic error for non-Error objects', async () => {
(apiClient.patch as jest.Mock).mockRejectedValueOnce('String error');
-
await expect(updateCohort('1', { name: 'Fail' })).rejects.toThrow('Failed to update cohort');
});
});
@@ -361,128 +336,92 @@ describe('cohortStorage', () => {
describe('deleteCohort', () => {
it('should delete cohort successfully', async () => {
(apiClient.delete as jest.Mock).mockResolvedValueOnce({});
-
await deleteCohort('1');
-
expect(apiClient.delete).toHaveBeenCalledWith('/accounts/cohorts/1/');
});
it('should throw error on API failure', async () => {
(apiClient.delete as jest.Mock).mockRejectedValueOnce(new Error('Delete failed'));
-
await expect(deleteCohort('1')).rejects.toThrow('Failed to delete cohort');
});
});
describe('duplicateCohort', () => {
it('should duplicate cohort with custom name', async () => {
- const mockApiResponse = {
- data: {
+ (apiClient.post as jest.Mock).mockResolvedValueOnce({
+ data: mockApiCohort({
id: 5,
name: 'Custom Duplicate',
- description: 'Original description',
filters: { visit: { visit_type: ['Inpatient'] } },
- visit_count: 50,
created_at: '2024-01-18T15:00:00Z',
updated_at: '2024-01-18T15:00:00Z',
- },
- };
-
- (apiClient.post as jest.Mock).mockResolvedValueOnce(mockApiResponse);
+ }),
+ });
const result = await duplicateCohort('1', 'Custom Duplicate');
expect(apiClient.post).toHaveBeenCalledWith('/accounts/cohorts/1/duplicate/', {
name: 'Custom Duplicate',
});
-
- expect(result).toEqual({
- id: '5',
- name: 'Custom Duplicate',
- description: 'Original description',
- filters: { visit: { visit_type: ['Inpatient'] } },
- visitCount: 50,
- createdAt: '2024-01-18T15:00:00Z',
- updatedAt: '2024-01-18T15:00:00Z',
- });
+ expect(result).toEqual(
+ expectedCohort({
+ id: '5',
+ name: 'Custom Duplicate',
+ filters: { visit: { visit_type: ['Inpatient'] } },
+ createdAt: '2024-01-18T15:00:00Z',
+ updatedAt: '2024-01-18T15:00:00Z',
+ })
+ );
});
it('should duplicate cohort without custom name', async () => {
- const mockApiResponse = {
- data: {
- id: 6,
- name: 'Copy of Original',
- description: 'Original description',
- filters: {},
- visit_count: 50,
- created_at: '2024-01-18T15:00:00Z',
- updated_at: '2024-01-18T15:00:00Z',
- },
- };
-
- (apiClient.post as jest.Mock).mockResolvedValueOnce(mockApiResponse);
+ (apiClient.post as jest.Mock).mockResolvedValueOnce({
+ data: mockApiCohort({ id: 6, name: 'Copy of Original' }),
+ });
const result = await duplicateCohort('1');
-
expect(apiClient.post).toHaveBeenCalledWith('/accounts/cohorts/1/duplicate/', {});
expect(result.name).toBe('Copy of Original');
});
it('should throw error on API failure', async () => {
(apiClient.post as jest.Mock).mockRejectedValueOnce(new Error('Duplicate failed'));
-
await expect(duplicateCohort('1')).rejects.toThrow('Failed to duplicate cohort');
});
});
describe('Data transformation', () => {
it('should correctly transform all snake_case fields to camelCase', async () => {
- const mockApiResponse = {
+ (apiClient.get as jest.Mock).mockResolvedValueOnce({
data: {
cohorts: [
- {
+ mockApiCohort({
id: 1,
- name: 'Test',
- description: 'Desc',
- filters: { test: 'data' },
visit_count: 42,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-02T00:00:00Z',
- },
+ }),
],
count: 1,
},
- };
-
- (apiClient.get as jest.Mock).mockResolvedValueOnce(mockApiResponse);
+ });
const result = await getCohorts();
-
expect(result[0]).toHaveProperty('visitCount');
expect(result[0]).toHaveProperty('createdAt');
expect(result[0]).toHaveProperty('updatedAt');
+ expect(result[0]).toHaveProperty('source');
+ expect(result[0]).toHaveProperty('searchQuery');
expect(result[0]).not.toHaveProperty('visit_count');
expect(result[0]).not.toHaveProperty('created_at');
expect(result[0]).not.toHaveProperty('updated_at');
});
it('should convert id to string', async () => {
- const mockApiResponse = {
- data: {
- id: 123,
- name: 'Test',
- description: '',
- filters: {},
- visit_count: 0,
- created_at: '2024-01-01T00:00:00Z',
- updated_at: '2024-01-01T00:00:00Z',
- },
- };
-
- (apiClient.get as jest.Mock).mockResolvedValueOnce(mockApiResponse);
-
+ (apiClient.get as jest.Mock).mockResolvedValueOnce({
+ data: mockApiCohort({ id: 123 }),
+ });
const result = await getCohort('123');
-
expect(result?.id).toBe('123');
expect(typeof result?.id).toBe('string');
});
@@ -493,23 +432,12 @@ describe('cohortStorage', () => {
let createElementSpy: jest.SpyInstance;
let appendChildSpy: jest.SpyInstance;
let removeChildSpy: jest.SpyInstance;
- let clickSpy: jest.SpyInstance;
beforeEach(() => {
- // Create a mock link element
- mockLink = {
- href: '',
- download: '',
- click: jest.fn(),
- } as any;
-
- // Spy on document methods
+ mockLink = { href: '', download: '', click: jest.fn() } as unknown as HTMLAnchorElement;
createElementSpy = jest.spyOn(document, 'createElement').mockReturnValue(mockLink);
appendChildSpy = jest.spyOn(document.body, 'appendChild').mockImplementation(() => mockLink);
removeChildSpy = jest.spyOn(document.body, 'removeChild').mockImplementation(() => mockLink);
- clickSpy = mockLink.click as jest.Mock;
-
- // Mock URL methods
global.URL.createObjectURL = jest.fn(() => 'blob:mock-url');
global.URL.revokeObjectURL = jest.fn();
});
@@ -525,6 +453,7 @@ describe('cohortStorage', () => {
id: '1',
name: 'Test Cohort',
description: 'Test description',
+ source: 'research',
filters: { visit: {}, person_demographics: {}, provider_demographics: {}, clinical: {} },
visitCount: 50,
createdAt: '2024-01-15T10:00:00Z',
@@ -532,20 +461,11 @@ describe('cohortStorage', () => {
};
exportCohortToJSON(cohort);
-
- // Verify link was created
expect(createElementSpy).toHaveBeenCalledWith('a');
-
- // Verify link properties
expect(mockLink.href).toBe('blob:mock-url');
expect(mockLink.download).toMatch(/^cohort-test-cohort-\d+\.json$/);
-
- // Verify link was added to DOM, clicked, and removed
expect(appendChildSpy).toHaveBeenCalledWith(mockLink);
- expect(clickSpy).toHaveBeenCalled();
expect(removeChildSpy).toHaveBeenCalledWith(mockLink);
-
- // Verify URL was revoked
expect(global.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
});
@@ -554,6 +474,7 @@ describe('cohortStorage', () => {
id: '1',
name: 'My Test Cohort With Spaces',
description: '',
+ source: 'research',
filters: {},
visitCount: 0,
createdAt: '2024-01-15T10:00:00Z',
@@ -561,57 +482,7 @@ describe('cohortStorage', () => {
};
exportCohortToJSON(cohort);
-
expect(mockLink.download).toMatch(/^cohort-my-test-cohort-with-spaces-\d+\.json$/);
});
-
- it('should create properly formatted JSON blob', () => {
- const cohort: Cohort = {
- id: '1',
- name: 'Export Test',
- description: 'Export description',
- filters: { visit: { visit_source_value: ['Inpatient'] } },
- visitCount: 25,
- createdAt: '2024-01-15T10:00:00Z',
- updatedAt: '2024-01-15T10:00:00Z',
- };
-
- // Mock Blob constructor to capture the data
- const originalBlob = global.Blob;
- const blobData: any[] = [];
- global.Blob = jest.fn((data, options) => {
- blobData.push(...data);
- return new originalBlob(data, options);
- }) as any;
-
- exportCohortToJSON(cohort);
-
- // Verify JSON is properly formatted
- const jsonString = blobData[0];
- const parsed = JSON.parse(jsonString);
-
- expect(parsed).toEqual(cohort);
- expect(jsonString).toContain('\n'); // Should be pretty-printed with indentation
-
- // Restore original Blob
- global.Blob = originalBlob;
- });
-
- it('should handle cohorts with special characters in name', () => {
- const cohort: Cohort = {
- id: '1',
- name: 'Test/Cohort:Name*With?Special|Chars',
- description: '',
- filters: {},
- visitCount: 0,
- createdAt: '2024-01-15T10:00:00Z',
- updatedAt: '2024-01-15T10:00:00Z',
- };
-
- exportCohortToJSON(cohort);
-
- // Filename should still be generated (characters replaced)
- expect(mockLink.download).toMatch(/\.json$/);
- });
});
});
diff --git a/src/lib/utils/cohortStorage.ts b/src/lib/utils/cohortStorage.ts
index 94e7e0e..b1b1f00 100644
--- a/src/lib/utils/cohortStorage.ts
+++ b/src/lib/utils/cohortStorage.ts
@@ -3,10 +3,32 @@
* Uses Observer backend API for persistent storage
*/
-import { Cohort, CohortCreateRequest } from '@/interfaces/cohort';
+import { Cohort, CohortCreateRequest, CohortSource } from '@/interfaces/cohort';
import { apiClient } from '../apiClient';
import { logger } from '../logger';
+
+
+/**
+ * Map a raw API cohort object to the frontend Cohort interface.
+ */
+function mapCohort(c: any): Cohort {
+ return {
+ id: c.id.toString(),
+ name: c.name,
+ description: c.description || '',
+ source: (c.source as CohortSource) || 'research',
+ filters: c.filters,
+ encounterIds: c.encounter_ids ?? null,
+ encounterIdCount: c.encounter_id_count ?? null,
+ searchQuery: c.search_query || '',
+ visitCount: c.visit_count,
+ createdAt: c.created_at,
+ updatedAt: c.updated_at,
+ userId: c.user_id,
+ };
+}
+
/**
* Get all cohorts for the current user
* GET /api/v1/accounts/cohorts/
@@ -14,20 +36,8 @@ import { logger } from '../logger';
export async function getCohorts(): Promise {
try {
const response = await apiClient.get('/accounts/cohorts/');
-
- // API returns { cohorts: Cohort[], count: number }
const cohorts = response.data.cohorts || [];
-
- // Transform API response to match frontend interface
- return cohorts.map((cohort: any) => ({
- id: cohort.id.toString(),
- name: cohort.name,
- description: cohort.description || '',
- filters: cohort.filters,
- visitCount: cohort.visit_count,
- createdAt: cohort.created_at,
- updatedAt: cohort.updated_at,
- }));
+ return cohorts.map(mapCohort);
} catch (error) {
logger.error('Failed to load cohorts:', error);
return [];
@@ -41,18 +51,7 @@ export async function getCohorts(): Promise {
export async function getCohort(id: string): Promise {
try {
const response = await apiClient.get(`/accounts/cohorts/${id}/`);
- const cohort = response.data;
-
- // Transform API response to match frontend interface
- return {
- id: cohort.id.toString(),
- name: cohort.name,
- description: cohort.description || '',
- filters: cohort.filters,
- visitCount: cohort.visit_count,
- createdAt: cohort.created_at,
- updatedAt: cohort.updated_at,
- };
+ return mapCohort(response.data);
} catch (error) {
logger.error('Failed to load cohort:', error);
return null;
@@ -65,31 +64,26 @@ export async function getCohort(id: string): Promise {
*/
export async function createCohort(data: CohortCreateRequest): Promise {
try {
- // Transform frontend request to API format
- const payload = {
+ const payload: Record = {
name: data.name,
description: data.description || '',
- filters: data.filters,
+ source: data.source,
+ filters: data.filters || {},
visit_count: data.visitCount,
};
+ if (data.encounterIds) {
+ payload.encounter_ids = data.encounterIds;
+ }
+ if (data.searchQuery) {
+ payload.search_query = data.searchQuery;
+ }
const response = await apiClient.post('/accounts/cohorts/', payload);
- const cohort = response.data;
-
- // Transform API response to match frontend interface
- return {
- id: cohort.id.toString(),
- name: cohort.name,
- description: cohort.description || '',
- filters: cohort.filters,
- visitCount: cohort.visit_count,
- createdAt: cohort.created_at,
- updatedAt: cohort.updated_at,
- };
+ return mapCohort(response.data);
} catch (error) {
logger.error('Failed to create cohort:', error);
if (error instanceof Error) {
- throw error; // Preserve original error message
+ throw error;
}
throw new Error('Failed to create cohort');
}
@@ -101,8 +95,7 @@ export async function createCohort(data: CohortCreateRequest): Promise {
*/
export async function updateCohort(id: string, updates: Partial): Promise {
try {
- // Transform frontend updates to API format
- const payload: any = {};
+ const payload: Record = {};
if (updates.name !== undefined) {
payload.name = updates.name;
}
@@ -117,18 +110,7 @@ export async function updateCohort(id: string, updates: Partial): Promis
}
const response = await apiClient.patch(`/accounts/cohorts/${id}/`, payload);
- const cohort = response.data;
-
- // Transform API response to match frontend interface
- return {
- id: cohort.id.toString(),
- name: cohort.name,
- description: cohort.description || '',
- filters: cohort.filters,
- visitCount: cohort.visit_count,
- createdAt: cohort.created_at,
- updatedAt: cohort.updated_at,
- };
+ return mapCohort(response.data);
} catch (error) {
logger.error('Failed to update cohort:', error);
if (error instanceof Error) {
@@ -159,18 +141,7 @@ export async function duplicateCohort(id: string, newName?: string): Promise