Add BanTrendChart component and useBanTrend hook
- Add BanTrendBucket / BanTrendResponse interfaces to types/ban.ts - Add dashboardBansTrend endpoint constant to api/endpoints.ts - Add fetchBanTrend() to api/dashboard.ts - Create useBanTrend hook with abort-safe data fetching - Create BanTrendChart: AreaChart with gradient fill, dynamic X-axis labels per range, custom tooltip, loading/error/empty states - tsc --noEmit and ESLint pass with zero warnings
This commit is contained in:
270
frontend/src/components/BanTrendChart.tsx
Normal file
270
frontend/src/components/BanTrendChart.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* BanTrendChart — area chart showing the number of bans over time.
|
||||
*
|
||||
* Calls `useBanTrend` internally and handles loading, error, and empty states.
|
||||
*/
|
||||
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
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 { useBanTrend } from "../hooks/useBanTrend";
|
||||
import type { BanOriginFilter, TimeRange } from "../types/ban";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Minimum chart height in pixels. */
|
||||
const MIN_CHART_HEIGHT = 220;
|
||||
|
||||
/** Number of X-axis ticks to skip per time-range preset. */
|
||||
const TICK_INTERVAL: Record<TimeRange, number> = {
|
||||
"24h": 3, // show every 4th tick → ~6 visible
|
||||
"7d": 3, // show every 4th tick → 7 visible
|
||||
"30d": 4, // show every 5th tick → 6 visible
|
||||
"365d": 7, // show every 8th tick → ~7 visible
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Props for {@link BanTrendChart}. */
|
||||
interface BanTrendChartProps {
|
||||
/** Time-range preset controlling the query window. */
|
||||
timeRange: TimeRange;
|
||||
/** Origin filter controlling which bans are included. */
|
||||
origin: BanOriginFilter;
|
||||
}
|
||||
|
||||
/** Internal chart data point shape. */
|
||||
interface TrendEntry {
|
||||
/** ISO 8601 UTC timestamp — used by the tooltip. */
|
||||
timestamp: string;
|
||||
/** Formatted string shown on the X-axis tick. */
|
||||
label: string;
|
||||
/** Number of bans in this bucket. */
|
||||
count: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Format an ISO 8601 timestamp as a compact human-readable X-axis label.
|
||||
*
|
||||
* @param timestamp - ISO 8601 UTC string.
|
||||
* @param range - Active time-range preset.
|
||||
* @returns Label string, e.g. `"Mon 14:00"`, `"Mar 5"`.
|
||||
*/
|
||||
function formatXLabel(timestamp: string, range: TimeRange): string {
|
||||
const d = new Date(timestamp);
|
||||
if (range === "24h") {
|
||||
const day = d.toLocaleDateString("en-US", { weekday: "short" });
|
||||
const time = d.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
return `${day} ${time}`;
|
||||
}
|
||||
if (range === "7d") {
|
||||
const date = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
const time = d.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
return `${date} ${time}`;
|
||||
}
|
||||
// 30d / 365d
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the chart data array from the raw bucket list.
|
||||
*
|
||||
* @param buckets - Ordered list of `{timestamp, count}` buckets.
|
||||
* @param timeRange - Active preset, used to format axis labels.
|
||||
* @returns Array of `TrendEntry` objects ready for Recharts.
|
||||
*/
|
||||
function buildEntries(
|
||||
buckets: Array<{ timestamp: string; count: number }>,
|
||||
timeRange: TimeRange,
|
||||
): TrendEntry[] {
|
||||
return buckets.map((b) => ({
|
||||
timestamp: b.timestamp,
|
||||
label: formatXLabel(b.timestamp, timeRange),
|
||||
count: b.count,
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom tooltip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TrendTooltip(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 { timestamp, count } = entry.payload as TrendEntry;
|
||||
const d = new Date(timestamp);
|
||||
const label = d.toLocaleString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
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>{label}</strong>
|
||||
<br />
|
||||
{String(count)} ban{count === 1 ? "" : "s"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Area chart showing ban counts over time.
|
||||
*
|
||||
* Fetches data via `useBanTrend` and handles loading, error, and empty states
|
||||
* inline so the parent only needs to pass filter props.
|
||||
*
|
||||
* @param props - `timeRange` and `origin` filter props.
|
||||
*/
|
||||
export function BanTrendChart({
|
||||
timeRange,
|
||||
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 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);
|
||||
const gridColour = resolveFluentToken(CHART_GRID_LINE_TOKEN);
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user