diff --git a/package-lock.json b/package-lock.json index ce0b55ffb..251af664e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5062,6 +5062,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/src/app/api/metrics/contributions/route.ts b/src/app/api/metrics/contributions/route.ts index 3a3f885ed..28ae5a7e6 100644 --- a/src/app/api/metrics/contributions/route.ts +++ b/src/app/api/metrics/contributions/route.ts @@ -14,6 +14,7 @@ import { withMetricsCache, } from "@/lib/metrics-cache"; import { supabaseAdmin } from "@/lib/supabase"; +import { githubFetch, RateLimitError } from "@/lib/githubFetch"; import { resolveAppUser } from "@/lib/resolve-user"; export const dynamic = "force-dynamic"; @@ -77,7 +78,7 @@ async function fetchContributionsForAccount( since.setDate(since.getDate() - days); const sinceStr = toLocalDateStr(since); - const searchRes = await fetch( + const searchRes = await githubFetch( `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${sinceStr}&per_page=100&sort=author-date&order=desc`, { headers: { @@ -255,7 +256,13 @@ export async function GET(req: NextRequest) { { bypass, userId: session.githubId ?? session.githubLogin } ); return Response.json(result); - } catch { + } catch (err) { + if (err instanceof RateLimitError) { + return Response.json( + { error: "rate_limited", resetAt: err.resetAt }, + { status: 429 } + ); + } return Response.json({ error: "GitHub API error" }, { status: 502 }); } } @@ -279,7 +286,13 @@ export async function GET(req: NextRequest) { }); return Response.json(merged); - } catch { + } catch (err) { + if (err instanceof RateLimitError) { + return Response.json( + { error: "rate_limited", resetAt: err.resetAt }, + { status: 429 } + ); + } return Response.json({ error: "GitHub API error" }, { status: 502 }); } } @@ -357,7 +370,13 @@ export async function GET(req: NextRequest) { }); return Response.json(merged); - } catch { + } catch (err) { + if (err instanceof RateLimitError) { + return Response.json( + { error: "rate_limited", resetAt: err.resetAt }, + { status: 429 } + ); + } return Response.json({ error: "GitHub API error" }, { status: 502 }); } } @@ -387,7 +406,13 @@ export async function GET(req: NextRequest) { { bypass, userId: accountId } ); return Response.json(result); - } catch { + } catch (err) { + if (err instanceof RateLimitError) { + return Response.json( + { error: "rate_limited", resetAt: err.resetAt }, + { status: 429 } + ); + } return Response.json({ error: "GitHub API error" }, { status: 502 }); } } diff --git a/src/app/api/metrics/prs/route.ts b/src/app/api/metrics/prs/route.ts index 0f14593a8..4936ad371 100644 --- a/src/app/api/metrics/prs/route.ts +++ b/src/app/api/metrics/prs/route.ts @@ -14,6 +14,7 @@ import { withMetricsCache, } from "@/lib/metrics-cache"; import { supabaseAdmin } from "@/lib/supabase"; +import { githubFetch, RateLimitError } from "@/lib/githubFetch"; import { resolveAppUser } from "@/lib/resolve-user"; export const dynamic = "force-dynamic"; @@ -82,11 +83,11 @@ async function fetchFirstReviewTimestamp( Accept: "application/vnd.github+json", }; const [reviewsRes, commentsRes] = await Promise.all([ - fetch(`${GITHUB_API}/repos/${repo}/pulls/${pr.number}/reviews?per_page=100`, { + githubFetch(`${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`, { + githubFetch(`${GITHUB_API}/repos/${repo}/pulls/${pr.number}/comments?per_page=100`, { headers, cache: "no-store", }), @@ -141,7 +142,7 @@ async function getAverageFirstReviewHours( } async function fetchPRMetrics(token: string): Promise { - const searchRes = await fetch( + const searchRes = await githubFetch( `${GITHUB_API}/search/issues?q=type:pr+author:@me&sort=updated&order=desc&per_page=100`, { headers: { Authorization: `Bearer ${token}` }, @@ -413,7 +414,13 @@ export async function GET(req: NextRequest) { }); const gitlab = await getGitLabMetrics(gitlabToken, gitlabCacheContext); return Response.json(formatPRMetricsResponse(result, gitlab)); - } catch { + } catch (err) { + if (err instanceof RateLimitError) { + return Response.json( + { error: "rate_limited", resetAt: err.resetAt }, + { status: 429 } + ); + } return Response.json({ error: "GitHub API error" }, { status: 502 }); } } @@ -500,7 +507,13 @@ export async function GET(req: NextRequest) { }); const gitlab = await getGitLabMetrics(gitlabToken, gitlabCacheContext); return Response.json(formatPRMetricsResponse(result, gitlab)); - } catch { + } catch (err) { + if (err instanceof RateLimitError) { + return Response.json( + { error: "rate_limited", resetAt: err.resetAt }, + { status: 429 } + ); + } return Response.json({ error: "GitHub API error" }, { status: 502 }); } } diff --git a/src/app/api/metrics/repos/route.ts b/src/app/api/metrics/repos/route.ts index ee3fd1cb4..bd0cb6cb0 100644 --- a/src/app/api/metrics/repos/route.ts +++ b/src/app/api/metrics/repos/route.ts @@ -14,6 +14,7 @@ import { withMetricsCache, } from "@/lib/metrics-cache"; import { supabaseAdmin } from "@/lib/supabase"; +import { githubFetch, RateLimitError } from "@/lib/githubFetch"; import { resolveAppUser } from "@/lib/resolve-user"; export const dynamic = "force-dynamic"; @@ -66,7 +67,7 @@ async function fetchRepoLanguages( token: string, repoName: string ): Promise { - const res = await fetch(`${GITHUB_API}/repos/${repoName}/languages`, { + const res = await githubFetch(`${GITHUB_API}/repos/${repoName}/languages`, { headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", @@ -117,7 +118,7 @@ async function fetchReposForAccount( since.setDate(since.getDate() - days); const sinceStr = since.toISOString().slice(0, 10); - const searchRes = await fetch( + const searchRes = await githubFetch( `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${sinceStr}&per_page=100&sort=author-date&order=desc`, { headers: { @@ -187,7 +188,13 @@ export async function GET(req: NextRequest) { { bypass, userId: session.githubId ?? session.githubLogin } ); return Response.json(result); - } catch { + } catch (err) { + if (err instanceof RateLimitError) { + return Response.json( + { error: "rate_limited", resetAt: err.resetAt }, + { status: 429 } + ); + } return Response.json({ error: "GitHub API error" }, { status: 502 }); } } @@ -242,7 +249,13 @@ export async function GET(req: NextRequest) { { bypass, userId: session.githubId } ); return Response.json(result); - } catch { + } catch (err) { + if (err instanceof RateLimitError) { + return Response.json( + { error: "rate_limited", resetAt: err.resetAt }, + { status: 429 } + ); + } return Response.json({ error: "GitHub API error" }, { status: 502 }); } } @@ -272,7 +285,13 @@ export async function GET(req: NextRequest) { { bypass, userId: accountId } ); return Response.json(result); - } catch { + } catch (err) { + if (err instanceof RateLimitError) { + return Response.json( + { error: "rate_limited", resetAt: err.resetAt }, + { status: 429 } + ); + } return Response.json({ error: "GitHub API error" }, { status: 502 }); } } diff --git a/src/app/api/metrics/streak/route.ts b/src/app/api/metrics/streak/route.ts index 3d0045c90..bec0cecb3 100644 --- a/src/app/api/metrics/streak/route.ts +++ b/src/app/api/metrics/streak/route.ts @@ -10,6 +10,7 @@ import { withMetricsCache, } from "@/lib/metrics-cache"; import { supabaseAdmin } from "@/lib/supabase"; +import { githubFetch, RateLimitError } from "@/lib/githubFetch"; import { resolveAppUser } from "@/lib/resolve-user"; export const dynamic = "force-dynamic"; @@ -44,7 +45,7 @@ async function fetchActiveDates( const activeDates = new Set(); let page = 1; while (true) { - const searchRes = await fetch( + const searchRes = await githubFetch( `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${sinceStr}&per_page=100&page=${page}&sort=author-date&order=desc`, { headers: { @@ -188,7 +189,13 @@ export async function GET(req: NextRequest) { return Response.json( calculateStreakFromDates(activeDates, freezeDates) ); - } catch { + } catch (err) { + if (err instanceof RateLimitError) { + return Response.json( + { error: "rate_limited", resetAt: err.resetAt }, + { status: 429 } + ); + } return Response.json({ error: "GitHub API error" }, { status: 502 }); } } @@ -257,7 +264,13 @@ export async function GET(req: NextRequest) { userId: accountId, }); return Response.json(calculateStreakFromDates(activeDates, freezeDates)); - } catch { + } catch (err) { + if (err instanceof RateLimitError) { + return Response.json( + { error: "rate_limited", resetAt: err.resetAt }, + { status: 429 } + ); + } return Response.json({ error: "GitHub API error" }, { status: 502 }); } } diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index ab68ccb56..686f1b6ee 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -311,8 +311,8 @@ function SettingsPageContent() {
{statusMessage.message} @@ -491,7 +491,7 @@ function SettingsPageContent() {
{removeError && ( -
+
{removeError}
)} @@ -531,7 +531,7 @@ function SettingsPageContent() { onClick={() => handleRemoveAccount(account.githubId)} aria-label={`Remove ${account.githubLogin}`} disabled={removingAccountId === account.githubId} - className="rounded-lg border border-[var(--border)] px-4 py-2 text-sm font-medium text-[var(--card-foreground)] transition-colors hover:bg-red-500/10 hover:text-red-400 disabled:opacity-60" + className="rounded-lg border border-[var(--border)] px-4 py-2 text-sm font-medium text-[var(--card-foreground)] transition-colors hover:bg-[var(--destructive-soft)] hover:text-[var(--destructive-foreground)] disabled:opacity-60" > {removingAccountId === account.githubId ? "Removing..." diff --git a/src/app/globals.css b/src/app/globals.css index d1d9f2102..f2e31b7d7 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -12,7 +12,18 @@ --card-muted: #e2e8f0; --border: #cbd5e1; --accent: #6366f1; - --success: #10b981; + --success: #22c55e; + --success-foreground: #15803d; + --success-soft: rgba(34, 197, 94, 0.15); + --success-border: rgba(34, 197, 94, 0.25); + --warning: #eab308; + --warning-foreground: #a16207; + --warning-soft: rgba(234, 179, 8, 0.15); + --warning-border: rgba(234, 179, 8, 0.25); + --destructive: #ef4444; + --destructive-foreground: #b91c1c; + --destructive-soft: rgba(239, 68, 68, 0.15); + --destructive-border: rgba(239, 68, 68, 0.25); --accent-soft: rgba(99, 102, 241, 0.15); --accent-foreground: #ffffff; --control: #e2e8f0; @@ -31,7 +42,18 @@ --card-muted: #334155; --border: #334155; --accent: #818cf8; - --success: #10b981; + --success: #4ade80; + --success-foreground: #4ade80; + --success-soft: rgba(74, 222, 128, 0.15); + --success-border: rgba(74, 222, 128, 0.25); + --warning: #facc15; + --warning-foreground: #facc15; + --warning-soft: rgba(250, 204, 21, 0.15); + --warning-border: rgba(250, 204, 21, 0.25); + --destructive: #f87171; + --destructive-foreground: #f87171; + --destructive-soft: rgba(248, 113, 113, 0.15); + --destructive-border: rgba(248, 113, 113, 0.25); --accent-soft: rgba(99, 102, 241, 0.2); --accent-foreground: #ffffff; --control: #334155; diff --git a/src/components/CIAnalytics.tsx b/src/components/CIAnalytics.tsx index e6ad07781..5e597fc20 100644 --- a/src/components/CIAnalytics.tsx +++ b/src/components/CIAnalytics.tsx @@ -90,12 +90,12 @@ export default function CIAnalytics() { ))}
) : error ? ( -
+

{error}

diff --git a/src/components/CommitTimeChart.tsx b/src/components/CommitTimeChart.tsx index 5275470fd..44b02d15a 100644 --- a/src/components/CommitTimeChart.tsx +++ b/src/components/CommitTimeChart.tsx @@ -128,12 +128,12 @@ export default function CommitTimeChart() {
) : error ? (
-
+

{error}

diff --git a/src/components/ContributionGraph.tsx b/src/components/ContributionGraph.tsx index 58881d80e..b0ecbd6b1 100644 --- a/src/components/ContributionGraph.tsx +++ b/src/components/ContributionGraph.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { useAccount } from "@/components/AccountContext"; +import RateLimitBanner from "@/components/RateLimitBanner"; import CommitSearchPanel from "@/components/CommitSearchPanel"; import type { CommitItem } from "@/lib/github"; import { @@ -112,6 +113,7 @@ export default function ContributionGraph() { const [lastUpdated, setLastUpdated] = useState(null); const [minutesAgo, setMinutesAgo] = useState(0); const [error, setError] = useState(null); + const [rateLimitResetAt, setRateLimitResetAt] = useState(null); const [commits, setCommits] = useState([]); const [usesTouchTooltip, setUsesTouchTooltip] = useState(false); @@ -164,6 +166,7 @@ export default function ContributionGraph() { useEffect(() => { setLoading(true); setError(null); + setRateLimitResetAt(null); setCommits([]); const accountParam = selectedAccount !== null @@ -171,6 +174,11 @@ export default function ContributionGraph() { : ""; fetch(`/api/metrics/contributions?days=${days}${accountParam}`) .then((r) => { + if (r.status === 429) { + return r.json().then((d: { error: string; resetAt: number }) => { + setRateLimitResetAt(d.resetAt); + }); + } if (!r.ok) throw new Error("API error"); return r.json(); }) @@ -378,6 +386,8 @@ export default function ContributionGraph() { className="h-[220px] rounded border border-[var(--border)] bg-[var(--background)] animate-pulse" />
+ ) : rateLimitResetAt ? ( + ) : error ? (

diff --git a/src/components/ContributionHeatmap.tsx b/src/components/ContributionHeatmap.tsx index 8c81db442..ac1e5eb40 100644 --- a/src/components/ContributionHeatmap.tsx +++ b/src/components/ContributionHeatmap.tsx @@ -219,8 +219,8 @@ export default function ContributionHeatmap({ {loading ? (

) : error ? ( -
-

{error} Please try refreshing.

+
+

{error} Please try refreshing.

) : ( <> diff --git a/src/components/CopyLinkButton.tsx b/src/components/CopyLinkButton.tsx index b4cd81ed3..202acc13e 100644 --- a/src/components/CopyLinkButton.tsx +++ b/src/components/CopyLinkButton.tsx @@ -26,7 +26,7 @@ export default function CopyLinkButton() { > {copied ? ( <> - + Copied! ) : ( diff --git a/src/components/FriendComparison.tsx b/src/components/FriendComparison.tsx index 8387ce66d..42d7abab9 100644 --- a/src/components/FriendComparison.tsx +++ b/src/components/FriendComparison.tsx @@ -116,7 +116,7 @@ export default function FriendComparison() {
{error && ( -
+
{error}
@@ -169,7 +169,7 @@ export default function FriendComparison() { diff --git a/src/components/GoalTracker.tsx b/src/components/GoalTracker.tsx index ad8407a49..48176131f 100644 --- a/src/components/GoalTracker.tsx +++ b/src/components/GoalTracker.tsx @@ -204,7 +204,7 @@ export default function GoalTracker() { )}
{completed && ( - + {completionLabel} )} @@ -221,7 +221,7 @@ export default function GoalTracker() { {createError && ( -

{createError}

+

{createError}

)}
diff --git a/src/components/IssueMetrics.tsx b/src/components/IssueMetrics.tsx index ac9a844af..ab0392148 100644 --- a/src/components/IssueMetrics.tsx +++ b/src/components/IssueMetrics.tsx @@ -53,7 +53,7 @@ export default function IssueMetrics() { : null; const trendColor = - metrics && metrics.trend > 0 ? "text-green-400" : "text-red-400"; + metrics && metrics.trend > 0 ? "text-[var(--success-foreground)]" : "text-[var(--destructive-foreground)]"; return (
@@ -77,12 +77,12 @@ export default function IssueMetrics() { ))}
) : error ? ( -
+

{error}

diff --git a/src/components/PRBreakdownChart.tsx b/src/components/PRBreakdownChart.tsx index af903ccec..15e092b60 100644 --- a/src/components/PRBreakdownChart.tsx +++ b/src/components/PRBreakdownChart.tsx @@ -68,12 +68,12 @@ export default function PRBreakdownChart() { return (

PR Breakdown

-
+

{error}

diff --git a/src/components/PRMetrics.tsx b/src/components/PRMetrics.tsx index 275562499..e2468f8c6 100644 --- a/src/components/PRMetrics.tsx +++ b/src/components/PRMetrics.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { useAccount } from "@/components/AccountContext"; +import RateLimitBanner from "@/components/RateLimitBanner"; import PRStatusDonutChart from "./PRStatusDonutChart"; interface PRMetricsSummary { @@ -36,10 +37,12 @@ export default function PRMetrics() { const [lastUpdated, setLastUpdated] = useState(null); const [minutesAgo, setMinutesAgo] = useState(0); const [error, setError] = useState(null); + const [rateLimitResetAt, setRateLimitResetAt] = useState(null); const fetchMetrics = useCallback(() => { setLoading(true); setError(null); + setRateLimitResetAt(null); const url = selectedAccount !== null @@ -48,6 +51,11 @@ export default function PRMetrics() { fetch(url) .then((r) => { + if (r.status === 429) { + return r.json().then((d: { error: string; resetAt: number }) => { + setRateLimitResetAt(d.resetAt); + }); + } if (!r.ok) throw new Error("API error"); return r.json(); }) @@ -140,13 +148,15 @@ export default function PRMetrics() {
+ ) : rateLimitResetAt ? ( + ) : error ? ( -
+

{error}

diff --git a/src/components/PRReviewTrendChart.tsx b/src/components/PRReviewTrendChart.tsx index 754ae4ce3..77722d879 100644 --- a/src/components/PRReviewTrendChart.tsx +++ b/src/components/PRReviewTrendChart.tsx @@ -179,12 +179,12 @@ export default function PRReviewTrendChart() {
) : error ? (
-
-

{error}

+
+

{error}

diff --git a/src/components/PersonalRecords.tsx b/src/components/PersonalRecords.tsx index 268109a81..aa1bc2eb2 100644 --- a/src/components/PersonalRecords.tsx +++ b/src/components/PersonalRecords.tsx @@ -238,12 +238,12 @@ export default function PersonalRecords() { ))}
) : error ? ( -
+

{error}

diff --git a/src/components/PinnedRepos.tsx b/src/components/PinnedRepos.tsx index 13243ad3a..17d1a0fbe 100644 --- a/src/components/PinnedRepos.tsx +++ b/src/components/PinnedRepos.tsx @@ -60,12 +60,12 @@ export default function PinnedRepos() { ))}
) : error ? ( -
+

{error}

diff --git a/src/components/RateLimitBanner.tsx b/src/components/RateLimitBanner.tsx new file mode 100644 index 000000000..695f54551 --- /dev/null +++ b/src/components/RateLimitBanner.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +interface RateLimitBannerProps { + resetAt: number; +} + +function toResetDate(resetAt: number): Date { + // GitHub reset timestamps are usually epoch seconds; support ms as well. + const ms = resetAt < 1_000_000_000_000 ? resetAt * 1000 : resetAt; + return new Date(ms); +} + +function formatRemaining(ms: number): string { + if (ms <= 0) return "a few seconds"; + + const totalSeconds = Math.ceil(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + if (minutes <= 0) return `${seconds}s`; + if (minutes < 60) return `${minutes}m ${seconds}s`; + + const hours = Math.floor(minutes / 60); + const remMinutes = minutes % 60; + return `${hours}h ${remMinutes}m`; +} + +export default function RateLimitBanner({ resetAt }: RateLimitBannerProps) { + const resetDate = useMemo(() => toResetDate(resetAt), [resetAt]); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, []); + + const remaining = resetDate.getTime() - now; + + return ( +
+

GitHub API rate limit reached.

+

+ Try again in {formatRemaining(remaining)} + {" "}(resets at {resetDate.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}). +

+
+ ); +} diff --git a/src/components/SignOutButton.tsx b/src/components/SignOutButton.tsx index 5055fdda3..ebf3d9daa 100644 --- a/src/components/SignOutButton.tsx +++ b/src/components/SignOutButton.tsx @@ -21,7 +21,7 @@ export default function SignOutButton() { type="button" disabled={signingOut} onClick={handleSignOut} - className="inline-flex h-10 items-center gap-2 rounded-full border border-red-600/50 bg-red-600/80 px-4 text-sm font-semibold text-white transition-colors hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-70"> + className="inline-flex h-10 items-center gap-2 rounded-full border border-[var(--destructive-border)] bg-[var(--destructive)] px-4 text-sm font-semibold text-white transition-colors hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-70"> {signingOut && ( diff --git a/src/components/StreakTracker.tsx b/src/components/StreakTracker.tsx index 5e49d8efc..13e9d106c 100644 --- a/src/components/StreakTracker.tsx +++ b/src/components/StreakTracker.tsx @@ -1,11 +1,12 @@ "use client"; import { useCallback, useEffect, useState } from "react"; import { useAccount } from "@/components/AccountContext"; -import { useCountUp } from "@/hooks/useCountUp"; +import RateLimitBanner from "@/components/RateLimitBanner"; import StreakMilestoneBanner from "@/components/StreakMilestoneBanner"; +import { useCountUp } from "@/hooks/useCountUp"; import { useHeatmapTheme } from "@/hooks/useHeatmapTheme"; -const STREAK_MILESTONES = [7, 30, 50, 100, 200, 365]; +const STREAK_MILESTONES = [3, 7, 14, 30]; interface StreakData { current: number; @@ -40,6 +41,7 @@ export default function StreakTracker() { const [freezeLoading, setFreezeLoading] = useState(true); const [cancelling, setCancelling] = useState(false); const [confirmCancel, setConfirmCancel] = useState(false); + const [rateLimitResetAt, setRateLimitResetAt] = useState(null); const animatedCurrent = useCountUp(data?.current ?? 0); const animatedLongest = useCountUp(data?.longest ?? 0); @@ -48,6 +50,7 @@ export default function StreakTracker() { const fetchStreak = useCallback(async () => { setLoading(true); setError(null); + setRateLimitResetAt(null); try { const streakUrl = @@ -63,6 +66,13 @@ export default function StreakTracker() { fetch(contributionUrl), ]); + if (streakRes.status === 429 || contributionRes.status === 429) { + const rateLimitRes = streakRes.status === 429 ? streakRes : contributionRes; + const d = await rateLimitRes.json() as { error: string; resetAt: number }; + setRateLimitResetAt(d.resetAt); + return; + } + if (!streakRes.ok || !contributionRes.ok) { throw new Error("Failed to fetch data"); } @@ -180,16 +190,25 @@ export default function StreakTracker() { ); } + if (rateLimitResetAt) { + return ( +
+

Commit Streaks

+ +
+ ); + } + if (error) { return (

Commit Streaks

-
+

{error}

@@ -355,7 +374,7 @@ export default function StreakTracker() { aria-label="Copy streak stats to clipboard" > {copied ? ( - Copied! + Copied! ) : ( 📋 )} @@ -493,7 +512,7 @@ export default function StreakTracker() { type="button" onClick={handleCancelFreeze} disabled={cancelling} - className="rounded-md bg-red-500/10 px-2.5 py-1 text-xs font-medium text-red-400 transition hover:bg-red-500/20 disabled:opacity-60" + className="rounded-md bg-[var(--destructive-soft)] px-2.5 py-1 text-xs font-medium text-[var(--destructive-foreground)] transition hover:opacity-90 disabled:opacity-60" > {cancelling ? "Removing..." : "Yes, remove"} @@ -787,10 +806,10 @@ function calculateMonthlyTrend(contrib: ContributionData | undefined | null): Mo if (deltaCalc > 0) { text = `↑${formatted}% vs last month`; - colorClass = "text-green-500 font-medium"; + colorClass = "text-[var(--success-foreground)] font-medium"; } else if (deltaCalc < 0) { text = `↓${Math.abs(deltaCalc).toFixed(0)}% vs last month`; - colorClass = "text-red-500 font-medium"; + colorClass = "text-[var(--destructive-foreground)] font-medium"; } else { text = `=0% vs last month`; colorClass = "text-[var(--muted-foreground)] font-medium"; diff --git a/src/components/TopRepos.tsx b/src/components/TopRepos.tsx index 351b872d5..11e142992 100644 --- a/src/components/TopRepos.tsx +++ b/src/components/TopRepos.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { useAccount } from "@/components/AccountContext"; +import RateLimitBanner from "@/components/RateLimitBanner"; import type { RepoHealthScore } from "@/types/repo-health"; interface RepoLanguage { @@ -80,18 +81,31 @@ export default function TopRepos() { const [minutesAgo, setMinutesAgo] = useState(0); const [healthScores, setHealthScores] = useState>({}); const [healthLoading, setHealthLoading] = useState(true); + const [rateLimitResetAt, setRateLimitResetAt] = useState(null); const [sortColumn, setSortColumn] = useState<"commits" | "name">("commits"); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); const fetchRepos = useCallback(() => { setLoading(true); setError(null); + setRateLimitResetAt(null); const accountParam = selectedAccount !== null ? `&accountId=${encodeURIComponent(selectedAccount)}` : ""; fetch(`/api/metrics/repos?days=${days}${accountParam}`) - .then((r) => r.json()) - .then((d: { repos: Repo[] }) => setRepos(d.repos ?? [])) + .then((r) => { + if (r.status === 429) { + return r.json().then((d: { error: string; resetAt: number }) => { + setRateLimitResetAt(d.resetAt); + }); + } + return r.json(); + }) + .then((d: { repos: Repo[] } | undefined) => { + if (d && 'repos' in d) { + setRepos(d.repos ?? []); + } + }) .catch(() => setError("We couldn't load your top repositories right now. Please try again in a moment.")) .finally(() => { setLoading(false); @@ -188,13 +202,15 @@ export default function TopRepos() { /> ))}
+ ) : rateLimitResetAt ? ( + ) : error ? ( -
+

{error}

@@ -246,10 +262,10 @@ export default function TopRepos() { : undefined; const badgeClass = health?.grade === "green" - ? "bg-green-500/15 text-green-300 border border-green-500/25" + ? "bg-[var(--success-soft)] text-[var(--success-foreground)] border border-[var(--success-border)]" : health?.grade === "yellow" - ? "bg-yellow-500/15 text-yellow-300 border border-yellow-500/25" - : "bg-red-500/15 text-red-300 border border-red-500/25"; + ? "bg-[var(--warning-soft)] text-[var(--warning-foreground)] border border-[var(--warning-border)]" + : "bg-[var(--destructive-soft)] text-[var(--destructive-foreground)] border border-[var(--destructive-border)]"; const visibleLanguages = repo.languages ? getVisibleLanguages(repo.languages) : []; return (
  • diff --git a/src/components/WeeklySummaryCard.tsx b/src/components/WeeklySummaryCard.tsx index ab0fc5672..3cc15a0c1 100644 --- a/src/components/WeeklySummaryCard.tsx +++ b/src/components/WeeklySummaryCard.tsx @@ -79,7 +79,7 @@ export default function WeeklySummaryCard() { ))}
  • ) : error ? ( -
    +
    {error}
    ) : summary ? ( @@ -93,12 +93,12 @@ export default function WeeklySummaryCard() { {summary.commits.current} {summary.commits.trend === "up" && ( - + + {summary.commits.delta} )} {summary.commits.trend === "down" && ( - + - {Math.abs(summary.commits.delta)} )} diff --git a/src/lib/githubFetch.ts b/src/lib/githubFetch.ts new file mode 100644 index 000000000..442eeadcd --- /dev/null +++ b/src/lib/githubFetch.ts @@ -0,0 +1,25 @@ +export class RateLimitError extends Error { + resetAt: number; + + constructor(resetAt: number) { + super("GitHub API rate limited"); + this.resetAt = resetAt; + this.name = "RateLimitError"; + } +} + +export async function githubFetch( + url: string, + options?: RequestInit +): Promise { + const response = await fetch(url, options); + + if (response.status === 429) { + const data = (await response.json()) as { message?: string }; + const resetAtHeader = response.headers.get("X-RateLimit-Reset"); + const resetAt = resetAtHeader ? parseInt(resetAtHeader) * 1000 : Date.now() + 3600000; + throw new RateLimitError(resetAt); + } + + return response; +}