From 4a4efee8a4d3a2b3eca78543724c53027134a535 Mon Sep 17 00:00:00 2001 From: devendra-w Date: Wed, 20 May 2026 13:51:59 +0530 Subject: [PATCH 1/5] feat: add 24-hour activity ring chart using RadarChart --- src/app/dashboard/page.tsx | 5 + src/components/ActivityRingChart.tsx | 131 +++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 src/components/ActivityRingChart.tsx diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 9dcc11eb..8c9c7658 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,3 +1,4 @@ +import ActivityRingChart from "@/components/ActivityRingChart"; import ContributionGraph from "@/components/ContributionGraph"; import ContributionHeatmap from "@/components/ContributionHeatmap"; import PRMetrics from "@/components/PRMetrics"; @@ -77,6 +78,10 @@ export default async function DashboardPage() { + {/* Row 2b: Activity Ring Chart */} +
+ +
{/* Row 3: Issue metrics + CI analytics */}
diff --git a/src/components/ActivityRingChart.tsx b/src/components/ActivityRingChart.tsx new file mode 100644 index 00000000..2c92e2e2 --- /dev/null +++ b/src/components/ActivityRingChart.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + RadarChart, + PolarGrid, + PolarAngleAxis, + Radar, + ResponsiveContainer, + Tooltip, +} from "recharts"; + +interface TimeBlocks { + morning: number; + afternoon: number; + evening: number; + night: number; +} + +interface ChartData { + hour: string; + commits: number; +} + +export default function ActivityRingChart() { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [days, setDays] = useState(30); + const [peakHour, setPeakHour] = useState(null); + + useEffect(() => { + setLoading(true); + setError(null); + fetch(`/api/metrics/contributions?days=${days}`) + .then((r) => r.json()) + .then((res: { timeBlocks?: TimeBlocks }) => { + if (!res.timeBlocks) { + setData([]); + return; + } + + const blocks = res.timeBlocks; + const chartData: ChartData[] = [ + { hour: "Morning\n6–12", commits: blocks.morning }, + { hour: "Afternoon\n12–18", commits: blocks.afternoon }, + { hour: "Evening\n18–22", commits: blocks.evening }, + { hour: "Night\n22–6", commits: blocks.night }, + ]; + + const peak = chartData.reduce((a, b) => + b.commits > a.commits ? b : a + ); + setPeakHour(peak.commits > 0 ? peak.hour.split("\n")[0] : null); + setData(chartData); + }) + .catch(() => setError("Failed to load activity data.")) + .finally(() => setLoading(false)); + }, [days]); + + return ( +
+
+

+ Activity Ring +

+ +
+ +

+ {peakHour && `Most active during ${peakHour.toLowerCase()}`} +

+ +
+ {loading ? ( +
+
+
+ ) : error ? ( +
+

{error}

+
+ ) : data.every((d) => d.commits === 0) ? ( +
+

+ No commits in the last {days} days. +

+
+ ) : ( + + + + + + + + + )} +
+
+ ); +} \ No newline at end of file From 365edc0d0707b222441dbb44b76393c0d0858f2c Mon Sep 17 00:00:00 2001 From: devendra-w Date: Thu, 21 May 2026 00:50:36 +0530 Subject: [PATCH 2/5] fix: resolve API shape mismatch, remove redundant fetch, fix useEffect listener, add EOF newlines --- package-lock.json | 1 - .../api/metrics/contributions/daily/route.ts | 65 ++++++++ src/components/ContributionHeatmap.tsx | 13 +- src/components/DailyBreakdownSheet.tsx | 139 ++++++++++++++++++ 4 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 src/app/api/metrics/contributions/daily/route.ts create mode 100644 src/components/DailyBreakdownSheet.tsx diff --git a/package-lock.json b/package-lock.json index 251af664..ce0b55ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5062,7 +5062,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/contributions/daily/route.ts b/src/app/api/metrics/contributions/daily/route.ts new file mode 100644 index 00000000..66b36746 --- /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 }); + } +} \ No newline at end of file diff --git a/src/components/ContributionHeatmap.tsx b/src/components/ContributionHeatmap.tsx index 8c81db44..6ce0d61d 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,11 @@ 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..f7b7df8f --- /dev/null +++ b/src/components/DailyBreakdownSheet.tsx @@ -0,0 +1,139 @@ +"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 ( + <> +