From c2f47ea467db5dc7f218044c1f8f5109fec37fe5 Mon Sep 17 00:00:00 2001 From: VIDYANKSHINI Date: Wed, 27 May 2026 14:58:36 +0530 Subject: [PATCH 1/2] feat: add per-repository commit heatmap drawer --- package-lock.json | 1 - .../metrics/repos/[...repo]/commits/route.ts | 77 ++++++ src/components/RepoActivityDrawer.tsx | 237 ++++++++++++++++++ src/components/TopRepos.tsx | 54 ++-- 4 files changed, 346 insertions(+), 23 deletions(-) create mode 100644 src/app/api/metrics/repos/[...repo]/commits/route.ts create mode 100644 src/components/RepoActivityDrawer.tsx diff --git a/package-lock.json b/package-lock.json index c47f690f..4d540824 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4564,7 +4564,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/src/app/api/metrics/repos/[...repo]/commits/route.ts b/src/app/api/metrics/repos/[...repo]/commits/route.ts new file mode 100644 index 00000000..f10d830b --- /dev/null +++ b/src/app/api/metrics/repos/[...repo]/commits/route.ts @@ -0,0 +1,77 @@ +import { getServerSession } from "next-auth"; +import { NextRequest } from "next/server"; +import { authOptions } from "@/lib/auth"; +import { GITHUB_API } from "@/lib/github"; +import { getAccountToken } from "@/lib/github-accounts"; +import { resolveAppUser } from "@/lib/resolve-user"; +import { supabaseAdmin } from "@/lib/supabase"; + +export const dynamic = "force-dynamic"; + +export async function GET( + req: NextRequest, + { params }: { params: { repo: string[] } } +) { + const session = await getServerSession(authOptions); + if (!session?.accessToken || !session.githubLogin) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const repoFullName = params.repo.join("/"); + const accountId = req.nextUrl.searchParams.get("accountId"); + + let token = session.accessToken; + let authorLogin = session.githubLogin; + + if (accountId && accountId !== "combined" && accountId !== session.githubId) { + if (session.githubId) { + const userRow = await resolveAppUser(session.githubId, session.githubLogin); + if (userRow) { + const accountToken = await getAccountToken(userRow.id, accountId); + if (accountToken) { + token = accountToken; + const { data: accountRow } = await supabaseAdmin + .from("user_github_accounts") + .select("github_login") + .eq("user_id", userRow.id) + .eq("github_id", accountId) + .single(); + if (accountRow?.github_login) { + authorLogin = accountRow.github_login; + } + } + } + } + } + + const since = new Date(); + since.setDate(since.getDate() - 90); + const sinceStr = since.toISOString(); + + const res = await fetch( + `${GITHUB_API}/repos/${repoFullName}/commits?author=${authorLogin}&since=${sinceStr}&per_page=100`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }, + cache: "no-store", + } + ); + + if (!res.ok) { + return Response.json({ error: "GitHub API error" }, { status: res.status }); + } + + const commits = (await res.json()) as any[]; + + const timestamps = commits.map(c => c.commit.author.date); + + const heatmapData: Record = {}; + for (const ts of timestamps) { + const dateKey = ts.split("T")[0]; + heatmapData[dateKey] = (heatmapData[dateKey] || 0) + 1; + } + + return Response.json({ heatmapData, timestamps }); +} diff --git a/src/components/RepoActivityDrawer.tsx b/src/components/RepoActivityDrawer.tsx new file mode 100644 index 00000000..538787e5 --- /dev/null +++ b/src/components/RepoActivityDrawer.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { useEffect, useState, useMemo } from "react"; +import { summarizeCodingActivity } from "@/lib/coding-activity-insights"; +import { useHeatmapTheme } from "@/hooks/useHeatmapTheme"; +import { useAccount } from "@/components/AccountContext"; + +interface RepoActivityDrawerProps { + repoName: string; + isOpen: boolean; + onClose: () => void; +} + +// Same helper as ContributionHeatmap +function formatDateKey(date: Date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function buildHeatmap(days: number, contributions: Record) { + const endDate = new Date(); + endDate.setHours(23, 59, 59, 999); + + const startDate = new Date(endDate); + startDate.setDate(endDate.getDate() - (days - 1)); + startDate.setHours(0, 0, 0, 0); + + const firstWeekStart = new Date(startDate); + firstWeekStart.setDate(startDate.getDate() - startDate.getDay()); + firstWeekStart.setHours(0, 0, 0, 0); + + const lastWeekEnd = new Date(endDate); + lastWeekEnd.setDate(endDate.getDate() + (6 - endDate.getDay())); + lastWeekEnd.setHours(23, 59, 59, 999); + + const cells: { date: Date; dateKey: string; count: number; inRange: boolean }[] = []; + const cursor = new Date(firstWeekStart); + + while (cursor <= lastWeekEnd) { + const dateKey = formatDateKey(cursor); + cells.push({ + date: new Date(cursor), + dateKey, + count: contributions[dateKey] ?? 0, + inRange: cursor >= startDate && cursor <= endDate, + }); + cursor.setDate(cursor.getDate() + 1); + } + + return cells; +} + +const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +export default function RepoActivityDrawer({ repoName, isOpen, onClose }: RepoActivityDrawerProps) { + const [loading, setLoading] = useState(false); + const [data, setData] = useState(null); + const { themeConfig } = useHeatmapTheme(); + const { selectedAccount } = useAccount(); + + useEffect(() => { + if (!isOpen || !repoName) return; + + const handleEsc = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handleEsc); + + let active = true; + setLoading(true); + + const accountParam = selectedAccount ? `?accountId=${selectedAccount}` : ""; + + fetch(`/api/metrics/repos/${repoName}/commits${accountParam}`) + .then(r => r.json()) + .then(d => { + if (!active) return; + if (d.error) { + setData(null); + } else { + const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const summary = summarizeCodingActivity(d.timestamps, userTimeZone); + setData({ heatmapData: d.heatmapData, summary }); + } + setLoading(false); + }) + .catch(() => { + if (active) setLoading(false); + }); + + return () => { + active = false; + window.removeEventListener("keydown", handleEsc); + }; + }, [isOpen, repoName, selectedAccount, onClose]); + + // Trap focus (simple version) + useEffect(() => { + if (isOpen) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = "unset"; + } + return () => { + document.body.style.overflow = "unset"; + }; + }, [isOpen]); + + const cells = useMemo(() => { + if (!data?.heatmapData) return []; + return buildHeatmap(90, data.heatmapData); + }, [data]); + + if (!isOpen) return null; + + return ( + <> +