Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit4438e31unknown_key

feat(BLOCK-J): J9 contribution heatmap on user profile

feat(BLOCK-J): J9 contribution heatmap on user profile

GitHub-style 53-week activity grid on /:user. Pure builder in
src/lib/contribution-heatmap.ts aggregates activity_feed rows into
Sunday-aligned weeks of {date, count, level 0-4} cells, plus rollup
stats (total, max day, longest streak, current streak).

Profile handler in src/routes/web.tsx queries activity_feed over the
last 365 days and renders a scrollable 11×11px cell grid with hover
titles, a legend, and streak counters. Hides when no contributions.

No schema changes — reuses existing activity_feed rows.

18 new tests. Total suite: 785/785 passing.

https://claude.ai/code/session_01U6sj9ezKCpmiwx7cXGZmS6
Claude committed on April 15, 2026Parent: 0cdfd89
4 files changed+44104438e31f2171650fa0104404b510c7c83d081183
4 changed files+441−0
ModifiedBUILD_BIBLE.md+2−0View fileUnifiedSplit
292292- **J6** — Repository rulesets (push policy engine) → ✅ shipped. `drizzle/0032_repo_rulesets.sql` adds `repo_rulesets` (unique on `(repository_id, name)`, enforcement enum active/evaluate/disabled) + `ruleset_rules` (JSON params). `src/lib/rulesets.ts` exposes six rule types (`commit_message_pattern`, `branch_name_pattern`, `tag_name_pattern`, `blocked_file_paths`, `max_file_size`, `forbid_force_push`) + the pure evaluator `evaluatePush(rulesets, ctx) → {allowed, violations}`. Helpers: glob-lite matcher (`globToRegex`), defensive `parseParams`. CRUD: `listRulesetsForRepo`, `getRuleset`, `createRuleset`, `updateRulesetEnforcement`, `deleteRuleset`, `addRule`, `deleteRule`. `src/routes/rulesets.tsx` serves owner-only UI at `/:owner/:repo/settings/rulesets` (list + create), `/:id` (detail, enforcement toggle, add rule), `/:id/delete`, `/:id/rules/:ruleId/delete`. 23 new tests covering each rule type, enforcement modes, glob edge cases, and route-auth redirects.
293293- **J7** — Closing keywords auto-close issues on PR merge → ✅ shipped. `src/lib/close-keywords.ts` exports pure `extractClosingRefs(text)` and `extractClosingRefsMulti(sources[])` — scans for `(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)[:|-]? #N` with case-insensitive, punctuation-tolerant, word-boundary-respecting matching. Rejects cross-repo refs (`owner/repo#N`), embedded-in-word verbs (`disclose`, `unresolved`), and non-positive numbers. Wired into the PR merge handler in `src/routes/pulls.tsx` — after a successful merge, scans `pr.title + pr.body`, looks up each referenced open issue in the same repo, closes it, and posts a "Closed by pull request #N" comment. Wrapped in try/catch so close-keyword failures never block the merge redirect. 14 new tests covering verb forms, punctuation variants, de-dup+sort, cross-repo rejection, embedded-word rejection, case-insensitivity, and multi-source merging. Total suite 749/749.
294294- **J8** — Commit status API (external CI signals) → ✅ shipped. `drizzle/0033_commit_statuses.sql` adds `commit_statuses` (unique on `(repository_id, commit_sha, context)`, state vocabulary pending/success/failure/error). `src/lib/commit-statuses.ts` exposes pure helpers (`isValidSha`, `isValidState`, `sanitiseContext`, `reduceCombined`) and DB helpers (`setStatus` with delete-then-insert upsert, `listStatuses`, `combinedStatus`). `src/routes/commit-statuses.ts` serves `POST /api/v1/repos/:owner/:repo/statuses/:sha` (requireAuth + owner check), `GET /api/v1/repos/:owner/:repo/commits/:sha/statuses` (list, private-repo visibility), `GET /api/v1/repos/:owner/:repo/commits/:sha/status` (combined rollup). Commit detail view now renders a "Checks" pill row when statuses exist, colour-coded per state with clickable target URLs. 18 new tests covering the pure helpers + route auth + invalid-sha rejection. Total suite 767/767.
295- **J9** — GitHub-style contribution heatmap on user profile → ✅ shipped. `src/lib/contribution-heatmap.ts` exposes pure `buildHeatmap(activities, windowDays=365, today?)` that returns a 53-week Sunday-aligned grid of `{date, count, level 0-4, dow}` cells plus `totalContributions`, `maxDayCount`, `longestStreak`, `currentStreak`, and window start/end dates. `levelFor(count, max)` buckets into 5 GitHub-style quartiles. Wired into the profile handler in `src/routes/web.tsx` — queries `activity_feed` rows authored by the user over the last 365 days and renders a scrollable 11×11 px cell grid with hover titles, a legend, and streak counters. No schema changes — reuses existing `activity_feed` rows. 18 new tests. Total suite 785/785.
295296
296297### BLOCK H — Marketplace
297298- **H1** — App marketplace → ✅ shipped. `src/routes/marketplace.tsx` + `src/lib/marketplace.ts` + `drizzle/0021_marketplace_and_apps.sql` (5 tables: `apps`, `app_installations`, `app_bots`, `app_install_tokens`, `app_events`). Routes: `GET /marketplace` (public directory with search), `GET /marketplace/:slug` (detail + install CTA), `POST /marketplace/:slug/install` (user-target install in v1), `POST /marketplace/installations/:id/uninstall`, `GET /settings/apps` (personal list), `GET+POST /developer/apps-new` (register), `GET /developer/apps/:slug/manage` (event log + install count), `POST /developer/apps/:slug/tokens/new` (show-once token). Install idempotent via soft-update on existing non-uninstalled row.
473474- `src/lib/close-keywords.ts` (Block J7) — pure parser. Exports `extractClosingRefs(text)` + `extractClosingRefsMulti(sources[])`. Verbs: close/closes/closed/fix/fixes/fixed/resolve/resolves/resolved. Case-insensitive, word-boundary-respecting, ignores cross-repo `owner/repo#N` refs, rejects non-positive numbers, returns sorted de-duped list. Tolerates `:` / `-` / whitespace between verb and ref.
474475- `src/lib/commit-statuses.ts` (Block J8) — pure helpers: `isValidSha`, `isValidState`, `sanitiseContext`, `reduceCombined`. DB helpers: `setStatus` (delete-then-insert upsert on `(repo, sha, context)`, SHA lowercased, description/url clamped to 1000/2048 chars), `listStatuses` (newest first), `combinedStatus` (latest per context, reduces to worst state, returns counts + context pills). `STATUS_STATES` exported array. Never throws on clamping; null/empty description returns null.
475476- `src/routes/commit-statuses.ts` (Block J8) — `POST /api/v1/repos/:owner/:repo/statuses/:sha` (requireAuth — accepts session/OAuth/PAT; owner-only), `GET /api/v1/repos/:owner/:repo/commits/:sha/statuses` (softAuth, private-repo visibility enforced), `GET /api/v1/repos/:owner/:repo/commits/:sha/status` (combined rollup, same visibility rules).
477- `src/lib/contribution-heatmap.ts` (Block J9) — pure heatmap builder. Exports `buildHeatmap(activities, windowDays=365, today?)` returning 53-week Sunday-aligned grid + rollup stats. Helpers: `levelFor(count, max)` (5-level quartile bucket), `formatDateKey` (UTC YYYY-MM-DD), `startOfUtcDay`, `daysBetween`. `__internal` re-exports for tests. Silently ignores invalid dates + activity outside the window.
476478
477479### 4.7 Views (locked contracts)
478480- `src/views/layout.tsx``Layout` accepts `title`, `user`, `notificationCount`
Addedsrc/__tests__/contribution-heatmap.test.ts+188−0View fileUnifiedSplit
1/**
2 * Block J9 — Contribution heatmap tests. All pure; no DB.
3 */
4
5import { describe, it, expect } from "bun:test";
6import {
7 buildHeatmap,
8 formatDateKey,
9 levelFor,
10 startOfUtcDay,
11 daysBetween,
12 __internal,
13} from "../lib/contribution-heatmap";
14
15describe("heatmap — levelFor", () => {
16 it("0 count → level 0 regardless of max", () => {
17 expect(levelFor(0, 0)).toBe(0);
18 expect(levelFor(0, 10)).toBe(0);
19 });
20
21 it("max=0 keeps everything at 0", () => {
22 expect(levelFor(5, 0)).toBe(0);
23 });
24
25 it("buckets by quartile of max", () => {
26 expect(levelFor(1, 10)).toBe(1); // 10%
27 expect(levelFor(3, 10)).toBe(2); // 30%
28 expect(levelFor(6, 10)).toBe(3); // 60%
29 expect(levelFor(9, 10)).toBe(4); // 90%
30 });
31
32 it("edge — exactly 25% → level 1", () => {
33 expect(levelFor(25, 100)).toBe(1);
34 expect(levelFor(26, 100)).toBe(2);
35 });
36});
37
38describe("heatmap — formatDateKey + startOfUtcDay", () => {
39 it("formats YYYY-MM-DD in UTC", () => {
40 const d = new Date("2026-01-02T23:59:59Z");
41 expect(formatDateKey(d)).toBe("2026-01-02");
42 });
43
44 it("strips time to 00:00:00 UTC", () => {
45 const d = startOfUtcDay(new Date("2026-03-15T18:30:00Z"));
46 expect(d.getUTCHours()).toBe(0);
47 expect(d.getUTCMinutes()).toBe(0);
48 expect(d.getUTCSeconds()).toBe(0);
49 expect(formatDateKey(d)).toBe("2026-03-15");
50 });
51
52 it("__internal re-exports match", () => {
53 expect(__internal.formatDateKey).toBe(formatDateKey);
54 expect(__internal.startOfUtcDay).toBe(startOfUtcDay);
55 });
56});
57
58describe("heatmap — daysBetween", () => {
59 it("returns 0 for same UTC day regardless of time", () => {
60 const a = new Date("2026-01-02T00:00:00Z");
61 const b = new Date("2026-01-02T23:59:59Z");
62 expect(daysBetween(a, b)).toBe(0);
63 });
64
65 it("returns positive delta for later end", () => {
66 expect(
67 daysBetween(new Date("2026-01-01Z"), new Date("2026-01-05Z"))
68 ).toBe(4);
69 });
70});
71
72describe("heatmap — buildHeatmap", () => {
73 const TODAY = new Date("2026-12-15T12:00:00Z"); // Tuesday
74
75 it("empty activity → zero totals, no levels above 0", () => {
76 const h = buildHeatmap([], 365, TODAY);
77 expect(h.totalContributions).toBe(0);
78 expect(h.maxDayCount).toBe(0);
79 expect(h.longestStreak).toBe(0);
80 expect(h.currentStreak).toBe(0);
81 for (const w of h.weeks) {
82 for (const d of w.days) {
83 if (d) expect(d.level).toBe(0);
84 }
85 }
86 });
87
88 it("sums contributions on the same day", () => {
89 const h = buildHeatmap(
90 [
91 { createdAt: "2026-12-10T10:00:00Z" },
92 { createdAt: "2026-12-10T11:00:00Z" },
93 { createdAt: "2026-12-10T23:59:59Z" },
94 ],
95 30,
96 TODAY
97 );
98 expect(h.totalContributions).toBe(3);
99 expect(h.maxDayCount).toBe(3);
100 });
101
102 it("drops activity outside the window", () => {
103 const h = buildHeatmap(
104 [
105 { createdAt: "2025-01-01T00:00:00Z" }, // way outside
106 { createdAt: "2026-12-14T00:00:00Z" }, // inside
107 ],
108 30,
109 TODAY
110 );
111 expect(h.totalContributions).toBe(1);
112 });
113
114 it("computes longest + current streaks", () => {
115 // 3-day streak ending on 2026-12-15 (today)
116 const h = buildHeatmap(
117 [
118 { createdAt: "2026-12-13T00:00:00Z" },
119 { createdAt: "2026-12-14T00:00:00Z" },
120 { createdAt: "2026-12-15T00:00:00Z" },
121 ],
122 30,
123 TODAY
124 );
125 expect(h.longestStreak).toBe(3);
126 expect(h.currentStreak).toBe(3);
127 });
128
129 it("currentStreak is 0 when today has no activity", () => {
130 const h = buildHeatmap(
131 [
132 { createdAt: "2026-12-13T00:00:00Z" },
133 { createdAt: "2026-12-14T00:00:00Z" },
134 ],
135 30,
136 TODAY
137 );
138 expect(h.longestStreak).toBe(2);
139 expect(h.currentStreak).toBe(0);
140 });
141
142 it("weeks are Sunday-aligned with 7 entries each", () => {
143 const h = buildHeatmap([], 30, TODAY);
144 for (const w of h.weeks) {
145 expect(w.days.length).toBe(7);
146 }
147 // Every non-null day's dow matches its index in the week array.
148 for (const w of h.weeks) {
149 w.days.forEach((d, idx) => {
150 if (d) expect(d.dow).toBe(idx);
151 });
152 }
153 });
154
155 it("start/end dates reflect the window", () => {
156 const h = buildHeatmap([], 10, TODAY);
157 expect(h.endDate).toBe("2026-12-15");
158 expect(h.startDate).toBe("2026-12-06");
159 });
160
161 it("tolerates invalid createdAt entries", () => {
162 const h = buildHeatmap(
163 [
164 { createdAt: "not a date" },
165 { createdAt: "2026-12-14T00:00:00Z" },
166 ],
167 30,
168 TODAY
169 );
170 expect(h.totalContributions).toBe(1);
171 });
172
173 it("grid covers entire window continuously", () => {
174 const h = buildHeatmap([], 14, TODAY);
175 const nonNull: string[] = [];
176 for (const w of h.weeks) {
177 for (const d of w.days) {
178 if (d) nonNull.push(d.date);
179 }
180 }
181 // 14-day window → 14 non-null cells.
182 expect(nonNull.length).toBe(14);
183 // Dates are strictly increasing.
184 for (let i = 1; i < nonNull.length; i++) {
185 expect(nonNull[i] > nonNull[i - 1]).toBe(true);
186 }
187 });
188});
Addedsrc/lib/contribution-heatmap.ts+164−0View fileUnifiedSplit
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
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 };
Modifiedsrc/routes/web.tsx+87−0View fileUnifiedSplit
1212 repositories,
1313 stars,
1414 commitVerifications,
15 activityFeed,
1516} from "../db/schema";
17import { gte } from "drizzle-orm";
1618import { Layout } from "../views/layout";
1719import {
1820 RepoHeader,
272274 profileReadmeHtml = null;
273275 }
274276
277 // Block J9 — contribution heatmap. 52-week activity grid sourced from
278 // activity_feed rows authored by this user. Failures fall through silently.
279 let heatmap: Awaited<
280 ReturnType<
281 typeof import("../lib/contribution-heatmap").buildHeatmap
282 >
283 > | null = null;
284 if (ownerUser) {
285 try {
286 const since = new Date();
287 since.setUTCDate(since.getUTCDate() - 365);
288 const activities = await db
289 .select({ createdAt: activityFeed.createdAt })
290 .from(activityFeed)
291 .where(
292 and(
293 eq(activityFeed.userId, ownerUser.id),
294 gte(activityFeed.createdAt, since)
295 )
296 );
297 const { buildHeatmap } = await import("../lib/contribution-heatmap");
298 heatmap = buildHeatmap(activities, 365);
299 } catch {
300 heatmap = null;
301 }
302 }
303
275304 return c.html(
276305 <Layout title={ownerName} user={user}>
277306 <div class="user-profile">
323352 </div>
324353 </div>
325354 </div>
355 {heatmap && heatmap.totalContributions > 0 && (
356 <div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:16px 20px;margin-bottom:20px">
357 <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:10px">
358 <strong>
359 {heatmap.totalContributions} contributions in the last year
360 </strong>
361 <span style="color:var(--text-muted);font-size:12px">
362 Longest streak {heatmap.longestStreak}d · Current{" "}
363 {heatmap.currentStreak}d
364 </span>
365 </div>
366 <div style="display:flex;gap:3px;overflow-x:auto;padding-bottom:4px">
367 {heatmap.weeks.map((w) => (
368 <div style="display:flex;flex-direction:column;gap:3px">
369 {w.days.map((d) =>
370 d ? (
371 <div
372 title={`${d.date}: ${d.count} contribution${d.count === 1 ? "" : "s"}`}
373 style={`width:11px;height:11px;border-radius:2px;background:${
374 d.level === 0
375 ? "var(--border)"
376 : d.level === 1
377 ? "#0e4429"
378 : d.level === 2
379 ? "#006d32"
380 : d.level === 3
381 ? "#26a641"
382 : "#39d353"
383 }`}
384 />
385 ) : (
386 <div style="width:11px;height:11px" />
387 )
388 )}
389 </div>
390 ))}
391 </div>
392 <div style="margin-top:8px;display:flex;gap:4px;align-items:center;justify-content:flex-end;font-size:11px;color:var(--text-muted)">
393 <span>Less</span>
394 {[0, 1, 2, 3, 4].map((l) => (
395 <div
396 style={`width:11px;height:11px;border-radius:2px;background:${
397 l === 0
398 ? "var(--border)"
399 : l === 1
400 ? "#0e4429"
401 : l === 2
402 ? "#006d32"
403 : l === 3
404 ? "#26a641"
405 : "#39d353"
406 }`}
407 />
408 ))}
409 <span>More</span>
410 </div>
411 </div>
412 )}
326413 {profileReadmeHtml && (
327414 <div
328415 class="markdown-body"
329416