From 19fd82e4e5a0988696ef9bee1237a3833c5f4621 Mon Sep 17 00:00:00 2001 From: abdullahxyz85 Date: Mon, 18 May 2026 18:17:09 +0500 Subject: [PATCH 1/5] feat: Add PR open time distribution histogram --- package-lock.json | 13 --- src/app/api/metrics/prs/route.ts | 84 ++++++++++++++--- src/components/PRMetrics.tsx | 152 ++++++++++++++++++++++++++----- 3 files changed, 201 insertions(+), 48 deletions(-) diff --git a/package-lock.json b/package-lock.json index 467c7810..df0ab101 100644 --- a/package-lock.json +++ b/package-lock.json @@ -746,7 +746,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1185,7 +1184,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1638,7 +1636,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2508,7 +2505,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", @@ -2677,7 +2673,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4118,7 +4113,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -4190,7 +4184,6 @@ "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.6", "fast-png": "^6.2.0", @@ -5033,7 +5026,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5204,7 +5196,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz", "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -5295,7 +5286,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" }, @@ -5308,7 +5298,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" @@ -6362,7 +6351,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6532,7 +6520,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/app/api/metrics/prs/route.ts b/src/app/api/metrics/prs/route.ts index fb28df2b..b49839c9 100644 --- a/src/app/api/metrics/prs/route.ts +++ b/src/app/api/metrics/prs/route.ts @@ -19,13 +19,27 @@ interface PRMetricsBase { mergeRate: number; } -async function fetchPRMetrics(token: string): Promise { +interface PRTimeDistribution { + lessThan1h: number; + from1hTo24h: number; + from1dTo7d: number; + moreThan7d: number; +} + +async function fetchPRMetrics( + token: string, + days: number = 30, +): Promise { + const since = new Date(); + since.setDate(since.getDate() - days); + const sinceStr = `${since.getFullYear()}-${String(since.getMonth() + 1).padStart(2, "0")}-${String(since.getDate()).padStart(2, "0")}`; + const searchRes = await fetch( - `${GITHUB_API}/search/issues?q=type:pr+author:@me&per_page=100`, + `${GITHUB_API}/search/issues?q=type:pr+author:@me+created:>=${sinceStr}&per_page=100`, { headers: { Authorization: `Bearer ${token}` }, cache: "no-store", - } + }, ); if (!searchRes.ok) { @@ -34,7 +48,11 @@ async function fetchPRMetrics(token: string): Promise { const data = (await searchRes.json()) as { total_count: number; - items: Array<{ state: string; created_at: string; closed_at: string | null }>; + items: Array<{ + state: string; + created_at: string; + closed_at: string | null; + }>; }; const open = data.items.filter((pr) => pr.state === "open").length; @@ -48,29 +66,58 @@ async function fetchPRMetrics(token: string): Promise { sum + (new Date(pr.closed_at!).getTime() - new Date(pr.created_at).getTime()), - 0 + 0, ) / closedPRs.length : 0; + // Calculate time distribution + const timeDistribution: PRTimeDistribution = { + lessThan1h: 0, + from1hTo24h: 0, + from1dTo7d: 0, + moreThan7d: 0, + }; + + for (const pr of closedPRs) { + const durationMs = + new Date(pr.closed_at!).getTime() - new Date(pr.created_at).getTime(); + + if (durationMs < 3600000) { + // Less than 1 hour + timeDistribution.lessThan1h++; + } else if (durationMs < 86400000) { + // 1 hour to 24 hours + timeDistribution.from1hTo24h++; + } else if (durationMs < 604800000) { + // 1 day to 7 days + timeDistribution.from1dTo7d++; + } else { + // More than 7 days + timeDistribution.moreThan7d++; + } + } + return { open, merged, total: data.total_count, avgReviewHours: Math.round(avgReviewMs / 3600000), mergeRate: data.total_count > 0 ? merged / data.total_count : 0, + timeDistribution, }; } -function formatPRMetrics(metrics: PRMetricsBase) { +function formatPRMetrics( + metrics: PRMetricsBase & { timeDistribution: PRTimeDistribution }, +) { return { open: metrics.open, merged: metrics.merged, total: metrics.total, avgReviewHours: metrics.avgReviewHours, mergeRate: - metrics.total > 0 - ? `${Math.round(metrics.mergeRate * 100)}%` - : "0%", + metrics.total > 0 ? `${Math.round(metrics.mergeRate * 100)}%` : "0%", + timeDistribution: metrics.timeDistribution, }; } @@ -81,10 +128,11 @@ export async function GET(req: NextRequest) { } const accountId = req.nextUrl.searchParams.get("accountId"); + const days = Number(req.nextUrl.searchParams.get("days")) || 30; if (!accountId) { try { - const result = await fetchPRMetrics(session.accessToken); + const result = await fetchPRMetrics(session.accessToken, days); return Response.json(formatPRMetrics(result)); } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); @@ -112,11 +160,11 @@ export async function GET(req: NextRequest) { githubId: session.githubId, githubLogin: session.githubLogin, }, - userRow.id + userRow.id, ); const results = await Promise.allSettled( - accounts.map((account) => fetchPRMetrics(account.token)) + accounts.map((account) => fetchPRMetrics(account.token, days)), ); const merged = mergeMetrics(results, (a, b) => { @@ -134,6 +182,16 @@ export async function GET(req: NextRequest) { avgReviewHours: Math.round(avgReviewHours * 10) / 10, mergeRate: total > 0 ? Math.round((mergedCount / total) * 100) / 100 : 0, + timeDistribution: { + lessThan1h: + a.timeDistribution.lessThan1h + b.timeDistribution.lessThan1h, + from1hTo24h: + a.timeDistribution.from1hTo24h + b.timeDistribution.from1hTo24h, + from1dTo7d: + a.timeDistribution.from1dTo7d + b.timeDistribution.from1dTo7d, + moreThan7d: + a.timeDistribution.moreThan7d + b.timeDistribution.moreThan7d, + }, }; }); @@ -154,7 +212,7 @@ export async function GET(req: NextRequest) { } try { - const result = await fetchPRMetrics(token); + const result = await fetchPRMetrics(token, days); return Response.json(formatPRMetrics(result)); } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); diff --git a/src/components/PRMetrics.tsx b/src/components/PRMetrics.tsx index 990d13f5..1f5026ed 100644 --- a/src/components/PRMetrics.tsx +++ b/src/components/PRMetrics.tsx @@ -1,15 +1,39 @@ "use client"; import { useCallback, useEffect, useState } from "react"; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; import { useAccount } from "@/components/AccountContext"; +interface TimeDistribution { + lessThan1h: number; + from1hTo24h: number; + from1dTo7d: number; + moreThan7d: number; +} + interface PRData { open: number; merged: number; avgReviewHours: number; mergeRate: string; + timeDistribution: TimeDistribution; } +const BUCKET_LABELS: Record = { + lessThan1h: "< 1h", + from1hTo24h: "1–24h", + from1dTo7d: "1–7d", + moreThan7d: "> 7d", +}; + export default function PRMetrics() { const { selectedAccount } = useAccount(); const [metrics, setMetrics] = useState(null); @@ -22,8 +46,8 @@ export default function PRMetrics() { const url = selectedAccount !== null - ? `/api/metrics/prs?accountId=${encodeURIComponent(selectedAccount)}` - : "/api/metrics/prs"; + ? `/api/metrics/prs?accountId=${encodeURIComponent(selectedAccount)}&days=30` + : "/api/metrics/prs?days=30"; fetch(url) .then((r) => { @@ -31,7 +55,11 @@ export default function PRMetrics() { return r.json(); }) .then((data: PRData) => setMetrics(data)) - .catch(() => setError("We couldn't load your PR analytics right now. Please try again in a moment.")) + .catch(() => + setError( + "We couldn't load your PR analytics right now. Please try again in a moment.", + ), + ) .finally(() => setLoading(false)); }, [selectedAccount]); @@ -48,17 +76,44 @@ export default function PRMetrics() { ] : []; + // Prepare histogram data + const histogramData = metrics + ? [ + { + bucket: BUCKET_LABELS.lessThan1h, + count: metrics.timeDistribution.lessThan1h, + }, + { + bucket: BUCKET_LABELS.from1hTo24h, + count: metrics.timeDistribution.from1hTo24h, + }, + { + bucket: BUCKET_LABELS.from1dTo7d, + count: metrics.timeDistribution.from1dTo7d, + }, + { + bucket: BUCKET_LABELS.moreThan7d, + count: metrics.timeDistribution.moreThan7d, + }, + ] + : []; + return (
-

PR Analytics

+

+ PR Analytics +

{loading ? ( -
- {[1, 2, 3, 4].map((i) => ( -
- ))} +
+
+ {[1, 2, 3, 4].map((i) => ( +
+ ))} +
+
) : error ? (
@@ -72,18 +127,71 @@ export default function PRMetrics() {
) : ( -
- {stats.map((stat) => ( -
-
- {stat.value} +
+
+ {stats.map((stat) => ( +
+
+ {stat.value} +
+
+ {stat.label} +
+
+ ))} +
+ + {/* PR Open Time Distribution Histogram */} +
+

+ PR Open Time Distribution (30d) +

+ {histogramData.some((item) => item.count > 0) ? ( +
+ + + + + + [`${value} PRs`, "Count"]} + /> + + + +
+ ) : ( +
+

No PR data available

-
{stat.label}
-
- ))} + )} +
)}
From 0ae9b084c5e6ed6231359e599cfa9ad3e5612a9b Mon Sep 17 00:00:00 2001 From: abdullahxyz85 Date: Mon, 18 May 2026 18:44:38 +0500 Subject: [PATCH 2/5] feat: Add commit diff size trend chart --- src/app/api/metrics/diff-trend/route.ts | 230 ++++++++++++++++++++++++ src/app/dashboard/page.tsx | 6 +- src/app/globals.css | 8 +- src/components/DiffTrendChart.tsx | 172 ++++++++++++++++++ 4 files changed, 414 insertions(+), 2 deletions(-) create mode 100644 src/app/api/metrics/diff-trend/route.ts create mode 100644 src/components/DiffTrendChart.tsx diff --git a/src/app/api/metrics/diff-trend/route.ts b/src/app/api/metrics/diff-trend/route.ts new file mode 100644 index 00000000..71a48070 --- /dev/null +++ b/src/app/api/metrics/diff-trend/route.ts @@ -0,0 +1,230 @@ +import { getServerSession } from "next-auth"; +import { NextRequest } from "next/server"; +import { authOptions } from "@/lib/auth"; +import { getAccountToken, getAllAccounts } from "@/lib/github-accounts"; +import { GITHUB_API } from "@/lib/github"; +import { supabaseAdmin } from "@/lib/supabase"; + +export const dynamic = "force-dynamic"; + +interface WeekData { + week: string; + additions: number; + deletions: number; +} + +async function fetchRepoStats( + token: string, + owner: string, + repo: string, +): Promise { + const res = await fetch( + `${GITHUB_API}/repos/${owner}/${repo}/stats/code_frequency`, + { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + }, + ); + + // 202 means GitHub is still computing the stats + if (res.status === 202) { + return []; + } + + if (!res.ok) { + throw new Error(`GitHub API error: ${res.status}`); + } + + const data = (await res.json()) as Array<[number, number, number]>; + + // Convert to WeekData format (last 12 weeks) + const weeks: WeekData[] = []; + const now = Math.floor(Date.now() / 1000); + const twelveWeeksAgo = now - 12 * 7 * 24 * 60 * 60; + + for (const [timestamp, additions, deletions] of data) { + if (timestamp >= twelveWeeksAgo) { + const date = new Date(timestamp * 1000); + weeks.push({ + week: date.toISOString().split("T")[0], + additions, + deletions, + }); + } + } + + return weeks; +} + +async function getUserTopRepos(token: string, githubLogin: string) { + const res = await fetch( + `${GITHUB_API}/users/${githubLogin}/repos?sort=updated&per_page=5&type=owner`, + { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + }, + ); + + if (!res.ok) { + throw new Error("GitHub API error"); + } + + const repos = (await res.json()) as Array<{ + name: string; + owner: { login: string }; + }>; + + return repos.slice(0, 3).map((r) => ({ + name: r.name, + owner: r.owner.login, + })); +} + +function aggregateWeeks(allWeeks: WeekData[][]): { + weeks: WeekData[]; + isComputing: boolean; +} { + const weekMap = new Map(); + let hasEmptyResponse = false; + + for (const weeks of allWeeks) { + if (weeks.length === 0) { + hasEmptyResponse = true; + continue; + } + + for (const week of weeks) { + const existing = weekMap.get(week.week) || { + additions: 0, + deletions: 0, + }; + weekMap.set(week.week, { + additions: existing.additions + week.additions, + deletions: existing.deletions + week.deletions, + }); + } + } + + const sorted = Array.from(weekMap.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([week, { additions, deletions }]) => ({ + week, + additions, + deletions, + })); + + return { + weeks: sorted, + isComputing: hasEmptyResponse && sorted.length === 0, + }; +} + +export async function GET(req: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.accessToken || !session.githubLogin) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const accountId = req.nextUrl.searchParams.get("accountId"); + + try { + if (!accountId) { + // Fetch for main account + const repos = await getUserTopRepos( + session.accessToken, + session.githubLogin, + ); + + const repoStats = await Promise.all( + repos.map((repo) => + fetchRepoStats(session.accessToken, repo.owner, repo.name), + ), + ); + + const { weeks, isComputing } = aggregateWeeks(repoStats); + + return Response.json({ + weeks: weeks.length > 0 ? weeks : [], + isComputing, + repoCount: repos.length, + }); + } + + // Handle multiple accounts + if (!session.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { data: userRow } = await supabaseAdmin + .from("users") + .select("id") + .eq("github_id", session.githubId) + .single(); + + if (!userRow) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (accountId === "combined") { + const accounts = await getAllAccounts( + { + token: session.accessToken, + githubId: session.githubId, + githubLogin: session.githubLogin, + }, + userRow.id, + ); + + const allRepoStats: WeekData[][] = []; + + for (const account of accounts) { + try { + const repos = await getUserTopRepos(account.token, account.login); + const repoStats = await Promise.all( + repos.map((repo) => + fetchRepoStats(account.token, repo.owner, repo.name), + ), + ); + allRepoStats.push(...repoStats); + } catch { + // Skip this account if it fails + } + } + + const { weeks, isComputing } = aggregateWeeks(allRepoStats); + + return Response.json({ + weeks: weeks.length > 0 ? weeks : [], + isComputing, + repoCount: accounts.length, + }); + } + + const token = + accountId === session.githubId + ? session.accessToken + : await getAccountToken(userRow.id, accountId); + + if (!token) { + return Response.json({ error: "Account not found" }, { status: 404 }); + } + + const repos = await getUserTopRepos(token, accountId); + const repoStats = await Promise.all( + repos.map((repo) => fetchRepoStats(token, repo.owner, repo.name)), + ); + + const { weeks, isComputing } = aggregateWeeks(repoStats); + + return Response.json({ + weeks: weeks.length > 0 ? weeks : [], + isComputing, + repoCount: repos.length, + }); + } catch { + return Response.json( + { error: "Failed to fetch diff trend data" }, + { status: 502 }, + ); + } +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 9e4bdb66..fb4c477f 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -15,6 +15,7 @@ import FriendComparison from "@/components/FriendComparison"; import WeeklySummaryCard from "@/components/WeeklySummaryCard"; import ExportButton from "@/components/ExportButton"; import PersonalRecords from "@/components/PersonalRecords"; +import DiffTrendChart from "@/components/DiffTrendChart"; import { authOptions } from "@/lib/auth"; import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; @@ -47,6 +48,9 @@ export default async function DashboardPage() {
+
+ +
@@ -80,4 +84,4 @@ export default async function DashboardPage() {
); -} \ No newline at end of file +} diff --git a/src/app/globals.css b/src/app/globals.css index 82888199..f9a16b70 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -18,6 +18,8 @@ --control-hover: #cbd5e1; --tooltip: #ffffff; --tooltip-foreground: #0f172a; + --success: #10b981; + --destructive: #ef4444; } .dark { @@ -36,10 +38,14 @@ --control-hover: #475569; --tooltip: #1e293b; --tooltip-foreground: #f8fafc; + --success: #34d399; + --destructive: #f87171; } body { background: var(--background); color: var(--foreground); - transition: background-color 200ms ease, color 200ms ease; + transition: + background-color 200ms ease, + color 200ms ease; } diff --git a/src/components/DiffTrendChart.tsx b/src/components/DiffTrendChart.tsx new file mode 100644 index 00000000..30475716 --- /dev/null +++ b/src/components/DiffTrendChart.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, +} from "recharts"; +import { useAccount } from "@/components/AccountContext"; + +interface WeekData { + week: string; + additions: number; + deletions: number; +} + +interface DiffTrendData { + weeks: WeekData[]; + isComputing: boolean; + repoCount: number; +} + +export default function DiffTrendChart() { + const { selectedAccount } = useAccount(); + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [isComputing, setIsComputing] = useState(false); + const [repoCount, setRepoCount] = useState(0); + + const fetchDiffTrend = useCallback(() => { + setLoading(true); + setError(null); + setIsComputing(false); + + const url = + selectedAccount !== null + ? `/api/metrics/diff-trend?accountId=${encodeURIComponent(selectedAccount)}` + : "/api/metrics/diff-trend"; + + fetch(url) + .then((r) => { + if (!r.ok) throw new Error("API error"); + return r.json(); + }) + .then((res: DiffTrendData) => { + setData(res.weeks || []); + setIsComputing(res.isComputing); + setRepoCount(res.repoCount || 0); + }) + .catch(() => + setError( + "We couldn't load your diff trend data. Please try again in a moment.", + ), + ) + .finally(() => setLoading(false)); + }, [selectedAccount]); + + useEffect(() => { + fetchDiffTrend(); + }, [fetchDiffTrend]); + + // Format week date for display + const chartData = data.map((d) => { + const date = new Date(d.week); + const formatted = `${date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + })}`; + return { + week: formatted, + additions: d.additions, + deletions: d.deletions, + }; + }); + + return ( +
+

+ Code Change Trend +

+

+ Lines added/removed per week (last 12 weeks, top {repoCount} repos) +

+ + {loading ? ( +
+ ) : error ? ( +
+

{error}

+ +
+ ) : isComputing ? ( +
+
+
+
+
+

Computing statistics...

+

+ GitHub is analyzing repository data (this happens once per day) +

+
+
+ ) : chartData.length === 0 ? ( +
+

No data available yet

+
+ ) : ( +
+ + + + + + { + if (name === "additions") { + return [`${value} added`, "Additions"]; + } + return [`${value} removed`, "Deletions"]; + }} + separator=": " + /> + + + + + +
+ )} +
+ ); +} From 91c35ee9e4f1d4386e2406d5d04ecda5cf293957 Mon Sep 17 00:00:00 2001 From: abdullahxyz85 Date: Tue, 19 May 2026 16:20:10 +0500 Subject: [PATCH 3/5] Added back to top button --- src/app/api/metrics/diff-trend/route.ts | 19 ++++---- src/app/providers.tsx | 2 + src/components/BackToTopButton.tsx | 63 +++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 10 deletions(-) create mode 100644 src/components/BackToTopButton.tsx diff --git a/src/app/api/metrics/diff-trend/route.ts b/src/app/api/metrics/diff-trend/route.ts index 71a48070..6f66c45d 100644 --- a/src/app/api/metrics/diff-trend/route.ts +++ b/src/app/api/metrics/diff-trend/route.ts @@ -126,19 +126,15 @@ export async function GET(req: NextRequest) { } const accountId = req.nextUrl.searchParams.get("accountId"); + const accessToken = session.accessToken as string; try { if (!accountId) { // Fetch for main account - const repos = await getUserTopRepos( - session.accessToken, - session.githubLogin, - ); + const repos = await getUserTopRepos(accessToken, session.githubLogin); const repoStats = await Promise.all( - repos.map((repo) => - fetchRepoStats(session.accessToken, repo.owner, repo.name), - ), + repos.map((repo) => fetchRepoStats(accessToken, repo.owner, repo.name)), ); const { weeks, isComputing } = aggregateWeeks(repoStats); @@ -168,7 +164,7 @@ export async function GET(req: NextRequest) { if (accountId === "combined") { const accounts = await getAllAccounts( { - token: session.accessToken, + token: accessToken, githubId: session.githubId, githubLogin: session.githubLogin, }, @@ -179,7 +175,10 @@ export async function GET(req: NextRequest) { for (const account of accounts) { try { - const repos = await getUserTopRepos(account.token, account.login); + const repos = await getUserTopRepos( + account.token, + account.githubLogin, + ); const repoStats = await Promise.all( repos.map((repo) => fetchRepoStats(account.token, repo.owner, repo.name), @@ -202,7 +201,7 @@ export async function GET(req: NextRequest) { const token = accountId === session.githubId - ? session.accessToken + ? accessToken : await getAccountToken(userRow.id, accountId); if (!token) { diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 6803006b..76015195 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -4,6 +4,7 @@ import type { ReactNode } from "react"; import { SessionProvider } from "next-auth/react"; import { AccountProvider } from "@/components/AccountContext"; import { ThemeProvider } from "@/components/ThemeContext"; +import BackToTopButton from "@/components/BackToTopButton"; export default function Providers({ children }: { children: ReactNode }) { return ( @@ -11,6 +12,7 @@ export default function Providers({ children }: { children: ReactNode }) { {children} + diff --git a/src/components/BackToTopButton.tsx b/src/components/BackToTopButton.tsx new file mode 100644 index 00000000..b7fb4be6 --- /dev/null +++ b/src/components/BackToTopButton.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export default function BackToTopButton() { + const [isVisible, setIsVisible] = useState(false); + + // Show button when page is scrolled down + const toggleVisibility = () => { + if (typeof window !== "undefined") { + setIsVisible(window.scrollY > 300); + } + }; + + // Smooth scroll to top + const scrollToTop = () => { + if (typeof window !== "undefined") { + window.scrollTo({ + top: 0, + behavior: "smooth", + }); + } + }; + + useEffect(() => { + if (typeof window !== "undefined") { + window.addEventListener("scroll", toggleVisibility); + return () => { + window.removeEventListener("scroll", toggleVisibility); + }; + } + }, []); + + return ( + <> + {isVisible && ( + + )} + + ); +} From c25b59b8fec01fddc1ec25adf2beb1d623e5919c Mon Sep 17 00:00:00 2001 From: abdullahxyz85 Date: Tue, 19 May 2026 17:55:31 +0500 Subject: [PATCH 4/5] feat: Add reusable Back to Top button [cleaned] --- src/app/api/metrics/diff-trend/route.ts | 229 ---------- src/app/api/metrics/prs/route.ts | 385 ---------------- src/app/dashboard/page.tsx | 106 ----- src/app/dashboard/settings/page.tsx | 557 ------------------------ src/components/DiffTrendChart.tsx | 172 -------- src/components/PRMetrics.tsx | 237 ---------- src/components/UserAvatar.tsx | 40 -- 7 files changed, 1726 deletions(-) delete mode 100644 src/app/api/metrics/diff-trend/route.ts delete mode 100644 src/app/api/metrics/prs/route.ts delete mode 100644 src/app/dashboard/page.tsx delete mode 100644 src/app/dashboard/settings/page.tsx delete mode 100644 src/components/DiffTrendChart.tsx delete mode 100644 src/components/PRMetrics.tsx delete mode 100644 src/components/UserAvatar.tsx diff --git a/src/app/api/metrics/diff-trend/route.ts b/src/app/api/metrics/diff-trend/route.ts deleted file mode 100644 index 6f66c45d..00000000 --- a/src/app/api/metrics/diff-trend/route.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { getServerSession } from "next-auth"; -import { NextRequest } from "next/server"; -import { authOptions } from "@/lib/auth"; -import { getAccountToken, getAllAccounts } from "@/lib/github-accounts"; -import { GITHUB_API } from "@/lib/github"; -import { supabaseAdmin } from "@/lib/supabase"; - -export const dynamic = "force-dynamic"; - -interface WeekData { - week: string; - additions: number; - deletions: number; -} - -async function fetchRepoStats( - token: string, - owner: string, - repo: string, -): Promise { - const res = await fetch( - `${GITHUB_API}/repos/${owner}/${repo}/stats/code_frequency`, - { - headers: { Authorization: `Bearer ${token}` }, - cache: "no-store", - }, - ); - - // 202 means GitHub is still computing the stats - if (res.status === 202) { - return []; - } - - if (!res.ok) { - throw new Error(`GitHub API error: ${res.status}`); - } - - const data = (await res.json()) as Array<[number, number, number]>; - - // Convert to WeekData format (last 12 weeks) - const weeks: WeekData[] = []; - const now = Math.floor(Date.now() / 1000); - const twelveWeeksAgo = now - 12 * 7 * 24 * 60 * 60; - - for (const [timestamp, additions, deletions] of data) { - if (timestamp >= twelveWeeksAgo) { - const date = new Date(timestamp * 1000); - weeks.push({ - week: date.toISOString().split("T")[0], - additions, - deletions, - }); - } - } - - return weeks; -} - -async function getUserTopRepos(token: string, githubLogin: string) { - const res = await fetch( - `${GITHUB_API}/users/${githubLogin}/repos?sort=updated&per_page=5&type=owner`, - { - headers: { Authorization: `Bearer ${token}` }, - cache: "no-store", - }, - ); - - if (!res.ok) { - throw new Error("GitHub API error"); - } - - const repos = (await res.json()) as Array<{ - name: string; - owner: { login: string }; - }>; - - return repos.slice(0, 3).map((r) => ({ - name: r.name, - owner: r.owner.login, - })); -} - -function aggregateWeeks(allWeeks: WeekData[][]): { - weeks: WeekData[]; - isComputing: boolean; -} { - const weekMap = new Map(); - let hasEmptyResponse = false; - - for (const weeks of allWeeks) { - if (weeks.length === 0) { - hasEmptyResponse = true; - continue; - } - - for (const week of weeks) { - const existing = weekMap.get(week.week) || { - additions: 0, - deletions: 0, - }; - weekMap.set(week.week, { - additions: existing.additions + week.additions, - deletions: existing.deletions + week.deletions, - }); - } - } - - const sorted = Array.from(weekMap.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([week, { additions, deletions }]) => ({ - week, - additions, - deletions, - })); - - return { - weeks: sorted, - isComputing: hasEmptyResponse && sorted.length === 0, - }; -} - -export async function GET(req: NextRequest) { - const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - - const accountId = req.nextUrl.searchParams.get("accountId"); - const accessToken = session.accessToken as string; - - try { - if (!accountId) { - // Fetch for main account - const repos = await getUserTopRepos(accessToken, session.githubLogin); - - const repoStats = await Promise.all( - repos.map((repo) => fetchRepoStats(accessToken, repo.owner, repo.name)), - ); - - const { weeks, isComputing } = aggregateWeeks(repoStats); - - return Response.json({ - weeks: weeks.length > 0 ? weeks : [], - isComputing, - repoCount: repos.length, - }); - } - - // Handle multiple accounts - if (!session.githubId) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { data: userRow } = await supabaseAdmin - .from("users") - .select("id") - .eq("github_id", session.githubId) - .single(); - - if (!userRow) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - - if (accountId === "combined") { - const accounts = await getAllAccounts( - { - token: accessToken, - githubId: session.githubId, - githubLogin: session.githubLogin, - }, - userRow.id, - ); - - const allRepoStats: WeekData[][] = []; - - for (const account of accounts) { - try { - const repos = await getUserTopRepos( - account.token, - account.githubLogin, - ); - const repoStats = await Promise.all( - repos.map((repo) => - fetchRepoStats(account.token, repo.owner, repo.name), - ), - ); - allRepoStats.push(...repoStats); - } catch { - // Skip this account if it fails - } - } - - const { weeks, isComputing } = aggregateWeeks(allRepoStats); - - return Response.json({ - weeks: weeks.length > 0 ? weeks : [], - isComputing, - repoCount: accounts.length, - }); - } - - const token = - accountId === session.githubId - ? accessToken - : await getAccountToken(userRow.id, accountId); - - if (!token) { - return Response.json({ error: "Account not found" }, { status: 404 }); - } - - const repos = await getUserTopRepos(token, accountId); - const repoStats = await Promise.all( - repos.map((repo) => fetchRepoStats(token, repo.owner, repo.name)), - ); - - const { weeks, isComputing } = aggregateWeeks(repoStats); - - return Response.json({ - weeks: weeks.length > 0 ? weeks : [], - isComputing, - repoCount: repos.length, - }); - } catch { - return Response.json( - { error: "Failed to fetch diff trend data" }, - { status: 502 }, - ); - } -} diff --git a/src/app/api/metrics/prs/route.ts b/src/app/api/metrics/prs/route.ts deleted file mode 100644 index 074feda8..00000000 --- a/src/app/api/metrics/prs/route.ts +++ /dev/null @@ -1,385 +0,0 @@ -import { getServerSession } from "next-auth"; -import { NextRequest } from "next/server"; -import { authOptions } from "@/lib/auth"; -import { - getAccountToken, - getAllAccounts, - mergeMetrics, -} from "@/lib/github-accounts"; -import { GITHUB_API } from "@/lib/github"; -import { supabaseAdmin } from "@/lib/supabase"; - -export const dynamic = "force-dynamic"; - -interface PRMetricsBase { - open: number; - merged: number; - total: number; - avgReviewHours: number; - avgFirstReviewHours: number | null; - mergeRate: number; -} - -interface PRTimeDistribution { - lessThan1h: number; - from1hTo24h: number; - from1dTo7d: number; - moreThan7d: number; -} - -async function fetchPRMetrics( - token: string, - days: number = 30, -): Promise { - const since = new Date(); - since.setDate(since.getDate() - days); - const sinceStr = `${since.getFullYear()}-${String(since.getMonth() + 1).padStart(2, "0")}-${String(since.getDate()).padStart(2, "0")}`; - - const searchRes = await fetch( - `${GITHUB_API}/search/issues?q=type:pr+author:@me+created:>=${sinceStr}&per_page=100`, -interface PullRequestSearchItem { - state: string; - created_at: string; - closed_at: string | null; - number: number; - repository_url: string; - pull_request?: { merged_at: string | null }; -} - -interface ReviewEvent { - submitted_at?: string | null; -} - -interface ReviewCommentEvent { - created_at?: string | null; -} - -function getRepoFullName(repositoryUrl: string): string | null { - const marker = "/repos/"; - const index = repositoryUrl.indexOf(marker); - return index >= 0 ? repositoryUrl.slice(index + marker.length) : null; -} - -function getEarliestTimestamp(values: Array) { - const timestamps = values - .filter((value): value is string => Boolean(value)) - .map((value) => new Date(value).getTime()) - .filter((value) => !Number.isNaN(value)); - - return timestamps.length > 0 ? Math.min(...timestamps) : null; -} - -async function fetchFirstReviewTimestamp( - token: string, - pr: PullRequestSearchItem -): Promise { - const repo = getRepoFullName(pr.repository_url); - - if (!repo) { - return null; - } - - const headers = { - Authorization: `Bearer ${token}`, - Accept: "application/vnd.github+json", - }; - const [reviewsRes, commentsRes] = await Promise.all([ - fetch(`${GITHUB_API}/repos/${repo}/pulls/${pr.number}/reviews?per_page=100`, { - headers, - cache: "no-store", - }), - fetch(`${GITHUB_API}/repos/${repo}/pulls/${pr.number}/comments?per_page=100`, { - headers, - cache: "no-store", - }), - ]); - - if (!reviewsRes.ok || !commentsRes.ok) { - return null; - } - - const reviews = (await reviewsRes.json()) as ReviewEvent[]; - const comments = (await commentsRes.json()) as ReviewCommentEvent[]; - - return getEarliestTimestamp([ - ...reviews.map((review) => review.submitted_at), - ...comments.map((comment) => comment.created_at), - ]); -} - -async function getAverageFirstReviewHours( - token: string, - prs: PullRequestSearchItem[] -): Promise { - const reviewedPrs = await Promise.all( - prs.slice(0, 30).map(async (pr) => { - const firstReviewAt = await fetchFirstReviewTimestamp(token, pr); - - if (!firstReviewAt) { - return null; - } - - const openedAt = new Date(pr.created_at).getTime(); - if (Number.isNaN(openedAt) || firstReviewAt < openedAt) { - return null; - } - - return (firstReviewAt - openedAt) / 3600000; - }) - ); - const validDurations = reviewedPrs.filter( - (value): value is number => typeof value === "number" - ); - - if (validDurations.length === 0) { - return null; - } - - const average = - validDurations.reduce((sum, value) => sum + value, 0) / - validDurations.length; - - return Math.round(average * 10) / 10; -} - -async function fetchPRMetrics(token: string): Promise { - const searchRes = await fetch( - `${GITHUB_API}/search/issues?q=type:pr+author:@me&sort=updated&order=desc&per_page=100`, - { - headers: { Authorization: `Bearer ${token}` }, - cache: "no-store", - }, - ); - - if (!searchRes.ok) { - throw new Error("GitHub API error"); - } - - const data = (await searchRes.json()) as { - total_count: number; - items: Array<{ - state: string; - created_at: string; - closed_at: string | null; - - // GitHub Search API includes a pull_request object on PR items. - // merged_at is non-null only when the PR was actually merged, as - // opposed to closed without merging. - pull_request?: { merged_at: string | null }; - }>; - - items: PullRequestSearchItem[]; - - }; - - const open = data.items.filter((pr) => pr.state === "open").length; - - // A PR with state "closed" may have been merged OR closed without merging - // (e.g. rejected, abandoned). Only count those with a non-null merged_at - // as truly merged so the dashboard does not inflate the merged count. - const merged = data.items.filter( - (pr) => pr.pull_request?.merged_at != null - ).length; - - // Average review time: use only actually merged PRs so we measure the time - // from open to merge, not open to close-without-merge. - const mergedPRs = data.items.filter( - (pr) => pr.pull_request?.merged_at != null - ); - const avgReviewMs = - mergedPRs.length > 0 - ? mergedPRs.reduce( - (sum, pr) => - sum + - (new Date(pr.pull_request!.merged_at!).getTime() - - new Date(pr.created_at).getTime()), - 0, - ) / closedPRs.length - : 0; - - // Calculate time distribution - const timeDistribution: PRTimeDistribution = { - lessThan1h: 0, - from1hTo24h: 0, - from1dTo7d: 0, - moreThan7d: 0, - }; - - for (const pr of closedPRs) { - const durationMs = - new Date(pr.closed_at!).getTime() - new Date(pr.created_at).getTime(); - - if (durationMs < 3600000) { - // Less than 1 hour - timeDistribution.lessThan1h++; - } else if (durationMs < 86400000) { - // 1 hour to 24 hours - timeDistribution.from1hTo24h++; - } else if (durationMs < 604800000) { - // 1 day to 7 days - timeDistribution.from1dTo7d++; - } else { - // More than 7 days - timeDistribution.moreThan7d++; - } - } - - 0 - ) / mergedPRs.length - : 0; - - // Use the number of fetched items as the denominator for mergeRate. - // data.total_count is the all-time GitHub total (potentially thousands) - // while data.items is capped at 100, so dividing merged/total_count - // produces a near-zero rate for any active user. The fetched sample - // (open + merged + closed-without-merge) is the correct base. - const sampleTotal = data.items.length; - const avgFirstReviewHours = await getAverageFirstReviewHours( - token, - data.items - ); - - - return { - open, - merged, - total: data.total_count, - avgReviewHours: Math.round(avgReviewMs / 3600000), - - - mergeRate: data.total_count > 0 ? merged / data.total_count : 0, - timeDistribution, - - - avgFirstReviewHours, - - mergeRate: sampleTotal > 0 ? merged / sampleTotal : 0, - - }; -} - -function formatPRMetrics( - metrics: PRMetricsBase & { timeDistribution: PRTimeDistribution }, -) { - return { - open: metrics.open, - merged: metrics.merged, - total: metrics.total, - avgReviewHours: metrics.avgReviewHours, - avgFirstReviewHours: metrics.avgFirstReviewHours, - mergeRate: - metrics.total > 0 ? `${Math.round(metrics.mergeRate * 100)}%` : "0%", - timeDistribution: metrics.timeDistribution, - }; -} - -export async function GET(req: NextRequest) { - const session = await getServerSession(authOptions); - if (!session?.accessToken) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - - const accountId = req.nextUrl.searchParams.get("accountId"); - const days = Number(req.nextUrl.searchParams.get("days")) || 30; - - if (!accountId) { - try { - const result = await fetchPRMetrics(session.accessToken, days); - return Response.json(formatPRMetrics(result)); - } catch { - return Response.json({ error: "GitHub API error" }, { status: 502 }); - } - } - - if (!session.githubId || !session.githubLogin) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { data: userRow } = await supabaseAdmin - .from("users") - .select("id") - .eq("github_id", session.githubId) - .single(); - - if (!userRow) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - - if (accountId === "combined") { - const accounts = await getAllAccounts( - { - token: session.accessToken, - githubId: session.githubId, - githubLogin: session.githubLogin, - }, - userRow.id, - ); - - const results = await Promise.allSettled( - accounts.map((account) => fetchPRMetrics(account.token, days)), - ); - - const merged = mergeMetrics(results, (a, b) => { - const total = a.total + b.total; - const mergedCount = a.merged + b.merged; - const avgReviewHours = - total > 0 - ? (a.avgReviewHours * a.total + b.avgReviewHours * b.total) / total - : 0; - const reviewedTotal = - (a.avgFirstReviewHours === null ? 0 : a.total) + - (b.avgFirstReviewHours === null ? 0 : b.total); - const avgFirstReviewHours = - reviewedTotal > 0 - ? ((a.avgFirstReviewHours ?? 0) * a.total + - (b.avgFirstReviewHours ?? 0) * b.total) / - reviewedTotal - : null; - - return { - open: a.open + b.open, - merged: mergedCount, - total, - avgReviewHours: Math.round(avgReviewHours * 10) / 10, - avgFirstReviewHours: - avgFirstReviewHours === null - ? null - : Math.round(avgFirstReviewHours * 10) / 10, - mergeRate: - total > 0 ? Math.round((mergedCount / total) * 100) / 100 : 0, - timeDistribution: { - lessThan1h: - a.timeDistribution.lessThan1h + b.timeDistribution.lessThan1h, - from1hTo24h: - a.timeDistribution.from1hTo24h + b.timeDistribution.from1hTo24h, - from1dTo7d: - a.timeDistribution.from1dTo7d + b.timeDistribution.from1dTo7d, - moreThan7d: - a.timeDistribution.moreThan7d + b.timeDistribution.moreThan7d, - }, - }; - }); - - if (!merged) { - return Response.json({ error: "GitHub API error" }, { status: 502 }); - } - - return Response.json(formatPRMetrics(merged)); - } - - const token = - accountId === session.githubId - ? session.accessToken - : await getAccountToken(userRow.id, accountId); - - if (!token) { - return Response.json({ error: "Account not found" }, { status: 404 }); - } - - try { - const result = await fetchPRMetrics(token, days); - return Response.json(formatPRMetrics(result)); - } catch { - return Response.json({ error: "GitHub API error" }, { status: 502 }); - } -} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx deleted file mode 100644 index 693fa2e1..00000000 --- a/src/app/dashboard/page.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import ContributionGraph from "@/components/ContributionGraph"; -import ContributionHeatmap from "@/components/ContributionHeatmap"; -import PRMetrics from "@/components/PRMetrics"; -import PRBreakdownChart from "@/components/PRBreakdownChart"; -import GoalTracker from "@/components/GoalTracker"; -import DashboardHeader from "@/components/DashboardHeader"; -import StreakTracker from "@/components/StreakTracker"; -import TopRepos from "@/components/TopRepos"; -import PinnedRepos from "@/components/PinnedRepos"; -import LanguageBreakdown from "@/components/LanguageBreakdown"; -import CommitTimeChart from "@/components/CommitTimeChart"; -import CIAnalytics from "@/components/CIAnalytics"; -import IssueMetrics from "@/components/IssueMetrics"; -import StreakAtRiskBanner from "@/components/StreakAtRiskBanner"; -import FriendComparison from "@/components/FriendComparison"; -import WeeklySummaryCard from "@/components/WeeklySummaryCard"; -import ExportButton from "@/components/ExportButton"; -import Link from "next/link"; -import PersonalRecords from "@/components/PersonalRecords"; -import DiffTrendChart from "@/components/DiffTrendChart"; -import { authOptions } from "@/lib/auth"; -import { cookies } from "next/headers"; -import { getServerSession } from "next-auth"; -import { redirect } from "next/navigation"; - -export default async function DashboardPage() { - const allowPlaywrightBypass = - process.env.PLAYWRIGHT_AUTH_BYPASS === "1" && - cookies().get("playwright-dashboard-auth")?.value === "1"; - const session = allowPlaywrightBypass - ? null - : await getServerSession(authOptions); - - if (!session && !allowPlaywrightBypass) { - redirect("/"); - } - - return ( -
- -
- - Settings - - -
- - -
- -
- -
- -
- - {/* Row 1: Contribution graph + Streak + Friend Comparison */} -
-
- -
- -
-
- -
-
- -
- - -
-
- - {/* Row 2: PR metrics, PR breakdown & Time Chart */} -
- - - -
- - {/* Row 3: Issue metrics + CI analytics */} -
-
- -
- -
- - {/* Row 4: Pinned repositories */} -
- -
- - {/* Row 5: Top repos + Language breakdown + Goal tracker */} -
- - - -
-
- ); -} diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx deleted file mode 100644 index ab68ccb5..00000000 --- a/src/app/dashboard/settings/page.tsx +++ /dev/null @@ -1,557 +0,0 @@ -"use client"; - -import { Suspense, useEffect, useMemo, useState } from "react"; -import { useSession } from "next-auth/react"; -import { redirect, useSearchParams } from "next/navigation"; -import { useHeatmapTheme } from "@/hooks/useHeatmapTheme"; - -interface UserSettings { - id: string; - github_login: string; - is_public: boolean; - leaderboard_opt_in: boolean; -} - -interface LinkedAccount { - id: string; - githubId: string; - githubLogin: string; - addedAt: string; -} - -interface AccountsResponse { - accounts: LinkedAccount[]; -} - -function formatAddedDate(addedAt: string): string { - return `Added ${new Date(addedAt).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - })}`; -} - -function getStatusMessage( - success: string | null, - error: string | null -): { kind: "success" | "error"; message: string } | null { - if (success === "account_linked") { - return { - kind: "success", - message: "Account linked successfully", - }; - } - - if (!error) { - return null; - } - - if (error === "already_linked") { - return { - kind: "error", - message: "This account is already linked", - }; - } - - if (error === "cannot_link_primary_account") { - return { - kind: "error", - message: "You cannot link your primary account", - }; - } - - if (error === "invalid_state") { - return { - kind: "error", - message: "Link failed: invalid state. Please try again.", - }; - } - - if (error === "oauth_cancelled") { - return { - kind: "error", - message: "Account linking was cancelled", - }; - } - - return { - kind: "error", - message: "Account linking failed. Please try again.", - }; -} - -function SettingsPageFallback() { - return ( -
-
-
-
-
- {[1, 2, 3].map((i) => ( -
- ))} -
-
-
-
- ); -} - -function SettingsPageContent() { - const { data: session, status } = useSession(); - const searchParams = useSearchParams(); - const [settings, setSettings] = useState(null); - const [linkedAccounts, setLinkedAccounts] = useState([]); - const [loading, setLoading] = useState(true); - const [accountsLoading, setAccountsLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [copied, setCopied] = useState(false); - const [removeError, setRemoveError] = useState(null); - const [removingAccountId, setRemovingAccountId] = useState( - null - ); - - const statusMessage = useMemo( - () => - getStatusMessage( - searchParams.get("success"), - searchParams.get("error") - ), - [searchParams] - ); - - const { theme, setTheme } = useHeatmapTheme(); - - // Redirect to signin if not authenticated - useEffect(() => { - if (status === "unauthenticated") { - redirect("/"); - } - }, [status]); - - // Load settings on mount - useEffect(() => { - if (status !== "authenticated" || !session?.githubLogin) { - return; - } - - async function loadSettings() { - try { - const res = await fetch("/api/user/settings"); - if (res.ok) { - const data = await res.json(); - setSettings(data); - } - } catch (error) { - console.error("Failed to load settings:", error); - } finally { - setLoading(false); - } - } - - loadSettings(); - }, [session, status]); - - useEffect(() => { - if (status !== "authenticated" || !session?.githubLogin) { - return; - } - - async function loadLinkedAccounts() { - try { - const res = await fetch("/api/user/github-accounts"); - if (!res.ok) { - setLinkedAccounts([]); - return; - } - - const data = (await res.json()) as AccountsResponse; - setLinkedAccounts(data.accounts ?? []); - } catch (error) { - console.error("Failed to load linked accounts:", error); - setLinkedAccounts([]); - } finally { - setAccountsLoading(false); - } - } - - loadLinkedAccounts(); - }, [session, status]); - - const handleTogglePublic = async (value: boolean) => { - if (!settings) return; - - setSaving(true); - try { - const res = await fetch("/api/user/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ is_public: value }), - }); - - if (res.ok) { - const updated = await res.json(); - setSettings(updated); - } else { - console.error("Failed to update settings"); - } - } catch (error) { - console.error("Error updating settings:", error); - } finally { - setSaving(false); - } - }; - - const handleToggleLeaderboard = async (value: boolean) => { - if (!settings) return; - - setSaving(true); - try { - const res = await fetch("/api/user/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ leaderboard_opt_in: value }), - }); - - if (res.ok) { - const updated = await res.json(); - setSettings(updated); - } else { - console.error("Failed to update leaderboard setting"); - } - } catch (error) { - console.error("Error updating leaderboard setting:", error); - } finally { - setSaving(false); - } - }; - - const copyShareLink = () => { - if (!settings) return; - const link = `${window.location.origin}/u/${settings.github_login}`; - navigator.clipboard.writeText(link).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }).catch(() => { }); - }; - - const handleRemoveAccount = async (githubId: string) => { - setRemoveError(null); - setRemovingAccountId(githubId); - - try { - const res = await fetch(`/api/user/github-accounts/${githubId}`, { - method: "DELETE", - }); - - if (!res.ok) { - const data = (await res.json()) as { error?: string }; - setRemoveError(data.error ?? "Failed to remove account"); - return; - } - - setLinkedAccounts((current) => - current.filter((account) => account.githubId !== githubId) - ); - } catch { - setRemoveError("Failed to remove account"); - } finally { - setRemovingAccountId(null); - } - }; - - if (status === "loading" || loading) { - return ( -
-
-
-
-
- {[1, 2, 3].map((i) => ( -
- ))} -
-
-
-
- ); - } - - if (!settings) { - return ( -
-
-

- Failed to load settings. -

-
-
- ); - } - - return ( -
-
-
-

- Settings -

-

- Manage your profile and preferences -

-
- - {statusMessage && ( -
- {statusMessage.message} -
- )} - - {/* Public Profile Section */} -
-
-
-

- Public Profile -

-

- Share your GitHub stats with a public profile link -

-
- - {/* Toggle Switch */} -