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.
Files changed (107) hide show
  1. package/.claude/settings.local.json +26 -0
  2. package/.prettierignore +7 -0
  3. package/.prettierrc +11 -0
  4. package/CONTRIBUTING.md +209 -0
  5. package/LICENSE +21 -0
  6. package/README.md +109 -0
  7. package/app/(dashboard)/coach/page.tsx +11 -0
  8. package/app/(dashboard)/commands/page.tsx +7 -0
  9. package/app/(dashboard)/community/[slug]/page.tsx +23 -0
  10. package/app/(dashboard)/community/page.tsx +71 -0
  11. package/app/(dashboard)/daily/page.tsx +19 -0
  12. package/app/(dashboard)/images/page.tsx +5 -0
  13. package/app/(dashboard)/layout.tsx +12 -0
  14. package/app/(dashboard)/page.tsx +23 -0
  15. package/app/(dashboard)/personality/page.tsx +11 -0
  16. package/app/(dashboard)/projects/page.tsx +11 -0
  17. package/app/(dashboard)/sessions/page.tsx +11 -0
  18. package/app/(dashboard)/tokens/page.tsx +11 -0
  19. package/app/(dashboard)/tools/page.tsx +11 -0
  20. package/app/api/check/route.ts +13 -0
  21. package/app/api/commands/route.ts +16 -0
  22. package/app/api/images/[...path]/route.ts +33 -0
  23. package/app/api/images-analysis/route.ts +177 -0
  24. package/app/api/sync/route.ts +14 -0
  25. package/app/api/usage/route.ts +117 -0
  26. package/app/favicon.ico +0 -0
  27. package/app/globals.css +144 -0
  28. package/app/icon.svg +3 -0
  29. package/app/layout.tsx +35 -0
  30. package/bin/agentfit.mjs +69 -0
  31. package/components/.gitkeep +0 -0
  32. package/components/agent-coach.tsx +248 -0
  33. package/components/app-sidebar.tsx +161 -0
  34. package/components/command-usage.tsx +294 -0
  35. package/components/daily-chart.tsx +118 -0
  36. package/components/daily-table.tsx +115 -0
  37. package/components/dashboard-shell.tsx +149 -0
  38. package/components/data-provider.tsx +213 -0
  39. package/components/fitness-score.tsx +95 -0
  40. package/components/overview-cards.tsx +198 -0
  41. package/components/pagination-controls.tsx +104 -0
  42. package/components/personality-fit.tsx +446 -0
  43. package/components/projects-table.tsx +70 -0
  44. package/components/screenshots-analysis.tsx +359 -0
  45. package/components/sessions-table.tsx +97 -0
  46. package/components/theme-provider.tsx +71 -0
  47. package/components/token-breakdown.tsx +179 -0
  48. package/components/tool-usage-chart.tsx +63 -0
  49. package/components/ui/badge.tsx +52 -0
  50. package/components/ui/button.tsx +60 -0
  51. package/components/ui/card.tsx +103 -0
  52. package/components/ui/chart.tsx +373 -0
  53. package/components/ui/dialog.tsx +160 -0
  54. package/components/ui/input.tsx +20 -0
  55. package/components/ui/scroll-area.tsx +55 -0
  56. package/components/ui/select.tsx +201 -0
  57. package/components/ui/separator.tsx +25 -0
  58. package/components/ui/sheet.tsx +138 -0
  59. package/components/ui/sidebar.tsx +723 -0
  60. package/components/ui/skeleton.tsx +13 -0
  61. package/components/ui/table.tsx +116 -0
  62. package/components/ui/tabs.tsx +82 -0
  63. package/components/ui/tooltip.tsx +66 -0
  64. package/components.json +25 -0
  65. package/generated/prisma/browser.ts +34 -0
  66. package/generated/prisma/client.ts +58 -0
  67. package/generated/prisma/commonInputTypes.ts +237 -0
  68. package/generated/prisma/enums.ts +15 -0
  69. package/generated/prisma/internal/class.ts +224 -0
  70. package/generated/prisma/internal/prismaNamespace.ts +920 -0
  71. package/generated/prisma/internal/prismaNamespaceBrowser.ts +130 -0
  72. package/generated/prisma/models/Image.ts +1310 -0
  73. package/generated/prisma/models/Session.ts +1695 -0
  74. package/generated/prisma/models/SyncLog.ts +1203 -0
  75. package/generated/prisma/models.ts +14 -0
  76. package/hooks/.gitkeep +0 -0
  77. package/hooks/use-mobile.ts +19 -0
  78. package/hooks/use-pagination.ts +60 -0
  79. package/lib/.gitkeep +0 -0
  80. package/lib/coach.ts +425 -0
  81. package/lib/commands.ts +239 -0
  82. package/lib/db.ts +15 -0
  83. package/lib/format.ts +26 -0
  84. package/lib/parse-codex.ts +201 -0
  85. package/lib/parse-logs.ts +369 -0
  86. package/lib/personality.ts +481 -0
  87. package/lib/plugins.ts +107 -0
  88. package/lib/pricing.ts +112 -0
  89. package/lib/queries-codex.ts +130 -0
  90. package/lib/queries.ts +154 -0
  91. package/lib/resolve-icon.ts +12 -0
  92. package/lib/sync.ts +335 -0
  93. package/lib/utils.ts +6 -0
  94. package/next.config.mjs +4 -0
  95. package/package.json +73 -0
  96. package/plugins/cost-heatmap/component.test.tsx +52 -0
  97. package/plugins/cost-heatmap/component.tsx +227 -0
  98. package/plugins/cost-heatmap/manifest.ts +13 -0
  99. package/plugins/index.ts +18 -0
  100. package/prisma/migrations/20260328152517_init/migration.sql +41 -0
  101. package/prisma/migrations/20260328153801_add_image_model/migration.sql +18 -0
  102. package/prisma/migrations/migration_lock.toml +3 -0
  103. package/prisma/schema.prisma +57 -0
  104. package/prisma.config.ts +14 -0
  105. package/public/.gitkeep +0 -0
  106. package/public/logo.svg +3 -0
  107. 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
+ }