From b10dfcad676ec14ac9dbc46dca4f94b16bc2d386 Mon Sep 17 00:00:00 2001 From: Prerak Yadav Date: Sat, 27 Jun 2026 07:48:14 +0530 Subject: [PATCH] feat(heatmap): add shared 365-day contribution calendar (#18) Extract ContributionHeatmapCalendar for dashboard and public profiles, paginate public contribution fetch for a full year, and align cell colors to absolute commit thresholds. --- README.md | 2 +- src/app/u/[username]/page.tsx | 61 +-- src/components/ContributionHeatmap.tsx | 511 ++---------------- .../ContributionHeatmapCalendar.tsx | 226 ++++++++ .../public/PublicContributionHeatmap.tsx | 38 ++ src/lib/contribution-heatmap.ts | 118 ++++ src/lib/public-profile-data.ts | 60 +- test/contribution-heatmap-calendar.test.ts | 67 +++ test/public-profile-data.test.ts | 28 + 9 files changed, 586 insertions(+), 525 deletions(-) create mode 100644 src/components/ContributionHeatmapCalendar.tsx create mode 100644 src/components/public/PublicContributionHeatmap.tsx create mode 100644 src/lib/contribution-heatmap.ts create mode 100644 test/contribution-heatmap-calendar.test.ts diff --git a/README.md b/README.md index 256484eb4..3c56645cb 100644 --- a/README.md +++ b/README.md @@ -410,6 +410,7 @@ These features are live in the current version. | RSS feed | Atom feed at `/u/[username]/feed.xml` | | Year Wrapped | Animated annual coding journey recap | | Real-time dashboard | Live Supabase Realtime sync with polling fallback | +| Contribution heatmap calendar | GitHub-style 365-day grid on dashboard and public profiles ([#18](https://github.com/Priyanshu-byte-coder/devtrack/issues/18)) | ### In Progress / Planned @@ -417,7 +418,6 @@ Want to contribute? Pick an item below and open an issue or start a PR. | Feature | Difficulty | Issue | |---|---|---| -| Contribution heatmap calendar | Intermediate | [#18](https://github.com/Priyanshu-byte-coder/devtrack/issues/18) | | Chart type toggle (bar / line) | Intermediate | [#17](https://github.com/Priyanshu-byte-coder/devtrack/issues/17) | | Language breakdown widget | Intermediate | [#32](https://github.com/Priyanshu-byte-coder/devtrack/issues/32) | | Activity feed | Intermediate | [#33](https://github.com/Priyanshu-byte-coder/devtrack/issues/33) | diff --git a/src/app/u/[username]/page.tsx b/src/app/u/[username]/page.tsx index 3a4a2afce..999712b19 100644 --- a/src/app/u/[username]/page.tsx +++ b/src/app/u/[username]/page.tsx @@ -13,6 +13,7 @@ import ShareProfileSection from "@/components/ShareProfileSection"; import SponsorBadge from "@/components/SponsorBadge"; import PinnedReposWidget from "@/components/PinnedReposWidget"; import CopyLinkButton from "@/components/CopyLinkButton"; +import PublicContributionHeatmap from "@/components/public/PublicContributionHeatmap"; import { Moon, Sun } from "lucide-react"; import { authOptions } from "@/lib/auth"; import { getUserByGithubId } from "@/lib/supabase"; @@ -367,7 +368,7 @@ export default async function PublicProfilePage({
{showContributions && (
- +
)} {showStreak && ( @@ -446,64 +447,6 @@ export default async function PublicProfilePage({ ); } -function PublicContributionGraph({ - data: contributionData, -}: { - data: { - days: number; - total: number; - data: Record; - }; -}) { - const data = Object.entries(contributionData.data ?? {}) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([day, commits]) => ({ day, commits })); - - return ( -
-
-
-

- Commit Activity ({contributionData.days} days) -

-

- Total commits: {contributionData.total} -

-
-
- - {data.length === 0 ? ( -

- No commit data available. -

- ) : ( -
-
- {data.length} active days -
-
- {data.map((day) => ( -
0 ? "var(--accent)" : "var(--control)", - opacity: - day.commits > 0 - ? Math.max(0.2, Math.min(day.commits / 10, 1)) - : 1, - }} - title={`${day.day}: ${day.commits} commits`} - /> - ))} -
-
- )} -
- ); -} - function PublicStreakTracker({ streak }: { streak: any }) { const stats = [ { diff --git a/src/components/ContributionHeatmap.tsx b/src/components/ContributionHeatmap.tsx index db8e0b260..7e5a02eb0 100644 --- a/src/components/ContributionHeatmap.tsx +++ b/src/components/ContributionHeatmap.tsx @@ -3,8 +3,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useHeatmapTheme } from "@/hooks/useHeatmapTheme"; import DailyBreakdownSheet from "@/components/DailyBreakdownSheet"; -import { getContributionInsights } from "@/lib/contribution-insights"; -import { Calendar, TrendingUp, Zap, Clock, Award, BarChart2 } from "lucide-react"; +import ContributionHeatmapCalendar from "@/components/ContributionHeatmapCalendar"; +import { formatDateKey, countInRangeCommits, buildHeatmap } from "@/lib/contribution-heatmap"; interface ContributionHeatmapProps { days?: number; @@ -14,20 +14,7 @@ interface ContributionResponse { data: Record; } -interface HeatmapCell { - date: Date; - dateKey: string; - count: number; - inRange: boolean; -} - const DEFAULT_DAYS = 365; -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 }, @@ -36,64 +23,6 @@ const PRESET_RANGES = [ { 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" }); - -function formatDateKey(date: Date) { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; -} - -function formatCommitCount(count: number) { - return `${count} commit${count === 1 ? "" : "s"}`; -} - -function buildHeatmap(days: number, contributions: Record, 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()); - firstWeekStart.setHours(0, 0, 0, 0); - - const lastWeekEnd = new Date(endDate); - lastWeekEnd.setDate(endDate.getDate() + (6 - endDate.getDay())); - lastWeekEnd.setHours(23, 59, 59, 999); - - const cells: HeatmapCell[] = []; - const cursor = new Date(firstWeekStart); - - while (cursor <= lastWeekEnd) { - const dateKey = formatDateKey(cursor); - cells.push({ - date: new Date(cursor), - dateKey, - count: contributions[dateKey] ?? 0, - inRange: cursor >= startDate && cursor <= endDate, - }); - cursor.setDate(cursor.getDate() + 1); - } - - return cells; -} - export default function ContributionHeatmap({ days = DEFAULT_DAYS, }: ContributionHeatmapProps) { @@ -105,7 +34,6 @@ export default function ContributionHeatmap({ const [selectedDate, setSelectedDate] = useState(null); const handleCloseSheet = useCallback(() => setSelectedDate(null), []); - // Range state const [selectedDays, setSelectedDays] = useState(days); const [showPopover, setShowPopover] = useState(false); const [customFrom, setCustomFrom] = useState(""); @@ -114,7 +42,6 @@ export default function ContributionHeatmap({ const [customError, setCustomError] = useState(null); const popoverRef = useRef(null); - // Load persisted range preference useEffect(() => { if (typeof window !== "undefined") { try { @@ -124,13 +51,12 @@ export default function ContributionHeatmap({ } else { localStorage.setItem("devtrack:heatmap-range", String(days)); } - } catch (e) { + } catch { setSelectedDays(days); } } }, [days]); - // Handle popover dismiss useEffect(() => { if (!showPopover) return; const handleKey = (e: KeyboardEvent) => { @@ -158,7 +84,9 @@ export default function ContributionHeatmap({ if (typeof window !== "undefined") { try { localStorage.setItem("devtrack:heatmap-range", String(newDays)); - } catch (e) {} + } catch { + /* ignore */ + } } }; @@ -197,12 +125,14 @@ export default function ContributionHeatmap({ 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 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()); @@ -214,7 +144,7 @@ export default function ContributionHeatmap({ 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"); @@ -249,121 +179,42 @@ export default function ContributionHeatmap({ }, [lastUpdated]); const { themeConfig, theme, setTheme } = useHeatmapTheme(); - + 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 ( + Math.ceil( + (new Date(customTo).getTime() - new Date(customFrom).getTime()) / msPerDay + ) + 1 + ); } return selectedDays; }, [customLabel, customFrom, customTo, selectedDays]); - - const cells = useMemo( - () => buildHeatmap( - displayDays, + + const totalCommits = useMemo(() => { + const cells = 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), - 1 - ); - // 100% MATHEMATICALLY PRECISE MONTH TRACKING SYSTEM - const monthMarkers = useMemo(() => { - const markers: Array<{ label: string; weekIndex: number }> = []; - const seenMonths = new Set(); - - for (let w = 0; w < weekCount; w++) { - const weekCells = cells.slice(w * 7, (w + 1) * 7); - - for (const cell of weekCells) { - if (!cell.inRange) continue; - - const currentMonth = cell.date.getMonth(); - const currentYear = cell.date.getFullYear(); - const monthKey = `${currentYear}-${currentMonth}`; - - if (!seenMonths.has(monthKey)) { - seenMonths.add(monthKey); - - markers.push({ - label: monthFormatter.format(cell.date), - weekIndex: w, - }); - break; // Move immediately to scanning the next column track block - } - } - } - return markers; - }, [cells, weekCount]); - - // Shared matrix geometries matching baseline canvas dimensions - const totalGridWidth = LABEL_WIDTH + (weekCount * CELL_SIZE) + ((weekCount - 1) * CELL_GAP); - - const gridStyle = { - gridTemplateColumns: `${LABEL_WIDTH}px repeat(${weekCount}, ${CELL_SIZE}px)`, - gridTemplateRows: `repeat(7, ${CELL_SIZE}px)`, - columnGap: `${CELL_GAP}px`, - rowGap: `${CELL_GAP}px`, - } as const; - - const today = new Date(); - const getHeatmapColor = (count: number) => { - if (count === 0) return themeConfig.missed; - - const normalized = count / maxCommits; - - if (normalized <= 0.25) { - return themeConfig.levelOne; - } - - if (normalized <= 0.5) { - return themeConfig.levelTwo; - } - - if (normalized <= 0.75) { - return themeConfig.levelThree; - } - - return themeConfig.levelFour; - }; - const totalCommits = cells - .filter((cell) => cell.inRange) - .reduce((total, cell) => total + cell.count, 0); - const { inRangeDays, insights } = useMemo(() => { - const days = cells - .filter((cell) => cell.inRange) - .map((cell) => ({ - date: cell.date, - dateKey: cell.dateKey, - count: cell.count, - })); - return { - inRangeDays: days, - insights: getContributionInsights(days), - }; - }, [cells]); - - const heatmapSummary = `Contribution heatmap showing ${formatCommitCount(totalCommits)} across ${displayDays} days.`; + ); + return countInRangeCommits(cells); + }, [displayDays, data, customLabel, customFrom, customTo]); return (
-
-
-

Contribution Heatmap

-

+

+
+

+ Contribution Heatmap +

+

{customLabel ? `${customLabel}` : `Last ${selectedDays} days of commit activity.`}

-
- {/* Range buttons */} +
{PRESET_RANGES.map((r) => (
- - {/* Legend - Less / More */} -
- Less -
- {[ - 0, - Math.ceil(maxCommits * 0.25), - Math.ceil(maxCommits * 0.5), - Math.ceil(maxCommits * 0.75), - maxCommits, - ].map((count, index) => { - const swatch = getHeatmapColor(count); - return ( - - ); - })} -
- More -
- {loading ? ( -
- ) : error ? ( + {error ? (

{error} Please try refreshing.

) : ( <> -
-
- - {/* MATHEMATICAL COORDINATE TIMELINE HEADER BANNER CONTAINER */} -
- {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 ( -
- {marker.label} -
- ); - })} -
- - {/* Grid System Area mapping identical columns */} -
- {DAY_LABELS.map((label, rowIndex) => ( -
- {rowIndex % 2 === 0 ? label : ""} -
- ))} - - {cells.map((cell, index) => { - const weekIndex = Math.floor(index / 7); - const dayIndex = index % 7; - const isFuture = cell.date > today; - const showTooltipBelow = dayIndex < 2; - const isNearRightEdge = weekIndex >= weekCount - 3; - const formattedDate = cell.date.toLocaleDateString("en-US", { - weekday: "short", - month: "short", - day: "numeric", - }); - const accessibleLabel = `${formatCommitCount(cell.count)} on ${cell.dateKey}`; - const tooltip = `${formatCommitCount(cell.count)} on ${formattedDate}`; - - return ( - - ); - })} -
-
-
- - {/* Commits shown + Updated timestamp */}

{totalCommits} commits shown across {displayDays} days. @@ -608,135 +343,9 @@ export default function ContributionHeatmap({

{minutesAgo === 0 ? "Updated just now" : `Updated ${minutesAgo} min ago`}

)}
- - {/* Contribution Insights section */} -
-

- Contribution Insights -

- - {inRangeDays.length === 0 ? ( -

No contribution data available for this range.

- ) : ( -
- {/* Most Productive Weekday */} -
-
-
-
- - {insights.mostProductiveWeekday} - -

- Day with highest average commits -

-
-
- - {/* Average Weekly Contributions */} -
-
-
-
- - {insights.averageWeeklyContributions.toFixed(1)} - -

- Commits per 7 calendar days -

-
-
- - {/* Longest Contribution Streak */} -
-
-
-
- - {insights.longestStreak} {insights.longestStreak === 1 ? 'day' : 'days'} - -

- Max consecutive active days -

-
-
- - {/* Longest Inactive Period */} -
-
-
-
- - {insights.longestInactivePeriod} {insights.longestInactivePeriod === 1 ? 'day' : 'days'} - -

- Max consecutive days with zero commits -

-
-
- - {/* Monthly Contribution Trends */} -
-
-
-
- - {insights.monthlyTrend} - -

- Recent month vs preceding month -

-
-
- - {/* Best Contribution Month */} -
-
-
-
- - {insights.bestMonth} - -

- Highest total ({insights.bestMonthTotal} commits) -

-
-
-
- )} -
)} + ; + days?: number; + fromDate?: string; + toDate?: string; + themeConfig: HeatmapThemeConfig; + onCellClick?: (dateKey: string) => void; + loading?: boolean; + className?: string; + showLegend?: boolean; +} + +export default function ContributionHeatmapCalendar({ + data, + days = 365, + fromDate, + toDate, + themeConfig, + onCellClick, + loading = false, + className = "", + showLegend = true, +}: ContributionHeatmapCalendarProps) { + const displayDays = useMemo(() => { + if (fromDate && toDate) { + const msPerDay = 1000 * 60 * 60 * 24; + return ( + Math.ceil( + (new Date(toDate).getTime() - new Date(fromDate).getTime()) / msPerDay + ) + 1 + ); + } + return days; + }, [days, fromDate, toDate]); + + const cells = useMemo( + () => buildHeatmap(displayDays, data, fromDate, toDate), + [displayDays, data, fromDate, toDate] + ); + + const weekCount = Math.ceil(cells.length / 7); + const monthMarkers = useMemo( + () => buildMonthMarkers(cells, weekCount), + [cells, weekCount] + ); + + const totalGridWidth = + HEATMAP_LABEL_WIDTH + weekCount * HEATMAP_CELL_SIZE + (weekCount - 1) * HEATMAP_CELL_GAP; + + const gridStyle = { + gridTemplateColumns: `${HEATMAP_LABEL_WIDTH}px repeat(${weekCount}, ${HEATMAP_CELL_SIZE}px)`, + gridTemplateRows: `repeat(7, ${HEATMAP_CELL_SIZE}px)`, + columnGap: `${HEATMAP_CELL_GAP}px`, + rowGap: `${HEATMAP_CELL_GAP}px`, + } as const; + + const today = new Date(); + today.setHours(23, 59, 59, 999); + + const totalCommits = countInRangeCommits(cells); + const heatmapSummary = `Contribution heatmap showing ${formatCommitCount(totalCommits)} across ${displayDays} days.`; + + if (loading) { + return ( +
+ ); + } + + return ( +
+
+
+
+ {monthMarkers.map((marker, idx) => { + const absoluteLeftOffset = + HEATMAP_LABEL_WIDTH + marker.weekIndex * (HEATMAP_CELL_SIZE + HEATMAP_CELL_GAP); + const nextMarker = monthMarkers[idx + 1]; + const nextOffset = nextMarker + ? HEATMAP_LABEL_WIDTH + + nextMarker.weekIndex * (HEATMAP_CELL_SIZE + HEATMAP_CELL_GAP) + : totalGridWidth; + const availableWidth = nextOffset - absoluteLeftOffset - 8; + + return ( +
+ {marker.label} +
+ ); + })} +
+ +
+ {HEATMAP_DAY_LABELS.map((label, rowIndex) => ( +
+ {rowIndex % 2 === 0 ? label : ""} +
+ ))} + + {cells.map((cell, index) => { + const weekIndex = Math.floor(index / 7); + const dayIndex = index % 7; + const isFuture = cell.date > today; + const showTooltipBelow = dayIndex < 2; + const isNearRightEdge = weekIndex >= weekCount - 3; + const cellStyle = getHeatmapCellStyle(cell.count, themeConfig); + const tooltip = formatCellTooltip(cell.count, cell.date); + const accessibleLabel = `${formatCommitCount(cell.count)} on ${cell.dateKey}`; + const isInteractive = !isFuture && Boolean(onCellClick); + + return ( + + ); + })} +
+
+
+ + {showLegend && ( +
+ Less +
+ {HEATMAP_LEGEND_COUNTS.map((count) => { + const swatch = getHeatmapCellStyle(count, themeConfig); + return ( + + ); + })} +
+ More +
+ )} +
+ ); +} diff --git a/src/components/public/PublicContributionHeatmap.tsx b/src/components/public/PublicContributionHeatmap.tsx new file mode 100644 index 000000000..de9073ba8 --- /dev/null +++ b/src/components/public/PublicContributionHeatmap.tsx @@ -0,0 +1,38 @@ +"use client"; + +import ContributionHeatmapCalendar from "@/components/ContributionHeatmapCalendar"; +import { useHeatmapTheme } from "@/hooks/useHeatmapTheme"; +import type { ContributionData } from "@/lib/public-profile-data"; + +interface PublicContributionHeatmapProps { + data: ContributionData; +} + +export default function PublicContributionHeatmap({ data: contributionData }: PublicContributionHeatmapProps) { + const { themeConfig } = useHeatmapTheme(); + + return ( +
+
+
+

+ Contribution Heatmap +

+

+ {contributionData.total} contributions in the last {contributionData.days} days +

+
+
+ + {Object.keys(contributionData.data ?? {}).length === 0 ? ( +

No commit data available.

+ ) : ( + + )} +
+ ); +} diff --git a/src/lib/contribution-heatmap.ts b/src/lib/contribution-heatmap.ts new file mode 100644 index 000000000..d5b02239b --- /dev/null +++ b/src/lib/contribution-heatmap.ts @@ -0,0 +1,118 @@ +export interface HeatmapCell { + date: Date; + dateKey: string; + count: number; + inRange: boolean; +} + +export const HEATMAP_CELL_SIZE = 14; +export const HEATMAP_CELL_GAP = 3; +export const HEATMAP_LABEL_WIDTH = 48; +export const HEATMAP_HEADER_HEIGHT = 20; +export const HEATMAP_DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const; + +/** Absolute commit counts matching getHeatmapCellStyle thresholds. */ +export const HEATMAP_LEGEND_COUNTS = [0, 1, 3, 6, 10] as const; + +const monthFormatter = new Intl.DateTimeFormat("en-US", { month: "short" }); + +export function formatDateKey(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +export function formatCommitCount(count: number): string { + return `${count} contribution${count === 1 ? "" : "s"}`; +} + +export function formatCellTooltip(count: number, date: Date): string { + const formattedDate = date.toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", + }); + return `${formatCommitCount(count)} on ${formattedDate}`; +} + +export function buildHeatmap( + days: number, + contributions: Record, + fromDate?: string, + toDate?: string +): HeatmapCell[] { + let endDate: Date; + let startDate: Date; + + if (fromDate && toDate) { + endDate = new Date(toDate); + endDate.setHours(23, 59, 59, 999); + startDate = new Date(fromDate); + startDate.setHours(0, 0, 0, 0); + } else { + 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()); + firstWeekStart.setHours(0, 0, 0, 0); + + const lastWeekEnd = new Date(endDate); + lastWeekEnd.setDate(endDate.getDate() + (6 - endDate.getDay())); + lastWeekEnd.setHours(23, 59, 59, 999); + + const cells: HeatmapCell[] = []; + const cursor = new Date(firstWeekStart); + + while (cursor <= lastWeekEnd) { + const dateKey = formatDateKey(cursor); + cells.push({ + date: new Date(cursor), + dateKey, + count: contributions[dateKey] ?? 0, + inRange: cursor >= startDate && cursor <= endDate, + }); + cursor.setDate(cursor.getDate() + 1); + } + + return cells; +} + +export function buildMonthMarkers( + cells: HeatmapCell[], + weekCount: number +): Array<{ label: string; weekIndex: number }> { + const markers: Array<{ label: string; weekIndex: number }> = []; + const seenMonths = new Set(); + + for (let w = 0; w < weekCount; w++) { + const weekCells = cells.slice(w * 7, (w + 1) * 7); + + for (const cell of weekCells) { + if (!cell.inRange) continue; + + const monthKey = `${cell.date.getFullYear()}-${cell.date.getMonth()}`; + + if (!seenMonths.has(monthKey)) { + seenMonths.add(monthKey); + markers.push({ + label: monthFormatter.format(cell.date), + weekIndex: w, + }); + break; + } + } + } + + return markers; +} + +export function countInRangeCommits(cells: HeatmapCell[]): number { + return cells.filter((cell) => cell.inRange).reduce((total, cell) => total + cell.count, 0); +} diff --git a/src/lib/public-profile-data.ts b/src/lib/public-profile-data.ts index 7558c9563..5056e67ce 100644 --- a/src/lib/public-profile-data.ts +++ b/src/lib/public-profile-data.ts @@ -129,37 +129,69 @@ export async function fetchPublicTopRepos( * Fetches the user's public contribution data over a specified number of days. * @param username - The GitHub username. * @param token - Optional GitHub personal access token. - * @param days - The number of days to look back for activity (default: 30). + * @param days - The number of days to look back for activity (default: 365). * @returns Contribution data including daily counts and total. */ export async function fetchPublicContributions( username: string, token?: string, - days = 30 + days = 365 ): Promise { const since = new Date(); since.setDate(since.getDate() - days); const sinceStr = since.toISOString().slice(0, 10); - const res = await ghFetch( - `${GITHUB_API}/search/commits?q=author:${username}+author-date:>=${sinceStr}&per_page=100&sort=author-date&order=desc`, - token - ); + const q = `author:${username}+author-date:>=${sinceStr}`; + let allItems: Array<{ commit: { author: { date: string } } }> = []; + let totalCount = 0; + let page = 1; + + while (page <= 10) { + const url = new URL(`${GITHUB_API}/search/commits`); + url.searchParams.set("q", q); + url.searchParams.set("per_page", "100"); + url.searchParams.set("page", String(page)); + url.searchParams.set("sort", "author-date"); + url.searchParams.set("order", "desc"); + + const res = await ghFetch(url.toString(), token); + + if (!res.ok) { + if (allItems.length === 0) { + return { days, total: 0, data: {} }; + } + break; + } - if (!res.ok) return { days, total: 0, data: {} }; + const data = (await res.json()) as { + total_count: number; + items: Array<{ commit: { author: { date: string } } }>; + }; - const data = (await res.json()) as { - total_count: number; - items: Array<{ commit: { author: { date: string } } }>; - }; + if (page === 1) { + totalCount = data.total_count; + } + + allItems = allItems.concat(data.items); + + if (data.items.length < 100) { + break; + } + + if (allItems.length >= 1000 || allItems.length >= totalCount) { + break; + } + + page += 1; + } const commitsByDay: Record = {}; - for (const item of data.items) { + for (const item of allItems) { const date = item.commit.author.date.slice(0, 10); commitsByDay[date] = (commitsByDay[date] ?? 0) + 1; } - return { days, total: data.total_count, data: commitsByDay }; + return { days, total: totalCount, data: commitsByDay }; } /** @@ -387,7 +419,7 @@ export async function fetchPublicProfile( ] = await Promise.all([ fetchPublicGists(user.github_login, githubToken), fetchPublicTopRepos(user.github_login, githubToken, 30), - fetchPublicContributions(user.github_login, githubToken, 30), + fetchPublicContributions(user.github_login, githubToken, 365), fetchPublicStreak(user.github_login, githubToken, user.timezone), fetchPublicTopLanguages(user.github_login, githubToken), fetchPublicPullRequests(user.github_login, githubToken), diff --git a/test/contribution-heatmap-calendar.test.ts b/test/contribution-heatmap-calendar.test.ts new file mode 100644 index 000000000..583b68c15 --- /dev/null +++ b/test/contribution-heatmap-calendar.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { + buildHeatmap, + buildMonthMarkers, + countInRangeCommits, + formatCellTooltip, + formatCommitCount, + formatDateKey, +} from "@/lib/contribution-heatmap"; + +describe("contribution-heatmap lib", () => { + it("formatDateKey returns YYYY-MM-DD", () => { + const date = new Date(2026, 2, 14); + expect(formatDateKey(date)).toBe("2026-03-14"); + }); + + it("formatCommitCount uses singular and plural", () => { + expect(formatCommitCount(1)).toBe("1 contribution"); + expect(formatCommitCount(3)).toBe("3 contributions"); + }); + + it("formatCellTooltip includes count and formatted date", () => { + const date = new Date(2026, 2, 14); + const tooltip = formatCellTooltip(3, date); + expect(tooltip).toContain("3 contributions"); + expect(tooltip).toContain("Mar"); + expect(tooltip).toContain("14"); + expect(tooltip).toContain("2026"); + }); + + it("buildHeatmap fills missing days with zero and marks inRange", () => { + const today = new Date(); + const todayKey = formatDateKey(today); + const contributions = { [todayKey]: 2 }; + const cells = buildHeatmap(7, contributions); + expect(cells.length % 7).toBe(0); + const inRange = cells.filter((c) => c.inRange); + expect(inRange.length).toBe(7); + const match = cells.find((c) => c.dateKey === todayKey); + expect(match?.count).toBe(2); + const empty = inRange.find((c) => c.count === 0); + expect(empty).toBeDefined(); + }); + + it("buildHeatmap uses custom from/to range", () => { + const cells = buildHeatmap(365, {}, "2026-01-01", "2026-01-07"); + const inRange = cells.filter((c) => c.inRange); + expect(inRange.length).toBe(7); + expect(inRange[0].dateKey).toBe("2026-01-01"); + expect(inRange[6].dateKey).toBe("2026-01-07"); + }); + + it("buildMonthMarkers returns one marker per month in range", () => { + const cells = buildHeatmap(60, {}, "2026-01-15", "2026-03-15"); + const weekCount = Math.ceil(cells.length / 7); + const markers = buildMonthMarkers(cells, weekCount); + expect(markers.length).toBeGreaterThanOrEqual(2); + expect(markers[0].label).toBeTruthy(); + }); + + it("countInRangeCommits sums only in-range cells", () => { + const cells = buildHeatmap(7, { + [formatDateKey(new Date())]: 4, + }); + expect(countInRangeCommits(cells)).toBe(4); + }); +}); diff --git a/test/public-profile-data.test.ts b/test/public-profile-data.test.ts index f217b64f7..e20b3a0d2 100644 --- a/test/public-profile-data.test.ts +++ b/test/public-profile-data.test.ts @@ -84,9 +84,37 @@ describe("public-profile-data", () => { vi.stubGlobal("fetch", mockFetch); const contributions = await fetchPublicContributions("user123"); + expect(contributions.days).toBe(365); expect(contributions.total).toBe(3); expect(contributions.data["2026-05-10"]).toBe(2); expect(contributions.data["2026-05-11"]).toBe(1); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("should paginate through multiple GitHub search pages", async () => { + const page1Items = Array.from({ length: 100 }, (_, i) => ({ + commit: { author: { date: `2026-05-${String((i % 28) + 1).padStart(2, "0")}T12:00:00Z` } }, + })); + const page2Items = [ + { commit: { author: { date: "2026-05-01T12:00:00Z" } } }, + ]; + + const mockFetch = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ total_count: 101, items: page1Items }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ total_count: 101, items: page2Items }), + }); + vi.stubGlobal("fetch", mockFetch); + + const contributions = await fetchPublicContributions("user123"); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(contributions.total).toBe(101); + expect(Object.values(contributions.data).reduce((a, b) => a + b, 0)).toBe(101); }); });