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.
This commit is contained in:
AgentSeal 2026-08-18 01:52:31 +02:00
parent ebfa3b8748
commit fa9c9edfd0
5 changed files with 16 additions and 10 deletions

View file

@ -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()) {

View file

@ -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 = {

View file

@ -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[] {

View file

@ -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')

View file

@ -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,