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({ 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[0], opts: Parameters[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(); 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(); 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(); 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(); const traders = new Set(); 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(); 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 }>(); 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() }; 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; shops: Set; events: Set; coaches: Set; filmers: Set; editors: Set; photographers: Set; builders: Set; tours: Set; crews: Set; pubkeys: Set; }>(); 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(); const mapperTotal = new Map(); 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(); 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(); 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, }); }