Skip to content

Commit acbaa0d

Browse files
feat(dashboard): add advanced achievements system with progress tracking (JhaSourav07#924)
## Description Fixes JhaSourav07#914 ## Pillar - [ ] 🎨 Pillar 1 — New Theme Design - [ ] 📐 Pillar 2 — Geometric SVG Improvement - [ ] 🕐 Pillar 3 — Timezone Logic Optimization - [ ] 🛠️ Other (Bug fix, refactoring, docs) ## Visual Preview ## Checklist before requesting a review: - [ ] I have read the `CONTRIBUTING.md` file. - [ ] I have tested these changes locally (`localhost:3000/api/streak?user=YOUR_USERNAME`). - [ ] I have run `npm run format` and `npm run lint` locally and resolved all errors (CI will fail otherwise). - [ ] My commits follow the Conventional Commits format (e.g., `feat(themes): ...`, `fix(calculate): ...`). - [ ] I have updated `README.md` if I added a new theme or URL parameter. - [ ] I have started the repo. - [ ] I have made sure that i have only one commit to merge in this PR. - [ ] The SVG output matches the CommitPulse "premium quality" aesthetic standard (no raw elements, smooth animations, correct fonts). - [ ] (Recommended) I joined the CommitPulse Discord community for contributor discussions, mentorship, and faster PR support.
1 parent baa5798 commit acbaa0d

6 files changed

Lines changed: 236 additions & 61 deletions

File tree

components/dashboard/Achievements.test.tsx

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ const mockAchievements = [
3737
id: '1',
3838
title: 'Achiev 1',
3939
description: 'Desc 1',
40-
icon: 'award',
40+
icon: '🏆',
4141
type: 'streak' as const,
4242
isUnlocked: true,
4343
currentValue: 10,
@@ -48,7 +48,7 @@ const mockAchievements = [
4848
id: '2',
4949
title: 'Achiev 2',
5050
description: 'Desc 2',
51-
icon: 'award',
51+
icon: '🏆',
5252
type: 'contributions' as const,
5353
isUnlocked: false,
5454
currentValue: 5,
@@ -59,7 +59,7 @@ const mockAchievements = [
5959
id: '3',
6060
title: 'Achiev 3',
6161
description: 'Desc 3',
62-
icon: 'award',
62+
icon: '🔥',
6363
type: 'streak' as const,
6464
isUnlocked: true,
6565
currentValue: 5,
@@ -70,7 +70,7 @@ const mockAchievements = [
7070
id: '4',
7171
title: 'Achiev 4',
7272
description: 'Desc 4',
73-
icon: 'award',
73+
icon: '🏆',
7474
type: 'contributions' as const,
7575
isUnlocked: false,
7676
currentValue: 1,
@@ -81,7 +81,7 @@ const mockAchievements = [
8181
id: '5',
8282
title: 'Achiev 5',
8383
description: 'Desc 5',
84-
icon: 'award',
84+
icon: '🔥',
8585
type: 'streak' as const,
8686
isUnlocked: false,
8787
currentValue: 0,
@@ -90,6 +90,43 @@ const mockAchievements = [
9090
},
9191
];
9292

93+
// New achievement types for extended tests
94+
const behaviorAchievements = [
95+
{
96+
id: 'weekend-warrior',
97+
title: 'Weekend Warrior',
98+
description: '10+ contributions on weekends (Sat & Sun)',
99+
icon: '🏋️',
100+
type: 'behavior' as const,
101+
isUnlocked: true,
102+
currentValue: 15,
103+
threshold: 10,
104+
progress: 100,
105+
},
106+
{
107+
id: 'polyglot',
108+
title: 'Polyglot',
109+
description: 'Used 5+ distinct programming languages',
110+
icon: '🐙',
111+
type: 'behavior' as const,
112+
isUnlocked: false,
113+
currentValue: 3,
114+
threshold: 5,
115+
progress: 60,
116+
},
117+
{
118+
id: 'consistency-500',
119+
title: 'Consistency King',
120+
description: 'Reached 500 total contributions',
121+
icon: '👑',
122+
type: 'contributions' as const,
123+
isUnlocked: false,
124+
currentValue: 250,
125+
threshold: 500,
126+
progress: 50,
127+
},
128+
];
129+
93130
describe('Achievements', () => {
94131
it('renders without crashing and shows the title', () => {
95132
render(<Achievements achievements={mockAchievements} />);
@@ -132,4 +169,37 @@ describe('Achievements', () => {
132169
expect(screen.queryByText('Achiev 5')).toBeNull();
133170
expect(screen.getByText('See All Achievements')).toBeDefined();
134171
});
172+
173+
// ── New achievement type tests ──────────────────────────────────────────────
174+
175+
it('renders a behavior achievement (Weekend Warrior) when unlocked', () => {
176+
render(<Achievements achievements={behaviorAchievements} />);
177+
expect(screen.getByText('Weekend Warrior')).toBeDefined();
178+
const card = screen.getByText('Weekend Warrior').parentElement;
179+
// Unlocked card should NOT have grayscale
180+
expect(card?.className).not.toContain('grayscale');
181+
expect(card?.className).toContain('bg-gray-100');
182+
});
183+
184+
it('renders a behavior achievement (Polyglot) as locked with progress bar text', () => {
185+
render(<Achievements achievements={behaviorAchievements} />);
186+
expect(screen.getByText('Polyglot')).toBeDefined();
187+
const card = screen.getByText('Polyglot').parentElement;
188+
// Locked card has grayscale
189+
expect(card?.className).toContain('grayscale');
190+
// Progress counter should appear (3/5)
191+
expect(screen.getByText('3/5')).toBeDefined();
192+
});
193+
194+
it('renders Consistency King as locked with correct progress text', () => {
195+
render(<Achievements achievements={behaviorAchievements} />);
196+
expect(screen.getByText('Consistency King')).toBeDefined();
197+
// Progress counter shows 250/500
198+
expect(screen.getByText('250/500')).toBeDefined();
199+
});
200+
201+
it('does not render a "See All" button when 4 or fewer achievements', () => {
202+
render(<Achievements achievements={behaviorAchievements} />);
203+
expect(screen.queryByText('See All Achievements')).toBeNull();
204+
});
135205
});

components/dashboard/Achievements.tsx

Lines changed: 46 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,21 @@
22

33
import { motion } from 'framer-motion';
44
import { useState } from 'react';
5-
import { Trophy, Flame } from 'lucide-react';
5+
import { Trophy, Flame, Sparkles } from 'lucide-react';
66
import { Achievement } from '@/types/dashboard';
77

8+
/** Map achievement type to the matching Lucide icon component. */
9+
function AchievementIcon({ type, isUnlocked }: { type: Achievement['type']; isUnlocked: boolean }) {
10+
const cls = `mb-2.5 ${isUnlocked ? 'text-[#A1A1AA]' : 'text-[#555]'}`;
11+
if (type === 'streak') return <Flame size={18} className={cls} />;
12+
if (type === 'behavior') return <Sparkles size={18} className={cls} />;
13+
return <Trophy size={18} className={cls} />;
14+
}
15+
816
export default function Achievements({ achievements }: { achievements: Achievement[] }) {
917
const [showAll, setShowAll] = useState(false);
1018
const visibleAchievements = showAll ? achievements : achievements.slice(0, 4);
19+
1120
return (
1221
<motion.div
1322
initial={{ opacity: 0, y: 12 }}
@@ -24,40 +33,45 @@ export default function Achievements({ achievements }: { achievements: Achieveme
2433
</div>
2534

2635
<div className="grid grid-cols-2 gap-2">
27-
{visibleAchievements.map((achievement, i) => {
28-
const Icon = achievement.type === 'streak' ? Flame : Trophy;
29-
return (
30-
<motion.div
31-
key={achievement.id}
32-
initial={{ opacity: 0, scale: 0.97 }}
33-
whileInView={{ opacity: 1, scale: 1 }}
34-
viewport={{ once: true }}
35-
transition={{ delay: 0.15 + i * 0.07, duration: 0.2 }}
36-
className={`p-4 flex flex-col items-center text-center rounded-lg border transition-all duration-200 ${
37-
achievement.isUnlocked
38-
? 'bg-gray-100 dark:bg-[#111] border-[rgba(255,255,255,0.08)] hover:border-[rgba(255,255,255,0.16)] hover:bg-gray-200 dark:hover:bg-[#161616] cursor-default'
39-
: 'bg-white dark:bg-[#0a0a0a] border border-gray-300 dark:border-[rgba(255,255,255,0.04)] opacity-30 grayscale pointer-events-none'
40-
}`}
41-
>
42-
<Icon
43-
size={18}
44-
className={`mb-2.5 ${achievement.isUnlocked ? 'text-[#A1A1AA]' : 'text-[#555]'}`}
45-
/>
46-
<h4 className="text-[11px] font-semibold text-gray-900 dark:text-white mb-1 text-center w-full leading-snug">
47-
{achievement.title}
48-
</h4>
49-
<p className="text-[10px] text-[#A1A1AA] line-clamp-2 w-full leading-relaxed">
50-
{achievement.description}
51-
</p>
52-
{achievement.progress !== undefined && !achievement.isUnlocked && (
53-
<p className="text-[10px] text-[#777] mt-2">
36+
{visibleAchievements.map((achievement, i) => (
37+
<motion.div
38+
key={achievement.id}
39+
initial={{ opacity: 0, scale: 0.97 }}
40+
whileInView={{ opacity: 1, scale: 1 }}
41+
viewport={{ once: true }}
42+
transition={{ delay: 0.15 + i * 0.07, duration: 0.2 }}
43+
className={`p-4 flex flex-col items-center text-center rounded-lg border transition-all duration-200 ${
44+
achievement.isUnlocked
45+
? 'bg-gray-100 dark:bg-[#111] border-[rgba(255,255,255,0.08)] hover:border-[rgba(255,255,255,0.16)] hover:bg-gray-200 dark:hover:bg-[#161616] cursor-default'
46+
: 'bg-white dark:bg-[#0a0a0a] border border-gray-300 dark:border-[rgba(255,255,255,0.04)] opacity-30 grayscale pointer-events-none'
47+
}`}
48+
>
49+
<AchievementIcon type={achievement.type} isUnlocked={achievement.isUnlocked} />
50+
<h4 className="text-[11px] font-semibold text-gray-900 dark:text-white mb-1 text-center w-full leading-snug">
51+
{achievement.title}
52+
</h4>
53+
<p className="text-[10px] text-[#A1A1AA] line-clamp-2 w-full leading-relaxed">
54+
{achievement.description}
55+
</p>
56+
57+
{/* Progress indicator for locked achievements */}
58+
{!achievement.isUnlocked && achievement.progress !== undefined && (
59+
<div className="mt-2 w-full">
60+
<div className="w-full h-1 rounded-full bg-[rgba(255,255,255,0.08)] overflow-hidden">
61+
<div
62+
className="h-full rounded-full bg-[#A1A1AA]/40 transition-all duration-500"
63+
style={{ width: `${achievement.progress}%` }}
64+
/>
65+
</div>
66+
<p className="text-[10px] text-[#777] mt-1">
5467
{achievement.currentValue}/{achievement.threshold}
5568
</p>
56-
)}
57-
</motion.div>
58-
);
59-
})}
69+
</div>
70+
)}
71+
</motion.div>
72+
))}
6073
</div>
74+
6175
{achievements.length > 4 && (
6276
<button
6377
onClick={() => setShowAll(!showAll)}

lib/github.test.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -751,16 +751,49 @@ describe('GitHub API cache behavior', () => {
751751

752752
describe('generateAchievements', () => {
753753
it('marks contribution milestones correctly', () => {
754-
const achievements = generateAchievements(600, 10);
754+
// 600 contributions satisfies the '500 Contributions' achievement but not '1000 Contributions'
755+
const achievements = generateAchievements(600, 10, 0, 0);
756+
755757
const unlocked = achievements.filter((a) => a.isUnlocked);
756758
expect(unlocked.some((a) => a.title === '500 Contributions')).toBe(true);
759+
expect(unlocked.some((a) => a.title === 'Consistency King')).toBe(true);
757760
expect(unlocked.some((a) => a.title === '1000 Contributions')).toBe(false);
758761
});
759762

760763
it('unlocks all achievements for max contribution and streak values', () => {
761-
const achievements = generateAchievements(1001, 101);
764+
// Consistency King III requires 2000, streak needs 100, weekend needs 10, polyglot needs 5
765+
const achievements = generateAchievements(2001, 101, 11, 6);
766+
762767
expect(achievements.every((achievement) => achievement.isUnlocked === true)).toBe(true);
763768
});
769+
770+
it('marks streak milestones correctly', () => {
771+
const achievements = generateAchievements(50, 35, 0, 0);
772+
773+
const unlocked = achievements.filter((a) => a.isUnlocked);
774+
775+
expect(unlocked.some((a) => a.title === '30 Day Streak')).toBe(true);
776+
expect(unlocked.some((a) => a.title === '100 Day Streak')).toBe(false);
777+
});
778+
779+
it('marks behavior milestones correctly', () => {
780+
const achievements = generateAchievements(10, 1, 15, 6);
781+
782+
const unlocked = achievements.filter((a) => a.isUnlocked);
783+
784+
expect(unlocked.some((a) => a.title === 'Weekend Warrior')).toBe(true);
785+
expect(unlocked.some((a) => a.title === 'Polyglot')).toBe(true);
786+
});
787+
788+
it('caps progress between 0 and 100 for extreme values', () => {
789+
const achievements = generateAchievements(999999, 999999, 999999, 999999);
790+
791+
for (const item of achievements) {
792+
expect(Number.isFinite(item.progress)).toBe(true);
793+
expect(item.progress).toBeGreaterThanOrEqual(0);
794+
expect(item.progress).toBeLessThanOrEqual(100);
795+
}
796+
});
764797
});
765798

766799
describe('validateGitHubUsername', () => {

0 commit comments

Comments
 (0)