From 3dc3e3271537bc5898c55f2ada277ff56a9cd423 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 5 May 2026 03:29:54 +0300 Subject: [PATCH 1/2] 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 --- CHANGELOG.md | 5 ++ README.md | 4 +- src/cli-date.ts | 85 +++++++++++++++++++++++++++++ src/cli.ts | 52 +----------------- src/dashboard.tsx | 29 +++------- tests/cli-date.test.ts | 118 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 217 insertions(+), 76 deletions(-) create mode 100644 tests/cli-date.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b31e30b7..ee16abe4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index 2d73f260..1602d877 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/cli-date.ts b/src/cli-date.ts index 66831b96..b3d502df 100644 --- a/src/cli-date.ts +++ b/src/cli-date.ts @@ -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 = { + 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' } + } + } +} diff --git a/src/cli.ts b/src/cli.ts index 368cbbc7..7474efca 100644 --- a/src/cli.ts +++ b/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 diff --git a/src/dashboard.tsx b/src/dashboard.tsx index c047d69e..16aea07f 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -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 = { - 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 { 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 diff --git a/tests/cli-date.test.ts b/tests/cli-date.test.ts new file mode 100644 index 00000000..f2f74049 --- /dev/null +++ b/tests/cli-date.test.ts @@ -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') + }) +}) From 9a258a8a99860f83d48a30cff2fe64c03b08a46b Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 5 May 2026 05:05:13 +0300 Subject: [PATCH 2/2] fix(date-range): avoid all-period month overflow --- gnome/prefs.js | 2 +- src/cli-date.ts | 2 +- src/dashboard.tsx | 4 ++-- tests/cli-date.test.ts | 29 ++++++++++++++++------------- 4 files changed, 20 insertions(+), 17 deletions(-) diff --git a/gnome/prefs.js b/gnome/prefs.js index 8d806797..b0d13f92 100644 --- a/gnome/prefs.js +++ b/gnome/prefs.js @@ -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 { diff --git a/src/cli-date.ts b/src/cli-date.ts index b3d502df..7dfd06f2 100644 --- a/src/cli-date.ts +++ b/src/cli-date.ts @@ -113,7 +113,7 @@ export function getDateRange(period: string): { range: DateRange; label: string return { range: { start, end }, label: 'Last 30 Days' } } case 'all': { - const start = new Date(now.getFullYear(), now.getMonth() - ALL_TIME_MONTHS, now.getDate()) + const start = new Date(now.getFullYear(), now.getMonth() - ALL_TIME_MONTHS, 1) return { range: { start, end }, label: 'Last 6 months' } } default: { diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 16aea07f..3193b5a9 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -13,7 +13,7 @@ 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 { getDateRange, PERIODS, PERIOD_LABELS, type Period } from './cli-date.js' import { join } from 'path' import { patchStdoutForWindows } from './ink-win.js' @@ -96,7 +96,7 @@ function gradientColor(pct: number): string { } function getPeriodRange(period: Period): { start: Date; end: Date } { - return getDateRangeShared(period).range + return getDateRange(period).range } type Layout = { dashWidth: number; wide: boolean; halfWidth: number; barWidth: number } diff --git a/tests/cli-date.test.ts b/tests/cli-date.test.ts index f2f74049..e30096d7 100644 --- a/tests/cli-date.test.ts +++ b/tests/cli-date.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { afterEach, describe, it, expect, vi } from 'vitest' import { getDateRange, PERIODS, @@ -7,6 +7,10 @@ import { 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') @@ -18,27 +22,26 @@ describe('getDateRange', () => { // 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) + 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('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('"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', () => {