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.
Files changed (68) hide show
  1. package/README.md +30 -34
  2. package/app/(dashboard)/daily/page.tsx +1 -1
  3. package/app/(dashboard)/flow/page.tsx +17 -0
  4. package/app/(dashboard)/layout.tsx +2 -0
  5. package/app/(dashboard)/page.tsx +24 -5
  6. package/app/(dashboard)/reports/[id]/page.tsx +72 -0
  7. package/app/(dashboard)/reports/page.tsx +132 -0
  8. package/app/(dashboard)/sessions/[id]/page.tsx +167 -0
  9. package/app/(dashboard)/settings/page.tsx +180 -0
  10. package/app/api/backup/route.ts +215 -0
  11. package/app/api/check/route.ts +11 -1
  12. package/app/api/command-insights/route.ts +13 -0
  13. package/app/api/images-analysis/route.ts +3 -4
  14. package/app/api/reports/[id]/route.ts +23 -0
  15. package/app/api/reports/route.ts +50 -0
  16. package/app/api/reset/route.ts +21 -0
  17. package/app/api/session/route.ts +40 -0
  18. package/app/api/usage/route.ts +25 -1
  19. package/app/layout.tsx +1 -1
  20. package/bin/agentfit.mjs +2 -2
  21. package/components/agent-coach.tsx +256 -129
  22. package/components/app-sidebar.tsx +258 -8
  23. package/components/backup-section.tsx +236 -0
  24. package/components/daily-chart.tsx +404 -83
  25. package/components/dashboard-shell.tsx +9 -24
  26. package/components/data-provider.tsx +66 -2
  27. package/components/fitness-score.tsx +95 -54
  28. package/components/overview-cards.tsx +148 -41
  29. package/components/report-view.tsx +307 -0
  30. package/components/screenshots-analysis.tsx +51 -46
  31. package/components/session-chatlog.tsx +124 -0
  32. package/components/session-timeline.tsx +184 -0
  33. package/components/session-workflow.tsx +183 -0
  34. package/components/sessions-table.tsx +9 -1
  35. package/components/tool-flow-graph.tsx +144 -0
  36. package/components/ui/carousel.tsx +242 -0
  37. package/components/ui/sidebar.tsx +1 -1
  38. package/components/ui/sonner.tsx +51 -0
  39. package/generated/prisma/browser.ts +5 -0
  40. package/generated/prisma/client.ts +5 -0
  41. package/generated/prisma/internal/class.ts +14 -4
  42. package/generated/prisma/internal/prismaNamespace.ts +96 -2
  43. package/generated/prisma/internal/prismaNamespaceBrowser.ts +20 -1
  44. package/generated/prisma/models/Report.ts +1219 -0
  45. package/generated/prisma/models/Session.ts +187 -1
  46. package/generated/prisma/models.ts +1 -0
  47. package/lib/coach.ts +530 -211
  48. package/lib/command-insights.ts +231 -0
  49. package/lib/db.ts +1 -1
  50. package/lib/parse-codex.ts +5 -0
  51. package/lib/parse-logs.ts +65 -0
  52. package/lib/queries-codex.ts +22 -0
  53. package/lib/queries.ts +42 -0
  54. package/lib/report.ts +156 -0
  55. package/lib/session-detail.ts +382 -0
  56. package/lib/sync.ts +77 -0
  57. package/lib/tool-flow.ts +71 -0
  58. package/next.config.mjs +6 -1
  59. package/package.json +16 -2
  60. package/plugins/cost-heatmap/component.tsx +72 -50
  61. package/prisma/schema.prisma +17 -0
  62. package/.claude/settings.local.json +0 -26
  63. package/CONTRIBUTING.md +0 -209
  64. package/prisma/migrations/20260328152517_init/migration.sql +0 -41
  65. package/prisma/migrations/20260328153801_add_image_model/migration.sql +0 -18
  66. package/prisma/migrations/migration_lock.toml +0 -3
  67. package/prisma.config.ts +0 -14
  68. 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, Trophy, AlertTriangle, Lightbulb } from 'lucide-react'
7
+ import { generateCoachInsights, type CraftScores } from '@/lib/coach'
8
+ import { ArrowRight, AlertTriangle, Lightbulb, Flame } from 'lucide-react'
9
9
 
10
- function ScoreRing({ score, size = 120 }: { score: number; size?: number }) {
11
- const radius = (size - 16) / 2
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
- stroke={getColor(score)}
33
- strokeWidth="8"
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 - 4} textAnchor="middle" dominantBaseline="middle" className="fill-foreground text-2xl font-bold" style={{ fontSize: size * 0.22 }}>
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 + 16} textAnchor="middle" className="fill-muted-foreground" style={{ fontSize: size * 0.09 }}>
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
- 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
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
- <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>
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 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
- )}
106
+ <div className="flex-1">
107
+ <CraftBars craft={coach.craft} />
84
108
  </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>
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 computePeakHour(sessions: SessionSummary[]): string {
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
- if (s.startTime) hourCounts[new Date(s.startTime).getHours()]++
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
- return `${fmt(peak)}–${fmt(end)}`
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
- // Merge overlapping sessions per day to avoid double-counting
44
- const dayIntervals = new Map<string, [number, number][]>()
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
- 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])
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 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])
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
- for (const [start, end] of merged) {
68
- totalMinutes += (end - start) / 60000
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 dayIntervals.size > 0 ? totalMinutes / dayIntervals.size : 0
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 peakHour = useMemo(() => computePeakHour(sessions), [sessions])
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: 'Avg Daily Time',
203
+ title: 'Est. Daily Time',
125
204
  value: formatDuration(avgDailyTime),
126
205
  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.',
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: 'Peak Hour',
131
- value: peakHour,
209
+ title: 'Top Hours',
210
+ value: topHours,
132
211
  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.',
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: 'Total Time',
137
- value: formatDuration(overview.totalDurationMinutes),
215
+ title: 'Avg Parallel',
216
+ value: computeAvgParallelSessions(sessions),
138
217
  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.',
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="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground"
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
- <metric.icon className="h-4 w-4 text-muted-foreground" />
191
- </div>
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>