Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
contribution-heatmap.ts5.0 KB · 164 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
/**
 * Block J9 — GitHub-style contribution heatmap.
 *
 * Takes a list of timestamped activity entries and produces a 53-week grid
 * (Sunday-aligned) of daily counts + level buckets 0-4, plus rollup stats.
 * Pure. Zero IO. The rendering of the grid lives in the caller (web.tsx)
 * so we can keep this file server/client agnostic.
 */

export interface ActivityTs {
  createdAt: Date | string | number;
}

export interface HeatmapDay {
  date: string; // YYYY-MM-DD (UTC)
  count: number;
  level: 0 | 1 | 2 | 3 | 4;
  dow: number; // 0 (Sun) – 6 (Sat)
}

export interface HeatmapWeek {
  /** Seven days, index 0 = Sunday. Entries before window start / after today
   *  are null so callers can render empty cells at the edges. */
  days: Array<HeatmapDay | null>;
}

export interface Heatmap {
  weeks: HeatmapWeek[];
  totalContributions: number;
  maxDayCount: number;
  /** Longest uninterrupted streak inside the window. */
  longestStreak: number;
  /** Streak counting back from `today`. Zero if today is empty. */
  currentStreak: number;
  /** Inclusive start / end (UTC, YYYY-MM-DD). */
  startDate: string;
  endDate: string;
}

/** Strip time, coerce to UTC midnight. */
export function startOfUtcDay(d: Date): Date {
  const x = new Date(d);
  x.setUTCHours(0, 0, 0, 0);
  return x;
}

export function formatDateKey(d: Date): string {
  const y = d.getUTCFullYear();
  const m = String(d.getUTCMonth() + 1).padStart(2, "0");
  const day = String(d.getUTCDate()).padStart(2, "0");
  return `${y}-${m}-${day}`;
}

export function daysBetween(a: Date, b: Date): number {
  const ms = startOfUtcDay(b).getTime() - startOfUtcDay(a).getTime();
  return Math.round(ms / 86_400_000);
}

/**
 * Map a raw day-count to one of 5 levels. GitHub's thresholds are non-linear;
 * we mirror with quartiles of the max count (fast, good enough for v1).
 */
export function levelFor(count: number, max: number): 0 | 1 | 2 | 3 | 4 {
  if (count <= 0) return 0;
  if (max <= 0) return 0;
  const q = count / max;
  if (q > 0.75) return 4;
  if (q > 0.5) return 3;
  if (q > 0.25) return 2;
  return 1;
}

/**
 * Build a 53-week Sunday-aligned grid ending on `today` and covering
 * `windowDays` days. Activities outside the window are ignored.
 */
export function buildHeatmap(
  activities: ActivityTs[],
  windowDays: number = 365,
  today: Date = new Date()
): Heatmap {
  const endDay = startOfUtcDay(today);
  const startDay = new Date(endDay);
  startDay.setUTCDate(startDay.getUTCDate() - (windowDays - 1));

  const byDay = new Map<string, number>();
  for (const a of activities) {
    const d = new Date(a.createdAt);
    if (isNaN(d.getTime())) continue;
    const k = startOfUtcDay(d).getTime();
    if (k < startDay.getTime() || k > endDay.getTime()) continue;
    const key = formatDateKey(new Date(k));
    byDay.set(key, (byDay.get(key) || 0) + 1);
  }

  // Pad the grid to start on a Sunday. If startDay is not Sunday, prepend
  // nulls back to the previous Sunday so the columns align by week.
  const firstDow = startDay.getUTCDay(); // 0 = Sun
  const gridStart = new Date(startDay);
  gridStart.setUTCDate(gridStart.getUTCDate() - firstDow);

  // End on Saturday of the endDay's week.
  const endDow = endDay.getUTCDay();
  const gridEnd = new Date(endDay);
  gridEnd.setUTCDate(gridEnd.getUTCDate() + (6 - endDow));

  // Compute max for level buckets first.
  let maxDayCount = 0;
  for (const v of byDay.values()) if (v > maxDayCount) maxDayCount = v;

  const weeks: HeatmapWeek[] = [];
  let cursor = new Date(gridStart);
  let totalContributions = 0;
  let longestStreak = 0;
  let currentStreakAtEnd = 0;
  let runningStreak = 0;

  while (cursor.getTime() <= gridEnd.getTime()) {
    const days: Array<HeatmapDay | null> = [];
    for (let i = 0; i < 7; i++) {
      const isBeforeStart = cursor.getTime() < startDay.getTime();
      const isAfterEnd = cursor.getTime() > endDay.getTime();
      if (isBeforeStart || isAfterEnd) {
        days.push(null);
      } else {
        const key = formatDateKey(cursor);
        const count = byDay.get(key) || 0;
        totalContributions += count;
        if (count > 0) {
          runningStreak += 1;
          if (runningStreak > longestStreak) longestStreak = runningStreak;
        } else {
          runningStreak = 0;
        }
        // Capture the streak value AT the endDay so we can report
        // currentStreak (trailing streak), which may or may not be == longest.
        if (cursor.getTime() === endDay.getTime()) {
          currentStreakAtEnd = runningStreak;
        }
        days.push({
          date: key,
          count,
          level: levelFor(count, maxDayCount),
          dow: cursor.getUTCDay(),
        });
      }
      cursor = new Date(cursor);
      cursor.setUTCDate(cursor.getUTCDate() + 1);
    }
    weeks.push({ days });
  }

  return {
    weeks,
    totalContributions,
    maxDayCount,
    longestStreak,
    currentStreak: currentStreakAtEnd,
    startDate: formatDateKey(startDay),
    endDate: formatDateKey(endDay),
  };
}

export const __internal = { formatDateKey, startOfUtcDay, daysBetween };