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.
This commit is contained in:
2026-08-12 08:40:23 -04:00
parent c62d1e3068
commit 63413f9eb7
5 changed files with 5000 additions and 34 deletions
+54 -33
View File
@@ -1,10 +1,42 @@
import { useState, useEffect } from 'react'
import { searchDistricts, fetchStateDistricts } from '../hooks/useApi.js'
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 — districts with the most arrests in the selected state (fetched once)
* 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('')
@@ -13,38 +45,32 @@ export default function DistrictSearch({ state, onSelect, onBack }) {
const [loadingSugg, setLoadingSugg] = useState(true)
const [loadingSearch, setLoadingSearch] = useState(false)
// ——— Fetch interesting suggestions (top arrests in this state) once on mount ———
// ——— Read the suggestion fixture for this state ———
useEffect(() => {
let cancelled = false
async function loadSuggestions() {
try {
// /estimates?state=XX&year=21-22 returns all districts; sort by observed_arrests desc client-side
const data = await fetchStateDistricts(state, '21-22', 500)
setLoadingSugg(true)
// Aggregate arrests per district (sum across race×sex groups), then sort
const byLeaid = {}
for (const row of data) {
if (!byLeaid[row.leaid]) {
byLeaid[row.leaid] = { leaid: row.leaid, lea_name: row.lea_name, state: row.state, observed_arrests: 0 }
}
byLeaid[row.leaid].observed_arrests += (row.observed_arrests || 0)
}
const sorted = Object.values(byLeaid).sort((a, b) => b.observed_arrests - a.observed_arrests)
if (!cancelled) {
// Take top 8 for suggestions; filter to those with >0 arrests
setSuggestions(sorted.filter(d => d.observed_arrests > 0).slice(0, 8))
}
} catch (err) {
console.error('Failed to load interesting districts:', err)
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 {
})
.finally(() => {
if (!cancelled) setLoadingSugg(false)
}
}
})
loadSuggestions()
return () => { cancelled = true }
}, [state])
@@ -158,11 +184,6 @@ export default function DistrictSearch({ state, onSelect, onBack }) {
</div>
)}
</div>
{/* Hint */}
<p style={{ fontSize: '0.85rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-4)' }}>
Tip: Try districts like Derby (KS), Paterson (NJ), or Mobile County (AL) — they have notable arrest rates.
</p>
</div>
)
}