fix(date-range): unify 'all' period semantics between CLI and dashboard

`getDateRange` was duplicated across `src/cli.ts` and `src/dashboard.tsx`
with conflicting semantics for `'all'`. The CLI intentionally bounded
`'all'` to the last 6 months (justified inline: keeps Codex/Cursor parses
responsive on sparse multi-year history). The dashboard returned
`new Date(0)` instead, so the same `--period all` flag silently meant
two different windows depending on which entry point you hit.

`Period`, `PERIODS`, `PERIOD_LABELS`, and `toPeriod` were duplicated as
well, and `cli-date.ts` already existed for date helpers
(`parseDateRangeFlags`) so the consolidation lives there.

Both call sites now go through a single `getDateRange(period: string)`
in `cli-date.ts` that returns `{ range, label }`. The dashboard wraps it
as `getPeriodRange(period: Period)` to keep the strict `Period` type at
the React boundary while letting the CLI continue to accept extras like
`'yesterday'`.

`PERIOD_LABELS.all` becomes `'6 Months'` (short, for the dashboard tab
strip; the previous `'All Time'` was misleading and the long-form
`'Last 6 months'` from `getDateRange().label` already drives CLI output).

Changes:
- src/cli-date.ts: add `Period`, `PERIODS`, `PERIOD_LABELS`, `toPeriod`,
  `getDateRange`. Pull the existing 6-month rationale into a named
  `ALL_TIME_MONTHS` constant.
- src/cli.ts: drop the local copies and import from cli-date.
- src/dashboard.tsx: drop the local copies, route through
  `getPeriodRange`, alias the shared `getDateRange` import to
  `getDateRangeShared` to avoid shadowing the wrapper.
- tests/cli-date.test.ts: 13 cases covering `'all'` regression guard
  (must never silently fall back to `Date(0)`), CLI/dashboard agreement,
  end-of-month clamping tolerance, `'yesterday'` support, and
  unknown-input fallback.
- README.md, CHANGELOG.md: surface the bound and point heavy users at
  `--from`/`--to` for unbounded windows.

The CLI flag `--period all` continues to be accepted; only the dashboard
window changes to match what the CLI was already doing. No public API
or schema change.

Refs #93
This commit is contained in:
ozymandiashh 2026-05-05 03:29:54 +03:00
parent 18335a1f9d
commit 3dc3e32715
6 changed files with 217 additions and 76 deletions

View file

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

View file

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

View file

@ -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}$/
@ -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`)
@ -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, 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' }
}
}
}

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

View file

@ -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 as getDateRangeShared, 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'
@ -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 getDateRangeShared(period).range
}
type Layout = { dashWidth: number; wide: boolean; halfWidth: number; barWidth: number }
@ -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()
@ -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
@ -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

118
tests/cli-date.test.ts Normal file
View file

@ -0,0 +1,118 @@
import { describe, it, expect } from 'vitest'
import {
getDateRange,
PERIODS,
PERIOD_LABELS,
toPeriod,
type Period,
} from '../src/cli-date.js'
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)
// Roughly 6 months back. Accept 5-7 months to absorb end-of-month
// clamping (e.g. on May 31, JS rolls Nov 31 -> Dec 1, shifting the
// computed month forward by one).
const monthsDiff =
(now.getFullYear() - range.start.getFullYear()) * 12 +
(now.getMonth() - range.start.getMonth())
expect(monthsDiff).toBeGreaterThanOrEqual(5)
expect(monthsDiff).toBeLessThanOrEqual(7)
// End is today, end of day.
expect(range.end.getHours()).toBe(23)
expect(range.end.getMinutes()).toBe(59)
})
it('CLI and dashboard agree on "all" semantics (no Date(0) drift)', () => {
const a = getDateRange('all')
const b = getDateRange('all')
expect(a.range.start.getTime()).toBe(b.range.start.getTime())
expect(a.label).toBe(b.label)
// Regression guard: must never silently fall back to epoch.
expect(a.range.start.getFullYear()).toBeGreaterThan(2000)
})
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')
})
})