fix: bucket turns by assistant timestamp, filter at turn level

A turn that straddles midnight (user typed at 23:58, assistant responded
at 00:30) was bucketed and filtered inconsistently across call sites.
parseSessionFile filtered entries by timestamp, producing orphan assistant
calls that groupIntoTurns pushed as turns with empty timestamp. Some
downstream code counted those (buildPeriodData summing project totals)
and other code dropped them (renderStatusBar's empty-timestamp skip).

The menubar showed today = $32 while the terminal status showed today = $27
for the same dataset; each was internally consistent but used a different
turn-bucket rule.

Fix both: parseSessionFile now builds all turns first, then filters each
turn by its first assistant call timestamp (the moment cost was incurred).
renderStatusBar buckets the same way. day-aggregator.ts already bucketed
on assistant time, so it is now consistent too.

Net effect: a turn is counted in the day the API call actually ran in.
This commit is contained in:
iamtoruk 2026-04-21 04:40:44 -07:00
parent 68e9c63088
commit b491a1f590
2 changed files with 22 additions and 14 deletions

View file

@ -34,12 +34,15 @@ export function renderStatusBar(projects: ProjectSummary[]): string {
for (const project of projects) {
for (const session of project.sessions) {
for (const turn of session.turns) {
if (!turn.timestamp) continue
// Bucket by the session timestamp's local date so the user's "today" and "this month"
// match the wall clock on their machine. Session timestamps are stored as UTC ISO
// strings; naively slicing `timestamp.slice(0,10)` bucketed them by UTC date, which
// showed `Today $0` during the UTC-midnight-to-local-midnight window.
const day = localDateString(new Date(turn.timestamp))
if (turn.assistantCalls.length === 0) continue
// Bucket by the first assistant call's local date -- the moment the cost was
// incurred. Bucketing by `turn.timestamp` (the user message time) drops turns
// that straddle midnight (user asked at 23:58, response arrived at 00:30) and
// disagrees with parseAllSessions' dateRange filter which is also on assistant
// time.
const bucketTs = turn.assistantCalls[0]!.timestamp
if (!bucketTs) continue
const day = localDateString(new Date(bucketTs))
const turnCost = turn.assistantCalls.reduce((s, c) => s + c.costUSD, 0)
const turnCalls = turn.assistantCalls.length
if (day === today) { todayCost += turnCost; todayCalls += turnCalls }

View file

@ -287,18 +287,23 @@ async function parseSessionFile(
if (entries.length === 0) return null
let filteredEntries = entries
const sessionId = basename(filePath, '.jsonl')
let turns = groupIntoTurns(entries, seenMsgIds)
if (dateRange) {
filteredEntries = entries.filter(e => {
if (!e.timestamp) return e.type === 'user'
const ts = new Date(e.timestamp)
// Bucket a turn by the timestamp of its first assistant call (when the cost was
// actually incurred). Filtering entries directly produced orphan assistant calls
// when a user message sat in one day and the response landed in another -- those
// got pushed as turns with empty timestamps, which some code paths counted and
// others dropped, producing inconsistent Today totals.
turns = turns.filter(turn => {
if (turn.assistantCalls.length === 0) return false
const firstCallTs = turn.assistantCalls[0]!.timestamp
if (!firstCallTs) return false
const ts = new Date(firstCallTs)
return ts >= dateRange.start && ts <= dateRange.end
})
if (filteredEntries.length === 0) return null
if (turns.length === 0) return null
}
const sessionId = basename(filePath, '.jsonl')
const turns = groupIntoTurns(filteredEntries, seenMsgIds)
const classified = turns.map(classifyTurn)
return buildSessionSummary(sessionId, project, classified)