diff --git a/app/me/analytics/page.tsx b/app/me/analytics/page.tsx index c1ff1b6d9..e6a082c9d 100644 --- a/app/me/analytics/page.tsx +++ b/app/me/analytics/page.tsx @@ -1,7 +1,137 @@ -import { redirect } from 'next/navigation'; +'use client'; -const page = () => { - redirect('/coming-soon'); -}; +import { useMemo, useState } from 'react'; +import { motion } from 'framer-motion'; +import { useAuthStatus } from '@/hooks/use-auth'; +import { GetMeResponse } from '@/lib/api/types'; +import { Activity } from '@/types/user'; +import { AnalyticsBentoGrid } from '@/components/analytics/AnalyticsBentoGrid'; +import { AnalyticsChart } from '@/components/analytics/AnalyticsChart'; +import { AuthGuard } from '@/components/auth'; +import LoadingSpinner from '@/components/LoadingSpinner'; +import ActivityHeatmap from '@/components/profile/ActivityHeatMap'; +import ActivityFeed from '@/components/profile/ActivityFeed'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; -export default page; +const FILTER_OPTIONS = [ + 'All Time', + 'This Year', + 'This Month', + 'This Week', + 'Today', +]; + +function AnalyticsContent() { + const { user, isLoading } = useAuthStatus(); + const [activityFilter, setActivityFilter] = useState('All Time'); + + const meData = useMemo( + () => (user?.profile as GetMeResponse | undefined) ?? null, + [user?.profile] + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!meData?.stats || !meData?.chart) { + return ( +
+ Analytics data unavailable. +
+ ); + } + + const activities = (meData.user?.activities ?? []) as Activity[]; + + return ( +
+ {/* Page header — matches earnings page pattern */} + +

Analytics

+

+ Your personal growth dashboard across the platform. +

+
+ + {/* Bento stats grid */} + + + {/* Activity chart */} + + + {/* Activity heatmap — uses recentActivities as activity array */} + + + Contribution Graph + Your activity over the last year + + + + + + + {/* Recent activity feed */} + + +
+ Recent Activity + + What you have been up to on the platform + +
+ + {/* Filter toggle — matches the pattern ActivityFeed expects */} +
+ {FILTER_OPTIONS.map(f => ( + + ))} +
+
+ + + +
+
+ ); +} + +export default function AnalyticsPage() { + return ( + Authenticating...} + > + + + ); +} diff --git a/components/analytics/AnalyticsBentoGrid.tsx b/components/analytics/AnalyticsBentoGrid.tsx new file mode 100644 index 000000000..066262990 --- /dev/null +++ b/components/analytics/AnalyticsBentoGrid.tsx @@ -0,0 +1,230 @@ +'use client'; + +import { useMemo } from 'react'; +import { motion, Variants } from 'framer-motion'; +import { + TrendingUp, + TrendingDown, + Minus, + Users, + FolderGit2, + Trophy, + Star, + DollarSign, + MessageSquare, + ThumbsUp, + GitBranch, +} from 'lucide-react'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +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; + large?: boolean; +} + +const containerVariants: Variants = { + hidden: {}, + show: { transition: { staggerChildren: 0.07 } }, +}; + +const tileVariants: Variants = { + hidden: { opacity: 0, y: 16 }, + show: { + opacity: 1, + y: 0, + transition: { duration: 0.35, ease: [0.4, 0, 0.2, 1] as const }, + }, +}; + +export function AnalyticsBentoGrid({ stats, chart }: Props) { + const chartTrend = useMemo(() => calculateChartTrend(chart), [chart]); + const flat: TrendResult = { percentage: 0, direction: 'flat' }; + + const tiles: TileConfig[] = useMemo( + () => [ + { + label: 'Global Reputation', + value: stats.reputation, + icon: , + trend: chartTrend, + colSpan: 'col-span-2', + rowSpan: 'row-span-2', + large: true, + }, + { + label: 'Community Score', + value: stats.communityScore, + icon: , + trend: chartTrend, + colSpan: 'col-span-1', + rowSpan: 'row-span-1', + }, + { + label: 'Projects Created', + value: stats.projectsCreated, + icon: , + trend: flat, + colSpan: 'col-span-1', + rowSpan: 'row-span-1', + }, + { + label: 'Hackathons Entered', + value: stats.hackathons, + icon: , + trend: flat, + colSpan: 'col-span-1', + rowSpan: 'row-span-1', + }, + { + label: 'Followers', + value: stats.followers, + icon: , + trend: flat, + colSpan: 'col-span-1', + rowSpan: 'row-span-1', + }, + { + label: 'Total Contributed', + value: stats.totalContributed, + icon: , + trend: flat, + colSpan: 'col-span-1', + rowSpan: 'row-span-1', + }, + { + label: 'Comments Posted', + value: stats.commentsPosted, + icon: , + trend: flat, + colSpan: 'col-span-1', + rowSpan: 'row-span-1', + }, + { + label: 'Votes Cast', + value: stats.votes, + icon: , + trend: flat, + colSpan: 'col-span-1', + rowSpan: 'row-span-1', + }, + { + label: 'Following', + value: stats.following, + icon: , + trend: flat, + colSpan: 'col-span-1', + rowSpan: 'row-span-1', + }, + ], + [stats, chartTrend, flat] + ); + + return ( + <> + {/* Screen reader fallback table */} + + + + + + + + + + {tiles.map(t => ( + + + + + + ))} + +
MetricValueTrend
{t.label}{t.value.toLocaleString()} + {t.trend.direction === 'flat' + ? 'No change' + : `${t.trend.direction === 'up' ? 'Up' : 'Down'} ${t.trend.percentage}%`} +
+ + + + ); +} diff --git a/components/analytics/AnalyticsChart.tsx b/components/analytics/AnalyticsChart.tsx new file mode 100644 index 000000000..6748a85ab --- /dev/null +++ b/components/analytics/AnalyticsChart.tsx @@ -0,0 +1,184 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { + ResponsiveContainer, + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + TooltipProps, +} from 'recharts'; +import { motion } from 'framer-motion'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { GetMeResponse } from '@/lib/api/types'; +import { + transformChartData, + filterChartByRange, +} from '@/lib/utils/calculateTrend'; + +type Range = '7D' | '30D' | '90D' | 'ALL'; +const RANGE_DAYS: Record = { + '7D': 7, + '30D': 30, + '90D': 90, + ALL: 'ALL', +}; + +interface Props { + chart: GetMeResponse['chart']; +} + +function CustomTooltip({ + active, + payload, + label, +}: TooltipProps) { + if (!active || !payload?.length) return null; + return ( +
+

{label}

+

+ {payload[0].value}{' '} + activities +

+
+ ); +} + +export function AnalyticsChart({ chart }: Props) { + const [range, setRange] = useState('30D'); + const ranges: Range[] = ['7D', '30D', '90D', 'ALL']; + + const allTransformed = useMemo(() => transformChartData(chart), [chart]); + + const filtered = useMemo( + () => filterChartByRange(allTransformed, RANGE_DAYS[range]), + [allTransformed, range] + ); + + return ( + + + +
+ Activity Over Time + Your platform activity trend +
+ +
+ {ranges.map(r => ( + + ))} +
+
+ + + {filtered.length === 0 ? ( +
+ No activity data available for this period. +
+ ) : ( + <> + {/* Screen reader fallback */} + + + + + + + + + {filtered.map(d => ( + + + + + ))} + +
DateActivity Count
{d.label}{d.count}
+ + + + )} +
+
+
+ ); +} diff --git a/lib/utils/calculateTrend.ts b/lib/utils/calculateTrend.ts new file mode 100644 index 000000000..1f02c913d --- /dev/null +++ b/lib/utils/calculateTrend.ts @@ -0,0 +1,65 @@ +export interface TrendResult { + percentage: number; + direction: 'up' | 'down' | 'flat'; +} + +export function calculateTrend( + current: number | null | undefined, + previous: number | null | undefined +): TrendResult { + const curr = current ?? 0; + const prev = previous ?? 0; + + if (prev === 0) { + if (curr === 0) return { percentage: 0, direction: 'flat' }; + return { percentage: 100, direction: 'up' }; + } + + const percentage = ((curr - prev) / prev) * 100; + const rounded = Math.round(percentage * 10) / 10; + + if (rounded > 0) return { percentage: rounded, direction: 'up' }; + if (rounded < 0) return { percentage: Math.abs(rounded), direction: 'down' }; + return { percentage: 0, direction: 'flat' }; +} + +export function calculateChartTrend( + data: Array<{ date: string; count: number }> | null | undefined +): TrendResult { + if (!data || data.length === 0) return { percentage: 0, direction: 'flat' }; + const mid = Math.floor(data.length / 2); + const previous = data + .slice(0, mid) + .reduce((sum, d) => sum + (d.count ?? 0), 0); + const current = data.slice(mid).reduce((sum, d) => sum + (d.count ?? 0), 0); + return calculateTrend(current, previous); +} + +/** Transforms raw API chart data into Recharts-compatible shape */ +export function transformChartData( + data: Array<{ date: string; count: number }> | null | undefined +): Array<{ date: string; count: number; label: string }> { + if (!data || data.length === 0) return []; + return [...data] + .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) + .map(d => ({ + date: d.date, + count: d.count ?? 0, + label: new Date(d.date).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }), + })); +} + +/** Filters transformed chart data by day range */ +export function filterChartByRange( + data: Array<{ date: string; count: number; label: string }>, + days: number | 'ALL' +): Array<{ date: string; count: number; label: string }> { + if (days === 'ALL' || data.length === 0) return data; + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - days); + const filtered = data.filter(d => new Date(d.date) >= cutoff); + return filtered.length > 0 ? filtered : data.slice(-days); +}