diff --git a/README.md b/README.md index ee71e3ba..4c72c36f 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,8 @@ Requires **Node.js 22.13+** and at least one supported tool with session data on codeburn overview # this month, clean tables codeburn overview --no-color # plain text, ready to paste codeburn overview --from 2026-06-01 --to 2026-06-15 # any date range -codeburn overview -p all # all time +codeburn overview -p all # last 6 months +codeburn overview -p lifetime # full history (uncapped) codeburn overview --provider claude # one tool only ``` @@ -386,7 +387,7 @@ Adding a new provider is a single file. See `src/providers/codex.ts` for an exam
All commands and keyboard shortcuts -Run `codeburn` for the dashboard, or use a subcommand below. Most commands also accept `--provider`, `--project` / `--exclude`, and a period flag (`-p today|week|30days|month|all`). +Run `codeburn` for the dashboard, or use a subcommand below. Most commands also accept `--provider`, `--project` / `--exclude`, and a period flag (`-p today|week|30days|month|all|lifetime`). **Dashboard & reports** @@ -472,7 +473,7 @@ Sync sends token counts, costs, models, and projects, never prompts or code. Thi | `codeburn models --task feature` | Filter to feature-development work | | `codeburn models --provider claude` | Filter to a single provider | -Left/right arrow keys switch between Today, 7 Days, 30 Days, Month, and 6 Months (use `--from` / `--to` for an exact historical window). The main Daily Activity panel always shows scrollable full history: use up/down to move one day, Page Up/Page Down (or Shift+Space/Space) to page, and `g`/`G` to jump to either end. These keys update the panel in place instead of moving terminal scrollback. Press `q` to quit, `1` `2` `3` `4` `5` as period shortcuts, `c` to open model comparison, or `o` to open optimize. The dashboard auto-refreshes every 30 seconds by default (`--refresh 0` to disable). It also shows average cost per session and the five most expensive sessions across all projects. +Left/right arrow keys switch between Today, 7 Days, 30 Days, Month, 6 Months, and Lifetime (use `--from` / `--to` for an exact historical window). The main Daily Activity panel always shows scrollable full history: use up/down to move one day, Page Up/Page Down (or Shift+Space/Space) to page, and `g`/`G` to jump to either end. These keys update the panel in place instead of moving terminal scrollback. Press `q` to quit, `1` `2` `3` `4` `5` `6` as period shortcuts, `c` to open model comparison, or `o` to open optimize. The dashboard auto-refreshes every 30 seconds by default (`--refresh 0` to disable). It also shows average cost per session and the five most expensive sessions across all projects.
@@ -582,7 +583,7 @@ codeburn report --from 2026-04-01 # this date through today codeburn report --to 2026-04-10 # earliest data through this date ``` -Either flag alone is valid. Inverted or malformed dates exit with a clear error. In the TUI, the custom range sets the initial load only; pressing `1` through `5` switches back to predefined periods. +Either flag alone is valid. Inverted or malformed dates exit with a clear error. In the TUI, the custom range sets the initial load only; pressing `1` through `6` switches back to predefined periods. ### Diagnosing detection diff --git a/src/cli-date.ts b/src/cli-date.ts index d1daceb6..3b4b5fff 100644 --- a/src/cli-date.ts +++ b/src/cli-date.ts @@ -8,16 +8,16 @@ const END_OF_DAY_MINUTES = 59 const END_OF_DAY_SECONDS = 59 const END_OF_DAY_MS = 999 -// "All Time" is intentionally bounded to the last 6 months. Older data is -// rarely actionable for a cost tracker, and capping the range keeps the parse -// path bounded so providers like Codex/Cursor with sparse multi-year history -// still load in seconds. Users who need an unbounded window can use -// `--from` / `--to`. +// The "all" period is intentionally bounded to the last 6 months. Older data +// is rarely actionable for a cost tracker, and capping the range keeps the +// parse path bounded so providers like Codex/Cursor with sparse multi-year +// history still load in seconds. Users who need an unbounded window can use +// the explicit `lifetime` period or `--from` / `--to`. const ALL_TIME_MONTHS = 6 -export type Period = 'today' | 'week' | '30days' | 'month' | 'all' +export type Period = 'today' | 'week' | '30days' | 'month' | 'all' | 'lifetime' -export const PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all'] +export const PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all', 'lifetime'] // Short labels suitable for the dashboard tab strip. Long-form labels for // header text come from `getDateRange().label`. @@ -27,9 +27,10 @@ export const PERIOD_LABELS: Record = { '30days': '30 Days', month: 'This Month', all: '6 Months', + lifetime: 'Lifetime', } -const VALID_PERIODS: ReadonlyArray = ['today', 'week', '30days', 'month', 'all'] +const VALID_PERIODS: ReadonlyArray = ['today', 'week', '30days', 'month', 'all', 'lifetime'] export class UsageQueryError extends Error { constructor(message: string) { @@ -145,8 +146,8 @@ export function parseDateRangeFlags(from: string | undefined, to: string | undef * surfaces a few extra inputs not exposed in the dashboard tab strip * (e.g. `'yesterday'`). Unknown values fall back to `'week'`. * - * Note: `'all'` is bounded to the last 6 months. Use `--from`/`--to` for - * an unbounded historical window. + * Note: `'all'` is bounded to the last 6 months. Use `'lifetime'` or + * `--from`/`--to` for an unbounded historical window. */ export function getDateRange(period: string): { range: DateRange; label: string } { const now = new Date() @@ -185,9 +186,13 @@ export function getDateRange(period: string): { range: DateRange; label: string const start = new Date(now.getFullYear(), now.getMonth() - ALL_TIME_MONTHS, 1) return { range: { start, end }, label: 'Last 6 months' } } + case 'lifetime': { + const start = new Date(1970, 0, 1) + return { range: { start, end }, label: 'Lifetime' } + } default: { process.stderr.write( - `codeburn: unknown period "${period}". Valid values: today, week, 30days, month, all.\n` + `codeburn: unknown period "${period}". Valid values: today, week, 30days, month, all, lifetime.\n` ) process.exit(1) } diff --git a/src/daily-cache.ts b/src/daily-cache.ts index 502bb1ed..f8543d98 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -263,9 +263,9 @@ export function withDailyCacheLock(fn: () => Promise): Promise { export const MS_PER_DAY = 24 * 60 * 60 * 1000 export const BACKFILL_DAYS = 365 -// Keep 2 years of history so the longest UI-exposed period (6 months -// today, with headroom for future longer windows) always reads from -// cache while old entries get pruned. +// Keep 2 years of history so the longest bounded UI period (6 months +// via `all`) and the uncapped `lifetime` period both have headroom to +// read from cache while old entries get pruned. export const DAILY_CACHE_RETENTION_DAYS = 730 export function toDateString(date: Date): string { diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 9f3cd6d9..c0c80624 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -49,7 +49,7 @@ const ORANGE = '#FF8C42' const DIM = '#555555' const GOLD = '#FFD700' const PLAN_BAR_WIDTH = 10 -const HEAVY_PERIODS = new Set(['30days', 'month', 'all']) +const HEAVY_PERIODS = new Set(['30days', 'month', 'all', 'lifetime']) const LANG_DISPLAY_NAMES: Record = { javascript: 'JavaScript', typescript: 'TypeScript', python: 'Python', @@ -760,7 +760,8 @@ function StatusBar({ width, showProvider, view, findingCount, optimizeAvailable, 2 week 3 30 days 4 month - 5 6 months + 5 6 months + 6 lifetime )} {!customRange && !isOptimize && ( @@ -806,7 +807,7 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets, return ( - + {isCursor ? ( @@ -1094,7 +1095,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, // Also disable while a custom --from/--to range is in effect. Switching // period would silently abandon the user's explicit range and reload // standard period data; the period tab strip is hidden in this mode so - // users have no expectation that 1-5 should do anything. + // users have no expectation that 1-6 should do anything. if (isCustomRange) return if (dayDate) { if (key.leftArrow) { void switchDay(shiftDay(dayDate, -1)); return } @@ -1115,6 +1116,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, else if (input === '3') switchPeriodImmediate('30days') else if (input === '4') switchPeriodImmediate('month') else if (input === '5') switchPeriodImmediate('all') + else if (input === '6') switchPeriodImmediate('lifetime') }) const headerLabel = dayDate ? formatDayRangeLabel(dayDate) : customRangeLabel ?? PERIOD_LABELS[period] diff --git a/src/main.ts b/src/main.ts index 6cccdc7e..c30c5fa2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -715,7 +715,7 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: program .command('report', { isDefault: true }) .description('Interactive usage dashboard') - .option('-p, --period ', 'Starting period: today, week, 30days, month, all', 'week') + .option('-p, --period ', 'Starting period: today, week, 30days, month, all, lifetime', 'week') .option('--day ', 'Single day to review (YYYY-MM-DD, today, or yesterday). Overrides --period when set') .option('--from ', 'Start date (YYYY-MM-DD). Overrides --period when set') .option('--to ', 'End date (YYYY-MM-DD). Overrides --period when set') @@ -797,7 +797,7 @@ program .command('devices [action] [target]') .description('Combined usage across your devices. Actions: scan | add (find nearby & pair) | add --pin (manual) | rm . Supports --format json for read-only output and scan.') .option('--pin ', 'Pairing PIN shown on the device you are adding') - .option('-p, --period ', 'Period: today, week, 30days, month, all', 'month') + .option('-p, --period ', 'Period: today, week, 30days, month, all, lifetime', 'month') .option('--port ', 'Default port when adding a device', parseInteger, 7777) .option('--format ', 'Output format: text, json', 'text') .action(async (action: string | undefined, target: string | undefined, opts) => { @@ -905,7 +905,7 @@ program program .command('overview') .description('Plain-text usage overview, copy-pasteable (defaults to this month)') - .option('-p, --period ', 'Period: today, week, 30days, month, all', 'month') + .option('-p, --period ', 'Period: today, week, 30days, month, all, lifetime', 'month') .option('--from ', 'Start date (YYYY-MM-DD). Overrides --period when set') .option('--to ', 'End date (YYYY-MM-DD). Overrides --period when set') .option('--provider ', 'Filter by provider (e.g. claude, codex, copilot)', 'all') @@ -975,7 +975,7 @@ program program .command('web') .description('Open the local web dashboard in your browser') - .option('-p, --period ', 'Initial period: today, week, 30days, month, all', 'today') + .option('-p, --period ', 'Initial period: today, week, 30days, month, all, lifetime', 'today') .option('--from ', 'Start date (YYYY-MM-DD)') .option('--to ', 'End date (YYYY-MM-DD)') .option('--provider ', 'Filter by provider (e.g. claude, codex, copilot)', 'all') @@ -1005,7 +1005,7 @@ program .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, []) - .option('--period ', 'Primary period for menubar-json: today, week, 30days, month, all', 'today') + .option('--period ', 'Primary period for menubar-json: today, week, 30days, month, all, lifetime', 'today') .option('--day ', 'Single day for menubar-json (YYYY-MM-DD, today, or yesterday). Overrides --period when set') .option('--from ', 'Start date (YYYY-MM-DD) for custom range') .option('--to ', 'End date (YYYY-MM-DD) for custom range') @@ -1723,7 +1723,7 @@ program program .command('optimize') .description('Find token waste and get exact fixes') - .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', '30days') + .option('-p, --period ', 'Analysis period: today, week, 30days, month, all, lifetime', '30days') .option('--from ', 'Custom range start (YYYY-MM-DD)') .option('--to ', 'Custom range end (YYYY-MM-DD)') .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') @@ -1806,7 +1806,7 @@ program program .command('compare') .description('Compare two AI models side-by-side') - .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', 'all') + .option('-p, --period ', 'Analysis period: today, week, 30days, month, all, lifetime', 'all') .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') .option('--format ', 'Output format: tui, json', 'tui') .option('--model-a ', 'First model to compare') @@ -1859,7 +1859,7 @@ program program .command('audit') .description("Token audit: raw provider token fields vs codeburn's displayed totals and cost derivation") - .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', '30days') + .option('-p, --period ', 'Analysis period: today, week, 30days, month, all, lifetime', '30days') .option('--from ', 'Custom range start (YYYY-MM-DD)') .option('--to ', 'Custom range end (YYYY-MM-DD)') .option('--provider ', 'Filter by provider (e.g. claude, codex, cursor)', 'all') @@ -1899,7 +1899,7 @@ program program .command('models') .description('Per-model token + cost table, optionally exploded by task type or agent') - .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', '30days') + .option('-p, --period ', 'Analysis period: today, week, 30days, month, all, lifetime', '30days') .option('--from ', 'Custom range start (YYYY-MM-DD)') .option('--to ', 'Custom range end (YYYY-MM-DD)') .option('--provider ', 'Filter by provider (e.g. claude, codex, cursor)', 'all') @@ -1962,7 +1962,7 @@ program program .command('sessions') .description('Full per-session usage report') - .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', '30days') + .option('-p, --period ', 'Analysis period: today, week, 30days, month, all, lifetime', '30days') .option('--from ', 'Custom range start (YYYY-MM-DD)') .option('--to ', 'Custom range end (YYYY-MM-DD)') .option('--provider ', 'Filter by provider (e.g. claude, codex, cursor)', 'all') @@ -1993,7 +1993,7 @@ program program .command('yield') .description('Track which AI spend shipped to main vs reverted/abandoned (experimental)') - .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', 'week') + .option('-p, --period ', 'Analysis period: today, week, 30days, month, all, lifetime', 'week') .option('--provider ', 'Filter by provider (e.g. claude, codex, cursor)', 'all') .option('--format ', 'Output format: text, json', 'text') .action(async (opts) => { @@ -2016,7 +2016,7 @@ program program .command('spend') .description('Emit model x project spend flow data') - .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', '30days') + .option('-p, --period ', 'Analysis period: today, week, 30days, month, all, lifetime', '30days') .option('--from ', 'Custom range start (YYYY-MM-DD)') .option('--to ', 'Custom range end (YYYY-MM-DD)') .option('--provider ', 'Filter by provider (e.g. claude, codex, cursor)', 'all') diff --git a/tests/cli-date.test.ts b/tests/cli-date.test.ts index f553c70b..fc496a4c 100644 --- a/tests/cli-date.test.ts +++ b/tests/cli-date.test.ts @@ -46,6 +46,17 @@ describe('getDateRange', () => { expect(range.start.getDate()).toBe(1) }) + it('"lifetime" starts at local epoch and stays open-ended through today', () => { + const { range, label } = getDateRange('lifetime') + + expect(label).toBe('Lifetime') + expect(range.start.getFullYear()).toBe(1970) + expect(range.start.getMonth()).toBe(0) + expect(range.start.getDate()).toBe(1) + expect(range.end.getHours()).toBe(23) + expect(range.end.getMinutes()).toBe(59) + }) + it('"week" returns the last 7 days', () => { const { range, label } = getDateRange('week') expect(label).toBe('Last 7 Days') @@ -90,7 +101,7 @@ describe('getDateRange', () => { describe('PERIODS / PERIOD_LABELS', () => { it('exposes the expected period set', () => { - expect(PERIODS).toEqual(['today', 'week', '30days', 'month', 'all']) + expect(PERIODS).toEqual(['today', 'week', '30days', 'month', 'all', 'lifetime']) }) it('has a label for every period', () => { @@ -104,11 +115,15 @@ describe('PERIODS / PERIOD_LABELS', () => { // ("Last 6 months") comes from getDateRange().label. expect(PERIOD_LABELS.all).toBe('6 Months') }) + + it('"lifetime" tab label is explicit about the unbounded range', () => { + expect(PERIOD_LABELS.lifetime).toBe('Lifetime') + }) }) describe('parsePeriodOrThrow', () => { it('round-trips known periods', () => { - const known: Period[] = ['today', 'week', '30days', 'month', 'all'] + const known: Period[] = ['today', 'week', '30days', 'month', 'all', 'lifetime'] for (const p of known) { expect(parsePeriodOrThrow(p)).toBe(p) } @@ -144,7 +159,7 @@ describe('periodInfoFromQuery', () => { describe('toPeriod', () => { it('round-trips known periods', () => { - const known: Period[] = ['today', 'week', '30days', 'month', 'all'] + const known: Period[] = ['today', 'week', '30days', 'month', 'all', 'lifetime'] for (const p of known) { expect(toPeriod(p)).toBe(p) } diff --git a/tests/cli-json-daily.test.ts b/tests/cli-json-daily.test.ts index 34ad500f..193ee90d 100644 --- a/tests/cli-json-daily.test.ts +++ b/tests/cli-json-daily.test.ts @@ -234,4 +234,51 @@ describe('codeburn report --format json daily[] one-shot fields (issue #279)', ( await rm(home, { recursive: true, force: true }) } }) + + it('includes older sessions under --period lifetime but not under --period all', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-json-lifetime-')) + + try { + const projectDir = join(home, '.claude', 'projects', 'app') + await mkdir(projectDir, { recursive: true }) + + await writeFile( + join(projectDir, 'history.jsonl'), + [ + userLine('old', '2025-10-01T09:00:00Z'), + assistantEditLine('old', '2025-10-01T09:01:00Z', 'm-old'), + userLine('recent', '2026-06-01T09:00:00Z'), + assistantEditLine('recent', '2026-06-01T09:01:00Z', 'm-recent'), + ].join('\n'), + ) + + const allResult = runCli([ + '--format', 'json', + '--period', 'all', + '--provider', 'claude', + ], home) + expect(allResult.status).toBe(0) + const allReport = JSON.parse(allResult.stdout) as { + daily: Array<{ date: string; calls: number }> + projects: Array<{ calls: number }> + } + expect(allReport.daily.map(d => d.date)).toEqual(['2026-06-01']) + expect(allReport.projects[0]?.calls).toBe(1) + + const lifetimeResult = runCli([ + '--format', 'json', + '--period', 'lifetime', + '--provider', 'claude', + ], home) + expect(lifetimeResult.status).toBe(0) + const lifetimeReport = JSON.parse(lifetimeResult.stdout) as { + daily: Array<{ date: string; calls: number }> + projects: Array<{ calls: number }> + } + expect(lifetimeReport.daily.map(d => d.date)).toEqual(['2025-10-01', '2026-06-01']) + expect(lifetimeReport.projects[0]?.calls).toBe(2) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) })