feat(export): support custom date ranges

This commit is contained in:
ozymandiashh 2026-05-06 09:18:48 +03:00 committed by GitHub
parent 869474b3b4
commit fc4c4f0091
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 150 additions and 14 deletions

View file

@ -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'}`
}

View file

@ -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 <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, [])
@ -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

View file

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

View file

@ -0,0 +1,96 @@
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,
TZ: 'UTC',
},
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 })
}
})
})

View file

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

View file

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