diff --git a/src/cli-date.ts b/src/cli-date.ts index 7dfd06f..2adfe97 100644 --- a/src/cli-date.ts +++ b/src/cli-date.ts @@ -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'}` +} diff --git a/src/cli.ts b/src/cli.ts index 7474efc..3828da2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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' @@ -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, @@ -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 ', 'Export format: csv, json', 'csv') .option('-o, --output ', 'Output file path') + .option('--from ', 'Start date (YYYY-MM-DD). Exports a single custom period when set') + .option('--to ', 'End date (YYYY-MM-DD). Exports a single custom period when set') .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') .option('--project ', 'Show only projects matching name (repeatable)', collect, []) .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) @@ -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') @@ -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 diff --git a/src/export.ts b/src/export.ts index 4e1afc1..e6e2ff9 100644 --- a/src/export.ts +++ b/src/export.ts @@ -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', '-----', diff --git a/tests/cli-export-date-range.test.ts b/tests/cli-export-date-range.test.ts new file mode 100644 index 0000000..e0201cc --- /dev/null +++ b/tests/cli-export-date-range.test.ts @@ -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 }) + } + }) +}) diff --git a/tests/date-range-filter.test.ts b/tests/date-range-filter.test.ts index c4f9408..76a3118 100644 --- a/tests/date-range-filter.test.ts +++ b/tests/date-range-filter.test.ts @@ -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', () => { @@ -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') + }) }) diff --git a/tests/export.test.ts b/tests/export.test.ts index 91c2d4c..daf200c 100644 --- a/tests/export.test.ts +++ b/tests/export.test.ts @@ -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') + }) })