T-18: Merge useDashboardCountryData and useMapData into shared base hook

Create useBansByCountry as the shared base hook containing all common
fetch logic, abort-controller pattern, and state management. Both
useDashboardCountryData and useMapData now wrap this base hook:

- useDashboardCountryData: Thin wrapper that calls base hook with autoFetch=true
- useMapData: Wraps base hook with 300ms debounce layer

Changes:
- Create useBansByCountry.ts (base hook with optional autoFetch parameter)
- Refactor useDashboardCountryData.ts to use base hook
- Refactor useMapData.ts to use base hook with debounce wrapper
- Add tests for all three hooks

Benefits:
- Single source of truth for ban-by-country logic
- Bug fixes in base hook apply to both consumers
- Eliminates code duplication (~80 lines reduced)
- Maintains backward compatibility: existing call sites work unchanged

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-25 19:39:51 +02:00
parent 3b527244aa
commit 69a0296c47
6 changed files with 309 additions and 88 deletions

View File

@@ -1,17 +1,17 @@
/**
* `useDashboardCountryData` hook.
*
* Fetches ban-by-country aggregates for dashboard chart components. Unlike
* `useMapData`, this hook has no debouncing or map-specific state.
* Fetches ban-by-country aggregates for dashboard chart components. This is a thin
* wrapper around {@link useBansByCountry} that preserves the original API and naming.
*
* Unlike `useMapData`, this hook has no debouncing.
*
* Re-fetches automatically when `timeRange` or `origin` changes.
*/
import { useCallback, useState } from "react";
import { fetchBansByCountry } from "../api/map";
import { useListData } from "./useListData";
import type { BanOriginFilter, TimeRange } from "../types/ban";
import type { BansByCountryResponse, MapBanItem } from "../types/map";
import type { MapBanItem } from "../types/map";
import { useBansByCountry } from "./useBansByCountry";
/** Return value shape for {@link useDashboardCountryData}. */
export interface UseDashboardCountryDataResult {
@@ -36,6 +36,7 @@ export interface UseDashboardCountryDataResult {
*
* @param timeRange - Time-range preset: `"24h"`, `"7d"`, `"30d"`, or `"365d"`.
* @param origin - Origin filter: `"all"`, `"blocklist"`, or `"selfblock"`.
* @param source - Data source: `"fail2ban"` or `"archive"`.
* @returns Aggregated country data, ban list, loading state, and error.
*/
export function useDashboardCountryData(
@@ -43,30 +44,8 @@ export function useDashboardCountryData(
origin: BanOriginFilter,
source: "fail2ban" | "archive" = "fail2ban",
): UseDashboardCountryDataResult {
const [countries, setCountries] = useState<Record<string, number>>({});
const [countryNames, setCountryNames] = useState<Record<string, string>>({});
const [total, setTotal] = useState<number>(0);
const fetcher = useCallback(
(signal: AbortSignal) =>
fetchBansByCountry(timeRange, origin, source, undefined, signal),
[timeRange, origin, source],
);
const selector = useCallback((response: BansByCountryResponse) => response.bans, []);
const onSuccess = useCallback((response: BansByCountryResponse) => {
setCountries(response.countries);
setCountryNames(response.country_names);
setTotal(response.total);
}, []);
const { items: bans, loading, error, refresh } = useListData<BansByCountryResponse, MapBanItem>({
fetcher,
selector,
errorMessage: "Failed to fetch dashboard country data",
onSuccess,
});
const { countries, countryNames, bans, total, loading, error, refresh } =
useBansByCountry(timeRange, origin, source, undefined);
return { countries, countryNames, bans, total, loading, error, reload: refresh };
}