CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
contribution-heatmap.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 4438e31 | 1 | /** |
| 2 | * Block J9 — GitHub-style contribution heatmap. | |
| 3 | * | |
| 4 | * Takes a list of timestamped activity entries and produces a 53-week grid | |
| 5 | * (Sunday-aligned) of daily counts + level buckets 0-4, plus rollup stats. | |
| 6 | * Pure. Zero IO. The rendering of the grid lives in the caller (web.tsx) | |
| 7 | * so we can keep this file server/client agnostic. | |
| 8 | */ | |
| 9 | ||
| 10 | export interface ActivityTs { | |
| 11 | createdAt: Date | string | number; | |
| 12 | } | |
| 13 | ||
| 14 | export interface HeatmapDay { | |
| 15 | date: string; // YYYY-MM-DD (UTC) | |
| 16 | count: number; | |
| 17 | level: 0 | 1 | 2 | 3 | 4; | |
| 18 | dow: number; // 0 (Sun) – 6 (Sat) | |
| 19 | } | |
| 20 | ||
| 21 | export interface HeatmapWeek { | |
| 22 | /** Seven days, index 0 = Sunday. Entries before window start / after today | |
| 23 | * are null so callers can render empty cells at the edges. */ | |
| 24 | days: Array<HeatmapDay | null>; | |
| 25 | } | |
| 26 | ||
| 27 | export interface Heatmap { | |
| 28 | weeks: HeatmapWeek[]; | |
| 29 | totalContributions: number; | |
| 30 | maxDayCount: number; | |
| 31 | /** Longest uninterrupted streak inside the window. */ | |
| 32 | longestStreak: number; | |
| 33 | /** Streak counting back from `today`. Zero if today is empty. */ | |
| 34 | currentStreak: number; | |
| 35 | /** Inclusive start / end (UTC, YYYY-MM-DD). */ | |
| 36 | startDate: string; | |
| 37 | endDate: string; | |
| 38 | } | |
| 39 | ||
| 40 | /** Strip time, coerce to UTC midnight. */ | |
| 41 | export function startOfUtcDay(d: Date): Date { | |
| 42 | const x = new Date(d); | |
| 43 | x.setUTCHours(0, 0, 0, 0); | |
| 44 | return x; | |
| 45 | } | |
| 46 | ||
| 47 | export function formatDateKey(d: Date): string { | |
| 48 | const y = d.getUTCFullYear(); | |
| 49 | const m = String(d.getUTCMonth() + 1).padStart(2, "0"); | |
| 50 | const day = String(d.getUTCDate()).padStart(2, "0"); | |
| 51 | return `${y}-${m}-${day}`; | |
| 52 | } | |
| 53 | ||
| 54 | export function daysBetween(a: Date, b: Date): number { | |
| 55 | const ms = startOfUtcDay(b).getTime() - startOfUtcDay(a).getTime(); | |
| 56 | return Math.round(ms / 86_400_000); | |
| 57 | } | |
| 58 | ||
| 59 | /** | |
| 60 | * Map a raw day-count to one of 5 levels. GitHub's thresholds are non-linear; | |
| 61 | * we mirror with quartiles of the max count (fast, good enough for v1). | |
| 62 | */ | |
| 63 | export function levelFor(count: number, max: number): 0 | 1 | 2 | 3 | 4 { | |
| 64 | if (count <= 0) return 0; | |
| 65 | if (max <= 0) return 0; | |
| 66 | const q = count / max; | |
| 67 | if (q > 0.75) return 4; | |
| 68 | if (q > 0.5) return 3; | |
| 69 | if (q > 0.25) return 2; | |
| 70 | return 1; | |
| 71 | } | |
| 72 | ||
| 73 | /** | |
| 74 | * Build a 53-week Sunday-aligned grid ending on `today` and covering | |
| 75 | * `windowDays` days. Activities outside the window are ignored. | |
| 76 | */ | |
| 77 | export function buildHeatmap( | |
| 78 | activities: ActivityTs[], | |
| 79 | windowDays: number = 365, | |
| 80 | today: Date = new Date() | |
| 81 | ): Heatmap { | |
| 82 | const endDay = startOfUtcDay(today); | |
| 83 | const startDay = new Date(endDay); | |
| 84 | startDay.setUTCDate(startDay.getUTCDate() - (windowDays - 1)); | |
| 85 | ||
| 86 | const byDay = new Map<string, number>(); | |
| 87 | for (const a of activities) { | |
| 88 | const d = new Date(a.createdAt); | |
| 89 | if (isNaN(d.getTime())) continue; | |
| 90 | const k = startOfUtcDay(d).getTime(); | |
| 91 | if (k < startDay.getTime() || k > endDay.getTime()) continue; | |
| 92 | const key = formatDateKey(new Date(k)); | |
| 93 | byDay.set(key, (byDay.get(key) || 0) + 1); | |
| 94 | } | |
| 95 | ||
| 96 | // Pad the grid to start on a Sunday. If startDay is not Sunday, prepend | |
| 97 | // nulls back to the previous Sunday so the columns align by week. | |
| 98 | const firstDow = startDay.getUTCDay(); // 0 = Sun | |
| 99 | const gridStart = new Date(startDay); | |
| 100 | gridStart.setUTCDate(gridStart.getUTCDate() - firstDow); | |
| 101 | ||
| 102 | // End on Saturday of the endDay's week. | |
| 103 | const endDow = endDay.getUTCDay(); | |
| 104 | const gridEnd = new Date(endDay); | |
| 105 | gridEnd.setUTCDate(gridEnd.getUTCDate() + (6 - endDow)); | |
| 106 | ||
| 107 | // Compute max for level buckets first. | |
| 108 | let maxDayCount = 0; | |
| 109 | for (const v of byDay.values()) if (v > maxDayCount) maxDayCount = v; | |
| 110 | ||
| 111 | const weeks: HeatmapWeek[] = []; | |
| 112 | let cursor = new Date(gridStart); | |
| 113 | let totalContributions = 0; | |
| 114 | let longestStreak = 0; | |
| 115 | let currentStreakAtEnd = 0; | |
| 116 | let runningStreak = 0; | |
| 117 | ||
| 118 | while (cursor.getTime() <= gridEnd.getTime()) { | |
| 119 | const days: Array<HeatmapDay | null> = []; | |
| 120 | for (let i = 0; i < 7; i++) { | |
| 121 | const isBeforeStart = cursor.getTime() < startDay.getTime(); | |
| 122 | const isAfterEnd = cursor.getTime() > endDay.getTime(); | |
| 123 | if (isBeforeStart || isAfterEnd) { | |
| 124 | days.push(null); | |
| 125 | } else { | |
| 126 | const key = formatDateKey(cursor); | |
| 127 | const count = byDay.get(key) || 0; | |
| 128 | totalContributions += count; | |
| 129 | if (count > 0) { | |
| 130 | runningStreak += 1; | |
| 131 | if (runningStreak > longestStreak) longestStreak = runningStreak; | |
| 132 | } else { | |
| 133 | runningStreak = 0; | |
| 134 | } | |
| 135 | // Capture the streak value AT the endDay so we can report | |
| 136 | // currentStreak (trailing streak), which may or may not be == longest. | |
| 137 | if (cursor.getTime() === endDay.getTime()) { | |
| 138 | currentStreakAtEnd = runningStreak; | |
| 139 | } | |
| 140 | days.push({ | |
| 141 | date: key, | |
| 142 | count, | |
| 143 | level: levelFor(count, maxDayCount), | |
| 144 | dow: cursor.getUTCDay(), | |
| 145 | }); | |
| 146 | } | |
| 147 | cursor = new Date(cursor); | |
| 148 | cursor.setUTCDate(cursor.getUTCDate() + 1); | |
| 149 | } | |
| 150 | weeks.push({ days }); | |
| 151 | } | |
| 152 | ||
| 153 | return { | |
| 154 | weeks, | |
| 155 | totalContributions, | |
| 156 | maxDayCount, | |
| 157 | longestStreak, | |
| 158 | currentStreak: currentStreakAtEnd, | |
| 159 | startDate: formatDateKey(startDay), | |
| 160 | endDate: formatDateKey(endDay), | |
| 161 | }; | |
| 162 | } | |
| 163 | ||
| 164 | export const __internal = { formatDateKey, startOfUtcDay, daysBetween }; |