Files
crdc-demo/src/components/DistrictSearch.jsx
T
jared 63413f9eb7 fix: rank district suggestions over the full state, not the first 500 rows
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.
2026-08-12 08:40:23 -04:00

197 lines
7.8 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}