Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -73,6 +74,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
207 changes: 207 additions & 0 deletions src/components/ActivityRingChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
"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)"
: "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