Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/cli-date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,7 @@ export function getDateRange(period: string): { range: DateRange; label: string
}
}
}

export function formatDateRangeLabel(from: string | undefined, to: string | undefined): string {
return `${from ?? 'all'} to ${to ?? 'today'}`
}
32 changes: 23 additions & 9 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateS
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, getDateRange, toPeriod, type Period } from './cli-date.js'
import { formatDateRangeLabel, parseDateRangeFlags, getDateRange, toPeriod, type Period } from './cli-date.js'
import { runOptimize, scanAndDetect } from './optimize.js'
import { renderCompare } from './compare.js'
import { getAllProviders } from './providers/index.js'
Expand Down Expand Up @@ -271,7 +271,7 @@ program
await loadPricing()
await hydrateCache()
if (customRange) {
const label = `${opts.from ?? 'all'} to ${opts.to ?? 'today'}`
const label = formatDateRangeLabel(opts.from, opts.to)
const projects = filterProjectsByName(
await parseAllSessions(customRange, opts.provider),
opts.project,
Expand Down Expand Up @@ -528,9 +528,11 @@ program

program
.command('export')
.description('Export usage data to CSV or JSON (includes 1 day, 7 days, 30 days)')
.description('Export usage data to CSV or JSON')
.option('-f, --format <format>', 'Export format: csv, json', 'csv')
.option('-o, --output <path>', 'Output file path')
.option('--from <date>', 'Start date (YYYY-MM-DD). Exports a single custom period when set')
.option('--to <date>', 'End date (YYYY-MM-DD). Exports a single custom period when set')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
Expand All @@ -539,11 +541,22 @@ program
await hydrateCache()
const pf = opts.provider
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
const periods: PeriodExport[] = [
{ label: 'Today', projects: fp(await parseAllSessions(getDateRange('today').range, pf)) },
{ label: '7 Days', projects: fp(await parseAllSessions(getDateRange('week').range, pf)) },
{ label: '30 Days', projects: fp(await parseAllSessions(getDateRange('30days').range, pf)) },
]
let customRange: DateRange | null = null
try {
customRange = parseDateRangeFlags(opts.from, opts.to)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`\n Error: ${message}\n`)
process.exit(1)
}

const periods: PeriodExport[] = customRange
? [{ label: formatDateRangeLabel(opts.from, opts.to), projects: fp(await parseAllSessions(customRange, pf)) }]
: [
{ label: 'Today', projects: fp(await parseAllSessions(getDateRange('today').range, pf)) },
{ label: '7 Days', projects: fp(await parseAllSessions(getDateRange('week').range, pf)) },
{ label: '30 Days', projects: fp(await parseAllSessions(getDateRange('30days').range, pf)) },
]

if (periods.every(p => p.projects.length === 0)) {
console.log('\n No usage data found.\n')
Expand All @@ -569,7 +582,8 @@ program
process.exit(1)
}

console.log(`\n Exported (Today + 7 Days + 30 Days) to: ${savedPath}\n`)
const exportedLabel = customRange ? formatDateRangeLabel(opts.from, opts.to) : 'Today + 7 Days + 30 Days'
console.log(`\n Exported (${exportedLabel}) to: ${savedPath}\n`)
})

program
Expand Down
8 changes: 4 additions & 4 deletions src/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,10 +247,10 @@ function buildReadme(periods: PeriodExport[]): string {
' daily.csv Day-by-day breakdown, Period column distinguishes the window.',
' activity.csv Time spent per task category (Coding, Debugging, Exploration, etc.).',
' models.csv Spend per model with token totals and cache usage.',
' projects.csv Spend per project folder (30-day window).',
' sessions.csv One row per session (30-day window) with session IDs and costs.',
' tools.csv Tool invocations and share (30-day window).',
' shell-commands.csv Shell commands executed via Bash tool (30-day window).',
' projects.csv Spend per project folder for the selected detail period.',
' sessions.csv One row per session for the selected detail period.',
' tools.csv Tool invocations and share for the selected detail period.',
' shell-commands.csv Shell commands executed via Bash tool for the selected detail period.',
'',
'Notes',
'-----',
Expand Down
95 changes: 95 additions & 0 deletions tests/cli-export-date-range.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'

import { describe, expect, it } from 'vitest'

function runCli(args: string[], home: string) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
cwd: process.cwd(),
env: {
...process.env,
CLAUDE_CONFIG_DIR: join(home, '.claude'),
HOME: home,
},
encoding: 'utf-8',
})
}

function userLine(sessionId: string, timestamp: string): string {
return JSON.stringify({
type: 'user',
sessionId,
timestamp,
message: { role: 'user', content: 'add feature' },
})
}

function assistantLine(sessionId: string, timestamp: string, messageId: string): string {
return JSON.stringify({
type: 'assistant',
sessionId,
timestamp,
message: {
id: messageId,
type: 'message',
role: 'assistant',
model: 'claude-sonnet-4-5',
content: [{ type: 'text', text: 'done' }],
usage: {
input_tokens: 1000,
output_tokens: 100,
},
},
})
}

describe('codeburn export custom date range', () => {
it('exports a single custom period filtered by --from/--to', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-export-'))

try {
const projectDir = join(home, '.claude', 'projects', 'app')
await mkdir(projectDir, { recursive: true })
await writeFile(
join(projectDir, 'in-range.jsonl'),
[
userLine('in-range', '2026-04-10T09:00:00Z'),
assistantLine('in-range', '2026-04-10T09:01:00Z', 'msg-in-range'),
].join('\n'),
)
await writeFile(
join(projectDir, 'out-of-range.jsonl'),
[
userLine('out-of-range', '2026-04-11T09:00:00Z'),
assistantLine('out-of-range', '2026-04-11T09:01:00Z', 'msg-out-of-range'),
].join('\n'),
)

const outputPath = join(home, 'custom-export.json')
const result = runCli([
'export',
'--format', 'json',
'--from', '2026-04-10',
'--to', '2026-04-10',
'--provider', 'claude',
'--output', outputPath,
], home)

expect(result.status).toBe(0)
expect(result.stdout).toContain('Exported (2026-04-10 to 2026-04-10)')

const exported = JSON.parse(await readFile(outputPath, 'utf-8')) as {
summary: Array<{ Period: string; Sessions: number }>
sessions: Array<{ 'Session ID': string }>
}
expect(exported.summary).toHaveLength(1)
expect(exported.summary[0]?.Period).toBe('2026-04-10 to 2026-04-10')
expect(exported.summary[0]?.Sessions).toBe(1)
expect(exported.sessions.map(s => s['Session ID'])).toEqual(['in-range'])
} finally {
await rm(home, { recursive: true, force: true })
}
})
})
8 changes: 7 additions & 1 deletion tests/date-range-filter.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { parseDateRangeFlags } from '../src/cli-date.js'
import { formatDateRangeLabel, parseDateRangeFlags } from '../src/cli-date.js'

describe('parseDateRangeFlags', () => {
it('returns null when neither flag is provided', () => {
Expand Down Expand Up @@ -54,4 +54,10 @@ describe('parseDateRangeFlags', () => {
expect(range!.start.getDate()).toBe(10)
expect(range!.end.getDate()).toBe(10)
})

it('formats custom range labels consistently', () => {
expect(formatDateRangeLabel('2026-04-07', '2026-04-10')).toBe('2026-04-07 to 2026-04-10')
expect(formatDateRangeLabel(undefined, '2026-04-10')).toBe('all to 2026-04-10')
expect(formatDateRangeLabel('2026-04-07', undefined)).toBe('2026-04-07 to today')
})
})
16 changes: 16 additions & 0 deletions tests/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,4 +158,20 @@ describe('exportCsv', () => {
const entries = await readdir(folder)
expect(entries.length).toBeGreaterThanOrEqual(0)
})

it('describes detail files without hardcoding a 30-day window', async () => {
const periods: PeriodExport[] = [
{
label: '2026-04-07 to 2026-04-10',
projects: [makeProject('app')],
},
]

const outputPath = join(tmpDir, 'custom.csv')
const folder = await exportCsv(periods, outputPath)
const readme = await readFile(join(folder, 'README.txt'), 'utf-8')

expect(readme).toContain('selected detail period')
expect(readme).not.toContain('30-day window')
})
})
Loading