From 97d37fad0428fa89b496c3fcf7a3dc56b9086e37 Mon Sep 17 00:00:00 2001 From: camjac251 Date: Mon, 20 Jul 2026 14:37:19 -0400 Subject: [PATCH] fix(ui): preserve local usage bucket dates Daily usage buckets represent local calendar dates, but standard date parsing interprets their ISO-like form as UTC and shifts them in non-UTC time zones. Parse date-only buckets directly in local time so heatmap totals stay on the reported day. --- packages/ui/src/lib/usage-activity.ts | 18 +++++- packages/ui/test/unit/usage-activity.test.mjs | 60 ++++++++++++------- 2 files changed, 55 insertions(+), 23 deletions(-) diff --git a/packages/ui/src/lib/usage-activity.ts b/packages/ui/src/lib/usage-activity.ts index bbaae29a..f2888f53 100644 --- a/packages/ui/src/lib/usage-activity.ts +++ b/packages/ui/src/lib/usage-activity.ts @@ -42,7 +42,7 @@ export function buildTokenActivity(series: UsageSeriesPoint[], options: TokenAct let observedEnd: Date | undefined; for (const point of series) { - const date = startOfLocalDay(new Date(point.bucket)); + const date = startOfLocalDay(parseActivityDate(point.bucket)); if (!isFiniteDate(date)) { continue; } @@ -111,6 +111,22 @@ export function buildTokenActivity(series: UsageSeriesPoint[], options: TokenAct }; } +function parseActivityDate(bucket: string): Date { + const dateOnly = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(bucket.trim()); + if (!dateOnly) { + return new Date(bucket); + } + + const year = Number(dateOnly[1]); + const month = Number(dateOnly[2]); + const day = Number(dateOnly[3]); + const date = new Date(year, month - 1, day); + if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) { + return new Date(Number.NaN); + } + return date; +} + export function activityDateKey(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); diff --git a/packages/ui/test/unit/usage-activity.test.mjs b/packages/ui/test/unit/usage-activity.test.mjs index 6b9da4e4..a006a3c3 100644 --- a/packages/ui/test/unit/usage-activity.test.mjs +++ b/packages/ui/test/unit/usage-activity.test.mjs @@ -3,32 +3,48 @@ import test from "node:test"; import { buildTokenActivity, activityDateKey } from "@ccr/ui/lib/usage-activity.ts"; test("buildTokenActivity summarizes observed token days and streaks", () => { - const summary = buildTokenActivity( - [ - { bucket: "2026-06-02", totalTokens: 10 }, - { bucket: "2026-06-03", totalTokens: 30 }, - { bucket: "2026-06-04", totalTokens: 60 }, - { bucket: "2026-06-05", totalTokens: -20 }, - { bucket: "not-a-date", totalTokens: 999 } - ], - { minWeeks: 2 } - ); + withTimezone("America/New_York", () => { + const summary = buildTokenActivity( + [ + { bucket: "2026-06-02", totalTokens: 10 }, + { bucket: "2026-06-03", totalTokens: 30 }, + { bucket: "2026-06-04", totalTokens: 60 }, + { bucket: "2026-06-05", totalTokens: -20 }, + { bucket: "not-a-date", totalTokens: 999 } + ], + { minWeeks: 2 } + ); - assert.equal(summary.totalTokens, 100); - assert.equal(summary.activeDays, 3); - assert.equal(summary.dayCount, 4); - assert.equal(summary.longestStreak, 3); - assert.equal(summary.maxTokens, 60); - assert.equal(summary.weekCount, 2); - assert.equal(summary.cells.length, 14); + assert.equal(summary.totalTokens, 100); + assert.equal(summary.activeDays, 3); + assert.equal(summary.dayCount, 4); + assert.equal(summary.longestStreak, 3); + assert.equal(summary.maxTokens, 60); + assert.equal(summary.weekCount, 2); + assert.equal(summary.cells.length, 14); - const cellsByDate = new Map(summary.cells.map((cell) => [cell.dateKey, cell])); - assert.equal(cellsByDate.get("2026-06-02")?.intensity, 1); - assert.equal(cellsByDate.get("2026-06-03")?.intensity, 3); - assert.equal(cellsByDate.get("2026-06-04")?.intensity, 4); - assert.equal(cellsByDate.get("2026-06-05")?.intensity, 0); + const cellsByDate = new Map(summary.cells.map((cell) => [cell.dateKey, cell])); + assert.equal(cellsByDate.get("2026-06-02")?.intensity, 1); + assert.equal(cellsByDate.get("2026-06-03")?.intensity, 3); + assert.equal(cellsByDate.get("2026-06-04")?.intensity, 4); + assert.equal(cellsByDate.get("2026-06-05")?.intensity, 0); + }); }); test("activityDateKey formats local calendar dates", () => { assert.equal(activityDateKey(new Date(2026, 0, 5, 14, 30)), "2026-01-05"); }); + +function withTimezone(timezone, run) { + const previousTimezone = process.env.TZ; + process.env.TZ = timezone; + try { + run(); + } finally { + if (previousTimezone === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = previousTimezone; + } + } +}