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
98 lines
3.3 KiB
TypeScript
98 lines
3.3 KiB
TypeScript
/**
|
|
* `useDashboardCountryData` hook.
|
|
*
|
|
* Fetches ban-by-country aggregates for dashboard chart components. Unlike
|
|
* `useMapData`, this hook has no debouncing or map-specific state.
|
|
*
|
|
* Re-fetches automatically when `timeRange` or `origin` changes.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { fetchBansByCountry } from "../api/map";
|
|
import type { DashboardBanItem, BanOriginFilter, TimeRange } from "../types/ban";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Return type
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Return value shape for {@link useDashboardCountryData}. */
|
|
export interface UseDashboardCountryDataResult {
|
|
/** ISO alpha-2 country code → ban count. */
|
|
countries: Record<string, number>;
|
|
/** ISO alpha-2 country code → human-readable country name. */
|
|
countryNames: Record<string, string>;
|
|
/** All ban records in the selected window. */
|
|
bans: DashboardBanItem[];
|
|
/** Total ban count in the window. */
|
|
total: number;
|
|
/** True while a fetch is in flight. */
|
|
isLoading: boolean;
|
|
/** Error message or `null`. */
|
|
error: string | null;
|
|
/** Re-fetch the data immediately. */
|
|
reload: () => void;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Hook
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Fetch and expose ban-by-country data for dashboard charts.
|
|
*
|
|
* @param timeRange - Time-range preset: `"24h"`, `"7d"`, `"30d"`, or `"365d"`.
|
|
* @param origin - Origin filter: `"all"`, `"blocklist"`, or `"selfblock"`.
|
|
* @returns Aggregated country data, ban list, loading state, and error.
|
|
*/
|
|
export function useDashboardCountryData(
|
|
timeRange: TimeRange,
|
|
origin: BanOriginFilter,
|
|
): UseDashboardCountryDataResult {
|
|
const [countries, setCountries] = useState<Record<string, number>>({});
|
|
const [countryNames, setCountryNames] = useState<Record<string, string>>({});
|
|
const [bans, setBans] = useState<DashboardBanItem[]>([]);
|
|
const [total, setTotal] = useState<number>(0);
|
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const abortRef = useRef<AbortController | null>(null);
|
|
|
|
const load = useCallback((): void => {
|
|
// Abort any in-flight request.
|
|
abortRef.current?.abort();
|
|
const controller = new AbortController();
|
|
abortRef.current = controller;
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
fetchBansByCountry(timeRange, origin)
|
|
.then((data) => {
|
|
if (controller.signal.aborted) return;
|
|
setCountries(data.countries);
|
|
setCountryNames(data.country_names);
|
|
// MapBanItem and DashboardBanItem are structurally identical.
|
|
setBans(data.bans as DashboardBanItem[]);
|
|
setTotal(data.total);
|
|
})
|
|
.catch((err: unknown) => {
|
|
if (controller.signal.aborted) return;
|
|
setError(err instanceof Error ? err.message : "Failed to fetch data");
|
|
})
|
|
.finally(() => {
|
|
if (!controller.signal.aborted) {
|
|
setIsLoading(false);
|
|
}
|
|
});
|
|
}, [timeRange, origin]);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
return (): void => {
|
|
abortRef.current?.abort();
|
|
};
|
|
}, [load]);
|
|
|
|
return { countries, countryNames, bans, total, isLoading, error, reload: load };
|
|
}
|