feat(stats): derive region from a pin's coordinates when it has no tag
The region card only placed pins carrying a city/country tag, so legacy pins (coords but no tag) drew on the map yet landed in 'Uncategorised' — the map and the stats disagreed. useGrowthStats now falls back to a cached reverse geocode of the pin's own coordinates (lib/derivedRegions, localStorage keyed by ~110m coords). useDeriveRegions (mounted on /stats) fills cache misses at 1.1s spacing for Nominatim's 1 req/s policy and invalidates growth-stats; the card shows a 'Resolving N...' note meanwhile. Display-only fallback; the admin backfill still writes real tags. Read-time geocoding disclosed on /privacy.
This commit is contained in:
parent
02b81a8b76
commit
7bad809878
6 changed files with 249 additions and 20 deletions
12
AGENTS.md
12
AGENTS.md
|
|
@ -905,6 +905,18 @@ session with **no other memory of this project**. So:
|
|||
count** — bubbles are 60 px proximity (`markerClusterGroup`) and can span
|
||||
borders, and the map also plots shops/gear/tours/events/crews the region card
|
||||
doesn't count.
|
||||
- **Read-time derived regions (2026-10-01):** so the region card matches the map
|
||||
with **NO signing**, `useGrowthStats` falls back to a **cached reverse geocode
|
||||
of an untagged pin's own coordinates** (`lib/derivedRegions.ts`, localStorage
|
||||
`skatespots:derived-regions-v1`, keyed by ~110 m-rounded coords). A
|
||||
`useDeriveRegions()` hook mounted on `/stats` scans the community relay for
|
||||
untagged spots/shops, fills cache misses at **1.1 s spacing** (Nominatim
|
||||
1 req/s) and invalidates `growth-stats`; the card shows "Resolving N…" until
|
||||
done. ⚠️ This geocodes pins **when the stats page is viewed**, so Nominatim
|
||||
sees the viewer's IP + public pin coords — disclosed on `/privacy`
|
||||
("Spot/shop geocoding"). It is a **display fallback only**; the backfill above
|
||||
is what writes real `city`/`country` tags. Either way the card's `Uncategorised`
|
||||
row now means "couldn't be tagged OR resolved", not "no tag".
|
||||
- **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`**
|
||||
|
|
|
|||
80
src/hooks/useDeriveRegions.ts
Normal file
80
src/hooks/useDeriveRegions.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* useDeriveRegions — keeps the stats' "Spots & Shops by Region" card in sync with
|
||||
* the map by resolving a region for untagged pins.
|
||||
*
|
||||
* It scans the community relay for spots/shops that have no `city`/`country`
|
||||
* tag, reverse-geocodes their pin coordinates ONCE (cached in localStorage by
|
||||
* `lib/derivedRegions`), then invalidates the stats query so the region card
|
||||
* picks the derived regions up. Mount it once on the stats page.
|
||||
*
|
||||
* Nominatim allows ~1 request/second, so the first run on a fresh device takes a
|
||||
* while; `pending` lets the UI say so. Later visits read the cache instantly.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
import { SKATE_SPOT_KIND, SKATE_SHOP_KIND } from '@/lib/types';
|
||||
import { coordsOf, getDerivedRegion, fillDerivedRegions } from '@/lib/derivedRegions';
|
||||
|
||||
export interface DeriveRegionsState {
|
||||
/** Untagged pins still awaiting a (cached) reverse geocode. */
|
||||
pending: number;
|
||||
}
|
||||
|
||||
export function useDeriveRegions(): DeriveRegionsState {
|
||||
const { queryCanonical } = useSkateRelays();
|
||||
const queryClient = useQueryClient();
|
||||
const running = useRef(false);
|
||||
const [pending, setPending] = useState(0);
|
||||
|
||||
const scan = useQuery({
|
||||
queryKey: ['derive-regions-scan'],
|
||||
// One scan per five minutes is plenty — this only changes when a new
|
||||
// untagged pin appears.
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
const signal = AbortSignal.timeout(15000);
|
||||
const [spots, shops] = await Promise.all([
|
||||
queryCanonical([{ kinds: [SKATE_SPOT_KIND], '#t': ['skatespot'], limit: 2000 }], { signal }).catch(() => []),
|
||||
queryCanonical([{ kinds: [SKATE_SHOP_KIND], limit: 2000 }], { signal }).catch(() => []),
|
||||
]);
|
||||
|
||||
const coords: Array<{ lat: number; lng: number }> = [];
|
||||
for (const event of [...spots, ...shops]) {
|
||||
const tagged = event.tags.some(
|
||||
([name, value]) => (name === 'city' || name === 'country') && !!value?.trim(),
|
||||
);
|
||||
if (tagged) continue;
|
||||
const c = coordsOf(event);
|
||||
if (c) coords.push(c);
|
||||
}
|
||||
return coords;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const coords = scan.data;
|
||||
if (!coords || running.current) return;
|
||||
|
||||
const misses = coords.filter((c) => !getDerivedRegion(c.lat, c.lng));
|
||||
setPending(misses.length);
|
||||
if (misses.length === 0) return;
|
||||
|
||||
running.current = true;
|
||||
void (async () => {
|
||||
try {
|
||||
await fillDerivedRegions(misses);
|
||||
const unresolved = misses.filter((c) => !getDerivedRegion(c.lat, c.lng)).length;
|
||||
setPending(unresolved);
|
||||
// Recompute the region card now that more pins have a derived region.
|
||||
queryClient.invalidateQueries({ queryKey: ['growth-stats'] });
|
||||
} finally {
|
||||
running.current = false;
|
||||
}
|
||||
})();
|
||||
}, [scan.data, queryClient]);
|
||||
|
||||
return { pending };
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ import {
|
|||
import { TRADE_ATTESTATION_KIND } from '@/hooks/useTradeAttestations';
|
||||
import { useAdminModeration } from '@/hooks/useAdminModeration';
|
||||
import { getSatoshisFromBolt11 } from '@/lib/bolt11';
|
||||
import { coordsOf, getDerivedRegion } from '@/lib/derivedRegions';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -290,20 +291,37 @@ export function useGrowthStats() {
|
|||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
// ── Regions ──────────────────────────────────────────────────────────
|
||||
// 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
|
||||
// Spots AND shops are considered together. A location's region comes from
|
||||
// its own `city`/`country` tag when present; otherwise it is DERIVED from
|
||||
// the pin's coordinates via a cached reverse geocode (`derivedRegions`), so
|
||||
// the card matches the pins the map draws instead of stranding them as
|
||||
// "Uncategorised". 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 regionOf = (e: { tags: string[][] }): { city: string; country: string; code: string } | null => {
|
||||
const city = e.tags.find((t) => t[0] === 'city' && t[1])?.[1]?.trim() ?? '';
|
||||
const country = e.tags.find((t) => t[0] === 'country' && t[1])?.[1]?.trim() ?? '';
|
||||
const code = e.tags.find((t) => t[0] === 'country_code' && t[1])?.[1]?.trim().toUpperCase() ?? '';
|
||||
if (city || country) return { city, country, code };
|
||||
// No tag — fall back to a cached reverse geocode of the pin itself.
|
||||
const coords = coordsOf(e);
|
||||
if (coords) {
|
||||
const derived = getDerivedRegion(coords.lat, coords.lng);
|
||||
if (derived && (derived.city || derived.country)) {
|
||||
return { city: derived.city, country: derived.country, code: derived.countryCode };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const regionEntries = allLocations.map((e) => regionOf(e));
|
||||
const regionGroups = (kind: 'country' | 'city') => {
|
||||
const groups = new Map<string, { total: number; labels: Map<string, number> }>();
|
||||
for (const s of allLocations) {
|
||||
const name = s.tags.find((t) => t[0] === nameTag && t[1])?.[1]?.trim();
|
||||
for (const region of regionEntries) {
|
||||
if (!region) continue;
|
||||
const name = kind === 'country' ? region.country : region.city;
|
||||
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 key = kind === 'country' ? region.code || name.toLowerCase() : 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);
|
||||
|
|
@ -316,21 +334,18 @@ export function useGrowthStats() {
|
|||
}))
|
||||
.sort((a, b) => b.spots - a.spots);
|
||||
};
|
||||
const allCountries = regionGroups('country', true);
|
||||
const allCities = regionGroups('city', false);
|
||||
const allCountries = regionGroups('country');
|
||||
const allCities = regionGroups('city');
|
||||
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
|
||||
// Regions that 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);
|
||||
// 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).
|
||||
// Locations whose region is neither tagged nor (yet) derivable. Surfaced as
|
||||
// an "Uncategorised" row so the cards reconcile with the location total.
|
||||
const locationsTotal = allLocations.length;
|
||||
const uncategorised = allLocations.filter(
|
||||
(e) => !e.tags.some((t) => (t[0] === 'country' || t[0] === 'city') && t[1]),
|
||||
).length;
|
||||
const uncategorised = regionEntries.filter((r) => r === null).length;
|
||||
|
||||
// ── Monthly bucketing ───────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
111
src/lib/derivedRegions.ts
Normal file
111
src/lib/derivedRegions.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* Derived regions — a read-time fallback for pins with no city/country tag
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* The map plots a spot by its **coordinates** (geohash); the region card can only
|
||||
* place a spot that carries a `city`/`country` tag. Spots made before reverse
|
||||
* geocoding existed therefore appear on the map but land in "Uncategorised" — the
|
||||
* map and the stats legitimately disagree.
|
||||
*
|
||||
* This module lets the stats DERIVE the region from the pin's own coordinates
|
||||
* instead, so the card matches the map with no action from the user. Results are
|
||||
* cached in localStorage (keyed by ~110 m-rounded coordinates), so each pin is
|
||||
* reverse-geocoded at most once per device.
|
||||
*
|
||||
* ⚠️ Privacy: unlike create/edit geocoding (which is the author's own pin), this
|
||||
* runs when the STATS PAGE IS VIEWED, so Nominatim sees the viewer's IP and the
|
||||
* (public) coordinates of untagged pins. Disclosed on /privacy.
|
||||
*/
|
||||
|
||||
import ngeohash from 'ngeohash';
|
||||
|
||||
import { reverseGeocodeRegions } from '@/lib/geocodeRegions';
|
||||
|
||||
export interface DerivedRegion {
|
||||
city: string;
|
||||
country: string;
|
||||
countryCode: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'skatespots:derived-regions-v1';
|
||||
/** Nominatim's usage policy allows at most one request per second. */
|
||||
const GEOCODE_SPACING_MS = 1100;
|
||||
|
||||
let memoryCache: Record<string, DerivedRegion> | null = null;
|
||||
|
||||
function readCache(): Record<string, DerivedRegion> {
|
||||
if (memoryCache) return memoryCache;
|
||||
try {
|
||||
memoryCache = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Record<string, DerivedRegion>;
|
||||
} catch {
|
||||
memoryCache = {};
|
||||
}
|
||||
return memoryCache;
|
||||
}
|
||||
|
||||
/** ~110 m bucket — matches the 7-char geohash precision used for pins. */
|
||||
export function regionCoordKey(lat: number, lng: number): string {
|
||||
return `${lat.toFixed(3)},${lng.toFixed(3)}`;
|
||||
}
|
||||
|
||||
export function getDerivedRegion(lat: number, lng: number): DerivedRegion | undefined {
|
||||
return readCache()[regionCoordKey(lat, lng)];
|
||||
}
|
||||
|
||||
export function setDerivedRegion(lat: number, lng: number, region: DerivedRegion): void {
|
||||
const cache = readCache();
|
||||
cache[regionCoordKey(lat, lng)] = region;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(cache));
|
||||
} catch {
|
||||
/* quota or storage unavailable — keep the in-memory copy */
|
||||
}
|
||||
}
|
||||
|
||||
/** Coordinates from explicit lat/lon tags, else decoded from the geohash. */
|
||||
export function coordsOf(event: { tags: string[][] }): { lat: number; lng: number } | null {
|
||||
const latTag = event.tags.find(([name]) => name === 'lat')?.[1];
|
||||
const lonTag = event.tags.find(([name]) => name === 'lon')?.[1];
|
||||
if (latTag && lonTag) {
|
||||
const lat = parseFloat(latTag);
|
||||
const lng = parseFloat(lonTag);
|
||||
if (Number.isFinite(lat) && Number.isFinite(lng) && Math.abs(lat) <= 90 && Math.abs(lng) <= 180) {
|
||||
return { lat, lng };
|
||||
}
|
||||
}
|
||||
|
||||
const geohash = event.tags.find(([name]) => name === 'g')?.[1];
|
||||
if (geohash) {
|
||||
try {
|
||||
const { latitude, longitude } = ngeohash.decode(geohash);
|
||||
if (Number.isFinite(latitude) && Number.isFinite(longitude)) {
|
||||
return { lat: latitude, lng: longitude };
|
||||
}
|
||||
} catch {
|
||||
/* not a decodable geohash */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse-geocode a list of coordinates and fill the cache. Sequential and
|
||||
* spaced for Nominatim's 1 req/s policy. Returns how many NEW regions were added.
|
||||
*/
|
||||
export async function fillDerivedRegions(
|
||||
coords: Array<{ lat: number; lng: number }>,
|
||||
): Promise<number> {
|
||||
let filled = 0;
|
||||
for (let i = 0; i < coords.length; i += 1) {
|
||||
const { lat, lng } = coords[i];
|
||||
if (getDerivedRegion(lat, lng)) continue;
|
||||
const geo = await reverseGeocodeRegions(lat, lng);
|
||||
if (geo.city || geo.country || geo.countryCode) {
|
||||
setDerivedRegion(lat, lng, { city: geo.city, country: geo.country, countryCode: geo.countryCode });
|
||||
filled += 1;
|
||||
}
|
||||
if (i < coords.length - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, GEOCODE_SPACING_MS));
|
||||
}
|
||||
}
|
||||
return filled;
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { genUserName } from '@/lib/genUserName';
|
|||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { useGrowthStats } from '@/hooks/useGrowthStats';
|
||||
import { RegionBackfillCard } from '@/components/RegionBackfillCard';
|
||||
import { useDeriveRegions } from '@/hooks/useDeriveRegions';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatAge } from '@/lib/routeCache';
|
||||
|
||||
|
|
@ -179,6 +180,8 @@ export default function CommunityStatsPage() {
|
|||
});
|
||||
|
||||
const { data: stats, isLoading, isError, isFetching, refetch, dataUpdatedAt } = useGrowthStats();
|
||||
// Resolve a region for untagged pins (cached) so the region card matches the map.
|
||||
const { pending: derivingPending } = useDeriveRegions();
|
||||
// Collapse the 13 Lab craft cards on mobile (they were ~7 rows of scrolling
|
||||
// before the first chart); always expanded from lg up.
|
||||
const [showAllLab, setShowAllLab] = useState(false);
|
||||
|
|
@ -370,6 +373,13 @@ export default function CommunityStatsPage() {
|
|||
<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 & Shops by Region
|
||||
</h2>
|
||||
{derivingPending > 0 && (
|
||||
<p className="-mt-2 mb-4 flex items-center gap-2 text-xs text-amber-300/90">
|
||||
<RefreshCw className="h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||
Resolving {derivingPending} untagged location{derivingPending === 1 ? '' : 's'} from
|
||||
their coordinates… one-time, then cached on this device.
|
||||
</p>
|
||||
)}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{topCountries.length > 0 && (
|
||||
<Card className="border-white/8">
|
||||
|
|
@ -444,7 +454,8 @@ export default function CommunityStatsPage() {
|
|||
</div>
|
||||
<p className="mt-3 text-[11px] text-gray-600">
|
||||
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),
|
||||
one's own city/country tag, or derived from its pin’s coordinates when it has
|
||||
none (cached on your device; countries grouped by ISO code when present),
|
||||
community relay only, with moderation-hidden content excluded.
|
||||
{locationsTotal > 0 && (
|
||||
<> <strong className="text-gray-500">{uncategorised} of {locationsTotal}</strong> location{locationsTotal === 1 ? '' : 's'} have no location tag and show as “Uncategorised”.</>
|
||||
|
|
|
|||
|
|
@ -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/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">Spot/shop geocoding</strong> (OpenStreetMap <strong className="text-white">Nominatim</strong>) — when you create a spot or shop we send its coordinates to fill in the city/country tag, and when you open <strong className="text-white">Community Stats</strong> we may send the coordinates of pins that have no location tag yet, so they can be grouped by region. Each is cached on your device, so it happens at most once per pin. Nominatim sees your IP. These are the <em>pin you placed / public pin coordinates</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