Skip to content

Commit 2532b26

Browse files
Merge pull request #348 from DevLoversTeam/feat/dashboard-stats-achievements
(SP: 4) [Frontend] Dashboard: Activity Heatmap, Achievements & Profile Card
2 parents c8adbe1 + 9934bf3 commit 2532b26

27 files changed

Lines changed: 2544 additions & 291 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { AchievementsSection } from '@/components/dashboard/AchievementsSection';
2+
import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground';
3+
import { computeAchievements } from '@/lib/achievements';
4+
5+
export default function AchievementsDemoPage() {
6+
// Mix of earned and unearned for a realistic preview
7+
const achievements = computeAchievements({
8+
totalAttempts: 4,
9+
averageScore: 78,
10+
perfectScores: 1,
11+
highScores: 2,
12+
isSponsor: false,
13+
uniqueQuizzes: 4,
14+
totalPoints: 80,
15+
topLeaderboard: false,
16+
hasStarredRepo: true, // demo: show star_gazer as earned
17+
sponsorCount: 0,
18+
hasNightOwl: false,
19+
});
20+
21+
return (
22+
<DynamicGridBackground className="min-h-screen bg-gray-50 py-16 dark:bg-transparent">
23+
<main className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
24+
<div className="mb-10 text-center">
25+
<h1 className="text-4xl font-black tracking-tight text-gray-900 dark:text-white">
26+
🏅 Achievements Preview
27+
</h1>
28+
<p className="mt-2 text-gray-500 dark:text-gray-400">
29+
Flip the badges to see details. Locked badges show your progress.
30+
</p>
31+
</div>
32+
<AchievementsSection achievements={achievements} />
33+
</main>
34+
</DynamicGridBackground>
35+
);
36+
}

frontend/app/[locale]/dashboard/page.tsx

Lines changed: 152 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { getTranslations } from 'next-intl/server';
2+
import { Heart, MessageSquare } from 'lucide-react';
23

34
import { PostAuthQuizSync } from '@/components/auth/PostAuthQuizSync';
5+
import { AchievementsSection } from '@/components/dashboard/AchievementsSection';
6+
import { ActivityHeatmapCard } from '@/components/dashboard/ActivityHeatmapCard';
47
import { ExplainedTermsCard } from '@/components/dashboard/ExplainedTermsCard';
58
import { FeedbackForm } from '@/components/dashboard/FeedbackForm';
69
import { ProfileCard } from '@/components/dashboard/ProfileCard';
@@ -9,10 +12,12 @@ import { QuizSavedBanner } from '@/components/dashboard/QuizSavedBanner';
912
import { StatsCard } from '@/components/dashboard/StatsCard';
1013
import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground';
1114
import { getUserLastAttemptPerQuiz, getUserQuizStats } from '@/db/queries/quizzes/quiz';
12-
import { getUserProfile } from '@/db/queries/users';
15+
import { getUserProfile, getUserGlobalRank } from '@/db/queries/users';
1316
import { redirect } from '@/i18n/routing';
14-
import { getSponsors } from '@/lib/about/github-sponsors';
17+
import { getSponsors, getAllSponsors } from '@/lib/about/github-sponsors';
1518
import { getCurrentUser } from '@/lib/auth';
19+
import { computeAchievements } from '@/lib/achievements';
20+
import { checkHasStarredRepo, resolveGitHubLogin } from '@/lib/github-stars';
1621

1722
export async function generateMetadata({
1823
params,
@@ -48,23 +53,48 @@ export default async function DashboardPage({
4853

4954
const t = await getTranslations('dashboard');
5055

56+
// Active sponsors — used for the sponsor badge / button display in the UI
5157
const sponsors = await getSponsors();
58+
// All-time sponsors (active + past) — used for the Supporter achievement check
59+
const allSponsors = await getAllSponsors();
60+
5261
const userEmail = user.email.toLowerCase();
5362
const userName = (user.name ?? '').toLowerCase();
5463
const userImage = user.image ?? '';
55-
const matchedSponsor = sponsors.find(s => {
56-
if (s.email && s.email.toLowerCase() === userEmail) return true;
57-
if (userName && s.login && s.login.toLowerCase() === userName) return true;
58-
if (userName && s.name && s.name.toLowerCase() === userName) return true;
59-
if (
60-
userImage &&
61-
s.avatarUrl &&
62-
s.avatarUrl.trim().length > 0 &&
63-
userImage.includes(s.avatarUrl.split('?')[0])
64-
)
65-
return true;
66-
return false;
67-
});
64+
65+
function findSponsor(list: typeof sponsors) {
66+
return list.find(s => {
67+
if (s.email && s.email.toLowerCase() === userEmail) return true;
68+
if (userName && s.login && s.login.toLowerCase() === userName) return true;
69+
if (userName && s.name && s.name.toLowerCase() === userName) return true;
70+
if (
71+
userImage &&
72+
s.avatarUrl &&
73+
s.avatarUrl.trim().length > 0 &&
74+
userImage.includes(s.avatarUrl.split('?')[0])
75+
) return true;
76+
return false;
77+
});
78+
}
79+
80+
const matchedSponsor = findSponsor(sponsors); // active — for UI display
81+
const everSponsor = findSponsor(allSponsors); // all-time — for achievements
82+
83+
// Determine the GitHub login to check against the stargazers list.
84+
// Priority:
85+
// 1. Matched sponsor login (most reliable — org PAT already resolved it)
86+
// 2. For GitHub-OAuth users: resolve login from numeric providerId
87+
// 3. user.name as last resort (may be a display name, not a login!)
88+
let githubLogin = matchedSponsor?.login || '';
89+
if (!githubLogin && user.provider === 'github' && user.providerId) {
90+
githubLogin = (await resolveGitHubLogin(user.providerId)) ?? user.name ?? '';
91+
} else if (!githubLogin) {
92+
githubLogin = user.name ?? '';
93+
}
94+
95+
const hasStarredRepo = githubLogin
96+
? await checkHasStarredRepo(githubLogin)
97+
: false;
6898

6999
const attempts = await getUserQuizStats(session.id);
70100
const lastAttempts = await getUserLastAttemptPerQuiz(session.id, locale);
@@ -84,6 +114,58 @@ export default async function DashboardPage({
84114
? new Date(attempts[0].completedAt).toLocaleDateString(locale)
85115
: null;
86116

117+
const globalRank = await getUserGlobalRank(session.id);
118+
119+
// 1. Calculate Daily Streak (using calendar-day strings to avoid DST issues)
120+
const toDateStr = (d: Date) =>
121+
`${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
122+
123+
const uniqueAttemptDays = Array.from(
124+
new Set(attempts.map(a => toDateStr(new Date(a.completedAt))))
125+
);
126+
127+
const getPrevDay = (d: Date): Date => {
128+
const prev = new Date(d);
129+
prev.setDate(prev.getDate() - 1);
130+
return prev;
131+
};
132+
133+
const now = new Date();
134+
const todayStr = toDateStr(now);
135+
const yesterdayStr = toDateStr(getPrevDay(now));
136+
137+
let currentStreak = 0;
138+
if (uniqueAttemptDays.includes(todayStr) || uniqueAttemptDays.includes(yesterdayStr)) {
139+
let checkDate = uniqueAttemptDays.includes(todayStr) ? now : getPrevDay(now);
140+
currentStreak = 1;
141+
while (true) {
142+
checkDate = getPrevDay(checkDate);
143+
if (uniqueAttemptDays.includes(toDateStr(checkDate))) {
144+
currentStreak++;
145+
} else {
146+
break;
147+
}
148+
}
149+
}
150+
151+
// 2. Calculate Trend Percentage (Last 3 vs Previous 3)
152+
let trendPercentage: number | null = null;
153+
if (attempts.length >= 6) {
154+
const last3 = attempts.slice(0, 3);
155+
const prev3 = attempts.slice(3, 6);
156+
157+
const last3Avg = last3.reduce((acc, curr) => acc + Number(curr.percentage), 0) / 3;
158+
const prev3Avg = prev3.reduce((acc, curr) => acc + Number(curr.percentage), 0) / 3;
159+
160+
trendPercentage = Math.round(last3Avg - prev3Avg);
161+
} else if (attempts.length > 2) {
162+
const lastPart = attempts.slice(0, Math.floor(attempts.length / 2));
163+
const prevPart = attempts.slice(Math.floor(attempts.length / 2), Math.floor(attempts.length / 2) * 2);
164+
const lastAvg = lastPart.reduce((acc, curr) => acc + Number(curr.percentage), 0) / lastPart.length;
165+
const prevAvg = prevPart.reduce((acc, curr) => acc + Number(curr.percentage), 0) / prevPart.length;
166+
trendPercentage = Math.round(lastAvg - prevAvg);
167+
}
168+
87169
const userForDisplay = {
88170
id: user.id,
89171
name: user.name ?? null,
@@ -98,8 +180,35 @@ export default async function DashboardPage({
98180
totalAttempts,
99181
averageScore,
100182
lastActiveDate,
183+
totalScore: user.points,
184+
trendPercentage,
101185
};
102186

187+
const perfectScores = attempts.filter((a) => Number(a.percentage) === 100).length;
188+
const highScores = attempts.filter((a) => Number(a.percentage) >= 90).length;
189+
const uniqueQuizzes = lastAttempts.length;
190+
191+
// Night Owl: any attempt completed between 00:00 and 05:00 local time
192+
const hasNightOwl = attempts.some((a) => {
193+
if (!a.completedAt) return false;
194+
const hour = new Date(a.completedAt).getHours();
195+
return hour >= 0 && hour < 5;
196+
});
197+
198+
const achievements = computeAchievements({
199+
totalAttempts,
200+
averageScore,
201+
perfectScores,
202+
highScores,
203+
isSponsor: !!everSponsor,
204+
uniqueQuizzes,
205+
totalPoints: user.points,
206+
topLeaderboard: false,
207+
hasStarredRepo,
208+
sponsorCount: matchedSponsor ? 1 : 0, // TODO: wire to actual sponsorship history count
209+
hasNightOwl,
210+
});
211+
103212
const outlineBtnStyles =
104213
'inline-flex items-center justify-center rounded-full border border-gray-200 dark:border-white/10 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm px-6 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 transition-colors hover:bg-white hover:text-(--accent-primary) dark:hover:bg-neutral-800 dark:hover:text-(--accent-primary)';
105214

@@ -120,21 +229,41 @@ export default async function DashboardPage({
120229
</p>
121230
</div>
122231

123-
<a
124-
href="#feedback"
125-
className={outlineBtnStyles}
126-
>
127-
{t('supportLink')}
128-
</a>
232+
<div className="flex flex-wrap items-center gap-4">
233+
<a
234+
href="#feedback"
235+
className={`group flex items-center gap-2 ${outlineBtnStyles}`}
236+
>
237+
<MessageSquare className="h-4 w-4 transition-transform group-hover:-translate-y-0.5" />
238+
{t('supportLink')}
239+
</a>
240+
<a
241+
href="https://github.com/sponsors/DevLoversTeam"
242+
target="_blank"
243+
rel="noopener noreferrer"
244+
className="group inline-flex items-center justify-center gap-2 rounded-full border border-(--accent-primary) bg-(--accent-primary)/10 px-6 py-2 text-sm font-medium text-(--accent-primary) transition-colors hover:bg-(--accent-primary) hover:text-white dark:border-(--accent-primary)/50 dark:bg-(--accent-primary)/10 dark:text-(--accent-primary) dark:hover:bg-(--accent-primary) dark:hover:text-white"
245+
>
246+
<Heart className="h-4 w-4 transition-transform group-hover:scale-110" />
247+
{!!matchedSponsor ? t('profile.supportAgain') : t('profile.becomeSponsor')}
248+
</a>
249+
</div>
129250
</header>
130251
<QuizSavedBanner />
131-
<div className="grid gap-8 md:grid-cols-2">
252+
<div className="flex flex-col gap-8">
132253
<ProfileCard
133254
user={userForDisplay}
134255
locale={locale}
135256
isSponsor={!!matchedSponsor}
257+
totalAttempts={totalAttempts}
258+
globalRank={globalRank}
136259
/>
137-
<StatsCard stats={stats} />
260+
<div className="grid gap-8 lg:grid-cols-2">
261+
<StatsCard stats={stats} attempts={attempts} />
262+
<ActivityHeatmapCard attempts={attempts} locale={locale} currentStreak={currentStreak} />
263+
</div>
264+
</div>
265+
<div className="mt-8">
266+
<AchievementsSection achievements={achievements} />
138267
</div>
139268
<div className="mt-8">
140269
<QuizResultsSection attempts={lastAttempts} locale={locale} />

frontend/components/about/HeroSection.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export function HeroSection({ stats }: { stats?: PlatformStats }) {
1616
questionsSolved: '850+',
1717
githubStars: '120+',
1818
activeUsers: '200+',
19-
linkedinFollowers: '1.5k+',
19+
linkedinFollowers: '1.6k+',
2020
};
2121

2222
return (

0 commit comments

Comments
 (0)