Fix timezone handling: menubar UTC bugs, --timezone flag, DST-safe dates

Three fixes for issue #184:

1. Menubar Swift code used UTC instead of local timezone in two places:
   computeHistoryStats hardcoded TimeZone("UTC") and
   effectiveTokensInLast7Days used ISO8601DateFormatter (UTC default).
   Both now use .current to match CLI-produced local date keys.

2. Add --timezone flag and CODEBURN_TZ env var to override the system
   timezone for all date grouping. Sets process.env.TZ before any Date
   operations so all existing local-timezone code works unchanged.

3. Replace MS_PER_DAY arithmetic with Date constructor day-of-month
   math for yesterday/backfill computations. Subtracting 86400000ms
   from midnight skips a day on DST spring-forward (23-hour day).

Fixes #184
This commit is contained in:
iamtoruk 2026-04-30 17:33:02 -07:00
parent f35400f199
commit 68c6f2c710
4 changed files with 22 additions and 8 deletions

View file

@ -174,7 +174,10 @@ final class AppStore {
/// last 7 days of dailyHistory. Used as the "tokens consumed in 7-day window" reading paired
/// with the API-reported percent for capacity estimation.
private func effectiveTokensInLast7Days(history: [DailyHistoryEntry], asOf now: Date) -> Double {
let cutoff = ISO8601DateFormatter().string(from: now.addingTimeInterval(-7 * 86400)).prefix(10)
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
f.timeZone = .current
let cutoff = f.string(from: now.addingTimeInterval(-7 * 86400))
return history
.filter { $0.date >= cutoff }
.reduce(0.0) { $0 + $1.effectiveTokens }

View file

@ -213,11 +213,11 @@ private struct HistoryStats {
private func computeHistoryStats(history: [DailyHistoryEntry]) -> HistoryStats {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "UTC")!
calendar.timeZone = .current
let formatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
f.timeZone = TimeZone(identifier: "UTC")
f.timeZone = .current
return f
}()
let now = Date()

View file

@ -7,7 +7,7 @@ import { convertCost } from './currency.js'
import { renderStatusBar } from './format.js'
import { type PeriodData, type ProviderCost } from './menubar-json.js'
import { buildMenubarPayload } from './menubar-json.js'
import { getDaysInRange, ensureCacheHydrated, emptyCache, MS_PER_DAY, BACKFILL_DAYS, toDateString } from './daily-cache.js'
import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString } from './daily-cache.js'
import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from './day-aggregator.js'
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
import { renderDashboard } from './dashboard.js'
@ -141,8 +141,19 @@ const program = new Command()
.description('See where your AI coding tokens go - by task, tool, model, and project')
.version(version)
.option('--verbose', 'print warnings to stderr on read failures and skipped files')
.option('--timezone <zone>', 'IANA timezone for date grouping (e.g. Asia/Tokyo, America/New_York)')
program.hook('preAction', async (thisCommand) => {
const tz = thisCommand.opts<{ timezone?: string }>().timezone ?? process.env['CODEBURN_TZ']
if (tz) {
try {
Intl.DateTimeFormat(undefined, { timeZone: tz })
} catch {
console.error(`\n Invalid timezone: "${tz}". Use an IANA timezone like "America/New_York" or "Asia/Tokyo".\n`)
process.exit(1)
}
process.env.TZ = tz
}
const config = await readConfig()
setModelAliases(config.modelAliases ?? {})
if (thisCommand.opts<{ verbose?: boolean }>().verbose) {
@ -383,7 +394,7 @@ program
const periodInfo = getDateRange(opts.period)
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterdayStr = toDateString(new Date(todayStart.getTime() - MS_PER_DAY))
const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
const isAllProviders = pf === 'all'
const cache = await hydrateCache()
@ -454,7 +465,7 @@ program
// Cache stores per-provider cost+calls per day in DailyEntry.providers, so we can derive
// a provider-filtered history without re-parsing. Tokens aren't broken down per provider
// in the cache, so the filtered view shows zero tokens (heatmap/trend still works on cost).
const historyStartStr = toDateString(new Date(todayStart.getTime() - BACKFILL_DAYS * MS_PER_DAY))
const historyStartStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS))
const allCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr)
// Parse only today for history; historical days come from cache
const todayRangeForHistory: DateRange = { start: todayStart, end: new Date() }

View file

@ -165,7 +165,7 @@ export async function ensureCacheHydrated(
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterdayEnd = new Date(todayStart.getTime() - 1)
const yesterdayStr = toDateString(new Date(todayStart.getTime() - MS_PER_DAY))
const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
return withDailyCacheLock(async () => {
let c = await loadDailyCache()
@ -183,7 +183,7 @@ export async function ensureCacheHydrated(
parseInt(c.lastComputedDate.slice(5, 7)) - 1,
parseInt(c.lastComputedDate.slice(8, 10)) + 1
)
: new Date(todayStart.getTime() - BACKFILL_DAYS * MS_PER_DAY)
: new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS)
if (gapStart.getTime() <= yesterdayEnd.getTime()) {
const gapRange: DateRange = { start: gapStart, end: yesterdayEnd }