[BUG] /stats heatmap shows every cell one day early in UTC-positive timezones (local-midnight Date keyed via toISOString)

Status Fixed / completed
Reported on v2.1.241
Maintainer reply None cached
Activity 0 comments · opened Aug 24, 2026 · closed Aug 25, 2026

Summary

In /stats, every cell of the activity heatmap displays the previous day's data when the user is in a UTC-positive timezone. The day-of-week grid is uniformly shifted one day, so a heavy Sunday renders as an empty Sunday and a busy Friday shows Thursday's numbers.

This looks like the root cause of #78129, and is the mirror image of #67625 (which covered UTC-negative offsets).

Environment

  • Version: 2.1.241
  • Platform: linux (WSL2)
  • Node: v20.20.0
  • Timezone: Africa/Nairobi (EAT, UTC+3)

Root cause

The heatmap builder walks the grid using Date objects pinned to local midnight, then derives the lookup key with toISOString(), which is UTC:

let c = new Date();
c.setHours(0, 0, 0, 0);          // local midnight today
let u = new Date(c);
u.setDate(c.getDate() - c.getDay());   // Sunday of this week, local
let d = new Date(u);
d.setDate(d.getDate() - (s - 1) * 7);

let h = new Date(d);
for (let w = 0; w < s; w++)
  for (let b = 0; b < 7; b++) {
    if (h > c) { p[b][w] = " "; h.setDate(h.getDate() + 1); continue; }
    let S = Ult(h);              // <-- Ult(e) = e.toISOString().split("T")[0]
    let v = a.get(S);
    ...
  }

h carries a local time-of-day of 00:00 through all the setDate() arithmetic. At UTC+3, local midnight on 2026-08-23 is 2026-08-22T21:00:00.000Z, so Ult(h) returns "2026-08-22". The Sunday cell therefore reads Saturday's bucket, and the same is true of every other cell in the grid.

Because the cache is written by bucketing real message timestamps (true instants) with the same Ult, the stored keys are genuine UTC dates. The read path and the write path disagree by exactly one day for any positive UTC offset. Negative offsets are unaffected: at UTC-4, local midnight on the 23rd is 2026-08-23T04:00Z, which keys correctly.

Worked example

Contents of ~/.claude/stats-cache.json versus what the heatmap draws:

| cell | key actually read | value displayed | true value for that day |
|---|---|---|---|
| Fri 2026-08-21 | 2026-08-20 | 397 | 1946 |
| Sat 2026-08-22 | 2026-08-21 | 1946 | 0 |
| Sun 2026-08-23 | 2026-08-22 | 0 | 3322 |
| Mon 2026-08-24 | 2026-08-23 | 3322 | 0 |

Sunday was by far the heaviest day of the week and renders completely empty. Saturday had no activity at all and renders as a solid block.

Suggested fix

Key the grid lookup by local date rather than by UTC:

function localDateKey(d) {
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}

and use that in place of Ult(h) in the heatmap builder.

Worth noting that this only realigns the grid. The write path still buckets by UTC day, so activity between local midnight and the UTC offset is still attributed to the previous day (3 hours' worth at UTC+3). Bucketing writes by local date as well would make the two paths consistent. The hour histogram already uses local time via getHours(), so local bucketing on the write side would also make the "Peak hour" stat agree with the daily columns.

Workaround

Launching with TZ=UTC claude collapses the discrepancy and renders the grid correctly, at the cost of reporting "Peak hour" in UTC.

View original on GitHub ↗