Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions e2e/dashboard-widgets.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ test.beforeEach(async ({ page }) => {
});
});

await page.route("**/api/goals/sync", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({ ok: true }),
});
});

await page.route("**/api/metrics/contributions**", async (route) => {
const url = new URL(route.request().url());
const days = Number(url.searchParams.get("days") ?? 30);
Expand Down Expand Up @@ -88,6 +95,7 @@ test.beforeEach(async ({ page }) => {
unit: "commits",
recurrence: "weekly",
period_start: "2026-05-18",
last_synced_at: new Date().toISOString(),
},
],
}),
Expand Down
13 changes: 1 addition & 12 deletions e2e/landing.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,9 @@ test("dashboard stays protected for unauthenticated users", async ({ page }) =>
await expect(page.getByRole("link", { name: "Sign in with GitHub" }).first()).toBeVisible();
});

test("landing has dashboard link", async ({ page }) => {
await page.goto("/");

await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible();
});

test("landing shows footer", async ({ page }) => {
await page.goto("/");

await expect(page.getByRole("contentinfo")).toBeVisible();
});

test("landing has dashboard link", async ({ page }) => {
await page.goto("/");

await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible();
await expect(page.getByRole("contentinfo").first()).toBeVisible();
});
22 changes: 6 additions & 16 deletions src/app/api/metrics/issues/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,7 @@ import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { authOptions } from "@/lib/auth";
import { fetchIssuesMetrics } from "@/lib/github";
import {
isMetricsCacheBypassed,
METRICS_CACHE_TTL_SECONDS,
metricsCacheKey,
withMetricsCache,
} from "@/lib/metrics-cache";
import { isMetricsCacheBypassed } from "@/lib/metrics-cache";

export const dynamic = "force-dynamic";

Expand All @@ -17,19 +12,14 @@ export async function GET(req: NextRequest) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

// 1. Check if the user is forcing a refresh
const bypass = isMetricsCacheBypassed(req);

// 2. Generate a unique cache key for this user's issues
const key = metricsCacheKey(session.githubId ?? session.githubLogin, "issues");
const userId = session.githubId ?? session.githubLogin;

try {
// 3. Wrap the GitHub fetch in our bulletproof cache!
const metrics = await withMetricsCache(
{ bypass, key, ttlSeconds: METRICS_CACHE_TTL_SECONDS.issues },
() => fetchIssuesMetrics(session.accessToken!)
);

const metrics = await fetchIssuesMetrics(session.accessToken, {
bypass,
userId,
});
return Response.json(metrics);
} catch {
return Response.json({ error: "GitHub API error" }, { status: 502 });
Expand Down
107 changes: 69 additions & 38 deletions src/app/api/metrics/languages/route.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,86 @@
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { authOptions } from "@/lib/auth";
import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache } from "@/lib/metrics-cache";
import {
isMetricsCacheBypassed,
METRICS_CACHE_TTL_SECONDS,
metricsCacheKey,
withMetricsCache,
} from "@/lib/metrics-cache";

export const dynamic = "force-dynamic";
const GITHUB_API = "https://api.github.com";

interface RepoItem {
repository: { full_name: string };
}

export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.accessToken || !session.githubLogin) return Response.json({ error: "Unauthorized" }, { status: 401 });
if (!session?.accessToken || !session.githubLogin) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const bypass = isMetricsCacheBypassed(req);
const key = metricsCacheKey(session.githubId ?? session.githubLogin, "languages" as any);
const key = metricsCacheKey(session.githubId ?? session.githubLogin, "languages");

try {
const data = await withMetricsCache({ bypass, key, ttlSeconds: 10 * 60 }, async () => {
const headers = { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" };
const since = new Date();
since.setDate(since.getDate() - 90);

const searchRes = await fetch(
`${GITHUB_API}/search/commits?q=author:${session.githubLogin}+author-date:>=${since.toISOString().slice(0, 10)}&per_page=100&sort=author-date&order=desc`,
{ headers, cache: "no-store" }
);
if (!searchRes.ok) throw new Error("API Error");

const raw = await searchRes.json();
const repoNames = Array.from(new Set<string>(raw.items.map((i: any) => i.repository.full_name)));
const langTotals: Record<string, number> = {};

await Promise.all(
repoNames.map(async (repoName) => {
try {
const res = await fetch(`${GITHUB_API}/repos/${repoName}/languages`, { headers, cache: "no-store" });
if (!res.ok) return;
const langs = await res.json();
for (const [lang, bytes] of Object.entries(langs)) {
langTotals[lang] = (langTotals[lang] ?? 0) + (bytes as number);
const data = await withMetricsCache(
{ bypass, key, ttlSeconds: METRICS_CACHE_TTL_SECONDS.languages },
async () => {
const headers = {
Authorization: `Bearer ${session.accessToken}`,
Accept: "application/vnd.github+json",
};
const since = new Date();
since.setDate(since.getDate() - 90);

const searchRes = await fetch(
`${GITHUB_API}/search/commits?q=author:${session.githubLogin}+author-date:>=${since.toISOString().slice(0, 10)}&per_page=100&sort=author-date&order=desc`,
{ headers, cache: "no-store" }
);
if (!searchRes.ok) {
throw new Error("GitHub API error");
}

const raw = (await searchRes.json()) as { items: RepoItem[] };
const repoNames = Array.from(new Set(raw.items.map((item) => item.repository.full_name)));
const langTotals: Record<string, number> = {};

await Promise.all(
repoNames.map(async (repoName) => {
try {
const res = await fetch(`${GITHUB_API}/repos/${repoName}/languages`, {
headers,
cache: "no-store",
});
if (!res.ok) {
return;
}
const langs = (await res.json()) as Record<string, number>;
for (const [lang, bytes] of Object.entries(langs)) {
langTotals[lang] = (langTotals[lang] ?? 0) + bytes;
}
} catch {
// Skip repos that fail.
}
} catch {}
})
);

const totalBytes = Object.values(langTotals).reduce((s, b) => s + b, 0);
const languages = Object.entries(langTotals)
.map(([name, bytes]) => ({ name, bytes, percentage: totalBytes > 0 ? Math.round((bytes / totalBytes) * 1000) / 10 : 0 }))
.sort((a, b) => b.percentage - a.percentage)
.slice(0, 6);

return { languages };
});
})
);

const totalBytes = Object.values(langTotals).reduce((sum, bytes) => sum + bytes, 0);
const languages = Object.entries(langTotals)
.map(([name, bytes]) => ({
name,
bytes,
percentage: totalBytes > 0 ? Math.round((bytes / totalBytes) * 1000) / 10 : 0,
}))
.sort((a, b) => b.percentage - a.percentage)
.slice(0, 6);

return { languages };
}
);

return Response.json(data);
} catch {
return Response.json({ error: "GitHub API error" }, { status: 502 });
Expand Down
63 changes: 41 additions & 22 deletions src/app/api/metrics/pinned-repos/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextRequest } from "next/server";
import {
isMetricsCacheBypassed,
METRICS_CACHE_TTL_SECONDS,
metricsCacheKey,
withMetricsCache,
} from "@/lib/metrics-cache";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -34,42 +41,54 @@ const PINNED_REPOS_QUERY = `
}
`;

export async function GET() {
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.accessToken) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const bypass = isMetricsCacheBypassed(req);
const key = metricsCacheKey(session.githubId ?? session.githubLogin ?? "primary", "pinnedRepos");

try {
const response = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.accessToken}`,
const pinnedRepos = await withMetricsCache(
{
bypass,
key,
ttlSeconds: METRICS_CACHE_TTL_SECONDS.pinnedRepos,
},
body: JSON.stringify({ query: PINNED_REPOS_QUERY }),
cache: "no-store",
});
async () => {
const response = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.accessToken}`,
},
body: JSON.stringify({ query: PINNED_REPOS_QUERY }),
cache: "no-store",
});

if (!response.ok) {
return Response.json({ error: "GitHub API error" }, { status: 502 });
}
if (!response.ok) {
throw new Error("GitHub API error");
}

const data = (await response.json()) as {
data?: {
viewer?: {
pinnedItems?: {
nodes?: Array<PinnedRepo | null | undefined>;
const data = (await response.json()) as {
data?: {
viewer?: {
pinnedItems?: {
nodes?: Array<PinnedRepo | null | undefined>;
};
};
};
};
};
};

const nodes = (data.data?.viewer?.pinnedItems?.nodes ?? []).filter(
(node): node is PinnedRepo => node != null
return (data.data?.viewer?.pinnedItems?.nodes ?? []).filter(
(node): node is PinnedRepo => node != null
);
}
);

return Response.json({ pinnedRepos: nodes });
return Response.json({ pinnedRepos });
} catch {
return Response.json({ error: "GitHub API error" }, { status: 502 });
}
Expand Down
71 changes: 50 additions & 21 deletions src/app/api/metrics/pr-breakdown/route.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,68 @@
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { authOptions } from "@/lib/auth";
import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache } from "@/lib/metrics-cache";
import {
isMetricsCacheBypassed,
METRICS_CACHE_TTL_SECONDS,
metricsCacheKey,
withMetricsCache,
} from "@/lib/metrics-cache";

export const dynamic = "force-dynamic";
const GITHUB_API = "https://api.github.com";

interface PRItem { state: string; draft?: boolean; pull_request?: { merged_at: string | null; }; }
interface PRItem {
state: string;
draft?: boolean;
pull_request?: { merged_at: string | null };
}

export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.accessToken) return Response.json({ error: "Unauthorized" }, { status: 401 });
if (!session?.accessToken) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const bypass = isMetricsCacheBypassed(req);
const key = metricsCacheKey(session.githubId ?? "unknown", "pr-breakdown" as any);
const key = metricsCacheKey(session.githubId ?? session.githubLogin ?? "primary", "prBreakdown");

try {
const data = await withMetricsCache({ bypass, key, ttlSeconds: 10 * 60 }, async () => {
const res = await fetch(`${GITHUB_API}/search/issues?q=type:pr+author:@me&per_page=100`, {
headers: { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" },
cache: "no-store",
});
if (!res.ok) throw new Error("API Error");

const raw = (await res.json()) as { items: PRItem[] };
let draft = 0, open = 0, merged = 0, closed = 0;

for (const pr of raw.items) {
if (pr.state === "open" && pr.draft) draft++;
else if (pr.state === "open") open++;
else if (pr.pull_request?.merged_at) merged++;
else closed++;
const data = await withMetricsCache(
{ bypass, key, ttlSeconds: METRICS_CACHE_TTL_SECONDS.prBreakdown },
async () => {
const res = await fetch(`${GITHUB_API}/search/issues?q=type:pr+author:@me&per_page=100`, {
headers: {
Authorization: `Bearer ${session.accessToken}`,
Accept: "application/vnd.github+json",
},
cache: "no-store",
});
if (!res.ok) {
throw new Error("GitHub API error");
}

const raw = (await res.json()) as { items: PRItem[] };
let draft = 0;
let open = 0;
let merged = 0;
let closed = 0;

for (const pr of raw.items) {
if (pr.state === "open" && pr.draft) {
draft++;
} else if (pr.state === "open") {
open++;
} else if (pr.pull_request?.merged_at) {
merged++;
} else {
closed++;
}
}

return { draft, open, merged, closed };
}
return { draft, open, merged, closed };
});
);

return Response.json(data);
} catch {
return Response.json({ error: "GitHub API error" }, { status: 502 });
Expand Down
Loading
Loading