open-navigator / frontend /src /pages /PolicyMap.tsx
jcbowyer's picture
Deploy: Consolidated gold tables, fixed nginx docs routing
5820798 verified
import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useSearchParams } from 'react-router-dom'
import api from '../lib/api'
import { MagnifyingGlassIcon, MapIcon as MapIconOutline, ListBulletIcon, AdjustmentsHorizontalIcon, XMarkIcon } from '@heroicons/react/24/outline'
import USMap from '../components/USMap'
import MultiSelect from '../components/MultiSelect'
interface Bill {
bill_id: string
bill_number: string
title: string
classification: string[]
session: string
session_name: string
first_action_date: string
latest_action_date: string
latest_action: string
latest_action_description: string
jurisdiction: string
jurisdiction_name: string
}
interface Session {
session: string
session_name: string
start_date: string
end_date: string
bill_count: number
}
interface StateData {
state: string
total_bills: number
type_counts: {
ban: number
restriction: number
protection: number
other: number
}
status_counts: {
enacted: number
failed: number
pending: number
}
primary_type: string
primary_status: string
map_category: string
}
export default function PolicyMap() {
const [searchParams, setSearchParams] = useSearchParams()
const [viewMode, setViewMode] = useState<'map' | 'list'>('map')
const [selectedState, setSelectedState] = useState('AL')
const [selectedSession, setSelectedSession] = useState<string>('')
const [searchQuery, setSearchQuery] = useState('')
const [sortBy, setSortBy] = useState<'date' | 'name'>('date')
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc')
const [expandedBill, setExpandedBill] = useState<string | null>(null)
// Multi-select filter states
const [selectedSessions, setSelectedSessions] = useState<string[]>([])
const [selectedChambers, setSelectedChambers] = useState<string[]>([])
const [selectedBillTypes, setSelectedBillTypes] = useState<string[]>([])
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([])
// Read topic from URL - use state that syncs with URL
const [selectedTopic, setSelectedTopic] = useState<string>('')
const [showTopicSelector, setShowTopicSelector] = useState(true)
// Advanced filters sidebar state
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false)
// Sync topic FROM URL to state (on mount and URL changes)
useEffect(() => {
const topicFromUrl = searchParams.get('topic') || ''
if (topicFromUrl) {
setSelectedTopic(topicFromUrl)
setShowTopicSelector(false)
console.log('🔗 Initialized topic from URL:', topicFromUrl)
} else {
setSelectedTopic('')
setShowTopicSelector(true)
}
}, [searchParams]) // Re-run when URL changes
// Sync topic changes TO URL (when user selects a topic)
// IMPORTANT: Don't include searchParams in deps to avoid circular updates
useEffect(() => {
const currentTopicInUrl = searchParams.get('topic') || ''
if (selectedTopic && !showTopicSelector) {
// Only update URL if topic is different
if (currentTopicInUrl !== selectedTopic) {
setSearchParams({ topic: selectedTopic }, { replace: true })
console.log('📝 Updated URL with topic:', selectedTopic)
}
} else if (showTopicSelector && currentTopicInUrl) {
// Clear topic from URL if selector is shown
setSearchParams({}, { replace: true })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedTopic, showTopicSelector, setSearchParams])
const [page, setPage] = useState(1)
const limit = 20
// Fetch map aggregation data
const { data: mapData, isLoading: mapLoading, error: mapError } = useQuery<{
states: Record<string, StateData>
topic: string | null
session: string | null
legend: {
types: Record<string, string>
statuses: Record<string, string>
}
}>({
queryKey: ['billsMap', selectedTopic, selectedSession],
queryFn: async () => {
const params = new URLSearchParams()
if (selectedTopic) params.append('topic', selectedTopic)
if (selectedSession) params.append('session', selectedSession)
const response = await api.get(`/bills/map?${params}`)
return response.data
},
enabled: viewMode === 'map' && !showTopicSelector && selectedTopic !== '',
staleTime: 5 * 60 * 1000, // 5 minutes - prevent refetch jitters
refetchOnWindowFocus: false,
retry: 2,
retryDelay: 1000,
})
// Fetch sessions
const { data: sessionsData } = useQuery({
queryKey: ['sessions', selectedState, selectedTopic, selectedChambers, selectedBillTypes, selectedStatuses, searchQuery],
queryFn: async () => {
const params = new URLSearchParams({ state: selectedState })
if (selectedTopic) params.append('topic', selectedTopic)
if (selectedChambers.length > 0) params.append('chambers', selectedChambers.join(','))
if (selectedBillTypes.length > 0) params.append('bill_types', selectedBillTypes.join(','))
if (selectedStatuses.length > 0) params.append('statuses', selectedStatuses.join(','))
if (searchQuery) params.append('q', searchQuery)
// Debug logging
console.log('🔍 Fetching sessions with params:', {
state: selectedState,
topic: selectedTopic,
chambers: selectedChambers,
bill_types: selectedBillTypes,
statuses: selectedStatuses,
q: searchQuery,
url: `/bills/sessions?${params}`
})
const response = await api.get(`/bills/sessions?${params}`)
console.log('✅ Sessions response:', {
total_sessions: response.data.total_sessions,
sessions: response.data.sessions?.length
})
return response.data
},
enabled: viewMode === 'list' && !showTopicSelector && selectedTopic !== '', // Only fetch when actually needed
staleTime: 5 * 60 * 1000, // 5 minutes - prevent refetch jitters
refetchOnWindowFocus: false,
retry: 2,
retryDelay: 1000,
})
// Fetch bills
const { data: billsData, isLoading, error: billsError } = useQuery<{
total: number
bills: Bill[]
pagination: { limit: number; offset: number; has_more: boolean }
}>({
queryKey: ['bills', selectedState, selectedSessions, searchQuery, selectedTopic, selectedChambers, selectedBillTypes, selectedStatuses, page],
queryFn: async () => {
const params = new URLSearchParams({
state: selectedState,
limit: limit.toString(),
offset: ((page - 1) * limit).toString(),
})
if (selectedSessions.length > 0) params.append('sessions', selectedSessions.join(','))
if (searchQuery) params.append('q', searchQuery)
if (selectedTopic) params.append('topic', selectedTopic)
if (selectedChambers.length > 0) params.append('chambers', selectedChambers.join(','))
if (selectedBillTypes.length > 0) params.append('bill_types', selectedBillTypes.join(','))
if (selectedStatuses.length > 0) params.append('statuses', selectedStatuses.join(','))
const response = await api.get(`/bills?${params}`)
return response.data
},
enabled: viewMode === 'list' && !showTopicSelector && selectedTopic !== '', // Only fetch when actually needed
staleTime: 5 * 60 * 1000, // 5 minutes - prevent refetch jitters
refetchOnWindowFocus: false,
retry: 2,
retryDelay: 1000,
})
const totalPages = Math.ceil((billsData?.total || 0) / limit)
const handleStateClick = (stateCode: string) => {
console.log('🗺️ State clicked:', stateCode, 'Current topic:', selectedTopic)
setSelectedState(stateCode)
setViewMode('list')
}
const handleTopicSelect = (topic: string) => {
setSelectedTopic(topic)
setShowTopicSelector(false)
setViewMode('map')
}
const handleBackToTopics = () => {
setShowTopicSelector(true)
setSelectedTopic('')
}
// Sort bills client-side
const sortedBills = billsData?.bills ? [...billsData.bills].sort((a, b) => {
if (sortBy === 'date') {
const dateA = a.latest_action_date ? new Date(a.latest_action_date).getTime() : 0
const dateB = b.latest_action_date ? new Date(b.latest_action_date).getTime() : 0
return sortOrder === 'asc' ? dateA - dateB : dateB - dateA
} else {
// Sort by bill number
const numA = a.bill_number
const numB = b.bill_number
return sortOrder === 'asc'
? numA.localeCompare(numB)
: numB.localeCompare(numA)
}
}) : []
const totalStatesWithLegislation = mapData ? Object.values(mapData.states).filter(s => s.total_bills > 0).length : 0
const totalBillsAcrossStates = mapData ? Object.values(mapData.states).reduce((sum, s) => sum + s.total_bills, 0) : 0
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-1">
📜 Legislative Policy Map
</h1>
<p className="text-gray-600">
{showTopicSelector
? 'Choose a topic to explore state-by-state legislation'
: 'Track state legislation initiatives compared across the country'
}
</p>
</div>
<div className="flex items-center gap-3">
{/* Back to Map button - show when in list view */}
{!showTopicSelector && viewMode === 'list' && (
<button
onClick={() => setViewMode('map')}
className="flex items-center gap-2 px-4 py-2 rounded-md font-medium bg-blue-600 text-white hover:bg-blue-700 transition-colors"
>
← Back to Map
</button>
)}
{/* Back to Topics button */}
{!showTopicSelector && (
<button
onClick={handleBackToTopics}
className="flex items-center gap-2 px-4 py-2 rounded-md font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors"
>
← Back to Topics
</button>
)}
{/* View Mode Toggle - only show when topic is selected */}
{!showTopicSelector && (
<div className="flex items-center gap-2">
<button
onClick={() => setViewMode('map')}
className={`flex items-center gap-2 px-4 py-2 rounded-md font-medium transition-colors ${
viewMode === 'map'
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
<MapIconOutline className="h-5 w-5" />
Map View
</button>
<button
onClick={() => setViewMode('list')}
className={`flex items-center gap-2 px-4 py-2 rounded-md font-medium transition-colors ${
viewMode === 'list'
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
<ListBulletIcon className="h-5 w-5" />
List View
</button>
</div>
)}
</div>
</div>
</div>
{/* Topic Selection View */}
{showTopicSelector && (
<div className="space-y-8">
<div className="text-center max-w-3xl mx-auto">
<h2 className="text-2xl font-bold text-gray-900 mb-3">
Select a Policy Topic
</h2>
<p className="text-gray-600">
Choose a topic below to see how states across the country are addressing it through legislation
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Fluoridation Card */}
<button
onClick={() => handleTopicSelect('fluoride')}
className="bg-white rounded-xl shadow-lg p-6 hover:shadow-xl transition-all hover:scale-105 text-left border-2 border-transparent hover:border-blue-500"
>
<div className="text-5xl mb-4">💧</div>
<h3 className="text-xl font-bold text-gray-900 mb-2">
Water Fluoridation
</h3>
<p className="text-gray-600 text-sm mb-4">
Track mandates, removals, funding initiatives, and studies on community water fluoridation programs
</p>
<div className="text-blue-600 font-medium text-sm">
View Legislation →
</div>
</button>
{/* Dental Health Card */}
<button
onClick={() => handleTopicSelect('dental')}
className="bg-white rounded-xl shadow-lg p-6 hover:shadow-xl transition-all hover:scale-105 text-left border-2 border-transparent hover:border-blue-500"
>
<div className="text-5xl mb-4">🦷</div>
<h3 className="text-xl font-bold text-gray-900 mb-2">
Dental Health
</h3>
<p className="text-gray-600 text-sm mb-4">
Monitor coverage expansions, screening programs, provider access, and funding for dental health services
</p>
<div className="text-blue-600 font-medium text-sm">
View Legislation →
</div>
</button>
{/* Oral Health Card */}
<button
onClick={() => handleTopicSelect('oral health')}
className="bg-white rounded-xl shadow-lg p-6 hover:shadow-xl transition-all hover:scale-105 text-left border-2 border-transparent hover:border-blue-500"
>
<div className="text-5xl mb-4">😁</div>
<h3 className="text-xl font-bold text-gray-900 mb-2">
Oral Health (General)
</h3>
<p className="text-gray-600 text-sm mb-4">
Explore comprehensive oral health policies including prevention, treatment, and public health initiatives
</p>
<div className="text-blue-600 font-medium text-sm">
View Legislation →
</div>
</button>
{/* Medicaid Card */}
<button
onClick={() => handleTopicSelect('medicaid')}
className="bg-white rounded-xl shadow-lg p-6 hover:shadow-xl transition-all hover:scale-105 text-left border-2 border-transparent hover:border-blue-500"
>
<div className="text-5xl mb-4">🏥</div>
<h3 className="text-xl font-bold text-gray-900 mb-2">
Medicaid
</h3>
<p className="text-gray-600 text-sm mb-4">
Follow Medicaid expansions, coverage changes, reimbursement rates, and eligibility requirements
</p>
<div className="text-blue-600 font-medium text-sm">
View Legislation →
</div>
</button>
{/* Education Card */}
<button
onClick={() => handleTopicSelect('education')}
className="bg-white rounded-xl shadow-lg p-6 hover:shadow-xl transition-all hover:scale-105 text-left border-2 border-transparent hover:border-blue-500"
>
<div className="text-5xl mb-4">🎓</div>
<h3 className="text-xl font-bold text-gray-900 mb-2">
Education
</h3>
<p className="text-gray-600 text-sm mb-4">
View educational requirements, curriculum changes, funding initiatives, and school health programs
</p>
<div className="text-blue-600 font-medium text-sm">
View Legislation →
</div>
</button>
{/* General Health Card */}
<button
onClick={() => handleTopicSelect('health')}
className="bg-white rounded-xl shadow-lg p-6 hover:shadow-xl transition-all hover:scale-105 text-left border-2 border-transparent hover:border-blue-500"
>
<div className="text-5xl mb-4">🏨</div>
<h3 className="text-xl font-bold text-gray-900 mb-2">
Health (General)
</h3>
<p className="text-gray-600 text-sm mb-4">
Examine broader health policies including protections, restrictions, funding, and healthcare reforms
</p>
<div className="text-blue-600 font-medium text-sm">
View Legislation →
</div>
</button>
</div>
</div>
)}
{/* Map and List View - only show when topic is selected */}
{!showTopicSelector && (
<>
{/* Selected Topic Badge */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">
{selectedTopic === 'fluoride' && '💧'}
{selectedTopic === 'dental' && '🦷'}
{selectedTopic === 'oral health' && '😁'}
{selectedTopic === 'medicaid' && '🏥'}
{selectedTopic === 'education' && '🎓'}
{selectedTopic === 'health' && '🏨'}
</span>
<div>
<div className="text-sm text-gray-600">Viewing legislation for:</div>
<div className="text-lg font-bold text-gray-900 capitalize">
{selectedTopic === 'fluoride' ? 'Water Fluoridation' :
selectedTopic === 'dental' ? 'Dental Health' :
selectedTopic === 'oral health' ? 'Oral Health' :
selectedTopic === 'medicaid' ? 'Medicaid' :
selectedTopic === 'education' ? 'Education' :
'Health'}
</div>
</div>
</div>
</div>
</div>
{/* Basic Filters - Clean and Spacious */}
<div className="bg-white rounded-lg shadow-sm p-6 mb-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* State Filter - list view only */}
{viewMode === 'list' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
State
</label>
<select
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-gray-900 py-2.5"
value={selectedState}
onChange={(e) => {
setSelectedState(e.target.value)
setPage(1)
}}
>
<option value="AL">Alabama</option>
<option value="AK">Alaska</option>
<option value="AZ">Arizona</option>
<option value="AR">Arkansas</option>
<option value="CA">California</option>
<option value="CO">Colorado</option>
<option value="CT">Connecticut</option>
<option value="DE">Delaware</option>
<option value="FL">Florida</option>
<option value="GA">Georgia</option>
<option value="HI">Hawaii</option>
<option value="ID">Idaho</option>
<option value="IL">Illinois</option>
<option value="IN">Indiana</option>
<option value="IA">Iowa</option>
<option value="KS">Kansas</option>
<option value="KY">Kentucky</option>
<option value="LA">Louisiana</option>
<option value="ME">Maine</option>
<option value="MD">Maryland</option>
<option value="MA">Massachusetts</option>
<option value="MI">Michigan</option>
<option value="MN">Minnesota</option>
<option value="MS">Mississippi</option>
<option value="MO">Missouri</option>
<option value="MT">Montana</option>
<option value="NE">Nebraska</option>
<option value="NV">Nevada</option>
<option value="NH">New Hampshire</option>
<option value="NJ">New Jersey</option>
<option value="NM">New Mexico</option>
<option value="NY">New York</option>
<option value="NC">North Carolina</option>
<option value="ND">North Dakota</option>
<option value="OH">Ohio</option>
<option value="OK">Oklahoma</option>
<option value="OR">Oregon</option>
<option value="PA">Pennsylvania</option>
<option value="RI">Rhode Island</option>
<option value="SC">South Carolina</option>
<option value="SD">South Dakota</option>
<option value="TN">Tennessee</option>
<option value="TX">Texas</option>
<option value="UT">Utah</option>
<option value="VT">Vermont</option>
<option value="VA">Virginia</option>
<option value="WA">Washington</option>
<option value="WV">West Virginia</option>
<option value="WI">Wisconsin</option>
<option value="WY">Wyoming</option>
</select>
</div>
)}
{/* Search - Prominent */}
<div className={viewMode === 'list' ? 'md:col-span-2' : 'md:col-span-3'}>
<label className="block text-sm font-medium text-gray-700 mb-2">
{viewMode === 'map' ? 'Search Keywords' : 'Search Bills'}
</label>
<div className="relative">
<input
type="text"
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 pl-10 text-gray-900 py-2.5"
placeholder="Search within results..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && setPage(1)}
/>
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
</div>
</div>
</div>
{/* Advanced Filters Button & Active Filter Indicator */}
{viewMode === 'list' && (
<div className="flex items-center justify-between mt-4 pt-4 border-t border-gray-200">
<button
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors font-medium"
>
<AdjustmentsHorizontalIcon className="h-5 w-5" />
Advanced Filters
{(selectedSessions.length > 0 || selectedChambers.length > 0 || selectedBillTypes.length > 0 || selectedStatuses.length > 0) && (
<span className="ml-1 bg-blue-800 text-white rounded-full px-2 py-0.5 text-xs font-bold">
{selectedSessions.length + selectedChambers.length + selectedBillTypes.length + selectedStatuses.length}
</span>
)}
</button>
{/* Active Filters Summary */}
{(selectedSessions.length > 0 || selectedChambers.length > 0 || selectedBillTypes.length > 0 || selectedStatuses.length > 0 || searchQuery) && (
<button
onClick={() => {
setSearchQuery('')
setSelectedSessions([])
setSelectedChambers([])
setSelectedBillTypes([])
setSelectedStatuses([])
setPage(1)
}}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors text-sm font-medium"
>
Clear All Filters
</button>
)}
</div>
)}
</div>
{/* Advanced Filters Sidebar */}
{viewMode === 'list' && showAdvancedFilters && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40"
onClick={() => setShowAdvancedFilters(false)}
/>
{/* Sidebar */}
<div className="fixed right-0 top-0 h-full w-full md:w-96 bg-white shadow-2xl z-50 overflow-y-auto">
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-bold text-gray-900">Advanced Filters</h3>
<button
onClick={() => setShowAdvancedFilters(false)}
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-md transition-colors"
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
{/* Filters */}
<div className="space-y-6">
{/* Session Filter */}
<div>
<MultiSelect
label="Legislative Session"
options={
sessionsData?.sessions
?.slice()
.sort((a: Session, b: Session) => {
const dateA = a.end_date ? new Date(a.end_date).getTime() : 0
const dateB = b.end_date ? new Date(b.end_date).getTime() : 0
return dateB - dateA
})
.map((session: Session) => ({
value: session.session,
label: session.session_name,
count: session.bill_count
})) || []
}
selected={selectedSessions}
onChange={(values) => {
setSelectedSessions(values)
setPage(1)
}}
placeholder="All Sessions"
/>
</div>
{/* Chamber Filter */}
<div>
<MultiSelect
label="Chamber"
options={[
{ value: 'house', label: 'House' },
{ value: 'senate', label: 'Senate' },
{ value: 'joint', label: 'Joint' }
]}
selected={selectedChambers}
onChange={(values) => {
setSelectedChambers(values)
setPage(1)
}}
placeholder="All Chambers"
/>
</div>
{/* Bill Type Filter */}
<div>
<MultiSelect
label="Bill Type"
options={[
{ value: 'bill', label: 'Bill (HB/SB)' },
{ value: 'resolution', label: 'Resolution (HR/SR)' },
{ value: 'joint_resolution', label: 'Joint Resolution (HJR/SJR)' },
{ value: 'concurrent_resolution', label: 'Concurrent Resolution (HCR/SCR)' },
{ value: 'memorial', label: 'Memorial (HJM/SJM)' }
]}
selected={selectedBillTypes}
onChange={(values) => {
setSelectedBillTypes(values)
setPage(1)
}}
placeholder="All Types"
/>
</div>
{/* Status Filter */}
<div>
<MultiSelect
label="Status"
options={[
{ value: 'enacted', label: 'Enacted' },
{ value: 'passed', label: 'Passed' },
{ value: 'adopted', label: 'Adopted' },
{ value: 'failed', label: 'Failed' },
{ value: 'introduced', label: 'Introduced' },
{ value: 'referred', label: 'Referred to Committee' },
{ value: 'reported', label: 'Reported from Committee' }
]}
selected={selectedStatuses}
onChange={(values) => {
setSelectedStatuses(values)
setPage(1)
}}
placeholder="All Statuses"
/>
</div>
{/* Sort Controls */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Sort By
</label>
<div className="space-y-2">
<select
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-gray-900 py-2.5"
value={sortBy}
onChange={(e) => setSortBy(e.target.value as 'date' | 'name')}
>
<option value="date">Latest Action</option>
<option value="name">Bill Number</option>
</select>
<button
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
className="w-full px-4 py-2.5 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors font-medium flex items-center justify-center gap-2"
>
{sortOrder === 'asc' ? '↑ Ascending' : '↓ Descending'}
</button>
</div>
</div>
</div>
{/* Footer Actions */}
<div className="mt-8 pt-6 border-t border-gray-200 space-y-3">
<button
onClick={() => {
setSelectedSessions([])
setSelectedChambers([])
setSelectedBillTypes([])
setSelectedStatuses([])
setPage(1)
}}
className="w-full px-4 py-2.5 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors font-medium"
>
Clear Advanced Filters
</button>
<button
onClick={() => setShowAdvancedFilters(false)}
className="w-full px-4 py-2.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors font-medium"
>
Apply Filters
</button>
</div>
</div>
</div>
</>
)}
{/* Map Visualization */}
{viewMode === 'map' && (
<div className="bg-white rounded-lg shadow-sm p-6 mb-6">
{/* Clear Explanatory Title */}
<div className="mb-6 border-b border-gray-200 pb-4">
<h2 className="text-2xl font-bold text-gray-900 mb-2">
{selectedTopic ? (
<>
{selectedTopic.charAt(0).toUpperCase() + selectedTopic.slice(1)} Legislation Across the US
</>
) : (
<>State-by-State Legislative Policy Overview</>
)}
</h2>
<p className="text-base text-gray-600">
{selectedTopic === 'fluoride' && (
<>See which states mandate water fluoridation, which have removed it, and where funding or studies are underway. Each state's color shows the primary type of legislation, while darker/lighter shades indicate whether bills have been enacted, are pending, or have failed.</>
)}
{selectedTopic === 'dental' && (
<>Track dental health policies including coverage expansion, screening programs, provider access initiatives, and funding. Colors show the main focus of legislation in each state, with shading indicating current status.</>
)}
{selectedTopic === 'medicaid' && (
<>Monitor Medicaid program changes across states, including expansions, coverage modifications, reimbursement adjustments, and eligibility requirements. The map shows what type of Medicaid legislation is most active in each state.</>
)}
{selectedTopic === 'health' && (
<>View health-related legislation including protections, restrictions, funding initiatives, and healthcare reforms. Each state's color indicates the dominant type of health policy being considered or enacted.</>
)}
{selectedTopic === 'education' && (
<>Explore educational policy across states, from new requirements and curriculum changes to funding initiatives and system reforms. Colors represent the primary focus of education legislation in each state.</>
)}
{!selectedTopic && (
<>This interactive map shows legislative activity across all 50 states. Click any state to drill down into specific bills, or use the topic filter above to focus on a particular policy area. Colors indicate the primary type of legislation, while shading shows whether bills have been enacted (darker), are pending (normal), or failed (lighter).</>
)}
</p>
</div>
{mapError ? (
<div className="bg-red-50 border border-red-200 rounded-lg p-8 text-center">
<div className="text-red-600 text-5xl mb-4">⚠️</div>
<h3 className="text-lg font-semibold text-red-900 mb-2">Failed to load map data</h3>
<p className="text-red-700">{String(mapError)}</p>
</div>
) : mapLoading ? (
<div className="flex justify-center items-center h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Loading map...</p>
</div>
</div>
) : (
<USMap
stateData={mapData?.states || {}}
onStateClick={handleStateClick}
legend={mapData?.legend}
/>
)}
</div>
)}
{/* Stats Summary - Below Map */}
{viewMode === 'map' && mapData && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div className="bg-white rounded-lg shadow-sm p-6 border-l-4 border-blue-500">
<div className="text-sm font-medium text-gray-600 uppercase tracking-wide">
States with Legislation
</div>
<div className="mt-2 text-3xl font-bold text-gray-900">
{totalStatesWithLegislation}
</div>
<div className="text-sm text-gray-500 mt-1">
{selectedTopic ? `matching "${selectedTopic}"` : 'all topics'}
</div>
</div>
<div className="bg-white rounded-lg shadow-sm p-6 border-l-4 border-green-500">
<div className="text-sm font-medium text-gray-600 uppercase tracking-wide">
Total Bills
</div>
<div className="mt-2 text-3xl font-bold text-gray-900">
{totalBillsAcrossStates.toLocaleString()}
</div>
<div className="text-sm text-gray-500 mt-1">
across all states
</div>
</div>
<div className="bg-white rounded-lg shadow-sm p-6 border-l-4 border-purple-500">
<div className="text-sm font-medium text-gray-600 uppercase tracking-wide">
Filter Topic
</div>
<div className="mt-2 text-xl font-bold text-gray-900">
{selectedTopic || 'All Topics'}
</div>
<div className="text-sm text-gray-500 mt-1">
Click map to drill down
</div>
</div>
</div>
)}
{/* List View */}
{viewMode === 'list' && billsData && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div className="bg-white rounded-lg shadow-sm p-6 border-l-4 border-blue-500">
<div className="text-sm font-medium text-gray-600 uppercase tracking-wide">
Total Bills
</div>
<div className="mt-2 text-3xl font-bold text-gray-900">
{billsData.total.toLocaleString()}
</div>
<div className="text-sm text-gray-500 mt-1">
{selectedSession ? `in ${selectedSession}` : 'all sessions'}
</div>
</div>
<div className="bg-white rounded-lg shadow-sm p-6 border-l-4 border-green-500">
<div className="text-sm font-medium text-gray-600 uppercase tracking-wide">
Sessions Available
</div>
<div className="mt-2 text-3xl font-bold text-gray-900">
{sessionsData?.total_sessions || 0}
</div>
<div className="text-sm text-gray-500 mt-1">
{sessionsData?.sessions?.[0]?.session} - {sessionsData?.sessions?.[sessionsData.sessions.length - 1]?.session}
</div>
</div>
<div className="bg-white rounded-lg shadow-sm p-6 border-l-4 border-purple-500">
<div className="text-sm font-medium text-gray-600 uppercase tracking-wide">
Showing
</div>
<div className="mt-2 text-3xl font-bold text-gray-900">
{billsData.bills.length}
</div>
<div className="text-sm text-gray-500 mt-1">
Page {page} of {totalPages}
</div>
</div>
</div>
)}
{/* Bills List */}
{viewMode === 'list' && (
<>
{billsError ? (
<div className="bg-red-50 border border-red-200 rounded-lg p-8 text-center">
<div className="text-red-600 text-5xl mb-4">⚠️</div>
<h3 className="text-xl font-semibold text-red-900 mb-2">
Unable to Load Bills
</h3>
<p className="text-red-700 mb-4">
{billsError instanceof Error
? billsError.message
: 'There was an error fetching bills data. The API server may be unavailable.'}
</p>
<div className="flex gap-3 justify-center">
<button
onClick={() => window.location.reload()}
className="px-6 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors font-medium"
>
Retry
</button>
<button
onClick={() => setViewMode('map')}
className="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors font-medium"
>
Switch to Map View
</button>
</div>
</div>
) : isLoading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading bills...</p>
</div>
) : billsData && billsData.total === 0 ? (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-8 text-center">
<div className="text-yellow-600 text-5xl mb-4">📭</div>
<h3 className="text-xl font-semibold text-yellow-900 mb-2">
No Bills Found
</h3>
<p className="text-yellow-700 mb-4">
{selectedState === 'LA' || selectedState === 'Louisiana' ? (
<>Louisiana data is not yet available in our database. We currently have data for Alabama, Georgia, Massachusetts, Washington, and Wisconsin.</>
) : (
<>No bills found for the selected filters. Try adjusting your search criteria or clearing filters.</>
)}
</p>
<div className="flex gap-3 justify-center">
{(selectedSessions.length > 0 || selectedChambers.length > 0 || selectedBillTypes.length > 0 || selectedStatuses.length > 0 || searchQuery) ? (
<button
onClick={() => {
setSearchQuery('')
setSelectedSessions([])
setSelectedChambers([])
setSelectedBillTypes([])
setSelectedStatuses([])
setPage(1)
}}
className="px-6 py-2 bg-yellow-600 text-white rounded-md hover:bg-yellow-700 transition-colors font-medium"
>
Clear Filters
</button>
) : null}
<button
onClick={() => setViewMode('map')}
className="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors font-medium"
>
Back to Map
</button>
</div>
</div>
) : (
<>
<div className="space-y-4 mb-6">
{sortedBills.map((bill) => {
const isExpanded = expandedBill === bill.bill_id
return (
<div
key={bill.bill_id}
className="bg-white rounded-lg shadow-sm border-l-4 border-blue-500 overflow-hidden transition-shadow hover:shadow-md"
>
{/* Bill Header - Always Visible */}
<div
className="p-6 cursor-pointer"
onClick={() => setExpandedBill(isExpanded ? null : bill.bill_id)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2 flex-wrap">
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">
{bill.bill_number}
</span>
{bill.classification && bill.classification.length > 0 && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-800">
{bill.classification.join(', ')}
</span>
)}
<span className="text-sm text-gray-500">
{bill.session_name}
</span>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
{bill.title}
</h3>
<div className="flex items-center gap-4 text-sm text-gray-600">
<span>
<strong>Latest Action:</strong> {bill.latest_action_description || bill.latest_action || 'N/A'}
</span>
{bill.latest_action_date && (
<span>
<strong>Date:</strong>{' '}
{new Date(bill.latest_action_date).toLocaleDateString()}
</span>
)}
</div>
</div>
<button className="ml-4 text-gray-400 hover:text-gray-600">
{isExpanded ? '▼' : '▶'}
</button>
</div>
</div>
{/* Expanded Details */}
{isExpanded && (
<div className="px-6 pb-6 border-t border-gray-100">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div>
<p className="text-sm font-medium text-gray-700 mb-1">Jurisdiction</p>
<p className="text-sm text-gray-900">{bill.jurisdiction_name}</p>
</div>
<div>
<p className="text-sm font-medium text-gray-700 mb-1">Session</p>
<p className="text-sm text-gray-900">{bill.session_name} ({bill.session})</p>
</div>
{bill.first_action_date && (
<div>
<p className="text-sm font-medium text-gray-700 mb-1">First Action</p>
<p className="text-sm text-gray-900">
{new Date(bill.first_action_date).toLocaleDateString()}
</p>
</div>
)}
<div>
<p className="text-sm font-medium text-gray-700 mb-1">Bill ID</p>
<p className="text-xs text-gray-600 font-mono break-all">{bill.bill_id}</p>
</div>
</div>
</div>
)}
</div>
)})}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm p-4">
<div className="text-sm text-gray-600">
Showing {(page - 1) * limit + 1} to{' '}
{Math.min(page * limit, billsData?.total || 0)} of{' '}
{billsData?.total.toLocaleString()} bills
</div>
<div className="flex gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<span className="px-4 py-2 text-gray-700">
Page {page} of {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
</div>
)}
{/* No Results */}
{!isLoading && !billsError && billsData && billsData.bills.length === 0 && (
<div className="bg-white rounded-lg shadow-sm p-12 text-center">
<p className="text-gray-600 text-lg">No bills found matching your filters.</p>
<button
onClick={() => {
setSearchQuery('')
setSelectedSession('')
setPage(1)
}}
className="mt-4 px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
Clear Filters
</button>
</div>
)}
</>
)}
</>
)}
</>
)}
</div>
</div>
)
}