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
65 changes: 65 additions & 0 deletions src/app/api/metrics/contributions/daily/route.ts
Original file line number Diff line number Diff line change
@@ -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<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 Response.json({ date, repos });
} catch {
return Response.json({ error: "GitHub API error" }, { status: 502 });
}
}
15 changes: 13 additions & 2 deletions src/components/ContributionHeatmap.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -77,6 +78,8 @@ export default function ContributionHeatmap({
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [minutesAgo, setMinutesAgo] = useState(0);
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const handleCloseSheet = useCallback(() => setSelectedDate(null), []);

useEffect(() => {
let active = true;
Expand Down Expand Up @@ -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"
}`}
Expand Down Expand Up @@ -333,6 +337,13 @@ export default function ContributionHeatmap({
</div>
</>
)}
<DailyBreakdownSheet
date={selectedDate}
onClose={handleCloseSheet}
heatmapData={data}
/>
</div>
);
}
}


141 changes: 141 additions & 0 deletions src/components/DailyBreakdownSheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"use client";

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

interface DailyBreakdownSheetProps {
date: string | null;
onClose: () => void;
heatmapData?: Record<string, number>;
}

interface RepoCommit {
repo: string;
count: number;
url: string;
}

export default function DailyBreakdownSheet({
date,
onClose,
heatmapData,
}: DailyBreakdownSheetProps) {
const [commits, setCommits] = useState<RepoCommit[]>([]);
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 (
<>
<div
className="fixed inset-0 z-40 bg-black/40"
onClick={onClose}
aria-hidden="true"
/>
<div
role="dialog"
aria-modal="true"
aria-label={`Daily breakdown for ${formattedDate}`}
className="fixed right-0 top-0 z-50 flex h-full w-80 flex-col border-l border-[var(--border)] bg-[var(--card)] shadow-xl"
>
<div className="flex items-center justify-between border-b border-[var(--border)] p-4">
<div>
<h2 className="font-semibold text-[var(--card-foreground)]">
Daily Breakdown
</h2>
<p className="text-xs text-[var(--muted-foreground)]">
{formattedDate}
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close panel"
className="rounded p-1 text-[var(--muted-foreground)] hover:text-[var(--card-foreground)]"
>
?
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-12 animate-pulse rounded-lg bg-[var(--card-muted)]"
/>
))}
</div>
) : commits.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-sm text-[var(--muted-foreground)]">
No commit data available for this day.
</p>
</div>
) : (
<div className="space-y-2">
{commits.map((item) => (
<a
key={item.repo}
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between rounded-lg border border-[var(--border)] p-3 hover:bg-[var(--accent)]/10"
>
<span className="truncate text-sm font-medium text-[var(--card-foreground)]">
{item.repo}
</span>
<span className="ml-2 shrink-0 rounded-full bg-[var(--accent)]/20 px-2 py-0.5 text-xs font-semibold text-[var(--accent-foreground)]">
{item.count} commit{item.count === 1 ? "" : "s"}
</span>
</a>
))}
</div>
)}
</div>
</div>
</>
);
}


Loading