DistrictSearch built its "most arrests" list from /estimates?state=XX&year=21-22&limit=500. That endpoint returns rows ORDER BY LEAID, RACE, SEX at eight rows per district, so a 500-row cap is not a sample of the state — it is the ~62 lowest-LEAID districts in it. Measured against California (11,488 rows, 1,715 districts): the old read covered 68 districts, and 6 of the true top 8 were invisible to it. It suggested districts with 1 and 2 arrests as the state's most notable, while San Diego Unified (178), Fresno Unified (77) and Kern High (69) never appeared. Replaced with a committed fixture, public/data/top_districts.json, generated by scripts/build-top-districts.mjs. The script pages each state to completion using meta.total from the response envelope and fails loudly on a short read, since a silent truncation there would reintroduce exactly this bug. 51 states, 135 requests, ~104KB, following the national_rates.json precedent. Re-run it only when a new CRDC wave lands. The search screen also loses a multi-second fetch on every visit, and the hardcoded "Try Derby (KS), Paterson (NJ)" hint goes with it — the real list supersedes it. Degrades to search-only if the fixture is missing. fetchStateDistricts() is kept for scripts and ad-hoc use, with its JSDoc now warning that any short read ranks by LEAID. pages.yml gains public/** in its paths filter: the fixture ships with the build, so regenerating it has to be able to trigger a deploy on its own.
197 lines
7.8 KiB
React
197 lines
7.8 KiB
React
import { useState, useEffect } from 'react'
|
||
import { searchDistricts } from '../hooks/useApi.js'
|
||
|
||
const TOP_DISTRICTS_URL = `${import.meta.env.BASE_URL}data/top_districts.json`
|
||
const SUGGESTION_COUNT = 8
|
||
|
||
// Module-level cache: the fixture covers every state, so it is fetched at most
|
||
// once per page load no matter how many states the visitor browses through.
|
||
let topDistrictsPromise = null
|
||
|
||
function loadTopDistricts() {
|
||
if (!topDistrictsPromise) {
|
||
topDistrictsPromise = fetch(TOP_DISTRICTS_URL)
|
||
.then((res) => {
|
||
if (!res.ok) throw new Error(`HTTP ${res.status} loading ${TOP_DISTRICTS_URL}`)
|
||
return res.json()
|
||
})
|
||
.catch((err) => {
|
||
// Don't poison the cache with a rejected promise — let a later visit retry.
|
||
topDistrictsPromise = null
|
||
throw err
|
||
})
|
||
}
|
||
return topDistrictsPromise
|
||
}
|
||
|
||
/**
|
||
* District search screen with:
|
||
* 1. "Interesting" suggestions — the districts with the most arrests in the
|
||
* selected state, read from the committed `public/data/top_districts.json`
|
||
* fixture (built by `scripts/build-top-districts.mjs`).
|
||
* 2. Live-search as you type → /api/v1/districts?q=...&state=XX
|
||
*
|
||
* The suggestions used to be computed at runtime from
|
||
* `/estimates?state=XX&year=21-22&limit=500`. That endpoint returns rows
|
||
* `ORDER BY LEAID, RACE, SEX` at eight rows per district, so the cap selected
|
||
* the ~62 lowest-LEAID districts in the state rather than the busiest ones —
|
||
* California has 11,488 rows. The fixture is ranked over the complete result
|
||
* set, and it also removes a multi-second fetch from this screen.
|
||
*/
|
||
export default function DistrictSearch({ state, onSelect, onBack }) {
|
||
const [query, setQuery] = useState('')
|
||
const [searchResults, setSearchResults] = useState([])
|
||
const [suggestions, setSuggestions] = useState([]) // "interesting" districts sorted by arrests desc
|
||
const [loadingSugg, setLoadingSugg] = useState(true)
|
||
const [loadingSearch, setLoadingSearch] = useState(false)
|
||
|
||
// ——— Read the suggestion fixture for this state ———
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
setLoadingSugg(true)
|
||
|
||
loadTopDistricts()
|
||
.then((fixture) => {
|
||
if (cancelled) return
|
||
const forState = fixture?.states?.[state] || []
|
||
setSuggestions(
|
||
forState.slice(0, SUGGESTION_COUNT).map((d) => ({
|
||
leaid: d.leaid,
|
||
lea_name: d.name,
|
||
state,
|
||
observed_arrests: d.arrests,
|
||
})),
|
||
)
|
||
})
|
||
.catch((err) => {
|
||
console.error('Failed to load suggested districts:', err)
|
||
if (!cancelled) setSuggestions([]) // degrade gracefully — just show search box
|
||
})
|
||
.finally(() => {
|
||
if (!cancelled) setLoadingSugg(false)
|
||
})
|
||
|
||
return () => { cancelled = true }
|
||
}, [state])
|
||
|
||
// ——— Live search as user types (debounced by React's natural event batching) ———
|
||
useEffect(() => {
|
||
if (!query.trim()) { setSearchResults([]); return }
|
||
|
||
const timer = setTimeout(async () => {
|
||
setLoadingSearch(true)
|
||
try {
|
||
const data = await searchDistricts(query, state)
|
||
setSearchResults(data || [])
|
||
} catch (err) {
|
||
console.error('District search failed:', err)
|
||
setSearchResults([])
|
||
} finally {
|
||
setLoadingSearch(false)
|
||
}
|
||
}, 300)
|
||
|
||
return () => clearTimeout(timer)
|
||
}, [query, state])
|
||
|
||
// ——— Render a district card (used for both suggestions and search results) ———
|
||
const renderDistrictCard = (dist) => (
|
||
<button
|
||
key={dist.leaid}
|
||
className="cv-card"
|
||
style={{ textAlign: 'left', padding: 'var(--space-2)' }}
|
||
onClick={() => onSelect(dist)}
|
||
>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||
<span style={{ fontWeight: 600, color: 'var(--cv-navy-700)', fontSize: '0.95rem' }}>
|
||
{dist.lea_name || dist.name}
|
||
</span>
|
||
{dist.observed_arrests !== undefined && (
|
||
<span className="stat-label accent" style={{ marginTop: 0 }}>
|
||
{dist.observed_arrests.toLocaleString()} arrests in 2021–22
|
||
</span>
|
||
)}
|
||
</div>
|
||
</button>
|
||
)
|
||
|
||
return (
|
||
<div style={{ padding: 'var(--space-5) 0 var(--space-7)' }}>
|
||
{/* Back button */}
|
||
<button className="btn-outline" onClick={onBack} style={{ marginBottom: 'var(--space-3)' }}>
|
||
← Change state
|
||
</button>
|
||
|
||
<h2 style={{ marginTop: 0, marginBottom: 'var(--space-1)' }}>
|
||
{state === 'DC' ? "District of Columbia" : `Find a district in ${getStateName(state)}`}
|
||
</h2>
|
||
<p style={{ color: 'var(--cv-ink-2)', marginBottom: 'var(--space-3)' }}>
|
||
Search by district name, or try one of the suggested districts with the highest arrest counts.
|
||
</p>
|
||
|
||
{/* "Interesting" suggestions */}
|
||
{!query && (
|
||
<div style={{ marginTop: 'var(--space-4)' }}>
|
||
<span className="eyebrow">Suggested districts — most arrests in 2021–22</span>
|
||
{loadingSugg ? (
|
||
<p style={{ color: 'var(--cv-ink-3)', padding: 'var(--space-2) 0' }}>Loading suggestions…</p>
|
||
) : suggestions.length > 0 ? (
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 'var(--space-1)' }}>
|
||
{suggestions.map(renderDistrictCard)}
|
||
</div>
|
||
) : (
|
||
<p style={{ color: 'var(--cv-ink-3)', padding: 'var(--space-2) 0' }}>
|
||
No districts with reported arrests found in this state. Try searching by name below.
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Live search */}
|
||
<div style={{ marginTop: query ? 0 : 'var(--space-5)' }}>
|
||
<label htmlFor="district-search" className="stat-label">Search district name:</label>
|
||
<input
|
||
id="district-search"
|
||
type="text"
|
||
placeholder="Type a district name… (e.g., Madison, Derby, Paterson)"
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
style={{
|
||
width: '100%', padding: 'var(--space-2)', fontSize: '1rem',
|
||
border: '1px solid var(--cv-rule)', borderRadius: 'var(--radius-md)',
|
||
fontFamily: 'var(--font-sans)'
|
||
}}
|
||
/>
|
||
|
||
{query && (
|
||
<div style={{ marginTop: 'var(--space-2)' }}>
|
||
{loadingSearch ? (
|
||
<p style={{ color: 'var(--cv-ink-3)' }}>Searching…</p>
|
||
) : searchResults.length > 0 ? (
|
||
<div style={{ display: 'grid', gap: 'var(--space-1)' }}>
|
||
{searchResults.slice(0, 20).map(renderDistrictCard)}
|
||
</div>
|
||
) : (
|
||
<p style={{ color: 'var(--cv-ink-3)' }}>No districts found matching "{query}".</p>
|
||
)}
|
||
|
||
{/* Back to suggestions */}
|
||
{searchResults.length > 0 && (
|
||
<button className="btn-outline" onClick={() => setQuery('')} style={{ marginTop: 'var(--space-2)' }}>
|
||
← Show suggested districts again
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function getStateName(code) {
|
||
const names = {
|
||
AL:'Alabama',AK:'Alaska',AZ:'Arizona',AR:'Arkansas',CA:'California',CO:'Colorado',CT:'Connecticut',DE:'Delaware',DC:'District of Columbia',FL:'Florida',GA:'Georgia',HI:'Hawaii',ID:'Idaho',IL:'Illinois',IN:'Indiana',IA:'Iowa',KS:'Kansas',KY:'Kentucky',LA:'Louisiana',ME:'Maine',MD:'Maryland',MA:'Massachusetts',MI:'Michigan',MN:'Minnesota',MS:'Mississippi',MO:'Missouri',MT:'Montana',NE:'Nebraska',NV:'Nevada',NH:'New Hampshire',NJ:'New Jersey',NM:'New Mexico',NY:'New York',NC:'North Carolina',ND:'North Dakota',OH:'Ohio',OK:'Oklahoma',OR:'Oregon',PA:'Pennsylvania',RI:'Rhode Island',SC:'South Carolina',SD:'South Dakota',TN:'Tennessee',TX:'Texas',UT:'Utah',VT:'Vermont',VA:'Virginia',WA:'Washington',WV:'West Virginia',WI:'Wisconsin',WY:'Wyoming'
|
||
}
|
||
return names[code] || code
|
||
}
|