feat(stats): honesty banner, marketplace + regions, mobile Lab collapse

Three improvements to /stats.

1. A relay outage must not read as real zeros. Every sub-query swallowed its
   error, so a failed relay rendered a full board of 0s. The queryFn now counts
   failures and returns 'partial'; the page shows 'Couldn't reach every relay —
   these numbers may be incomplete'. Badge also gains 'updated Nm ago' + Refresh.

2. Surface more of the community: Marketplace (live listings, completed trades,
   self-reported sats, cash/barter split) and Regions (top countries/cities from
   each spot's own tags). Zap receipts are counted ONLY for the top contributors
   and labelled as not-global-volume, because global zap volume isn't measurable
   on a public relay.

3. Mobile/desktop: collapse the 13 Lab cards on mobile behind 'Show all' (a
   display:contents wrapper keeps them grid items); widen max-w-6xl -> max-w-7xl;
   StatCard gains a compact prop so big sats don't overflow.

Docs: AGENTS.md community-stats note.
This commit is contained in:
SkateSpots 2026-10-01 12:54:36 +07:00
parent c1bf6516f5
commit d32df04c79
3 changed files with 276 additions and 16 deletions

View file

@ -808,6 +808,27 @@ session with **no other memory of this project**. So:
with the QR; the `bunker://` paste stays as the fallback. ⚠️ **NOT verified
against a real signer — needs an on-device check with Amber** (scan → pair →
signed in); only the URI shape was verified in Node.
- **Community stats (`/stats`, 2026-10-01):** `useGrowthStats` + `CommunityStatsPage`.
- ⚠️ **A relay outage used to render a board of ZEROS as truth** — every
sub-query ended in `.catch(() => [])`. The queryFn now counts failures (a
tracked `querySkateContent` wrapper) and returns **`partial: boolean`**; the
page shows *"Couldn't reach every relay — these numbers may be incomplete"*.
Don't drop this: zeros must never be presented as real numbers.
- New data: **marketplace** — live listings (`30402`, `#t:['skateboarding']`,
`showcase=true` excluded), completed trades (`31871` from
`useTradeAttestations`, `s=revoked` excluded), `tradedSats` summed from the
self-reported `amount` tag, `tradesByMethod`; **regions** —
`topCountries`/`topCities` built from each spot's own `city`/`country` tags
(spots without one aren't counted); and a **bounded zap signal** —
`zapCount`/`zapSats` from kind-9735 receipts `#p` the top ~25 contributors
(amounts decoded with `getSatoshisFromBolt11`). ⚠️ **Global zap volume is NOT
measurable on a public relay**, so the code and copy say "not global volume"
— never relabel it as the community total.
- UX: the badge shows **"updated Nm ago" + Refresh** (`dataUpdatedAt`/`refetch`);
the **13 Lab cards collapse on mobile** (a `display:contents` wrapper toggled
`hidden lg:contents`, so children stay real grid items) with a "Show all 13
crafts" toggle; container widened `max-w-6xl` → `max-w-7xl`; `StatCard` gained
a `compact` prop (`+compactNumber`) so big sats don't overflow `text-4xl`.
- **NIP-05 names — `you@skate-spots.org` (2026-09-28):** SkateSpots hands out
free handles so a new skater has a human name from minute one. The registry is
the Netlify Function **`netlify/functions/nip05.mjs`**

View file

@ -19,7 +19,10 @@ import {
SKATE_BARBER_KIND,
SKATE_TOUR_KIND,
SKATE_CREW_KIND,
MARKETPLACE_ITEM_KIND,
} from '@/lib/types';
import { TRADE_ATTESTATION_KIND } from '@/hooks/useTradeAttestations';
import { getSatoshisFromBolt11 } from '@/lib/bolt11';
// ─── Types ───────────────────────────────────────────────────────────────────
@ -87,6 +90,32 @@ export interface GrowthStats {
// ── 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 }[];
/**
* 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 ─────────────────────────────────────────────────────────────────
@ -127,13 +156,21 @@ export function useGrowthStats() {
// 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();
const querySkateContent = queryCanonical;
return useQuery<GrowthStats>({
queryKey: ['growth-stats', 'canonical'],
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 [
@ -155,6 +192,8 @@ export function useGrowthStats() {
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(() => []),
@ -176,6 +215,9 @@ export function useGrowthStats() {
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)
@ -211,6 +253,40 @@ export function useGrowthStats() {
// 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 — from each spot's own country/city tags ─────────────────
const regionCounts = (tagName: string) => {
const m = new Map<string, number>();
for (const s of spots) {
const v = s.tags.find((t) => t[0] === tagName && t[1])?.[1]?.trim();
if (v) m.set(v, (m.get(v) ?? 0) + 1);
}
return Array.from(m.entries())
.map(([name, count]) => ({ name, spots: count }))
.sort((a, b) => b.spots - a.spots);
};
const topCountries = regionCounts('country').slice(0, 6);
const topCities = regionCounts('city').slice(0, 6);
// ── Monthly bucketing ───────────────────────────────────────────────
const bucketMap = new Map<string, {
@ -315,6 +391,29 @@ export function useGrowthStats() {
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,
@ -340,6 +439,15 @@ export function useGrowthStats() {
monthly,
cumulativeSpots,
topMappers,
totalListings: listings.length,
totalTrades: trades.length,
tradedSats,
tradesByMethod,
zapCount,
zapSats,
topCountries,
topCities,
partial: failedQueries > 0,
};
},
staleTime: 15 * 60 * 1000, // 15 min — stats don't need to be live

View file

@ -1,4 +1,4 @@
import { useMemo } from 'react';
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';
@ -8,11 +8,12 @@ import { genUserName } from '@/lib/genUserName';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { useGrowthStats } from '@/hooks/useGrowthStats';
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 } from 'lucide-react';
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 ─────────────────────────────────────────────────────────────────
@ -41,6 +42,11 @@ const CHART_TOOLTIP_STYLE = {
// ─── 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,
@ -51,6 +57,7 @@ function StatCard({
loading,
thisMonthDelta,
to,
compact,
}: {
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
value: number;
@ -64,6 +71,8 @@ function StatCard({
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 = (
@ -82,7 +91,9 @@ function StatCard({
<Skeleton className="h-10 w-20 mb-1" />
) : (
<div className="flex items-end gap-2 flex-wrap">
<p className="text-4xl font-black leading-none" style={{ color }}>{value.toLocaleString()}</p>
<p className="text-4xl font-black leading-none" style={{ color }} title={compact ? value.toLocaleString() : undefined}>
{compact ? compactNumber(value) : value.toLocaleString()}
</p>
{typeof thisMonthDelta === 'number' && thisMonthDelta > 0 && (
<span
className="inline-flex items-center gap-0.5 text-[10px] font-black uppercase tracking-wide px-1.5 py-0.5 rounded-full mb-0.5"
@ -166,7 +177,10 @@ export default function CommunityStatsPage() {
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 } = useGrowthStats();
const { data: stats, isLoading, isError, isFetching, refetch, dataUpdatedAt } = useGrowthStats();
// 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
@ -194,7 +208,7 @@ export default function CommunityStatsPage() {
return (
<>
<div className="container max-w-6xl px-4 py-10 md:py-14">
<div className="container max-w-7xl px-4 py-10 md:py-14">
{/* ── Header ── */}
<div className="text-center mb-10">
@ -208,13 +222,32 @@ export default function CommunityStatsPage() {
<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">
{/* Data source badge + freshness + manual refresh */}
<div className="inline-flex flex-wrap items-center justify-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
{dataUpdatedAt > 0 && (
<><span aria-hidden>·</span><span>updated {formatAge(Date.now() - dataUpdatedAt)}</span></>
)}
<button
type="button"
onClick={() => refetch()}
disabled={isFetching}
className="ml-1 inline-flex items-center gap-1 rounded-full border border-white/10 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-gray-400 transition-colors hover:text-white disabled:opacity-50"
>
<RefreshCw className={cn('h-3 w-3', isFetching && 'animate-spin')} /> Refresh
</button>
</div>
{/* Honesty banner: a relay outage must not read as real zeros. */}
{stats?.partial && (
<div className="mt-5 rounded-2xl border border-amber-500/40 bg-amber-500/10 px-4 py-3">
<p className="text-xs font-bold text-amber-300">
Couldn't reach every relay — these numbers may be <span className="uppercase">incomplete</span>.
</p>
</div>
)}
</div>
{/* ── Error state ── */}
@ -256,14 +289,26 @@ export default function CommunityStatsPage() {
<StatCard icon={Camera} value={stats?.totalPhotographers ?? 0} label="Photographers" color="#38bdf8" loading={isLoading} thisMonthDelta={thisMonth?.photographers} to="/lab?craft=photography" />
<StatCard icon={Hammer} value={stats?.totalBuilders ?? 0} label="Builders" color="#fb923c" loading={isLoading} thisMonthDelta={thisMonth?.builders} to="/lab?craft=building" />
<StatCard icon={Palette} value={stats?.totalArtists ?? 0} label="Artists" color="#f472b6" loading={isLoading} to="/lab?craft=art" />
<StatCard icon={SprayCan} value={stats?.totalGraffiti ?? 0} label="Graffiti" color="#a3e635" loading={isLoading} to="/lab?craft=graffiti" />
<StatCard icon={PenTool} value={stats?.totalTattoo ?? 0} label="Tattoo" color="#f87171" loading={isLoading} to="/lab?craft=tattoo" />
<StatCard icon={Dumbbell} value={stats?.totalTrainers ?? 0} label="Trainers" color="#34d399" loading={isLoading} to="/lab?craft=training" />
<StatCard icon={Music} value={stats?.totalMusicians ?? 0} label="Musicians" color="#818cf8" loading={isLoading} to="/lab?craft=music" />
<StatCard icon={Gavel} value={stats?.totalJudges ?? 0} label="Judges" color="#fbbf24" loading={isLoading} to="/lab?craft=judging" />
<StatCard icon={Tent} value={stats?.totalOrganizers ?? 0} label="Organizers" color="#2dd4bf" loading={isLoading} to="/lab?craft=organizing" />
<StatCard icon={Sparkles} value={stats?.totalBarbers ?? 0} label="Barbers" color="#22d3ee" loading={isLoading} to="/lab?craft=barbering" />
{/* 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. */}
<div className={cn(showAllLab ? 'contents' : 'hidden lg:contents')}>
<StatCard icon={SprayCan} value={stats?.totalGraffiti ?? 0} label="Graffiti" color="#a3e635" loading={isLoading} to="/lab?craft=graffiti" />
<StatCard icon={PenTool} value={stats?.totalTattoo ?? 0} label="Tattoo" color="#f87171" loading={isLoading} to="/lab?craft=tattoo" />
<StatCard icon={Dumbbell} value={stats?.totalTrainers ?? 0} label="Trainers" color="#34d399" loading={isLoading} to="/lab?craft=training" />
<StatCard icon={Music} value={stats?.totalMusicians ?? 0} label="Musicians" color="#818cf8" loading={isLoading} to="/lab?craft=music" />
<StatCard icon={Gavel} value={stats?.totalJudges ?? 0} label="Judges" color="#fbbf24" loading={isLoading} to="/lab?craft=judging" />
<StatCard icon={Tent} value={stats?.totalOrganizers ?? 0} label="Organizers" color="#2dd4bf" loading={isLoading} to="/lab?craft=organizing" />
<StatCard icon={Sparkles} value={stats?.totalBarbers ?? 0} label="Barbers" color="#22d3ee" loading={isLoading} to="/lab?craft=barbering" />
</div>
</div>
<button
type="button"
onClick={() => setShowAllLab((v) => !v)}
className="mt-3 w-full rounded-full border border-white/10 py-2 text-[11px] font-bold uppercase tracking-wide text-gray-400 transition-colors hover:text-white lg:hidden"
>
{showAllLab ? 'Show fewer' : 'Show all 13 crafts'}
</button>
</section>
{/* ── Section 1c: Community — tours & crews ── */}
@ -277,6 +322,92 @@ export default function CommunityStatsPage() {
</div>
</section>
{/* ── Section 1d: Marketplace ── */}
<section className="mb-12">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
<ShoppingBag className="h-3.5 w-3.5 text-[#00cc66]" /> Marketplace
</h2>
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard icon={Tag} value={stats?.totalListings ?? 0} label="Live Listings" color="#00cc66" loading={isLoading} to="/marketplace" />
<StatCard icon={Handshake} value={stats?.totalTrades ?? 0} label="Trades Recorded" color="#22c55e" loading={isLoading} to="/trades" />
<StatCard icon={Zap} value={stats?.tradedSats ?? 0} label="Sats Traded" color="#facc15" loading={isLoading} compact />
<StatCard icon={Zap} value={stats?.zapCount ?? 0} label="Zaps to Mappers" color="#f472b6" loading={isLoading} />
</div>
{stats && stats.tradesByMethod.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{stats.tradesByMethod.map((m) => (
<span
key={m.method}
className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide text-gray-300"
>
{m.method} · {m.count}
</span>
))}
</div>
)}
<p className="mt-3 text-[11px] leading-relaxed text-gray-600">
Sats traded are self-reported in trade records (the app can't observe an off-app payment).
"Zaps to mappers" counts only receipts addressed to our top contributors — it is
<strong className="text-gray-500"> not </strong> global zap volume.
</p>
</section>
{/* ── Section 1e: Regions ── */}
{stats && (stats.topCountries.length > 0 || stats.topCities.length > 0) && (
<section className="mb-12">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
<Globe className="h-3.5 w-3.5 text-[#4ade80]" /> Where Skaters Are
</h2>
<div className="grid md:grid-cols-2 gap-4">
{stats.topCountries.length > 0 && (
<Card className="border-white/8">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#4ade80]">Top Countries</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{stats.topCountries.map((c) => (
<div key={c.name} className="flex items-center gap-3">
<span className="w-24 truncate text-xs text-gray-300" title={c.name}>{c.name}</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/5">
<div
className="h-full rounded-full bg-[#4ade80]"
style={{ width: `${Math.max(4, (c.spots / stats.topCountries[0].spots) * 100)}%` }}
/>
</div>
<span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-white">{c.spots}</span>
</div>
))}
</CardContent>
</Card>
)}
{stats.topCities.length > 0 && (
<Card className="border-white/8">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#38bdf8]">Top Cities</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{stats.topCities.map((c) => (
<div key={c.name} className="flex items-center gap-3">
<span className="w-24 truncate text-xs text-gray-300" title={c.name}>{c.name}</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/5">
<div
className="h-full rounded-full bg-[#38bdf8]"
style={{ width: `${Math.max(4, (c.spots / stats.topCities[0].spots) * 100)}%` }}
/>
</div>
<span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-white">{c.spots}</span>
</div>
))}
</CardContent>
</Card>
)}
</div>
<p className="mt-3 text-[11px] text-gray-600">
Derived from each spot's own city/country tag — spots without one aren't counted.
</p>
</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">