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.
185 lines
6.6 KiB
TypeScript
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,
|
|
};
|
|
}
|