11import { getTranslations } from 'next-intl/server' ;
2+ import { Heart , MessageSquare } from 'lucide-react' ;
23
34import { PostAuthQuizSync } from '@/components/auth/PostAuthQuizSync' ;
5+ import { AchievementsSection } from '@/components/dashboard/AchievementsSection' ;
6+ import { ActivityHeatmapCard } from '@/components/dashboard/ActivityHeatmapCard' ;
47import { ExplainedTermsCard } from '@/components/dashboard/ExplainedTermsCard' ;
58import { FeedbackForm } from '@/components/dashboard/FeedbackForm' ;
69import { ProfileCard } from '@/components/dashboard/ProfileCard' ;
@@ -9,10 +12,12 @@ import { QuizSavedBanner } from '@/components/dashboard/QuizSavedBanner';
912import { StatsCard } from '@/components/dashboard/StatsCard' ;
1013import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground' ;
1114import { getUserLastAttemptPerQuiz , getUserQuizStats } from '@/db/queries/quizzes/quiz' ;
12- import { getUserProfile } from '@/db/queries/users' ;
15+ import { getUserProfile , getUserGlobalRank } from '@/db/queries/users' ;
1316import { redirect } from '@/i18n/routing' ;
14- import { getSponsors } from '@/lib/about/github-sponsors' ;
17+ import { getSponsors , getAllSponsors } from '@/lib/about/github-sponsors' ;
1518import { getCurrentUser } from '@/lib/auth' ;
19+ import { computeAchievements } from '@/lib/achievements' ;
20+ import { checkHasStarredRepo , resolveGitHubLogin } from '@/lib/github-stars' ;
1621
1722export 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 } />
0 commit comments