diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 9f3cd6d9..57d2bd92 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -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' @@ -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) } @@ -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[] @@ -841,8 +856,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, const [projectBudgets, setProjectBudgets] = useState>(new Map()) const [planUsages, setPlanUsages] = useState(initialPlanUsages ?? []) const [dayDate, setDayDate] = useState(initialDay ?? null) - const [dailyHistoryProjects, setDailyHistoryProjects] = useState(initialProjects) - const [dailyHistoryLoading, setDailyHistoryLoading] = useState(initialDay == null && customRange == null) + const [dailyHistoryProjects, setDailyHistoryProjects] = useState(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 @@ -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(() => { @@ -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) @@ -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) @@ -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) @@ -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 @@ -1150,7 +1140,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, ? setView('dashboard')} /> : view === 'optimize' && optimizeResult ? - : } + : } {view !== 'compare' && } ) @@ -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( - + ) await waitUntilExit() } else { diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index e8da69aa..59ada8fd 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -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' @@ -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 = [ diff --git a/tests/providers/vercel-gateway.test.ts b/tests/providers/vercel-gateway.test.ts index 856c6750..2aa71083 100644 --- a/tests/providers/vercel-gateway.test.ts +++ b/tests/providers/vercel-gateway.test.ts @@ -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 @@ -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)