mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-11 01:25:03 +00:00
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:
parent
18335a1f9d
commit
3dc3e32715
6 changed files with 217 additions and 76 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
52
src/cli.ts
52
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 } 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
|
||||
|
|
|
|||
|
|
@ -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
118
tests/cli-date.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue