Skip to content

Commit 3096290

Browse files
authored
feat: add country-based impact leaderboard and paginate GitHub API searches (#155)
1 parent 287869c commit 3096290

20 files changed

Lines changed: 1871 additions & 145 deletions

.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ GITHUB_TOKEN=your_github_token_here
33
# If omitted, the app falls back to https://github.com/O2sa/DevImpact
44
NEXT_PUBLIC_GITHUB_REPO_URL=your_github_repo_url_here
55

6-
# Redis caching (optional)
6+
# Redis caching (optional — strongly recommended for leaderboard performance)
77
# Use either redis://localhost:6379 or include password if enabled: redis://:password@localhost:6379
88
REDIS_URL=
99
REDIS_ENABLED=false

app/api/score/route.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { NextResponse } from "next/server";
2+
import { fetchGitHubUserData } from "@/lib/github";
3+
import { calculateUserScore } from "@/lib/score";
4+
5+
export const runtime = "nodejs";
6+
7+
export async function GET(request: Request) {
8+
const { searchParams } = new URL(request.url);
9+
const usernames = searchParams.getAll("username").map((u) => u.trim()).filter(Boolean);
10+
11+
if (usernames.length === 0) {
12+
return NextResponse.json(
13+
{ success: false, error: "Provide at least one username" },
14+
{ status: 400 },
15+
);
16+
}
17+
18+
if (usernames.length > 256) {
19+
return NextResponse.json(
20+
{ success: false, error: "Maximum 256 usernames per request" },
21+
{ status: 400 },
22+
);
23+
}
24+
25+
const scored: Array<{
26+
username: string;
27+
name: string | null;
28+
avatarUrl: string;
29+
repoScore: number;
30+
prScore: number;
31+
contributionScore: number;
32+
finalScore: number;
33+
}> = [];
34+
35+
const errors: string[] = [];
36+
37+
for (const username of usernames) {
38+
try {
39+
const data = await fetchGitHubUserData(username);
40+
const score = calculateUserScore(data, username);
41+
scored.push({
42+
username,
43+
name: data.name,
44+
avatarUrl: data.avatarUrl,
45+
repoScore: Math.round(score.repoScore),
46+
prScore: Math.round(score.prScore),
47+
contributionScore: Math.round(score.contributionScore),
48+
finalScore: Math.round(score.finalScore),
49+
});
50+
} catch {
51+
errors.push(username);
52+
}
53+
}
54+
55+
return NextResponse.json({ success: true, scored, errors });
56+
}

app/globals.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
@import "flag-icons/css/flag-icons.min.css";
2+
13
@tailwind base;
24
@tailwind components;
35
@tailwind utilities;

app/leaderboard/[country]/page.tsx

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
"use client";
2+
3+
import { useEffect, useState, use } from "react";
4+
import Link from "next/link";
5+
import { ArrowLeft, Loader2 } from "lucide-react";
6+
import yaml from "js-yaml";
7+
import { LeaderboardTable } from "@/components/leaderboard-table";
8+
import { AppHeader } from "@/components/app-header";
9+
import { AppFooter } from "@/components/app-footer";
10+
import { Button } from "@/components/ui/button";
11+
import { useTranslation } from "@/components/language-provider";
12+
13+
// ─── Types ─────────────────────────────────────────────────────────────
14+
15+
type CommitterEntry = {
16+
rank: number;
17+
name: string;
18+
login: string;
19+
avatarUrl: string;
20+
contributions: number;
21+
};
22+
23+
type CommitterYaml = {
24+
title?: string;
25+
total_user_count?: number;
26+
users?: CommitterEntry[];
27+
};
28+
29+
type ScoredEntry = {
30+
username: string;
31+
name: string | null;
32+
avatarUrl: string;
33+
repoScore: number;
34+
prScore: number;
35+
contributionScore: number;
36+
finalScore: number;
37+
originalRank: number;
38+
originalContributions: number;
39+
impactRank: number;
40+
};
41+
42+
type Props = {
43+
params: Promise<{ country: string }>;
44+
};
45+
46+
// ─── Fetch helpers ─────────────────────────────────────────────────────
47+
48+
async function fetchCommiters(country: string) {
49+
const url = `https://raw.githubusercontent.com/ashkulz/committers.top/gh-pages/_data/locations/${country}.yml`;
50+
const res = await fetch(url, {
51+
headers: { "User-Agent": "DevImpact-Bot" },
52+
});
53+
if (!res.ok) throw new Error(`Failed to fetch ${country}`);
54+
const text = await res.text();
55+
const data = yaml.load(text) as CommitterYaml;
56+
if (!data?.users) throw new Error("Invalid data");
57+
return {
58+
title: data.title || country,
59+
totalFromSource: data.total_user_count ?? data.users.length,
60+
users: data.users,
61+
};
62+
}
63+
64+
async function fetchScores(logins: string[]) {
65+
const params = logins
66+
.map((u) => `username=${encodeURIComponent(u)}`)
67+
.join("&");
68+
const res = await fetch(`/api/score?${params}`);
69+
if (!res.ok) throw new Error("Failed to score users");
70+
const json = await res.json();
71+
if (!json.success) throw new Error(json.error || "Scoring failed");
72+
return { scored: json.scored, errors: json.errors };
73+
}
74+
75+
// ─── Component ─────────────────────────────────────────────────────────
76+
77+
export default function CountryLeaderboardPage({ params }: Props) {
78+
const { country } = use(params);
79+
const { t } = useTranslation();
80+
81+
const [title, setTitle] = useState("");
82+
const [totalFromSource, setTotalFromSource] = useState(0);
83+
const [scored, setScored] = useState<ScoredEntry[]>([]);
84+
const [errors, setErrors] = useState<string[]>([]);
85+
const [loading, setLoading] = useState(true);
86+
const [failed, setFailed] = useState<string | null>(null);
87+
88+
useEffect(() => {
89+
let cancelled = false;
90+
91+
(async () => {
92+
try {
93+
const data = await fetchCommiters(country);
94+
if (cancelled) return;
95+
setTitle(data.title);
96+
setTotalFromSource(data.totalFromSource);
97+
98+
const logins = data.users.map((u) => u.login);
99+
const { scored: apiScored, errors: apiErrors } =
100+
await fetchScores(logins);
101+
if (cancelled) return;
102+
103+
// Build a rank lookup from the original committers data
104+
const rankMap = new Map(
105+
data.users.map((u) => [u.login, { rank: u.rank, contributions: u.contributions }])
106+
);
107+
108+
const results: ScoredEntry[] = apiScored.map((s: Record<string, unknown>) => {
109+
const original = rankMap.get(s.username as string) ?? { rank: 0, contributions: 0 };
110+
return {
111+
username: s.username as string,
112+
name: s.name as string | null,
113+
avatarUrl: s.avatarUrl as string,
114+
repoScore: s.repoScore as number,
115+
prScore: s.prScore as number,
116+
contributionScore: s.contributionScore as number,
117+
finalScore: s.finalScore as number,
118+
originalRank: original.rank,
119+
originalContributions: original.contributions,
120+
impactRank: 0,
121+
};
122+
});
123+
124+
results.sort((a, b) => b.finalScore - a.finalScore);
125+
results.forEach((u, idx) => (u.impactRank = idx + 1));
126+
127+
setScored(results);
128+
setErrors(apiErrors);
129+
setLoading(false);
130+
} catch (err) {
131+
if (!cancelled)
132+
setFailed(
133+
err instanceof Error ? err.message : "Failed to load leaderboard"
134+
);
135+
}
136+
})();
137+
138+
return () => {
139+
cancelled = true;
140+
};
141+
}, [country]);
142+
143+
if (failed) {
144+
return (
145+
<main className="flex min-h-screen flex-col">
146+
<AppHeader />
147+
<div className="w-full flex-1 max-w-6xl mx-auto px-4 py-10">
148+
<div className="mx-auto flex max-w-2xl flex-col items-center justify-center gap-5 rounded-3xl border border-destructive/25 bg-gradient-to-b from-destructive/10 via-destructive/5 to-background px-6 py-12 text-center shadow-sm">
149+
<p className="text-lg font-semibold tracking-tight text-foreground">
150+
{t("leaderboard.error.title")}
151+
</p>
152+
<p className="max-w-xl text-sm leading-7 text-muted-foreground">
153+
{failed}
154+
</p>
155+
<Link href="/leaderboard">
156+
<Button variant="ghost">{t("leaderboard.back")}</Button>
157+
</Link>
158+
</div>
159+
</div>
160+
<AppFooter />
161+
</main>
162+
);
163+
}
164+
165+
return (
166+
<main className="flex min-h-screen flex-col">
167+
<AppHeader />
168+
<div className="w-full flex-1 max-w-6xl mx-auto px-4 py-10 space-y-6">
169+
{/* Back navigation */}
170+
<div className="flex items-center gap-3">
171+
<Link href="/leaderboard">
172+
<Button variant="ghost" size="sm">
173+
<ArrowLeft className="mr-1 h-4 w-4 rtl:-scale-x-100" />
174+
{t("leaderboard.back")}
175+
</Button>
176+
</Link>
177+
</div>
178+
179+
{/* Leaderboard table */}
180+
{scored.length > 0 ? (
181+
<div className="animate-fadeIn">
182+
<LeaderboardTable
183+
users={scored}
184+
failedUsers={errors}
185+
title={title}
186+
totalFromSource={totalFromSource}
187+
usersProcessed={scored.length}
188+
/>
189+
</div>
190+
) : loading ? (
191+
<div className="flex items-center justify-center gap-2 py-20">
192+
<Loader2 className="h-5 w-5 animate-spin text-primary" />
193+
<span className="text-sm text-muted-foreground">
194+
{t("leaderboard.loading")}
195+
</span>
196+
</div>
197+
) : (
198+
<div className="flex flex-col items-center gap-4 py-20 text-center">
199+
<p className="text-lg font-medium text-muted-foreground">
200+
{t("leaderboard.noDevelopersFor", { title })}
201+
</p>
202+
<Link href="/leaderboard">
203+
<Button variant="secondary">{t("leaderboard.back")}</Button>
204+
</Link>
205+
</div>
206+
)}
207+
208+
{/* Scoring progress indicator */}
209+
{loading && scored.length > 0 && (
210+
<div className="flex items-center justify-center gap-2 py-4">
211+
<Loader2 className="h-5 w-5 animate-spin text-primary" />
212+
<span className="text-sm text-muted-foreground">
213+
{t("leaderboard.loading")}
214+
</span>
215+
</div>
216+
)}
217+
</div>
218+
<AppFooter />
219+
</main>
220+
);
221+
}

0 commit comments

Comments
 (0)