import { describe, expect, it } from 'vitest' import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from '../src/day-aggregator.js' import type { ProjectSummary } from '../src/types.js' function makeProject(overrides: Partial & { sessions: ProjectSummary['sessions'] }): ProjectSummary { return { project: 'p', projectPath: '/p', totalCostUSD: overrides.sessions.reduce((s, sess) => s + sess.totalCostUSD, 0), totalApiCalls: overrides.sessions.reduce((s, sess) => s + sess.apiCalls, 0), ...overrides, } } function makeCall(timestamp: string, costUSD: number, model = 'Opus 4.7', provider = 'claude') { return { provider, model, usage: { inputTokens: 100, outputTokens: 200, cacheCreationInputTokens: 0, cacheReadInputTokens: 50, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, }, costUSD, tools: [], mcpTools: [], skills: [], hasAgentSpawn: false, hasPlanMode: false, speed: 'standard' as const, timestamp, bashCommands: [], deduplicationKey: `dk-${timestamp}-${costUSD}`, } } function makeSingleTurnProject( assistantCalls: ReturnType[], { retries = 0, hasEdits = true }: { retries?: number; hasEdits?: boolean } = {}, ): ProjectSummary { const timestamp = assistantCalls[0]!.timestamp const totalCostUSD = assistantCalls.reduce((sum, call) => sum + call.costUSD, 0) return makeProject({ sessions: [{ sessionId: 'multi-provider-session', project: 'p', firstTimestamp: timestamp, lastTimestamp: assistantCalls.at(-1)!.timestamp, totalCostUSD, totalInputTokens: assistantCalls.reduce((sum, call) => sum + call.usage.inputTokens, 0), totalOutputTokens: assistantCalls.reduce((sum, call) => sum + call.usage.outputTokens, 0), totalCacheReadTokens: assistantCalls.reduce((sum, call) => sum + call.usage.cacheReadInputTokens, 0), totalCacheWriteTokens: assistantCalls.reduce((sum, call) => sum + call.usage.cacheCreationInputTokens, 0), apiCalls: assistantCalls.length, turns: [{ userMessage: 'compare providers', timestamp, sessionId: 'multi-provider-session', category: 'coding', retries, hasEdits, assistantCalls, }], modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {}, categoryBreakdown: {} as never, skillBreakdown: {} as never, }], }) } describe('aggregateProjectsIntoDays', () => { it("buckets call-derived values under each call's own date when a turn straddles midnight", () => { // Per-call bucketing (issue #852): a turn whose calls straddle midnight // puts each call's cost/calls/tokens on the day the call happened, so // day-N + day-N+1 reconcile with a range parse that sliced the turn at // the same boundary. Turn-level judgments (editTurns, category turns) // stay anchored on the turn's day. const projects: ProjectSummary[] = [ makeProject({ sessions: [{ sessionId: 's1', project: 'p', firstTimestamp: '2026-04-09T10:00:00', lastTimestamp: '2026-04-10T08:00:00', totalCostUSD: 10, totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0, apiCalls: 2, turns: [ { userMessage: 'hi', timestamp: '2026-04-09T10:00:00', sessionId: 's1', category: 'coding', retries: 0, hasEdits: true, assistantCalls: [ makeCall('2026-04-09T10:00:00', 4), makeCall('2026-04-10T08:00:00', 6), ], }, ], modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {}, categoryBreakdown: {} as never, skillBreakdown: {} as never, }], }), ] const days = aggregateProjectsIntoDays(projects) expect(days.map(d => d.date)).toEqual(['2026-04-09', '2026-04-10']) expect(days[0]!.cost).toBe(4) expect(days[0]!.calls).toBe(1) expect(days[1]!.cost).toBe(6) expect(days[1]!.calls).toBe(1) // Turn-level stats anchor on the turn's day only — they describe the // whole exchange, not a per-call sum. expect(days[0]!.editTurns).toBe(1) expect(days[1]!.editTurns).toBe(0) expect(days[0]!.categories['coding']?.turns).toBe(1) expect(days[1]!.categories['coding']).toBeUndefined() }) it('attributes category turns + editTurns + oneShotTurns to the first call date of the turn', () => { const projects: ProjectSummary[] = [ makeProject({ sessions: [{ sessionId: 's1', project: 'p', firstTimestamp: '2026-04-09T10:00:00', lastTimestamp: '2026-04-09T10:05:00', totalCostUSD: 3, totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0, apiCalls: 1, turns: [ { userMessage: 'hi', timestamp: '2026-04-09T10:00:00', sessionId: 's1', category: 'coding', retries: 0, hasEdits: true, assistantCalls: [makeCall('2026-04-09T10:00:00', 3)], }, ], modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {}, categoryBreakdown: {} as never, skillBreakdown: {} as never, }], }), ] const days = aggregateProjectsIntoDays(projects) const day = days[0]! expect(day.editTurns).toBe(1) expect(day.oneShotTurns).toBe(1) expect(day.categories['coding']).toEqual({ turns: 1, cost: 3, savingsUSD: 0, editTurns: 1, oneShotTurns: 1, }) }) it('skips a timestamped empty-call turn without throwing or counting it at day level', () => { const projects: ProjectSummary[] = [ makeProject({ sessions: [{ sessionId: 'empty-call-turn', project: 'p', firstTimestamp: '2026-04-09T10:00:00', lastTimestamp: '2026-04-09T10:00:00', totalCostUSD: 0, totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0, apiCalls: 0, turns: [{ userMessage: 'no assistant response', timestamp: '2026-04-09T10:00:00', sessionId: 'empty-call-turn', category: 'coding', retries: 0, hasEdits: true, assistantCalls: [], }], modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {}, categoryBreakdown: {} as never, skillBreakdown: {} as never, }], }), ] let days: ReturnType = [] expect(() => { days = aggregateProjectsIntoDays(projects) }).not.toThrow() expect(days).toHaveLength(1) expect(days[0]).toMatchObject({ cost: 0, calls: 0, editTurns: 0, oneShotTurns: 0 }) expect(days[0]!.categories).toEqual({}) expect(days[0]!.providers).toEqual({}) }) it('counts a session under its firstTimestamp date', () => { const projects: ProjectSummary[] = [ makeProject({ sessions: [{ sessionId: 's1', project: 'p', firstTimestamp: '2026-04-09T23:59:00', lastTimestamp: '2026-04-10T00:10:00', totalCostUSD: 1, totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0, apiCalls: 0, turns: [], modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {}, categoryBreakdown: {} as never, skillBreakdown: {} as never, }], }), ] const days = aggregateProjectsIntoDays(projects) const expectedDate = dateKey('2026-04-09T23:59:00') expect(days[0]!.date).toBe(expectedDate) expect(days[0]!.sessions).toBe(1) }) it('aggregates per-model and per-provider totals inside each day', () => { const projects: ProjectSummary[] = [ makeProject({ sessions: [{ sessionId: 's1', project: 'p', firstTimestamp: '2026-04-10T10:00:00', lastTimestamp: '2026-04-10T10:00:00', totalCostUSD: 10, totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0, apiCalls: 2, turns: [ { userMessage: 'x', timestamp: '2026-04-10T10:00:00', sessionId: 's1', category: 'coding', retries: 0, hasEdits: false, assistantCalls: [ makeCall('2026-04-10T10:00:00', 7, 'Opus 4.7', 'claude'), makeCall('2026-04-10T10:00:00', 3, 'gpt-5', 'codex'), ], }, ], modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {}, categoryBreakdown: {} as never, skillBreakdown: {} as never, }], }), ] const days = aggregateProjectsIntoDays(projects) const day = days[0]! expect(day.models['Opus 4.7']).toEqual({ calls: 1, cost: 7, savingsUSD: 0, inputTokens: 100, outputTokens: 200, cacheReadTokens: 50, cacheWriteTokens: 0, }) expect(day.models['gpt-5']).toEqual({ calls: 1, cost: 3, savingsUSD: 0, inputTokens: 100, outputTokens: 200, cacheReadTokens: 50, cacheWriteTokens: 0, }) // Provider slices carry the full per-provider breakdown (v14) so that a // carried-forward slice stays exact across daily-cache rebuilds. expect(day.providers['claude']).toMatchObject({ calls: 1, cost: 7, savingsUSD: 0, inputTokens: 100, outputTokens: 200, cacheReadTokens: 50, cacheWriteTokens: 0, }) expect(day.providers['claude']!.models).toEqual({ 'Opus 4.7': { calls: 1, cost: 7, savingsUSD: 0, inputTokens: 100, outputTokens: 200, cacheReadTokens: 50, cacheWriteTokens: 0 }, }) expect(day.providers['codex']).toMatchObject({ calls: 1, cost: 3, savingsUSD: 0, inputTokens: 100, outputTokens: 200, cacheReadTokens: 50, cacheWriteTokens: 0, }) expect(day.providers['codex']!.models).toEqual({ 'gpt-5': { calls: 1, cost: 3, savingsUSD: 0, inputTokens: 100, outputTokens: 200, cacheReadTokens: 50, cacheWriteTokens: 0 }, }) // Slice categories hold only that provider's share of cost; the primary // provider (the first call in this tie) owns the turn count. expect(day.providers['claude']!.categories!['coding']).toMatchObject({ turns: 1, cost: 7 }) expect(day.providers['codex']!.categories!['coding']).toMatchObject({ turns: 0, cost: 3 }) // Day-level category still counts the whole turn once. expect(day.categories['coding']).toMatchObject({ turns: 1, cost: 10 }) // Per-project rollup at day level and inside each provider slice; path is // stored so display layers can derive a friendly name once sessions expire. expect(day.projects!['p']).toEqual({ cost: 10, calls: 2, savingsUSD: 0, sessions: 1, path: '/p' }) expect(day.providers['claude']!.projects!['p']).toMatchObject({ cost: 7, calls: 1 }) expect(day.providers['codex']!.projects!['p']).toMatchObject({ cost: 3, calls: 1 }) }) it('attributes a multi-provider turn to the majority provider exactly once', () => { const timestamp = '2026-04-10T10:00:00' const projects = [makeSingleTurnProject([ makeCall(timestamp, 2, 'gpt-5', 'codex'), makeCall(timestamp, 3, 'Opus 4.7', 'claude'), makeCall(timestamp, 4, 'Opus 4.7', 'claude'), ])] const day = aggregateProjectsIntoDays(projects)[0]! const slices = Object.values(day.providers) expect(day.editTurns).toBe(1) expect(day.oneShotTurns).toBe(1) expect(day.categories['coding']!.turns).toBe(1) expect(slices.reduce((sum, provider) => sum + (provider.editTurns ?? 0), 0)).toBe(1) expect(slices.reduce((sum, provider) => sum + (provider.oneShotTurns ?? 0), 0)).toBe(1) expect(slices.reduce((sum, provider) => sum + (provider.categories?.['coding']?.turns ?? 0), 0)).toBe(1) expect(day.providers['claude']).toMatchObject({ cost: 7, editTurns: 1, oneShotTurns: 1 }) expect(day.providers['claude']!.categories!['coding']).toMatchObject({ turns: 1, cost: 7, editTurns: 1, oneShotTurns: 1 }) expect(day.providers['codex']).toMatchObject({ cost: 2, editTurns: 0, oneShotTurns: 0 }) expect(day.providers['codex']!.categories!['coding']).toMatchObject({ turns: 0, cost: 2, editTurns: 0, oneShotTurns: 0 }) }) it("breaks a provider call-count tie with the turn's first call", () => { const timestamp = '2026-04-10T10:00:00' const projects = [makeSingleTurnProject([ makeCall(timestamp, 2, 'gpt-5', 'codex'), makeCall(timestamp, 7, 'Opus 4.7', 'claude'), ])] const day = aggregateProjectsIntoDays(projects)[0]! const slices = Object.values(day.providers) expect(slices.reduce((sum, provider) => sum + (provider.editTurns ?? 0), 0)).toBe(1) expect(slices.reduce((sum, provider) => sum + (provider.categories?.['coding']?.turns ?? 0), 0)).toBe(1) expect(day.providers['codex']).toMatchObject({ editTurns: 1, oneShotTurns: 1 }) expect(day.providers['codex']!.categories!['coding']).toMatchObject({ turns: 1, editTurns: 1, oneShotTurns: 1 }) expect(day.providers['claude']).toMatchObject({ editTurns: 0, oneShotTurns: 0 }) expect(day.providers['claude']!.categories!['coding']).toMatchObject({ turns: 0, editTurns: 0, oneShotTurns: 0 }) }) }) describe('buildPeriodDataFromDays', () => { function makeDay(date: string, cost: number) { return { date, cost, calls: 10, sessions: 2, inputTokens: 100, outputTokens: 200, cacheReadTokens: 300, cacheWriteTokens: 0, editTurns: 3, oneShotTurns: 2, models: { 'Opus 4.7': { calls: 8, cost: cost * 0.8, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }, 'Haiku 4.5': { calls: 2, cost: cost * 0.2, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }, }, categories: { 'coding': { turns: 2, cost: cost * 0.5, savingsUSD: 0, editTurns: 2, oneShotTurns: 1 } }, providers: { 'claude': { calls: 10, cost, savingsUSD: 0 } }, } } it('sums cost, calls, sessions, tokens across days', () => { const days = [makeDay('2026-04-09', 10), makeDay('2026-04-10', 20)] const pd = buildPeriodDataFromDays(days, '7 Days') expect(pd.label).toBe('7 Days') expect(pd.cost).toBe(30) expect(pd.calls).toBe(20) expect(pd.sessions).toBe(4) expect(pd.inputTokens).toBe(200) expect(pd.outputTokens).toBe(400) expect(pd.cacheReadTokens).toBe(600) }) it('merges per-model totals across days and sorts by cost desc', () => { const days = [makeDay('2026-04-09', 10), makeDay('2026-04-10', 20)] const pd = buildPeriodDataFromDays(days, 'Today') expect(pd.models[0]!.name).toBe('Opus 4.7') expect(pd.models[0]!.cost).toBeCloseTo(24) expect(pd.models[1]!.name).toBe('Haiku 4.5') expect(pd.models[1]!.cost).toBeCloseTo(6) }) it('merges per-category totals and keeps editTurns + oneShotTurns per category', () => { const days = [makeDay('2026-04-09', 10), makeDay('2026-04-10', 20)] const pd = buildPeriodDataFromDays(days, 'Today') const coding = pd.categories.find(c => c.name === 'Coding')! expect(coding.turns).toBe(4) expect(coding.editTurns).toBe(4) expect(coding.oneShotTurns).toBe(2) expect(coding.cost).toBeCloseTo(15) }) it('returns empty period totals when no days supplied', () => { const pd = buildPeriodDataFromDays([], 'Today') expect(pd.cost).toBe(0) expect(pd.calls).toBe(0) expect(pd.sessions).toBe(0) expect(pd.categories).toEqual([]) expect(pd.models).toEqual([]) }) it("attributes a midnight-straddling turn's cost to the call's own date", () => { // A turn whose user message sits on one side of midnight and whose // assistant response lands on the other buckets its cost under the CALL's // day (issue #852's per-call rule), so the daily cache (history.daily + // provider breakdown) reconciles exactly to a range parse that slices the // same turn at the same boundary — and day-N + day-N+1 sum to the period // total with nothing lost on either side. const userTs = '2026-04-20T23:58:00Z' const assistantTs = '2026-04-21T00:30:00Z' const assistantLocal = new Date(assistantTs) const expectedDate = `${assistantLocal.getFullYear()}-${String(assistantLocal.getMonth() + 1).padStart(2, '0')}-${String(assistantLocal.getDate()).padStart(2, '0')}` const projects: ProjectSummary[] = [ makeProject({ sessions: [{ sessionId: 's1', project: 'p', firstTimestamp: userTs, lastTimestamp: assistantTs, totalCostUSD: 5, totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0, apiCalls: 1, turns: [{ userMessage: 'ask', timestamp: userTs, sessionId: 's1', category: 'coding', retries: 0, hasEdits: false, assistantCalls: [makeCall(assistantTs, 5)], }], modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {}, categoryBreakdown: {} as never, skillBreakdown: {} as never, }], }), ] const days = aggregateProjectsIntoDays(projects) const costDay = days.find(d => d.cost === 5) expect(costDay, 'turn cost must be bucketed somewhere').toBeDefined() expect(costDay!.date).toBe(expectedDate) expect(costDay!.calls).toBe(1) }) }) describe('daily-cache ↔ report daily-bucket parity', () => { // The daily cache (history.daily + provider breakdown) and the live report / // headline (main.ts daily rollup) must bucket days by the SAME rule, or their // per-day totals drift and their period sums diverge from current.cost at // window boundaries — the V1 audit's constant -$3.45/-81-calls finding. Both // are now PER-CALL for cost/savings/calls (issue #852) with turn-level stats // still turn-anchored: this asserts per-day equality against a reference // that mirrors main.ts buildJsonReport's dailyMap fallback (each call on its // own date), plus the invariant history.daily Σ == report.daily Σ == total // call cost. // Mirrors the live report/headline daily rollup fallback in src/main.ts // (cost/savings/calls bucket under each call's own date). function reportDailyByDate(projects: ProjectSummary[]): Record { const byDate: Record = {} for (const p of projects) { for (const sess of p.sessions) { for (const turn of sess.turns) { for (const call of turn.assistantCalls) { byDate[dateKey(call.timestamp)] = (byDate[dateKey(call.timestamp)] ?? 0) + call.costUSD } } } } return byDate } it('buckets each day identically to the report and reconciles the period total', () => { // Construct in LOCAL time so the turn genuinely straddles local midnight on // any machine TZ (UTC-literal timestamps would straddle in some zones only). const iso = (y: number, mo: number, d: number, h: number, mi: number) => new Date(y, mo, d, h, mi, 0).toISOString() const turnTs = iso(2026, 3, 16, 23, 50) // day A, late evening const call1Ts = iso(2026, 3, 16, 23, 55) // day A const call2Ts = iso(2026, 3, 17, 0, 10) // day A+1 (turn straddles midnight) const turn2Ts = iso(2026, 3, 17, 9, 0) // day A+1 const dayA = dateKey(turnTs) const dayB = dateKey(call2Ts) expect(dayA).not.toBe(dayB) // sanity: the fixture really straddles local midnight // A midnight-straddling turn (calls on both days) plus a same-day turn, so // whole-TURN anchoring would produce DIFFERENT per-day totals than the // per-call rule — the case the old code got wrong. const projects: ProjectSummary[] = [ makeProject({ sessions: [{ sessionId: 's1', project: 'p', firstTimestamp: turnTs, lastTimestamp: turn2Ts, totalCostUSD: 12, totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0, apiCalls: 3, turns: [ { userMessage: 'straddles into next day', timestamp: turnTs, sessionId: 's1', category: 'coding', retries: 0, hasEdits: false, assistantCalls: [makeCall(call1Ts, 2), makeCall(call2Ts, 3)], }, { userMessage: 'later same day', timestamp: turn2Ts, sessionId: 's1', category: 'coding', retries: 0, hasEdits: false, assistantCalls: [makeCall(turn2Ts, 7)], }, ], modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {}, categoryBreakdown: {} as never, skillBreakdown: {} as never, }], }), ] const historyDaily = aggregateProjectsIntoDays(projects) const historyByDate = Object.fromEntries(historyDaily.map(d => [d.date, d.cost])) const reportByDate = reportDailyByDate(projects) // history.daily (cache path) buckets each day EXACTLY as the report does. expect(historyByDate).toEqual(reportByDate) // And the provider breakdown, summed per day, matches too (same bug root). for (const d of historyDaily) { const providerSum = Object.values(d.providers).reduce((s, pr) => s + pr.cost, 0) expect(providerSum).toBeCloseTo(d.cost, 10) } // history.daily Σ == report.daily Σ == current.cost (total of all call costs). const historySum = historyDaily.reduce((s, d) => s + d.cost, 0) const reportSum = Object.values(reportByDate).reduce((s, c) => s + c, 0) const totalCallCost = 2 + 3 + 7 expect(historySum).toBeCloseTo(totalCallCost, 10) expect(reportSum).toBeCloseTo(totalCallCost, 10) // Day A owns only the straddling turn's pre-midnight call (2); day B owns // the post-midnight call plus the same-day turn (3+7=10). Both paths agree // per day and the period total is conserved. expect(historyByDate[dayA]).toBe(2) expect(historyByDate[dayB]).toBe(10) }) })