SkateSpots/src/hooks/useRegionBackfill.ts
SkateSpots dc0ae9bc32 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.
2026-10-01 13:58:21 +07:00

185 lines
6.6 KiB
TypeScript

/**
* 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,
};
}