From fa9c9edfd0e658015393367ec2b9f00d06071e5d Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Tue, 18 Aug 2026 01:52:31 +0200 Subject: [PATCH] fix(cli): bucket daily history by local date The daily cache keyed days by UTC while period ranges used local midnight, so any user outside UTC lost or double-counted a whole day in history.daily and the menubar trend, forecast, and stats disagreed with the period totals. Use the local date everywhere and bump the cache version to rebuild. --- src/cli.ts | 15 ++++++++++----- src/daily-cache.ts | 2 +- src/day-aggregator.ts | 3 ++- src/format.ts | 2 +- tests/day-aggregator.test.ts | 4 ++-- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 059c9976..b71679c2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,7 +4,7 @@ import { exportCsv, exportJson, type PeriodExport } from './export.js' import { loadPricing } from './models.js' import { parseAllSessions, filterProjectsByName } from './parser.js' import { convertCost } from './currency.js' -import { renderStatusBar } from './format.js' +import { localDateString, renderStatusBar } from './format.js' import { type PeriodData, type ProviderCost } from './menubar-json.js' import { buildMenubarPayload } from './menubar-json.js' import { addNewDays, getDaysInRange, loadDailyCache, saveDailyCache, withDailyCacheLock } from './daily-cache.js' @@ -25,7 +25,12 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000 const BACKFILL_DAYS = 365 function toDateString(date: Date): string { - return date.toISOString().slice(0, 10) + return localDateString(date) +} + +function localMidnightAfter(dateStr: string): Date { + const [y, m, d] = dateStr.split('-').map(Number) + return new Date(y!, m! - 1, d! + 1) } function getDateRange(period: string): { range: DateRange; label: string } { @@ -35,12 +40,12 @@ function getDateRange(period: string): { range: DateRange; label: string } { switch (period) { case 'today': { const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()) - return { range: { start, end }, label: `Today (${start.toISOString().slice(0, 10)})` } + return { range: { start, end }, label: `Today (${toDateString(start)})` } } case 'yesterday': { const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1) const yesterdayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 23, 59, 59, 999) - return { range: { start, end: yesterdayEnd }, label: `Yesterday (${start.toISOString().slice(0, 10)})` } + return { range: { start, end: yesterdayEnd }, label: `Yesterday (${toDateString(start)})` } } case 'week': { const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7) @@ -342,7 +347,7 @@ program const cache = await withDailyCacheLock(async () => { let c = await loadDailyCache() const gapStart = c.lastComputedDate - ? new Date(new Date(`${c.lastComputedDate}T00:00:00.000Z`).getTime() + MS_PER_DAY) + ? localMidnightAfter(c.lastComputedDate) : new Date(todayStart.getTime() - BACKFILL_DAYS * MS_PER_DAY) if (gapStart.getTime() <= yesterdayEnd.getTime()) { diff --git a/src/daily-cache.ts b/src/daily-cache.ts index 1320aa65..2fe429b1 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -4,7 +4,7 @@ import { mkdir, open, readFile, rename, unlink } from 'fs/promises' import { homedir } from 'os' import { join } from 'path' -export const DAILY_CACHE_VERSION = 2 +export const DAILY_CACHE_VERSION = 3 const DAILY_CACHE_FILENAME = 'daily-cache.json' export type DailyEntry = { diff --git a/src/day-aggregator.ts b/src/day-aggregator.ts index 5030f8d8..f706a663 100644 --- a/src/day-aggregator.ts +++ b/src/day-aggregator.ts @@ -1,4 +1,5 @@ import type { DailyEntry } from './daily-cache.js' +import { localDateString } from './format.js' import type { PeriodData } from './menubar-json.js' import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js' @@ -21,7 +22,7 @@ function emptyEntry(date: string): DailyEntry { } function dateKey(iso: string): string { - return iso.slice(0, 10) + return localDateString(new Date(iso)) } export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntry[] { diff --git a/src/format.ts b/src/format.ts index 3905048c..c77d3ad8 100644 --- a/src/format.ts +++ b/src/format.ts @@ -17,7 +17,7 @@ export function formatTokens(n: number): string { /// out to Intl.DateTimeFormat for every turn in a loop and avoids the UTC drift that bites /// `Date.toISOString().slice(0,10)` whenever the user runs this between local midnight and /// UTC midnight. -function localDateString(d: Date): string { +export function localDateString(d: Date): string { const y = d.getFullYear() const m = String(d.getMonth() + 1).padStart(2, '0') const day = String(d.getDate()).padStart(2, '0') diff --git a/tests/day-aggregator.test.ts b/tests/day-aggregator.test.ts index fb908408..b28f1341 100644 --- a/tests/day-aggregator.test.ts +++ b/tests/day-aggregator.test.ts @@ -135,8 +135,8 @@ describe('aggregateProjectsIntoDays', () => { sessions: [{ sessionId: 's1', project: 'p', - firstTimestamp: '2026-04-09T23:59:00Z', - lastTimestamp: '2026-04-10T00:10:00Z', + firstTimestamp: new Date(2026, 3, 9, 23, 59).toISOString(), + lastTimestamp: new Date(2026, 3, 10, 0, 10).toISOString(), totalCostUSD: 1, totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0, apiCalls: 0,