Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

contribution-heatmap.tsBlame164 lines · 1 contributor
4438e31Claude1/**
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
10export interface ActivityTs {
11 createdAt: Date | string | number;
12}
13
14export 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
21export 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
27export 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. */
41export function startOfUtcDay(d: Date): Date {
42 const x = new Date(d);
43 x.setUTCHours(0, 0, 0, 0);
44 return x;
45}
46
47export 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
54export 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 */
63export 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 */
77export 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
164export const __internal = { formatDateKey, startOfUtcDay, daysBetween };