import { useMemo, useState } from 'react'; import { useSeoMeta } from '@unhead/react'; import { Link } from 'react-router-dom'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { useGrowthStats } from '@/hooks/useGrowthStats'; import { RegionBackfillCard } from '@/components/RegionBackfillCard'; import { useDeriveRegions } from '@/hooks/useDeriveRegions'; import { cn } from '@/lib/utils'; import { formatAge } from '@/lib/routeCache'; const ANGOR_PROJECT_URL = 'https://angor.io/project/angor1qtunna00uqyx52rr0nvqa059z2gknhrmn7urd6z'; import { SKATE_RELAY } from '@/lib/types'; import { AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend } from 'recharts'; import { MapPin, Store, Calendar, GraduationCap, Video, Scissors, Route, Users, Users2, Camera, Hammer, TrendingUp, Radio, Zap, ExternalLink, Trophy, FlaskConical, Palette, SprayCan, PenTool, Dumbbell, Music, Gavel, Tent, Sparkles, ShoppingBag, Tag, Handshake, Globe, RefreshCw } from 'lucide-react'; // ─── Helpers ───────────────────────────────────────────────────────────────── /** Sum of every Lab craft count — keeps the "total" badge and donut in sync. */ function labCraftTotal(s: { totalCoaches: number; totalFilmers: number; totalEditors: number; totalPhotographers: number; totalBuilders: number; totalArtists: number; totalGraffiti: number; totalTattoo: number; totalTrainers: number; totalMusicians: number; totalJudges: number; totalOrganizers: number; totalBarbers: number; }): number { return ( s.totalCoaches + s.totalFilmers + s.totalEditors + s.totalPhotographers + s.totalBuilders + s.totalArtists + s.totalGraffiti + s.totalTattoo + s.totalTrainers + s.totalMusicians + s.totalJudges + s.totalOrganizers + s.totalBarbers ); } const CHART_TOOLTIP_STYLE = { backgroundColor: '#0d0d0d', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff', fontSize: '12px', }; // ─── Hero stat card ────────────────────────────────────────────────────────── /** Compact number for large values (1234567 → "1.2M"); exact value in the title. */ function compactNumber(n: number): string { return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(n); } function StatCard({ icon: Icon, value, label, sub, trustedValue, color, loading, thisMonthDelta, to, compact, }: { icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; value: number; label: string; sub?: string; /** If set, shows a secondary "X trusted" line below the main count */ trustedValue?: number; color: string; loading: boolean; /** How many new items were added this month */ thisMonthDelta?: number; /** If set, the whole card becomes a link to this route (view all items). */ to?: string; /** Abbreviate big numbers (e.g. sats) so they don't overflow the card. */ compact?: boolean; }) { const clickable = !!to && !loading && value > 0; const card = (

{label}

{loading ? ( ) : (

{compact ? compactNumber(value) : value.toLocaleString()}

{typeof thisMonthDelta === 'number' && thisMonthDelta > 0 && ( +{thisMonthDelta} this month )}
)} {!loading && trustedValue !== undefined && (

🛡 {trustedValue.toLocaleString()} from active accounts

)} {sub &&

{sub}

} {clickable && (

View all

)}
); return clickable ? {card} : 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 = { 1: '#f7931a', 2: '#c0c0c0', 3: '#cd7f32' }; return (
{rank <= 3 ? ['🥇','🥈','🥉'][rank - 1] : rank} {name.slice(0, 2).toUpperCase()} {name} {spotCount} {spotCount === 1 ? 'spot' : 'spots'}
); } // ─── Skeleton helpers ──────────────────────────────────────────────────────── function ChartSkeleton() { return (
{Array.from({ length: 6 }).map((_, i) => ( ))}
); } // ─── Page ──────────────────────────────────────────────────────────────────── export default function CommunityStatsPage() { useSeoMeta({ title: 'Community Stats — SkateSpots', description: 'Live community metrics for SkateSpots — spots, events, coaches, filmers, editors, tours and growth over time. Pulled directly from Nostr.', }); const { data: stats, isLoading, isError, isFetching, refetch, dataUpdatedAt } = useGrowthStats(); // Resolve a region for untagged pins (cached) so the region card matches the map. const { pending: derivingPending } = useDeriveRegions(); // Collapse the 13 Lab craft cards on mobile (they were ~7 rows of scrolling // before the first chart); always expanded from lg up. const [showAllLab, setShowAllLab] = useState(false); // 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: labCraftTotal(stats), color: '#ff6600' }, { name: 'Shops', value: stats.totalShops, color: '#4ade80' }, { name: 'Tours', value: stats.totalTours, color: '#c084fc' }, { name: 'Crews', value: stats.totalCrews, color: '#2dd4bf' }, ].filter(d => d.value > 0); }, [stats]); // 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]); // Current month delta — how many items were added this month const thisMonth = useMemo(() => stats?.monthly[stats.monthly.length - 1], [stats]); // Total across ALL Lab creator/service crafts (matches what /lab lists) const labTotal = useMemo(() => (stats ? labCraftTotal(stats) : 0), [stats]); // Defensive reads: a persisted cache saved before these fields existed would // lack them, and `stats.topCountries.length` would throw. (The persist buster // was bumped too, but never trust a cache blindly.) const tradesByMethod = stats?.tradesByMethod ?? []; const topCountries = stats?.topCountries ?? []; const topCities = stats?.topCities ?? []; const otherCountries = stats?.otherCountries ?? 0; const otherCities = stats?.otherCities ?? 0; const uncategorised = stats?.uncategorised ?? 0; const locationsTotal = stats?.locationsTotal ?? 0; return ( <>
{/* ── Header ── */}

Community
Stats

Live metrics pulled directly from the Nostr network. Transparent, decentralized, verifiable by anyone.

{/* Data source badge + freshness + manual refresh */}
Data source:  {SKATE_RELAY.replace('wss://', '')} {dataUpdatedAt > 0 && ( <>·updated {formatAge(Date.now() - dataUpdatedAt)} )}
{/* Honesty banner: a relay outage must not read as real zeros. */} {stats?.partial && (

Couldn't reach every relay — these numbers may be incomplete.

)}
{/* ── Error state ── */} {isError && (
Could not reach the relay right now. Stats will load once the connection is restored.
)} {/* ── Section 1: Hero numbers ── */}

Community at a Glance

{/* ── Section 1b: The Lab — creators & services ── */}

The Lab · Creators & Services

{!isLoading && labTotal > 0 && ( {labTotal.toLocaleString()} total )}
{/* Extra crafts: collapsed on mobile (they were ~7 rows of scroll before the first chart), always visible from `lg` up. The `contents` wrapper keeps its children as real grid items. */}
{/* ── Section 1c: Community — tours & crews ── */}

Community

{/* ── Section 1d: Marketplace ── */}

Marketplace

{tradesByMethod.length > 0 && (
{tradesByMethod.map((m) => ( {m.method} · {m.count} ))}
)}

{stats?.tradedCount ?? 0} mutual trade {(stats?.tradedCount ?? 0) === 1 ? '' : 's'} across{' '} {stats?.tradedTraders ?? 0} people. Sats are self-reported by the traders — the app can't observe an off-app payment — so the headline counts only mutual trades with a per-pair cap; the raw claim is{' '} {(stats?.tradedSatsClaimed ?? 0).toLocaleString()}{' '} sats. "Zaps to mappers" counts only receipts addressed to our top contributors — it is not global zap volume.

{/* ── Section 1e: Regions ── */} {(topCountries.length > 0 || topCities.length > 0) && (

Spots & Shops by Region

{derivingPending > 0 && (

Resolving {derivingPending} untagged location{derivingPending === 1 ? '' : 's'} from their coordinates… one-time, then cached on this device.

)}
{topCountries.length > 0 && ( Top Countries {topCountries.map((c) => (
{c.name}
{c.spots}
))} {otherCountries > 0 && (
Other
{otherCountries}
)} {uncategorised > 0 && (
Uncategorised
{uncategorised}
)} )} {topCities.length > 0 && ( Top Cities {topCities.map((c) => (
{c.name}
{c.spots}
))} {otherCities > 0 && (
Other
{otherCities}
)} {uncategorised > 0 && (
Uncategorised
{uncategorised}
)} )}

Counts spots & shops, not skaters — from each one's own city/country tag, or derived from its pin’s coordinates when it has none (cached on your device; countries grouped by ISO code when present), community relay only, with moderation-hidden content excluded. {locationsTotal > 0 && ( <> {uncategorised} of {locationsTotal} location{locationsTotal === 1 ? '' : 's'} have no location tag and show as “Uncategorised”. )}

{/* Admin-only (renders null otherwise): fix the gap right where it shows up — re-derive region tags for the viewer's own legacy pins. */} {uncategorised > 0 &&
}
)} {/* ── Section 2: Growth charts ── */}

Growth Over Time

{/* Cumulative spots — area chart */} Spots Mapped (Cumulative)

Total spots added to the map over time

{isLoading ? : ( )}
{/* Monthly new content — stacked bar */} Monthly Activity

New content published each month

{isLoading ? : ( )}
{/* Active mappers per month — bar */} Active Mappers / Month

Unique contributors publishing each month

{isLoading ? : ( {recentMonthly.map((_, i) => ( ))} )}
{/* Content mix — donut */} Content Mix

Breakdown by content type (all time)

{isLoading ? (
) : contentMix.length > 0 ? ( {contentMix.map((entry, i) => ( ))} ) : (

No data yet

)}
{/* ── Section 3: Top mappers ── */}

Top Mappers — most spots added

{isLoading ? ( <> {Array.from({ length: 5 }).map((_, i) => (
))}
{Array.from({ length: 5 }).map((_, i) => (
))}
) : stats && stats.topMappers.length > 0 ? ( <> {stats.topMappers.slice(0, 5).map((m, i) => ( ))} {stats.topMappers.slice(5, 10).map((m, i) => ( ))} ) : (
No mapper data yet
)}
{/* ── Section 4: Supporters ── */}

Community Supporters

Funded by the Community

Decentralized Bitcoin crowdfunding via Angor.
No ads, no investors, no data selling.

Back us on Angor Community →
{/* ── Footer / verifiability note ── */}

All data is pulled live from{' '} {SKATE_RELAY} {' '}— a public Nostr relay.{' '} Every event is signed by its creator and cryptographically verifiable.{' '} No numbers can be faked.

); }