mirror of
https://github.com/layer-systems/website.git
synced 2026-06-17 01:58:30 +02:00
feat: add RecentActivityChart component to Dashboard
This commit is contained in:
184
src/components/dashboard/RecentActivityChart.tsx
Normal file
184
src/components/dashboard/RecentActivityChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
186
src/index.css
186
src/index.css
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user