fix: use local timezone for daily date bucketing instead of UTC

Timestamps in session files are UTC ISO strings. Several code paths
extracted the date via .slice(0, 10) which gives the UTC date, while
date range filtering uses local-time boundaries. This caused turns
between UTC midnight and local midnight to be bucketed under the wrong
day -- the menubar showed lower today cost than the TUI because those
turns were attributed to tomorrow (UTC) but filtered as today (local).

format.ts already had a localDateString fix; this applies the same
pattern everywhere via dateKey() in day-aggregator.ts.
This commit is contained in:
iamtoruk 2026-04-19 03:18:38 -07:00 committed by AgentSeal
parent 888030fce3
commit 72ccf34a5a
4 changed files with 14 additions and 11 deletions

View file

@ -8,7 +8,7 @@ import { 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'
import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggregator.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'
import { parseDateRangeFlags } from './cli-date.js'
@ -25,7 +25,7 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000
const BACKFILL_DAYS = 365
function toDateString(date: Date): string {
return date.toISOString().slice(0, 10)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
function getDateRange(period: string): { range: DateRange; label: string } {
@ -35,12 +35,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)
@ -123,7 +123,7 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
for (const sess of sessions) {
for (const turn of sess.turns) {
if (!turn.timestamp) { continue }
const day = turn.timestamp.slice(0, 10)
const day = dateKey(turn.timestamp)
if (!dailyMap[day]) { dailyMap[day] = { cost: 0, calls: 0 } }
for (const call of turn.assistantCalls) {
dailyMap[day].cost += call.costUSD
@ -204,7 +204,7 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
Object.entries(m).sort(([, a], [, b]) => b - a).map(([name, calls]) => ({ name, calls }))
const topSessions = projects
.flatMap(p => p.sessions.map(s => ({ project: p.project, sessionId: s.sessionId, date: s.firstTimestamp?.slice(0, 10) ?? null, cost: convertCost(s.totalCostUSD), calls: s.apiCalls })))
.flatMap(p => p.sessions.map(s => ({ project: p.project, sessionId: s.sessionId, date: s.firstTimestamp ? dateKey(s.firstTimestamp) : null, cost: convertCost(s.totalCostUSD), calls: s.apiCalls })))
.sort((a, b) => b.cost - a.cost)
.slice(0, 5)
@ -545,7 +545,7 @@ program
return
}
const defaultName = `codeburn-${new Date().toISOString().slice(0, 10)}`
const defaultName = `codeburn-${toDateString(new Date())}`
const outputPath = opts.output ?? `${defaultName}.${opts.format}`
let savedPath: string

View file

@ -9,6 +9,7 @@ import { loadPricing } from './models.js'
import { getAllProviders } from './providers/index.js'
import { scanAndDetect, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js'
import { estimateContextBudget, discoverProjectCwd, type ContextBudget } from './context-budget.js'
import { dateKey } from './day-aggregator.js'
import { join } from 'path'
type Period = 'today' | 'week' | '30days' | 'month' | 'all'
@ -195,7 +196,7 @@ function DailyActivity({ projects, days = 14, pw, bw }: { projects: ProjectSumma
for (const session of project.sessions) {
for (const turn of session.turns) {
if (!turn.timestamp) continue
const day = turn.timestamp.slice(0, 10)
const day = dateKey(turn.timestamp)
dailyCosts[day] = (dailyCosts[day] ?? 0) + turn.assistantCalls.reduce((s, c) => s + c.costUSD, 0)
dailyCalls[day] = (dailyCalls[day] ?? 0) + turn.assistantCalls.length
}

View file

@ -20,8 +20,9 @@ function emptyEntry(date: string): DailyEntry {
}
}
function dateKey(iso: string): string {
return iso.slice(0, 10)
export function dateKey(iso: string): string {
const d = new Date(iso)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntry[] {

View file

@ -3,6 +3,7 @@ import { dirname, join, resolve } from 'path'
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
import { getCurrency, convertCost } from './currency.js'
import { dateKey } from './day-aggregator.js'
function escCsv(s: string): string {
const sanitized = /^[=+\-@]/.test(s) ? `'${s}` : s
@ -48,7 +49,7 @@ function buildDailyRows(projects: ProjectSummary[], period: string): Row[] {
for (const session of project.sessions) {
for (const turn of session.turns) {
if (!turn.timestamp) continue
const day = turn.timestamp.slice(0, 10)
const day = dateKey(turn.timestamp)
if (!daily[day]) {
daily[day] = { cost: 0, calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, sessions: new Set() }
}