Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions frontend/app/[locale]/achievements-demo/page.tsx
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>
);
Comment on lines +5 to +35
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Demo page with hardcoded English in a localized [locale] route.

Two concerns:

  1. 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.

  2. Production accessibility: This demo page will be accessible at /{locale}/achievements-demo in 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
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`[locale]/achievements-demo/page.tsx around lines 5 - 35, The
AchievementsDemoPage currently embeds hardcoded English strings in the localized
route (e.g., the h1 "Achievements Preview" and the paragraph "Flip the badges…")
and is exposed in production; update AchievementsDemoPage to use your i18n
translation hook/utility (replace literal text passed into
DynamicGridBackground/AchievementsSection with translation keys like
t('achievements.preview.title') and t('achievements.preview.subtitle')) and
either gate this page behind an environment check (use process.env.NODE_ENV or a
feature flag to return 404 or redirect when in production) or move the component
out of the [locale] route tree so it’s not served to end users; locate and
change the strings inside AchievementsDemoPage (and any direct JSX literals) and
add the gating logic at the top of the AchievementsDemoPage function.

}
175 changes: 152 additions & 23 deletions frontend/app/[locale]/dashboard/page.tsx
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';
Expand All @@ -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,
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

String.prototype.includes on avatar URLs can create false-positive sponsor matches.

The check userImage.includes(s.avatarUrl.split('?')[0]) strips the query string only from the sponsor side, then does a substring search. For GitHub avatar URLs of the form .../u/<ID>, a user whose numeric ID begins with a sponsor's shorter ID (e.g., user 123456 vs. sponsor 12345) will satisfy the includes check and be incorrectly granted sponsor status.

🔒 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (
userImage &&
s.avatarUrl &&
s.avatarUrl.trim().length > 0 &&
userImage.includes(s.avatarUrl.split('?')[0])
) return true;
if (
userImage &&
s.avatarUrl &&
s.avatarUrl.trim().length > 0 &&
userImage.split('?')[0] === s.avatarUrl.split('?')[0]
) return true;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`[locale]/dashboard/page.tsx around lines 70 - 75, The current
check uses userImage.includes(s.avatarUrl.split('?')[0]) which allows substring
matches and causes false-positive sponsor matches (e.g., user ID 123456 matching
sponsor 12345); change the logic to compare normalized base URLs exactly instead
of using includes — normalize both sides by removing query strings (or parse
with new URL and compare origin + pathname) and then use strict equality between
the resulting baseUserImage and baseSponsor (refer to the userImage variable and
s.avatarUrl in the conditional) so only exact avatar URL matches grant sponsor
status.

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);
Expand All @@ -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,
Expand All @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Night owl uses server-process local time, not the user's timezone.

new Date(a.completedAt).getHours() resolves to the server's local timezone (typically UTC in production). A user in UTC+7 who completes a quiz at 02:00 AM local time produces completedAt = 19:00 UTC; getHours() returns 19, so the achievement is never awarded. The inverse problem also applies for users west of UTC.

Two mitigations in increasing accuracy:

  1. Store UTC hours consistently — use getUTCHours() to at least be deterministic and document that "night owl" is defined in UTC.
  2. Persist user timezone — store a timezone field on the user profile and use Intl.DateTimeFormat / toLocaleString('en-US', { timeZone }) to obtain the local hour server-side.
🌙 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
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`[locale]/dashboard/page.tsx around lines 192 - 196, The
night-owl calculation uses server-local time via new
Date(a.completedAt).getHours(), causing incorrect results across timezones;
update the hasNightOwl logic that iterates over attempts (variable hasNightOwl,
array attempts, field completedAt) to use a deterministic timezone: either call
getUTCHours() (UTC-consistent stopgap) or, if a user timezone is available on
the profile (e.g., user.timezone), compute the hour with
Intl.DateTimeFormat('en-US', { timeZone: user.timezone, hour: 'numeric', hour12:
false }) or toLocaleString with the timeZone option and parse that hour, then
check 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,
});
Comment on lines +198 to +210
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

topLeaderboard: false is hardcoded — the royalty achievement can never be earned.

globalRank is fetched at line 117 and forwarded to ProfileCard, but it is not used to derive topLeaderboard in computeAchievements. As a result the royalty badge is permanently locked for all users with no TODO tracking the gap.

🏆 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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,
});
// 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: globalRank !== null && globalRank <= TOP_LEADERBOARD_THRESHOLD,
hasStarredRepo,
sponsorCount: matchedSponsor ? 1 : 0, // TODO: wire to actual sponsorship history count
hasNightOwl,
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`[locale]/dashboard/page.tsx around lines 198 - 210, The call to
computeAchievements currently hardcodes topLeaderboard: false so the "royalty"
badge never unlocks; change that to derive from the previously-fetched
globalRank by passing topLeaderboard: (typeof globalRank === 'number' &&
globalRank <= TOP_LEADERBOARD_RANK) and define or import a clear constant
TOP_LEADERBOARD_RANK (or replace with the numeric threshold used by
computeAchievements) — update the computeAchievements invocation in page.tsx and
ensure ProfileCard/globalRank usage stays consistent.


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)';

Expand All @@ -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} />
Expand Down
2 changes: 1 addition & 1 deletion frontend/components/about/HeroSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function HeroSection({ stats }: { stats?: PlatformStats }) {
questionsSolved: '850+',
githubStars: '120+',
activeUsers: '200+',
linkedinFollowers: '1.5k+',
linkedinFollowers: '1.6k+',
};

return (
Expand Down
Loading