fix(stats): apply moderation to the stats, like the map already does

A spot the community has hidden as 'not a skate spot' no longer inflates the
community numbers.

- useGrowthStats calls useAdminModeration() and drops hidden content inside
  dedup(), so it's excluded from totalSpots, the region breakdown and the
  trusted counts (kind-agnostic: the coord is built from each event's own kind).
- The hidden-set sizes are part of the query key, so the stats recompute once
  the labels load.
- Region footnote says 'community relay only, with moderation-hidden content
  excluded'.

This narrows the map<->stats gap. The remaining deltas are legitimate and
documented in AGENTS.md (untagged pins; auto-hidden spots).
This commit is contained in:
SkateSpots 2026-10-01 13:33:51 +07:00
parent 9fe219aac6
commit 6d2492d847
3 changed files with 21 additions and 3 deletions

View file

@ -857,6 +857,14 @@ session with **no other memory of this project**. So:
and the region card only counts locations **with a location tag** while the
map draws every pin. The number comparable to the map is the hero
**"Skate Spots"** card (`totalSpots`), not the region rows.
- **Moderation now applies to the stats too (2026-10-01):** `useGrowthStats`
calls `useAdminModeration()` and drops hidden content inside `dedup()`, so a
spot the community removed no longer inflates `totalSpots`, the regions, or
the trusted counts. The hidden-set **sizes are part of the query key** so the
stats recompute once the labels load. Remaining map↔stats deltas (both
legitimate): **untagged pins** (the map draws them, the region rows only count
tagged ones) and **auto-hidden** spots (the map drops them at `MapPage:847`;
the stats can't know about reports without querying them).
- **Reverse geocoding hits Nominatim — disclosed (2026-10-01).**
`src/lib/geocodeRegions.ts` (`reverseGeocodeRegions` / `pushRegionTags`) is the
**single source of truth**, used by **spot create, shop create and shop edit**;

View file

@ -22,6 +22,7 @@ import {
MARKETPLACE_ITEM_KIND,
} from '@/lib/types';
import { TRADE_ATTESTATION_KIND } from '@/hooks/useTradeAttestations';
import { useAdminModeration } from '@/hooks/useAdminModeration';
import { getSatoshisFromBolt11 } from '@/lib/bolt11';
// ─── Types ───────────────────────────────────────────────────────────────────
@ -163,9 +164,14 @@ export function useGrowthStats() {
// so the numbers always match the authoritative community dataset — not a
// blend of the user's personal relays that would drift per-device.
const { queryCanonical } = useSkateRelays();
// Admin-hidden content is excluded from the stats EXACTLY as the map excludes
// it, so a spot the community removed can't inflate the numbers. The moderation
// set is part of the query key so the stats recompute once the labels load.
const hidden = useAdminModeration();
const hiddenSig = `${hidden.coords.size}:${hidden.ids.size}`;
return useQuery<GrowthStats>({
queryKey: ['growth-stats', 'canonical'],
queryKey: ['growth-stats', 'canonical', hiddenSig],
queryFn: async () => {
const signal = AbortSignal.timeout(15000);
@ -227,11 +233,14 @@ export function useGrowthStats() {
querySkateContent([{ kinds: [TRADE_ATTESTATION_KIND], limit: 1000 }], { signal }).catch(() => []),
]);
// Deduplicate addressable events (keep newest per pubkey+kind+d)
// Deduplicate addressable events (keep newest per pubkey+kind+d), and drop
// anything an admin has hidden — moderation applies here exactly as it does
// to the map, so removed/spam content doesn't inflate the numbers.
function dedup(events: typeof spotEvents) {
const map = new Map<string, typeof events[0]>();
for (const e of events) {
const d = e.tags.find(t => t[0] === 'd')?.[1] ?? e.id;
if (hidden.has({ coord: `${e.kind}:${e.pubkey}:${d}`, id: e.id })) continue;
const key = `${e.pubkey}:${e.kind}:${d}`;
const existing = map.get(key);
if (!existing || e.created_at > existing.created_at) map.set(key, e);

View file

@ -443,7 +443,8 @@ export default function CommunityStatsPage() {
</div>
<p className="mt-3 text-[11px] text-gray-600">
Counts <strong className="text-gray-500">spots &amp; 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 (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”.</>
)}