From fe7e5cfae476b91d28a6bc479c1f0dea83564b49 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Thu, 1 Oct 2026 14:37:29 +0700 Subject: [PATCH] feat(spots): tag new spots/shops by default with a BigDataCloud fallback Create/edit geocoding used Nominatim alone, so when it failed (e.g. the 429) a new spot published with no city/country and landed in 'Uncategorised'. Now reverseGeocodeRegions() tries Nominatim, then falls back to BigDataCloud's client-side reverse-geocode-client, so a spot is region-tagged by default. Still never throws (an outage can't block a publish); the spot keeps its geohash and the stats' read-time derivation can still place it. derivedRegions reuses the same geocodeBigDataCloud helper (one implementation). --- AGENTS.md | 21 ++++++----- src/lib/derivedRegions.ts | 32 +++-------------- src/lib/geocodeRegions.ts | 76 +++++++++++++++++++++++++++++++++------ 3 files changed, 81 insertions(+), 48 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ba9ae3e..f652193 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -866,15 +866,18 @@ session with **no other memory of this project**. So: tagged ones) and **auto-hidden** spots (the map drops them at `MapPage:847`; the stats can't know about reports without querying them). - **Reverse geocoding hits Nominatim — disclosed (2026-10-01).** - `src/lib/geocodeRegions.ts` (`reverseGeocodeRegions` / `pushRegionTags`) is the - **single source of truth**, used by **spot create, spot edit, 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. + `src/lib/geocodeRegions.ts` (`reverseGeocodeRegions` / `geocodeBigDataCloud` / + `pushRegionTags`) is the **single source of truth**, used by **spot create, spot + edit, shop create and shop edit**. `reverseGeocodeRegions()` tries + `nominatim.openstreetmap.org/reverse?lat=…&lon=…` (`Accept-Language: en`) first, + then **falls back to BigDataCloud** when Nominatim returns nothing (it 429s — + see the derived-regions note above), so a new spot is tagged **by default**. It + **never throws** (a geocoder outage can't block a publish; the spot still gets + its geohash and the stats' read-time derivation can place it). Both providers + **see the creator's IP** and are disclosed on `/privacy` → "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. ⚠️ **`EditSpotPage` used to DROP region tags (fixed 2026-10-01):** like `EditShopPage` it rebuilds `eventTags` from scratch, so any `city`/`country`/ `country_code` the spot already had were silently lost on save (the new diff --git a/src/lib/derivedRegions.ts b/src/lib/derivedRegions.ts index 1b14c39..545c8bf 100644 --- a/src/lib/derivedRegions.ts +++ b/src/lib/derivedRegions.ts @@ -23,6 +23,8 @@ import ngeohash from 'ngeohash'; +import { geocodeBigDataCloud } from '@/lib/geocodeRegions'; + export interface DerivedRegion { city: string; country: string; @@ -35,7 +37,6 @@ const STORAGE_KEY = 'skatespots:derived-regions-v1'; * ~3 req/s. The whole list is one pass per device, then cached. */ const GEOCODE_SPACING_MS = 300; -const REQUEST_TIMEOUT_MS = 8000; let memoryCache: Record | null = null; @@ -94,31 +95,6 @@ export function coordsOf(event: { tags: string[][] }): { lat: number; lng: numbe return null; } -/** Best-effort reverse geocode via BigDataCloud. Never throws. */ -async function geocodeClient(lat: number, lng: number): Promise { - const empty: DerivedRegion = { city: '', country: '', countryCode: '' }; - try { - const res = await fetch( - `https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${lat}&longitude=${lng}&localityLanguage=en`, - { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }, - ); - if (!res.ok) return empty; - const data = (await res.json()) as { - city?: string; - locality?: string; - countryName?: string; - countryCode?: string; - }; - return { - city: data.city || data.locality || '', - country: data.countryName || '', - countryCode: String(data.countryCode ?? '').trim().toUpperCase(), - }; - } catch { - return empty; - } -} - /** * Reverse-geocode a list of coordinates and fill the cache. Sequential and * lightly spaced. `onProgress` fires after each item (so a UI counter can tick). @@ -136,9 +112,9 @@ export async function fillDerivedRegions( // Skip duplicates / already-cached pins, but still advance the progress bar. if (!seen.has(key) && !getDerivedRegion(lat, lng)) { seen.add(key); - const geo = await geocodeClient(lat, lng); + const geo = await geocodeBigDataCloud(lat, lng); if (geo.city || geo.country || geo.countryCode) { - setDerivedRegion(lat, lng, geo); + setDerivedRegion(lat, lng, { city: geo.city, country: geo.country, countryCode: geo.countryCode }); filled += 1; } if (i < coords.length - 1) { diff --git a/src/lib/geocodeRegions.ts b/src/lib/geocodeRegions.ts index 338a24b..e554a53 100644 --- a/src/lib/geocodeRegions.ts +++ b/src/lib/geocodeRegions.ts @@ -1,16 +1,24 @@ /** * 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. + * Turns a pin into `city` / `country` / `country_code` tags so the map AND the + * community stats agree on where a spot is. This is the **single source of truth** + * for the request, used by spot create/edit and shop create/edit. * - * ⚠️ Nominatim sees the caller's IP. That is disclosed in `/privacy` → - * "What leaves your device" → *Spot geocoding*. The coordinates are the + * Two providers, tried in order, so a new spot is tagged by default: + * 1. OpenStreetMap **Nominatim** (primary — matches existing data). + * 2. **BigDataCloud** `reverse-geocode-client` (fallback — client-friendly, no + * key). Nominatim's usage policy forbids per-viewer volume and can 429; a + * single create/edit call is fine, but we don't want a failure to mean an + * untagged spot, so we fall back. + * + * Best-effort by design — it **never throws**, so a geocoder outage can never + * block a publish. If both providers fail the spot still publishes (with its + * geohash); the stats' read-time derivation can still place it. + * + * ⚠️ Both providers see the caller's IP. Disclosed in `/privacy` → + * "What leaves your device" → *Spot/shop 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 { @@ -26,12 +34,18 @@ export interface GeocodedRegions { 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 { +const NOMINATIM_TIMEOUT_MS = 6000; +const BIGDATA_TIMEOUT_MS = 8000; + +/** Primary: OpenStreetMap Nominatim. Never throws. */ +async function geocodeNominatim(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' } }, + { + headers: { 'Accept-Language': 'en', 'User-Agent': 'SkateSpots/1.0' }, + signal: AbortSignal.timeout(NOMINATIM_TIMEOUT_MS), + }, ); if (!res.ok) return EMPTY; const data = (await res.json()) as { address?: Record }; @@ -47,6 +61,46 @@ export async function reverseGeocodeRegions(lat: number, lng: number): Promise { + try { + const res = await fetch( + `https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${lat}&longitude=${lng}&localityLanguage=en`, + { signal: AbortSignal.timeout(BIGDATA_TIMEOUT_MS) }, + ); + if (!res.ok) return EMPTY; + const data = (await res.json()) as { + city?: string; + locality?: string; + countryName?: string; + countryCode?: string; + principalSubdivision?: string; + }; + return { + city: data.city || data.locality || '', + country: data.countryName || '', + state: data.principalSubdivision || '', + countryCode: String(data.countryCode ?? '').trim().toUpperCase(), + }; + } catch { + return EMPTY; + } +} + +/** + * Reverse geocode a pin. Nominatim first, then BigDataCloud. Resolves to empty + * strings only when BOTH providers fail. + */ +export async function reverseGeocodeRegions(lat: number, lng: number): Promise { + const primary = await geocodeNominatim(lat, lng); + if (primary.city || primary.country || primary.countryCode) return primary; + return geocodeBigDataCloud(lat, lng); +} + /** 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]);