Skip to content
Merged
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
c506dde
package-lock.json
YamiNo-Okami May 27, 2026
533ad24
feat: add dynamic date range controls for contribution heatmap
YamiNo-Okami May 27, 2026
54ba0b5
Restore files from main branch
YamiNo-Okami May 28, 2026
fd4141f
Merge branch 'main' into feature/contribution-heatmap-date-range
YamiNo-Okami May 28, 2026
08a1bc0
Merge branch 'main' into feature/contribution-heatmap-date-range
YamiNo-Okami May 28, 2026
aafc6c3
Merge branch 'main' into feature/contribution-heatmap-date-range
YamiNo-Okami May 28, 2026
7b08f26
Merge branch 'main' into feature/contribution-heatmap-date-range
YamiNo-Okami May 28, 2026
9183cfe
Merge branch 'main' into feature/contribution-heatmap-date-range
YamiNo-Okami May 28, 2026
b361988
Merge branch 'main' into feature/contribution-heatmap-date-range
YamiNo-Okami May 28, 2026
b2dc926
Merge branch 'main' into feature/contribution-heatmap-date-range
YamiNo-Okami May 28, 2026
0ad5740
Merge branch 'main' into feature/contribution-heatmap-date-range
YamiNo-Okami May 28, 2026
28dfeb9
Merge branch 'main' into feature/contribution-heatmap-date-range
YamiNo-Okami May 28, 2026
0c58724
Merge branch 'main' into feature/contribution-heatmap-date-range
YamiNo-Okami May 28, 2026
cef662c
Merge branch 'main' into feature/contribution-heatmap-date-range
YamiNo-Okami May 28, 2026
0942c5e
Merge branch 'main' into feature/contribution-heatmap-date-range
YamiNo-Okami May 28, 2026
f1ad2da
Merge branch 'main' into feature/contribution-heatmap-date-range
YamiNo-Okami May 28, 2026
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
274 changes: 249 additions & 25 deletions src/components/ContributionHeatmap.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useHeatmapTheme } from "@/hooks/useHeatmapTheme";
import DailyBreakdownSheet from "@/components/DailyBreakdownSheet";

Expand All @@ -20,13 +20,20 @@ interface HeatmapCell {
}

const DEFAULT_DAYS = 365;
const CELL_SIZE = 12;
const CELL_GAP = 2;
const LABEL_WIDTH = 42;
const HEADER_HEIGHT = 18;
const CELL_SIZE = 14;
const CELL_GAP = 3;
const LABEL_WIDTH = 48;
const HEADER_HEIGHT = 20;

const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];

const PRESET_RANGES = [
{ label: "30d", days: 30 },
{ label: "90d", days: 90 },
{ label: "6mo", days: 180 },
{ label: "1yr", days: 365 },
] as const;

// Memoized formatting engine to avoid recreation garbage collection cycles inside render loops
const monthFormatter = new Intl.DateTimeFormat("en-US", { month: "short" });

Expand All @@ -37,13 +44,24 @@ function formatDateKey(date: Date) {
return `${year}-${month}-${day}`;
}

function buildHeatmap(days: number, contributions: Record<string, number>) {
const endDate = new Date();
endDate.setHours(23, 59, 59, 999);

const startDate = new Date(endDate);
startDate.setDate(endDate.getDate() - (days - 1));
startDate.setHours(0, 0, 0, 0);
function buildHeatmap(days: number, contributions: Record<string, number>, fromDate?: string, toDate?: string) {
let endDate: Date;
let startDate: Date;

if (fromDate && toDate) {
// Use provided custom date range
endDate = new Date(toDate);
endDate.setHours(23, 59, 59, 999);
startDate = new Date(fromDate);
startDate.setHours(0, 0, 0, 0);
} else {
// Calculate from N days ago until today
endDate = new Date();
endDate.setHours(23, 59, 59, 999);
startDate = new Date(endDate);
startDate.setDate(endDate.getDate() - (days - 1));
startDate.setHours(0, 0, 0, 0);
}

const firstWeekStart = new Date(startDate);
firstWeekStart.setDate(startDate.getDate() - startDate.getDay());
Expand Down Expand Up @@ -81,12 +99,117 @@ export default function ContributionHeatmap({
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const handleCloseSheet = useCallback(() => setSelectedDate(null), []);

// Range state
const [selectedDays, setSelectedDays] = useState(days);
const [showPopover, setShowPopover] = useState(false);
const [customFrom, setCustomFrom] = useState("");
const [customTo, setCustomTo] = useState("");
const [customLabel, setCustomLabel] = useState<string | null>(null);
const [customError, setCustomError] = useState<string | null>(null);
const popoverRef = useRef<HTMLDivElement>(null);

// Load persisted range preference
useEffect(() => {
if (typeof window !== "undefined") {
try {
const stored = localStorage.getItem("devtrack:heatmap-range");
if (stored === "30" || stored === "90" || stored === "180" || stored === "365") {
setSelectedDays(Number(stored));
} else {
localStorage.setItem("devtrack:heatmap-range", String(days));
}
} catch {
setSelectedDays(days);
}
}
}, [days]);

// Handle popover dismiss
useEffect(() => {
if (!showPopover) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setShowPopover(false);
};
const handleClick = (e: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
setShowPopover(false);
}
};
document.addEventListener("keydown", handleKey);
document.addEventListener("mousedown", handleClick);
return () => {
document.removeEventListener("keydown", handleKey);
document.removeEventListener("mousedown", handleClick);
};
}, [showPopover]);

const handleRangeChange = (newDays: number) => {
setSelectedDays(newDays);
setCustomLabel(null);
setCustomFrom("");
setCustomTo("");
setCustomError(null);
if (typeof window !== "undefined") {
try {
localStorage.setItem("devtrack:heatmap-range", String(newDays));
} catch {}
}
};

const handleCustomApply = () => {
setCustomError(null);
const today = new Date().toISOString().slice(0, 10);

if (!customFrom || !customTo) {
setCustomError("Please select both dates.");
return;
}
if (customFrom > customTo) {
setCustomError("Start date must be before end date.");
return;
}
if (customTo > today) {
setCustomError("End date can't be in the future.");
return;
}
const msPerDay = 1000 * 60 * 60 * 24;
const diff =
(new Date(customTo).getTime() - new Date(customFrom).getTime()) / msPerDay;
if (diff > 365 * 2) {
setCustomError("Max range is 2 years.");
return;
}

const fmt = (d: string) => {
const [year, month, day] = d.split("-").map(Number);
return new Date(year, month - 1, day).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
};
setCustomLabel(`${fmt(customFrom)} – ${fmt(customTo)}`);
setShowPopover(false);
};

const currentFrom = customLabel ? customFrom : (() => {
const endDate = new Date();
const startDate = new Date(endDate);
startDate.setDate(endDate.getDate() - (selectedDays - 1));
return formatDateKey(startDate);
})();

const currentTo = customLabel ? customTo : formatDateKey(new Date());

useEffect(() => {
let active = true;
setLoading(true);
setError(null);

fetch(`/api/metrics/contributions?days=${days}`)
const params = new URLSearchParams();
params.set("from", currentFrom);
params.set("to", currentTo);

fetch(`/api/metrics/contributions?${params.toString()}`)
.then((response) => {
if (!response.ok) throw new Error("API error");
return response.json();
Expand All @@ -109,7 +232,7 @@ export default function ContributionHeatmap({
return () => {
active = false;
};
}, [days]);
}, [currentFrom, currentTo]);

useEffect(() => {
if (!lastUpdated) return;
Expand All @@ -120,7 +243,26 @@ export default function ContributionHeatmap({
}, [lastUpdated]);

const { themeConfig, theme, setTheme } = useHeatmapTheme();
const cells = useMemo(() => buildHeatmap(days, data), [days, data]);

const displayDays = useMemo(() => {
if (customLabel && customFrom && customTo) {
const msPerDay = 1000 * 60 * 60 * 24;
return Math.ceil(
(new Date(customTo).getTime() - new Date(customFrom).getTime()) / msPerDay
) + 1;
}
return selectedDays;
}, [customLabel, customFrom, customTo, selectedDays]);

const cells = useMemo(
() => buildHeatmap(
displayDays,
data,
customLabel ? customFrom : undefined,
customLabel ? customTo : undefined
),
[displayDays, data, customLabel, customFrom, customTo]
);
const weekCount = Math.ceil(cells.length / 7);
const maxCommits = Math.max(
...cells.map((cell) => cell.count),
Expand Down Expand Up @@ -191,10 +333,89 @@ export default function ContributionHeatmap({
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<h2 className="text-lg font-semibold text-[var(--card-foreground)]">Contribution Heatmap</h2>
<p className="text-sm text-[var(--muted-foreground)]">Last {days} days of commit activity.</p>
<p className="text-sm text-[var(--muted-foreground)]">
{customLabel ? `${customLabel}` : `Last ${selectedDays} days of commit activity.`}
</p>
</div>

<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
{/* Range buttons */}
<div className="flex gap-1 rounded-lg border border-[var(--border)] bg-[var(--background)] p-1">
{PRESET_RANGES.map((r) => (
<button
key={r.days}
onClick={() => handleRangeChange(r.days)}
aria-label={`Show ${r.days}-day range`}
aria-pressed={selectedDays === r.days && !customLabel}
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors ${
selectedDays === r.days && !customLabel
? "bg-[var(--accent)] text-[var(--background)]"
: "text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
}`}
>
{r.label}
</button>
))}
</div>

{/* Custom date range popover */}
<div className="relative" ref={popoverRef}>
<button
onClick={() => setShowPopover((v) => !v)}
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors border border-[var(--border)] ${
customLabel
? "bg-[var(--accent)] text-[var(--background)]"
: "text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
}`}
>
{customLabel ?? "Custom…"}
</button>

{showPopover && (
<div className="absolute right-0 top-10 z-50 w-72 rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 shadow-lg">
<p className="text-sm font-medium text-[var(--foreground)] mb-3">
Custom range
</p>
<div className="flex flex-col gap-2">
<label className="text-xs text-[var(--muted-foreground)]">
Start date
<input
type="date"
value={customFrom}
max={new Date().toISOString().slice(0, 10)}
onChange={(e) => {
setCustomFrom(e.target.value);
if (!customTo) {
setCustomTo(new Date().toISOString().slice(0, 10));
}
}}
className="mt-1 w-full rounded-md border border-[var(--border)] bg-[var(--background)] px-2 py-1 text-xs text-[var(--foreground)]"
/>
</label>
<label className="text-xs text-[var(--muted-foreground)]">
End date
<input
type="date"
value={customTo}
max={new Date().toISOString().slice(0, 10)}
onChange={(e) => setCustomTo(e.target.value)}
className="mt-1 w-full rounded-md border border-[var(--border)] bg-[var(--background)] px-2 py-1 text-xs text-[var(--foreground)]"
/>
</label>
{customError && (
<p className="text-xs text-[var(--destructive)]">{customError}</p>
)}
<button
onClick={handleCustomApply}
className="mt-2 w-full rounded-md bg-[var(--accent)] px-3 py-1 text-xs font-medium text-[var(--background)] transition-opacity hover:opacity-90"
>
Apply
</button>
</div>
</div>
)}
</div>

<button
type="button"
onClick={() => setTheme("default")}
Expand Down Expand Up @@ -251,22 +472,25 @@ export default function ContributionHeatmap({

{/* MATHEMATICAL COORDINATE TIMELINE HEADER BANNER CONTAINER */}
<div
className="relative w-full text-[10px] font-medium text-[var(--muted-foreground)]"
className="relative w-full text-[11px] font-semibold text-[var(--foreground)]"
style={{ height: `${HEADER_HEIGHT}px` }}
>
{monthMarkers.map((marker) => {
{monthMarkers.map((marker, idx) => {
const absoluteLeftOffset = LABEL_WIDTH + (marker.weekIndex * (CELL_SIZE + CELL_GAP));
const nextMarker = monthMarkers[idx + 1];
const nextOffset = nextMarker ? LABEL_WIDTH + (nextMarker.weekIndex * (CELL_SIZE + CELL_GAP)) : totalGridWidth;
const availableWidth = nextOffset - absoluteLeftOffset - 8;

return (
<div
key={`${marker.label}-${marker.weekIndex}`}
className="absolute top-0 text-left overflow-hidden text-ellipsis whitespace-nowrap"
className="absolute top-0 truncate font-semibold"
style={{
left: `${absoluteLeftOffset}px`,
width: "auto",
minWidth: "max-content",
width: `${Math.max(0, availableWidth)}px`,
paddingRight: "4px",
}}
title={marker.label}
>
{marker.label}
</div>
Expand Down Expand Up @@ -310,8 +534,8 @@ export default function ContributionHeatmap({
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"
className={`group relative z-0 h-4 w-4 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-20 ${
cell.inRange ? "opacity-100" : "opacity-40"
}`}
style={{
gridRow: dayIndex + 1,
Expand Down Expand Up @@ -346,7 +570,7 @@ export default function ContributionHeatmap({

<div className="mt-4 flex items-center justify-between gap-4 text-xs text-[var(--muted-foreground)]">
<p>
{cells.filter((cell) => cell.inRange).reduce((total, cell) => total + cell.count, 0)} commits shown across {days} days.
{cells.filter((cell) => cell.inRange).reduce((total, cell) => total + cell.count, 0)} commits shown.
</p>
{lastUpdated && (
<p>{minutesAgo === 0 ? "Updated just now" : `Updated ${minutesAgo} min ago`}</p>
Expand Down
Loading