feat(stats): derive region from a pin's coordinates when it has no tag

The region card only placed pins carrying a city/country tag, so legacy pins
(coords but no tag) drew on the map yet landed in 'Uncategorised' — the map and
the stats disagreed. useGrowthStats now falls back to a cached reverse geocode
of the pin's own coordinates (lib/derivedRegions, localStorage keyed by ~110m
coords). useDeriveRegions (mounted on /stats) fills cache misses at 1.1s spacing
for Nominatim's 1 req/s policy and invalidates growth-stats; the card shows a
'Resolving N...' note meanwhile. Display-only fallback; the admin backfill still
writes real tags. Read-time geocoding disclosed on /privacy.
This commit is contained in:
SkateSpots 2026-10-01 14:17:04 +07:00
parent 02b81a8b76
commit 7bad809878
6 changed files with 249 additions and 20 deletions

View file

@ -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`**

View file

@ -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 };
}

View file

@ -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<string, { total: number; labels: Map<string, number> }>();
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<string, number>() };
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 ───────────────────────────────────────────────

111
src/lib/derivedRegions.ts Normal file
View file

@ -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<string, DerivedRegion> | null = null;
function readCache(): Record<string, DerivedRegion> {
if (memoryCache) return memoryCache;
try {
memoryCache = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Record<string, DerivedRegion>;
} 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<number> {
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;
}

View file

@ -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() {
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
<Globe className="h-3.5 w-3.5 text-[#4ade80]" /> Spots &amp; Shops by Region
</h2>
{derivingPending > 0 && (
<p className="-mt-2 mb-4 flex items-center gap-2 text-xs text-amber-300/90">
<RefreshCw className="h-3.5 w-3.5 shrink-0 animate-spin" />
Resolving {derivingPending} untagged location{derivingPending === 1 ? '' : 's'} from
their coordinates… one-time, then cached on this device.
</p>
)}
<div className="grid md:grid-cols-2 gap-4">
{topCountries.length > 0 && (
<Card className="border-white/8">
@ -444,7 +454,8 @@ export default function CommunityStatsPage() {
</div>
<p className="mt-3 text-[11px] text-gray-600">
Counts <strong className="text-gray-500">spots &amp; shops</strong>, 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 && (
<> <strong className="text-gray-500">{uncategorised} of {locationsTotal}</strong> location{locationsTotal === 1 ? '' : 's'} have no location tag and show as “Uncategorised”.</>

View file

@ -148,7 +148,7 @@ export default function PrivacyPage() {
</p>
<ul className="list-disc pl-5 space-y-1 text-gray-400">
<li><strong className="text-white">Map tiles</strong> (OpenStreetMap) — see your IP and which area of the map you look at.</li>
<li><strong className="text-white">Spot/shop geocoding</strong> (OpenStreetMap <strong className="text-white">Nominatim</strong>) — <em>only when you create a spot or shop</em>, we send its coordinates to fill in the city/country tag. Nominatim sees your IP. This is the <em>pin you placed</em>, never your device's live GPS.</li>
<li><strong className="text-white">Spot/shop geocoding</strong> (OpenStreetMap <strong className="text-white">Nominatim</strong>) — when you create a spot or shop we send its coordinates to fill in the city/country tag, and when you open <strong className="text-white">Community Stats</strong> 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 <em>pin you placed / public pin coordinates</em>, never your device's live GPS.</li>
<li><strong className="text-white">Bitcoin places</strong> (BTCMap, when you switch that layer on) — see your IP and the area you're viewing.</li>
<li><strong className="text-white">Nostr relays</strong> — see your IP when the app reads or publishes.</li>
<li><strong className="text-white">Cashu mints</strong> — see your IP whenever your wallet sends, receives or melts.</li>