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:
parent
863b6a115d
commit
58154bf5d5
6 changed files with 79 additions and 13 deletions
16
AGENTS.md
16
AGENTS.md
|
|
@ -819,7 +819,13 @@ session with **no other memory of this project**. So:
|
||||||
`useTradeAttestations`, `s=revoked` excluded), `tradedSats` summed from the
|
`useTradeAttestations`, `s=revoked` excluded), `tradedSats` summed from the
|
||||||
self-reported `amount` tag, `tradesByMethod`; **regions** —
|
self-reported `amount` tag, `tradesByMethod`; **regions** —
|
||||||
`topCountries`/`topCities` built from each spot's own `city`/`country` tags
|
`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
|
`zapCount`/`zapSats` from kind-9735 receipts `#p` the top ~25 contributors
|
||||||
(amounts decoded with `getSatoshisFromBolt11`). ⚠️ **Global zap volume is NOT
|
(amounts decoded with `getSatoshisFromBolt11`). ⚠️ **Global zap volume is NOT
|
||||||
measurable on a public relay**, so the code and copy say "not global volume"
|
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
|
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
|
passed while real users crashed. Any new field on an allow-listed query needs
|
||||||
both.
|
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
|
- **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
|
free handles so a new skater has a human name from minute one. The registry is
|
||||||
the Netlify Function **`netlify/functions/nip05.mjs`**
|
the Netlify Function **`netlify/functions/nip05.mjs`**
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,9 @@ export interface GrowthStats {
|
||||||
// ── Regions (from each spot's own `country`/`city` tags) ─────────────────
|
// ── Regions (from each spot's own `country`/`city` tags) ─────────────────
|
||||||
topCountries: { name: string; spots: number }[];
|
topCountries: { name: string; spots: number }[];
|
||||||
topCities: { 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
|
* 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 }))
|
.map(([method, count]) => ({ method, count }))
|
||||||
.sort((a, b) => b.count - a.count);
|
.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
// ── Regions — from each spot's own country/city tags ─────────────────
|
// ── Regions ──────────────────────────────────────────────────────────
|
||||||
const regionCounts = (tagName: string) => {
|
// Group by a STABLE key: the ISO `country_code` when present, else a
|
||||||
const m = new Map<string, number>();
|
// 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) {
|
for (const s of spots) {
|
||||||
const v = s.tags.find((t) => t[0] === tagName && t[1])?.[1]?.trim();
|
const name = s.tags.find((t) => t[0] === nameTag && t[1])?.[1]?.trim();
|
||||||
if (v) m.set(v, (m.get(v) ?? 0) + 1);
|
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())
|
return Array.from(groups.values())
|
||||||
.map(([name, count]) => ({ name, spots: count }))
|
.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);
|
.sort((a, b) => b.spots - a.spots);
|
||||||
};
|
};
|
||||||
const topCountries = regionCounts('country').slice(0, 6);
|
const allCountries = regionGroups('country', true);
|
||||||
const topCities = regionCounts('city').slice(0, 6);
|
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 ───────────────────────────────────────────────
|
// ── Monthly bucketing ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -447,6 +470,8 @@ export function useGrowthStats() {
|
||||||
zapSats,
|
zapSats,
|
||||||
topCountries,
|
topCountries,
|
||||||
topCities,
|
topCities,
|
||||||
|
otherCountries,
|
||||||
|
otherCities,
|
||||||
partial: failedQueries > 0,
|
partial: failedQueries > 0,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ import type { Query } from '@tanstack/react-query';
|
||||||
const IDB_KEY = 'skatespots:react-query-cache';
|
const IDB_KEY = 'skatespots:react-query-cache';
|
||||||
|
|
||||||
/** Bump when the persisted data shape changes to invalidate old caches. */
|
/** 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). */
|
/** Discard any persisted cache older than this (ms). */
|
||||||
export const PERSIST_MAX_AGE = 24 * 60 * 60 * 1000; // 24h
|
export const PERSIST_MAX_AGE = 24 * 60 * 60 * 1000; // 24h
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,8 @@ export default function CommunityStatsPage() {
|
||||||
const tradesByMethod = stats?.tradesByMethod ?? [];
|
const tradesByMethod = stats?.tradesByMethod ?? [];
|
||||||
const topCountries = stats?.topCountries ?? [];
|
const topCountries = stats?.topCountries ?? [];
|
||||||
const topCities = stats?.topCities ?? [];
|
const topCities = stats?.topCities ?? [];
|
||||||
|
const otherCountries = stats?.otherCountries ?? 0;
|
||||||
|
const otherCities = stats?.otherCities ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -363,7 +365,7 @@ export default function CommunityStatsPage() {
|
||||||
{(topCountries.length > 0 || topCities.length > 0) && (
|
{(topCountries.length > 0 || topCities.length > 0) && (
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
|
<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>
|
</h2>
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
{topCountries.length > 0 && (
|
{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>
|
<span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-white">{c.spots}</span>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
<span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-white">{c.spots}</span>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-[11px] text-gray-600">
|
<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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -321,6 +321,7 @@ export default function CreateSpotPage() {
|
||||||
// These power Sasquatch's city/country filtering (relay queries)
|
// These power Sasquatch's city/country filtering (relay queries)
|
||||||
let cityTag = '';
|
let cityTag = '';
|
||||||
let countryTag = '';
|
let countryTag = '';
|
||||||
|
let countryCodeTag = '';
|
||||||
let locationStr = location.trim();
|
let locationStr = location.trim();
|
||||||
try {
|
try {
|
||||||
const geoResp = await fetch(
|
const geoResp = await fetch(
|
||||||
|
|
@ -332,6 +333,9 @@ export default function CreateSpotPage() {
|
||||||
const addr = geoData.address ?? {};
|
const addr = geoData.address ?? {};
|
||||||
cityTag = addr.city ?? addr.town ?? addr.village ?? addr.county ?? '';
|
cityTag = addr.city ?? addr.town ?? addr.village ?? addr.county ?? '';
|
||||||
countryTag = addr.country ?? '';
|
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 user didn't fill in location, auto-populate from geocoding
|
||||||
if (!locationStr && cityTag) {
|
if (!locationStr && cityTag) {
|
||||||
locationStr = [cityTag, addr.state, countryTag].filter(Boolean).join(', ');
|
locationStr = [cityTag, addr.state, countryTag].filter(Boolean).join(', ');
|
||||||
|
|
@ -363,6 +367,11 @@ export default function CreateSpotPage() {
|
||||||
if (countryTag) {
|
if (countryTag) {
|
||||||
eventTags.push(['country', 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()) {
|
if (contactInfo.trim()) {
|
||||||
eventTags.push(['contact', contactInfo.trim()]);
|
eventTags.push(['contact', contactInfo.trim()]);
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,7 @@ export default function PrivacyPage() {
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc pl-5 space-y-1 text-gray-400">
|
<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">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">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">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>
|
<li><strong className="text-white">Cashu mints</strong> — see your IP whenever your wallet sends, receives or melts.</li>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue