Skip to content
Merged
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## Unreleased

### Fixed (CLI)
- **`all` period semantics unified between CLI and dashboard.** The dashboard treated `--period all` as all-time (epoch start) while the CLI bounded it to the last 6 months. Both now consistently mean "Last 6 months". Period helpers (`Period`, `PERIODS`, `PERIOD_LABELS`, `toPeriod`, `getDateRange`) consolidated into `cli-date.ts`. Use `--from` / `--to` for unbounded historical ranges.

## 0.9.6 - 2026-05-03

### Added (CLI)
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ codeburn yield # track productive vs reverted/abandoned spend
codeburn yield -p 30days # yield analysis for last 30 days
```

Arrow keys switch between Today, 7 Days, 30 Days, Month, and All Time. Press `q` to quit, `1` `2` `3` `4` `5` as shortcuts, `c` to open model comparison, `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.
Arrow keys switch between Today, 7 Days, 30 Days, Month, and 6 Months (use `--from` / `--to` for an exact historical window). Press `q` to quit, `1` `2` `3` `4` `5` as shortcuts, `c` to open model comparison, `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.

## Supported Providers

Expand Down Expand Up @@ -196,7 +196,7 @@ You can also open it inline from the dashboard: press `o` when a finding count a
### Compare

```bash
codeburn compare # interactive model picker (default: all time)
codeburn compare # interactive model picker (default: last 6 months)
codeburn compare -p week # last 7 days
codeburn compare -p today # today only
codeburn compare --provider claude # Claude Code sessions only
Expand Down
2 changes: 1 addition & 1 deletion gnome/prefs.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const PERIODS = [
{ id: 'week', label: '7 Days' },
{ id: '30days', label: '30 Days' },
{ id: 'month', label: 'Month' },
{ id: 'all', label: 'All Time' },
{ id: 'all', label: '6 Months' },
];

export default class CodeBurnPreferences extends ExtensionPreferences {
Expand Down
85 changes: 85 additions & 0 deletions src/cli-date.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { DateRange } from './types.js'
import { toDateString } from './daily-cache.js'

const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/

Expand All @@ -7,6 +8,35 @@ 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`.
const ALL_TIME_MONTHS = 6

export type Period = 'today' | 'week' | '30days' | 'month' | 'all'

export const PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all']

// Short labels suitable for the dashboard tab strip. Long-form labels for
// header text come from `getDateRange().label`.
export const PERIOD_LABELS: Record<Period, string> = {
today: 'Today',
week: '7 Days',
'30days': '30 Days',
month: 'This Month',
all: '6 Months',
}

export function toPeriod(s: string): Period {
if (s === 'today') return 'today'
if (s === 'month') return 'month'
if (s === '30days') return '30days'
if (s === 'all') return 'all'
return 'week'
}

function parseLocalDate(s: string): Date {
if (!ISO_DATE_RE.test(s)) {
throw new Error(`Invalid date format "${s}": expected YYYY-MM-DD`)
Expand Down Expand Up @@ -37,3 +67,58 @@ export function parseDateRangeFlags(from: string | undefined, to: string | undef
}
return { start, end }
}

/**
* Returns the date range and a human-readable label for a named period.
*
* Accepts a string (rather than the strict `Period` type) because the CLI
* 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.
*/
export function getDateRange(period: string): { range: DateRange; label: string } {
const now = new Date()
const end = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
END_OF_DAY_HOURS,
END_OF_DAY_MINUTES,
END_OF_DAY_SECONDS,
END_OF_DAY_MS,
)

switch (period) {
case 'today': {
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate())
return { range: { start, end }, label: `Today (${toDateString(start)})` }
}
case 'yesterday': {
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
const yesterdayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, END_OF_DAY_HOURS, END_OF_DAY_MINUTES, END_OF_DAY_SECONDS, END_OF_DAY_MS)
return { range: { start, end: yesterdayEnd }, label: `Yesterday (${toDateString(start)})` }
}
case 'week': {
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)
return { range: { start, end }, label: 'Last 7 Days' }
}
case 'month': {
const start = new Date(now.getFullYear(), now.getMonth(), 1)
return { range: { start, end }, label: `${now.toLocaleString('default', { month: 'long' })} ${now.getFullYear()}` }
}
case '30days': {
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30)
return { range: { start, end }, label: 'Last 30 Days' }
}
case 'all': {
const start = new Date(now.getFullYear(), now.getMonth() - ALL_TIME_MONTHS, 1)
return { range: { start, end }, label: 'Last 6 months' }
}
default: {
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)
return { range: { start, end }, label: 'Last 7 Days' }
}
}
}
52 changes: 1 addition & 51 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 } from './cli-date.js'
import { 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 All @@ -35,56 +35,6 @@ async function hydrateCache() {
}
}

function getDateRange(period: string): { range: DateRange; label: string } {
const now = new Date()
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999)

switch (period) {
case 'today': {
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate())
return { range: { start, end }, label: `Today (${toDateString(start)})` }
}
case 'yesterday': {
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
const yesterdayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 23, 59, 59, 999)
return { range: { start, end: yesterdayEnd }, label: `Yesterday (${toDateString(start)})` }
}
case 'week': {
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)
return { range: { start, end }, label: 'Last 7 Days' }
}
case 'month': {
const start = new Date(now.getFullYear(), now.getMonth(), 1)
return { range: { start, end }, label: `${now.toLocaleString('default', { month: 'long' })} ${now.getFullYear()}` }
}
case '30days': {
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30)
return { range: { start, end }, label: 'Last 30 Days' }
}
case 'all': {
// Cap "All Time" to the last 6 months. Older data is rarely actionable for a cost
// tracker and keeps the parse path bounded so providers like Codex/Cursor with sparse
// data still load in seconds.
const start = new Date(now.getFullYear(), now.getMonth() - 6, now.getDate())
return { range: { start, end }, label: 'Last 6 months' }
}
default: {
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)
return { range: { start, end }, label: 'Last 7 Days' }
}
}
}

type Period = 'today' | 'week' | '30days' | 'month' | 'all'

function toPeriod(s: string): Period {
if (s === 'today') return 'today'
if (s === 'month') return 'month'
if (s === '30days') return '30days'
if (s === 'all') return 'all'
return 'week'
}

function collect(val: string, acc: string[]): string[] {
acc.push(val)
return acc
Expand Down
29 changes: 6 additions & 23 deletions src/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,12 @@ import { dateKey } from './day-aggregator.js'
import { CompareView } from './compare.js'
import { getPlanUsageOrNull, type PlanUsage } from './plan-usage.js'
import { planDisplayName } from './plans.js'
import { getDateRange, PERIODS, PERIOD_LABELS, type Period } from './cli-date.js'
import { join } from 'path'
import { patchStdoutForWindows } from './ink-win.js'

type Period = 'today' | 'week' | '30days' | 'month' | 'all'
type View = 'dashboard' | 'optimize' | 'compare'

const PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all']
const PERIOD_LABELS: Record<Period, string> = {
today: 'Today',
week: '7 Days',
'30days': '30 Days',
month: 'This Month',
all: 'All Time',
}

const MIN_WIDE = 90
const ORANGE = '#FF8C42'
const DIM = '#555555'
Expand Down Expand Up @@ -104,16 +95,8 @@ function gradientColor(pct: number): string {
return toHex(lerp(255, 245, t), lerp(140, 91, t), lerp(66, 91, t))
}

function getDateRange(period: Period): { start: Date; end: Date } {
const now = new Date()
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999)
switch (period) {
case 'today': return { start: new Date(now.getFullYear(), now.getMonth(), now.getDate()), end }
case 'week': return { start: new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7), end }
case '30days': return { start: new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30), end }
case 'month': return { start: new Date(now.getFullYear(), now.getMonth(), 1), end }
case 'all': return { start: new Date(0), end }
}
function getPeriodRange(period: Period): { start: Date; end: Date } {
return getDateRange(period).range
}

type Layout = { dashWidth: number; wide: boolean; halfWidth: number; barWidth: number }
Expand Down Expand Up @@ -711,7 +694,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
let cancelled = false
async function scan() {
if (projects.length === 0) { setOptimizeResult(null); return }
const result = await scanAndDetect(projects, getDateRange(period))
const result = await scanAndDetect(projects, getPeriodRange(period))
if (!cancelled) setOptimizeResult(result)
}
scan()
Expand All @@ -723,7 +706,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
setLoading(true)
setOptimizeResult(null)
try {
const range = getDateRange(p)
const range = getPeriodRange(p)
const data = await parseAllSessions(range, prov)
if (reloadGenerationRef.current !== generation) return

Expand Down Expand Up @@ -828,7 +811,7 @@ function StaticDashboard({ projects, period, activeProvider, planUsage }: { proj

export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number, projectFilter?: string[], excludeFilter?: string[], customRange?: DateRange | null): Promise<void> {
await loadPricing()
const range = customRange ?? getDateRange(period)
const range = customRange ?? getPeriodRange(period)
const filteredProjects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter)
const planUsage = await getPlanUsageOrNull()
const isTTY = process.stdin.isTTY && process.stdout.isTTY
Expand Down
121 changes: 121 additions & 0 deletions tests/cli-date.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { afterEach, describe, it, expect, vi } from 'vitest'
import {
getDateRange,
PERIODS,
PERIOD_LABELS,
toPeriod,
type Period,
} from '../src/cli-date.js'

afterEach(() => {
vi.useRealTimers()
})

describe('getDateRange', () => {
it('"all" is bounded to the last 6 months, not epoch', () => {
const { range, label } = getDateRange('all')
const now = new Date()

expect(label).toBe('Last 6 months')

// Regression guard: must never silently fall back to epoch (the old
// dashboard bug) or any pre-2000 date.
expect(range.start.getFullYear()).toBeGreaterThan(2000)

const monthsDiff =
(now.getFullYear() - range.start.getFullYear()) * 12 +
(now.getMonth() - range.start.getMonth())
expect(monthsDiff).toBe(6)
expect(range.start.getDate()).toBe(1)

// End is today, end of day.
expect(range.end.getHours()).toBe(23)
expect(range.end.getMinutes()).toBe(59)
})

it('"all" does not overflow past the target month at end-of-month', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 7, 31, 12, 0, 0))

const { range } = getDateRange('all')

expect(range.start.getFullYear()).toBe(2026)
expect(range.start.getMonth()).toBe(1)
expect(range.start.getDate()).toBe(1)
})

it('"week" returns the last 7 days', () => {
const { range, label } = getDateRange('week')
expect(label).toBe('Last 7 Days')
// start = midnight 7 days ago, end = today 23:59:59.999 -> ~8 days span.
const diffDays = (range.end.getTime() - range.start.getTime()) / (1000 * 60 * 60 * 24)
expect(diffDays).toBeGreaterThanOrEqual(7)
expect(diffDays).toBeLessThanOrEqual(8)
})

it('"month" starts on day 1 of the current month', () => {
const { range } = getDateRange('month')
expect(range.start.getDate()).toBe(1)
expect(range.start.getHours()).toBe(0)
})

it('"30days" returns 30 days back', () => {
const { range, label } = getDateRange('30days')
expect(label).toBe('Last 30 Days')
const diffDays = (range.end.getTime() - range.start.getTime()) / (1000 * 60 * 60 * 24)
expect(diffDays).toBeGreaterThanOrEqual(30)
expect(diffDays).toBeLessThanOrEqual(31)
})

it('"today" starts at local midnight', () => {
const { range } = getDateRange('today')
expect(range.start.getHours()).toBe(0)
expect(range.start.getMinutes()).toBe(0)
expect(range.end.getHours()).toBe(23)
})

it('"yesterday" is supported (CLI-only convenience)', () => {
const { range, label } = getDateRange('yesterday')
expect(label).toMatch(/^Yesterday/)
expect(range.start.getHours()).toBe(0)
expect(range.end.getHours()).toBe(23)
})

it('unknown period falls back to "week"', () => {
const fallback = getDateRange('not-a-period')
const week = getDateRange('week')
expect(fallback.label).toBe(week.label)
})
})

describe('PERIODS / PERIOD_LABELS', () => {
it('exposes the expected period set', () => {
expect(PERIODS).toEqual(['today', 'week', '30days', 'month', 'all'])
})

it('has a label for every period', () => {
for (const p of PERIODS) {
expect(PERIOD_LABELS[p]).toBeTruthy()
}
})

it('"all" tab label reflects the 6-month bound', () => {
// Short label used in the dashboard tab strip. The long-form label
// ("Last 6 months") comes from getDateRange().label.
expect(PERIOD_LABELS.all).toBe('6 Months')
})
})

describe('toPeriod', () => {
it('round-trips known periods', () => {
const known: Period[] = ['today', 'week', '30days', 'month', 'all']
for (const p of known) {
expect(toPeriod(p)).toBe(p)
}
})

it('falls back to "week" for unknown input', () => {
expect(toPeriod('garbage')).toBe('week')
expect(toPeriod('')).toBe('week')
})
})
Loading