From 8781b1d6f1bd1601b28365a7600239a4b952a7a9 Mon Sep 17 00:00:00 2001 From: Harshit Date: Wed, 17 Jun 2026 10:29:22 +0530 Subject: [PATCH 1/4] feat(web): add contributor leaderboard page Add a /leaderboard route to apps/web that ranks repo contributors by merged PRs, issues created, and open PRs (data from the GitHub API), showing each contributor's avatar and GitHub username. Mention the route in the README Contributors section. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 + apps/web/src/App.tsx | 2 + apps/web/src/pages/LeaderboardPage.css | 145 +++++++++++++++++++ apps/web/src/pages/LeaderboardPage.tsx | 187 +++++++++++++++++++++++++ 4 files changed, 336 insertions(+) create mode 100644 apps/web/src/pages/LeaderboardPage.css create mode 100644 apps/web/src/pages/LeaderboardPage.tsx diff --git a/README.md b/README.md index 2690181e..53b82a00 100644 --- a/README.md +++ b/README.md @@ -307,6 +307,8 @@ Thanks to all the amazing people who contribute to **DevCard** 🚀

+> 🏆 **Contributor Leaderboard** — contributors are also ranked by merged PRs, issues, and open PRs in the web app at the [`/leaderboard`](https://devcard.app/leaderboard) route (`apps/web`). +
## Project Support diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 49c29037..8dd9dc07 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -2,6 +2,7 @@ import { Routes, Route } from 'react-router-dom'; import LandingPage from './pages/LandingPage'; import ProfilePage from './pages/ProfilePage'; import CardPage from './pages/CardPage'; +import LeaderboardPage from './pages/LeaderboardPage'; import NotFound from './pages/NotFound'; export default function App() { @@ -10,6 +11,7 @@ export default function App() { } /> } /> } /> + } /> } /> ); diff --git a/apps/web/src/pages/LeaderboardPage.css b/apps/web/src/pages/LeaderboardPage.css new file mode 100644 index 00000000..d4daa973 --- /dev/null +++ b/apps/web/src/pages/LeaderboardPage.css @@ -0,0 +1,145 @@ +.leaderboard { + max-width: 860px; + margin: 0 auto; + padding: 7rem 1.5rem 4rem; +} + +.leaderboard-header { + text-align: center; + margin-bottom: 3rem; +} + +.leaderboard-header h1 { + font-size: clamp(2.2rem, 5vw, 3.2rem); + font-weight: 900; + font-family: 'Outfit', sans-serif; + margin: 1rem 0 0.75rem; +} + +.leaderboard-subtitle { + color: var(--text-secondary); + line-height: 1.7; + max-width: 520px; + margin: 0 auto; +} + +.leaderboard-state { + text-align: center; + padding: 2.5rem 1.5rem; + border-radius: var(--radius-lg); + color: var(--text-secondary); +} + +.leaderboard-state.error { + color: #ef4444; +} + +.leaderboard-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0; + margin: 0; +} + +.leaderboard-row { + display: grid; + grid-template-columns: 2.5rem 1fr auto; + align-items: center; + gap: 1rem; + padding: 1rem 1.25rem; + border-radius: var(--radius-lg); +} + +.rank { + font-weight: 800; + font-size: 1.1rem; + text-align: center; + color: var(--text-muted); +} + +.rank-1 { color: #f5c518; } +.rank-2 { color: #c0c5ce; } +.rank-3 { color: #cd7f32; } + +.contributor { + display: flex; + align-items: center; + gap: 0.85rem; + text-decoration: none; + color: var(--text-primary); + min-width: 0; +} + +.contributor-avatar { + width: 44px; + height: 44px; + border-radius: 50%; + border: 2px solid var(--border-glass); + flex-shrink: 0; +} + +.contributor-name { + font-weight: 600; + font-family: 'Outfit', sans-serif; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.contributor:hover .contributor-name { + color: var(--primary); +} + +.stats { + display: flex; + gap: 1.25rem; +} + +.stat { + display: flex; + flex-direction: column; + align-items: center; + min-width: 3.5rem; +} + +.stat-value { + font-weight: 800; + font-size: 1.15rem; + color: var(--text-primary); +} + +.stat-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); +} + +.leaderboard-footer { + text-align: center; + margin-top: 2.5rem; +} + +.leaderboard-footer a { + text-decoration: none; + font-weight: 600; +} + +@media (max-width: 600px) { + .leaderboard-row { + grid-template-columns: 2rem 1fr; + grid-template-areas: + 'rank contributor' + 'stats stats'; + row-gap: 0.85rem; + } + .rank { grid-area: rank; } + .contributor { grid-area: contributor; } + .stats { + grid-area: stats; + justify-content: space-around; + width: 100%; + } +} diff --git a/apps/web/src/pages/LeaderboardPage.tsx b/apps/web/src/pages/LeaderboardPage.tsx new file mode 100644 index 00000000..219d1e6d --- /dev/null +++ b/apps/web/src/pages/LeaderboardPage.tsx @@ -0,0 +1,187 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import Navbar from '../components/Navbar'; +import './LeaderboardPage.css'; + +const REPO = 'Dev-Card/DevCard'; +const GITHUB_API = 'https://api.github.com'; + +// Maintainers / accounts excluded from the contributor leaderboard. +const EXCLUDED = new Set( + ['ShantKhatri', 'Harxhit', 'blankirigaya'].map((u) => u.toLowerCase()) +); + +type Contributor = { + login: string; + avatarUrl: string; + profileUrl: string; + issues: number; + mergedPrs: number; + openPrs: number; +}; + +type GithubContributor = { + login: string; + avatar_url: string; + html_url: string; + type: string; +}; + +type SearchResult = { total_count: number }; + +async function ghJson(url: string): Promise { + const response = await fetch(url, { + headers: { Accept: 'application/vnd.github+json' }, + }); + if (!response.ok) { + throw new Error(`GitHub request failed: ${response.status}`); + } + return response.json() as Promise; +} + +async function countSearch(query: string): Promise { + const url = `${GITHUB_API}/search/issues?q=${encodeURIComponent(query)}&per_page=1`; + const result = await ghJson(url); + return result.total_count; +} + +async function loadContributorStats(login: string): Promise> { + const base = `repo:${REPO} author:${login}`; + const [issues, mergedPrs, openPrs] = await Promise.all([ + countSearch(`${base} type:issue`), + countSearch(`${base} type:pr is:merged`), + countSearch(`${base} type:pr is:open`), + ]); + return { issues, mergedPrs, openPrs }; +} + +export default function LeaderboardPage() { + const [contributors, setContributors] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + document.title = 'Contributor Leaderboard | DevCard'; + }, []); + + useEffect(() => { + let cancelled = false; + + async function load() { + setLoading(true); + setError(null); + try { + const list = await ghJson( + `${GITHUB_API}/repos/${REPO}/contributors?per_page=100` + ); + + const eligible = list.filter( + (c) => c.type === 'User' && !EXCLUDED.has(c.login.toLowerCase()) + ); + + const enriched = await Promise.all( + eligible.map(async (c) => { + const stats = await loadContributorStats(c.login); + return { + login: c.login, + avatarUrl: c.avatar_url, + profileUrl: c.html_url, + ...stats, + } satisfies Contributor; + }) + ); + + enriched.sort( + (a, b) => + b.mergedPrs - a.mergedPrs || + b.issues - a.issues || + b.openPrs - a.openPrs || + a.login.localeCompare(b.login) + ); + + if (!cancelled) setContributors(enriched); + } catch { + if (!cancelled) setError('Could not load contributors. GitHub may be rate-limiting — try again shortly.'); + } finally { + if (!cancelled) setLoading(false); + } + } + + load(); + return () => { + cancelled = true; + }; + }, []); + + return ( + <> +
+ +
+
+
🏆 Community
+

+ Contributor Leaderboard +

+

+ The developers building DevCard — ranked by merged pull requests, issues, and open work. +

+
+ + {loading && ( +
Loading contributors…
+ )} + + {error && !loading && ( +
{error}
+ )} + + {!loading && !error && ( +
    + {contributors.map((c, i) => ( +
  1. + + {i + 1} + + + {c.login} + @{c.login} + +
    + + {c.mergedPrs} + Merged PRs + + + {c.openPrs} + Open PRs + + + {c.issues} + Issues + +
    +
  2. + ))} +
+ )} + +
+ + ← Back to Home + +
+
+ + ); +} From 4c96ea0a879d19d96bd5d4efeeb2a652825689a7 Mon Sep 17 00:00:00 2001 From: Harshit Date: Mon, 22 Jun 2026 09:34:05 +0530 Subject: [PATCH 2/4] fix: generate leaderboard data via GitHub Actions --- .github/scripts/generateLeaderboard.js | 126 ++++++++++ .github/workflows/leaderboard.yml | 0 apps/web/src/pages/LeaderboardPage.tsx | 336 ++++++++++++------------- 3 files changed, 293 insertions(+), 169 deletions(-) create mode 100644 .github/scripts/generateLeaderboard.js create mode 100644 .github/workflows/leaderboard.yml diff --git a/.github/scripts/generateLeaderboard.js b/.github/scripts/generateLeaderboard.js new file mode 100644 index 00000000..9cb78e2d --- /dev/null +++ b/.github/scripts/generateLeaderboard.js @@ -0,0 +1,126 @@ +module.exports = async ({ github, context }) => { + const owner = context.repo.owner; + const repo = context.repo.repo; + + const EXCLUDED = new Set([ + 'shantkhatri', + 'harxhit', + 'blankirigaya' + ]); + + const contributors = new Map(); + + const ensure = (login, avatarUrl, profileUrl) => { + if (!contributors.has(login)) { + contributors.set(login, { + login, + avatarUrl, + profileUrl, + mergedPrs: 0, + openPrs: 0, + issues: 0 + }); + } + + return contributors.get(login); + }; + + const mergedPrs = await github.paginate( + github.rest.pulls.list, + { + owner, + repo, + state: 'closed', + per_page: 100 + } + ); + + for (const pr of mergedPrs) { + if (!pr.merged_at) continue; + if (!pr.user) continue; + + const login = pr.user.login; + + if (EXCLUDED.has(login.toLowerCase())) continue; + + const user = ensure( + login, + pr.user.avatar_url, + pr.user.html_url + ); + + user.mergedPrs++; + } + + const openPrs = await github.paginate( + github.rest.pulls.list, + { + owner, + repo, + state: 'open', + per_page: 100 + } + ); + + for (const pr of openPrs) { + if (!pr.user) continue; + + const login = pr.user.login; + + if (EXCLUDED.has(login.toLowerCase())) continue; + + const user = ensure( + login, + pr.user.avatar_url, + pr.user.html_url + ); + + user.openPrs++; + } + + const issues = await github.paginate( + github.rest.issues.listForRepo, + { + owner, + repo, + state: 'all', + per_page: 100 + } + ); + + for (const issue of issues) { + if (issue.pull_request) continue; + if (!issue.user) continue; + + const login = issue.user.login; + + if (EXCLUDED.has(login.toLowerCase())) continue; + + const user = ensure( + login, + issue.user.avatar_url, + issue.user.html_url + ); + + user.issues++; + } + + const leaderboard = [...contributors.values()].sort( + (a, b) => + b.mergedPrs - a.mergedPrs || + b.issues - a.issues || + b.openPrs - a.openPrs || + a.login.localeCompare(b.login) + ); + + const fs = require('fs'); + + fs.mkdirSync('public', { recursive: true }); + + fs.writeFileSync( + 'public/leaderboard.json', + JSON.stringify(leaderboard, null, 2) + ); + + console.log(`Generated ${leaderboard.length} contributors`); +}; \ No newline at end of file diff --git a/.github/workflows/leaderboard.yml b/.github/workflows/leaderboard.yml new file mode 100644 index 00000000..e69de29b diff --git a/apps/web/src/pages/LeaderboardPage.tsx b/apps/web/src/pages/LeaderboardPage.tsx index 219d1e6d..e90f1ef2 100644 --- a/apps/web/src/pages/LeaderboardPage.tsx +++ b/apps/web/src/pages/LeaderboardPage.tsx @@ -3,185 +3,183 @@ import { Link } from 'react-router-dom'; import Navbar from '../components/Navbar'; import './LeaderboardPage.css'; -const REPO = 'Dev-Card/DevCard'; -const GITHUB_API = 'https://api.github.com'; - -// Maintainers / accounts excluded from the contributor leaderboard. -const EXCLUDED = new Set( - ['ShantKhatri', 'Harxhit', 'blankirigaya'].map((u) => u.toLowerCase()) -); - type Contributor = { - login: string; - avatarUrl: string; - profileUrl: string; - issues: number; - mergedPrs: number; - openPrs: number; +login: string; +avatarUrl: string; +profileUrl: string; +issues: number; +mergedPrs: number; +openPrs: number; }; -type GithubContributor = { - login: string; - avatar_url: string; - html_url: string; - type: string; -}; +export default function LeaderboardPage() { +const [contributors, setContributors] = useState([]); +const [loading, setLoading] = useState(true); +const [error, setError] = useState(null); + +useEffect(() => { +document.title = 'Contributor Leaderboard | DevCard'; +}, []); + +useEffect(() => { +let cancelled = false; + -type SearchResult = { total_count: number }; +async function load() { + setLoading(true); + setError(null); -async function ghJson(url: string): Promise { - const response = await fetch(url, { - headers: { Accept: 'application/vnd.github+json' }, - }); - if (!response.ok) { - throw new Error(`GitHub request failed: ${response.status}`); + try { + const response = await fetch('/leaderboard.json', { + cache: 'no-store', + }); + + if (!response.ok) { + throw new Error('Failed to load leaderboard'); + } + + const data: Contributor[] = await response.json(); + + if (!cancelled) { + setContributors(data); + } + } catch { + if (!cancelled) { + console.error(error); + setError('Could not load leaderboard.'); + } + } finally { + if (!cancelled) { + setLoading(false); + } } - return response.json() as Promise; } -async function countSearch(query: string): Promise { - const url = `${GITHUB_API}/search/issues?q=${encodeURIComponent(query)}&per_page=1`; - const result = await ghJson(url); - return result.total_count; -} +load(); -async function loadContributorStats(login: string): Promise> { - const base = `repo:${REPO} author:${login}`; - const [issues, mergedPrs, openPrs] = await Promise.all([ - countSearch(`${base} type:issue`), - countSearch(`${base} type:pr is:merged`), - countSearch(`${base} type:pr is:open`), - ]); - return { issues, mergedPrs, openPrs }; -} +return () => { + cancelled = true; +}; -export default function LeaderboardPage() { - const [contributors, setContributors] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - document.title = 'Contributor Leaderboard | DevCard'; - }, []); - - useEffect(() => { - let cancelled = false; - - async function load() { - setLoading(true); - setError(null); - try { - const list = await ghJson( - `${GITHUB_API}/repos/${REPO}/contributors?per_page=100` - ); - - const eligible = list.filter( - (c) => c.type === 'User' && !EXCLUDED.has(c.login.toLowerCase()) - ); - - const enriched = await Promise.all( - eligible.map(async (c) => { - const stats = await loadContributorStats(c.login); - return { - login: c.login, - avatarUrl: c.avatar_url, - profileUrl: c.html_url, - ...stats, - } satisfies Contributor; - }) - ); - - enriched.sort( - (a, b) => - b.mergedPrs - a.mergedPrs || - b.issues - a.issues || - b.openPrs - a.openPrs || - a.login.localeCompare(b.login) - ); - - if (!cancelled) setContributors(enriched); - } catch { - if (!cancelled) setError('Could not load contributors. GitHub may be rate-limiting — try again shortly.'); - } finally { - if (!cancelled) setLoading(false); - } - } - load(); - return () => { - cancelled = true; - }; - }, []); - - return ( - <> -
- -
-
-
🏆 Community
-

- Contributor Leaderboard -

-

- The developers building DevCard — ranked by merged pull requests, issues, and open work. -

-
- - {loading && ( -
Loading contributors…
- )} - - {error && !loading && ( -
{error}
- )} - - {!loading && !error && ( -
    - {contributors.map((c, i) => ( -
  1. - - {i + 1} +}, []); + +return ( +<>
    + +
    +
    +
    🏆 Community
    + +

    + Contributor{' '} + Leaderboard +

    + +

    + The developers building DevCard, ranked by merged + pull requests, issues, and open work. +

    +
    + + {loading && ( +
    + Loading contributors… +
    + )} + + {error && !loading && ( +
    + {error} +
    + )} + + {!loading && !error && ( +
      + {contributors.map((c, i) => ( +
    1. + + {i + 1} + + + + {c.login} + + + @{c.login} + + + +
      + + + {c.mergedPrs} + + + + Merged PRs + + + + + + {c.openPrs} + + + + Open PRs + + + + + + {c.issues} + + + + Issues - - {c.login} - @{c.login} - -
      - - {c.mergedPrs} - Merged PRs - - - {c.openPrs} - Open PRs - - - {c.issues} - Issues - -
      -
    2. - ))} -
    - )} - -
    - - ← Back to Home - -
    -
    - - ); + +
    +
  2. + ))} +
+ )} + +
+ + ← Back to Home + +
+
+); } From cec42a9ca2e8a7a3b4384b69f3c3aef6ea45c1ff Mon Sep 17 00:00:00 2001 From: Harshit Date: Mon, 22 Jun 2026 09:36:09 +0530 Subject: [PATCH 3/4] feat: Leaderboard yaml --- .github/workflows/leaderboard.yml | 48 +++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/.github/workflows/leaderboard.yml b/.github/workflows/leaderboard.yml index e69de29b..f3356f83 100644 --- a/.github/workflows/leaderboard.yml +++ b/.github/workflows/leaderboard.yml @@ -0,0 +1,48 @@ +name: Update Leaderboard + +on: +workflow_dispatch: +schedule: +- cron: '0 * * * *' + +permissions: +contents: write +pull-requests: read +issues: read + +jobs: +leaderboard: +runs-on: ubuntu-latest + +steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Generate leaderboard + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const script = require('./.github/scripts/generateLeaderboard.js'); + await script({ github, context }); + + - name: Commit leaderboard + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git add public/leaderboard.json + + if git diff --staged --quiet; then + echo "No changes to commit" + exit 0 + fi + + git commit -m "chore: update leaderboard" + git push + From 6015c2c582e9266ac89016a316e109875664ff1b Mon Sep 17 00:00:00 2001 From: Harshit Date: Mon, 22 Jun 2026 09:45:16 +0530 Subject: [PATCH 4/4] fix: Updated leaderboard script --- .github/scripts/generateLeaderboard.js | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/scripts/generateLeaderboard.js b/.github/scripts/generateLeaderboard.js index 9cb78e2d..47e4225f 100644 --- a/.github/scripts/generateLeaderboard.js +++ b/.github/scripts/generateLeaderboard.js @@ -36,8 +36,7 @@ module.exports = async ({ github, context }) => { ); for (const pr of mergedPrs) { - if (!pr.merged_at) continue; - if (!pr.user) continue; + if (!pr.merged_at || !pr.user) continue; const login = pr.user.login; @@ -89,8 +88,7 @@ module.exports = async ({ github, context }) => { ); for (const issue of issues) { - if (issue.pull_request) continue; - if (!issue.user) continue; + if (issue.pull_request || !issue.user) continue; const login = issue.user.login; @@ -114,13 +112,19 @@ module.exports = async ({ github, context }) => { ); const fs = require('fs'); + const path = require('path'); + + const outputDir = path.join('apps', 'web', 'public'); + const outputFile = path.join(outputDir, 'leaderboard.json'); - fs.mkdirSync('public', { recursive: true }); + fs.mkdirSync(outputDir, { recursive: true }); fs.writeFileSync( - 'public/leaderboard.json', - JSON.stringify(leaderboard, null, 2) + outputFile, + JSON.stringify(leaderboard, null, 2), + 'utf8' ); console.log(`Generated ${leaderboard.length} contributors`); + console.log(`Leaderboard written to ${outputFile}`); }; \ No newline at end of file