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.
This commit is contained in:
camjac251
2026-07-20 14:37:19 -04:00
parent f22f2a4c79
commit 97d37fad04
2 changed files with 55 additions and 23 deletions
+17 -1
View File
@@ -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");
+38 -22
View File
@@ -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;
}
}
}