open-navigator / web_app /src /components /MeetingCardList.tsx
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
10.7 kB
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 (
<div className="rounded-lg border border-gray-200 bg-white shadow-sm transition hover:border-indigo-200">
<button
type="button"
onClick={() => canDrill && setOpen((v) => !v)}
aria-expanded={canDrill ? open : undefined}
className={`flex w-full items-start gap-3 p-5 text-left ${
canDrill ? 'cursor-pointer' : 'cursor-default'
}`}
>
{canDrill ? (
open ? (
<ChevronDownIcon className="mt-0.5 h-5 w-5 flex-shrink-0 text-indigo-500" />
) : (
<ChevronRightIcon className="mt-0.5 h-5 w-5 flex-shrink-0 text-gray-400" />
)
) : (
<span className="mt-0.5 h-5 w-5 flex-shrink-0" aria-hidden />
)}
{/* YouTube meeting preview — addressed by video id off the ytimg CDN.
Renders nothing when the meeting has no recording. */}
{meeting.video_id && (
<div className="w-32 shrink-0 self-start sm:w-48">
<MeetingThumbnail
videoId={meeting.video_id}
alt={meeting.title ?? undefined}
className="rounded-lg"
/>
</div>
)}
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-gray-900">{meeting.title || 'Untitled meeting'}</h3>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500">
{placeLabel(meeting) && (
<span className="inline-flex items-center gap-1">
<MapPinIcon className="h-3.5 w-3.5" />
{placeLabel(meeting)}
</span>
)}
{date && <span>{date}</span>}
</div>
{/* Real counts — 0 is honest, not hidden. */}
<div className="mt-2 flex flex-wrap gap-2">
<span
className={`rounded-full px-2.5 py-1 text-xs font-medium ${
meeting.decision_count > 0
? 'bg-indigo-50 text-indigo-700'
: 'bg-gray-100 text-gray-500'
}`}
>
{meeting.decision_count} {meeting.decision_count === 1 ? 'decision' : 'decisions'}
</span>
{meeting.question_count > 0 && (
<span className="rounded-full bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-700">
{meeting.question_count} {meeting.question_count === 1 ? 'question' : 'questions'}
</span>
)}
</div>
</div>
</button>
{/* Inline drill-down: this meeting's decisions. */}
{canDrill && open && (
<div className="border-t border-gray-100 bg-gray-50/60 p-5">
<DecisionCardList meetingId={meeting.meeting_id} />
</div>
)}
</div>
)
}
export default function MeetingCardList({
topicId,
theme,
questionId,
state,
city,
title,
}: MeetingCardListProps) {
const [rawQuery, setRawQuery] = useState('')
const [debouncedQuery, setDebouncedQuery] = useState('')
const [sort, setSort] = useState<MeetingSort>('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 (
<div>
{/* Heading + real total count */}
<div className="mb-4 flex flex-wrap items-baseline justify-between gap-2">
{title ? <h2 className="text-xl font-bold text-gray-900">{title}</h2> : <span />}
{!isLoading && !isError && (
<span className="text-sm text-gray-500">
{total.toLocaleString()} {total === 1 ? 'meeting' : 'meetings'}
</span>
)}
</div>
{/* Search bar + sort filters */}
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={rawQuery}
onChange={(e) => 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 && (
<button
type="button"
onClick={() => setRawQuery('')}
aria-label="Clear search"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<XMarkIcon className="h-5 w-5" />
</button>
)}
</div>
<div className="inline-flex shrink-0 rounded-full border border-gray-200 bg-white p-1">
{SORTS.map((s) => {
const on = sort === s.id
return (
<button
key={s.id}
type="button"
onClick={() => setSort(s.id)}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition-colors ${
on ? 'bg-indigo-600 text-white shadow-sm' : 'text-gray-600 hover:text-gray-900'
}`}
>
{s.label}
</button>
)
})}
</div>
</div>
{/* Body — honest loading / error / empty / list states */}
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-24 animate-pulse rounded-lg border border-gray-200 bg-gray-100" />
))}
</div>
) : isError ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center text-sm text-red-700">
Couldn&apos;t load meetings. Please try again.
</div>
) : meetings.length === 0 ? (
<div className="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center text-sm text-gray-500">
{debouncedQuery ? `No meetings match “${debouncedQuery}”.` : 'No meetings here yet.'}
</div>
) : (
<>
<div className={`space-y-3 transition-opacity ${isFetching ? 'opacity-60' : ''}`}>
{meetings.map((m) => (
<MeetingRow key={m.meeting_id} meeting={m} />
))}
</div>
{totalPages > 1 && (
<div className="mt-8 flex items-center justify-center gap-3">
<button
type="button"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:text-gray-300"
>
Previous
</button>
<span className="text-sm text-gray-500">
Page {page + 1} of {totalPages.toLocaleString()}
</span>
<button
type="button"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:text-gray-300"
>
Next
</button>
</div>
)}
</>
)}
</div>
)
}