From 77a6266c71b7658251d32e845c53a758ff073a6b Mon Sep 17 00:00:00 2001 From: Aidan Date: Sat, 13 Sep 2025 02:46:53 -0400 Subject: [PATCH] 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) --- app/ai/claude/components/Activity.tsx | 172 ++++++++ app/ai/claude/components/LoadingSkeleton.tsx | 177 ++++++++ app/ai/claude/components/ModelUsageCard.tsx | 73 ++++ app/ai/claude/components/PageHeader.tsx | 26 ++ app/ai/claude/components/RecentSessions.tsx | 37 ++ app/ai/claude/components/StatsGrid.tsx | 34 ++ app/ai/claude/components/TokenComposition.tsx | 30 ++ .../claude/components/TokenTypeBreakdown.tsx | 27 ++ app/ai/claude/components/types.ts | 42 ++ app/ai/claude/components/utils.ts | 191 +++++++++ app/ai/claude/page.tsx | 404 +----------------- app/ai/components/AIStack.tsx | 38 +- app/ai/components/TopPick.tsx | 7 +- app/ai/data.tsx | 115 +++-- app/ai/types.ts | 2 + app/globals.css | 7 +- components/widgets/NowPlaying.tsx | 12 +- package.json | 2 +- public/data/cc.json | 191 ++++++++- tools/ccombine.ts | 237 ++++++++++ 20 files changed, 1380 insertions(+), 444 deletions(-) create mode 100644 app/ai/claude/components/Activity.tsx create mode 100644 app/ai/claude/components/LoadingSkeleton.tsx create mode 100644 app/ai/claude/components/ModelUsageCard.tsx create mode 100644 app/ai/claude/components/PageHeader.tsx create mode 100644 app/ai/claude/components/RecentSessions.tsx create mode 100644 app/ai/claude/components/StatsGrid.tsx create mode 100644 app/ai/claude/components/TokenComposition.tsx create mode 100644 app/ai/claude/components/TokenTypeBreakdown.tsx create mode 100644 app/ai/claude/components/types.ts create mode 100644 app/ai/claude/components/utils.ts create mode 100644 tools/ccombine.ts 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); +});