agentfit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +26 -0
- package/.prettierignore +7 -0
- package/.prettierrc +11 -0
- package/CONTRIBUTING.md +209 -0
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/app/(dashboard)/coach/page.tsx +11 -0
- package/app/(dashboard)/commands/page.tsx +7 -0
- package/app/(dashboard)/community/[slug]/page.tsx +23 -0
- package/app/(dashboard)/community/page.tsx +71 -0
- package/app/(dashboard)/daily/page.tsx +19 -0
- package/app/(dashboard)/images/page.tsx +5 -0
- package/app/(dashboard)/layout.tsx +12 -0
- package/app/(dashboard)/page.tsx +23 -0
- package/app/(dashboard)/personality/page.tsx +11 -0
- package/app/(dashboard)/projects/page.tsx +11 -0
- package/app/(dashboard)/sessions/page.tsx +11 -0
- package/app/(dashboard)/tokens/page.tsx +11 -0
- package/app/(dashboard)/tools/page.tsx +11 -0
- package/app/api/check/route.ts +13 -0
- package/app/api/commands/route.ts +16 -0
- package/app/api/images/[...path]/route.ts +33 -0
- package/app/api/images-analysis/route.ts +177 -0
- package/app/api/sync/route.ts +14 -0
- package/app/api/usage/route.ts +117 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +144 -0
- package/app/icon.svg +3 -0
- package/app/layout.tsx +35 -0
- package/bin/agentfit.mjs +69 -0
- package/components/.gitkeep +0 -0
- package/components/agent-coach.tsx +248 -0
- package/components/app-sidebar.tsx +161 -0
- package/components/command-usage.tsx +294 -0
- package/components/daily-chart.tsx +118 -0
- package/components/daily-table.tsx +115 -0
- package/components/dashboard-shell.tsx +149 -0
- package/components/data-provider.tsx +213 -0
- package/components/fitness-score.tsx +95 -0
- package/components/overview-cards.tsx +198 -0
- package/components/pagination-controls.tsx +104 -0
- package/components/personality-fit.tsx +446 -0
- package/components/projects-table.tsx +70 -0
- package/components/screenshots-analysis.tsx +359 -0
- package/components/sessions-table.tsx +97 -0
- package/components/theme-provider.tsx +71 -0
- package/components/token-breakdown.tsx +179 -0
- package/components/tool-usage-chart.tsx +63 -0
- package/components/ui/badge.tsx +52 -0
- package/components/ui/button.tsx +60 -0
- package/components/ui/card.tsx +103 -0
- package/components/ui/chart.tsx +373 -0
- package/components/ui/dialog.tsx +160 -0
- package/components/ui/input.tsx +20 -0
- package/components/ui/scroll-area.tsx +55 -0
- package/components/ui/select.tsx +201 -0
- package/components/ui/separator.tsx +25 -0
- package/components/ui/sheet.tsx +138 -0
- package/components/ui/sidebar.tsx +723 -0
- package/components/ui/skeleton.tsx +13 -0
- package/components/ui/table.tsx +116 -0
- package/components/ui/tabs.tsx +82 -0
- package/components/ui/tooltip.tsx +66 -0
- package/components.json +25 -0
- package/generated/prisma/browser.ts +34 -0
- package/generated/prisma/client.ts +58 -0
- package/generated/prisma/commonInputTypes.ts +237 -0
- package/generated/prisma/enums.ts +15 -0
- package/generated/prisma/internal/class.ts +224 -0
- package/generated/prisma/internal/prismaNamespace.ts +920 -0
- package/generated/prisma/internal/prismaNamespaceBrowser.ts +130 -0
- package/generated/prisma/models/Image.ts +1310 -0
- package/generated/prisma/models/Session.ts +1695 -0
- package/generated/prisma/models/SyncLog.ts +1203 -0
- package/generated/prisma/models.ts +14 -0
- package/hooks/.gitkeep +0 -0
- package/hooks/use-mobile.ts +19 -0
- package/hooks/use-pagination.ts +60 -0
- package/lib/.gitkeep +0 -0
- package/lib/coach.ts +425 -0
- package/lib/commands.ts +239 -0
- package/lib/db.ts +15 -0
- package/lib/format.ts +26 -0
- package/lib/parse-codex.ts +201 -0
- package/lib/parse-logs.ts +369 -0
- package/lib/personality.ts +481 -0
- package/lib/plugins.ts +107 -0
- package/lib/pricing.ts +112 -0
- package/lib/queries-codex.ts +130 -0
- package/lib/queries.ts +154 -0
- package/lib/resolve-icon.ts +12 -0
- package/lib/sync.ts +335 -0
- package/lib/utils.ts +6 -0
- package/next.config.mjs +4 -0
- package/package.json +73 -0
- package/plugins/cost-heatmap/component.test.tsx +52 -0
- package/plugins/cost-heatmap/component.tsx +227 -0
- package/plugins/cost-heatmap/manifest.ts +13 -0
- package/plugins/index.ts +18 -0
- package/prisma/migrations/20260328152517_init/migration.sql +41 -0
- package/prisma/migrations/20260328153801_add_image_model/migration.sql +18 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +57 -0
- package/prisma.config.ts +14 -0
- package/public/.gitkeep +0 -0
- package/public/logo.svg +3 -0
- package/setup.sh +73 -0
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
package/next.config.mjs
ADDED
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
|
+
}
|