Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 135 additions & 5 deletions app/me/analytics/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className='flex h-64 items-center justify-center'>
<LoadingSpinner size='xl' color='white' />
</div>
);
}

if (!meData?.stats || !meData?.chart) {
return (
<div className='text-muted-foreground flex h-64 items-center justify-center text-sm'>
Analytics data unavailable.
</div>
);
}

const activities = (meData.user?.activities ?? []) as Activity[];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify activity field shape and usage sites
set -euo pipefail

echo "== GetMeResponse / User shape =="
rg -n -C3 'interface GetMeResponse|interface User|recentActivities|activities' lib/api/types.ts

echo
echo "== Analytics page activity source =="
rg -n -C3 'meData\.user\?\.activities|recentActivities|ActivityHeatmap|ActivityFeed' app/me/analytics/page.tsx

echo
echo "== ActivityFeed expected input shape =="
rg -n -C3 'type ActivityFeedProps|user\.user\.activities|recentActivities' components/profile/ActivityFeed.tsx

Repository: boundlessfi/boundless

Length of output: 3578


Use meData.recentActivities instead of meData.user?.activities to prevent silently empty activity widgets.

Line 55 sources activities from meData.user?.activities, but GetMeResponse explicitly provides recentActivities at the top level. If the backend populates only recentActivities and omits the nested user.activities field, both the heatmap and feed render empty despite valid analytics data in the response. The comment on line 77 ("uses recentActivities") also contradicts the actual code path.

Replace line 55 with:

const activities = (meData.recentActivities ?? []) as Activity[];

Also update line 121 to pass activities directly to ActivityFeed:

<ActivityFeed filter={activityFilter} activities={activities} />

Then verify ActivityFeed component accepts an activities prop instead of deriving it from user.user.activities.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/me/analytics/page.tsx` at line 55, Replace the activities source with
meData.recentActivities by changing the activities declaration that currently
uses meData.user?.activities to use meData.recentActivities (cast to
Activity[]), update the ActivityFeed invocation to accept an explicit activities
prop (pass activities into the ActivityFeed component instead of relying on it
to derive from user.user.activities), and modify the ActivityFeed component
signature/prop usage to accept and use the incoming activities prop rather than
pulling activities from the nested user object.


return (
<div className='container mx-auto space-y-8 px-6 py-8'>
{/* Page header — matches earnings page pattern */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className='flex flex-col gap-2'
>
<h1 className='text-3xl font-bold tracking-tight'>Analytics</h1>
<p className='text-muted-foreground text-lg'>
Your personal growth dashboard across the platform.
</p>
</motion.div>

{/* Bento stats grid */}
<AnalyticsBentoGrid stats={meData.stats} chart={meData.chart} />

{/* Activity chart */}
<AnalyticsChart chart={meData.chart} />

{/* Activity heatmap — uses recentActivities as activity array */}
<Card>
<CardHeader>
<CardTitle>Contribution Graph</CardTitle>
<CardDescription>Your activity over the last year</CardDescription>
</CardHeader>
<CardContent>
<ActivityHeatmap activities={activities} />
</CardContent>
</Card>

{/* Recent activity feed */}
<Card>
<CardHeader className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<div>
<CardTitle>Recent Activity</CardTitle>
<CardDescription>
What you have been up to on the platform
</CardDescription>
</div>

{/* Filter toggle — matches the pattern ActivityFeed expects */}
<div
role='group'
aria-label='Activity time filter'
className='border-border bg-muted flex flex-wrap gap-1 rounded-xl border p-1'
>
{FILTER_OPTIONS.map(f => (
<button
key={f}
onClick={() => setActivityFilter(f)}
aria-pressed={activityFilter === f}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
activityFilter === f
? 'bg-primary text-primary-foreground shadow'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{f}
</button>
))}
</div>
</CardHeader>
<CardContent>
<ActivityFeed filter={activityFilter} user={meData} />
</CardContent>
</Card>
</div>
);
}

export default function AnalyticsPage() {
return (
<AuthGuard
redirectTo='/auth?mode=signin'
fallback={<div className='p-8 text-center'>Authenticating...</div>}
>
<AnalyticsContent />
</AuthGuard>
);
}
230 changes: 230 additions & 0 deletions components/analytics/AnalyticsBentoGrid.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span
aria-label={`Up ${trend.percentage}%`}
className='bg-primary/15 text-primary inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium'
>
<TrendingUp className='h-3 w-3' aria-hidden='true' />
{trend.percentage}%
</span>
);
}
if (trend.direction === 'down') {
return (
<span
aria-label={`Down ${trend.percentage}%`}
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'
>
<TrendingDown className='h-3 w-3' aria-hidden='true' />
{trend.percentage}%
</span>
);
}
return (
<span
aria-label='No change'
className='bg-muted text-muted-foreground inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium'
>
<Minus className='h-3 w-3' aria-hidden='true' />
0%
</span>
);
}

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: <Star className='h-5 w-5' />,
trend: chartTrend,
colSpan: 'col-span-2',
rowSpan: 'row-span-2',
large: true,
},
{
label: 'Community Score',
value: stats.communityScore,
icon: <Users className='h-4 w-4' />,
trend: chartTrend,
colSpan: 'col-span-1',
rowSpan: 'row-span-1',
},
{
label: 'Projects Created',
value: stats.projectsCreated,
icon: <FolderGit2 className='h-4 w-4' />,
trend: flat,
colSpan: 'col-span-1',
rowSpan: 'row-span-1',
},
{
label: 'Hackathons Entered',
value: stats.hackathons,
icon: <Trophy className='h-4 w-4' />,
trend: flat,
colSpan: 'col-span-1',
rowSpan: 'row-span-1',
},
{
label: 'Followers',
value: stats.followers,
icon: <Users className='h-4 w-4' />,
trend: flat,
colSpan: 'col-span-1',
rowSpan: 'row-span-1',
},
{
label: 'Total Contributed',
value: stats.totalContributed,
icon: <DollarSign className='h-4 w-4' />,
trend: flat,
colSpan: 'col-span-1',
rowSpan: 'row-span-1',
},
{
label: 'Comments Posted',
value: stats.commentsPosted,
icon: <MessageSquare className='h-4 w-4' />,
trend: flat,
colSpan: 'col-span-1',
rowSpan: 'row-span-1',
},
{
label: 'Votes Cast',
value: stats.votes,
icon: <ThumbsUp className='h-4 w-4' />,
trend: flat,
colSpan: 'col-span-1',
rowSpan: 'row-span-1',
},
{
label: 'Following',
value: stats.following,
icon: <GitBranch className='h-4 w-4' />,
trend: flat,
colSpan: 'col-span-1',
rowSpan: 'row-span-1',
},
],
[stats, chartTrend, flat]
);

return (
<>
{/* Screen reader fallback table */}
<table className='sr-only' aria-label='Analytics statistics'>
<thead>
<tr>
<th>Metric</th>
<th>Value</th>
<th>Trend</th>
</tr>
</thead>
<tbody>
{tiles.map(t => (
<tr key={t.label}>
<td>{t.label}</td>
<td>{t.value.toLocaleString()}</td>
<td>
{t.trend.direction === 'flat'
? 'No change'
: `${t.trend.direction === 'up' ? 'Up' : 'Down'} ${t.trend.percentage}%`}
</td>
</tr>
))}
</tbody>
</table>

<motion.div
aria-hidden='true'
variants={containerVariants}
initial='hidden'
animate='show'
className='grid grid-cols-2 gap-4 sm:grid-cols-4'
>
{tiles.map(tile => (
<motion.div
key={tile.label}
variants={tileVariants}
className={`${tile.colSpan} ${tile.rowSpan}`}
>
<Card className='h-full'>
<CardHeader className='flex flex-row items-start justify-between space-y-0 pb-2'>
<div className='bg-primary/10 text-primary flex h-9 w-9 items-center justify-center rounded-xl'>
{tile.icon}
</div>
<TrendBadge trend={tile.trend} />
</CardHeader>
<CardContent>
<p
className={`font-bold tracking-tight ${tile.large ? 'text-4xl' : 'text-2xl'}`}
>
{tile.value.toLocaleString()}
</p>
<p className='text-muted-foreground mt-1 text-sm'>
{tile.label}
</p>
</CardContent>
</Card>
</motion.div>
))}
</motion.div>
</>
);
}
Loading
Loading