diff --git a/package-lock.json b/package-lock.json index 881e9c3..e9eb891 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1694,7 +1694,6 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.6.tgz", "integrity": "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA==", "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.2.2", @@ -1923,7 +1922,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2036,7 +2034,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -4236,7 +4233,6 @@ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.105.4.tgz", "integrity": "sha512-cEnx+k49knU+qdIP7rXwR6fqEXPHZs+74xFK1R0S8MgQ7v9tbePVdGxvO03n3bPympMdJWVLadARBfU4TgNHCQ==", "license": "MIT", - "peer": true, "dependencies": { "@supabase/auth-js": "2.105.4", "@supabase/functions-js": "2.105.4", @@ -4572,7 +4568,6 @@ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -4601,7 +4596,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -4613,7 +4607,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -5315,7 +5308,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5845,7 +5837,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -7090,7 +7081,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -7165,7 +7155,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7334,7 +7323,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -9338,7 +9326,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -10117,7 +10104,6 @@ "integrity": "sha512-0f8aRfBVL+mpzfBjYfQuLWh2WyAwtJXCRfkPF4UJ5qd2YwrHczsrSzXU4tRMV0OAxR8ZJZWPFn6uhSC56UTsLA==", "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "14.2.5", "@swc/helpers": "0.5.5", @@ -10745,7 +10731,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10916,7 +10901,6 @@ "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==", "license": "Unlicense", - "peer": true, "engines": { "node": ">=12" }, @@ -10980,7 +10964,6 @@ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -11149,7 +11132,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -11162,7 +11144,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -12692,7 +12673,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12803,7 +12783,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -13360,7 +13339,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13585,7 +13563,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -14099,7 +14076,6 @@ "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", diff --git a/src/app/(app)/dashboard/active-issues.tsx b/src/app/(app)/dashboard/active-issues.tsx new file mode 100644 index 0000000..7cc380e --- /dev/null +++ b/src/app/(app)/dashboard/active-issues.tsx @@ -0,0 +1,61 @@ +import Link from 'next/link'; +import { ArrowRight } from 'lucide-react'; +import { getRecommendations } from '@/app/actions/recommendations'; +import { isOk } from '@/lib/result'; +import RecCards from './rec-cards'; + +export default async function ActiveIssuesSection() { + const recsResult = await getRecommendations(); + let recs: any[] = []; + if (isOk(recsResult)) { + recs = recsResult.data; + } + + return ( +
+
+

ACTIVE ISSUES

+ + BROWSE MORE + +
+ + {recs.length > 0 ? ( + + ) : ( +
No recommendations yet. Check back soon.
+ )} +
+ ); +} + +export function RecsSkeleton() { + return ( +
+
+

ACTIVE ISSUES

+
+
+
+ {[1, 2].map((i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+
+ ); +} diff --git a/src/app/(app)/dashboard/github-prs-wrapper.tsx b/src/app/(app)/dashboard/github-prs-wrapper.tsx new file mode 100644 index 0000000..3f6f929 --- /dev/null +++ b/src/app/(app)/dashboard/github-prs-wrapper.tsx @@ -0,0 +1,75 @@ +import { getServiceSupabase } from '@/lib/supabase/service'; +import { GitHubPRsPanel } from './github-prs-panel'; +import type { GitHubPR } from '@/app/actions/github-sync'; + +export default async function GitHubPRsWrapper({ + userId, + githubHandle, +}: { + userId: string; + githubHandle: string; +}) { + const service = getServiceSupabase(); + if (!service) return null; + + // Query pull_requests directly (populated by webhooks) + const { data: prsData } = await service + .from('pull_requests') + .select( + 'id, github_pr_id, repo_full_name, number, title, state, url, github_created_at, merged_at', + ) + .eq('author_user_id', userId) + .order('github_created_at', { ascending: false }); + + const prs = (prsData ?? []) as GitHubPR[]; + + // Active Issues: claimed recommendations only + const { data: claimedRecs } = await service + .from('recommendations') + .select( + ` + id, + status, + xp_reward, + linked_pr_url, + difficulty, + issues ( + title, + repo_full_name, + url + ) + `, + ) + .eq('user_id', userId) + .eq('status', 'claimed') + .limit(2); + + const claimedPrUrls = (claimedRecs ?? []) + .map((r: any) => r.linked_pr_url) + .filter(Boolean) as string[]; + + return ; +} + +export function PrsSkeleton() { + return ( +
+
+

MY PRS

+
+
+
+
+
+
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+ ))} +
+
+ ); +} diff --git a/src/app/(app)/dashboard/leaderboard-snapshot.tsx b/src/app/(app)/dashboard/leaderboard-snapshot.tsx new file mode 100644 index 0000000..7677116 --- /dev/null +++ b/src/app/(app)/dashboard/leaderboard-snapshot.tsx @@ -0,0 +1,71 @@ +import { getServiceSupabase } from '@/lib/supabase/service'; + +export default async function LeaderboardSnapshot({ githubHandle }: { githubHandle: string }) { + const service = getServiceSupabase(); + if (!service) return null; + + // Leaderboard + const { data: leaders } = await service + .from('profiles') + .select('github_handle, xp') + .order('xp', { ascending: false }) + .limit(4); + + return ( +
+
+

+ LEADERBOARD SNAPSHOT +

+ GLOBAL +
+ +
+ {leaders && leaders.length > 0 ? ( + leaders.map((leader, index) => { + const isMe = leader.github_handle === githubHandle; + return ( +
+
+ + {(index + 1).toString().padStart(2, '0')} + + {leader.github_handle} {isMe && '(YOU)'} +
+ {leader.xp.toLocaleString()} XP +
+ ); + }) + ) : ( +
+ BE THE FIRST ON THE BOARD — MERGE A PR TO EARN XP +
+ )} +
+
+ ); +} + +export function LeaderboardSkeleton() { + return ( +
+
+

+ LEADERBOARD SNAPSHOT +

+ GLOBAL +
+
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+
+ ))} +
+
+ ); +} diff --git a/src/app/(app)/dashboard/mentees-section.tsx b/src/app/(app)/dashboard/mentees-section.tsx new file mode 100644 index 0000000..a254284 --- /dev/null +++ b/src/app/(app)/dashboard/mentees-section.tsx @@ -0,0 +1,92 @@ +import { getServiceSupabase } from '@/lib/supabase/service'; +import Link from 'next/link'; + +export default async function MenteesSection({ userId }: { userId: string }) { + const service = getServiceSupabase(); + if (!service) return null; + + // Mentees + const { data: menteesData } = await service + .from('help_requests') + .select('id, pr_url, status, user_id') + .eq('resolved_by', userId) + .in('status', ['open', 'escalated']) + .limit(2); + + let enrichedMentees: any[] = []; + if (menteesData && menteesData.length > 0) { + const userIds = menteesData.map((m: any) => m.user_id); + const { data: menteeProfiles } = await service + .from('profiles') + .select('id, github_handle') + .in('id', userIds); + enrichedMentees = menteesData.map((m: any) => { + const p = menteeProfiles?.find((p) => p.id === m.user_id); + return { ...m, github_handle: p?.github_handle || 'Unknown' }; + }); + } + + return ( +
+
+

YOUR MENTEES

+
+
+ {enrichedMentees && enrichedMentees.length > 0 ? ( + enrichedMentees.map((mentee: any) => ( +
+
+
+ {mentee.github_handle.substring(0, 2)} +
+
+
+ {mentee.github_handle} +
+
Help Request: {mentee.status}
+
+
+ + REVIEW DRAFT + +
+ )) + ) : ( +
+ No active mentees assigned to you. +
+ )} +
+
+ ); +} + +export function MenteesSkeleton() { + return ( +
+
+

YOUR MENTEES

+
+
+ {[1, 2].map((i) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+
+ ); +} diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index 4ac5026..73aea65 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -1,26 +1,19 @@ import { Suspense } from 'react'; -import { getRecommendations } from '@/app/actions/recommendations'; import { getServerSupabase } from '@/lib/supabase/server'; import { getServiceSupabase } from '@/lib/supabase/service'; import { SyncButton } from './sync-button'; -import { GitHubPRsPanel } from './github-prs-panel'; -import RecCards from './rec-cards'; import LevelUpBanner from './level-up-banner'; import { redirect } from 'next/navigation'; -import { isOk } from '@/lib/result'; -import { xpToNextLevel, xpForLevel } from '@/lib/xp/curve'; -import { cacheGet, cacheSet } from '@/lib/cache'; import Link from 'next/link'; -import { ArrowRight, TrendingUp, Box } from 'lucide-react'; -import type { GitHubPR } from '@/app/actions/github-sync'; -export const dynamic = 'force-dynamic'; +// Component imports +import StatsRow, { StatsSkeleton } from './stats-row'; +import ActiveIssuesSection, { RecsSkeleton } from './active-issues'; +import GitHubPRsWrapper, { PrsSkeleton } from './github-prs-wrapper'; +import LeaderboardSnapshot, { LeaderboardSkeleton } from './leaderboard-snapshot'; +import MenteesSection, { MenteesSkeleton } from './mentees-section'; -type DashboardCache = { - merges: number | null; - streak: number | null; - syncedAt: string | null; -}; +export const dynamic = 'force-dynamic'; export default async function DashboardPage() { const sb = getServerSupabase(); @@ -36,114 +29,13 @@ export default async function DashboardPage() { const service = getServiceSupabase(); if (!service) return ; + // Fetch only the profile info we need for the page shell header and subcomponents const { data: profile } = await service .from('profiles') - .select( - 'github_handle, xp, level, audit_completed, github_total_merges, github_streak, github_stats_synced_at', - ) + .select('github_handle, xp, level, github_total_merges, github_streak, github_stats_synced_at') .eq('id', user.id) .maybeSingle(); - const xp = profile?.xp ?? 0; - const level = profile?.level ?? 0; - const { needed, next } = xpToNextLevel(xp); - const nextLevel = next ?? level; - - // Read stats from Redis cache, fall back to profile data - const cacheKey = `gh:dashboard:${user.id}`; - let dashCache = await cacheGet(cacheKey); - - if (!dashCache) { - dashCache = { - merges: (profile?.github_total_merges as number | null) ?? null, - streak: (profile?.github_streak as number | null) ?? null, - syncedAt: (profile?.github_stats_synced_at as string | null) ?? null, - }; - await cacheSet(cacheKey, dashCache, 300); - } - - // Query pull_requests directly (populated by webhooks) - const { data: prsData } = await service - .from('pull_requests') - .select( - 'id, github_pr_id, repo_full_name, number, title, state, url, github_created_at, merged_at', - ) - .eq('author_user_id', user.id) - .order('github_created_at', { ascending: false }); - - const prs = (prsData ?? []) as GitHubPR[]; - - // Active Issues: claimed recommendations only - const { data: claimedRecs } = await service - .from('recommendations') - .select( - ` - id, - status, - xp_reward, - linked_pr_url, - difficulty, - issues ( - title, - repo_full_name, - url - ) - `, - ) - .eq('user_id', user.id) - .eq('status', 'claimed') - .limit(2); - - const claimedPrUrls = (claimedRecs ?? []) - .map((r: any) => r.linked_pr_url) - .filter(Boolean) as string[]; - - const recsResult = await getRecommendations(); - let recs: any[] = []; - if (isOk(recsResult)) { - recs = recsResult.data; - } - - // Mentor points - const { data: mentorEvents } = await service - .from('xp_events') - .select('xp_delta') - .eq('user_id', user.id) - .in('source', ['review', 'help_review']); - const mentorPoints = mentorEvents?.reduce((acc, e) => acc + (e.xp_delta || 0), 0) || 0; - - // Leaderboard - const { data: leaders } = await service - .from('profiles') - .select('github_handle, xp') - .order('xp', { ascending: false }) - .limit(4); - - // Mentees - const { data: menteesData } = await service - .from('help_requests') - .select('id, pr_url, status, user_id') - .eq('resolved_by', user.id) - .in('status', ['open', 'escalated']) - .limit(2); - - let enrichedMentees: any[] = []; - if (menteesData && menteesData.length > 0) { - const userIds = menteesData.map((m: any) => m.user_id); - const { data: menteeProfiles } = await service - .from('profiles') - .select('id, github_handle') - .in('id', userIds); - enrichedMentees = menteesData.map((m: any) => { - const p = menteeProfiles?.find((p) => p.id === m.user_id); - return { ...m, github_handle: p?.github_handle || 'Unknown' }; - }); - } - - const merges = dashCache.merges; - const streak = dashCache.streak; - const syncedAt = dashCache.syncedAt; - return (
@@ -159,195 +51,36 @@ export default async function DashboardPage() {
- +
+ {/* Stats Row */} }> -
- {/* Level Progress */} -
-
- LEVEL PROGRESS -
-
-
- L{level} -
-
-
-
-
-
- {xp.toLocaleString()} / {(xp + needed).toLocaleString()} XP TO L{nextLevel} -
-
-
-
- - {/* Total Merges */} -
-
- TOTAL MERGES -
-
- - {(merges ?? 0).toString().padStart(2, '0')} - - -
-
- - {/* Mentor Points */} -
-
- MENTOR POINTS -
-
- - {mentorPoints.toLocaleString()} - - -
-
- - {/* Current Streak */} -
-
- CURRENT STREAK -
-
- {(streak ?? 0) > 0 ? ( - <> - - {(streak ?? 0).toString().padStart(2, '0')} - - - DAYS 🔥 - - - ) : ( - - NO STREAK - - )} -
-
-
+ {/* Main Columns */}
{/* Left Column */}
-
-
-

- ACTIVE ISSUES -

- - BROWSE MORE - -
- - {recs.length > 0 ? ( - - ) : ( -
- No recommendations yet. Check back soon. -
- )} -
+ }> + + -
-
-

- YOUR MENTEES -

-
-
- {enrichedMentees && enrichedMentees.length > 0 ? ( - enrichedMentees.map((mentee: any) => ( -
-
-
- {mentee.github_handle.substring(0, 2)} -
-
-
- {mentee.github_handle} -
-
Help Request: {mentee.status}
-
-
- - REVIEW DRAFT - -
- )) - ) : ( -
- No active mentees assigned to you. -
- )} -
-
+ }> + +
{/* Right Column */}
- - -
-
-

- LEADERBOARD SNAPSHOT -

- GLOBAL -
- -
- {leaders && leaders.length > 0 ? ( - leaders.map((leader, index) => { - const isMe = leader.github_handle === profile?.github_handle; - return ( -
-
- - {(index + 1).toString().padStart(2, '0')} - - {leader.github_handle} {isMe && '(YOU)'} -
- {leader.xp.toLocaleString()} XP -
- ); - }) - ) : ( -
- BE THE FIRST ON THE BOARD — MERGE A PR TO EARN XP -
- )} -
-
+ }> + + + }> + +
@@ -371,14 +104,6 @@ export default async function DashboardPage() { ); } -function levelProgressPct(xp: number, level: number): number { - const floor = xpForLevel(level); - const ceiling = xpForLevel(level + 1); - if (ceiling <= floor) return 100; - const pct = ((xp - floor) / (ceiling - floor)) * 100; - return Math.max(0, Math.min(100, pct)); -} - function NotConfigured() { return (
@@ -389,54 +114,3 @@ function NotConfigured() {
); } - -function StatsSkeleton() { - return ( -
- {/* Level Progress Skeleton */} -
-
- LEVEL PROGRESS -
-
-
-
-
-
-
-
-
- - {/* Total Merges Skeleton */} -
-
TOTAL MERGES
-
-
-
-
-
- - {/* Mentor Points Skeleton */} -
-
- MENTOR POINTS -
-
-
-
-
-
- - {/* Current Streak Skeleton */} -
-
- CURRENT STREAK -
-
-
-
-
-
-
- ); -} diff --git a/src/app/(app)/dashboard/stats-row.tsx b/src/app/(app)/dashboard/stats-row.tsx new file mode 100644 index 0000000..e115925 --- /dev/null +++ b/src/app/(app)/dashboard/stats-row.tsx @@ -0,0 +1,180 @@ +import { getServiceSupabase } from '@/lib/supabase/service'; +import { xpToNextLevel, xpForLevel } from '@/lib/xp/curve'; +import { cacheGet, cacheSet } from '@/lib/cache'; +import { TrendingUp, Box } from 'lucide-react'; + +type DashboardCache = { + merges: number | null; + streak: number | null; + syncedAt: string | null; +}; + +function levelProgressPct(xp: number, level: number): number { + const floor = xpForLevel(level); + const ceiling = xpForLevel(level + 1); + if (ceiling <= floor) return 100; + const pct = ((xp - floor) / (ceiling - floor)) * 100; + return Math.max(0, Math.min(100, pct)); +} + +type PartialProfile = { + github_handle: string | null; + xp: number; + level: number; + github_total_merges: number | null; + github_streak: number | null; + github_stats_synced_at: string | null; +} | null; + +export default async function StatsRow({ + userId, + profile, +}: { + userId: string; + profile: PartialProfile; +}) { + const service = getServiceSupabase(); + if (!service) return null; + + const xp = profile?.xp ?? 0; + const level = profile?.level ?? 0; + const { needed, next } = xpToNextLevel(xp); + const nextLevel = next ?? level; + + // Read stats from Redis cache, fall back to profile data + const cacheKey = `gh:dashboard:${userId}`; + let dashCache = await cacheGet(cacheKey); + + if (!dashCache) { + dashCache = { + merges: (profile?.github_total_merges as number | null) ?? null, + streak: (profile?.github_streak as number | null) ?? null, + syncedAt: (profile?.github_stats_synced_at as string | null) ?? null, + }; + await cacheSet(cacheKey, dashCache, 300); + } + + // Mentor points + const { data: mentorEvents } = await service + .from('xp_events') + .select('xp_delta') + .eq('user_id', userId) + .in('source', ['review', 'help_review']); + const mentorPoints = mentorEvents?.reduce((acc, e) => acc + (e.xp_delta || 0), 0) || 0; + + const merges = dashCache.merges; + const streak = dashCache.streak; + + return ( +
+ {/* Level Progress */} +
+
+ LEVEL PROGRESS +
+
+
+ L{level} +
+
+
+
+
+
+ {xp.toLocaleString()} / {(xp + needed).toLocaleString()} XP TO L{nextLevel} +
+
+
+
+ + {/* Total Merges */} +
+
TOTAL MERGES
+
+ + {(merges ?? 0).toString().padStart(2, '0')} + + +
+
+ + {/* Mentor Points */} +
+
+ MENTOR POINTS +
+
+ {mentorPoints.toLocaleString()} + +
+
+ + {/* Current Streak */} +
+
+ CURRENT STREAK +
+
+ + {(streak ?? 0).toString().padStart(2, '0')} + + DAYS 🔥 +
+
+
+ ); +} + +export function StatsSkeleton() { + return ( +
+ {/* Level Progress Skeleton */} +
+
+ LEVEL PROGRESS +
+
+
+
+
+
+
+
+
+ + {/* Total Merges Skeleton */} +
+
TOTAL MERGES
+
+
+
+
+
+ + {/* Mentor Points Skeleton */} +
+
+ MENTOR POINTS +
+
+
+
+
+
+ + {/* Current Streak Skeleton */} +
+
+ CURRENT STREAK +
+
+
+
+
+
+
+ ); +} diff --git a/src/lib/cache.ts b/src/lib/cache.ts index 467233f..8cfb3ee 100644 --- a/src/lib/cache.ts +++ b/src/lib/cache.ts @@ -153,7 +153,7 @@ function pickDefaultBackend(): CacheBackend { maxRetriesPerRequest: 1, retryStrategy: () => null, // Do not keep retrying connection }); - client.on('error', (err) => { + client.on('error', (err: Error) => { console.warn(`[cache] Local Redis error: ${err.message}. Falling back to memory.`); backend = new MemoryBackend(); client.disconnect();