feat: open the interactive dashboard on today, falling back to 7 days when today is empty

The unset default opened on 7 days everywhere. It now opens on today and
only falls back to 7 days when today holds no sessions yet — the decision
is made from the today-scoped slice of the parse the first paint already
runs, so the probe costs a filter, not a second pass.

Explicit selections are untouched: -p/--period, --day, --from/--to, the
TUI period keys, the app's persisted default period, and every one-shot
(--format json, report/sessions/status, the piped non-TTY render) keep
the 7-day default they had.

Fixes #1111
This commit is contained in:
iamtoruk 2026-08-23 02:24:21 -07:00
parent 43bbb99a92
commit 8f39cc5487
7 changed files with 210 additions and 25 deletions

View file

@ -92,7 +92,7 @@ Everything runs locally. No wrapper, no proxy, no API keys, nothing leaves your
npx codeburn
```
That opens the interactive dashboard (last 7 days by default). Arrow keys switch periods, `q` quits. That is the 30-second version. You now know where your AI budget goes.
That opens the interactive dashboard (today by default, or the last 7 days when today has no usage yet). Arrow keys switch periods, `q` quits. That is the 30-second version. You now know where your AI budget goes.
**Install it** for a permanent `codeburn` command:
@ -440,7 +440,7 @@ Run `codeburn` for the dashboard, or use a subcommand below. Most commands also
| Command | What it does |
|---------|--------------|
| `codeburn` | Interactive dashboard, last 7 days (the default view) |
| `codeburn` | Interactive dashboard, today (falls back to the last 7 days when today is empty) |
| `codeburn today` | Today's usage |
| `codeburn month` | This calendar month's usage |
| `codeburn overview` | Plain-text monthly summary, copy-pasteable (`--no-color`, `--from`/`--to`) |

View file

@ -241,6 +241,25 @@ describe('App shortcuts', () => {
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('today', 'all'))
})
it('falls back to 7 days when the boot payload shows today has no sessions', async () => {
localStorage.removeItem('codeburn.defaultPeriod')
const empty = overviewPayload()
mocks.getOverview.mockResolvedValue({ ...empty, current: { ...empty.current, sessions: 0 } })
render(<App />)
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('today', 'all'))
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('week', 'all'))
})
it('leaves a persisted default period alone when today has no sessions', async () => {
localStorage.setItem('codeburn.defaultPeriod', 'today')
const empty = overviewPayload()
mocks.getOverview.mockResolvedValue({ ...empty, current: { ...empty.current, sessions: 0 } })
render(<App />)
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('today', 'all'))
await act(async () => { await Promise.resolve() })
expect(mocks.getOverview).not.toHaveBeenCalledWith('week', 'all')
})
it('switches sections with command-number shortcuts', async () => {
render(<App />)

View file

@ -146,11 +146,16 @@ function isPeriod(value: string): value is Period {
return (STANDARD_PERIODS as string[]).includes(value)
}
/** Boot period = the persisted "Default period" Settings writes, else today. */
function initialPeriod(): Period {
/** The persisted "Default period" Settings writes, when there is one. */
function savedPeriod(): Period | null {
let saved: string | null = null
try { saved = globalThis.localStorage?.getItem('codeburn.defaultPeriod') ?? null } catch { /* storage can be unavailable */ }
return saved && isPeriod(saved) ? saved : 'today'
return saved && isPeriod(saved) ? saved : null
}
/** Boot period = the persisted "Default period" Settings writes, else today. */
function initialPeriod(): Period {
return savedPeriod() ?? 'today'
}
/** Persisted Claude config override (empty/absent = aggregate all configs). */
@ -254,6 +259,17 @@ function AppMain() {
// every section to spawn its own read behind the still-running parse, and each
// one then died on its own timeout. Stay gated (and keep the splash) until the
// hydration actually settles.
// #1111: with no persisted default the app opens on Today and falls back to 7
// days once, when the first payload shows today has no sessions yet. Disarmed
// by the period picker, so it can never move a period the user chose.
const autoPeriod = useRef(savedPeriod() === null)
useEffect(() => {
const sessions = overview.data?.current.sessions
if (!autoPeriod.current || sessions === undefined) return
autoPeriod.current = false
if (period === 'today' && sessions === 0) setPeriod('week')
}, [overview.data, period])
const overviewCold = isColdHydrating(overview.error)
const [ready, setReady] = useState(false)
useEffect(() => {
@ -460,6 +476,7 @@ function AppMain() {
const onPeriodChange = (value: string) => {
if (isPeriod(value)) {
autoPeriod.current = false
setCustomRange(null)
setPeriod(value)
}

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
@ -495,6 +495,17 @@ export function App() {
const primary = viewing ?? local
const c0 = primary?.payload?.current
// #1111: the dashboard opens on Today and falls back to 7 days once, when the
// first local payload shows today still has no sessions. Disarmed by the
// period picker, so it can never move a period the user chose.
const autoPeriod = useRef(true)
useEffect(() => {
const sessions = local?.payload?.current?.sessions
if (!autoPeriod.current || sessions === undefined) return
autoPeriod.current = false
if (period === 'today' && sessions === 0) setPeriod('week')
}, [local, period])
const providerOptions = useMemo(
() =>
c0
@ -589,7 +600,7 @@ export function App() {
<button
key={p.key}
type="button"
onClick={() => setPeriod(p.key)}
onClick={() => { autoPeriod.current = false; setPeriod(p.key) }}
className={cn(
'rounded-[5px] px-3 py-1 text-xs font-medium transition-colors max-md:inline-flex max-md:min-h-9 max-md:items-center max-md:justify-center',
period === p.key ? 'bg-active-primary text-foreground shadow-sm' : 'text-tertiary-foreground hover:text-foreground',

View file

@ -1921,7 +1921,7 @@ function StaticDashboard({ projects, period, activeProvider, planUsages, label,
/// serve the rest by slicing it — but only the ones that are a pure narrowing,
/// so a range that genuinely needs its own file set (a plan window starting
/// before the scan range, a past `--day`) still parses on its own.
async function assembleDashboardData(
export async function assembleDashboardData(
period: Period,
provider: string,
projectFilter: string[] | undefined,
@ -1929,26 +1929,40 @@ async function assembleDashboardData(
customRange: DateRange | null | undefined,
initialDay: string | null,
scrollableDailyHistory: boolean,
): Promise<{ scannedProjects: ProjectSummary[]; filteredProjects: ProjectSummary[]; planUsages: PlanUsage[]; initialDurable: DurableOverview }> {
autoFallback = false,
): Promise<{ period: Period; scannedProjects: ProjectSummary[]; filteredProjects: ProjectSummary[]; planUsages: PlanUsage[]; initialDurable: DurableOverview }> {
const range = getDashboardScanRange(period, customRange, initialDay, scrollableDailyHistory)
const durableRange = getDurableRange(period, customRange, initialDay)
// With the fallback armed the scope must cover the period it can land on too,
// so declare the wider of the two durable ranges.
const durableRange = getDurableRange(autoFallback ? AUTO_FALLBACK_PERIOD : period, customRange, initialDay)
const superset: DateRange = {
start: new Date(Math.min(range.start.getTime(), durableRange.start.getTime())),
end: new Date(Math.max(range.end.getTime(), durableRange.end.getTime())),
}
return withSinglePassParse(superset, async () => {
const scannedProjects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter)
const filteredProjects = selectDashboardPeriodProjects(scannedProjects, period, scrollableDailyHistory)
// #1111: the today-scoped slice IS the probe for the unset default — it
// comes out of the parse that had to happen anyway, so an empty today costs
// a slice, not a second pass. Sessions rather than projects: a project can
// survive the slice on subagent anchors alone, which carry no in-range
// spend and are not a day worth opening on.
const opened = autoFallback && !selectDashboardPeriodProjects(scannedProjects, period, scrollableDailyHistory).some(p => p.sessions.length > 0)
? AUTO_FALLBACK_PERIOD
: period
const filteredProjects = selectDashboardPeriodProjects(scannedProjects, opened, scrollableDailyHistory)
const planUsages = await getPlanUsages()
// Durable headline totals for the initial paint (carry-forward cache + today),
// matching the menubar/report. The interactive tree recomputes this on every
// period/provider/refresh change; the static one-shot render uses just this.
const initialDurable = await computeDurableOverview(period, provider, projectFilter, excludeFilter, customRange, initialDay)
return { scannedProjects, filteredProjects, planUsages, initialDurable }
const initialDurable = await computeDurableOverview(opened, provider, projectFilter, excludeFilter, customRange, initialDay)
return { period: opened, scannedProjects, filteredProjects, planUsages, initialDurable }
})
}
export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number, projectFilter?: string[], excludeFilter?: string[], customRange?: DateRange | null, customRangeLabel?: string, initialDay?: string): Promise<void> {
/// Where the unset interactive default lands when today has no sessions yet (#1111).
export const AUTO_FALLBACK_PERIOD: Period = 'week'
export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number, projectFilter?: string[], excludeFilter?: string[], customRange?: DateRange | null, customRangeLabel?: string, initialDay?: string, autoPeriod = false): Promise<void> {
// Interactive Ink UI: it renders to the same terminal and has its own in-frame
// loading state, so the CLI scan-progress line must stay silent for its whole
// lifetime (initial scan and every enabled auto-refresh, including the
@ -1966,20 +1980,33 @@ export async function renderDashboard(period: Period = 'week', provider: string
// (json/csv/markdown, report, sessions, the app and menubar payloads) keeps
// the full parse and can never return a partial total.
const progressive = isTTY && dayRange == null && customRange == null && await isColdCacheOnDisk()
const assemble = () =>
assembleDashboardData(period, provider, projectFilter, excludeFilter, customRange, initialDay ?? null, scrollableDailyHistory)
const paint = progressive
? await withColdFirstPaintFloor(getPeriodRange(period).start, assemble)
: { result: await assemble(), deferredFiles: 0 }
// #1111: with no explicit period the interactive dashboard opens on Today and
// falls back to 7 days only when today holds nothing yet. Explicit -p/--day/
// --from/--to and the non-interactive render keep the 7-day default.
const auto = autoPeriod && isTTY && dayRange == null && customRange == null
const openPeriod: Period = auto ? 'today' : period
const runPaint = async (p: Period, fallback: boolean) => {
const assemble = () =>
assembleDashboardData(p, provider, projectFilter, excludeFilter, customRange, initialDay ?? null, scrollableDailyHistory, fallback)
return progressive
? await withColdFirstPaintFloor(getPeriodRange(p).start, assemble)
: { result: await assemble(), deferredFiles: 0 }
}
let paint = await runPaint(openPeriod, auto)
// A cold first paint is floored to the files its period needs, so a fallback
// decided under the Today floor would paint 7 days off today's files alone.
// Repaint it on the 7-day floor instead — pass one's files are in the cache
// it just wrote, so they are served rather than parsed again.
if (progressive && paint.result.period !== openPeriod) paint = await runPaint(paint.result.period, false)
if (process.env['CODEBURN_VERBOSE'] === '1') {
process.stderr.write(`codeburn: progressive cold start ${progressive ? 'on' : 'off'}, ${paint.deferredFiles} files deferred to the background fill\n`)
}
const { scannedProjects, filteredProjects, planUsages, initialDurable } = paint.result
const { period: opened, scannedProjects, filteredProjects, planUsages, initialDurable } = paint.result
const label = initialDay ? formatDayRangeLabel(initialDay) : customRangeLabel
patchStdoutForWindows()
if (isTTY) {
const app = renderDebouncedInteractive(process.stdout, ({ columns }) => (
<InteractiveDashboard initialProjects={filteredProjects} initialDailyHistoryProjects={scrollableDailyHistory ? scannedProjects : undefined} initialPeriod={period} initialProvider={provider} initialPlanUsages={planUsages} initialDurable={initialDurable} refreshSeconds={refreshSeconds} projectFilter={projectFilter} excludeFilter={excludeFilter} customRange={customRange} customRangeLabel={customRangeLabel} initialDay={initialDay} windowColumns={columns} initialIndexPendingFiles={paint.deferredFiles} />
<InteractiveDashboard initialProjects={filteredProjects} initialDailyHistoryProjects={scrollableDailyHistory ? scannedProjects : undefined} initialPeriod={opened} initialProvider={provider} initialPlanUsages={planUsages} initialDurable={initialDurable} refreshSeconds={refreshSeconds} projectFilter={projectFilter} excludeFilter={excludeFilter} customRange={customRange} customRangeLabel={customRangeLabel} initialDay={initialDay} windowColumns={columns} initialIndexPendingFiles={paint.deferredFiles} />
))
try {
await app.waitUntilExit()
@ -1987,7 +2014,7 @@ export async function renderDashboard(period: Period = 'week', provider: string
app.dispose()
}
} else {
const { unmount } = render(<StaticDashboard projects={filteredProjects} period={period} activeProvider={provider} planUsages={planUsages} label={label} dayMode={initialDay != null} durable={initialDurable} />, { patchConsole: false })
const { unmount } = render(<StaticDashboard projects={filteredProjects} period={opened} activeProvider={provider} planUsages={planUsages} label={label} dayMode={initialDay != null} durable={initialDurable} />, { patchConsole: false })
// Non-interactive one-shot output: ink schedules the frame through a
// throttled render, so yield a tick to let it flush to stdout before
// unmounting. Unmounting synchronously can race the flush and drop output.

View file

@ -720,7 +720,7 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
program
.command('report', { isDefault: true })
.description('Interactive usage dashboard')
.option('-p, --period <period>', 'Starting period: today, week, 30days, month, all, lifetime', 'week')
.option('-p, --period <period>', 'Starting period: today, week, 30days, month, all, lifetime (interactive default: today, or week when today is empty)', 'week')
.option('--day <date>', 'Single day to review (YYYY-MM-DD, today, or yesterday). Overrides --period when set')
.option('--from <date>', 'Start date (YYYY-MM-DD). Overrides --period when set')
.option('--to <date>', 'End date (YYYY-MM-DD). Overrides --period when set')
@ -729,7 +729,7 @@ program
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--refresh <seconds>', 'Auto-refresh interval in seconds (minimum 60; 0 to disable)', parseInteger, 60)
.action(async (opts) => {
.action(async (opts, command) => {
assertFormat(opts.format, ['tui', 'json'], 'report')
assertProvider(opts.provider, 'report')
let customRange: DateRange | null = null
@ -761,7 +761,12 @@ program
return
}
const customRangeLabel = customRange ? formatDateRangeLabel(opts.from, opts.to) : undefined
await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange, customRangeLabel, daySelection?.day)
// #1111: no explicit period of any kind means the interactive dashboard
// picks its own — Today, or 7 days when today is still empty. Any source
// other than the option default (a flag, an env value) is the user's
// choice and is honored as given.
const autoPeriod = command.getOptionValueSource('period') === 'default' && !daySelection && !customRange
await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange, customRangeLabel, daySelection?.day, autoPeriod)
})
program

View file

@ -0,0 +1,106 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm, utimes } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { assembleDashboardData, renderDashboard } from '../src/dashboard.js'
import { clearSessionCache } from '../src/parser.js'
import { clearLoadCacheMemo } from '../src/session-cache.js'
const DAY_MS = 24 * 60 * 60 * 1000
let tmpDir: string
beforeEach(async () => {
clearSessionCache()
clearLoadCacheMemo()
tmpDir = await mkdtemp(join(tmpdir(), 'default-today-'))
process.env['CLAUDE_CONFIG_DIR'] = tmpDir
process.env['CODEBURN_CACHE_DIR'] = join(tmpDir, 'cache')
process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(tmpDir, 'desktop-sessions')
})
afterEach(async () => {
clearSessionCache()
clearLoadCacheMemo()
await rm(tmpDir, { recursive: true, force: true })
})
/** One Claude session whose only turn landed `hoursAgo` ago. */
async function writeSession(name: string, hoursAgo: number): Promise<void> {
const dir = join(tmpDir, 'projects', 'proj')
await mkdir(dir, { recursive: true })
const at = new Date(Date.now() - hoursAgo * 60 * 60 * 1000)
const path = join(dir, `${name}.jsonl`)
await writeFile(path, JSON.stringify({
type: 'assistant',
sessionId: name,
timestamp: at.toISOString(),
cwd: '/tmp/proj',
message: {
id: `msg-${name}`, type: 'message', role: 'assistant', model: 'claude-sonnet-4-5',
content: [], usage: { input_tokens: 100, output_tokens: 50 },
},
}) + '\n')
await utimes(path, at, at)
}
/** The interactive first paint with no explicit period: opens on `today`, with
* the fallback armed. */
function assembleAuto(period: 'today' | 'week', autoFallback: boolean) {
return assembleDashboardData(period, 'all', undefined, undefined, null, null, true, autoFallback)
}
describe('unset default period (#1111)', () => {
it('opens on today when today has sessions', async () => {
await writeSession('now', 0)
await writeSession('older', 3 * 24)
const { period, filteredProjects } = await assembleAuto('today', true)
expect(period).toBe('today')
expect(filteredProjects.length).toBe(1)
})
it('falls back to 7 days when today is empty', async () => {
await writeSession('older', 3 * 24)
const { period, filteredProjects } = await assembleAuto('today', true)
expect(period).toBe('week')
expect(filteredProjects.length).toBe(1)
})
it('resolves to 7 days when there is no data at all', async () => {
const { period } = await assembleAuto('today', true)
// Both windows are empty, so the fallback still fires — the point is that it
// resolves to one period deterministically instead of throwing.
expect(period).toBe('week')
})
it('an explicit period is never moved, even when its window is empty', async () => {
await writeSession('older', 3 * 24)
const today = await assembleAuto('today', false)
expect(today.period).toBe('today')
expect(today.filteredProjects.length).toBe(0)
const week = await assembleAuto('week', false)
expect(week.period).toBe('week')
})
})
describe('non-interactive render (#1111)', () => {
it('keeps the 7-day default when stdout is not a TTY', async () => {
await writeSession('now', 0)
const chunks: string[] = []
const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
chunks.push(String(chunk))
return true
})
try {
// isTTY is false under vitest, so this is the piped `codeburn` path even
// with the auto flag set by the CLI.
await renderDashboard('week', 'all', 0, undefined, undefined, null, undefined, undefined, true)
} finally {
write.mockRestore()
}
const out = chunks.join('')
expect(out).toContain('[ 7 Days ]')
expect(out).not.toContain('[ Today ]')
})
})