"When does everyone actually commit to this repo?" I built a tool that answers it: paste your
git logoutput and get an author ranking, hour-of-day and day-of-week histograms, and a punchcard heatmap (day × hour). Fully client-side — your log never leaves the browser. Two implementation hinges: (1) auto-detecting and parsing twogit logformats (pipe-delimited and the default), and (2) the timezone trap — converting23:00+09:00to UTC turns it into 14:00 and destroys the "when do they work" signal.
🌐 Demo: https://sen.ltd/portfolio/git-stats/
📦 GitHub: https://github.com/sen-ltd/git-stats
Why do it in the browser
You can get commit stats with git log | awk or tools like gitstats. But browser-side means:
- No install — just open a URL
- Your log never leaves the machine — pasting work-repo author names and emails into a SaaS is uncomfortable
- Works even without git locally (paste a colleague's log)
The input is git log output text. We parse it.
Auto-detecting two formats
git log output shape depends on the options used. Two are supported:
1. Pipe-delimited (recommended) — git log --pretty=format:'%H|%an|%ae|%aI':
8e18b4d|Alice Tanaka|alice@example.com|2026-06-15T22:30:00+09:00
2. Default — plain git log:
commit 8e18b4d1234567890
Author: Alice Tanaka <alice@example.com>
Date: Mon Jun 15 22:30:00 2026 +0900
commit message
Detection is a heuristic — "any line with 3+ pipes and an ISO date":
export function parseGitLog(text) {
const lines = text.split("\n");
const looksPipe = lines.some((l) =>
l.split("|").length >= 4 && /\d{4}-\d{2}-\d{2}T/.test(l));
return looksPipe ? parsePipe(text) : parseDefault(text);
}
The default format parses with a small state machine: commit starts a new record, Author:/Date: fill its fields.
The timezone trap — the real lesson
We want commit time as a "when do they work" signal. Git records timestamps like:
2026-06-15T22:30:00+09:00 ← ISO
Mon Jun 15 22:30:00 2026 +0900 ← default
Key fact: the displayed time is already in the author's local time. The +09:00 is an annotation ("this wall-clock is UTC+9"), not an offset to apply.
So new Date("2026-06-15T22:30:00+09:00").getHours() is a disaster:
- JS parses the ISO string with the offset, storing it as UTC (13:30Z)
-
getHours()returns it in the runtime's local timezone - On CI (UTC) you get 13; in a JST browser you get 22 — environment-dependent drift
The fix: read the digits in the string directly. Ignore the offset.
export function parseISO(s) {
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(s.trim());
if (!m) return null;
const [, y, mo, d, h, mi] = m.map(Number);
return { year: y, monthIdx: mo - 1, day: d, hour: h, minute: mi,
dow: dayOfWeek(y, mo - 1, d) };
}
test("local hour is read directly, NOT offset-adjusted", () => {
// 23:00+09:00 stays hour 23, must NOT become 14 UTC
assert.equal(parseISO("2026-06-15T23:00:00+09:00").hour, 23);
});
A commit at 23:00 was made at 23:00 in the author's timezone. Convert to UTC and the answer to "when do they work" breaks.
Day-of-week is also a timezone hazard
The ISO format has no weekday name, so we compute it from the date. Again, new Date(y, m, d) drags in the local timezone — so use Date.UTC purely as a calendar calculator:
export function dayOfWeek(year, monthIdx, day) {
return new Date(Date.UTC(year, monthIdx, day)).getUTCDay();
}
test("2026-06-15 → Monday (1)", () => assert.equal(dayOfWeek(2026, 5, 15), 1));
test("2000-01-01 → Saturday (6)", () => assert.equal(dayOfWeek(2000, 0, 1), 6));
Pass already-local (year, monthIdx, day) to Date.UTC, read back with getUTCDay(). No timezone enters the calculation.
Punchcard: a 7×24 heatmap
Same idea as GitHub's old punchcard. A day (7 rows) × hour (24 cols) grid, each cell shaded by commit count:
export function punchcard(commits) {
const grid = Array.from({ length: 7 }, () => new Array(24).fill(0));
for (const c of commits) grid[c.date.dow][c.date.hour]++;
return grid;
}
Color is a blue ramp normalized by the max, drawn as 7×24 = 168 SVG rects. The value of a punchcard: you can see at a glance whether activity clusters on weekday afternoons (a day job) or scatters into nights and weekends (hobby/OSS).
Test aggregations with conservation laws
For histogram-style aggregations, asserting "the sum equals the total commit count" catches everything:
test("byHour sum equals total", () => {
assert.equal(byHour(SAMPLE).reduce((a, b) => a + b, 0), SAMPLE.length);
});
test("punchcard total equals commit count", () => {
let sum = 0;
for (const row of punchcard(SAMPLE)) for (const v of row) sum += v;
assert.equal(sum, SAMPLE.length);
});
A conservation law (Σ buckets = input size) catches dropped and double-counted items more reliably than checking individual buckets.
Skip broken lines, but count them
Real git log output has stray lines (merge commit extras, etc.). The parser skips broken lines and counts them — never fatal:
test("malformed lines are skipped, not fatal", () => {
const r = parseGitLog("good|A|a@x|2026-06-15T09:00:00+09:00\ngarbage line\n");
assert.equal(r.commits.length, 1);
assert.equal(r.skipped, 1);
});
The UI shows "Parsed 15 (pipe format) — 2 lines skipped" so nothing is silently dropped.
Architecture
parse.js ← git log → normalized commits (pipe + default, DOM-free)
stats.js ← author/hour/dow/punchcard/month/summary aggregations (DOM-free)
app.js ← SVG punchcard + histograms
37 Node tests across date parsing (ISO + git-default), format detection, skip handling, and every aggregation.
Try it
Run git log --pretty=format:'%H|%an|%ae|%aI' | pbcopy on your own repo and paste. The punchcard will tell you whether you're a night owl or an early bird.
Takeaways
- Commit stats can be fully client-side. Not sending the log anywhere is what makes it safe for work repos.
- A
git logtimestamp is already in the author's local time.new Date(iso).getHours()drifts with the runtime TZ — read the string's digits directly. - Compute weekday with
Date.UTC(y,m,d)used only as a calendar calculator to stay TZ-independent. - A punchcard is a 7×24 grid — just lay out SVG
rects to visualize "when do they work." - Test aggregations with a conservation law (Σ buckets = input count).
- Skip and count broken lines; surface the skip count instead of silently dropping.
This is OSS portfolio #264 from SEN LLC (Tokyo). https://sen.ltd/portfolio/

Top comments (0)