From 58154bf5d56317f664843a6f894286d5857928cd Mon Sep 17 00:00:00 2001
From: SkateSpots
Date: Thu, 1 Oct 2026 13:16:02 +0700
Subject: [PATCH] feat(stats): stable country grouping, an Other row, and
disclose Nominatim
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Answers 'how are these accounted?' and fixes the privacy-page gap it exposed.
- Spot creation now also publishes ['country_code', ], kept
alongside the display 'country' name so existing search/display still work.
- Stats group countries by country_code when present, else a lowercased name;
the label is the most common raw name in the group, so a localised spelling
can't split one country across two rows.
- The tail beyond the top 6 is surfaced as an 'Other' row, and the section is
relabelled 'Spots by Region' (it counts spots, not people) with a footnote.
- PERSIST_BUSTER v2 -> v3 (GrowthStats shape changed again).
- /privacy: disclose OpenStreetMap Nominatim — spot geocoding sees your IP and
the spot's pin (never the device's GPS). It was missing from the IP list.
Docs: AGENTS.md.
---
AGENTS.md | 16 +++++++++++-
src/hooks/useGrowthStats.ts | 43 +++++++++++++++++++++++++-------
src/lib/queryPersister.ts | 2 +-
src/pages/CommunityStatsPage.tsx | 21 ++++++++++++++--
src/pages/CreateSpotPage.tsx | 9 +++++++
src/pages/PrivacyPage.tsx | 1 +
6 files changed, 79 insertions(+), 13 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index f0af24f..22eee31 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -819,7 +819,13 @@ session with **no other memory of this project**. So:
`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); and a **bounded zap signal** —
+ (spots without one aren't counted). 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
+ country into two rows. The tail beyond the top 6 is surfaced as an
+ **`otherCountries`/`otherCities`** "Other" row, and the heading reads **"Spots
+ by Region"** — it counts spots, not people; and a **bounded zap signal** —
`zapCount`/`zapSats` from kind-9735 receipts `#p` the top ~25 contributors
(amounts decoded with `getSatoshisFromBolt11`). ⚠️ **Global zap volume is NOT
measurable on a public relay**, so the code and copy say "not global volume"
@@ -838,6 +844,14 @@ 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.
- **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 6c3372d..b2c2c72 100644
--- a/src/hooks/useGrowthStats.ts
+++ b/src/hooks/useGrowthStats.ts
@@ -110,6 +110,9 @@ export interface GrowthStats {
// ── Regions (from each spot's own `country`/`city` tags) ─────────────────
topCountries: { name: string; spots: number }[];
topCities: { name: string; spots: number }[];
+ /** Spots with a country/city tag that didn't make the top 6. */
+ otherCountries: number;
+ otherCities: number;
/**
* True when one or more relay sub-queries failed. The page must say the
@@ -273,19 +276,39 @@ export function useGrowthStats() {
.map(([method, count]) => ({ method, count }))
.sort((a, b) => b.count - a.count);
- // ── Regions — from each spot's own country/city tags ─────────────────
- const regionCounts = (tagName: string) => {
- const m = new Map();
+ // ── 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.
+ const regionGroups = (nameTag: string, useCountryCode: boolean) => {
+ const groups = new Map }>();
for (const s of spots) {
- const v = s.tags.find((t) => t[0] === tagName && t[1])?.[1]?.trim();
- if (v) m.set(v, (m.get(v) ?? 0) + 1);
+ const name = s.tags.find((t) => t[0] === nameTag && t[1])?.[1]?.trim();
+ 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 g = groups.get(key) ?? { total: 0, labels: new Map() };
+ g.total += 1;
+ g.labels.set(name, (g.labels.get(name) ?? 0) + 1);
+ groups.set(key, g);
}
- return Array.from(m.entries())
- .map(([name, count]) => ({ name, spots: count }))
+ return Array.from(groups.values())
+ .map((g) => ({
+ name: [...g.labels.entries()].sort((a, b) => b[1] - a[1])[0][0],
+ spots: g.total,
+ }))
.sort((a, b) => b.spots - a.spots);
};
- const topCountries = regionCounts('country').slice(0, 6);
- const topCities = regionCounts('city').slice(0, 6);
+ const allCountries = regionGroups('country', true);
+ const allCities = regionGroups('city', false);
+ 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
+ // 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);
// ── Monthly bucketing ───────────────────────────────────────────────
@@ -447,6 +470,8 @@ export function useGrowthStats() {
zapSats,
topCountries,
topCities,
+ otherCountries,
+ otherCities,
partial: failedQueries > 0,
};
},
diff --git a/src/lib/queryPersister.ts b/src/lib/queryPersister.ts
index af93f39..d86c41f 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 = 'v2';
+export const PERSIST_BUSTER = 'v3';
/** 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 8a91ed2..1737a77 100644
--- a/src/pages/CommunityStatsPage.tsx
+++ b/src/pages/CommunityStatsPage.tsx
@@ -212,6 +212,8 @@ export default function CommunityStatsPage() {
const tradesByMethod = stats?.tradesByMethod ?? [];
const topCountries = stats?.topCountries ?? [];
const topCities = stats?.topCities ?? [];
+ const otherCountries = stats?.otherCountries ?? 0;
+ const otherCities = stats?.otherCities ?? 0;
return (
<>
@@ -363,7 +365,7 @@ export default function CommunityStatsPage() {
{(topCountries.length > 0 || topCities.length > 0) && (
- Where Skaters Are
+ Spots by Region
{topCountries.length > 0 && (
@@ -384,6 +386,13 @@ export default function CommunityStatsPage() {
{c.spots}
))}
+ {otherCountries > 0 && (
+
+
Other
+
+
{otherCountries}
+
+ )}
)}
@@ -405,12 +414,20 @@ export default function CommunityStatsPage() {
{c.spots}
))}
+ {otherCities > 0 && (
+
+
Other
+
+
{otherCities}
+
+ )}
)}
- Derived from each spot's own city/country tag — spots without one aren't counted.
+ 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.
)}
diff --git a/src/pages/CreateSpotPage.tsx b/src/pages/CreateSpotPage.tsx
index 357d00e..de8ddbc 100644
--- a/src/pages/CreateSpotPage.tsx
+++ b/src/pages/CreateSpotPage.tsx
@@ -321,6 +321,7 @@ export default function CreateSpotPage() {
// These power Sasquatch's city/country filtering (relay queries)
let cityTag = '';
let countryTag = '';
+ let countryCodeTag = '';
let locationStr = location.trim();
try {
const geoResp = await fetch(
@@ -332,6 +333,9 @@ export default function CreateSpotPage() {
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(', ');
@@ -363,6 +367,11 @@ export default function CreateSpotPage() {
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]);
+ }
if (contactInfo.trim()) {
eventTags.push(['contact', contactInfo.trim()]);
diff --git a/src/pages/PrivacyPage.tsx b/src/pages/PrivacyPage.tsx
index 4b59c09..31cb4a1 100644
--- a/src/pages/PrivacyPage.tsx
+++ b/src/pages/PrivacyPage.tsx
@@ -148,6 +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.
- 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.