Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
GITHUB_TOKEN=your_github_token_here

# Redis caching (optional)
# Redis caching (optional — strongly recommended for leaderboard performance)
# Use either redis://localhost:6379 or include password if enabled: redis://:password@localhost:6379
REDIS_URL=
REDIS_ENABLED=false
Expand Down
56 changes: 56 additions & 0 deletions app/api/score/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import { fetchGitHubUserData } from "@/lib/github";
import { calculateUserScore } from "@/lib/score";

export const runtime = "nodejs";

export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const usernames = searchParams.getAll("username").map((u) => u.trim()).filter(Boolean);

if (usernames.length === 0) {
return NextResponse.json(
{ success: false, error: "Provide at least one username" },
{ status: 400 },
);
}

if (usernames.length > 256) {
return NextResponse.json(
{ success: false, error: "Maximum 256 usernames per request" },
{ status: 400 },
);
}

const scored: Array<{
username: string;
name: string | null;
avatarUrl: string;
repoScore: number;
prScore: number;
contributionScore: number;
finalScore: number;
}> = [];

const errors: string[] = [];

for (const username of usernames) {
try {
const data = await fetchGitHubUserData(username);
const score = calculateUserScore(data, username);
scored.push({
username,
name: data.name,
avatarUrl: data.avatarUrl,
repoScore: Math.round(score.repoScore),
prScore: Math.round(score.prScore),
contributionScore: Math.round(score.contributionScore),
finalScore: Math.round(score.finalScore),
});
} catch {
errors.push(username);
}
}

return NextResponse.json({ success: true, scored, errors });
}
2 changes: 2 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@import "flag-icons/css/flag-icons.min.css";

@tailwind base;
@tailwind components;
@tailwind utilities;
Expand Down
221 changes: 221 additions & 0 deletions app/leaderboard/[country]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
"use client";

import { useEffect, useState, use } from "react";
import Link from "next/link";
import { ArrowLeft, Loader2 } from "lucide-react";
import yaml from "js-yaml";
import { LeaderboardTable } from "@/components/leaderboard-table";
import { AppHeader } from "@/components/app-header";
import { AppFooter } from "@/components/app-footer";
import { Button } from "@/components/ui/button";
import { useTranslation } from "@/components/language-provider";

// ─── Types ─────────────────────────────────────────────────────────────

type CommitterEntry = {
rank: number;
name: string;
login: string;
avatarUrl: string;
contributions: number;
};

type CommitterYaml = {
title?: string;
total_user_count?: number;
users?: CommitterEntry[];
};

type ScoredEntry = {
username: string;
name: string | null;
avatarUrl: string;
repoScore: number;
prScore: number;
contributionScore: number;
finalScore: number;
originalRank: number;
originalContributions: number;
impactRank: number;
};

type Props = {
params: Promise<{ country: string }>;
};

// ─── Fetch helpers ─────────────────────────────────────────────────────

async function fetchCommiters(country: string) {
const url = `https://raw.githubusercontent.com/ashkulz/committers.top/gh-pages/_data/locations/${country}.yml`;
const res = await fetch(url, {
headers: { "User-Agent": "DevImpact-Bot" },
});
if (!res.ok) throw new Error(`Failed to fetch ${country}`);
const text = await res.text();
const data = yaml.load(text) as CommitterYaml;
if (!data?.users) throw new Error("Invalid data");
return {
title: data.title || country,
totalFromSource: data.total_user_count ?? data.users.length,
users: data.users,
};
}

async function fetchScores(logins: string[]) {
const params = logins
.map((u) => `username=${encodeURIComponent(u)}`)
.join("&");
const res = await fetch(`/api/score?${params}`);
if (!res.ok) throw new Error("Failed to score users");
const json = await res.json();
if (!json.success) throw new Error(json.error || "Scoring failed");
return { scored: json.scored, errors: json.errors };
}

// ─── Component ─────────────────────────────────────────────────────────

export default function CountryLeaderboardPage({ params }: Props) {
const { country } = use(params);
const { t } = useTranslation();

const [title, setTitle] = useState("");
const [totalFromSource, setTotalFromSource] = useState(0);
const [scored, setScored] = useState<ScoredEntry[]>([]);
const [errors, setErrors] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState<string | null>(null);

useEffect(() => {
let cancelled = false;

(async () => {
try {
const data = await fetchCommiters(country);
if (cancelled) return;
setTitle(data.title);
setTotalFromSource(data.totalFromSource);

const logins = data.users.map((u) => u.login);
const { scored: apiScored, errors: apiErrors } =
await fetchScores(logins);
if (cancelled) return;

// Build a rank lookup from the original committers data
const rankMap = new Map(
data.users.map((u) => [u.login, { rank: u.rank, contributions: u.contributions }])
);

const results: ScoredEntry[] = apiScored.map((s: Record<string, unknown>) => {
const original = rankMap.get(s.username as string) ?? { rank: 0, contributions: 0 };
return {
username: s.username as string,
name: s.name as string | null,
avatarUrl: s.avatarUrl as string,
repoScore: s.repoScore as number,
prScore: s.prScore as number,
contributionScore: s.contributionScore as number,
finalScore: s.finalScore as number,
originalRank: original.rank,
originalContributions: original.contributions,
impactRank: 0,
};
});

results.sort((a, b) => b.finalScore - a.finalScore);
results.forEach((u, idx) => (u.impactRank = idx + 1));

setScored(results);
setErrors(apiErrors);
setLoading(false);
} catch (err) {
if (!cancelled)
setFailed(
err instanceof Error ? err.message : "Failed to load leaderboard"
);
}
})();

return () => {
cancelled = true;
};
}, [country]);

if (failed) {
return (
<main className="flex min-h-screen flex-col">
<AppHeader />
<div className="w-full flex-1 max-w-6xl mx-auto px-4 py-10">
<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">
<p className="text-lg font-semibold tracking-tight text-foreground">
{t("leaderboard.error.title")}
</p>
<p className="max-w-xl text-sm leading-7 text-muted-foreground">
{failed}
</p>
<Link href="/leaderboard">
<Button variant="ghost">{t("leaderboard.back")}</Button>
</Link>
</div>
</div>
<AppFooter />
</main>
);
}

return (
<main className="flex min-h-screen flex-col">
<AppHeader />
<div className="w-full flex-1 max-w-6xl mx-auto px-4 py-10 space-y-6">
{/* Back navigation */}
<div className="flex items-center gap-3">
<Link href="/leaderboard">
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-1 h-4 w-4 rtl:-scale-x-100" />
{t("leaderboard.back")}
</Button>
</Link>
</div>

{/* Leaderboard table */}
{scored.length > 0 ? (
<div className="animate-fadeIn">
<LeaderboardTable
users={scored}
failedUsers={errors}
title={title}
totalFromSource={totalFromSource}
usersProcessed={scored.length}
/>
</div>
) : loading ? (
<div className="flex items-center justify-center gap-2 py-20">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">
{t("leaderboard.loading")}
</span>
</div>
) : (
<div className="flex flex-col items-center gap-4 py-20 text-center">
<p className="text-lg font-medium text-muted-foreground">
{t("leaderboard.noDevelopersFor", { title })}
</p>
<Link href="/leaderboard">
<Button variant="secondary">{t("leaderboard.back")}</Button>
</Link>
</div>
)}

{/* Scoring progress indicator */}
{loading && scored.length > 0 && (
<div className="flex items-center justify-center gap-2 py-4">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">
{t("leaderboard.loading")}
</span>
</div>
)}
</div>
<AppFooter />
</main>
);
}
Loading
Loading