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