;
}) {
const cellClass =
- 'px-2 sm:px-6 py-3 sm:py-4 border-b border-slate-100 dark:border-white/5';
+ 'px-2 sm:px-6 py-3 sm:py-4 border-b border-gray-200/50 dark:border-white/5';
const leftBorderClass = 'border-l border-l-transparent';
const rightBorderClass = 'border-r border-r-transparent';
@@ -126,7 +126,7 @@ function TableRow({
'group transition-all duration-300',
isCurrentUser
? 'bg-[color-mix(in_srgb,var(--accent-primary),transparent_90%)] shadow-inner'
- : 'hover:bg-slate-50/60 dark:hover:bg-white/4'
+ : 'hover:bg-white/30 dark:hover:bg-white/5'
)}
>
@@ -142,7 +142,7 @@ function TableRow({
'relative h-8 w-8 shrink-0 overflow-hidden rounded-full border transition-all duration-300 sm:h-10 sm:w-10',
isCurrentUser
? 'border-(--accent-primary) shadow-[0_0_1px_var(--accent-primary)]'
- : 'border-slate-200 group-hover:border-(--accent-primary) dark:border-white/10'
+ : 'border-white/20 group-hover:border-(--accent-primary) dark:border-white/10'
)}
>
{user.username}
@@ -216,7 +216,7 @@ function TableRow({
'inline-block font-mono font-bold transition-all',
isCurrentUser
? 'scale-110 text-sm text-(--accent-primary) drop-shadow-sm sm:text-lg'
- : 'text-sm text-slate-700 group-hover:scale-105 sm:text-base dark:text-slate-300'
+ : 'text-sm text-gray-800 group-hover:scale-105 sm:text-base dark:text-gray-200'
)}
>
{user.points.toLocaleString()}
@@ -229,8 +229,8 @@ function TableRow({
function RankBadge({ rank }: { rank: number }) {
if (rank === 1) {
return (
-
-
+
+
1
@@ -240,7 +240,7 @@ function RankBadge({ rank }: { rank: number }) {
}
if (rank === 2) {
return (
-
+
2
@@ -250,8 +250,8 @@ function RankBadge({ rank }: { rank: number }) {
}
if (rank === 3) {
return (
-
-
+
+
3
@@ -259,7 +259,7 @@ function RankBadge({ rank }: { rank: number }) {
);
}
return (
-
+
{rank}
);
diff --git a/frontend/components/quiz/QuizCard.tsx b/frontend/components/quiz/QuizCard.tsx
index 610410c1..c957469e 100644
--- a/frontend/components/quiz/QuizCard.tsx
+++ b/frontend/components/quiz/QuizCard.tsx
@@ -112,15 +112,17 @@ export function QuizCard({ quiz, userProgress }: QuizCardProps) {
{userProgress && (
-
+
- {t('best')} {userProgress.bestScore}/{userProgress.totalQuestions}
+ {t('best')} {userProgress.bestScore}/{userProgress.totalQuestions}
-
- {userProgress.attemptsCount}{' '}
+ ·
+
+ {userProgress.attemptsCount}{' '}
{userProgress.attemptsCount === 1 ? t('attempt') : t('attempts')}
-
+ ·
+
{percentage}%
diff --git a/frontend/components/quiz/QuizResult.tsx b/frontend/components/quiz/QuizResult.tsx
index 5caf8581..8aa4ef7f 100644
--- a/frontend/components/quiz/QuizResult.tsx
+++ b/frontend/components/quiz/QuizResult.tsx
@@ -100,13 +100,30 @@ export function QuizResult({
{motivation.icon}
{!isIncomplete && (
<>
-
-
- {score} / {total}
-
-
- {percentage.toFixed(0)}% {t('correctAnswers')}
-
+
+
+
+ {t('accuracy', { fallback: 'Accuracy' })}
+
+
+ {percentage.toFixed(0)}%
+
+
+ {score} / {total} {t('correctAnswers')}
+
+
+
+
+
+ {t('integrity', { fallback: 'Integrity' })}
+
+
+ {Math.max(0, 100 - (violationsCount || 0) * 10)}%
+
+
+ {violationsCount || 0} {t('violationsLabel', { fallback: 'Violations' })}
+
+
diff --git a/frontend/db/queries/users.ts b/frontend/db/queries/users.ts
index d9370a1b..b447404f 100644
--- a/frontend/db/queries/users.ts
+++ b/frontend/db/queries/users.ts
@@ -16,6 +16,8 @@ export const getUserProfile = cache(async (userId: string) => {
email: true,
image: true,
role: true,
+ provider: true,
+ providerId: true,
createdAt: true,
},
with: {
@@ -40,3 +42,31 @@ export const getUserProfile = cache(async (userId: string) => {
points: Number(pointsResult[0]?.total) || 0,
};
});
+
+export const getUserGlobalRank = cache(async (userId: string) => {
+ // Get all users' total points by grouping transactions
+ const rankQuery = sql`
+ WITH user_scores AS (
+ SELECT user_id, COALESCE(SUM(points), 0) as total_points
+ FROM point_transactions
+ GROUP BY user_id
+ ),
+ ranked_users AS (
+ SELECT user_id, total_points,
+ RANK() OVER (ORDER BY total_points DESC) as rank
+ FROM user_scores
+ )
+ SELECT rank
+ FROM ranked_users
+ WHERE user_id = ${userId}
+ `;
+
+ const result = await db.execute(rankQuery);
+ const rankRow = (result as { rows: any[] }).rows[0];
+
+ if (!rankRow || !rankRow.rank) {
+ return null;
+ }
+
+ return Number(rankRow.rank);
+});
diff --git a/frontend/lib/about/github-sponsors.ts b/frontend/lib/about/github-sponsors.ts
index 18d6af87..c5781328 100644
--- a/frontend/lib/about/github-sponsors.ts
+++ b/frontend/lib/about/github-sponsors.ts
@@ -21,7 +21,7 @@ function getTierDetails(amount: number): {
return { name: '☕ Coffee Support', color: 'bronze' };
}
-export async function getSponsors(): Promise {
+async function fetchSponsors(activeOnly: boolean): Promise {
const token = process.env.GITHUB_SPONSORS_TOKEN;
if (!token) {
@@ -32,7 +32,7 @@ export async function getSponsors(): Promise {
const query = `
query {
organization(login: "DevLoversTeam") {
- sponsorshipsAsMaintainer(first: 100, orderBy: {field: CREATED_AT, direction: DESC}, includePrivate: false) {
+ sponsorshipsAsMaintainer(first: 100, orderBy: {field: CREATED_AT, direction: DESC}, includePrivate: false, activeOnly: ${activeOnly}) {
nodes {
tier { monthlyPriceInDollars }
sponsorEntity {
@@ -53,7 +53,7 @@ export async function getSponsors(): Promise {
'Content-Type': 'application/json',
},
body: JSON.stringify({ query }),
- cache: 'no-store', // Завжди свіжі дані
+ cache: 'no-store',
});
const json = await res.json();
@@ -67,7 +67,7 @@ export async function getSponsors(): Promise {
json.data?.organization?.sponsorshipsAsMaintainer?.nodes || [];
console.log(
- `✅ GitHub: Found ${rawNodes.length} sponsors for Organization`
+ `✅ GitHub: Found ${rawNodes.length} sponsors (activeOnly=${activeOnly})`
);
const sponsors: Sponsor[] = rawNodes
@@ -95,3 +95,16 @@ export async function getSponsors(): Promise {
return [];
}
}
+
+/** Active sponsors only — used for public-facing sponsor displays. */
+export async function getSponsors(): Promise {
+ return fetchSponsors(true);
+}
+
+/**
+ * All sponsors (active + past/cancelled) — used for achievement checks.
+ * A user who donated once and later cancelled still earns the Supporter badge.
+ */
+export async function getAllSponsors(): Promise {
+ return fetchSponsors(false);
+}
diff --git a/frontend/lib/about/stats.ts b/frontend/lib/about/stats.ts
index 2b0ea081..ec988638 100644
--- a/frontend/lib/about/stats.ts
+++ b/frontend/lib/about/stats.ts
@@ -43,7 +43,7 @@ export const getPlatformStats = unstable_cache(
const linkedinCount = process.env.LINKEDIN_FOLLOWER_COUNT
? parseInt(process.env.LINKEDIN_FOLLOWER_COUNT)
- : 1500;
+ : 1600;
let totalUsers = 243;
let solvedTests = 1890;
diff --git a/frontend/lib/achievements.ts b/frontend/lib/achievements.ts
new file mode 100644
index 00000000..28f728a9
--- /dev/null
+++ b/frontend/lib/achievements.ts
@@ -0,0 +1,132 @@
+export type AchievementIconName =
+ | 'Fire'
+ | 'Target'
+ | 'Lightning'
+ | 'Brain'
+ | 'Diamond'
+ | 'Star'
+ | 'Heart'
+ | 'Trophy'
+ | 'Rocket'
+ | 'Crown'
+ | 'Code'
+ | 'Infinity'
+ // batch 2
+ | 'GithubLogo'
+ | 'Medal'
+ | 'Seal'
+ | 'Moon'
+ | 'Shield'
+ | 'Waves';
+
+export interface Achievement {
+ id: string;
+ icon: AchievementIconName;
+ /** gradient colors for the badge circle [from, to] */
+ gradient: [string, string];
+ /** glow color (css color) */
+ glow: string;
+}
+
+export interface EarnedAchievement extends Achievement {
+ earned: boolean;
+ /** 0–100, only meaningful when earned === false */
+ progress?: number;
+ /** Date string when the achievement was earned */
+ earnedAt?: string;
+}
+
+export const ACHIEVEMENTS: Achievement[] = [
+ // ── Group 1: Quiz progression (attempts & points) ─────────────────
+ { id: 'first_blood', icon: 'Fire', gradient: ['#f97316', '#ef4444'], glow: 'rgba(249,115,22,0.55)' },
+ { id: 'on_a_roll', icon: 'Lightning', gradient: ['#eab308', '#f59e0b'], glow: 'rgba(234,179,8,0.55)' },
+ { id: 'rocket_start', icon: 'Rocket', gradient: ['#10b981', '#059669'], glow: 'rgba(16,185,129,0.55)' },
+ { id: 'big_brain', icon: 'Brain', gradient: ['#06b6d4', '#3b82f6'], glow: 'rgba(6,182,212,0.55)' },
+ { id: 'centurion', icon: 'Shield', gradient: ['#10b981', '#14b8a6'], glow: 'rgba(16,185,129,0.6)' },
+ { id: 'endless', icon: 'Infinity', gradient: ['#14b8a6', '#0ea5e9'], glow: 'rgba(20,184,166,0.55)' },
+
+ // ── Group 2: Skill / accuracy ──────────────────────────────────────
+ { id: 'sharpshooter', icon: 'Target', gradient: ['#6366f1', '#8b5cf6'], glow: 'rgba(99,102,241,0.55)' },
+ { id: 'perfectionist', icon: 'Star', gradient: ['#f59e0b', '#fbbf24'], glow: 'rgba(251,191,36,0.55)' },
+ { id: 'diamond_mind', icon: 'Diamond', gradient: ['#67e8f9', '#a78bfa'], glow: 'rgba(167,139,250,0.55)'},
+ { id: 'deep_diver', icon: 'Waves', gradient: ['#0ea5e9', '#6366f1'], glow: 'rgba(14,165,233,0.6)' },
+ { id: 'code_wizard', icon: 'Code', gradient: ['#8b5cf6', '#6366f1'], glow: 'rgba(139,92,246,0.55)' },
+ { id: 'legend', icon: 'Trophy', gradient: ['#d97706', '#b45309'], glow: 'rgba(217,119,6,0.55)' },
+
+ // ── Group 3: Social / special / sponsor ───────────────────────────
+ { id: 'royalty', icon: 'Crown', gradient: ['#f59e0b', '#dc2626'], glow: 'rgba(245,158,11,0.55)' },
+ { id: 'night_owl', icon: 'Moon', gradient: ['#6366f1', '#8b5cf6'], glow: 'rgba(99,102,241,0.6)' },
+ { id: 'star_gazer', icon: 'GithubLogo',gradient: ['#fbbf24', '#f59e0b'], glow: 'rgba(251,191,36,0.7)' },
+ { id: 'supporter', icon: 'Heart', gradient: ['#ec4899', '#f43f5e'], glow: 'rgba(236,72,153,0.55)' },
+ { id: 'silver_patron', icon: 'Medal', gradient: ['#94a3b8', '#e2e8f0'], glow: 'rgba(148,163,184,0.6)' },
+ { id: 'golden_patron', icon: 'Seal', gradient: ['#f59e0b', '#b45309'], glow: 'rgba(245,158,11,0.7)' },
+];
+
+export interface UserStats {
+ totalAttempts: number;
+ averageScore: number;
+ perfectScores: number;
+ highScores: number;
+ isSponsor: boolean;
+ uniqueQuizzes: number;
+ totalPoints: number;
+ topLeaderboard: boolean;
+ hasStarredRepo: boolean;
+ sponsorCount: number;
+ hasNightOwl: boolean;
+}
+
+export function computeAchievements(stats: UserStats): EarnedAchievement[] {
+ // Mock date for demo purposes
+ const MOCK_DATE = new Date().toLocaleDateString('en-US', {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ });
+
+ return ACHIEVEMENTS.map((a) => {
+ let earned = false;
+ let progress = 0;
+
+ switch (a.id) {
+ case 'first_blood': earned = stats.totalAttempts >= 1; progress = earned ? 100 : 0; break;
+ case 'sharpshooter': earned = stats.perfectScores >= 1; progress = earned ? 100 : 0; break;
+ case 'royalty': earned = stats.topLeaderboard; progress = earned ? 100 : 0; break;
+ case 'supporter': earned = stats.isSponsor; progress = earned ? 100 : 0; break;
+ case 'star_gazer': earned = stats.hasStarredRepo; progress = earned ? 100 : 0; break;
+ case 'night_owl': earned = stats.hasNightOwl; progress = earned ? 100 : 0; break;
+
+ case 'on_a_roll': earned = stats.totalAttempts >= 3; progress = Math.min((stats.totalAttempts / 3) * 100, 100); break;
+ case 'big_brain': earned = stats.totalAttempts >= 10; progress = Math.min((stats.totalAttempts / 10) * 100, 100); break;
+ case 'rocket_start': earned = stats.totalAttempts >= 5; progress = Math.min((stats.totalAttempts / 5) * 100, 100); break;
+ case 'diamond_mind': earned = stats.highScores >= 5; progress = Math.min((stats.highScores / 5) * 100, 100); break;
+ case 'perfectionist': earned = stats.perfectScores >= 3; progress = Math.min((stats.perfectScores / 3) * 100, 100); break;
+ case 'legend': earned = stats.uniqueQuizzes >= 10; progress = Math.min((stats.uniqueQuizzes / 10) * 100, 100); break;
+ case 'code_wizard': earned = stats.uniqueQuizzes >= 5; progress = Math.min((stats.uniqueQuizzes / 5) * 100, 100); break;
+ case 'endless': earned = stats.totalPoints >= 1000; progress = Math.min((stats.totalPoints / 1000) * 100, 100); break;
+ case 'centurion': earned = stats.totalPoints >= 100; progress = Math.min((stats.totalPoints / 100) * 100, 100); break;
+ case 'golden_patron': earned = stats.sponsorCount >= 3; progress = Math.min((stats.sponsorCount / 3) * 100, 100); break;
+
+ case 'silver_patron':
+ earned = stats.sponsorCount >= 2;
+ progress = stats.sponsorCount >= 2 ? 100 : stats.sponsorCount >= 1 ? 50 : 0;
+ break;
+
+ 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);
+ break;
+
+ default: earned = false; progress = 0;
+ }
+
+ return {
+ ...a,
+ earned,
+ progress,
+ earnedAt: earned ? MOCK_DATE : undefined,
+ };
+ });
+}
diff --git a/frontend/lib/github-stars.ts b/frontend/lib/github-stars.ts
new file mode 100644
index 00000000..1d799c31
--- /dev/null
+++ b/frontend/lib/github-stars.ts
@@ -0,0 +1,86 @@
+import 'server-only';
+
+const REPO_OWNER = 'DevLoversTeam';
+const REPO_NAME = 'devlovers.net';
+const MAX_PAGES = 10; // caps at 1000 stargazers
+
+function getToken() {
+ return process.env.GITHUB_SPONSORS_TOKEN;
+}
+
+function makeHeaders(): Record {
+ return {
+ Accept: 'application/vnd.github+json',
+ 'X-GitHub-Api-Version': '2022-11-28',
+ Authorization: `Bearer ${getToken()}`,
+ };
+}
+
+/**
+ * Resolves a GitHub login (username) from a numeric provider ID.
+ * Used when the user signed in via GitHub OAuth — the DB stores the numeric
+ * `providerId`, but the stargazers API works with logins.
+ */
+export async function resolveGitHubLogin(providerId: string): Promise {
+ const token = getToken();
+ if (!token || !providerId) return null;
+
+ try {
+ const res = await fetch(`https://api.github.com/user/${providerId}`, {
+ headers: makeHeaders(),
+ next: { revalidate: 3600 },
+ });
+ if (!res.ok) return null;
+ const data: { login?: string } = await res.json();
+ return data.login ?? null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Checks whether a given GitHub login has starred the DevLovers repo.
+ * Uses the GITHUB_SPONSORS_TOKEN (server-side org PAT) — no user token needed.
+ * Paginates through stargazers up to MAX_PAGES × 100 entries.
+ */
+export async function checkHasStarredRepo(
+ githubLogin: string,
+): Promise {
+ const token = getToken();
+ if (!token || !githubLogin) return false;
+
+ const loginLower = githubLogin.toLowerCase();
+
+ for (let page = 1; page <= MAX_PAGES; page++) {
+ const url = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/stargazers?per_page=100&page=${page}`;
+
+ try {
+ const res = await fetch(url, {
+ headers: makeHeaders(),
+ next: { revalidate: 3600 }, // cache 1 hour per page
+ });
+
+ if (!res.ok) {
+ console.warn(`⚠️ GitHub stargazers API error: ${res.status}`);
+ return false;
+ }
+
+ const stargazers: { login: string }[] = await res.json();
+
+ // Empty page = we've exhausted all stargazers
+ if (stargazers.length === 0) return false;
+
+ if (stargazers.some((s) => s.login.toLowerCase() === loginLower)) {
+ return true;
+ }
+
+ // Last page (less than 100 results) — user not found
+ if (stargazers.length < 100) return false;
+ } catch (err) {
+ console.error('❌ Failed to check GitHub stargazers:', err);
+ return false;
+ }
+ }
+
+ return false;
+}
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 1fa74ad5..1cb74c85 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -203,6 +203,9 @@
"hurryUp": "Hurry up!"
},
"result": {
+ "accuracy": "Accuracy",
+ "integrity": "Integrity",
+ "violationsLabel": "Violations",
"correctAnswers": "correct answers",
"timeUp": {
"title": "Time's Up",
@@ -822,24 +825,42 @@
"label": "Auto Sync",
"desc": "Saves progress post-login"
},
- "tracking": { "label": "Tracking", "desc": "Best scores & attempts" }
+ "tracking": {
+ "label": "Tracking",
+ "desc": "Best scores & attempts"
+ }
},
"leaderboard": {
"podium": {
"label": "The Podium",
"desc": "Top 3 exclusive spotlight"
},
- "globalRank": { "label": "Global Rank", "desc": "Compete worldwide" },
+ "globalRank": {
+ "label": "Global Rank",
+ "desc": "Compete worldwide"
+ },
"xpSystem": {
"label": "XP System",
"desc": "Points for every answer"
},
- "liveFeed": { "label": "Live Feed", "desc": "Real-time rank updates" }
+ "liveFeed": {
+ "label": "Live Feed",
+ "desc": "Real-time rank updates"
+ }
},
"profile": {
- "statsHub": { "label": "Stats Hub", "desc": "Visualize your growth" },
- "history": { "label": "History", "desc": "Track learning streaks" },
- "identity": { "label": "Identity", "desc": "Manage role & profile" },
+ "statsHub": {
+ "label": "Stats Hub",
+ "desc": "Visualize your growth"
+ },
+ "history": {
+ "label": "History",
+ "desc": "Track learning streaks"
+ },
+ "identity": {
+ "label": "Identity",
+ "desc": "Manage role & profile"
+ },
"reminders": {
"label": "Reminders",
"desc": "Finish incomplete quizzes"
@@ -850,15 +871,36 @@
"label": "Tech Trends",
"desc": "Stay ahead of the curve"
},
- "tutorials": { "label": "Tutorials", "desc": "Step-by-step guides" },
- "deepDives": { "label": "Deep Dives", "desc": "In-depth analysis" },
- "community": { "label": "Community", "desc": "Written by developers" }
+ "tutorials": {
+ "label": "Tutorials",
+ "desc": "Step-by-step guides"
+ },
+ "deepDives": {
+ "label": "Deep Dives",
+ "desc": "In-depth analysis"
+ },
+ "community": {
+ "label": "Community",
+ "desc": "Written by developers"
+ }
},
"shop": {
- "newDrops": { "label": "New Drops", "desc": "Regular fresh content" },
- "curated": { "label": "Curated", "desc": "Dev-focused collections" },
- "checkout": { "label": "Checkout", "desc": "Seamless Stripe flow" },
- "premium": { "label": "Premium", "desc": "High-quality material" }
+ "newDrops": {
+ "label": "New Drops",
+ "desc": "Regular fresh content"
+ },
+ "curated": {
+ "label": "Curated",
+ "desc": "Dev-focused collections"
+ },
+ "checkout": {
+ "label": "Checkout",
+ "desc": "Seamless Stripe flow"
+ },
+ "premium": {
+ "label": "Premium",
+ "desc": "High-quality material"
+ }
}
}
},
@@ -1033,24 +1075,52 @@
"metaDescription": "Track your progress and quiz performance",
"title": "Dashboard",
"subtitle": "Welcome back to your training ground",
- "supportLink": "Support & Feedback",
+ "supportLink": "Feedback",
"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..."
},
"stats": {
"title": "Quiz Statistics",
- "noActivity": "Ready to level up? Challenge yourself with a new quiz",
- "startQuiz": "Start a Quiz",
+ "scoreDistribution": "Score Distribution",
+ "scoreDistributionSubtext": "Based on your recent attempts",
+ "activityHeatmap": "Activity Heatmap",
"attempts": "Attempts",
- "avgScore": "Avg Score",
- "continueLearning": "Continue Learning"
+ "avgScore": "Average Score",
+ "mastered": "Mastered",
+ "review": "Review",
+ "study": "Study",
+ "totalAttempts": "Quizzes",
+ "noActivity": "You haven't taken any quizzes yet. Start learning and track your progress here!",
+ "startQuiz": "Start your first quiz",
+ "continueLearning": "Continue Learning",
+ "less": "Less",
+ "more": "More",
+ "attemptsInPeriod": "{count} attempts in the period",
+ "last3Months": "Last 3 Months",
+ "last5Months": "Last 5 Months",
+ "last6Months": "Last 6 Months",
+ "lastYear": "This Year",
+ "totalActiveDays": "Active Days",
+ "mostActiveMonth": "Active Month"
},
"quizSaved": {
"title": "Quiz result saved!",
@@ -1066,6 +1136,7 @@
"noAttempts": "You haven't taken any quizzes yet",
"startQuiz": "Try one",
"score": "Score",
+ "accuracy": "Accuracy",
"integrity": "Integrity",
"points": "Points",
"scoreHint": "Number of correct answers out of total questions",
@@ -1119,9 +1190,112 @@
"messagePlaceholder": "Tell us what you think...",
"submit": "Send Feedback",
"submitting": "Sending...",
+ "attachFile": "Attach File",
"success": "Thank you! Your feedback has been sent.",
"error": "Something went wrong. Please try again.",
"requiredField": "Please fill out this field."
+ },
+ "achievements": {
+ "title": "Achievements",
+ "subtitle": "{earned} of {total} earned",
+ "ui": {
+ "expand": "View all",
+ "collapse": "Collapse",
+ "clickInfo": "Click for info",
+ "clickBack": "Click to flip back"
+ },
+ "badges": {
+ "first_blood": {
+ "name": "First Blood",
+ "desc": "Completed your first quiz!",
+ "hint": "Complete 1 quiz to unlock"
+ },
+ "sharpshooter": {
+ "name": "Sharpshooter",
+ "desc": "Scored 100% on a quiz!",
+ "hint": "Score 100% on any quiz"
+ },
+ "on_a_roll": {
+ "name": "On a Roll",
+ "desc": "Completed 3 quizzes!",
+ "hint": "Complete 3 quizzes to unlock"
+ },
+ "big_brain": {
+ "name": "Big Brain",
+ "desc": "Completed 10 quizzes!",
+ "hint": "Complete 10 quizzes to unlock"
+ },
+ "diamond_mind": {
+ "name": "Diamond Mind",
+ "desc": "Scored 90%+ on 5 quizzes!",
+ "hint": "Score 90%+ on 5 quizzes"
+ },
+ "perfectionist": {
+ "name": "Perfectionist",
+ "desc": "3 perfect scores achieved!",
+ "hint": "Score 100% on 3 quizzes"
+ },
+ "supporter": {
+ "name": "Supporter",
+ "desc": "Thank you for sponsoring!",
+ "hint": "Become a GitHub sponsor"
+ },
+ "legend": {
+ "name": "Legend",
+ "desc": "Completed 10 unique quizzes!",
+ "hint": "Complete 10 different quizzes"
+ },
+ "rocket_start": {
+ "name": "Rocket Start",
+ "desc": "Completed 5 quizzes!",
+ "hint": "Complete 5 quizzes to unlock"
+ },
+ "royalty": {
+ "name": "Royalty",
+ "desc": "Reached the top of the leaderboard!",
+ "hint": "Reach the top leaderboard position"
+ },
+ "code_wizard": {
+ "name": "Code Wizard",
+ "desc": "Mastered 5 different quiz topics!",
+ "hint": "Complete 5 unique quiz topics"
+ },
+ "endless": {
+ "name": "Endless",
+ "desc": "Earned 1000+ total points!",
+ "hint": "Earn 1000 total points"
+ },
+ "star_gazer": {
+ "name": "Star Gazer",
+ "desc": "You starred the DevLovers repo on GitHub!",
+ "hint": "Star our GitHub repository to unlock"
+ },
+ "silver_patron": {
+ "name": "Silver Patron",
+ "desc": "You've sponsored us twice — thank you!",
+ "hint": "Sponsor DevLovers a 2nd time to unlock"
+ },
+ "golden_patron": {
+ "name": "Golden Patron",
+ "desc": "You've sponsored us 3+ times — legendary!",
+ "hint": "Sponsor DevLovers a 3rd time to unlock"
+ },
+ "night_owl": {
+ "name": "Night Owl",
+ "desc": "Completed a quiz after midnight!",
+ "hint": "Complete a quiz after midnight"
+ },
+ "centurion": {
+ "name": "Centurion",
+ "desc": "Reached 100 total points!",
+ "hint": "Earn 100 total points"
+ },
+ "deep_diver": {
+ "name": "Deep Diver",
+ "desc": "Averaged 80%+ across 10+ quizzes!",
+ "hint": "Average 80%+ across 10 or more attempts"
+ }
+ }
}
},
"aiHelper": {
@@ -1294,4 +1468,4 @@
}
}
}
-}
+}
\ No newline at end of file
diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json
index 3fa68c36..44a7039e 100644
--- a/frontend/messages/pl.json
+++ b/frontend/messages/pl.json
@@ -203,6 +203,9 @@
"hurryUp": "Pośpiesz się!"
},
"result": {
+ "accuracy": "Skuteczność",
+ "integrity": "Uczciwość",
+ "violationsLabel": "Naruszenia",
"correctAnswers": "poprawnych odpowiedzi",
"timeUp": {
"title": "Czas Minął",
@@ -792,7 +795,10 @@
},
"bubbles": {
"qa": {
- "languages": { "label": "3 języki", "desc": "EN, UK i PL" },
+ "languages": {
+ "label": "3 języki",
+ "desc": "EN, UK i PL"
+ },
"aiHelper": {
"label": "Pomocnik AI",
"desc": "Zaznacz tekst do wyjaśnienia"
@@ -847,7 +853,10 @@
"label": "Centrum statystyk",
"desc": "Wizualizuj swój rozwój"
},
- "history": { "label": "Historia", "desc": "Śledź serie nauki" },
+ "history": {
+ "label": "Historia",
+ "desc": "Śledź serie nauki"
+ },
"identity": {
"label": "Tożsamość",
"desc": "Zarządzaj rolą i profilem"
@@ -876,7 +885,10 @@
}
},
"shop": {
- "newDrops": { "label": "Nowości", "desc": "Regularne aktualizacje" },
+ "newDrops": {
+ "label": "Nowości",
+ "desc": "Regularne aktualizacje"
+ },
"curated": {
"label": "Selekcja",
"desc": "Kolekcje dla developerów"
@@ -885,7 +897,10 @@
"label": "Płatność",
"desc": "Płynny proces przez Stripe"
},
- "premium": { "label": "Premium", "desc": "Wysoka jakość materiałów" }
+ "premium": {
+ "label": "Premium",
+ "desc": "Wysoka jakość materiałów"
+ }
}
}
},
@@ -1060,24 +1075,53 @@
"metaDescription": "Śledź swój postęp i wyniki quizów",
"title": "Panel użytkownika",
"subtitle": "Witaj z powrotem na swoim placu treningowym",
- "supportLink": "Wsparcie i opinie",
+ "supportLink": "Opinie",
"profile": {
- "defaultName": "Programista",
- "defaultRole": "użytkownik",
- "sponsor": "Sponsor",
+ "title": "Profil",
+ "subtitle": "Zarządzaj swoim profilem publicznym i preferencjami",
"becomeSponsor": "Zostań sponsorem",
+ "supportAgain": "Okaż więcej wsparcia",
+ "points": "Pkt",
+ "quizzesTaken": "Quizy",
+ "joined": "Dołączył",
+ "globalRank": "Miejsce",
+ "dayStreak": "Dzień z rzędu",
+ "daysStreak": "Dni z rzędu",
"sponsorThanks": "Dziękujemy za wsparcie!",
"sponsorMore": "Wesprzyj jeszcze więcej",
"totalPoints": "Łączna liczba punktów",
- "joined": "Dołączył"
+ "settings": "Ustawienia",
+ "editProfile": "Edytuj profil",
+ "changeName": "Zmień imię",
+ "saveChanges": "Zapisz zmiany",
+ "changePassword": "Zmień hasło",
+ "currentPassword": "Aktualne hasło",
+ "newPassword": "Nowe hasło",
+ "saving": "Zapisywanie..."
},
"stats": {
"title": "Statystyki quizów",
- "noActivity": "Gotowy na rozwój? Sprawdź się w nowym quizie",
- "startQuiz": "Rozpocznij quiz",
+ "scoreDistribution": "Rozkład wyników",
+ "scoreDistributionSubtext": "Na podstawie ostatnich podejść",
+ "activityHeatmap": "Wykres aktywności",
"attempts": "Próby",
"avgScore": "Średni wynik",
- "continueLearning": "Kontynuuj naukę"
+ "mastered": "Opanowane",
+ "review": "Wymaga powtórki",
+ "study": "Nauka",
+ "totalAttempts": "Wszystkie próby",
+ "noActivity": "Nie rozwiązałeś jeszcze żadnego quizu. Rozpocznij naukę i śledź swoje postępy tutaj!",
+ "startQuiz": "Rozpocznij pierwszy quiz",
+ "continueLearning": "Kontynuuj naukę",
+ "less": "Mniej",
+ "more": "Więcej",
+ "attemptsInPeriod": "{count} prób w tym okresie",
+ "last3Months": "Ostatnie 3 miesiące",
+ "last5Months": "Ostatnie 5 miesięcy",
+ "last6Months": "Ostatnie 6 miesięcy",
+ "lastYear": "Ten rok",
+ "totalActiveDays": "Aktywne dni",
+ "mostActiveMonth": "Aktywny miesiąc"
},
"quizSaved": {
"title": "Wynik quizu zapisany!",
@@ -1093,6 +1137,7 @@
"noAttempts": "Nie przeszedłeś jeszcze żadnego quizu",
"startQuiz": "Spróbuj",
"score": "Wynik",
+ "accuracy": "Skuteczność",
"integrity": "Czystość",
"points": "Punkty",
"scoreHint": "Liczba poprawnych odpowiedzi z ogólnej liczby",
@@ -1146,9 +1191,112 @@
"messagePlaceholder": "Powiedz nam, co myślisz...",
"submit": "Wyślij opinię",
"submitting": "Wysyłanie...",
+ "attachFile": "Dołącz plik",
"success": "Dziękujemy! Twoja opinia została wysłana.",
"error": "Coś poszło nie tak. Spróbuj ponownie.",
"requiredField": "Proszę wypełnić to pole."
+ },
+ "achievements": {
+ "title": "Osiągnięcia",
+ "subtitle": "{earned} z {total} odblokowanych",
+ "ui": {
+ "expand": "Zobacz wszystkie",
+ "collapse": "Zwiń",
+ "clickInfo": "Kliknij po info",
+ "clickBack": "Kliknij, aby wrócić"
+ },
+ "badges": {
+ "first_blood": {
+ "name": "Pierwszy krok",
+ "desc": "Ukończyłeś pierwszy quiz!",
+ "hint": "Ukończ 1 quiz"
+ },
+ "sharpshooter": {
+ "name": "Snajper",
+ "desc": "100% w quizie!",
+ "hint": "Zdobądź 100% w dowolnym quizie"
+ },
+ "on_a_roll": {
+ "name": "W biegu",
+ "desc": "Ukończyłeś 3 quizy!",
+ "hint": "Ukończ 3 quizy"
+ },
+ "big_brain": {
+ "name": "Wielki mózg",
+ "desc": "Ukończyłeś 10 quizów!",
+ "hint": "Ukończ 10 quizów"
+ },
+ "diamond_mind": {
+ "name": "Diamentowy umysł",
+ "desc": "90%+ w 5 quizach!",
+ "hint": "Zdobądź 90%+ w 5 quizach"
+ },
+ "perfectionist": {
+ "name": "Perfekcjonista",
+ "desc": "3 idealne wyniki!",
+ "hint": "Zdobądź 100% w 3 quizach"
+ },
+ "supporter": {
+ "name": "Wspierający",
+ "desc": "Dziękujemy za wsparcie!",
+ "hint": "Zostań sponsorem na GitHub"
+ },
+ "legend": {
+ "name": "Legenda",
+ "desc": "10 różnych quizów ukończonych!",
+ "hint": "Ukończ 10 różnych quizów"
+ },
+ "rocket_start": {
+ "name": "Rakietowy Start",
+ "desc": "Ukończyłeś 5 quizów!",
+ "hint": "Ukończ 5 quizów"
+ },
+ "royalty": {
+ "name": "Royalty",
+ "desc": "Dotarłeś na szczyt rankingu!",
+ "hint": "Zajmij pierwsze miejsce w rankingu"
+ },
+ "code_wizard": {
+ "name": "Czarodziej Kodu",
+ "desc": "Opanowałeś 5 różnych tematów!",
+ "hint": "Ukończ 5 różnych tematów quizów"
+ },
+ "endless": {
+ "name": "Nieskończony",
+ "desc": "Zdobyłeś 1000+ punktów!",
+ "hint": "Zdobądź 1000 punktów"
+ },
+ "star_gazer": {
+ "name": "Gwiazdozbiór",
+ "desc": "Dodałeś gwiazdkę do repozytorium DevLovers na GitHub!",
+ "hint": "Dodaj gwiazdkę do naszego repozytorium GitHub"
+ },
+ "silver_patron": {
+ "name": "Srebrny Patron",
+ "desc": "Sponsorowałeś nas dwukrotnie — dziękujemy!",
+ "hint": "Sponsoruj DevLovers po raz drugi"
+ },
+ "golden_patron": {
+ "name": "Złoty Patron",
+ "desc": "Sponsorowałeś nas 3+ razy — legendarne!",
+ "hint": "Sponsoruj DevLovers po raz trzeci"
+ },
+ "night_owl": {
+ "name": "Nocna Sowa",
+ "desc": "Ukończyłeś quiz po północy!",
+ "hint": "Ukończ quiz po północy"
+ },
+ "centurion": {
+ "name": "Centurion",
+ "desc": "Osiągnąłeś 100 punktów!",
+ "hint": "Zdobądź 100 punktów"
+ },
+ "deep_diver": {
+ "name": "Głębinowiec",
+ "desc": "Średnia 80%+ przez 10+ quizów!",
+ "hint": "Utrzymaj średnią 80%+ w 10 lub więcej próbach"
+ }
+ }
}
},
"aiHelper": {
@@ -1321,4 +1469,4 @@
}
}
}
-}
+}
\ No newline at end of file
diff --git a/frontend/messages/uk.json b/frontend/messages/uk.json
index 582d3dde..f3313944 100644
--- a/frontend/messages/uk.json
+++ b/frontend/messages/uk.json
@@ -203,6 +203,9 @@
"hurryUp": "Поспішайте!"
},
"result": {
+ "accuracy": "Точність",
+ "integrity": "Чесність",
+ "violationsLabel": "Порушення",
"correctAnswers": "правильних відповідей",
"timeUp": {
"title": "Час вийшов",
@@ -792,7 +795,10 @@
},
"bubbles": {
"qa": {
- "languages": { "label": "3 мови", "desc": "EN, UK та PL" },
+ "languages": {
+ "label": "3 мови",
+ "desc": "EN, UK та PL"
+ },
"aiHelper": {
"label": "AI-помічник",
"desc": "Виділи текст для пояснення"
@@ -811,7 +817,10 @@
"label": "Розумний таймер",
"desc": "Змагайся з часом"
},
- "antiCheat": { "label": "Античіт", "desc": "Детекція втрати фокусу" },
+ "antiCheat": {
+ "label": "Античіт",
+ "desc": "Детекція втрати фокусу"
+ },
"autoSync": {
"label": "Синхронізація",
"desc": "Зберігає прогрес після входу"
@@ -822,7 +831,10 @@
}
},
"leaderboard": {
- "podium": { "label": "Подіум", "desc": "Топ-3 в центрі уваги" },
+ "podium": {
+ "label": "Подіум",
+ "desc": "Топ-3 в центрі уваги"
+ },
"globalRank": {
"label": "Глобальний рейтинг",
"desc": "Змагайся зі світом"
@@ -841,27 +853,54 @@
"label": "Центр статистики",
"desc": "Візуалізуй своє зростання"
},
- "history": { "label": "Історія", "desc": "Відстежуй серії навчання" },
- "identity": { "label": "Профіль", "desc": "Керуй роллю та даними" },
+ "history": {
+ "label": "Історія",
+ "desc": "Відстежуй серії навчання"
+ },
+ "identity": {
+ "label": "Профіль",
+ "desc": "Керуй роллю та даними"
+ },
"reminders": {
"label": "Нагадування",
"desc": "Заверши незакінчені квізи"
}
},
"blog": {
- "techTrends": { "label": "Tech-тренди", "desc": "Будь попереду" },
- "tutorials": { "label": "Туторіали", "desc": "Покрокові гайди" },
+ "techTrends": {
+ "label": "Tech-тренди",
+ "desc": "Будь попереду"
+ },
+ "tutorials": {
+ "label": "Туторіали",
+ "desc": "Покрокові гайди"
+ },
"deepDives": {
"label": "Глибокий аналіз",
"desc": "Детальний розбір"
},
- "community": { "label": "Спільнота", "desc": "Написано розробниками" }
+ "community": {
+ "label": "Спільнота",
+ "desc": "Написано розробниками"
+ }
},
"shop": {
- "newDrops": { "label": "Новинки", "desc": "Регулярні оновлення" },
- "curated": { "label": "Добірка", "desc": "Колекції для девелоперів" },
- "checkout": { "label": "Оплата", "desc": "Зручний Stripe checkout" },
- "premium": { "label": "Преміум", "desc": "Висока якість матеріалів" }
+ "newDrops": {
+ "label": "Новинки",
+ "desc": "Регулярні оновлення"
+ },
+ "curated": {
+ "label": "Добірка",
+ "desc": "Колекції для девелоперів"
+ },
+ "checkout": {
+ "label": "Оплата",
+ "desc": "Зручний Stripe checkout"
+ },
+ "premium": {
+ "label": "Преміум",
+ "desc": "Висока якість матеріалів"
+ }
}
}
},
@@ -1036,24 +1075,56 @@
"metaDescription": "Відстежуйте свій прогрес та результати квізів",
"title": "Кабінет користувача",
"subtitle": "З поверненням на вашу навчальну площадку",
- "supportLink": "Підтримка та відгуки",
+ "supportLink": "Відгук",
"profile": {
+ "title": "Профіль",
+ "subtitle": "Керуйте своїм публічним профілем та налаштуваннями",
"defaultName": "Розробник",
"defaultRole": "користувач",
"sponsor": "Спонсор",
"becomeSponsor": "Стати спонсором",
+ "supportAgain": "Проявити більше любові",
"sponsorThanks": "Дякуємо за вашу підтримку!",
"sponsorMore": "Підтримати ще більше",
+ "points": "Очок",
+ "quizzesTaken": "Квізів",
+ "joined": "Приєднався",
+ "globalRank": "Рейтинг",
+ "dayStreak": "День поспіль",
+ "daysStreak": "Дні поспіль",
"totalPoints": "Загальна кількість балів",
- "joined": "Приєднався"
+ "settings": "Налаштування",
+ "editProfile": "Редагувати профіль",
+ "changeName": "Змінити ім'я",
+ "saveChanges": "Зберегти зміни",
+ "changePassword": "Змінити пароль",
+ "currentPassword": "Поточний пароль",
+ "newPassword": "Новий пароль",
+ "saving": "Збереження..."
},
"stats": {
"title": "Статистика квізів",
- "noActivity": "Готові прокачатися? Випробуйте себе в новому квізі",
- "startQuiz": "Почати квіз",
+ "scoreDistribution": "Розподіл балів",
+ "scoreDistributionSubtext": "На основі ваших останніх спроб",
+ "activityHeatmap": "Графік активності",
"attempts": "Спроби",
"avgScore": "Середній бал",
- "continueLearning": "Продовжити навчання"
+ "mastered": "Засвоєно",
+ "review": "Потребує повторення",
+ "study": "Вивчення",
+ "totalAttempts": "Всього спроб",
+ "noActivity": "Ви ще не проходили жодного квізу. Почніть навчання та відстежуйте свій прогрес тут!",
+ "startQuiz": "Почати перший квіз",
+ "continueLearning": "Продовжити навчання",
+ "less": "Менше",
+ "more": "Більше",
+ "attemptsInPeriod": "{count} спроб за період",
+ "last3Months": "Останні 3 місяці",
+ "last5Months": "Останні 5 місяців",
+ "last6Months": "Останні 6 місяців",
+ "lastYear": "Цей рік",
+ "totalActiveDays": "Активні дні",
+ "mostActiveMonth": "Активний місяць"
},
"quizSaved": {
"title": "Результат квізу збережено!",
@@ -1069,6 +1140,7 @@
"noAttempts": "Ви ще не проходили жодного квізу",
"startQuiz": "Спробувати",
"score": "Результат",
+ "accuracy": "Точність",
"integrity": "Чистота",
"points": "Балів",
"scoreHint": "Кількість правильних відповідей з загальної кількості",
@@ -1122,9 +1194,112 @@
"messagePlaceholder": "Розкажіть, що ви думаєте...",
"submit": "Надіслати відгук",
"submitting": "Надсилання...",
+ "attachFile": "Прикріпити файл",
"success": "Дякуємо! Ваш відгук надіслано.",
"error": "Щось пішло не так. Спробуйте ще раз.",
"requiredField": "Будь ласка, заповніть це поле."
+ },
+ "achievements": {
+ "title": "Досягнення",
+ "subtitle": "{earned} з {total} отримано",
+ "ui": {
+ "expand": "Переглянути всі",
+ "collapse": "Згорнути",
+ "clickInfo": "Натисніть для інфо",
+ "clickBack": "Натисніть, щоб повернутись"
+ },
+ "badges": {
+ "first_blood": {
+ "name": "Перший крок",
+ "desc": "Завершив перший квіз!",
+ "hint": "Завершіть 1 квіз"
+ },
+ "sharpshooter": {
+ "name": "Снайпер",
+ "desc": "100% у квізі!",
+ "hint": "Отримайте 100% у будь-якому квізі"
+ },
+ "on_a_roll": {
+ "name": "У потоці",
+ "desc": "Завершив 3 квізи!",
+ "hint": "Завершіть 3 квізи"
+ },
+ "big_brain": {
+ "name": "Великий мозок",
+ "desc": "Завершив 10 квізів!",
+ "hint": "Завершіть 10 квізів"
+ },
+ "diamond_mind": {
+ "name": "Діамантовий розум",
+ "desc": "90%+ у 5 квізах!",
+ "hint": "Отримайте 90%+ у 5 квізах"
+ },
+ "perfectionist": {
+ "name": "Перфекціоніст",
+ "desc": "3 ідеальні результати!",
+ "hint": "Отримайте 100% у 3 квізах"
+ },
+ "supporter": {
+ "name": "Підтримувач",
+ "desc": "Дякуємо за підтримку!",
+ "hint": "Станьте спонсором на GitHub"
+ },
+ "legend": {
+ "name": "Легенда",
+ "desc": "10 різних квізів завершено!",
+ "hint": "Завершіть 10 різних квізів"
+ },
+ "rocket_start": {
+ "name": "Ракетний старт",
+ "desc": "Завершив 5 квізів!",
+ "hint": "Завершіть 5 квізів"
+ },
+ "royalty": {
+ "name": "Роялті",
+ "desc": "Досяг вершини рейтингу!",
+ "hint": "Займіть перше місце в рейтингу"
+ },
+ "code_wizard": {
+ "name": "Чарівник коду",
+ "desc": "Опанував 5 різних тем!",
+ "hint": "Завершіть 5 різних тем квізів"
+ },
+ "endless": {
+ "name": "Нескінченний",
+ "desc": "Набрав 1000+ балів!",
+ "hint": "Наберіть 1000 балів"
+ },
+ "star_gazer": {
+ "name": "Зоряний глядач",
+ "desc": "Ти додав зірочку репозиторію DevLovers на GitHub!",
+ "hint": "Постав зірочку нашому GitHub репозиторію"
+ },
+ "silver_patron": {
+ "name": "Срібний патрон",
+ "desc": "Ти спонсорував нас двічі — дякуємо!",
+ "hint": "Спонсоруй DevLovers вдруге"
+ },
+ "golden_patron": {
+ "name": "Золотий патрон",
+ "desc": "Ти спонсорував нас 3+ рази — легендарно!",
+ "hint": "Спонсоруй DevLovers втретє"
+ },
+ "night_owl": {
+ "name": "Нічна сова",
+ "desc": "Пройшов квіз після півночі!",
+ "hint": "Пройди квіз після опівночі"
+ },
+ "centurion": {
+ "name": "Центуріон",
+ "desc": "Досяг 100 балів!",
+ "hint": "Наберіть 100 балів"
+ },
+ "deep_diver": {
+ "name": "Глибокий ныряльщик",
+ "desc": "Середній результат 80%+ за 10+ квізів!",
+ "hint": "Тримай середній результат 80%+ у 10+ спробах"
+ }
+ }
}
},
"aiHelper": {
@@ -1297,4 +1472,4 @@
}
}
}
-}
+}
\ No newline at end of file
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 4d5c7c90..ea63d5e6 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -9,6 +9,7 @@
"version": "1.0.1",
"dependencies": {
"@neondatabase/serverless": "^1.0.2",
+ "@phosphor-icons/react": "^2.1.10",
"@portabletext/react": "^5.0.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-radio-group": "^1.3.8",
@@ -3368,6 +3369,18 @@
"url": "https://opencollective.com/parcel"
}
},
+ "node_modules/@phosphor-icons/react": {
+ "version": "2.1.10",
+ "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz",
+ "integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==",
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8",
+ "react-dom": ">= 16.8"
+ }
+ },
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index e5a3209e..a70c6c38 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -24,6 +24,7 @@
},
"dependencies": {
"@neondatabase/serverless": "^1.0.2",
+ "@phosphor-icons/react": "^2.1.10",
"@portabletext/react": "^5.0.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-radio-group": "^1.3.8",
From 2e940f1d7e466cae008c19ee801f101fc1b7fcda Mon Sep 17 00:00:00 2001
From: Yevhenii Datsenko
Date: Sat, 21 Feb 2026 15:58:52 +0200
Subject: [PATCH 2/3] fix: ProfileCard sponsor button
---
frontend/components/dashboard/ProfileCard.tsx | 31 -------------------
1 file changed, 31 deletions(-)
diff --git a/frontend/components/dashboard/ProfileCard.tsx b/frontend/components/dashboard/ProfileCard.tsx
index 66fd1321..eccc44c3 100644
--- a/frontend/components/dashboard/ProfileCard.tsx
+++ b/frontend/components/dashboard/ProfileCard.tsx
@@ -266,37 +266,6 @@ export function ProfileCard({
-
);
}
From 9934bf3d937c0a52d14b4ead375bf78b5ef90886 Mon Sep 17 00:00:00 2001
From: Yevhenii Datsenko
Date: Sat, 21 Feb 2026 16:25:20 +0200
Subject: [PATCH 3/3] fix: DST-safe streak calc, remove star_gazer logs,
suppress mock earnedAt date
---
frontend/app/[locale]/dashboard/page.tsx | 51 +++++++++++++-----------
frontend/lib/achievements.ts | 9 +----
2 files changed, 29 insertions(+), 31 deletions(-)
diff --git a/frontend/app/[locale]/dashboard/page.tsx b/frontend/app/[locale]/dashboard/page.tsx
index 429367d8..f692531b 100644
--- a/frontend/app/[locale]/dashboard/page.tsx
+++ b/frontend/app/[locale]/dashboard/page.tsx
@@ -92,14 +92,10 @@ export default async function DashboardPage({
githubLogin = user.name ?? '';
}
- console.log('[star_gazer] provider:', user.provider, '| providerId:', user.providerId, '| resolved login:', githubLogin);
-
const hasStarredRepo = githubLogin
? await checkHasStarredRepo(githubLogin)
: false;
- console.log('[star_gazer] hasStarredRepo:', hasStarredRepo);
-
const attempts = await getUserQuizStats(session.id);
const lastAttempts = await getUserLastAttemptPerQuiz(session.id, locale);
@@ -120,27 +116,34 @@ export default async function DashboardPage({
const globalRank = await getUserGlobalRank(session.id);
- // 1. Calculate Daily Streak
- const uniqueAttemptDates = Array.from(
- new Set(attempts.map(a => new Date(a.completedAt).setHours(0, 0, 0, 0)))
- ).sort((a, b) => b - a);
-
+ // 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;
- const today = new Date().setHours(0, 0, 0, 0);
- const yesterday = new Date(today - 86400000).setHours(0, 0, 0, 0);
-
- if (uniqueAttemptDates.length > 0) {
- const lastActive = uniqueAttemptDates[0];
- if (lastActive === today || lastActive === yesterday) {
- currentStreak = 1;
- let checkDate = lastActive;
- for (let i = 1; i < uniqueAttemptDates.length; i++) {
- checkDate -= 86400000;
- if (uniqueAttemptDates[i] === checkDate) {
- currentStreak++;
- } else {
- break;
- }
+ 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;
}
}
}
diff --git a/frontend/lib/achievements.ts b/frontend/lib/achievements.ts
index 28f728a9..2defd07a 100644
--- a/frontend/lib/achievements.ts
+++ b/frontend/lib/achievements.ts
@@ -77,12 +77,7 @@ export interface UserStats {
}
export function computeAchievements(stats: UserStats): EarnedAchievement[] {
- // Mock date for demo purposes
- const MOCK_DATE = new Date().toLocaleDateString('en-US', {
- day: 'numeric',
- month: 'short',
- year: 'numeric',
- });
+
return ACHIEVEMENTS.map((a) => {
let earned = false;
@@ -126,7 +121,7 @@ export function computeAchievements(stats: UserStats): EarnedAchievement[] {
...a,
earned,
progress,
- earnedAt: earned ? MOCK_DATE : undefined,
+ earnedAt: undefined,
};
});
}
|