diff --git a/src/app/api/metrics/contributions/daily/route.ts b/src/app/api/metrics/contributions/daily/route.ts new file mode 100644 index 00000000..a8ff88c5 --- /dev/null +++ b/src/app/api/metrics/contributions/daily/route.ts @@ -0,0 +1,65 @@ +import { getServerSession } from "next-auth"; +import { NextRequest } from "next/server"; +import { authOptions } from "@/lib/auth"; +import { GITHUB_API } from "@/lib/github"; + +export const dynamic = "force-dynamic"; + +interface RepoCommit { + repo: string; + count: number; + url: string; +} + +export async function GET(req: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.accessToken || !session.githubLogin) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const date = req.nextUrl.searchParams.get("date"); + if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) { + return Response.json({ error: "Missing or invalid date" }, { status: 400 }); + } + + try { + const searchRes = await fetch( + `${GITHUB_API}/search/commits?q=author:${session.githubLogin}+author-date:${date}&per_page=100`, + { + headers: { + Authorization: `Bearer ${session.accessToken}`, + Accept: "application/vnd.github+json", + }, + cache: "no-store", + } + ); + + if (!searchRes.ok) { + return Response.json({ error: "GitHub API error" }, { status: 502 }); + } + + const data = (await searchRes.json()) as { + items: Array<{ + repository: { full_name: string; html_url: string }; + }>; + }; + + // Group commits by repo + const repoMap: Record = {}; + for (const item of data.items) { + const { full_name, html_url } = item.repository; + if (!repoMap[full_name]) { + repoMap[full_name] = { count: 0, url: html_url }; + } + repoMap[full_name].count++; + } + + const repos: RepoCommit[] = Object.entries(repoMap).map( + ([repo, { count, url }]) => ({ repo, count, url }) + ); + + return Response.json({ date, repos }); + } catch { + return Response.json({ error: "GitHub API error" }, { status: 502 }); + } +} diff --git a/src/components/ContributionHeatmap.tsx b/src/components/ContributionHeatmap.tsx index 8c81db44..f4fa8105 100644 --- a/src/components/ContributionHeatmap.tsx +++ b/src/components/ContributionHeatmap.tsx @@ -1,7 +1,8 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useHeatmapTheme } from "@/hooks/useHeatmapTheme"; +import DailyBreakdownSheet from "@/components/DailyBreakdownSheet"; interface ContributionHeatmapProps { days?: number; @@ -77,6 +78,8 @@ export default function ContributionHeatmap({ const [error, setError] = useState(null); const [lastUpdated, setLastUpdated] = useState(null); const [minutesAgo, setMinutesAgo] = useState(0); + const [selectedDate, setSelectedDate] = useState(null); + const handleCloseSheet = useCallback(() => setSelectedDate(null), []); useEffect(() => { let active = true; @@ -286,6 +289,7 @@ export default function ContributionHeatmap({ title={isFuture ? "" : tooltip} aria-label={isFuture ? `${cell.dateKey}: future date` : tooltip} disabled={isFuture} + onClick={() => !isFuture && setSelectedDate(cell.dateKey)} className={`group relative z-0 h-3 w-3 rounded-[3px] border transition-transform hover:z-20 hover:scale-110 focus:z-20 focus:outline-none focus:ring-2 focus:ring-[var(--heatmap-focus-ring)] disabled:cursor-default disabled:opacity-30 ${ cell.inRange ? "" : "opacity-35" }`} @@ -333,6 +337,13 @@ export default function ContributionHeatmap({ )} + ); -} \ No newline at end of file +} + + diff --git a/src/components/DailyBreakdownSheet.tsx b/src/components/DailyBreakdownSheet.tsx new file mode 100644 index 00000000..8c4d4c92 --- /dev/null +++ b/src/components/DailyBreakdownSheet.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +interface DailyBreakdownSheetProps { + date: string | null; + onClose: () => void; + heatmapData?: Record; +} + +interface RepoCommit { + repo: string; + count: number; + url: string; +} + +export default function DailyBreakdownSheet({ + date, + onClose, + heatmapData, +}: DailyBreakdownSheetProps) { + const [commits, setCommits] = useState([]); + const [loading, setLoading] = useState(false); + const isOpen = date !== null; + + useEffect(() => { + if (!date) return; + const totalForDay = heatmapData?.[date] ?? 0; + if (totalForDay === 0) { + setCommits([]); + setLoading(false); + return; + } + setLoading(true); + fetch(`/api/metrics/contributions/daily?date=${date}`) + .then((res) => res.json()) + .then((result) => { + setCommits(result.repos ?? []); + }) + .catch(() => setCommits([])) + .finally(() => setLoading(false)); + }, [date, heatmapData]); + + const onCloseRef = useRef(onClose); + useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onCloseRef.current(); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, []); + + if (!isOpen) return null; + + const formattedDate = date + ? new Date(date + "T00:00:00").toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + year: "numeric", + }) + : ""; + + return ( + <> +