diff --git a/AGENTS.md b/AGENTS.md index 8e7a9df..a55ca93 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -905,6 +905,18 @@ session with **no other memory of this project**. So: 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. + - **Read-time derived regions (2026-10-01):** so the region card matches the map + with **NO signing**, `useGrowthStats` falls back to a **cached reverse geocode + of an untagged pin's own coordinates** (`lib/derivedRegions.ts`, localStorage + `skatespots:derived-regions-v1`, keyed by ~110 m-rounded coords). A + `useDeriveRegions()` hook mounted on `/stats` scans the community relay for + untagged spots/shops, fills cache misses at **1.1 s spacing** (Nominatim + 1 req/s) and invalidates `growth-stats`; the card shows "Resolving N…" until + done. ⚠️ This geocodes pins **when the stats page is viewed**, so Nominatim + sees the viewer's IP + public pin coords — disclosed on `/privacy` + ("Spot/shop geocoding"). It is a **display fallback only**; the backfill above + is what writes real `city`/`country` tags. Either way the card's `Uncategorised` + row now means "couldn't be tagged OR resolved", not "no tag". - **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/hooks/useDeriveRegions.ts b/src/hooks/useDeriveRegions.ts new file mode 100644 index 0000000..7c8ea91 --- /dev/null +++ b/src/hooks/useDeriveRegions.ts @@ -0,0 +1,80 @@ +/** + * 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 () => { + try { + await fillDerivedRegions(misses); + const unresolved = misses.filter((c) => !getDerivedRegion(c.lat, c.lng)).length; + setPending(unresolved); + // Recompute the region card now that more pins have a derived region. + queryClient.invalidateQueries({ queryKey: ['growth-stats'] }); + } finally { + running.current = false; + } + })(); + }, [scan.data, queryClient]); + + return { pending }; +} diff --git a/src/hooks/useGrowthStats.ts b/src/hooks/useGrowthStats.ts index a19f7ad..3b21c4a 100644 --- a/src/hooks/useGrowthStats.ts +++ b/src/hooks/useGrowthStats.ts @@ -24,6 +24,7 @@ import { import { TRADE_ATTESTATION_KIND } from '@/hooks/useTradeAttestations'; import { useAdminModeration } from '@/hooks/useAdminModeration'; import { getSatoshisFromBolt11 } from '@/lib/bolt11'; +import { coordsOf, getDerivedRegion } from '@/lib/derivedRegions'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -290,20 +291,37 @@ export function useGrowthStats() { .sort((a, b) => b.count - a.count); // ── Regions ────────────────────────────────────────────────────────── - // Spots AND shops are considered together (shops are geocoded at creation - // now, same as spots). Group by a STABLE key: the ISO `country_code` when + // Spots AND shops are considered together. A location's region comes from + // its own `city`/`country` tag when present; otherwise it is DERIVED from + // the pin's coordinates via a cached reverse geocode (`derivedRegions`), so + // the card matches the pins the map draws instead of stranding them as + // "Uncategorised". Group by a STABLE key: the ISO `country_code` when // present, else a normalised name. The displayed label is the most common // raw name in the group, so a localised spelling can't split one country. const allLocations = [...spots, ...shops]; - const regionGroups = (nameTag: string, useCountryCode: boolean) => { + const regionOf = (e: { tags: string[][] }): { city: string; country: string; code: string } | null => { + const city = e.tags.find((t) => t[0] === 'city' && t[1])?.[1]?.trim() ?? ''; + const country = e.tags.find((t) => t[0] === 'country' && t[1])?.[1]?.trim() ?? ''; + const code = e.tags.find((t) => t[0] === 'country_code' && t[1])?.[1]?.trim().toUpperCase() ?? ''; + if (city || country) return { city, country, code }; + // No tag — fall back to a cached reverse geocode of the pin itself. + const coords = coordsOf(e); + if (coords) { + const derived = getDerivedRegion(coords.lat, coords.lng); + if (derived && (derived.city || derived.country)) { + return { city: derived.city, country: derived.country, code: derived.countryCode }; + } + } + return null; + }; + const regionEntries = allLocations.map((e) => regionOf(e)); + const regionGroups = (kind: 'country' | 'city') => { const groups = new Map }>(); - for (const s of allLocations) { - const name = s.tags.find((t) => t[0] === nameTag && t[1])?.[1]?.trim(); + for (const region of regionEntries) { + if (!region) continue; + const name = kind === 'country' ? region.country : region.city; if (!name) continue; - const code = useCountryCode - ? s.tags.find((t) => t[0] === 'country_code' && t[1])?.[1]?.trim().toUpperCase() - : undefined; - const key = code || name.toLowerCase(); + const key = kind === 'country' ? region.code || name.toLowerCase() : name.toLowerCase(); const g = groups.get(key) ?? { total: 0, labels: new Map() }; g.total += 1; g.labels.set(name, (g.labels.get(name) ?? 0) + 1); @@ -316,21 +334,18 @@ export function useGrowthStats() { })) .sort((a, b) => b.spots - a.spots); }; - const allCountries = regionGroups('country', true); - const allCities = regionGroups('city', false); + const allCountries = regionGroups('country'); + const allCities = regionGroups('city'); const topCountries = allCountries.slice(0, 6); const topCities = allCities.slice(0, 6); - // Spots whose tag didn't make the top 6 — surfaced as an "Other" row so the + // Regions that didn't make the top 6 — surfaced as an "Other" row so the // cards can't imply the top 6 is the whole picture. const otherCountries = allCountries.slice(6).reduce((n, c) => n + c.spots, 0); const otherCities = allCities.slice(6).reduce((n, c) => n + c.spots, 0); - // Locations with neither a country nor a city tag. Surfaced as an - // "Uncategorised" row so the cards reconcile with the location total - // rather than silently not adding up (geocoding is best-effort). + // Locations whose region is neither tagged nor (yet) derivable. Surfaced as + // an "Uncategorised" row so the cards reconcile with the location total. const locationsTotal = allLocations.length; - const uncategorised = allLocations.filter( - (e) => !e.tags.some((t) => (t[0] === 'country' || t[0] === 'city') && t[1]), - ).length; + const uncategorised = regionEntries.filter((r) => r === null).length; // ── Monthly bucketing ─────────────────────────────────────────────── diff --git a/src/lib/derivedRegions.ts b/src/lib/derivedRegions.ts new file mode 100644 index 0000000..8a7dc31 --- /dev/null +++ b/src/lib/derivedRegions.ts @@ -0,0 +1,111 @@ +/** + * Derived regions — a read-time fallback for pins with no city/country tag + * ───────────────────────────────────────────────────────────────────────────── + * The map plots a spot by its **coordinates** (geohash); the region card can only + * place a spot that carries a `city`/`country` tag. Spots made before reverse + * geocoding existed therefore appear on the map but land in "Uncategorised" — the + * map and the stats legitimately disagree. + * + * This module lets the stats DERIVE the region from the pin's own coordinates + * instead, so the card matches the map with no action from the user. Results are + * cached in localStorage (keyed by ~110 m-rounded coordinates), so each pin is + * reverse-geocoded at most once per device. + * + * ⚠️ Privacy: unlike create/edit geocoding (which is the author's own pin), this + * runs when the STATS PAGE IS VIEWED, so Nominatim sees the viewer's IP and the + * (public) coordinates of untagged pins. Disclosed on /privacy. + */ + +import ngeohash from 'ngeohash'; + +import { reverseGeocodeRegions } from '@/lib/geocodeRegions'; + +export interface DerivedRegion { + city: string; + country: string; + countryCode: string; +} + +const STORAGE_KEY = 'skatespots:derived-regions-v1'; +/** Nominatim's usage policy allows at most one request per second. */ +const GEOCODE_SPACING_MS = 1100; + +let memoryCache: Record | null = null; + +function readCache(): Record { + if (memoryCache) return memoryCache; + try { + memoryCache = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Record; + } catch { + memoryCache = {}; + } + return memoryCache; +} + +/** ~110 m bucket — matches the 7-char geohash precision used for pins. */ +export function regionCoordKey(lat: number, lng: number): string { + return `${lat.toFixed(3)},${lng.toFixed(3)}`; +} + +export function getDerivedRegion(lat: number, lng: number): DerivedRegion | undefined { + return readCache()[regionCoordKey(lat, lng)]; +} + +export function setDerivedRegion(lat: number, lng: number, region: DerivedRegion): void { + const cache = readCache(); + cache[regionCoordKey(lat, lng)] = region; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(cache)); + } catch { + /* quota or storage unavailable — keep the in-memory copy */ + } +} + +/** Coordinates from explicit lat/lon tags, else decoded from the geohash. */ +export function coordsOf(event: { tags: string[][] }): { 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) && Math.abs(lat) <= 90 && Math.abs(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; +} + +/** + * Reverse-geocode a list of coordinates and fill the cache. Sequential and + * spaced for Nominatim's 1 req/s policy. Returns how many NEW regions were added. + */ +export async function fillDerivedRegions( + coords: Array<{ lat: number; lng: number }>, +): Promise { + let filled = 0; + for (let i = 0; i < coords.length; i += 1) { + const { lat, lng } = coords[i]; + if (getDerivedRegion(lat, lng)) continue; + const geo = await reverseGeocodeRegions(lat, lng); + if (geo.city || geo.country || geo.countryCode) { + setDerivedRegion(lat, lng, { city: geo.city, country: geo.country, countryCode: geo.countryCode }); + filled += 1; + } + if (i < coords.length - 1) { + await new Promise((resolve) => setTimeout(resolve, GEOCODE_SPACING_MS)); + } + } + return filled; +} diff --git a/src/pages/CommunityStatsPage.tsx b/src/pages/CommunityStatsPage.tsx index ba00076..6c6a287 100644 --- a/src/pages/CommunityStatsPage.tsx +++ b/src/pages/CommunityStatsPage.tsx @@ -8,6 +8,7 @@ import { genUserName } from '@/lib/genUserName'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { useGrowthStats } from '@/hooks/useGrowthStats'; import { RegionBackfillCard } from '@/components/RegionBackfillCard'; +import { useDeriveRegions } from '@/hooks/useDeriveRegions'; import { cn } from '@/lib/utils'; import { formatAge } from '@/lib/routeCache'; @@ -179,6 +180,8 @@ export default function CommunityStatsPage() { }); const { data: stats, isLoading, isError, isFetching, refetch, dataUpdatedAt } = useGrowthStats(); + // Resolve a region for untagged pins (cached) so the region card matches the map. + const { pending: derivingPending } = useDeriveRegions(); // Collapse the 13 Lab craft cards on mobile (they were ~7 rows of scrolling // before the first chart); always expanded from lg up. const [showAllLab, setShowAllLab] = useState(false); @@ -370,6 +373,13 @@ export default function CommunityStatsPage() {

Spots & Shops by Region

+ {derivingPending > 0 && ( +

+ + Resolving {derivingPending} untagged location{derivingPending === 1 ? '' : 's'} from + their coordinates… one-time, then cached on this device. +

+ )}
{topCountries.length > 0 && ( @@ -444,7 +454,8 @@ export default function CommunityStatsPage() {

Counts spots & shops, not skaters — from each - one's own city/country tag (countries grouped by ISO code when present), + one's own city/country tag, or derived from its pin’s coordinates when it has + none (cached on your device; countries grouped by ISO code when present), community relay only, with moderation-hidden content excluded. {locationsTotal > 0 && ( <> {uncategorised} of {locationsTotal} location{locationsTotal === 1 ? '' : 's'} have no location tag and show as “Uncategorised”. diff --git a/src/pages/PrivacyPage.tsx b/src/pages/PrivacyPage.tsx index 488b3eb..c9dff7c 100644 --- a/src/pages/PrivacyPage.tsx +++ b/src/pages/PrivacyPage.tsx @@ -148,7 +148,7 @@ export default function PrivacyPage() {

  • Map tiles (OpenStreetMap) — see your IP and which area of the map you look at.
  • -
  • Spot/shop geocoding (OpenStreetMap Nominatim) — only when you create a spot or shop, we send its coordinates to fill in the city/country tag. Nominatim sees your IP. This is the pin you placed, never your device's live GPS.
  • +
  • Spot/shop geocoding (OpenStreetMap Nominatim) — when you create a spot or shop we send its coordinates to fill in the city/country tag, and when you open Community Stats we may send the coordinates of pins that have no location tag yet, so they can be grouped by region. Each is cached on your device, so it happens at most once per pin. Nominatim sees your IP. These are the pin you placed / public pin coordinates, never your device's live GPS.
  • Bitcoin places (BTCMap, when you switch that layer on) — see your IP and the area you're viewing.
  • Nostr relays — see your IP when the app reads or publishes.
  • Cashu mints — see your IP whenever your wallet sends, receives or melts.