Fix routing, data fetching, and rendering issues in CRDC demo app:
Deploy to git-pages / deploy (push) Successful in 10s

- App.jsx: Fix 'search another district' button href to use /crdc-demo/ instead of / (root)
- LoadingAnimation.jsx: Replace broken inline fetch helper with centralized api client from useApi.js — was bypassing envelope unwrapping ({status:'success',data:[...]}), causing all 43 API calls to fail silently
- ChartPanel.jsx: Same fix — replace raw fetch() calls with api.fetchDistrictEstimates(), fix national_rates.json path (remove hardcoded /crdc-demo/ prefix that breaks in dev), parallelize wave/model fetches with Promise.all for faster loading

Root causes fixed:
1. Navigation button linked to site root instead of app subdirectory → users lost their way after selecting a district
2. Dual API client implementations — inline fetch helpers didn't unwrap the JSON envelope structure, causing data parsing failures that made the page appear stuck on 'Organizing data...'
3. Sequential fetches in ChartPanel caused slow loading; parallelized for better UX
This commit is contained in:
2026-08-10 11:42:31 -04:00
parent c475bfe50b
commit 8ba7440a34
3 changed files with 30 additions and 45 deletions
+4 -3
View File
@@ -20,7 +20,8 @@ export default function App() {
const leaid = params.get('leaid') const leaid = params.get('leaid')
const stateParam = params.get('state') const stateParam = params.get('state')
if (leaid && stateParam) { if (leaid && stateParam) {
// Deep link: skip straight to loading → results // Deep link: skip straight to loading → results.
// We need to fetch the district name since we only have LEAID from URL.
setSelectedState(stateParam) setSelectedState(stateParam)
setDistrict({ leaid, lea_name: 'Loading…', state: stateParam }) setDistrict({ leaid, lea_name: 'Loading…', state: stateParam })
setStep('loading') setStep('loading')
@@ -44,7 +45,7 @@ export default function App() {
<div className="cv-app"> <div className="cv-app">
{/* Header */} {/* Header */}
<header className="cv-header cv-wrap"> <header className="cv-header cv-wrap">
<a href="/" aria-label="Civilytics" style={{ display: 'block' }}> <a href="/crdc-demo" aria-label="Civilytics" style={{ display: 'block' }}>
<img <img
src="/civilytics-logo.svg" src="/civilytics-logo.svg"
alt="Civilytics — social science for the public good" alt="Civilytics — social science for the public good"
@@ -81,7 +82,7 @@ export default function App() {
<button <button
className="btn-outline" className="btn-outline"
style={{ margin: 'var(--space-3) 0', display: 'block' }} style={{ margin: 'var(--space-3) 0', display: 'block' }}
onClick={() => { window.location.href = '/' }} onClick={() => { window.location.href = '/crdc-demo/' }}
> >
← Search another district ← Search another district
</button> </button>
+9 -15
View File
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import * as api from '../hooks/useApi.js'
import ArrestsOverTime from '../charts/ArrestsOverTime.jsx' import ArrestsOverTime from '../charts/ArrestsOverTime.jsx'
import RateByGroupBar from '../charts/RateByGroupBar.jsx' import RateByGroupBar from '../charts/RateByGroupBar.jsx'
import DistrictVsNational from '../charts/DistrictVsNational.jsx' import DistrictVsNational from '../charts/DistrictVsNational.jsx'
@@ -15,19 +16,14 @@ export default function ChartPanel({ district, state }) {
const [nationalRates, setNationalRates] = useState(null) const [nationalRates, setNationalRates] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
// Proxy support for CORS bypass when deployed on pages.civilytics.org // Use centralized API client for consistent error handling and CORS proxy support.
const PROXY_URL = import.meta.env.VITE_PROXY_URL || ''
const apiFetchWithProxy = (path) => {
return PROXY_URL ? `${PROXY_URL}?target=${encodeURIComponent(path)}` : `https://crdc-api.civilytics.org/api/v1${path}`
}
useEffect(() => { useEffect(() => {
async function fetchData() { async function fetchData() {
try { try {
// National rates from static fixture (or cache from loading step) // National rates from static fixture (or cache from loading step)
let nat = window.__NATIONAL_RATES__ let nat = window.__NATIONAL_RATES__
if (!nat) { if (!nat) {
const res = await fetch('/crdc-demo/data/national_rates.json') const res = await fetch('/data/national_rates.json')
nat = await res.json() nat = await res.json()
} }
setNationalRates(nat) setNationalRates(nat)
@@ -35,18 +31,16 @@ export default function ChartPanel({ district, state }) {
// Fetch all 3 waves × default model (unified_m2_mod) for Chart 1 + Charts 2-3 // Fetch all 3 waves × default model (unified_m2_mod) for Chart 1 + Charts 2-3
const waves = ['21-22', '17-18', '15-16'] const waves = ['21-22', '17-18', '15-16']
const waveData = {} const waveData = {}
for (const year of waves) { await Promise.all(waves.map(async (year) => {
const res = await fetch(apiFetchWithProxy(`/estimates/${district.leaid}?model=unified_m2_mod&year=${year}`)) try { waveData[year] = await api.fetchDistrictEstimates(district.leaid, { model: 'unified_m2_mod', year }) } catch(e) {}
if (res.ok) waveData[year] = (await res.json()).data || [] }))
}
// Fetch all 4 quadrant models for Charts 4-6 (8 groups × 4 models, but we batch by model) // Fetch all 4 quadrant models for Charts 4-6 (8 groups × 4 models, but we batch by model)
const quadrants = ['unified_m1_mod', 'unified_m2_mod', 'unified_m3_mod', 'unified_m4_mod'] const quadrants = ['unified_m1_mod', 'unified_m2_mod', 'unified_m3_mod', 'unified_m4_mod']
const quadData = {} const quadData = {}
for (const model of quadrants) { await Promise.all(quadrants.map(async (model) => {
const res = await fetch(apiFetchWithProxy(`/estimates/${district.leaid}?model=${model}&year=21-22`)) try { quadData[model] = await api.fetchDistrictEstimates(district.leaid, { model, year: '21-22' }) } catch(e) {}
if (res.ok) quadData[model] = (await res.json()).data || [] }))
}
setData({ waveData, quadData }) setData({ waveData, quadData })
} catch (err) { } catch (err) {
+17 -27
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import ChartPanel from './ChartPanel.jsx' import ChartPanel from './ChartPanel.jsx'
import * as api from '../hooks/useApi.js'
// The 8 student groups we fetch for (race×sex combinations) // The 8 student groups we fetch for (race×sex combinations)
const STUDENT_GROUPS = [ const STUDENT_GROUPS = [
@@ -102,18 +103,18 @@ export default function LoadingAnimation({ district, state }) {
return renderError(error, district) return renderError(error, district)
} }
// Build the grid of "bars" — one per API call needed // Build the grid of "bars" — one per API call needed.
const allBars = [ // Total = WAVES (3) + STUDENT_GROUPS (8) + MODEL_QUADRANTS×STUDENT_GROUPS (4×8=32)
...WAVES.map((y, i) => ({ label: `Wave ${y}`, subLabel: 'Time series', col: 0, row: i })), const TOTAL_BARS = WAVES.length + STUDENT_GROUPS.length + (MODEL_QUADRANTS.length * STUDENT_GROUPS.length)
...STUDENT_GROUPS.map((sg, i) => ({ label: sg.label, subLabel: 'Rate by group', col: 1, row: i })),
...MODEL_QUADRANTS.flatMap((q, qi) => // Build bar metadata for rendering
STUDENT_GROUPS.map((sg, si) => ({ const allBars = Array.from({ length: TOTAL_BARS }, (_, i) => {
label: `${q.model.split('_m')[0].substring(0,3).toUpperCase()} M${qi+1}`, if (i < WAVES.length) return { subLabel: 'Time series', group: 'waves' }
subLabel: sg.label, if (i < WAVES.length + STUDENT_GROUPS.length) return { subLabel: STUDENT_GROUPS[i - WAVES.length].label, group: 'groups' }
col: 2 + qi % 2, row: si, quadrant: q const quadIdx = Math.floor((i - WAVES.length - STUDENT_GROUPS.length) / STUDENT_GROUPS.length)
})) const sgIdx = (i - WAVES.length - STUDENT_GROUPS.length) % STUDENT_GROUPS.length
), return { subLabel: STUDENT_GROUPS[sgIdx].label, group: 'models', modelIdx: quadIdx }
] })
return ( return (
<div style={{ padding: 'var(--space-5) 0 var(--space-7)' }}> <div style={{ padding: 'var(--space-5) 0 var(--space-7)' }}>
@@ -148,7 +149,7 @@ export default function LoadingAnimation({ district, state }) {
))} ))}
{/* Grid of animated bars — each represents one API call */} {/* Grid of animated bars — each represents one API call */}
{allBars.slice(0, totalCalls || allBars.length).map((bar, i) => { {allBars.map((bar, i) => {
const filled = i < loadedCount const filled = i < loadedCount
// Random height for "data viz" aesthetic (deterministic via seed = index) // Random height for "data viz" aesthetic (deterministic via seed = index)
const heightSeed = ((i * 37) % 100) + 20 // 20–120px range const heightSeed = ((i * 37) % 100) + 20 // 20–120px range
@@ -201,21 +202,10 @@ export default function LoadingAnimation({ district, state }) {
) )
} }
// ——— Inline fetch helper with CORS proxy support ——— // ——— API helper using the centralized client from useApi.js ———
const PROXY_URL = import.meta.env.VITE_PROXY_URL || '' // This ensures consistent error handling, envelope unwrapping, and CORS proxy support.
async function fetchDistrictEstimatesBatch(leaid, year, race = null, sex = null, model = 'unified_m2_mod') { async function fetchDistrictEstimatesBatch(leaid, year, race = null, sex = null, model = 'unified_m2_mod') {
const params = new URLSearchParams({ leaid, year, model }) return api.fetchDistrictEstimates(leaid, { year, model, race, sex })
if (race) params.set('race', race)
if (sex) params.set('sex', sex)
// Route through proxy to bypass CORS restrictions when deployed on pages.civilytics.org
const targetPath = `/api/v1/estimates/${leaid}?${params}`
const url = PROXY_URL ? `${PROXY_URL}?target=${encodeURIComponent(targetPath)}` : `https://crdc-api.civilytics.org/api/v1/estimates/${leaid}?${params}`
const res = await fetch(url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
} }
function getStateName(code) { function getStateName(code) {