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

@@ -15,10 +15,6 @@ import {
} from "recharts";
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
import {
MessageBar,
MessageBarBody,
Spinner,
Text,
tokens,
makeStyles,
} from "@fluentui/react-components";
@@ -28,6 +24,7 @@ import {
CHART_PALETTE,
resolveFluentToken,
} from "../utils/chartTheme";
import { ChartStateWrapper } from "./ChartStateWrapper";
import { useBanTrend } from "../hooks/useBanTrend";
import type { BanOriginFilter, TimeRange } from "../types/ban";
@@ -73,19 +70,9 @@ interface TrendEntry {
// ---------------------------------------------------------------------------
const useStyles = makeStyles({
wrapper: {
width: "100%",
minHeight: `${String(MIN_CHART_HEIGHT)}px`,
display: "flex",
alignItems: "center",
justifyContent: "center",
},
chartWrapper: {
width: "100%",
},
emptyText: {
color: tokens.colorNeutralForeground3,
},
});
// ---------------------------------------------------------------------------
@@ -190,6 +177,12 @@ function TrendTooltip(props: TooltipContentProps): React.JSX.Element | null {
* Fetches data via `useBanTrend` and handles loading, error, and empty states
* inline so the parent only needs to pass filter props.
*
/**
* Area chart showing ban counts over time.
*
* Fetches data via `useBanTrend` and delegates loading, error, and empty states
* to `ChartStateWrapper`.
*
* @param props - `timeRange` and `origin` filter props.
*/
export function BanTrendChart({
@@ -197,33 +190,9 @@ export function BanTrendChart({
origin,
}: BanTrendChartProps): React.JSX.Element {
const styles = useStyles();
const { buckets, isLoading, error } = useBanTrend(timeRange, origin);
if (error != null) {
return (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
);
}
if (isLoading) {
return (
<div className={styles.wrapper}>
<Spinner label="Loading trend data…" />
</div>
);
}
const { buckets, isLoading, error, reload } = useBanTrend(timeRange, origin);
const isEmpty = buckets.every((b) => b.count === 0);
if (isEmpty) {
return (
<div className={styles.wrapper}>
<Text className={styles.emptyText}>No bans in this time range.</Text>
</div>
);
}
const entries = buildEntries(buckets, timeRange);
const primaryColour = resolveFluentToken(CHART_PALETTE[0] ?? "");
const axisColour = resolveFluentToken(CHART_AXIS_TEXT_TOKEN);
@@ -231,40 +200,50 @@ export function BanTrendChart({
const tickInterval = TICK_INTERVAL[timeRange];
return (
<div className={styles.chartWrapper} style={{ height: MIN_CHART_HEIGHT }}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={entries} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="trendAreaFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={primaryColour} stopOpacity={0.4} />
<stop offset="95%" stopColor={primaryColour} stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke={gridColour} />
<XAxis
dataKey="label"
tick={{ fill: axisColour, fontSize: 11 }}
interval={tickInterval}
tickLine={false}
/>
<YAxis
allowDecimals={false}
tick={{ fill: axisColour, fontSize: 11 }}
tickLine={false}
axisLine={false}
/>
<Tooltip content={TrendTooltip} />
<Area
type="monotone"
dataKey="count"
stroke={primaryColour}
strokeWidth={2}
fill="url(#trendAreaFill)"
dot={false}
activeDot={{ r: 4, fill: primaryColour }}
/>
</AreaChart>
</ResponsiveContainer>
</div>
<ChartStateWrapper
isLoading={isLoading}
error={error}
onRetry={reload}
isEmpty={isEmpty}
emptyMessage="No bans in this time range."
minHeight={MIN_CHART_HEIGHT}
>
<div className={styles.chartWrapper} style={{ height: MIN_CHART_HEIGHT }}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={entries} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="trendAreaFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={primaryColour} stopOpacity={0.4} />
<stop offset="95%" stopColor={primaryColour} stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke={gridColour} />
<XAxis
dataKey="label"
tick={{ fill: axisColour, fontSize: 11 }}
interval={tickInterval}
tickLine={false}
/>
<YAxis
allowDecimals={false}
tick={{ fill: axisColour, fontSize: 11 }}
tickLine={false}
axisLine={false}
/>
<Tooltip content={TrendTooltip} />
<Area
type="monotone"
dataKey="count"
stroke={primaryColour}
strokeWidth={2}
fill="url(#trendAreaFill)"
dot={false}
activeDot={{ r: 4, fill: primaryColour }}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</ChartStateWrapper>
);
}