diff --git a/AGENTS.md b/AGENTS.md index 5b11e4f..1706545 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`** diff --git a/src/hooks/useGrowthStats.ts b/src/hooks/useGrowthStats.ts index 5ea81de..6c3372d 100644 --- a/src/hooks/useGrowthStats.ts +++ b/src/hooks/useGrowthStats.ts @@ -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({ 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[0], + opts: Parameters[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(); + 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(); + 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 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 diff --git a/src/pages/CommunityStatsPage.tsx b/src/pages/CommunityStatsPage.tsx index 3331741..55bf234 100644 --- a/src/pages/CommunityStatsPage.tsx +++ b/src/pages/CommunityStatsPage.tsx @@ -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({ ) : (
-

{value.toLocaleString()}

+

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

{typeof thisMonthDelta === 'number' && thisMonthDelta > 0 && ( -
+
{/* ── Header ── */}
@@ -208,13 +222,32 @@ export default function CommunityStatsPage() {

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

- {/* Data source badge */} -
+ {/* Data source badge + freshness + manual refresh */} +
Data source:  {SKATE_RELAY.replace('wss://', '')} -  ·  cached 15 min + {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 ── */} @@ -256,14 +289,26 @@ export default function CommunityStatsPage() { - - - - - - - + {/* 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 ── */} @@ -277,6 +322,92 @@ export default function CommunityStatsPage() {
+ {/* ── Section 1d: Marketplace ── */} +
+

+ Marketplace +

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

+ 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 + not global zap volume. +

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

+ Where Skaters Are +

+
+ {stats.topCountries.length > 0 && ( + + + Top Countries + + + {stats.topCountries.map((c) => ( +
+ {c.name} +
+
+
+ {c.spots} +
+ ))} + + + )} + {stats.topCities.length > 0 && ( + + + Top Cities + + + {stats.topCities.map((c) => ( +
+ {c.name} +
+
+
+ {c.spots} +
+ ))} + + + )} +
+

+ Derived from each spot's own city/country tag — spots without one aren't counted. +

+
+ )} + {/* ── Section 2: Growth charts ── */}