Skip to content

Commit 5eda51b

Browse files
authored
Feat/analytics dashboard (#421)
* feat(analytics): implement /me/analytics dashboard (#397) * chore: fix audit vulnerabilities * fix(analytics): address PR review corrections - use Card component, primary color, and integrate activity feeds
1 parent 06e038b commit 5eda51b

4 files changed

Lines changed: 614 additions & 5 deletions

File tree

app/me/analytics/page.tsx

Lines changed: 135 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,137 @@
1-
import { redirect } from 'next/navigation';
1+
'use client';
22

3-
const page = () => {
4-
redirect('/coming-soon');
5-
};
3+
import { useMemo, useState } from 'react';
4+
import { motion } from 'framer-motion';
5+
import { useAuthStatus } from '@/hooks/use-auth';
6+
import { GetMeResponse } from '@/lib/api/types';
7+
import { Activity } from '@/types/user';
8+
import { AnalyticsBentoGrid } from '@/components/analytics/AnalyticsBentoGrid';
9+
import { AnalyticsChart } from '@/components/analytics/AnalyticsChart';
10+
import { AuthGuard } from '@/components/auth';
11+
import LoadingSpinner from '@/components/LoadingSpinner';
12+
import ActivityHeatmap from '@/components/profile/ActivityHeatMap';
13+
import ActivityFeed from '@/components/profile/ActivityFeed';
14+
import {
15+
Card,
16+
CardContent,
17+
CardDescription,
18+
CardHeader,
19+
CardTitle,
20+
} from '@/components/ui/card';
621

7-
export default page;
22+
const FILTER_OPTIONS = [
23+
'All Time',
24+
'This Year',
25+
'This Month',
26+
'This Week',
27+
'Today',
28+
];
29+
30+
function AnalyticsContent() {
31+
const { user, isLoading } = useAuthStatus();
32+
const [activityFilter, setActivityFilter] = useState('All Time');
33+
34+
const meData = useMemo(
35+
() => (user?.profile as GetMeResponse | undefined) ?? null,
36+
[user?.profile]
37+
);
38+
39+
if (isLoading) {
40+
return (
41+
<div className='flex h-64 items-center justify-center'>
42+
<LoadingSpinner size='xl' color='white' />
43+
</div>
44+
);
45+
}
46+
47+
if (!meData?.stats || !meData?.chart) {
48+
return (
49+
<div className='text-muted-foreground flex h-64 items-center justify-center text-sm'>
50+
Analytics data unavailable.
51+
</div>
52+
);
53+
}
54+
55+
const activities = (meData.user?.activities ?? []) as Activity[];
56+
57+
return (
58+
<div className='container mx-auto space-y-8 px-6 py-8'>
59+
{/* Page header — matches earnings page pattern */}
60+
<motion.div
61+
initial={{ opacity: 0, y: 20 }}
62+
animate={{ opacity: 1, y: 0 }}
63+
className='flex flex-col gap-2'
64+
>
65+
<h1 className='text-3xl font-bold tracking-tight'>Analytics</h1>
66+
<p className='text-muted-foreground text-lg'>
67+
Your personal growth dashboard across the platform.
68+
</p>
69+
</motion.div>
70+
71+
{/* Bento stats grid */}
72+
<AnalyticsBentoGrid stats={meData.stats} chart={meData.chart} />
73+
74+
{/* Activity chart */}
75+
<AnalyticsChart chart={meData.chart} />
76+
77+
{/* Activity heatmap — uses recentActivities as activity array */}
78+
<Card>
79+
<CardHeader>
80+
<CardTitle>Contribution Graph</CardTitle>
81+
<CardDescription>Your activity over the last year</CardDescription>
82+
</CardHeader>
83+
<CardContent>
84+
<ActivityHeatmap activities={activities} />
85+
</CardContent>
86+
</Card>
87+
88+
{/* Recent activity feed */}
89+
<Card>
90+
<CardHeader className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
91+
<div>
92+
<CardTitle>Recent Activity</CardTitle>
93+
<CardDescription>
94+
What you have been up to on the platform
95+
</CardDescription>
96+
</div>
97+
98+
{/* Filter toggle — matches the pattern ActivityFeed expects */}
99+
<div
100+
role='group'
101+
aria-label='Activity time filter'
102+
className='border-border bg-muted flex flex-wrap gap-1 rounded-xl border p-1'
103+
>
104+
{FILTER_OPTIONS.map(f => (
105+
<button
106+
key={f}
107+
onClick={() => setActivityFilter(f)}
108+
aria-pressed={activityFilter === f}
109+
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
110+
activityFilter === f
111+
? 'bg-primary text-primary-foreground shadow'
112+
: 'text-muted-foreground hover:text-foreground'
113+
}`}
114+
>
115+
{f}
116+
</button>
117+
))}
118+
</div>
119+
</CardHeader>
120+
<CardContent>
121+
<ActivityFeed filter={activityFilter} user={meData} />
122+
</CardContent>
123+
</Card>
124+
</div>
125+
);
126+
}
127+
128+
export default function AnalyticsPage() {
129+
return (
130+
<AuthGuard
131+
redirectTo='/auth?mode=signin'
132+
fallback={<div className='p-8 text-center'>Authenticating...</div>}
133+
>
134+
<AnalyticsContent />
135+
</AuthGuard>
136+
);
137+
}
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
'use client';
2+
3+
import { useMemo } from 'react';
4+
import { motion, Variants } from 'framer-motion';
5+
import {
6+
TrendingUp,
7+
TrendingDown,
8+
Minus,
9+
Users,
10+
FolderGit2,
11+
Trophy,
12+
Star,
13+
DollarSign,
14+
MessageSquare,
15+
ThumbsUp,
16+
GitBranch,
17+
} from 'lucide-react';
18+
import { Card, CardContent, CardHeader } from '@/components/ui/card';
19+
import { GetMeResponse } from '@/lib/api/types';
20+
import { calculateChartTrend, TrendResult } from '@/lib/utils/calculateTrend';
21+
22+
interface Props {
23+
stats: GetMeResponse['stats'];
24+
chart: GetMeResponse['chart'];
25+
}
26+
27+
function TrendBadge({ trend }: { trend: TrendResult }) {
28+
if (trend.direction === 'up') {
29+
return (
30+
<span
31+
aria-label={`Up ${trend.percentage}%`}
32+
className='bg-primary/15 text-primary inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium'
33+
>
34+
<TrendingUp className='h-3 w-3' aria-hidden='true' />
35+
{trend.percentage}%
36+
</span>
37+
);
38+
}
39+
if (trend.direction === 'down') {
40+
return (
41+
<span
42+
aria-label={`Down ${trend.percentage}%`}
43+
className='inline-flex items-center gap-1 rounded-full bg-red-500/15 px-2 py-0.5 text-xs font-medium text-red-400'
44+
>
45+
<TrendingDown className='h-3 w-3' aria-hidden='true' />
46+
{trend.percentage}%
47+
</span>
48+
);
49+
}
50+
return (
51+
<span
52+
aria-label='No change'
53+
className='bg-muted text-muted-foreground inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium'
54+
>
55+
<Minus className='h-3 w-3' aria-hidden='true' />
56+
0%
57+
</span>
58+
);
59+
}
60+
61+
interface TileConfig {
62+
label: string;
63+
value: number;
64+
icon: React.ReactNode;
65+
trend: TrendResult;
66+
colSpan: string;
67+
rowSpan: string;
68+
large?: boolean;
69+
}
70+
71+
const containerVariants: Variants = {
72+
hidden: {},
73+
show: { transition: { staggerChildren: 0.07 } },
74+
};
75+
76+
const tileVariants: Variants = {
77+
hidden: { opacity: 0, y: 16 },
78+
show: {
79+
opacity: 1,
80+
y: 0,
81+
transition: { duration: 0.35, ease: [0.4, 0, 0.2, 1] as const },
82+
},
83+
};
84+
85+
export function AnalyticsBentoGrid({ stats, chart }: Props) {
86+
const chartTrend = useMemo(() => calculateChartTrend(chart), [chart]);
87+
const flat: TrendResult = { percentage: 0, direction: 'flat' };
88+
89+
const tiles: TileConfig[] = useMemo(
90+
() => [
91+
{
92+
label: 'Global Reputation',
93+
value: stats.reputation,
94+
icon: <Star className='h-5 w-5' />,
95+
trend: chartTrend,
96+
colSpan: 'col-span-2',
97+
rowSpan: 'row-span-2',
98+
large: true,
99+
},
100+
{
101+
label: 'Community Score',
102+
value: stats.communityScore,
103+
icon: <Users className='h-4 w-4' />,
104+
trend: chartTrend,
105+
colSpan: 'col-span-1',
106+
rowSpan: 'row-span-1',
107+
},
108+
{
109+
label: 'Projects Created',
110+
value: stats.projectsCreated,
111+
icon: <FolderGit2 className='h-4 w-4' />,
112+
trend: flat,
113+
colSpan: 'col-span-1',
114+
rowSpan: 'row-span-1',
115+
},
116+
{
117+
label: 'Hackathons Entered',
118+
value: stats.hackathons,
119+
icon: <Trophy className='h-4 w-4' />,
120+
trend: flat,
121+
colSpan: 'col-span-1',
122+
rowSpan: 'row-span-1',
123+
},
124+
{
125+
label: 'Followers',
126+
value: stats.followers,
127+
icon: <Users className='h-4 w-4' />,
128+
trend: flat,
129+
colSpan: 'col-span-1',
130+
rowSpan: 'row-span-1',
131+
},
132+
{
133+
label: 'Total Contributed',
134+
value: stats.totalContributed,
135+
icon: <DollarSign className='h-4 w-4' />,
136+
trend: flat,
137+
colSpan: 'col-span-1',
138+
rowSpan: 'row-span-1',
139+
},
140+
{
141+
label: 'Comments Posted',
142+
value: stats.commentsPosted,
143+
icon: <MessageSquare className='h-4 w-4' />,
144+
trend: flat,
145+
colSpan: 'col-span-1',
146+
rowSpan: 'row-span-1',
147+
},
148+
{
149+
label: 'Votes Cast',
150+
value: stats.votes,
151+
icon: <ThumbsUp className='h-4 w-4' />,
152+
trend: flat,
153+
colSpan: 'col-span-1',
154+
rowSpan: 'row-span-1',
155+
},
156+
{
157+
label: 'Following',
158+
value: stats.following,
159+
icon: <GitBranch className='h-4 w-4' />,
160+
trend: flat,
161+
colSpan: 'col-span-1',
162+
rowSpan: 'row-span-1',
163+
},
164+
],
165+
[stats, chartTrend, flat]
166+
);
167+
168+
return (
169+
<>
170+
{/* Screen reader fallback table */}
171+
<table className='sr-only' aria-label='Analytics statistics'>
172+
<thead>
173+
<tr>
174+
<th>Metric</th>
175+
<th>Value</th>
176+
<th>Trend</th>
177+
</tr>
178+
</thead>
179+
<tbody>
180+
{tiles.map(t => (
181+
<tr key={t.label}>
182+
<td>{t.label}</td>
183+
<td>{t.value.toLocaleString()}</td>
184+
<td>
185+
{t.trend.direction === 'flat'
186+
? 'No change'
187+
: `${t.trend.direction === 'up' ? 'Up' : 'Down'} ${t.trend.percentage}%`}
188+
</td>
189+
</tr>
190+
))}
191+
</tbody>
192+
</table>
193+
194+
<motion.div
195+
aria-hidden='true'
196+
variants={containerVariants}
197+
initial='hidden'
198+
animate='show'
199+
className='grid grid-cols-2 gap-4 sm:grid-cols-4'
200+
>
201+
{tiles.map(tile => (
202+
<motion.div
203+
key={tile.label}
204+
variants={tileVariants}
205+
className={`${tile.colSpan} ${tile.rowSpan}`}
206+
>
207+
<Card className='h-full'>
208+
<CardHeader className='flex flex-row items-start justify-between space-y-0 pb-2'>
209+
<div className='bg-primary/10 text-primary flex h-9 w-9 items-center justify-center rounded-xl'>
210+
{tile.icon}
211+
</div>
212+
<TrendBadge trend={tile.trend} />
213+
</CardHeader>
214+
<CardContent>
215+
<p
216+
className={`font-bold tracking-tight ${tile.large ? 'text-4xl' : 'text-2xl'}`}
217+
>
218+
{tile.value.toLocaleString()}
219+
</p>
220+
<p className='text-muted-foreground mt-1 text-sm'>
221+
{tile.label}
222+
</p>
223+
</CardContent>
224+
</Card>
225+
</motion.div>
226+
))}
227+
</motion.div>
228+
</>
229+
);
230+
}

0 commit comments

Comments
 (0)