DEV Community

SEN LLC
SEN LLC

Posted on

Visualizing git log in the Browser — Punchcard Heatmaps and the Timezone Trap

"When does everyone actually commit to this repo?" I built a tool that answers it: paste your git log output 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 two git log formats (pipe-delimited and the default), and (2) the timezone trap — converting 23:00+09:00 to 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

Screenshot

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
Enter fullscreen mode Exit fullscreen mode

2. Default — plain git log:

commit 8e18b4d1234567890
Author: Alice Tanaka <alice@example.com>
Date:   Mon Jun 15 22:30:00 2026 +0900

    commit message
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 log timestamp 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)