/** * Region backfill — admin tooling * ───────────────────────────────────────────────────────────────────────────── * Spots and shops created before reverse geocoding landed (2026-10-01) have a * geohash/coordinates but no `city` / `country` tag. On the map they render * normally; in `/stats` they can only ever be "Uncategorised". The community * stats therefore under-count a country relative to the map's pins. * * This hook finds the LOGGED-IN user's own untagged spots/shops, re-derives the * region tags from each pin's coordinates (the same `reverseGeocodeRegions()` * that create/edit use) and republishes the addressable event with those tags * added. It only ever touches the current user's content — an addressable event * can only be replaced by its author's key. * * Faithful to the original event: every existing tag is preserved; only an * out-of-date `city`/`country`/`country_code` is replaced. */ import { useCallback, useMemo, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; import ngeohash from 'ngeohash'; import type { NostrEvent } from '@nostrify/nostrify'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useSkateRelays } from '@/hooks/useSkateRelays'; import { SKATE_SPOT_KIND, SKATE_SHOP_KIND } from '@/lib/types'; import { reverseGeocodeRegions } from '@/lib/geocodeRegions'; /** Nominatim's usage policy allows at most one request per second. */ const GEOCODE_SPACING_MS = 1100; const REGION_TAG_NAMES = new Set(['city', 'country', 'country_code']); export interface BackfillCandidate { id: string; kind: number; identifier: string; title: string; } export interface BackfillResult { updated: number; skipped: number; failed: number; } /** Same definition the stats use to decide a location is "tagged". */ function hasRegionTag(event: NostrEvent): boolean { return event.tags.some( ([name, value]) => (name === 'city' || name === 'country') && !!value?.trim(), ); } /** Coordinates from explicit lat/lon tags, else decoded from the geohash. */ function coordsOf(event: NostrEvent): { lat: number; lng: number } | null { const latTag = event.tags.find(([name]) => name === 'lat')?.[1]; const lonTag = event.tags.find(([name]) => name === 'lon')?.[1]; if (latTag && lonTag) { const lat = parseFloat(latTag); const lng = parseFloat(lonTag); if ( Number.isFinite(lat) && Number.isFinite(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180 ) { return { lat, lng }; } } const geohash = event.tags.find(([name]) => name === 'g')?.[1]; if (geohash) { try { const { latitude, longitude } = ngeohash.decode(geohash); if (Number.isFinite(latitude) && Number.isFinite(longitude)) { return { lat: latitude, lng: longitude }; } } catch { /* not a decodable geohash */ } } return null; } export function useRegionBackfill(enabled = true) { const { user } = useCurrentUser(); const { nostr } = useNostr(); const { querySkateContent, getWriteRelays } = useSkateRelays(); const queryClient = useQueryClient(); const [progress, setProgress] = useState<{ done: number; total: number } | null>(null); const eventsQuery = useQuery({ queryKey: ['region-backfill', user?.pubkey ?? 'anon'], enabled: enabled && !!user, staleTime: 0, queryFn: async () => { if (!user) return []; const signal = AbortSignal.timeout(15000); return querySkateContent( [{ kinds: [SKATE_SPOT_KIND, SKATE_SHOP_KIND], authors: [user.pubkey], limit: 500 }], { signal }, ); }, }); const candidates = useMemo( () => (eventsQuery.data ?? []) .filter((event) => !hasRegionTag(event)) .map((event) => ({ id: event.id, kind: event.kind, identifier: event.tags.find(([name]) => name === 'd')?.[1] ?? '', title: event.tags.find(([name]) => name === 'title')?.[1] ?? '(untitled)', })), [eventsQuery.data], ); const backfill = useCallback(async (): Promise => { if (!user) throw new Error('Log in first.'); const events = (eventsQuery.data ?? []).filter((event) => !hasRegionTag(event)); const relays = getWriteRelays(); const result: BackfillResult = { updated: 0, skipped: 0, failed: 0 }; setProgress({ done: 0, total: events.length }); for (let i = 0; i < events.length; i += 1) { const event = events[i]; try { const coords = coordsOf(event); if (!coords) { result.skipped += 1; continue; } const geo = await reverseGeocodeRegions(coords.lat, coords.lng); if (!geo.city && !geo.country && !geo.countryCode) { result.skipped += 1; continue; } // Preserve every existing tag; only refresh the region tags. const tags = event.tags.filter(([name]) => !REGION_TAG_NAMES.has(name)); if (geo.city) tags.push(['city', geo.city]); if (geo.country) tags.push(['country', geo.country]); if (geo.countryCode) tags.push(['country_code', geo.countryCode]); const signed = await user.signer.signEvent({ kind: event.kind, content: event.content, created_at: Math.floor(Date.now() / 1000), tags, }); // Publish where the stats READ (community relay + the author's relays). const published = await Promise.allSettled( relays.map((url) => nostr.relay(url).event(signed, { signal: AbortSignal.timeout(8000) }), ), ); if (published.some((r) => r.status === 'fulfilled')) result.updated += 1; else result.failed += 1; } catch { result.failed += 1; } finally { setProgress({ done: i + 1, total: events.length }); } // Respect Nominatim's 1 req/s limit (no need to wait after the last one). if (i < events.length - 1) { await new Promise((resolve) => setTimeout(resolve, GEOCODE_SPACING_MS)); } } setProgress(null); queryClient.invalidateQueries({ queryKey: ['skate-spots'] }); queryClient.invalidateQueries({ queryKey: ['growth-stats'] }); queryClient.invalidateQueries({ queryKey: ['region-backfill'] }); return result; }, [user, eventsQuery.data, nostr, getWriteRelays, queryClient]); return { candidates, isLoading: eventsQuery.isLoading, backfill, progress, isRunning: progress !== null, }; }