Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 32 additions & 40 deletions src/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { render, Box, Text, useInput, useApp, useWindowSize } from 'ink'
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
import { formatCost, formatTokens, markEstimated } from './format.js'
import { aggregateModelEfficiency } from './model-efficiency.js'
import { parseAllSessions, filterProjectsByName, setInteractiveScanUI } from './parser.js'
import { parseAllSessions, filterProjectsByDateRange, filterProjectsByName, setInteractiveScanUI } from './parser.js'
import { findUnpricedModels, loadPricing } from './models.js'
import { getAllProviders } from './providers/index.js'
import { scanAndDetect, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js'
Expand Down Expand Up @@ -131,6 +131,20 @@ function getDayRange(day: string): DateRange {
return parseDayFlag(day)!.range
}

export function getDashboardScanRange(period: Period, customRange: DateRange | null | undefined, day: string | null, scrollableHistory = true): DateRange {
if (day) return getDayRange(day)
if (customRange) return customRange
// Daily Activity is scrollable on the standard dashboard, so one bounded
// six-month scan supplies both the selected period and its history. A
// concrete range is also required by network-backed providers.
return getPeriodRange(scrollableHistory ? 'all' : period)
}

export function selectDashboardPeriodProjects(projects: ProjectSummary[], period: Period, scrollableHistory: boolean): ProjectSummary[] {
if (!scrollableHistory || period === 'all') return projects
return filterProjectsByDateRange(projects, getPeriodRange(period))
}

function isHeavyPeriod(period: Period): boolean {
return HEAVY_PERIODS.has(period)
}
Expand Down Expand Up @@ -817,8 +831,9 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets,
)
}

function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, initialPlanUsages, refreshSeconds, projectFilter, excludeFilter, customRange, customRangeLabel, initialDay }: {
function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, initialPeriod, initialProvider, initialPlanUsages, refreshSeconds, projectFilter, excludeFilter, customRange, customRangeLabel, initialDay }: {
initialProjects: ProjectSummary[]
initialDailyHistoryProjects?: ProjectSummary[]
initialPeriod: Period
initialProvider: string
initialPlanUsages?: PlanUsage[]
Expand All @@ -841,8 +856,7 @@ 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 [dailyHistoryProjects, setDailyHistoryProjects] = useState<ProjectSummary[]>(initialDailyHistoryProjects ?? initialProjects)
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
Expand All @@ -867,7 +881,6 @@ 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(() => {
Expand Down Expand Up @@ -909,6 +922,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
}
reloadInFlightRef.current = true
currentReloadRef.current = { period: p, provider: prov, day }
const shouldLoadHistory = !day && customRange == null
const generation = ++reloadGenerationRef.current
setLoading(true)
setOptimizeLoading(false)
Expand All @@ -920,14 +934,15 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
await nextTick()
if (reloadGenerationRef.current !== generation) return
}
const range = day ? getDayRange(day) : getPeriodRange(p)
const range = getDashboardScanRange(p, customRange, day, shouldLoadHistory)
const data = await parseAllSessions(range, prov)
if (reloadGenerationRef.current !== generation) return

const filteredProjects = filterProjectsByName(data, projectFilter, excludeFilter)
if (reloadGenerationRef.current !== generation) return

setProjects(filteredProjects)
if (shouldLoadHistory) setDailyHistoryProjects(filteredProjects)
setProjects(selectDashboardPeriodProjects(filteredProjects, p, shouldLoadHistory))
const usage = await getPlanUsages()
if (reloadGenerationRef.current !== generation) return
setPlanUsages(usage)
Expand All @@ -945,7 +960,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
void reloadData(pending.period, pending.provider, pending.day)
}
}
}, [projectFilter, excludeFilter])
}, [projectFilter, excludeFilter, customRange])

const currentRange = useCallback(() => {
return dayDate ? getDayRange(dayDate) : getPeriodRange(period)
Expand All @@ -969,37 +984,12 @@ 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(() => {
void reloadData(period, activeProvider, dayDate)
if (scrollableDailyHistory) void loadDailyHistory(activeProvider)
}, refreshSeconds * 1000)
const id = setInterval(() => { void reloadData(period, activeProvider, dayDate) }, refreshSeconds * 1000)
return () => clearInterval(id)
}, [refreshSeconds, period, activeProvider, dayDate, reloadData, scrollableDailyHistory, loadDailyHistory])
}, [refreshSeconds, period, activeProvider, dayDate, reloadData])

const switchPeriod = useCallback((np: Period) => {
if (np === period && !dayDate) return
Expand Down Expand Up @@ -1150,7 +1140,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} dailyHistoryProjects={dailyHistoryProjects} scrollableDailyHistory={scrollableDailyHistory} dailyHistoryCursor={Math.min(dailyHistoryCursor, dailyHistoryMaxCursor)} dailyHistoryLoading={dailyHistoryLoading} />}
: <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)} />}
{view !== 'compare' && <StatusBar width={dashWidth} showProvider={multipleProviders} view={view} findingCount={findingCount} optimizeAvailable={optimizeAvailable} compareAvailable={compareAvailable} customRange={isCustomRange} dayMode={isDayMode} />}
</Box>
)
Expand Down Expand Up @@ -1192,15 +1182,17 @@ export async function renderDashboard(period: Period = 'week', provider: string
setInteractiveScanUI()
await loadPricing()
const dayRange = initialDay ? getDayRange(initialDay) : null
const range = dayRange ?? customRange ?? getPeriodRange(period)
const filteredProjects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter)
const isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY)
const scrollableDailyHistory = isTTY && dayRange == null && customRange == null
const range = getDashboardScanRange(period, customRange, initialDay ?? null, scrollableDailyHistory)
const scannedProjects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter)
const filteredProjects = selectDashboardPeriodProjects(scannedProjects, period, scrollableDailyHistory)
const planUsages = await getPlanUsages()
const isTTY = process.stdin.isTTY && process.stdout.isTTY
const label = initialDay ? formatDayRangeLabel(initialDay) : customRangeLabel
patchStdoutForWindows()
if (isTTY) {
const { waitUntilExit } = render(
<InteractiveDashboard initialProjects={filteredProjects} initialPeriod={period} initialProvider={provider} initialPlanUsages={planUsages} refreshSeconds={refreshSeconds} projectFilter={projectFilter} excludeFilter={excludeFilter} customRange={customRange} customRangeLabel={customRangeLabel} initialDay={initialDay} />
<InteractiveDashboard initialProjects={filteredProjects} initialDailyHistoryProjects={scrollableDailyHistory ? scannedProjects : undefined} initialPeriod={period} initialProvider={provider} initialPlanUsages={planUsages} refreshSeconds={refreshSeconds} projectFilter={projectFilter} excludeFilter={excludeFilter} customRange={customRange} customRangeLabel={customRangeLabel} initialDay={initialDay} />
)
await waitUntilExit()
} else {
Expand Down
32 changes: 31 additions & 1 deletion tests/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { homedir } from 'os'

import { describe, it, expect } from 'vitest'

import { getDailyActivityRows, pageHistoryCursor, scrollHistoryCursor, shortProject, showEmptyState } from '../src/dashboard.js'
import { getDailyActivityRows, getDashboardScanRange, pageHistoryCursor, scrollHistoryCursor, selectDashboardPeriodProjects, shortProject, showEmptyState } from '../src/dashboard.js'
import { getDateRange } from '../src/cli-date.js'
import { formatCost } from '../src/format.js'
import type { ProjectSummary, SessionSummary } from '../src/types.js'

Expand Down Expand Up @@ -173,6 +174,35 @@ describe('avg/s in ProjectBreakdown', () => {
})

describe('Daily Activity history', () => {
it('uses one concrete six-month scan for standard dashboard periods', () => {
const scanRange = getDashboardScanRange('week', null, null)
const allRange = getDateRange('all').range

expect(scanRange.start.getTime()).toBe(allRange.start.getTime())
expect(scanRange.end.getTime()).toBe(allRange.end.getTime())
})

it('keeps non-interactive output scoped to the selected period', () => {
const scanRange = getDashboardScanRange('week', null, null, false)
const weekRange = getDateRange('week').range

expect(scanRange.start.getTime()).toBe(weekRange.start.getTime())
expect(scanRange.end.getTime()).toBe(weekRange.end.getTime())
})

it('derives the selected period from the bounded history scan', () => {
const recent = new Date().toISOString()
const old = new Date()
old.setMonth(old.getMonth() - 2)
const session = makeSession('s1', 0)
session.turns = [makeTurn(old.toISOString(), [1]), makeTurn(recent, [2])]

const selected = selectDashboardPeriodProjects([makeProject('proj', [session])], 'week', true)
expect(getDailyActivityRows(selected)).toEqual([
{ day: recent.slice(0, 10), cost: 2, calls: 1 },
])
})

it('aggregates every active day in chronological order', () => {
const session = makeSession('s1', 0)
session.turns = [
Expand Down
6 changes: 2 additions & 4 deletions tests/providers/vercel-gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { tmpdir } from 'os'
import { join } from 'path'
import { fetchVercelGatewayReport, vercelGateway } from '../../src/providers/vercel-gateway.js'
import { parseAllSessions, clearSessionCache } from '../../src/parser.js'
import { getDashboardScanRange } from '../../src/dashboard.js'

describe('vercel-gateway provider', () => {
const originalFetch = globalThis.fetch
Expand Down Expand Up @@ -105,10 +106,7 @@ describe('vercel-gateway end-to-end (parseAllSessions network path)', () => {
}),
})) as typeof fetch

const range = {
start: new Date('2026-05-01T00:00:00.000Z'),
end: new Date('2026-06-09T23:59:59.999Z'),
}
const range = getDashboardScanRange('week', null, null)
const projects = await parseAllSessions(range, 'vercel-gateway')
const total = projects.reduce((sum, p) => sum + p.totalCostUSD, 0)

Expand Down
Loading