From 2fd6c93c0e87c1d53475a82a938277b512e5f425 Mon Sep 17 00:00:00 2001 From: sendi0011 Date: Sun, 1 Mar 2026 00:50:41 +0100 Subject: [PATCH 1/3] feat(analytics): implement /me/analytics dashboard (#397) --- app/me/analytics/page.tsx | 63 ++++++ components/analytics/AnalyticsBentoGrid.tsx | 237 ++++++++++++++++++++ components/analytics/AnalyticsChart.tsx | 171 ++++++++++++++ lib/utils/calculateTrend.ts | 65 ++++++ 4 files changed, 536 insertions(+) create mode 100644 app/me/analytics/page.tsx create mode 100644 components/analytics/AnalyticsBentoGrid.tsx create mode 100644 components/analytics/AnalyticsChart.tsx create mode 100644 lib/utils/calculateTrend.ts diff --git a/app/me/analytics/page.tsx b/app/me/analytics/page.tsx new file mode 100644 index 000000000..11730c97b --- /dev/null +++ b/app/me/analytics/page.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { useMemo } from 'react'; +import { useAuthStatus } from '@/hooks/use-auth'; +import { GetMeResponse } from '@/lib/api/types'; +import { AnalyticsBentoGrid } from '@/components/analytics/AnalyticsBentoGrid'; +import { AnalyticsChart } from '@/components/analytics/AnalyticsChart'; +import { AuthGuard } from '@/components/auth'; +import LoadingSpinner from '@/components/LoadingSpinner'; + +function AnalyticsContent() { + const { user, isLoading } = useAuthStatus(); + + const meData = useMemo( + () => (user?.profile as GetMeResponse | undefined) ?? null, + [user?.profile] + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!meData?.stats || !meData?.chart) { + return ( +
+ Analytics data unavailable. +
+ ); + } + + return ( +
+ {/* Page header */} +
+

Analytics

+

+ Your personal growth dashboard +

+
+ + {/* Bento grid — core stats */} + + + {/* Activity chart */} + +
+ ); +} + +export default function AnalyticsPage() { + return ( + Authenticating...} + > + + + ); +} diff --git a/components/analytics/AnalyticsBentoGrid.tsx b/components/analytics/AnalyticsBentoGrid.tsx new file mode 100644 index 000000000..67c4e1f9a --- /dev/null +++ b/components/analytics/AnalyticsBentoGrid.tsx @@ -0,0 +1,237 @@ +'use client'; + +import { useMemo } from 'react'; +import { motion } from 'framer-motion'; +import { + TrendingUp, + TrendingDown, + Minus, + Users, + FolderGit2, + Trophy, + Star, + DollarSign, + MessageSquare, + ThumbsUp, + GitBranch, +} from 'lucide-react'; +import { GetMeResponse } from '@/lib/api/types'; +import { calculateChartTrend, TrendResult } from '@/lib/utils/calculateTrend'; + +interface Props { + stats: GetMeResponse['stats']; + chart: GetMeResponse['chart']; +} + +function TrendBadge({ trend }: { trend: TrendResult }) { + if (trend.direction === 'up') { + return ( + + + ); + } + if (trend.direction === 'down') { + return ( + + + ); + } + return ( + + + ); +} + +interface TileConfig { + label: string; + value: number; + icon: React.ReactNode; + trend: TrendResult; + colSpan: string; + rowSpan: string; + gradient: string; + large?: boolean; +} + +const FLAT_TREND: TrendResult = { percentage: 0, direction: 'flat' }; + +const containerVariants = { + hidden: {}, + show: { transition: { staggerChildren: 0.07 } }, +}; + +const tileVariants = { + hidden: { opacity: 0, y: 16 }, + show: { + opacity: 1, + y: 0, + transition: { duration: 0.35, ease: 'easeOut' as const }, + }, +}; + +export function AnalyticsBentoGrid({ stats, chart }: Props) { + const chartTrend = useMemo(() => calculateChartTrend(chart), [chart]); + + const tiles: TileConfig[] = useMemo( + () => [ + { + label: 'Global Reputation', + value: stats.reputation, + icon: , + trend: chartTrend, + colSpan: 'col-span-2', + rowSpan: 'row-span-2', + gradient: + 'bg-gradient-to-br from-[#06b6d4]/20 via-[#4f46e5]/10 to-transparent', + large: true, + }, + { + label: 'Community Score', + value: stats.communityScore, + icon: , + trend: chartTrend, + colSpan: 'col-span-1', + rowSpan: 'row-span-1', + gradient: 'bg-white/[0.03]', + }, + { + label: 'Projects Created', + value: stats.projectsCreated, + icon: , + trend: FLAT_TREND, + colSpan: 'col-span-1', + rowSpan: 'row-span-1', + gradient: 'bg-white/[0.03]', + }, + { + label: 'Hackathons Entered', + value: stats.hackathons, + icon: , + trend: FLAT_TREND, + colSpan: 'col-span-1', + rowSpan: 'row-span-1', + gradient: 'bg-gradient-to-br from-amber-500/10 to-transparent', + }, + { + label: 'Followers', + value: stats.followers, + icon: , + trend: FLAT_TREND, + colSpan: 'col-span-1', + rowSpan: 'row-span-1', + gradient: 'bg-white/[0.03]', + }, + { + label: 'Total Contributed', + value: stats.totalContributed, + icon: , + trend: FLAT_TREND, + colSpan: 'col-span-1', + rowSpan: 'row-span-1', + gradient: 'bg-gradient-to-br from-emerald-500/10 to-transparent', + }, + { + label: 'Comments Posted', + value: stats.commentsPosted, + icon: , + trend: FLAT_TREND, + colSpan: 'col-span-1', + rowSpan: 'row-span-1', + gradient: 'bg-white/[0.03]', + }, + { + label: 'Votes Cast', + value: stats.votes, + icon: , + trend: FLAT_TREND, + colSpan: 'col-span-1', + rowSpan: 'row-span-1', + gradient: 'bg-white/[0.03]', + }, + { + label: 'Following', + value: stats.following, + icon: , + trend: FLAT_TREND, + colSpan: 'col-span-1', + rowSpan: 'row-span-1', + gradient: 'bg-white/[0.03]', + }, + ], + [stats, chartTrend] + ); + + return ( + <> + + + + + + + + + + {tiles.map(t => ( + + + + + + ))} + +
MetricValueTrend
{t.label}{t.value.toLocaleString()} + {t.trend.direction === 'flat' + ? 'No change' + : `${t.trend.direction === 'up' ? 'Up' : 'Down'} ${t.trend.percentage}%`} +
+ +