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.