diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 3877196..10b894b 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -5,6 +5,7 @@ import Index from "./pages/Index"; import { Explore } from "./pages/Explore"; import { Dashboard } from "./pages/Dashboard"; import { DashboardEvents } from "./pages/DashboardEvents"; +import { Stats } from "./pages/Stats"; import { NIP19Page } from "./pages/NIP19Page"; import { Terms } from "./pages/Terms"; import { Privacy } from "./pages/Privacy"; @@ -19,6 +20,7 @@ export function AppRouter() { } /> } /> } /> + } /> } /> } /> {/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */} diff --git a/src/components/navigation/AppSidebar.tsx b/src/components/navigation/AppSidebar.tsx index a4aee59..3910bb8 100644 --- a/src/components/navigation/AppSidebar.tsx +++ b/src/components/navigation/AppSidebar.tsx @@ -1,4 +1,4 @@ -import { Home, LayoutDashboard, FileText } from 'lucide-react'; +import { Home, LayoutDashboard, FileText, BarChart3 } from 'lucide-react'; import { Link, useLocation } from 'react-router-dom'; import { Sidebar, @@ -29,6 +29,11 @@ const navigationItems = [ url: '/dashboard/events', icon: FileText, }, + { + title: 'Relay Stats', + url: '/stats', + icon: BarChart3, + }, ]; export function AppSidebar() { diff --git a/src/hooks/useRelayStats.ts b/src/hooks/useRelayStats.ts new file mode 100644 index 0000000..7d9e907 --- /dev/null +++ b/src/hooks/useRelayStats.ts @@ -0,0 +1,89 @@ +import { useQuery } from '@tanstack/react-query'; +import { useNostr } from '@nostrify/react'; +import type { NostrEvent } from '@nostrify/nostrify'; + +export interface RelayStats { + totalEvents: number; + eventsByKind: Record; + recentEvents: NostrEvent[]; + uniqueAuthors: number; + eventsPerDay: Record; + topAuthors: Array<{ pubkey: string; count: number }>; + topKinds: Array<{ kind: number; count: number }>; +} + +export function useRelayStats() { + const { nostr } = useNostr(); + + return useQuery({ + queryKey: ['relay-stats'], + queryFn: async (c) => { + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]); + + // Query recent events from the relay (last 7 days, limited to 1000 for performance) + const sevenDaysAgo = Math.floor(Date.now() / 1000) - 7 * 24 * 60 * 60; + + const events = await nostr.query( + [ + { + since: sevenDaysAgo, + // limit: 1000, + }, + ], + { signal } + ); + + // Calculate statistics + const eventsByKind: Record = {}; + const authorCounts = new Map(); + const eventsPerDay: Record = {}; + const uniqueAuthors = new Set(); + + for (const event of events) { + // Count by kind + eventsByKind[event.kind] = (eventsByKind[event.kind] || 0) + 1; + + // Count unique authors + uniqueAuthors.add(event.pubkey); + + // Count events per author + authorCounts.set(event.pubkey, (authorCounts.get(event.pubkey) || 0) + 1); + + // Count events per day + const date = new Date(event.created_at * 1000); + const dateKey = date.toISOString().split('T')[0]; + eventsPerDay[dateKey] = (eventsPerDay[dateKey] || 0) + 1; + } + + // Get top authors + const topAuthors = Array.from(authorCounts.entries()) + .map(([pubkey, count]) => ({ pubkey, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + // Get top kinds + const topKinds = Object.entries(eventsByKind) + .map(([kind, count]) => ({ kind: parseInt(kind), count })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + // Get most recent events + const recentEvents = [...events] + .sort((a, b) => b.created_at - a.created_at) + .slice(0, 10); + + const stats: RelayStats = { + totalEvents: events.length, + eventsByKind, + recentEvents, + uniqueAuthors: uniqueAuthors.size, + eventsPerDay, + topAuthors, + topKinds, + }; + + return stats; + }, + staleTime: 60000, // Cache for 1 minute + }); +} diff --git a/src/pages/Stats.tsx b/src/pages/Stats.tsx new file mode 100644 index 0000000..38defce --- /dev/null +++ b/src/pages/Stats.tsx @@ -0,0 +1,349 @@ +import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'; +import { AppSidebar } from '@/components/navigation/AppSidebar'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useRelayStats } from '@/hooks/useRelayStats'; +import { Bar, BarChart, CartesianGrid, Line, LineChart, Pie, PieChart, XAxis, YAxis } from 'recharts'; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from '@/components/ui/chart'; +import { Activity, Users, FileText, TrendingUp } from 'lucide-react'; +import { useMemo } from 'react'; +import { genUserName } from '@/lib/genUserName'; + +const activityChartConfig = { + events: { + label: 'Events', + color: 'hsl(var(--chart-1))', + }, +} satisfies ChartConfig; + +const kindChartConfig = { + count: { + label: 'Count', + color: 'hsl(var(--chart-2))', + }, +} satisfies ChartConfig; + +const pieChartConfig = { + events: { + label: 'Events', + }, +} satisfies ChartConfig; + +export function Stats() { + const { data: stats, isLoading } = useRelayStats(); + + const activityData = useMemo(() => { + if (!stats) return []; + + // Get last 7 days + const days: Array<{ date: string; day: string; events: number }> = []; + const today = new Date(); + for (let i = 6; i >= 0; i--) { + const date = new Date(today); + date.setDate(date.getDate() - i); + const dateKey = date.toISOString().split('T')[0]; + days.push({ + date: dateKey, + day: date.toLocaleDateString('en-US', { weekday: 'short' }), + events: stats.eventsPerDay[dateKey] || 0, + }); + } + return days; + }, [stats]); + + const kindData = useMemo(() => { + if (!stats) return []; + return stats.topKinds.map((item) => ({ + kind: `Kind ${item.kind}`, + count: item.count, + })); + }, [stats]); + + const pieData = useMemo(() => { + if (!stats || stats.topKinds.length === 0) return []; + + const colors = [ + 'hsl(var(--chart-1))', + 'hsl(var(--chart-2))', + 'hsl(var(--chart-3))', + 'hsl(var(--chart-4))', + 'hsl(var(--chart-5))', + ]; + + return stats.topKinds.slice(0, 5).map((item, index) => ({ + kind: `Kind ${item.kind}`, + events: item.count, + fill: colors[index % colors.length], + })); + }, [stats]); + + return ( + +
+ +
+
+ +

Relay Statistics

+
+ +
+
+

+ Network Overview +

+

+ Real-time statistics from connected Nostr relays (last 7 days). +

+
+ + {/* Stats Overview Cards */} +
+ + + Total Events + + + + {isLoading ? ( + + ) : ( + <> +
{stats?.totalEvents.toLocaleString()}
+

+ Last 7 days +

+ + )} +
+
+ + + + Unique Authors + + + + {isLoading ? ( + + ) : ( + <> +
{stats?.uniqueAuthors.toLocaleString()}
+

+ Active contributors +

+ + )} +
+
+ + + + Event Types + + + + {isLoading ? ( + + ) : ( + <> +
+ {Object.keys(stats?.eventsByKind || {}).length} +
+

+ Different kinds +

+ + )} +
+
+ + + + Avg. per Day + + + + {isLoading ? ( + + ) : ( + <> +
+ {Math.round((stats?.totalEvents || 0) / 7).toLocaleString()} +
+

+ Events per day +

+ + )} +
+
+
+ + {/* Activity Timeline Chart */} + + + Activity Timeline + Events published over the last 7 days + + + {isLoading ? ( + + ) : ( + + + + + + } /> + + + + )} + + + + {/* Charts Grid */} +
+ {/* Top Event Kinds Chart */} + + + Top Event Kinds + Most popular event types by count + + + {isLoading ? ( + + ) : ( + + + + + + } /> + + + + )} + + + + {/* Event Distribution Pie Chart */} + + + Event Distribution + Distribution of top 5 event kinds + + + {isLoading ? ( + + ) : ( + + + } /> + + + + )} + + +
+ + {/* Top Authors List */} + + + Most Active Authors + Top contributors in the last 7 days + + + {isLoading ? ( +
+ {[...Array(5)].map((_, i) => ( +
+ + +
+ ))} +
+ ) : stats?.topAuthors && stats.topAuthors.length > 0 ? ( +
+ {stats.topAuthors.map((author, index) => ( +
+
+
+ {index + 1} +
+
+

+ {genUserName(author.pubkey)} +

+

+ {author.pubkey.slice(0, 8)}...{author.pubkey.slice(-8)} +

+
+
+
+ {author.count.toLocaleString()} events +
+
+ ))} +
+ ) : ( +
+ No author data available +
+ )} +
+
+
+
+
+
+ ); +} + +export default Stats;