diff --git a/e2e/dashboard-widgets.spec.js b/e2e/dashboard-widgets.spec.js index 0ed5886a..67363bd1 100644 --- a/e2e/dashboard-widgets.spec.js +++ b/e2e/dashboard-widgets.spec.js @@ -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); @@ -88,6 +95,7 @@ test.beforeEach(async ({ page }) => { unit: "commits", recurrence: "weekly", period_start: "2026-05-18", + last_synced_at: new Date().toISOString(), }, ], }), diff --git a/e2e/landing.spec.js b/e2e/landing.spec.js index b2be7233..57a7e6f8 100644 --- a/e2e/landing.spec.js +++ b/e2e/landing.spec.js @@ -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(); }); diff --git a/src/app/api/metrics/issues/route.ts b/src/app/api/metrics/issues/route.ts index cc6441a6..9673d442 100644 --- a/src/app/api/metrics/issues/route.ts +++ b/src/app/api/metrics/issues/route.ts @@ -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"; @@ -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 }); diff --git a/src/app/api/metrics/languages/route.ts b/src/app/api/metrics/languages/route.ts index 53b6e3b0..56bcedb9 100644 --- a/src/app/api/metrics/languages/route.ts +++ b/src/app/api/metrics/languages/route.ts @@ -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(raw.items.map((i: any) => i.repository.full_name))); - const langTotals: Record = {}; - - 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 = {}; + + 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; + 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 }); diff --git a/src/app/api/metrics/pinned-repos/route.ts b/src/app/api/metrics/pinned-repos/route.ts index a7de84ef..73e4cf3b 100644 --- a/src/app/api/metrics/pinned-repos/route.ts +++ b/src/app/api/metrics/pinned-repos/route.ts @@ -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"; @@ -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; + const data = (await response.json()) as { + data?: { + viewer?: { + pinnedItems?: { + nodes?: Array; + }; + }; }; }; - }; - }; - 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 }); } diff --git a/src/app/api/metrics/pr-breakdown/route.ts b/src/app/api/metrics/pr-breakdown/route.ts index 6c919b05..20e59988 100644 --- a/src/app/api/metrics/pr-breakdown/route.ts +++ b/src/app/api/metrics/pr-breakdown/route.ts @@ -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 }); diff --git a/src/app/api/metrics/repo-health/route.ts b/src/app/api/metrics/repo-health/route.ts index 5a8ee0f6..7dd27988 100644 --- a/src/app/api/metrics/repo-health/route.ts +++ b/src/app/api/metrics/repo-health/route.ts @@ -2,84 +2,174 @@ import { getServerSession } from "next-auth"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { computeHealthScore } from "@/lib/repo-health"; -import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache } from "@/lib/metrics-cache"; +import { + isMetricsCacheBypassed, + METRICS_CACHE_TTL_SECONDS, + metricsCacheKey, + withMetricsCache, +} from "@/lib/metrics-cache"; import type { RepoHealthResponse, RepoHealthSignals, RepoHealthScore } from "@/types/repo-health"; export const dynamic = "force-dynamic"; const GITHUB_API = "https://api.github.com"; -interface RepoSummary { name: string; commits: number; url: string; } -interface RepoListResponse { repos: RepoSummary[]; days: number; } +interface RepoSummary { + name: string; + commits: number; + url: string; +} + +interface RepoListResponse { + repos: RepoSummary[]; + days: number; +} -async function fetchReposForAccount(token: string, githubLogin: string, days: number): Promise { +async function fetchReposForAccount( + token: string, + githubLogin: string, + days: number +): Promise { const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10); - const searchRes = await fetch(`${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${since}&per_page=100&sort=author-date&order=desc`, { headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json" }, cache: "no-store" }); - if (!searchRes.ok) throw new Error("API error"); - const data = await searchRes.json(); + const searchRes = await fetch( + `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${since}&per_page=100&sort=author-date&order=desc`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }, + cache: "no-store", + } + ); + if (!searchRes.ok) { + throw new Error("GitHub API error"); + } + + const data = (await searchRes.json()) as { + items: Array<{ repository: { full_name: string; html_url: string } }>; + }; const repoMap: Record = {}; + for (const item of data.items) { const name = item.repository.full_name; - if (!repoMap[name]) repoMap[name] = { commits: 0, url: item.repository.html_url }; + if (!repoMap[name]) { + repoMap[name] = { commits: 0, url: item.repository.html_url }; + } repoMap[name].commits++; } - return { repos: Object.entries(repoMap).map(([name, info]) => ({ name, ...info })).sort((a, b) => b.commits - a.commits).slice(0, 6), days }; + + const repos = Object.entries(repoMap) + .map(([name, info]) => ({ name, ...info })) + .sort((a, b) => b.commits - a.commits) + .slice(0, 6); + + return { repos, days }; +} + +function hoursBetween(a: string, b: string): number { + return (new Date(b).getTime() - new Date(a).getTime()) / 3600000; } -function hoursBetween(a: string, b: string): number { return (new Date(b).getTime() - new Date(a).getTime()) / 3600000; } -function daysSince(isoDate: string): number { return Math.max(0, Math.floor((Date.now() - new Date(isoDate).getTime()) / 86400000)); } +function daysSince(isoDate: string): number { + return Math.max(0, Math.floor((Date.now() - new Date(isoDate).getTime()) / 86400000)); +} async function fetchJson(url: string, token: string, accept?: string): Promise { - const res = await fetch(url, { headers: { Authorization: `Bearer ${token}`, Accept: accept ?? "application/vnd.github+json" }, cache: "no-store" }); - if (!res.ok) throw new Error("API error"); + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + Accept: accept ?? "application/vnd.github+json", + }, + cache: "no-store", + }); + if (!res.ok) { + throw new Error("GitHub API error"); + } return await res.json(); } -async function fetchSignalsForRepo(token: string, repoFullName: string, days: number): Promise { +async function fetchSignalsForRepo( + token: string, + repoFullName: string, + days: number +): Promise { const since = new Date(Date.now() - days * 86400000).toISOString().split("T")[0]; - const commitSearch = await fetchJson(`${GITHUB_API}/search/commits?q=repo:${repoFullName}+committer-date:>${since}&per_page=100&sort=committer-date&order=desc`, token, "application/vnd.github+json"); - const openedPrs = await fetchJson(`${GITHUB_API}/search/issues?q=repo:${repoFullName}+type:pr+created:>${since}&per_page=100&sort=created&order=desc`, token); - const mergedPrs = await fetchJson(`${GITHUB_API}/search/issues?q=repo:${repoFullName}+type:pr+is:merged+merged:>${since}&per_page=100&sort=updated&order=desc`, token); - - const openedCount = openedPrs.total_count || 0; - const mergedCount = mergedPrs.total_count || 0; - const closedItems = (openedPrs.items ?? []).filter((i: any) => i.closed_at); - const avgPrOpenTimeHours = closedItems.length > 0 ? closedItems.reduce((sum: number, pr: any) => sum + hoursBetween(pr.created_at, pr.closed_at!), 0) / closedItems.length : 0; - - const openIssues = await fetchJson(`${GITHUB_API}/search/issues?q=repo:${repoFullName}+type:issue+state:open&per_page=1`, token); - const commits = await fetchJson(`${GITHUB_API}/repos/${repoFullName}/commits?per_page=1`, token); + + const commitSearch = await fetchJson<{ items: unknown[] }>( + `${GITHUB_API}/search/commits?q=repo:${repoFullName}+committer-date:>${since}&per_page=100&sort=committer-date&order=desc`, + token, + "application/vnd.github+json" + ); + const openedPrs = await fetchJson<{ + total_count: number; + items: Array<{ created_at: string; closed_at: string | null }>; + }>( + `${GITHUB_API}/search/issues?q=repo:${repoFullName}+type:pr+created:>${since}&per_page=100&sort=created&order=desc`, + token + ); + const mergedPrs = await fetchJson<{ total_count: number }>( + `${GITHUB_API}/search/issues?q=repo:${repoFullName}+type:pr+is:merged+merged:>${since}&per_page=100&sort=updated&order=desc`, + token + ); + + const openedCount = typeof openedPrs.total_count === "number" ? openedPrs.total_count : 0; + const mergedCount = typeof mergedPrs.total_count === "number" ? mergedPrs.total_count : 0; + const closedItems = (openedPrs.items ?? []).filter((pr) => pr.closed_at); + const avgPrOpenTimeHours = + closedItems.length > 0 + ? closedItems.reduce((sum, pr) => sum + hoursBetween(pr.created_at, pr.closed_at!), 0) / + closedItems.length + : 0; + + const openIssues = await fetchJson<{ total_count: number }>( + `${GITHUB_API}/search/issues?q=repo:${repoFullName}+type:issue+state:open&per_page=1`, + token + ); + const commits = await fetchJson>( + `${GITHUB_API}/repos/${repoFullName}/commits?per_page=1`, + token + ); const lastCommitDate = commits?.[0]?.commit?.committer?.date ?? null; return { commitFrequency: Array.isArray(commitSearch.items) ? commitSearch.items.length : 0, prMergeRate: openedCount > 0 ? mergedCount / openedCount : 0, avgPrOpenTimeHours, - openIssuesCount: openIssues.total_count || 0, + openIssuesCount: typeof openIssues.total_count === "number" ? openIssues.total_count : 0, daysSinceLastCommit: lastCommitDate ? daysSince(lastCommitDate) : 9999, }; } 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 requestedDays = parseInt(req.nextUrl.searchParams.get("days") ?? "30", 10); const days = requestedDays === 7 || requestedDays === 30 || requestedDays === 90 ? requestedDays : 30; - const bypass = isMetricsCacheBypassed(req); - const key = metricsCacheKey(session.githubId ?? session.githubLogin, "repo-health" as any, { days }); + const key = metricsCacheKey(session.githubId ?? session.githubLogin, "repoHealth", { days }); try { - const data = await withMetricsCache({ bypass, key, ttlSeconds: 10 * 60 }, async () => { - const topRepos = (await fetchReposForAccount(session.accessToken!, session.githubLogin!, days)).repos; - const scores: RepoHealthScore[] = []; - for (const repo of topRepos) { - try { - const signals = await fetchSignalsForRepo(session.accessToken!, repo.name, days); - scores.push(computeHealthScore(repo.name, signals)); - } catch {} + const data = await withMetricsCache( + { bypass, key, ttlSeconds: METRICS_CACHE_TTL_SECONDS.repoHealth }, + async () => { + const topRepos = (await fetchReposForAccount(session.accessToken!, session.githubLogin!, days)).repos; + const scores: RepoHealthScore[] = []; + + for (const repo of topRepos) { + try { + const signals = await fetchSignalsForRepo(session.accessToken!, repo.name, days); + scores.push(computeHealthScore(repo.name, signals)); + } catch { + // Skip repo on any failure. + } + } + + return { repos: scores }; } - return { repos: scores }; - }); + ); + return Response.json(data); } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); diff --git a/src/app/api/metrics/weekly-summary/route.ts b/src/app/api/metrics/weekly-summary/route.ts index ad82a38b..96b47ea7 100644 --- a/src/app/api/metrics/weekly-summary/route.ts +++ b/src/app/api/metrics/weekly-summary/route.ts @@ -2,7 +2,12 @@ import { getServerSession } from "next-auth"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { GITHUB_API } from "@/lib/github"; -import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache } from "@/lib/metrics-cache"; +import { + isMetricsCacheBypassed, + METRICS_CACHE_TTL_SECONDS, + metricsCacheKey, + withMetricsCache, +} from "@/lib/metrics-cache"; import { dateDiffDays, toDateStr } from "@/lib/dateUtils"; export const dynamic = "force-dynamic"; @@ -26,8 +31,12 @@ function calculateCurrentStreak(activeDates: Set): number { for (let i = 1; i < commitDays.length; i++) { const diff = dateDiffDays(commitDays[i - 1], commitDays[i]); - if (diff === 1) { currentRun++; } - else { runs.push({ end: commitDays[i - 1], length: currentRun }); currentRun = 1; } + if (diff === 1) { + currentRun++; + } else { + runs.push({ end: commitDays[i - 1], length: currentRun }); + currentRun = 1; + } } runs.push({ end: commitDays[commitDays.length - 1], length: currentRun }); @@ -42,21 +51,34 @@ async function fetchActiveDates(githubLogin: string, token: string): Promise(); let page = 1; while (true) { const searchRes = await fetch( `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${sinceStr}&per_page=100&page=${page}&sort=author-date&order=desc`, - { headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json" }, cache: "no-store" } + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }, + cache: "no-store", + } ); - if (!searchRes.ok) throw new Error("GitHub API error"); - const data = (await searchRes.json()) as { items: Array<{ commit: { author: { date: string } } }> }; + if (!searchRes.ok) { + throw new Error("GitHub API error"); + } + + const data = (await searchRes.json()) as { + items: Array<{ commit: { author: { date: string } } }>; + }; for (const item of data.items) { activeDates.add(item.commit.author.date.slice(0, 10)); } - if (data.items.length < 100 || page >= 10) break; + + if (data.items.length < 100 || page >= 10) { + break; + } page++; } @@ -65,127 +87,145 @@ async function fetchActiveDates(githubLogin: string, token: string): Promise { - const currentWeekStart = getCurrentWeekStartUtc(); - const prevWeekStart = new Date(currentWeekStart.getTime() - 7 * 86400000); - const prevWeekEnd = new Date(currentWeekStart.getTime() - 1); - const fourteenDaysAgoStr = toDateStr(new Date(Date.now() - 14 * 86400000)); - - const commitsRes = await fetch( - `${GITHUB_API}/search/commits?q=author:${session.githubLogin}+author-date:>=${fourteenDaysAgoStr}&per_page=100`, - { headers: { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" }, cache: "no-store" } - ); -const commitsData = (await commitsRes.json()) as { - items: Array<{ - commit: { author: { date: string } }; - repository: { full_name: string }; - }>; - }; - - let commitsThisWeek = 0; - let commitsPrevWeek = 0; - const activeDaysThisWeek = new Set(); - const activeDaysLastWeek = new Set(); - const repoCounts = new Map(); - - for (const item of commitsData.items) { - const commitDate = new Date(item.commit.author.date); - - if (commitDate >= currentWeekStart) { - commitsThisWeek++; - activeDaysThisWeek.add(item.commit.author.date.slice(0, 10)); - - const repoName = item.repository.full_name; - repoCounts.set(repoName, (repoCounts.get(repoName) ?? 0) + 1); - } else if (commitDate >= prevWeekStart && commitDate <= prevWeekEnd) { - commitsPrevWeek++; - activeDaysLastWeek.add(item.commit.author.date.slice(0, 10)); + const data = await withMetricsCache( + { bypass, key, ttlSeconds: METRICS_CACHE_TTL_SECONDS.weeklySummary }, + async () => { + const currentWeekStart = getCurrentWeekStartUtc(); + const prevWeekStart = new Date(currentWeekStart.getTime() - 7 * 86400000); + const prevWeekEnd = new Date(currentWeekStart.getTime() - 1); + const fourteenDaysAgoStr = toDateStr(new Date(Date.now() - 14 * 86400000)); + + const commitsRes = await fetch( + `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${fourteenDaysAgoStr}&per_page=100`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }, + cache: "no-store", + } + ); + if (!commitsRes.ok) { + throw new Error("GitHub API error"); } - } - let topRepo: string | null = null; - let topRepoCount = 0; - Array.from(repoCounts.entries()).forEach(([repoName, count]) => { - if (count > topRepoCount) { - topRepo = repoName; - topRepoCount = count; - } - }); - - const prsRes = await fetch( - `${GITHUB_API}/search/issues?q=type:pr+author:@me+created:>=${fourteenDaysAgoStr}&per_page=100`, - { - headers: { - Authorization: `Bearer ${session.accessToken}`, - Accept: "application/vnd.github+json", - }, - cache: "no-store", + const commitsData = (await commitsRes.json()) as { + items: Array<{ + commit: { author: { date: string } }; + repository: { full_name: string }; + }>; + }; + + let commitsThisWeek = 0; + let commitsPrevWeek = 0; + const activeDaysThisWeek = new Set(); + const activeDaysLastWeek = new Set(); + const repoCounts = new Map(); + + for (const item of commitsData.items) { + const commitDate = new Date(item.commit.author.date); + + if (commitDate >= currentWeekStart) { + commitsThisWeek++; + activeDaysThisWeek.add(item.commit.author.date.slice(0, 10)); + + const repoName = item.repository.full_name; + repoCounts.set(repoName, (repoCounts.get(repoName) ?? 0) + 1); + } else if (commitDate >= prevWeekStart && commitDate <= prevWeekEnd) { + commitsPrevWeek++; + activeDaysLastWeek.add(item.commit.author.date.slice(0, 10)); + } } - ); - if (!prsRes.ok) { - throw new Error("GitHub API error"); - } + let topRepo: string | null = null; + let topRepoCount = 0; + Array.from(repoCounts.entries()).forEach(([repoName, count]) => { + if (count > topRepoCount) { + topRepo = repoName; + topRepoCount = count; + } + }); + + const prsRes = await fetch( + `${GITHUB_API}/search/issues?q=type:pr+author:@me+created:>=${fourteenDaysAgoStr}&per_page=100`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }, + cache: "no-store", + } + ); + if (!prsRes.ok) { + throw new Error("GitHub API error"); + } - const prsData = (await prsRes.json()) as { - items: Array<{ - created_at: string; - state: string; - pull_request?: { merged_at: string | null }; - }>; - }; - - let prsOpenedThisWeek = 0; - let prsMergedThisWeek = 0; - let prsOpenedLastWeek = 0; - let prsMergedLastWeek = 0; - - for (const item of prsData.items) { - const createdAt = new Date(item.created_at); - if (Number.isNaN(createdAt.getTime())) continue; - if (createdAt >= currentWeekStart) { - prsOpenedThisWeek++; - if (item.pull_request?.merged_at != null) { - prsMergedThisWeek++; + const prsData = (await prsRes.json()) as { + items: Array<{ + created_at: string; + pull_request?: { merged_at: string | null }; + }>; + }; + + let prsOpenedThisWeek = 0; + let prsMergedThisWeek = 0; + let prsOpenedLastWeek = 0; + let prsMergedLastWeek = 0; + + for (const item of prsData.items) { + const createdAt = new Date(item.created_at); + if (Number.isNaN(createdAt.getTime())) { + continue; } - } else if (createdAt >= prevWeekStart && createdAt <= prevWeekEnd) { - prsOpenedLastWeek++; - if (item.pull_request?.merged_at != null) { - prsMergedLastWeek++; + if (createdAt >= currentWeekStart) { + prsOpenedThisWeek++; + if (item.pull_request?.merged_at != null) { + prsMergedThisWeek++; + } + } else if (createdAt >= prevWeekStart && createdAt <= prevWeekEnd) { + prsOpenedLastWeek++; + if (item.pull_request?.merged_at != null) { + prsMergedLastWeek++; + } } } - } - const streakDates = await fetchActiveDates(session.githubLogin!, session.accessToken!); - const commitDelta = commitsThisWeek - commitsPrevWeek; + const streakDates = await fetchActiveDates(githubLogin, token); + const commitDelta = commitsThisWeek - commitsPrevWeek; + + return { + commits: { + current: commitsThisWeek, + previous: commitsPrevWeek, + delta: commitDelta, + trend: commitDelta > 0 ? "up" : commitDelta < 0 ? "down" : "same", + }, + prs: { + thisWeek: { opened: prsOpenedThisWeek, merged: prsMergedThisWeek }, + lastWeek: { opened: prsOpenedLastWeek, merged: prsMergedLastWeek }, + }, + activeDays: { + thisWeek: activeDaysThisWeek.size, + lastWeek: activeDaysLastWeek.size, + }, + streak: calculateCurrentStreak(streakDates), + topRepo, + }; + } + ); - return { - commits: { - current: commitsThisWeek, - previous: commitsPrevWeek, - delta: commitDelta, - trend: commitDelta > 0 ? "up" : commitDelta < 0 ? "down" : "same", - }, - prs: { - thisWeek: { opened: prsOpenedThisWeek, merged: prsMergedThisWeek }, - lastWeek: { opened: prsOpenedLastWeek, merged: prsMergedLastWeek }, - }, - activeDays: { - thisWeek: activeDaysThisWeek.size, - lastWeek: activeDaysLastWeek.size, - }, - streak: calculateCurrentStreak(streakDates), - topRepo, - }; - }); return Response.json(data); } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); } -} \ No newline at end of file +} diff --git a/src/lib/github.ts b/src/lib/github.ts index ffffff9d..e72664a4 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -1,3 +1,9 @@ +import { + metricsCacheKey, + withMetricsCache, + METRICS_CACHE_TTL_SECONDS, +} from "./metrics-cache"; + export const GITHUB_API = "https://api.github.com"; /** @@ -12,8 +18,7 @@ async function githubFetch( const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { - const res = await fetch(url, { ...options, signal: controller.signal }); - return res; + return await fetch(url, { ...options, signal: controller.signal }); } finally { clearTimeout(timeout); } @@ -123,72 +128,89 @@ export interface IssuesMetrics { } export async function fetchIssuesMetrics( - token: string + token: string, + cacheContext?: { bypass: boolean; userId: string } ): Promise { - const headers = { - Authorization: `Bearer ${token}`, - Accept: "application/vnd.github+json", - }; + const load = async () => { + const headers = { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }; - const now = new Date(); - const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1); - const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1); - const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0); - const since30d = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + const now = new Date(); + const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1); + const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1); + const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0); + const since30d = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); - const searchRes = await githubFetch( - `https://api.github.com/search/issues?q=type:issue+author:@me+created:>=${since30d.toISOString().slice(0, 10)}&per_page=100`, - { headers, cache: "no-store" } - ); - if (!searchRes.ok) throw new Error(`GitHub API error: ${searchRes.status}`); - - const searchData = (await searchRes.json()) as { items: GitHubIssueItem[] }; - const items = searchData.items; - - const opened = items.length; - const closedItems = items.filter((i) => i.state === "closed" && i.closed_at); - const closed = closedItems.length; - const currentlyOpen = items.filter((i) => i.state === "open").length; - - const avgCloseTimeDays = - closedItems.length > 0 - ? Math.round( - closedItems.reduce((sum, i) => { - return sum + (new Date(i.closed_at!).getTime() - new Date(i.created_at).getTime()); - }, 0) / - closedItems.length / - 86400000 - ) - : 0; - - const thisMonthRes = await githubFetch( - `https://api.github.com/search/issues?q=type:issue+author:@me+created:>=${thisMonthStart.toISOString().slice(0, 10)}&per_page=1`, - { headers, cache: "no-store" } - ); - const lastMonthRes = await githubFetch( - `https://api.github.com/search/issues?q=type:issue+author:@me+created:${lastMonthStart.toISOString().slice(0, 10)}..${lastMonthEnd.toISOString().slice(0, 10)}&per_page=1`, - { headers, cache: "no-store" } - ); + const searchRes = await githubFetch( + `${GITHUB_API}/search/issues?q=type:issue+author:@me+created:>=${since30d.toISOString().slice(0, 10)}&per_page=100`, + { headers, cache: "no-store" } + ); + if (!searchRes.ok) throw new Error(`GitHub API error: ${searchRes.status}`); + + const searchData = (await searchRes.json()) as { items: GitHubIssueItem[] }; + const items = searchData.items; + + const opened = items.length; + const closedItems = items.filter((i) => i.state === "closed" && i.closed_at); + const closed = closedItems.length; + const currentlyOpen = items.filter((i) => i.state === "open").length; + + const avgCloseTimeDays = + closedItems.length > 0 + ? Math.round( + closedItems.reduce((sum, i) => { + return sum + (new Date(i.closed_at!).getTime() - new Date(i.created_at).getTime()); + }, 0) / + closedItems.length / + 86400000 + ) + : 0; + + const thisMonthRes = await githubFetch( + `${GITHUB_API}/search/issues?q=type:issue+author:@me+created:>=${thisMonthStart.toISOString().slice(0, 10)}&per_page=1`, + { headers, cache: "no-store" } + ); + const lastMonthRes = await githubFetch( + `${GITHUB_API}/search/issues?q=type:issue+author:@me+created:${lastMonthStart.toISOString().slice(0, 10)}..${lastMonthEnd.toISOString().slice(0, 10)}&per_page=1`, + { headers, cache: "no-store" } + ); - const thisMonthCount = thisMonthRes.ok ? ((await thisMonthRes.json()) as { total_count: number }).total_count : 0; - const lastMonthCount = lastMonthRes.ok ? ((await lastMonthRes.json()) as { total_count: number }).total_count : 0; + const thisMonthCount = thisMonthRes.ok ? ((await thisMonthRes.json()) as { total_count: number }).total_count : 0; + const lastMonthCount = lastMonthRes.ok ? ((await lastMonthRes.json()) as { total_count: number }).total_count : 0; - const repoCounts: Record = {}; - for (const item of items) { - const repo = item.repository_url.split("/").pop() ?? ""; - repoCounts[repo] = (repoCounts[repo] ?? 0) + 1; - } - const mostActiveRepo = - Object.keys(repoCounts).length > 0 - ? Object.entries(repoCounts).sort((a, b) => b[1] - a[1])[0][0] - : null; - - return { - opened, - closed, - currentlyOpen, - avgCloseTimeDays, - trend: thisMonthCount - lastMonthCount, - mostActiveRepo, + const repoCounts: Record = {}; + for (const item of items) { + const repo = item.repository_url.split("/").pop() ?? ""; + repoCounts[repo] = (repoCounts[repo] ?? 0) + 1; + } + const mostActiveRepo = + Object.keys(repoCounts).length > 0 + ? Object.entries(repoCounts).sort((a, b) => b[1] - a[1])[0][0] + : null; + + return { + opened, + closed, + currentlyOpen, + avgCloseTimeDays, + trend: thisMonthCount - lastMonthCount, + mostActiveRepo, + }; }; + + if (!cacheContext) { + return load(); + } + + const key = metricsCacheKey(cacheContext.userId, "issues"); + return withMetricsCache( + { + bypass: cacheContext.bypass, + key, + ttlSeconds: METRICS_CACHE_TTL_SECONDS.issues, + }, + load + ); } diff --git a/src/lib/metrics-cache.ts b/src/lib/metrics-cache.ts index 9738430e..4e83c0bf 100644 --- a/src/lib/metrics-cache.ts +++ b/src/lib/metrics-cache.ts @@ -12,7 +12,12 @@ export const METRICS_CACHE_TTL_SECONDS = { streak: 2 * 60, streak_freeze: 2 * 60, activity: 5 * 60, - issues: 10 * 60, + issues: 60 * 60, + languages: 60 * 60, + pinnedRepos: 60 * 60, + prBreakdown: 60 * 60, + repoHealth: 60 * 60, + weeklySummary: 60 * 60, "coding-activity-insights": 5 * 60, } as const; @@ -23,9 +28,6 @@ type MemoryCacheEntry = { value: unknown; expiresAt: number }; let redisClient: Redis | null | undefined; const MAX_MEMORY_CACHE_ENTRIES = 500; -/* ============================================================ - Persists across Next.js Fast Refresh in local development - ============================================================ */ const globalForCache = globalThis as unknown as { metricsMemoryCache?: Map; }; @@ -126,7 +128,7 @@ export function metricsCacheKey( Object.entries(params) .filter(([, value]) => value !== undefined && value !== null) - .sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) .forEach(([key, value]) => cacheParams.set(key, String(value))); return `metrics:${userId}:${endpoint}:${cacheParams.toString() || "default"}`; @@ -142,7 +144,6 @@ export async function cacheGet( } const redis = getRedisClient(); - if (redis) { try { const redisValue = await redis.get(key); @@ -151,10 +152,9 @@ export async function cacheGet( } return redisValue; } catch { - return null; + // Cache failures must not break dashboard metrics. } } - return null; } @@ -168,12 +168,12 @@ export async function cacheSet( } const redis = getRedisClient(); - if (redis) { try { await redis.set(key, value, { ex: ttlSeconds }); + return; } catch { - // Cache failures must not break dashboard metrics. + // Fall back to memory cache if Redis is unavailable. } } diff --git a/test/StreakTracker.test.ts b/test/StreakTracker.test.ts index 19ddb212..c1d737c2 100644 --- a/test/StreakTracker.test.ts +++ b/test/StreakTracker.test.ts @@ -51,7 +51,11 @@ describe('StreakTracker - StreakData interface', () => { describe('StreakTracker - copy to clipboard behavior', () => { beforeEach(() => { - global.navigator = {} as Navigator; + Object.defineProperty(global, 'navigator', { + value: {}, + writable: true, + configurable: true, + }); }); it('copies streak data as formatted string', async () => {