Skip to content

Commit dbd0861

Browse files
authored
feat(dashboard): implement profile comparison view with custom SVG ch… (JhaSourav07#817)
feat(dashboard): implement profile comparison view with custom SVG charts and inactivity insights
2 parents 4252202 + e7dbb5a commit dbd0861

13 files changed

Lines changed: 2183 additions & 99 deletions
Lines changed: 3 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,9 @@
11
// app/(root)/dashboard/[username]/page.tsx
22

33
import type { Metadata } from 'next';
4-
import { notFound, redirect } from 'next/navigation';
5-
import Link from 'next/link';
6-
7-
import RefreshButton from '@/components/dashboard/RefreshButton';
8-
import ProfileCard from '@/components/dashboard/ProfileCard';
9-
import ActivityLandscape from '@/components/dashboard/ActivityLandscape';
10-
import StatsCard from '@/components/dashboard/StatsCard';
11-
import LanguageChart from '@/components/dashboard/LanguageChart';
12-
import CommitClock from '@/components/dashboard/CommitClock';
13-
import Heatmap from '@/components/dashboard/Heatmap';
14-
import AIInsights from '@/components/dashboard/AIInsights';
15-
import Achievements from '@/components/dashboard/Achievements';
4+
import DashboardClient from '@/components/dashboard/DashboardClient';
165
import { getFullDashboardData, fetchUserProfile } from '@/lib/github';
6+
import { notFound, redirect } from 'next/navigation';
177

188
export const revalidate = 3600; // Cache for 1 hour
199

@@ -84,90 +74,5 @@ export default async function DashboardPage({
8474
throw error;
8575
}
8676

87-
return (
88-
<div id="dashboard-root" data-dashboard className="p-4 md:p-6 lg:p-8 min-h-screen relative">
89-
<div id="generate-dashboard-btn" className="flex justify-end gap-4 mb-6">
90-
<RefreshButton username={username} />
91-
<Link
92-
href="/"
93-
className="flex items-center gap-2 rounded-xl border border-black/10 dark:border-[rgba(255,255,255,0.15)] bg-black dark:bg-black px-4 py-2 text-sm font-semibold text-white dark:text-white transition-all duration-200 hover:bg-gray-200 dark:hover:bg-white/10 active:scale-[0.98]"
94-
>
95-
<svg
96-
xmlns="http://www.w3.org/2000/svg"
97-
width="16"
98-
height="16"
99-
viewBox="0 0 24 24"
100-
fill="none"
101-
stroke="currentColor"
102-
strokeWidth="2"
103-
strokeLinecap="round"
104-
strokeLinejoin="round"
105-
>
106-
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
107-
</svg>
108-
Generate Your Own
109-
</Link>
110-
</div>
111-
112-
<div className="grid grid-cols-1 lg:grid-cols-[300px_1fr_320px] gap-6 lg:gap-8">
113-
{/* Left Sidebar */}
114-
<aside className="flex flex-col gap-6">
115-
<ProfileCard
116-
user={data.profile}
117-
exportData={{
118-
stats: data.stats,
119-
languages: data.languages,
120-
}}
121-
/>
122-
<Achievements achievements={data.achievements} />
123-
</aside>
124-
125-
{/* Main Content */}
126-
<div className="flex flex-col gap-6 lg:gap-8 min-w-0">
127-
<section>
128-
<ActivityLandscape data={data.activity} />
129-
</section>
130-
131-
<section className="grid grid-cols-1 md:grid-cols-2 gap-6">
132-
<LanguageChart languages={data.languages} />
133-
<CommitClock data={data.commitClock} />
134-
</section>
135-
136-
<section>
137-
<Heatmap data={data.activity} />
138-
</section>
139-
</div>
140-
141-
{/* Right Sidebar */}
142-
<aside className="flex flex-col gap-6">
143-
<div className="flex flex-col gap-4">
144-
<StatsCard
145-
title="Current Streak"
146-
value={data.stats.currentStreak.toString()}
147-
description="Days"
148-
icon="Flame"
149-
showUTCDisclaimer={true}
150-
utcDate={new Date().toISOString().split('T')[0]}
151-
/>
152-
153-
<StatsCard
154-
title="Peak Streak"
155-
value={data.stats.peakStreak.toString()}
156-
description="Days"
157-
icon="TrendingUp"
158-
/>
159-
160-
<StatsCard
161-
title="Contributions"
162-
value={data.stats.totalContributions.toString()}
163-
description="Last 365 Days"
164-
icon="GitCommit"
165-
/>
166-
</div>
167-
168-
<AIInsights insights={data.insights} />
169-
</aside>
170-
</div>
171-
</div>
172-
);
77+
return <DashboardClient initialData={data} username={username} />;
17378
}

app/favicon.ico

-4.19 KB
Binary file not shown.

app/icon.png

-242 KB
Binary file not shown.
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import { render, screen } from '@testing-library/react';
3+
import { describe, expect, it, vi } from 'vitest';
4+
import ComparisonStatsCard from './ComparisonStatsCard';
5+
6+
vi.mock('framer-motion', () => ({
7+
motion: {
8+
div: ({ children, className, style, ...props }: any) => {
9+
delete props.initial;
10+
delete props.animate;
11+
delete props.whileInView;
12+
delete props.viewport;
13+
delete props.transition;
14+
delete props.whileHover;
15+
16+
return (
17+
<div className={className} style={style} {...props}>
18+
{children}
19+
</div>
20+
);
21+
},
22+
},
23+
}));
24+
25+
describe('ComparisonStatsCard', () => {
26+
it('renders correctly with title, labels and values', () => {
27+
render(
28+
<ComparisonStatsCard
29+
title="Developer Score"
30+
valueA={85}
31+
valueB={72}
32+
labelA="User One"
33+
labelB="User Two"
34+
icon="Award"
35+
/>
36+
);
37+
38+
expect(screen.getByText(/Developer Score/i)).toBeDefined();
39+
expect(screen.getByText('User One')).toBeDefined();
40+
expect(screen.getByText('User Two')).toBeDefined();
41+
expect(screen.getByText('85')).toBeDefined();
42+
expect(screen.getByText('72')).toBeDefined();
43+
});
44+
45+
it('renders a Winner badge on User One when valueA is greater', () => {
46+
render(
47+
<ComparisonStatsCard
48+
title="Developer Score"
49+
valueA={100}
50+
valueB={50}
51+
labelA="User One"
52+
labelB="User Two"
53+
icon="Award"
54+
/>
55+
);
56+
57+
const winnerBadges = screen.getAllByText('Winner');
58+
expect(winnerBadges.length).toBe(1);
59+
expect(screen.getByText('100').parentElement?.innerHTML).toContain('Winner');
60+
});
61+
62+
it('renders a Winner badge on User Two when valueB is greater', () => {
63+
render(
64+
<ComparisonStatsCard
65+
title="Developer Score"
66+
valueA={30}
67+
valueB={90}
68+
labelA="User One"
69+
labelB="User Two"
70+
icon="Award"
71+
/>
72+
);
73+
74+
const winnerBadges = screen.getAllByText('Winner');
75+
expect(winnerBadges.length).toBe(1);
76+
expect(screen.getByText('90').parentElement?.innerHTML).toContain('Winner');
77+
});
78+
79+
it('does not render any Winner badge if values are equal', () => {
80+
render(
81+
<ComparisonStatsCard
82+
title="Developer Score"
83+
valueA={50}
84+
valueB={50}
85+
labelA="User One"
86+
labelB="User Two"
87+
icon="Award"
88+
/>
89+
);
90+
91+
expect(screen.queryByText('Winner')).toBeNull();
92+
});
93+
});
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
'use client';
2+
3+
import { motion } from 'framer-motion';
4+
import {
5+
Flame,
6+
TrendingUp,
7+
GitCommit,
8+
GitBranch,
9+
Users,
10+
UserPlus,
11+
Award,
12+
LucideIcon,
13+
} from 'lucide-react';
14+
15+
const iconMap: Record<string, LucideIcon> = {
16+
Flame,
17+
TrendingUp,
18+
GitCommit,
19+
GitBranch,
20+
Users,
21+
UserPlus,
22+
Award,
23+
};
24+
25+
interface ComparisonStatsCardProps {
26+
title: string;
27+
valueA: number;
28+
valueB: number;
29+
labelA: string;
30+
labelB: string;
31+
icon: string;
32+
}
33+
34+
export default function ComparisonStatsCard({
35+
title,
36+
valueA,
37+
valueB,
38+
labelA,
39+
labelB,
40+
icon,
41+
}: ComparisonStatsCardProps) {
42+
const IconComponent = iconMap[icon] || Award;
43+
44+
const total = valueA + valueB;
45+
const pctA = total > 0 ? (valueA / total) * 100 : 50;
46+
const pctB = total > 0 ? (valueB / total) * 100 : 50;
47+
48+
const isWinnerA = valueA > valueB;
49+
const isWinnerB = valueB > valueA;
50+
51+
return (
52+
<motion.div
53+
initial={{ opacity: 0, y: 12 }}
54+
whileInView={{ opacity: 1, y: 0 }}
55+
viewport={{ once: true }}
56+
whileHover={{ y: -2 }}
57+
transition={{ duration: 0.2, ease: 'easeOut' }}
58+
className="group p-6 rounded-xl bg-white dark:bg-[#0a0a0a] border border-black/10 dark:border-[rgba(255,255,255,0.08)] hover:border-black/20 dark:hover:border-[rgba(255,255,255,0.14)] hover:shadow-[0_0_24px_rgba(16,185,129,0.04)] transition-all duration-200 relative overflow-hidden"
59+
>
60+
{/* Title & Icon Header */}
61+
<div className="flex justify-between items-center mb-6">
62+
<p className="text-xs text-[#A1A1AA] uppercase tracking-widest font-medium">{title}</p>
63+
<div className="p-2 rounded-lg bg-gray-100 dark:bg-[#111] border border-black/10 dark:border-[rgba(255,255,255,0.06)] group-hover:border-[rgba(16,185,129,0.2)] transition-colors duration-200">
64+
<IconComponent
65+
size={18}
66+
className="text-[#A1A1AA] group-hover:text-black dark:group-hover:text-white transition-colors duration-200"
67+
/>
68+
</div>
69+
</div>
70+
71+
{/* Side-by-side values */}
72+
<div className="grid grid-cols-2 gap-4 items-center mb-6 relative">
73+
{/* User A Side */}
74+
<div className={`text-left pr-4 ${isWinnerA ? 'border-r border-emerald-500/10' : ''}`}>
75+
<p className="text-xs text-[#A1A1AA] truncate mb-1" title={labelA}>
76+
{labelA}
77+
</p>
78+
<div className="flex items-baseline gap-2">
79+
<span
80+
className={`text-3xl font-bold tracking-tight transition-colors duration-300 ${
81+
isWinnerA
82+
? 'text-emerald-600 dark:text-emerald-400 font-extrabold drop-shadow-[0_0_12px_rgba(16,185,129,0.2)]'
83+
: 'text-gray-900 dark:text-white'
84+
}`}
85+
>
86+
{valueA}
87+
</span>
88+
{isWinnerA && (
89+
<span className="text-[10px] font-bold text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 px-1.5 py-0.5 rounded uppercase tracking-wide">
90+
Winner
91+
</span>
92+
)}
93+
</div>
94+
</div>
95+
96+
{/* User B Side */}
97+
<div className="text-right pl-4">
98+
<p className="text-xs text-[#A1A1AA] truncate mb-1" title={labelB}>
99+
{labelB}
100+
</p>
101+
<div className="flex items-baseline justify-end gap-2">
102+
{isWinnerB && (
103+
<span className="text-[10px] font-bold text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 px-1.5 py-0.5 rounded uppercase tracking-wide">
104+
Winner
105+
</span>
106+
)}
107+
<span
108+
className={`text-3xl font-bold tracking-tight transition-colors duration-300 ${
109+
isWinnerB
110+
? 'text-emerald-600 dark:text-emerald-400 font-extrabold drop-shadow-[0_0_12px_rgba(16,185,129,0.2)]'
111+
: 'text-gray-900 dark:text-white'
112+
}`}
113+
>
114+
{valueB}
115+
</span>
116+
</div>
117+
</div>
118+
119+
{/* Visual center divider (desktop only) */}
120+
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[1px] h-8 bg-black/5 dark:bg-white/5 pointer-events-none hidden md:block" />
121+
</div>
122+
123+
{/* Comparison Progress Bar */}
124+
<div className="w-full h-2 bg-gray-100 dark:bg-[#111] rounded-full overflow-hidden flex border border-black/5 dark:border-[rgba(255,255,255,0.04)]">
125+
{total > 0 ? (
126+
<>
127+
<motion.div
128+
initial={{ width: 0 }}
129+
animate={{ width: `${pctA}%` }}
130+
transition={{ duration: 1.0, ease: [0.16, 1, 0.3, 1] }}
131+
className={`h-full rounded-l-full ${
132+
isWinnerA
133+
? 'bg-emerald-500 dark:bg-emerald-400 shadow-[0_0_8px_rgba(16,185,129,0.4)]'
134+
: 'bg-zinc-400 dark:bg-zinc-600'
135+
}`}
136+
/>
137+
<motion.div
138+
initial={{ width: 0 }}
139+
animate={{ width: `${pctB}%` }}
140+
transition={{ duration: 1.0, ease: [0.16, 1, 0.3, 1] }}
141+
className={`h-full rounded-r-full ${
142+
isWinnerB
143+
? 'bg-emerald-500 dark:bg-emerald-400 shadow-[0_0_8px_rgba(16,185,129,0.4)]'
144+
: 'bg-zinc-400 dark:bg-zinc-600'
145+
}`}
146+
/>
147+
</>
148+
) : (
149+
<div className="w-full h-full bg-zinc-300 dark:bg-zinc-800" />
150+
)}
151+
</div>
152+
</motion.div>
153+
);
154+
}

0 commit comments

Comments
 (0)