import { useEffect, useMemo, useState } from 'react' import { useQuery, keepPreviousData } from '@tanstack/react-query' import { MagnifyingGlassIcon, XMarkIcon, ChevronDownIcon, ChevronRightIcon, MapPinIcon, } from '@heroicons/react/24/outline' import { fetchMeetings, type MeetingSort, type MeetingCard } from '../api/meetings' import DecisionCardList from './DecisionCardList' import MeetingThumbnail from './MeetingThumbnail' /** * MeetingCardList — a reusable, meeting-grain browser. * * Backed by `GET /api/meetings`, where meetings are linked to a topic / policy * question through their transcript. Each card shows the meeting's real decision * and question counts; a meeting with decisions expands inline into its own * decision cards (DecisionCardList scoped by meetingId). * * 100% live data — honest loading / error / empty states, never fabricated * cards or counts (CLAUDE.md: No Fabricated Data). */ const PAGE_SIZE = 24 const SORTS: { id: MeetingSort; label: string }[] = [ { id: 'recent', label: 'Most recent' }, { id: 'decisions', label: 'Most decisions' }, { id: 'interesting', label: 'Most interesting' }, ] interface MeetingCardListProps { topicId?: number theme?: string questionId?: string /** 2-letter state code or full state name. */ state?: string city?: string /** Heading shown above the list, e.g. "Meetings on Affordable housing". */ title?: string } // A clean place label from the card's jurisdiction + state. function placeLabel(m: MeetingCard): string { const place = m.city || m.jurisdiction || '' return [place, m.state_code].filter(Boolean).join(', ') } // Friendly date from an ISO yyyy-mm-dd string (the API already coerces). function fmtDate(iso: string | null): string { if (!iso) return '' const d = new Date(`${iso}T00:00:00`) if (Number.isNaN(d.getTime())) return '' return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) } function MeetingRow({ meeting }: { meeting: MeetingCard }) { const [open, setOpen] = useState(false) const canDrill = meeting.has_decisions && meeting.decision_count > 0 const date = fmtDate(meeting.date) return (
{/* Inline drill-down: this meeting's decisions. */} {canDrill && open && (
)}
) } export default function MeetingCardList({ topicId, theme, questionId, state, city, title, }: MeetingCardListProps) { const [rawQuery, setRawQuery] = useState('') const [debouncedQuery, setDebouncedQuery] = useState('') const [sort, setSort] = useState('recent') const [page, setPage] = useState(0) // Debounce the search box (~300ms) and reset to the first page on a new term. useEffect(() => { const t = setTimeout(() => { setDebouncedQuery(rawQuery.trim()) setPage(0) }, 300) return () => clearTimeout(t) }, [rawQuery]) // Reset to the first page when the scope or sort changes. useEffect(() => { setPage(0) }, [topicId, theme, questionId, state, city, sort]) const q = debouncedQuery || undefined const { data, isLoading, isError, isFetching } = useQuery({ queryKey: [ 'meetings', topicId ?? null, theme ?? null, questionId ?? null, state ?? null, city ?? null, q ?? null, sort, page, ], queryFn: () => fetchMeetings({ topicId, theme, questionId, state, city, q, sort, limit: PAGE_SIZE, offset: page * PAGE_SIZE, }), placeholderData: keepPreviousData, }) const meetings = useMemo(() => data?.items ?? [], [data]) const total = data?.pagination.total ?? 0 const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) return (
{/* Heading + real total count */}
{title ?

{title}

: } {!isLoading && !isError && ( {total.toLocaleString()} {total === 1 ? 'meeting' : 'meetings'} )}
{/* Search bar + sort filters */}
setRawQuery(e.target.value)} placeholder="Search these meetings…" className="w-full rounded-full border border-gray-300 bg-white py-2.5 pl-10 pr-10 text-sm focus:border-transparent focus:outline-none focus:ring-2 focus:ring-indigo-500" /> {rawQuery && ( )}
{SORTS.map((s) => { const on = sort === s.id return ( ) })}
{/* Body — honest loading / error / empty / list states */} {isLoading ? (
{Array.from({ length: 6 }).map((_, i) => (
))}
) : isError ? (
Couldn't load meetings. Please try again.
) : meetings.length === 0 ? (
{debouncedQuery ? `No meetings match “${debouncedQuery}”.` : 'No meetings here yet.'}
) : ( <>
{meetings.map((m) => ( ))}
{totalPages > 1 && (
Page {page + 1} of {totalPages.toLocaleString()}
)} )}
) }