From 82d0eb5596386d19d9d5fa177d064756c174f564 Mon Sep 17 00:00:00 2001 From: Yevhenii Datsenko Date: Sat, 21 Feb 2026 15:46:42 +0200 Subject: [PATCH 1/3] (SP: 5) [Frontend] Dashboard: Activity Heatmap, Achievements & Profile Card - Implemented Activity Heatmap card with circuit-board SVG visualization, streak detection and month labels - Built Achievement Badge system with 18 badges, hexagonal 3D flip card design and earned/locked states - Redesigned Profile Card layout with responsive stats, streak pill and sponsor badge - Added GitHub star_gazer achievement check via OAuth providerId resolution - Score Distribution and Achievements responsive improvements for tablet/mobile --- .../app/[locale]/achievements-demo/page.tsx | 36 ++ frontend/app/[locale]/dashboard/page.tsx | 172 +++++- frontend/components/about/HeroSection.tsx | 2 +- .../components/dashboard/AchievementBadge.tsx | 487 ++++++++++++++++ .../dashboard/AchievementsSection.tsx | 95 ++++ .../dashboard/ActivityHeatmapCard.tsx | 523 ++++++++++++++++++ .../dashboard/ExplainedTermsCard.tsx | 18 +- .../components/dashboard/FeedbackForm.tsx | 68 ++- frontend/components/dashboard/ProfileCard.tsx | 299 +++++++--- .../components/dashboard/QuizResultRow.tsx | 42 +- .../dashboard/QuizResultsSection.tsx | 28 +- frontend/components/dashboard/StatsCard.tsx | 221 ++++++-- .../leaderboard/LeaderboardClient.tsx | 2 +- .../leaderboard/LeaderboardPodium.tsx | 26 +- .../leaderboard/LeaderboardTable.tsx | 36 +- frontend/components/quiz/QuizCard.tsx | 12 +- frontend/components/quiz/QuizResult.tsx | 31 +- frontend/db/queries/users.ts | 30 + frontend/lib/about/github-sponsors.ts | 21 +- frontend/lib/about/stats.ts | 2 +- frontend/lib/achievements.ts | 132 +++++ frontend/lib/github-stars.ts | 86 +++ frontend/messages/en.json | 216 +++++++- frontend/messages/pl.json | 174 +++++- frontend/messages/uk.json | 211 ++++++- frontend/package-lock.json | 13 + frontend/package.json | 1 + 27 files changed, 2664 insertions(+), 320 deletions(-) create mode 100644 frontend/app/[locale]/achievements-demo/page.tsx create mode 100644 frontend/components/dashboard/AchievementBadge.tsx create mode 100644 frontend/components/dashboard/AchievementsSection.tsx create mode 100644 frontend/components/dashboard/ActivityHeatmapCard.tsx create mode 100644 frontend/lib/achievements.ts create mode 100644 frontend/lib/github-stars.ts diff --git a/frontend/app/[locale]/achievements-demo/page.tsx b/frontend/app/[locale]/achievements-demo/page.tsx new file mode 100644 index 00000000..2d36f523 --- /dev/null +++ b/frontend/app/[locale]/achievements-demo/page.tsx @@ -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 ( + +
+
+

+ 🏅 Achievements Preview +

+

+ Flip the badges to see details. Locked badges show your progress. +

+
+ +
+
+ ); +} diff --git a/frontend/app/[locale]/dashboard/page.tsx b/frontend/app/[locale]/dashboard/page.tsx index 89b61336..618b1493 100644 --- a/frontend/app/[locale]/dashboard/page.tsx +++ b/frontend/app/[locale]/dashboard/page.tsx @@ -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/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,52 @@ 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; + 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 ?? ''; + } + + console.log('[star_gazer] provider:', user.provider, '| providerId:', user.providerId, '| resolved login:', githubLogin); + + const hasStarredRepo = githubLogin + ? await checkHasStarredRepo(githubLogin) + : false; + + console.log('[star_gazer] hasStarredRepo:', hasStarredRepo); const attempts = await getUserQuizStats(session.id); const lastAttempts = await getUserLastAttemptPerQuiz(session.id, locale); @@ -84,6 +118,51 @@ export default async function DashboardPage({ ? new Date(attempts[0].completedAt).toLocaleDateString(locale) : null; + const globalRank = await getUserGlobalRank(session.id); + + // 1. Calculate Daily Streak + const uniqueAttemptDates = Array.from( + new Set(attempts.map(a => new Date(a.completedAt).setHours(0, 0, 0, 0))) + ).sort((a, b) => b - a); + + let currentStreak = 0; + const today = new Date().setHours(0, 0, 0, 0); + const yesterday = new Date(today - 86400000).setHours(0, 0, 0, 0); + + if (uniqueAttemptDates.length > 0) { + const lastActive = uniqueAttemptDates[0]; + if (lastActive === today || lastActive === yesterday) { + currentStreak = 1; + let checkDate = lastActive; + for (let i = 1; i < uniqueAttemptDates.length; i++) { + checkDate -= 86400000; + if (uniqueAttemptDates[i] === 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 +177,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; + }); + + 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, + }); + 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 +226,41 @@ export default async function DashboardPage({

- - {t('supportLink')} - +
+ + + {t('supportLink')} + + + + {!!matchedSponsor ? t('profile.supportAgain') : t('profile.becomeSponsor')} + +
-
+
- +
+ + +
+
+
+
diff --git a/frontend/components/about/HeroSection.tsx b/frontend/components/about/HeroSection.tsx index 29f3f263..283e58a1 100644 --- a/frontend/components/about/HeroSection.tsx +++ b/frontend/components/about/HeroSection.tsx @@ -16,7 +16,7 @@ export function HeroSection({ stats }: { stats?: PlatformStats }) { questionsSolved: '850+', githubStars: '120+', activeUsers: '200+', - linkedinFollowers: '1.5k+', + linkedinFollowers: '1.6k+', }; return ( diff --git a/frontend/components/dashboard/AchievementBadge.tsx b/frontend/components/dashboard/AchievementBadge.tsx new file mode 100644 index 00000000..d901ac5c --- /dev/null +++ b/frontend/components/dashboard/AchievementBadge.tsx @@ -0,0 +1,487 @@ +'use client'; + +import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from 'framer-motion'; +import { + Fire, + Target, + Lightning, + Brain, + Diamond, + Star, + Heart, + Trophy, + Rocket, + Crown, + Code, + Infinity as InfinityIcon, + GithubLogo, + Medal, + Seal, + Moon, + Shield, + Waves, +} from '@phosphor-icons/react'; +import { useTranslations } from 'next-intl'; +import { useState, useEffect } from 'react'; + +import type { EarnedAchievement, AchievementIconName } from '@/lib/achievements'; + +const ICON_MAP: Record = { + Fire, + Target, + Lightning, + Brain, + Diamond, + Star, + Heart, + Trophy, + Rocket, + Crown, + Code, + Infinity: InfinityIcon, + GithubLogo, + Medal, + Seal, + Moon, + Shield, + Waves, +}; + +interface AchievementBadgeProps { + achievement: EarnedAchievement; +} + +function hexPoints(cx: number, cy: number, r: number): string { + return Array.from({ length: 6 }, (_, i) => { + const angle = (Math.PI / 180) * (60 * i - 90); + return `${cx + r * Math.cos(angle)},${cy + r * Math.sin(angle)}`; + }).join(' '); +} + +function hexClipPath(r: number): string { + const cx = 80; + const cy = 80; + const pts = Array.from({ length: 6 }, (_, i) => { + const angle = (Math.PI / 180) * (60 * i - 90); + const x = ((cx + r * Math.cos(angle)) / 160) * 100; + const y = ((cy + r * Math.sin(angle)) / 160) * 100; + return `${x.toFixed(2)}% ${y.toFixed(2)}%`; + }); + return `polygon(${pts.join(', ')})`; +} + +const OUTER_R = 76; +const INNER_R = 68; +const CX = 80; +const CY = 80; + +const outerPts = hexPoints(CX, CY, OUTER_R); +const innerPts = hexPoints(CX, CY, INNER_R); +const clipPath = hexClipPath(INNER_R - 1); + +export function AchievementBadge({ achievement }: AchievementBadgeProps) { + const t = useTranslations('dashboard.achievements'); + const [isFlipped, setIsFlipped] = useState(false); + const shouldReduceMotion = useReducedMotion(); + + const [isDark, setIsDark] = useState(false); + useEffect(() => { + const root = document.documentElement; + setIsDark(root.classList.contains('dark')); + const observer = new MutationObserver(() => { + setIsDark(root.classList.contains('dark')); + }); + observer.observe(root, { attributes: true, attributeFilter: ['class'] }); + return () => observer.disconnect(); + }, []); + + const x = useMotionValue(0); + const y = useMotionValue(0); + + const rotateX = useTransform(y, [-60, 60], [14, -14]); + const tiltY = useTransform(x, [-60, 60], [-14, 14]); + const flipRotation = isFlipped ? 180 : 0; + const rotateY = useTransform(tiltY, (v) => v + flipRotation); + + const springConfig = { damping: 22, stiffness: 280 }; + const rotateXSpring = useSpring(rotateX, springConfig); + const rotateYSpring = useSpring(rotateY, springConfig); + + const handleMouseMove = (e: React.MouseEvent) => { + if (shouldReduceMotion || isFlipped) return; + const rect = e.currentTarget.getBoundingClientRect(); + x.set((e.clientX - rect.left - rect.width / 2) * 1.4); + y.set((e.clientY - rect.top - rect.height / 2) * 1.4); + }; + + const handleMouseLeave = () => { + x.set(0); + y.set(0); + }; + + const [from, to] = achievement.gradient; + const badgeLabel = t(`badges.${achievement.id}.name`); + const badgeDesc = t(`badges.${achievement.id}.desc`); + const badgeHint = t(`badges.${achievement.id}.hint`); + const progress = achievement.progress ?? 0; + + const Icon = ICON_MAP[achievement.icon]; + + const hexPerimeter = 6 * INNER_R; + + const locked = { + bezel: isDark + ? [{ o: '0%', c: '#334155' }, { o: '30%', c: '#475569' }, { o: '60%', c: '#1e293b' }, { o: '100%', c: '#334155' }] + : [{ o: '0%', c: '#c8d0da' }, { o: '25%', c: '#e8edf3' }, { o: '55%', c: '#a8b0bc' }, { o: '80%', c: '#dce2ea' }, { o: '100%', c: '#b8c0cc' }], + bodyFrom: isDark ? '#1e293b' : '#eef0f4', + bodyTo: isDark ? '#0f172a' : '#dde0e8', + iconColor: isDark ? 'rgba(148,163,184,0.55)' : 'rgba(100,116,139,0.65)', + iconFilter: isDark + ? 'drop-shadow(0 1px 3px rgba(0,0,0,0.6))' + : 'drop-shadow(0 1px 2px rgba(0,0,0,0.12))', + bevelStroke: isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)', + progressTrack: isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)', + backBg: isDark + ? 'linear-gradient(150deg, #334155, #1e293b)' + : 'linear-gradient(150deg, #4b5563, #1f2937)', + backShadow: isDark + ? 'inset 0 0 20px rgba(0,0,0,0.5)' + : 'inset 0 0 20px rgba(0,0,0,0.3)', + }; + + return ( +
+ + setIsFlipped((p) => !p)} + role="button" + tabIndex={0} + aria-label={`${badgeLabel}. ${isFlipped ? t('ui.clickBack') : t('ui.clickInfo')}`} + onKeyDown={(e) => { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + setIsFlipped((p) => !p); + } + }} + > + +
+ + +
+ +
+
+ +
+