diff --git a/app/ai/claude/components/Activity.tsx b/app/ai/claude/components/Activity.tsx new file mode 100644 index 0000000..1838500 --- /dev/null +++ b/app/ai/claude/components/Activity.tsx @@ -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 ( +
+
+

Activity

+
+ {viewMode === 'heatmap' ? 'Heatmap' : 'Chart'} + +
+
+ {viewMode === 'heatmap' ? ( +
+
+
+
+
+ {['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((day) => ( +
+ {day} +
+ ))} +
+
+
+ {(() => { + 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 ( +
+ {monthLabels.map((label, idx) => ( +
+ {label.month} +
+ ))} +
+ ) + })()} +
+
+ {heatmapWeeks.map((week, weekIndex) => ( +
+ {week.map((day, dayIndex) => ( +
+
+ {day && ( +
+
+

{day.formattedDate}

+

Cost: ${day.cost.toFixed(2)}

+

Tokens: {(day.tokens / 1000000).toFixed(2)}M

+
+
+ )} +
+ ))} +
+ ))} +
+
+
+
+ Less +
+
+
+
+
+
+
+ More +
+
+
+ ) : ( + <> +
+ + +
+ + + + + + selectedMetric === 'cost' ? formatCurrency(value) : formatTokens(value)} + /> + + + + + )} +
+ ) +} + diff --git a/app/ai/claude/components/LoadingSkeleton.tsx b/app/ai/claude/components/LoadingSkeleton.tsx new file mode 100644 index 0000000..5b5bc01 --- /dev/null +++ b/app/ai/claude/components/LoadingSkeleton.tsx @@ -0,0 +1,177 @@ +"use client" + +import PageHeader from './PageHeader' + +export default function LoadingSkeleton() { + return ( +
+ + +
+
+

Total Cost

+
+
+
+

Total Tokens

+
+
+
+

Days Active

+
+
+
+
+
+
+

Avg Daily Cost

+
+
+
+ +
+
+
+

Activity

+
+ Heatmap +
+
+
+
+
+
+
+
+ {['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((day) => ( +
+ {day} +
+ ))} +
+
+
+
+ {['Jan', 'Mar', 'May', 'Jul', 'Sep', 'Nov'].map((month) => ( +
+ ))} +
+
+
+ {(() => { + 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) => ( +
+ {[...Array(7)].map((_, dayIndex) => ( +
+ ))} +
+ )) + })()} +
+
+
+
+ Less +
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+ More +
+
+
+
+
+ +
+
+

Model Usage Distribution

+
+
+
+ {[...Array(3)].map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+
+ Total Models Used +
+
+
+ Most Used +
+
+
+
+
+
+
+

Token Type Breakdown

+
+
+
+

Token Composition

+
+
+
+ +
+
+

Recent Sessions

+
+ + + + + + + + + + + {[...Array(5)].map((_, index) => ( + + + + + + + ))} + +
DateModels UsedTotal TokensCost
+
+
+
+
+
+
+
+
+
+
+
+
+ ) +} + diff --git a/app/ai/claude/components/ModelUsageCard.tsx b/app/ai/claude/components/ModelUsageCard.tsx new file mode 100644 index 0000000..92518d4 --- /dev/null +++ b/app/ai/claude/components/ModelUsageCard.tsx @@ -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 ( +
+

Model Usage Distribution

+
+ + + + {modelUsageData.map((_entry, index) => ( + + ))} + + formatCurrency(value)} + labelStyle={{ color: '#fff' }} + itemStyle={{ color: '#fff' }} + /> + + +
+ {modelUsageData.map((model, index) => { + const percentage = ((model.value / Math.max(totalCost, 1)) * 100).toFixed(1) + return ( +
+
+
+ {model.name} +
+
+ {percentage}% + ${model.value.toFixed(2)} +
+
+ ) + })} +
+
+ Total Models Used + {modelUsageData.length} +
+
+ Most Used + + {modelUsageData[0]?.name} + +
+
+
+
+
+ ) +} + diff --git a/app/ai/claude/components/PageHeader.tsx b/app/ai/claude/components/PageHeader.tsx new file mode 100644 index 0000000..8b94c94 --- /dev/null +++ b/app/ai/claude/components/PageHeader.tsx @@ -0,0 +1,26 @@ +"use client" + +import Link from 'next/link' +import { SiClaude } from 'react-icons/si' + +export default function PageHeader() { + return ( + <> + + ← Back to AI + + +
+
+ +
+

Claude Code Usage

+

How much I use Claude Code!

+
+ + ) +} + diff --git a/app/ai/claude/components/RecentSessions.tsx b/app/ai/claude/components/RecentSessions.tsx new file mode 100644 index 0000000..cc0d991 --- /dev/null +++ b/app/ai/claude/components/RecentSessions.tsx @@ -0,0 +1,37 @@ +"use client" + +import { DailyData } from './types' +import { getModelLabel } from './utils' + +export default function RecentSessions({ daily }: { daily: DailyData[] }) { + return ( +
+

Recent Sessions

+
+ + + + + + + + + + + {daily.slice(-5).reverse().map((day, index) => ( + + + + + + + ))} + +
DateModels UsedTotal TokensCost
{new Date(day.date + 'T00:00:00').toLocaleDateString()} + {day.modelsUsed.map(getModelLabel).join(', ')} + {(day.totalTokens / 1000000).toFixed(2)}M${day.totalCost.toFixed(2)}
+
+
+ ) +} + diff --git a/app/ai/claude/components/StatsGrid.tsx b/app/ai/claude/components/StatsGrid.tsx new file mode 100644 index 0000000..2ff4298 --- /dev/null +++ b/app/ai/claude/components/StatsGrid.tsx @@ -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 ( +
+
+

Total Cost

+

${totals.totalCost.toFixed(2)}

+
+
+

Total Tokens

+

{(totals.totalTokens / 1000000).toFixed(1)}M

+
+
+

Days Active

+

+ {daily.length} + + 🔥 {formatStreakCompact(streak)} + +

+
+
+

Avg Daily Cost

+

${(totals.totalCost / Math.max(daily.length, 1)).toFixed(2)}

+
+
+ ) +} + diff --git a/app/ai/claude/components/TokenComposition.tsx b/app/ai/claude/components/TokenComposition.tsx new file mode 100644 index 0000000..d3569f0 --- /dev/null +++ b/app/ai/claude/components/TokenComposition.tsx @@ -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 ( +
+

Token Composition

+ + + + + `${value}K`} /> + `${value.toFixed(1)}K tokens`} + /> + + + + + + +
+ ) +} + diff --git a/app/ai/claude/components/TokenTypeBreakdown.tsx b/app/ai/claude/components/TokenTypeBreakdown.tsx new file mode 100644 index 0000000..c8625d8 --- /dev/null +++ b/app/ai/claude/components/TokenTypeBreakdown.tsx @@ -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 ( +
+

Token Type Breakdown

+ + + + + `${(value / 1000000).toFixed(0)}M`} /> + `${(value / 1000000).toFixed(2)}M tokens`} + /> + + + +
+ ) +} + diff --git a/app/ai/claude/components/types.ts b/app/ai/claude/components/types.ts new file mode 100644 index 0000000..7000dca --- /dev/null +++ b/app/ai/claude/components/types.ts @@ -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 +} + diff --git a/app/ai/claude/components/utils.ts b/app/ai/claude/components/utils.ts new file mode 100644 index 0000000..d8d706e --- /dev/null +++ b/app/ai/claude/components/utils.ts @@ -0,0 +1,191 @@ +import { CCData, DailyData, HeatmapDay } from './types' + +export const COLORS = ['#c15f3c', '#b1ada1', '#f4f3ee', '#c15f3c', '#b1ada1', '#f4f3ee'] + +export const MODEL_LABELS: Record = { + '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( + 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() + 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 }, +]) + diff --git a/app/ai/claude/page.tsx b/app/ai/claude/page.tsx index fb549da..db934a8 100644 --- a/app/ai/claude/page.tsx +++ b/app/ai/claude/page.tsx @@ -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(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const [selectedMetric, setSelectedMetric] = useState<'cost' | 'tokens'>('cost') useEffect(() => { fetch('/data/cc.json') @@ -84,104 +38,7 @@ export default function AI() { return (
-
- - ← Back to AI - - -
-
- -
-

Claude Code Usage

-

How much I use Claude Code!

-
- -
-
-

Total Cost

-
-
-
-

Total Tokens

-
-
-
-

Days Active

-
-
-
-

Avg Daily Cost

-
-
-
- -
-
-

Daily Usage Trend

-
- - -
-
-
-
-

Model Usage Distribution

-
-
-
-

Token Type Breakdown

-
-
-
-

Daily Token Composition

-
-
-
- -
-
-

Recent Sessions

-
- - - - - - - - - - - {[...Array(5)].map((_, index) => ( - - - - - - - ))} - -
DateModels UsedTotal TokensCost
-
-
-
-
-
-
-
-
-
-
-
-
+
) @@ -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 (
- - ← Back to AI - -
-
- -
-

Claude Code Usage

-

How much I use Claude Code!

-
+ -
-
-

Total Cost

-

${data.totals.totalCost.toFixed(2)}

-
-
-

Total Tokens

-

{(data.totals.totalTokens / 1000000).toFixed(1)}M

-
-
-

Days Active

-

{data.daily.length}

-
-
-

Avg Daily Cost

-

${(data.totals.totalCost / data.daily.length).toFixed(2)}

-
+ + +
+
-
-

Daily Usage Trend

-
- - -
- - - - - - selectedMetric === 'cost' ? formatCurrency(value) : formatTokens(value)} - /> - - - -
- -
-

Model Usage Distribution

-
- - - - {modelUsageData.map((entry, index) => ( - - ))} - - formatCurrency(value)} - labelStyle={{ color: '#fff' }} - itemStyle={{ color: '#fff' }} - /> - - -
- {modelUsageData.map((model, index) => { - const percentage = ((model.value / data.totals.totalCost) * 100).toFixed(1) - return ( -
-
-
- {model.name} -
-
- {percentage}% - ${model.value.toFixed(2)} -
-
- ) - })} -
-
- Total Models Used - {modelUsageData.length} -
-
- Most Used - - {modelUsageData[0]?.name} - -
-
-
-
-
- -
-

Token Type Breakdown

- - - - - `${(value / 1000000).toFixed(0)}M`} /> - `${(value / 1000000).toFixed(2)}M tokens`} - /> - - - -
- -
-

Daily Token Composition

- - - - - `${value}K`} /> - `${value.toFixed(1)}K tokens`} - /> - - - - - - -
+ + +
-
-

Recent Sessions

-
- - - - - - - - - - - {data.daily.slice(-5).reverse().map((day, index) => ( - - - - - - - ))} - -
DateModels UsedTotal TokensCost
{new Date(day.date + 'T00:00:00').toLocaleDateString()} - {day.modelsUsed.join(', ')} - {(day.totalTokens / 1000000).toFixed(2)}M${day.totalCost.toFixed(2)}
-
-
+
) -} \ No newline at end of file +} + diff --git a/app/ai/components/AIStack.tsx b/app/ai/components/AIStack.tsx index 7a62a05..2abaf6b 100644 --- a/app/ai/components/AIStack.tsx +++ b/app/ai/components/AIStack.tsx @@ -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 (
@@ -38,15 +44,35 @@ export default function AIStack({ tools }: AIStackProps) { {tools.map((tool, index) => (
-
+
{tool.icon && } {tool.svg && (
{tool.svg}
)} -
-

{tool.name}

+
+
+

{tool.name}

+ {tool.price !== undefined && ( +
+ {tool.discountedPrice !== undefined ? ( + <> + + {formatPrice(tool.price)} + + + {formatPrice(tool.discountedPrice)} + + + ) : ( + + {formatPrice(tool.price)} + + )} +
+ )} +

{tool.description}

@@ -58,7 +84,7 @@ export default function AIStack({ tools }: AIStackProps) { {tool.link && ( - View → + Visit → )} {tool.usage && ( diff --git a/app/ai/components/TopPick.tsx b/app/ai/components/TopPick.tsx index e7e251b..b58daa7 100644 --- a/app/ai/components/TopPick.tsx +++ b/app/ai/components/TopPick.tsx @@ -17,6 +17,9 @@ export default function TopPick() {

Claude

by Anthropic

+ + Visit + My Usage @@ -31,8 +34,8 @@ export default function TopPick() {
Top-Tier Tool Calling - Max Plan is High Value - Fast Interface + High-Value Plans + Good Speed
diff --git a/app/ai/data.tsx b/app/ai/data.tsx index d7c395c..3c457ca 100644 --- a/app/ai/data.tsx +++ b/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 */ + + Z.ai + + + ), + 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: v0, - description: "Generating boilerplate UIs", - status: "occasional", - link: "https://v0.dev/" - }, - { - name: "Qwen", + name: "Qwen Chat", svg: ( @@ -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: ( + + + + + + + + + + + + + ), + 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: v0, + 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" }, -] \ No newline at end of file +] diff --git a/app/ai/types.ts b/app/ai/types.ts index 034db85..5bf7aae 100644 --- a/app/ai/types.ts +++ b/app/ai/types.ts @@ -6,6 +6,8 @@ export interface AITool { status: 'primary' | 'active' | 'occasional' | string; link?: string; usage?: string; + price?: number; + discountedPrice?: number; } export interface FavoriteModel { diff --git a/app/globals.css b/app/globals.css index e05ccc7..9b1c661 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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 { diff --git a/components/widgets/NowPlaying.tsx b/components/widgets/NowPlaying.tsx index 0f1b9b3..1f96af0 100644 --- a/components/widgets/NowPlaying.tsx +++ b/components/widgets/NowPlaying.tsx @@ -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 = () => {
{nowPlaying.message || 'Connecting...'}
- 0 ? (progressSteps.current * 100) / progressSteps.total : 0} - className="h-1" + className="h-1" />
@@ -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%)'}} >
- + {nowPlaying.release_name && }
@@ -182,7 +182,7 @@ const NowPlaying: React.FC = () => { return (
-
+
{/* Volume buttons */}
setVolume(v => Math.min(100, v + 5))}>
{/* up */} diff --git a/package.json b/package.json index 3f1d539..1738e98 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/data/cc.json b/public/data/cc.json index 7acbffb..67f84fa 100644 --- a/public/data/cc.json +++ b/public/data/cc.json @@ -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 } } diff --git a/tools/ccombine.ts b/tools/ccombine.ts new file mode 100644 index 0000000..ab9ff23 --- /dev/null +++ b/tools/ccombine.ts @@ -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(filePath: string): Promise { + const raw = await fs.readFile(filePath, "utf8"); + return JSON.parse(raw) as T; +} + +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +function isObject(value: unknown): value is Record { + 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 [--base public/data/cc.json] [--out ] [--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 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(); + 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); +});