/** * useDeriveRegions — keeps the stats' "Spots & Shops by Region" card in sync with * the map by resolving a region for untagged pins. * * It scans the community relay for spots/shops that have no `city`/`country` * tag, reverse-geocodes their pin coordinates ONCE (cached in localStorage by * `lib/derivedRegions`), then invalidates the stats query so the region card * picks the derived regions up. Mount it once on the stats page. * * Nominatim allows ~1 request/second, so the first run on a fresh device takes a * while; `pending` lets the UI say so. Later visits read the cache instantly. */ import { useEffect, useRef, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useSkateRelays } from '@/hooks/useSkateRelays'; import { SKATE_SPOT_KIND, SKATE_SHOP_KIND } from '@/lib/types'; import { coordsOf, getDerivedRegion, fillDerivedRegions } from '@/lib/derivedRegions'; export interface DeriveRegionsState { /** Untagged pins still awaiting a (cached) reverse geocode. */ pending: number; } export function useDeriveRegions(): DeriveRegionsState { const { queryCanonical } = useSkateRelays(); const queryClient = useQueryClient(); const running = useRef(false); const [pending, setPending] = useState(0); const scan = useQuery({ queryKey: ['derive-regions-scan'], // One scan per five minutes is plenty — this only changes when a new // untagged pin appears. staleTime: 5 * 60 * 1000, queryFn: async () => { const signal = AbortSignal.timeout(15000); const [spots, shops] = await Promise.all([ queryCanonical([{ kinds: [SKATE_SPOT_KIND], '#t': ['skatespot'], limit: 2000 }], { signal }).catch(() => []), queryCanonical([{ kinds: [SKATE_SHOP_KIND], limit: 2000 }], { signal }).catch(() => []), ]); const coords: Array<{ lat: number; lng: number }> = []; for (const event of [...spots, ...shops]) { const tagged = event.tags.some( ([name, value]) => (name === 'city' || name === 'country') && !!value?.trim(), ); if (tagged) continue; const c = coordsOf(event); if (c) coords.push(c); } return coords; }, }); useEffect(() => { const coords = scan.data; if (!coords || running.current) return; const misses = coords.filter((c) => !getDerivedRegion(c.lat, c.lng)); setPending(misses.length); if (misses.length === 0) return; running.current = true; void (async () => { let lastRefresh = 0; try { await fillDerivedRegions(misses, (done, total) => { setPending(Math.max(0, total - done)); // Refresh the card as pins resolve (not just at the end) so the numbers // climb while you watch, instead of jumping once. if (done - lastRefresh >= 5) { lastRefresh = done; queryClient.invalidateQueries({ queryKey: ['growth-stats'] }); } }); const unresolved = misses.filter((c) => !getDerivedRegion(c.lat, c.lng)).length; setPending(unresolved); // Final recompute now that every pin has (or has failed to get) a region. queryClient.invalidateQueries({ queryKey: ['growth-stats'] }); } finally { running.current = false; } })(); }, [scan.data, queryClient]); return { pending }; }