feat(stats): stable country grouping, an Other row, and disclose Nominatim

Answers 'how are these accounted?' and fixes the privacy-page gap it exposed.

- Spot creation now also publishes ['country_code', <ISO-3166-1 alpha-2>], 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.
This commit is contained in:
SkateSpots 2026-10-01 13:16:02 +07:00
parent 863b6a115d
commit 58154bf5d5
6 changed files with 79 additions and 13 deletions

View file

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

View file

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

View file

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

View file

@ -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) && (
<section className="mb-12">
<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]" /> Where Skaters Are
<Globe className="h-3.5 w-3.5 text-[#4ade80]" /> Spots by Region
</h2>
<div className="grid md:grid-cols-2 gap-4">
{topCountries.length > 0 && (
@ -384,6 +386,13 @@ export default function CommunityStatsPage() {
<span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-white">{c.spots}</span>
</div>
))}
{otherCountries > 0 && (
<div className="flex items-center gap-3">
<span className="w-24 truncate text-xs text-gray-500" title="Countries outside the top 6">Other</span>
<div className="h-2 flex-1 rounded-full bg-white/5" />
<span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-gray-400">{otherCountries}</span>
</div>
)}
</CardContent>
</Card>
)}
@ -405,12 +414,20 @@ export default function CommunityStatsPage() {
<span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-white">{c.spots}</span>
</div>
))}
{otherCities > 0 && (
<div className="flex items-center gap-3">
<span className="w-24 truncate text-xs text-gray-500" title="Cities outside the top 6">Other</span>
<div className="h-2 flex-1 rounded-full bg-white/5" />
<span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-gray-400">{otherCities}</span>
</div>
)}
</CardContent>
</Card>
)}
</div>
<p className="mt-3 text-[11px] text-gray-600">
Derived from each spot's own city/country tag — spots without one aren't counted.
Counts <strong className="text-gray-500">spots</strong>, not skaters — from each spot's own
city/country tag (countries grouped by ISO code when present). Spots without one aren't counted.
</p>
</section>
)}

View file

@ -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()]);

View file

@ -148,6 +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 geocoding</strong> (OpenStreetMap <strong className="text-white">Nominatim</strong>) — <em>only when you create a spot</em>, we send the spot's coordinates to fill in its city/country tag. Nominatim sees your IP. This is the <em>spot's</em> pin, 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>