- backend: GET /api/dashboard/bans/by-jail endpoint - JailBanCount + BansByJailResponse Pydantic models in ban.py - bans_by_jail() service function with origin filter support - Route added to dashboard router - 17 new tests (7 service, 10 router); full suite 497 passed, 83% coverage - frontend: JailDistributionChart component - JailBanCount / BansByJailResponse types in types/ban.ts - dashboardBansByJail endpoint constant in api/endpoints.ts - fetchBansByJail() in api/dashboard.ts - useJailDistribution hook in hooks/useJailDistribution.ts - JailDistributionChart component (horizontal bar chart, Recharts) - DashboardPage: full-width Jail Distribution section below Top Countries
219 lines
6.3 KiB
TypeScript
219 lines
6.3 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 {
|
|
MessageBar,
|
|
MessageBarBody,
|
|
Spinner,
|
|
Text,
|
|
tokens,
|
|
makeStyles,
|
|
} from "@fluentui/react-components";
|
|
import {
|
|
CHART_AXIS_TEXT_TOKEN,
|
|
CHART_GRID_LINE_TOKEN,
|
|
CHART_PALETTE,
|
|
resolveFluentToken,
|
|
} from "../utils/chartTheme";
|
|
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",
|
|
},
|
|
stateWrapper: {
|
|
width: "100%",
|
|
minHeight: `${String(MIN_CHART_HEIGHT)}px`,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
emptyText: {
|
|
color: tokens.colorNeutralForeground3,
|
|
},
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 } = 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 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>
|
|
);
|
|
}
|