feat: extended community stats page with Nostr growth data

New useGrowthStats hook:
- Queries wss://goskatespots.nostr1.com directly (authoritative source)
- Fetches all 7 content types in parallel: spots, shops, events,
  coaches, filmers, editors, tours
- Deduplicates addressable events (keep newest per pubkey+kind+d)
- Buckets events by month with gap-filling for continuous X axis
- Computes cumulative spots line chart data
- Extracts top 10 mappers by spot count
- 15 min stale time — stats don't need to be live

New useGeyserStats hook:
- Queries Geyser GraphQL for supporter count + total sats raised
- Reuses same API already used in SupportersPage

Rewritten CommunityStatsPage:
- Section 1: 8 hero stat cards (spots, shops, events, coaches,
  filmers, editors, tours, mappers) with per-type accent colours
- Section 2: 4 charts (cumulative spots area, monthly stacked bar,
  active mappers bar, content mix donut)
- Section 3: Top 10 mappers leaderboard with avatars + 🥇🥈🥉
- Section 4: Supporters banner with live Geyser count + sats raised
- Footer: data source + verifiability note
- Full skeleton loading for each section independently
- Error state for relay connectivity issues

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-06-18 12:54:32 +07:00
parent 0e5abcb2db
commit 0451887a17
2 changed files with 701 additions and 489 deletions

280
src/hooks/useGrowthStats.ts Normal file
View file

@ -0,0 +1,280 @@
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import {
SKATE_SPOT_KIND,
SKATE_SHOP_KIND,
CALENDAR_EVENT_KIND,
SKATE_COACH_KIND,
SKATE_FILMER_KIND,
SKATE_EDITOR_KIND,
SKATE_TOUR_KIND,
SKATE_RELAY,
} from '@/lib/types';
// ─── 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;
tours: 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;
totalTours: number;
/** All unique pubkeys that have published any content */
totalMappers: 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[];
}
// ─── 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() {
const { nostr } = useNostr();
return useQuery<GrowthStats>({
queryKey: ['growth-stats'],
queryFn: async () => {
const relay = nostr.relay(SKATE_RELAY);
const signal = AbortSignal.timeout(15000);
// 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,
tourEvents,
] = await Promise.all([
relay.query([{ kinds: [SKATE_SPOT_KIND], '#t': ['skatespot'], limit: 2000 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_SHOP_KIND], limit: 2000 }], { signal }).catch(() => []),
relay.query([{ kinds: [CALENDAR_EVENT_KIND], '#t': ['skateboarding'], limit: 2000 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_COACH_KIND], limit: 500 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_FILMER_KIND], limit: 500 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_EDITOR_KIND], limit: 500 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_TOUR_KIND], limit: 500 }], { signal }).catch(() => []),
]);
// Deduplicate addressable events (keep newest per pubkey+kind+d)
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;
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 tours = dedup(tourEvents);
// ── 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>;
tours: 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(),
tours: 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 tours) { const b = ensureBucket(toMonthKey(e.created_at)); b.tours.add(e.id); 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 to = b?.tours.size ?? 0;
return {
label: toMonthLabel(key),
key,
spots: s, shops: sh, events: ev,
coaches: co, filmers: fi, editors: ed, tours: to,
total: s + sh + ev + co + fi + ed + to,
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, ...tours];
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));
return {
totalSpots: spots.length,
totalShops: shops.length,
totalEvents: events.length,
totalCoaches: coaches.length,
totalFilmers: filmers.length,
totalEditors: editors.length,
totalTours: tours.length,
totalMappers: allPubkeys.size,
monthly,
cumulativeSpots,
topMappers,
};
},
staleTime: 15 * 60 * 1000, // 15 min — stats don't need to be live
gcTime: 60 * 60 * 1000, // 1 hr in memory
retry: 1,
});
}
// ─── Geyser supporter count ──────────────────────────────────────────────────
const GEYSER_API_URL = 'https://api.geyser.fund/graphql';
export interface GeyserStats {
supporterCount: number;
totalSats: number;
}
export function useGeyserStats() {
return useQuery<GeyserStats>({
queryKey: ['geyser-stats'],
queryFn: async () => {
try {
const res = await fetch(GEYSER_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `{
projectGet(where: { name: "skatespots" }) {
fundersCount
balance
}
}`,
}),
});
const json = await res.json();
const project = json?.data?.projectGet;
return {
supporterCount: project?.fundersCount ?? 0,
totalSats: project?.balance ?? 0,
};
} catch {
return { supporterCount: 0, totalSats: 0 };
}
},
staleTime: 15 * 60 * 1000,
retry: false,
});
}

View file

@ -3,517 +3,449 @@ import { useSeoMeta } from '@unhead/react';
import { Link } from 'react-router-dom';
import { Layout } from '@/components/Layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { useSkateSpots } from '@/hooks/useSkateSpots';
import { useSkateEvents } from '@/hooks/useSkateEvents';
import { useLabProfiles } from '@/hooks/useLabProfiles';
import { useShops } from '@/hooks/useShops';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { useGrowthStats, useGeyserStats } from '@/hooks/useGrowthStats';
import { SKATE_RELAY } from '@/lib/types';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
AreaChart, Area,
BarChart, Bar,
XAxis, YAxis,
CartesianGrid, Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
Legend,
PieChart, Pie, Cell, Legend,
} from 'recharts';
import {
TrendingUp,
Users,
Shield,
CheckCircle2,
AlertCircle,
MapPin,
Calendar,
GraduationCap,
Store,
Radio,
MapPin, Store, Calendar, GraduationCap,
Video, Scissors, Route, Users,
TrendingUp, Radio, Zap, ExternalLink,
Trophy,
} from 'lucide-react';
const TRUST_COLORS = {
verified: '#22c55e', // green-500
trusted: '#eab308', // yellow-500
unverified: '#6b7280', // gray-500
// ─── Helpers ─────────────────────────────────────────────────────────────────
function formatSats(sats: number): string {
if (sats >= 1_000_000) return `${(sats / 1_000_000).toFixed(1)}M`;
if (sats >= 1_000) return `${Math.round(sats / 1_000)}k`;
return sats.toLocaleString();
}
const CHART_TOOLTIP_STYLE = {
backgroundColor: '#0d0d0d',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '8px',
color: '#fff',
fontSize: '12px',
};
// ─── Hero stat card ──────────────────────────────────────────────────────────
function StatCard({
icon: Icon,
value,
label,
sub,
color,
loading,
}: {
icon: React.ComponentType<{ className?: string }>;
value: number;
label: string;
sub?: string;
color: string;
loading: boolean;
}) {
return (
<Card className="bg-black/80 border" style={{ borderColor: `${color}33` }}>
<CardContent className="pt-5 pb-5">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-xs font-bold uppercase tracking-widest mb-2" style={{ color: '#666' }}>{label}</p>
{loading ? (
<Skeleton className="h-10 w-20 mb-1" />
) : (
<p className="text-4xl font-black leading-none" style={{ color }}>{value.toLocaleString()}</p>
)}
{sub && <p className="text-xs mt-1.5" style={{ color: '#555' }}>{sub}</p>}
</div>
<div className="p-2.5 rounded-xl flex-shrink-0" style={{ background: `${color}15` }}>
<Icon className="h-6 w-6" style={{ color }} />
</div>
</div>
</CardContent>
</Card>
);
}
// ─── Top mapper row ──────────────────────────────────────────────────────────
function MapperRow({ pubkey, rank, spotCount }: { pubkey: string; rank: number; spotCount: number }) {
const author = useAuthor(pubkey);
const meta = author.data?.metadata;
const name = meta?.name ?? genUserName(pubkey);
const rankColors: Record<number, string> = { 1: '#f7931a', 2: '#c0c0c0', 3: '#cd7f32' };
return (
<div className="flex items-center gap-3 py-2.5 border-b border-white/5 last:border-0">
<span className="text-xs font-black w-5 flex-shrink-0 text-center" style={{ color: rankColors[rank] ?? '#555' }}>
{rank <= 3 ? ['🥇','🥈','🥉'][rank - 1] : rank}
</span>
<Avatar className="h-8 w-8 flex-shrink-0 border border-white/10">
<AvatarImage src={meta?.picture} alt={name} />
<AvatarFallback className="text-xs font-bold bg-gradient-to-br from-[#ff0040]/20 to-[#ccff00]/20">
{name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="flex-1 text-sm font-semibold truncate">{name}</span>
<span className="text-xs font-bold flex-shrink-0" style={{ color: '#ccff00' }}>
{spotCount} {spotCount === 1 ? 'spot' : 'spots'}
</span>
</div>
);
}
// ─── Skeleton helpers ────────────────────────────────────────────────────────
function ChartSkeleton() {
return (
<div className="h-52 flex items-end gap-2 px-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="flex-1 rounded-t" style={{ height: `${30 + Math.random() * 60}%` }} />
))}
</div>
);
}
// ─── Page ────────────────────────────────────────────────────────────────────
export default function CommunityStatsPage() {
useSeoMeta({
title: 'Community Stats - SkateSpots',
description: 'View trust analytics and community statistics for the SkateSpots community.',
title: 'Community Stats — SkateSpots',
description: 'Live community metrics for SkateSpots — spots, events, coaches, filmers, editors, tours and growth over time. Pulled directly from Nostr.',
});
// Call all hooks unconditionally at the top
const { getReadRelays, isLoggedIn } = useSkateRelays();
const relays = getReadRelays();
const { data: stats, isLoading, isError } = useGrowthStats();
const { data: geyser } = useGeyserStats();
const { data: spots, isLoading: spotsLoading } = useSkateSpots(500);
const { data: events, isLoading: eventsLoading } = useSkateEvents(500);
const { data: profiles, isLoading: profilesLoading } = useLabProfiles(500);
const { data: shops, isLoading: shopsLoading } = useShops(500);
// Content mix for donut chart
const contentMix = useMemo(() => {
if (!stats) return [];
return [
{ name: 'Spots', value: stats.totalSpots, color: '#ff0040' },
{ name: 'Events', value: stats.totalEvents, color: '#ccff00' },
{ name: 'Lab', value: stats.totalCoaches + stats.totalFilmers + stats.totalEditors, color: '#ff6600' },
{ name: 'Shops', value: stats.totalShops, color: '#4ade80' },
{ name: 'Tours', value: stats.totalTours, color: '#c084fc' },
].filter(d => d.value > 0);
}, [stats]);
// Combine all unique pubkeys
const allPubkeys = useMemo(() => {
const pubkeySet = new Set<string>();
spots?.forEach((s) => pubkeySet.add(s.pubkey));
events?.forEach((e) => pubkeySet.add(e.pubkey));
profiles?.forEach((p) => pubkeySet.add(p.pubkey));
shops?.forEach((s) => pubkeySet.add(s.pubkey));
return Array.from(pubkeySet);
}, [spots, events, profiles, shops]);
const { data: trustScores, isLoading: trustLoading } = useBatchTrustScores(allPubkeys);
const isLoading = spotsLoading || eventsLoading || profilesLoading || shopsLoading || trustLoading;
// Calculate trust distribution
const trustDistribution = useMemo(() => {
if (!trustScores) return { verified: 0, trusted: 0, unverified: 0 };
const distribution = {
verified: 0, // score >= 90
trusted: 0, // score >= 60 && score < 90
unverified: 0, // score < 60
};
Array.from(trustScores.values()).forEach((score) => {
if (score >= 90) distribution.verified++;
else if (score >= 60) distribution.trusted++;
else distribution.unverified++;
});
return distribution;
}, [trustScores]);
// Calculate content by trust level
const contentByTrust = useMemo(() => {
if (!trustScores) return { verified: 0, trusted: 0, unverified: 0 };
const spotsByTrust = { verified: 0, trusted: 0, unverified: 0 };
spots?.forEach((spot) => {
const score = trustScores.get(spot.pubkey) || 0;
if (score >= 90) spotsByTrust.verified++;
else if (score >= 60) spotsByTrust.trusted++;
else spotsByTrust.unverified++;
});
return spotsByTrust;
}, [spots, trustScores]);
// Prepare chart data
const trustDistributionData = [
{ name: 'Verified', value: trustDistribution.verified, color: TRUST_COLORS.verified },
{ name: 'Trusted', value: trustDistribution.trusted, color: TRUST_COLORS.trusted },
{ name: 'Unverified', value: trustDistribution.unverified, color: TRUST_COLORS.unverified },
];
const contentByTrustData = [
{ category: 'Verified', spots: contentByTrust.verified },
{ category: 'Trusted', spots: contentByTrust.trusted },
{ category: 'Unverified', spots: contentByTrust.unverified },
];
const totalContributors = allPubkeys.length;
const totalContent = (spots?.length || 0) + (events?.length || 0) + (profiles?.length || 0) + (shops?.length || 0);
const verifiedPercentage = totalContributors > 0 ? ((trustDistribution.verified / totalContributors) * 100).toFixed(1) : '0';
const trustedPercentage = totalContributors > 0 ? (((trustDistribution.verified + trustDistribution.trusted) / totalContributors) * 100).toFixed(1) : '0';
if (isLoading) {
return (
<Layout>
<div className="container px-4 py-12 max-w-6xl">
<Skeleton className="h-12 w-64 mb-8" />
<div className="grid md:grid-cols-3 gap-6 mb-8">
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i}>
<CardContent className="pt-6">
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
))}
</div>
<div className="grid md:grid-cols-2 gap-6">
{Array.from({ length: 2 }).map((_, i) => (
<Card key={i}>
<CardContent className="pt-6">
<Skeleton className="h-64 w-full" />
</CardContent>
</Card>
))}
</div>
</div>
</Layout>
);
}
// Only show last 12 months on charts to keep them readable
const recentMonthly = useMemo(() => stats?.monthly.slice(-12) ?? [], [stats]);
const recentCumulative = useMemo(() => stats?.cumulativeSpots.slice(-12) ?? [], [stats]);
return (
<Layout>
<div className="container px-4 py-12 max-w-6xl">
{/* Header */}
<div className="mb-12 text-center">
<h1 className="text-4xl md:text-6xl font-black uppercase mb-4 gradient-text" style={{
fontFamily: 'Impact, "Arial Black", sans-serif',
letterSpacing: '0.05em'
}}>
Community Stats
<div className="container max-w-6xl px-4 py-10 md:py-14">
{/* ── Header ── */}
<div className="text-center mb-10">
<h1 className="text-5xl md:text-7xl font-black uppercase leading-none mb-3">
<span className="bg-gradient-to-r from-[#ff0040] via-[#ff6600] to-[#ccff00] bg-clip-text text-transparent">
Community
</span>
<br />
<span className="text-white">Stats</span>
</h1>
<p className="text-xl text-gray-400 max-w-2xl mx-auto">
Trust analytics and community metrics for the SkateSpots network
<p className="text-gray-400 max-w-xl mx-auto text-sm md:text-base mb-5">
Live metrics pulled directly from the Nostr network. Transparent, decentralized, verifiable by anyone.
</p>
{/* Data source badge */}
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-[#ccff00]/5 border border-[#ccff00]/20 text-xs text-gray-400">
<Radio className="h-3 w-3 text-[#ccff00]" />
Data source:&nbsp;
<span className="text-[#ccff00] font-mono">{SKATE_RELAY.replace('wss://', '')}</span>
&nbsp;·&nbsp; cached 15 min
</div>
</div>
{/* ── Error state ── */}
{isError && (
<div className="mb-8 p-4 rounded-xl border border-[#ff0040]/30 bg-[#ff0040]/5 text-center text-sm text-gray-400">
Could not reach the relay right now. Stats will load once the connection is restored.
</div>
)}
{/* ── Section 1: Hero numbers ── */}
<section className="mb-12">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
<TrendingUp className="h-3.5 w-3.5" /> Community at a Glance
</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<StatCard icon={MapPin} value={stats?.totalSpots ?? 0} label="Skate Spots" color="#ff0040" loading={isLoading} />
<StatCard icon={Store} value={stats?.totalShops ?? 0} label="Listed Shops" color="#4ade80" loading={isLoading} />
<StatCard icon={Calendar} value={stats?.totalEvents ?? 0} label="Events" color="#ccff00" loading={isLoading} />
<StatCard icon={Users} value={stats?.totalMappers ?? 0} label="Mappers" color="#facc15" loading={isLoading} sub="unique contributors" />
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-3">
<StatCard icon={GraduationCap} value={stats?.totalCoaches ?? 0} label="Coaches" color="#ff6600" loading={isLoading} />
<StatCard icon={Video} value={stats?.totalFilmers ?? 0} label="Filmers" color="#60a5fa" loading={isLoading} />
<StatCard icon={Scissors} value={stats?.totalEditors ?? 0} label="Editors" color="#c084fc" loading={isLoading} />
<StatCard icon={Route} value={stats?.totalTours ?? 0} label="Tours" color="#f472b6" loading={isLoading} />
</div>
</section>
{/* ── Section 2: Growth charts ── */}
<section className="mb-12">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
<TrendingUp className="h-3.5 w-3.5" /> Growth Over Time
</h2>
<div className="grid md:grid-cols-2 gap-4">
{/* Cumulative spots — area chart */}
<Card className="bg-black/80 border border-[#ff0040]/20">
<CardHeader className="pb-2 pt-5 px-5">
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#ff0040]">
Spots Mapped (Cumulative)
</CardTitle>
<p className="text-xs text-gray-500">Total spots added to the map over time</p>
</CardHeader>
<CardContent className="px-2 pb-4">
{isLoading ? <ChartSkeleton /> : (
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={recentCumulative} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="gradSpots" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ff0040" stopOpacity={0.3} />
<stop offset="95%" stopColor="#ff0040" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1a1a1a" />
<XAxis dataKey="label" tick={{ fill: '#555', fontSize: 10 }} tickLine={false} axisLine={false} />
<YAxis tick={{ fill: '#555', fontSize: 10 }} tickLine={false} axisLine={false} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
<Area type="monotone" dataKey="value" name="Spots" stroke="#ff0040" strokeWidth={2} fill="url(#gradSpots)" dot={false} activeDot={{ r: 4, fill: '#ff0040' }} />
</AreaChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
{/* Monthly new content — stacked bar */}
<Card className="bg-black/80 border border-[#ccff00]/20">
<CardHeader className="pb-2 pt-5 px-5">
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#ccff00]">
Monthly Activity
</CardTitle>
<p className="text-xs text-gray-500">New content published each month</p>
</CardHeader>
<CardContent className="px-2 pb-4">
{isLoading ? <ChartSkeleton /> : (
<ResponsiveContainer width="100%" height={200}>
<BarChart data={recentMonthly} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1a1a1a" />
<XAxis dataKey="label" tick={{ fill: '#555', fontSize: 10 }} tickLine={false} axisLine={false} />
<YAxis tick={{ fill: '#555', fontSize: 10 }} tickLine={false} axisLine={false} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
<Bar dataKey="spots" name="Spots" stackId="a" fill="#ff0040" radius={[0,0,0,0]} />
<Bar dataKey="events" name="Events" stackId="a" fill="#ff6600" radius={[0,0,0,0]} />
<Bar dataKey="shops" name="Shops" stackId="a" fill="#4ade80" radius={[0,0,0,0]} />
<Bar dataKey="coaches" name="Coaches" stackId="a" fill="#ccff00" radius={[4,4,0,0]} />
<Legend wrapperStyle={{ fontSize: '10px', color: '#666', paddingTop: '8px' }} />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
{/* Active mappers per month — bar */}
<Card className="bg-black/80 border border-[#ff6600]/20">
<CardHeader className="pb-2 pt-5 px-5">
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#ff6600]">
Active Mappers / Month
</CardTitle>
<p className="text-xs text-gray-500">Unique contributors publishing each month</p>
</CardHeader>
<CardContent className="px-2 pb-4">
{isLoading ? <ChartSkeleton /> : (
<ResponsiveContainer width="100%" height={200}>
<BarChart data={recentMonthly} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1a1a1a" />
<XAxis dataKey="label" tick={{ fill: '#555', fontSize: 10 }} tickLine={false} axisLine={false} />
<YAxis tick={{ fill: '#555', fontSize: 10 }} tickLine={false} axisLine={false} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
<Bar dataKey="activeMappers" name="Active Mappers" fill="#ff6600" radius={[4,4,0,0]}>
{recentMonthly.map((_, i) => (
<Cell key={i} fill={i === recentMonthly.length - 1 ? '#ccff00' : '#ff6600'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
{/* Content mix — donut */}
<Card className="bg-black/80 border border-[#c084fc]/20">
<CardHeader className="pb-2 pt-5 px-5">
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#c084fc]">
Content Mix
</CardTitle>
<p className="text-xs text-gray-500">Breakdown by content type (all time)</p>
</CardHeader>
<CardContent className="px-2 pb-4 flex items-center justify-center">
{isLoading ? (
<div className="h-52 flex items-center justify-center">
<Skeleton className="w-40 h-40 rounded-full" />
</div>
) : contentMix.length > 0 ? (
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie data={contentMix} cx="50%" cy="50%" innerRadius={55} outerRadius={80} paddingAngle={3} dataKey="value">
{contentMix.map((entry, i) => (
<Cell key={i} fill={entry.color} />
))}
</Pie>
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
<Legend wrapperStyle={{ fontSize: '11px', color: '#888' }} />
</PieChart>
</ResponsiveContainer>
) : (
<p className="text-sm text-gray-600 py-8">No data yet</p>
)}
</CardContent>
</Card>
</div>
</section>
{/* ── Section 3: Top mappers ── */}
<section className="mb-12">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
<Trophy className="h-3.5 w-3.5" /> Top Mappers
<span className="text-gray-600 font-normal normal-case tracking-normal">— most spots added</span>
</h2>
<div className="grid md:grid-cols-2 gap-4">
{isLoading ? (
<>
<Card className="bg-black/80 border border-white/8">
<CardContent className="pt-5">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 py-2.5 border-b border-white/5 last:border-0">
<Skeleton className="w-5 h-4" />
<Skeleton className="w-8 h-8 rounded-full" />
<Skeleton className="flex-1 h-4" />
<Skeleton className="w-12 h-4" />
</div>
))}
</CardContent>
</Card>
<Card className="bg-black/80 border border-white/8">
<CardContent className="pt-5">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 py-2.5 border-b border-white/5 last:border-0">
<Skeleton className="w-5 h-4" />
<Skeleton className="w-8 h-8 rounded-full" />
<Skeleton className="flex-1 h-4" />
<Skeleton className="w-12 h-4" />
</div>
))}
</CardContent>
</Card>
</>
) : stats && stats.topMappers.length > 0 ? (
<>
<Card className="bg-black/80 border border-white/8">
<CardContent className="pt-5">
{stats.topMappers.slice(0, 5).map((m, i) => (
<MapperRow key={m.pubkey} pubkey={m.pubkey} rank={i + 1} spotCount={m.spotCount} />
))}
</CardContent>
</Card>
<Card className="bg-black/80 border border-white/8">
<CardContent className="pt-5">
{stats.topMappers.slice(5, 10).map((m, i) => (
<MapperRow key={m.pubkey} pubkey={m.pubkey} rank={i + 6} spotCount={m.spotCount} />
))}
</CardContent>
</Card>
</>
) : (
<div className="col-span-2 text-center py-10 text-gray-600 text-sm">
No mapper data yet
</div>
)}
</div>
</section>
{/* ── Section 4: Supporters ── */}
<section className="mb-12">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
<Zap className="h-3.5 w-3.5" /> Community Supporters
</h2>
<div className="relative rounded-2xl overflow-hidden border border-[#ccff00]/20 bg-gradient-to-br from-[#ff0040]/5 to-[#ccff00]/5 p-6 md:p-8">
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-[#ff0040] via-[#ff6600] to-[#ccff00]" />
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-6">
<div>
<h3 className="text-xl font-black uppercase text-[#ccff00] mb-1">Funded by the Community</h3>
<p className="text-sm text-gray-500 max-w-sm mb-4">
Bitcoin Lightning supporters via Geyser Fund.<br />
No ads, no investors, no data selling.
</p>
<div className="flex flex-wrap gap-3">
<a
href="https://geyser.fund/project/skatespots"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-[#ff0040]/15 border border-[#ff0040]/40 text-[#ff0040] text-xs font-bold hover:bg-[#ff0040]/25 transition-colors"
>
<Zap className="h-3.5 w-3.5" /> Support on Geyser
<ExternalLink className="h-3 w-3 opacity-60" />
</a>
<Link
to="/supporters"
className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-[#ccff00]/8 border border-[#ccff00]/30 text-[#ccff00] text-xs font-bold hover:bg-[#ccff00]/15 transition-colors"
>
<Users className="h-3.5 w-3.5" /> View all supporters →
</Link>
</div>
</div>
<div className="text-right flex-shrink-0">
{geyser ? (
<>
<p className="text-5xl font-black text-[#ccff00] leading-none">
{geyser.supporterCount}
</p>
<p className="text-sm text-gray-500 mt-1">supporters</p>
{geyser.totalSats > 0 && (
<p className="text-xs text-[#f7931a] font-bold mt-1">
⚡ {formatSats(geyser.totalSats)} sats raised
</p>
)}
</>
) : (
<Skeleton className="w-20 h-14" />
)}
</div>
</div>
</div>
</section>
{/* ── Footer / verifiability note ── */}
<div className="text-center p-6 rounded-xl bg-white/[0.02] border border-white/5">
<p className="text-xs text-gray-600 leading-relaxed">
All data is pulled live from{' '}
<span className="text-[#ccff00] font-mono">{SKATE_RELAY}</span>
{' '}— a public Nostr relay.{' '}
Every event is signed by its creator and cryptographically verifiable.{' '}
No numbers can be faked.
</p>
</div>
{/* Relay Info Notice */}
<div className="mb-8 p-4 rounded-lg bg-gray-900/50 border border-gray-700 flex items-start gap-3">
<Radio className="h-5 w-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div className="text-sm text-gray-400">
<p>
<span className="text-gray-300 font-medium">Data source:</span>{' '}
{relays.length === 1 ? (
<>Querying <span className="text-[#ccff00] font-mono text-xs">{relays[0].replace('wss://', '')}</span></>
) : (
<>Querying {relays.length} relays</>
)}
{isLoggedIn && (
<>
{' '}—{' '}
<Link to="/settings?tab=relays" className="text-[#ccff00] hover:underline">
Manage relays
</Link>
</>
)}
</p>
{!isLoggedIn && (
<p className="mt-1 text-xs text-gray-500">
Log in to customize which relays to query
</p>
)}
</div>
</div>
{/* Key Metrics */}
<div className="grid md:grid-cols-3 gap-6 mb-8">
{/* Total Contributors */}
<Card className="bg-black/90 border-2 border-[#ff0040]/30">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-400 uppercase font-bold mb-2">Total Contributors</p>
<p className="text-4xl font-black text-white">{totalContributors}</p>
</div>
<Users className="h-12 w-12 text-[#ff0040]" />
</div>
<div className="mt-4 pt-4 border-t border-[#ff0040]/20">
<div className="flex items-center gap-2 text-sm">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<span className="text-gray-300">{verifiedPercentage}% verified</span>
</div>
</div>
</CardContent>
</Card>
{/* Total Content */}
<Card className="bg-black/90 border-2 border-[#ccff00]/30">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-400 uppercase font-bold mb-2">Total Content</p>
<p className="text-4xl font-black text-white">{totalContent}</p>
</div>
<MapPin className="h-12 w-12 text-[#ccff00]" />
</div>
<div className="mt-4 pt-4 border-t border-[#ccff00]/20 space-y-1">
<div className="flex justify-between text-xs text-gray-400">
<span>Spots: {spots?.length || 0}</span>
<span>Events: {events?.length || 0}</span>
</div>
<div className="flex justify-between text-xs text-gray-400">
<span>Coaches: {profiles?.length || 0}</span>
<span>Shops: {shops?.length || 0}</span>
</div>
</div>
</CardContent>
</Card>
{/* Trust Score */}
<Card className="bg-black/90 border-2 border-green-500/30">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-400 uppercase font-bold mb-2">Trusted Network</p>
<p className="text-4xl font-black text-white">{trustedPercentage}%</p>
</div>
<Shield className="h-12 w-12 text-green-500" />
</div>
<div className="mt-4 pt-4 border-t border-green-500/20">
<div className="flex items-center gap-2 text-sm text-gray-300">
<TrendingUp className="h-4 w-4 text-green-500" />
Contributors with trust ≥60
</div>
</div>
</CardContent>
</Card>
</div>
{/* Charts */}
<div className="grid md:grid-cols-2 gap-6 mb-8">
{/* Trust Distribution Pie Chart */}
<Card className="bg-black/90 border-2 border-[#ff0040]/20">
<CardHeader>
<CardTitle className="text-xl font-black uppercase text-[#ccff00] flex items-center gap-2">
<Users className="h-5 w-5" />
Trust Distribution
</CardTitle>
<p className="text-sm text-gray-400">
Breakdown of contributors by trust level
</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={trustDistributionData}
cx="50%"
cy="50%"
labelLine={false}
label={(entry) => `${entry.name}: ${entry.value}`}
outerRadius={100}
fill="#8884d8"
dataKey="value"
>
{trustDistributionData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: '#000',
border: '2px solid #ff0040',
borderRadius: '8px',
}}
/>
<Legend />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Content by Trust Bar Chart */}
<Card className="bg-black/90 border-2 border-[#ccff00]/20">
<CardHeader>
<CardTitle className="text-xl font-black uppercase text-[#ff0040] flex items-center gap-2">
<MapPin className="h-5 w-5" />
Spots by Trust Level
</CardTitle>
<p className="text-sm text-gray-400">
Number of skate spots created by each trust level
</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={contentByTrustData}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis dataKey="category" stroke="#999" />
<YAxis stroke="#999" />
<Tooltip
contentStyle={{
backgroundColor: '#000',
border: '2px solid #ccff00',
borderRadius: '8px',
}}
/>
<Bar dataKey="spots" fill="#ff0040" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
{/* Trust Level Breakdown */}
<div className="grid md:grid-cols-3 gap-6">
{/* Verified Users */}
<Card className="bg-black/90 border-2 border-green-500/30">
<CardHeader>
<CardTitle className="text-lg font-black uppercase text-green-500 flex items-center gap-2">
<CheckCircle2 className="h-5 w-5" />
Verified
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-baseline gap-2">
<span className="text-5xl font-black text-white">{trustDistribution.verified}</span>
<span className="text-gray-400 text-sm">contributors</span>
</div>
<p className="text-sm text-gray-400">
People you follow directly or your own content (trust ≥90)
</p>
<Badge className="bg-green-500 text-white">
{verifiedPercentage}% of community
</Badge>
</div>
</CardContent>
</Card>
{/* Trusted Users */}
<Card className="bg-black/90 border-2 border-yellow-500/30">
<CardHeader>
<CardTitle className="text-lg font-black uppercase text-yellow-500 flex items-center gap-2">
<Shield className="h-5 w-5" />
Trusted
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-baseline gap-2">
<span className="text-5xl font-black text-white">{trustDistribution.trusted}</span>
<span className="text-gray-400 text-sm">contributors</span>
</div>
<p className="text-sm text-gray-400">
Friends-of-friends or NIP-05 verified users (trust 60-89)
</p>
<Badge className="bg-yellow-500 text-white">
{((trustDistribution.trusted / totalContributors) * 100).toFixed(1)}% of community
</Badge>
</div>
</CardContent>
</Card>
{/* Unverified Users */}
<Card className="bg-black/90 border-2 border-gray-500/30">
<CardHeader>
<CardTitle className="text-lg font-black uppercase text-gray-400 flex items-center gap-2">
<AlertCircle className="h-5 w-5" />
Unverified
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-baseline gap-2">
<span className="text-5xl font-black text-white">{trustDistribution.unverified}</span>
<span className="text-gray-400 text-sm">contributors</span>
</div>
<p className="text-sm text-gray-400">
Unknown or no trust signals (trust &lt;60)
</p>
<Badge variant="outline" className="border-gray-500 text-gray-400">
{((trustDistribution.unverified / totalContributors) * 100).toFixed(1)}% of community
</Badge>
</div>
</CardContent>
</Card>
</div>
{/* Content Breakdown */}
<div className="mt-8">
<h2 className="text-3xl font-black uppercase text-[#ccff00] mb-6" style={{
fontFamily: 'Impact, "Arial Black", sans-serif'
}}>
Content Breakdown
</h2>
<div className="grid md:grid-cols-4 gap-4">
<Card className="bg-black/90 border-2 border-[#ff0040]/30">
<CardContent className="pt-6">
<MapPin className="h-8 w-8 text-[#ff0040] mb-3" />
<p className="text-3xl font-black text-white mb-1">{spots?.length || 0}</p>
<p className="text-sm text-gray-400 uppercase font-bold">Skate Spots</p>
<div className="mt-3 space-y-1">
<div className="text-xs text-gray-500">
✓ {contentByTrust.verified} from verified users
</div>
<div className="text-xs text-gray-500">
🛡️ {contentByTrust.trusted} from trusted users
</div>
</div>
</CardContent>
</Card>
<Card className="bg-black/90 border-2 border-[#ccff00]/30">
<CardContent className="pt-6">
<Calendar className="h-8 w-8 text-[#ccff00] mb-3" />
<p className="text-3xl font-black text-white mb-1">{events?.length || 0}</p>
<p className="text-sm text-gray-400 uppercase font-bold">Events</p>
</CardContent>
</Card>
<Card className="bg-black/90 border-2 border-[#ff6600]/30">
<CardContent className="pt-6">
<GraduationCap className="h-8 w-8 text-[#ff6600] mb-3" />
<p className="text-3xl font-black text-white mb-1">{profiles?.length || 0}</p>
<p className="text-sm text-gray-400 uppercase font-bold">Coaches/Filmers</p>
</CardContent>
</Card>
<Card className="bg-black/90 border-2 border-green-500/30">
<CardContent className="pt-6">
<Store className="h-8 w-8 text-green-500 mb-3" />
<p className="text-3xl font-black text-white mb-1">{shops?.length || 0}</p>
<p className="text-sm text-gray-400 uppercase font-bold">Skate Shops</p>
</CardContent>
</Card>
</div>
</div>
{/* About Trust Scores */}
<Card className="mt-8 bg-gradient-to-br from-[#ff0040]/10 to-[#ccff00]/10 border-2 border-[#ccff00]/50">
<CardHeader>
<CardTitle className="text-xl font-black uppercase text-[#ccff00] flex items-center gap-2">
<Shield className="h-6 w-6" />
About Trust Scores
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<p className="text-gray-300">
Trust scores help identify reliable contributors in the skateboarding community using a
<strong className="text-[#ccff00]"> Web of Trust</strong> algorithm.
</p>
<div className="grid md:grid-cols-3 gap-4">
<div className="p-4 rounded-lg bg-green-500/10 border border-green-500/30">
<div className="flex items-center gap-2 mb-2">
<CheckCircle2 className="h-5 w-5 text-green-500" />
<span className="font-bold text-green-500 uppercase text-sm">Verified (90-100)</span>
</div>
<p className="text-xs text-gray-400">
Users you follow directly or your own content
</p>
</div>
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
<div className="flex items-center gap-2 mb-2">
<Shield className="h-5 w-5 text-yellow-500" />
<span className="font-bold text-yellow-500 uppercase text-sm">Trusted (60-89)</span>
</div>
<p className="text-xs text-gray-400">
Friends-of-friends or NIP-05 verified users
</p>
</div>
<div className="p-4 rounded-lg bg-gray-500/10 border border-gray-500/30">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="h-5 w-5 text-gray-400" />
<span className="font-bold text-gray-400 uppercase text-sm">Unverified (0-59)</span>
</div>
<p className="text-xs text-gray-400">
Unknown users with no trust signals
</p>
</div>
</div>
<p className="text-sm text-gray-400 italic">
Trust scores are calculated client-side using your follow list. Scores are personalized -
different users see different trust levels for the same contributor.
</p>
</div>
</CardContent>
</Card>
</div>
</Layout>
);