agentfit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +26 -0
- package/.prettierignore +7 -0
- package/.prettierrc +11 -0
- package/CONTRIBUTING.md +209 -0
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/app/(dashboard)/coach/page.tsx +11 -0
- package/app/(dashboard)/commands/page.tsx +7 -0
- package/app/(dashboard)/community/[slug]/page.tsx +23 -0
- package/app/(dashboard)/community/page.tsx +71 -0
- package/app/(dashboard)/daily/page.tsx +19 -0
- package/app/(dashboard)/images/page.tsx +5 -0
- package/app/(dashboard)/layout.tsx +12 -0
- package/app/(dashboard)/page.tsx +23 -0
- package/app/(dashboard)/personality/page.tsx +11 -0
- package/app/(dashboard)/projects/page.tsx +11 -0
- package/app/(dashboard)/sessions/page.tsx +11 -0
- package/app/(dashboard)/tokens/page.tsx +11 -0
- package/app/(dashboard)/tools/page.tsx +11 -0
- package/app/api/check/route.ts +13 -0
- package/app/api/commands/route.ts +16 -0
- package/app/api/images/[...path]/route.ts +33 -0
- package/app/api/images-analysis/route.ts +177 -0
- package/app/api/sync/route.ts +14 -0
- package/app/api/usage/route.ts +117 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +144 -0
- package/app/icon.svg +3 -0
- package/app/layout.tsx +35 -0
- package/bin/agentfit.mjs +69 -0
- package/components/.gitkeep +0 -0
- package/components/agent-coach.tsx +248 -0
- package/components/app-sidebar.tsx +161 -0
- package/components/command-usage.tsx +294 -0
- package/components/daily-chart.tsx +118 -0
- package/components/daily-table.tsx +115 -0
- package/components/dashboard-shell.tsx +149 -0
- package/components/data-provider.tsx +213 -0
- package/components/fitness-score.tsx +95 -0
- package/components/overview-cards.tsx +198 -0
- package/components/pagination-controls.tsx +104 -0
- package/components/personality-fit.tsx +446 -0
- package/components/projects-table.tsx +70 -0
- package/components/screenshots-analysis.tsx +359 -0
- package/components/sessions-table.tsx +97 -0
- package/components/theme-provider.tsx +71 -0
- package/components/token-breakdown.tsx +179 -0
- package/components/tool-usage-chart.tsx +63 -0
- package/components/ui/badge.tsx +52 -0
- package/components/ui/button.tsx +60 -0
- package/components/ui/card.tsx +103 -0
- package/components/ui/chart.tsx +373 -0
- package/components/ui/dialog.tsx +160 -0
- package/components/ui/input.tsx +20 -0
- package/components/ui/scroll-area.tsx +55 -0
- package/components/ui/select.tsx +201 -0
- package/components/ui/separator.tsx +25 -0
- package/components/ui/sheet.tsx +138 -0
- package/components/ui/sidebar.tsx +723 -0
- package/components/ui/skeleton.tsx +13 -0
- package/components/ui/table.tsx +116 -0
- package/components/ui/tabs.tsx +82 -0
- package/components/ui/tooltip.tsx +66 -0
- package/components.json +25 -0
- package/generated/prisma/browser.ts +34 -0
- package/generated/prisma/client.ts +58 -0
- package/generated/prisma/commonInputTypes.ts +237 -0
- package/generated/prisma/enums.ts +15 -0
- package/generated/prisma/internal/class.ts +224 -0
- package/generated/prisma/internal/prismaNamespace.ts +920 -0
- package/generated/prisma/internal/prismaNamespaceBrowser.ts +130 -0
- package/generated/prisma/models/Image.ts +1310 -0
- package/generated/prisma/models/Session.ts +1695 -0
- package/generated/prisma/models/SyncLog.ts +1203 -0
- package/generated/prisma/models.ts +14 -0
- package/hooks/.gitkeep +0 -0
- package/hooks/use-mobile.ts +19 -0
- package/hooks/use-pagination.ts +60 -0
- package/lib/.gitkeep +0 -0
- package/lib/coach.ts +425 -0
- package/lib/commands.ts +239 -0
- package/lib/db.ts +15 -0
- package/lib/format.ts +26 -0
- package/lib/parse-codex.ts +201 -0
- package/lib/parse-logs.ts +369 -0
- package/lib/personality.ts +481 -0
- package/lib/plugins.ts +107 -0
- package/lib/pricing.ts +112 -0
- package/lib/queries-codex.ts +130 -0
- package/lib/queries.ts +154 -0
- package/lib/resolve-icon.ts +12 -0
- package/lib/sync.ts +335 -0
- package/lib/utils.ts +6 -0
- package/next.config.mjs +4 -0
- package/package.json +73 -0
- package/plugins/cost-heatmap/component.test.tsx +52 -0
- package/plugins/cost-heatmap/component.tsx +227 -0
- package/plugins/cost-heatmap/manifest.ts +13 -0
- package/plugins/index.ts +18 -0
- package/prisma/migrations/20260328152517_init/migration.sql +41 -0
- package/prisma/migrations/20260328153801_add_image_model/migration.sql +18 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +57 -0
- package/prisma.config.ts +14 -0
- package/public/.gitkeep +0 -0
- package/public/logo.svg +3 -0
- package/setup.sh +73 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useEffect, useState, useCallback, useMemo, type ReactNode } from 'react'
|
|
4
|
+
import type { UsageData, SessionSummary, DailyUsage, ProjectSummary, OverviewStats } from '@/lib/parse-logs'
|
|
5
|
+
|
|
6
|
+
export type AgentType = 'claude' | 'codex' | 'combined'
|
|
7
|
+
export type TimeRange = '7d' | '30d' | '90d' | 'all'
|
|
8
|
+
|
|
9
|
+
interface SyncResult {
|
|
10
|
+
sessionsAdded: number
|
|
11
|
+
sessionsSkipped: number
|
|
12
|
+
filesProcessed: number
|
|
13
|
+
imagesExtracted: number
|
|
14
|
+
errors: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface DataContextValue {
|
|
18
|
+
data: UsageData | null
|
|
19
|
+
loading: boolean
|
|
20
|
+
error: string | null
|
|
21
|
+
agent: AgentType
|
|
22
|
+
setAgent: (agent: AgentType) => void
|
|
23
|
+
timeRange: TimeRange
|
|
24
|
+
setTimeRange: (range: TimeRange) => void
|
|
25
|
+
syncing: boolean
|
|
26
|
+
lastSyncResult: SyncResult | null
|
|
27
|
+
lastSyncTime: Date | null
|
|
28
|
+
handleSync: () => Promise<void>
|
|
29
|
+
newSessionsAvailable: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DataContext = createContext<DataContextValue | null>(null)
|
|
33
|
+
|
|
34
|
+
export function useData() {
|
|
35
|
+
const ctx = useContext(DataContext)
|
|
36
|
+
if (!ctx) throw new Error('useData must be used within DataProvider')
|
|
37
|
+
return ctx
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const RANGE_DAYS: Record<TimeRange, number> = {
|
|
41
|
+
'7d': 7,
|
|
42
|
+
'30d': 30,
|
|
43
|
+
'90d': 90,
|
|
44
|
+
'all': Infinity,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function filterByTimeRange(raw: UsageData | null, range: TimeRange): UsageData | null {
|
|
48
|
+
if (!raw || range === 'all') return raw
|
|
49
|
+
|
|
50
|
+
const days = RANGE_DAYS[range]
|
|
51
|
+
const cutoff = new Date()
|
|
52
|
+
cutoff.setDate(cutoff.getDate() - days)
|
|
53
|
+
const cutoffISO = cutoff.toISOString()
|
|
54
|
+
|
|
55
|
+
// Filter sessions
|
|
56
|
+
const sessions = raw.sessions.filter(s => s.startTime >= cutoffISO)
|
|
57
|
+
|
|
58
|
+
// Re-aggregate from filtered sessions
|
|
59
|
+
const projectMap = new Map<string, ProjectSummary>()
|
|
60
|
+
const dailyMap = new Map<string, DailyUsage>()
|
|
61
|
+
const toolUsage: Record<string, number> = {}
|
|
62
|
+
const models: Record<string, number> = {}
|
|
63
|
+
|
|
64
|
+
for (const s of sessions) {
|
|
65
|
+
// Projects
|
|
66
|
+
if (!projectMap.has(s.project)) {
|
|
67
|
+
projectMap.set(s.project, {
|
|
68
|
+
name: s.project,
|
|
69
|
+
path: s.projectPath,
|
|
70
|
+
sessions: 0,
|
|
71
|
+
totalMessages: 0,
|
|
72
|
+
totalTokens: 0,
|
|
73
|
+
totalCost: 0,
|
|
74
|
+
totalDurationMinutes: 0,
|
|
75
|
+
toolCalls: {},
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
const proj = projectMap.get(s.project)!
|
|
79
|
+
proj.sessions++
|
|
80
|
+
proj.totalMessages += s.totalMessages
|
|
81
|
+
proj.totalTokens += s.totalTokens
|
|
82
|
+
proj.totalCost += s.costUSD
|
|
83
|
+
proj.totalDurationMinutes += s.durationMinutes
|
|
84
|
+
for (const [tool, count] of Object.entries(s.toolCalls)) {
|
|
85
|
+
proj.toolCalls[tool] = (proj.toolCalls[tool] || 0) + count
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Daily
|
|
89
|
+
const date = s.startTime.slice(0, 10)
|
|
90
|
+
if (!dailyMap.has(date)) {
|
|
91
|
+
dailyMap.set(date, {
|
|
92
|
+
date, sessions: 0, messages: 0,
|
|
93
|
+
inputTokens: 0, outputTokens: 0,
|
|
94
|
+
cacheCreationTokens: 0, cacheReadTokens: 0,
|
|
95
|
+
totalTokens: 0, costUSD: 0, toolCalls: 0,
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
const day = dailyMap.get(date)!
|
|
99
|
+
day.sessions++
|
|
100
|
+
day.messages += s.totalMessages
|
|
101
|
+
day.inputTokens += s.inputTokens
|
|
102
|
+
day.outputTokens += s.outputTokens
|
|
103
|
+
day.cacheCreationTokens += s.cacheCreationTokens
|
|
104
|
+
day.cacheReadTokens += s.cacheReadTokens
|
|
105
|
+
day.totalTokens += s.totalTokens
|
|
106
|
+
day.costUSD += s.costUSD
|
|
107
|
+
day.toolCalls += s.toolCallsTotal
|
|
108
|
+
|
|
109
|
+
// Tools
|
|
110
|
+
for (const [tool, count] of Object.entries(s.toolCalls)) {
|
|
111
|
+
toolUsage[tool] = (toolUsage[tool] || 0) + count
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Models
|
|
115
|
+
models[s.model] = (models[s.model] || 0) + 1
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const projects = Array.from(projectMap.values()).sort((a, b) => b.totalCost - a.totalCost)
|
|
119
|
+
const daily = Array.from(dailyMap.values()).sort((a, b) => a.date.localeCompare(b.date))
|
|
120
|
+
|
|
121
|
+
const overview: OverviewStats = {
|
|
122
|
+
totalSessions: sessions.length,
|
|
123
|
+
totalProjects: projects.length,
|
|
124
|
+
totalMessages: sessions.reduce((a, s) => a + s.totalMessages, 0),
|
|
125
|
+
totalUserMessages: sessions.reduce((a, s) => a + s.userMessages, 0),
|
|
126
|
+
totalAssistantMessages: sessions.reduce((a, s) => a + s.assistantMessages, 0),
|
|
127
|
+
totalInputTokens: sessions.reduce((a, s) => a + s.inputTokens, 0),
|
|
128
|
+
totalOutputTokens: sessions.reduce((a, s) => a + s.outputTokens, 0),
|
|
129
|
+
totalCacheCreationTokens: sessions.reduce((a, s) => a + s.cacheCreationTokens, 0),
|
|
130
|
+
totalCacheReadTokens: sessions.reduce((a, s) => a + s.cacheReadTokens, 0),
|
|
131
|
+
totalTokens: sessions.reduce((a, s) => a + s.totalTokens, 0),
|
|
132
|
+
totalCostUSD: sessions.reduce((a, s) => a + s.costUSD, 0),
|
|
133
|
+
totalDurationMinutes: sessions.reduce((a, s) => a + s.durationMinutes, 0),
|
|
134
|
+
totalToolCalls: sessions.reduce((a, s) => a + s.toolCallsTotal, 0),
|
|
135
|
+
models,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { overview, sessions, projects, daily, toolUsage }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function DataProvider({ children }: { children: ReactNode }) {
|
|
142
|
+
const [rawData, setRawData] = useState<UsageData | null>(null)
|
|
143
|
+
const [loading, setLoading] = useState(true)
|
|
144
|
+
const [error, setError] = useState<string | null>(null)
|
|
145
|
+
const [agent, setAgentState] = useState<AgentType>('claude')
|
|
146
|
+
const [timeRange, setTimeRange] = useState<TimeRange>('all')
|
|
147
|
+
const [syncing, setSyncing] = useState(false)
|
|
148
|
+
const [lastSyncResult, setLastSyncResult] = useState<SyncResult | null>(null)
|
|
149
|
+
const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null)
|
|
150
|
+
const [newSessionsAvailable, setNewSessionsAvailable] = useState(0)
|
|
151
|
+
|
|
152
|
+
const data = useMemo(() => filterByTimeRange(rawData, timeRange), [rawData, timeRange])
|
|
153
|
+
|
|
154
|
+
const fetchData = useCallback(async (selectedAgent: AgentType) => {
|
|
155
|
+
try {
|
|
156
|
+
const res = await fetch(`/api/usage?agent=${selectedAgent}`)
|
|
157
|
+
const d = await res.json()
|
|
158
|
+
setRawData(d)
|
|
159
|
+
setError(null)
|
|
160
|
+
} catch (e) {
|
|
161
|
+
setError((e as Error).message)
|
|
162
|
+
}
|
|
163
|
+
}, [])
|
|
164
|
+
|
|
165
|
+
const handleSync = useCallback(async () => {
|
|
166
|
+
setSyncing(true)
|
|
167
|
+
try {
|
|
168
|
+
if (agent !== 'codex') {
|
|
169
|
+
const res = await fetch('/api/sync', { method: 'POST' })
|
|
170
|
+
const result: SyncResult = await res.json()
|
|
171
|
+
setLastSyncResult(result)
|
|
172
|
+
setLastSyncTime(new Date())
|
|
173
|
+
} else {
|
|
174
|
+
setLastSyncTime(new Date())
|
|
175
|
+
setLastSyncResult(null)
|
|
176
|
+
}
|
|
177
|
+
await fetchData(agent)
|
|
178
|
+
setNewSessionsAvailable(0)
|
|
179
|
+
} catch (e) {
|
|
180
|
+
setError((e as Error).message)
|
|
181
|
+
} finally {
|
|
182
|
+
setSyncing(false)
|
|
183
|
+
}
|
|
184
|
+
}, [fetchData, agent])
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
const setAgent = useCallback((newAgent: AgentType) => {
|
|
189
|
+
setAgentState(newAgent)
|
|
190
|
+
fetchData(newAgent)
|
|
191
|
+
}, [fetchData])
|
|
192
|
+
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
async function init() {
|
|
195
|
+
setLoading(true)
|
|
196
|
+
await handleSync()
|
|
197
|
+
setLoading(false)
|
|
198
|
+
}
|
|
199
|
+
init()
|
|
200
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
201
|
+
}, [])
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<DataContext.Provider value={{
|
|
205
|
+
data, loading, error, agent, setAgent,
|
|
206
|
+
timeRange, setTimeRange,
|
|
207
|
+
syncing, lastSyncResult, lastSyncTime, handleSync,
|
|
208
|
+
newSessionsAvailable,
|
|
209
|
+
}}>
|
|
210
|
+
{children}
|
|
211
|
+
</DataContext.Provider>
|
|
212
|
+
)
|
|
213
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react'
|
|
4
|
+
import Link from 'next/link'
|
|
5
|
+
import { Card, CardContent } from '@/components/ui/card'
|
|
6
|
+
import type { UsageData } from '@/lib/parse-logs'
|
|
7
|
+
import { generateCoachInsights } from '@/lib/coach'
|
|
8
|
+
import { ArrowRight, Trophy, AlertTriangle, Lightbulb } from 'lucide-react'
|
|
9
|
+
|
|
10
|
+
function ScoreRing({ score, size = 120 }: { score: number; size?: number }) {
|
|
11
|
+
const radius = (size - 16) / 2
|
|
12
|
+
const circumference = 2 * Math.PI * radius
|
|
13
|
+
|
|
14
|
+
const getColor = (s: number) => {
|
|
15
|
+
if (s >= 85) return 'var(--chart-2)'
|
|
16
|
+
if (s >= 70) return 'var(--chart-1)'
|
|
17
|
+
if (s >= 55) return 'var(--chart-3)'
|
|
18
|
+
return 'var(--chart-5)'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
|
23
|
+
<circle
|
|
24
|
+
cx={size / 2} cy={size / 2} r={radius}
|
|
25
|
+
fill="none"
|
|
26
|
+
stroke="var(--muted)"
|
|
27
|
+
strokeWidth="8"
|
|
28
|
+
/>
|
|
29
|
+
<circle
|
|
30
|
+
cx={size / 2} cy={size / 2} r={radius}
|
|
31
|
+
fill="none"
|
|
32
|
+
stroke={getColor(score)}
|
|
33
|
+
strokeWidth="8"
|
|
34
|
+
strokeLinecap="round"
|
|
35
|
+
strokeDasharray={circumference}
|
|
36
|
+
strokeDashoffset={circumference - (score / 100) * circumference}
|
|
37
|
+
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
|
38
|
+
className="transition-all duration-1000"
|
|
39
|
+
/>
|
|
40
|
+
<text x={size / 2} y={size / 2 - 4} textAnchor="middle" dominantBaseline="middle" className="fill-foreground text-2xl font-bold" style={{ fontSize: size * 0.22 }}>
|
|
41
|
+
{score}
|
|
42
|
+
</text>
|
|
43
|
+
<text x={size / 2} y={size / 2 + 16} textAnchor="middle" className="fill-muted-foreground" style={{ fontSize: size * 0.09 }}>
|
|
44
|
+
/ 100
|
|
45
|
+
</text>
|
|
46
|
+
</svg>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function FitnessScore({ data }: { data: UsageData }) {
|
|
51
|
+
const coach = useMemo(() => generateCoachInsights(data), [data])
|
|
52
|
+
|
|
53
|
+
const achievements = coach.insights.filter(i => i.severity === 'achievement').length
|
|
54
|
+
const warnings = coach.insights.filter(i => i.severity === 'warning').length
|
|
55
|
+
const tips = coach.insights.filter(i => i.severity === 'tip').length
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<Card>
|
|
59
|
+
<CardContent className="flex items-center gap-6 pt-6">
|
|
60
|
+
<ScoreRing score={coach.score} />
|
|
61
|
+
<div className="flex-1 space-y-3">
|
|
62
|
+
<div>
|
|
63
|
+
<div className="text-lg font-semibold">Fitness Score: {coach.scoreLabel}</div>
|
|
64
|
+
<p className="text-sm text-muted-foreground">
|
|
65
|
+
Based on cost efficiency, tool habits, streaks, and context management
|
|
66
|
+
</p>
|
|
67
|
+
</div>
|
|
68
|
+
<div className="flex items-center gap-4 text-sm">
|
|
69
|
+
{achievements > 0 && (
|
|
70
|
+
<span className="flex items-center gap-1 text-chart-2">
|
|
71
|
+
<Trophy className="h-3.5 w-3.5" /> {achievements} achievement{achievements !== 1 && 's'}
|
|
72
|
+
</span>
|
|
73
|
+
)}
|
|
74
|
+
{warnings > 0 && (
|
|
75
|
+
<span className="flex items-center gap-1 text-chart-5">
|
|
76
|
+
<AlertTriangle className="h-3.5 w-3.5" /> {warnings} warning{warnings !== 1 && 's'}
|
|
77
|
+
</span>
|
|
78
|
+
)}
|
|
79
|
+
{tips > 0 && (
|
|
80
|
+
<span className="flex items-center gap-1 text-chart-1">
|
|
81
|
+
<Lightbulb className="h-3.5 w-3.5" /> {tips} tip{tips !== 1 && 's'}
|
|
82
|
+
</span>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
<Link
|
|
86
|
+
href="/coach"
|
|
87
|
+
className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline"
|
|
88
|
+
>
|
|
89
|
+
View coaching insights <ArrowRight className="h-3.5 w-3.5" />
|
|
90
|
+
</Link>
|
|
91
|
+
</div>
|
|
92
|
+
</CardContent>
|
|
93
|
+
</Card>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from 'react'
|
|
4
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
5
|
+
import {
|
|
6
|
+
Dialog,
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogDescription,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
DialogTrigger,
|
|
12
|
+
} from '@/components/ui/dialog'
|
|
13
|
+
import type { OverviewStats, SessionSummary } from '@/lib/parse-logs'
|
|
14
|
+
import { formatCost, formatTokens, formatDuration, formatNumber } from '@/lib/format'
|
|
15
|
+
import {
|
|
16
|
+
DollarSign,
|
|
17
|
+
Coins,
|
|
18
|
+
LayoutList,
|
|
19
|
+
FolderOpen,
|
|
20
|
+
MessageSquare,
|
|
21
|
+
Wrench,
|
|
22
|
+
Clock,
|
|
23
|
+
Cpu,
|
|
24
|
+
Sun,
|
|
25
|
+
CalendarClock,
|
|
26
|
+
HelpCircle,
|
|
27
|
+
} from 'lucide-react'
|
|
28
|
+
|
|
29
|
+
function computePeakHour(sessions: SessionSummary[]): string {
|
|
30
|
+
if (sessions.length === 0) return 'N/A'
|
|
31
|
+
const hourCounts = new Array(24).fill(0)
|
|
32
|
+
for (const s of sessions) {
|
|
33
|
+
if (s.startTime) hourCounts[new Date(s.startTime).getHours()]++
|
|
34
|
+
}
|
|
35
|
+
const peak = hourCounts.indexOf(Math.max(...hourCounts))
|
|
36
|
+
const end = (peak + 1) % 24
|
|
37
|
+
const fmt = (h: number) => `${h % 12 || 12}${h < 12 ? 'am' : 'pm'}`
|
|
38
|
+
return `${fmt(peak)}–${fmt(end)}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function computeAvgDailyTime(sessions: SessionSummary[]): number {
|
|
42
|
+
if (sessions.length === 0) return 0
|
|
43
|
+
// Merge overlapping sessions per day to avoid double-counting
|
|
44
|
+
const dayIntervals = new Map<string, [number, number][]>()
|
|
45
|
+
for (const s of sessions) {
|
|
46
|
+
if (!s.startTime || !s.endTime) continue
|
|
47
|
+
const date = s.startTime.slice(0, 10)
|
|
48
|
+
const start = new Date(s.startTime).getTime()
|
|
49
|
+
const end = new Date(s.endTime).getTime()
|
|
50
|
+
if (end <= start) continue
|
|
51
|
+
if (!dayIntervals.has(date)) dayIntervals.set(date, [])
|
|
52
|
+
dayIntervals.get(date)!.push([start, end])
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let totalMinutes = 0
|
|
56
|
+
for (const intervals of dayIntervals.values()) {
|
|
57
|
+
intervals.sort((a, b) => a[0] - b[0])
|
|
58
|
+
const merged: [number, number][] = [intervals[0]]
|
|
59
|
+
for (let i = 1; i < intervals.length; i++) {
|
|
60
|
+
const last = merged[merged.length - 1]
|
|
61
|
+
if (intervals[i][0] <= last[1]) {
|
|
62
|
+
last[1] = Math.max(last[1], intervals[i][1])
|
|
63
|
+
} else {
|
|
64
|
+
merged.push(intervals[i])
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const [start, end] of merged) {
|
|
68
|
+
totalMinutes += (end - start) / 60000
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return dayIntervals.size > 0 ? totalMinutes / dayIntervals.size : 0
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface MetricDef {
|
|
76
|
+
title: string
|
|
77
|
+
value: string
|
|
78
|
+
icon: React.ComponentType<{ className?: string }>
|
|
79
|
+
explanation: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function OverviewCards({ overview, sessions }: { overview: OverviewStats; sessions: SessionSummary[] }) {
|
|
83
|
+
const peakHour = useMemo(() => computePeakHour(sessions), [sessions])
|
|
84
|
+
const avgDailyTime = useMemo(() => computeAvgDailyTime(sessions), [sessions])
|
|
85
|
+
|
|
86
|
+
const cards: MetricDef[] = [
|
|
87
|
+
{
|
|
88
|
+
title: 'Total Cost',
|
|
89
|
+
value: formatCost(overview.totalCostUSD),
|
|
90
|
+
icon: DollarSign,
|
|
91
|
+
explanation: 'Estimated total USD cost across all sessions. Calculated by multiplying each session\'s token counts (input, output, cache write, cache read) by the model\'s per-token pricing from LiteLLM. Does not include subscription fees — only API token costs.',
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
title: 'Total Tokens',
|
|
95
|
+
value: formatTokens(overview.totalTokens),
|
|
96
|
+
icon: Coins,
|
|
97
|
+
explanation: 'Sum of all tokens across every session: input tokens + output tokens + cache creation tokens + cache read tokens. This is the raw volume of data processed by the model.',
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
title: 'Sessions',
|
|
101
|
+
value: formatNumber(overview.totalSessions),
|
|
102
|
+
icon: LayoutList,
|
|
103
|
+
explanation: 'Number of distinct conversation sessions (each JSONL file = one session). A session starts when you launch Claude Code and ends when you close it or it times out.',
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
title: 'Projects',
|
|
107
|
+
value: formatNumber(overview.totalProjects),
|
|
108
|
+
icon: FolderOpen,
|
|
109
|
+
explanation: 'Number of unique project directories you\'ve used Claude Code in. Derived from the folder name in ~/.claude/projects/.',
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
title: 'Messages',
|
|
113
|
+
value: formatNumber(overview.totalMessages),
|
|
114
|
+
icon: MessageSquare,
|
|
115
|
+
explanation: 'Total number of user + assistant messages across all sessions. Each back-and-forth exchange counts as two messages (one user, one assistant).',
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
title: 'Tool Calls',
|
|
119
|
+
value: formatNumber(overview.totalToolCalls),
|
|
120
|
+
icon: Wrench,
|
|
121
|
+
explanation: 'Total number of tool invocations by the assistant (Read, Edit, Bash, Grep, Write, Agent, etc.). Each tool_use content block in an assistant message counts as one call.',
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
title: 'Avg Daily Time',
|
|
125
|
+
value: formatDuration(avgDailyTime),
|
|
126
|
+
icon: CalendarClock,
|
|
127
|
+
explanation: 'Average active time per day. Calculated by summing session durations for each day (merging overlapping sessions to avoid double-counting), then dividing by the number of active days. Session duration = time from first message to last message.',
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
title: 'Peak Hour',
|
|
131
|
+
value: peakHour,
|
|
132
|
+
icon: Sun,
|
|
133
|
+
explanation: 'The hour of day when you start the most sessions. Based on the start timestamp of each session, grouped by hour in your local timezone.',
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
title: 'Total Time',
|
|
137
|
+
value: formatDuration(overview.totalDurationMinutes),
|
|
138
|
+
icon: Clock,
|
|
139
|
+
explanation: 'Sum of all session durations (wall-clock time from first to last message in each session). Note: overlapping sessions are counted separately, so this may exceed calendar time.',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
title: 'Top Model',
|
|
143
|
+
value: Object.entries(overview.models).sort((a, b) => b[1] - a[1])[0]?.[0] || 'N/A',
|
|
144
|
+
icon: Cpu,
|
|
145
|
+
explanation: 'The Claude model used in the most sessions. Determined by the model field in assistant responses. If a session uses multiple models, the last one seen is recorded.',
|
|
146
|
+
},
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
|
151
|
+
{cards.map((card) => (
|
|
152
|
+
<MetricCard key={card.title} metric={card} />
|
|
153
|
+
))}
|
|
154
|
+
</div>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function MetricCard({ metric }: { metric: MetricDef }) {
|
|
159
|
+
const [open, setOpen] = useState(false)
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<Card className="group relative">
|
|
163
|
+
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
164
|
+
<CardTitle className="text-sm font-medium text-muted-foreground">
|
|
165
|
+
{metric.title}
|
|
166
|
+
</CardTitle>
|
|
167
|
+
<div className="flex items-center gap-1">
|
|
168
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
169
|
+
<DialogTrigger
|
|
170
|
+
render={<button />}
|
|
171
|
+
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground"
|
|
172
|
+
>
|
|
173
|
+
<HelpCircle className="h-3.5 w-3.5" />
|
|
174
|
+
</DialogTrigger>
|
|
175
|
+
<DialogContent className="sm:max-w-md">
|
|
176
|
+
<DialogHeader>
|
|
177
|
+
<DialogTitle className="flex items-center gap-2">
|
|
178
|
+
<metric.icon className="h-5 w-5" />
|
|
179
|
+
{metric.title}
|
|
180
|
+
</DialogTitle>
|
|
181
|
+
<DialogDescription className="text-sm leading-relaxed pt-2">
|
|
182
|
+
{metric.explanation}
|
|
183
|
+
</DialogDescription>
|
|
184
|
+
</DialogHeader>
|
|
185
|
+
<div className="rounded-lg bg-muted p-4 text-center">
|
|
186
|
+
<div className="text-3xl font-bold">{metric.value}</div>
|
|
187
|
+
</div>
|
|
188
|
+
</DialogContent>
|
|
189
|
+
</Dialog>
|
|
190
|
+
<metric.icon className="h-4 w-4 text-muted-foreground" />
|
|
191
|
+
</div>
|
|
192
|
+
</CardHeader>
|
|
193
|
+
<CardContent>
|
|
194
|
+
<div className="text-2xl font-bold tracking-tight">{metric.value}</div>
|
|
195
|
+
</CardContent>
|
|
196
|
+
</Card>
|
|
197
|
+
)
|
|
198
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Button } from '@/components/ui/button'
|
|
4
|
+
import {
|
|
5
|
+
Select,
|
|
6
|
+
SelectContent,
|
|
7
|
+
SelectItem,
|
|
8
|
+
SelectTrigger,
|
|
9
|
+
SelectValue,
|
|
10
|
+
} from '@/components/ui/select'
|
|
11
|
+
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
|
12
|
+
import type { PaginationState } from '@/hooks/use-pagination'
|
|
13
|
+
|
|
14
|
+
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100]
|
|
15
|
+
|
|
16
|
+
export function PaginationControls<T>({
|
|
17
|
+
pagination,
|
|
18
|
+
showPageSize = true,
|
|
19
|
+
noun = 'items',
|
|
20
|
+
}: {
|
|
21
|
+
pagination: PaginationState<T>
|
|
22
|
+
showPageSize?: boolean
|
|
23
|
+
noun?: string
|
|
24
|
+
}) {
|
|
25
|
+
const { page, totalPages, totalItems, canPrevious, canNext, previous, next, setPage, setPageSize, pageSize, startIndex, endIndex } = pagination
|
|
26
|
+
|
|
27
|
+
if (totalItems === 0) return null
|
|
28
|
+
|
|
29
|
+
// Build page numbers to show
|
|
30
|
+
const pages: (number | 'ellipsis')[] = []
|
|
31
|
+
if (totalPages <= 7) {
|
|
32
|
+
for (let i = 1; i <= totalPages; i++) pages.push(i)
|
|
33
|
+
} else {
|
|
34
|
+
pages.push(1)
|
|
35
|
+
if (page > 3) pages.push('ellipsis')
|
|
36
|
+
for (let i = Math.max(2, page - 1); i <= Math.min(totalPages - 1, page + 1); i++) {
|
|
37
|
+
pages.push(i)
|
|
38
|
+
}
|
|
39
|
+
if (page < totalPages - 2) pages.push('ellipsis')
|
|
40
|
+
pages.push(totalPages)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="flex items-center justify-between pt-4">
|
|
45
|
+
<div className="text-xs text-muted-foreground">
|
|
46
|
+
{startIndex}–{endIndex} of {totalItems} {noun}
|
|
47
|
+
</div>
|
|
48
|
+
<div className="flex items-center gap-1.5">
|
|
49
|
+
{showPageSize && (
|
|
50
|
+
<Select
|
|
51
|
+
value={String(pageSize)}
|
|
52
|
+
onValueChange={(v) => v && setPageSize(Number(v))}
|
|
53
|
+
>
|
|
54
|
+
<SelectTrigger className="h-8 w-[70px] text-xs">
|
|
55
|
+
<SelectValue />
|
|
56
|
+
</SelectTrigger>
|
|
57
|
+
<SelectContent>
|
|
58
|
+
{PAGE_SIZE_OPTIONS.map((size) => (
|
|
59
|
+
<SelectItem key={size} value={String(size)}>
|
|
60
|
+
{size}
|
|
61
|
+
</SelectItem>
|
|
62
|
+
))}
|
|
63
|
+
</SelectContent>
|
|
64
|
+
</Select>
|
|
65
|
+
)}
|
|
66
|
+
<Button
|
|
67
|
+
variant="outline"
|
|
68
|
+
size="icon"
|
|
69
|
+
className="h-8 w-8"
|
|
70
|
+
onClick={previous}
|
|
71
|
+
disabled={!canPrevious}
|
|
72
|
+
>
|
|
73
|
+
<ChevronLeft className="h-4 w-4" />
|
|
74
|
+
</Button>
|
|
75
|
+
{pages.map((p, i) =>
|
|
76
|
+
p === 'ellipsis' ? (
|
|
77
|
+
<span key={`e${i}`} className="px-1 text-xs text-muted-foreground">
|
|
78
|
+
...
|
|
79
|
+
</span>
|
|
80
|
+
) : (
|
|
81
|
+
<Button
|
|
82
|
+
key={p}
|
|
83
|
+
variant={p === page ? 'default' : 'outline'}
|
|
84
|
+
size="icon"
|
|
85
|
+
className="h-8 w-8 text-xs"
|
|
86
|
+
onClick={() => setPage(p)}
|
|
87
|
+
>
|
|
88
|
+
{p}
|
|
89
|
+
</Button>
|
|
90
|
+
)
|
|
91
|
+
)}
|
|
92
|
+
<Button
|
|
93
|
+
variant="outline"
|
|
94
|
+
size="icon"
|
|
95
|
+
className="h-8 w-8"
|
|
96
|
+
onClick={next}
|
|
97
|
+
disabled={!canNext}
|
|
98
|
+
>
|
|
99
|
+
<ChevronRight className="h-4 w-4" />
|
|
100
|
+
</Button>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
)
|
|
104
|
+
}
|