Two issues from the first derived-regions pass:
- The 'Resolving N...' counter only updated when the whole pass finished, so it
looked frozen. fillDerivedRegions now reports progress per pin, the hook ticks
the counter down, and the card refreshes every 5 resolved pins so the numbers
climb while you watch.
- A country could appear twice ('United Kingdom' 1 + 1) when one pin carried a
country_code and another only a name. Learn name->code from pins that have
both and fold name-only pins into the same bucket.
533 lines
25 KiB
TypeScript
533 lines
25 KiB
TypeScript
import { useQuery } from '@tanstack/react-query';
|
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
|
import {
|
|
SKATE_SPOT_KIND,
|
|
SKATE_SHOP_KIND,
|
|
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 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[];
|
|
|
|
// ── Marketplace ──────────────────────────────────────────────────────────
|
|
/** Live NIP-99 listings (stash "showcase-only" items excluded). */
|
|
totalListings: number;
|
|
/** Completed trades (sale/purchase attestations; revoked excluded). */
|
|
totalTrades: number;
|
|
/** Sats recorded across those attestations (self-reported). */
|
|
tradedSats: 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 ────────────────────────────────────────────────────────────────────
|
|
|
|
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();
|
|
const hiddenSig = `${hidden.coords.size}:${hidden.ids.size}`;
|
|
|
|
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).catch((err) => { failedQueries++; throw err; });
|
|
|
|
// 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], 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);
|
|
const shops = dedup(shopEvents);
|
|
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',
|
|
);
|
|
let tradedSats = 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) tradedSats += 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);
|
|
|
|
// ── 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,
|
|
}));
|
|
|
|
// ── 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,
|
|
totalListings: listings.length,
|
|
totalTrades: trades.length,
|
|
tradedSats,
|
|
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,
|
|
});
|
|
}
|
|
|
|
|