feat(stats): admin backfill for legacy untagged spots/shops
Spots created before reverse geocoding have coordinates but no city/country tag, so the map draws them while /stats can only show "Uncategorised". Counted on the canonical relay: 54 raw -> 43 after admin-hide -> 13 tagged / 30 uncategorised, and 21 of the 30 are the two admin accounts' own pins. useRegionBackfill finds the logged-in user's own untagged spots/shops, decodes each pin's coords (lat/lon else geohash), reverse-geocodes via the shared reverseGeocodeRegions(), and republishes the addressable event with the region tags added - preserving every existing tag. Publishes to getWriteRelays() (community + author) so the canonical stats see it, spaced 1.1s for Nominatim's 1 req/s policy. Surfaced as an admin-only RegionBackfillCard in Settings -> Advanced.
This commit is contained in:
parent
cc4e1cd5ca
commit
dc0ae9bc32
4 changed files with 325 additions and 0 deletions
21
AGENTS.md
21
AGENTS.md
|
|
@ -884,6 +884,27 @@ session with **no other memory of this project**. So:
|
|||
editor is added. Related: spots created before the tags existed stay
|
||||
"Uncategorised" in `/stats` until they're re-saved (only the author can update
|
||||
an addressable event).
|
||||
- **Admin backfill for legacy untagged pins (2026-10-01):** the reason
|
||||
`/stats` "Spots & Shops by Region" under-counts vs the map is **tag coverage,
|
||||
not a stats bug** — the map draws a pin by its coordinates, the region card
|
||||
can only place a pin that carries a `city`/`country` tag. Measured on the
|
||||
canonical relay: raw **54** spots+shops → **43** after admin-hide → **13
|
||||
tagged / 30 "Uncategorised"**, and **21 of those 30 are the two admin
|
||||
accounts' own pre-geocoding pins**. So `useRegionBackfill.ts` +
|
||||
`RegionBackfillCard` (Settings → Advanced, admin-only) find the **logged-in
|
||||
user's OWN** untagged spots/shops, decode each pin's coords (`lat`/`lon` else
|
||||
`g`), `reverseGeocodeRegions()` them (spaced **1.1 s** for Nominatim's
|
||||
1 req/s policy), and republish the addressable event with the region tags
|
||||
added — **every existing tag preserved**, only stale `city`/`country`/
|
||||
`country_code` replaced. Publishes to **`getWriteRelays()`** (community relay
|
||||
+ author) so canonical `queryCanonical` stats see it, then invalidates
|
||||
`skate-spots` + `growth-stats`. **Only the author's key can update an
|
||||
addressable event**, so each admin must run it while logged in as themselves;
|
||||
the 9 remaining untagged belong to 9 distinct community mappers and can't be
|
||||
fixed centrally. ⚠️ Even after backfill, a map **cluster bubble ≠ a country
|
||||
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.
|
||||
- **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`**
|
||||
|
|
|
|||
117
src/components/RegionBackfillCard.tsx
Normal file
117
src/components/RegionBackfillCard.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* RegionBackfillCard — admin-only: backfill missing `city`/`country` tags on the
|
||||
* logged-in user's own legacy spots & shops.
|
||||
*
|
||||
* Why: spots created before reverse geocoding existed have coordinates (so they
|
||||
* appear on the map) but no region tags (so `/stats` can only call them
|
||||
* "Uncategorised", under-counting the country vs the map). This re-derives the
|
||||
* tags from each pin and republishes the addressable event.
|
||||
*
|
||||
* Renders nothing for non-admins, so it can sit in Settings → Advanced beside the
|
||||
* other owner cards.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { MapPin, Loader2 } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useRegionBackfill, type BackfillResult } from '@/hooks/useRegionBackfill';
|
||||
import { isAdminPubkey } from '@/lib/admins';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
export function RegionBackfillCard() {
|
||||
const { user } = useCurrentUser();
|
||||
const isAdmin = isAdminPubkey(user?.pubkey);
|
||||
const { candidates, isLoading, backfill, progress, isRunning } = useRegionBackfill(isAdmin);
|
||||
const [summary, setSummary] = useState<BackfillResult | null>(null);
|
||||
|
||||
if (!isAdmin) return null;
|
||||
|
||||
const count = candidates.length;
|
||||
|
||||
const run = async () => {
|
||||
setSummary(null);
|
||||
try {
|
||||
const result = await backfill();
|
||||
setSummary(result);
|
||||
if (result.updated > 0) {
|
||||
toast({
|
||||
title: `Tagged ${result.updated} location${result.updated === 1 ? '' : 's'}`,
|
||||
description:
|
||||
result.skipped > 0
|
||||
? `${result.skipped} skipped (no coordinates or no geocoder match).`
|
||||
: 'Region tags written to the community relay.',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'Nothing to tag',
|
||||
description: 'No pins could be geocoded. Try again later.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Backfill failed',
|
||||
description: err instanceof Error ? err.message : 'Try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-white/10 bg-white/2">
|
||||
<CardContent className="py-3.5 space-y-2.5">
|
||||
<p className="flex items-center gap-2 text-sm font-bold text-white">
|
||||
<MapPin className="h-4 w-4 text-[#ccff00]" />
|
||||
Backfill region tags
|
||||
<span className="rounded bg-[#ccff00]/10 px-1.5 py-0.5 text-[9px] font-black uppercase tracking-widest text-[#ccff00]/70">
|
||||
Admin
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pins made before geocoding existed have coordinates but no city/country tag, so the map
|
||||
draws them while Community Stats can only say “Uncategorised”. This re-derives the tags
|
||||
from each pin and republishes <strong>your own</strong> untagged spots & shops.
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-xs text-muted-foreground">Scanning your spots…</p>
|
||||
) : count === 0 ? (
|
||||
<p className="text-xs text-[#ccff00]">
|
||||
Every spot & shop you own already has a region tag. 🎉
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{count} of your spot{count === 1 ? '' : 's'}/shops have no region tag. Your signer may
|
||||
ask to approve each one.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
onClick={run}
|
||||
disabled={isRunning}
|
||||
className="h-9 gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
{isRunning ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<MapPin className="h-4 w-4" />
|
||||
)}
|
||||
{isRunning && progress
|
||||
? `Tagging ${progress.done}/${progress.total}…`
|
||||
: 'Backfill region tags'}
|
||||
</Button>
|
||||
{summary && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{summary.updated} updated · {summary.skipped} skipped · {summary.failed} failed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
185
src/hooks/useRegionBackfill.ts
Normal file
185
src/hooks/useRegionBackfill.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
/**
|
||||
* Region backfill — admin tooling
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* Spots and shops created before reverse geocoding landed (2026-10-01) have a
|
||||
* geohash/coordinates but no `city` / `country` tag. On the map they render
|
||||
* normally; in `/stats` they can only ever be "Uncategorised". The community
|
||||
* stats therefore under-count a country relative to the map's pins.
|
||||
*
|
||||
* This hook finds the LOGGED-IN user's own untagged spots/shops, re-derives the
|
||||
* region tags from each pin's coordinates (the same `reverseGeocodeRegions()`
|
||||
* that create/edit use) and republishes the addressable event with those tags
|
||||
* added. It only ever touches the current user's content — an addressable event
|
||||
* can only be replaced by its author's key.
|
||||
*
|
||||
* Faithful to the original event: every existing tag is preserved; only an
|
||||
* out-of-date `city`/`country`/`country_code` is replaced.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import ngeohash from 'ngeohash';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
import { SKATE_SPOT_KIND, SKATE_SHOP_KIND } from '@/lib/types';
|
||||
import { reverseGeocodeRegions } from '@/lib/geocodeRegions';
|
||||
|
||||
/** Nominatim's usage policy allows at most one request per second. */
|
||||
const GEOCODE_SPACING_MS = 1100;
|
||||
const REGION_TAG_NAMES = new Set(['city', 'country', 'country_code']);
|
||||
|
||||
export interface BackfillCandidate {
|
||||
id: string;
|
||||
kind: number;
|
||||
identifier: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface BackfillResult {
|
||||
updated: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
/** Same definition the stats use to decide a location is "tagged". */
|
||||
function hasRegionTag(event: NostrEvent): boolean {
|
||||
return event.tags.some(
|
||||
([name, value]) => (name === 'city' || name === 'country') && !!value?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
/** Coordinates from explicit lat/lon tags, else decoded from the geohash. */
|
||||
function coordsOf(event: NostrEvent): { 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) &&
|
||||
lat >= -90 && lat <= 90 && lng >= -180 && 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;
|
||||
}
|
||||
|
||||
export function useRegionBackfill(enabled = true) {
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
const { querySkateContent, getWriteRelays } = useSkateRelays();
|
||||
const queryClient = useQueryClient();
|
||||
const [progress, setProgress] = useState<{ done: number; total: number } | null>(null);
|
||||
|
||||
const eventsQuery = useQuery({
|
||||
queryKey: ['region-backfill', user?.pubkey ?? 'anon'],
|
||||
enabled: enabled && !!user,
|
||||
staleTime: 0,
|
||||
queryFn: async () => {
|
||||
if (!user) return [];
|
||||
const signal = AbortSignal.timeout(15000);
|
||||
return querySkateContent(
|
||||
[{ kinds: [SKATE_SPOT_KIND, SKATE_SHOP_KIND], authors: [user.pubkey], limit: 500 }],
|
||||
{ signal },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const candidates = useMemo<BackfillCandidate[]>(
|
||||
() =>
|
||||
(eventsQuery.data ?? [])
|
||||
.filter((event) => !hasRegionTag(event))
|
||||
.map((event) => ({
|
||||
id: event.id,
|
||||
kind: event.kind,
|
||||
identifier: event.tags.find(([name]) => name === 'd')?.[1] ?? '',
|
||||
title: event.tags.find(([name]) => name === 'title')?.[1] ?? '(untitled)',
|
||||
})),
|
||||
[eventsQuery.data],
|
||||
);
|
||||
|
||||
const backfill = useCallback(async (): Promise<BackfillResult> => {
|
||||
if (!user) throw new Error('Log in first.');
|
||||
const events = (eventsQuery.data ?? []).filter((event) => !hasRegionTag(event));
|
||||
const relays = getWriteRelays();
|
||||
const result: BackfillResult = { updated: 0, skipped: 0, failed: 0 };
|
||||
setProgress({ done: 0, total: events.length });
|
||||
|
||||
for (let i = 0; i < events.length; i += 1) {
|
||||
const event = events[i];
|
||||
try {
|
||||
const coords = coordsOf(event);
|
||||
if (!coords) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const geo = await reverseGeocodeRegions(coords.lat, coords.lng);
|
||||
if (!geo.city && !geo.country && !geo.countryCode) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Preserve every existing tag; only refresh the region tags.
|
||||
const tags = event.tags.filter(([name]) => !REGION_TAG_NAMES.has(name));
|
||||
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]);
|
||||
|
||||
const signed = await user.signer.signEvent({
|
||||
kind: event.kind,
|
||||
content: event.content,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
});
|
||||
|
||||
// Publish where the stats READ (community relay + the author's relays).
|
||||
const published = await Promise.allSettled(
|
||||
relays.map((url) =>
|
||||
nostr.relay(url).event(signed, { signal: AbortSignal.timeout(8000) }),
|
||||
),
|
||||
);
|
||||
if (published.some((r) => r.status === 'fulfilled')) result.updated += 1;
|
||||
else result.failed += 1;
|
||||
} catch {
|
||||
result.failed += 1;
|
||||
} finally {
|
||||
setProgress({ done: i + 1, total: events.length });
|
||||
}
|
||||
|
||||
// Respect Nominatim's 1 req/s limit (no need to wait after the last one).
|
||||
if (i < events.length - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, GEOCODE_SPACING_MS));
|
||||
}
|
||||
}
|
||||
|
||||
setProgress(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['skate-spots'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['growth-stats'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['region-backfill'] });
|
||||
return result;
|
||||
}, [user, eventsQuery.data, nostr, getWriteRelays, queryClient]);
|
||||
|
||||
return {
|
||||
candidates,
|
||||
isLoading: eventsQuery.isLoading,
|
||||
backfill,
|
||||
progress,
|
||||
isRunning: progress !== null,
|
||||
};
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import { CVMHealthCheck } from '@/components/CVMHealthCheck';
|
|||
import { SasquatchAiStatusCard } from '@/components/SasquatchAiStatusCard';
|
||||
import { GrantProCard } from '@/components/GrantProCard';
|
||||
import { AdminReviewCard } from '@/components/AdminReviewCard';
|
||||
import { RegionBackfillCard } from '@/components/RegionBackfillCard';
|
||||
import { NotificationSettingsCard } from '@/components/NotificationSettingsCard';
|
||||
import { CoordinatorSettingsCard } from '@/components/CoordinatorSettingsCard';
|
||||
import { LanguageSelector } from '@/components/LanguageToggle';
|
||||
|
|
@ -657,6 +658,7 @@ function AdvancedScreen({ onBack }: { onBack: () => void }) {
|
|||
<SasquatchAiStatusCard />
|
||||
<GrantProCard />
|
||||
<AdminReviewCard />
|
||||
<RegionBackfillCard />
|
||||
<div className="rounded-2xl border border-amber-500/20 overflow-hidden bg-white/2">
|
||||
<div className="px-4 py-3.5 border-b border-white/5">
|
||||
<p className="text-sm font-bold text-amber-400">🦧 Sasquatch Guide</p>
|
||||
|
|
|
|||
Loading…
Reference in a new issue