610 lines
29 KiB
TypeScript
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,
|
|
});
|
|
}
|
|
|
|
|