import { Fragment, useMemo, useState } from 'react' import { useNavigate, useLocation, useSearchParams } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' import { Dialog, Transition } from '@headlessui/react' import { ArrowLeftIcon, MagnifyingGlassIcon, XMarkIcon, ChartBarIcon, ArrowRightIcon, } from '@heroicons/react/24/outline' import { fetchPolicyQuestions, fetchPolicyQuestion, fetchQuestionMeetings, type PolicyQuestionSummary, type PolicyQuestionDetail, type QuestionTrendPoint, type QuestionMeeting, } from '../api/policyQuestions' import DecisionCardList from '../components/DecisionCardList' // ──────────────────────────────────────────────────────────────────────────── // Questions That Keep Coming Up — restyled to match Browse Topics / Causes: // a row of question pills scopes a DecisionCardList (the shared StoryCard grid, // with its search bar + advanced filters) shown immediately at the top — no // click-into-a-separate-view. Picking a question also surfaces a compact detail // panel with its REAL stats (approval, money & talk, arguments, trend). // // EVERY figure is REAL (CLAUDE.md: No Fabricated Data): // • approval, theme, instances → /api/policy-question (rollup mart) // • Money & Talk bars + tags → money_total / money_share / talk_share // • "by quarter" trend → /api/policy-question/{id}.trend // • case for / against, instances → question detail (arguments + instances) // ──────────────────────────────────────────────────────────────────────────── // How many question pills to surface inline before the rest move to the flyout. // The API returns featured questions ordered by display_order, so the first few // are the most prominent. const TOP_PILL_COUNT = 5 const SPEND_COLOR = '#0d9488' // teal — money const TALK_COLOR = '#d97706' // amber — talk / how often const fmtMoney = (v: number) => v >= 1e9 ? `$${(v / 1e9).toFixed(1)}B` : v >= 1e6 ? `$${(v / 1e6).toFixed(1)}M` : v >= 1e3 ? `$${Math.round(v / 1e3)}K` : `$${Math.round(v)}` // "2024-04-01" → "Q2'24" const quarterLabel = (iso: string) => { const d = new Date(iso) const q = Math.floor(d.getUTCMonth() / 3) + 1 return `Q${q}'${String(d.getUTCFullYear()).slice(2)}` } // Show the full question text in the chip; the title attribute mirrors it. const pillLabel = (q: PolicyQuestionSummary) => { return (q.canonical_text || 'Untitled question').trim() } const approvalRate = (q: PolicyQuestionSummary) => q.jurisdictions_total > 0 ? (q.jurisdictions_approved / q.jurisdictions_total) * 100 : null export default function PolicyQuestionsPage() { const navigate = useNavigate() const routerLocation = useLocation() // Place filter carried over from the homepage (e.g. ?state=GA&city=Atlanta) — // passed straight through to the scoped MeetingCardList, like Browse Topics. const [searchParams] = useSearchParams() const stateCode = (searchParams.get('state') || '').trim().toUpperCase() || undefined const cityName = (searchParams.get('city') || '').trim() || undefined // Picking a question SCOPES the meeting cards shown below — it no longer drills // into a separate accordion view. The cards stay at the top the whole time. const [selectedQuestion, setSelectedQuestion] = useState(null) // The full question catalog + keyword search lives in a slide-over flyout so // the main view stays focused on the top handful of questions. const [flyoutOpen, setFlyoutOpen] = useState(false) const [query, setQuery] = useState('') // Go back to wherever the user came from; fall back to the home page when // this is the first in-app view (direct link / refresh). const handleBack = () => { if (routerLocation.key !== 'default') { navigate(-1) } else { navigate('/') } } // For now we focus the whole site on the curated/pinned "big questions" only, // so this registry lists the featured set. Swap to { limit: 200 } for the full // clustered registry. const { data, isLoading, isError, error } = useQuery({ queryKey: ['policy-questions-registry', 'featured'], queryFn: () => fetchPolicyQuestions({ featured: true }), }) const questions = useMemo(() => data ?? [], [data]) // Full catalog filtered by the flyout keyword search (text or theme). const filtered = useMemo(() => { const q = query.trim().toLowerCase() if (!q) return questions return questions.filter((item) => { if ((item.canonical_text ?? '').toLowerCase().includes(q)) return true return (item.primary_theme ?? '').toLowerCase().includes(q) }) }, [questions, query]) // Inline pills: the top N featured questions, plus the active one if the user // picked something further down the list via the flyout. const topPills = useMemo(() => { const top = questions.slice(0, TOP_PILL_COUNT) if (selectedQuestion && !top.some((q) => q.question_id === selectedQuestion.question_id)) { return [...top, selectedQuestion] } return top }, [questions, selectedQuestion]) // Pill styling for the question filter row — solid indigo when active. const chipClass = (on: boolean) => `inline-flex max-w-full shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors ${ on ? 'border-indigo-600 bg-indigo-600 text-white' : 'border-gray-200 bg-white text-gray-700 hover:border-indigo-300 hover:text-indigo-700' }` const pickQuestion = (q: PolicyQuestionSummary | null) => { setSelectedQuestion(q) setFlyoutOpen(false) } const listTitle = selectedQuestion ? 'Decisions on this question' : `Most contested decisions${stateCode ? ` · ${stateCode}` : ''}` return (
{/* Header card — title + description; the full question search lives in the flyout, matching Browse Topics. */}

Browse Questions{stateCode ? ` · ${stateCode}` : ''}

{/* Top questions inline — pick one to scope the meeting cards below. The rest of the catalog + keyword search live in the "More questions" flyout. */} {isError ? (
Couldn't load questions.{' '} {(error as { message?: string } | undefined)?.message ?? 'Please try again.'}
) : (
{isLoading ? ( Loading questions… ) : ( topPills.map((q) => { const on = selectedQuestion?.question_id === q.question_id return ( ) }) )}
)}
{/* Selected-question detail panel — REAL stats only. Hidden on "All". */} {selectedQuestion && } {/* Decision cards — the shared Contested StoryCard grid with YouTube previews, search bar, and advanced filters (matching Browse Topics). Picking a question scopes this list; the `key` remounts it so the new scope applies cleanly. */} Question } /> {/* Transcript fallback — real meetings whose transcript discusses this question's alias keywords. Many questions (e.g. water fluoridation) have zero structured decisions but are debated in real meetings; this surfaces them honestly (keyword match, NOT an AI-extracted decision). */} {selectedQuestion && ( )}
{/* Filter flyout — full question catalog + keyword search, slid in from the right. Drilldowns (selecting any question) happen here too. */} setFlyoutOpen(false)}>
All questions
setQuery(e.target.value)} placeholder="Filter questions or themes (e.g. housing, zoning)…" className="w-full rounded-lg border-2 border-gray-300 px-10 py-2 text-sm text-gray-900 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-indigo-500" />
{filtered.map((q) => { const on = selectedQuestion?.question_id === q.question_id const r = approvalRate(q) return ( ) })} {filtered.length === 0 && ( No questions match “{query.trim()}”. )}
) } // ── Selected-question summary card ── // A compact, attractive header (approval + theme + instances + headline money/ // talk figures) with a single drill-down button that opens the full dashboard // in a modal — instead of dumping every chart inline at the top of the page. function QuestionDetailPanel({ question }: { question: PolicyQuestionSummary }) { const [dashboardOpen, setDashboardOpen] = useState(false) const r = approvalRate(question) return ( <>

{question.canonical_text || 'Untitled question'}

{r != null && ( {question.jurisdictions_approved}/{question.jurisdictions_total} approved ({r.toFixed(0)}%) )} {question.primary_theme && question.primary_theme !== '__unthemed__' && ( {question.primary_theme} )} · {question.instances_total} {question.instances_total === 1 ? 'instance' : 'instances'} · {fmtMoney(question.money_total ?? 0)} moved {(question.discussion_meeting_count ?? 0) > 0 && ( <> · discussed in {(question.discussion_meeting_count ?? 0).toLocaleString()} meetings )}
{/* Attractive drill-down — opens the full money & talk dashboard. */}
setDashboardOpen(false)} /> ) } // ── Full question dashboard, rendered in a drill-down modal ── // Reuses the summary's already-loaded shares for the bars, and lazily fetches // the full detail (arguments, trend, recent instances). All REAL data. function QuestionDashboardModal({ question, open, onClose, }: { question: PolicyQuestionSummary open: boolean onClose: () => void }) { const { data: detail, isLoading } = useQuery({ queryKey: ['policy-question-detail', question.question_id], queryFn: () => fetchPolicyQuestion(question.question_id), staleTime: 5 * 60 * 1000, enabled: open, }) const r = approvalRate(question) const mShare = question.money_share ?? 0 const tShare = question.talk_share ?? 0 const maxS = Math.max(mShare, tShare, 1) const pros = detail?.arguments.filter((a) => a.stance === 'pro') ?? [] const cons = detail?.arguments.filter((a) => a.stance === 'con') ?? [] return (
{question.canonical_text || 'Untitled question'}
{r != null && ( {question.jurisdictions_approved}/{question.jurisdictions_total} approved ({r.toFixed(0)}%) )} {question.primary_theme && question.primary_theme !== '__unthemed__' && ( {question.primary_theme} )} · {question.instances_total} {question.instances_total === 1 ? 'instance' : 'instances'}
{/* Money & talk — REAL shares of all decisions */}
Money & talk · share of all decisions {fmtMoney(question.money_total ?? 0)} moved · came up {question.instances_total}×
Money
{mShare.toFixed(1)}% Talk
{tShare.toFixed(1)}%
{isLoading ? (
) : ( detail && ( <> {/* Arguments for / against */} {(pros.length > 0 || cons.length > 0) && (
The case for
{pros.length ? ( pros.map((a) => (

— {a.label || a.summary}

)) ) : (

No arguments captured yet.

)}
The case against
{cons.length ? ( cons.map((a) => (

— {a.label || a.summary}

)) ) : (

No arguments captured yet.

)}
)} {/* By quarter — real money line + instance bars */} {detail.trend.length > 0 && (
By quarter money how often
)} {/* Recent instances — real decisions/bills that instantiate the question */} {detail.sample_instances.length > 0 && (
Recent instances
{detail.sample_instances.slice(0, 6).map((ex) => (
{[ex.city || ex.jurisdiction_name, ex.state_code].filter(Boolean).join(', ') || 'Unknown jurisdiction'} {ex.occurred_at && ( {new Date(ex.occurred_at).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} )}
{ex.outcome_normalized && ( {ex.outcome_normalized} )}
))}
)} ) )}
) } // ── "Discussed in N meetings" — transcript-keyword fallback ── // Real meetings whose transcript matched this question's alias keyword(s) // (dbt question_transcript_link via /api/policy-question/{id}/meetings). This is // a HIGH-RECALL keyword signal, NOT an AI-extracted decision — labeled as such so // it's never mistaken for the structured decision list above. Every row is a real // meeting (CLAUDE.md: No Fabricated Data); empty → the section hides itself. const MEETING_PAGE = 24 function QuestionMeetingList({ question, state, }: { question: PolicyQuestionSummary state?: string }) { const [expanded, setExpanded] = useState(false) const total = question.discussion_meeting_count ?? 0 const { data, isLoading } = useQuery({ queryKey: ['policy-question-meetings', question.question_id], queryFn: () => fetchQuestionMeetings(question.question_id, 200, 0), staleTime: 5 * 60 * 1000, enabled: total > 0, }) // Optional place scoping carried over from the homepage (?state=…). const meetings = useMemo(() => { const all = data ?? [] return state ? all.filter((m) => (m.state_code ?? '').toUpperCase() === state) : all }, [data, state]) if (total === 0) return null const shown = expanded ? meetings : meetings.slice(0, MEETING_PAGE) return (

Discussed in meetings {meetings.length.toLocaleString()} meeting{meetings.length === 1 ? '' : 's'} mention it

Real meetings whose transcript mentions this topic (keyword match) — not yet captured as a formal decision.

{isLoading ? (
Loading meetings…
) : meetings.length === 0 ? (
No meetings here yet{state ? ` for ${state}` : ''}.
) : ( <>
{shown.map((m) => { const place = [m.city || m.jurisdiction_name, m.state_code].filter(Boolean).join(', ') const date = m.event_date ? new Date(m.event_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', }) : null const card = ( <>
{place || 'Unknown place'} {date && {date}}

{m.event_title || 'Untitled meeting'}

) return m.video_url ? ( {card} ) : (
{card}
) })}
{meetings.length > MEETING_PAGE && ( )} )}
) } // Compact money line + per-quarter instance bars over the real trend points. function Trend({ points }: { points: QuestionTrendPoint[] }) { const w = 560 const h = 70 const pad = 4 const mMax = Math.max(...points.map((p) => p.money), 1) * 1.15 const px = (i: number) => pad + (points.length <= 1 ? 0 : (i / (points.length - 1)) * (w - pad * 2)) const py = (v: number) => h - pad - (v / mMax) * (h - pad * 2) const path = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${py(p.money).toFixed(1)}`).join('') const maxInst = Math.max(...points.map((p) => p.instances), 1) const anyMoney = points.some((p) => p.money > 0) return (
{anyMoney ? ( ) : (
No dollar impact recorded for this question (e.g. bills, or non-financial votes).
)}
{points.map((p) => (
0 ? `, ${fmtMoney(p.money)}` : ''}`} /> ))}
{quarterLabel(points[0].quarter_start)} amber bars: how often it came up {quarterLabel(points[points.length - 1].quarter_start)}
) }