aidxnCC/app/ai/usage/page.tsx

200 lines
6.5 KiB
TypeScript

"use client"
import { useEffect, useState, useMemo } from 'react'
import LoadingSkeleton from './components/LoadingSkeleton'
import PageHeader from './components/PageHeader'
import ProviderFilter from './components/ProviderFilter'
import StatsGrid from './components/StatsGrid'
import Activity from './components/Activity'
import ModelUsageCard from './components/ModelUsageCard'
import TokenType from './components/TokenType'
import TokenComposition from './components/TokenComposition'
import RecentSessions from './components/RecentSessions'
import TimeRangeFilter from './components/TimeRangeFilter'
import { filterDailyByRange, computeTotalsFromDaily } from './components/utils'
import type { ExtendedCCData, CCData, TimeRangeKey, DailyData } from '@/lib/types/ai'
import { getToolTheme } from '@/app/ai/theme'
export default function Usage() {
const [data, setData] = useState<ExtendedCCData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedProvider, setSelectedProvider] = useState<'all' | 'claudeCode' | 'codex'>('all')
const [timeRange, setTimeRange] = useState<TimeRangeKey>('1m')
const sortedAllDaily = useMemo<DailyData[]>(() => {
if (!data) return []
const dateMap = new Map<string, DailyData>()
if (data.claudeCode?.daily) {
for (const entry of data.claudeCode.daily) {
dateMap.set(entry.date, { ...entry })
}
}
if (data.codex?.daily) {
for (const entry of data.codex.daily) {
const existing = dateMap.get(entry.date)
if (existing) {
existing.inputTokens += entry.inputTokens
existing.outputTokens += entry.outputTokens
existing.cacheCreationTokens += entry.cacheCreationTokens
existing.cacheReadTokens += entry.cacheReadTokens
existing.totalTokens += entry.totalTokens
existing.totalCost += entry.totalCost
existing.modelsUsed = [...existing.modelsUsed, ...entry.modelsUsed]
existing.modelBreakdowns = [...existing.modelBreakdowns, ...entry.modelBreakdowns]
} else {
dateMap.set(entry.date, { ...entry })
}
}
}
return Array.from(dateMap.values()).sort((a, b) => a.date.localeCompare(b.date))
}, [data])
const globalEndDate = useMemo<Date | null>(() => {
if (!sortedAllDaily.length) return null
const last = sortedAllDaily[sortedAllDaily.length - 1]
return new Date(last.date + 'T00:00:00Z')
}, [sortedAllDaily])
useEffect(() => {
fetch('/data/cc.json')
.then(res => {
if (!res.ok) throw new Error('Failed to fetch data')
return res.json()
})
.then(data => {
setData(data)
setLoading(false)
})
.catch(err => {
setError(err.message)
setLoading(false)
})
}, [])
const providerScopedData = useMemo<CCData | null>(() => {
if (!data) return null
const baseDaily = sortedAllDaily
const createEmptyDay = (date: string): DailyData => ({
date,
inputTokens: 0,
outputTokens: 0,
cacheCreationTokens: 0,
cacheReadTokens: 0,
totalTokens: 0,
totalCost: 0,
modelsUsed: [],
modelBreakdowns: [],
})
if (selectedProvider === 'claudeCode' && data.claudeCode) {
const byDate = new Map(data.claudeCode.daily.map(day => [day.date, day] as const))
const normalizedDaily = baseDaily.map(day => byDate.get(day.date) ?? createEmptyDay(day.date))
return {
daily: normalizedDaily,
totals: data.claudeCode.totals,
}
}
if (selectedProvider === 'codex' && data.codex) {
const byDate = new Map(data.codex.daily.map(day => [day.date, day] as const))
const normalizedDaily = baseDaily.map(day => byDate.get(day.date) ?? createEmptyDay(day.date))
return {
daily: normalizedDaily,
totals: data.codex.totals,
}
}
const totals = data.totals || computeTotalsFromDaily(baseDaily)
return {
daily: baseDaily,
totals,
}
}, [data, selectedProvider, sortedAllDaily])
const filteredData = useMemo<CCData | null>(() => {
if (!providerScopedData) return null
const scopedDaily = filterDailyByRange(providerScopedData.daily, timeRange, {
endDate: globalEndDate ?? undefined,
})
const totals = timeRange === 'all'
? providerScopedData.totals
: computeTotalsFromDaily(scopedDaily)
return {
daily: scopedDaily,
totals
}
}, [providerScopedData, timeRange, globalEndDate])
const theme = getToolTheme(selectedProvider)
if (loading) {
return (
<LoadingSkeleton
theme={theme}
selectedProvider={selectedProvider}
timeRange={timeRange}
/>
)
}
if (error || !data || !filteredData) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-red-400">Error loading data: {error}</div>
</div>
)
}
return (
<div className="w-full relative">
<PageHeader selectedProvider={selectedProvider} theme={theme} />
<div className="mb-6 px-4">
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-4">
<div aria-hidden="true" />
<div className="justify-self-center">
<ProviderFilter
selectedProvider={selectedProvider}
onProviderChange={setSelectedProvider}
hasClaudeCode={!!data.claudeCode}
hasCodex={!!data.codex}
theme={theme}
/>
</div>
<div className="justify-self-end">
<TimeRangeFilter
value={timeRange}
onChange={setTimeRange}
theme={theme}
/>
</div>
</div>
</div>
<StatsGrid totals={filteredData.totals} daily={filteredData.daily} theme={theme} />
<div className="p-4 pb-0">
<Activity daily={filteredData.daily} theme={theme} timeRange={timeRange} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4">
<ModelUsageCard daily={filteredData.daily} totalCost={filteredData.totals.totalCost} theme={theme} />
<TokenType totals={filteredData.totals} theme={theme} />
<TokenComposition daily={filteredData.daily} theme={theme} timeRange={timeRange} />
</div>
<div className="px-4 pb-4">
<RecentSessions daily={filteredData.daily} theme={theme} />
</div>
</div>
)
}