feat(dashboard): make Daily Activity history directly scrollable (#672)
Some checks are pending
CI / semgrep (push) Waiting to run

* feat(dashboard): add inline daily history paging

* refactor(dashboard): make daily activity directly scrollable

* fix(dashboard): keep the empty state for users with no history at all

Scrollable mode kept the dashboard shell up whenever full history exists,
but a truly-new user with zero data anywhere saw a zeroed skeleton instead
of the No usage data message. Extract the decision into showEmptyState so
the boundary is test-pinned: empty period with history renders the shell,
empty everything renders the empty state, and the loading pass never
flashes it.

---------

Co-authored-by: AgentSeal <hello@agentseal.org>
This commit is contained in:
ihearttokyo 2026-07-16 20:10:08 +09:00 • committed by GitHub
parent df78e50052
commit ccd8764e5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 189 additions and 21 deletions

View file

@ -448,7 +448,7 @@ Sync sends token counts, costs, models, and projects — never prompts or code.
| `codeburn models --task feature` | Filter to feature-development work |
| `codeburn models --provider claude` | Filter to a single provider |
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.
Left/right arrow keys switch between Today, 7 Days, 30 Days, Month, and 6 Months (use `--from` / `--to` for an exact historical window). The main Daily Activity panel always shows scrollable full history: use up/down to move one day, Page Up/Page Down (or Shift+Space/Space) to page, and `g`/`G` to jump to either end. These keys update the panel in place instead of moving terminal scrollback. Press `q` to quit, `1` `2` `3` `4` `5` as period shortcuts, `c` to open model comparison, or `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.
</details>

View file

@ -19,6 +19,31 @@ import { patchStdoutForWindows } from './ink-win.js'
type View = 'dashboard' | 'optimize' | 'compare'
export type DailyActivityRow = {
day: string
cost: number
calls: number
}
export function pageHistoryCursor(cursor: number, direction: -1 | 1, pageSize: number, rowCount: number): number {
const maxCursor = Math.max(0, rowCount - pageSize)
return Math.max(0, Math.min(cursor + direction * pageSize, maxCursor))
}
export function scrollHistoryCursor(cursor: number, direction: -1 | 1, pageSize: number, rowCount: number): number {
const maxCursor = Math.max(0, rowCount - pageSize)
return Math.max(0, Math.min(cursor + direction, maxCursor))
}
// Scrollable mode keeps the dashboard up when only the selected period is
// empty (full history still renders), but a truly-new user with no history at
// all should still get the clean empty state instead of a zeroed shell.
export function showEmptyState(projectCount: number, scrollableHistory: boolean, historyProjectCount: number, historyLoading: boolean): boolean {
if (projectCount > 0) return false
if (!scrollableHistory) return true
return historyProjectCount === 0 && !historyLoading
}
const MIN_WIDE = 90
const ORANGE = '#FF8C42'
const DIM = '#555555'
@ -253,7 +278,7 @@ function Overview({ projects, label, width, planUsages }: { projects: ProjectSum
)
}
function DailyActivity({ projects, days = 14, pw, bw }: { projects: ProjectSummary[]; days?: number; pw: number; bw: number }) {
export function getDailyActivityRows(projects: ProjectSummary[]): DailyActivityRow[] {
const dailyCosts: Record<string, number> = {}
const dailyCalls: Record<string, number> = {}
for (const project of projects) {
@ -266,20 +291,37 @@ function DailyActivity({ projects, days = 14, pw, bw }: { projects: ProjectSumma
}
}
}
const sortedDays = days !== undefined ? Object.keys(dailyCosts).sort().slice(-days) : Object.keys(dailyCosts).sort()
const maxCost = Math.max(...sortedDays.map(d => dailyCosts[d] ?? 0))
return Object.keys(dailyCosts).sort().map(day => ({
day,
cost: dailyCosts[day] ?? 0,
calls: dailyCalls[day] ?? 0,
}))
}
function DailyActivity({ projects, days = 14, pw, bw, scrollable = false, cursor = 0, loading = false }: { projects: ProjectSummary[]; days?: number; pw: number; bw: number; scrollable?: boolean; cursor?: number; loading?: boolean }) {
const allRows = getDailyActivityRows(projects)
const orderedRows = scrollable ? [...allRows].reverse() : allRows
const rows = scrollable ? orderedRows.slice(cursor, cursor + days) : orderedRows.slice(-days)
const maxCost = Math.max(0, ...(scrollable ? orderedRows : rows).map(row => row.cost))
return (
<Panel title="Daily Activity" color={PANEL_COLORS.daily} width={pw}>
<Text dimColor wrap="truncate-end">{''.padEnd(6 + bw)}{'cost'.padStart(8)}{'calls'.padStart(6)}</Text>
{sortedDays.map(day => (
<Text key={day} wrap="truncate-end">
<Text dimColor>{day.slice(5)} </Text>
<HBar value={dailyCosts[day] ?? 0} max={maxCost} width={bw} />
<Text color={GOLD}>{formatCost(dailyCosts[day] ?? 0).padStart(8)}</Text>
<Text>{String(dailyCalls[day] ?? 0).padStart(6)}</Text>
</Text>
))}
{loading
? <Text dimColor>Loading daily history...</Text>
: <>
<Text dimColor wrap="truncate-end">{''.padEnd((scrollable ? 11 : 6) + bw)}{'cost'.padStart(8)}{'calls'.padStart(6)}</Text>
{rows.map(row => (
<Text key={row.day} wrap="truncate-end">
<Text dimColor>{scrollable ? row.day : row.day.slice(5)} </Text>
<HBar value={row.cost} max={maxCost} width={bw} />
<Text color={GOLD}>{formatCost(row.cost).padStart(8)}</Text>
<Text>{String(row.calls).padStart(6)}</Text>
</Text>
))}
{scrollable && orderedRows.length > 0 && (
<Text dimColor wrap="truncate-end">Showing {cursor + 1}–{Math.min(cursor + days, orderedRows.length)} of {orderedRows.length} · newest first</Text>
)}
</>}
</Panel>
)
}
@ -727,6 +769,12 @@ function StatusBar({ width, showProvider, view, findingCount, optimizeAvailable,
{!isOptimize && compareAvailable && (
<><Text dimColor> </Text><Text color={ORANGE} bold>c</Text><Text dimColor> compare</Text></>
)}
{!isOptimize && !customRange && !dayMode && view === 'dashboard' && (
<>
<Text dimColor> </Text><Text color={PANEL_COLORS.daily} bold>↑</Text><Text dimColor>/</Text><Text color={PANEL_COLORS.daily} bold>↓</Text><Text dimColor> daily </Text>
<Text color={PANEL_COLORS.daily} bold>PgUp</Text><Text dimColor>/</Text><Text color={PANEL_COLORS.daily} bold>PgDn</Text><Text dimColor> page</Text>
</>
)}
{showProvider && (<><Text dimColor> </Text><Text color={ORANGE} bold>p</Text><Text dimColor> provider</Text></>)}
</Text>
</Box>
@ -738,13 +786,13 @@ function Row({ wide, width, children }: { wide: boolean; width: number; children
return <>{children}</>
}
function DashboardContent({ projects, period, columns, activeProvider, budgets, planUsages, label, dayMode }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string; budgets?: Map<string, ContextBudget>; planUsages?: PlanUsage[]; label?: string; dayMode?: boolean }) {
function DashboardContent({ projects, period, columns, activeProvider, budgets, planUsages, label, dayMode, dailyHistoryProjects, scrollableDailyHistory = false, dailyHistoryCursor = 0, dailyHistoryLoading = false }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string; budgets?: Map<string, ContextBudget>; planUsages?: PlanUsage[]; label?: string; dayMode?: boolean; dailyHistoryProjects?: ProjectSummary[]; scrollableDailyHistory?: boolean; dailyHistoryCursor?: number; dailyHistoryLoading?: boolean }) {
const { dashWidth, wide, halfWidth, barWidth } = getLayout(columns)
const isCursor = activeProvider === 'cursor'
const activeLabel = label ?? PERIOD_LABELS[period]
if (projects.length === 0) return <Panel title="CodeBurn" color={ORANGE} width={dashWidth}><Text dimColor>No usage data found for {activeLabel}.</Text></Panel>
if (showEmptyState(projects.length, scrollableDailyHistory, (dailyHistoryProjects ?? []).length, dailyHistoryLoading)) return <Panel title="CodeBurn" color={ORANGE} width={dashWidth}><Text dimColor>No usage data found for {activeLabel}.</Text></Panel>
const pw = wide ? halfWidth : dashWidth
const days = dayMode ? 1 : period === 'all' ? undefined : (period === 'month' || period === '30days' ? 31 : 14)
const days = dayMode ? 1 : (period === 'month' || period === '30days' ? 31 : 14)
// A provider-scoped plan (e.g. SuperGrok) only makes sense on its own
// provider tab, where the shown cost matches the plan's spend. Hide it on
// every other tab, including All, so its budget isn't compared to spend it
@ -753,7 +801,7 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets,
return (
<Box flexDirection="column" width={dashWidth}>
<Overview projects={projects} label={activeLabel} width={dashWidth} planUsages={visiblePlanUsages} />
<Row wide={wide} width={dashWidth}><DailyActivity projects={projects} days={days} pw={pw} bw={barWidth} /><ProjectBreakdown projects={projects} pw={pw} bw={barWidth} budgets={budgets} rows={dayMode ? 8 : period === 'all' ? 14 : period === 'month' || period === '30days' ? 14 : 8} /></Row>
<Row wide={wide} width={dashWidth}><DailyActivity projects={scrollableDailyHistory ? (dailyHistoryProjects ?? []) : projects} days={days} pw={pw} bw={barWidth} scrollable={scrollableDailyHistory} cursor={dailyHistoryCursor} loading={dailyHistoryLoading} /><ProjectBreakdown projects={projects} pw={pw} bw={barWidth} budgets={budgets} rows={dayMode ? 8 : period === 'all' ? 14 : period === 'month' || period === '30days' ? 14 : 8} /></Row>
<Row wide={wide} width={dashWidth}><ActivityBreakdown projects={projects} pw={pw} bw={barWidth} /><ModelBreakdown projects={projects} pw={pw} bw={barWidth} /></Row>
{isCursor ? (
<ToolBreakdown projects={projects} pw={dashWidth} bw={barWidth} title="Languages" filterPrefix="lang:" />
@ -788,14 +836,21 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
const [projectBudgets, setProjectBudgets] = useState<Map<string, ContextBudget>>(new Map())
const [planUsages, setPlanUsages] = useState<PlanUsage[]>(initialPlanUsages ?? [])
const [dayDate, setDayDate] = useState<string | null>(initialDay ?? null)
const [dailyHistoryProjects, setDailyHistoryProjects] = useState<ProjectSummary[]>(initialProjects)
const [dailyHistoryLoading, setDailyHistoryLoading] = useState(initialDay == null && customRange == null)
const [dailyHistoryCursor, setDailyHistoryCursor] = useState(0)
// Cursor for the OptimizeView's findings window. Reset whenever the user
// leaves the optimize view OR the underlying findings change so a long
// findings list never strands the user past the new array length.
const [findingsCursor, setFindingsCursor] = useState(0)
const isDayMode = dayDate != null
const isCustomRange = customRange != null && !isDayMode
const scrollableDailyHistory = !isCustomRange && !isDayMode
const { columns } = useWindowSize()
const { dashWidth } = getLayout(columns)
const dailyHistoryPageSize = isDayMode ? 1 : (period === 'month' || period === '30days' ? 31 : 14)
const dailyHistoryRowCount = getDailyActivityRows(dailyHistoryProjects).length
const dailyHistoryMaxCursor = Math.max(0, dailyHistoryRowCount - dailyHistoryPageSize)
const multipleProviders = detectedProviders.length > 1
const optimizeAvailable = !isCustomRange && (activeProvider === 'all' || activeProvider === 'claude')
const modelCount = new Set(
@ -807,6 +862,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
const reloadInFlightRef = useRef(false)
const currentReloadRef = useRef<{ period: Period; provider: string; day: string | null } | null>(null)
const pendingReloadRef = useRef<{ period: Period; provider: string; day: string | null } | null>(null)
const dailyHistoryGenerationRef = useRef(0)
const findingCount = optimizeResult?.findings.length ?? 0
useEffect(() => {
@ -908,12 +964,37 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
}
}, [optimizeAvailable, projects, currentRange, optimizeLoading, optimizeResult])
const loadDailyHistory = useCallback(async (provider: string) => {
const generation = ++dailyHistoryGenerationRef.current
setDailyHistoryLoading(true)
try {
const data = await parseAllSessions(undefined, provider)
if (dailyHistoryGenerationRef.current !== generation) return
setDailyHistoryProjects(filterProjectsByName(data, projectFilter, excludeFilter))
} catch (error) {
if (dailyHistoryGenerationRef.current !== generation) return
console.error(error)
setDailyHistoryProjects([])
} finally {
if (dailyHistoryGenerationRef.current === generation) setDailyHistoryLoading(false)
}
}, [projectFilter, excludeFilter])
useEffect(() => {
if (!scrollableDailyHistory) return
setDailyHistoryCursor(0)
void loadDailyHistory(activeProvider)
}, [activeProvider, loadDailyHistory, scrollableDailyHistory])
useEffect(() => {
if (!refreshSeconds || refreshSeconds <= 0) return
if (!dayDate && isHeavyPeriod(period)) return
const id = setInterval(() => { reloadData(period, activeProvider, dayDate) }, refreshSeconds * 1000)
const id = setInterval(() => {
void reloadData(period, activeProvider, dayDate)
if (scrollableDailyHistory) void loadDailyHistory(activeProvider)
}, refreshSeconds * 1000)
return () => clearInterval(id)
}, [refreshSeconds, period, activeProvider, dayDate, reloadData])
}, [refreshSeconds, period, activeProvider, dayDate, reloadData, scrollableDailyHistory, loadDailyHistory])
const switchPeriod = useCallback((np: Period) => {
if (np === period && !dayDate) return
@ -923,6 +1004,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
// there's a frame-to-hundreds-of-ms window where users saw wrong
// figures captioned with the new period.
setPeriod(np)
setDailyHistoryCursor(0)
setDayDate(null)
setProjects([])
setLoading(true)
@ -933,6 +1015,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
const switchPeriodImmediate = useCallback(async (np: Period) => {
if (np === period && !dayDate) return
setPeriod(np)
setDailyHistoryCursor(0)
setDayDate(null)
setProjects([])
setLoading(true)
@ -945,6 +1028,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
const clampedDay = nextDay > today ? today : nextDay
if (clampedDay === dayDate) return
setDayDate(clampedDay)
setDailyHistoryCursor(0)
setProjects([])
setLoading(true)
setView('dashboard')
@ -970,9 +1054,18 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
}
if (input === 'c' && compareAvailable && view === 'dashboard') { setView('compare'); return }
if ((input === 'b' || key.escape) && view === 'compare') { setView('dashboard'); return }
if (view === 'dashboard' && scrollableDailyHistory) {
if (key.pageDown || (input === ' ' && !key.shift)) { setDailyHistoryCursor(c => pageHistoryCursor(c, 1, dailyHistoryPageSize, dailyHistoryRowCount)); return }
if (key.pageUp || (input === ' ' && key.shift)) { setDailyHistoryCursor(c => pageHistoryCursor(c, -1, dailyHistoryPageSize, dailyHistoryRowCount)); return }
if (input === 'j' || key.downArrow) { setDailyHistoryCursor(c => scrollHistoryCursor(c, 1, dailyHistoryPageSize, dailyHistoryRowCount)); return }
if (input === 'k' || key.upArrow) { setDailyHistoryCursor(c => scrollHistoryCursor(c, -1, dailyHistoryPageSize, dailyHistoryRowCount)); return }
if (input === 'g') { setDailyHistoryCursor(0); return }
if (input === 'G') { setDailyHistoryCursor(dailyHistoryMaxCursor); return }
}
if (input === 'p' && multipleProviders && view !== 'compare') {
const opts = ['all', ...detectedProviders]; const next = opts[(opts.indexOf(activeProvider) + 1) % opts.length]
setActiveProvider(next); setView('dashboard')
setDailyHistoryCursor(0)
if (debounceRef.current) clearTimeout(debounceRef.current)
reloadData(period, next, dayDate); return
}
@ -984,6 +1077,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
if (!customRange && input === 'd') {
if (dayDate) {
setDayDate(null)
setDailyHistoryCursor(0)
setProjects([])
setLoading(true)
void reloadData(period, activeProvider, null)
@ -1051,7 +1145,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
? <CompareView projects={projects} onBack={() => setView('dashboard')} />
: view === 'optimize' && optimizeResult
? <OptimizeView findings={optimizeResult.findings} costRate={optimizeResult.costRate} projects={projects} label={headerLabel} width={dashWidth} healthScore={optimizeResult.healthScore} healthGrade={optimizeResult.healthGrade} cursor={findingsCursor} />
: <DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} budgets={projectBudgets} planUsages={planUsages} label={headerLabel} dayMode={isDayMode} />}
: <DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} budgets={projectBudgets} planUsages={planUsages} label={headerLabel} dayMode={isDayMode} dailyHistoryProjects={dailyHistoryProjects} scrollableDailyHistory={scrollableDailyHistory} dailyHistoryCursor={Math.min(dailyHistoryCursor, dailyHistoryMaxCursor)} dailyHistoryLoading={dailyHistoryLoading} />}
{view !== 'compare' && <StatusBar width={dashWidth} showProvider={multipleProviders} view={view} findingCount={findingCount} optimizeAvailable={optimizeAvailable} compareAvailable={compareAvailable} customRange={isCustomRange} dayMode={isDayMode} />}
</Box>
)

View file

@ -2,7 +2,7 @@ import { homedir } from 'os'
import { describe, it, expect } from 'vitest'
import { shortProject } from '../src/dashboard.js'
import { getDailyActivityRows, pageHistoryCursor, scrollHistoryCursor, shortProject, showEmptyState } from '../src/dashboard.js'
import { formatCost } from '../src/format.js'
import type { ProjectSummary, SessionSummary } from '../src/types.js'
@ -54,6 +54,33 @@ function makeProject(name: string, sessions: SessionSummary[]): ProjectSummary {
}
}
function makeTurn(timestamp: string, costs: number[]): SessionSummary['turns'][number] {
return {
userMessage: 'fixture turn',
sessionId: 'fixture-session',
timestamp,
category: 'coding',
retries: 0,
hasEdits: false,
assistantCalls: costs.map((costUSD, index) => ({
provider: 'codex',
model: 'test-model',
usage: { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0 },
costUSD,
tools: [],
mcpTools: [],
skills: [],
subagentTypes: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
timestamp,
bashCommands: [],
deduplicationKey: `fixture-${timestamp}-${index}`,
})),
}
}
// Logic replicated from TopSessions component
function getTopSessions(projects: ProjectSummary[], n = 5) {
const all = projects.flatMap(p => p.sessions.map(s => ({ ...s, projectPath: p.projectPath })))
@ -144,3 +171,50 @@ describe('avg/s in ProjectBreakdown', () => {
expect(avgCostLabel(project)).toBe(formatCost(3.0))
})
})
describe('Daily Activity history', () => {
it('aggregates every active day in chronological order', () => {
const session = makeSession('s1', 0)
session.turns = [
makeTurn('2025-01-02T12:00:00Z', [1.25, 0.75]),
makeTurn('2024-12-31T12:00:00Z', [3]),
]
expect(getDailyActivityRows([makeProject('proj', [session])])).toEqual([
{ day: '2024-12-31', cost: 3, calls: 1 },
{ day: '2025-01-02', cost: 2, calls: 2 },
])
})
it('pages one viewport and keeps the final page full', () => {
expect(pageHistoryCursor(0, 1, 35, 69)).toBe(34)
expect(pageHistoryCursor(34, -1, 35, 69)).toBe(0)
expect(pageHistoryCursor(0, -1, 35, 69)).toBe(0)
})
it('scrolls one row without moving past either end', () => {
expect(scrollHistoryCursor(0, 1, 14, 21)).toBe(1)
expect(scrollHistoryCursor(1, -1, 14, 21)).toBe(0)
expect(scrollHistoryCursor(0, -1, 14, 21)).toBe(0)
expect(scrollHistoryCursor(7, 1, 14, 21)).toBe(7)
})
})
describe('showEmptyState', () => {
it('keeps the clean empty state for a truly-new user in scrollable mode', () => {
expect(showEmptyState(0, true, 0, false)).toBe(true)
})
it('renders the dashboard while full history is still loading', () => {
expect(showEmptyState(0, true, 0, true)).toBe(false)
})
it('renders the dashboard when the period is empty but history exists', () => {
expect(showEmptyState(0, true, 3, false)).toBe(false)
})
it('non-scrollable mode (custom range, day view) keeps the original behavior', () => {
expect(showEmptyState(0, false, 0, false)).toBe(true)
expect(showEmptyState(2, false, 0, false)).toBe(false)
})
})