[BUG] /stats heatmap dates shifted −1 day in UTC-negative timezones (UTC bucket keys parsed as UTC midnight, rendered in local time)

Resolved 💬 2 comments Opened Jun 11, 2026 by miffycs Closed Jun 16, 2026

Environment

  • Claude Code version: 2.1.167 (native install, Mach-O arm64). Latest at time of writing is 2.1.173; the changelog for 2.1.168–2.1.173 contains no stats/heatmap/timezone fixes.
  • Operating system: macOS (Darwin 25.1.0), Apple Silicon
  • Timezone: America/Los_Angeles (PDT, UTC−7). Verified no override: $TZ unset, no TZ= in any shell rc file, no env block in ~/.claude/settings.json, launchctl getenv TZ empty, /etc/localtime → America/Los_Angeles, system clock correct.

Describe the bug

The /stats (now /usage) activity view keys daily buckets by UTC date but renders them in local time, producing two visible date errors for users in UTC-negative timezones:

  1. Date labels read one day early. My second-biggest day was June 4, 2026 (PDT) — 4,312 user+assistant messages in my main session transcripts when I first counted (4,329 by the time of this report; 7,021 including subagent transcripts). /stats shows that activity labeled June 3, and June 4 appears empty. The "Most active day:" line and the daily-token-chart x-axis both exhibit the shift.
  2. Evening activity lands on the next day's heatmap cell. Anything after 17:00 PDT is bucketed under the next UTC date, so it appears one cell late on the grid — and same-evening activity is keyed to "tomorrow," whose cell is blanked as future, making it invisible until the next day. Example from my data: June 10 (PDT) had 2,948 messages, all in the evening; under UTC bucketing June 10 shows zero and all of that work is credited to June 11.
  3. "Active days" undercounts. /stats reports 9 active days for me. Bucketing my transcript messages by UTC date gives 12 active days; by my local (PDT) calendar date, 14. Part of that gap is a separate already-reported bug — whole sessions are credited only to the UTC date of their first message (#65611, #65775, #67085) — which compounds with the timezone issue.

Steps to reproduce

  1. Set system timezone to a UTC-negative zone (e.g. America/Los_Angeles).
  2. Generate activity during local daytime on day N, and some activity after 17:00 local (past UTC midnight).
  3. Run /stats and compare against the transcript timestamps in ~/.claude/projects/*/*.jsonl.
  4. Daytime activity for day N is labeled day N−1; post-17:00 activity appears under day N+1 (or not at all, if it's today).

Minimal repro of the label path:

$ TZ=America/Los_Angeles node -e 'console.log(new Date("2026-06-04").toLocaleDateString("en-US",{month:"short",day:"numeric"}))'
Jun 3

Expected behavior

A day's activity is bucketed and labeled by the user's local calendar date: June 4 work shows under June 4.

Actual behavior

Buckets are keyed by UTC calendar date; date labels render the key minus one day; evening work shifts one cell forward on the grid and today's evening work is invisible.

Evidence: per-day message counts, UTC vs local bucketing

Counted user+assistant lines with timestamps across all transcripts under ~/.claude/projects/ (timestamps are ISO-8601 UTC; converted with Python zoneinfo):

| Date (2026) | UTC bucketing | America/Los_Angeles bucketing |
|---|---:|---:|
| May 20 | 0 | 0 |
| May 21 | 175 | 175 |
| May 22 | 6 | 10 |
| May 23 | 4 | 0 |
| May 24–27 | 0 | 0 |
| May 28 | 4,832 | 7,745 |
| May 29 | 3,353 | 440 |
| May 30 | 0 | 0 |
| May 31 | 0 | 320 |
| Jun 1 | 535 | 215 |
| Jun 2 | 0 | 474 |
| Jun 3 | 1,013 | 934 |
| Jun 4 | 6,659 | 7,021 |
| Jun 5 | 1,257 | 511 |
| Jun 6 | 35 | 24 |
| Jun 7–8 | 0 | 0 |
| Jun 9 | 2 | 2 |
| Jun 10 | 0 | 2,948 |
| Jun 11 (partial) | 7,613 | 4,665 |
| Active days in range | 12 | 14 |

The Jun 10 row is the clearest single demonstration: a full evening of real work that UTC bucketing erases from that day entirely.

Suspected root cause (verified against the 2.1.167 binary)

All excerpts below were extracted verbatim from the embedded JS in the 2.1.167 native binary and independently re-verified, including a behavioral simulation that reproduces the symptom exactly. Identifiers are minified and version-specific; anchor on the string literals ("Active days", "Most active day:", "stats-cache.json", "Less ░▒▓█ More").

1. Daily bucket keys are UTC. The sole date-key helper applies toISOString():

function ze(H){let q=H.toISOString().split("T")[0];if(!q)throw Error("Invalid ISO date string");return q}

The transcript aggregator buckets each session under the UTC date of its first message: let I=new Date(C.timestamp); … let b=ze(I)dailyActivity[].date is a UTC YYYY-MM-DD string.

2. Date labels parse the key as UTC midnight, then render local — the classic −1-day pattern for UTC-negative timezones:

// "Most active day:" formatter
function HP3(H){return new Date(H).toLocaleDateString("en-US",{month:"short",day:"numeric"})}
// token-chart x-axis builder does the same:
J=new Date(H[w].date).toLocaleDateString("en-US",{month:"short",day:"numeric"})

new Date("2026-06-04") is 2026-06-04T00:00Z = June 3, 5:00 PM PDT → renders "Jun 3".

3. The heatmap grid mixes the two clocks. The grid walks local midnights (Y.setHours(0,0,0,0), w.setDate(Y.getDate()-Y.getDay())) but looks up each cell with the UTC key of that local-midnight Date (let G=ze(M),R=z.get(G)). In UTC-negative zones the grid is effectively local-date-positioned while the data is UTC-keyed, so post-17:00-PDT activity lands one cell late, and same-evening activity keys to tomorrow's cell, which is blanked as future (if(M>Y){J[Z][W]=" " …}).

Also internally inconsistent: streaks roll over at UTC midnight (= 5 PM PDT) via setUTCDate, while "Peak hour" uses local getHours().

Suggested fix direction: derive bucket keys from local calendar dates (or keep UTC keys but parse with new Date(y, m-1, d) / render with timeZone:"UTC"), and use one convention consistently across key construction, grid lookup, labels, and streaks.

Data integrity

Underlying data is intact — transcript timestamps in ~/.claude/projects/*/*.jsonl are correct ISO-8601 UTC. Only the aggregation keys and display are affected; a fix requires no data migration (at most a stats-cache.json rebuild, and that cache only persists through "yesterday" anyway).

Related issues

  • #65665 (open) — /stats heatmap date labels off by one day (Desktop app variant); likely the same label-path root cause.
  • #62528 (open) / #66935 (closed as dup) — heatmap data off by one weekday row; plausibly the same grid/key mismatch (in UTC-positive zones the grid lookup shifts all data one cell forward, which matches that reporter's UTC+2 observation).
  • #65611, #65775, #67085 (open) — active days/streak undercount because whole sessions are credited only to their first message's date; separate root cause (b=ze(I) from v[0] only), compounds with this bug.
  • #4514 (closed, not planned) — earlier precedent of UTC-date handling (toISOString) being reported in non-UTC timezones.

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗