feat: add RecentActivityChart component to Dashboard

This commit is contained in:
2025-12-28 22:13:13 +01:00
parent 686c31aca8
commit 91d51a35f9
3 changed files with 285 additions and 88 deletions

View File

@@ -0,0 +1,184 @@
"use client"
import { TrendingUp } from "lucide-react"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"
import { useMemo } from "react"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
import { Skeleton } from "@/components/ui/skeleton"
import { useCurrentUser } from "@/hooks/useCurrentUser"
import { useUserStats } from "@/hooks/useUserStats"
export const description = "Activity timeline showing events per day"
const chartConfig = {
events: {
label: "Events",
color: "hsl(var(--chart-1))",
},
} satisfies ChartConfig
export function RecentActivityChart() {
const { user } = useCurrentUser()
const { data: stats, isLoading } = useUserStats(user?.pubkey)
const { chartData, totalEvents, daysWithActivity } = useMemo(() => {
if (!stats || !stats.events || stats.events.length === 0) {
return { chartData: [], totalEvents: 0, daysWithActivity: 0 }
}
// Group events by day
const eventsByDay = new Map<string, number>()
const now = new Date()
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
// Initialize all days in the last 30 days with 0 events
for (let i = 0; i < 30; i++) {
const date = new Date(thirtyDaysAgo.getTime() + i * 24 * 60 * 60 * 1000)
const dateKey = date.toISOString().split('T')[0]
eventsByDay.set(dateKey, 0)
}
// Count events per day
stats.events.forEach((event) => {
const eventDate = new Date(event.created_at * 1000)
if (eventDate >= thirtyDaysAgo) {
const dateKey = eventDate.toISOString().split('T')[0]
eventsByDay.set(dateKey, (eventsByDay.get(dateKey) || 0) + 1)
}
})
// Convert to chart data format
const data = Array.from(eventsByDay.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, count]) => {
const dateObj = new Date(date)
return {
date,
displayDate: dateObj.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric'
}),
events: count,
}
})
const total = Array.from(eventsByDay.values()).reduce((sum, count) => sum + count, 0)
const activeDays = Array.from(eventsByDay.values()).filter(count => count > 0).length
return {
chartData: data,
totalEvents: total,
daysWithActivity: activeDays,
}
}, [stats])
if (isLoading) {
return (
<Card>
<CardHeader>
<Skeleton className="h-6 w-40 mb-2" />
<Skeleton className="h-4 w-60" />
</CardHeader>
<CardContent>
<Skeleton className="h-[250px] w-full" />
</CardContent>
</Card>
)
}
if (!user) {
return (
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
<CardDescription>Last 30 days</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center h-[250px] text-muted-foreground">
Log in to view your activity timeline
</div>
</CardContent>
</Card>
)
}
if (chartData.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
<CardDescription>Last 30 days</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center h-[250px] text-muted-foreground">
No activity in the last 30 days
</div>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
<CardDescription>
Your event activity over the last 30 days
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[250px] w-full">
<BarChart data={chartData} accessibilityLayer>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="displayDate"
tickLine={false}
tickMargin={10}
axisLine={false}
interval="preserveStartEnd"
minTickGap={30}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
allowDecimals={false}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="line" />}
/>
<Bar
dataKey="events"
fill="var(--color-events)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ChartContainer>
</CardContent>
<CardFooter className="flex-col items-start gap-2 text-sm">
<div className="flex gap-2 font-medium leading-none">
{daysWithActivity} active days with {totalEvents} events
<TrendingUp className="h-4 w-4" />
</div>
<div className="leading-none text-muted-foreground">
Showing activity distribution over the last 30 days
</div>
</CardFooter>
</Card>
)
}

View File

@@ -5,100 +5,110 @@
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--foreground: 0 0% 14.9020%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--card-foreground: 0 0% 14.9020%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--popover-foreground: 0 0% 14.9020%;
--primary: 37.6923 92.1260% 50.1961%;
--primary-foreground: 0 0% 0%;
--secondary: 220.0000 14.2857% 95.8824%;
--secondary-foreground: 215 13.7931% 34.1176%;
--muted: 210 20.0000% 98.0392%;
--muted-foreground: 220 8.9362% 46.0784%;
--accent: 48.0000 100.0000% 96.0784%;
--accent-foreground: 22.7273 82.5000% 31.3725%;
--destructive: 0 84.2365% 60.1961%;
--destructive-foreground: 0 0% 100%;
--border: 220 13.0435% 90.9804%;
--input: 220 13.0435% 90.9804%;
--ring: 37.6923 92.1260% 50.1961%;
--chart-1: 37.6923 92.1260% 50.1961%;
--chart-2: 32.1327 94.6188% 43.7255%;
--chart-3: 25.9649 90.4762% 37.0588%;
--chart-4: 22.7273 82.5000% 31.3725%;
--chart-5: 21.7143 77.7778% 26.4706%;
--sidebar: 210 20.0000% 98.0392%;
--sidebar-foreground: 0 0% 14.9020%;
--sidebar-primary: 37.6923 92.1260% 50.1961%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 48.0000 100.0000% 96.0784%;
--sidebar-accent-foreground: 22.7273 82.5000% 31.3725%;
--sidebar-border: 220 13.0435% 90.9804%;
--sidebar-ring: 37.6923 92.1260% 50.1961%;
--font-sans: Inter, sans-serif;
--font-serif: Source Serif 4, serif;
--font-mono: JetBrains Mono, monospace;
--radius: 0.375rem;
--shadow-x: 0px;
--shadow-y: 4px;
--shadow-blur: 8px;
--shadow-spread: -1px;
--shadow-opacity: 0.1;
--shadow-color: hsl(0 0% 0%);
--shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
--shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
--shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10);
--shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10);
--shadow-md: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 2px 4px -2px hsl(0 0% 0% / 0.10);
--shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 4px 6px -2px hsl(0 0% 0% / 0.10);
--shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 8px 10px -2px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.25);
--tracking-normal: 0em;
--spacing: 0.25rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--background: 0 0% 9.0196%;
--foreground: 0 0% 89.8039%;
--card: 0 0% 14.9020%;
--card-foreground: 0 0% 89.8039%;
--popover: 0 0% 14.9020%;
--popover-foreground: 0 0% 89.8039%;
--primary: 37.6923 92.1260% 50.1961%;
--primary-foreground: 0 0% 0%;
--secondary: 0 0% 14.9020%;
--secondary-foreground: 0 0% 89.8039%;
--muted: 0 0% 12.1569%;
--muted-foreground: 0 0% 63.9216%;
--accent: 22.7273 82.5000% 31.3725%;
--accent-foreground: 48 96.6387% 76.6667%;
--destructive: 0 84.2365% 60.1961%;
--destructive-foreground: 0 0% 100%;
--border: 0 0% 25.0980%;
--input: 0 0% 25.0980%;
--ring: 37.6923 92.1260% 50.1961%;
--chart-1: 43.2558 96.4126% 56.2745%;
--chart-2: 32.1327 94.6188% 43.7255%;
--chart-3: 22.7273 82.5000% 31.3725%;
--chart-4: 25.9649 90.4762% 37.0588%;
--chart-5: 22.7273 82.5000% 31.3725%;
--sidebar: 0 0% 5.8824%;
--sidebar-foreground: 0 0% 89.8039%;
--sidebar-primary: 37.6923 92.1260% 50.1961%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar-accent: 22.7273 82.5000% 31.3725%;
--sidebar-accent-foreground: 48 96.6387% 76.6667%;
--sidebar-border: 0 0% 25.0980%;
--sidebar-ring: 37.6923 92.1260% 50.1961%;
--font-sans: Inter, sans-serif;
--font-serif: Source Serif 4, serif;
--font-mono: JetBrains Mono, monospace;
--radius: 0.375rem;
--shadow-x: 0px;
--shadow-y: 4px;
--shadow-blur: 8px;
--shadow-spread: -1px;
--shadow-opacity: 0.1;
--shadow-color: hsl(0 0% 0%);
--shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
--shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
--shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10);
--shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10);
--shadow-md: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 2px 4px -2px hsl(0 0% 0% / 0.10);
--shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 4px 6px -2px hsl(0 0% 0% / 0.10);
--shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 8px 10px -2px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.25);
}
}

View File

@@ -2,6 +2,7 @@ import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/navigation/AppSidebar';
import { DashboardStats } from '@/components/dashboard/DashboardStats';
import { EventKindsChart } from '@/components/dashboard/EventKindsChart';
import { RecentActivityChart } from '@/components/dashboard/RecentActivityChart';
import { RecentActivityList } from '@/components/dashboard/RecentActivityList';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { Card, CardContent } from '@/components/ui/card';
@@ -48,6 +49,8 @@ export function Dashboard() {
<DashboardStats pubkey={user.pubkey} />
<RecentActivityChart />
<div className="grid gap-6 md:grid-cols-2">
<EventKindsChart />
<RecentActivityList pubkey={user.pubkey} />