feat (v1.0.0): initial refactor and redesign

This commit is contained in:
Aidan 2025-10-09 04:12:05 -04:00
parent 3058aa1ab4
commit fe9b50b30e
134 changed files with 17792 additions and 3670 deletions

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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}
/>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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}
/>
)
}

View 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>
)
}

View 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>
)
}

View 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
}

View 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
View 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>
)
}