mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-17 03:56:45 +00:00
feat(export): support custom date ranges
This commit is contained in:
parent
869474b3b4
commit
fc4c4f0091
6 changed files with 150 additions and 14 deletions
|
|
@ -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
src/cli.ts
32
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 <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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
'-----',
|
||||
|
|
|
|||
96
tests/cli-export-date-range.test.ts
Normal file
96
tests/cli-export-date-range.test.ts
Normal 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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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…
Add table
Add a link
Reference in a new issue