Polish dashboard charts and add frontend tests (Stage 6)

Task 6.1 - Consistent loading/error/empty states across all charts:
- Add ChartStateWrapper shared component with Spinner, error MessageBar
  + Retry button, and friendly empty message
- Expose reload() in useBanTrend, useJailDistribution,
  useDashboardCountryData hooks
- Update BanTrendChart and JailDistributionChart to use ChartStateWrapper
- Add empty state to TopCountriesBarChart and TopCountriesPieChart
- Replace manual loading/error logic in DashboardPage with ChartStateWrapper

Task 6.2 - Frontend tests (5 files, 20 tests):
- Install Vitest v4, jsdom, @testing-library/react, @testing-library/jest-dom
- Add vitest.config.ts (separate from vite.config.ts to avoid Vite v5/v7 clash)
- Add src/setupTests.ts with jest-dom matchers and ResizeObserver/matchMedia stubs
- Tests: ChartStateWrapper (7), BanTrendChart (4), JailDistributionChart (4),
  TopCountriesPieChart (2), TopCountriesBarChart (3)

Task 6.3 - Full QA:
- ruff: clean
- mypy --strict: 52 files, no issues
- pytest: 497 passed
- tsc --noEmit: clean
- eslint: clean (added test-file override for explicit-function-return-type)
- vite build: success
This commit is contained in:
2026-03-11 17:25:28 +01:00
parent fe8eefa173
commit 576ec43854
21 changed files with 2628 additions and 167 deletions

View File

@@ -17,10 +17,6 @@ import {
} from "recharts";
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
import {
MessageBar,
MessageBarBody,
Spinner,
Text,
tokens,
makeStyles,
} from "@fluentui/react-components";
@@ -30,6 +26,7 @@ import {
CHART_PALETTE,
resolveFluentToken,
} from "../utils/chartTheme";
import { ChartStateWrapper } from "./ChartStateWrapper";
import { useJailDistribution } from "../hooks/useJailDistribution";
import type { BanOriginFilter, TimeRange } from "../types/ban";
@@ -77,16 +74,6 @@ const useStyles = makeStyles({
width: "100%",
overflowX: "hidden",
},
stateWrapper: {
width: "100%",
minHeight: `${String(MIN_CHART_HEIGHT)}px`,
display: "flex",
alignItems: "center",
justifyContent: "center",
},
emptyText: {
color: tokens.colorNeutralForeground3,
},
});
// ---------------------------------------------------------------------------
@@ -153,66 +140,50 @@ export function JailDistributionChart({
origin,
}: JailDistributionChartProps): React.JSX.Element {
const styles = useStyles();
const { jails, isLoading, error } = useJailDistribution(timeRange, origin);
if (error != null) {
return (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
);
}
if (isLoading) {
return (
<div className={styles.stateWrapper}>
<Spinner label="Loading chart data…" />
</div>
);
}
if (jails.length === 0) {
return (
<div className={styles.stateWrapper}>
<Text className={styles.emptyText}>No ban data for the selected period.</Text>
</div>
);
}
const { jails, isLoading, error, reload } = useJailDistribution(timeRange, origin);
const entries = buildEntries(jails);
const chartHeight = Math.max(entries.length * BAR_HEIGHT_PX, MIN_CHART_HEIGHT);
const primaryColour = resolveFluentToken(CHART_PALETTE[0] ?? "");
const axisColour = resolveFluentToken(CHART_AXIS_TEXT_TOKEN);
const gridColour = resolveFluentToken(CHART_GRID_LINE_TOKEN);
return (
<div className={styles.wrapper} style={{ height: chartHeight }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart
layout="vertical"
data={entries}
margin={{ top: 4, right: 16, bottom: 4, left: 8 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={gridColour} horizontal={false} />
<XAxis
type="number"
tick={{ fill: axisColour, fontSize: 12 }}
axisLine={{ stroke: gridColour }}
tickLine={false}
/>
<YAxis
type="category"
dataKey="name"
width={160}
tick={{ fill: axisColour, fontSize: 12 }}
axisLine={false}
tickLine={false}
/>
<Tooltip content={JailTooltip} cursor={{ fill: "transparent" }} />
<Bar dataKey="value" fill={primaryColour} radius={[0, 3, 3, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<ChartStateWrapper
isLoading={isLoading}
error={error}
onRetry={reload}
isEmpty={jails.length === 0}
emptyMessage="No bans in this time range."
minHeight={MIN_CHART_HEIGHT}
>
<div className={styles.wrapper} style={{ height: chartHeight }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart
layout="vertical"
data={entries}
margin={{ top: 4, right: 16, bottom: 4, left: 8 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={gridColour} horizontal={false} />
<XAxis
type="number"
tick={{ fill: axisColour, fontSize: 12 }}
axisLine={{ stroke: gridColour }}
tickLine={false}
/>
<YAxis
type="category"
dataKey="name"
width={160}
tick={{ fill: axisColour, fontSize: 12 }}
axisLine={false}
tickLine={false}
/>
<Tooltip content={JailTooltip} cursor={{ fill: "transparent" }} />
<Bar dataKey="value" fill={primaryColour} radius={[0, 3, 3, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</ChartStateWrapper>
);
}