Files
BanGUI/frontend/src/components/JailDistributionChart.tsx
Lukas 576ec43854 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
2026-03-11 17:25:28 +01:00

190 lines
5.9 KiB
TypeScript

/**
* JailDistributionChart — horizontal bar chart showing ban counts per jail,
* sorted descending, for the selected time window.
*
* Calls `useJailDistribution` internally and handles loading, error, and
* empty states so the parent only needs to pass filter props.
*/
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
import {
tokens,
makeStyles,
} from "@fluentui/react-components";
import {
CHART_AXIS_TEXT_TOKEN,
CHART_GRID_LINE_TOKEN,
CHART_PALETTE,
resolveFluentToken,
} from "../utils/chartTheme";
import { ChartStateWrapper } from "./ChartStateWrapper";
import { useJailDistribution } from "../hooks/useJailDistribution";
import type { BanOriginFilter, TimeRange } from "../types/ban";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Maximum characters before truncating a jail name on the Y-axis. */
const MAX_LABEL_LENGTH = 24;
/** Height per bar row in pixels. */
const BAR_HEIGHT_PX = 36;
/** Minimum chart height in pixels. */
const MIN_CHART_HEIGHT = 180;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Props for {@link JailDistributionChart}. */
interface JailDistributionChartProps {
/** Time-range preset controlling the query window. */
timeRange: TimeRange;
/** Origin filter controlling which bans are included. */
origin: BanOriginFilter;
}
/** Internal chart data point shape. */
interface BarEntry {
/** Full jail name used by Tooltip. */
fullName: string;
/** Truncated name displayed on the Y-axis. */
name: string;
/** Ban count. */
value: number;
}
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const useStyles = makeStyles({
wrapper: {
width: "100%",
overflowX: "hidden",
},
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Build the chart dataset from the raw jail list.
*
* @param jails - Ordered list of `{jail, count}` items from the API.
* @returns Array of `BarEntry` objects ready for Recharts.
*/
function buildEntries(jails: Array<{ jail: string; count: number }>): BarEntry[] {
return jails.map(({ jail, count }) => ({
fullName: jail,
name: jail.length > MAX_LABEL_LENGTH ? `${jail.slice(0, MAX_LABEL_LENGTH)}` : jail,
value: count,
}));
}
// ---------------------------------------------------------------------------
// Custom tooltip
// ---------------------------------------------------------------------------
function JailTooltip(props: TooltipContentProps): React.JSX.Element | null {
const { active, payload } = props;
if (!active || payload.length === 0) return null;
const entry = payload[0];
if (entry == null) return null;
const { fullName, value } = entry.payload as BarEntry;
return (
<div
style={{
backgroundColor: resolveFluentToken(tokens.colorNeutralBackground1),
border: `1px solid ${resolveFluentToken(tokens.colorNeutralStroke2)}`,
borderRadius: "4px",
padding: "8px 12px",
color: resolveFluentToken(tokens.colorNeutralForeground1),
fontSize: "13px",
}}
>
<strong>{fullName}</strong>
<br />
{String(value)} ban{value === 1 ? "" : "s"}
</div>
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Horizontal bar chart showing ban counts per jail for the selected window.
*
* Fetches data via `useJailDistribution` and renders loading, error, and
* empty states inline.
*
* @param props - `timeRange` and `origin` filter props.
*/
export function JailDistributionChart({
timeRange,
origin,
}: JailDistributionChartProps): React.JSX.Element {
const styles = useStyles();
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 (
<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>
);
}