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
package/lib/sync.ts ADDED
@@ -0,0 +1,335 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import os from 'os'
4
+ import { prisma } from './db'
5
+ import { loadPricing, calculateCost, type ModelPricing } from './pricing'
6
+
7
+ const PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects')
8
+ const IMAGES_DIR = path.resolve(process.cwd(), 'data', 'images')
9
+
10
+ interface LogEntry {
11
+ type?: string
12
+ uuid?: string
13
+ message?: {
14
+ role?: string
15
+ content?: unknown[]
16
+ model?: string
17
+ usage?: {
18
+ input_tokens?: number
19
+ output_tokens?: number
20
+ cache_creation_input_tokens?: number
21
+ cache_read_input_tokens?: number
22
+ }
23
+ }
24
+ timestamp?: string
25
+ }
26
+
27
+ interface ImageInfo {
28
+ messageId: string
29
+ filename: string
30
+ mediaType: string
31
+ sizeBytes: number
32
+ timestamp: string
33
+ role: string
34
+ }
35
+
36
+ function decodeProjectPath(dirName: string): string {
37
+ return dirName.replace(/-/g, '/')
38
+ }
39
+
40
+ function getProjectName(projectPath: string): string {
41
+ const parts = projectPath.split('/')
42
+ return parts[parts.length - 1] || parts[parts.length - 2] || projectPath
43
+ }
44
+
45
+ function parseSessionFile(
46
+ filePath: string,
47
+ sessionId: string,
48
+ allPricing: Record<string, ModelPricing>
49
+ ) {
50
+ const content = fs.readFileSync(filePath, 'utf-8')
51
+ const lines = content.trim().split('\n')
52
+
53
+ let userMessages = 0
54
+ let assistantMessages = 0
55
+ let inputTokens = 0
56
+ let outputTokens = 0
57
+ let cacheCreationTokens = 0
58
+ let cacheReadTokens = 0
59
+ let costUSD = 0
60
+ let model = ''
61
+ let startTime = ''
62
+ let endTime = ''
63
+ const toolCalls: Record<string, number> = {}
64
+ const images: ImageInfo[] = []
65
+
66
+ for (const line of lines) {
67
+ if (!line.trim()) continue
68
+ let entry: LogEntry
69
+ try {
70
+ entry = JSON.parse(line)
71
+ } catch {
72
+ continue
73
+ }
74
+
75
+ if (entry.timestamp) {
76
+ if (!startTime) startTime = entry.timestamp
77
+ endTime = entry.timestamp
78
+ }
79
+
80
+ const entryType = entry.type
81
+ const msg = entry.message
82
+
83
+ if (entryType === 'user') {
84
+ userMessages++
85
+ } else if (entryType === 'assistant' && msg) {
86
+ assistantMessages++
87
+
88
+ if (msg.model && msg.model !== '<synthetic>') {
89
+ model = msg.model
90
+ }
91
+
92
+ if (msg.usage) {
93
+ const u = msg.usage
94
+ inputTokens += u.input_tokens || 0
95
+ outputTokens += u.output_tokens || 0
96
+ cacheCreationTokens += u.cache_creation_input_tokens || 0
97
+ cacheReadTokens += u.cache_read_input_tokens || 0
98
+ if (model) {
99
+ costUSD += calculateCost(model, u, allPricing)
100
+ }
101
+ }
102
+ }
103
+
104
+ // Extract tool calls and images from content blocks (both user and assistant)
105
+ if (msg && Array.isArray(msg.content)) {
106
+ let imageIndex = 0
107
+ for (const block of msg.content) {
108
+ if (!block || typeof block !== 'object' || !('type' in block)) continue
109
+ const b = block as Record<string, unknown>
110
+
111
+ if (b.type === 'tool_use') {
112
+ const toolName = b.name as string
113
+ if (toolName) {
114
+ toolCalls[toolName] = (toolCalls[toolName] || 0) + 1
115
+ }
116
+ }
117
+
118
+ if (b.type === 'image') {
119
+ const source = b.source as Record<string, unknown> | undefined
120
+ if (source && source.type === 'base64' && source.data) {
121
+ const data = source.data as string
122
+ const mediaType = (source.media_type as string) || 'image/png'
123
+ const ext = mediaType.split('/')[1] || 'png'
124
+ const messageId = entry.uuid || `unknown-${Date.now()}`
125
+ const filename = `${sessionId}/${messageId}_${imageIndex}.${ext}`
126
+
127
+ // Write image file
128
+ const imgPath = path.join(IMAGES_DIR, filename)
129
+ const imgDir = path.dirname(imgPath)
130
+ if (!fs.existsSync(imgDir)) {
131
+ fs.mkdirSync(imgDir, { recursive: true })
132
+ }
133
+
134
+ const buffer = Buffer.from(data, 'base64')
135
+ fs.writeFileSync(imgPath, buffer)
136
+
137
+ images.push({
138
+ messageId,
139
+ filename,
140
+ mediaType,
141
+ sizeBytes: buffer.length,
142
+ timestamp: entry.timestamp || startTime,
143
+ role: msg.role || entryType || 'unknown',
144
+ })
145
+ imageIndex++
146
+ }
147
+ }
148
+ }
149
+ }
150
+ }
151
+
152
+ if (userMessages === 0 && assistantMessages === 0) return null
153
+
154
+ const durationMinutes =
155
+ startTime && endTime
156
+ ? (new Date(endTime).getTime() - new Date(startTime).getTime()) / 60000
157
+ : 0
158
+
159
+ return {
160
+ startTime,
161
+ endTime,
162
+ durationMinutes: Math.max(0, durationMinutes),
163
+ userMessages,
164
+ assistantMessages,
165
+ totalMessages: userMessages + assistantMessages,
166
+ inputTokens,
167
+ outputTokens,
168
+ cacheCreationTokens,
169
+ cacheReadTokens,
170
+ totalTokens: inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens,
171
+ costUSD,
172
+ model: model || 'unknown',
173
+ toolCalls,
174
+ toolCallsTotal: Object.values(toolCalls).reduce((a, b) => a + b, 0),
175
+ images,
176
+ }
177
+ }
178
+
179
+ export interface SyncResult {
180
+ filesProcessed: number
181
+ sessionsAdded: number
182
+ sessionsSkipped: number
183
+ imagesExtracted: number
184
+ errors: number
185
+ }
186
+
187
+ export async function syncLogs(): Promise<SyncResult> {
188
+ const allPricing = await loadPricing()
189
+ const result: SyncResult = {
190
+ filesProcessed: 0,
191
+ sessionsAdded: 0,
192
+ sessionsSkipped: 0,
193
+ imagesExtracted: 0,
194
+ errors: 0,
195
+ }
196
+
197
+ if (!fs.existsSync(PROJECTS_DIR)) {
198
+ return result
199
+ }
200
+
201
+ // Ensure images directory exists
202
+ if (!fs.existsSync(IMAGES_DIR)) {
203
+ fs.mkdirSync(IMAGES_DIR, { recursive: true })
204
+ }
205
+
206
+ // Get all existing sessionIds to skip
207
+ const existing = await prisma.session.findMany({ select: { sessionId: true } })
208
+ const existingIds = new Set(existing.map((s) => s.sessionId))
209
+
210
+ const projectDirs = fs.readdirSync(PROJECTS_DIR).filter((d) => {
211
+ try {
212
+ return fs.statSync(path.join(PROJECTS_DIR, d)).isDirectory()
213
+ } catch {
214
+ return false
215
+ }
216
+ })
217
+
218
+ for (const dir of projectDirs) {
219
+ const projectPath = decodeProjectPath(dir)
220
+ const projectName = getProjectName(projectPath)
221
+ const dirPath = path.join(PROJECTS_DIR, dir)
222
+
223
+ let jsonlFiles: string[]
224
+ try {
225
+ jsonlFiles = fs.readdirSync(dirPath).filter((f) => f.endsWith('.jsonl'))
226
+ } catch {
227
+ continue
228
+ }
229
+
230
+ for (const file of jsonlFiles) {
231
+ result.filesProcessed++
232
+ const sessionId = path.basename(file, '.jsonl')
233
+
234
+ if (existingIds.has(sessionId)) {
235
+ result.sessionsSkipped++
236
+ continue
237
+ }
238
+
239
+ try {
240
+ const parsed = parseSessionFile(path.join(dirPath, file), sessionId, allPricing)
241
+ if (!parsed) {
242
+ result.sessionsSkipped++
243
+ continue
244
+ }
245
+
246
+ await prisma.session.create({
247
+ data: {
248
+ sessionId,
249
+ project: projectName,
250
+ projectPath,
251
+ startTime: new Date(parsed.startTime),
252
+ endTime: new Date(parsed.endTime),
253
+ durationMinutes: parsed.durationMinutes,
254
+ userMessages: parsed.userMessages,
255
+ assistantMessages: parsed.assistantMessages,
256
+ totalMessages: parsed.totalMessages,
257
+ inputTokens: parsed.inputTokens,
258
+ outputTokens: parsed.outputTokens,
259
+ cacheCreationTokens: parsed.cacheCreationTokens,
260
+ cacheReadTokens: parsed.cacheReadTokens,
261
+ totalTokens: parsed.totalTokens,
262
+ costUSD: parsed.costUSD,
263
+ model: parsed.model,
264
+ toolCallsTotal: parsed.toolCallsTotal,
265
+ toolCallsJson: JSON.stringify(parsed.toolCalls),
266
+ },
267
+ })
268
+
269
+ // Insert image records
270
+ for (const img of parsed.images) {
271
+ await prisma.image.create({
272
+ data: {
273
+ sessionId,
274
+ messageId: img.messageId,
275
+ filename: img.filename,
276
+ mediaType: img.mediaType,
277
+ sizeBytes: img.sizeBytes,
278
+ timestamp: new Date(img.timestamp),
279
+ role: img.role,
280
+ },
281
+ })
282
+ result.imagesExtracted++
283
+ }
284
+
285
+ result.sessionsAdded++
286
+ } catch {
287
+ result.errors++
288
+ }
289
+ }
290
+ }
291
+
292
+ // Log the sync
293
+ await prisma.syncLog.create({
294
+ data: {
295
+ filesProcessed: result.filesProcessed,
296
+ sessionsAdded: result.sessionsAdded,
297
+ sessionsSkipped: result.sessionsSkipped,
298
+ },
299
+ })
300
+
301
+ return result
302
+ }
303
+
304
+ // Lightweight check: count how many JSONL files exist on disk that aren't in the DB
305
+ export async function checkForNewSessions(): Promise<number> {
306
+ if (!fs.existsSync(PROJECTS_DIR)) return 0
307
+
308
+ const existing = await prisma.session.findMany({ select: { sessionId: true } })
309
+ const existingIds = new Set(existing.map((s) => s.sessionId))
310
+
311
+ let newCount = 0
312
+ const projectDirs = fs.readdirSync(PROJECTS_DIR).filter((d) => {
313
+ try {
314
+ return fs.statSync(path.join(PROJECTS_DIR, d)).isDirectory()
315
+ } catch {
316
+ return false
317
+ }
318
+ })
319
+
320
+ for (const dir of projectDirs) {
321
+ const dirPath = path.join(PROJECTS_DIR, dir)
322
+ let jsonlFiles: string[]
323
+ try {
324
+ jsonlFiles = fs.readdirSync(dirPath).filter((f) => f.endsWith('.jsonl'))
325
+ } catch {
326
+ continue
327
+ }
328
+ for (const file of jsonlFiles) {
329
+ const sessionId = path.basename(file, '.jsonl')
330
+ if (!existingIds.has(sessionId)) newCount++
331
+ }
332
+ }
333
+
334
+ return newCount
335
+ }
package/lib/utils.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from "clsx"
2
+ import { twMerge } from "tailwind-merge"
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }
@@ -0,0 +1,4 @@
1
+ /** @type {import('next').NextConfig} */
2
+ const nextConfig = {}
3
+
4
+ export default nextConfig
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "agentfit",
3
+ "version": "0.1.0",
4
+ "description": "Fitness tracker dashboard for AI coding agents (Claude Code, Codex). Visualize usage, cost, tokens, and productivity from local conversation logs.",
5
+ "type": "module",
6
+ "bin": {
7
+ "agentfit": "bin/agentfit.mjs"
8
+ },
9
+ "keywords": [
10
+ "claude-code",
11
+ "codex",
12
+ "ai-agent",
13
+ "dashboard",
14
+ "analytics",
15
+ "usage-tracker"
16
+ ],
17
+ "author": "Harry Wang",
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/harrywang/agentfit.git"
22
+ },
23
+ "scripts": {
24
+ "postinstall": "prisma generate",
25
+ "dev": "next dev --turbopack",
26
+ "build": "next build",
27
+ "start": "next start",
28
+ "lint": "eslint",
29
+ "format": "prettier --write \"**/*.{ts,tsx}\"",
30
+ "typecheck": "tsc --noEmit",
31
+ "test": "vitest run",
32
+ "test:watch": "vitest"
33
+ },
34
+ "dependencies": {
35
+ "@base-ui/react": "^1.3.0",
36
+ "@libsql/client": "^0.17.2",
37
+ "@prisma/adapter-libsql": "^7.6.0",
38
+ "@prisma/client": "^7.6.0",
39
+ "@tabler/icons-react": "^3.41.0",
40
+ "class-variance-authority": "^0.7.1",
41
+ "clsx": "^2.1.1",
42
+ "date-fns": "^4.1.0",
43
+ "lucide-react": "^1.7.0",
44
+ "next": "16.1.7",
45
+ "next-themes": "^0.4.6",
46
+ "prisma": "^7.6.0",
47
+ "react": "^19.2.4",
48
+ "react-dom": "^19.2.4",
49
+ "recharts": "^3.8.0",
50
+ "shadcn": "^4.1.1",
51
+ "tailwind-merge": "^3.5.0",
52
+ "tw-animate-css": "^1.4.0"
53
+ },
54
+ "devDependencies": {
55
+ "@eslint/eslintrc": "^3",
56
+ "@tailwindcss/postcss": "^4.2.1",
57
+ "@testing-library/jest-dom": "^6.9.1",
58
+ "@testing-library/react": "^16.3.2",
59
+ "@types/node": "^25.5.0",
60
+ "@types/react": "^19.2.14",
61
+ "@types/react-dom": "^19.2.3",
62
+ "@vitejs/plugin-react": "^6.0.1",
63
+ "eslint": "^9.39.4",
64
+ "eslint-config-next": "16.1.7",
65
+ "jsdom": "^29.0.1",
66
+ "postcss": "^8",
67
+ "prettier": "^3.8.1",
68
+ "prettier-plugin-tailwindcss": "^0.7.2",
69
+ "tailwindcss": "^4.2.1",
70
+ "typescript": "^5.9.3",
71
+ "vitest": "^4.1.2"
72
+ }
73
+ }
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { screen } from '@testing-library/react'
3
+ import { renderPlugin } from '@/tests/plugin-helpers'
4
+ import { validateManifest } from '@/lib/plugins'
5
+ import CostHeatmap from './component'
6
+ import manifest from './manifest'
7
+
8
+ describe('cost-heatmap plugin', () => {
9
+ describe('manifest', () => {
10
+ it('passes validation', () => {
11
+ const errors = validateManifest(manifest)
12
+ expect(errors).toEqual([])
13
+ })
14
+
15
+ it('has required fields', () => {
16
+ expect(manifest.slug).toBe('cost-heatmap')
17
+ expect(manifest.name).toBe('Cost Heatmap')
18
+ expect(manifest.version).toMatch(/^\d+\.\d+\.\d+/)
19
+ })
20
+ })
21
+
22
+ describe('component', () => {
23
+ it('renders without crashing', () => {
24
+ const { container } = renderPlugin(CostHeatmap)
25
+ expect(container).toBeTruthy()
26
+ })
27
+
28
+ it('displays stats cards', () => {
29
+ renderPlugin(CostHeatmap)
30
+ expect(screen.getByText('Total Spend')).toBeInTheDocument()
31
+ expect(screen.getByText('Active Days')).toBeInTheDocument()
32
+ expect(screen.getByText('Peak Day')).toBeInTheDocument()
33
+ expect(screen.getByText('Peak Cost')).toBeInTheDocument()
34
+ })
35
+
36
+ it('displays the heatmap card', () => {
37
+ renderPlugin(CostHeatmap)
38
+ expect(screen.getByText('Daily Cost Heatmap')).toBeInTheDocument()
39
+ })
40
+
41
+ it('shows empty state when no data', () => {
42
+ renderPlugin(CostHeatmap, { daily: [] })
43
+ expect(screen.getByText('No data available yet.')).toBeInTheDocument()
44
+ })
45
+
46
+ it('renders legend', () => {
47
+ renderPlugin(CostHeatmap)
48
+ expect(screen.getByText('Less')).toBeInTheDocument()
49
+ expect(screen.getByText('More')).toBeInTheDocument()
50
+ })
51
+ })
52
+ })
@@ -0,0 +1,227 @@
1
+ 'use client'
2
+
3
+ import { useMemo } from 'react'
4
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
5
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
6
+ import { formatCost } from '@/lib/format'
7
+ import type { PluginProps } from '@/lib/plugins'
8
+
9
+ // ─── Helpers ────────────────────────────────────────────────────────
10
+
11
+ function getWeekday(dateStr: string): number {
12
+ return new Date(dateStr + 'T00:00:00').getDay()
13
+ }
14
+
15
+ function getMonthLabel(dateStr: string): string {
16
+ return new Date(dateStr + 'T00:00:00').toLocaleString('en-US', { month: 'short' })
17
+ }
18
+
19
+ function intensityClass(ratio: number): string {
20
+ if (ratio === 0) return 'bg-muted'
21
+ if (ratio < 0.25) return 'bg-chart-3/30'
22
+ if (ratio < 0.5) return 'bg-chart-3/50'
23
+ if (ratio < 0.75) return 'bg-chart-3/70'
24
+ return 'bg-chart-3'
25
+ }
26
+
27
+ const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
28
+
29
+ // ─── Component ──────────────────────────────────────────────────────
30
+
31
+ export default function CostHeatmap({ data }: PluginProps) {
32
+ const { grid, maxCost, weeks, monthLabels, stats } = useMemo(() => {
33
+ const dailyMap = new Map(data.daily.map((d) => [d.date, d.costUSD]))
34
+ const dates = data.daily.map((d) => d.date).sort()
35
+
36
+ if (dates.length === 0) {
37
+ return { grid: [], maxCost: 0, weeks: 0, monthLabels: [], stats: null }
38
+ }
39
+
40
+ const first = dates[0]
41
+ const last = dates[dates.length - 1]
42
+
43
+ // Build a continuous date range
44
+ const allDates: string[] = []
45
+ const cur = new Date(first + 'T00:00:00')
46
+ const end = new Date(last + 'T00:00:00')
47
+ while (cur <= end) {
48
+ allDates.push(cur.toISOString().slice(0, 10))
49
+ cur.setDate(cur.getDate() + 1)
50
+ }
51
+
52
+ // Pad the start to align to Sunday
53
+ const startPad = getWeekday(allDates[0])
54
+ const padded = [
55
+ ...Array.from({ length: startPad }, () => null),
56
+ ...allDates,
57
+ ]
58
+
59
+ const maxVal = Math.max(...allDates.map((d) => dailyMap.get(d) || 0), 0.01)
60
+ const numWeeks = Math.ceil(padded.length / 7)
61
+
62
+ // Build grid: grid[weekday][weekIndex]
63
+ const g: (({ date: string; cost: number; ratio: number } | null))[][] = Array.from(
64
+ { length: 7 },
65
+ () => Array.from({ length: numWeeks }, () => null),
66
+ )
67
+
68
+ for (let i = 0; i < padded.length; i++) {
69
+ const week = Math.floor(i / 7)
70
+ const day = i % 7
71
+ const date = padded[i]
72
+ if (date) {
73
+ const cost = dailyMap.get(date) || 0
74
+ g[day][week] = { date, cost, ratio: cost / maxVal }
75
+ }
76
+ }
77
+
78
+ // Month labels at week boundaries
79
+ const labels: { label: string; week: number }[] = []
80
+ let lastMonth = ''
81
+ for (let i = 0; i < padded.length; i++) {
82
+ const date = padded[i]
83
+ if (!date) continue
84
+ const month = getMonthLabel(date)
85
+ if (month !== lastMonth) {
86
+ labels.push({ label: month, week: Math.floor(i / 7) })
87
+ lastMonth = month
88
+ }
89
+ }
90
+
91
+ // Stats
92
+ const costs = allDates.map((d) => dailyMap.get(d) || 0)
93
+ const total = costs.reduce((a, b) => a + b, 0)
94
+ const activeDays = costs.filter((c) => c > 0).length
95
+ const peakDay = allDates[costs.indexOf(Math.max(...costs))]
96
+
97
+ return {
98
+ grid: g,
99
+ maxCost: maxVal,
100
+ weeks: numWeeks,
101
+ monthLabels: labels,
102
+ stats: { total, activeDays, totalDays: allDates.length, peakDay, peakCost: maxVal },
103
+ }
104
+ }, [data.daily])
105
+
106
+ if (!stats) {
107
+ return (
108
+ <Card>
109
+ <CardHeader>
110
+ <CardTitle>Cost Heatmap</CardTitle>
111
+ </CardHeader>
112
+ <CardContent>
113
+ <p className="text-muted-foreground">No data available yet.</p>
114
+ </CardContent>
115
+ </Card>
116
+ )
117
+ }
118
+
119
+ return (
120
+ <div className="space-y-6">
121
+ {/* Stats row */}
122
+ <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
123
+ <Card>
124
+ <CardHeader className="pb-2">
125
+ <CardDescription>Total Spend</CardDescription>
126
+ <CardTitle className="text-2xl">{formatCost(stats.total)}</CardTitle>
127
+ </CardHeader>
128
+ </Card>
129
+ <Card>
130
+ <CardHeader className="pb-2">
131
+ <CardDescription>Active Days</CardDescription>
132
+ <CardTitle className="text-2xl">
133
+ {stats.activeDays} / {stats.totalDays}
134
+ </CardTitle>
135
+ </CardHeader>
136
+ </Card>
137
+ <Card>
138
+ <CardHeader className="pb-2">
139
+ <CardDescription>Peak Day</CardDescription>
140
+ <CardTitle className="text-2xl">{stats.peakDay}</CardTitle>
141
+ </CardHeader>
142
+ </Card>
143
+ <Card>
144
+ <CardHeader className="pb-2">
145
+ <CardDescription>Peak Cost</CardDescription>
146
+ <CardTitle className="text-2xl">{formatCost(stats.peakCost)}</CardTitle>
147
+ </CardHeader>
148
+ </Card>
149
+ </div>
150
+
151
+ {/* Heatmap */}
152
+ <Card>
153
+ <CardHeader>
154
+ <CardTitle>Daily Cost Heatmap</CardTitle>
155
+ <CardDescription>
156
+ Each cell represents one day. Darker cells = higher spending.
157
+ </CardDescription>
158
+ </CardHeader>
159
+ <CardContent>
160
+ <div className="overflow-x-auto">
161
+ {/* Month labels */}
162
+ <div className="mb-1 flex" style={{ paddingLeft: '2rem' }}>
163
+ {monthLabels.map((m, i) => (
164
+ <span
165
+ key={i}
166
+ className="text-xs text-muted-foreground"
167
+ style={{
168
+ position: 'relative',
169
+ left: `${m.week * 14}px`,
170
+ marginRight: i < monthLabels.length - 1 ? 0 : undefined,
171
+ }}
172
+ >
173
+ {m.label}
174
+ </span>
175
+ ))}
176
+ </div>
177
+ {/* Grid */}
178
+ <div className="flex gap-0.5">
179
+ {/* Weekday labels */}
180
+ <div className="flex flex-col gap-0.5 pr-1">
181
+ {WEEKDAYS.map((d, i) => (
182
+ <span
183
+ key={d}
184
+ className="flex h-3 w-6 items-center text-[10px] text-muted-foreground"
185
+ >
186
+ {i % 2 === 1 ? d : ''}
187
+ </span>
188
+ ))}
189
+ </div>
190
+ {/* Weeks */}
191
+ {Array.from({ length: weeks }, (_, weekIdx) => (
192
+ <div key={weekIdx} className="flex flex-col gap-0.5">
193
+ {Array.from({ length: 7 }, (_, dayIdx) => {
194
+ const cell = grid[dayIdx]?.[weekIdx]
195
+ if (!cell) {
196
+ return <div key={dayIdx} className="h-3 w-3 rounded-sm" />
197
+ }
198
+ return (
199
+ <Tooltip key={dayIdx}>
200
+ <TooltipTrigger render={<div className={`h-3 w-3 rounded-sm ${intensityClass(cell.ratio)} transition-colors hover:ring-1 hover:ring-foreground`} />}>
201
+ </TooltipTrigger>
202
+ <TooltipContent>
203
+ <p className="text-xs font-medium">{cell.date}</p>
204
+ <p className="text-xs text-muted-foreground">{formatCost(cell.cost)}</p>
205
+ </TooltipContent>
206
+ </Tooltip>
207
+ )
208
+ })}
209
+ </div>
210
+ ))}
211
+ </div>
212
+ {/* Legend */}
213
+ <div className="mt-3 flex items-center gap-1 text-xs text-muted-foreground">
214
+ <span>Less</span>
215
+ <div className="h-3 w-3 rounded-sm bg-muted" />
216
+ <div className="h-3 w-3 rounded-sm bg-chart-3/30" />
217
+ <div className="h-3 w-3 rounded-sm bg-chart-3/50" />
218
+ <div className="h-3 w-3 rounded-sm bg-chart-3/70" />
219
+ <div className="h-3 w-3 rounded-sm bg-chart-3" />
220
+ <span>More</span>
221
+ </div>
222
+ </div>
223
+ </CardContent>
224
+ </Card>
225
+ </div>
226
+ )
227
+ }