feat(stats): reconcile the region card, and geocode shops too
Answers 'why don't the map and stats match' by making the gap visible, and
brings shops into the region breakdown.
- src/lib/geocodeRegions.ts is now the single reverse-geocode helper (spot
create, shop create, shop edit), so the Nominatim call + Accept-Language + the
parse rules can't drift. Never throws.
- Shops now publish city/country/country_code (previously none), so they can
appear in the region breakdown.
- Stats: regions cover spots AND shops; a new 'Uncategorised (N)' row plus a
'{N} of {M}' footnote let the card reconcile with the location total. Section
retitled 'Spots & Shops by Region'.
- PERSIST_BUSTER v3 -> v4.
- /privacy: wording now 'spot or shop geocoding'.
The map deliberately still reads the community relay + the reader's relays
(forcing canonical would hide an opted-out user's own spots) - documented.
This commit is contained in:
parent
58154bf5d5
commit
9fe219aac6
9 changed files with 147 additions and 56 deletions
35
AGENTS.md
35
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`**
|
||||
|
|
|
|||
|
|
@ -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<string, { total: number; labels: Map<string, number> }>();
|
||||
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,
|
||||
};
|
||||
},
|
||||
|
|
|
|||
55
src/lib/geocodeRegions.ts
Normal file
55
src/lib/geocodeRegions.ts
Normal file
|
|
@ -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<GeocodedRegions> {
|
||||
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<string, string> };
|
||||
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]);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) && (
|
||||
<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]" /> Spots by Region
|
||||
<Globe className="h-3.5 w-3.5 text-[#4ade80]" /> Spots & Shops by Region
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{topCountries.length > 0 && (
|
||||
|
|
@ -393,6 +395,13 @@ export default function CommunityStatsPage() {
|
|||
<span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-gray-400">{otherCountries}</span>
|
||||
</div>
|
||||
)}
|
||||
{uncategorised > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-24 truncate text-xs text-gray-500" title="No city/country tag">Uncategorised</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">{uncategorised}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
|
@ -421,13 +430,23 @@ export default function CommunityStatsPage() {
|
|||
<span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-gray-400">{otherCities}</span>
|
||||
</div>
|
||||
)}
|
||||
{uncategorised > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-24 truncate text-xs text-gray-500" title="No city/country tag">Uncategorised</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">{uncategorised}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 text-[11px] text-gray-600">
|
||||
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.
|
||||
Counts <strong className="text-gray-500">spots & shops</strong>, not skaters — from each
|
||||
one's own city/country tag (countries grouped by ISO code when present).
|
||||
{locationsTotal > 0 && (
|
||||
<> <strong className="text-gray-500">{uncategorised} of {locationsTotal}</strong> location{locationsTotal === 1 ? '' : 's'} have no location tag and show as “Uncategorised”.</>
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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()]);
|
||||
|
|
|
|||
|
|
@ -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()]);
|
||||
|
|
|
|||
|
|
@ -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()]);
|
||||
|
|
|
|||
|
|
@ -148,7 +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">Spot/shop geocoding</strong> (OpenStreetMap <strong className="text-white">Nominatim</strong>) — <em>only when you create a spot or shop</em>, we send its coordinates to fill in the city/country tag. Nominatim sees your IP. This is the <em>pin you placed</em>, 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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue