agentfit 0.1.0 → 0.1.1
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/README.md +30 -34
- package/app/(dashboard)/daily/page.tsx +1 -1
- package/app/(dashboard)/flow/page.tsx +17 -0
- package/app/(dashboard)/layout.tsx +2 -0
- package/app/(dashboard)/page.tsx +24 -5
- package/app/(dashboard)/reports/[id]/page.tsx +72 -0
- package/app/(dashboard)/reports/page.tsx +132 -0
- package/app/(dashboard)/sessions/[id]/page.tsx +167 -0
- package/app/(dashboard)/settings/page.tsx +180 -0
- package/app/api/backup/route.ts +215 -0
- package/app/api/check/route.ts +11 -1
- package/app/api/command-insights/route.ts +13 -0
- package/app/api/images-analysis/route.ts +3 -4
- package/app/api/reports/[id]/route.ts +23 -0
- package/app/api/reports/route.ts +50 -0
- package/app/api/reset/route.ts +21 -0
- package/app/api/session/route.ts +40 -0
- package/app/api/usage/route.ts +25 -1
- package/app/layout.tsx +1 -1
- package/bin/agentfit.mjs +2 -2
- package/components/agent-coach.tsx +256 -129
- package/components/app-sidebar.tsx +258 -8
- package/components/backup-section.tsx +236 -0
- package/components/daily-chart.tsx +404 -83
- package/components/dashboard-shell.tsx +9 -24
- package/components/data-provider.tsx +66 -2
- package/components/fitness-score.tsx +95 -54
- package/components/overview-cards.tsx +148 -41
- package/components/report-view.tsx +307 -0
- package/components/screenshots-analysis.tsx +51 -46
- package/components/session-chatlog.tsx +124 -0
- package/components/session-timeline.tsx +184 -0
- package/components/session-workflow.tsx +183 -0
- package/components/sessions-table.tsx +9 -1
- package/components/tool-flow-graph.tsx +144 -0
- package/components/ui/carousel.tsx +242 -0
- package/components/ui/sidebar.tsx +1 -1
- package/components/ui/sonner.tsx +51 -0
- package/generated/prisma/browser.ts +5 -0
- package/generated/prisma/client.ts +5 -0
- package/generated/prisma/internal/class.ts +14 -4
- package/generated/prisma/internal/prismaNamespace.ts +96 -2
- package/generated/prisma/internal/prismaNamespaceBrowser.ts +20 -1
- package/generated/prisma/models/Report.ts +1219 -0
- package/generated/prisma/models/Session.ts +187 -1
- package/generated/prisma/models.ts +1 -0
- package/lib/coach.ts +530 -211
- package/lib/command-insights.ts +231 -0
- package/lib/db.ts +1 -1
- package/lib/parse-codex.ts +5 -0
- package/lib/parse-logs.ts +65 -0
- package/lib/queries-codex.ts +22 -0
- package/lib/queries.ts +42 -0
- package/lib/report.ts +156 -0
- package/lib/session-detail.ts +382 -0
- package/lib/sync.ts +77 -0
- package/lib/tool-flow.ts +71 -0
- package/next.config.mjs +6 -1
- package/package.json +16 -2
- package/plugins/cost-heatmap/component.tsx +72 -50
- package/prisma/schema.prisma +17 -0
- package/.claude/settings.local.json +0 -26
- package/CONTRIBUTING.md +0 -209
- package/prisma/migrations/20260328152517_init/migration.sql +0 -41
- package/prisma/migrations/20260328153801_add_image_model/migration.sql +0 -18
- package/prisma/migrations/migration_lock.toml +0 -3
- package/prisma.config.ts +0 -14
- package/setup.sh +0 -73
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { createContext, useContext, useEffect, useState, useCallback, useMemo, type ReactNode } from 'react'
|
|
4
|
+
import { toast } from 'sonner'
|
|
4
5
|
import type { UsageData, SessionSummary, DailyUsage, ProjectSummary, OverviewStats } from '@/lib/parse-logs'
|
|
5
6
|
|
|
6
7
|
export type AgentType = 'claude' | 'codex' | 'combined'
|
|
@@ -23,9 +24,11 @@ interface DataContextValue {
|
|
|
23
24
|
timeRange: TimeRange
|
|
24
25
|
setTimeRange: (range: TimeRange) => void
|
|
25
26
|
syncing: boolean
|
|
27
|
+
resetting: boolean
|
|
26
28
|
lastSyncResult: SyncResult | null
|
|
27
29
|
lastSyncTime: Date | null
|
|
28
30
|
handleSync: () => Promise<void>
|
|
31
|
+
handleReset: () => Promise<void>
|
|
29
32
|
newSessionsAvailable: number
|
|
30
33
|
}
|
|
31
34
|
|
|
@@ -90,14 +93,18 @@ function filterByTimeRange(raw: UsageData | null, range: TimeRange): UsageData |
|
|
|
90
93
|
if (!dailyMap.has(date)) {
|
|
91
94
|
dailyMap.set(date, {
|
|
92
95
|
date, sessions: 0, messages: 0,
|
|
96
|
+
userMessages: 0, assistantMessages: 0,
|
|
93
97
|
inputTokens: 0, outputTokens: 0,
|
|
94
98
|
cacheCreationTokens: 0, cacheReadTokens: 0,
|
|
95
99
|
totalTokens: 0, costUSD: 0, toolCalls: 0,
|
|
100
|
+
toolCallsDetail: {}, interruptions: 0, rateLimitErrors: 0,
|
|
96
101
|
})
|
|
97
102
|
}
|
|
98
103
|
const day = dailyMap.get(date)!
|
|
99
104
|
day.sessions++
|
|
100
105
|
day.messages += s.totalMessages
|
|
106
|
+
day.userMessages += s.userMessages
|
|
107
|
+
day.assistantMessages += s.assistantMessages
|
|
101
108
|
day.inputTokens += s.inputTokens
|
|
102
109
|
day.outputTokens += s.outputTokens
|
|
103
110
|
day.cacheCreationTokens += s.cacheCreationTokens
|
|
@@ -105,6 +112,11 @@ function filterByTimeRange(raw: UsageData | null, range: TimeRange): UsageData |
|
|
|
105
112
|
day.totalTokens += s.totalTokens
|
|
106
113
|
day.costUSD += s.costUSD
|
|
107
114
|
day.toolCalls += s.toolCallsTotal
|
|
115
|
+
day.interruptions += s.userInterruptions
|
|
116
|
+
day.rateLimitErrors += s.rateLimitErrors
|
|
117
|
+
for (const [tool, count] of Object.entries(s.toolCalls)) {
|
|
118
|
+
day.toolCallsDetail[tool] = (day.toolCallsDetail[tool] || 0) + count
|
|
119
|
+
}
|
|
108
120
|
|
|
109
121
|
// Tools
|
|
110
122
|
for (const [tool, count] of Object.entries(s.toolCalls)) {
|
|
@@ -132,7 +144,34 @@ function filterByTimeRange(raw: UsageData | null, range: TimeRange): UsageData |
|
|
|
132
144
|
totalCostUSD: sessions.reduce((a, s) => a + s.costUSD, 0),
|
|
133
145
|
totalDurationMinutes: sessions.reduce((a, s) => a + s.durationMinutes, 0),
|
|
134
146
|
totalToolCalls: sessions.reduce((a, s) => a + s.toolCallsTotal, 0),
|
|
147
|
+
totalApiErrors: sessions.reduce((a, s) => a + s.apiErrors, 0),
|
|
148
|
+
totalRateLimitDays: (() => {
|
|
149
|
+
const days = new Set<string>()
|
|
150
|
+
for (const s of sessions) {
|
|
151
|
+
if (s.rateLimitErrors > 0) days.add(s.startTime.slice(0, 10))
|
|
152
|
+
}
|
|
153
|
+
return days.size
|
|
154
|
+
})(),
|
|
155
|
+
totalUserInterruptions: sessions.reduce((a, s) => a + s.userInterruptions, 0),
|
|
135
156
|
models,
|
|
157
|
+
skillUsage: (() => {
|
|
158
|
+
const skills: Record<string, number> = {}
|
|
159
|
+
for (const s of sessions) {
|
|
160
|
+
for (const [skill, count] of Object.entries(s.skillCalls)) {
|
|
161
|
+
skills[skill] = (skills[skill] || 0) + count
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return skills
|
|
165
|
+
})(),
|
|
166
|
+
permissionModes: (() => {
|
|
167
|
+
const modes: Record<string, number> = {}
|
|
168
|
+
for (const s of sessions) {
|
|
169
|
+
for (const [mode, count] of Object.entries(s.permissionModes || {})) {
|
|
170
|
+
modes[mode] = (modes[mode] || 0) + count
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return modes
|
|
174
|
+
})(),
|
|
136
175
|
}
|
|
137
176
|
|
|
138
177
|
return { overview, sessions, projects, daily, toolUsage }
|
|
@@ -145,6 +184,7 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|
|
145
184
|
const [agent, setAgentState] = useState<AgentType>('claude')
|
|
146
185
|
const [timeRange, setTimeRange] = useState<TimeRange>('all')
|
|
147
186
|
const [syncing, setSyncing] = useState(false)
|
|
187
|
+
const [resetting, setResetting] = useState(false)
|
|
148
188
|
const [lastSyncResult, setLastSyncResult] = useState<SyncResult | null>(null)
|
|
149
189
|
const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null)
|
|
150
190
|
const [newSessionsAvailable, setNewSessionsAvailable] = useState(0)
|
|
@@ -183,7 +223,31 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|
|
183
223
|
}
|
|
184
224
|
}, [fetchData, agent])
|
|
185
225
|
|
|
186
|
-
|
|
226
|
+
const handleReset = useCallback(async () => {
|
|
227
|
+
setResetting(true)
|
|
228
|
+
try {
|
|
229
|
+
const res = await fetch('/api/reset', { method: 'POST' })
|
|
230
|
+
const body = await res.json()
|
|
231
|
+
if (!res.ok) {
|
|
232
|
+
throw new Error(body.error || 'Reset failed')
|
|
233
|
+
}
|
|
234
|
+
setLastSyncResult(body)
|
|
235
|
+
setLastSyncTime(new Date())
|
|
236
|
+
setNewSessionsAvailable(0)
|
|
237
|
+
toast.success('Database reset complete', {
|
|
238
|
+
description: `Re-imported ${body.sessionsAdded || 0} sessions from disk.`,
|
|
239
|
+
})
|
|
240
|
+
// Full reload to dashboard so all state is fresh
|
|
241
|
+
setTimeout(() => { window.location.href = '/' }, 1500)
|
|
242
|
+
return
|
|
243
|
+
} catch (e) {
|
|
244
|
+
const msg = (e as Error).message
|
|
245
|
+
setError(msg)
|
|
246
|
+
toast.error('Reset failed', { description: msg })
|
|
247
|
+
} finally {
|
|
248
|
+
setResetting(false)
|
|
249
|
+
}
|
|
250
|
+
}, [fetchData, agent])
|
|
187
251
|
|
|
188
252
|
const setAgent = useCallback((newAgent: AgentType) => {
|
|
189
253
|
setAgentState(newAgent)
|
|
@@ -204,7 +268,7 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|
|
204
268
|
<DataContext.Provider value={{
|
|
205
269
|
data, loading, error, agent, setAgent,
|
|
206
270
|
timeRange, setTimeRange,
|
|
207
|
-
syncing, lastSyncResult, lastSyncTime, handleSync,
|
|
271
|
+
syncing, resetting, lastSyncResult, lastSyncTime, handleSync, handleReset,
|
|
208
272
|
newSessionsAvailable,
|
|
209
273
|
}}>
|
|
210
274
|
{children}
|
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
import { useMemo } from 'react'
|
|
4
4
|
import Link from 'next/link'
|
|
5
|
-
import { Card, CardContent } from '@/components/ui/card'
|
|
5
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
6
6
|
import type { UsageData } from '@/lib/parse-logs'
|
|
7
|
-
import { generateCoachInsights } from '@/lib/coach'
|
|
8
|
-
import { ArrowRight,
|
|
7
|
+
import { generateCoachInsights, type CraftScores } from '@/lib/coach'
|
|
8
|
+
import { ArrowRight, AlertTriangle, Lightbulb, Flame } from 'lucide-react'
|
|
9
9
|
|
|
10
|
-
function ScoreRing({ score, size =
|
|
11
|
-
const radius = (size -
|
|
10
|
+
function ScoreRing({ score, size = 100 }: { score: number; size?: number }) {
|
|
11
|
+
const radius = (size - 14) / 2
|
|
12
12
|
const circumference = 2 * Math.PI * radius
|
|
13
|
+
const offset = circumference - (score / 100) * circumference
|
|
13
14
|
|
|
14
15
|
const getColor = (s: number) => {
|
|
15
16
|
if (s >= 85) return 'var(--chart-2)'
|
|
@@ -22,74 +23,114 @@ function ScoreRing({ score, size = 120 }: { score: number; size?: number }) {
|
|
|
22
23
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
|
23
24
|
<circle
|
|
24
25
|
cx={size / 2} cy={size / 2} r={radius}
|
|
25
|
-
fill="none"
|
|
26
|
-
stroke="var(--muted)"
|
|
27
|
-
strokeWidth="8"
|
|
26
|
+
fill="none" stroke="var(--muted)" strokeWidth="7"
|
|
28
27
|
/>
|
|
29
28
|
<circle
|
|
30
29
|
cx={size / 2} cy={size / 2} r={radius}
|
|
31
|
-
fill="none"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
strokeLinecap="round"
|
|
35
|
-
strokeDasharray={circumference}
|
|
36
|
-
strokeDashoffset={circumference - (score / 100) * circumference}
|
|
30
|
+
fill="none" stroke={getColor(score)} strokeWidth="7"
|
|
31
|
+
strokeLinecap="round" strokeDasharray={circumference}
|
|
32
|
+
strokeDashoffset={offset}
|
|
37
33
|
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
|
38
|
-
className="transition-all duration-1000"
|
|
39
34
|
/>
|
|
40
|
-
<text x={size / 2} y={size / 2 -
|
|
35
|
+
<text x={size / 2} y={size / 2 - 2} textAnchor="middle" dominantBaseline="middle" className="fill-foreground font-bold" style={{ fontSize: size * 0.24 }}>
|
|
41
36
|
{score}
|
|
42
37
|
</text>
|
|
43
|
-
<text x={size / 2} y={size / 2 +
|
|
38
|
+
<text x={size / 2} y={size / 2 + 14} textAnchor="middle" className="fill-muted-foreground" style={{ fontSize: size * 0.1 }}>
|
|
44
39
|
/ 100
|
|
45
40
|
</text>
|
|
46
41
|
</svg>
|
|
47
42
|
)
|
|
48
43
|
}
|
|
49
44
|
|
|
45
|
+
const CRAFT_LABELS: { key: keyof CraftScores; letter: string; label: string; color: string }[] = [
|
|
46
|
+
{ key: 'context', letter: 'C', label: 'Context', color: 'var(--chart-1)' },
|
|
47
|
+
{ key: 'reach', letter: 'R', label: 'Reach', color: 'var(--chart-2)' },
|
|
48
|
+
{ key: 'autonomy', letter: 'A', label: 'Autonomy', color: 'var(--chart-3)' },
|
|
49
|
+
{ key: 'flow', letter: 'F', label: 'Flow', color: 'var(--chart-4)' },
|
|
50
|
+
{ key: 'throughput', letter: 'T', label: 'Throughput', color: 'var(--chart-6)' },
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
function CraftBars({ craft }: { craft: CraftScores }) {
|
|
54
|
+
return (
|
|
55
|
+
<div className="space-y-1.5">
|
|
56
|
+
{CRAFT_LABELS.map(({ key, letter, label, color }) => (
|
|
57
|
+
<div key={key} className="flex items-center gap-2">
|
|
58
|
+
<span className="w-4 text-[10px] font-bold" style={{ color }}>{letter}</span>
|
|
59
|
+
<span className="w-16 text-[10px] text-muted-foreground truncate">{label}</span>
|
|
60
|
+
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
|
|
61
|
+
<div
|
|
62
|
+
className="h-full rounded-full transition-all duration-500"
|
|
63
|
+
style={{ width: `${craft[key]}%`, backgroundColor: color }}
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
<span className="w-6 text-[10px] font-medium text-right">{craft[key]}</span>
|
|
67
|
+
</div>
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
50
73
|
export function FitnessScore({ data }: { data: UsageData }) {
|
|
51
74
|
const coach = useMemo(() => generateCoachInsights(data), [data])
|
|
52
75
|
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
76
|
+
// Pick top 2 insights: warnings first, then tips
|
|
77
|
+
const topInsights = [
|
|
78
|
+
...coach.insights.filter(i => i.severity === 'warning'),
|
|
79
|
+
...coach.insights.filter(i => i.severity === 'tip'),
|
|
80
|
+
].slice(0, 2)
|
|
56
81
|
|
|
57
82
|
return (
|
|
58
|
-
<
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
<
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
83
|
+
<div className="grid gap-4 lg:grid-cols-3">
|
|
84
|
+
{/* CRAFT Status — score ring + dimension bars in one card */}
|
|
85
|
+
<Card>
|
|
86
|
+
<CardHeader className="pb-2">
|
|
87
|
+
<CardTitle className="text-sm font-medium text-muted-foreground">CRAFT Status</CardTitle>
|
|
88
|
+
</CardHeader>
|
|
89
|
+
<CardContent className="flex items-center gap-6">
|
|
90
|
+
<div className="flex flex-col items-center gap-1">
|
|
91
|
+
<ScoreRing score={coach.score} />
|
|
92
|
+
<div className="text-xs font-semibold">{coach.scoreLabel}</div>
|
|
93
|
+
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
|
94
|
+
<Flame className="h-3 w-3" />
|
|
95
|
+
{coach.stats.currentStreak > 0
|
|
96
|
+
? `${coach.stats.currentStreak}d streak (best: ${coach.stats.longestStreak}d)`
|
|
97
|
+
: 'Start a streak'}
|
|
98
|
+
</div>
|
|
99
|
+
<Link
|
|
100
|
+
href="/coach"
|
|
101
|
+
className="inline-flex items-center gap-1 text-[10px] font-medium text-primary hover:underline mt-1"
|
|
102
|
+
>
|
|
103
|
+
All insights <ArrowRight className="h-3 w-3" />
|
|
104
|
+
</Link>
|
|
67
105
|
</div>
|
|
68
|
-
<div className="flex
|
|
69
|
-
{
|
|
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
|
-
)}
|
|
106
|
+
<div className="flex-1">
|
|
107
|
+
<CraftBars craft={coach.craft} />
|
|
84
108
|
</div>
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
109
|
+
</CardContent>
|
|
110
|
+
</Card>
|
|
111
|
+
|
|
112
|
+
{/* Top 2 Insights */}
|
|
113
|
+
{topInsights.map((insight, i) => (
|
|
114
|
+
<Card
|
|
115
|
+
key={insight.id}
|
|
116
|
+
className={`border-l-4 ${insight.severity === 'warning' ? 'border-l-chart-5' : 'border-l-chart-1'}`}
|
|
117
|
+
>
|
|
118
|
+
<CardHeader className="pb-1.5">
|
|
119
|
+
<CardTitle className="text-[11px] font-medium flex items-center gap-1.5 text-muted-foreground">
|
|
120
|
+
{insight.severity === 'warning'
|
|
121
|
+
? <><AlertTriangle className="h-3 w-3 text-chart-5" /> {i === 0 ? 'Top Priority' : 'Improve'}</>
|
|
122
|
+
: <><Lightbulb className="h-3 w-3 text-chart-1" /> Tip</>}
|
|
123
|
+
{insight.craft && (
|
|
124
|
+
<span className="ml-auto text-[9px] uppercase tracking-wider opacity-60">{insight.craft}</span>
|
|
125
|
+
)}
|
|
126
|
+
</CardTitle>
|
|
127
|
+
</CardHeader>
|
|
128
|
+
<CardContent>
|
|
129
|
+
<div className="text-sm font-semibold mb-1 line-clamp-1">{insight.title}</div>
|
|
130
|
+
<p className="text-xs text-muted-foreground line-clamp-2">{insight.recommendation || insight.description}</p>
|
|
131
|
+
</CardContent>
|
|
132
|
+
</Card>
|
|
133
|
+
))}
|
|
134
|
+
</div>
|
|
94
135
|
)
|
|
95
136
|
}
|
|
@@ -23,53 +23,131 @@ import {
|
|
|
23
23
|
Cpu,
|
|
24
24
|
Sun,
|
|
25
25
|
CalendarClock,
|
|
26
|
+
AlertTriangle,
|
|
27
|
+
Ban,
|
|
28
|
+
Flame,
|
|
29
|
+
Zap,
|
|
30
|
+
Calendar,
|
|
26
31
|
HelpCircle,
|
|
27
32
|
} from 'lucide-react'
|
|
28
33
|
|
|
29
|
-
function
|
|
34
|
+
function computeTopHours(sessions: SessionSummary[]): string {
|
|
30
35
|
if (sessions.length === 0) return 'N/A'
|
|
31
36
|
const hourCounts = new Array(24).fill(0)
|
|
32
37
|
for (const s of sessions) {
|
|
33
|
-
|
|
38
|
+
const timestamps = s.messageTimestamps
|
|
39
|
+
if (timestamps && timestamps.length > 0) {
|
|
40
|
+
for (const ts of timestamps) {
|
|
41
|
+
hourCounts[new Date(ts).getHours()]++
|
|
42
|
+
}
|
|
43
|
+
} else if (s.startTime) {
|
|
44
|
+
hourCounts[new Date(s.startTime).getHours()]++
|
|
45
|
+
}
|
|
34
46
|
}
|
|
35
|
-
const peak = hourCounts.indexOf(Math.max(...hourCounts))
|
|
36
|
-
const end = (peak + 1) % 24
|
|
37
47
|
const fmt = (h: number) => `${h % 12 || 12}${h < 12 ? 'am' : 'pm'}`
|
|
38
|
-
|
|
48
|
+
const top2 = hourCounts
|
|
49
|
+
.map((count, hour) => ({ hour, count }))
|
|
50
|
+
.sort((a, b) => b.count - a.count)
|
|
51
|
+
.slice(0, 2)
|
|
52
|
+
return top2.map((t) => fmt(t.hour)).join(', ')
|
|
39
53
|
}
|
|
40
54
|
|
|
55
|
+
const IDLE_THRESHOLD_MS = 30 * 60 * 1000 // 30 min — gaps longer than this are idle time
|
|
56
|
+
|
|
41
57
|
function computeAvgDailyTime(sessions: SessionSummary[]): number {
|
|
42
58
|
if (sessions.length === 0) return 0
|
|
43
|
-
//
|
|
44
|
-
|
|
59
|
+
// Collect all message timestamps grouped by local date.
|
|
60
|
+
// Sum only gaps between consecutive messages that are under the idle threshold.
|
|
61
|
+
const dayTimestamps = new Map<string, number[]>()
|
|
62
|
+
|
|
45
63
|
for (const s of sessions) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
64
|
+
const timestamps = s.messageTimestamps
|
|
65
|
+
if (!timestamps || timestamps.length === 0) continue
|
|
66
|
+
for (const ts of timestamps) {
|
|
67
|
+
const d = new Date(ts)
|
|
68
|
+
const ms = d.getTime()
|
|
69
|
+
const date = d.toLocaleDateString('en-CA')
|
|
70
|
+
if (!dayTimestamps.has(date)) dayTimestamps.set(date, [])
|
|
71
|
+
dayTimestamps.get(date)!.push(ms)
|
|
72
|
+
}
|
|
53
73
|
}
|
|
54
74
|
|
|
55
75
|
let totalMinutes = 0
|
|
56
|
-
for (const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
last[1] = Math.max(last[1], intervals[i][1])
|
|
63
|
-
} else {
|
|
64
|
-
merged.push(intervals[i])
|
|
76
|
+
for (const timestamps of dayTimestamps.values()) {
|
|
77
|
+
timestamps.sort((a, b) => a - b)
|
|
78
|
+
for (let i = 1; i < timestamps.length; i++) {
|
|
79
|
+
const gap = timestamps[i] - timestamps[i - 1]
|
|
80
|
+
if (gap <= IDLE_THRESHOLD_MS) {
|
|
81
|
+
totalMinutes += gap / 60000
|
|
65
82
|
}
|
|
66
83
|
}
|
|
67
|
-
|
|
68
|
-
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return dayTimestamps.size > 0 ? totalMinutes / dayTimestamps.size : 0
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function computeAvgParallelSessions(sessions: SessionSummary[]): string {
|
|
90
|
+
if (sessions.length === 0) return '0'
|
|
91
|
+
// Group sessions by local date, count how many are active per day
|
|
92
|
+
const daySessions = new Map<string, number>()
|
|
93
|
+
for (const s of sessions) {
|
|
94
|
+
if (!s.startTime) continue
|
|
95
|
+
const date = new Date(s.startTime).toLocaleDateString('en-CA')
|
|
96
|
+
daySessions.set(date, (daySessions.get(date) || 0) + 1)
|
|
97
|
+
}
|
|
98
|
+
if (daySessions.size === 0) return '0'
|
|
99
|
+
const total = Array.from(daySessions.values()).reduce((a, b) => a + b, 0)
|
|
100
|
+
const avg = total / daySessions.size
|
|
101
|
+
return avg.toFixed(1)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function computeStreaks(sessions: SessionSummary[]): { longest: number; current: number; activeDays: number } {
|
|
105
|
+
if (sessions.length === 0) return { longest: 0, current: 0, activeDays: 0 }
|
|
106
|
+
|
|
107
|
+
const activeDates = new Set<string>()
|
|
108
|
+
for (const s of sessions) {
|
|
109
|
+
if (s.startTime) {
|
|
110
|
+
activeDates.add(new Date(s.startTime).toLocaleDateString('en-CA'))
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (activeDates.size === 0) return { longest: 0, current: 0, activeDays: 0 }
|
|
115
|
+
|
|
116
|
+
const sorted = Array.from(activeDates).sort()
|
|
117
|
+
let longest = 1
|
|
118
|
+
let streak = 1
|
|
119
|
+
|
|
120
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
121
|
+
const prev = new Date(sorted[i - 1])
|
|
122
|
+
const curr = new Date(sorted[i])
|
|
123
|
+
const diffDays = (curr.getTime() - prev.getTime()) / (1000 * 60 * 60 * 24)
|
|
124
|
+
if (diffDays === 1) {
|
|
125
|
+
streak++
|
|
126
|
+
if (streak > longest) longest = streak
|
|
127
|
+
} else {
|
|
128
|
+
streak = 1
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Current streak: count backwards from today
|
|
133
|
+
const today = new Date().toLocaleDateString('en-CA')
|
|
134
|
+
const yesterday = new Date(Date.now() - 86400000).toLocaleDateString('en-CA')
|
|
135
|
+
let current = 0
|
|
136
|
+
if (activeDates.has(today) || activeDates.has(yesterday)) {
|
|
137
|
+
const start = activeDates.has(today) ? today : yesterday
|
|
138
|
+
current = 1
|
|
139
|
+
let d = new Date(start)
|
|
140
|
+
while (true) {
|
|
141
|
+
d = new Date(d.getTime() - 86400000)
|
|
142
|
+
if (activeDates.has(d.toLocaleDateString('en-CA'))) {
|
|
143
|
+
current++
|
|
144
|
+
} else {
|
|
145
|
+
break
|
|
146
|
+
}
|
|
69
147
|
}
|
|
70
148
|
}
|
|
71
149
|
|
|
72
|
-
return
|
|
150
|
+
return { longest, current, activeDays: activeDates.size }
|
|
73
151
|
}
|
|
74
152
|
|
|
75
153
|
interface MetricDef {
|
|
@@ -80,8 +158,9 @@ interface MetricDef {
|
|
|
80
158
|
}
|
|
81
159
|
|
|
82
160
|
export function OverviewCards({ overview, sessions }: { overview: OverviewStats; sessions: SessionSummary[] }) {
|
|
83
|
-
const
|
|
161
|
+
const topHours = useMemo(() => computeTopHours(sessions), [sessions])
|
|
84
162
|
const avgDailyTime = useMemo(() => computeAvgDailyTime(sessions), [sessions])
|
|
163
|
+
const streaks = useMemo(() => computeStreaks(sessions), [sessions])
|
|
85
164
|
|
|
86
165
|
const cards: MetricDef[] = [
|
|
87
166
|
{
|
|
@@ -121,22 +200,22 @@ export function OverviewCards({ overview, sessions }: { overview: OverviewStats;
|
|
|
121
200
|
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
201
|
},
|
|
123
202
|
{
|
|
124
|
-
title: '
|
|
203
|
+
title: 'Est. Daily Time',
|
|
125
204
|
value: formatDuration(avgDailyTime),
|
|
126
205
|
icon: CalendarClock,
|
|
127
|
-
explanation: 'Average active time per day.
|
|
206
|
+
explanation: 'Average active coding time per day. Computed from individual message timestamps: sums gaps between consecutive messages that are under 30 minutes (idle gaps are excluded). Then divides by the number of active days.',
|
|
128
207
|
},
|
|
129
208
|
{
|
|
130
|
-
title: '
|
|
131
|
-
value:
|
|
209
|
+
title: 'Top Hours',
|
|
210
|
+
value: topHours,
|
|
132
211
|
icon: Sun,
|
|
133
|
-
explanation: 'The
|
|
212
|
+
explanation: 'The two hours of day with the most message activity. Based on individual message timestamps across all sessions, grouped by hour in your local timezone. Note: logs only store UTC timestamps, so this conversion uses your browser\'s current timezone — it is only accurate if you mostly code from one timezone.',
|
|
134
213
|
},
|
|
135
214
|
{
|
|
136
|
-
title: '
|
|
137
|
-
value:
|
|
215
|
+
title: 'Avg Parallel',
|
|
216
|
+
value: computeAvgParallelSessions(sessions),
|
|
138
217
|
icon: Clock,
|
|
139
|
-
explanation: '
|
|
218
|
+
explanation: 'Average number of sessions active in parallel per day. For each active day, counts how many sessions have overlapping time ranges, then averages across all days.',
|
|
140
219
|
},
|
|
141
220
|
{
|
|
142
221
|
title: 'Top Model',
|
|
@@ -144,6 +223,36 @@ export function OverviewCards({ overview, sessions }: { overview: OverviewStats;
|
|
|
144
223
|
icon: Cpu,
|
|
145
224
|
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
225
|
},
|
|
226
|
+
{
|
|
227
|
+
title: 'Longest Streak',
|
|
228
|
+
value: `${streaks.longest}d`,
|
|
229
|
+
icon: Flame,
|
|
230
|
+
explanation: 'The longest consecutive-day coding streak. A day counts as active if at least one session was started that day. Helps you see your most sustained periods of AI-assisted coding.',
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
title: 'Current Streak',
|
|
234
|
+
value: `${streaks.current}d`,
|
|
235
|
+
icon: Zap,
|
|
236
|
+
explanation: 'Your current consecutive-day coding streak, counting back from today (or yesterday if you haven\'t coded today yet). Keep it going!',
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
title: 'Active Days',
|
|
240
|
+
value: formatNumber(streaks.activeDays),
|
|
241
|
+
icon: Calendar,
|
|
242
|
+
explanation: 'Total number of unique days with at least one coding session. Shows how consistently you\'ve been using AI coding tools over time.',
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
title: 'Rate Limit Days',
|
|
246
|
+
value: formatNumber(overview.totalRateLimitDays),
|
|
247
|
+
icon: AlertTriangle,
|
|
248
|
+
explanation: 'Number of unique days where you hit API rate limits. Rate limits (HTTP 429/529, "Rate limit reached/exceeded") indicate heavy token usage that day. This is a better signal of usage intensity than raw API error counts, which include unrelated server errors.',
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
title: 'Interruptions',
|
|
252
|
+
value: formatNumber(overview.totalUserInterruptions),
|
|
253
|
+
icon: Ban,
|
|
254
|
+
explanation: 'Number of times you interrupted or rejected a tool execution. Detected from tool_result messages containing "The user doesn\'t want to proceed". High counts may indicate the agent is taking unwanted actions.',
|
|
255
|
+
},
|
|
147
256
|
]
|
|
148
257
|
|
|
149
258
|
return (
|
|
@@ -161,14 +270,12 @@ function MetricCard({ metric }: { metric: MetricDef }) {
|
|
|
161
270
|
return (
|
|
162
271
|
<Card className="group relative">
|
|
163
272
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
164
|
-
<CardTitle className="text-sm font-medium text-muted-foreground">
|
|
273
|
+
<CardTitle className="flex items-center gap-1 text-sm font-medium text-muted-foreground">
|
|
165
274
|
{metric.title}
|
|
166
|
-
</CardTitle>
|
|
167
|
-
<div className="flex items-center gap-1">
|
|
168
275
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
169
276
|
<DialogTrigger
|
|
170
277
|
render={<button />}
|
|
171
|
-
className="
|
|
278
|
+
className="text-muted-foreground/50 hover:text-foreground transition-colors"
|
|
172
279
|
>
|
|
173
280
|
<HelpCircle className="h-3.5 w-3.5" />
|
|
174
281
|
</DialogTrigger>
|
|
@@ -187,8 +294,8 @@ function MetricCard({ metric }: { metric: MetricDef }) {
|
|
|
187
294
|
</div>
|
|
188
295
|
</DialogContent>
|
|
189
296
|
</Dialog>
|
|
190
|
-
|
|
191
|
-
|
|
297
|
+
</CardTitle>
|
|
298
|
+
<metric.icon className="h-4 w-4 text-muted-foreground" />
|
|
192
299
|
</CardHeader>
|
|
193
300
|
<CardContent>
|
|
194
301
|
<div className="text-2xl font-bold tracking-tight">{metric.value}</div>
|