import { useEffect, useMemo, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getArbaShows, refreshArbaShows } from '../api'
import Card from '../components/Card'
import Button from '../components/Button'
import PageHeader from '../components/PageHeader'
import EmptyState from '../components/EmptyState'
import AddToEventModal from '../components/AddToEventModal'
import { Input } from '../components/FormControls'
const PAGE_SIZE = 50
// The five buckets lib/arba.ts normalizes to. 'OTHER' is intentionally omitted
// from the filter — it's a small catch-all for rows whose show-information
// string didn't match the expected pattern, and they still show when no
// show-type filter is active.
const SHOW_TYPE_OPTIONS = [
{ value: 'ALL BREED OPEN', label: 'All Breed — Open' },
{ value: 'ALL BREED YOUTH', label: 'All Breed — Youth' },
{ value: 'SPECIALTY OPEN', label: 'Specialty — Open' },
{ value: 'SPECIALTY YOUTH', label: 'Specialty — Youth' },
{ value: 'FAIR SHOW', label: 'Fair Show' },
]
function todayIso() {
return new Date().toISOString().slice(0, 10)
}
function formatTimestamp(iso) {
if (!iso) return 'never'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return 'unknown'
return d.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
}
function ChevronIcon() {
return (
)
}
// Compact checkbox dropdown used for State / District / Show Type. Closes on
// outside click. `options` is [{ value, label }]; `selected` is an array of
// values.
function MultiSelect({ label, options, selected, onChange }) {
const [open, setOpen] = useState(false)
const ref = useRef(null)
useEffect(() => {
if (!open) return undefined
function onClick(e) {
if (ref.current && !ref.current.contains(e.target)) setOpen(false)
}
document.addEventListener('mousedown', onClick)
return () => document.removeEventListener('mousedown', onClick)
}, [open])
function toggle(value) {
if (selected.includes(value)) onChange(selected.filter((v) => v !== value))
else onChange([...selected, value])
}
return (
setOpen((v) => !v)}
className="flex min-h-[44px] md:min-h-[32px] items-center gap-2 rounded-md border border-border bg-surface-raised px-3 py-1.5 font-body text-sm text-text-primary"
>
{label}
{selected.length > 0 && (
{selected.length}
)}
{open && (
{selected.length > 0 && (
onChange([])}
className="mb-1 w-full rounded px-2 py-1 text-left font-body text-xs text-accent hover:bg-surface-sunken"
>
Clear {label}
)}
{options.map((option) => (
toggle(option.value)}
className="accent-accent"
/>
{option.label || '—'}
))}
)}
)
}
function MapPinIcon() {
return (
)
}
// Phone/tablet layout for one show: the same fields as a table row, stacked,
// so the list never scrolls sideways.
function ShowListCard({ show: s, onAdd }) {
const place = [s.city, s.state].filter(Boolean).join(', ')
return (
{s.club_name}
{s.show_type_full || s.show_type}
{s.special_breed ? ` · ${s.special_breed}` : ''}
{s.raw_date || s.date || '—'}
{place || '—'}
{s.district ? ` · District ${s.district}` : ''}
{s.secretary || s.email ? (
{s.secretary}
{s.secretary && s.email ? ' · ' : ''}
{s.email ? (
{s.email}
) : null}
) : null}
{s.maps_url ? (
Map
) : null}
{s.catalog_url ? (
Catalog
) : null}
Add to Event
)
}
function Shows() {
const queryClient = useQueryClient()
const { data, isLoading, isError, error } = useQuery({
queryKey: ['arba-shows'],
queryFn: getArbaShows,
})
const refresh = useMutation({
mutationFn: refreshArbaShows,
onSuccess: (result) => queryClient.setQueryData(['arba-shows'], result),
})
const shows = useMemo(() => data?.shows ?? [], [data])
// Filter state
const [states, setStates] = useState([])
const [districts, setDistricts] = useState([])
const [showTypes, setShowTypes] = useState([])
const [dateFrom, setDateFrom] = useState(todayIso)
const [dateTo, setDateTo] = useState('')
const [keyword, setKeyword] = useState('')
const [page, setPage] = useState(1)
const [addingShow, setAddingShow] = useState(null)
// Options derived from the loaded data.
const stateOptions = useMemo(() => {
const set = [...new Set(shows.map((s) => s.state).filter(Boolean))].sort()
return set.map((v) => ({ value: v, label: v }))
}, [shows])
const districtOptions = useMemo(() => {
const set = [...new Set(shows.map((s) => s.district ?? ''))].sort((a, b) => {
if (a === '') return 1
if (b === '') return -1
return Number(a) - Number(b)
})
return set.map((v) => ({ value: v, label: v === '' ? '— (none)' : v }))
}, [shows])
const filtered = useMemo(() => {
const kw = keyword.trim().toLowerCase()
return shows.filter((s) => {
if (states.length && !states.includes(s.state)) return false
if (districts.length && !districts.includes(s.district ?? '')) return false
if (showTypes.length && !showTypes.includes(s.show_type)) return false
if (dateFrom && (!s.date || s.date < dateFrom)) return false
if (dateTo && (!s.date || s.date > dateTo)) return false
if (kw) {
const haystack = `${s.club_name} ${s.city} ${s.secretary}`.toLowerCase()
if (!haystack.includes(kw)) return false
}
return true
})
}, [shows, states, districts, showTypes, dateFrom, dateTo, keyword])
// Snap back to page 1 whenever the filtered set changes size/content.
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const currentPage = Math.min(page, totalPages)
const pageRows = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE)
function resetFilters() {
setStates([])
setDistricts([])
setShowTypes([])
setDateFrom(todayIso())
setDateTo('')
setKeyword('')
setPage(1)
}
const hasActiveFilters =
states.length || districts.length || showTypes.length || dateTo || keyword || dateFrom !== todayIso()
return (
Updated {formatTimestamp(data?.fetched_at)}
{data?.stale ? ' (stale)' : ''}
My Events
refresh.mutate()}
disabled={refresh.isPending}
>
{refresh.isPending ? 'Refreshing…' : 'Refresh Data'}
}
/>
{/* Filter bar */}
{ setStates(v); setPage(1) }} />
{ setDistricts(v); setPage(1) }} />
{ setShowTypes(v); setPage(1) }} />
From
{ setDateFrom(e.target.value); setPage(1) }}
className=""
/>
To
{ setDateTo(e.target.value); setPage(1) }}
className=""
/>
{ setKeyword(e.target.value); setPage(1) }}
placeholder="Club, city, or secretary"
aria-label="Search by club, city, or secretary"
className="w-56"
/>
{hasActiveFilters ? (
Reset Filters
) : null}
{refresh.isError ? (
Refresh failed: {refresh.error.message}
) : null}
{isError ? (
refresh.mutate()}>Try Again}
/>
) : isLoading ? (
Loading shows…
) : filtered.length === 0 ? (
Reset Filters : null}
/>
) : (
<>
{filtered.length} show{filtered.length === 1 ? '' : 's'}
{pageRows.map((s, i) => (
setAddingShow(s)} />
))}
{/* The full table needs ~900px, which only fits beside the sidebar on wide screens. */}
Date
State
Dist.
City
Club / Show
Specialty
Secretary
Links
{pageRows.map((s, i) => (
{s.raw_date || s.date || '—'}
{s.state}
{s.district || '—'}
{s.city}
{s.club_name}
{s.show_type_full || s.show_type}
{s.special_breed || '—'}
{s.secretary || '—'}
{s.email ? (
{s.email}
) : null}
{s.maps_url ? (
) : null}
{s.catalog_url ? (
Catalog
) : null}
setAddingShow(s)}>
Add to Event
))}
{totalPages > 1 ? (
Page {currentPage} of {totalPages}
setPage(currentPage - 1)}>
Previous
= totalPages} onClick={() => setPage(currentPage + 1)}>
Next
) : null}
>
)}
{addingShow ? (
setAddingShow(null)} />
) : null}
)
}
export default Shows