diff --git a/src/app/stats/page.tsx b/src/app/stats/page.tsx index aaa0517..13f0754 100644 --- a/src/app/stats/page.tsx +++ b/src/app/stats/page.tsx @@ -1,54 +1,44 @@ "use client"; -import { useEffect, useState } from "react"; -import { buildApiUrl } from "@/lib/api"; import { useConfig } from "@/components/ConfigContext"; import { getRegionConfig } from "@/lib/regions"; +import { + useTotalNodes, + useNodesOverTime, + usePopularChannels, + useRepeaterPrefixes, + useUnusedPrefixes +} from "@/hooks/useStats"; export default function StatsPage() { const { config } = useConfig(); - const [totalNodes, setTotalNodes] = useState(null); - const [nodesOverTime, setNodesOverTime] = useState([]); - const [popularChannels, setPopularChannels] = useState([]); - const [repeaterPrefixes, setRepeaterPrefixes] = useState([]); - const [unusedPrefixes, setUnusedPrefixes] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - async function fetchStats() { - setLoading(true); - - // Build API URLs with region parameter if selected - const regionParam = config?.selectedRegion ? `?region=${encodeURIComponent(config.selectedRegion)}` : ''; - - const [totalNodesRes, nodesOverTimeRes, popularChannelsRes, repeaterPrefixesRes] = await Promise.all([ - fetch(buildApiUrl(`/api/stats/total-nodes${regionParam}`)).then(r => r.json()), - fetch(buildApiUrl(`/api/stats/nodes-over-time${regionParam}`)).then(r => r.json()), - fetch(buildApiUrl(`/api/stats/popular-channels${regionParam}`)).then(r => r.json()), - fetch(buildApiUrl(`/api/stats/repeater-prefixes${regionParam}`)).then(r => r.json()), - ]); - setTotalNodes(totalNodesRes.total_nodes ?? null); - setNodesOverTime(nodesOverTimeRes.data ?? []); - setPopularChannels(popularChannelsRes.data ?? []); - setRepeaterPrefixes(repeaterPrefixesRes.data ?? []); - - // Generate all possible 2-character hex prefixes (01-FE, excluding 00 and FF) - const allPrefixes = []; - for (let i = 1; i < 255; i++) { - allPrefixes.push(i.toString(16).padStart(2, '0').toUpperCase()); - } - - // Get used prefixes from the API response - const usedPrefixes = new Set((repeaterPrefixesRes.data ?? []).map((row: any) => row.prefix)); - - // Find unused prefixes - const unused = allPrefixes.filter(prefix => !usedPrefixes.has(prefix)); - setUnusedPrefixes(unused); - - setLoading(false); - } - fetchStats(); - }, [config?.selectedRegion]); + const region = config?.selectedRegion; + + // Use TanStack Query hooks for data fetching + const totalNodesQuery = useTotalNodes(region); + const nodesOverTimeQuery = useNodesOverTime(region); + const popularChannelsQuery = usePopularChannels(region); + const repeaterPrefixesQuery = useRepeaterPrefixes(region); + const unusedPrefixesQuery = useUnusedPrefixes(region); + + // Combine loading states - show loading if any query is loading + const isLoading = totalNodesQuery.isLoading || + nodesOverTimeQuery.isLoading || + popularChannelsQuery.isLoading || + repeaterPrefixesQuery.isLoading; + + // Combine error states + const error = totalNodesQuery.error || + nodesOverTimeQuery.error || + popularChannelsQuery.error || + repeaterPrefixesQuery.error; + + // Extract data with fallbacks + const totalNodes = totalNodesQuery.data?.total_nodes ?? null; + const nodesOverTime = nodesOverTimeQuery.data?.data ?? []; + const popularChannels = popularChannelsQuery.data?.data ?? []; + const repeaterPrefixes = repeaterPrefixesQuery.data?.data ?? []; + const unusedPrefixes = unusedPrefixesQuery.data ?? []; // Get the friendly name for the selected region const regionFriendlyName = config?.selectedRegion @@ -65,8 +55,16 @@ export default function StatsPage() { )} - {loading ? ( -
Loading...
+ {error ? ( +
+

Error Loading Stats

+

{error.message || 'An error occurred while loading statistics.'}

+
+ ) : isLoading ? ( +
+
+

Loading statistics...

+
) : ( <>
diff --git a/src/hooks/useStats.ts b/src/hooks/useStats.ts new file mode 100644 index 0000000..eb6d3cc --- /dev/null +++ b/src/hooks/useStats.ts @@ -0,0 +1,151 @@ +import React from "react"; +import { useQuery } from "@tanstack/react-query"; +import { buildApiUrl } from "@/lib/api"; + +interface TotalNodesResponse { + total_nodes: number; +} + +interface NodesOverTimeRow { + day: string; + cumulative_unique_nodes: number; + nodes_with_location: number; + nodes_without_location: number; + repeaters: number; + room_servers: number; +} + +interface NodesOverTimeResponse { + data: NodesOverTimeRow[]; +} + +interface PopularChannelRow { + channel_hash: string; + message_count: number; +} + +interface PopularChannelsResponse { + data: PopularChannelRow[]; +} + +interface RepeaterPrefixRow { + prefix: string; + node_names: string[]; +} + +interface RepeaterPrefixesResponse { + data: RepeaterPrefixRow[]; +} + +const STALE_TIME = 5 * 60 * 1000; // 5 minutes +const GC_TIME = 10 * 60 * 1000; // 10 minutes + +export function useTotalNodes(region?: string) { + return useQuery({ + queryKey: ['stats', 'total-nodes', region], + queryFn: async ({ signal }) => { + const regionParam = region ? `?region=${encodeURIComponent(region)}` : ''; + const response = await fetch(buildApiUrl(`/api/stats/total-nodes${regionParam}`), { + signal + }); + + if (!response.ok) { + throw new Error(`Failed to fetch total nodes: ${response.statusText}`); + } + + return response.json(); + }, + staleTime: STALE_TIME, + gcTime: GC_TIME, + retry: 2, + }); +} + +export function useNodesOverTime(region?: string) { + return useQuery({ + queryKey: ['stats', 'nodes-over-time', region], + queryFn: async ({ signal }) => { + const regionParam = region ? `?region=${encodeURIComponent(region)}` : ''; + const response = await fetch(buildApiUrl(`/api/stats/nodes-over-time${regionParam}`), { + signal + }); + + if (!response.ok) { + throw new Error(`Failed to fetch nodes over time: ${response.statusText}`); + } + + return response.json(); + }, + staleTime: STALE_TIME, + gcTime: GC_TIME, + retry: 2, + }); +} + +export function usePopularChannels(region?: string) { + return useQuery({ + queryKey: ['stats', 'popular-channels', region], + queryFn: async ({ signal }) => { + const regionParam = region ? `?region=${encodeURIComponent(region)}` : ''; + const response = await fetch(buildApiUrl(`/api/stats/popular-channels${regionParam}`), { + signal + }); + + if (!response.ok) { + throw new Error(`Failed to fetch popular channels: ${response.statusText}`); + } + + return response.json(); + }, + staleTime: STALE_TIME, + gcTime: GC_TIME, + retry: 2, + }); +} + +export function useRepeaterPrefixes(region?: string) { + return useQuery({ + queryKey: ['stats', 'repeater-prefixes', region], + queryFn: async ({ signal }) => { + const regionParam = region ? `?region=${encodeURIComponent(region)}` : ''; + const response = await fetch(buildApiUrl(`/api/stats/repeater-prefixes${regionParam}`), { + signal + }); + + if (!response.ok) { + throw new Error(`Failed to fetch repeater prefixes: ${response.statusText}`); + } + + return response.json(); + }, + staleTime: STALE_TIME, + gcTime: GC_TIME, + retry: 2, + }); +} + +export function useUnusedPrefixes(region?: string) { + const { data: repeaterPrefixesData, isLoading, error } = useRepeaterPrefixes(region); + + const unusedPrefixes = React.useMemo(() => { + if (!repeaterPrefixesData?.data) return []; + + // Generate all possible 2-character hex prefixes (01-FE, excluding 00 and FF) + const allPrefixes = []; + for (let i = 1; i < 255; i++) { + allPrefixes.push(i.toString(16).padStart(2, '0').toUpperCase()); + } + + // Get used prefixes from the API response + const usedPrefixes = new Set(repeaterPrefixesData.data.map(row => row.prefix)); + + // Find unused prefixes + return allPrefixes.filter(prefix => !usedPrefixes.has(prefix)); + }, [repeaterPrefixesData?.data]); + + return { + data: unusedPrefixes, + isLoading, + error, + }; +}