[BUG] /stats heatmap month labels are evenly spaced, drifting up to 3 columns from the weeks they label (a month can appear empty)
Summary
The /stats activity heatmap draws its month header by concatenating labels at a fixed pad width rather than positioning each label over the week column where that month actually starts. Because months average ~4.35 weeks but every label is padded to floor(52/13) = 4 columns, the header drifts left of the data it labels — by up to 3 columns in the 52-week view.
The practical effect: a month with a real activity gap can appear completely empty while its actual data is drawn under the previous month's label. I spent a while convinced Claude Code had lost a month of my history before finding the data intact in stats-cache.json.
Environment
- Claude Code 2.1.241 (native installer,
~/.local/share/claude/versions/2.1.241) - Linux 6.12.57 (Debian 13), zsh, 80-col terminal
stats-cache.jsonv5, ~9 months of history
Root cause
In the heatmap renderer (o_s in the bundle), month-start columns are recorded correctly:
if (b === 0) {
let T = h.getMonth();
if (T !== m) { f.push({ month: T, week: _ }); m = T; } // f[i].week = the true column
}
…but f[i].week is then discarded when the header is built:
let _ = ["Jan","Feb",...],
b = f.map(H => H.month),
S = Math.floor(s / Math.max(b.length, 1)), // 52 / 13 = 4
w = b.map(H => _[H].padEnd(S)).join(""); // even spacing, position ignored
g.push(" " + w);
Every label gets exactly S columns regardless of how many weeks its month actually spans, so the error accumulates.
Reproduction
Any cache spanning ~52 weeks shows the drift. It becomes visibly wrong when a month contains a multi-week gap. Measured against my own cache (52-week view, 13 labels, S = 4):
| Label | Drawn over cols | True cols | Drift |
|---|---|---|---|
| Sep | 4–7 | 1–4 | +3 |
| Oct | 8–11 | 5–8 | +3 |
| Nov | 12–15 | 9–13 | +3 |
| Dec | 16–19 | 14–17 | +2 |
| Jan | 20–23 | 18–21 | +2 |
| Feb | 24–27 | 22–25 | +2 |
| Mar | 28–31 | 26–30 | +2 |
| Apr | 32–35 | 31–34 | +1 |
| Jun | 40–43 | 40–43 | 0 |
Column contents in that region:
col 20: week of 2026-01-18 6,257 msgs
col 21: week of 2026-01-25 16,707 msgs
col 22: week of 2026-02-01 31,053 msgs <- drawn under "Jan"
col 23: week of 2026-02-08 17,231 msgs <- drawn under "Jan"
col 24: week of 2026-02-15 0 <- drawn under "Feb"
col 25: week of 2026-02-22 0 <- drawn under "Feb"
col 26: week of 2026-03-01 0 <- drawn under "Feb"
col 27: week of 2026-03-08 0 <- drawn under "Feb"
col 29: week of 2026-03-22 4,605 msgs
I happened to be idle 2026-02-13 → 2026-03-21. Combined with the +2 drift, the "Feb" label lands squarely on that dead zone while my two heaviest weeks of the year render under "Jan" — so February reads as no data at all, with January and March populated on either side.
Expected
Each month label starts at the column of that month's first week, matching how GitHub's contribution graph places its labels.
Suggested fix
Use the week value that's already being collected, and skip labels too narrow to fit (the leading partial month):
if (n) {
let names = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
let row = "";
for (let i = 0; i < f.length; i++) {
let col = f[i].week;
let next = i + 1 < f.length ? f[i + 1].week : s;
if (next - col < 4 || col < row.length) continue; // no room / would overlap
row = row.padEnd(col) + names[f[i].month];
}
g.push(" " + row);
}
Note
The underlying data is correct throughout — this is purely a header-rendering bug, not data loss. But it presents as data loss, which is what makes it worth fixing.