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:
2026-03-11 16:48:49 +01:00
parent 9242b4709a
commit 259ff17eba
6 changed files with 416 additions and 2 deletions

View File

@@ -0,0 +1,83 @@
/**
* `useBanTrend` hook.
*
* Fetches time-bucketed ban counts for the trend chart.
* Re-fetches automatically when `timeRange` or `origin` changes.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchBanTrend } from "../api/dashboard";
import type { BanTrendBucket, BanOriginFilter, TimeRange } from "../types/ban";
// ---------------------------------------------------------------------------
// Return type
// ---------------------------------------------------------------------------
/** Return value shape for {@link useBanTrend}. */
export interface UseBanTrendResult {
/** Ordered list of time buckets with ban counts. */
buckets: BanTrendBucket[];
/** Human-readable bucket size label, e.g. `"1h"`. */
bucketSize: string;
/** True while a fetch is in flight. */
isLoading: boolean;
/** Error message or `null`. */
error: string | null;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
/**
* Fetch and expose ban trend data for the `BanTrendChart` component.
*
* @param timeRange - Time-range preset: `"24h"`, `"7d"`, `"30d"`, or `"365d"`.
* @param origin - Origin filter: `"all"`, `"blocklist"`, or `"selfblock"`.
* @returns Ordered bucket list, bucket-size label, loading state, and error.
*/
export function useBanTrend(
timeRange: TimeRange,
origin: BanOriginFilter,
): UseBanTrendResult {
const [buckets, setBuckets] = useState<BanTrendBucket[]>([]);
const [bucketSize, setBucketSize] = useState<string>("1h");
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const load = useCallback((): void => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setIsLoading(true);
setError(null);
fetchBanTrend(timeRange, origin)
.then((data) => {
if (controller.signal.aborted) return;
setBuckets(data.buckets);
setBucketSize(data.bucket_size);
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
setError(err instanceof Error ? err.message : "Failed to fetch trend data");
})
.finally(() => {
if (!controller.signal.aborted) {
setIsLoading(false);
}
});
}, [timeRange, origin]);
useEffect(() => {
load();
return (): void => {
abortRef.current?.abort();
};
}, [load]);
return { buckets, bucketSize, isLoading, error };
}