Skip to content

Commit cdbee69

Browse files
Merge pull request #357 from DevLoversTeam/feat/dashboard-stats-achievements
(SP: 2) [Frontend] Dashboard: Score Distribution Fixes, 6 New Achievements, Updated Header
2 parents cc6f39c + 08509ea commit cdbee69

32 files changed

Lines changed: 4841 additions & 862 deletions

frontend/actions/notifications.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
'use server';
2+
3+
import { desc, eq, and } from 'drizzle-orm';
4+
import { revalidatePath } from 'next/cache';
5+
6+
import { db } from '@/db';
7+
import { notifications } from '@/db/schema/notifications';
8+
9+
import { getCurrentUser } from '@/lib/auth';
10+
11+
export async function getNotifications() {
12+
const session = await getCurrentUser();
13+
if (!session) return [];
14+
15+
try {
16+
const data = await db.query.notifications.findMany({
17+
where: eq(notifications.userId, session.id),
18+
orderBy: [desc(notifications.createdAt)],
19+
limit: 20,
20+
});
21+
return data;
22+
} catch (error) {
23+
console.error('Failed to fetch notifications:', error);
24+
return [];
25+
}
26+
}
27+
28+
export async function markAsRead(notificationId: string) {
29+
const session = await getCurrentUser();
30+
if (!session || !notificationId) return { success: false };
31+
32+
try {
33+
await db
34+
.update(notifications)
35+
.set({ isRead: true })
36+
.where(
37+
and(
38+
eq(notifications.id, notificationId),
39+
eq(notifications.userId, session.id)
40+
)
41+
);
42+
43+
revalidatePath('/', 'layout');
44+
return { success: true };
45+
} catch (error) {
46+
console.error('Failed to mark notification as read:', error);
47+
return { success: false };
48+
}
49+
}
50+
51+
export async function markAllAsRead() {
52+
const session = await getCurrentUser();
53+
if (!session) return { success: false };
54+
55+
try {
56+
await db
57+
.update(notifications)
58+
.set({ isRead: true })
59+
.where(and(eq(notifications.userId, session.id), eq(notifications.isRead, false)));
60+
61+
revalidatePath('/', 'layout');
62+
return { success: true };
63+
} catch (error) {
64+
console.error('Failed to mark all notifications as read:', error);
65+
return { success: false };
66+
}
67+
}
68+
69+
export async function createNotification(data: {
70+
userId: string;
71+
type: string;
72+
title: string;
73+
message: string;
74+
metadata?: any;
75+
}) {
76+
if (!data.userId) return null;
77+
78+
try {
79+
const [result] = await db
80+
.insert(notifications)
81+
.values({
82+
userId: data.userId,
83+
type: data.type,
84+
title: data.title,
85+
message: data.message,
86+
metadata: data.metadata || null,
87+
})
88+
.returning();
89+
90+
revalidatePath('/', 'layout');
91+
return result;
92+
} catch (error) {
93+
console.error('Failed to create notification:', error);
94+
return null;
95+
}
96+
}

frontend/actions/quiz.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import {
1111
quizQuestions,
1212
} from '@/db/schema/quiz';
1313
import { getCurrentUser } from '@/lib/auth';
14+
import { ACHIEVEMENTS, computeAchievements } from '@/lib/achievements';
15+
import { getUserStatsForAchievements } from '@/lib/user-stats';
16+
import { createNotification } from './notifications';
1417

1518
export interface UserAnswer {
1619
questionId: string;
@@ -87,6 +90,16 @@ export async function submitQuizAttempt(
8790
return { success: false, error: 'User mismatch' };
8891
}
8992

93+
// Capture user achievements state BEFORE saving this attempt
94+
const statsBefore = await getUserStatsForAchievements(session.id);
95+
const earnedBefore = new Set(
96+
statsBefore
97+
? computeAchievements(statsBefore)
98+
.filter(a => a.earned)
99+
.map(a => a.id)
100+
: []
101+
);
102+
90103
if (!quizId || !Array.isArray(answers) || answers.length === 0) {
91104
return {
92105
success: false,
@@ -212,6 +225,25 @@ export async function submitQuizAttempt(
212225
integrityScore,
213226
});
214227

228+
// Capture user achievements state AFTER saving this attempt
229+
const statsAfter = await getUserStatsForAchievements(session.id);
230+
if (statsAfter) {
231+
const earnedAfter = computeAchievements(statsAfter).filter(a => a.earned);
232+
const newlyEarned = earnedAfter.filter(a => !earnedBefore.has(a.id));
233+
234+
// Trigger notifications for any newly earned achievements
235+
for (const achievement of newlyEarned) {
236+
// Find full object to get the fancy translated string (if needed) or just generic name
237+
await createNotification({
238+
userId: session.id,
239+
type: 'ACHIEVEMENT',
240+
title: 'Achievement Unlocked!',
241+
message: `You just earned the ${achievement.id} badge!`,
242+
metadata: { badgeId: achievement.id, icon: achievement.icon },
243+
});
244+
}
245+
}
246+
215247
return {
216248
success: true,
217249
attemptId: attempt.id,

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

Lines changed: 25 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,9 @@ import {
1717
} from '@/db/queries/quizzes/quiz';
1818
import { getUserGlobalRank, getUserProfile } from '@/db/queries/users';
1919
import { redirect } from '@/i18n/routing';
20-
import { getAllSponsors, getSponsors } from '@/lib/about/github-sponsors';
21-
import { computeAchievements } from '@/lib/achievements';
2220
import { getCurrentUser } from '@/lib/auth';
23-
import { checkHasStarredRepo, resolveGitHubLogin } from '@/lib/github-stars';
21+
import { computeAchievements } from '@/lib/achievements';
22+
import { getUserStatsForAchievements } from '@/lib/user-stats';
2423

2524
export async function generateMetadata({
2625
params,
@@ -56,62 +55,16 @@ export default async function DashboardPage({
5655

5756
const t = await getTranslations('dashboard');
5857

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

10861
const totalAttempts = attempts.length;
10962

11063
const averageScore =
111-
totalAttempts > 0
64+
lastAttempts.length > 0
11265
? Math.round(
113-
attempts.reduce((acc, curr) => acc + Number(curr.percentage), 0) /
114-
totalAttempts
66+
lastAttempts.reduce((acc, curr) => acc + Number(curr.percentage), 0) /
67+
lastAttempts.length
11568
)
11669
: 0;
11770

@@ -204,34 +157,16 @@ export default async function DashboardPage({
204157
trendPercentage,
205158
};
206159

207-
const perfectScores = attempts.filter(
208-
a => Number(a.percentage) === 100
209-
).length;
210-
const highScores = attempts.filter(a => Number(a.percentage) >= 90).length;
211-
const uniqueQuizzes = lastAttempts.length;
160+
const userStats = await getUserStatsForAchievements(session.id);
161+
const achievements = userStats ? computeAchievements(userStats) : [];
212162

213-
const hasNightOwl = attempts.some(a => {
214-
if (!a.completedAt) return false;
215-
const hour = new Date(a.completedAt).getHours();
216-
return hour >= 0 && hour < 5;
217-
});
218-
219-
const achievements = computeAchievements({
220-
totalAttempts,
221-
averageScore,
222-
perfectScores,
223-
highScores,
224-
isSponsor: !!everSponsor,
225-
uniqueQuizzes,
226-
totalPoints: user.points,
227-
topLeaderboard: false,
228-
hasStarredRepo,
229-
sponsorCount: matchedSponsor ? 1 : 0,
230-
hasNightOwl,
231-
});
163+
const isMatchedSponsor = userStats ? userStats.sponsorCount > 0 : false;
232164

233165
const outlineBtnStyles =
234-
'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)';
166+
'inline-flex items-center justify-center rounded-full border border-gray-200/50 bg-white/10 px-6 py-2.5 text-sm font-semibold tracking-wide text-gray-700 backdrop-blur-md transition-all hover:-translate-y-0.5 hover:bg-white/20 hover:shadow-md hover:border-gray-300 dark:border-white/10 dark:bg-neutral-900/40 dark:text-gray-200 dark:hover:bg-neutral-800/80 dark:hover:border-white/20';
167+
168+
const sponsorBtnStyles =
169+
'group relative inline-flex items-center justify-center gap-2 rounded-full border border-(--accent-primary)/30 bg-(--accent-primary)/10 px-6 py-2.5 text-sm font-semibold tracking-wide text-(--accent-primary) backdrop-blur-md transition-all hover:-translate-y-0.5 hover:bg-(--accent-primary)/20 hover:shadow-[0_4px_12px_rgba(var(--accent-primary-rgb),0.2)] hover:border-(--accent-primary)/50 dark:border-(--accent-primary)/20 dark:bg-(--accent-primary)/5 dark:hover:bg-(--accent-primary)/20 dark:hover:border-(--accent-primary)/40 dark:hover:shadow-[0_4px_15px_rgba(var(--accent-primary-rgb),0.3)] overflow-hidden';
235170

236171
return (
237172
<div className="min-h-screen">
@@ -253,19 +188,22 @@ export default async function DashboardPage({
253188
href="#feedback"
254189
className={`group flex items-center gap-2 ${outlineBtnStyles}`}
255190
>
256-
<MessageSquare className="h-4 w-4 transition-transform group-hover:-translate-y-0.5" />
191+
<MessageSquare className="h-4 w-4 transition-transform group-hover:-translate-y-0.5 text-gray-400 group-hover:text-gray-600 dark:group-hover:text-white" />
257192
{t('supportLink')}
258193
</a>
259194
<a
260195
href="https://github.com/sponsors/DevLoversTeam"
261196
target="_blank"
262197
rel="noopener noreferrer"
263-
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"
198+
className={sponsorBtnStyles}
264199
>
265-
<Heart className="h-4 w-4 transition-transform group-hover:scale-110" />
266-
{!!matchedSponsor
267-
? t('profile.supportAgain')
268-
: t('profile.becomeSponsor')}
200+
{/* Subtle gradient glow background effect */}
201+
<div className="absolute inset-0 z-0 bg-linear-to-r from-transparent via-(--accent-primary)/10 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
202+
203+
<span className="relative z-10 flex items-center gap-2">
204+
<Heart className="h-4 w-4 transition-transform group-hover:scale-110 group-hover:fill-(--accent-primary)/20" />
205+
{isMatchedSponsor ? t('profile.supportAgain') : t('profile.becomeSponsor')}
206+
</span>
269207
</a>
270208
</div>
271209
</header>
@@ -274,17 +212,13 @@ export default async function DashboardPage({
274212
<ProfileCard
275213
user={userForDisplay}
276214
locale={locale}
277-
isSponsor={!!matchedSponsor}
215+
isSponsor={isMatchedSponsor}
278216
totalAttempts={totalAttempts}
279217
globalRank={globalRank}
280218
/>
281-
<div id="stats" className="grid scroll-mt-8 gap-8 lg:grid-cols-2">
282-
<StatsCard stats={stats} attempts={attempts} />
283-
<ActivityHeatmapCard
284-
attempts={attempts}
285-
locale={locale}
286-
currentStreak={currentStreak}
287-
/>
219+
<div className="grid gap-8 lg:grid-cols-2">
220+
<StatsCard stats={stats} attempts={lastAttempts} />
221+
<ActivityHeatmapCard attempts={attempts} locale={locale} currentStreak={currentStreak} />
288222
</div>
289223
</div>
290224
<div className="mt-8">

frontend/components/auth/logoutButton.tsx

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -37,31 +37,12 @@ export function LogoutButton({ iconOnly = false }: LogoutButtonProps) {
3737
<button
3838
type="button"
3939
onClick={handleLogout}
40-
className="group bg-secondary text-secondary-foreground relative inline-flex w-fit items-center gap-2 overflow-hidden rounded-lg px-4 py-2 text-sm font-medium transition-all duration-500 hover:text-white active:text-white"
40+
className="group flex w-full items-center justify-between rounded-md px-3 py-2 text-sm transition-colors text-muted-foreground hover:bg-(--accent-primary)/10 hover:text-(--accent-primary) active:bg-(--accent-primary)/20"
4141
>
42-
<span
43-
className="absolute inset-0 opacity-0 transition-opacity duration-500 ease-out group-hover:opacity-100 group-active:opacity-100"
44-
style={{
45-
background:
46-
'linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-hover) 100%)',
47-
}}
48-
aria-hidden="true"
49-
/>
50-
51-
<span
52-
className="absolute inset-0 translate-x-[-100%] transition-transform duration-1000 ease-in-out group-hover:translate-x-[100%] group-active:translate-x-[100%]"
53-
style={{
54-
background:
55-
'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.4) 50%, transparent 100%)',
56-
transform: 'skewX(-20deg)',
57-
}}
58-
aria-hidden="true"
59-
/>
60-
61-
<span className="relative z-10 flex items-center gap-2">
62-
<LogOut className="h-4 w-4 transition-transform duration-300 group-hover:scale-110 group-active:scale-110" />
63-
{t('logout')}
64-
</span>
42+
<div className="flex items-center gap-2">
43+
<LogOut className="h-4 w-4" />
44+
<span>{t('logout')}</span>
45+
</div>
6546
</button>
6647
);
6748
}

frontend/components/dashboard/AchievementBadge.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ import {
1919
Target,
2020
Trophy,
2121
Waves,
22+
Meteor,
23+
Sparkle,
24+
GraduationCap,
25+
Atom,
26+
Sun,
27+
Anchor,
2228
} from '@phosphor-icons/react';
2329
import {
2430
motion,
@@ -54,6 +60,12 @@ const ICON_MAP: Record<AchievementIconName, React.ElementType> = {
5460
Moon,
5561
Shield,
5662
Waves,
63+
Meteor,
64+
Sparkle,
65+
GraduationCap,
66+
Atom,
67+
Sun,
68+
Anchor,
5769
};
5870

5971
interface AchievementBadgeProps {

0 commit comments

Comments
 (0)