feat(map): spots and shops come only from the community relay

Owner decision: the map's spots + shops are canonical (relay.skate-spots.org
only), not blended with the viewer's personal relays. useSkateSpots and useShops
now use queryCanonical with a stable 'canonical' query-key suffix. The spot
detail page (useSkateSpot) stays blended so shared links to a spot published only
on a user relay still resolve. Verified with a headless map screenshot showing
canonical clusters.
This commit is contained in:
SkateSpots 2026-10-26 12:00:21 +07:00
parent 6751642eed
commit f88dfe79ab
3 changed files with 24 additions and 17 deletions

View file

@ -1696,16 +1696,19 @@ session with **no other memory of this project**. So:
cannot catch this** — it boots a clean profile with no persisted cache, so it cannot catch this** — it boots a clean profile with no persisted cache, so it
passed while real users crashed. Any new field on an allow-listed query needs passed while real users crashed. Any new field on an allow-listed query needs
both. both.
- ⚠️ **The map and the stats legitimately differ — do NOT "fix" the map to - ✅ **The map is now CANONICAL for spots + shops (owner decision, 2026-10-26).**
`queryCanonical` (2026-10-01).** The map (`useSkateSpots` → `useSkateSpots` and `useShops` use `queryCanonical` (community relay
`querySkateContent`) reads the community relay **+ the reader's own relays**; `relay.skate-spots.org` ONLY) with a stable `'canonical'` query-key suffix, so
the stats read the community relay only (`queryCanonical`). Forcing canonical the map always matches the community dataset regardless of the viewer's
on the map would hide spots for a user who opted out of the community relay personal relays. This **supersedes** the 2026-10-01 note below (which kept the
(`useSkateRelay=false`) — including **their own**. Two more reasons a "23" map blended so a `useSkateRelay=false` user still saw their own pins). The
cluster ≠ a country row: a cluster bubble is proximity (may span countries), spot **detail** page (`useSkateSpot`) stays blended so shared links to a spot
and the region card only counts locations **with a location tag** while the published only on a user relay still resolve. Remaining map↔stats deltas:
map draws every pin. The number comparable to the map is the hero a cluster bubble is proximity (may span countries), and the region card only
**"Skate Spots"** card (`totalSpots`), not the region rows. counts locations **with a location tag** while the map draws every pin — the
number comparable to the map is the hero **"Skate Spots"** card.
- ~~The map and the stats legitimately differ — do NOT "fix" the map to
`queryCanonical` (2026-10-01).~~ (superseded above)
- **Moderation now applies to the stats too (2026-10-01):** `useGrowthStats` - **Moderation now applies to the stats too (2026-10-01):** `useGrowthStats`
calls `useAdminModeration()` and drops hidden content inside `dedup()`, so a calls `useAdminModeration()` and drops hidden content inside `dedup()`, so a
spot the community removed no longer inflates `totalSpots`, the regions, or spot the community removed no longer inflates `totalSpots`, the regions, or

View file

@ -3,15 +3,16 @@ import { SKATE_SHOP_KIND, SKATE_SPOT_KIND, parseSkateShop, parseSkateSpot, type
import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useSkateRelays } from '@/hooks/useSkateRelays';
export function useShops(limit = 100) { export function useShops(limit = 100) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); // Map/shop directory is canonical: shops come from the community relay only.
const { queryCanonical } = useSkateRelays();
return useQuery({ return useQuery({
queryKey: ['shop-directory', 'list', limit, getQueryKeySuffix()], queryKey: ['shop-directory', 'list', limit, 'canonical'],
queryFn: async () => { queryFn: async () => {
const signal = AbortSignal.timeout(10000); const signal = AbortSignal.timeout(10000);
// Query new shop kind (30017) // Query new shop kind (30017)
const newShops = await querySkateContent( const newShops = await queryCanonical(
[ [
{ {
kinds: [SKATE_SHOP_KIND], kinds: [SKATE_SHOP_KIND],
@ -22,7 +23,7 @@ export function useShops(limit = 100) {
{ signal, requireAny: true } { signal, requireAny: true }
); );
// Query old shops (kind 30818 with 'shop' type) for backward compatibility // Query old shops (kind 30818 with 'shop' type) for backward compatibility
const oldShops = await querySkateContent( const oldShops = await queryCanonical(
[ [
{ {
kinds: [SKATE_SPOT_KIND], kinds: [SKATE_SPOT_KIND],

View file

@ -20,11 +20,14 @@ const QUERY_TIMEOUT = 15000; // 15s — mobile cold-start needs mo
*/ */
export function useSkateSpots(limit = 100, options: { includeHidden?: boolean } = {}) { export function useSkateSpots(limit = 100, options: { includeHidden?: boolean } = {}) {
const { includeHidden = false } = options; const { includeHidden = false } = options;
const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); // The map is CANONICAL: spots come from the community relay only, so what is
// shown always matches the community dataset regardless of the viewer's
// personal relays. (Details/profile pages stay blended via useSkateSpot.)
const { queryCanonical } = useSkateRelays();
const hidden = useAdminModeration(); const hidden = useAdminModeration();
return useQuery({ return useQuery({
queryKey: ['skate-spots', limit, includeHidden, getQueryKeySuffix()], queryKey: ['skate-spots', limit, includeHidden, 'canonical'],
select: (spots: SkateSpot[]) => select: (spots: SkateSpot[]) =>
includeHidden ? spots : filterAdminHidden(spots, SKATE_SPOT_KIND, hidden), includeHidden ? spots : filterAdminHidden(spots, SKATE_SPOT_KIND, hidden),
queryFn: async () => { queryFn: async () => {
@ -41,7 +44,7 @@ export function useSkateSpots(limit = 100, options: { includeHidden?: boolean }
const signal = AbortSignal.timeout(QUERY_TIMEOUT); const signal = AbortSignal.timeout(QUERY_TIMEOUT);
try { try {
const events = await querySkateContent( const events = await queryCanonical(
[ [
{ {
kinds: [SKATE_SPOT_KIND], kinds: [SKATE_SPOT_KIND],