Skip to content

(SP: 4) [Frontend] Dashboard: Activity Heatmap, Achievements & Profile Card#348

Merged
ViktorSvertoka merged 4 commits intodevelopfrom
feat/dashboard-stats-achievements
Feb 21, 2026
Merged

(SP: 4) [Frontend] Dashboard: Activity Heatmap, Achievements & Profile Card#348
ViktorSvertoka merged 4 commits intodevelopfrom
feat/dashboard-stats-achievements

Conversation

@yevheniidatsenko
Copy link
Collaborator

@yevheniidatsenko yevheniidatsenko commented Feb 21, 2026

Description

This PR delivers the core user engagement layer of the dashboard — a fully custom Activity Heatmap, a complete Achievement system with 18 badges, and a redesigned Profile Card with live stats.

The heatmap visualizes daily quiz activity using a custom circuit-board SVG graph with animated nodes, traces, and an orange streak highlight for consecutive active days. Achievements are displayed as interactive hexagonal badge cards that flip on click to reveal details and progress. The Profile Card was rebuilt from scratch with a responsive stats grid, sponsor recognition badge, and an expandable settings panel.

On top of the UI work, this PR also fixes a critical bug where the star_gazer achievement was never awarded — the system was comparing the user's display name against GitHub's stargazer list instead of their actual GitHub username. The fix resolves the correct login via the GitHub API using the OAuth providerId.


Changes

  • Added [ActivityHeatmapCard]: circuit-board SVG heatmap with month labels, orange streak glow, period selector and tooltip
  • Added AchievementBadge + [AchievementsSection]: 18 hexagonal 3D flip badges with earned/locked states, responsive 3→6 column grid
  • Added lib/achievements.ts (computeAchievements) and lib/github-stars.ts (resolveGitHubLogin, checkHasStarredRepo)
  • Fixed star_gazer achievement: resolves GitHub login from OAuth providerId instead of display name
  • Improved [StatsCard] and [AchievementsSection]

Database Changes (if applicable)

  • Schema migration required
  • Seed data updated
  • Breaking changes to existing queries
  • Transaction-safe migration
  • Migration tested locally on Neon

How Has This Been Tested?

  • Tested locally
  • Verified in development environment
  • Checked responsive layout (if UI-related)
  • Tested accessibility (keyboard / screen reader)

Checklist

Before submitting

  • Code has been self-reviewed
  • No TypeScript or console errors
  • Code follows project conventions
  • Scope is limited to this feature/fix
  • No unrelated refactors included
  • English used in code, commits, and docs
  • New dependencies discussed with team (@phosphor-icons/react)
  • Database migration tested locally (if applicable)
  • GitHub Projects card moved to In Review

Reviewers

Summary by CodeRabbit

  • New Features

    • Achievement system with interactive hexagonal badges, demo page, and computed progress
    • Activity heatmap visualization with period selection, tooltips and streak badge
    • File attachment support in the feedback form
    • Sponsor and GitHub-star indicators surfaced in the dashboard/achievements
  • Improvements

    • Richer dashboard: profile card with settings, expanded stats with score-distribution chart, and updated layout
    • Quiz results updated to show accuracy and integrity metrics
    • Various UI/styling refinements across leaderboard, quiz cards, and terms card
  • Localization

    • Extensive new and updated translations for achievements, dashboard, stats, profile, and feedback

…e 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
… paths, ProfileCard sponsor badge, LeaderboardTable dots
@vercel
Copy link
Contributor

vercel bot commented Feb 21, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
devlovers-net Ready Ready Preview, Comment Feb 21, 2026 2:27pm

@netlify
Copy link

netlify bot commented Feb 21, 2026

Deploy Preview for develop-devlovers ready!

Name Link
🔨 Latest commit 9934bf3
🔍 Latest deploy log https://app.netlify.com/projects/develop-devlovers/deploys/6999c0681b935800081d5f5b
😎 Deploy Preview https://deploy-preview-348--develop-devlovers.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 21, 2026

📝 Walkthrough

Walkthrough

Adds an achievements system, achievement UI (badges, section, demo), activity heatmap, GitHub sponsor/star checks, dashboard wiring (stats, global rank, total attempts), multiple dashboard component UI updates, i18n expansions, and a new runtime icon dependency.

Changes

Cohort / File(s) Summary
Achievements System
frontend/lib/achievements.ts, frontend/components/dashboard/AchievementBadge.tsx, frontend/components/dashboard/AchievementsSection.tsx, frontend/app/[locale]/achievements-demo/page.tsx
New achievements data model and compute function; 3D SVG AchievementBadge component with animations; AchievementsSection UI with expand/collapse; demo page added.
Activity Heatmap & Stats
frontend/components/dashboard/ActivityHeatmapCard.tsx, frontend/components/dashboard/StatsCard.tsx
New interactive activity heatmap card with period selection and streak logic; StatsCard replaced with animated multi-segment distribution chart and added attempts/totalScore props.
Dashboard Integration
frontend/app/[locale]/dashboard/page.tsx
Wires achievements, heatmap, and new stats into dashboard; adds sponsor matching, GitHub star check, globalRank, totalAttempts, and layout adjustments.
Profile & Settings
frontend/components/dashboard/ProfileCard.tsx
ProfileCard extended with totalAttempts and globalRank props, animated settings panel, larger avatar, and settings forms for name/password.
GitHub & Sponsor Integration
frontend/lib/github-stars.ts, frontend/lib/about/github-sponsors.ts, frontend/db/queries/users.ts
New GitHub helpers to resolve login and check repo stars; refactored sponsor fetch with getAllSponsors; added getUserGlobalRank and extended profile fields in DB query.
Dashboard Components & Forms
frontend/components/dashboard/FeedbackForm.tsx, frontend/components/dashboard/QuizResultsSection.tsx, frontend/components/dashboard/ExplainedTermsCard.tsx, frontend/components/dashboard/QuizResultRow.tsx
Feedback form file attachments and FormData submit; results section and terms card styling/layout updates; mobile layout tweaks for quiz rows.
Leaderboard Styling
frontend/components/leaderboard/LeaderboardClient.tsx, frontend/components/leaderboard/LeaderboardPodium.tsx, frontend/components/leaderboard/LeaderboardTable.tsx
Visual refresh: rounded-3xl containers, updated neutral color tokens, translucent badges/rings, and a subtle pulse for rank 1; no logic changes.
Quiz UI
frontend/components/quiz/QuizCard.tsx, frontend/components/quiz/QuizResult.tsx
Presentation changes: two-column accuracy/integrity layout in results; metric separators and bolded values in quiz cards.
About & Metadata
frontend/components/about/HeroSection.tsx, frontend/lib/about/stats.ts
Default LinkedIn follower count bumped from 1.5k to 1.6k.
Internationalization
frontend/messages/en.json, frontend/messages/pl.json, frontend/messages/uk.json
Large i18n expansion: dashboard/profile/stats/feedback/achievements keys and many badge strings added.
Dependencies
frontend/package.json
Adds @phosphor-icons/react runtime dependency.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Server
  participant DB
  participant GitHub
  participant SponsorsAPI

  Client->>Server: Request dashboard page (SSR)
  Server->>DB: getUserProfile + getUserAttempts + getUserGlobalRank
  DB-->>Server: profile, attempts, total points
  Server->>SponsorsAPI: fetchSponsors(activeOnly=true/false)
  SponsorsAPI-->>Server: sponsor lists
  Server->>GitHub: resolveGitHubLogin(providerId) (if provider)
  GitHub-->>Server: githubLogin or null
  Server->>GitHub: checkHasStarredRepo(githubLogin)
  GitHub-->>Server: hasStarred boolean
  Server->>Server: computeAchievements(UserStats including sponsors, hasStarred, streaks)
  Server-->>Client: Rendered dashboard with achievements, heatmap, stats
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

bug, refactor

Suggested reviewers

  • AM1007
  • ViktorSvertoka

Poem

🐰 I hopped through code to shape a prize,
Little hex badges, bright to the eyes.
Heatmaps flicker, stars align,
Sponsors cheer — the dashboard shines.
A carrot-toast to features new, hooray! 🥕✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.71% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main changes: Activity Heatmap, Achievements system, and Profile Card updates for the frontend dashboard, matching the substantial updates described in the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/dashboard-stats-achievements

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 17

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/components/leaderboard/LeaderboardTable.tsx (1)

199-207: ⚠️ Potential issue | 🟡 Minor

Missing aria-label on sponsor link — icon-only on mobile has no accessible name

On small screens, <span className="hidden sm:inline"> is display: none, removing the label from the accessibility tree. The link becomes an icon-only interactive element with no accessible name. LeaderboardPodium.tsx (line 119) correctly adds aria-label={t('sponsor')} on the same pattern — apply it here too.

🛡️ Proposed fix
  <a
    href="https://github.com/sponsors/DevLoversTeam"
    target="_blank"
    rel="noopener noreferrer"
+   aria-label={t('sponsor')}
    className="inline-flex shrink-0 items-center gap-1 ..."
  >
    <Heart className="h-2.5 w-2.5 fill-current" />
    <span className="hidden sm:inline">{t('sponsor')}</span>
  </a>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/leaderboard/LeaderboardTable.tsx` around lines 199 - 207,
The sponsor link in LeaderboardTable.tsx is icon-only on small screens and lacks
an accessible name; update the anchor element (the <a> that links to
"https://github.com/sponsors/DevLoversTeam" and uses the Heart icon and <span
className="hidden sm:inline">) to include aria-label={t('sponsor')} so it
matches the pattern used in LeaderboardPodium.tsx and provides an accessible
name when the span is hidden.
🧹 Nitpick comments (15)
frontend/lib/about/stats.ts (1)

44-46: Consider a single source of truth for the LinkedIn default.

The numeric default 1600 here and the derived string '1.6k+' in HeroSection.tsx line 19 must be kept in sync manually. If someone updates one without the other, the server-rendered stat and the client-side SSR fallback will diverge.

An easy fix: export the formatted default from this file and import it in HeroSection.

♻️ Proposed refactor
+export const DEFAULT_LINKEDIN_FOLLOWERS = 1600;

 const linkedinCount = process.env.LINKEDIN_FOLLOWER_COUNT
   ? parseInt(process.env.LINKEDIN_FOLLOWER_COUNT)
-  : 1600;
+  : DEFAULT_LINKEDIN_FOLLOWERS;

Then in HeroSection.tsx:

+import { DEFAULT_LINKEDIN_FOLLOWERS, formatMetric } from '@/lib/about/stats';
 ...
-    linkedinFollowers: '1.6k+',
+    linkedinFollowers: formatMetric(DEFAULT_LINKEDIN_FOLLOWERS),

Note: formatMetric would also need to be exported from stats.ts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/about/stats.ts` around lines 44 - 46, The LinkedIn default value
is duplicated between frontend/lib/about/stats.ts (linkedinCount) and
HeroSection.tsx (hardcoded '1.6k+'); export a single source of truth by moving
the default and its formatted representation into stats.ts (export
linkedinCountDefault and a formattedLinkedinDefault via the existing
formatMetric function—export formatMetric if needed) and update HeroSection.tsx
to import and use these exports instead of hardcoding the string so
server-rendered and client-side SSR fallback remain consistent.
frontend/components/leaderboard/LeaderboardPodium.tsx (1)

20-20: Inconsistent badge text weight vs. LeaderboardTable's RankBadge

All three podium badge configs now use -500 text variants (text-yellow-500, text-slate-500, text-orange-500), while RankBadge in LeaderboardTable.tsx uses the darker -600 variants (text-yellow-600, text-slate-600, text-orange-600). Against the very translucent bg-*-500/20 backgrounds, the -500 text has noticeably less contrast — especially yellow-on-yellow in light mode.

♻️ Align to -600 for consistency and contrast
-      badge: 'bg-yellow-500/20 text-yellow-500 border border-yellow-500/30 backdrop-blur-sm',
+      badge: 'bg-yellow-500/20 text-yellow-600 border border-yellow-500/30 backdrop-blur-sm dark:text-yellow-500',
-      badge: 'bg-slate-500/20 text-slate-500 border border-slate-500/30 backdrop-blur-sm',
+      badge: 'bg-slate-500/20 text-slate-600 border border-slate-500/30 backdrop-blur-sm dark:text-slate-400',
-      badge: 'bg-orange-500/20 text-orange-500 border border-orange-500/30 backdrop-blur-sm',
+      badge: 'bg-orange-500/20 text-orange-600 border border-orange-500/30 backdrop-blur-sm dark:text-orange-400',

Also applies to: 31-31, 42-42

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/leaderboard/LeaderboardPodium.tsx` at line 20, The podium
badge configs in LeaderboardPodium.tsx use lighter text classes
(text-yellow-500, text-slate-500, text-orange-500) which reduces contrast versus
the translucent bg-*-500/20; update those three badge class strings to use the
-600 variants (text-yellow-600, text-slate-600, text-orange-600) so they match
the RankBadge in LeaderboardTable.tsx and restore proper contrast while leaving
the bg-*-500/20 values unchanged.
frontend/components/leaderboard/LeaderboardTable.tsx (1)

177-177: border-white/20 avatar border is invisible in light mode

rgba(255,255,255,0.2) on a white/light-gray background is effectively invisible, so non-highlighted avatars appear borderless in light mode. The dark-mode variant dark:border-white/10 is even more subtle. Consider a visible light-mode fallback:

♻️ Suggested tweak
-: 'border-white/20 group-hover:border-(--accent-primary) dark:border-white/10'
+: 'border-gray-200/70 group-hover:border-(--accent-primary) dark:border-white/10'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/leaderboard/LeaderboardTable.tsx` at line 177, The
avatar's light-mode border class 'border-white/20' is effectively invisible on
light backgrounds; update the class in LeaderboardTable.tsx (the avatar
element/className string containing "border-white/20 group-hover:...
dark:border-white/10") to a visible light-mode fallback such as
'border-gray-200' or 'border-neutral-200' (e.g., replace 'border-white/20' with
'border-gray-200') while keeping the existing group-hover and dark-mode classes
unchanged.
frontend/lib/github-stars.ts (1)

11-17: makeHeaders() will produce Authorization: Bearer undefined if called without a token.

Both callers guard with getToken() first, so this isn't exploitable today, but the helper itself is unsafe. Consider adding a guard or making the token a parameter.

♻️ Suggested improvement
-function makeHeaders(): Record<string, string> {
-  return {
-    Accept: 'application/vnd.github+json',
-    'X-GitHub-Api-Version': '2022-11-28',
-    Authorization: `Bearer ${getToken()}`,
-  };
-}
+function makeHeaders(token: string): Record<string, string> {
+  return {
+    Accept: 'application/vnd.github+json',
+    'X-GitHub-Api-Version': '2022-11-28',
+    Authorization: `Bearer ${token}`,
+  };
+}

Then update callers to pass the already-validated token:

const token = getToken();
if (!token) return null;
// ...
const res = await fetch(url, { headers: makeHeaders(token), ... });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/github-stars.ts` around lines 11 - 17, The makeHeaders helper
currently calls getToken() internally and can emit Authorization: Bearer
undefined; change makeHeaders to accept a validated token parameter (e.g.,
makeHeaders(token: string)) or at minimum throw if token is missing, and update
all callers to call const token = getToken(); if (!token) return null; then pass
that token into makeHeaders(token) so Authorization is only set when a real
token exists (refer to makeHeaders and getToken to locate the changes).
frontend/db/queries/users.ts (2)

48-62: Raw SQL uses hardcoded table name point_transactions.

The CTE references the table name as a string literal rather than using the drizzle schema reference (pointTransactions). This is a common trade-off when writing raw SQL CTEs, but note that if the table is ever renamed, this query will silently break. A brief inline comment noting the coupling would help future maintainers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/db/queries/users.ts` around lines 48 - 62, The raw SQL in rankQuery
embeds the hardcoded table name point_transactions inside the CTE which will
break silently if the schema/identifier changes; add a clear inline comment
immediately above the CTE in users.ts stating that point_transactions is
intentionally hardcoded (and why), note the coupling to the pointTransactions
schema name, and recommend using a Drizzle query or updating this string if the
table is renamed so future maintainers know to update this raw SQL; reference
the rankQuery constant and the point_transactions vs pointTransactions naming to
locate where to add the comment.

64-71: Add runtime guard for db.execute() result shape.

The cast (result as { rows: any[] }).rows[0] assumes a specific return format, but db.execute() can return either an array directly or an object with a rows property depending on the driver. The codebase has established patterns for handling both (see inventory.ts:readStatus() and test helpers), but this code lacks that resilience.

♻️ Suggested improvement
-  const result = await db.execute(rankQuery);
-  const rankRow = (result as { rows: any[] }).rows[0];
-  
-  if (!rankRow || !rankRow.rank) {
-    return null; 
-  }
-  
-  return Number(rankRow.rank);
+  const result = await db.execute(rankQuery);
+  const rows = Array.isArray(result) ? result : (result as { rows: unknown[] }).rows ?? [];
+  const rankRow = rows[0] as { rank?: string | number } | undefined;
+
+  if (!rankRow?.rank) {
+    return null;
+  }
+
+  return Number(rankRow.rank);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/db/queries/users.ts` around lines 64 - 71, The code assumes
db.execute(rankQuery) returns an object with rows; add a runtime shape guard to
handle both return shapes: if result is an array use the array directly, else if
it has a rows array use that, then read the first entry into rankRow; keep the
existing null checks for rankRow and rankRow.rank and return
Number(rankRow.rank) otherwise null. Locate the usage around the
rankQuery/db.execute call and the rankRow handling and update it to branch on
Array.isArray(result) and on ('rows' in result && Array.isArray(result.rows))
before accessing .rows[0].
frontend/components/quiz/QuizResult.tsx (1)

120-122: Integrity formula uses a magic number and can produce counterintuitive results.

100 - violations * 10 means each violation costs exactly 10%. With the current formula, 3 violations → 70% integrity, which may seem generous. Consider extracting the penalty factor (10) into a named constant and adding a brief comment explaining the rationale behind the scaling.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/quiz/QuizResult.tsx` around lines 120 - 122, The
integrity score calculation in QuizResult.tsx uses a magic number (100 -
violations * 10) which is unclear; refactor by extracting the penalty factor
into a named constant (e.g., PENALTY_PER_VIOLATION) near the top of the
component or module and replace the literal 10 in the expression inside the h2
(the JSX rendering the score) with that constant; also add a short comment
explaining the chosen scale/rationale for PENALTY_PER_VIOLATION and ensure the
existing Math.max(0, ...) clamp remains to prevent negative scores.
frontend/lib/about/github-sponsors.ts (1)

56-56: cache: 'no-store' disables Next.js fetch cache for every sponsor call.

Both getSponsors() and getAllSponsors() are now invoked on the dashboard page (potentially per request). With no-store, each page load fires two separate uncached GitHub GraphQL calls. Consider using next: { revalidate: 300 } (or similar ISR-style caching) instead, to reduce GitHub API pressure and improve TTFB — especially since sponsor lists change infrequently.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/about/github-sponsors.ts` at line 56, The fetch call currently
sets cache: 'no-store', causing uncached GraphQL requests for both getSponsors()
and getAllSponsors() on each page load; change the fetch/cache option to use
ISR-style caching (e.g., next: { revalidate: 300 }) instead of 'no-store' so
sponsor lists are cached for a reasonable period and reduce GitHub API calls and
TTFB; update the fetch options where getSponsors() and getAllSponsors() perform
the GitHub GraphQL request to replace cache: 'no-store' with next: { revalidate:
<seconds> } (pick an appropriate TTL like 300).
frontend/app/[locale]/dashboard/page.tsx (3)

65-78: findSponsor matching by display name is fragile.

Lines 68-69 compare user.name (a mutable display name) against the sponsor's GitHub login and name. Users can change their display name to anything, leading to false positives (claiming someone else's sponsorship) or false negatives (name doesn't match login). The email and avatar-URL checks are more reliable; consider dropping the name-based matches or at least making them lowest priority with a stricter check.

🤖 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 65 - 78, The
findSponsor function currently matches by mutable display name (userName) which
is fragile; update findSponsor to prefer stable identifiers only: keep the email
(s.email) and avatar (s.avatarUrl compared to userImage) checks as primary,
remove the s.name === userName check entirely, and only attempt a userName match
against s.login as a last-resort exact, case-insensitive check (or drop it
altogether if you want strictness). Ensure you modify the function where
findSponsor, userEmail, userName, userImage and sponsors are referenced so
name-based matching is either removed or moved to lowest priority with strict
equality to s.login.

56-59: Two uncached GitHub API calls fire on every dashboard page load.

getSponsors() and getAllSponsors() each make a no-store GraphQL request. Consider fetching once and filtering client-side, or caching with a short TTL. (Also noted on the github-sponsors.ts file.)

🤖 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 56 - 59, Both
getSponsors() and getAllSponsors() currently perform separate no-store GitHub
GraphQL requests on every page load; change the page to fetch once and reuse the
result (or add a short server-side cache/TTL) by calling getAllSponsors() a
single time and deriving active sponsors client-side (e.g., const allSponsors =
await getAllSponsors(); const sponsors = allSponsors.filter(...active
check...)); alternatively modify getSponsors() in github-sponsors.ts to accept
an optional pre-fetched list so the page can call getAllSponsors() only and pass
that into getSponsors() to avoid two network requests, or implement a small
in-memory/route cache with a short TTL around the GitHub fetch helper to reduce
requests.

188-193: Night-owl detection uses server timezone, not user's local time.

new Date(a.completedAt).getHours() evaluates in the server's timezone. A user in UTC+5 completing a quiz at 2 AM local time would register as 9 PM on a UTC server and miss the achievement. This may be acceptable as a known limitation, but worth documenting or deferring to a client-side check if user timezone is available.

🤖 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 188 - 193, The
night-owl detection currently uses new Date(a.completedAt).getHours() (in the
hasNightOwl computation over attempts) which evaluates in the server's timezone;
change the logic to use the user's timezone instead—either move this check to
the client so you can call new Date(a.completedAt).getHours() in the browser, or
accept a userTimezone prop/setting and compute the hour with Intl.DateTimeFormat
or Date.toLocaleString({ timeZone: userTimezone }) before comparing to 0-4;
update the hasNightOwl computation to derive hour using the user's
timezone-aware conversion of completedAt.
frontend/components/dashboard/ActivityHeatmapCard.tsx (3)

81-86: Identical branches in if/else — simplify.

Both the if and else branches execute the exact same statement. Remove the conditional and keep a single setDate call.

♻️ Suggested simplification
-    if (periodOffset === 0) {
-       windowEndExclusive.setDate(windowEndExclusive.getDate() + 1);
-    } else {
-       windowEndExclusive.setDate(windowEndExclusive.getDate() + 1);
-    }
+    windowEndExclusive.setDate(windowEndExclusive.getDate() + 1);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/dashboard/ActivityHeatmapCard.tsx` around lines 81 - 86,
The conditional around adjusting windowEndExclusive is redundant because both
branches call the same setDate; remove the if/else and replace with a single
call to windowEndExclusive.setDate(windowEndExclusive.getDate() + 1) (located in
ActivityHeatmapCard where windowEndExclusive and periodOffset are used) to
simplify the logic and avoid dead branching.

166-176: Dead code: streak and max are computed but never used.

The todayKey memo computes streak and max inside the loop, but only todayKey is returned. This is wasted computation and confusing for future readers.

♻️ Remove unused variables
 const { todayKey } = useMemo(() => {
-    let streak = 0, max = 0;
-    for (const d of heatmapData) {
-      if (d.count > 0) { streak++; max = Math.max(max, streak); }
-      else streak = 0;
-    }
     const td = new Date();
     return {
       todayKey: `${td.getFullYear()}-${td.getMonth() + 1}-${td.getDate()}`,
     };
-  }, [heatmapData]);
+  }, []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/dashboard/ActivityHeatmapCard.tsx` around lines 166 -
176, The useMemo block computing todayKey currently declares and updates streak
and max inside a loop over heatmapData but never returns or uses them; remove
the unused variables and the loop to avoid wasted computation: simplify the
useMemo for todayKey (the useMemo that defines todayKey) to directly compute and
return the date string, keeping the dependency array (heatmapData) if you want
recomputation when data changes, or remove that dependency if not needed.

392-406: Large number of animated SVG elements may cause performance issues.

Each trace and node is a motion.path / motion.circle with individual staggered animations (delay based on index). For a 90-day heatmap, that's potentially ~90 nodes + dozens of traces, each with spring/path animations. On lower-end devices, this many simultaneous Framer Motion animations could cause jank.

Consider using CSS animations or initial={false} for subsequent period changes to skip entrance animations after the first render. Alternatively, the stagger delay (i * 0.015 for traces, i * 0.005 for nodes) should be capped to avoid very late animations.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/dashboard/ActivityHeatmapCard.tsx` around lines 392 -
406, The current map render of traces and nodes uses per-element Framer Motion
entrance animations (motion.path in the traces.map and motion.circle for nodes)
with delays computed as i * 0.015 / i * 0.005 which can cause jank on low-end
devices; modify the component to (1) avoid re-running entrance animations after
the initial mount by tracking a mounted flag (e.g., useRef or state like
hasMounted) and pass initial={hasMounted ? false : undefined} to motion elements
or skip animate on subsequent renders, and (2) cap the stagger delay when
computing the per-index delay (e.g., delay = Math.min(i * 0.015, 0.5) for traces
and Math.min(i * 0.005, 0.3) for nodes) so animations don’t stretch too long;
alternatively, replace complex per-element Framer Motion animations with
lightweight CSS transitions for the static render path. Ensure you update uses
of getTracePath, traces.map and the motion.circle map to use the new delay
calculation and the hasMounted flag.
frontend/components/dashboard/AchievementBadge.tsx (1)

82-96: Dark mode detection via MutationObserver is fine, but consider using a shared hook.

Each of the 18 badge instances creates its own MutationObserver on document.documentElement. While individually lightweight, 18 observers watching the same attribute on the same element is wasteful. Extract this into a shared useDarkMode() hook (or a React context) so all badges share one observer.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/dashboard/AchievementBadge.tsx` around lines 82 - 96, The
AchievementBadge component currently creates a MutationObserver inside its
useEffect to track dark mode (document.documentElement and setIsDark); extract
that logic into a shared hook (e.g., useDarkMode) that returns a boolean isDark
and internally creates one MutationObserver (observing
document.documentElement.class) with proper cleanup, then replace the local
useState/useEffect in AchievementBadge with a call to useDarkMode() to consume
the single shared observer across all badges; ensure the hook initializes state
from document.documentElement.classList.contains('dark') and exposes the boolean
to callers.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/app/`[locale]/achievements-demo/page.tsx:
- Around line 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.

In `@frontend/app/`[locale]/dashboard/page.tsx:
- Around line 95-101: Remove the two console.log calls that print user.provider,
user.providerId and githubLogin/hasStarredRepo from the dashboard page; instead
either delete them or replace with a debug-level logger gated by environment
(e.g., only log when process.env.NODE_ENV !== 'production' or a DEBUG flag).
Locate the calls in frontend/app/[locale]/dashboard/page.tsx where
user.provider, user.providerId and githubLogin are logged and where
hasStarredRepo (result of checkHasStarredRepo) is logged, and update to use the
gated debug logger or remove the logs entirely to avoid leaking PII-adjacent
data.
- Around line 124-146: uniqueAttemptDates and the streak loop use local-midnight
timestamps and subtract 86400000ms, which breaks across DST; change to
calendar-day arithmetic using day numbers or UTC-midnights. For example, replace
the Set mapping in uniqueAttemptDates with day keys like Math.floor(new
Date(a.completedAt).getTime() / 86400000) (or use new
Date(...).setUTCHours(0,0,0,0)) to produce unique day identifiers, compute
today/yesterday the same way, and in the streak loop decrement the day key by 1
(or use Date.setDate(Date.getDate()-1)) when comparing instead of subtracting
86400000 from checkDate; update references to uniqueAttemptDates, currentStreak,
today, yesterday, and checkDate accordingly.

In `@frontend/components/dashboard/AchievementsSection.tsx`:
- Around line 50-64: The expand/collapse button currently hides its text on
small screens (class "hidden sm:inline") so screen readers lack context; update
the button element (where setIsExpanded, isExpanded, and
aria-controls="achievements-grid" are used) to include an accessible label by
adding an aria-label (or aria-labelledby) that reflects the current state (use
t('ui.expand') when isExpanded is false and t('ui.collapse') when true) so the
icon-only button announces meaningful text while keeping visual layout
unchanged; ensure aria-expanded remains in place and the label updates whenever
isExpanded changes.

In `@frontend/components/dashboard/ActivityHeatmapCard.tsx`:
- Around line 511-516: The displayed "active day(s)" string is hardcoded and
must use i18n pluralization; replace the inline plural logic in
ActivityHeatmapCard.tsx (the span near the CalendarDays icon that uses
totalActiveDays) with a t() call that supports counts (e.g., t('...key...', {
count: totalActiveDays })), and add the corresponding singular/plural
translation entries for that key in your locale files; ensure the component
imports and uses the existing t function (or useTranslation hook) so
translations render correctly.
- Around line 469-479: Replace the hardcoded tooltip strings in
ActivityHeatmapCard.tsx (the tooltip rendering that uses tooltip.date and
tooltip.count) with translation calls to t(): use a key for the zero case (e.g.,
t('tooltipNoActivity')) instead of "No activity", and use a pluralized
translation for attempts (e.g., t('tooltipAttempts', { count: tooltip.count })
or an existing plural key like t('attempt', { count: tooltip.count })) so the
tooltip text is internationalized and consistent with the rest of the component.

In `@frontend/components/dashboard/FeedbackForm.tsx`:
- Around line 246-256: The file input's onChange (feedback-attachment) currently
only sets setAttachmentName and lacks file-size validation; add a
MAX_ATTACHMENT_SIZE (e.g. 5 * 1024 * 1024) and in the onChange handler check
file.size, and if it exceeds the limit clear the selection (do not call
setAttachmentName), set a validation error state (e.g. setAttachmentError or
similar) and optionally reset the input value so the user gets immediate
feedback; update the component to render that error message near the attachment
input so users know the file was too large.

In `@frontend/components/dashboard/ProfileCard.tsx`:
- Around line 35-36: The shared isSaving state causes both forms to show
"Saving…" together; split it into two distinct booleans (e.g., isSavingName and
isSavingPassword) and their setters (setIsSavingName, setIsSavingPassword)
instead of the single isSaving currently declared, then update the submit
handlers (the functions that call setIsSaving) and the button/label render logic
in the Change Name and Change Password form blocks to use the corresponding new
state (isSavingName for the name form, isSavingPassword for the password form)
so each form only shows its own saving state.
- Around line 190-195: The submit handlers in ProfileCard.tsx currently only
toggle setIsSaving with a setTimeout (in the onSubmit for the forms that control
"Change Name" and "Change Password") and do not persist changes; replace the
stubbed behavior by wiring these handlers to real save logic: call the
appropriate API or server action (e.g., invoke your users update endpoint or
password change action) inside the onSubmit, await the response, handle errors,
and setIsSaving(false) afterward; alternatively, if you intend these to remain
stubs, remove the forms or add an explicit TODO comment and a visible
user-facing toast/message indicating the feature is not implemented so users
aren't misled.
- Around line 237-252: In ProfileCard.tsx, the two password inputs use only
placeholders (t('currentPassword') and t('newPassword')) and need accessible
labels: add unique id attributes (e.g., id="currentPassword" and
id="newPassword") to the respective <input> elements and add corresponding
<label htmlFor="currentPassword"> and <label htmlFor="newPassword"> elements
(use the same sr-only classname pattern used for the name input near Line 199 so
labels are visually hidden but available to screen readers); keep the existing
placeholders and validation attributes unchanged.

In `@frontend/components/dashboard/QuizResultsSection.tsx`:
- Around line 73-74: In QuizResultsSection replace the incorrect use of
t('accuracy', { fallback: 'Accuracy' }) with a simple t('accuracy') (or remove
the second argument) because useTranslations treats the second parameter as ICU
values and the fallback is ignored; if you need a default message for missing
keys instead, adjust the i18n config (onError/getMessageFallback) rather than
passing a fallback object to t.

In `@frontend/components/dashboard/StatsCard.tsx`:
- Around line 78-79: The call to the translation helper t in StatsCard.tsx
incorrectly passes a fallback object ({ fallback: '...' }) as the second
argument — that second parameter is for ICU interpolation values, not a default
string; remove the fallback object and call t('scoreDistributionSubtext')
instead (same fix applied in QuizResultsSection where the same pattern appears)
so the key is used correctly without passing invalid ICU values.
- Around line 183-185: The hardcoded "Score" string in the StatsCard component
should be replaced with the existing localization call; locate the StatsCard
function/component where useTranslations is imported/used and swap the literal
"Score" span content for the translation lookup (e.g. t('score') or the
component's existing namespace like t('dashboard.score')) so the label uses the
same useTranslations instance as the rest of the component.

In `@frontend/components/quiz/QuizResult.tsx`:
- Line 106: The review flags incorrect usage of next-intl's t() in QuizResult:
remove the second argument object ({ fallback: '...' }) from all t() calls since
the second param is for ICU interpolation, not fallback text; update the t
invocations in the QuizResult component (e.g., t('accuracy', { fallback:
'Accuracy' }), t('integrity', { fallback: 'Integrity' }), t('violationsLabel', {
fallback: 'Violations' })) to just t('accuracy'), t('integrity'), and
t('violationsLabel') respectively so translations rely on the locale files.

In `@frontend/lib/achievements.ts`:
- Around line 79-85: computeAchievements currently sets MOCK_DATE using new
Date() which causes every EarnedAchievement.earnedAt to show today's date;
replace this mock behavior by sourcing real earn dates from the data model: add
an earnedAt field to UserStats (or provide a separate lookup keyed by
achievement id), and update computeAchievements to read earnedAt from UserStats
(or the lookup) for each achievement (e.g., derive from the earliest qualifying
attempt.completedAt if available); if no real date exists, return undefined/null
for earnedAt (or suppress the date display) instead of using MOCK_DATE. Ensure
references to computeAchievements, MOCK_DATE, UserStats, and earnedAt are
updated accordingly.

In `@frontend/messages/en.json`:
- Around line 1088-1109: The dashboard.profile translation keys differ between
locales; ensure both en.json and pl.json contain the same key set by adding the
missing keys: in en.json add defaultName, defaultRole, sponsor (and any
sponsor-related keys like becomeSponsor, sponsorThanks, sponsorMore) and in
pl.json add points, quizzesTaken, subtitle, title (and mirror other keys such as
joined, globalRank, dayStreak/daysStreak, settings, editProfile, changeName,
saveChanges, changePassword, currentPassword, newPassword, saving) so that
dashboard.profile has an identical set across locales; locate the
dashboard.profile object in both files and add the missing keys with appropriate
translations to prevent next-intl fallback errors.

In `@frontend/messages/uk.json`:
- Around line 1306-1309: The "deep_diver" translation key uses the Russian
letter "ы" in the name value ("Глибокий ныряльщик"); update the "name" for the
deep_diver entry to a correct Ukrainian term (e.g., replace "Глибокий ныряльщик"
with "Глибокий пірнальник" or "Глибоководний дослідник") while leaving "desc"
and "hint" intact and ensuring proper UTF-8 characters.

---

Outside diff comments:
In `@frontend/components/leaderboard/LeaderboardTable.tsx`:
- Around line 199-207: The sponsor link in LeaderboardTable.tsx is icon-only on
small screens and lacks an accessible name; update the anchor element (the <a>
that links to "https://github.com/sponsors/DevLoversTeam" and uses the Heart
icon and <span className="hidden sm:inline">) to include
aria-label={t('sponsor')} so it matches the pattern used in
LeaderboardPodium.tsx and provides an accessible name when the span is hidden.

---

Duplicate comments:
In `@frontend/messages/pl.json`:
- Around line 1088-1110: The pl.json "profile" object has mismatched keys
compared to en.json: remove or rename extraneous keys (title, subtitle, points,
quizzesTaken) and add the missing keys (defaultName, defaultRole, sponsor) so
the "profile" structure matches the English source; update the Polish
translations for the added keys (defaultName, defaultRole, sponsor) and ensure
any UI that reads dashboard.profile uses the consistent key names (e.g.,
profile.defaultName, profile.defaultRole, profile.sponsor) to avoid lookup
failures.

---

Nitpick comments:
In `@frontend/app/`[locale]/dashboard/page.tsx:
- Around line 65-78: The findSponsor function currently matches by mutable
display name (userName) which is fragile; update findSponsor to prefer stable
identifiers only: keep the email (s.email) and avatar (s.avatarUrl compared to
userImage) checks as primary, remove the s.name === userName check entirely, and
only attempt a userName match against s.login as a last-resort exact,
case-insensitive check (or drop it altogether if you want strictness). Ensure
you modify the function where findSponsor, userEmail, userName, userImage and
sponsors are referenced so name-based matching is either removed or moved to
lowest priority with strict equality to s.login.
- Around line 56-59: Both getSponsors() and getAllSponsors() currently perform
separate no-store GitHub GraphQL requests on every page load; change the page to
fetch once and reuse the result (or add a short server-side cache/TTL) by
calling getAllSponsors() a single time and deriving active sponsors client-side
(e.g., const allSponsors = await getAllSponsors(); const sponsors =
allSponsors.filter(...active check...)); alternatively modify getSponsors() in
github-sponsors.ts to accept an optional pre-fetched list so the page can call
getAllSponsors() only and pass that into getSponsors() to avoid two network
requests, or implement a small in-memory/route cache with a short TTL around the
GitHub fetch helper to reduce requests.
- Around line 188-193: The night-owl detection currently uses new
Date(a.completedAt).getHours() (in the hasNightOwl computation over attempts)
which evaluates in the server's timezone; change the logic to use the user's
timezone instead—either move this check to the client so you can call new
Date(a.completedAt).getHours() in the browser, or accept a userTimezone
prop/setting and compute the hour with Intl.DateTimeFormat or
Date.toLocaleString({ timeZone: userTimezone }) before comparing to 0-4; update
the hasNightOwl computation to derive hour using the user's timezone-aware
conversion of completedAt.

In `@frontend/components/dashboard/AchievementBadge.tsx`:
- Around line 82-96: The AchievementBadge component currently creates a
MutationObserver inside its useEffect to track dark mode
(document.documentElement and setIsDark); extract that logic into a shared hook
(e.g., useDarkMode) that returns a boolean isDark and internally creates one
MutationObserver (observing document.documentElement.class) with proper cleanup,
then replace the local useState/useEffect in AchievementBadge with a call to
useDarkMode() to consume the single shared observer across all badges; ensure
the hook initializes state from
document.documentElement.classList.contains('dark') and exposes the boolean to
callers.

In `@frontend/components/dashboard/ActivityHeatmapCard.tsx`:
- Around line 81-86: The conditional around adjusting windowEndExclusive is
redundant because both branches call the same setDate; remove the if/else and
replace with a single call to
windowEndExclusive.setDate(windowEndExclusive.getDate() + 1) (located in
ActivityHeatmapCard where windowEndExclusive and periodOffset are used) to
simplify the logic and avoid dead branching.
- Around line 166-176: The useMemo block computing todayKey currently declares
and updates streak and max inside a loop over heatmapData but never returns or
uses them; remove the unused variables and the loop to avoid wasted computation:
simplify the useMemo for todayKey (the useMemo that defines todayKey) to
directly compute and return the date string, keeping the dependency array
(heatmapData) if you want recomputation when data changes, or remove that
dependency if not needed.
- Around line 392-406: The current map render of traces and nodes uses
per-element Framer Motion entrance animations (motion.path in the traces.map and
motion.circle for nodes) with delays computed as i * 0.015 / i * 0.005 which can
cause jank on low-end devices; modify the component to (1) avoid re-running
entrance animations after the initial mount by tracking a mounted flag (e.g.,
useRef or state like hasMounted) and pass initial={hasMounted ? false :
undefined} to motion elements or skip animate on subsequent renders, and (2) cap
the stagger delay when computing the per-index delay (e.g., delay = Math.min(i *
0.015, 0.5) for traces and Math.min(i * 0.005, 0.3) for nodes) so animations
don’t stretch too long; alternatively, replace complex per-element Framer Motion
animations with lightweight CSS transitions for the static render path. Ensure
you update uses of getTracePath, traces.map and the motion.circle map to use the
new delay calculation and the hasMounted flag.

In `@frontend/components/leaderboard/LeaderboardPodium.tsx`:
- Line 20: The podium badge configs in LeaderboardPodium.tsx use lighter text
classes (text-yellow-500, text-slate-500, text-orange-500) which reduces
contrast versus the translucent bg-*-500/20; update those three badge class
strings to use the -600 variants (text-yellow-600, text-slate-600,
text-orange-600) so they match the RankBadge in LeaderboardTable.tsx and restore
proper contrast while leaving the bg-*-500/20 values unchanged.

In `@frontend/components/leaderboard/LeaderboardTable.tsx`:
- Line 177: The avatar's light-mode border class 'border-white/20' is
effectively invisible on light backgrounds; update the class in
LeaderboardTable.tsx (the avatar element/className string containing
"border-white/20 group-hover:... dark:border-white/10") to a visible light-mode
fallback such as 'border-gray-200' or 'border-neutral-200' (e.g., replace
'border-white/20' with 'border-gray-200') while keeping the existing group-hover
and dark-mode classes unchanged.

In `@frontend/components/quiz/QuizResult.tsx`:
- Around line 120-122: The integrity score calculation in QuizResult.tsx uses a
magic number (100 - violations * 10) which is unclear; refactor by extracting
the penalty factor into a named constant (e.g., PENALTY_PER_VIOLATION) near the
top of the component or module and replace the literal 10 in the expression
inside the h2 (the JSX rendering the score) with that constant; also add a short
comment explaining the chosen scale/rationale for PENALTY_PER_VIOLATION and
ensure the existing Math.max(0, ...) clamp remains to prevent negative scores.

In `@frontend/db/queries/users.ts`:
- Around line 48-62: The raw SQL in rankQuery embeds the hardcoded table name
point_transactions inside the CTE which will break silently if the
schema/identifier changes; add a clear inline comment immediately above the CTE
in users.ts stating that point_transactions is intentionally hardcoded (and
why), note the coupling to the pointTransactions schema name, and recommend
using a Drizzle query or updating this string if the table is renamed so future
maintainers know to update this raw SQL; reference the rankQuery constant and
the point_transactions vs pointTransactions naming to locate where to add the
comment.
- Around line 64-71: The code assumes db.execute(rankQuery) returns an object
with rows; add a runtime shape guard to handle both return shapes: if result is
an array use the array directly, else if it has a rows array use that, then read
the first entry into rankRow; keep the existing null checks for rankRow and
rankRow.rank and return Number(rankRow.rank) otherwise null. Locate the usage
around the rankQuery/db.execute call and the rankRow handling and update it to
branch on Array.isArray(result) and on ('rows' in result &&
Array.isArray(result.rows)) before accessing .rows[0].

In `@frontend/lib/about/github-sponsors.ts`:
- Line 56: The fetch call currently sets cache: 'no-store', causing uncached
GraphQL requests for both getSponsors() and getAllSponsors() on each page load;
change the fetch/cache option to use ISR-style caching (e.g., next: {
revalidate: 300 }) instead of 'no-store' so sponsor lists are cached for a
reasonable period and reduce GitHub API calls and TTFB; update the fetch options
where getSponsors() and getAllSponsors() perform the GitHub GraphQL request to
replace cache: 'no-store' with next: { revalidate: <seconds> } (pick an
appropriate TTL like 300).

In `@frontend/lib/about/stats.ts`:
- Around line 44-46: The LinkedIn default value is duplicated between
frontend/lib/about/stats.ts (linkedinCount) and HeroSection.tsx (hardcoded
'1.6k+'); export a single source of truth by moving the default and its
formatted representation into stats.ts (export linkedinCountDefault and a
formattedLinkedinDefault via the existing formatMetric function—export
formatMetric if needed) and update HeroSection.tsx to import and use these
exports instead of hardcoding the string so server-rendered and client-side SSR
fallback remain consistent.

In `@frontend/lib/github-stars.ts`:
- Around line 11-17: The makeHeaders helper currently calls getToken()
internally and can emit Authorization: Bearer undefined; change makeHeaders to
accept a validated token parameter (e.g., makeHeaders(token: string)) or at
minimum throw if token is missing, and update all callers to call const token =
getToken(); if (!token) return null; then pass that token into
makeHeaders(token) so Authorization is only set when a real token exists (refer
to makeHeaders and getToken to locate the changes).

Comment on lines +5 to +35
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>
);
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.

Comment on lines +50 to +64
<button
onClick={() => setIsExpanded((p) => !p)}
aria-expanded={isExpanded}
aria-controls="achievements-grid"
className="shrink-0 flex items-center gap-1.5 rounded-full border border-gray-200 bg-white/50 px-2.5 py-1.5 sm:px-4 sm:py-2 text-xs sm:text-sm font-medium text-gray-600 backdrop-blur-sm transition-all hover:bg-white hover:text-(--accent-primary) dark:border-white/10 dark:bg-neutral-900/50 dark:text-gray-300 dark:hover:bg-neutral-800 dark:hover:text-(--accent-primary)"
>
<span className="hidden sm:inline">{isExpanded ? t('ui.collapse') : t('ui.expand')}</span>
<motion.span
animate={{ rotate: isExpanded ? 180 : 0 }}
transition={{ duration: 0.25, ease: 'easeInOut' }}
className="flex"
>
<ChevronDown className="h-4 w-4" />
</motion.span>
</button>
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

Expand/collapse button lacks an accessible label on small screens.

The button text is hidden sm:inline, so on mobile only the chevron icon is shown. Without an aria-label, screen readers announce just "button, expanded/collapsed" with no context.

♿ Proposed fix
 <button
   onClick={() => setIsExpanded((p) => !p)}
   aria-expanded={isExpanded}
   aria-controls="achievements-grid"
+  aria-label={isExpanded ? t('ui.collapse') : t('ui.expand')}
   className="shrink-0 flex items-center gap-1.5 ..."
 >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/dashboard/AchievementsSection.tsx` around lines 50 - 64,
The expand/collapse button currently hides its text on small screens (class
"hidden sm:inline") so screen readers lack context; update the button element
(where setIsExpanded, isExpanded, and aria-controls="achievements-grid" are
used) to include an accessible label by adding an aria-label (or
aria-labelledby) that reflects the current state (use t('ui.expand') when
isExpanded is false and t('ui.collapse') when true) so the icon-only button
announces meaningful text while keeping visual layout unchanged; ensure
aria-expanded remains in place and the label updates whenever isExpanded
changes.

Comment on lines +469 to +479
<p className="text-[11px] font-semibold text-white dark:text-gray-900">
{tooltip.date.toLocaleDateString(locale, { weekday: 'short', month: 'short', day: 'numeric' })}
</p>
<p className={`text-xs mt-0.5 font-bold ${
tooltip.count === 0
? 'text-gray-500 dark:text-gray-400'
: 'text-(--accent-primary)'
}`}>
{tooltip.count === 0
? 'No activity'
: `${tooltip.count} ${tooltip.count === 1 ? 'attempt' : 'attempts'}`}
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

Hardcoded English strings in tooltip — should use translation keys.

The tooltip text "No activity", "attempt", and "attempts" are hardcoded in English. These strings should be internationalized using the t() translation function, consistent with the rest of the component.

♻️ Proposed fix
               <p className={`text-xs mt-0.5 font-bold ${
                 tooltip.count === 0
                   ? 'text-gray-500 dark:text-gray-400'
                   : 'text-(--accent-primary)'
               }`}>
-                {tooltip.count === 0
-                  ? 'No activity'
-                  : `${tooltip.count} ${tooltip.count === 1 ? 'attempt' : 'attempts'}`}
+                {tooltip.count === 0
+                  ? t('noActivity')
+                  : t('attemptsInPeriod', { count: tooltip.count })}
               </p>

You may need a separate, shorter translation key for the tooltip (e.g., "tooltipNoActivity" and "tooltipAttempts") if the existing keys don't fit the tooltip context.

📝 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
<p className="text-[11px] font-semibold text-white dark:text-gray-900">
{tooltip.date.toLocaleDateString(locale, { weekday: 'short', month: 'short', day: 'numeric' })}
</p>
<p className={`text-xs mt-0.5 font-bold ${
tooltip.count === 0
? 'text-gray-500 dark:text-gray-400'
: 'text-(--accent-primary)'
}`}>
{tooltip.count === 0
? 'No activity'
: `${tooltip.count} ${tooltip.count === 1 ? 'attempt' : 'attempts'}`}
<p className="text-[11px] font-semibold text-white dark:text-gray-900">
{tooltip.date.toLocaleDateString(locale, { weekday: 'short', month: 'short', day: 'numeric' })}
</p>
<p className={`text-xs mt-0.5 font-bold ${
tooltip.count === 0
? 'text-gray-500 dark:text-gray-400'
: 'text-(--accent-primary)'
}`}>
{tooltip.count === 0
? t('noActivity')
: t('attemptsInPeriod', { count: tooltip.count })}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/dashboard/ActivityHeatmapCard.tsx` around lines 469 -
479, Replace the hardcoded tooltip strings in ActivityHeatmapCard.tsx (the
tooltip rendering that uses tooltip.date and tooltip.count) with translation
calls to t(): use a key for the zero case (e.g., t('tooltipNoActivity')) instead
of "No activity", and use a pluralized translation for attempts (e.g.,
t('tooltipAttempts', { count: tooltip.count }) or an existing plural key like
t('attempt', { count: tooltip.count })) so the tooltip text is internationalized
and consistent with the rest of the component.

Comment on lines +183 to +185
<span className="text-[10px] font-bold tracking-widest text-gray-400 uppercase mt-0.5">
Score
</span>
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

Hardcoded English string "Score" in a localized component.

This component uses useTranslations throughout, but line 184 renders a hardcoded "Score" label. Use the existing translation key instead.

 <span className="text-[10px] font-bold tracking-widest text-gray-400 uppercase mt-0.5">
-  Score
+  {t('avgScore')}
 </span>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/dashboard/StatsCard.tsx` around lines 183 - 185, The
hardcoded "Score" string in the StatsCard component should be replaced with the
existing localization call; locate the StatsCard function/component where
useTranslations is imported/used and swap the literal "Score" span content for
the translation lookup (e.g. t('score') or the component's existing namespace
like t('dashboard.score')) so the label uses the same useTranslations instance
as the rest of the component.

<div className="flex flex-row justify-center gap-12 sm:gap-24 text-center">
<div className="flex flex-col items-center">
<span className="mb-2 text-sm font-bold uppercase tracking-widest text-gray-400 dark:text-gray-500">
{t('accuracy', { fallback: 'Accuracy' })}
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find locale files (typically in public/locales or src/locales)
echo "=== Searching for locale files ==="
fd -t f -e json . --full-path | head -20

Repository: DevLoversTeam/devlovers.net

Length of output: 1322


🏁 Script executed:

#!/bin/bash
# Look for messages directory structure
find . -type d -name "messages" -o -name "locales" -o -name "i18n" 2>/dev/null | head -10

Repository: DevLoversTeam/devlovers.net

Length of output: 106


🏁 Script executed:

#!/bin/bash
# Search for where next-intl configuration might be
rg -t ts -t tsx "createTranslator|getTranslator|useTranslations" --max-count 5 -A 2

Repository: DevLoversTeam/devlovers.net

Length of output: 98


🏁 Script executed:

#!/bin/bash
# Find quiz-related translation keys in JSON files
rg -t json "accuracy|integrity|violationsLabel" --max-count 10 -B 2 -A 2

Repository: DevLoversTeam/devlovers.net

Length of output: 50383


🏁 Script executed:

#!/bin/bash
# Check QuizResult.tsx around the affected lines
cat -n frontend/components/quiz/QuizResult.tsx | sed -n '100,130p'

Repository: DevLoversTeam/devlovers.net

Length of output: 2249


🏁 Script executed:

#!/bin/bash
# Search for how next-intl is used in the codebase
rg "useTranslations|getTranslations|import.*next-intl" -A 3 --max-count 5

Repository: DevLoversTeam/devlovers.net

Length of output: 50383


🏁 Script executed:

#!/bin/bash
# Check i18n configuration
cat frontend/i18n/routing.ts 2>/dev/null || cat frontend/i18n.ts 2>/dev/null || find frontend/i18n -type f -name "*.ts" -o -name "*.tsx" | head -5

Repository: DevLoversTeam/devlovers.net

Length of output: 416


🏁 Script executed:

#!/bin/bash
# Check i18n/request.ts configuration
cat -n frontend/i18n/request.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 574


🏁 Script executed:

#!/bin/bash
# Search for any fallback or error configuration in next-intl setup
rg "getMessageFallback|onError|fallback" frontend/i18n/ -A 3 -B 1

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

#!/bin/bash
# Check package.json for next-intl version
rg "\"next-intl\"" frontend/package.json

Repository: DevLoversTeam/devlovers.net

Length of output: 97


Remove incorrect { fallback: '...' } parameter from t() calls.

next-intl's t() second argument is for ICU message interpolation values, not a fallback mechanism. Passing { fallback: 'Accuracy' } will be silently ignored since the translation string has no {fallback} placeholder.

Since the translation keys (accuracy, integrity, violationsLabel) exist in all locale files, simply remove the second argument:

♻️ Suggested fix
-              {t('accuracy', { fallback: 'Accuracy' })}
+              {t('accuracy')}
-              {t('integrity', { fallback: 'Integrity' })}
+              {t('integrity')}
-              {t('violationsLabel', { fallback: 'Violations' })}
+              {t('violationsLabel')}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/quiz/QuizResult.tsx` at line 106, The review flags
incorrect usage of next-intl's t() in QuizResult: remove the second argument
object ({ fallback: '...' }) from all t() calls since the second param is for
ICU interpolation, not fallback text; update the t invocations in the QuizResult
component (e.g., t('accuracy', { fallback: 'Accuracy' }), t('integrity', {
fallback: 'Integrity' }), t('violationsLabel', { fallback: 'Violations' })) to
just t('accuracy'), t('integrity'), and t('violationsLabel') respectively so
translations rely on the locale files.

Comment on lines 1088 to 1109
"profile": {
"defaultName": "Developer",
"defaultRole": "user",
"sponsor": "Sponsor",
"becomeSponsor": "Become a Sponsor",
"supportAgain": "Show More Love",
"sponsorThanks": "Thank you for your support!",
"sponsorMore": "Support even more",
"totalPoints": "Total Points",
"joined": "Joined"
"totalPoints": "Points",
"joined": "Joined",
"globalRank": "Global Rank",
"dayStreak": "Day Streak",
"daysStreak": "Days Streak",
"settings": "Settings",
"editProfile": "Edit Profile",
"changeName": "Change Name",
"saveChanges": "Save changes",
"changePassword": "Change Password",
"currentPassword": "Current password",
"newPassword": "New password",
"saving": "Saving..."
},
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Compare dashboard.profile keys between en.json and pl.json
echo "=== en.json dashboard.profile keys ==="
cat frontend/messages/en.json | python3 -c "
import json, sys
data = json.load(sys.stdin)
keys = sorted(data.get('dashboard', {}).get('profile', {}).keys())
for k in keys: print(k)
"

echo ""
echo "=== pl.json dashboard.profile keys ==="
cat frontend/messages/pl.json | python3 -c "
import json, sys
data = json.load(sys.stdin)
keys = sorted(data.get('dashboard', {}).get('profile', {}).keys())
for k in keys: print(k)
"

echo ""
echo "=== Keys comparison ==="
python3 << 'PYEOF'
import json
en = json.load(open('frontend/messages/en.json'))
pl = json.load(open('frontend/messages/pl.json'))
en_keys = set(en.get('dashboard', {}).get('profile', {}).keys())
pl_keys = set(pl.get('dashboard', {}).get('profile', {}).keys())
print('In en.json only:', sorted(en_keys - pl_keys))
print('In pl.json only:', sorted(pl_keys - en_keys))
PYEOF

Repository: DevLoversTeam/devlovers.net

Length of output: 761


Missing translation keys: en.json and pl.json have divergent key sets under dashboard.profile.

The profile sections are inconsistent across locales:

  • en.json defines defaultName, defaultRole, sponsor — missing from pl.json
  • pl.json defines points, quizzesTaken, subtitle, title — missing from en.json

This will cause next-intl fallback errors when these keys are referenced, potentially displaying raw key names to users instead of translated text.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/messages/en.json` around lines 1088 - 1109, The dashboard.profile
translation keys differ between locales; ensure both en.json and pl.json contain
the same key set by adding the missing keys: in en.json add defaultName,
defaultRole, sponsor (and any sponsor-related keys like becomeSponsor,
sponsorThanks, sponsorMore) and in pl.json add points, quizzesTaken, subtitle,
title (and mirror other keys such as joined, globalRank, dayStreak/daysStreak,
settings, editProfile, changeName, saveChanges, changePassword, currentPassword,
newPassword, saving) so that dashboard.profile has an identical set across
locales; locate the dashboard.profile object in both files and add the missing
keys with appropriate translations to prevent next-intl fallback errors.

Comment on lines +1306 to +1309
"deep_diver": {
"name": "Глибокий ныряльщик",
"desc": "Середній результат 80%+ за 10+ квізів!",
"hint": "Тримай середній результат 80%+ у 10+ спробах"
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

Typo / language mix: "ныряльщик" contains Russian characters.

"Глибокий ныряльщик" — the letter ы doesn't exist in the Ukrainian alphabet (it's Russian). The correct Ukrainian would be something like "Глибокий пірнальник" or "Глибоководний дослідник".

-        "name": "Глибокий ныряльщик",
+        "name": "Глибокий пірнальник",
📝 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
"deep_diver": {
"name": "Глибокий ныряльщик",
"desc": "Середній результат 80%+ за 10+ квізів!",
"hint": "Тримай середній результат 80%+ у 10+ спробах"
"deep_diver": {
"name": "Глибокий пірнальник",
"desc": "Середній результат 80%+ за 10+ квізів!",
"hint": "Тримай середній результат 80%+ у 10+ спробах"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/messages/uk.json` around lines 1306 - 1309, The "deep_diver"
translation key uses the Russian letter "ы" in the name value ("Глибокий
ныряльщик"); update the "name" for the deep_diver entry to a correct Ukrainian
term (e.g., replace "Глибокий ныряльщик" with "Глибокий пірнальник" or
"Глибоководний дослідник") while leaving "desc" and "hint" intact and ensuring
proper UTF-8 characters.

@ViktorSvertoka ViktorSvertoka merged commit 2532b26 into develop Feb 21, 2026
10 of 11 checks passed
@ViktorSvertoka ViktorSvertoka deleted the feat/dashboard-stats-achievements branch February 21, 2026 14:34
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (3)
frontend/lib/achievements.ts (1)

110-115: deep_diver progress can visibly regress when the attempt count crosses 10.

The progress expression switches from the attempts dimension (totalAttempts/10) to the score dimension (averageScore/80) the moment totalAttempts reaches 10. A user at 9 attempts with a 70% average sees 90% progress; after completing their 10th attempt that same 70% average gives them only 87.5%. Taking the minimum of both conditions' completion never regresses and correctly represents the bottleneck dimension.

♻️ Proposed fix
 case 'deep_diver':
   earned   = stats.totalAttempts >= 10 && stats.averageScore >= 80;
-  progress = stats.totalAttempts < 10
-    ? Math.min((stats.totalAttempts / 10) * 100, 100)
-    : Math.min((stats.averageScore   / 80) * 100, 100);
+  progress = Math.min(
+    (stats.totalAttempts / 10) * 100,
+    (stats.averageScore  / 80) * 100,
+    100
+  );
   break;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/achievements.ts` around lines 110 - 115, The progress
calculation for the 'deep_diver' case in frontend/lib/achievements.ts
incorrectly switches from attempts to score at totalAttempts >= 10 causing
visible regressions; update the progress logic in the 'deep_diver' branch (where
earned and progress are set) to compute progress as the minimum of the
normalized attempts and normalized average score (e.g., attempts progress =
totalAttempts/10 capped at 100, score progress = averageScore/80 capped at 100)
so progress never decreases when totalAttempts crosses 10 while keeping the
earned condition (stats.totalAttempts >= 10 && stats.averageScore >= 80)
unchanged.
frontend/app/[locale]/dashboard/page.tsx (2)

119-121: getMonth() returns 0-indexed values — date strings look like "2025-0-15" for January.

This is not a correctness bug here since toDateStr is applied consistently to both stored dates and the checkDate walker, so the streak equality check still works. However, the non-standard format is surprising, will produce confusing output if ever logged, and diverges from the calendar-day intent described in the comment above.

♻️ Proposed fix
 const toDateStr = (d: Date) =>
-  `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
+  `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
🤖 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 119 - 121, toDateStr
currently uses d.getMonth() which is 0-indexed so month values are off (e.g.,
"2025-0-15"); update the toDateStr function to use d.getMonth() + 1 (and
optionally pad month/day if you want fixed-width) so the produced calendar-day
strings reflect human months correctly; ensure the change is applied where
toDateStr is used (streak calculation / checkDate walker) to maintain consistent
comparisons.

56-117: Multiple independent awaits that should be parallelized — externally-hitting GitHub calls add latency.

getSponsors / getAllSponsors, and separately getUserQuizStats / getUserLastAttemptPerQuiz / getUserGlobalRank, are all independent of each other. The two GitHub API calls (resolveGitHubLogin, checkHasStarredRepo) are necessarily serial with respect to each other, but can run after sponsor data is already fetched.

⚡ Proposed parallel data-fetching sketch
-  const sponsors    = await getSponsors();
-  const allSponsors = await getAllSponsors();
+  const [sponsors, allSponsors] = await Promise.all([getSponsors(), getAllSponsors()]);

   // ... findSponsor / githubLogin resolution as before ...

-  const attempts     = await getUserQuizStats(session.id);
-  const lastAttempts = await getUserLastAttemptPerQuiz(session.id, locale);
   ...
-  const globalRank = await getUserGlobalRank(session.id);
+  const [attempts, lastAttempts, globalRank] = await Promise.all([
+    getUserQuizStats(session.id),
+    getUserLastAttemptPerQuiz(session.id, locale),
+    getUserGlobalRank(session.id),
+  ]);
🤖 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 56 - 117, Multiple
independent awaits are executed serially causing extra latency; run independent
fetches in parallel using Promise.all: fetch getSponsors and getAllSponsors
together to produce sponsors/allSponsors (then call findSponsor to produce
matchedSponsor/everSponsor), and in parallel fetch getUserQuizStats,
getUserLastAttemptPerQuiz, and getUserGlobalRank to compute
attempts/lastAttempts/totalAttempts/averageScore/lastActiveDate/globalRank.
After resolving matchedSponsor, only then run the GitHub-specific flow: if
matchedSponsor.login use it, else if user.provider === 'github' call
resolveGitHubLogin and then (sequentially) call checkHasStarredRepo with the
resolved githubLogin; avoid awaiting checkHasStarredRepo until githubLogin is
known. Use Promise.all for independent groups (sponsors group, quiz/stats group)
to minimize overall latency.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/app/`[locale]/dashboard/page.tsx:
- Around line 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.
- Around line 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.
- Around line 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.

---

Duplicate comments:
In `@frontend/lib/achievements.ts`:
- Around line 120-126: The mapping that builds EarnedAchievement objects should
intentionally set earnedAt to undefined (replacing the prior MOCK_DATE); keep
the returned object shape as return { ...a, earned, progress, earnedAt:
undefined } and ensure the EarnedAchievement TypeScript type allows earnedAt to
be optional or undefined so typings and tests align until real
persist-and-retrieve logic is implemented; do not reintroduce the mock date and
update any unit tests that asserted the old MOCK_DATE behavior to expect
undefined instead.

---

Nitpick comments:
In `@frontend/app/`[locale]/dashboard/page.tsx:
- Around line 119-121: toDateStr currently uses d.getMonth() which is 0-indexed
so month values are off (e.g., "2025-0-15"); update the toDateStr function to
use d.getMonth() + 1 (and optionally pad month/day if you want fixed-width) so
the produced calendar-day strings reflect human months correctly; ensure the
change is applied where toDateStr is used (streak calculation / checkDate
walker) to maintain consistent comparisons.
- Around line 56-117: Multiple independent awaits are executed serially causing
extra latency; run independent fetches in parallel using Promise.all: fetch
getSponsors and getAllSponsors together to produce sponsors/allSponsors (then
call findSponsor to produce matchedSponsor/everSponsor), and in parallel fetch
getUserQuizStats, getUserLastAttemptPerQuiz, and getUserGlobalRank to compute
attempts/lastAttempts/totalAttempts/averageScore/lastActiveDate/globalRank.
After resolving matchedSponsor, only then run the GitHub-specific flow: if
matchedSponsor.login use it, else if user.provider === 'github' call
resolveGitHubLogin and then (sequentially) call checkHasStarredRepo with the
resolved githubLogin; avoid awaiting checkHasStarredRepo until githubLogin is
known. Use Promise.all for independent groups (sponsors group, quiz/stats group)
to minimize overall latency.

In `@frontend/lib/achievements.ts`:
- Around line 110-115: The progress calculation for the 'deep_diver' case in
frontend/lib/achievements.ts incorrectly switches from attempts to score at
totalAttempts >= 10 causing visible regressions; update the progress logic in
the 'deep_diver' branch (where earned and progress are set) to compute progress
as the minimum of the normalized attempts and normalized average score (e.g.,
attempts progress = totalAttempts/10 capped at 100, score progress =
averageScore/80 capped at 100) so progress never decreases when totalAttempts
crosses 10 while keeping the earned condition (stats.totalAttempts >= 10 &&
stats.averageScore >= 80) unchanged.

Comment on lines +70 to +75
if (
userImage &&
s.avatarUrl &&
s.avatarUrl.trim().length > 0 &&
userImage.includes(s.avatarUrl.split('?')[0])
) return true;
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.

Comment on lines +192 to +196
const hasNightOwl = attempts.some((a) => {
if (!a.completedAt) return false;
const hour = new Date(a.completedAt).getHours();
return hour >= 0 && hour < 5;
});
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.

Comment on lines +198 to +210
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,
});
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants