SkateSpots/src/hooks/useGrowthStats.ts

610 lines
29 KiB
TypeScript

import { useQuery } from '@tanstack/react-query';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import {
SKATE_SPOT_KIND,
SKATE_SHOP_KIND,
parseSkateShop,
CALENDAR_EVENT_KIND,
SKATE_COACH_KIND,
SKATE_FILMER_KIND,
SKATE_EDITOR_KIND,
SKATE_PHOTOGRAPHER_KIND,
SKATE_BUILDER_KIND,
SKATE_ARTIST_KIND,
SKATE_GRAFFITI_KIND,
SKATE_TATTOO_KIND,
SKATE_TRAINER_KIND,
SKATE_MUSICIAN_KIND,
SKATE_JUDGE_KIND,
SKATE_ORGANIZER_KIND,
SKATE_BARBER_KIND,
SKATE_TOUR_KIND,
SKATE_CREW_KIND,
MARKETPLACE_ITEM_KIND,
} from '@/lib/types';
import { TRADE_ATTESTATION_KIND } from '@/hooks/useTradeAttestations';
import { useAdminModeration } from '@/hooks/useAdminModeration';
import { getSatoshisFromBolt11 } from '@/lib/bolt11';
import { coordsOf, getDerivedRegion } from '@/lib/derivedRegions';
// ─── Types ───────────────────────────────────────────────────────────────────
export interface MonthBucket {
/** "Jan '25" */
label: string;
/** YYYY-MM for sorting */
key: string;
spots: number;
shops: number;
events: number;
coaches: number;
filmers: number;
editors: number;
photographers: number;
builders: number;
tours: number;
crews: number;
total: number;
/** Unique pubkeys that published anything this month */
activeMappers: number;
}
export interface TopMapper {
pubkey: string;
spotCount: number;
totalContent: number;
}
export interface TopShop {
pubkey: string;
shopCount: number;
}
export interface GrowthStats {
// ── Totals ──────────────────────────────────────────────────────────────
totalSpots: number;
totalShops: number;
totalEvents: number;
totalCoaches: number;
totalFilmers: number;
totalEditors: number;
totalPhotographers: number;
totalBuilders: number;
// Additional Lab crafts
totalArtists: number;
totalGraffiti: number;
totalTattoo: number;
totalTrainers: number;
totalMusicians: number;
totalJudges: number;
totalOrganizers: number;
totalBarbers: number;
totalTours: number;
totalCrews: number;
/** All unique pubkeys that have published any content */
totalMappers: number;
// ── Trusted counts (pubkeys with ≥2 published items — organic community members) ──
/** Spots from pubkeys with ≥2 total published items (low-effort spam filter) */
trustedSpots: number;
/** Unique pubkeys with ≥2 total published items */
trustedMappers: number;
// ── Time series ─────────────────────────────────────────────────────────
/** Monthly buckets oldest → newest */
monthly: MonthBucket[];
/** Cumulative spots per month (for line chart) */
cumulativeSpots: { key: string; label: string; value: number }[];
// ── Leaderboard ─────────────────────────────────────────────────────────
topMappers: TopMapper[];
/** Top shops by shop count (kind 30017). */
topShops: TopShop[];
// ── Marketplace ──────────────────────────────────────────────────────────
/** Live NIP-99 listings (stash "showcase-only" items excluded). */
totalListings: number;
/** Completed trades (sale/purchase attestations; revoked excluded). */
totalTrades: number;
/** Robust sats: MUTUAL trades only, per-pair capped, junk amounts dropped. */
tradedSats: number;
/** Raw self-reported sats across ALL attestations (a claim — sybil-inflatable). */
tradedSatsClaimed: number;
/** Mutual trades counted for the robust figure. */
tradedCount: number;
/** Distinct people involved in those mutual trades. */
tradedTraders: number;
/** Trades by self-reported method (cash / lightning / trade / other). */
tradesByMethod: { method: string; count: number }[];
// ── Zaps (bounded — NOT global volume) ───────────────────────────────────
/** Zap receipts addressed to the top contributors. */
zapCount: number;
/** Sats across those receipts. */
zapSats: number;
// ── Regions (from each spot's own `country`/`city` tags) ─────────────────
topCountries: { name: string; spots: number }[];
topCities: { name: string; spots: number }[];
/** Spots with a country/city tag that didn't make the top 6. */
otherCountries: number;
otherCities: number;
/** Locations (spots + shops) considered for the region breakdown. */
locationsTotal: number;
/** Of those, how many carry neither a country nor a city tag. */
uncategorised: number;
/**
* True when one or more relay sub-queries failed. The page must say the
* numbers may be incomplete — never present an outage as real zeros.
*/
partial: boolean;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function toMonthKey(ts: number): string {
const d = new Date(ts * 1000);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
return `${y}-${m}`;
}
function toMonthLabel(key: string): string {
const [y, m] = key.split('-');
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
return `${months[parseInt(m) - 1]} '${y.slice(2)}`;
}
function fillMonthGaps(keys: string[]): string[] {
if (keys.length === 0) return [];
const sorted = [...keys].sort();
const first = sorted[0];
const last = sorted[sorted.length - 1];
const result: string[] = [];
let [y, m] = first.split('-').map(Number);
const [ey, em] = last.split('-').map(Number);
while (y < ey || (y === ey && m <= em)) {
result.push(`${y}-${String(m).padStart(2, '0')}`);
m++;
if (m > 12) { m = 1; y++; }
}
return result;
}
// ─── Hook ────────────────────────────────────────────────────────────────────
/** Stable, short hash of a string — keeps a query key small with many hidden items. */
function tinyHash(input: string): number {
let h = 0;
for (let i = 0; i < input.length; i++) h = (h * 31 + input.charCodeAt(i)) | 0;
return h;
}
export function useGrowthStats() { // Community stats read from the CANONICAL community relay (goskatespots) only,
// 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();
// Sig must change when the SET changes, not just its size — hiding one item
// while unhiding another (same count) must still recompute the stats.
const hiddenSig = `${hidden.coords.size}:${hidden.ids.size}:${tinyHash(
`${[...hidden.coords].sort().join(',')}#${[...hidden.ids].sort().join(',')}`,
)}`;
return useQuery<GrowthStats>({
queryKey: ['growth-stats', 'canonical', hiddenSig],
queryFn: async () => {
const signal = AbortSignal.timeout(15000);
// Every sub-query below swallows its own error so one dead request can't
// blank the page — but we COUNT the failures so the UI can say the numbers
// may be incomplete instead of presenting an outage as a board of zeros.
let failedQueries = 0;
const querySkateContent = (
filters: Parameters<typeof queryCanonical>[0],
opts: Parameters<typeof queryCanonical>[1],
) => queryCanonical(filters, { ...opts, onError: () => { failedQueries++; } });
// Query all content types in parallel from the community relay.
// High limits to get full history — this is a stats page, not a feed.
const [
spotEvents,
shopEvents,
calendarEvents,
coachEvents,
filmerEvents,
editorEvents,
photographerEvents,
builderEvents,
tourEvents,
crewEvents,
artistEvents,
graffitiEvents,
tattooEvents,
trainerEvents,
musicianEvents,
judgeEvents,
organizerEvents,
barberEvents,
listingEvents,
tradeEvents,
] = await Promise.all([
querySkateContent([{ kinds: [SKATE_SPOT_KIND], '#t': ['skatespot'], limit: 2000 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_SHOP_KIND], '#t': ['skateshop'], limit: 2000 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [CALENDAR_EVENT_KIND], '#t': ['skateboarding'], limit: 2000 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_COACH_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_FILMER_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_EDITOR_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_PHOTOGRAPHER_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_BUILDER_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_TOUR_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_CREW_KIND], limit: 500 }], { signal }).catch(() => []),
// All other Lab crafts, each counted individually so the stats page can
// show a card per craft (matching what /lab lists).
querySkateContent([{ kinds: [SKATE_ARTIST_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_GRAFFITI_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_TATTOO_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_TRAINER_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_MUSICIAN_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_JUDGE_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_ORGANIZER_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_BARBER_KIND], limit: 500 }], { signal }).catch(() => []),
// Marketplace: live listings + completed-trade attestations.
querySkateContent([{ kinds: [MARKETPLACE_ITEM_KIND], '#t': ['skateboarding'], limit: 2000 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [TRADE_ATTESTATION_KIND], limit: 1000 }], { signal }).catch(() => []),
]);
// 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);
}
return Array.from(map.values());
}
const spots = dedup(spotEvents);
// Shops: run the SAME validator the directory uses — kind 30017 is not a
// reserved kind, so other apps (zap.cooking, agent stalls, …) publish
// unrelated events under it on shared relays. Counting raw events put
// those on the "Top Shops" board, labelled with the author's profile name.
const shops = dedup(shopEvents.filter((e) => parseSkateShop(e) !== null));
const events = dedup(calendarEvents);
const coaches = dedup(coachEvents);
const filmers = dedup(filmerEvents);
const editors = dedup(editorEvents);
const photographers = dedup(photographerEvents);
const builders = dedup(builderEvents);
const tours = dedup(tourEvents);
const crews = dedup(crewEvents);
const artists = dedup(artistEvents);
const graffiti = dedup(graffitiEvents);
const tattoo = dedup(tattooEvents);
const trainers = dedup(trainerEvents);
const musicians = dedup(musicianEvents);
const judges = dedup(judgeEvents);
const organizers = dedup(organizerEvents);
const barbers = dedup(barberEvents);
// Aggregate of the "other" crafts for active-mapper / total rollups.
const otherLab = [...artists, ...graffiti, ...tattoo, ...trainers, ...musicians, ...judges, ...organizers, ...barbers];
// ── Marketplace ──────────────────────────────────────────────────────
// Showcase-only stash items are public but NOT for sale, so exclude them.
const listings = dedup(listingEvents).filter(
(e) => !e.tags.some((t) => t[0] === 'showcase' && t[1] === 'true'),
);
const trades = dedup(tradeEvents).filter(
(e) => e.tags.find((t) => t[0] === 's')?.[1] !== 'revoked',
);
// Raw self-reported total — what the tags CLAIM. A sybil pair can inflate
// this without bound, so it is shown as "claimed", never the headline.
let tradedSatsClaimed = 0;
const methodCounts = new Map<string, number>();
for (const t of trades) {
const amount = Number(t.tags.find((x) => x[0] === 'amount')?.[1] ?? 0);
if (Number.isFinite(amount) && amount > 0) tradedSatsClaimed += amount;
const method = t.tags.find((x) => x[0] === 'method')?.[1] ?? 'other';
methodCounts.set(method, (methodCounts.get(method) ?? 0) + 1);
}
const tradesByMethod = Array.from(methodCounts.entries())
.map(([method, count]) => ({ method, count }))
.sort((a, b) => b.count - a.count);
// Robust volume — the figure worth trusting. It counts only MUTUAL trades
// (a listing attested by ≥2 distinct people, the signal a swap/ring would
// have to fake on BOTH sides), drops obviously-junk amounts, and caps how
// many trades any one pair can contribute (mirrors the reputation cap).
const MAX_TRADE_SATS = 100_000_000; // 1 BTC — above this the amount is junk
const PER_PAIR_CAP = 3;
const byListing = new Map<string, typeof trades>();
for (const t of trades) {
const coord = t.tags.find((x) => x[0] === 'a')?.[1];
if (!coord) continue;
const list = byListing.get(coord) ?? [];
list.push(t);
byListing.set(coord, list);
}
const pairCounts = new Map<string, number>();
const traders = new Set<string>();
let tradedSats = 0;
let tradedCount = 0;
for (const [, list] of byListing) {
const authors = [...new Set(list.map((e) => e.pubkey))];
if (authors.length < 2) continue; // one-sided = a claim, not a mutual trade
const amounts = list
.map((e) => Number(e.tags.find((x) => x[0] === 'amount')?.[1] ?? 0))
.filter((n) => Number.isFinite(n) && n > 0 && n <= MAX_TRADE_SATS);
const pairKey = [...authors].sort().join(':');
const used = pairCounts.get(pairKey) ?? 0;
if (used >= PER_PAIR_CAP) continue;
pairCounts.set(pairKey, used + 1);
tradedCount += 1;
tradedSats += amounts.length ? amounts[0] : 0;
for (const p of authors) traders.add(p);
}
const tradedTraders = traders.size;
// ── Regions ──────────────────────────────────────────────────────────
// Spots AND shops are considered together. A location's region comes from
// its own `city`/`country` tag when present; otherwise it is DERIVED from
// the pin's coordinates via a cached reverse geocode (`derivedRegions`), so
// the card matches the pins the map draws instead of stranding them as
// "Uncategorised". Group by a STABLE key: the ISO `country_code` when
// present, else a normalised name. The displayed label is the most common
// raw name in the group, so a localised spelling can't split one country.
const allLocations = [...spots, ...shops];
const regionOf = (e: { tags: string[][] }): { city: string; country: string; code: string } | null => {
const city = e.tags.find((t) => t[0] === 'city' && t[1])?.[1]?.trim() ?? '';
const country = e.tags.find((t) => t[0] === 'country' && t[1])?.[1]?.trim() ?? '';
const code = e.tags.find((t) => t[0] === 'country_code' && t[1])?.[1]?.trim().toUpperCase() ?? '';
if (city || country) return { city, country, code };
// No tag — fall back to a cached reverse geocode of the pin itself.
const coords = coordsOf(e);
if (coords) {
const derived = getDerivedRegion(coords.lat, coords.lng);
if (derived && (derived.city || derived.country)) {
return { city: derived.city, country: derived.country, code: derived.countryCode };
}
}
return null;
};
const regionEntries = allLocations.map((e) => regionOf(e));
// A country can arrive WITH a code (tagged pins) or WITHOUT one (some
// geocodes). Learn name→code from whichever pins carry both, so e.g.
// "United Kingdom" with no code folds into the "GB" bucket instead of
// splitting into two identical rows.
const codeByName = new Map<string, string>();
for (const region of regionEntries) {
if (region?.country && region.code) codeByName.set(region.country.toLowerCase(), region.code);
}
const regionGroups = (kind: 'country' | 'city') => {
const groups = new Map<string, { total: number; labels: Map<string, number> }>();
for (const region of regionEntries) {
if (!region) continue;
const name = kind === 'country' ? region.country : region.city;
if (!name) continue;
const key = kind === 'country'
? region.code || codeByName.get(name.toLowerCase()) || name.toLowerCase()
: name.toLowerCase();
const g = groups.get(key) ?? { total: 0, labels: new Map<string, number>() };
g.total += 1;
g.labels.set(name, (g.labels.get(name) ?? 0) + 1);
groups.set(key, g);
}
return Array.from(groups.values())
.map((g) => ({
name: [...g.labels.entries()].sort((a, b) => b[1] - a[1])[0][0],
spots: g.total,
}))
.sort((a, b) => b.spots - a.spots);
};
const allCountries = regionGroups('country');
const allCities = regionGroups('city');
const topCountries = allCountries.slice(0, 6);
const topCities = allCities.slice(0, 6);
// Regions that didn't make the top 6 — surfaced as an "Other" row so the
// cards can't imply the top 6 is the whole picture.
const otherCountries = allCountries.slice(6).reduce((n, c) => n + c.spots, 0);
const otherCities = allCities.slice(6).reduce((n, c) => n + c.spots, 0);
// Locations whose region is neither tagged nor (yet) derivable. Surfaced as
// an "Uncategorised" row so the cards reconcile with the location total.
const locationsTotal = allLocations.length;
const uncategorised = regionEntries.filter((r) => r === null).length;
// ── Monthly bucketing ───────────────────────────────────────────────
const bucketMap = new Map<string, {
spots: Set<string>; shops: Set<string>; events: Set<string>;
coaches: Set<string>; filmers: Set<string>; editors: Set<string>;
photographers: Set<string>; builders: Set<string>;
tours: Set<string>; crews: Set<string>; pubkeys: Set<string>;
}>();
function ensureBucket(key: string) {
if (!bucketMap.has(key)) {
bucketMap.set(key, {
spots: new Set(), shops: new Set(), events: new Set(),
coaches: new Set(), filmers: new Set(), editors: new Set(),
photographers: new Set(), builders: new Set(),
tours: new Set(), crews: new Set(), pubkeys: new Set(),
});
}
return bucketMap.get(key)!;
}
for (const e of spots) { const b = ensureBucket(toMonthKey(e.created_at)); b.spots.add(e.id); b.pubkeys.add(e.pubkey); }
for (const e of shops) { const b = ensureBucket(toMonthKey(e.created_at)); b.shops.add(e.id); b.pubkeys.add(e.pubkey); }
for (const e of events) { const b = ensureBucket(toMonthKey(e.created_at)); b.events.add(e.id); b.pubkeys.add(e.pubkey); }
for (const e of coaches) { const b = ensureBucket(toMonthKey(e.created_at)); b.coaches.add(e.id); b.pubkeys.add(e.pubkey); }
for (const e of filmers) { const b = ensureBucket(toMonthKey(e.created_at)); b.filmers.add(e.id); b.pubkeys.add(e.pubkey); }
for (const e of editors) { const b = ensureBucket(toMonthKey(e.created_at)); b.editors.add(e.id); b.pubkeys.add(e.pubkey); }
for (const e of photographers) { const b = ensureBucket(toMonthKey(e.created_at)); b.photographers.add(e.id); b.pubkeys.add(e.pubkey); }
for (const e of builders) { const b = ensureBucket(toMonthKey(e.created_at)); b.builders.add(e.id); b.pubkeys.add(e.pubkey); }
for (const e of tours) { const b = ensureBucket(toMonthKey(e.created_at)); b.tours.add(e.id); b.pubkeys.add(e.pubkey); }
for (const e of crews) { const b = ensureBucket(toMonthKey(e.created_at)); b.crews.add(e.id); b.pubkeys.add(e.pubkey); }
// Other Lab crafts contribute to active-mapper counts (no dedicated series).
for (const e of otherLab) { const b = ensureBucket(toMonthKey(e.created_at)); b.pubkeys.add(e.pubkey); }
// Fill any gaps between first and last month so charts have continuous X axis
const allKeys = fillMonthGaps(Array.from(bucketMap.keys()));
const monthly: MonthBucket[] = allKeys.map(key => {
const b = bucketMap.get(key);
const s = b?.spots.size ?? 0;
const sh = b?.shops.size ?? 0;
const ev = b?.events.size ?? 0;
const co = b?.coaches.size ?? 0;
const fi = b?.filmers.size ?? 0;
const ed = b?.editors.size ?? 0;
const ph = b?.photographers.size ?? 0;
const bu = b?.builders.size ?? 0;
const to = b?.tours.size ?? 0;
const cr = b?.crews.size ?? 0;
return {
label: toMonthLabel(key),
key,
spots: s, shops: sh, events: ev,
coaches: co, filmers: fi, editors: ed,
photographers: ph, builders: bu, tours: to, crews: cr,
total: s + sh + ev + co + fi + ed + ph + bu + to + cr,
activeMappers: b?.pubkeys.size ?? 0,
};
});
// Cumulative spots line chart data
let running = 0;
const cumulativeSpots = monthly.map(m => {
running += m.spots;
return { key: m.key, label: m.label, value: running };
});
// ── Top mappers by spot count ────────────────────────────────────────
const mapperSpots = new Map<string, number>();
const mapperTotal = new Map<string, number>();
for (const e of spots) { mapperSpots.set(e.pubkey, (mapperSpots.get(e.pubkey) ?? 0) + 1); }
const allContent = [...spots, ...shops, ...events, ...coaches, ...filmers, ...editors, ...photographers, ...builders, ...tours, ...crews, ...otherLab];
for (const e of allContent) { mapperTotal.set(e.pubkey, (mapperTotal.get(e.pubkey) ?? 0) + 1); }
const topMappers: TopMapper[] = Array.from(mapperSpots.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([pubkey, spotCount]) => ({
pubkey,
spotCount,
totalContent: mapperTotal.get(pubkey) ?? spotCount,
}));
// ── Top shops by shop count ──────────────────────────────────────────
const mapperShops = new Map<string, number>();
for (const e of shops) { mapperShops.set(e.pubkey, (mapperShops.get(e.pubkey) ?? 0) + 1); }
const topShops: TopShop[] = Array.from(mapperShops.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([pubkey, shopCount]) => ({ pubkey, shopCount }));
// ── Unique mappers ───────────────────────────────────────────────────
const allPubkeys = new Set(allContent.map(e => e.pubkey));
// ── Trusted counts ───────────────────────────────────────────────────
// "Trusted" = pubkey that has published ≥2 distinct items.
// This is a lightweight heuristic: spam accounts typically publish 1 fake
// event and stop, while real community members publish multiple items over time.
// No extra relay queries needed — we already have all the content above.
const pubkeyItemCount = new Map<string, number>();
for (const e of allContent) {
pubkeyItemCount.set(e.pubkey, (pubkeyItemCount.get(e.pubkey) ?? 0) + 1);
}
const isTrustedPubkey = (pk: string) => (pubkeyItemCount.get(pk) ?? 0) >= 2;
const trustedSpots = spots.filter(e => isTrustedPubkey(e.pubkey)).length;
const trustedMappers = Array.from(allPubkeys).filter(isTrustedPubkey).length;
// ── Zaps (bounded) ───────────────────────────────────────────────────
// Global zap volume is NOT reliably measurable on a public relay, so we
// deliberately count only receipts addressed to known contributors and
// label them that way — never implying this is the community's total.
const topPubkeys = Array.from(mapperTotal.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 25)
.map(([pk]) => pk);
let zapCount = 0;
let zapSats = 0;
if (topPubkeys.length > 0) {
const zapReceipts = await querySkateContent(
[{ kinds: [9735], '#p': topPubkeys, limit: 500 }],
{ signal },
).catch(() => []);
zapCount = zapReceipts.length;
for (const z of zapReceipts) {
const invoice = z.tags.find((t) => t[0] === 'bolt11')?.[1];
if (!invoice) continue;
try { zapSats += getSatoshisFromBolt11(invoice); } catch { /* not decodable */ }
}
}
return {
totalSpots: spots.length,
totalShops: shops.length,
totalEvents: events.length,
totalCoaches: coaches.length,
totalFilmers: filmers.length,
totalEditors: editors.length,
totalPhotographers: photographers.length,
totalBuilders: builders.length,
totalArtists: artists.length,
totalGraffiti: graffiti.length,
totalTattoo: tattoo.length,
totalTrainers: trainers.length,
totalMusicians: musicians.length,
totalJudges: judges.length,
totalOrganizers: organizers.length,
totalBarbers: barbers.length,
totalTours: tours.length,
totalCrews: crews.length,
totalMappers: allPubkeys.size,
trustedSpots,
trustedMappers,
monthly,
cumulativeSpots,
topMappers,
topShops,
totalListings: listings.length,
totalTrades: trades.length,
tradedSats,
tradedSatsClaimed,
tradedCount,
tradedTraders,
tradesByMethod,
zapCount,
zapSats,
topCountries,
topCities,
otherCountries,
otherCities,
locationsTotal,
uncategorised,
partial: failedQueries > 0,
};
},
staleTime: 15 * 60 * 1000, // 15 min — stats don't need to be live
gcTime: 60 * 60 * 1000, // 1 hr in memory
retry: 1,
});
}