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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,5 @@ test-results/

.vercel
.env*

package-lock.json
1 change: 0 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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

// 100% MATHEMATICALLY PRECISE MONTH TRACKING SYSTEM
Expand Down Expand Up @@ -169,10 +311,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 @@ -232,22 +453,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 @@ -290,8 +514,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 @@ -329,7 +553,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