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.