From 1c276dfaf2ea607348bb9a1d2a6b56f496261726 Mon Sep 17 00:00:00 2001
From: ws-rush
Date: Sat, 29 Aug 2026 10:13:49 +0300
Subject: [PATCH 1/4] feat(profile): add dedicated developer profile pages and
API
- Add /user/[username] dynamic route with SSR metadata and JSON-LD
- Create UserProfileClient dashboard with score breakdown, top work, and transparency signals
- Add /api/user/[username] endpoint and getUserProfile utility with automatic DB syncing
- Update leaderboard and comparison views to link to developer profiles
- Add full i18n support (EN/AR) and comprehensive unit tests
---
app/api/user/[username]/route.ts | 104 +++++
app/user/[username]/loading.tsx | 15 +
app/user/[username]/page.tsx | 171 +++++++
components/home-page-client.tsx | 23 +-
components/leaderboard-table.tsx | 42 +-
components/result-dashboard.tsx | 150 ++++---
components/top-list.tsx | 23 +-
components/user-not-found.tsx | 43 ++
components/user-profile-client.tsx | 645 +++++++++++++++++++++++++++
components/user-profile-skeleton.tsx | 92 ++++
lib/leaderboard.ts | 90 ++--
lib/user.ts | 116 +++++
locales/ar.json | 25 +-
locales/en.json | 25 +-
scripts/init-db.ts | 1 +
test/api/user.route.test.ts | 212 +++++++++
16 files changed, 1667 insertions(+), 110 deletions(-)
create mode 100644 app/api/user/[username]/route.ts
create mode 100644 app/user/[username]/loading.tsx
create mode 100644 app/user/[username]/page.tsx
create mode 100644 components/user-not-found.tsx
create mode 100644 components/user-profile-client.tsx
create mode 100644 components/user-profile-skeleton.tsx
create mode 100644 lib/user.ts
create mode 100644 test/api/user.route.test.ts
diff --git a/app/api/user/[username]/route.ts b/app/api/user/[username]/route.ts
new file mode 100644
index 0000000..4f90321
--- /dev/null
+++ b/app/api/user/[username]/route.ts
@@ -0,0 +1,104 @@
+import { NextResponse } from "next/server";
+import { getUserProfile, UserFetchError } from "@/lib/user";
+import { normalizeSelectedLanguages } from "@/lib/scoring/languageScoring";
+import { toSafeApiError } from "@/lib/github-graphql-client";
+import type { SafeApiError } from "@/types/api-response";
+
+export const runtime = "nodejs";
+
+type ClientSafeError = Pick;
+
+function parseSelectedLanguagesFromSearchParams(searchParams: URLSearchParams): string[] {
+ const fromRepeated = searchParams.getAll("selectedLanguage");
+ const fromCsv = searchParams
+ .get("selectedLanguages")
+ ?.split(",")
+ .map((language) => language.trim())
+ .filter(Boolean);
+
+ return normalizeSelectedLanguages([...(fromRepeated ?? []), ...(fromCsv ?? [])]);
+}
+
+function toClientSafeError(error: SafeApiError): ClientSafeError {
+ return {
+ code: error.code,
+ message: error.message,
+ targetUsernames: error.targetUsernames,
+ };
+}
+
+function toApiErrorStatus(code: ReturnType["code"]): number {
+ switch (code) {
+ case "RATE_LIMITED":
+ case "TEMPORARY_THROTTLE":
+ return 429;
+ case "GITHUB_TIMEOUT":
+ case "GITHUB_RESOURCE_LIMIT":
+ case "GITHUB_AUTH":
+ return code === "GITHUB_AUTH" ? 401 : 503;
+ case "GITHUB_NOT_FOUND":
+ return 404;
+ case "NETWORK":
+ return 503;
+ case "UNKNOWN":
+ default:
+ return 500;
+ }
+}
+
+export async function GET(request: Request, { params }: { params: Promise<{ username: string }> }) {
+ const { username } = await params;
+ const trimmed = username?.trim();
+
+ if (!trimmed) {
+ return NextResponse.json(
+ { success: false, error: "Username parameter is required" },
+ { status: 400 },
+ );
+ }
+
+ const { searchParams } = new URL(request.url);
+ const selectedLanguages = parseSelectedLanguagesFromSearchParams(searchParams);
+
+ try {
+ const { user, location } = await getUserProfile(trimmed, selectedLanguages);
+ return NextResponse.json({ success: true, user, location });
+ } catch (error: unknown) {
+ console.error("User profile fetch error:", error);
+
+ let safeError: SafeApiError;
+
+ if (error instanceof UserFetchError) {
+ const mappedCause = toSafeApiError(error.causeError);
+ if (
+ mappedCause.code === "GITHUB_NOT_FOUND" ||
+ (error.causeError instanceof Error && error.causeError.message === "User not found")
+ ) {
+ safeError = {
+ code: "GITHUB_NOT_FOUND",
+ message: "GitHub user not found",
+ targetUsernames: [error.username],
+ rateLimit: mappedCause.rateLimit,
+ };
+ } else {
+ safeError = mappedCause;
+ }
+ } else {
+ safeError =
+ error instanceof Error && error.message === "User not found"
+ ? { code: "GITHUB_NOT_FOUND", message: "GitHub user not found" }
+ : toSafeApiError(error);
+ }
+
+ const clientSafeError = toClientSafeError(safeError);
+
+ return NextResponse.json(
+ {
+ success: false,
+ error: clientSafeError.message,
+ errorDetails: clientSafeError,
+ },
+ { status: toApiErrorStatus(safeError.code) },
+ );
+ }
+}
diff --git a/app/user/[username]/loading.tsx b/app/user/[username]/loading.tsx
new file mode 100644
index 0000000..651af70
--- /dev/null
+++ b/app/user/[username]/loading.tsx
@@ -0,0 +1,15 @@
+import { AppHeader } from "@/components/app-header";
+import { AppFooter } from "@/components/app-footer";
+import { UserProfileSkeleton } from "@/components/user-profile-skeleton";
+
+export default function UserProfileLoading() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/user/[username]/page.tsx b/app/user/[username]/page.tsx
new file mode 100644
index 0000000..7e1b69a
--- /dev/null
+++ b/app/user/[username]/page.tsx
@@ -0,0 +1,171 @@
+import type { Metadata } from "next";
+import { JsonLd } from "@/components/seo/json-ld";
+import { UserProfileClient } from "@/components/user-profile-client";
+import { UserNotFoundCard } from "@/components/user-not-found";
+import { AppHeader } from "@/components/app-header";
+import { AppFooter } from "@/components/app-footer";
+import { getUserProfile } from "@/lib/user";
+import { toAbsoluteUrl } from "@/lib/seo";
+
+type Props = {
+ params: Promise<{ username: string }>;
+};
+
+export async function generateMetadata({ params }: Props): Promise {
+ const { username } = await params;
+ const cleanUsername = decodeURIComponent(username.trim());
+
+ let displayName = cleanUsername;
+ try {
+ const { user } = await getUserProfile(cleanUsername);
+ displayName = user.name?.trim() || cleanUsername;
+ } catch {
+ // Fallback if user cannot be fetched during metadata generation
+ }
+
+ const pageTitle = `${displayName} (@${cleanUsername}) - Developer Impact & Stats`;
+ const description = `Explore ${displayName}'s (@${cleanUsername}) open-source developer impact score, top repositories, merged pull requests, and community contributions on DevImpact.`;
+ const pageUrl = `/user/${cleanUsername}`;
+
+ return {
+ title: pageTitle,
+ description,
+ keywords: [
+ `${cleanUsername} GitHub`,
+ `${displayName} developer stats`,
+ `${cleanUsername} open source impact`,
+ "developer impact score",
+ "GitHub profile analytics",
+ ],
+ alternates: {
+ canonical: pageUrl,
+ },
+ openGraph: {
+ type: "profile",
+ title: `${pageTitle} | DevImpact`,
+ description,
+ url: pageUrl,
+ images: [
+ {
+ url: toAbsoluteUrl("/og-image.svg"),
+ width: 1200,
+ height: 630,
+ alt: `${displayName} GitHub developer impact score preview`,
+ },
+ ],
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: `${pageTitle} | DevImpact`,
+ description,
+ images: [toAbsoluteUrl("/og-image.svg")],
+ },
+ robots: {
+ index: true,
+ follow: true,
+ googleBot: {
+ index: true,
+ follow: true,
+ "max-image-preview": "large",
+ "max-snippet": -1,
+ "max-video-preview": -1,
+ },
+ },
+ };
+}
+
+export default async function UserProfilePage({ params }: Props) {
+ const { username } = await params;
+ const cleanUsername = decodeURIComponent(username.trim());
+ const profileUrl = toAbsoluteUrl(`/user/${cleanUsername}`);
+
+ let profileData: Awaited> | null = null;
+ let fetchErrorMessage: string | null = null;
+
+ try {
+ profileData = await getUserProfile(cleanUsername);
+ } catch (err: unknown) {
+ fetchErrorMessage = err instanceof Error ? err.message : "Failed to load user profile";
+ }
+
+ if (!profileData || fetchErrorMessage) {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ const { user, location } = profileData;
+ const displayName = user.name?.trim() || user.username;
+
+ const profilePageSchema = {
+ "@context": "https://schema.org",
+ "@type": "ProfilePage",
+ name: `${displayName} Developer Profile`,
+ description: `Open-source impact statistics and scoring for ${displayName} (@${user.username}).`,
+ url: profileUrl,
+ mainEntity: {
+ "@type": "Person",
+ name: displayName,
+ alternateName: user.username,
+ image: user.avatarUrl,
+ url: `https://github.com/${user.username}`,
+ ...(location ? { homeLocation: location } : {}),
+ interactionStatistic: [
+ {
+ "@type": "InteractionCounter",
+ interactionType: "https://schema.org/LikeAction",
+ userInteractionCount: user.finalScore,
+ },
+ ],
+ },
+ isPartOf: {
+ "@type": "WebSite",
+ name: "DevImpact",
+ url: toAbsoluteUrl("/"),
+ },
+ };
+
+ const breadcrumbSchema = {
+ "@context": "https://schema.org",
+ "@type": "BreadcrumbList",
+ itemListElement: [
+ {
+ "@type": "ListItem",
+ position: 1,
+ name: "Home",
+ item: toAbsoluteUrl("/"),
+ },
+ {
+ "@type": "ListItem",
+ position: 2,
+ name: "Leaderboards",
+ item: toAbsoluteUrl("/leaderboard"),
+ },
+ {
+ "@type": "ListItem",
+ position: 3,
+ name: displayName,
+ item: profileUrl,
+ },
+ ],
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/home-page-client.tsx b/components/home-page-client.tsx
index 5680925..dd723b5 100644
--- a/components/home-page-client.tsx
+++ b/components/home-page-client.tsx
@@ -55,13 +55,26 @@ function normalizeUsers(body: ApiResponse): { user1: UserResult; user2: UserResu
return null;
}
+function parseUsernamesFromSearchParams(searchParams: {
+ getAll: (name: string) => string[];
+ get: (name: string) => string | null;
+}): [string, string] {
+ const repeated = searchParams
+ .getAll("username")
+ .map((u) => u.trim())
+ .filter(Boolean);
+ const u1 =
+ repeated[0] || searchParams.get("username1")?.trim() || searchParams.get("user1")?.trim() || "";
+ const u2 =
+ repeated[1] || searchParams.get("username2")?.trim() || searchParams.get("user2")?.trim() || "";
+ return [u1, u2];
+}
+
export function HomePageClient() {
const { t } = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
- const initialUsernames = searchParams.getAll("username");
- const initialUsername1 = initialUsernames[0] ?? "";
- const initialUsername2 = initialUsernames[1] ?? "";
+ const [initialUsername1, initialUsername2] = parseUsernamesFromSearchParams(searchParams);
const initialSelectedLanguages = sanitizeSelectedLanguages(
searchParams.getAll("selectedLanguage"),
);
@@ -334,10 +347,10 @@ export function HomePageClient() {
});
useEffect(() => {
- const params = searchParams.getAll("username");
+ const [u1, u2] = parseUsernamesFromSearchParams(searchParams);
const urlLanguages = sanitizeSelectedLanguages(searchParams.getAll("selectedLanguage"));
queueMicrotask(() => {
- syncToUrl(params[0] ?? "", params[1] ?? "", urlLanguages);
+ syncToUrl(u1, u2, urlLanguages);
});
}, [searchParams]);
diff --git a/components/leaderboard-table.tsx b/components/leaderboard-table.tsx
index d56ecd3..631af93 100644
--- a/components/leaderboard-table.tsx
+++ b/components/leaderboard-table.tsx
@@ -1,7 +1,9 @@
"use client";
import { useState, useMemo } from "react";
-import { Search, AlertTriangle } from "lucide-react";
+import Link from "next/link";
+import type { Route } from "next";
+import { Search, AlertTriangle, ExternalLink } from "lucide-react";
import { Avatar } from "./avatar";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "./ui/card";
@@ -146,17 +148,35 @@ export function LeaderboardTable({
|
diff --git a/components/result-dashboard.tsx b/components/result-dashboard.tsx
index 95b0e8e..44ea05d 100644
--- a/components/result-dashboard.tsx
+++ b/components/result-dashboard.tsx
@@ -1,7 +1,9 @@
"use client";
import { useMemo, useState } from "react";
-import { Check, Copy, Trophy } from "lucide-react";
+import Link from "next/link";
+import type { Route } from "next";
+import { Check, Copy, ExternalLink, Trophy } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { Avatar } from "@/components/avatar";
import { ComparisonChart } from "./comparison-chart";
@@ -150,20 +152,31 @@ export function ResultDashboard({
{isWinner ? (
@@ -214,6 +227,15 @@ export function ResultDashboard({
) : null}
+
+
+ {t("profile.viewFullStats")}
+ →
+
+
);
@@ -270,20 +292,31 @@ export function ResultDashboard({
-
-
- {getDisplayName(user)}
-
+
+
+
+
+
+ {getDisplayName(user)}
+
+
+
+
+
@@ -314,17 +347,25 @@ export function ResultDashboard({
@@ -361,24 +402,33 @@ export function ResultDashboard({
return (
{winnerAvatar ? (
-
+
+
+
) : null}
-
- {t("banner.languageWinner")}:{" "}
+
);
})()}
diff --git a/components/top-list.tsx b/components/top-list.tsx
index 4e87596..f16027c 100644
--- a/components/top-list.tsx
+++ b/components/top-list.tsx
@@ -1,7 +1,10 @@
import type { ReactNode } from "react";
+import Link from "next/link";
+import type { Route } from "next";
import {
ArrowDown,
ArrowUp,
+ ExternalLink,
Eye,
GitFork,
GitPullRequest,
@@ -185,8 +188,24 @@ export function TopList({ userResults, selectedLanguages = [] }: Props) {
{userResults.map((user, idx) => (
-
- {t("topwork.titleForUser", { username: user.name || user.username })}
+
+
+
+ {t("topwork.titleForUser", { username: user.name || user.username })}
+
+
+
+
+
{t("topwork.desc")}
diff --git a/components/user-not-found.tsx b/components/user-not-found.tsx
new file mode 100644
index 0000000..a47e15d
--- /dev/null
+++ b/components/user-not-found.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import Link from "next/link";
+import { ArrowLeft, Search, Trophy } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { useTranslation } from "@/components/language-provider";
+
+type Props = {
+ username: string;
+};
+
+export function UserNotFoundCard({ username }: Props) {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+ {t("profile.notFound.title")}
+
+ {t("profile.notFound.description", { username })}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/user-profile-client.tsx b/components/user-profile-client.tsx
new file mode 100644
index 0000000..bf8950e
--- /dev/null
+++ b/components/user-profile-client.tsx
@@ -0,0 +1,645 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import type { Route } from "next";
+import {
+ ArrowLeft,
+ Check,
+ Copy,
+ ExternalLink,
+ GitFork,
+ GitPullRequest,
+ MapPin,
+ MessageSquare,
+ Scale,
+ ShieldCheck,
+ Star,
+ Trophy,
+} from "lucide-react";
+import { Avatar } from "@/components/avatar";
+import { ScoreCard } from "@/components/score-card";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Progress } from "@/components/ui/progress";
+import { useTranslation } from "@/components/language-provider";
+import { getCountryCode } from "@/lib/country-flags";
+import { detectCountry } from "@/lib/location-detector";
+import type { UserResult } from "@/types/user-result";
+
+type Props = {
+ user: UserResult;
+ location?: string | null;
+};
+
+type LanguageEntry = {
+ name: string;
+ percentage: number;
+};
+
+function getLanguageColor(name: string): string {
+ const normalized = name.trim().toLowerCase();
+ if (normalized === "typescript") return "bg-sky-500";
+ if (normalized === "javascript") return "bg-amber-400";
+ if (normalized === "python") return "bg-blue-500";
+ if (normalized === "go") return "bg-cyan-500";
+ if (normalized === "rust") return "bg-orange-500";
+ if (normalized === "java") return "bg-red-500";
+ if (normalized === "c#") return "bg-violet-500";
+ if (normalized === "php") return "bg-indigo-500";
+ if (normalized === "ruby") return "bg-rose-500";
+ if (normalized === "swift") return "bg-orange-400";
+ if (normalized === "kotlin") return "bg-fuchsia-500";
+ if (normalized === "c++") return "bg-blue-700";
+ return "bg-slate-500";
+}
+
+function StatChip({ icon, label, value }: { icon: React.ReactNode; label: string; value: number }) {
+ return (
+
+ {icon}
+ {label}
+ {value}
+
+ );
+}
+
+function LanguageBreakdown({ topLanguages }: { topLanguages?: LanguageEntry[] }) {
+ if (!topLanguages || topLanguages.length === 0) return null;
+
+ const normalized = topLanguages.slice(0, 4).filter((lang) => lang.percentage > 0);
+
+ if (normalized.length === 0) return null;
+
+ return (
+
+
+ {normalized.map((lang, idx) => (
+
+ ))}
+
+
+ {normalized.map((lang, idx) => (
+
+
+ {lang.name} {Math.round(lang.percentage * 100)}%
+
+ ))}
+
+
+ );
+}
+
+export function UserProfileClient({ user, location }: Props) {
+ const { t } = useTranslation();
+ const [copied, setCopied] = useState(false);
+
+ const displayName = user.name?.trim() || user.username;
+ const githubUrl = `https://github.com/${user.username}`;
+ const compareUrl = `/?user1=${encodeURIComponent(user.username)}`;
+
+ // Location & Country flag detection
+ const detectedSlug = detectCountry(location ?? null);
+ const flagCode = detectedSlug ? getCountryCode(detectedSlug) : null;
+
+ const handleCopyLink = async () => {
+ try {
+ await navigator.clipboard.writeText(window.location.href);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ setCopied(false);
+ }
+ };
+
+ // Signal stats entries for transparency
+ const signalEntries = user.signals
+ ? [
+ { label: t("signals.reposAnalyzed"), value: user.signals.reposAnalyzed ?? "-" },
+ {
+ label: t("signals.pullRequestsAnalyzed"),
+ value: user.signals.pullRequestsAnalyzed ?? "-",
+ },
+ {
+ label: t("signals.mergedExternalPRs"),
+ value: user.signals.mergedExternalPRs ?? "-",
+ },
+ {
+ label: t("signals.ownRepoPRsIgnored"),
+ value: user.signals.ownRepoPRsIgnored ?? "-",
+ },
+ {
+ label: t("signals.unmergedPRsIgnored"),
+ value: user.signals.unmergedPRsIgnored ?? "-",
+ },
+ {
+ label: t("signals.uniqueExternalPRRepos"),
+ value: user.signals.uniqueExternalPRRepos ?? "-",
+ },
+ {
+ label: t("signals.issuesAnalyzed"),
+ value: user.signals.issuesAnalyzed ?? "-",
+ },
+ {
+ label: t("signals.externalIssuesCounted"),
+ value: user.signals.externalIssuesCounted ?? "-",
+ },
+ {
+ label: t("signals.discussionsAnalyzed"),
+ value: user.signals.discussionsAnalyzed ?? "-",
+ },
+ {
+ label: t("signals.externalDiscussionsCounted"),
+ value: user.signals.externalDiscussionsCounted ?? "-",
+ },
+ ]
+ : [];
+
+ const totalRawSum = Math.max(
+ 1,
+ user.repoScore * 0.45 + user.prScore * 0.45 + user.contributionScore * 0.1,
+ );
+ const repoWeightPct = Math.round(((user.repoScore * 0.45) / totalRawSum) * 100);
+ const prWeightPct = Math.round(((user.prScore * 0.45) / totalRawSum) * 100);
+ const contributionWeightPct = Math.max(0, 100 - repoWeightPct - prWeightPct);
+
+ return (
+
+ {/* ── Back Navigation ────────────────────────────────────────── */}
+
+
+
+
+
+
+ {/* ── Header Profile Hero Section ────────────────────────────── */}
+
+
+
+
+
+
+ {t("profile.header.eyebrow")}
+
+
+
+ {displayName}
+
+
+ @{user.username}
+
+
+
+ {location ? (
+
+ {flagCode ? (
+
+ ) : (
+
+ )}
+ {location}
+
+ ) : null}
+
+
+
+ GitHub
+
+
+ {user.scoreVersion ? (
+
+ • {t("results.scoreVersion")}: {user.scoreVersion}
+
+ ) : null}
+
+
+
+
+ {/* Actions */}
+
+
+
+
+
+
+
+
+
+
+ {/* ── Score Cards Grid ────────────────────────────────────────── */}
+
+
+
+ {t("profile.scoreOverview")}
+
+
+ {t("profile.title")}
+
+
+
+
+
+
+
+
+
+
+
+ {/* ── Score Weighting Distribution ────────────────────────────── */}
+
+
+
+
+ {t("profile.scoreDistribution")}
+
+ {t("methodology.sections.weights.formula")}
+
+
+
+
+
+
+ {t("breakdown.repo")} (45%)
+
+
+ {t("profile.signalShare", { pct: repoWeightPct })}
+
+
+
+
+
+
+
+
+
+ {t("breakdown.pr")} (45%)
+
+
+ {t("profile.signalShare", { pct: prWeightPct })}
+
+
+
+
+
+
+
+
+
+ {t("breakdown.contribution")} (10%)
+
+
+ {t("profile.signalShare", { pct: contributionWeightPct })}
+
+
+
+
+
+
+
+ {/* ── Top Work Section ────────────────────────────────────────── */}
+
+
+
+ {t("topwork.title")}
+
+
+ {t("profile.topWork")}
+
+
+
+
+ {/* Top Repositories */}
+
+
+
+
+ {t("topwork.toprepos")}
+
+ {t("topwork.desc")}
+
+
+ {user.topRepos.length === 0 ? (
+ {t("empty.repos")}
+ ) : (
+ user.topRepos.slice(0, 3).map((repo, idx) => (
+
+
+
+
+
+
+ }
+ label={t("topwork.stars")}
+ value={repo.stars ?? 0}
+ />
+ }
+ label={t("topwork.forks")}
+ value={repo.forks ?? 0}
+ />
+
+
+
+
+
+
+
{repo.score ?? 0}
+
{t("comparsion.score")}
+
+
+
+ ))
+ )}
+
+
+
+ {/* Top Pull Requests */}
+
+
+
+
+ {t("topwork.topprs")}
+
+ {t("topwork.desc")}
+
+
+ {user.topPullRequests.length === 0 ? (
+ {t("empty.pullRequests")}
+ ) : (
+ user.topPullRequests.slice(0, 3).map((pr, idx) => (
+
+
+
+
+
+
+ {t("topwork.inRepo", {
+ repo: pr.repo || t("unknown.repo"),
+ })}
+
+
+
+
}
+ label={t("topwork.pr.repo.stars")}
+ value={pr.stars ?? 0}
+ />
+
+
+ +{pr.additions ?? 0}
+
+ /
+
+ -{pr.deletions ?? 0}
+
+
+
+
+
+
+
+
+
{pr.score ?? 0}
+
{t("comparsion.score")}
+
+
+
+ ))
+ )}
+
+
+
+ {/* Top Community Contributions */}
+
+
+
+
+ {t("community.title")}
+
+ {t("community.comments")}
+
+
+ {user.topCommunityContributions && user.topCommunityContributions.length > 0 ? (
+ user.topCommunityContributions.slice(0, 3).map((item, idx) => (
+
+
+
+
+
+ #{idx + 1}
+
+
+ {item.type === "issue"
+ ? t("community.issue")
+ : t("community.discussion")}
+
+
+
+ {item.url ? (
+
+ {item.title}
+
+ ) : (
+
{item.title}
+ )}
+
+
+ {item.repo}
+
+
+
+ }
+ label={t("topwork.stars")}
+ value={item.stars}
+ />
+ }
+ label={t("community.comments")}
+ value={item.comments}
+ />
+
+
+
+
+
{item.score}
+
{t("comparsion.score")}
+
+
+
+ ))
+ ) : (
+ {t("empty.community")}
+ )}
+
+
+
+
+
+ {/* ── Transparency Signals ────────────────────────────────────── */}
+ {signalEntries.length > 0 ? (
+
+
+
+
+ {t("signals.title")}
+
+
+ ▼
+
+
+
+ {signalEntries.map((entry) => (
+
+
{entry.label}
+
{entry.value}
+
+ ))}
+
+
+ ) : null}
+
+ {/* ── Scoring Methodology CTA ─────────────────────────────────── */}
+
+
+
+
+ {t("explanations.title")}
+
+
+
+ {t("methodology.cta.description")}
+
+ {t("methodology.cta.button")}
+
+
+
+
+ );
+}
diff --git a/components/user-profile-skeleton.tsx b/components/user-profile-skeleton.tsx
new file mode 100644
index 0000000..8fc5965
--- /dev/null
+++ b/components/user-profile-skeleton.tsx
@@ -0,0 +1,92 @@
+import { Skeleton } from "@/components/ui/skeleton";
+import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
+
+export function UserProfileSkeleton() {
+ return (
+
+ {/* Header Profile Skeleton */}
+
+
+
+
+
+
+ {/* Score Cards Skeleton */}
+
+
+
+
+
+
+
+ {/* Score Distribution Bars Skeleton */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Top Work Cards Skeleton */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/lib/leaderboard.ts b/lib/leaderboard.ts
index ee13438..22aa93e 100644
--- a/lib/leaderboard.ts
+++ b/lib/leaderboard.ts
@@ -64,52 +64,62 @@ export async function getLeaderboardResult(country: string): Promise ({
+ username: row.username,
+ name: row.name,
+ avatarUrl: row.avatar_url,
+ repoScore: row.repo_score,
+ prScore: row.pr_score,
+ contributionScore: row.contribution_score,
+ finalScore: row.final_score,
+ impactRank: 0,
+ }));
+
+ scored.sort((a, b) => b.finalScore - a.finalScore);
+ scored.forEach((user, index) => {
+ user.impactRank = index + 1;
+ });
+
+ const result: LeaderboardResult = {
+ title: country,
+ totalFromSource: totalCount,
+ scored,
+ errors: [],
+ };
- if (rows.length === 0) {
+ if (cacheStore.enabled) {
+ try {
+ const cacheKey = buildLeaderboardCacheKey(country, cacheConfig.namespace);
+ await cacheStore.set(cacheKey, result, cacheConfig.ttlSeconds);
+ } catch {
+ // Cache write failures should not block the page/API response.
+ }
+ }
+
+ return result;
+ } catch (err: unknown) {
+ console.warn("Leaderboard database query error:", err);
return {
title: country,
totalFromSource: 0,
scored: [],
- errors: [],
+ errors: [err instanceof Error ? err.message : "Database unavailable"],
};
}
-
- const scored: ScoredLeaderboardEntry[] = rows.map((row) => ({
- username: row.username,
- name: row.name,
- avatarUrl: row.avatar_url,
- repoScore: row.repo_score,
- prScore: row.pr_score,
- contributionScore: row.contribution_score,
- finalScore: row.final_score,
- impactRank: 0,
- }));
-
- scored.sort((a, b) => b.finalScore - a.finalScore);
- scored.forEach((user, index) => {
- user.impactRank = index + 1;
- });
-
- const result: LeaderboardResult = {
- title: country,
- totalFromSource: totalCount,
- scored,
- errors: [],
- };
-
- if (cacheStore.enabled) {
- try {
- const cacheKey = buildLeaderboardCacheKey(country, cacheConfig.namespace);
- await cacheStore.set(cacheKey, result, cacheConfig.ttlSeconds);
- } catch {
- // Cache write failures should not block the page/API response.
- }
- }
-
- return result;
}
diff --git a/lib/user.ts b/lib/user.ts
new file mode 100644
index 0000000..22c151d
--- /dev/null
+++ b/lib/user.ts
@@ -0,0 +1,116 @@
+import { getUserData } from "@/lib/github";
+import { calculateUserScore } from "@/lib/score";
+import { getDatabaseStore } from "@/lib/db-store";
+import { createCacheStore, getCacheConfigFromEnv } from "@/lib/cache-store";
+import { detectCountry } from "@/lib/location-detector";
+import type { UserResult } from "@/types/user-result";
+import type { GitHubUserData } from "@/types/github";
+
+export class UserFetchError extends Error {
+ readonly username: string;
+ readonly causeError: unknown;
+
+ constructor(username: string, causeError: unknown) {
+ super(`Failed to fetch GitHub data for ${username}`);
+ this.name = "UserFetchError";
+ this.username = username;
+ this.causeError = causeError;
+ }
+}
+
+export type UserProfileResponse = {
+ user: UserResult;
+ location: string | null;
+};
+
+export async function getUserProfile(
+ username: string,
+ selectedLanguages: string[] = [],
+): Promise {
+ const normalizedUsername = username.trim();
+ if (!normalizedUsername) {
+ throw new Error("Username is required");
+ }
+
+ let data: GitHubUserData;
+ try {
+ const { data: userData } = await getUserData(normalizedUsername, {
+ cacheInRedis: true,
+ withMetrics: true,
+ });
+ data = userData;
+ } catch (error: unknown) {
+ throw new UserFetchError(normalizedUsername, error);
+ }
+
+ const score = calculateUserScore(
+ {
+ ...data,
+ selectedLanguages,
+ },
+ normalizedUsername,
+ );
+
+ const user: UserResult = {
+ username: data.login,
+ 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),
+ normalizedRepoScore: Math.round(score.normalizedRepoScore),
+ normalizedPRScore: Math.round(score.normalizedPRScore),
+ normalizedContributionScore: Math.round(score.normalizedContributionScore),
+ normalizedFinalScore: Math.round(score.normalizedFinalScore),
+ topRepos: score.topRepos,
+ topPullRequests: score.topPullRequests,
+ topCommunityContributions: score.topCommunityContributions,
+ languageScores: score.languageScores,
+ signals: score.signals,
+ explanations: score.explanations,
+ scoreVersion: process.env.DEVIMPACT_VERSION || undefined,
+ };
+
+ // Fire-and-forget: detect country & upsert into DB if configured
+ const country = detectCountry(data.location);
+ if (country && process.env.DATABASE_URL?.trim()) {
+ const staleDays = parseInt(process.env.GITHUB_USER_STALE_DAYS ?? "14", 10);
+
+ try {
+ const db = getDatabaseStore();
+ db.upsertUser({
+ username: data.login,
+ name: data.name,
+ avatarUrl: data.avatarUrl,
+ location: data.location,
+ country,
+ rawData: data,
+ scores: score,
+ repoScore: Math.round(score.repoScore),
+ prScore: Math.round(score.prScore),
+ contributionScore: Math.round(score.contributionScore),
+ finalScore: Math.round(score.finalScore),
+ staleDays,
+ })
+ .then(() => {
+ const cacheConfig = getCacheConfigFromEnv();
+ const cacheStore = createCacheStore(cacheConfig);
+ if (cacheStore.enabled && cacheStore.del) {
+ const key = `${cacheConfig.namespace}:leaderboard:${country.trim().toLowerCase()}`;
+ cacheStore.del(key).catch(() => {});
+ }
+ })
+ .catch((err: unknown) => {
+ console.warn("Failed to upsert user from user profile:", err);
+ });
+ } catch {
+ // Ignore in environments without DB
+ }
+ }
+
+ return {
+ user,
+ location: data.location ?? null,
+ };
+}
diff --git a/locales/ar.json b/locales/ar.json
index c1182eb..911bfc8 100644
--- a/locales/ar.json
+++ b/locales/ar.json
@@ -250,5 +250,28 @@
"leaderboard.noCountriesSearch": "لا توجد دول تطابق بحثك.",
"leaderboard.noDevelopersFor": "لم يتم حساب قائمة المتصدرين في هذه الدولة بعد.",
"leaderboard.error.retry": "حاول مرة أخرى",
- "leaderboard.viewCountry": "عرض لوحة المتصدرين"
+ "leaderboard.viewCountry": "عرض لوحة المتصدرين",
+ "profile.backHome": "العودة إلى الرئيسية",
+ "profile.backToLeaderboard": "تصفح لوحات الصدارة",
+ "profile.compareWith": "مقارنة مع مطور آخر",
+ "profile.copied": "تم نسخ الرابط!",
+ "profile.copyLink": "مشاركة الملف الشخصي",
+ "profile.directStats": "تحليلات التأثير الفردي",
+ "profile.header.eyebrow": "الملف الشخصي للمطور",
+ "profile.loading": "جاري تحميل الملف الشخصي للمطور...",
+ "profile.location": "الموقع",
+ "profile.metaDescription": "استكشف درجة تأثير المطور مفتوح المصدر لـ {name} (@{username})، وأفضل المستودعات، وطلبات السحب المدمجة، ومساهمات المجتمع على DevImpact.",
+ "profile.metaTitle": "{name} (@{username}) - تأثير وإحصائيات المطور على GitHub",
+ "profile.methodologyCta": "تعرف على كيفية حساب درجة هذا المطور",
+ "profile.notFound.description": "لم نتمكن من العثور على بيانات GitHub للمستخدم @{username}. يرجى التحقق من اسم المستخدم والمحاولة مرة أخرى.",
+ "profile.notFound.title": "لم يتم العثور على المطور",
+ "profile.openInGithub": "فتح على GitHub",
+ "profile.scoreDistribution": "توزيع أوزان الدرجات",
+ "profile.scoreOverview": "نظرة عامة على درجة التأثير",
+ "profile.signalShare": "{pct}% من الإشارة النهائية",
+ "profile.title": "تأثير وإحصائيات المطور",
+ "profile.topWork": "أبرز الأعمال والمساهمات",
+ "profile.transparencyTitle": "إشارات شفافية التقييم",
+ "profile.viewFullStats": "عرض الملف الشخصي للمطور",
+ "profile.viewOnGithub": "عرض الملف الشخصي على GitHub"
}
diff --git a/locales/en.json b/locales/en.json
index af52421..1c68bbd 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -250,5 +250,28 @@
"leaderboard.noCountriesSearch": "No countries match your search.",
"leaderboard.noDevelopersFor": "No leaderboard has been calculated for this country yet.",
"leaderboard.error.retry": "Try again",
- "leaderboard.viewCountry": "View leaderboard"
+ "leaderboard.viewCountry": "View leaderboard",
+ "profile.backHome": "Back to Home",
+ "profile.backToLeaderboard": "Browse Leaderboards",
+ "profile.compareWith": "Compare with another developer",
+ "profile.copied": "Link Copied!",
+ "profile.copyLink": "Share Profile",
+ "profile.directStats": "Individual Impact Analytics",
+ "profile.header.eyebrow": "Developer Profile",
+ "profile.loading": "Loading developer profile...",
+ "profile.location": "Location",
+ "profile.metaDescription": "Explore {name}'s (@{username}) open-source developer impact score, top repositories, merged pull requests, and community contributions on DevImpact.",
+ "profile.metaTitle": "{name} (@{username}) - GitHub Impact & Developer Stats",
+ "profile.methodologyCta": "Learn how this developer score was calculated",
+ "profile.notFound.description": "We couldn't find GitHub data for @{username}. Please check the username and try again.",
+ "profile.notFound.title": "Developer Not Found",
+ "profile.openInGithub": "Open on GitHub",
+ "profile.scoreDistribution": "Score Weight Distribution",
+ "profile.scoreOverview": "Impact Score Overview",
+ "profile.signalShare": "{pct}% of final signal",
+ "profile.title": "Developer Impact & Statistics",
+ "profile.topWork": "Top Work & Highlights",
+ "profile.transparencyTitle": "Scoring Transparency Signals",
+ "profile.viewFullStats": "View developer profile",
+ "profile.viewOnGithub": "View GitHub Profile"
}
diff --git a/scripts/init-db.ts b/scripts/init-db.ts
index 07e22ca..d05cb71 100644
--- a/scripts/init-db.ts
+++ b/scripts/init-db.ts
@@ -8,6 +8,7 @@
* - PostgreSQL must be running (e.g. `docker compose up -d postgres`)
* - DATABASE_URL must be set in .env or environment
*/
+import "dotenv/config";
import { getDatabaseStore } from "../lib/db-store";
async function main() {
diff --git a/test/api/user.route.test.ts b/test/api/user.route.test.ts
new file mode 100644
index 0000000..f2f1adb
--- /dev/null
+++ b/test/api/user.route.test.ts
@@ -0,0 +1,212 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { GitHubApiError } from "@/lib/github-graphql-client";
+
+const mocks = vi.hoisted(() => ({
+ getUserData: vi.fn(),
+ calculateUserScore: vi.fn(),
+}));
+
+vi.mock("@/lib/github", () => ({
+ getUserData: mocks.getUserData,
+}));
+
+vi.mock("@/lib/score", () => ({
+ calculateUserScore: mocks.calculateUserScore,
+}));
+
+import { GET } from "@/app/api/user/[username]/route";
+import { getUserProfile } from "@/lib/user";
+
+function makeUser(login: string, name: string) {
+ return {
+ login,
+ name,
+ avatarUrl: `https://example.com/${login}.png`,
+ location: "Stockholm, Sweden",
+ repos: [],
+ pullRequests: [],
+ contributions: {
+ totalCommitContributions: 0,
+ totalPullRequestContributions: 0,
+ totalIssueContributions: 0,
+ },
+ issues: [],
+ discussions: [],
+ };
+}
+
+function makeScore(finalScore: number) {
+ return {
+ repoScore: 15,
+ prScore: 25,
+ contributionScore: 5,
+ finalScore,
+ normalizedRepoScore: 35,
+ normalizedPRScore: 45,
+ normalizedContributionScore: 10,
+ normalizedFinalScore: 40,
+ topRepos: [
+ {
+ name: "test-repo",
+ url: "https://github.com/torvalds/test-repo",
+ stars: 100,
+ forks: 20,
+ watchers: 100,
+ score: 15,
+ },
+ ],
+ topPullRequests: [
+ {
+ repo: "torvalds/linux",
+ stars: 500,
+ score: 25,
+ title: "Kernel patch",
+ url: "https://github.com/torvalds/linux/pull/1",
+ additions: 100,
+ deletions: 20,
+ },
+ ],
+ topCommunityContributions: [
+ {
+ type: "issue" as const,
+ title: "Test issue",
+ url: "https://github.com/torvalds/linux/issues/2",
+ repo: "torvalds/linux",
+ stars: 50,
+ comments: 10,
+ score: 5,
+ },
+ ],
+ signals: {
+ reposAnalyzed: 10,
+ pullRequestsAnalyzed: 5,
+ mergedExternalPRs: 3,
+ ownRepoPRsIgnored: 2,
+ unmergedPRsIgnored: 0,
+ uniqueExternalPRRepos: 2,
+ issuesAnalyzed: 4,
+ externalIssuesCounted: 2,
+ discussionsAnalyzed: 1,
+ externalDiscussionsCounted: 1,
+ },
+ explanations: {
+ repo: [],
+ pr: [],
+ contribution: [],
+ overall: [],
+ },
+ scoreVersion: "1.0",
+ };
+}
+
+describe("GET /api/user/[username]", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test("returns user profile result for valid username", async () => {
+ const rawUser = makeUser("torvalds", "Linus Torvalds");
+ mocks.getUserData.mockResolvedValueOnce({
+ data: rawUser,
+ metrics: { duration: 100, errors: [] },
+ });
+ mocks.calculateUserScore.mockReturnValueOnce(makeScore(40));
+
+ const request = new Request("http://localhost/api/user/torvalds");
+ const response = await GET(request, {
+ params: Promise.resolve({ username: "torvalds" }),
+ });
+
+ expect(response.status).toBe(200);
+ const body = (await response.json()) as {
+ success: boolean;
+ user: {
+ username: string;
+ name: string;
+ finalScore: number;
+ repoScore: number;
+ prScore: number;
+ contributionScore: number;
+ };
+ location: string;
+ };
+
+ expect(body.success).toBe(true);
+ expect(body.user.username).toBe("torvalds");
+ expect(body.user.name).toBe("Linus Torvalds");
+ expect(body.user.finalScore).toBe(40);
+ expect(body.location).toBe("Stockholm, Sweden");
+ });
+
+ test("returns 404 when user is not found on GitHub", async () => {
+ mocks.getUserData.mockRejectedValueOnce(
+ new GitHubApiError({
+ message: "Could not resolve to a User with the login of 'missing'.",
+ kind: "NOT_FOUND",
+ status: 200,
+ rateLimit: {},
+ }),
+ );
+
+ const request = new Request("http://localhost/api/user/missing");
+ const response = await GET(request, {
+ params: Promise.resolve({ username: "missing" }),
+ });
+
+ expect(response.status).toBe(404);
+ const body = (await response.json()) as {
+ success: boolean;
+ error: string;
+ errorDetails: { code: string };
+ };
+
+ expect(body.success).toBe(false);
+ expect(body.errorDetails.code).toBe("GITHUB_NOT_FOUND");
+ });
+
+ test("returns 429 when GitHub rate limit is hit", async () => {
+ mocks.getUserData.mockRejectedValueOnce(
+ new GitHubApiError({
+ message: "API rate limit exceeded",
+ kind: "PRIMARY_RATE_LIMIT",
+ status: 403,
+ rateLimit: { remaining: 0, retryAfterSeconds: 60 },
+ }),
+ );
+
+ const request = new Request("http://localhost/api/user/ratelimited");
+ const response = await GET(request, {
+ params: Promise.resolve({ username: "ratelimited" }),
+ });
+
+ expect(response.status).toBe(429);
+ });
+
+ test("returns 400 when username is empty", async () => {
+ const request = new Request("http://localhost/api/user/");
+ const response = await GET(request, {
+ params: Promise.resolve({ username: " " }),
+ });
+
+ expect(response.status).toBe(400);
+ const body = (await response.json()) as { success: boolean; error: string };
+ expect(body.success).toBe(false);
+ });
+
+ test("getUserProfile helper calculates score and extracts signals correctly", async () => {
+ const rawUser = makeUser("octocat", "The Octocat");
+ mocks.getUserData.mockResolvedValueOnce({
+ data: rawUser,
+ metrics: { duration: 50, errors: [] },
+ });
+ mocks.calculateUserScore.mockReturnValueOnce(makeScore(55));
+
+ const result = await getUserProfile("octocat");
+
+ expect(result.user.username).toBe("octocat");
+ expect(result.user.finalScore).toBe(55);
+ expect(result.location).toBe("Stockholm, Sweden");
+ expect(result.user.topRepos.length).toBe(1);
+ expect(result.user.signals?.reposAnalyzed).toBe(10);
+ });
+});
From 07c7bda28eaea00f02226854f8310ead7f272021 Mon Sep 17 00:00:00 2001
From: ws-rush
Date: Sat, 29 Aug 2026 10:31:16 +0300
Subject: [PATCH 2/4] fix(profile): preserve country context and improve back
navigation
- Pass ?country= parameter when navigating from country leaderboards
- Update back button to return to specific country leaderboard with fallback
- Add breadcrumb navigation (Home / Leaderboards / Country / User)
- Add Arabic and English localization for back navigation and breadcrumbs
---
.../[country]/country-leaderboard-client.tsx | 3 +
app/leaderboard/[country]/page.tsx | 6 +-
app/user/[username]/page.tsx | 81 +++++++++++-----
components/leaderboard-table.tsx | 74 ++++++++------
components/user-profile-client.tsx | 96 ++++++++++++++++---
locales/ar.json | 3 +
locales/en.json | 3 +
7 files changed, 202 insertions(+), 64 deletions(-)
diff --git a/app/leaderboard/[country]/country-leaderboard-client.tsx b/app/leaderboard/[country]/country-leaderboard-client.tsx
index fedc345..82d4bdb 100644
--- a/app/leaderboard/[country]/country-leaderboard-client.tsx
+++ b/app/leaderboard/[country]/country-leaderboard-client.tsx
@@ -12,12 +12,14 @@ import type { LeaderboardResult } from "@/lib/leaderboard";
type Props = {
countryTitle: string;
+ countrySlug?: string;
initialLeaderboard: LeaderboardResult;
initialError?: string | null;
};
export function CountryLeaderboardClient({
countryTitle,
+ countrySlug,
initialLeaderboard,
initialError = null,
}: Props) {
@@ -82,6 +84,7 @@ export function CountryLeaderboardClient({
users={scored}
failedUsers={errors}
title={title}
+ countrySlug={countrySlug}
totalFromSource={totalFromSource}
usersProcessed={scored.length}
/>
diff --git a/app/leaderboard/[country]/page.tsx b/app/leaderboard/[country]/page.tsx
index 7b7f670..a9f60ea 100644
--- a/app/leaderboard/[country]/page.tsx
+++ b/app/leaderboard/[country]/page.tsx
@@ -168,7 +168,11 @@ export default async function CountryLeaderboardPage({ params }: Props) {
: [webPageSchema, breadcrumbSchema]
}
/>
-
+
>
);
}
diff --git a/app/user/[username]/page.tsx b/app/user/[username]/page.tsx
index 7e1b69a..ae2303c 100644
--- a/app/user/[username]/page.tsx
+++ b/app/user/[username]/page.tsx
@@ -7,8 +7,19 @@ import { AppFooter } from "@/components/app-footer";
import { getUserProfile } from "@/lib/user";
import { toAbsoluteUrl } from "@/lib/seo";
+import countriesData from "@/data/countries.json";
+import { detectCountry } from "@/lib/location-detector";
+
+type CountryInfo = {
+ slug: string;
+ title: string;
+};
+
+const countries = countriesData as CountryInfo[];
+
type Props = {
params: Promise<{ username: string }>;
+ searchParams?: Promise<{ country?: string }>;
};
export async function generateMetadata({ params }: Props): Promise {
@@ -74,11 +85,14 @@ export async function generateMetadata({ params }: Props): Promise {
};
}
-export default async function UserProfilePage({ params }: Props) {
+export default async function UserProfilePage({ params, searchParams }: Props) {
const { username } = await params;
const cleanUsername = decodeURIComponent(username.trim());
const profileUrl = toAbsoluteUrl(`/user/${cleanUsername}`);
+ const resolvedSearchParams = searchParams ? await searchParams : undefined;
+ const countryParam = resolvedSearchParams?.country;
+
let profileData: Awaited> | null = null;
let fetchErrorMessage: string | null = null;
@@ -131,29 +145,52 @@ export default async function UserProfilePage({ params }: Props) {
},
};
+ const detectedSlug = (countryParam || detectCountry(location))?.trim().toLowerCase();
+ const countryInfo = detectedSlug
+ ? countries.find((c) => c.slug.toLowerCase() === detectedSlug)
+ : null;
+
+ const breadcrumbElements = [
+ {
+ "@type": "ListItem",
+ position: 1,
+ name: "Home",
+ item: toAbsoluteUrl("/"),
+ },
+ {
+ "@type": "ListItem",
+ position: 2,
+ name: "Leaderboards",
+ item: toAbsoluteUrl("/leaderboard"),
+ },
+ ];
+
+ if (countryInfo) {
+ breadcrumbElements.push({
+ "@type": "ListItem",
+ position: 3,
+ name: countryInfo.title,
+ item: toAbsoluteUrl(`/leaderboard/${countryInfo.slug}`),
+ });
+ breadcrumbElements.push({
+ "@type": "ListItem",
+ position: 4,
+ name: displayName,
+ item: profileUrl,
+ });
+ } else {
+ breadcrumbElements.push({
+ "@type": "ListItem",
+ position: 3,
+ name: displayName,
+ item: profileUrl,
+ });
+ }
+
const breadcrumbSchema = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
- itemListElement: [
- {
- "@type": "ListItem",
- position: 1,
- name: "Home",
- item: toAbsoluteUrl("/"),
- },
- {
- "@type": "ListItem",
- position: 2,
- name: "Leaderboards",
- item: toAbsoluteUrl("/leaderboard"),
- },
- {
- "@type": "ListItem",
- position: 3,
- name: displayName,
- item: profileUrl,
- },
- ],
+ itemListElement: breadcrumbElements,
};
return (
@@ -163,7 +200,7 @@ export default async function UserProfilePage({ params }: Props) {
-
+
diff --git a/components/leaderboard-table.tsx b/components/leaderboard-table.tsx
index 631af93..50cf2ba 100644
--- a/components/leaderboard-table.tsx
+++ b/components/leaderboard-table.tsx
@@ -26,6 +26,7 @@ type Props = {
users: LeaderboardEntry[];
failedUsers: string[];
title: string;
+ countrySlug?: string;
totalFromSource: number;
usersProcessed: number;
};
@@ -38,6 +39,7 @@ export function LeaderboardTable({
users,
failedUsers,
title,
+ countrySlug,
totalFromSource,
usersProcessed,
}: Props) {
@@ -147,38 +149,50 @@ export function LeaderboardTable({
-
-
-
-
-
-
-
- {user.name || user.username}
+ {(() => {
+ const profileUrl = (
+ countrySlug
+ ? `/user/${user.username}?country=${encodeURIComponent(countrySlug)}`
+ : `/user/${user.username}`
+ ) as Route;
+
+ return (
+
+
+
-
-
-
+
+
+
+ {user.name || user.username}
+
+
+
+
+
+
+ @{user.username}
+
+
- @{user.username}
-
-
+ );
+ })()}
|
{user.finalScore}
diff --git a/components/user-profile-client.tsx b/components/user-profile-client.tsx
index bf8950e..0174c9a 100644
--- a/components/user-profile-client.tsx
+++ b/components/user-profile-client.tsx
@@ -2,6 +2,7 @@
import { useState } from "react";
import Link from "next/link";
+import { useSearchParams } from "next/navigation";
import type { Route } from "next";
import {
ArrowLeft,
@@ -25,11 +26,20 @@ import { Progress } from "@/components/ui/progress";
import { useTranslation } from "@/components/language-provider";
import { getCountryCode } from "@/lib/country-flags";
import { detectCountry } from "@/lib/location-detector";
+import countriesData from "@/data/countries.json";
import type { UserResult } from "@/types/user-result";
+type CountryInfo = {
+ slug: string;
+ title: string;
+};
+
+const countries = countriesData as CountryInfo[];
+
type Props = {
user: UserResult;
location?: string | null;
+ countryParam?: string | null;
};
type LanguageEntry = {
@@ -95,17 +105,35 @@ function LanguageBreakdown({ topLanguages }: { topLanguages?: LanguageEntry[] })
);
}
-export function UserProfileClient({ user, location }: Props) {
+export function UserProfileClient({ user, location, countryParam }: Props) {
const { t } = useTranslation();
+ const searchParams = useSearchParams();
const [copied, setCopied] = useState(false);
const displayName = user.name?.trim() || user.username;
const githubUrl = `https://github.com/${user.username}`;
const compareUrl = `/?user1=${encodeURIComponent(user.username)}`;
- // Location & Country flag detection
+ // Location & Country detection
const detectedSlug = detectCountry(location ?? null);
- const flagCode = detectedSlug ? getCountryCode(detectedSlug) : null;
+ const rawCountry = (countryParam || searchParams.get("country") || detectedSlug || "")
+ .trim()
+ .toLowerCase();
+ const activeCountrySlug = rawCountry ? rawCountry.replace(/[^a-z0-9_-]/g, "") : null;
+
+ const activeCountryInfo = activeCountrySlug
+ ? (countries.find((c) => c.slug.toLowerCase() === activeCountrySlug) ?? {
+ slug: activeCountrySlug,
+ title: activeCountrySlug
+ .split("_")
+ .filter(Boolean)
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
+ .join(" "),
+ })
+ : null;
+
+ const flagSlug = activeCountryInfo?.slug || detectedSlug;
+ const flagCode = flagSlug ? getCountryCode(flagSlug) : null;
const handleCopyLink = async () => {
try {
@@ -170,14 +198,60 @@ export function UserProfileClient({ user, location }: Props) {
return (
- {/* ── Back Navigation ────────────────────────────────────────── */}
-
-
-
-
+ {/* ── Breadcrumb & Back Navigation ────────────────────────────── */}
+
+
+ {activeCountryInfo ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* Breadcrumbs */}
+
{/* ── Header Profile Hero Section ────────────────────────────── */}
diff --git a/locales/ar.json b/locales/ar.json
index 911bfc8..ea82b76 100644
--- a/locales/ar.json
+++ b/locales/ar.json
@@ -252,7 +252,10 @@
"leaderboard.error.retry": "حاول مرة أخرى",
"leaderboard.viewCountry": "عرض لوحة المتصدرين",
"profile.backHome": "العودة إلى الرئيسية",
+ "profile.backToCountry": "العودة إلى لوحة صدارة {country}",
"profile.backToLeaderboard": "تصفح لوحات الصدارة",
+ "profile.breadcrumbs.home": "الرئيسية",
+ "profile.breadcrumbs.leaderboards": "لوحات الصدارة",
"profile.compareWith": "مقارنة مع مطور آخر",
"profile.copied": "تم نسخ الرابط!",
"profile.copyLink": "مشاركة الملف الشخصي",
diff --git a/locales/en.json b/locales/en.json
index 1c68bbd..1a6da07 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -252,7 +252,10 @@
"leaderboard.error.retry": "Try again",
"leaderboard.viewCountry": "View leaderboard",
"profile.backHome": "Back to Home",
+ "profile.backToCountry": "Back to {country} leaderboard",
"profile.backToLeaderboard": "Browse Leaderboards",
+ "profile.breadcrumbs.home": "Home",
+ "profile.breadcrumbs.leaderboards": "Leaderboards",
"profile.compareWith": "Compare with another developer",
"profile.copied": "Link Copied!",
"profile.copyLink": "Share Profile",
From 71dcff53d0df1e0d6ff3bc257ee2beca88ab322e Mon Sep 17 00:00:00 2001
From: ws-rush
Date: Sat, 29 Aug 2026 10:33:23 +0300
Subject: [PATCH 3/4] fix(profile): make compare and share buttons side by side
on mobile
---
components/user-profile-client.tsx | 20 ++++++++++----------
1 file changed, 10 insertions(+), 10 deletions(-)
diff --git a/components/user-profile-client.tsx b/components/user-profile-client.tsx
index 0174c9a..0536858 100644
--- a/components/user-profile-client.tsx
+++ b/components/user-profile-client.tsx
@@ -312,11 +312,11 @@ export function UserProfileClient({ user, location, countryParam }: Props) {
{/* Actions */}
-
-
- |