-
-
Notifications
You must be signed in to change notification settings - Fork 3
(SP: 4) [Frontend] Dashboard: Activity Heatmap, Achievements & Profile Card #348
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
82d0eb5
8612059
2e940f1
9934bf3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| import { AchievementsSection } from '@/components/dashboard/AchievementsSection'; | ||
| import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground'; | ||
| import { computeAchievements } from '@/lib/achievements'; | ||
|
|
||
| export default function AchievementsDemoPage() { | ||
| // Mix of earned and unearned for a realistic preview | ||
| const achievements = computeAchievements({ | ||
| totalAttempts: 4, | ||
| averageScore: 78, | ||
| perfectScores: 1, | ||
| highScores: 2, | ||
| isSponsor: false, | ||
| uniqueQuizzes: 4, | ||
| totalPoints: 80, | ||
| topLeaderboard: false, | ||
| hasStarredRepo: true, // demo: show star_gazer as earned | ||
| sponsorCount: 0, | ||
| hasNightOwl: false, | ||
| }); | ||
|
|
||
| return ( | ||
| <DynamicGridBackground className="min-h-screen bg-gray-50 py-16 dark:bg-transparent"> | ||
| <main className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8"> | ||
| <div className="mb-10 text-center"> | ||
| <h1 className="text-4xl font-black tracking-tight text-gray-900 dark:text-white"> | ||
| 🏅 Achievements Preview | ||
| </h1> | ||
| <p className="mt-2 text-gray-500 dark:text-gray-400"> | ||
| Flip the badges to see details. Locked badges show your progress. | ||
| </p> | ||
| </div> | ||
| <AchievementsSection achievements={achievements} /> | ||
| </main> | ||
| </DynamicGridBackground> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,6 +1,9 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { getTranslations } from 'next-intl/server'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { Heart, MessageSquare } from 'lucide-react'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { PostAuthQuizSync } from '@/components/auth/PostAuthQuizSync'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { AchievementsSection } from '@/components/dashboard/AchievementsSection'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { ActivityHeatmapCard } from '@/components/dashboard/ActivityHeatmapCard'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { ExplainedTermsCard } from '@/components/dashboard/ExplainedTermsCard'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { FeedbackForm } from '@/components/dashboard/FeedbackForm'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { ProfileCard } from '@/components/dashboard/ProfileCard'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -9,10 +12,12 @@ import { QuizSavedBanner } from '@/components/dashboard/QuizSavedBanner'; | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { StatsCard } from '@/components/dashboard/StatsCard'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { getUserLastAttemptPerQuiz, getUserQuizStats } from '@/db/queries/quizzes/quiz'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { getUserProfile } from '@/db/queries/users'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { getUserProfile, getUserGlobalRank } from '@/db/queries/users'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { redirect } from '@/i18n/routing'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { getSponsors } from '@/lib/about/github-sponsors'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { getSponsors, getAllSponsors } from '@/lib/about/github-sponsors'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { getCurrentUser } from '@/lib/auth'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { computeAchievements } from '@/lib/achievements'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { checkHasStarredRepo, resolveGitHubLogin } from '@/lib/github-stars'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export async function generateMetadata({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| params, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -48,23 +53,48 @@ export default async function DashboardPage({ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const t = await getTranslations('dashboard'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Active sponsors — used for the sponsor badge / button display in the UI | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const sponsors = await getSponsors(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // All-time sponsors (active + past) — used for the Supporter achievement check | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const allSponsors = await getAllSponsors(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const userEmail = user.email.toLowerCase(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const userName = (user.name ?? '').toLowerCase(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const userImage = user.image ?? ''; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const matchedSponsor = sponsors.find(s => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (s.email && s.email.toLowerCase() === userEmail) return true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (userName && s.login && s.login.toLowerCase() === userName) return true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (userName && s.name && s.name.toLowerCase() === userName) return true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| userImage && | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| s.avatarUrl && | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| s.avatarUrl.trim().length > 0 && | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| userImage.includes(s.avatarUrl.split('?')[0]) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function findSponsor(list: typeof sponsors) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return list.find(s => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (s.email && s.email.toLowerCase() === userEmail) return true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (userName && s.login && s.login.toLowerCase() === userName) return true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (userName && s.name && s.name.toLowerCase() === userName) return true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| userImage && | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| s.avatarUrl && | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| s.avatarUrl.trim().length > 0 && | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| userImage.includes(s.avatarUrl.split('?')[0]) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) return true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+70
to
+75
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The check 🔒 Proposed fix — exact base-URL comparison if (
userImage &&
s.avatarUrl &&
s.avatarUrl.trim().length > 0 &&
- userImage.includes(s.avatarUrl.split('?')[0])
+ userImage.split('?')[0] === s.avatarUrl.split('?')[0]
) return true;📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const matchedSponsor = findSponsor(sponsors); // active — for UI display | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const everSponsor = findSponsor(allSponsors); // all-time — for achievements | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Determine the GitHub login to check against the stargazers list. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Priority: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 1. Matched sponsor login (most reliable — org PAT already resolved it) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 2. For GitHub-OAuth users: resolve login from numeric providerId | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 3. user.name as last resort (may be a display name, not a login!) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let githubLogin = matchedSponsor?.login || ''; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!githubLogin && user.provider === 'github' && user.providerId) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| githubLogin = (await resolveGitHubLogin(user.providerId)) ?? user.name ?? ''; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else if (!githubLogin) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| githubLogin = user.name ?? ''; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const hasStarredRepo = githubLogin | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ? await checkHasStarredRepo(githubLogin) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| : false; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const attempts = await getUserQuizStats(session.id); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const lastAttempts = await getUserLastAttemptPerQuiz(session.id, locale); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -84,6 +114,58 @@ export default async function DashboardPage({ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ? new Date(attempts[0].completedAt).toLocaleDateString(locale) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| : null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const globalRank = await getUserGlobalRank(session.id); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 1. Calculate Daily Streak (using calendar-day strings to avoid DST issues) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const toDateStr = (d: Date) => | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const uniqueAttemptDays = Array.from( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| new Set(attempts.map(a => toDateStr(new Date(a.completedAt)))) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const getPrevDay = (d: Date): Date => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const prev = new Date(d); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| prev.setDate(prev.getDate() - 1); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return prev; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const now = new Date(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const todayStr = toDateStr(now); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const yesterdayStr = toDateStr(getPrevDay(now)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let currentStreak = 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (uniqueAttemptDays.includes(todayStr) || uniqueAttemptDays.includes(yesterdayStr)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let checkDate = uniqueAttemptDays.includes(todayStr) ? now : getPrevDay(now); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| currentStreak = 1; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| while (true) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| checkDate = getPrevDay(checkDate); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (uniqueAttemptDays.includes(toDateStr(checkDate))) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| currentStreak++; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| break; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 2. Calculate Trend Percentage (Last 3 vs Previous 3) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let trendPercentage: number | null = null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (attempts.length >= 6) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const last3 = attempts.slice(0, 3); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const prev3 = attempts.slice(3, 6); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const last3Avg = last3.reduce((acc, curr) => acc + Number(curr.percentage), 0) / 3; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const prev3Avg = prev3.reduce((acc, curr) => acc + Number(curr.percentage), 0) / 3; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| trendPercentage = Math.round(last3Avg - prev3Avg); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else if (attempts.length > 2) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const lastPart = attempts.slice(0, Math.floor(attempts.length / 2)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const prevPart = attempts.slice(Math.floor(attempts.length / 2), Math.floor(attempts.length / 2) * 2); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const lastAvg = lastPart.reduce((acc, curr) => acc + Number(curr.percentage), 0) / lastPart.length; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const prevAvg = prevPart.reduce((acc, curr) => acc + Number(curr.percentage), 0) / prevPart.length; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| trendPercentage = Math.round(lastAvg - prevAvg); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const userForDisplay = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| id: user.id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name: user.name ?? null, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -98,8 +180,35 @@ export default async function DashboardPage({ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| totalAttempts, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| averageScore, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| lastActiveDate, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| totalScore: user.points, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| trendPercentage, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const perfectScores = attempts.filter((a) => Number(a.percentage) === 100).length; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const highScores = attempts.filter((a) => Number(a.percentage) >= 90).length; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const uniqueQuizzes = lastAttempts.length; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Night Owl: any attempt completed between 00:00 and 05:00 local time | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const hasNightOwl = attempts.some((a) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!a.completedAt) return false; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const hour = new Date(a.completedAt).getHours(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return hour >= 0 && hour < 5; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+192
to
+196
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Night owl uses server-process local time, not the user's timezone.
Two mitigations in increasing accuracy:
🌙 Stopgap: UTC-consistent check (until user timezone is available) const hasNightOwl = attempts.some((a) => {
if (!a.completedAt) return false;
- const hour = new Date(a.completedAt).getHours();
+ const hour = new Date(a.completedAt).getUTCHours();
return hour >= 0 && hour < 5;
});🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const achievements = computeAchievements({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| totalAttempts, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| averageScore, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| perfectScores, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| highScores, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isSponsor: !!everSponsor, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| uniqueQuizzes, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| totalPoints: user.points, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| topLeaderboard: false, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| hasStarredRepo, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sponsorCount: matchedSponsor ? 1 : 0, // TODO: wire to actual sponsorship history count | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| hasNightOwl, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+198
to
+210
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
🏆 Proposed fix — wire globalRank to topLeaderboard+ // Define your own leaderboard-top threshold (e.g., top 10)
+ const TOP_LEADERBOARD_THRESHOLD = 10;
const achievements = computeAchievements({
totalAttempts,
averageScore,
perfectScores,
highScores,
isSponsor: !!everSponsor,
uniqueQuizzes,
totalPoints: user.points,
- topLeaderboard: false,
+ topLeaderboard: globalRank !== null && globalRank <= TOP_LEADERBOARD_THRESHOLD,
hasStarredRepo,
sponsorCount: matchedSponsor ? 1 : 0,
hasNightOwl,
});📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const outlineBtnStyles = | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| '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)'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -120,21 +229,41 @@ export default async function DashboardPage({ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </p> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <a | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| href="#feedback" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| className={outlineBtnStyles} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {t('supportLink')} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </a> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className="flex flex-wrap items-center gap-4"> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <a | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| href="#feedback" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| className={`group flex items-center gap-2 ${outlineBtnStyles}`} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <MessageSquare className="h-4 w-4 transition-transform group-hover:-translate-y-0.5" /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {t('supportLink')} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </a> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <a | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| href="https://github.com/sponsors/DevLoversTeam" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| target="_blank" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rel="noopener noreferrer" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <Heart className="h-4 w-4 transition-transform group-hover:scale-110" /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {!!matchedSponsor ? t('profile.supportAgain') : t('profile.becomeSponsor')} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </a> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </header> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <QuizSavedBanner /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className="grid gap-8 md:grid-cols-2"> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className="flex flex-col gap-8"> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <ProfileCard | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| user={userForDisplay} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| locale={locale} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isSponsor={!!matchedSponsor} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| totalAttempts={totalAttempts} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| globalRank={globalRank} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <StatsCard stats={stats} /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className="grid gap-8 lg:grid-cols-2"> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <StatsCard stats={stats} attempts={attempts} /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <ActivityHeatmapCard attempts={attempts} locale={locale} currentStreak={currentStreak} /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className="mt-8"> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <AchievementsSection achievements={achievements} /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className="mt-8"> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <QuizResultsSection attempts={lastAttempts} locale={locale} /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Demo page with hardcoded English in a localized
[locale]route.Two concerns:
Hardcoded strings: Lines 25-30 use English text ("Achievements Preview", "Flip the badges…") instead of translation keys. Since this page lives under the
[locale]route segment, non-English users will see English-only content.Production accessibility: This demo page will be accessible at
/{locale}/achievements-demoin production. If it's only intended for development/preview purposes, consider gating it behind an environment check or moving it outside the main route tree.🤖 Prompt for AI Agents