From 9fe219aac6ce470b30491648fa963b98f9652abc Mon Sep 17 00:00:00 2001
From: SkateSpots
Date: Thu, 1 Oct 2026 13:27:44 +0700
Subject: [PATCH] feat(stats): reconcile the region card, and geocode shops too
Answers 'why don't the map and stats match' by making the gap visible, and
brings shops into the region breakdown.
- src/lib/geocodeRegions.ts is now the single reverse-geocode helper (spot
create, shop create, shop edit), so the Nominatim call + Accept-Language + the
parse rules can't drift. Never throws.
- Shops now publish city/country/country_code (previously none), so they can
appear in the region breakdown.
- Stats: regions cover spots AND shops; a new 'Uncategorised (N)' row plus a
'{N} of {M}' footnote let the card reconcile with the location total. Section
retitled 'Spots & Shops by Region'.
- PERSIST_BUSTER v3 -> v4.
- /privacy: wording now 'spot or shop geocoding'.
The map deliberately still reads the community relay + the reader's relays
(forcing canonical would hide an opted-out user's own spots) - documented.
---
AGENTS.md | 35 ++++++++++++++------
src/hooks/useGrowthStats.ts | 23 ++++++++++---
src/lib/geocodeRegions.ts | 55 ++++++++++++++++++++++++++++++++
src/lib/queryPersister.ts | 2 +-
src/pages/CommunityStatsPage.tsx | 25 +++++++++++++--
src/pages/CreateShopPage.tsx | 6 ++++
src/pages/CreateSpotPage.tsx | 48 +++++++---------------------
src/pages/EditShopPage.tsx | 7 ++++
src/pages/PrivacyPage.tsx | 2 +-
9 files changed, 147 insertions(+), 56 deletions(-)
create mode 100644 src/lib/geocodeRegions.ts
diff --git a/AGENTS.md b/AGENTS.md
index 22eee31..4081c52 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -818,8 +818,11 @@ session with **no other memory of this project**. So:
`showcase=true` excluded), completed trades (`31871` from
`useTradeAttestations`, `s=revoked` excluded), `tradedSats` summed from the
self-reported `amount` tag, `tradesByMethod`; **regions** —
- `topCountries`/`topCities` built from each spot's own `city`/`country` tags
- (spots without one aren't counted). Countries are grouped by a **stable key**:
+ `topCountries`/`topCities` built from each **spot or shop's** own
+ `city`/`country` tags. Anything without one is surfaced as an
+ **`uncategorised`** row, and the footnote states the denominator
+ (`{uncategorised} of {locationsTotal}`), so the card reconciles with the
+ location total instead of silently not adding up. Countries are grouped by a **stable key**:
the **`country_code`** the create-spot geocoder now also publishes (ISO-3166-1
alpha-2, e.g. `GB`), else a lowercased name; the label shown is the **most
common raw name** in the group, so a localised spelling can't split one
@@ -844,14 +847,26 @@ session with **no other memory of this project**. So:
cannot catch this** — it boots a clean profile with no persisted cache, so it
passed while real users crashed. Any new field on an allow-listed query needs
both.
-- **Spot geocoding hits Nominatim — now disclosed (2026-10-01).** Creating a spot
- reverse-geocodes its pin via
- `nominatim.openstreetmap.org/reverse?lat=…&lon=…` (with `Accept-Language: en`)
- to fill the `city`/`country`/`country_code` tags. Nominatim **sees the creator's
- IP** and was missing from `/privacy`'s "servers that see your IP" list — added.
- It is the **spot's pin**, never the device's live GPS, and the spot is published
- publicly anyway; keep that distinction in the copy. ⚠️ Any new outbound call
- that receives coordinates must be added to `/privacy` in the same change.
+ - ⚠️ **The map and the stats legitimately differ — do NOT "fix" the map to
+ `queryCanonical` (2026-10-01).** The map (`useSkateSpots` →
+ `querySkateContent`) reads the community relay **+ the reader's own relays**;
+ the stats read the community relay only (`queryCanonical`). Forcing canonical
+ on the map would hide spots for a user who opted out of the community relay
+ (`useSkateRelay=false`) — including **their own**. Two more reasons a "23"
+ cluster ≠ a country row: a cluster bubble is proximity (may span countries),
+ and the region card only counts locations **with a location tag** while the
+ map draws every pin. The number comparable to the map is the hero
+ **"Skate Spots"** card (`totalSpots`), not the region rows.
+- **Reverse geocoding hits Nominatim — disclosed (2026-10-01).**
+ `src/lib/geocodeRegions.ts` (`reverseGeocodeRegions` / `pushRegionTags`) is the
+ **single source of truth**, used by **spot create, shop create and shop edit**;
+ it fills `city`/`country`/`country_code` via
+ `nominatim.openstreetmap.org/reverse?lat=…&lon=…` (`Accept-Language: en`) and
+ **never throws** (a geocoder outage can't block a publish). Nominatim **sees the
+ creator's IP** and was missing from `/privacy`'s "servers that see your IP"
+ list — now disclosed as "Spot/shop geocoding". Coordinates are the **pin you
+ placed**, never the device's live GPS. ⚠️ Any new outbound call that receives
+ coordinates must be added to `/privacy` in the same change.
- **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/useGrowthStats.ts b/src/hooks/useGrowthStats.ts
index b2c2c72..d00cd0d 100644
--- a/src/hooks/useGrowthStats.ts
+++ b/src/hooks/useGrowthStats.ts
@@ -113,6 +113,10 @@ export interface GrowthStats {
/** Spots with a country/city tag that didn't make the top 6. */
otherCountries: number;
otherCities: number;
+ /** Locations (spots + shops) considered for the region breakdown. */
+ locationsTotal: number;
+ /** Of those, how many carry neither a country nor a city tag. */
+ uncategorised: number;
/**
* True when one or more relay sub-queries failed. The page must say the
@@ -277,12 +281,14 @@ export function useGrowthStats() {
.sort((a, b) => b.count - a.count);
// ── Regions ──────────────────────────────────────────────────────────
- // 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 into two rows.
+ // 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
+ // 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 groups = new Map }>();
- for (const s of spots) {
+ for (const s of allLocations) {
const name = s.tags.find((t) => t[0] === nameTag && t[1])?.[1]?.trim();
if (!name) continue;
const code = useCountryCode
@@ -309,6 +315,13 @@ export function useGrowthStats() {
// 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).
+ const locationsTotal = allLocations.length;
+ const uncategorised = allLocations.filter(
+ (e) => !e.tags.some((t) => (t[0] === 'country' || t[0] === 'city') && t[1]),
+ ).length;
// ── Monthly bucketing ───────────────────────────────────────────────
@@ -472,6 +485,8 @@ export function useGrowthStats() {
topCities,
otherCountries,
otherCities,
+ locationsTotal,
+ uncategorised,
partial: failedQueries > 0,
};
},
diff --git a/src/lib/geocodeRegions.ts b/src/lib/geocodeRegions.ts
new file mode 100644
index 0000000..338a24b
--- /dev/null
+++ b/src/lib/geocodeRegions.ts
@@ -0,0 +1,55 @@
+/**
+ * Reverse geocoding for spot/shop location tags
+ * ─────────────────────────────────────────────────────────────────────────────
+ * Turns a pin into `city` / `country` / `country_code` tags via OpenStreetMap
+ * **Nominatim**. Best-effort by design — it never throws, so a geocoder outage
+ * can never block a publish.
+ *
+ * ⚠️ Nominatim sees the caller's IP. That is disclosed in `/privacy` →
+ * "What leaves your device" → *Spot geocoding*. The coordinates are the
+ * **spot/shop pin**, never the device's live GPS.
+ *
+ * Single source of truth for the request (spot create, shop create, shop edit),
+ * so the `Accept-Language: en` + parse rules can't drift between callers.
+ */
+
+export interface GeocodedRegions {
+ /** Best available human city/town/village/county name. */
+ city: string;
+ /** Nominatim's display country name (e.g. "United Kingdom"). */
+ country: string;
+ /** Region/state, used to build a readable location string. */
+ state: string;
+ /** ISO-3166-1 alpha-2, uppercased (e.g. "GB"). The stable stats grouping key. */
+ countryCode: string;
+}
+
+const EMPTY: GeocodedRegions = { city: '', country: '', state: '', countryCode: '' };
+
+/** Best-effort reverse geocode. Resolves to empty strings on any failure. */
+export async function reverseGeocodeRegions(lat: number, lng: number): Promise {
+ try {
+ const res = await fetch(
+ `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=10`,
+ { headers: { 'Accept-Language': 'en', 'User-Agent': 'SkateSpots/1.0' } },
+ );
+ if (!res.ok) return EMPTY;
+ const data = (await res.json()) as { address?: Record };
+ const addr = data.address ?? {};
+ return {
+ city: addr.city ?? addr.town ?? addr.village ?? addr.county ?? '',
+ country: addr.country ?? '',
+ state: addr.state ?? '',
+ countryCode: String(addr.country_code ?? '').trim().toUpperCase(),
+ };
+ } catch {
+ return EMPTY;
+ }
+}
+
+/** Push the region tags onto an event tag list, skipping empty values. */
+export function pushRegionTags(tags: string[][], geo: GeocodedRegions): void {
+ 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]);
+}
diff --git a/src/lib/queryPersister.ts b/src/lib/queryPersister.ts
index d86c41f..8694724 100644
--- a/src/lib/queryPersister.ts
+++ b/src/lib/queryPersister.ts
@@ -40,7 +40,7 @@ import type { Query } from '@tanstack/react-query';
const IDB_KEY = 'skatespots:react-query-cache';
/** Bump when the persisted data shape changes to invalidate old caches. */
-export const PERSIST_BUSTER = 'v3';
+export const PERSIST_BUSTER = 'v4';
/** Discard any persisted cache older than this (ms). */
export const PERSIST_MAX_AGE = 24 * 60 * 60 * 1000; // 24h
diff --git a/src/pages/CommunityStatsPage.tsx b/src/pages/CommunityStatsPage.tsx
index 1737a77..4121468 100644
--- a/src/pages/CommunityStatsPage.tsx
+++ b/src/pages/CommunityStatsPage.tsx
@@ -214,6 +214,8 @@ export default function CommunityStatsPage() {
const topCities = stats?.topCities ?? [];
const otherCountries = stats?.otherCountries ?? 0;
const otherCities = stats?.otherCities ?? 0;
+ const uncategorised = stats?.uncategorised ?? 0;
+ const locationsTotal = stats?.locationsTotal ?? 0;
return (
<>
@@ -365,7 +367,7 @@ export default function CommunityStatsPage() {
{(topCountries.length > 0 || topCities.length > 0) && (
- Spots by Region
+ Spots & Shops by Region
{topCountries.length > 0 && (
@@ -393,6 +395,13 @@ export default function CommunityStatsPage() {
{otherCountries}
)}
+ {uncategorised > 0 && (
+
+
Uncategorised
+
+
{uncategorised}
+
+ )}
)}
@@ -421,13 +430,23 @@ export default function CommunityStatsPage() {
{otherCities}
)}
+ {uncategorised > 0 && (
+
+
Uncategorised
+
+
{uncategorised}
+
+ )}
)}
- Counts spots, not skaters — from each spot's own
- city/country tag (countries grouped by ISO code when present). Spots without one aren't counted.
+ Counts spots & shops, not skaters — from each
+ one's own city/country tag (countries grouped by ISO code when present).
+ {locationsTotal > 0 && (
+ <> {uncategorised} of {locationsTotal} location{locationsTotal === 1 ? '' : 's'} have no location tag and show as “Uncategorised”.>
+ )}
)}
diff --git a/src/pages/CreateShopPage.tsx b/src/pages/CreateShopPage.tsx
index 31e1168..eb2188e 100644
--- a/src/pages/CreateShopPage.tsx
+++ b/src/pages/CreateShopPage.tsx
@@ -14,6 +14,7 @@ import { useToast } from '@/hooks/useToast';
import { ShopBountyCard } from '@/components/ShopBountyCard';
import { LoginArea } from '@/components/auth/LoginArea';
import { SKATE_SHOP_KIND, isValidNoffer } from '@/lib/types';
+import { reverseGeocodeRegions, pushRegionTags } from '@/lib/geocodeRegions';
import { NofferField } from '@/components/NofferField';
import {
Store,
@@ -179,6 +180,10 @@ export default function CreateShopPage() {
.replace(/^-|-$/g, '')
.slice(0, 50) + '-' + Date.now().toString(36);
+ // Best-effort reverse geocode → city/country/country_code, so shops show
+ // up in the /stats region breakdown (same helper as spots; never throws).
+ const geo = await reverseGeocodeRegions(lat, lng);
+
// Build tags according to NIP-15 and custom extensions
const tags: string[][] = [
['d', identifier], // Required for addressable events
@@ -189,6 +194,7 @@ export default function CreateShopPage() {
['lon', lng.toString()],
['location', location.trim()],
];
+ pushRegionTags(tags, geo);
if (phone.trim()) {
tags.push(['phone', phone.trim()]);
diff --git a/src/pages/CreateSpotPage.tsx b/src/pages/CreateSpotPage.tsx
index de8ddbc..c793eaa 100644
--- a/src/pages/CreateSpotPage.tsx
+++ b/src/pages/CreateSpotPage.tsx
@@ -20,6 +20,7 @@ import { QualityMeter } from '@/components/QualityMeter';
import { PhotoDropzone } from '@/components/PhotoDropzone';
import { TagInput } from '@/components/TagInput';
import { SKATE_SPOT_KIND } from '@/lib/types';
+import { reverseGeocodeRegions, pushRegionTags } from '@/lib/geocodeRegions';
import { spotCategories, MAX_TAGS, shelterOptions, DEFAULT_SHELTER, SHELTERED_VALUES, type ShelterType } from '@/lib/skateTags';
import { cn } from '@/lib/utils';
import {
@@ -317,32 +318,14 @@ export default function CreateSpotPage() {
.replace(/^-|-$/g, '')
.slice(0, 50) + '-' + Date.now().toString(36);
- // Reverse geocode to get structured city/country tags
- // These power Sasquatch's city/country filtering (relay queries)
- let cityTag = '';
- let countryTag = '';
- let countryCodeTag = '';
+ // Reverse geocode to get structured city/country tags (best-effort; the
+ // helper never throws). These power Sasquatch's city/country search and
+ // the /stats region breakdown.
+ const geo = await reverseGeocodeRegions(lat, lng);
let locationStr = location.trim();
- try {
- const geoResp = await fetch(
- `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=10`,
- { headers: { 'Accept-Language': 'en', 'User-Agent': 'SkateSpots/1.0' } }
- );
- if (geoResp.ok) {
- const geoData = await geoResp.json();
- const addr = geoData.address ?? {};
- cityTag = addr.city ?? addr.town ?? addr.village ?? addr.county ?? '';
- countryTag = addr.country ?? '';
- // ISO-3166-1 alpha-2 (e.g. "GB"). A STABLE aggregation key, so stats
- // never split one country across localised spellings.
- countryCodeTag = String(addr.country_code ?? '').trim().toUpperCase();
- // If user didn't fill in location, auto-populate from geocoding
- if (!locationStr && cityTag) {
- locationStr = [cityTag, addr.state, countryTag].filter(Boolean).join(', ');
- }
- }
- } catch {
- // Geocoding is best-effort — don't block spot creation
+ if (!locationStr && geo.city) {
+ // Auto-populate an empty address from the geocode.
+ locationStr = [geo.city, geo.state, geo.country].filter(Boolean).join(', ');
}
// Build event tags
@@ -360,18 +343,9 @@ export default function CreateSpotPage() {
eventTags.push(['location', locationStr]);
}
- // Structured city/country tags — critical for Sasquatch relay search
- if (cityTag) {
- eventTags.push(['city', cityTag]);
- }
- if (countryTag) {
- eventTags.push(['country', countryTag]);
- }
- // Optional ISO code — the stable key the stats page aggregates on. Kept
- // alongside the display name so old/new spots and DNS search still work.
- if (countryCodeTag) {
- eventTags.push(['country_code', countryCodeTag]);
- }
+ // Structured city/country/country_code tags — critical for Sasquatch relay
+ // search and the /stats region breakdown (country_code is the stable key).
+ pushRegionTags(eventTags, geo);
if (contactInfo.trim()) {
eventTags.push(['contact', contactInfo.trim()]);
diff --git a/src/pages/EditShopPage.tsx b/src/pages/EditShopPage.tsx
index 76a7cf4..e290d6d 100644
--- a/src/pages/EditShopPage.tsx
+++ b/src/pages/EditShopPage.tsx
@@ -15,6 +15,7 @@ import { useToast } from '@/hooks/useToast';
import { useShop } from '@/hooks/useShops';
import { LoginArea } from '@/components/auth/LoginArea';
import { SKATE_SHOP_KIND, isValidNoffer } from '@/lib/types';
+import { reverseGeocodeRegions, pushRegionTags } from '@/lib/geocodeRegions';
import { NofferField } from '@/components/NofferField';
import {
Store,
@@ -221,6 +222,11 @@ export default function EditShopPage() {
// Generate geohash from coordinates (7 chars = ~153m precision)
const geohash = ngeohash.encode(lat, lng, 7);
+ // Best-effort reverse geocode → city/country/country_code (same helper as
+ // spot/shop creation; never throws). Re-runs on edit so a moved shop's
+ // region tags stay correct.
+ const geo = await reverseGeocodeRegions(lat, lng);
+
// Build tags - use the same identifier to update the addressable event
const tags: string[][] = [
['d', shop.identifier],
@@ -232,6 +238,7 @@ export default function EditShopPage() {
['location', location.trim()],
['alt', `Skate shop: ${name.trim()}`],
];
+ pushRegionTags(tags, geo);
if (phone.trim()) {
tags.push(['phone', phone.trim()]);
diff --git a/src/pages/PrivacyPage.tsx b/src/pages/PrivacyPage.tsx
index 31cb4a1..488b3eb 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 geocoding (OpenStreetMap Nominatim) — only when you create a spot, we send the spot's coordinates to fill in its city/country tag. Nominatim sees your IP. This is the spot's pin, never your device's live GPS.
+ - 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.
- 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.