diff --git a/.env.example b/.env.example index 35cc70f..6a82d8f 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/api/score/route.ts b/app/api/score/route.ts new file mode 100644 index 0000000..e1fd639 --- /dev/null +++ b/app/api/score/route.ts @@ -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 }); +} diff --git a/app/globals.css b/app/globals.css index b503fd8..65ff396 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,3 +1,5 @@ +@import "flag-icons/css/flag-icons.min.css"; + @tailwind base; @tailwind components; @tailwind utilities; diff --git a/app/leaderboard/[country]/page.tsx b/app/leaderboard/[country]/page.tsx new file mode 100644 index 0000000..09c8067 --- /dev/null +++ b/app/leaderboard/[country]/page.tsx @@ -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([]); + const [errors, setErrors] = useState([]); + const [loading, setLoading] = useState(true); + const [failed, setFailed] = useState(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) => { + 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 ( +
+ +
+
+

+ {t("leaderboard.error.title")} +

+

+ {failed} +

+ + + +
+
+ +
+ ); + } + + return ( +
+ +
+ {/* Back navigation */} +
+ + + +
+ + {/* Leaderboard table */} + {scored.length > 0 ? ( +
+ +
+ ) : loading ? ( +
+ + + {t("leaderboard.loading")} + +
+ ) : ( +
+

+ {t("leaderboard.noDevelopersFor", { title })} +

+ + + +
+ )} + + {/* Scoring progress indicator */} + {loading && scored.length > 0 && ( +
+ + + {t("leaderboard.loading")} + +
+ )} +
+ +
+ ); +} diff --git a/app/leaderboard/country-grid-client.tsx b/app/leaderboard/country-grid-client.tsx new file mode 100644 index 0000000..9087274 --- /dev/null +++ b/app/leaderboard/country-grid-client.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { Globe, Search, Users } from "lucide-react"; +import Link from "next/link"; +import Image from "next/image"; +import { Input } from "@/components/ui/input"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { useTranslation } from "@/components/language-provider"; +import { getCountryCode } from "@/lib/country-flags"; +import type { CountryInfo } from "@/types/leaderboard"; +import type { Route } from "next"; + +type Props = { + countries: CountryInfo[]; +}; + +export function CountryGridClient({ countries }: Props) { + const { t } = useTranslation(); + const [search, setSearch] = useState(""); + + const filtered = useMemo(() => { + if (!search.trim()) return countries; + const q = search.trim().toLowerCase(); + return countries.filter( + (c) => + c.slug.toLowerCase().includes(q) || c.title.toLowerCase().includes(q), + ); + }, [countries, search]); + + return ( + + +

+ {t("leaderboard.header.eyebrow")} +

+ {t("leaderboard.header.title")} + + {t("leaderboard.header.description")} + +
+ + {countries.length > 10 && ( +
+ + setSearch(e.target.value)} + /> +
+ )} + + {filtered.length === 0 && ( +
+ +

+ {search + ? t("leaderboard.noCountriesSearch") + : t("leaderboard.empty.title")} +

+
+ )} + + {filtered.length > 0 && ( +
+ {filtered.map((c) => { + const code = getCountryCode(c.slug); + return ( + + + {code ? ( + + ) : ( + + )} + {c.title} + + + + {t("leaderboard.viewCountry")} + + + ); + })} +
+ )} +
+
+ ); +} diff --git a/app/leaderboard/page.tsx b/app/leaderboard/page.tsx new file mode 100644 index 0000000..abd6e23 --- /dev/null +++ b/app/leaderboard/page.tsx @@ -0,0 +1,69 @@ +import type { Metadata } from "next"; +import { AppHeader } from "@/components/app-header"; +import { AppFooter } from "@/components/app-footer"; +import { JsonLd } from "@/components/seo/json-ld"; +import { toAbsoluteUrl } from "@/lib/seo"; +import countriesData from "@/data/countries.json"; +import { CountryGridClient } from "./country-grid-client"; +import type { CountryInfo } from "@/types/leaderboard"; + +// ─── Metadata ────────────────────────────────────────────────────────── + +export const metadata: Metadata = { + title: "Leaderboard — Country Impact Rankings", + description: + "Browse committers.top countries and view developers ranked by DevImpact's transparent repository, PR, and community contribution scoring.", + alternates: { + canonical: "/leaderboard", + }, + openGraph: { + title: "Country Leaderboards by Impact | DevImpact", + description: + "Browse committers.top country leaderboards reordered by real open-source impact: repository quality, merged PRs, and community contributions.", + url: "/leaderboard", + images: [ + { + url: toAbsoluteUrl("/og-image.svg"), + width: 1200, + height: 630, + alt: "DevImpact Country Leaderboard", + }, + ], + }, + twitter: { + card: "summary_large_image", + title: "Country Leaderboards by Impact | DevImpact", + description: + "Browse committers.top country leaderboards reordered by real open-source impact.", + images: [toAbsoluteUrl("/og-image.svg")], + }, +}; + +const webPageSchema = { + "@context": "https://schema.org", + "@type": "WebPage", + name: "Country Leaderboards by Impact", + description: + "Browse committers.top country leaderboards reordered by DevImpact impact scoring.", + url: toAbsoluteUrl("/leaderboard"), +}; + +// ─── Static country list (sourced from committers.top YAML, converted to JSON) ─ +const countries: CountryInfo[] = countriesData as CountryInfo[]; + +// ─── Page ────────────────────────────────────────────────────────────── + +export default function LeaderboardPage() { + return ( + <> + +
+ +
+ +
+ +
+ + ); +} diff --git a/app/sitemap.ts b/app/sitemap.ts index e758893..8fad05f 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,17 +1,23 @@ import type { MetadataRoute } from "next"; import { getSiteUrl } from "@/lib/seo"; -export default function sitemap(): MetadataRoute.Sitemap { +export default async function sitemap(): Promise { const baseUrl = getSiteUrl(); const now = new Date(); - return [ + const entries: MetadataRoute.Sitemap = [ { url: `${baseUrl}/`, lastModified: now, changeFrequency: "weekly", priority: 1, }, + { + url: `${baseUrl}/leaderboard`, + lastModified: now, + changeFrequency: "weekly", + priority: 0.8, + }, { url: `${baseUrl}/scoring-methodology`, lastModified: now, @@ -19,4 +25,6 @@ export default function sitemap(): MetadataRoute.Sitemap { priority: 0.7, }, ]; + + return entries; } diff --git a/components/app-header.tsx b/components/app-header.tsx index 90fd01f..efad37b 100644 --- a/components/app-header.tsx +++ b/components/app-header.tsx @@ -11,10 +11,16 @@ export function AppHeader() { -
+
+ ); diff --git a/components/leaderboard-table.tsx b/components/leaderboard-table.tsx new file mode 100644 index 0000000..aa43525 --- /dev/null +++ b/components/leaderboard-table.tsx @@ -0,0 +1,304 @@ +"use client"; + +import { useState, useMemo } from "react"; +import Image from "next/image"; +import { + TrendingUp, + TrendingDown, + Minus, + ChevronLeft, + ChevronRight, + Search, + AlertTriangle, +} from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "./ui/tooltip"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "./ui/card"; +import { Input } from "./ui/input"; +import { Button } from "./ui/button"; +import { useTranslation } from "./language-provider"; +import { cn } from "@/lib/utils"; + +type LeaderboardEntry = { + username: string; + name: string | null; + avatarUrl: string; + repoScore: number; + prScore: number; + contributionScore: number; + finalScore: number; + originalRank: number; + originalContributions: number; + impactRank: number; +}; + +type Props = { + users: LeaderboardEntry[]; + failedUsers: string[]; + title: string; + totalFromSource: number; + usersProcessed: number; +}; + +const PAGE_SIZE = 25; + +function RankChangeIndicator({ + impactRank, + originalRank, +}: { + impactRank: number; + originalRank: number; +}) { + const change = originalRank - impactRank; + if (change > 0) { + return ( + + +{change} + + ); + } + if (change < 0) { + return ( + + + {change} + + ); + } + return ( + + + + ); +} + +function getGithubProfileUrl(username: string): string { + return `https://github.com/${username}`; +} + +export function LeaderboardTable({ + users, + failedUsers, + title, + totalFromSource, + usersProcessed, +}: Props) { + const { t } = useTranslation(); + const [page, setPage] = useState(0); + const [search, setSearch] = useState(""); + + const filtered = useMemo(() => { + if (!search.trim()) return users; + const q = search.trim().toLowerCase(); + return users.filter( + (u) => + u.username.toLowerCase().includes(q) || + (u.name && u.name.toLowerCase().includes(q)), + ); + }, [users, search]); + + const totalPages = Math.ceil(filtered.length / PAGE_SIZE); + const paged = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); + + if (users.length === 0) return null; + + return ( +
+ + + + {t("leaderboard.title")} — {title} + +
+ + {t("leaderboard.description", { + processed: usersProcessed, + total: totalFromSource, + })} + + {failedUsers.length > 0 && ( + + + + + {t("leaderboard.partialErrors", { count: failedUsers.length })} + + + +

{failedUsers.join(", ")}

+
+
+ )} +
+ {filtered.length > PAGE_SIZE && ( +
+ + { + setSearch(e.target.value); + setPage(0); + }} + /> +
+ )} +
+ +
+ + + + + + + + + + + + + + + {paged.map((user) => ( + + + + + + + + + + + ))} + +
+ {t("leaderboard.impactRank")} + + {t("leaderboard.developer")} + + {t("comparsion.final.score")} + + {t("comparsion.repo.score")} + + {t("comparsion.pr.score")} + + {t("comparsion.contribution.score")} + + {t("leaderboard.committersRank")} + + {t("leaderboard.change")} +
+ + {user.impactRank} + + +
+ {user.name +
+ + {user.name || user.username} + +

+ {user.username} +

+
+
+
+ + {user.finalScore} + + + {user.repoScore} + + {user.prScore} + + {user.contributionScore} + + #{user.originalRank} + + +
+
+ + {totalPages > 1 && ( +
+

+ {t("leaderboard.pagination", { + from: page * PAGE_SIZE + 1, + to: Math.min((page + 1) * PAGE_SIZE, filtered.length), + total: filtered.length, + })} +

+
+ + + {page + 1} / {totalPages} + + +
+
+ )} +
+
+
+ ); +} diff --git a/components/scoring-methodology-page-client.tsx b/components/scoring-methodology-page-client.tsx index 76f9ed1..16565f3 100644 --- a/components/scoring-methodology-page-client.tsx +++ b/components/scoring-methodology-page-client.tsx @@ -9,11 +9,9 @@ import { ScoringMethodologyFlow } from "@/components/scoring/scoring-methodology import { ScoringMethodologySection } from "@/components/scoring/scoring-methodology-section"; export function ScoringMethodologyPageClient() { - const { t, dir } = useTranslation(); + const { t } = useTranslation(); const router = useRouter(); const searchParams = useSearchParams(); - const backIconClass = dir === "rtl" ? "rotate-180" : ""; - const handleBack = () => { const query = searchParams.toString(); if (query) { @@ -39,7 +37,7 @@ export function ScoringMethodologyPageClient() { className="inline-flex items-center gap-2 rounded-lg border border-border bg-background px-3 py-2 text-sm font-semibold text-foreground transition-colors hover:bg-muted" aria-label={t("methodology.back")} > - + {t("methodology.back")} diff --git a/data/countries.json b/data/countries.json new file mode 100644 index 0000000..403d9f9 --- /dev/null +++ b/data/countries.json @@ -0,0 +1,606 @@ +[ + { + "slug": "afghanistan", + "title": "Afghanistan" + }, + { + "slug": "albania", + "title": "Albania" + }, + { + "slug": "algeria", + "title": "Algeria" + }, + { + "slug": "angola", + "title": "Angola" + }, + { + "slug": "argentina", + "title": "Argentina" + }, + { + "slug": "armenia", + "title": "Armenia" + }, + { + "slug": "australia", + "title": "Australia" + }, + { + "slug": "austria", + "title": "Austria" + }, + { + "slug": "azerbaijan", + "title": "Azerbaijan" + }, + { + "slug": "bahrain", + "title": "Bahrain" + }, + { + "slug": "bangladesh", + "title": "Bangladesh" + }, + { + "slug": "belarus", + "title": "Belarus" + }, + { + "slug": "belgium", + "title": "Belgium" + }, + { + "slug": "benin", + "title": "Benin" + }, + { + "slug": "bolivia", + "title": "Bolivia" + }, + { + "slug": "bosnia_and_herzegovina", + "title": "Bosnia and Herzegovina" + }, + { + "slug": "botswana", + "title": "Botswana" + }, + { + "slug": "brazil", + "title": "Brazil" + }, + { + "slug": "bulgaria", + "title": "Bulgaria" + }, + { + "slug": "burkina_faso", + "title": "Burkina Faso" + }, + { + "slug": "burundi", + "title": "Burundi" + }, + { + "slug": "cambodia", + "title": "Cambodia" + }, + { + "slug": "cameroon", + "title": "Cameroon" + }, + { + "slug": "canada", + "title": "Canada" + }, + { + "slug": "chad", + "title": "Chad" + }, + { + "slug": "chile", + "title": "Chile" + }, + { + "slug": "china", + "title": "China" + }, + { + "slug": "colombia", + "title": "Colombia" + }, + { + "slug": "costa_rica", + "title": "Costa Rica" + }, + { + "slug": "croatia", + "title": "Croatia" + }, + { + "slug": "cuba", + "title": "Cuba" + }, + { + "slug": "cyprus", + "title": "Cyprus" + }, + { + "slug": "czech_republic", + "title": "Czech Republic" + }, + { + "slug": "congo_kinshasa", + "title": "Democratic Republic of the Congo" + }, + { + "slug": "denmark", + "title": "Denmark" + }, + { + "slug": "dominican_republic", + "title": "Dominican Republic" + }, + { + "slug": "ecuador", + "title": "Ecuador" + }, + { + "slug": "egypt", + "title": "Egypt" + }, + { + "slug": "el_salvador", + "title": "El Salvador" + }, + { + "slug": "estonia", + "title": "Estonia" + }, + { + "slug": "ethiopia", + "title": "Ethiopia" + }, + { + "slug": "finland", + "title": "Finland" + }, + { + "slug": "france", + "title": "France" + }, + { + "slug": "gabon", + "title": "Gabon" + }, + { + "slug": "georgia", + "title": "Georgia" + }, + { + "slug": "germany", + "title": "Germany" + }, + { + "slug": "ghana", + "title": "Ghana" + }, + { + "slug": "greece", + "title": "Greece" + }, + { + "slug": "guatemala", + "title": "Guatemala" + }, + { + "slug": "guinea", + "title": "Guinea" + }, + { + "slug": "haiti", + "title": "Haiti" + }, + { + "slug": "honduras", + "title": "Honduras" + }, + { + "slug": "hong_kong", + "title": "Hong Kong" + }, + { + "slug": "hungary", + "title": "Hungary" + }, + { + "slug": "india", + "title": "India" + }, + { + "slug": "indonesia", + "title": "Indonesia" + }, + { + "slug": "iran", + "title": "Iran" + }, + { + "slug": "iraq", + "title": "Iraq" + }, + { + "slug": "ireland", + "title": "Ireland" + }, + { + "slug": "israel", + "title": "Israel" + }, + { + "slug": "italy", + "title": "Italy" + }, + { + "slug": "ivory_coast", + "title": "Ivory Coast" + }, + { + "slug": "japan", + "title": "Japan" + }, + { + "slug": "jordan", + "title": "Jordan" + }, + { + "slug": "kazakhstan", + "title": "Kazakhstan" + }, + { + "slug": "kenya", + "title": "Kenya" + }, + { + "slug": "kosovo", + "title": "Kosovo" + }, + { + "slug": "kurdistan", + "title": "Kurdistan" + }, + { + "slug": "kyrgyzstan", + "title": "Kyrgyzstan" + }, + { + "slug": "laos", + "title": "Laos" + }, + { + "slug": "latvia", + "title": "Latvia" + }, + { + "slug": "lebanon", + "title": "Lebanon" + }, + { + "slug": "libya", + "title": "Libya" + }, + { + "slug": "lithuania", + "title": "Lithuania" + }, + { + "slug": "luxembourg", + "title": "Luxembourg" + }, + { + "slug": "macau", + "title": "Macau" + }, + { + "slug": "madagascar", + "title": "Madagascar" + }, + { + "slug": "malawi", + "title": "Malawi" + }, + { + "slug": "malaysia", + "title": "Malaysia" + }, + { + "slug": "mali", + "title": "Mali" + }, + { + "slug": "malta", + "title": "Malta" + }, + { + "slug": "mauritania", + "title": "Mauritania" + }, + { + "slug": "mauritius", + "title": "Mauritius" + }, + { + "slug": "mexico", + "title": "Mexico" + }, + { + "slug": "moldova", + "title": "Moldova" + }, + { + "slug": "morocco", + "title": "Morocco" + }, + { + "slug": "mozambique", + "title": "Mozambique" + }, + { + "slug": "myanmar", + "title": "Myanmar" + }, + { + "slug": "nepal", + "title": "Nepal" + }, + { + "slug": "netherlands", + "title": "Netherlands" + }, + { + "slug": "new_zealand", + "title": "New Zealand" + }, + { + "slug": "nicaragua", + "title": "Nicaragua" + }, + { + "slug": "niger", + "title": "Niger" + }, + { + "slug": "nigeria", + "title": "Nigeria" + }, + { + "slug": "macedonia", + "title": "North Macedonia" + }, + { + "slug": "norway", + "title": "Norway" + }, + { + "slug": "oman", + "title": "Oman" + }, + { + "slug": "pakistan", + "title": "Pakistan" + }, + { + "slug": "palestine", + "title": "Palestine" + }, + { + "slug": "panama", + "title": "Panama" + }, + { + "slug": "papua_new_guinea", + "title": "Papua New Guinea" + }, + { + "slug": "paraguay", + "title": "Paraguay" + }, + { + "slug": "peru", + "title": "Peru" + }, + { + "slug": "philippines", + "title": "Philippines" + }, + { + "slug": "poland", + "title": "Poland" + }, + { + "slug": "portugal", + "title": "Portugal" + }, + { + "slug": "qatar", + "title": "Qatar" + }, + { + "slug": "south_korea", + "title": "Republic of Korea" + }, + { + "slug": "congo_brazzaville", + "title": "Republic of the Congo" + }, + { + "slug": "romania", + "title": "Romania" + }, + { + "slug": "russia", + "title": "Russia" + }, + { + "slug": "rwanda", + "title": "Rwanda" + }, + { + "slug": "saudi_arabia", + "title": "Saudi Arabia" + }, + { + "slug": "senegal", + "title": "Senegal" + }, + { + "slug": "serbia", + "title": "Serbia" + }, + { + "slug": "sierra_leone", + "title": "Sierra Leone" + }, + { + "slug": "singapore", + "title": "Singapore" + }, + { + "slug": "slovakia", + "title": "Slovakia" + }, + { + "slug": "slovenia", + "title": "Slovenia" + }, + { + "slug": "somalia", + "title": "Somalia" + }, + { + "slug": "south_africa", + "title": "South Africa" + }, + { + "slug": "south_sudan", + "title": "South Sudan" + }, + { + "slug": "spain", + "title": "Spain" + }, + { + "slug": "sri_lanka", + "title": "Sri Lanka" + }, + { + "slug": "sudan", + "title": "Sudan" + }, + { + "slug": "suriname", + "title": "Suriname" + }, + { + "slug": "sweden", + "title": "Sweden" + }, + { + "slug": "switzerland", + "title": "Switzerland" + }, + { + "slug": "syria", + "title": "Syria" + }, + { + "slug": "taiwan", + "title": "Taiwan" + }, + { + "slug": "tajikistan", + "title": "Tajikistan" + }, + { + "slug": "tanzania", + "title": "Tanzania" + }, + { + "slug": "thailand", + "title": "Thailand" + }, + { + "slug": "the_bahamas", + "title": "The Bahamas" + }, + { + "slug": "togo", + "title": "Togo" + }, + { + "slug": "tunisia", + "title": "Tunisia" + }, + { + "slug": "turkey", + "title": "Turkey" + }, + { + "slug": "turkmenistan", + "title": "Turkmenistan" + }, + { + "slug": "uganda", + "title": "Uganda" + }, + { + "slug": "ukraine", + "title": "Ukraine" + }, + { + "slug": "uae", + "title": "United Arab Emirates" + }, + { + "slug": "uk", + "title": "United Kingdom" + }, + { + "slug": "united_states", + "title": "United States" + }, + { + "slug": "uruguay", + "title": "Uruguay" + }, + { + "slug": "uzbekistan", + "title": "Uzbekistan" + }, + { + "slug": "venezuela", + "title": "Venezuela" + }, + { + "slug": "vietnam", + "title": "Vietnam" + }, + { + "slug": "worldwide", + "title": "Worldwide" + }, + { + "slug": "yemen", + "title": "Yemen" + }, + { + "slug": "zambia", + "title": "Zambia" + }, + { + "slug": "zimbabwe", + "title": "Zimbabwe" + } +] diff --git a/lib/country-flags.ts b/lib/country-flags.ts new file mode 100644 index 0000000..7228197 --- /dev/null +++ b/lib/country-flags.ts @@ -0,0 +1,162 @@ +// ─── Slug → ISO 3166-1 alpha-2 mapping ───────────────────────────────── +// Maps committers.top location slugs to ISO country codes for flag icons. +// Keep in sync with data/countries.json. + +const SLUG_TO_ISO: Record = { + afghanistan: "af", + albania: "al", + algeria: "dz", + angola: "ao", + argentina: "ar", + armenia: "am", + australia: "au", + austria: "at", + azerbaijan: "az", + bahrain: "bh", + bangladesh: "bd", + belarus: "by", + belgium: "be", + benin: "bj", + bolivia: "bo", + bosnia_and_herzegovina: "ba", + botswana: "bw", + brazil: "br", + bulgaria: "bg", + burkina_faso: "bf", + burundi: "bi", + cambodia: "kh", + cameroon: "cm", + canada: "ca", + chad: "td", + chile: "cl", + china: "cn", + colombia: "co", + congo_brazzaville: "cg", + congo_kinshasa: "cd", + costa_rica: "cr", + croatia: "hr", + cuba: "cu", + cyprus: "cy", + czech_republic: "cz", + denmark: "dk", + dominican_republic: "do", + ecuador: "ec", + egypt: "eg", + el_salvador: "sv", + estonia: "ee", + ethiopia: "et", + finland: "fi", + france: "fr", + gabon: "ga", + georgia: "ge", + germany: "de", + ghana: "gh", + greece: "gr", + guatemala: "gt", + guinea: "gn", + haiti: "ht", + honduras: "hn", + hong_kong: "hk", + hungary: "hu", + india: "in", + indonesia: "id", + iran: "ir", + iraq: "iq", + ireland: "ie", + israel: "il", + italy: "it", + ivory_coast: "ci", + japan: "jp", + jordan: "jo", + kazakhstan: "kz", + kenya: "ke", + kosovo: "xk", + kyrgyzstan: "kg", + laos: "la", + latvia: "lv", + lebanon: "lb", + libya: "ly", + lithuania: "lt", + luxembourg: "lu", + macau: "mo", + madagascar: "mg", + malawi: "mw", + malaysia: "my", + mali: "ml", + malta: "mt", + mauritania: "mr", + mauritius: "mu", + mexico: "mx", + moldova: "md", + morocco: "ma", + mozambique: "mz", + myanmar: "mm", + macedonia: "mk", + nepal: "np", + netherlands: "nl", + new_zealand: "nz", + nicaragua: "ni", + niger: "ne", + nigeria: "ng", + norway: "no", + oman: "om", + pakistan: "pk", + palestine: "ps", + panama: "pa", + papua_new_guinea: "pg", + paraguay: "py", + peru: "pe", + philippines: "ph", + poland: "pl", + portugal: "pt", + qatar: "qa", + south_korea: "kr", + romania: "ro", + russia: "ru", + rwanda: "rw", + saudi_arabia: "sa", + senegal: "sn", + serbia: "rs", + sierra_leone: "sl", + singapore: "sg", + slovakia: "sk", + slovenia: "si", + somalia: "so", + south_africa: "za", + south_sudan: "ss", + spain: "es", + sri_lanka: "lk", + sudan: "sd", + suriname: "sr", + sweden: "se", + switzerland: "ch", + syria: "sy", + taiwan: "tw", + tajikistan: "tj", + tanzania: "tz", + thailand: "th", + the_bahamas: "bs", + togo: "tg", + tunisia: "tn", + turkey: "tr", + turkmenistan: "tm", + uganda: "ug", + ukraine: "ua", + uae: "ae", + uk: "gb", + united_states: "us", + uruguay: "uy", + uzbekistan: "uz", + venezuela: "ve", + vietnam: "vn", + yemen: "ye", + zambia: "zm", + zimbabwe: "zw", +}; + +/** + * Get the ISO 3166-1 alpha-2 code for a committers.top country slug. + */ +export function getCountryCode(slug: string): string | null { + return SLUG_TO_ISO[slug] ?? null; +} diff --git a/lib/github.ts b/lib/github.ts index 4c2a369..d7aa5c7 100644 --- a/lib/github.ts +++ b/lib/github.ts @@ -49,17 +49,38 @@ type RawDiscussionNode = { }; }; +type PageInfo = { + hasNextPage: boolean; + endCursor: string | null; +}; + type FetchUserAndPullRequestsResponse = { user: GitHubRawUser | null; - pullRequests: { nodes: Array }; + pullRequests: { + nodes: Array; + pageInfo: PageInfo; + }; }; -type FetchIssuesResponse = { - issues: { nodes: Array }; +type FetchPullRequestsPageResponse = { + pullRequests: { + nodes: Array; + pageInfo: PageInfo; + }; }; -type FetchDiscussionsResponse = { - discussions: { nodes: Array }; +type FetchIssuesPageResponse = { + issues: { + nodes: Array; + pageInfo: PageInfo; + }; +}; + +type FetchDiscussionsPageResponse = { + discussions: { + nodes: Array; + pageInfo: PageInfo; + }; }; type GitHubQueryExecutor = Pick; @@ -71,13 +92,8 @@ export type GitHubFetcherDependencies = { logger?: Logger; }; -const USER_AND_PULL_REQUESTS_QUERY = /* GraphQL */ ` - query FetchUserAndPullRequests( - $login: String! - $repoCount: Int = 100 - $prCount: Int = 100 - $externalPrQuery: String! - ) { +const USER_QUERY = /* GraphQL */ ` + query FetchUser($login: String!, $repoCount: Int = 100) { user(login: $login) { name avatarUrl(size: 80) @@ -114,30 +130,31 @@ const USER_AND_PULL_REQUESTS_QUERY = /* GraphQL */ ` } } } + } +`; - pullRequests: search(query: $externalPrQuery, type: ISSUE, first: $prCount) { - nodes { - ... on PullRequest { - merged - additions - deletions - title - url - repository { - nameWithOwner - url - stargazerCount - pushedAt - owner { - login - } - languages(first: 5, orderBy: { field: SIZE, direction: DESC }) { - edges { - size - node { - name - } - } +const PULL_REQUESTS_SEARCH_FRAGMENT = ` + pageInfo { hasNextPage endCursor } + nodes { + ... on PullRequest { + merged + additions + deletions + title + url + repository { + nameWithOwner + url + stargazerCount + pushedAt + owner { + login + } + languages(first: 5, orderBy: { field: SIZE, direction: DESC }) { + edges { + size + node { + name } } } @@ -146,9 +163,18 @@ const USER_AND_PULL_REQUESTS_QUERY = /* GraphQL */ ` } `; +const PULL_REQUESTS_QUERY = /* GraphQL */ ` + query FetchUserPullRequests($prCount: Int = 100, $externalPrQuery: String!, $prCursor: String) { + pullRequests: search(query: $externalPrQuery, type: ISSUE, first: $prCount, after: $prCursor) { + ${PULL_REQUESTS_SEARCH_FRAGMENT} + } + } +`; + const ISSUES_QUERY = /* GraphQL */ ` - query FetchUserIssues($issueCount: Int = 20, $externalIssueQuery: String!) { - issues: search(query: $externalIssueQuery, type: ISSUE, first: $issueCount) { + query FetchUserIssues($issueCount: Int = 100, $externalIssueQuery: String!, $issueCursor: String) { + issues: search(query: $externalIssueQuery, type: ISSUE, first: $issueCount, after: $issueCursor) { + pageInfo { hasNextPage endCursor } nodes { ... on Issue { title @@ -171,14 +197,17 @@ const ISSUES_QUERY = /* GraphQL */ ` const DISCUSSIONS_QUERY = /* GraphQL */ ` query FetchUserDiscussions( - $discussionCount: Int = 10 + $discussionCount: Int = 100 $externalDiscussionQuery: String! + $discussionCursor: String ) { discussions: search( query: $externalDiscussionQuery type: DISCUSSION first: $discussionCount + after: $discussionCursor ) { + pageInfo { hasNextPage endCursor } nodes { ... on Discussion { title @@ -199,6 +228,65 @@ const DISCUSSIONS_QUERY = /* GraphQL */ ` } `; +// --------------------------------------------------------------------------- +// Search pagination helper +// --------------------------------------------------------------------------- + +type SearchPaginateParams = { + operationName: string; + query: string; + buildVariables: (cursor: string | null) => Record; + extractField: (data: Record) => { + nodes: Array; + pageInfo: PageInfo; + } | undefined; + maxPages?: number; +}; + +const SEARCH_PAGE_SIZE = 100; +const DEFAULT_MAX_SEARCH_PAGES = 10; + +async function paginateSearch( + executor: GitHubQueryExecutor, + params: SearchPaginateParams, +): Promise> { + const maxPages = params.maxPages ?? DEFAULT_MAX_SEARCH_PAGES; + const allNodes: Array = []; + let cursor: string | null = null; + + for (let page = 0; page < maxPages; page += 1) { + const variables = params.buildVariables(cursor); + const data = await executor.execute< + Record, + Record + >({ + operationName: params.operationName, + query: params.query, + variables, + }); + + const field = params.extractField(data); + if (!field) { + break; + } + + const pageNodes = field.nodes.filter(isDefined); + allNodes.push(...pageNodes); + + if (!field.pageInfo.hasNextPage || !field.pageInfo.endCursor) { + break; + } + + cursor = field.pageInfo.endCursor; + } + + return allNodes; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + function isDefined(value: T | null | undefined): value is T { return value !== null && value !== undefined; } @@ -275,6 +363,10 @@ export function buildGitHubUserCacheKey( return `${namespace}:github-user:${normalizeGitHubUsername(username)}`; } +// --------------------------------------------------------------------------- +// Core data fetch +// --------------------------------------------------------------------------- + async function fetchUserDataFromGitHub( executor: GitHubQueryExecutor, username: string, @@ -283,75 +375,76 @@ async function fetchUserDataFromGitHub( const externalIssueQuery = `type:issue author:${username} -user:${username}`; const externalDiscussionQuery = `author:${username} -user:${username}`; - const userAndPrResponse = - await executor.execute< - FetchUserAndPullRequestsResponse, - { - login: string; - repoCount: number; - prCount: number; - externalPrQuery: string; - } - >({ - operationName: "FetchUserAndPullRequests", - query: USER_AND_PULL_REQUESTS_QUERY, - variables: { - login: username, - repoCount: 30, - prCount: 80, - externalPrQuery, - }, - }); - - const user = userAndPrResponse.user; - if (!user) { - throw new Error("User not found"); - } - - const [issuesResponse, discussionsResponse] = await Promise.all([ + const [userResponse, pullRequests, issues, discussions] = await Promise.all([ executor.execute< - FetchIssuesResponse, - { - issueCount: number; - externalIssueQuery: string; - } + { user: GitHubRawUser | null }, + { login: string; repoCount: number } >({ + operationName: "FetchUser", + query: USER_QUERY, + variables: { login: username, repoCount: 30 }, + }), + paginateSearch(executor, { + operationName: "FetchUserPullRequests", + query: PULL_REQUESTS_QUERY, + buildVariables: (cursor) => ({ + prCount: SEARCH_PAGE_SIZE, + externalPrQuery, + prCursor: cursor, + }), + extractField: (data) => + data.pullRequests as + | { nodes: Array; pageInfo: PageInfo } + | undefined, + }), + paginateSearch(executor, { operationName: "FetchUserIssues", query: ISSUES_QUERY, - variables: { - issueCount: 20, + buildVariables: (cursor) => ({ + issueCount: SEARCH_PAGE_SIZE, externalIssueQuery, - }, + issueCursor: cursor, + }), + extractField: (data) => + data.issues as + | { nodes: Array; pageInfo: PageInfo } + | undefined, }), - executor.execute< - FetchDiscussionsResponse, - { - discussionCount: number; - externalDiscussionQuery: string; - } - >({ + paginateSearch(executor, { operationName: "FetchUserDiscussions", query: DISCUSSIONS_QUERY, - variables: { - discussionCount: 10, + buildVariables: (cursor) => ({ + discussionCount: SEARCH_PAGE_SIZE, externalDiscussionQuery, - }, + discussionCursor: cursor, + }), + extractField: (data) => + data.discussions as + | { nodes: Array; pageInfo: PageInfo } + | undefined, }), ]); + const user = userResponse.user; + if (!user) { + throw new Error("User not found"); + } + return { name: user.name, avatarUrl: user.avatarUrl, repos: user.repositories.nodes.filter(isDefined), - pullRequests: userAndPrResponse.pullRequests.nodes.filter(isDefined), + pullRequests, contributions: user.contributionsCollection, - issues: issuesResponse.issues.nodes.filter(isDefined).map(toIssueNode), - discussions: discussionsResponse.discussions.nodes - .filter(isDefined) - .map(toDiscussionNode), + issues: issues.map(toIssueNode), + discussions: discussions.map(toDiscussionNode), }; } +// --------------------------------------------------------------------------- +// Fetcher factory (caching + single-flight) +// --------------------------------------------------------------------------- + export function createGitHubUserDataFetcher( dependencies: GitHubFetcherDependencies, ): (username: string) => Promise { @@ -427,6 +520,10 @@ export function createGitHubUserDataFetcher( }; } +// --------------------------------------------------------------------------- +// Default singleton +// --------------------------------------------------------------------------- + function createDefaultGitHubExecutor(): GitHubQueryExecutor { const token = process.env.GITHUB_TOKEN?.trim(); if (!token) { diff --git a/locales/ar.json b/locales/ar.json index 1d205a4..f70b7f6 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -215,5 +215,33 @@ "a11y.openCommunityContribution": "افتح مساهمة المجتمع {title} على GitHub", "a11y.openProfile": "افتح ملف GitHub للمستخدم {name}", "a11y.openPullRequest": "افتح طلب السحب {title} على GitHub", - "a11y.openRepo": "افتح المستودع {name} على GitHub" + "a11y.openRepo": "افتح المستودع {name} على GitHub", + "leaderboard.header.eyebrow": "لوحة المتصدرين", + "leaderboard.header.title": "إعادة ترتيب المساهمين حسب الأثر", + "leaderboard.header.description": "اختر دولة لعرض المطورين مرتبين حسب أثر المصدر المفتوح الفعلي", + "leaderboard.title": "لوحة المتصدرين حسب الأثر", + "leaderboard.description": "تم تحليل {processed} مطور من أصل {total} مسجل على committers.top", + "leaderboard.impactRank": "ترتيب الأثر", + "leaderboard.developer": "المطور", + "leaderboard.committersRank": "ترتيب المساهمين", + "leaderboard.change": "التغير", + "leaderboard.topImpact": "المطور الأعلى أثرًا", + "leaderboard.impactScore": "درجة الأثر", + "leaderboard.error.title": "فشل تحميل لوحة المتصدرين", + "leaderboard.error.generic": "حدث خطأ أثناء تحميل لوحة المتصدرين. يرجى المحاولة لاحقًا.", + "leaderboard.error.upstreamError": "تعذر جلب البيانات. يرجى المحاولة بعد قليل.", + "leaderboard.partialErrors": "لم يمكن حساب درجة {count} مطورين", + "leaderboard.search": "البحث عن مطور...", + "leaderboard.pagination": "عرض {from}–{to} من {total}", + "leaderboard.back": "جميع الدول", + "leaderboard.loading": "جاري حساب درجات المطورين...", + "leaderboard.scoringHint": "قد يستغرق هذا لحظة عند التحميل الأول. يتم تخزين النتائج مؤقتًا للزيارات اللاحقة.", + "leaderboard.searchCountry": "البحث عن دول...", + "leaderboard.loadingCountries": "جاري تحميل الدول المتاحة...", + "leaderboard.empty.title": "لم يتم العثور على دول", + "leaderboard.empty.description": "ستظهر الدول من committers.top هنا.", + "leaderboard.noCountriesSearch": "لا توجد دول تطابق بحثك.", + "leaderboard.noDevelopersFor": "لم يتم العثور على مطورين في {title}", + "leaderboard.error.retry": "حاول مرة أخرى", + "leaderboard.viewCountry": "عرض لوحة المتصدرين" } diff --git a/locales/en.json b/locales/en.json index 1d617a0..067be4b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -215,5 +215,33 @@ "a11y.openCommunityContribution": "Open community contribution {title} on GitHub", "a11y.openProfile": "Open GitHub profile for {name}", "a11y.openPullRequest": "Open pull request {title} on GitHub", - "a11y.openRepo": "Open repository {name} on GitHub" + "a11y.openRepo": "Open repository {name} on GitHub", + "leaderboard.header.eyebrow": "Country Leaderboard", + "leaderboard.header.title": "Reorder Committers by Impact", + "leaderboard.header.description": "Select a country to view developers ranked by real open-source impact", + "leaderboard.title": "Impact Leaderboard", + "leaderboard.description": "{processed} developers analyzed out of {total} total on committers.top", + "leaderboard.impactRank": "Impact Rank", + "leaderboard.developer": "Developer", + "leaderboard.committersRank": "Committers Rank", + "leaderboard.change": "Change", + "leaderboard.topImpact": "Highest Impact Developer", + "leaderboard.impactScore": "Impact Score", + "leaderboard.error.title": "Leaderboard load failed", + "leaderboard.error.generic": "Something went wrong while loading the leaderboard. Please try again later.", + "leaderboard.error.upstreamError": "Failed to fetch data. Please try again shortly.", + "leaderboard.partialErrors": "{count} developers could not be scored", + "leaderboard.search": "Search developer...", + "leaderboard.pagination": "Showing {from}–{to} of {total}", + "leaderboard.back": "All countries", + "leaderboard.loading": "Scoring developers…", + "leaderboard.scoringHint": "This may take a moment on first load. Results are cached for subsequent visits.", + "leaderboard.searchCountry": "Search countries...", + "leaderboard.loadingCountries": "Loading available countries...", + "leaderboard.empty.title": "No countries found", + "leaderboard.empty.description": "Countries from committers.top will appear here.", + "leaderboard.noCountriesSearch": "No countries match your search.", + "leaderboard.noDevelopersFor": "No developers found for {title}", + "leaderboard.error.retry": "Try again", + "leaderboard.viewCountry": "View leaderboard" } diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package.json b/package.json index d879c0a..dbe8386 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "@octokit/graphql": "^9.0.3", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "flag-icons": "^7.5.0", + "js-yaml": "^4.1.1", "lucide-react": "^1.7.0", "next": "^16.2.6", "next-themes": "^0.3.0", @@ -28,6 +30,7 @@ "tailwind-merge": "^2.5.3" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@types/node": "^25.5.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e083670..bf6a652 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,12 +17,18 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + flag-icons: + specifier: ^7.5.0 + version: 7.5.0 + js-yaml: + specifier: ^4.1.1 + version: 4.1.1 lucide-react: specifier: ^1.7.0 version: 1.8.0(react@19.2.5) next: specifier: ^16.2.6 - version: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 16.2.6(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) next-themes: specifier: ^0.3.0 version: 0.3.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -48,6 +54,9 @@ importers: specifier: ^2.5.3 version: 2.6.1 devDependencies: + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 '@types/node': specifier: ^25.5.2 version: 25.6.0 @@ -270,105 +279,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -441,28 +434,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@16.2.6': resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@16.2.6': resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@16.2.6': resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@16.2.6': resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==} @@ -517,6 +506,11 @@ packages: '@oxc-project/types@0.128.0': resolution: {integrity: sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==} + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} + hasBin: true + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -1278,42 +1272,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.18': resolution: {integrity: sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18': resolution: {integrity: sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18': resolution: {integrity: sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.18': resolution: {integrity: sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.18': resolution: {integrity: sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.18': resolution: {integrity: sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==} @@ -1389,6 +1377,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1504,49 +1495,41 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -2148,6 +2131,9 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + flag-icons@7.5.0: + resolution: {integrity: sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==} + flat-cache@4.0.1: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} @@ -2162,6 +2148,11 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2492,28 +2483,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -2734,6 +2721,16 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -3757,6 +3754,11 @@ snapshots: '@oxc-project/types@0.128.0': {} + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + optional: true + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -4621,6 +4623,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/js-yaml@4.0.9': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -5531,6 +5535,8 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + flag-icons@7.5.0: {} + flat-cache@4.0.1: dependencies: flatted: 3.4.2 @@ -5544,6 +5550,9 @@ snapshots: fraction.js@5.3.4: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -5950,7 +5959,7 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + next@16.2.6(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: '@next/env': 16.2.6 '@swc/helpers': 0.5.15 @@ -5969,6 +5978,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.2.6 '@next/swc-win32-arm64-msvc': 16.2.6 '@next/swc-win32-x64-msvc': 16.2.6 + '@playwright/test': 1.60.0 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -6076,6 +6086,16 @@ snapshots: pirates@4.0.7: {} + playwright-core@1.60.0: + optional: true + + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + optional: true + possible-typed-array-names@1.1.0: {} postcss-import@15.1.0(postcss@8.5.14): diff --git a/test/github/github-cache.test.ts b/test/github/github-cache.test.ts index 7c744b1..29d5a87 100644 --- a/test/github/github-cache.test.ts +++ b/test/github/github-cache.test.ts @@ -35,7 +35,7 @@ function makeExecutor( await new Promise((resolve) => setTimeout(resolve, delayMs)); } - if (params.operationName === "FetchUserAndPullRequests") { + if (params.operationName === "FetchUser") { return { user: { name: "User Name", @@ -63,6 +63,11 @@ function makeExecutor( totalIssueContributions: 0, }, }, + } as unknown as TData; + } + + if (params.operationName === "FetchUserPullRequests") { + return { pullRequests: { nodes: [ { @@ -83,6 +88,7 @@ function makeExecutor( }, }, ], + pageInfo: { hasNextPage: false, endCursor: null }, }, } as unknown as TData; } @@ -102,6 +108,7 @@ function makeExecutor( }, }, ], + pageInfo: { hasNextPage: false, endCursor: null }, }, } as unknown as TData; } @@ -121,6 +128,7 @@ function makeExecutor( }, }, ], + pageInfo: { hasNextPage: false, endCursor: null }, }, } as unknown as TData; } @@ -224,7 +232,7 @@ describe("GitHub user data caching", () => { const result = await fetcher("TeStUser"); expect(isGitHubUserData(result)).toBe(true); - expect(calls).toHaveLength(3); + expect(calls).toHaveLength(4); expect(setCalls).toHaveLength(1); expect(setCalls[0]?.ttl).toBe(604_800); expect(setCalls[0]?.key).toBe("devimpact:v1:github-user:testuser"); @@ -247,7 +255,7 @@ describe("GitHub user data caching", () => { const result = await fetcher("testuser"); expect(result.name).toBe("User Name"); - expect(calls).toHaveLength(3); + expect(calls).toHaveLength(4); }); test("cache write failure does not fail request", async () => { @@ -268,7 +276,7 @@ describe("GitHub user data caching", () => { const result = await fetcher("testuser"); expect(result.pullRequests).toHaveLength(1); - expect(calls).toHaveLength(3); + expect(calls).toHaveLength(4); }); test("corrupted cache payload is treated as miss", async () => { @@ -290,7 +298,7 @@ describe("GitHub user data caching", () => { const result = await fetcher("testuser"); expect(result.name).toBe("User Name"); - expect(calls).toHaveLength(3); + expect(calls).toHaveLength(4); expect(deleted).toEqual(["devimpact:v1:github-user:testuser"]); }); @@ -313,7 +321,7 @@ describe("GitHub user data caching", () => { ]); expect(first.avatarUrl).toBe(second.avatarUrl); - expect(calls).toHaveLength(3); + expect(calls).toHaveLength(4); }); test("default cache TTL is seven days", () => { diff --git a/test/seo/seo.test.ts b/test/seo/seo.test.ts index b80a6f3..7bec353 100644 --- a/test/seo/seo.test.ts +++ b/test/seo/seo.test.ts @@ -42,8 +42,8 @@ describe("seo routes", () => { expect(result.sitemap).toContain("/sitemap.xml"); }); - test("sitemap includes key public pages", () => { - const result = sitemap(); + test("sitemap includes key public pages", async () => { + const result = await sitemap(); const urls = result.map((entry) => entry.url); expect(urls).toContain("http://localhost:3000/"); diff --git a/types/leaderboard.ts b/types/leaderboard.ts new file mode 100644 index 0000000..e75c2f1 --- /dev/null +++ b/types/leaderboard.ts @@ -0,0 +1,4 @@ +export type CountryInfo = { + slug: string; + title: string; +};