mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-26 08:52:59 +00:00
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:
parent
43bbb99a92
commit
8f39cc5487
7 changed files with 210 additions and 25 deletions
|
|
@ -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`) |
|
||||
|
|
|
|||
|
|
@ -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 />)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
11
src/main.ts
11
src/main.ts
|
|
@ -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
|
||||
|
|
|
|||
106
tests/default-today-period.test.ts
Normal file
106
tests/default-today-period.test.ts
Normal 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 ]')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue