diff --git a/.github/scripts/generateLeaderboard.js b/.github/scripts/generateLeaderboard.js
new file mode 100644
index 00000000..47e4225f
--- /dev/null
+++ b/.github/scripts/generateLeaderboard.js
@@ -0,0 +1,130 @@
+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 || !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 || !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');
+ const path = require('path');
+
+ const outputDir = path.join('apps', 'web', 'public');
+ const outputFile = path.join(outputDir, 'leaderboard.json');
+
+ fs.mkdirSync(outputDir, { recursive: true });
+
+ fs.writeFileSync(
+ 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
diff --git a/.github/workflows/leaderboard.yml b/.github/workflows/leaderboard.yml
new file mode 100644
index 00000000..f3356f83
--- /dev/null
+++ 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
+
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..e90f1ef2
--- /dev/null
+++ b/apps/web/src/pages/LeaderboardPage.tsx
@@ -0,0 +1,185 @@
+import { useEffect, useState } from 'react';
+import { Link } from 'react-router-dom';
+import Navbar from '../components/Navbar';
+import './LeaderboardPage.css';
+
+type Contributor = {
+login: string;
+avatarUrl: string;
+profileUrl: string;
+issues: number;
+mergedPrs: number;
+openPrs: number;
+};
+
+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 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);
+ }
+ }
+}
+
+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) => (
+ -
+
+ {i + 1}
+
+
+
+
+
+
+ @{c.login}
+
+
+
+
+
+
+ {c.mergedPrs}
+
+
+
+ Merged PRs
+
+
+
+
+
+ {c.openPrs}
+
+
+
+ Open PRs
+
+
+
+
+
+ {c.issues}
+
+
+
+ Issues
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ ← Back to Home
+
+
+
+>);
+}