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

@ -1,177 +0,0 @@
"use client"
import PageHeader from './PageHeader'
export default function LoadingSkeleton() {
return (
<main className="w-full relative">
<PageHeader />
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 px-4">
<div className="p-6 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<h3 className="text-sm font-medium text-gray-400 mb-2">Total Cost</h3>
<div className="h-9 w-32 bg-gray-800 rounded animate-pulse" />
</div>
<div className="p-6 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<h3 className="text-sm font-medium text-gray-400 mb-2">Total Tokens</h3>
<div className="h-9 w-32 bg-gray-800 rounded animate-pulse" />
</div>
<div className="p-6 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<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 bg-gray-800 rounded animate-pulse" />
<div className="ml-3 h-5 w-12 bg-gray-800 rounded-full animate-pulse" />
</div>
</div>
<div className="p-6 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<h3 className="text-sm font-medium text-gray-400 mb-2">Avg Daily Cost</h3>
<div className="h-9 w-32 bg-gray-800 rounded animate-pulse" />
</div>
</div>
<div className="p-4 pb-0">
<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">Heatmap</span>
<div className="h-6 w-11 bg-gray-700 rounded-full animate-pulse" />
</div>
</div>
<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">
<div className="flex gap-16">
{['Jan', 'Mar', 'May', 'Jul', 'Sep', 'Nov'].map((month) => (
<div key={month} className="w-12 h-3 bg-gray-800 rounded animate-pulse" />
))}
</div>
</div>
<div className="flex gap-1">
{(() => {
const today = new Date()
const startOfYear = new Date(Date.UTC(today.getUTCFullYear(), 0, 1))
const endDate = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()))
const firstDay = startOfYear.getUTCDay()
const startDate = new Date(startOfYear)
startDate.setUTCDate(startDate.getUTCDate() - firstDay)
const msPerWeek = 7 * 24 * 60 * 60 * 1000
const weekCount = Math.ceil((endDate.getTime() - startDate.getTime()) / msPerWeek)
return [...Array(weekCount)].map((_, weekIndex) => (
<div key={weekIndex} className="flex flex-col gap-1">
{[...Array(7)].map((_, dayIndex) => (
<div key={dayIndex} className="w-4 h-4 bg-gray-800 rounded-sm animate-pulse" />
))}
</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">
{[...Array(5)].map((_, i) => (
<div key={i} className="w-3 h-3 bg-gray-800 rounded-sm animate-pulse" />
))}
</div>
<span>More</span>
</div>
</div>
</div>
</section>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4">
<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">
<div className="h-[300px] bg-gray-800 rounded animate-pulse" />
<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 bg-gray-800 rounded-full animate-pulse" />
<div className="h-4 w-20 bg-gray-800 rounded animate-pulse" />
</div>
<div className="flex items-center gap-3">
<div className="h-4 w-10 bg-gray-800 rounded animate-pulse" />
<div className="h-4 w-16 bg-gray-800 rounded animate-pulse" />
</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 bg-gray-800 rounded animate-pulse" />
</div>
<div className="flex justify-between items-center mt-2">
<span className="text-gray-400">Most Used</span>
<div className="h-4 w-20 bg-gray-800 rounded animate-pulse" />
</div>
</div>
</div>
</div>
</section>
<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 Breakdown</h2>
<div className="h-[300px] bg-gray-800 rounded animate-pulse" />
</section>
<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>
<div className="h-[300px] bg-gray-800 rounded animate-pulse" />
</section>
</div>
<div className="px-4 pb-4">
<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>
{[...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 bg-gray-800 rounded animate-pulse" />
</td>
<td className="py-2 px-4">
<div className="h-5 w-96 bg-gray-800 rounded animate-pulse" />
</td>
<td className="py-2 px-4">
<div className="h-5 w-16 bg-gray-800 rounded animate-pulse" />
</td>
<td className="py-2 px-4">
<div className="h-5 w-20 bg-gray-800 rounded animate-pulse" />
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
</main>
)
}

View file

@ -1,26 +0,0 @@
"use client"
import Link from 'next/link'
import { SiClaude } from 'react-icons/si'
export default function PageHeader() {
return (
<>
<Link
href="/ai"
className="absolute top-4 left-4 text-gray-400 hover:text-gray-200 hover:underline transition-colors duration-200 z-10 px-2 py-1 text-sm sm:text-base"
>
Back to AI
</Link>
<div className="my-12 text-center">
<div className="flex justify-center mb-6">
<SiClaude size={60} />
</div>
<h1 className="text-4xl font-bold mb-2 text-gray-100 glow">Claude Code Usage</h1>
<p className="text-gray-400">How much I use Claude Code!</p>
</div>
</>
)
}

View file

@ -1,37 +0,0 @@
"use client"
import { DailyData } from './types'
import { getModelLabel } from './utils'
export default function RecentSessions({ daily }: { daily: DailyData[] }) {
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>
{daily.slice(-5).reverse().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 text-[#c15f3c] font-semibold">${day.totalCost.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)
}

View file

@ -1,34 +0,0 @@
"use client"
import { CCData, DailyData } from './types'
import { formatStreakCompact, computeStreak } from './utils'
export default function StatsGrid({ totals, daily }: { totals: CCData['totals']; daily: DailyData[] }) {
const streak = computeStreak(daily)
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 px-4">
<div className="p-6 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<h3 className="text-sm font-medium text-gray-400 mb-2">Total Cost</h3>
<p className="text-3xl font-bold text-[#c15f3c]">${totals.totalCost.toFixed(2)}</p>
</div>
<div className="p-6 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<h3 className="text-sm font-medium text-gray-400 mb-2">Total Tokens</h3>
<p className="text-3xl font-bold text-[#c15f3c]">{(totals.totalTokens / 1000000).toFixed(1)}M</p>
</div>
<div className="p-6 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<h3 className="text-sm font-medium text-gray-400 mb-2">Days Active</h3>
<p className="text-3xl font-bold text-[#c15f3c] flex items-center">
{daily.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="p-6 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<h3 className="text-sm font-medium text-gray-400 mb-2">Avg Daily Cost</h3>
<p className="text-3xl font-bold text-[#c15f3c]">${(totals.totalCost / Math.max(daily.length, 1)).toFixed(2)}</p>
</div>
</div>
)
}

View file

@ -1,30 +0,0 @@
"use client"
import { ResponsiveContainer, ComposedChart, CartesianGrid, XAxis, YAxis, Tooltip, Legend, Bar, Line } from 'recharts'
import { DailyData } from './types'
import { buildDailyTrendData } from './utils'
export default function TokenComposition({ daily }: { daily: DailyData[] }) {
const dailyTrendData = buildDailyTrendData(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={dailyTrendData}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis dataKey="date" stroke="#9ca3af" />
<YAxis stroke="#9ca3af" tickFormatter={(value) => `${value}K`} />
<Tooltip
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151' }}
formatter={(value: number) => `${value.toFixed(1)}K tokens`}
/>
<Legend />
<Bar dataKey="inputTokens" stackId="a" fill="#c15f3c" name="Input (K)" />
<Bar dataKey="outputTokens" stackId="a" fill="#b1ada1" name="Output (K)" />
<Line type="monotone" dataKey="cacheTokens" stroke="#f4f3ee" name="Cache (M)" strokeWidth={2} />
</ComposedChart>
</ResponsiveContainer>
</section>
)
}

View file

@ -1,27 +0,0 @@
"use client"
import { ResponsiveContainer, BarChart, CartesianGrid, XAxis, YAxis, Tooltip, Bar } from 'recharts'
import { CCData } from './types'
import { buildTokenTypeData } from './utils'
export default function TokenTypeBreakdown({ totals }: { totals: CCData['totals'] }) {
const tokenTypeData = buildTokenTypeData(totals)
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 Breakdown</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`} />
<Tooltip
contentStyle={{ backgroundColor: 'rgba(31, 41, 55)', border: '1px solid #374151' }}
formatter={(value: number) => `${(value / 1000000).toFixed(2)}M tokens`}
/>
<Bar dataKey="value" fill="#b1ada1" />
</BarChart>
</ResponsiveContainer>
</section>
)
}

View file

@ -1,191 +0,0 @@
import { CCData, DailyData, HeatmapDay } from './types'
export const COLORS = ['#c15f3c', '#b1ada1', '#f4f3ee', '#c15f3c', '#b1ada1', '#f4f3ee']
export const MODEL_LABELS: Record<string, string> = {
'claude-sonnet-4-20250514': 'Sonnet 4',
'claude-opus-4-1-20250805': 'Opus 4.1',
}
export const getModelLabel = (modelName: string): string => {
return MODEL_LABELS[modelName] || modelName
}
export const formatCurrency = (value: number) => `$${value.toFixed(2)}`
export const formatTokens = (value: number) => `${value.toFixed(1)}M`
export const computeStreak = (daily: DailyData[]): number => {
if (!daily.length) return 0
const datesSet = new Set(daily.map(d => d.date))
const latest = daily
.map(d => new Date(d.date + 'T00:00:00Z'))
.reduce((a, b) => (a > b ? a : b))
const toKey = (d: Date) => {
const y = d.getUTCFullYear()
const m = (d.getUTCMonth() + 1).toString().padStart(2, '0')
const day = d.getUTCDate().toString().padStart(2, '0')
return `${y}-${m}-${day}`
}
let count = 0
for (
let d = new Date(latest.getTime());
;
d = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - 1))
) {
const key = toKey(d)
if (datesSet.has(key)) count++
else break
}
return count
}
export const formatStreakCompact = (days: number) => {
if (days >= 365) return `${Math.floor(days / 365)}y`
if (days >= 30) return `${Math.floor(days / 30)}mo`
if (days >= 7) return `${Math.floor(days / 7)}w`
return `${days}d`
}
export const computeFilledDailyRange = (daily: DailyData[]): DailyData[] => {
if (!daily.length) return []
const dates = daily.map(d => new Date(d.date + 'T00:00:00Z'))
const start = dates.reduce((a, b) => (a < b ? a : b))
const end = dates.reduce((a, b) => (a > b ? a : b))
const byDate = new Map<string, DailyData>(
daily.map(d => [d.date, d] as const)
)
const result: DailyData[] = []
for (
let d = new Date(start.getTime());
d <= end;
d = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1))
) {
const y = d.getUTCFullYear()
const m = (d.getUTCMonth() + 1).toString().padStart(2, '0')
const day = d.getUTCDate().toString().padStart(2, '0')
const key = `${y}-${m}-${day}`
if (byDate.has(key)) {
result.push(byDate.get(key)!)
} else {
result.push({
date: key,
inputTokens: 0,
outputTokens: 0,
cacheCreationTokens: 0,
cacheReadTokens: 0,
totalTokens: 0,
totalCost: 0,
modelsUsed: [],
modelBreakdowns: [],
})
}
}
return result
}
export const buildDailyTrendData = (daily: DailyData[]) => {
const filled = computeFilledDailyRange(daily)
return filled.map(day => ({
date: new Date(day.date + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
cost: day.totalCost,
tokens: day.totalTokens / 1000000,
inputTokens: day.inputTokens / 1000,
outputTokens: day.outputTokens / 1000,
cacheTokens: (day.cacheCreationTokens + day.cacheReadTokens) / 1000000,
}))
}
export const prepareHeatmapData = (daily: DailyData[]): (HeatmapDay | null)[][] => {
const dayMap = new Map<string, DailyData>()
daily.forEach(day => {
dayMap.set(day.date, day)
})
const today = new Date()
const startOfYear = new Date(Date.UTC(today.getUTCFullYear(), 0, 1))
const endDate = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()))
const weeks: (HeatmapDay | null)[][] = []
let currentWeek: (HeatmapDay | null)[] = []
const firstDay = startOfYear.getUTCDay()
const startDate = new Date(startOfYear)
startDate.setUTCDate(startDate.getUTCDate() - firstDay)
for (
let d = new Date(startDate);
d <= endDate;
d = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1))
) {
if (d < startOfYear) {
currentWeek.push(null)
if (d.getUTCDay() === 6) {
weeks.push(currentWeek)
currentWeek = []
}
continue
}
const dateStr = `${d.getUTCFullYear()}-${(d.getUTCMonth() + 1).toString().padStart(2, '0')}-${d.getUTCDate().toString().padStart(2, '0')}`
const dayData = dayMap.get(dateStr)
currentWeek.push({
date: dateStr,
value: dayData ? dayData.totalCost : 0,
tokens: dayData ? dayData.totalTokens : 0,
cost: dayData ? dayData.totalCost : 0,
day: d.getUTCDay(),
formattedDate: d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })
})
if (d.getUTCDay() === 6 || d.getTime() === endDate.getTime()) {
while (currentWeek.length < 7) {
currentWeek.push(null)
}
weeks.push(currentWeek)
currentWeek = []
}
}
return weeks
}
export const getHeatmapColor = (maxCost: number, value: number) => {
if (value === 0) return '#1f2937'
const denominator = maxCost === 0 ? 1 : maxCost
const intensity = value / denominator
if (intensity < 0.25) return '#4a3328'
if (intensity < 0.5) return '#6b4530'
if (intensity < 0.75) return '#8d5738'
return '#c15f3c'
}
export const buildModelUsageData = (daily: DailyData[]) => {
const raw = daily.reduce((acc, day) => {
day.modelBreakdowns.forEach(model => {
const label = getModelLabel(model.modelName)
const existing = acc.find(m => m.name === label)
if (existing) {
existing.value += model.cost
} else {
acc.push({ name: label, value: model.cost })
}
})
return acc
}, [] as { name: string; value: number }[])
return raw.sort((a, b) => b.value - a.value)
}
export const buildTokenTypeData = (totals: CCData['totals']) => ([
{ name: 'Input', value: totals.inputTokens },
{ name: 'Output', value: totals.outputTokens },
{ name: 'Cache Creation', value: totals.cacheCreationTokens },
{ name: 'Cache Read', value: totals.cacheReadTokens },
])

View file

@ -1,85 +0,0 @@
"use client"
import Header from '@/components/Header'
import Footer from '@/components/Footer'
import { useEffect, useState } from 'react'
import LoadingSkeleton from './components/LoadingSkeleton'
import PageHeader from './components/PageHeader'
import StatsGrid from './components/StatsGrid'
import Activity from './components/Activity'
import ModelUsageCard from './components/ModelUsageCard'
import TokenTypeBreakdown from './components/TokenTypeBreakdown'
import TokenComposition from './components/TokenComposition'
import RecentSessions from './components/RecentSessions'
import { CCData } from './components/types'
export default function AI() {
const [data, setData] = useState<CCData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
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)
})
}, [])
if (loading) {
return (
<div className="min-h-screen flex flex-col">
<Header />
<LoadingSkeleton />
<Footer />
</div>
)
}
if (error || !data) {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1 flex items-center justify-center">
<div className="text-red-400">Error loading data: {error}</div>
</main>
<Footer />
</div>
)
}
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="w-full relative">
<PageHeader />
<StatsGrid totals={data.totals} daily={data.daily} />
<div className="p-4 pb-0">
<Activity daily={data.daily} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4">
<ModelUsageCard daily={data.daily} totalCost={data.totals.totalCost} />
<TokenTypeBreakdown totals={data.totals} />
<TokenComposition daily={data.daily} />
</div>
<div className="px-4 pb-4">
<RecentSessions daily={data.daily} />
</div>
</main>
<Footer />
</div>
)
}

View file

@ -32,63 +32,71 @@ export default function AIStack({ tools }: AIStackProps) {
}
return (
<section className="p-4 sm:p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<div className="flex flex-row justify-between">
<h2 className="text-2xl font-semibold mb-6 text-gray-200 flex items-center gap-2">
<TbStack2 size={24} />
<section className="p-4 sm:p-6 lg:p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<div className="flex flex-col sm:flex-row sm:justify-between gap-2 mb-4 sm:mb-6">
<h2 className="text-xl sm:text-2xl font-semibold text-gray-200 flex items-center gap-2">
<TbStack2 size={20} className="sm:w-6 sm:h-6" />
My AI Stack
</h2>
<p className="text-muted-foreground">The AI tools I use as a part of my routine and workflow.</p>
<p className="text-muted-foreground text-xs sm:text-sm">The AI tools I use as a part of my routine and workflow.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
{tools.map((tool, index) => (
<div key={index} className="p-4 border border-gray-700 rounded-lg hover:border-gray-500 transition-all duration-300 flex flex-col">
<div className="flex items-start justify-between mb-3 flex-1">
<div className="flex items-center gap-3 flex-1">
{tool.icon && <tool.icon className="text-2xl text-gray-300" />}
<div key={index} className="p-3 sm:p-4 border border-gray-700 rounded-lg hover:border-gray-500 transition-all duration-300 flex flex-col">
<div className="flex items-start justify-between mb-2 sm:mb-3 flex-1">
<div className="flex items-center gap-2 sm:gap-3 flex-1 min-w-0">
{tool.icon && <tool.icon className="text-xl sm:text-2xl text-gray-300 flex-shrink-0" />}
{tool.svg && (
<div className="w-6 h-6 text-gray-300 fill-current">
<div className="w-5 h-5 sm:w-6 sm:h-6 text-gray-300 fill-current flex-shrink-0">
{tool.svg}
</div>
)}
<div className="flex-1">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-gray-200">{tool.name}</h3>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<h3 className="font-semibold text-sm sm:text-base text-gray-200 truncate">{tool.name}</h3>
{tool.price !== undefined && (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 sm:gap-2 flex-shrink-0">
{tool.discountedPrice !== undefined ? (
<>
<span className="text-gray-500 line-through">
<span className="text-xs sm:text-sm text-gray-500 line-through">
{formatPrice(tool.price)}
</span>
<span className="text-gray-200">
<span className="text-xs sm:text-sm text-gray-200">
{formatPrice(tool.discountedPrice)}
</span>
</>
) : (
<span className="text-gray-200">
<span className="text-xs sm:text-sm text-gray-200">
{formatPrice(tool.price)}
</span>
)}
</div>
)}
</div>
<p className="text-sm text-gray-400">{tool.description}</p>
<p className="text-xs sm:text-sm text-gray-400 line-clamp-2">{tool.description}</p>
</div>
</div>
</div>
<div className="flex items-center justify-between mt-auto">
<span className={`text-xs px-2 py-1 rounded-full border ${getStatusColor(tool.status)}`}>
<div className="flex items-center justify-between mt-auto pt-2 gap-2">
<span className={`text-xs px-2 py-0.5 sm:py-1 rounded-full border whitespace-nowrap ${getStatusColor(tool.status)}`}>
{getStatusLabel(tool.status)}
</span>
<span className="flex flex-row items-center gap-4">
<span className="flex flex-row items-center gap-2 sm:gap-4">
{tool.link && (
<Link href={tool.link} className="text-blue-400 hover:text-blue-300 text-sm" target="_blank" rel="noopener noreferrer">
<Link
href={tool.link}
className="text-xs sm:text-sm hover:text-blue-300 whitespace-nowrap"
target="_blank"
rel="noopener noreferrer"
>
Visit
</Link>
)}
{tool.usage && (
<Link href={tool.usage} className="text-blue-400 hover:text-blue-300 text-sm">
{(tool.usage || tool.hasUsage) && (
<Link
href={tool.usage ?? '/ai/usage'}
className="text-xs sm:text-sm hover:text-blue-300 whitespace-nowrap"
>
Usage
</Link>
)}

View file

@ -1,4 +1,5 @@
import { Brain, Star } from 'lucide-react'
import { Brain } from 'lucide-react'
import PaginatedCardList from '@/components/ui/PaginatedCardList'
import type { FavoriteModel } from '../types'
interface FavoriteModelsProps {
@ -7,36 +8,29 @@ interface FavoriteModelsProps {
export default function FavoriteModels({ models }: FavoriteModelsProps) {
return (
<section className="p-4 sm:p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<div className="flex flex-row justify-between">
<h2 className="text-2xl font-semibold mb-6 text-gray-200 flex items-center gap-2">
<Brain size={24} />
Favorite Models
</h2>
<p className="text-muted-foreground italic text-sm">Based on personal preference</p>
</div>
<div className="space-y-4">
{models.map((model, index) => (
<div key={index} className="p-4 bg-gray-800/50 rounded-lg">
<div className="flex justify-between items-start mb-2">
<div>
<h3 className="font-semibold text-gray-200">{model.name}</h3>
<p className="text-sm text-gray-400">{model.provider}</p>
</div>
<div className="flex gap-1">
{[...Array(5)].map((_, i) => (
<Star
key={i}
size={14}
className={i < model.rating ? 'fill-yellow-400 text-yellow-400' : 'text-gray-600'}
/>
))}
</div>
<PaginatedCardList
items={models}
title="Favorite Models"
icon={<Brain size={24} />}
subtitle="Based on personal preference"
itemsPerPage={5}
getItemKey={(model) => model.name}
renderItem={(model) => (
<div className="p-3 sm:p-4 bg-gray-800/50 rounded-lg">
<div className="flex justify-between items-start gap-2 mb-2">
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-sm sm:text-base text-gray-200 truncate">{model.name}</h3>
<p className="text-xs sm:text-sm text-gray-400">{model.provider}</p>
</div>
<div className="flex items-center gap-1 px-2 sm:px-3 py-0.5 sm:py-1 bg-yellow-400/10 border border-yellow-400/20 rounded-md flex-shrink-0">
<span className="text-base sm:text-lg font-bold text-yellow-400">
{model.rating.toFixed(1)}
</span>
</div>
<p className="text-sm text-gray-300">{model.review}</p>
</div>
))}
</div>
</section>
<p className="text-xs sm:text-sm text-gray-300 leading-relaxed">{model.review}</p>
</div>
)}
/>
)
}

View file

@ -1,5 +1,5 @@
import { Star } from 'lucide-react'
import { TbTool } from 'react-icons/tb'
import PaginatedCardList from '@/components/ui/PaginatedCardList'
import type { AIReview } from '../types'
interface FavoriteToolsProps {
@ -8,51 +8,44 @@ interface FavoriteToolsProps {
export default function FavoriteTools({ reviews }: FavoriteToolsProps) {
return (
<section className="p-4 sm:p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<div className="flex flex-row justify-between">
<h2 className="text-2xl font-semibold mb-6 text-gray-200 flex items-center gap-2">
<TbTool size={24} />
Favorite Tools
</h2>
<p className="text-muted-foreground italic text-sm">Based on personal preference</p>
</div>
<div className="space-y-4">
{reviews.map((review, index) => (
<div key={index} className="p-4 bg-gray-800/50 rounded-lg">
<div className="flex justify-between items-center mb-3">
<h3 className="font-semibold text-gray-200">{review.tool}</h3>
<div className="flex gap-1">
{[...Array(5)].map((_, i) => (
<Star
key={i}
size={14}
className={i < review.rating ? 'fill-yellow-400 text-yellow-400' : 'text-gray-600'}
/>
))}
</div>
<PaginatedCardList
items={reviews}
title="Favorite Tools"
icon={<TbTool size={24} />}
subtitle="Based on personal preference"
itemsPerPage={3}
getItemKey={(review) => review.tool}
renderItem={(review) => (
<div className="p-3 sm:p-4 bg-gray-800/50 rounded-lg">
<div className="flex justify-between items-center gap-2 mb-2 sm:mb-3">
<h3 className="font-semibold text-sm sm:text-base text-gray-200 truncate flex-1">{review.tool}</h3>
<div className="flex items-center gap-1 px-2 sm:px-3 py-0.5 sm:py-1 bg-yellow-400/10 border border-yellow-400/20 rounded-md flex-shrink-0">
<span className="text-base sm:text-lg font-bold text-yellow-400">
{review.rating.toFixed(1)}
</span>
</div>
<div className="grid grid-cols-2 gap-2 mb-2 text-sm">
<div>
<p className="text-green-400 font-medium mb-1">Pros:</p>
<ul className="text-gray-300 space-y-1">
{review.pros.map((pro, i) => (
<li key={i} className="text-xs"> {pro}</li>
))}
</ul>
</div>
<div>
<p className="text-red-400 font-medium mb-1">Cons:</p>
<ul className="text-gray-300 space-y-1">
{review.cons.map((con, i) => (
<li key={i} className="text-xs"> {con}</li>
))}
</ul>
</div>
</div>
<p className="text-sm text-blue-400 font-medium">{review.verdict}</p>
</div>
))}
</div>
</section>
<div className="grid grid-cols-2 gap-2 mb-2 text-xs sm:text-sm">
<div>
<p className="text-green-400 font-medium mb-1 text-xs sm:text-sm">Pros:</p>
<ul className="text-gray-300 space-y-0.5 sm:space-y-1">
{review.pros.map((pro, i) => (
<li key={i} className="text-xs leading-tight"> {pro}</li>
))}
</ul>
</div>
<div>
<p className="text-red-400 font-medium mb-1 text-xs sm:text-sm">Cons:</p>
<ul className="text-gray-300 space-y-0.5 sm:space-y-1">
{review.cons.map((con, i) => (
<li key={i} className="text-xs leading-tight"> {con}</li>
))}
</ul>
</div>
</div>
<p className="text-xs sm:text-sm text-blue-400 font-medium">{review.verdict}</p>
</div>
)}
/>
)
}

View file

@ -1,41 +1,44 @@
import { Trophy, ChevronRight } from 'lucide-react'
import { SiClaude } from 'react-icons/si'
import Link from '@/components/objects/Link'
import { surfaces, colors } from '@/lib/theme'
export default function TopPick() {
return (
<div className="px-4 mb-4">
<h2 className="text-4xl font-semibold mb-6 text-gray-200 flex items-center gap-2">
<Trophy size={32} className="text-orange-300" />
Top Pick of <i className="-ml-[1.55px]">2025</i>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-semibold mb-4 sm:mb-6 text-gray-200 flex items-center gap-2">
<Trophy size={24} className="sm:w-8 sm:h-8 text-orange-300" />
<span className="flex items-center gap-1">
Top Pick of <i className="-ml-[1.55px]">2025</i>
</span>
</h2>
<div className="p-6 sm:p-8 border-2 border-[#c15f3c] rounded-lg bg-orange-500/5">
<div className="grid md:grid-cols-2 gap-6">
<div className="flex items-center gap-4">
<SiClaude className="text-6xl text-[#c15f3c]" />
<div>
<h3 className="text-3xl font-bold text-gray-100">Claude</h3>
<p className="text-gray-400">by Anthropic</p>
<div className="flex items-center gap-2 mt-2">
<Link href="https://claude.ai" className="text-blue-400 hover:text-blue-300 flex items-center gap-1">
Visit <ChevronRight size={16} />
<div className={surfaces.card.featured}>
<div className="grid md:grid-cols-2 gap-4 sm:gap-6">
<div className="flex items-center gap-3 sm:gap-4">
<SiClaude className="text-4xl sm:text-5xl md:text-6xl flex-shrink-0" style={{ color: colors.accents.ai }} />
<div className="min-w-0">
<h3 className="text-2xl sm:text-3xl font-bold text-gray-100">Claude</h3>
<p className="text-sm sm:text-base text-gray-400">by Anthropic</p>
<div className="flex flex-wrap items-center gap-2 mt-2">
<Link href="https://claude.ai" className="flex items-center gap-1 text-sm sm:text-base hover:text-blue-300">
Visit <ChevronRight size={14} className="sm:w-4 sm:h-4" />
</Link>
<Link href="/ai/claude" className="text-blue-400 hover:text-blue-300 flex items-center gap-1">
My Usage <ChevronRight size={16} />
<Link href="/ai/usage" className="flex items-center gap-1 text-sm sm:text-base hover:text-blue-300">
My Usage <ChevronRight size={14} className="sm:w-4 sm:h-4" />
</Link>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-gray-300">
<div className="space-y-2 sm:space-y-3">
<p className="text-sm sm:text-base text-gray-300 leading-relaxed">
Claude has become my go-to AI assistant for coding, writing, and learning very quickly.
I believe their Max 5x ($100/mo) is the best value for budget-conscious consumers like myself.
</p>
<div className='flex flex-col items-center gap-y-6 sm:flex-row sm:justify-between'>
<div className="flex gap-2 flex-wrap">
<span className="px-2 py-1 bg-gray-700 rounded text-xs text-gray-300">Top-Tier Tool Calling</span>
<span className="px-2 py-1 bg-gray-700 rounded text-xs text-gray-300">High-Value Plans</span>
<span className="px-2 py-1 bg-gray-700 rounded text-xs text-gray-300">Good Speed</span>
<div className="flex gap-2 flex-wrap justify-center sm:justify-start">
<span className={surfaces.badge.default}>Top-Tier Tool Calling</span>
<span className={surfaces.badge.default}>High-Value Plans</span>
<span className={surfaces.badge.default}>Good Speed</span>
</div>
</div>
</div>

View file

@ -13,7 +13,8 @@ export const aiTools: AITool[] = [
icon: SiClaude,
description: "My favorite model provider for general use and coding",
status: "primary",
usage: "/ai/claude",
usage: "/ai/usage",
hasUsage: true,
link: "https://claude.ai/",
price: 100
},
@ -22,6 +23,7 @@ export const aiTools: AITool[] = [
icon: SiOpenai,
description: "Feature-rich and budget-friendly (for now)",
status: "active",
hasUsage: true,
link: "https://chatgpt.com/",
price: 60
},
@ -126,71 +128,71 @@ export const favoriteModels: FavoriteModel[] = [
name: "Claude 4 Sonnet",
provider: "Anthropic",
review: "The perfect balance of capability, speed, and price. Perfect for development with React.",
rating: 5
rating: 10.0
},
{
name: "Claude 4.1 Opus",
provider: "Anthropic",
review: "Amazing planner, useful for Plan Mode in Claude Code. Useful in code generation, albeit at a higher cost.",
rating: 5
rating: 10.0
},
{
name: "Qwen3-235B-A22B",
provider: "Alibaba",
review: "The OG thinking model. Amazing, funny, and smart for chats. Surprisingly good at coding too.",
rating: 5
rating: 9.5
},
{
name: "GPT-5",
provider: "OpenAI",
review: "A model I am still testing with. Seems to be good with coding and following instructions so far, but not with the same flair as Claude.",
rating: 4
rating: 8.0
},
{
name: "Qwen3-Max-Preview",
provider: "Alibaba",
review: "A new personality for Qwen3 at a larger size, amazing for use in chats. I'm not so happy that it's closed source (for now).",
rating: 4
rating: 8.5
},
{
name: "Gemini 2.5 Pro",
provider: "Google",
review: "Amazing for Deep Research and reasoning tasks. I hate it for coding.",
rating: 4
rating: 7.5
},
{
name: "gemma3 27B",
provider: "Google",
review: "My favorite for playing around with AI or creating a project. Easy to run locally and open weight!",
rating: 4
rating: 8.0
},
]
export const aiReviews: AIReview[] = [
{
tool: "Claude Code",
rating: 5,
rating: 10.0,
pros: ["Flagship models", "High usage limits", "Exceptional Claude integration"],
cons: ["API interface be slow at times", "High investment cost to get full value"],
verdict: "Best overall for Claude lovers"
},
{
tool: "Cursor",
rating: 4,
rating: 8.0,
pros: ["Works like magic", "Lots of model support", "Huge ecosystem and community"],
cons: ["Expensive", "Hype around it is dying", "Unclear/manipulative pricing"],
verdict: "Great all-rounder, slowly dying"
},
{
tool: "Trae",
rating: 4,
rating: 8.5,
pros: ["Good UI/UX", "Very budget-friendly", "Fantastic premium usage limits"],
cons: ["No thinking", "Occasional parsing issues"],
verdict: "Budget-friendly productivity boost"
},
{
tool: "GitHub Copilot",
rating: 3,
rating: 6.0,
pros: ["Latest models", "Great autocomplete", "Budget-friendly subscription price"],
cons: ["No thinking", "Low quality output", "Bad support for other IDEs"],
verdict: "Good for casual use"

View file

@ -1,7 +1,6 @@
"use client"
import Header from '@/components/Header'
import Footer from '@/components/Footer'
import PageHeader from '@/components/objects/PageHeader'
import { Brain } from 'lucide-react'
import TopPick from './components/TopPick'
import AIStack from './components/AIStack'
@ -11,15 +10,13 @@ import { aiTools, favoriteModels, aiReviews } from './data'
export default function AI() {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="w-full px-2 sm:px-6">
<div className="w-full px-2 sm:px-6">
<div className="my-12 text-center">
<div className="flex justify-center mb-6">
<Brain size={60} />
</div>
<h1 className="text-4xl font-bold mb-2 text-gray-100 glow">AI</h1>
<p className="text-gray-400">My journey with using LLMs</p>
<PageHeader
icon={<Brain size={60} />}
title="AI"
subtitle="My journey with using LLMs"
/>
</div>
<TopPick />
@ -32,8 +29,6 @@ export default function AI() {
<FavoriteModels models={favoriteModels} />
<FavoriteTools reviews={aiReviews} />
</div>
</main>
<Footer />
</div>
)
}

141
app/ai/theme.ts Normal file
View file

@ -0,0 +1,141 @@
export type ProviderId = 'all' | 'claudeCode' | 'codex'
export interface HeatmapPalette {
empty: string
steps: string[]
}
export interface ChartTheme {
areaStroke: string
areaFill: string
trend: string
pie: string[]
barPrimary: string
barSecondary: string
line: string
}
export interface ButtonTheme {
activeBackground: string
activeText: string
}
export interface ToolTheme {
id: ProviderId
label: string
accent: string
accentContrast: string
accentMuted: string
secondary: string
tertiary: string
focusRing: string
button: ButtonTheme
chart: ChartTheme
heatmap: HeatmapPalette
emphasis: {
cost: string
}
}
const claudeTheme: ToolTheme = {
id: 'claudeCode',
label: 'Claude Code',
accent: '#c15f3c',
accentContrast: '#1a100d',
accentMuted: '#d68b6b',
secondary: '#b1ada1',
tertiary: '#f4f3ee',
focusRing: '#c15f3c',
button: {
activeBackground: '#c15f3c',
activeText: '#1a100d',
},
chart: {
areaStroke: '#c15f3c',
areaFill: '#c15f3c',
trend: '#b1ada1',
pie: ['#c15f3c', '#d68b6b', '#b1ada1', '#8d5738', '#f4f3ee'],
barPrimary: '#c15f3c',
barSecondary: '#b1ada1',
line: '#f4f3ee',
},
heatmap: {
empty: '#1f2937',
steps: ['#4a3328', '#6b4530', '#8d5738', '#c15f3c'],
},
emphasis: {
cost: '#c15f3c',
},
}
const codexTheme: ToolTheme = {
id: 'codex',
label: 'Codex',
accent: '#f5f5f5',
accentContrast: '#111827',
accentMuted: '#d1d5db',
secondary: '#9ca3af',
tertiary: '#6b7280',
focusRing: '#f5f5f5',
button: {
activeBackground: '#f5f5f5',
activeText: '#111827',
},
chart: {
areaStroke: '#f5f5f5',
areaFill: '#f5f5f5',
trend: '#d1d5db',
pie: ['#f5f5f5', '#d1d5db', '#9ca3af', '#6b7280', '#374151'],
barPrimary: '#f5f5f5',
barSecondary: '#9ca3af',
line: '#e5e7eb',
},
heatmap: {
empty: '#111827',
steps: ['#1f2937', '#374151', '#4b5563', '#f5f5f5'],
},
emphasis: {
cost: '#f5f5f5',
},
}
const combinedTheme: ToolTheme = {
id: 'all',
label: 'All Tools',
accent: '#9ca3af',
accentContrast: '#111827',
accentMuted: '#6b7280',
secondary: '#6b7280',
tertiary: '#e5e7eb',
focusRing: '#9ca3af',
button: {
activeBackground: '#9ca3af',
activeText: '#111827',
},
chart: {
areaStroke: '#9ca3af',
areaFill: '#9ca3af',
trend: '#6b7280',
pie: ['#e5e7eb', '#d1d5db', '#9ca3af', '#6b7280', '#4b5563'],
barPrimary: '#9ca3af',
barSecondary: '#6b7280',
line: '#e5e7eb',
},
heatmap: {
empty: '#1f2937',
steps: ['#374151', '#4b5563', '#6b7280', '#9ca3af'],
},
emphasis: {
cost: '#9ca3af',
},
}
export const toolThemes: Record<ProviderId, ToolTheme> = {
all: combinedTheme,
claudeCode: claudeTheme,
codex: codexTheme,
}
export const getToolTheme = (provider: ProviderId): ToolTheme => {
return toolThemes[provider] ?? toolThemes.all
}

View file

@ -6,6 +6,7 @@ export interface AITool {
status: 'primary' | 'active' | 'occasional' | string;
link?: string;
usage?: string;
hasUsage?: boolean;
price?: number;
discountedPrice?: number;
}
@ -14,13 +15,13 @@ export interface FavoriteModel {
name: string;
provider: string;
review: string;
rating: number;
rating: number; // 1.0 - 10.0 scale
}
export interface AIReview {
tool: string;
rating: number;
rating: number; // 1.0 - 10.0 scale
pros: string[];
cons: string[];
verdict: string;
}
}

View file

@ -1,26 +1,36 @@
"use client"
import { useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import {
AreaChart,
Area,
Line,
CartesianGrid,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
} from 'recharts'
import { DailyData } from './types'
import { DailyData, TimeRangeKey } from '@/lib/types'
import {
buildDailyTrendData,
formatCurrency,
formatTokens,
getHeatmapColor,
prepareHeatmapData,
formatAxisLabel,
formatTooltipDate,
} from './utils'
import type { ToolTheme } from '@/app/ai/theme'
export default function Activity({ daily }: { daily: DailyData[] }) {
const [viewMode, setViewMode] = useState<'heatmap' | 'chart'>('heatmap')
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])
@ -30,6 +40,50 @@ export default function Activity({ daily }: { daily: DailyData[] }) {
[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">
@ -38,11 +92,13 @@ export default function Activity({ daily }: { daily: DailyData[] }) {
<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:ring-2 focus:ring-[#c15f3c] focus:ring-offset-2 focus:ring-offset-gray-900"
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-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-[#c15f3c] transition-transform`}
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>
@ -96,13 +152,15 @@ export default function Activity({ daily }: { daily: DailyData[] }) {
<div key={dayIndex} className="relative group">
<div
className="w-4 h-4 rounded-sm"
style={{ backgroundColor: getHeatmapColor(maxCost, day?.value || 0) }}
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="text-[#c15f3c] font-bold text-sm">Cost: ${day.cost.toFixed(2)}</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>
@ -117,11 +175,9 @@ export default function Activity({ daily }: { daily: DailyData[] }) {
<div className="flex items-center gap-2 mt-4 text-xs text-gray-400">
<span>Less</span>
<div className="flex gap-1">
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: '#1f2937' }}></div>
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: '#4a3328' }}></div>
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: '#6b4530' }}></div>
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: '#8d5738' }}></div>
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: '#c15f3c' }}></div>
{heatmapLegendColors.map((color, idx) => (
<div key={idx} className="w-3 h-3 rounded-sm" style={{ backgroundColor: color }}></div>
))}
</div>
<span>More</span>
</div>
@ -132,13 +188,21 @@ export default function Activity({ daily }: { daily: DailyData[] }) {
<div className="flex gap-2 mb-4">
<button
onClick={() => setSelectedMetric('cost')}
className={`px-3 py-1 rounded ${selectedMetric === 'cost' ? 'bg-[#c15f3c] text-white' : 'bg-gray-700 text-gray-300'}`}
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 ${selectedMetric === 'tokens' ? 'bg-[#c15f3c] text-white' : 'bg-gray-700 text-gray-300'}`}
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>
@ -146,21 +210,40 @@ export default function Activity({ daily }: { daily: DailyData[] }) {
<ResponsiveContainer width="100%" height={400}>
<AreaChart data={dailyTrendData}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis dataKey="date" stroke="#9ca3af" />
<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' }}
formatter={(value: number) => selectedMetric === 'cost' ? formatCurrency(value) : formatTokens(value)}
labelFormatter={tooltipLabelFormatter}
formatter={tooltipFormatter}
/>
<Area
type="monotone"
dataKey={selectedMetric === 'cost' ? 'cost' : 'tokens'}
stroke="#c15f3c"
fill="#c15f3c"
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>
@ -169,4 +252,3 @@ export default function Activity({ daily }: { daily: DailyData[] }) {
</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

@ -1,11 +1,19 @@
"use client"
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip } from 'recharts'
import { DailyData } from './types'
import { COLORS, buildModelUsageData, formatCurrency } from './utils'
import { DailyData } from '@/lib/types'
import { buildModelUsageData, formatCurrency } from './utils'
import type { ToolTheme } from '@/app/ai/theme'
export default function ModelUsageCard({ daily, totalCost }: { daily: DailyData[]; totalCost: number }) {
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>
@ -23,12 +31,15 @@ export default function ModelUsageCard({ daily, totalCost }: { daily: DailyData[
dataKey="value"
>
{modelUsageData.map((_entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
<Cell key={`cell-${index}`} fill={palette[index % palette.length]} />
))}
</Pie>
<Tooltip
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151', borderRadius: '8px' }}
formatter={(value: number) => formatCurrency(value)}
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' }}
/>
@ -42,7 +53,7 @@ export default function ModelUsageCard({ daily, totalCost }: { daily: DailyData[
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: COLORS[index % COLORS.length] }}
style={{ backgroundColor: palette[index % palette.length] }}
/>
<span className="text-gray-300 font-medium text-xs">{model.name}</span>
</div>
@ -70,4 +81,3 @@ export default function ModelUsageCard({ daily, totalCost }: { daily: DailyData[
</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,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>
)
}