From dc0ae9bc321402fb0932cd21d18ee01c742a5874 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Thu, 1 Oct 2026 13:58:21 +0700 Subject: [PATCH] feat(stats): admin backfill for legacy untagged spots/shops Spots created before reverse geocoding have coordinates but no city/country tag, so the map draws them while /stats can only show "Uncategorised". Counted on the canonical relay: 54 raw -> 43 after admin-hide -> 13 tagged / 30 uncategorised, and 21 of the 30 are the two admin accounts' own pins. useRegionBackfill finds the logged-in user's own untagged spots/shops, decodes each pin's coords (lat/lon else geohash), reverse-geocodes via the shared reverseGeocodeRegions(), and republishes the addressable event with the region tags added - preserving every existing tag. Publishes to getWriteRelays() (community + author) so the canonical stats see it, spaced 1.1s for Nominatim's 1 req/s policy. Surfaced as an admin-only RegionBackfillCard in Settings -> Advanced. --- AGENTS.md | 21 +++ src/components/RegionBackfillCard.tsx | 117 ++++++++++++++++ src/hooks/useRegionBackfill.ts | 185 ++++++++++++++++++++++++++ src/pages/SettingsPage.tsx | 2 + 4 files changed, 325 insertions(+) create mode 100644 src/components/RegionBackfillCard.tsx create mode 100644 src/hooks/useRegionBackfill.ts diff --git a/AGENTS.md b/AGENTS.md index 85f349b..8e7a9df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -884,6 +884,27 @@ session with **no other memory of this project**. So: editor is added. Related: spots created before the tags existed stay "Uncategorised" in `/stats` until they're re-saved (only the author can update an addressable event). + - **Admin backfill for legacy untagged pins (2026-10-01):** the reason + `/stats` "Spots & Shops by Region" under-counts vs the map is **tag coverage, + not a stats bug** — the map draws a pin by its coordinates, the region card + can only place a pin that carries a `city`/`country` tag. Measured on the + canonical relay: raw **54** spots+shops → **43** after admin-hide → **13 + tagged / 30 "Uncategorised"**, and **21 of those 30 are the two admin + accounts' own pre-geocoding pins**. So `useRegionBackfill.ts` + + `RegionBackfillCard` (Settings → Advanced, admin-only) find the **logged-in + user's OWN** untagged spots/shops, decode each pin's coords (`lat`/`lon` else + `g`), `reverseGeocodeRegions()` them (spaced **1.1 s** for Nominatim's + 1 req/s policy), and republish the addressable event with the region tags + added — **every existing tag preserved**, only stale `city`/`country`/ + `country_code` replaced. Publishes to **`getWriteRelays()`** (community relay + + author) so canonical `queryCanonical` stats see it, then invalidates + `skate-spots` + `growth-stats`. **Only the author's key can update an + addressable event**, so each admin must run it while logged in as themselves; + the 9 remaining untagged belong to 9 distinct community mappers and can't be + fixed centrally. ⚠️ Even after backfill, a map **cluster bubble ≠ a country + count** — bubbles are 60 px proximity (`markerClusterGroup`) and can span + borders, and the map also plots shops/gear/tours/events/crews the region card + doesn't count. - **NIP-05 names — `you@skate-spots.org` (2026-09-28):** SkateSpots hands out free handles so a new skater has a human name from minute one. The registry is the Netlify Function **`netlify/functions/nip05.mjs`** diff --git a/src/components/RegionBackfillCard.tsx b/src/components/RegionBackfillCard.tsx new file mode 100644 index 0000000..8ff2845 --- /dev/null +++ b/src/components/RegionBackfillCard.tsx @@ -0,0 +1,117 @@ +/** + * RegionBackfillCard — admin-only: backfill missing `city`/`country` tags on the + * logged-in user's own legacy spots & shops. + * + * Why: spots created before reverse geocoding existed have coordinates (so they + * appear on the map) but no region tags (so `/stats` can only call them + * "Uncategorised", under-counting the country vs the map). This re-derives the + * tags from each pin and republishes the addressable event. + * + * Renders nothing for non-admins, so it can sit in Settings → Advanced beside the + * other owner cards. + */ + +import { useState } from 'react'; +import { MapPin, Loader2 } from 'lucide-react'; + +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useRegionBackfill, type BackfillResult } from '@/hooks/useRegionBackfill'; +import { isAdminPubkey } from '@/lib/admins'; +import { toast } from '@/hooks/useToast'; + +export function RegionBackfillCard() { + const { user } = useCurrentUser(); + const isAdmin = isAdminPubkey(user?.pubkey); + const { candidates, isLoading, backfill, progress, isRunning } = useRegionBackfill(isAdmin); + const [summary, setSummary] = useState(null); + + if (!isAdmin) return null; + + const count = candidates.length; + + const run = async () => { + setSummary(null); + try { + const result = await backfill(); + setSummary(result); + if (result.updated > 0) { + toast({ + title: `Tagged ${result.updated} location${result.updated === 1 ? '' : 's'}`, + description: + result.skipped > 0 + ? `${result.skipped} skipped (no coordinates or no geocoder match).` + : 'Region tags written to the community relay.', + }); + } else { + toast({ + title: 'Nothing to tag', + description: 'No pins could be geocoded. Try again later.', + variant: 'destructive', + }); + } + } catch (err) { + toast({ + title: 'Backfill failed', + description: err instanceof Error ? err.message : 'Try again.', + variant: 'destructive', + }); + } + }; + + return ( + + +

+ + Backfill region tags + + Admin + +

+

+ Pins made before geocoding existed have coordinates but no city/country tag, so the map + draws them while Community Stats can only say “Uncategorised”. This re-derives the tags + from each pin and republishes your own untagged spots & shops. +

+ + {isLoading ? ( +

Scanning your spots…

+ ) : count === 0 ? ( +

+ Every spot & shop you own already has a region tag. 🎉 +

+ ) : ( + <> +

+ {count} of your spot{count === 1 ? '' : 's'}/shops have no region tag. Your signer may + ask to approve each one. +

+
+ + {summary && ( + + {summary.updated} updated · {summary.skipped} skipped · {summary.failed} failed + + )} +
+ + )} +
+
+ ); +} diff --git a/src/hooks/useRegionBackfill.ts b/src/hooks/useRegionBackfill.ts new file mode 100644 index 0000000..a97e3f5 --- /dev/null +++ b/src/hooks/useRegionBackfill.ts @@ -0,0 +1,185 @@ +/** + * 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, + }; +} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index e23f027..15e4b66 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -12,6 +12,7 @@ import { CVMHealthCheck } from '@/components/CVMHealthCheck'; import { SasquatchAiStatusCard } from '@/components/SasquatchAiStatusCard'; import { GrantProCard } from '@/components/GrantProCard'; import { AdminReviewCard } from '@/components/AdminReviewCard'; +import { RegionBackfillCard } from '@/components/RegionBackfillCard'; import { NotificationSettingsCard } from '@/components/NotificationSettingsCard'; import { CoordinatorSettingsCard } from '@/components/CoordinatorSettingsCard'; import { LanguageSelector } from '@/components/LanguageToggle'; @@ -657,6 +658,7 @@ function AdvancedScreen({ onBack }: { onBack: () => void }) { +

🦧 Sasquatch Guide