feat/fix: ai page improvements, bump, update ccusage
add more content on ai, add heatmap, fix header glow, add price to ai tools, fix NowPlaying style issue, new ccombine utility, better claude code usage page w/ model labels+better typing, bump, update ccusage (restore old dates)
This commit is contained in:
parent
57dd627ca3
commit
77a6266c71
20 changed files with 1380 additions and 444 deletions
172
app/ai/claude/components/Activity.tsx
Normal file
172
app/ai/claude/components/Activity.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
CartesianGrid,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { DailyData } from './types'
|
||||
import {
|
||||
buildDailyTrendData,
|
||||
formatCurrency,
|
||||
formatTokens,
|
||||
getHeatmapColor,
|
||||
prepareHeatmapData,
|
||||
} from './utils'
|
||||
|
||||
export default function Activity({ daily }: { daily: DailyData[] }) {
|
||||
const [viewMode, setViewMode] = useState<'heatmap' | 'chart'>('heatmap')
|
||||
const [selectedMetric, setSelectedMetric] = useState<'cost' | 'tokens'>('cost')
|
||||
|
||||
const dailyTrendData = useMemo(() => buildDailyTrendData(daily), [daily])
|
||||
const heatmapWeeks = useMemo(() => prepareHeatmapData(daily), [daily])
|
||||
const maxCost = useMemo(
|
||||
() => (daily.length ? Math.max(...daily.map(d => d.totalCost)) : 0),
|
||||
[daily]
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300 relative md:col-span-2 lg:col-span-1">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-semibold text-gray-200">Activity</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-400">{viewMode === 'heatmap' ? 'Heatmap' : 'Chart'}</span>
|
||||
<button
|
||||
onClick={() => setViewMode(viewMode === 'heatmap' ? 'chart' : 'heatmap')}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full bg-gray-700 transition-colors focus:outline-none focus:ring-2 focus:ring-[#c15f3c] focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
>
|
||||
<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`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === 'heatmap' ? (
|
||||
<div className="overflow-x-auto pb-6">
|
||||
<div className="min-w-[900px]">
|
||||
<div className="flex gap-1">
|
||||
<div className="flex flex-col gap-1 text-xs text-gray-400 w-10 pr-2">
|
||||
<div className="h-4"></div>
|
||||
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((day) => (
|
||||
<div key={day} className="h-4 flex items-center justify-end text-[10px]">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="h-4 mb-1 text-xs text-gray-400">
|
||||
{(() => {
|
||||
const monthLabels: { month: string; position: number }[] = []
|
||||
let lastMonth = -1
|
||||
heatmapWeeks.forEach((week, weekIndex) => {
|
||||
const firstDay = week.find(d => d !== null)
|
||||
if (firstDay) {
|
||||
const date = new Date(firstDay.date + 'T00:00:00Z')
|
||||
const month = date.getUTCMonth()
|
||||
if (month !== lastMonth) {
|
||||
monthLabels.push({
|
||||
month: date.toLocaleDateString('en-US', { month: 'short', timeZone: 'UTC' }),
|
||||
position: weekIndex * 20
|
||||
})
|
||||
lastMonth = month
|
||||
}
|
||||
}
|
||||
})
|
||||
return (
|
||||
<div className="flex relative">
|
||||
{monthLabels.map((label, idx) => (
|
||||
<div key={idx} style={{ position: 'absolute', left: label.position }} className="w-10">
|
||||
{label.month}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{heatmapWeeks.map((week, weekIndex) => (
|
||||
<div key={weekIndex} className="flex flex-col gap-1">
|
||||
{week.map((day, dayIndex) => (
|
||||
<div key={dayIndex} className="relative group">
|
||||
<div
|
||||
className="w-4 h-4 rounded-sm"
|
||||
style={{ backgroundColor: getHeatmapColor(maxCost, day?.value || 0) }}
|
||||
/>
|
||||
{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="text-gray-400 text-xs">Tokens: {(day.tokens / 1000000).toFixed(2)}M</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-4 text-xs text-gray-400">
|
||||
<span>Less</span>
|
||||
<div className="flex gap-1">
|
||||
<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>
|
||||
</div>
|
||||
<span>More</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<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'}`}
|
||||
>
|
||||
Cost
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedMetric('tokens')}
|
||||
className={`px-3 py-1 rounded ${selectedMetric === 'tokens' ? 'bg-[#c15f3c] text-white' : 'bg-gray-700 text-gray-300'}`}
|
||||
>
|
||||
Tokens
|
||||
</button>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<AreaChart data={dailyTrendData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||
<XAxis dataKey="date" stroke="#9ca3af" />
|
||||
<YAxis
|
||||
stroke="#9ca3af"
|
||||
tickFormatter={selectedMetric === 'cost' ? formatCurrency : formatTokens}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151' }}
|
||||
formatter={(value: number) => selectedMetric === 'cost' ? formatCurrency(value) : formatTokens(value)}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey={selectedMetric === 'cost' ? 'cost' : 'tokens'}
|
||||
stroke="#c15f3c"
|
||||
fill="#c15f3c"
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
177
app/ai/claude/components/LoadingSkeleton.tsx
Normal file
177
app/ai/claude/components/LoadingSkeleton.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
"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>
|
||||
)
|
||||
}
|
||||
|
||||
73
app/ai/claude/components/ModelUsageCard.tsx
Normal file
73
app/ai/claude/components/ModelUsageCard.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
"use client"
|
||||
|
||||
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip } from 'recharts'
|
||||
import { DailyData } from './types'
|
||||
import { COLORS, buildModelUsageData, formatCurrency } from './utils'
|
||||
|
||||
export default function ModelUsageCard({ daily, totalCost }: { daily: DailyData[]; totalCost: number }) {
|
||||
const modelUsageData = buildModelUsageData(daily)
|
||||
return (
|
||||
<section className="p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300 col-span-2 lg:col-span-1">
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-200">Model Usage Distribution</h2>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={modelUsageData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
fill="#8884d8"
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{modelUsageData.map((_entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151', borderRadius: '8px' }}
|
||||
formatter={(value: number) => formatCurrency(value)}
|
||||
labelStyle={{ color: '#fff' }}
|
||||
itemStyle={{ color: '#fff' }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-col justify-center space-y-3">
|
||||
{modelUsageData.map((model, index) => {
|
||||
const percentage = ((model.value / Math.max(totalCost, 1)) * 100).toFixed(1)
|
||||
return (
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: COLORS[index % COLORS.length] }}
|
||||
/>
|
||||
<span className="text-gray-300 font-medium text-xs">{model.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-gray-400 text-sm">{percentage}%</span>
|
||||
<span className="text-gray-200 font-semibold">${model.value.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="pt-3 mt-3 border-t border-gray-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-400">Total Models Used</span>
|
||||
<span className="text-gray-200 font-bold">{modelUsageData.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
<span className="text-gray-400">Most Used</span>
|
||||
<span className="text-gray-200 font-bold text-xs">
|
||||
{modelUsageData[0]?.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
26
app/ai/claude/components/PageHeader.tsx
Normal file
26
app/ai/claude/components/PageHeader.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
37
app/ai/claude/components/RecentSessions.tsx
Normal file
37
app/ai/claude/components/RecentSessions.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"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>
|
||||
)
|
||||
}
|
||||
|
||||
34
app/ai/claude/components/StatsGrid.tsx
Normal file
34
app/ai/claude/components/StatsGrid.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
"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>
|
||||
)
|
||||
}
|
||||
|
||||
30
app/ai/claude/components/TokenComposition.tsx
Normal file
30
app/ai/claude/components/TokenComposition.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"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>
|
||||
)
|
||||
}
|
||||
|
||||
27
app/ai/claude/components/TokenTypeBreakdown.tsx
Normal file
27
app/ai/claude/components/TokenTypeBreakdown.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"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>
|
||||
)
|
||||
}
|
||||
|
||||
42
app/ai/claude/components/types.ts
Normal file
42
app/ai/claude/components/types.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
export interface ModelBreakdown {
|
||||
modelName: string
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheCreationTokens: number
|
||||
cacheReadTokens: number
|
||||
cost: number
|
||||
}
|
||||
|
||||
export interface DailyData {
|
||||
date: string
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheCreationTokens: number
|
||||
cacheReadTokens: number
|
||||
totalTokens: number
|
||||
totalCost: number
|
||||
modelsUsed: string[]
|
||||
modelBreakdowns: ModelBreakdown[]
|
||||
}
|
||||
|
||||
export interface CCData {
|
||||
daily: DailyData[]
|
||||
totals: {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheCreationTokens: number
|
||||
cacheReadTokens: number
|
||||
totalCost: number
|
||||
totalTokens: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface HeatmapDay {
|
||||
date: string
|
||||
value: number
|
||||
tokens: number
|
||||
cost: number
|
||||
day: number
|
||||
formattedDate: string
|
||||
}
|
||||
|
||||
191
app/ai/claude/components/utils.ts
Normal file
191
app/ai/claude/components/utils.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
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 },
|
||||
])
|
||||
|
||||
|
|
@ -2,67 +2,21 @@
|
|||
|
||||
import Header from '@/components/Header'
|
||||
import Footer from '@/components/Footer'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { SiClaude } from 'react-icons/si'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Line,
|
||||
BarChart,
|
||||
Bar,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Area,
|
||||
AreaChart,
|
||||
ComposedChart,
|
||||
} from 'recharts'
|
||||
|
||||
interface ModelBreakdown {
|
||||
modelName: string
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheCreationTokens: number
|
||||
cacheReadTokens: number
|
||||
cost: number
|
||||
}
|
||||
|
||||
interface DailyData {
|
||||
date: string
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheCreationTokens: number
|
||||
cacheReadTokens: number
|
||||
totalTokens: number
|
||||
totalCost: number
|
||||
modelsUsed: string[]
|
||||
modelBreakdowns: ModelBreakdown[]
|
||||
}
|
||||
|
||||
interface CCData {
|
||||
daily: DailyData[]
|
||||
totals: {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheCreationTokens: number
|
||||
cacheReadTokens: number
|
||||
totalCost: number
|
||||
totalTokens: number
|
||||
}
|
||||
}
|
||||
|
||||
const COLORS = ['#c15f3c', '#b1ada1', '#f4f3ee', '#c15f3c', '#b1ada1', '#f4f3ee']
|
||||
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)
|
||||
const [selectedMetric, setSelectedMetric] = useState<'cost' | 'tokens'>('cost')
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/data/cc.json')
|
||||
|
|
@ -84,104 +38,7 @@ export default function AI() {
|
|||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main className="w-full relative">
|
||||
<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>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 px-4">
|
||||
<div className="p-6 border-2 border-gray-700 rounded-lg">
|
||||
<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">
|
||||
<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">
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-2">Days Active</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">
|
||||
<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="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4">
|
||||
<section className="p-8 border-2 border-gray-700 rounded-lg">
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-200">Daily Usage Trend</h2>
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button className="px-3 py-1 rounded bg-gray-700 text-gray-300" disabled>
|
||||
Cost
|
||||
</button>
|
||||
<button className="px-3 py-1 rounded bg-gray-700 text-gray-300" disabled>
|
||||
Tokens
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-[300px] bg-gray-800 rounded animate-pulse" />
|
||||
</section>
|
||||
<section className="p-8 border-2 border-gray-700 rounded-lg">
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-200">Model Usage Distribution</h2>
|
||||
<div className="h-[300px] bg-gray-800 rounded animate-pulse" />
|
||||
</section>
|
||||
<section className="p-8 border-2 border-gray-700 rounded-lg">
|
||||
<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">
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-200">Daily 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">
|
||||
<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>
|
||||
<LoadingSkeleton />
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
|
|
@ -199,247 +56,30 @@ export default function AI() {
|
|||
)
|
||||
}
|
||||
|
||||
const modelUsageData = data.daily.reduce((acc, day) => {
|
||||
day.modelBreakdowns.forEach(model => {
|
||||
const existing = acc.find(m => m.name === model.modelName)
|
||||
if (existing) {
|
||||
existing.value += model.cost
|
||||
} else {
|
||||
acc.push({ name: model.modelName, value: model.cost })
|
||||
}
|
||||
})
|
||||
return acc
|
||||
}, [] as { name: string; value: number }[])
|
||||
.sort((a, b) => b.value - a.value)
|
||||
|
||||
const tokenTypeData = [
|
||||
{ name: 'Input', value: data.totals.inputTokens },
|
||||
{ name: 'Output', value: data.totals.outputTokens },
|
||||
{ name: 'Cache Creation', value: data.totals.cacheCreationTokens },
|
||||
{ name: 'Cache Read', value: data.totals.cacheReadTokens },
|
||||
]
|
||||
|
||||
const dailyTrendData = data.daily.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,
|
||||
}))
|
||||
|
||||
const formatCurrency = (value: number) => `$${value.toFixed(2)}`
|
||||
const formatTokens = (value: number) => `${value.toFixed(1)}M`
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main className="w-full relative">
|
||||
<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>
|
||||
<PageHeader />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg: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]">${data.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]">{(data.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]">{data.daily.length}</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]">${(data.totals.totalCost / data.daily.length).toFixed(2)}</p>
|
||||
</div>
|
||||
<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">
|
||||
<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">Daily Usage Trend</h2>
|
||||
<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'}`}
|
||||
>
|
||||
Cost
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedMetric('tokens')}
|
||||
className={`px-3 py-1 rounded ${selectedMetric === 'tokens' ? 'bg-[#c15f3c] text-white' : 'bg-gray-700 text-gray-300'}`}
|
||||
>
|
||||
Tokens
|
||||
</button>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={dailyTrendData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||
<XAxis dataKey="date" stroke="#9ca3af" />
|
||||
<YAxis
|
||||
stroke="#9ca3af"
|
||||
tickFormatter={selectedMetric === 'cost' ? formatCurrency : formatTokens}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151' }}
|
||||
formatter={(value: number) => selectedMetric === 'cost' ? formatCurrency(value) : formatTokens(value)}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey={selectedMetric === 'cost' ? 'cost' : 'tokens'}
|
||||
stroke="#c15f3c"
|
||||
fill="#c15f3c"
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</section>
|
||||
|
||||
<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">Model Usage Distribution</h2>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={modelUsageData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
fill="#8884d8"
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{modelUsageData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151', borderRadius: '8px' }}
|
||||
formatter={(value: number) => formatCurrency(value)}
|
||||
labelStyle={{ color: '#fff' }}
|
||||
itemStyle={{ color: '#fff' }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-col justify-center space-y-3">
|
||||
{modelUsageData.map((model, index) => {
|
||||
const percentage = ((model.value / data.totals.totalCost) * 100).toFixed(1)
|
||||
return (
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: COLORS[index % COLORS.length] }}
|
||||
/>
|
||||
<span className="text-gray-300 font-medium text-xs">{model.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-gray-400 text-sm">{percentage}%</span>
|
||||
<span className="text-gray-200 font-semibold">${model.value.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="pt-3 mt-3 border-t border-gray-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-400">Total Models Used</span>
|
||||
<span className="text-gray-200 font-bold">{modelUsageData.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
<span className="text-gray-400">Most Used</span>
|
||||
<span className="text-gray-200 font-bold text-xs">
|
||||
{modelUsageData[0]?.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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">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>
|
||||
|
||||
<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">Daily 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>
|
||||
<ModelUsageCard daily={data.daily} totalCost={data.totals.totalCost} />
|
||||
<TokenTypeBreakdown totals={data.totals} />
|
||||
<TokenComposition daily={data.daily} />
|
||||
</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>
|
||||
{data.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.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>
|
||||
<RecentSessions daily={data.daily} />
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ export default function AIStack({ tools }: AIStackProps) {
|
|||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'primary': return 'text-green-400 border-green-400 bg-green-400/10'
|
||||
case 'active': return 'text-blue-400 border-blue-400 bg-blue-400/10'
|
||||
case 'occasional': return 'text-yellow-400 border-yellow-400 bg-yellow-400/10'
|
||||
case 'active': return 'text-green-300 border-green-300 bg-green-300/10'
|
||||
case 'occasional': return 'text-orange-300 border-orange-300 bg-orange-300/10'
|
||||
default: return 'text-gray-400 border-gray-400'
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,12 @@ export default function AIStack({ tools }: AIStackProps) {
|
|||
}
|
||||
}
|
||||
|
||||
const formatPrice = (price: number) => {
|
||||
if (price === 0) return 'Free'
|
||||
if (price % 1 === 0) return `$${price}/mo`
|
||||
return `$${price.toFixed(2)}/mo`
|
||||
}
|
||||
|
||||
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">
|
||||
|
|
@ -38,15 +44,35 @@ export default function AIStack({ tools }: AIStackProps) {
|
|||
{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">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
{tool.icon && <tool.icon className="text-2xl text-gray-300" />}
|
||||
{tool.svg && (
|
||||
<div className="w-6 h-6 text-gray-300 fill-current">
|
||||
{tool.svg}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-200">{tool.name}</h3>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-200">{tool.name}</h3>
|
||||
{tool.price !== undefined && (
|
||||
<div className="flex items-center gap-2">
|
||||
{tool.discountedPrice !== undefined ? (
|
||||
<>
|
||||
<span className="text-gray-500 line-through">
|
||||
{formatPrice(tool.price)}
|
||||
</span>
|
||||
<span className="text-gray-200">
|
||||
{formatPrice(tool.discountedPrice)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-200">
|
||||
{formatPrice(tool.price)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">{tool.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -58,7 +84,7 @@ export default function AIStack({ tools }: AIStackProps) {
|
|||
<span className="flex flex-row items-center gap-4">
|
||||
{tool.link && (
|
||||
<Link href={tool.link} className="text-blue-400 hover:text-blue-300 text-sm" target="_blank" rel="noopener noreferrer">
|
||||
View →
|
||||
Visit →
|
||||
</Link>
|
||||
)}
|
||||
{tool.usage && (
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ export default function TopPick() {
|
|||
<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} />
|
||||
</Link>
|
||||
<Link href="/ai/claude" className="text-blue-400 hover:text-blue-300 flex items-center gap-1">
|
||||
My Usage <ChevronRight size={16} />
|
||||
</Link>
|
||||
|
|
@ -31,8 +34,8 @@ export default function TopPick() {
|
|||
<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">Max Plan is High Value</span>
|
||||
<span className="px-2 py-1 bg-gray-700 rounded text-xs text-gray-300">Fast Interface</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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
115
app/ai/data.tsx
115
app/ai/data.tsx
|
|
@ -2,7 +2,8 @@ import {
|
|||
SiClaude,
|
||||
SiGithubcopilot,
|
||||
SiGooglegemini,
|
||||
SiPerplexity
|
||||
SiPerplexity,
|
||||
SiOpenai
|
||||
} from 'react-icons/si'
|
||||
import type { AITool, FavoriteModel, AIReview } from './types'
|
||||
|
||||
|
|
@ -13,31 +14,42 @@ export const aiTools: AITool[] = [
|
|||
description: "My favorite model provider for general use and coding",
|
||||
status: "primary",
|
||||
usage: "/ai/claude",
|
||||
link: "https://claude.ai/"
|
||||
link: "https://claude.ai/",
|
||||
price: 100
|
||||
},
|
||||
{
|
||||
name: "GitHub Copilot Pro",
|
||||
icon: SiGithubcopilot,
|
||||
description: "Random edits when I don't want to start a Claude session",
|
||||
name: "ChatGPT Business",
|
||||
icon: SiOpenai,
|
||||
description: "Feature-rich and budget-friendly (for now)",
|
||||
status: "active",
|
||||
link: "https://github.com/features/copilot"
|
||||
link: "https://chatgpt.com/",
|
||||
price: 60
|
||||
},
|
||||
{
|
||||
name: "GLM Coding Lite",
|
||||
svg: (
|
||||
/* Icon by lobe-icons: https://github.com/lobehub/lobe-icons */
|
||||
<svg fill="currentColor" fillRule="evenodd" height="1em" style={{ flex: 'none', lineHeight: 1 }} viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Z.ai</title>
|
||||
<path d="M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z"></path>
|
||||
</svg>
|
||||
),
|
||||
description: "Cheap, Claude-like model with a slow API",
|
||||
status: "active",
|
||||
link: "https://z.ai/",
|
||||
price: 3
|
||||
},
|
||||
{
|
||||
name: "Gemini Pro",
|
||||
icon: SiGooglegemini,
|
||||
description: "Chatting, asking questions, and image generation",
|
||||
status: "occasional",
|
||||
link: "https://gemini.google.com/"
|
||||
link: "https://gemini.google.com/",
|
||||
price: 20,
|
||||
discountedPrice: 0
|
||||
},
|
||||
{
|
||||
name: "v0 Free",
|
||||
svg: <svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>v0</title><path d="M14.066 6.028v2.22h5.729q.075-.001.148.005l-5.853 5.752a2 2 0 0 1-.024-.309V8.247h-2.353v5.45c0 2.322 1.935 4.222 4.258 4.222h5.675v-2.22h-5.675q-.03 0-.059-.003l5.729-5.629q.006.082.006.166v5.465H24v-5.465a4.204 4.204 0 0 0-4.205-4.205zM0 8.245l8.28 9.266c.839.94 2.396.346 2.396-.914V8.245H8.19v5.44l-4.86-5.44Z"/></svg>,
|
||||
description: "Generating boilerplate UIs",
|
||||
status: "occasional",
|
||||
link: "https://v0.dev/"
|
||||
},
|
||||
{
|
||||
name: "Qwen",
|
||||
name: "Qwen Chat",
|
||||
svg: (
|
||||
<svg viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg" className="size-8">
|
||||
<path d="M174.82 108.75L155.38 75L165.64 57.75C166.46 56.31 166.46 54.53 165.64 53.09L155.38 35.84C154.86 34.91 153.87 34.33 152.78 34.33H114.88L106.14 19.03C105.62 18.1 104.63 17.52 103.54 17.52H83.3C82.21 17.52 81.22 18.1 80.7 19.03L61.26 52.77H41.02C39.93 52.77 38.94 53.35 38.42 54.28L28.16 71.53C27.34 72.97 27.34 74.75 28.16 76.19L45.52 107.5L36.78 122.8C35.96 124.24 35.96 126.02 36.78 127.46L47.04 144.71C47.56 145.64 48.55 146.22 49.64 146.22H87.54L96.28 161.52C96.8 162.45 97.79 163.03 98.88 163.03H119.12C120.21 163.03 121.2 162.45 121.72 161.52L141.16 127.78H158.52C159.61 127.78 160.6 127.2 161.12 126.27L171.38 109.02C172.2 107.58 172.2 105.8 171.38 104.36L174.82 108.75Z" fill="url(#paint0_radial)"/>
|
||||
|
|
@ -57,15 +69,56 @@ export const aiTools: AITool[] = [
|
|||
),
|
||||
description: "My favorite open source LLM for chatting",
|
||||
status: "occasional",
|
||||
link: "https://chat.qwen.ai/"
|
||||
link: "https://chat.qwen.ai/",
|
||||
price: 0
|
||||
},
|
||||
{
|
||||
"name": "Perplexity",
|
||||
name: "Perplexity Pro",
|
||||
icon: SiPerplexity,
|
||||
description: "Reliable for more in-depth searching",
|
||||
status: "occasional",
|
||||
link: "https://perplexity.ai/"
|
||||
}
|
||||
link: "https://perplexity.ai/",
|
||||
price: 20,
|
||||
discountedPrice: 0
|
||||
},
|
||||
{
|
||||
name: "OpenCode",
|
||||
svg: (
|
||||
<svg viewBox="0 0 289 50" fill="none" xmlns="http://www.w3.org/2000/svg" className="size-7">
|
||||
<path d="M264.5 0H288.5V8.5H272.5V16.5H288.5V25H272.5V33H288.5V41.5H264.5V0Z" fill="white"/>
|
||||
<path d="M248.5 0H224.5V41.5H248.5V33H232.5V8.5H248.5V0Z" fill="white"/>
|
||||
<path d="M256.5 8.5H248.5V33H256.5V8.5Z" fill="white"/>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M184.5 0H216.5V41.5H184.5V0ZM208.5 8.5H192.5V33H208.5V8.5Z" fill="white"/>
|
||||
<path d="M144.5 8.5H136.5V41.5H144.5V8.5Z" fill="white"/>
|
||||
<path d="M136.5 0H112.5V41.5H120.5V8.5H136.5V0Z" fill="white"/>
|
||||
<path d="M80.5 0H104.5V8.5H88.5V16.5H104.5V25H88.5V33H104.5V41.5H80.5V0Z" fill="white"/>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M40.5 0H72.5V41.5H48.5V49.5H40.5V0ZM64.5 8.5H48.5V33H64.5V8.5Z" fill="white"/>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M0.5 0H32.5V41.5955H0.5V0ZM24.5 8.5H8.5V33H24.5V8.5Z" fill="white"/>
|
||||
<path d="M152.5 0H176.5V8.5H160.5V33H176.5V41.5H152.5V0Z" fill="white"/>
|
||||
</svg>
|
||||
),
|
||||
description: "My favorite FOSS AI coding assistant",
|
||||
status: "occasional",
|
||||
link: "https://opencode.ai/",
|
||||
price: 0
|
||||
},
|
||||
{
|
||||
name: "GitHub Copilot Pro",
|
||||
icon: SiGithubcopilot,
|
||||
description: "Random edits when I don't want to start a Claude session",
|
||||
status: "occasional",
|
||||
link: "https://github.com/features/copilot",
|
||||
price: 10,
|
||||
discountedPrice: 0
|
||||
},
|
||||
{
|
||||
name: "v0 Free",
|
||||
svg: <svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>v0</title><path d="M14.066 6.028v2.22h5.729q.075-.001.148.005l-5.853 5.752a2 2 0 0 1-.024-.309V8.247h-2.353v5.45c0 2.322 1.935 4.222 4.258 4.222h5.675v-2.22h-5.675q-.03 0-.059-.003l5.729-5.629q.006.082.006.166v5.465H24v-5.465a4.204 4.204 0 0 0-4.205-4.205zM0 8.245l8.28 9.266c.839.94 2.396.346 2.396-.914V8.245H8.19v5.44l-4.86-5.44Z"/></svg>,
|
||||
description: "Generating boilerplate UIs",
|
||||
status: "occasional",
|
||||
link: "https://v0.dev/",
|
||||
price: 0
|
||||
},
|
||||
]
|
||||
|
||||
export const favoriteModels: FavoriteModel[] = [
|
||||
|
|
@ -81,18 +134,24 @@ export const favoriteModels: FavoriteModel[] = [
|
|||
review: "Amazing planner, useful for Plan Mode in Claude Code. Useful in code generation, albeit at a higher cost.",
|
||||
rating: 5
|
||||
},
|
||||
{
|
||||
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: 5
|
||||
},
|
||||
{
|
||||
name: "Qwen3-235B-A22B",
|
||||
provider: "Alibaba",
|
||||
review: "The OG thinking model. Amazing, funny, and smart for chats. Surprisingly good at coding too.",
|
||||
rating: 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
|
||||
},
|
||||
{
|
||||
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
|
||||
},
|
||||
{
|
||||
name: "Gemini 2.5 Pro",
|
||||
provider: "Google",
|
||||
|
|
@ -112,7 +171,7 @@ export const aiReviews: AIReview[] = [
|
|||
tool: "Claude Code",
|
||||
rating: 5,
|
||||
pros: ["Flagship models", "High usage limits", "Exceptional Claude integration"],
|
||||
cons: ["Can be slow", "High investment cost to get value"],
|
||||
cons: ["API interface be slow at times", "High investment cost to get full value"],
|
||||
verdict: "Best overall for Claude lovers"
|
||||
},
|
||||
{
|
||||
|
|
@ -127,7 +186,7 @@ export const aiReviews: AIReview[] = [
|
|||
rating: 4,
|
||||
pros: ["Good UI/UX", "Very budget-friendly", "Fantastic premium usage limits"],
|
||||
cons: ["No thinking", "Occasional parsing issues"],
|
||||
verdict: "Essential for productivity"
|
||||
verdict: "Budget-friendly productivity boost"
|
||||
},
|
||||
{
|
||||
tool: "GitHub Copilot",
|
||||
|
|
@ -136,4 +195,4 @@ export const aiReviews: AIReview[] = [
|
|||
cons: ["No thinking", "Low quality output", "Bad support for other IDEs"],
|
||||
verdict: "Good for casual use"
|
||||
},
|
||||
]
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ export interface AITool {
|
|||
status: 'primary' | 'active' | 'occasional' | string;
|
||||
link?: string;
|
||||
usage?: string;
|
||||
price?: number;
|
||||
discountedPrice?: number;
|
||||
}
|
||||
|
||||
export interface FavoriteModel {
|
||||
|
|
|
|||
|
|
@ -66,8 +66,13 @@ html {
|
|||
}
|
||||
}
|
||||
|
||||
.hover\:glow {
|
||||
transition: text-shadow 0.3s ease;
|
||||
text-shadow: 0 0 0px rgba(255, 255, 255, 0);
|
||||
}
|
||||
|
||||
.hover\:glow:hover {
|
||||
animation: pulse-glow 2s infinite;
|
||||
text-shadow: 0 0 15px rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.sub {
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ const NowPlaying: React.FC = () => {
|
|||
|
||||
useEffect(() => {
|
||||
const socket = connectSocket()
|
||||
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('Connected to server')
|
||||
socket.emit('requestNowPlaying')
|
||||
|
|
@ -61,7 +61,7 @@ const NowPlaying: React.FC = () => {
|
|||
...prevState,
|
||||
...data
|
||||
}))
|
||||
|
||||
|
||||
if (data.status === 'loading') {
|
||||
setProgressSteps({ current: 1, total: 3 })
|
||||
} else if (data.status === 'partial') {
|
||||
|
|
@ -107,9 +107,9 @@ const NowPlaying: React.FC = () => {
|
|||
<Loader2 className="animate-spin text-white mb-4" size={32} />
|
||||
<div className="text-white text-xs text-center px-4">
|
||||
<div className="mb-2">{nowPlaying.message || 'Connecting...'}</div>
|
||||
<Progress
|
||||
<Progress
|
||||
value={progressSteps.total > 0 ? (progressSteps.current * 100) / progressSteps.total : 0}
|
||||
className="h-1"
|
||||
className="h-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -151,7 +151,7 @@ const NowPlaying: React.FC = () => {
|
|||
className="bg-gradient-to-b from-gray-700 to-gray-900 border-b border-gray-700 px-2 py-0 block" style={{background: 'linear-gradient(to bottom, #4b5563 0%, #374151 30%, #1f2937 70%, #111827 100%)'}}
|
||||
>
|
||||
<div className="text-center leading-none pb-1">
|
||||
<ScrollTxt text={nowPlaying.artist_name?.toUpperCase() || ''} type="artist" />
|
||||
<ScrollTxt text={nowPlaying.artist_name?.toUpperCase() || ''} type="artist" className="-mt-0.5" />
|
||||
<ScrollTxt text={nowPlaying.track_name || ''} type="track" className="-mt-0.5" />
|
||||
{nowPlaying.release_name && <ScrollTxt text={nowPlaying.release_name} type="release" className="-mt-1.5" />}
|
||||
</div>
|
||||
|
|
@ -182,7 +182,7 @@ const NowPlaying: React.FC = () => {
|
|||
|
||||
return (
|
||||
<div className="flex justify-center items-center">
|
||||
<div className={`relative w-52 bg-[#D4C29A] rounded-xs border border-[#BFAF8A] z-10 ${nowPlaying.release_name ? "h-[24.25rem]" : "h-[23.6rem]"}`}>
|
||||
<div className={`relative w-52 bg-[#D4C29A] rounded-xs border border-[#BFAF8A] z-10 ${nowPlaying.release_name ? "h-[24.1rem]" : "h-[23.6rem]"}`}>
|
||||
{/* Volume buttons */}
|
||||
<div className="absolute -left-[2.55px] top-8 rounded-l w-[1.75px] flex flex-col z-0">
|
||||
<div className="h-8 bg-[#BFAF8A] border-b border-[#A09070] rounded-l cursor-pointer" onClick={() => setVolume(v => Math.min(100, v + 5))}></div> {/* up */}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@
|
|||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/node": "^24.3.3",
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"eslint": "^9.35.0",
|
||||
|
|
|
|||
|
|
@ -1,24 +1,148 @@
|
|||
{
|
||||
"daily": [
|
||||
{
|
||||
"date": "2025-08-12",
|
||||
"inputTokens": 182,
|
||||
"outputTokens": 7432,
|
||||
"cacheCreationTokens": 260815,
|
||||
"cacheReadTokens": 1814702,
|
||||
"totalTokens": 2083131,
|
||||
"totalCost": 1.63449285,
|
||||
"date": "2025-08-08",
|
||||
"inputTokens": 14919,
|
||||
"outputTokens": 23378,
|
||||
"cacheCreationTokens": 480031,
|
||||
"cacheReadTokens": 11034031,
|
||||
"totalTokens": 11552359,
|
||||
"totalCost": 6.777273749999996,
|
||||
"modelsUsed": [
|
||||
"claude-opus-4-1-20250805",
|
||||
"claude-sonnet-4-20250514"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-sonnet-4-20250514",
|
||||
"inputTokens": 182,
|
||||
"outputTokens": 7432,
|
||||
"cacheCreationTokens": 260815,
|
||||
"cacheReadTokens": 1814702,
|
||||
"cost": 1.63449285
|
||||
"inputTokens": 4837,
|
||||
"outputTokens": 20788,
|
||||
"cacheCreationTokens": 443453,
|
||||
"cacheReadTokens": 10661975,
|
||||
"cost": 5.18787225
|
||||
},
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 10082,
|
||||
"outputTokens": 2590,
|
||||
"cacheCreationTokens": 36578,
|
||||
"cacheReadTokens": 372056,
|
||||
"cost": 1.5894014999999997
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-08-09",
|
||||
"inputTokens": 3142,
|
||||
"outputTokens": 20594,
|
||||
"cacheCreationTokens": 513312,
|
||||
"cacheReadTokens": 13270007,
|
||||
"totalTokens": 13807055,
|
||||
"totalCost": 20.561232300000007,
|
||||
"modelsUsed": [
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-opus-4-1-20250805"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 373,
|
||||
"outputTokens": 10485,
|
||||
"cacheCreationTokens": 294339,
|
||||
"cacheReadTokens": 7740261,
|
||||
"cost": 17.92121775
|
||||
},
|
||||
{
|
||||
"modelName": "claude-sonnet-4-20250514",
|
||||
"inputTokens": 2769,
|
||||
"outputTokens": 10109,
|
||||
"cacheCreationTokens": 218973,
|
||||
"cacheReadTokens": 5529746,
|
||||
"cost": 2.640014549999999
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-08-10",
|
||||
"inputTokens": 2384,
|
||||
"outputTokens": 33087,
|
||||
"cacheCreationTokens": 752268,
|
||||
"cacheReadTokens": 12833548,
|
||||
"totalTokens": 13621287,
|
||||
"totalCost": 24.83825640000001,
|
||||
"modelsUsed": [
|
||||
"claude-opus-4-1-20250805",
|
||||
"claude-sonnet-4-20250514"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 983,
|
||||
"outputTokens": 24065,
|
||||
"cacheCreationTokens": 320876,
|
||||
"cacheReadTokens": 9495745,
|
||||
"cost": 22.079662499999998
|
||||
},
|
||||
{
|
||||
"modelName": "claude-sonnet-4-20250514",
|
||||
"inputTokens": 1401,
|
||||
"outputTokens": 9022,
|
||||
"cacheCreationTokens": 431392,
|
||||
"cacheReadTokens": 3337803,
|
||||
"cost": 2.7585938999999993
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-08-11",
|
||||
"inputTokens": 1127,
|
||||
"outputTokens": 23663,
|
||||
"cacheCreationTokens": 746606,
|
||||
"cacheReadTokens": 10310633,
|
||||
"totalTokens": 11082029,
|
||||
"totalCost": 31.256441999999993,
|
||||
"modelsUsed": [
|
||||
"claude-opus-4-1-20250805"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 1127,
|
||||
"outputTokens": 23663,
|
||||
"cacheCreationTokens": 746606,
|
||||
"cacheReadTokens": 10310633,
|
||||
"cost": 31.256441999999993
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-08-12",
|
||||
"inputTokens": 17245,
|
||||
"outputTokens": 164864,
|
||||
"cacheCreationTokens": 2646250,
|
||||
"cacheReadTokens": 49767559,
|
||||
"totalTokens": 52595918,
|
||||
"totalCost": 85.49760780000005,
|
||||
"modelsUsed": [
|
||||
"claude-opus-4-1-20250805",
|
||||
"claude-sonnet-4-20250514"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 13710,
|
||||
"outputTokens": 77330,
|
||||
"cacheCreationTokens": 1413354,
|
||||
"cacheReadTokens": 26762148,
|
||||
"cost": 72.64900950000008
|
||||
},
|
||||
{
|
||||
"modelName": "claude-sonnet-4-20250514",
|
||||
"inputTokens": 3535,
|
||||
"outputTokens": 87534,
|
||||
"cacheCreationTokens": 1232896,
|
||||
"cacheReadTokens": 23005411,
|
||||
"cost": 12.848598300000004
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -658,14 +782,45 @@
|
|||
"cost": 2.6278957499999995
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-09-13",
|
||||
"inputTokens": 461,
|
||||
"outputTokens": 21931,
|
||||
"cacheCreationTokens": 653276,
|
||||
"cacheReadTokens": 5601864,
|
||||
"totalTokens": 6277532,
|
||||
"totalCost": 21.709448999999996,
|
||||
"modelsUsed": [
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-opus-4-1-20250805"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 425,
|
||||
"outputTokens": 21677,
|
||||
"cacheCreationTokens": 623184,
|
||||
"cacheReadTokens": 5496064,
|
||||
"cost": 21.560945999999998
|
||||
},
|
||||
{
|
||||
"modelName": "claude-sonnet-4-20250514",
|
||||
"inputTokens": 36,
|
||||
"outputTokens": 254,
|
||||
"cacheCreationTokens": 30092,
|
||||
"cacheReadTokens": 105800,
|
||||
"cost": 0.148503
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
"inputTokens": 206952,
|
||||
"outputTokens": 1248290,
|
||||
"cacheCreationTokens": 29625753,
|
||||
"cacheReadTokens": 558468397,
|
||||
"totalCost": 785.74532145,
|
||||
"totalTokens": 589549392
|
||||
"inputTokens": 246048,
|
||||
"outputTokens": 1528375,
|
||||
"cacheCreationTokens": 35156681,
|
||||
"cacheReadTokens": 659471337,
|
||||
"totalTokens": 696402441,
|
||||
"totalCost": 974.7510898500002
|
||||
}
|
||||
}
|
||||
|
|
|
|||
237
tools/ccombine.ts
Normal file
237
tools/ccombine.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
type NumberLike = number | undefined | null;
|
||||
|
||||
interface ModelBreakdown {
|
||||
modelName: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
interface DailyEntry {
|
||||
date: string; // YYYY-MM-DD
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
cacheReadTokens: number;
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
modelsUsed?: string[];
|
||||
modelBreakdowns?: ModelBreakdown[];
|
||||
}
|
||||
|
||||
interface Totals {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
cacheReadTokens: number;
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
}
|
||||
|
||||
interface CcFile {
|
||||
daily: DailyEntry[];
|
||||
totals?: Totals;
|
||||
}
|
||||
|
||||
function toNumber(n: NumberLike): number {
|
||||
return typeof n === "number" && Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function computeTotals(daily: DailyEntry[]): Totals {
|
||||
const totals: Totals = {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0,
|
||||
};
|
||||
for (const d of daily) {
|
||||
totals.inputTokens += toNumber(d.inputTokens);
|
||||
totals.outputTokens += toNumber(d.outputTokens);
|
||||
totals.cacheCreationTokens += toNumber(d.cacheCreationTokens);
|
||||
totals.cacheReadTokens += toNumber(d.cacheReadTokens);
|
||||
totals.totalTokens += toNumber(d.totalTokens);
|
||||
totals.totalCost += toNumber(d.totalCost);
|
||||
}
|
||||
return totals;
|
||||
}
|
||||
|
||||
function isReplacementBetter(a: DailyEntry, b: DailyEntry): boolean {
|
||||
const aTokens = toNumber(a.totalTokens);
|
||||
const bTokens = toNumber(b.totalTokens);
|
||||
if (bTokens !== aTokens) return bTokens > aTokens;
|
||||
const aCost = toNumber(a.totalCost);
|
||||
const bCost = toNumber(b.totalCost);
|
||||
if (bCost !== aCost) return bCost > aCost;
|
||||
|
||||
const aBreakdowns = a.modelBreakdowns?.length ?? 0;
|
||||
const bBreakdowns = b.modelBreakdowns?.length ?? 0;
|
||||
if (bBreakdowns !== aBreakdowns) return bBreakdowns > aBreakdowns;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function readJson<T = unknown>(filePath: string): Promise<T> {
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
return JSON.parse(raw) as T;
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function coerceTotals(t: unknown): Totals {
|
||||
const r = isObject(t) ? t : {};
|
||||
return {
|
||||
inputTokens: toNumber(r["inputTokens"] as NumberLike),
|
||||
outputTokens: toNumber(r["outputTokens"] as NumberLike),
|
||||
cacheCreationTokens: toNumber(r["cacheCreationTokens"] as NumberLike),
|
||||
cacheReadTokens: toNumber(r["cacheReadTokens"] as NumberLike),
|
||||
totalTokens: toNumber(r["totalTokens"] as NumberLike),
|
||||
totalCost: toNumber(r["totalCost"] as NumberLike),
|
||||
};
|
||||
}
|
||||
|
||||
function coerceDailyEntry(item: unknown): DailyEntry {
|
||||
const r = isObject(item) ? item : {};
|
||||
|
||||
const modelBreakdownsRaw = Array.isArray(r["modelBreakdowns"]) ? (r["modelBreakdowns"] as unknown[]) : [];
|
||||
const modelBreakdowns: ModelBreakdown[] = modelBreakdownsRaw.map((mb) => {
|
||||
const m = isObject(mb) ? mb : {};
|
||||
return {
|
||||
modelName: typeof m["modelName"] === "string" ? (m["modelName"] as string) : "",
|
||||
inputTokens: toNumber(m["inputTokens"] as NumberLike),
|
||||
outputTokens: toNumber(m["outputTokens"] as NumberLike),
|
||||
cacheCreationTokens: toNumber(m["cacheCreationTokens"] as NumberLike),
|
||||
cacheReadTokens: toNumber(m["cacheReadTokens"] as NumberLike),
|
||||
cost: toNumber(m["cost"] as NumberLike),
|
||||
};
|
||||
});
|
||||
|
||||
const modelsUsed = Array.isArray(r["modelsUsed"]) ? (r["modelsUsed"] as unknown[]).filter((x): x is string => typeof x === "string") : undefined;
|
||||
|
||||
return {
|
||||
date: String((r["date"] as unknown) ?? ""),
|
||||
inputTokens: toNumber(r["inputTokens"] as NumberLike),
|
||||
outputTokens: toNumber(r["outputTokens"] as NumberLike),
|
||||
cacheCreationTokens: toNumber(r["cacheCreationTokens"] as NumberLike),
|
||||
cacheReadTokens: toNumber(r["cacheReadTokens"] as NumberLike),
|
||||
totalTokens: toNumber(r["totalTokens"] as NumberLike),
|
||||
totalCost: toNumber(r["totalCost"] as NumberLike),
|
||||
modelsUsed,
|
||||
modelBreakdowns: modelBreakdowns.length ? modelBreakdowns : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCcShape(obj: unknown): CcFile {
|
||||
const o = isObject(obj) ? obj : {};
|
||||
const rawDaily = Array.isArray(o["daily"]) ? (o["daily"] as unknown[]) : [];
|
||||
const daily = rawDaily.map(coerceDailyEntry);
|
||||
const totals = isObject(o["totals"]) ? coerceTotals(o["totals"]) : undefined;
|
||||
return { daily, totals };
|
||||
}
|
||||
|
||||
function sortByDateAsc(entries: DailyEntry[]): DailyEntry[] {
|
||||
return entries.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 0 || args.includes("-h") || args.includes("--help")) {
|
||||
console.log(`Usage: tsx tools/ccombine.ts <new-cc.json> [--base public/data/cc.json] [--out <out.json>] [--dry]`);
|
||||
process.exit(args.length === 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
let inputPath = "";
|
||||
let basePath = path.join(process.cwd(), "public", "data", "cc.json");
|
||||
let outPath: string | undefined;
|
||||
let dryRun = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === "--base") {
|
||||
basePath = path.resolve(args[++i]);
|
||||
} else if (a === "--out") {
|
||||
outPath = path.resolve(args[++i]);
|
||||
} else if (a === "--dry" || a === "--dry-run") {
|
||||
dryRun = true;
|
||||
} else if (!a.startsWith("-")) {
|
||||
inputPath = path.resolve(a);
|
||||
} else {
|
||||
console.error(`Unknown option: ${a}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!inputPath) {
|
||||
console.error("Error: missing <new-cc.json> input path");
|
||||
process.exit(1);
|
||||
}
|
||||
if (!outPath) outPath = basePath;
|
||||
|
||||
if (!(await fileExists(inputPath))) {
|
||||
console.error(`Error: input file not found: ${inputPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const baseExists = await fileExists(basePath);
|
||||
const baseCc = baseExists ? normalizeCcShape(await readJson(basePath)) : { daily: [] };
|
||||
const newCc = normalizeCcShape(await readJson(inputPath));
|
||||
|
||||
const baseByDate = new Map<string, DailyEntry>();
|
||||
for (const d of baseCc.daily) baseByDate.set(d.date, d);
|
||||
|
||||
const added: string[] = [];
|
||||
const replaced: string[] = [];
|
||||
const unchanged: string[] = [];
|
||||
|
||||
for (const incoming of newCc.daily) {
|
||||
const existing = baseByDate.get(incoming.date);
|
||||
if (!existing) {
|
||||
baseByDate.set(incoming.date, incoming);
|
||||
added.push(incoming.date);
|
||||
continue;
|
||||
}
|
||||
if (isReplacementBetter(existing, incoming)) {
|
||||
baseByDate.set(incoming.date, incoming);
|
||||
replaced.push(incoming.date);
|
||||
} else {
|
||||
unchanged.push(incoming.date);
|
||||
}
|
||||
}
|
||||
|
||||
const mergedDaily = sortByDateAsc(Array.from(baseByDate.values()));
|
||||
const totals = computeTotals(mergedDaily);
|
||||
const merged: CcFile = { daily: mergedDaily, totals };
|
||||
|
||||
if (dryRun) {
|
||||
console.log("[ccombine] Dry run. No files written.");
|
||||
} else {
|
||||
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
||||
await fs.writeFile(outPath, JSON.stringify(merged, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
const outDisplay = dryRun ? "(dry run)" : outPath;
|
||||
console.log("[ccombine] Output:", outDisplay);
|
||||
console.log(`[ccombine] Added: ${added.length} | Replaced: ${replaced.length} | Unchanged (overlap): ${unchanged.length}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("[ccombine] Error:", err?.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue