Skip to content
68 changes: 26 additions & 42 deletions e2e/dashboard-widgets.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,36 @@ test.beforeEach(async ({ page }) => {
{
name: "next-auth.session-token",
value: sessionToken,
domain: "127.0.0.1",
path: "/",
httpOnly: true,
sameSite: "Lax",
secure: false,
expires: Math.floor(Date.now() / 1000) + 60 * 60,
url: "http://127.0.0.1:3000",
},
]);

await page.route("**/api/auth/session", async (route) => {
await page.route("**/api/goals", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
goals: [
{
id: "goal-1",
title: "Make 10 commits",
target: 10,
current: 4,
unit: "commits",
recurrence: "weekly",
period_start: "2026-05-18",
last_synced_at: null,
},
],
}),
});
});

await page.route("**/api/goals/sync", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
user: { name: "Playwright User", email: "playwright@example.com" },
githubLogin: "playwright-user",
githubId: "12345",
accessToken: "test-token",
expires: "2099-01-01T00:00:00.000Z",
synced: true,
updated: 1,
}),
});
});
Expand All @@ -65,34 +77,6 @@ test.beforeEach(async ({ page }) => {
});
});

await page.route("**/api/goals", async (route) => {
if (route.request().method() === "POST") {
await route.fulfill({
contentType: "application/json",
status: 201,
body: JSON.stringify({ ok: true }),
});
return;
}

await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
goals: [
{
id: "goal-1",
title: "Make 10 commits",
target: 10,
current: 4,
unit: "commits",
recurrence: "weekly",
period_start: "2026-05-18",
},
],
}),
});
});

const metricRoutes = [
"**/api/metrics/prs**",
"**/api/metrics/pr-breakdown**",
Expand Down Expand Up @@ -122,10 +106,10 @@ test.beforeEach(async ({ page }) => {
test("dashboard widgets render with mocked metrics", async ({ page }) => {
await page.goto("/dashboard", { waitUntil: "load" });
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible({ timeout: 30000 });
await page.waitForLoadState("networkidle");
await expect(page.getByRole("heading", { name: "Your Commits" })).toBeVisible({ timeout: 10000 });
await expect(page.getByRole("heading", { name: "PR Analytics" })).toBeVisible({ timeout: 10000 });
await expect(page.getByRole("heading", { name: "Goals" })).toBeVisible({ timeout: 10000 });
await expect(page.getByText("Make 10 commits")).toBeVisible({ timeout: 10000 });
await expect(page.getByText("Make 10 commits", { exact: true })).toBeVisible({ timeout: 20000 });
});

test("contribution graph range buttons request a new range", async ({ page }) => {
Expand Down
205 changes: 201 additions & 4 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,51 @@ import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { redirect } from "next/navigation";

interface Contributor {
login: string;
avatar_url: string;
html_url: string;
contributions: number;
}

async function fetchContributors(): Promise<Contributor[]> {
try {
const token = process.env.GITHUB_TOKEN;
const headers: Record<string, string> = {
"User-Agent": "DevTrack-App",
Accept: "application/vnd.github+json",
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}

const res = await fetch(
"https://api.github.com/repos/Priyanshu-byte-coder/devtrack/contributors",
{
headers,
next: { revalidate: 3600 },
}
);
if (!res.ok) {
console.error(`GitHub API error: ${res.status}`);
return [];
}
const data = await res.json();
if (Array.isArray(data)) {
return data.map((item: any) => ({
login: item.login,
avatar_url: item.avatar_url,
html_url: item.html_url,
contributions: item.contributions,
}));
}
return [];
} catch (error) {
console.error("Failed to fetch contributors from GitHub:", error);
return [];
}
}

export default async function HomePage() {
const session = await getServerSession(authOptions);

Expand Down Expand Up @@ -34,11 +79,18 @@ export default async function HomePage() {
},
];

const contributors = await fetchContributors();
const sortedContributors = [...contributors].sort((a, b) => b.contributions - a.contributions);
const top3 = sortedContributors.slice(0, 3);
const rest = sortedContributors.slice(3);
const firstPlace = top3[0];
const secondPlace = top3[1];
const thirdPlace = top3[2];

return (
<main className="min-h-screen flex flex-col items-center px-4 py-24 bg-[var(--background)]">
{/* Hero Section */}
<div className="max-w-3xl text-center">
<h1 className="text-6xl font-extrabold mb-6 text-[var(--foreground)] tracking-tight drop-shadow-sm">
<main className="min-h-screen flex flex-col items-center px-4 py-20">
<div className="max-w-2xl text-center fade-up">
<h1 className="text-5xl md:text-6xl font-bold mb-4 text-[var(--foreground)]">
DevTrack
</h1>

Expand Down Expand Up @@ -97,6 +149,151 @@ export default async function HomePage() {
))}
</div>
</section>

{contributors.length > 0 && (
<section className="w-full max-w-6xl mt-28 mb-16 fade-up">
<h2 className="text-3xl font-bold text-center text-[var(--foreground)] mb-3">
Top Contributors
</h2>
<p className="text-center text-[var(--muted-foreground)] text-sm max-w-lg mx-auto mb-12">
Meet the developers who are actively shaping DevTrack. Thank you for making our open-source productivity platform grow!
</p>

{/* Podium for top 3 */}
<div className="flex flex-row items-end justify-center gap-2 sm:gap-6 mt-12 mb-16 w-full max-w-4xl mx-auto px-2">
{/* 2nd Place */}
{secondPlace && (
<div className="flex flex-col items-center bg-[var(--card)] border border-[var(--border)] rounded-2xl p-4 sm:p-5 shadow-sm w-[30%] max-w-[190px] h-52 sm:h-60 flex-shrink-0 transition duration-300 hover:scale-[1.03] hover:shadow-md">
<div className="relative mb-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={secondPlace.avatar_url}
alt={secondPlace.login}
className="w-14 h-14 sm:w-18 sm:h-18 rounded-full border-4 border-[var(--border)] object-cover shadow-sm"
/>
<div className="absolute -bottom-2 left-1/2 -translate-x-1/2 bg-[var(--control-hover)] text-[var(--foreground)] text-[10px] sm:text-xs font-extrabold px-2 py-0.5 rounded-full shadow-sm border border-[var(--border)]">
2nd
</div>
</div>
<div className="text-center mt-2 w-full font-bold">
<a
href={secondPlace.html_url}
target="_blank"
rel="noopener noreferrer"
className="block font-bold text-xs sm:text-sm text-[var(--foreground)] hover:text-[var(--accent)] hover:underline truncate"
>
@{secondPlace.login}
</a>
<span className="inline-block mt-2 px-2 py-0.5 rounded-full bg-[var(--control)] text-[var(--muted-foreground)] text-[10px] sm:text-xs font-semibold">
{secondPlace.contributions} commits
</span>
</div>
</div>
)}

{/* 1st Place */}
{firstPlace && (
<div className="flex flex-col items-center bg-[var(--card)] border-2 border-[var(--warning)] rounded-2xl p-5 sm:p-6 shadow-[0_8px_30px_rgb(0,0,0,0.12)] dark:shadow-[0_8px_30px_rgba(245,158,11,0.15)] w-[36%] max-w-[220px] h-60 sm:h-72 flex-shrink-0 relative transition duration-300 hover:scale-[1.03] z-10">
<div className="absolute -top-7 text-3xl sm:text-4xl animate-bounce" style={{ animationDuration: '3s' }}>👑</div>
<div className="relative mb-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={firstPlace.avatar_url}
alt={firstPlace.login}
className="w-16 h-16 sm:w-22 sm:h-22 rounded-full border-4 border-[var(--warning)] object-cover shadow-md"
/>
<div className="absolute -bottom-2 left-1/2 -translate-x-1/2 bg-[var(--warning)] text-[var(--background)] text-[10px] sm:text-xs font-extrabold px-2.5 py-0.5 rounded-full shadow-sm">
1st
</div>
</div>
<div className="text-center mt-3 w-full font-bold">
<a
href={firstPlace.html_url}
target="_blank"
rel="noopener noreferrer"
className="block font-bold text-xs sm:text-base text-[var(--foreground)] hover:text-[var(--accent)] hover:underline truncate"
>
@{firstPlace.login}
</a>
<span className="inline-block mt-2 px-2.5 py-0.5 rounded-full bg-[var(--accent-soft)] text-[var(--accent)] text-[10px] sm:text-xs font-semibold">
{firstPlace.contributions} commits
</span>
</div>
</div>
)}

{/* 3rd Place */}
{thirdPlace && (
<div className="flex flex-col items-center bg-[var(--card)] border border-[var(--border)] rounded-2xl p-3 sm:p-4 shadow-sm w-[26%] max-w-[170px] h-44 sm:h-52 flex-shrink-0 transition duration-300 hover:scale-[1.03] hover:shadow-md">
<div className="relative mb-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={thirdPlace.avatar_url}
alt={thirdPlace.login}
className="w-12 h-12 sm:w-14 sm:h-14 rounded-full border-4 border-[var(--border)] object-cover shadow-sm"
/>
<div className="absolute -bottom-2 left-1/2 -translate-x-1/2 bg-[var(--border)] text-[var(--muted-foreground)] text-[10px] sm:text-xs font-extrabold px-2 py-0.5 rounded-full shadow-sm">
3rd
</div>
</div>
<div className="text-center mt-1 w-full font-bold">
<a
href={thirdPlace.html_url}
target="_blank"
rel="noopener noreferrer"
className="block font-bold text-[10px] sm:text-xs text-[var(--foreground)] hover:text-[var(--accent)] hover:underline truncate"
>
@{thirdPlace.login}
</a>
<span className="inline-block mt-2 px-1.5 py-0.5 rounded-full bg-[var(--control)] text-[var(--muted-foreground)] text-[9px] sm:text-xs font-semibold">
{thirdPlace.contributions} commits
</span>
</div>
</div>
)}
</div>

{/* Rest of Contributors Grid */}
{rest.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mt-8 w-full">
{rest.map((contrib, index) => {
const rank = index + 4;
return (
<div
key={contrib.login}
className="flex items-center gap-3 p-4 rounded-xl border border-[var(--border)] bg-[var(--card)] hover:border-[var(--muted-foreground)] hover:scale-[1.02] transition duration-200"
>
<span className="text-xs font-extrabold text-[var(--muted-foreground)] bg-[var(--control)] px-2 py-1 rounded border border-[var(--border)] min-w-[32px] text-center">
#{rank}
</span>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={contrib.avatar_url}
alt={contrib.login}
className="w-10 h-10 rounded-full border border-[var(--border)] object-cover"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<a
href={contrib.html_url}
target="_blank"
rel="noopener noreferrer"
className="block font-semibold text-sm text-[var(--foreground)] hover:text-[var(--accent)] hover:underline truncate"
>
@{contrib.login}
</a>
</div>
<p className="text-xs text-[var(--muted-foreground)]">
{contrib.contributions} commits
</p>
</div>
</div>
);
})}
</div>
)}
</section>
)}
</main>
);
}
Loading