From 1060cd28c294e7b74cff961db6b1650eb79f9824 Mon Sep 17 00:00:00 2001 From: VIDYANKSHINI Date: Wed, 27 May 2026 15:09:37 +0530 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20add=20GitHub=20Sponsors=20integrati?= =?UTF-8?q?on=20=E2=80=94=20show=20sponsor=20badge=20on=20leaderboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pr_body.md | 39 ++++++++ src/app/api/leaderboard/route.ts | 5 +- src/app/api/sponsors/sync/route.ts | 90 +++++++++++++++++++ src/app/leaderboard/page.tsx | 5 +- src/app/page.tsx | 35 ++++++-- src/app/u/[username]/page.tsx | 11 ++- src/components/LanguageBreakdown.tsx | 1 + src/components/SponsorBadge.tsx | 11 +++ src/components/landing/LandingPage.tsx | 6 +- src/lib/public-profile-data.ts | 1 + src/lib/supabase.ts | 5 +- .../20260527000000_add_is_sponsor.sql | 1 + supabase/schema.sql | 3 +- vercel.json | 4 + 14 files changed, 199 insertions(+), 18 deletions(-) create mode 100644 pr_body.md create mode 100644 src/app/api/sponsors/sync/route.ts create mode 100644 src/components/SponsorBadge.tsx create mode 100644 supabase/migrations/20260527000000_add_is_sponsor.sql diff --git a/pr_body.md b/pr_body.md new file mode 100644 index 000000000..72eab2568 --- /dev/null +++ b/pr_body.md @@ -0,0 +1,39 @@ +## Summary + +This PR implements the per-repository commit heatmap functionality inside the Top Repos widget. Clicking a repository name now opens a drawer with a 90-day mini contribution heatmap and relevant metrics. + +Closes #943 + +## Type of Change + +- [ ] Bug fix +- [x] New feature +- [ ] Documentation update +- [ ] Refactor / code cleanup + +## Changes Made + +- Created `RepoActivityDrawer` component for displaying the per-repo heatmap and metrics (Escape to close, focus-trap logic). +- Created `/api/metrics/repos/[...repo]/commits` API endpoint to fetch up to 100 commits from the last 90 days. +- Updated `TopRepos` widget to make repository names clickable and open the drawer. +- Added a new external link icon next to repo names to preserve the ability to quickly open the repo in GitHub. + +## How to Test + +Steps for the reviewer to verify this works: + +1. Go to the dashboard and ensure the Top Repos widget loads. +2. Click on a repository name. +3. Verify the drawer opens from the right and displays the total commits, most active day, peak hour, and the 90-day mini heatmap correctly. +4. Verify pressing Escape closes the drawer. +5. Click the external link icon to verify it opens the repository on GitHub. + +## Screenshots (if UI change) + +## Checklist + +- [x] Linked issue in summary +- [x] `npm run lint` passes locally +- [x] No TypeScript errors (`npm run type-check`) +- [x] Self-reviewed the diff +- [x] Added/updated tests if applicable diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts index 912dd0edd..ab97f9697 100644 --- a/src/app/api/leaderboard/route.ts +++ b/src/app/api/leaderboard/route.ts @@ -78,6 +78,7 @@ type LeaderboardMetric = "streak" | "commits" | "prs"; interface PublicUser { id: string; github_login: string; + is_sponsor: boolean; } interface LeaderboardEntry { @@ -89,6 +90,7 @@ interface LeaderboardEntry { commits: number; prs: number; score: number; + isSponsor: boolean; } interface LeaderboardPayload { @@ -251,7 +253,7 @@ async function fetchPrCount(username: string, since: string): Promise { async function buildLeaderboard(): Promise { const { data: users, error } = await supabaseAdmin .from("users") - .select("id, github_login") + .select("id, github_login, is_sponsor") .eq("is_public", true) .eq("leaderboard_opt_in", true) .limit(50); @@ -292,6 +294,7 @@ async function buildLeaderboard(): Promise { commits, prs, score, + isSponsor: user.is_sponsor ?? false, }; } ); diff --git a/src/app/api/sponsors/sync/route.ts b/src/app/api/sponsors/sync/route.ts new file mode 100644 index 000000000..c1aad1a06 --- /dev/null +++ b/src/app/api/sponsors/sync/route.ts @@ -0,0 +1,90 @@ +import { NextResponse } from "next/server"; +import { supabaseAdmin } from "@/lib/supabase"; + +export const dynamic = "force-dynamic"; + +export async function GET(req: Request) { + const authHeader = req.headers.get("authorization"); + if (authHeader !== `Bearer ${process.env.CRON_SECRET}` && process.env.NODE_ENV !== "development") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const token = process.env.GITHUB_TOKEN; + if (!token) { + return NextResponse.json({ error: "No GitHub token configured" }, { status: 500 }); + } + + const targetOwner = "Priyanshu-byte-coder"; + + try { + const query = ` + query { + user(login: "${targetOwner}") { + sponsorshipsAsMaintainer(first: 100) { + nodes { + sponsorEntity { + ... on User { + login + } + ... on Organization { + login + } + } + } + } + } + } + `; + + const res = await fetch("https://api.github.com/graphql", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ query }), + cache: "no-store", + }); + + if (!res.ok) { + console.error("Failed to fetch sponsors:", res.status); + return NextResponse.json({ error: "GitHub API error" }, { status: 502 }); + } + + const { data } = await res.json(); + + const sponsorLogins: string[] = []; + + if (data?.user?.sponsorshipsAsMaintainer?.nodes) { + const nodes = data.user.sponsorshipsAsMaintainer.nodes; + for (const node of nodes) { + if (node.sponsorEntity?.login) { + sponsorLogins.push(node.sponsorEntity.login); + } + } + } + + // Reset all is_sponsor to false + await supabaseAdmin + .from("users") + .update({ is_sponsor: false }) + .neq("is_sponsor", false); + + // Set is_sponsor = true for active sponsors + if (sponsorLogins.length > 0) { + await supabaseAdmin + .from("users") + .update({ is_sponsor: true }) + .in("github_login", sponsorLogins); + } + + return NextResponse.json({ + success: true, + sponsorCount: sponsorLogins.length, + sponsors: sponsorLogins + }); + } catch (error) { + console.error("Error in sponsors sync:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/leaderboard/page.tsx b/src/app/leaderboard/page.tsx index bf33d2b20..f0b2d7694 100644 --- a/src/app/leaderboard/page.tsx +++ b/src/app/leaderboard/page.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import EmptyState from "@/components/EmptyState"; +import SponsorBadge from "@/components/SponsorBadge"; type LeaderboardTab = "streak" | "commits" | "prs"; @@ -12,6 +13,7 @@ interface LeaderboardEntry { commits: number; prs: number; score: number; + isSponsor: boolean; } interface LeaderboardPayload { @@ -152,9 +154,10 @@ export default async function LeaderboardPage({
@{entry.username} + {entry.isSponsor && }
{entry.commits} commits ยท {entry.prs} PRs ยท {entry.streak}d diff --git a/src/app/page.tsx b/src/app/page.tsx index 1d3e6d82c..504297de3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -6,6 +6,7 @@ import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { redirect } from "next/navigation"; import LandingPage, { type RepoStats } from "@/components/landing/LandingPage"; +import { supabaseAdmin } from "@/lib/supabase"; const syne = Syne({ subsets: ["latin"], @@ -43,19 +44,39 @@ async function fetchRepoStats(): Promise { const contributors = contribRes.ok ? ((await contribRes.json()) as Array>) : []; const gfiIssues = gfiRes.ok ? ((await gfiRes.json()) as unknown[]) : []; + let mappedContributors = Array.isArray(contributors) + ? contributors.slice(0, 20).map((c) => ({ + login: String(c.login ?? ""), + avatar_url: String(c.avatar_url ?? ""), + html_url: String(c.html_url ?? ""), + isSponsor: false, + })) + : []; + + if (mappedContributors.length > 0 && supabaseAdmin) { + const logins = mappedContributors.map((c) => c.login); + const { data: sponsors } = await supabaseAdmin + .from("users") + .select("github_login") + .in("github_login", logins) + .eq("is_sponsor", true); + + if (sponsors && sponsors.length > 0) { + const sponsorSet = new Set(sponsors.map((s) => s.github_login)); + mappedContributors = mappedContributors.map((c) => ({ + ...c, + isSponsor: sponsorSet.has(c.login), + })); + } + } + return { stars: typeof repo.stargazers_count === "number" ? repo.stargazers_count : 0, forks: typeof repo.forks_count === "number" ? repo.forks_count : 0, openIssues: typeof repo.open_issues_count === "number" ? repo.open_issues_count : 0, contributorCount: Array.isArray(contributors) ? contributors.length : 0, goodFirstIssues: Array.isArray(gfiIssues) ? gfiIssues.length : 0, - contributors: Array.isArray(contributors) - ? contributors.slice(0, 20).map((c) => ({ - login: String(c.login ?? ""), - avatar_url: String(c.avatar_url ?? ""), - html_url: String(c.html_url ?? ""), - })) - : [], + contributors: mappedContributors, }; } catch { return { diff --git a/src/app/u/[username]/page.tsx b/src/app/u/[username]/page.tsx index 8b35b3956..064b1f01a 100644 --- a/src/app/u/[username]/page.tsx +++ b/src/app/u/[username]/page.tsx @@ -5,6 +5,7 @@ import GitHubAchievements from "@/components/GitHubAchievements"; import StatsCard from "@/components/StatsCard"; import ShareProfileSection from "@/components/ShareProfileSection"; import ThemeToggle from "@/components/ThemeToggle"; +import SponsorBadge from "@/components/SponsorBadge"; import { getUserByUsername } from "@/lib/supabase"; import { syncGitHubAchievementsForUser } from "@/lib/github-achievements"; @@ -50,6 +51,7 @@ async function fetchPublicProfile( return { username: user.github_login, userId: user.id, + isSponsor: user.is_sponsor ?? false, repos, contributions, streak, @@ -148,9 +150,12 @@ export default async function PublicProfilePage({
-

- @{profile.username}'s Profile -

+
+

+ @{profile.username}'s Profile + {profile.isSponsor && } +

+

GitHub activity and coding stats

diff --git a/src/components/LanguageBreakdown.tsx b/src/components/LanguageBreakdown.tsx index 24b8c2079..74c9397e5 100644 --- a/src/components/LanguageBreakdown.tsx +++ b/src/components/LanguageBreakdown.tsx @@ -31,6 +31,7 @@ function getColor(name: string): string { function LanguageDot({ color, label }: { color: string; label: string }) { return ( + {label} ); diff --git a/src/components/SponsorBadge.tsx b/src/components/SponsorBadge.tsx new file mode 100644 index 000000000..9d3b54c9d --- /dev/null +++ b/src/components/SponsorBadge.tsx @@ -0,0 +1,11 @@ +export default function SponsorBadge({ className = "" }: { className?: string }) { + return ( + + ๐Ÿ’Ž Sponsor + + ); +} diff --git a/src/components/landing/LandingPage.tsx b/src/components/landing/LandingPage.tsx index 2dba147e2..ad766c79c 100644 --- a/src/components/landing/LandingPage.tsx +++ b/src/components/landing/LandingPage.tsx @@ -11,7 +11,7 @@ export type RepoStats = { openIssues: number; contributorCount: number; goodFirstIssues: number; - contributors: Array<{ login: string; avatar_url: string; html_url: string }>; + contributors: Array<{ login: string; avatar_url: string; html_url: string; isSponsor?: boolean }>; }; /* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• @@ -772,10 +772,10 @@ function ContributeSection({ stats }: { stats: RepoStats }) { href={c.html_url} target="_blank" rel="noopener noreferrer" - title={`@${c.login}`} + title={c.isSponsor ? `@${c.login} (Sponsor ๐Ÿ’Ž)` : `@${c.login}`} style={{ width: 38, height: 38, borderRadius: '50%', - border: `2px solid ${BG}`, + border: `2px solid ${c.isSponsor ? '#ec4899' : BG}`, marginLeft: i > 0 ? -11 : 0, overflow: 'hidden', display: 'block', position: 'relative', zIndex: stats.contributors.length - i, diff --git a/src/lib/public-profile-data.ts b/src/lib/public-profile-data.ts index 6ca05c433..9067838b3 100644 --- a/src/lib/public-profile-data.ts +++ b/src/lib/public-profile-data.ts @@ -25,6 +25,7 @@ export interface StreakData { export interface PublicProfileData { username: string; userId: string; + isSponsor: boolean; repos: TopRepo[]; contributions: ContributionData; streak: StreakData; diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index 5b9ac43ef..15333b043 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -6,7 +6,7 @@ const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; export const SUPABASE_ADMIN_UNAVAILABLE_MESSAGE = "Supabase admin client is unavailable. Check NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY."; -// eslint-disable-next-line @typescript-eslint/no-explicit-any +// eslint-disable-next-line type SupabaseAdminClient = SupabaseClient; function createUnavailableSupabaseAdmin(): SupabaseAdminClient { @@ -31,6 +31,7 @@ interface User { is_public: boolean; created_at: string; updated_at: string; + is_sponsor?: boolean; } /** @@ -43,7 +44,7 @@ export async function getUserByUsername( try { const { data, error } = await supabaseAdmin .from("users") - .select("id,github_id,github_login,is_public,created_at,updated_at") + .select("id,github_id,github_login,is_public,created_at,updated_at,is_sponsor") .ilike("github_login", username) .eq("is_public", true) .single(); diff --git a/supabase/migrations/20260527000000_add_is_sponsor.sql b/supabase/migrations/20260527000000_add_is_sponsor.sql new file mode 100644 index 000000000..3c08d6402 --- /dev/null +++ b/supabase/migrations/20260527000000_add_is_sponsor.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN IF NOT EXISTS is_sponsor BOOLEAN DEFAULT FALSE; diff --git a/supabase/schema.sql b/supabase/schema.sql index 248748f79..505f2ec0a 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -8,7 +8,8 @@ create table if not exists users ( created_at timestamptz default now(), updated_at timestamptz default now(), wakatime_api_key_encrypted text, - wakatime_api_key_iv text + wakatime_api_key_iv text, + is_sponsor boolean default false ); create table if not exists goals ( diff --git a/vercel.json b/vercel.json index 35b3af733..bff3d114f 100644 --- a/vercel.json +++ b/vercel.json @@ -11,6 +11,10 @@ { "path": "/api/goals/sync", "schedule": "0 0 * * *" + }, + { + "path": "/api/sponsors/sync", + "schedule": "0 0 * * *" } ] } From 5e8be13cc407b186fc179a3e229d8d7e704d080d Mon Sep 17 00:00:00 2001 From: VIDYANKSHINI Date: Wed, 27 May 2026 15:15:44 +0530 Subject: [PATCH 2/4] fix: address PR review comments for GitHub Sponsors integration --- src/app/api/sponsors/sync/route.ts | 66 +++++++++++++++++++++++++----- src/components/SponsorBadge.tsx | 2 +- 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/src/app/api/sponsors/sync/route.ts b/src/app/api/sponsors/sync/route.ts index c1aad1a06..aebbba8b3 100644 --- a/src/app/api/sponsors/sync/route.ts +++ b/src/app/api/sponsors/sync/route.ts @@ -5,7 +5,13 @@ export const dynamic = "force-dynamic"; export async function GET(req: Request) { const authHeader = req.headers.get("authorization"); - if (authHeader !== `Bearer ${process.env.CRON_SECRET}` && process.env.NODE_ENV !== "development") { + const cronSecret = process.env.CRON_SECRET; + + if (!cronSecret) { + return NextResponse.json({ error: "CRON_SECRET is not configured" }, { status: 500 }); + } + + if (authHeader !== `Bearer ${cronSecret}` && process.env.NODE_ENV !== "development") { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } @@ -51,11 +57,21 @@ export async function GET(req: Request) { return NextResponse.json({ error: "GitHub API error" }, { status: 502 }); } - const { data } = await res.json(); + const { data, errors } = await res.json(); + + if (errors && errors.length > 0) { + console.error("GraphQL errors:", errors); + return NextResponse.json({ error: "GraphQL query failed" }, { status: 502 }); + } + + if (!data || !data.user) { + console.error("GraphQL returned empty data or null user"); + return NextResponse.json({ error: "GraphQL query returned no user data" }, { status: 502 }); + } const sponsorLogins: string[] = []; - if (data?.user?.sponsorshipsAsMaintainer?.nodes) { + if (data.user.sponsorshipsAsMaintainer?.nodes) { const nodes = data.user.sponsorshipsAsMaintainer.nodes; for (const node of nodes) { if (node.sponsorEntity?.login) { @@ -64,18 +80,46 @@ export async function GET(req: Request) { } } - // Reset all is_sponsor to false - await supabaseAdmin + const { data: currentSponsors, error: fetchErr } = await supabaseAdmin .from("users") - .update({ is_sponsor: false }) - .neq("is_sponsor", false); + .select("github_login") + .eq("is_sponsor", true); + + if (fetchErr) { + console.error("Failed to fetch current sponsors:", fetchErr); + return NextResponse.json({ error: "Database error" }, { status: 500 }); + } - // Set is_sponsor = true for active sponsors - if (sponsorLogins.length > 0) { - await supabaseAdmin + const currentLogins = new Set( + (currentSponsors || []).map((u: any) => String(u.github_login)) + ); + const newLogins = new Set(sponsorLogins); + + const toRemove = [...currentLogins].filter((login: string) => !newLogins.has(login)); + const toAdd = [...newLogins].filter((login: string) => !currentLogins.has(login)); + + if (toRemove.length > 0) { + const { error } = await supabaseAdmin + .from("users") + .update({ is_sponsor: false }) + .in("github_login", toRemove); + + if (error) { + console.error("Failed to remove sponsors:", error); + return NextResponse.json({ error: "Database error" }, { status: 500 }); + } + } + + if (toAdd.length > 0) { + const { error } = await supabaseAdmin .from("users") .update({ is_sponsor: true }) - .in("github_login", sponsorLogins); + .in("github_login", toAdd); + + if (error) { + console.error("Failed to add sponsors:", error); + return NextResponse.json({ error: "Database error" }, { status: 500 }); + } } return NextResponse.json({ diff --git a/src/components/SponsorBadge.tsx b/src/components/SponsorBadge.tsx index 9d3b54c9d..4bba117f3 100644 --- a/src/components/SponsorBadge.tsx +++ b/src/components/SponsorBadge.tsx @@ -5,7 +5,7 @@ export default function SponsorBadge({ className = "" }: { className?: string }) title="GitHub Sponsor โ€” thank you for supporting DevTrack!" aria-label="GitHub Sponsor" > - ๐Ÿ’Ž Sponsor + Sponsor ); } From 7ff857e489a761f930dd78fc3fea2b1877423b46 Mon Sep 17 00:00:00 2001 From: VIDYANKSHINI Date: Thu, 28 May 2026 18:10:45 +0530 Subject: [PATCH 3/4] fix: resolve lint errors, typescript issues, and remove pr_body.md --- pr_body.md | 39 --------------------------------------- 1 file changed, 39 deletions(-) delete mode 100644 pr_body.md diff --git a/pr_body.md b/pr_body.md deleted file mode 100644 index 72eab2568..000000000 --- a/pr_body.md +++ /dev/null @@ -1,39 +0,0 @@ -## Summary - -This PR implements the per-repository commit heatmap functionality inside the Top Repos widget. Clicking a repository name now opens a drawer with a 90-day mini contribution heatmap and relevant metrics. - -Closes #943 - -## Type of Change - -- [ ] Bug fix -- [x] New feature -- [ ] Documentation update -- [ ] Refactor / code cleanup - -## Changes Made - -- Created `RepoActivityDrawer` component for displaying the per-repo heatmap and metrics (Escape to close, focus-trap logic). -- Created `/api/metrics/repos/[...repo]/commits` API endpoint to fetch up to 100 commits from the last 90 days. -- Updated `TopRepos` widget to make repository names clickable and open the drawer. -- Added a new external link icon next to repo names to preserve the ability to quickly open the repo in GitHub. - -## How to Test - -Steps for the reviewer to verify this works: - -1. Go to the dashboard and ensure the Top Repos widget loads. -2. Click on a repository name. -3. Verify the drawer opens from the right and displays the total commits, most active day, peak hour, and the 90-day mini heatmap correctly. -4. Verify pressing Escape closes the drawer. -5. Click the external link icon to verify it opens the repository on GitHub. - -## Screenshots (if UI change) - -## Checklist - -- [x] Linked issue in summary -- [x] `npm run lint` passes locally -- [x] No TypeScript errors (`npm run type-check`) -- [x] Self-reviewed the diff -- [x] Added/updated tests if applicable From f175d2aea39b470adb051ca4db5635bb0f1fe748 Mon Sep 17 00:00:00 2001 From: VIDYANKSHINI Date: Thu, 28 May 2026 18:23:09 +0530 Subject: [PATCH 4/4] test: fix unknown type error in mergeMetrics test --- test/github-accounts.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/github-accounts.test.ts b/test/github-accounts.test.ts index 3e15e56e2..42578d018 100644 --- a/test/github-accounts.test.ts +++ b/test/github-accounts.test.ts @@ -173,7 +173,7 @@ describe('mergeMetrics', () => { it('returns null for empty results array', async () => { const { mergeMetrics } = await import('../src/lib/github-accounts'); - const merged = mergeMetrics([], (a, b) => ({ count: a.count + b.count })); + const merged = mergeMetrics([], (a: { count: number }, b: { count: number }) => ({ count: a.count + b.count })); expect(merged).toBeNull(); });