feat (v1.0.0): initial refactor and redesign
This commit is contained in:
parent
3058aa1ab4
commit
fe9b50b30e
134 changed files with 17792 additions and 3670 deletions
254
app/ai/usage/components/Activity.tsx
Normal file
254
app/ai/usage/components/Activity.tsx
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
"use client"
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
Line,
|
||||
CartesianGrid,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { DailyData, TimeRangeKey } from '@/lib/types'
|
||||
import {
|
||||
buildDailyTrendData,
|
||||
formatCurrency,
|
||||
formatTokens,
|
||||
getHeatmapColor,
|
||||
prepareHeatmapData,
|
||||
formatAxisLabel,
|
||||
formatTooltipDate,
|
||||
} from './utils'
|
||||
import type { ToolTheme } from '@/app/ai/theme'
|
||||
|
||||
interface ActivityProps {
|
||||
daily: DailyData[]
|
||||
theme: ToolTheme
|
||||
timeRange: TimeRangeKey
|
||||
}
|
||||
|
||||
export default function Activity({ daily, theme, timeRange }: ActivityProps) {
|
||||
const [viewMode, setViewMode] = useState<'heatmap' | 'chart'>('chart')
|
||||
const [selectedMetric, setSelectedMetric] = useState<'cost' | 'tokens'>('cost')
|
||||
|
||||
const dailyTrendData = useMemo(() => buildDailyTrendData(daily), [daily])
|
||||
const heatmapWeeks = useMemo(() => prepareHeatmapData(daily), [daily])
|
||||
const maxCost = useMemo(
|
||||
() => (daily.length ? Math.max(...daily.map(d => d.totalCost)) : 0),
|
||||
[daily]
|
||||
)
|
||||
|
||||
const toggleStyles = {
|
||||
'--ring-color': theme.focusRing,
|
||||
'--knob-color': theme.button.activeBackground,
|
||||
} as React.CSSProperties
|
||||
|
||||
const heatmapLegendColors = useMemo(
|
||||
() => [theme.heatmap.empty, ...theme.heatmap.steps],
|
||||
[theme]
|
||||
)
|
||||
|
||||
const xAxisFormatter = useCallback(
|
||||
(value: string) => formatAxisLabel(String(value), timeRange),
|
||||
[timeRange]
|
||||
)
|
||||
|
||||
const tooltipLabelFormatter = useCallback(
|
||||
(value: string) => formatTooltipDate(String(value)),
|
||||
[]
|
||||
)
|
||||
|
||||
const tooltipFormatter = useCallback(
|
||||
(value: number | string, name: string) => {
|
||||
const isTrend = name === 'Trend'
|
||||
const label = isTrend
|
||||
? selectedMetric === 'cost'
|
||||
? 'Cost Trend'
|
||||
: 'Token Trend'
|
||||
: selectedMetric === 'cost'
|
||||
? 'Daily Cost'
|
||||
: 'Daily Tokens'
|
||||
|
||||
if (typeof value !== 'number') {
|
||||
return ['—', label]
|
||||
}
|
||||
|
||||
if (selectedMetric === 'cost') {
|
||||
return [formatCurrency(value), label]
|
||||
}
|
||||
|
||||
return [`${formatTokens(value)} tokens`, label]
|
||||
},
|
||||
[selectedMetric]
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300 relative md:col-span-2 lg:col-span-1">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-semibold text-gray-200">Activity</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-400">{viewMode === 'heatmap' ? 'Heatmap' : 'Chart'}</span>
|
||||
<button
|
||||
onClick={() => setViewMode(viewMode === 'heatmap' ? 'chart' : 'heatmap')}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full bg-gray-700 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring-color)] focus-visible:ring-offset-2 focus-visible:ring-offset-gray-900"
|
||||
style={toggleStyles}
|
||||
>
|
||||
<span className="sr-only">Toggle view mode</span>
|
||||
<span
|
||||
className={`${viewMode === 'chart' ? 'translate-x-1' : 'translate-x-6'} inline-block h-4 w-4 transform rounded-full transition-transform`}
|
||||
style={{ backgroundColor: theme.button.activeBackground }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === 'heatmap' ? (
|
||||
<div className="overflow-x-auto pb-6">
|
||||
<div className="min-w-[900px]">
|
||||
<div className="flex gap-1">
|
||||
<div className="flex flex-col gap-1 text-xs text-gray-400 w-10 pr-2">
|
||||
<div className="h-4"></div>
|
||||
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((day) => (
|
||||
<div key={day} className="h-4 flex items-center justify-end text-[10px]">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="h-4 mb-1 text-xs text-gray-400">
|
||||
{(() => {
|
||||
const monthLabels: { month: string; position: number }[] = []
|
||||
let lastMonth = -1
|
||||
heatmapWeeks.forEach((week, weekIndex) => {
|
||||
const firstDay = week.find(d => d !== null)
|
||||
if (firstDay) {
|
||||
const date = new Date(firstDay.date + 'T00:00:00Z')
|
||||
const month = date.getUTCMonth()
|
||||
if (month !== lastMonth) {
|
||||
monthLabels.push({
|
||||
month: date.toLocaleDateString('en-US', { month: 'short', timeZone: 'UTC' }),
|
||||
position: weekIndex * 20
|
||||
})
|
||||
lastMonth = month
|
||||
}
|
||||
}
|
||||
})
|
||||
return (
|
||||
<div className="flex relative">
|
||||
{monthLabels.map((label, idx) => (
|
||||
<div key={idx} style={{ position: 'absolute', left: label.position }} className="w-10">
|
||||
{label.month}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{heatmapWeeks.map((week, weekIndex) => (
|
||||
<div key={weekIndex} className="flex flex-col gap-1">
|
||||
{week.map((day, dayIndex) => (
|
||||
<div key={dayIndex} className="relative group">
|
||||
<div
|
||||
className="w-4 h-4 rounded-sm"
|
||||
style={{ backgroundColor: getHeatmapColor(maxCost, day?.value || 0, theme.heatmap) }}
|
||||
/>
|
||||
{day && (
|
||||
<div className="absolute z-10 invisible group-hover:visible -top-2 left-6">
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-lg p-2 shadow-lg whitespace-nowrap">
|
||||
<p className="text-gray-300 text-xs font-medium mb-1">{day.formattedDate}</p>
|
||||
<p className="font-bold text-sm" style={{ color: theme.emphasis.cost }}>
|
||||
Cost: ${day.cost.toFixed(2)}
|
||||
</p>
|
||||
<p className="text-gray-400 text-xs">Tokens: {(day.tokens / 1000000).toFixed(2)}M</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-4 text-xs text-gray-400">
|
||||
<span>Less</span>
|
||||
<div className="flex gap-1">
|
||||
{heatmapLegendColors.map((color, idx) => (
|
||||
<div key={idx} className="w-3 h-3 rounded-sm" style={{ backgroundColor: color }}></div>
|
||||
))}
|
||||
</div>
|
||||
<span>More</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setSelectedMetric('cost')}
|
||||
className={`px-3 py-1 rounded transition-colors ${selectedMetric === 'cost' ? '' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}`}
|
||||
style={selectedMetric === 'cost'
|
||||
? { backgroundColor: theme.button.activeBackground, color: theme.button.activeText }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Cost
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedMetric('tokens')}
|
||||
className={`px-3 py-1 rounded transition-colors ${selectedMetric === 'tokens' ? '' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}`}
|
||||
style={selectedMetric === 'tokens'
|
||||
? { backgroundColor: theme.button.activeBackground, color: theme.button.activeText }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Tokens
|
||||
</button>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<AreaChart data={dailyTrendData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="#9ca3af"
|
||||
tickFormatter={xAxisFormatter}
|
||||
interval={timeRange === '7d' ? 0 : undefined}
|
||||
tickMargin={12}
|
||||
minTickGap={12}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#9ca3af"
|
||||
tickFormatter={selectedMetric === 'cost' ? formatCurrency : formatTokens}
|
||||
domain={[0, 'auto']}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151' }}
|
||||
labelFormatter={tooltipLabelFormatter}
|
||||
formatter={tooltipFormatter}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey={selectedMetric === 'cost' ? 'cost' : 'tokens'}
|
||||
stroke={theme.chart.areaStroke}
|
||||
fill={theme.chart.areaFill}
|
||||
fillOpacity={0.3}
|
||||
name={selectedMetric === 'cost' ? 'Daily Cost' : 'Daily Tokens'}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={selectedMetric === 'cost' ? 'costTrend' : 'tokensTrend'}
|
||||
stroke={theme.chart.trend}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
strokeDasharray="6 4"
|
||||
name="Trend"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
242
app/ai/usage/components/LoadingSkeleton.tsx
Normal file
242
app/ai/usage/components/LoadingSkeleton.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
"use client"
|
||||
|
||||
import PageHeader from './PageHeader'
|
||||
import ProviderFilter from './ProviderFilter'
|
||||
import TimeRangeFilter from './TimeRangeFilter'
|
||||
import type { ToolTheme, ProviderId } from '@/app/ai/theme'
|
||||
import type { TimeRangeKey } from '@/lib/types'
|
||||
|
||||
interface LoadingSkeletonProps {
|
||||
theme: ToolTheme
|
||||
selectedProvider?: ProviderId
|
||||
timeRange?: TimeRangeKey
|
||||
}
|
||||
|
||||
const hexToRgba = (hex: string, alpha: number): string => {
|
||||
const normalized = hex.replace('#', '')
|
||||
const value = normalized.length === 3
|
||||
? normalized.split('').map((char) => `${char}${char}`).join('')
|
||||
: normalized.padEnd(6, '0')
|
||||
|
||||
const num = parseInt(value, 16)
|
||||
const r = (num >> 16) & 255
|
||||
const g = (num >> 8) & 255
|
||||
const b = num & 255
|
||||
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||
}
|
||||
|
||||
const buildSkeletonStyles = (theme: ToolTheme) => {
|
||||
const accentBase = theme.id === 'codex' ? theme.accentContrast : theme.accent
|
||||
const softAccent = hexToRgba(accentBase, 0.14)
|
||||
const mediumAccent = hexToRgba(accentBase, 0.22)
|
||||
const strongAccent = hexToRgba(accentBase, 0.35)
|
||||
|
||||
return {
|
||||
cardBorder: hexToRgba(accentBase, 0.28),
|
||||
chipBorder: hexToRgba(accentBase, 0.4),
|
||||
solid: { backgroundColor: mediumAccent },
|
||||
gradient: {
|
||||
backgroundImage: `linear-gradient(90deg, ${softAccent}, ${strongAccent}, ${softAccent})`,
|
||||
backgroundColor: softAccent,
|
||||
},
|
||||
subtle: { backgroundColor: softAccent },
|
||||
}
|
||||
}
|
||||
|
||||
export default function LoadingSkeleton({ theme, selectedProvider = 'all', timeRange = '1m' }: LoadingSkeletonProps) {
|
||||
const placeholderStyles = buildSkeletonStyles(theme)
|
||||
return (
|
||||
<main className="w-full relative">
|
||||
<PageHeader theme={theme} selectedProvider={selectedProvider} />
|
||||
|
||||
<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={() => {}}
|
||||
hasClaudeCode
|
||||
hasCodex
|
||||
theme={theme}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div className="justify-self-end">
|
||||
<TimeRangeFilter
|
||||
value={timeRange}
|
||||
onChange={() => {}}
|
||||
theme={theme}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 px-4">
|
||||
<div
|
||||
className="p-6 border-2 rounded-lg transition-colors duration-300"
|
||||
style={{ borderColor: placeholderStyles.cardBorder }}
|
||||
>
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-2">Total Cost</h3>
|
||||
<div className="h-9 w-32 rounded animate-pulse" style={placeholderStyles.gradient} />
|
||||
</div>
|
||||
<div
|
||||
className="p-6 border-2 rounded-lg transition-colors duration-300"
|
||||
style={{ borderColor: placeholderStyles.cardBorder }}
|
||||
>
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-2">Total Tokens</h3>
|
||||
<div className="h-9 w-32 rounded animate-pulse" style={placeholderStyles.gradient} />
|
||||
</div>
|
||||
<div
|
||||
className="p-6 border-2 rounded-lg transition-colors duration-300"
|
||||
style={{ borderColor: placeholderStyles.cardBorder }}
|
||||
>
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-2">Days Active</h3>
|
||||
<div className="flex items-center">
|
||||
<div className="h-9 w-16 rounded animate-pulse" style={placeholderStyles.gradient} />
|
||||
<div className="ml-3 h-5 w-12 rounded-full animate-pulse" style={placeholderStyles.subtle} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="p-6 border-2 rounded-lg transition-colors duration-300"
|
||||
style={{ borderColor: placeholderStyles.cardBorder }}
|
||||
>
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-2">Avg Daily Cost</h3>
|
||||
<div className="h-9 w-32 rounded animate-pulse" style={placeholderStyles.gradient} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 pb-0">
|
||||
<section
|
||||
className="p-8 border-2 rounded-lg transition-colors duration-300 relative md:col-span-2 lg:col-span-1"
|
||||
style={{ borderColor: placeholderStyles.cardBorder }}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-semibold text-gray-200">Activity</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-400">Chart</span>
|
||||
<button
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full"
|
||||
style={{ backgroundColor: hexToRgba(theme.focusRing, 0.25) }}
|
||||
>
|
||||
<span className="sr-only">Toggle view mode</span>
|
||||
<span
|
||||
className="inline-block h-4 w-4 transform rounded-full translate-x-1 animate-pulse"
|
||||
style={placeholderStyles.gradient}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pb-6">
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
className="px-3 py-1 rounded"
|
||||
style={{ backgroundColor: theme.button.activeBackground, color: theme.button.activeText }}
|
||||
>
|
||||
Cost
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1 rounded border text-gray-300"
|
||||
style={{ borderColor: placeholderStyles.chipBorder, backgroundColor: hexToRgba(theme.focusRing, 0.12) }}
|
||||
>
|
||||
Tokens
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-[400px] w-full rounded animate-pulse" style={placeholderStyles.gradient} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4">
|
||||
<section
|
||||
className="p-8 border-2 rounded-lg transition-colors duration-300 col-span-2 lg:col-span-1"
|
||||
style={{ borderColor: placeholderStyles.cardBorder }}
|
||||
>
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-200">Model Usage Distribution</h2>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<div className="h-[300px] rounded animate-pulse" style={placeholderStyles.gradient} />
|
||||
<div className="flex flex-col justify-center space-y-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full animate-pulse" style={placeholderStyles.gradient} />
|
||||
<div className="h-4 w-20 rounded animate-pulse" style={placeholderStyles.gradient} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-4 w-10 rounded animate-pulse" style={placeholderStyles.subtle} />
|
||||
<div className="h-4 w-16 rounded animate-pulse" style={placeholderStyles.gradient} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="pt-3 mt-3 border-t border-gray-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-400">Total Models Used</span>
|
||||
<div className="h-5 w-8 rounded animate-pulse" style={placeholderStyles.gradient} />
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
<span className="text-gray-400">Most Used</span>
|
||||
<div className="h-4 w-20 rounded animate-pulse" style={placeholderStyles.subtle} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
className="p-8 border-2 rounded-lg transition-colors duration-300 col-span-2 lg:col-span-1"
|
||||
style={{ borderColor: placeholderStyles.cardBorder }}
|
||||
>
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-200">By Token Type</h2>
|
||||
<div className="h-[300px] rounded animate-pulse" style={placeholderStyles.gradient} />
|
||||
</section>
|
||||
<section
|
||||
className="p-8 border-2 rounded-lg transition-colors duration-300 sm:col-span-2"
|
||||
style={{ borderColor: placeholderStyles.cardBorder }}
|
||||
>
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-200">Token Composition</h2>
|
||||
<div className="h-[300px] rounded animate-pulse" style={placeholderStyles.gradient} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-4">
|
||||
<section
|
||||
className="p-8 border-2 rounded-lg transition-colors duration-300"
|
||||
style={{ borderColor: placeholderStyles.cardBorder }}
|
||||
>
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-200">Recent Sessions</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-700">
|
||||
<th className="py-2 px-4 text-gray-400">Date</th>
|
||||
<th className="py-2 px-4 text-gray-400">Models Used</th>
|
||||
<th className="py-2 px-4 text-gray-400">Total Tokens</th>
|
||||
<th className="py-2 px-4 text-gray-400">Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...Array(5)].map((_, index) => (
|
||||
<tr key={index} className="border-b border-gray-800">
|
||||
<td className="py-2 px-4">
|
||||
<div className="h-5 w-24 rounded animate-pulse" style={placeholderStyles.gradient} />
|
||||
</td>
|
||||
<td className="py-2 px-4">
|
||||
<div className="h-5 w-96 rounded animate-pulse" style={placeholderStyles.gradient} />
|
||||
</td>
|
||||
<td className="py-2 px-4">
|
||||
<div className="h-5 w-16 rounded animate-pulse" style={placeholderStyles.subtle} />
|
||||
</td>
|
||||
<td className="py-2 px-4">
|
||||
<div className="h-5 w-20 rounded animate-pulse" style={placeholderStyles.gradient} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
83
app/ai/usage/components/ModelUsageCard.tsx
Normal file
83
app/ai/usage/components/ModelUsageCard.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"use client"
|
||||
|
||||
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip } from 'recharts'
|
||||
import { DailyData } from '@/lib/types'
|
||||
import { buildModelUsageData, formatCurrency } from './utils'
|
||||
import type { ToolTheme } from '@/app/ai/theme'
|
||||
|
||||
interface ModelUsageCardProps {
|
||||
daily: DailyData[]
|
||||
totalCost: number
|
||||
theme: ToolTheme
|
||||
}
|
||||
|
||||
export default function ModelUsageCard({ daily, totalCost, theme }: ModelUsageCardProps) {
|
||||
const modelUsageData = buildModelUsageData(daily)
|
||||
const palette = theme.chart.pie
|
||||
return (
|
||||
<section className="p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300 col-span-2 lg:col-span-1">
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-200">Model Usage Distribution</h2>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={modelUsageData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
fill="#8884d8"
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{modelUsageData.map((_entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={palette[index % palette.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151', borderRadius: '8px' }}
|
||||
formatter={(value: number, _name, props) => {
|
||||
const percentage = props?.payload?.percentage ?? 0
|
||||
return [`${formatCurrency(Number(value))} · ${percentage.toFixed(1)}%`, 'Cost']
|
||||
}}
|
||||
labelStyle={{ color: '#fff' }}
|
||||
itemStyle={{ color: '#fff' }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-col justify-center space-y-3">
|
||||
{modelUsageData.map((model, index) => {
|
||||
const percentage = ((model.value / Math.max(totalCost, 1)) * 100).toFixed(1)
|
||||
return (
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: palette[index % palette.length] }}
|
||||
/>
|
||||
<span className="text-gray-300 font-medium text-xs">{model.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-gray-400 text-sm">{percentage}%</span>
|
||||
<span className="text-gray-200 font-semibold">${model.value.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="pt-3 mt-3 border-t border-gray-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-400">Total Models Used</span>
|
||||
<span className="text-gray-200 font-bold">{modelUsageData.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
<span className="text-gray-400">Most Used</span>
|
||||
<span className="text-gray-200 font-bold text-xs">
|
||||
{modelUsageData[0]?.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
72
app/ai/usage/components/PageHeader.tsx
Normal file
72
app/ai/usage/components/PageHeader.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { SiClaude, SiOpenai } from 'react-icons/si'
|
||||
import { toolThemes, type ToolTheme, type ProviderId } from '@/app/ai/theme'
|
||||
|
||||
interface PageHeaderProps {
|
||||
selectedProvider?: ProviderId
|
||||
theme: ToolTheme
|
||||
}
|
||||
|
||||
export default function PageHeader({ selectedProvider = 'all', theme }: PageHeaderProps) {
|
||||
const iconSize = 60
|
||||
|
||||
const renderIcons = (): React.JSX.Element => {
|
||||
if (selectedProvider === 'claudeCode') {
|
||||
return <SiClaude size={iconSize} style={{ color: theme.accent }} />
|
||||
} else if (selectedProvider === 'codex') {
|
||||
return (
|
||||
<SiOpenai
|
||||
size={iconSize}
|
||||
style={{ color: theme.accent }}
|
||||
className="drop-shadow-[0_0_12px_rgba(255,255,255,0.25)]"
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<div className="flex gap-4 justify-center">
|
||||
<SiClaude size={iconSize} style={{ color: toolThemes.claudeCode.accent }} />
|
||||
<SiOpenai
|
||||
size={iconSize}
|
||||
style={{ color: toolThemes.codex.accent }}
|
||||
className="drop-shadow-[0_0_12px_rgba(255,255,255,0.25)]"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const getTitle = (): string => {
|
||||
if (selectedProvider === 'claudeCode') return 'Claude Code Usage'
|
||||
if (selectedProvider === 'codex') return 'Codex Usage'
|
||||
return 'AI Usage'
|
||||
}
|
||||
|
||||
const getSubtitle = (): string => {
|
||||
if (selectedProvider === 'claudeCode') return 'Track my Claude Code usage'
|
||||
if (selectedProvider === 'codex') return 'Track my Codex usage'
|
||||
return 'Track my AI usage across providers'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="container mx-auto px-4 relative">
|
||||
<Link
|
||||
href="/ai"
|
||||
className="absolute top-5 left-2 text-gray-400 hover:text-gray-200 hover:underline transition-colors duration-200 px-2 py-1 text-sm sm:text-base z-10"
|
||||
>
|
||||
← Back to AI
|
||||
</Link>
|
||||
<div className="py-12 text-center">
|
||||
<div className="flex justify-center mb-6">
|
||||
{renderIcons()}
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold mb-2 text-gray-100 glow">{getTitle()}</h1>
|
||||
<p className="text-gray-400">{getSubtitle()}</p>
|
||||
<div className="mx-auto mt-6 h-1 w-16 rounded-full" style={{ backgroundColor: theme.accent }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
71
app/ai/usage/components/ProviderFilter.tsx
Normal file
71
app/ai/usage/components/ProviderFilter.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"use client"
|
||||
|
||||
import { SiClaude, SiOpenai } from 'react-icons/si'
|
||||
import { toolThemes, type ToolTheme } from '@/app/ai/theme'
|
||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl'
|
||||
|
||||
type ProviderOptionId = 'all' | 'claudeCode' | 'codex'
|
||||
|
||||
interface ProviderFilterProps {
|
||||
selectedProvider: ProviderOptionId
|
||||
onProviderChange: (provider: ProviderOptionId) => void
|
||||
hasClaudeCode: boolean
|
||||
hasCodex: boolean
|
||||
theme: ToolTheme
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function ProviderFilter({
|
||||
selectedProvider,
|
||||
onProviderChange,
|
||||
hasClaudeCode,
|
||||
hasCodex,
|
||||
theme,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
className,
|
||||
}: ProviderFilterProps) {
|
||||
const providers: Array<SegmentedOption<ProviderOptionId> & { available: boolean }> = [
|
||||
{
|
||||
id: 'all',
|
||||
label: 'All Tools',
|
||||
icon: null,
|
||||
available: hasClaudeCode || hasCodex,
|
||||
accentColor: toolThemes.all.accent,
|
||||
},
|
||||
{
|
||||
id: 'claudeCode',
|
||||
label: 'Claude Code',
|
||||
icon: <SiClaude />,
|
||||
available: hasClaudeCode,
|
||||
accentColor: toolThemes.claudeCode.accent,
|
||||
},
|
||||
{
|
||||
id: 'codex',
|
||||
label: 'Codex',
|
||||
icon: <SiOpenai />,
|
||||
available: hasCodex,
|
||||
accentColor: toolThemes.codex.accent,
|
||||
}
|
||||
]
|
||||
|
||||
const segmentedOptions: SegmentedOption<ProviderOptionId>[] = providers.map(provider => ({
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
icon: provider.icon,
|
||||
accentColor: provider.accentColor ?? theme.accent,
|
||||
disabled: !provider.available,
|
||||
}))
|
||||
|
||||
return (
|
||||
<SegmentedControl
|
||||
options={segmentedOptions}
|
||||
value={selectedProvider}
|
||||
onChange={onProviderChange}
|
||||
disabled={disabled || loading}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
55
app/ai/usage/components/RecentSessions.tsx
Normal file
55
app/ai/usage/components/RecentSessions.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"use client"
|
||||
|
||||
import { DailyData } from '@/lib/types'
|
||||
import { getModelLabel } from './utils'
|
||||
import type { ToolTheme } from '@/app/ai/theme'
|
||||
|
||||
interface RecentSessionsProps {
|
||||
daily: DailyData[]
|
||||
theme: ToolTheme
|
||||
}
|
||||
|
||||
export default function RecentSessions({ daily, theme }: RecentSessionsProps) {
|
||||
const sessions = daily.filter(day => day.totalTokens > 0 || day.totalCost > 0)
|
||||
const rows = sessions.slice(-5).reverse()
|
||||
|
||||
return (
|
||||
<section className="p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-200">Recent Sessions</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-700">
|
||||
<th className="py-2 px-4 text-gray-400">Date</th>
|
||||
<th className="py-2 px-4 text-gray-400">Models Used</th>
|
||||
<th className="py-2 px-4 text-gray-400">Total Tokens</th>
|
||||
<th className="py-2 px-4 text-gray-400">Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="py-4 px-4 text-center text-gray-500">
|
||||
No sessions in this range.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((day, index) => (
|
||||
<tr key={index} className="border-b border-gray-800 hover:bg-gray-800/50">
|
||||
<td className="py-2 px-4 text-gray-300">{new Date(day.date + 'T00:00:00').toLocaleDateString()}</td>
|
||||
<td className="py-2 px-4 text-gray-300">
|
||||
{day.modelsUsed.map(getModelLabel).join(', ')}
|
||||
</td>
|
||||
<td className="py-2 px-4 text-gray-300">{(day.totalTokens / 1000000).toFixed(2)}M</td>
|
||||
<td className="py-2 px-4 font-semibold" style={{ color: theme.emphasis.cost }}>
|
||||
${day.totalCost.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
71
app/ai/usage/components/SegmentedControl.tsx
Normal file
71
app/ai/usage/components/SegmentedControl.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"use client"
|
||||
|
||||
import { type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface SegmentedOption<T extends string> {
|
||||
id: T
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
disabled?: boolean
|
||||
accentColor?: string
|
||||
}
|
||||
|
||||
interface SegmentedControlProps<T extends string> {
|
||||
options: SegmentedOption<T>[]
|
||||
value: T
|
||||
onChange?: (value: T) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SegmentedControl<T extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
className,
|
||||
}: SegmentedControlProps<T>) {
|
||||
return (
|
||||
<div className={cn('inline-flex rounded-xl border border-gray-800 bg-gray-900/60 p-1', className)}>
|
||||
{options.map((option, index) => {
|
||||
const isSelected = option.id === value
|
||||
const isDisabled = disabled || option.disabled
|
||||
const accent = option.accentColor ?? '#f9fafb'
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
aria-pressed={isSelected}
|
||||
disabled={isDisabled}
|
||||
onClick={() => {
|
||||
if (!isDisabled && option.id !== value) onChange?.(option.id)
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200',
|
||||
isSelected && 'bg-gray-800 text-gray-100',
|
||||
!isSelected && !isDisabled && 'text-gray-400 hover:text-gray-200 hover:bg-gray-800/50',
|
||||
isDisabled && 'text-gray-600 cursor-not-allowed opacity-50',
|
||||
index > 0 && 'ml-1'
|
||||
)}
|
||||
style={isSelected ? { boxShadow: `0 0 0 1px ${accent}`, color: accent } : undefined}
|
||||
>
|
||||
{option.icon && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex items-center"
|
||||
style={{
|
||||
color: isSelected ? accent : isDisabled ? '#4b5563' : '#9ca3af',
|
||||
}}
|
||||
>
|
||||
{option.icon}
|
||||
</span>
|
||||
)}
|
||||
{option.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
app/ai/usage/components/StatsGrid.tsx
Normal file
48
app/ai/usage/components/StatsGrid.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"use client"
|
||||
|
||||
import { Totals, DailyData } from '@/lib/types/ai'
|
||||
import { formatStreakCompact, computeStreak } from './utils'
|
||||
import type { ToolTheme } from '@/app/ai/theme'
|
||||
import { surfaces } from '@/lib/theme'
|
||||
|
||||
interface StatsGridProps {
|
||||
totals: Totals
|
||||
daily: DailyData[]
|
||||
theme: ToolTheme
|
||||
}
|
||||
|
||||
export default function StatsGrid({ totals, daily, theme }: StatsGridProps) {
|
||||
const activeDays = daily.filter(day => day.totalTokens > 0 || day.totalCost > 0)
|
||||
const streak = computeStreak(activeDays)
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 px-4">
|
||||
<div className={surfaces.card.ai}>
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-2">Total Cost</h3>
|
||||
<p className="text-3xl font-bold" style={{ color: theme.emphasis.cost }}>
|
||||
${totals.totalCost.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div className={surfaces.card.ai}>
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-2">Total Tokens</h3>
|
||||
<p className="text-3xl font-bold" style={{ color: theme.emphasis.cost }}>
|
||||
{(totals.totalTokens / 1000000).toFixed(1)}M
|
||||
</p>
|
||||
</div>
|
||||
<div className={surfaces.card.ai}>
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-2">Days Active</h3>
|
||||
<p className="text-3xl font-bold flex items-center" style={{ color: theme.emphasis.cost }}>
|
||||
{activeDays.length}
|
||||
<span className="ml-3 text-xs font-semibold text-gray-300 bg-gray-800 px-2 py-0.5 rounded-full">
|
||||
🔥 {formatStreakCompact(streak)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className={surfaces.card.ai}>
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-2">Avg Daily Cost</h3>
|
||||
<p className="text-3xl font-bold" style={{ color: theme.emphasis.cost }}>
|
||||
${(totals.totalCost / Math.max(daily.length, 1)).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
app/ai/usage/components/TimeRangeFilter.tsx
Normal file
47
app/ai/usage/components/TimeRangeFilter.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"use client"
|
||||
|
||||
import type { ToolTheme } from '@/app/ai/theme'
|
||||
import type { TimeRangeKey } from '@/lib/types'
|
||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl'
|
||||
|
||||
const TIME_RANGE_OPTIONS = [
|
||||
{ id: '7d', label: '7d' },
|
||||
{ id: '1m', label: '1mo' },
|
||||
{ id: '3m', label: '3mo' },
|
||||
{ id: '6m', label: '6mo' },
|
||||
{ id: '1y', label: '1y' },
|
||||
{ id: 'all', label: 'All' },
|
||||
] as const satisfies ReadonlyArray<SegmentedOption<TimeRangeKey>>
|
||||
|
||||
type TimeRangeOptionId = (typeof TIME_RANGE_OPTIONS)[number]['id']
|
||||
|
||||
interface TimeRangeFilterProps {
|
||||
value: TimeRangeKey
|
||||
onChange: (value: TimeRangeKey) => void
|
||||
theme: ToolTheme
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function TimeRangeFilter({
|
||||
value,
|
||||
onChange,
|
||||
theme,
|
||||
disabled = false,
|
||||
className,
|
||||
}: TimeRangeFilterProps) {
|
||||
const options = TIME_RANGE_OPTIONS.map<SegmentedOption<TimeRangeOptionId>>(option => ({
|
||||
...option,
|
||||
accentColor: theme.accent,
|
||||
}))
|
||||
|
||||
return (
|
||||
<SegmentedControl
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
75
app/ai/usage/components/TokenComposition.tsx
Normal file
75
app/ai/usage/components/TokenComposition.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"use client"
|
||||
|
||||
import { ResponsiveContainer, ComposedChart, CartesianGrid, XAxis, YAxis, Tooltip, Legend, Bar, Line } from 'recharts'
|
||||
import { DailyData, TimeRangeKey } from '@/lib/types'
|
||||
import { buildTokenCompositionData, formatAxisLabel, formatTooltipDate } from './utils'
|
||||
import type { ToolTheme } from '@/app/ai/theme'
|
||||
|
||||
const formatWithUnit = (value: number): string => {
|
||||
if (value >= 1000) {
|
||||
return `${(value / 1000).toFixed(1)}M`
|
||||
} else if (value >= 1) {
|
||||
return `${value.toFixed(value >= 100 ? 0 : 1)}K`
|
||||
} else {
|
||||
return value.toFixed(2)
|
||||
}
|
||||
}
|
||||
|
||||
const formatTooltipValue = (value: number, dataKey: string | undefined): string => {
|
||||
if (dataKey === 'cacheTokens') {
|
||||
if (value >= 1000) {
|
||||
return `${(value / 1000).toFixed(2)}B tokens`
|
||||
} else if (value >= 1) {
|
||||
return `${value.toFixed(2)}M tokens`
|
||||
} else {
|
||||
return `${(value * 1000).toFixed(0)}K tokens`
|
||||
}
|
||||
} else {
|
||||
if (value >= 1000) {
|
||||
return `${(value / 1000).toFixed(2)}M tokens`
|
||||
} else if (value >= 1) {
|
||||
return `${value.toFixed(1)}K tokens`
|
||||
} else {
|
||||
return `${(value * 1000).toFixed(0)} tokens`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface TokenCompositionProps {
|
||||
daily: DailyData[]
|
||||
theme: ToolTheme
|
||||
timeRange: TimeRangeKey
|
||||
}
|
||||
|
||||
export default function TokenComposition({ daily, theme, timeRange }: TokenCompositionProps) {
|
||||
const tokenCompositionData = buildTokenCompositionData(daily)
|
||||
return (
|
||||
<section className="p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300 sm:col-span-2">
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-200">Token Composition</h2>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ComposedChart data={tokenCompositionData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="#9ca3af"
|
||||
tickFormatter={(value) => formatAxisLabel(String(value), timeRange)}
|
||||
interval={timeRange === '7d' ? 0 : undefined}
|
||||
tickMargin={12}
|
||||
minTickGap={12}
|
||||
/>
|
||||
<YAxis stroke="#9ca3af" tickFormatter={formatWithUnit} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151' }}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
formatter={(value: number, _name: string, props: any) => formatTooltipValue(value, props?.dataKey)}
|
||||
labelFormatter={(value: string) => formatTooltipDate(String(value))}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar dataKey="inputTokens" stackId="a" fill={theme.chart.barPrimary} name="Input" />
|
||||
<Bar dataKey="outputTokens" stackId="a" fill={theme.chart.barSecondary} name="Output" />
|
||||
<Line type="monotone" dataKey="cacheTokens" stroke={theme.chart.line} name="Cache" strokeWidth={2} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
60
app/ai/usage/components/TokenType.tsx
Normal file
60
app/ai/usage/components/TokenType.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
Bar,
|
||||
} from 'recharts'
|
||||
import type { TooltipProps } from 'recharts'
|
||||
import type { Payload, ValueType, NameType } from 'recharts/types/component/DefaultTooltipContent'
|
||||
import type { CCData } from '@/lib/types'
|
||||
import { buildTokenTypeData } from './utils'
|
||||
import type { ToolTheme } from '@/app/ai/theme'
|
||||
|
||||
type TokenTooltipProps = TooltipProps<ValueType, NameType> & {
|
||||
payload?: Payload<ValueType, NameType>[]
|
||||
}
|
||||
|
||||
interface TokenTypeProps {
|
||||
totals: CCData['totals']
|
||||
theme: ToolTheme
|
||||
}
|
||||
|
||||
export default function TokenType({ totals, theme }: TokenTypeProps) {
|
||||
const tokenTypeData = buildTokenTypeData(totals)
|
||||
const renderTooltip = ({ active, payload }: TokenTooltipProps) => {
|
||||
if (!active || !payload?.length) return null
|
||||
|
||||
const [firstEntry] = payload
|
||||
const dataPoint = (firstEntry?.payload ?? null) as (typeof tokenTypeData)[number] | null
|
||||
const rawValue = Number(firstEntry?.value ?? 0)
|
||||
const formattedValue = `${(rawValue / 1_000_000).toFixed(2)}M tokens`
|
||||
const percentage = dataPoint?.percentage ?? 0
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-gray-700 bg-gray-900/80 px-3 py-2 text-sm text-gray-100">
|
||||
<p className="font-medium">{dataPoint?.name ?? firstEntry?.name ?? 'Token Type'}</p>
|
||||
<p className="text-xs text-gray-400">{percentage.toFixed(1)}% · {formattedValue}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300 col-span-2 lg:col-span-1">
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-200">Token Type</h2>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={tokenTypeData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||
<XAxis dataKey="name" stroke="#9ca3af" />
|
||||
<YAxis stroke="#9ca3af" tickFormatter={(value) => `${(value / 1000000).toFixed(0)}M`} domain={[0, 'auto']} />
|
||||
<Tooltip content={renderTooltip} cursor={{ fill: 'rgba(31, 41, 55, 0.3)' }} />
|
||||
<Bar dataKey="value" fill={theme.chart.barSecondary} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
42
app/ai/usage/components/types.ts
Normal file
42
app/ai/usage/components/types.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
export interface ModelBreakdown {
|
||||
modelName: string
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheCreationTokens: number
|
||||
cacheReadTokens: number
|
||||
cost: number
|
||||
}
|
||||
|
||||
export interface DailyData {
|
||||
date: string
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheCreationTokens: number
|
||||
cacheReadTokens: number
|
||||
totalTokens: number
|
||||
totalCost: number
|
||||
modelsUsed: string[]
|
||||
modelBreakdowns: ModelBreakdown[]
|
||||
}
|
||||
|
||||
export interface CCData {
|
||||
daily: DailyData[]
|
||||
totals: {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheCreationTokens: number
|
||||
cacheReadTokens: number
|
||||
totalCost: number
|
||||
totalTokens: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface HeatmapDay {
|
||||
date: string
|
||||
value: number
|
||||
tokens: number
|
||||
cost: number
|
||||
day: number
|
||||
formattedDate: string
|
||||
}
|
||||
|
||||
119
app/ai/usage/components/utils.ts
Normal file
119
app/ai/usage/components/utils.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { CCData, DailyData, HeatmapDay, TimeRangeKey } from '@/lib/types'
|
||||
import { AIService } from '@/lib/services'
|
||||
import type { HeatmapPalette } from '@/app/ai/theme'
|
||||
|
||||
export const getModelLabel = (modelName: string): string => {
|
||||
return AIService.getModelLabel(modelName)
|
||||
}
|
||||
|
||||
export const formatCurrency = (value: number) => `$${value.toFixed(2)}`
|
||||
export const formatTokens = (value: number) => `${value.toFixed(1)}M`
|
||||
|
||||
export const computeStreak = (daily: DailyData[]): number => {
|
||||
return AIService.computeStreak(daily)
|
||||
}
|
||||
|
||||
export const formatStreakCompact = (days: number) => {
|
||||
return AIService.formatStreakCompact(days)
|
||||
}
|
||||
|
||||
export const computeFilledDailyRange = (daily: DailyData[]): DailyData[] => {
|
||||
return AIService.computeFilledDailyRange(daily)
|
||||
}
|
||||
|
||||
export const buildDailyTrendData = (daily: DailyData[]) => {
|
||||
const trendData = AIService.buildDailyTrendData(daily)
|
||||
return trendData.map(day => ({
|
||||
date: day.date,
|
||||
cost: day.totalCost,
|
||||
tokens: day.totalTokens / 1000000,
|
||||
inputTokens: day.inputTokensNormalized,
|
||||
outputTokens: day.outputTokensNormalized,
|
||||
cacheTokens: day.cacheTokensNormalized,
|
||||
costTrend: day.costTrend,
|
||||
tokensTrend: day.tokensTrend,
|
||||
}))
|
||||
}
|
||||
|
||||
export const prepareHeatmapData = (daily: DailyData[]): (HeatmapDay | null)[][] => {
|
||||
return AIService.prepareHeatmapData(daily)
|
||||
}
|
||||
|
||||
export const getHeatmapColor = (maxCost: number, value: number, palette: HeatmapPalette) => {
|
||||
return AIService.getHeatmapColor(maxCost, value, palette)
|
||||
}
|
||||
|
||||
export const buildModelUsageData = (daily: DailyData[]) => {
|
||||
return AIService.buildModelUsageData(daily)
|
||||
}
|
||||
|
||||
export const buildTokenTypeData = (totals: CCData['totals']) => {
|
||||
return AIService.buildTokenTypeData(totals)
|
||||
}
|
||||
|
||||
export const buildTokenCompositionData = (daily: DailyData[]) => {
|
||||
return AIService.buildTokenCompositionData(daily)
|
||||
}
|
||||
|
||||
export const filterDailyByRange = (
|
||||
daily: DailyData[],
|
||||
range: TimeRangeKey,
|
||||
options?: { endDate?: Date }
|
||||
) => {
|
||||
return AIService.filterDailyByRange(daily, range, options)
|
||||
}
|
||||
|
||||
export const computeTotalsFromDaily = (daily: DailyData[]) => {
|
||||
return AIService.computeTotalsFromDaily(daily)
|
||||
}
|
||||
|
||||
const toUtcDate = (isoDate: string) => new Date(`${isoDate}T00:00:00Z`)
|
||||
|
||||
export const formatTooltipDate = (isoDate: string): string => {
|
||||
const date = toUtcDate(isoDate)
|
||||
if (Number.isNaN(date.getTime())) return isoDate
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
}
|
||||
|
||||
export const formatAxisLabel = (isoDate: string, range: TimeRangeKey): string => {
|
||||
const date = toUtcDate(isoDate)
|
||||
if (Number.isNaN(date.getTime())) return isoDate
|
||||
|
||||
switch (range) {
|
||||
case '7d':
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
case '1m':
|
||||
case '3m':
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
case '6m':
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
case '1y':
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
default:
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
}
|
||||
}
|
||||
200
app/ai/usage/page.tsx
Normal file
200
app/ai/usage/page.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
"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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue