fix(dashboard): Bound Daily Activity scans (#727)

Reuse one concrete six-month scan for interactive history and selected-period totals. This restores network-backed providers, removes concurrent refresh scans, and keeps non-interactive reports scoped to their requested period.
This commit is contained in:
ihearttokyo 2026-07-20 21:21:35 +09:00 committed by GitHub
parent 7f2739e130
commit 92b2b5ef39
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 65 additions and 45 deletions

View file

@ -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<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
@ -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,
? <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>
)
@ -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 {

View file

@ -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 = [

View file

@ -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)