Skip to content
86 changes: 86 additions & 0 deletions src/app/api/metrics/contributions/daily/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { authOptions } from "@/lib/auth";
import { GITHUB_API } from "@/lib/github";
import {
isMetricsCacheBypassed,
METRICS_CACHE_TTL_SECONDS,
metricsCacheKey,
withMetricsCache,
} from "@/lib/metrics-cache";

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 });
}

const bypass = isMetricsCacheBypassed(req);
const key = metricsCacheKey(
session.githubId ?? session.githubLogin,
"contributions",
{ date }
);

try {
const result = await withMetricsCache(
{
bypass,
key,
ttlSeconds: METRICS_CACHE_TTL_SECONDS.contributions,
},
async () => {
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) throw new Error("GitHub API error");

const data = (await searchRes.json()) as {
items: Array<{
repository: { full_name: string; html_url: string };
}>;
};

const repoMap: Record<string, { count: number; url: string }> = {};
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 { date, repos };
}
);

return Response.json(result);
} catch {
return Response.json({ error: "GitHub API error" }, { status: 502 });
}
}
82 changes: 82 additions & 0 deletions src/app/api/metrics/contributions/hourly/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { authOptions } from "@/lib/auth";
import { GITHUB_API } from "@/lib/github";
import {
isMetricsCacheBypassed,
METRICS_CACHE_TTL_SECONDS,
metricsCacheKey,
withMetricsCache,
} from "@/lib/metrics-cache";

export const dynamic = "force-dynamic";

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

const days = Number(req.nextUrl.searchParams.get("days")) || 30;
const bypass = isMetricsCacheBypassed(req);
const key = metricsCacheKey(
session.githubId ?? session.githubLogin,
"contributions",
{ days }
);

try {
const result = await withMetricsCache(
{
bypass,
key,
ttlSeconds: METRICS_CACHE_TTL_SECONDS.contributions,
},
async () => {
const since = new Date();
since.setDate(since.getDate() - days);
const sinceStr = since.toISOString().slice(0, 10);

const searchRes = await fetch(
`${GITHUB_API}/search/commits?q=author:${session.githubLogin}+author-date:>=${sinceStr}&per_page=100&sort=author-date&order=desc`,
{
headers: {
Authorization: `Bearer ${session.accessToken}`,
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 } } }>;
};

// Initialize all 24 hours to 0
const hourMap: Record<number, number> = {};
for (let i = 0; i < 24; i++) hourMap[i] = 0;

// NOTE: date.getHours() returns UTC hours from the server,
// not the user's local timezone. The client displays these as-is.
for (const item of data.items) {
const date = new Date(item.commit.author.date);
const hour = date.getHours();
hourMap[hour]++;
}

const hours = Array.from({ length: 24 }, (_, i) => ({
hour: i,
commits: hourMap[i],
}));

return { days, hours };
}
);

return Response.json(result);
} catch {
return Response.json({ error: "GitHub API error" }, { status: 502 });
}
}
5 changes: 5 additions & 0 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import ActivityRingChart from "@/components/ActivityRingChart";
import ContributionGraph from "@/components/ContributionGraph";
import ContributionHeatmap from "@/components/ContributionHeatmap";
import PRMetrics from "@/components/PRMetrics";
Expand Down Expand Up @@ -71,6 +72,10 @@ export default async function DashboardPage() {
<PRBreakdownChart />
<CommitTimeChart />
</div>
{/* Row 2b: Activity Ring Chart */}
<div className="mt-6">
<ActivityRingChart />
</div>

<div className="mt-6">
<PRReviewTrendChart />
Expand Down
205 changes: 205 additions & 0 deletions src/components/ActivityRingChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
"use client";

import { useEffect, useState, useRef } from "react";

interface HourData {
hour: number;
commits: number;
}

function formatHour(hour: number): string {
if (hour === 0) return "12am";
if (hour < 12) return `${hour}am`;
if (hour === 12) return "12pm";
return `${hour - 12}pm`;
}

export default function ActivityRingChart() {
const [data, setData] = useState<HourData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [days, setDays] = useState(30);
const [hoveredHour, setHoveredHour] = useState<HourData | null>(null);
const [animated, setAnimated] = useState(false);
const prefersReduced = useRef(
typeof window !== "undefined"
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
: false
);

useEffect(() => {
setLoading(true);
setError(null);
setAnimated(false);
fetch(`/api/metrics/contributions/hourly?days=${days}`)
.then((r) => r.json())
.then((res: { hours?: HourData[] }) => {
setData(res.hours ?? []);
setTimeout(() => setAnimated(true), 50);
})
.catch(() => setError("Failed to load activity data."))
.finally(() => setLoading(false));
}, [days]);

const maxCommits = Math.max(...data.map((d) => d.commits), 1);
const peakHour = data.reduce(
(a, b) => (b.commits > a.commits ? b : a),
{ hour: 0, commits: 0 }
);

const cx = 150;
const cy = 150;
const innerR = 55;
const outerMaxR = 120;
const segments = 24;
const anglePerSegment = (2 * Math.PI) / segments;
const gap = 0.04;

function polarToCartesian(angle: number, r: number) {
return {
x: cx + r * Math.cos(angle - Math.PI / 2),
y: cy + r * Math.sin(angle - Math.PI / 2),
};
}

function segmentPath(index: number, outerR: number) {
const startAngle = index * anglePerSegment + gap;
const endAngle = (index + 1) * anglePerSegment - gap;
const o1 = polarToCartesian(startAngle, outerR);
const o2 = polarToCartesian(endAngle, outerR);
const i1 = polarToCartesian(startAngle, innerR);
const i2 = polarToCartesian(endAngle, innerR);
const largeArc = endAngle - startAngle > Math.PI ? 1 : 0;
return [
`M ${i1.x} ${i1.y}`,
`L ${o1.x} ${o1.y}`,
`A ${outerR} ${outerR} 0 ${largeArc} 1 ${o2.x} ${o2.y}`,
`L ${i2.x} ${i2.y}`,
`A ${innerR} ${innerR} 0 ${largeArc} 0 ${i1.x} ${i1.y}`,
"Z",
].join(" ");
}

return (
<div className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm flex flex-col h-full">
<div className="flex items-center justify-between mb-2">
<h2 className="text-lg font-semibold text-[var(--card-foreground)]">
Activity Ring
</h2>
<select
value={days}
onChange={(e) => setDays(Number(e.target.value))}
className="rounded-lg border border-[var(--border)] bg-[var(--card)] px-2 py-1 text-sm text-[var(--card-foreground)] focus:outline-none focus:border-[var(--accent)]"
>
<option value={7}>Last 7d</option>
<option value={30}>Last 30d</option>
<option value={90}>Last 90d</option>
</select>
</div>

<p className="text-sm text-[var(--muted-foreground)] mb-4 h-5">
{peakHour.commits > 0 &&
`Most active at ${formatHour(peakHour.hour)} (${peakHour.commits} commits)`}
</p>

<div className="flex-1 flex items-center justify-center min-h-[300px]">
{loading ? (
<div className="h-48 w-48 animate-pulse rounded-full bg-[var(--card-muted)]" />
) : error ? (
<p className="text-sm text-[var(--destructive)]">{error}</p>
) : data.every((d) => d.commits === 0) ? (
<p className="text-sm text-[var(--muted-foreground)]">
No commits in the last {days} days.
</p>
) : (
<div className="flex flex-col items-center gap-4">
<svg width="300" height="300" viewBox="0 0 300 300">
{/* Hour labels */}
{[0, 6, 12, 18].map((h) => {
const angle = h * anglePerSegment - Math.PI / 2;
const labelR = outerMaxR + 18;
const x = cx + labelR * Math.cos(angle);
const y = cy + labelR * Math.sin(angle);
return (
<text
key={h}
x={x}
y={y}
textAnchor="middle"
dominantBaseline="middle"
fontSize="10"
fill="var(--muted-foreground)"
>
{formatHour(h)}
</text>
);
})}

{/* Segments */}
{data.map((d) => {
const isPeak = d.hour === peakHour.hour && d.commits > 0;
const isHovered = hoveredHour?.hour === d.hour;
const ratio = d.commits / maxCommits;
const targetR = d.commits === 0
? innerR + 4
: innerR + (outerMaxR - innerR) * ratio;
const currentR =
!prefersReduced.current && !animated ? innerR : targetR;

return (
<path
key={d.hour}
d={segmentPath(d.hour, currentR)}
fill={
isPeak || isHovered
? "var(--accent)"
: d.commits === 0
? "var(--card-muted, #2a2a2a)"
: "var(--accent)"
}
opacity={
d.commits === 0
? 0.15
: isPeak || isHovered
? 1
: 0.5 + 0.5 * ratio
}
style={{
transition: prefersReduced.current
? "none"
: "all 0.6s cubic-bezier(0.34, 1.56, 0.64, 1)",
cursor: d.commits > 0 ? "pointer" : "default",
}}
onMouseEnter={() => setHoveredHour(d)}
onMouseLeave={() => setHoveredHour(null)}
/>
);
})}

{/* Center label */}
<text
x={cx}
y={cy - 8}
textAnchor="middle"
fontSize="22"
fontWeight="bold"
fill="var(--card-foreground)"
>
{hoveredHour ? hoveredHour.commits : data.reduce((s, d) => s + d.commits, 0)}
</text>
<text
x={cx}
y={cy + 14}
textAnchor="middle"
fontSize="11"
fill="var(--muted-foreground)"
>
{hoveredHour ? formatHour(hoveredHour.hour) : "commits"}
</text>
</svg>
</div>
)}
</div>
</div>
);
}
Loading
Loading