specrails-hub 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.
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>specrails manager</title>
7
+ <script type="module" crossorigin src="/assets/index-DoIYcnfd.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-BEc7DzgE.css">
9
+ </head>
10
+ <body>
11
+ <div id="root"></div>
12
+ </body>
13
+ </html>
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "specrails-hub",
3
+ "version": "0.1.0",
4
+ "description": "Local dashboard and CLI for managing multiple specrails projects from a single interface",
5
+ "keywords": ["specrails", "claude", "ai", "pipeline", "dashboard", "cli"],
6
+ "author": "fjpulidop",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/fjpulidop/specrails-hub.git"
11
+ },
12
+ "homepage": "https://github.com/fjpulidop/specrails-hub#readme",
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "files": [
17
+ "cli/dist",
18
+ "server",
19
+ "client/dist",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "bin": {
24
+ "srm": "./cli/dist/srm.js"
25
+ },
26
+ "scripts": {
27
+ "dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
28
+ "dev:server": "tsx watch server/index.ts",
29
+ "dev:client": "cd client && npm run dev",
30
+ "build:cli": "tsc --project tsconfig.cli.json",
31
+ "build": "cd client && npm run build && cd .. && npm run build:cli",
32
+ "typecheck": "tsc --noEmit && cd client && tsc --noEmit",
33
+ "test": "vitest run",
34
+ "test:watch": "vitest"
35
+ },
36
+ "dependencies": {
37
+ "@tailwindcss/typography": "^0.5.19",
38
+ "better-sqlite3": "^12.8.0",
39
+ "express": "^4.18.0",
40
+ "tree-kill": "^1.2.2",
41
+ "uuid": "^9.0.0",
42
+ "ws": "^8.16.0"
43
+ },
44
+ "devDependencies": {
45
+ "@types/better-sqlite3": "^7.6.0",
46
+ "@types/express": "^4.17.0",
47
+ "@types/node": "^20.0.0",
48
+ "@types/supertest": "^2.0.0",
49
+ "@types/uuid": "^9.0.0",
50
+ "@types/ws": "^8.5.0",
51
+ "concurrently": "^8.2.0",
52
+ "supertest": "^6.3.0",
53
+ "tsx": "^4.7.0",
54
+ "typescript": "^5.4.0",
55
+ "vitest": "^3.0.0"
56
+ }
57
+ }
@@ -0,0 +1,166 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { initDb } from './db'
3
+ import type { DbInstance } from './db'
4
+ import { getAnalytics } from './analytics'
5
+
6
+ function insertJob(
7
+ db: DbInstance,
8
+ opts: {
9
+ id: string
10
+ command?: string
11
+ started_at: string
12
+ status?: string
13
+ total_cost_usd?: number | null
14
+ duration_ms?: number | null
15
+ duration_api_ms?: number | null
16
+ model?: string | null
17
+ tokens_in?: number | null
18
+ tokens_out?: number | null
19
+ tokens_cache_read?: number | null
20
+ }
21
+ ) {
22
+ db.prepare(`
23
+ INSERT INTO jobs (id, command, started_at, status, total_cost_usd, duration_ms, duration_api_ms, model, tokens_in, tokens_out, tokens_cache_read)
24
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
25
+ `).run(
26
+ opts.id,
27
+ opts.command ?? 'test-command',
28
+ opts.started_at,
29
+ opts.status ?? 'completed',
30
+ opts.total_cost_usd ?? null,
31
+ opts.duration_ms ?? null,
32
+ opts.duration_api_ms ?? null,
33
+ opts.model ?? null,
34
+ opts.tokens_in ?? null,
35
+ opts.tokens_out ?? null,
36
+ opts.tokens_cache_read ?? null,
37
+ )
38
+ }
39
+
40
+ describe('getAnalytics', () => {
41
+ let db: DbInstance
42
+
43
+ beforeEach(() => {
44
+ db = initDb(':memory:')
45
+ })
46
+
47
+ it('empty DB returns zero aggregates and empty arrays', () => {
48
+ const result = getAnalytics(db, { period: '7d' })
49
+
50
+ expect(result.kpi.totalJobs).toBe(0)
51
+ expect(result.kpi.totalCostUsd).toBe(0)
52
+ expect(result.kpi.successRate).toBe(0)
53
+ expect(result.kpi.avgDurationMs).toBeNull()
54
+ expect(result.statusBreakdown).toHaveLength(0)
55
+ expect(result.tokenEfficiency).toHaveLength(0)
56
+ expect(result.commandPerformance).toHaveLength(0)
57
+ expect(result.durationPercentiles.p50).toBeNull()
58
+ expect(result.durationPercentiles.p75).toBeNull()
59
+ expect(result.durationPercentiles.p95).toBeNull()
60
+ })
61
+
62
+ it('single completed job populates KPI correctly', () => {
63
+ insertJob(db, {
64
+ id: 'job-1',
65
+ command: 'sr:implement',
66
+ started_at: new Date().toISOString(),
67
+ status: 'completed',
68
+ total_cost_usd: 0.0042,
69
+ duration_ms: 90000,
70
+ })
71
+
72
+ const result = getAnalytics(db, { period: 'all' })
73
+
74
+ expect(result.kpi.totalJobs).toBe(1)
75
+ expect(result.kpi.totalCostUsd).toBeCloseTo(0.0042)
76
+ expect(result.kpi.successRate).toBe(1)
77
+ expect(result.kpi.avgDurationMs).toBe(90000)
78
+ // 'all' period has no deltas
79
+ expect(result.kpi.costDelta).toBeNull()
80
+ expect(result.kpi.jobsDelta).toBeNull()
81
+ })
82
+
83
+ it('fills zero-cost date gaps in costTimeline', () => {
84
+ // Insert jobs on day 1 and day 3 (gap on day 2)
85
+ insertJob(db, {
86
+ id: 'job-day1',
87
+ started_at: '2026-03-01T12:00:00.000Z',
88
+ status: 'completed',
89
+ total_cost_usd: 0.001,
90
+ })
91
+ insertJob(db, {
92
+ id: 'job-day3',
93
+ started_at: '2026-03-03T12:00:00.000Z',
94
+ status: 'completed',
95
+ total_cost_usd: 0.002,
96
+ })
97
+
98
+ const result = getAnalytics(db, { period: 'custom', from: '2026-03-01', to: '2026-03-03' })
99
+
100
+ expect(result.costTimeline).toHaveLength(3)
101
+ const dates = result.costTimeline.map((r) => r.date)
102
+ expect(dates).toContain('2026-03-01')
103
+ expect(dates).toContain('2026-03-02')
104
+ expect(dates).toContain('2026-03-03')
105
+
106
+ const day2 = result.costTimeline.find((r) => r.date === '2026-03-02')
107
+ expect(day2?.costUsd).toBe(0)
108
+ })
109
+
110
+ it('computes correct deltas when previous period has higher cost', () => {
111
+ // Previous period: 2026-02-01 to 2026-02-07 with cost 0.010
112
+ insertJob(db, {
113
+ id: 'prev-job',
114
+ started_at: '2026-02-04T12:00:00.000Z',
115
+ status: 'completed',
116
+ total_cost_usd: 0.010,
117
+ })
118
+ // Current period: 2026-02-08 to 2026-02-14 with cost 0.004
119
+ insertJob(db, {
120
+ id: 'curr-job',
121
+ started_at: '2026-02-11T12:00:00.000Z',
122
+ status: 'completed',
123
+ total_cost_usd: 0.004,
124
+ })
125
+
126
+ const result = getAnalytics(db, { period: 'custom', from: '2026-02-08', to: '2026-02-14' })
127
+
128
+ // costDelta = current - previous = 0.004 - 0.010 = -0.006
129
+ expect(result.kpi.costDelta).not.toBeNull()
130
+ expect(result.kpi.costDelta!).toBeCloseTo(-0.006, 5)
131
+ // jobsDelta = 1 - 1 = 0
132
+ expect(result.kpi.jobsDelta).toBe(0)
133
+ })
134
+
135
+ it('treats NULL cost job as 0 in aggregations', () => {
136
+ insertJob(db, {
137
+ id: 'null-cost-job',
138
+ started_at: new Date().toISOString(),
139
+ status: 'canceled',
140
+ total_cost_usd: null,
141
+ })
142
+
143
+ const result = getAnalytics(db, { period: 'all' })
144
+
145
+ expect(result.kpi.totalJobs).toBe(1)
146
+ expect(result.kpi.totalCostUsd).toBe(0)
147
+ // canceled job does not count toward success rate
148
+ expect(result.kpi.successRate).toBe(0)
149
+ })
150
+
151
+ it('duration histogram returns fixed bucket order', () => {
152
+ // Insert jobs spanning multiple buckets
153
+ insertJob(db, { id: 'j1', started_at: new Date().toISOString(), status: 'completed', duration_ms: 30000 }) // <1m
154
+ insertJob(db, { id: 'j2', started_at: new Date().toISOString(), status: 'completed', duration_ms: 120000 }) // 1-3m
155
+ insertJob(db, { id: 'j3', started_at: new Date().toISOString(), status: 'completed', duration_ms: 700000 }) // >10m
156
+
157
+ const result = getAnalytics(db, { period: 'all' })
158
+
159
+ const buckets = result.durationHistogram.map((r) => r.bucket)
160
+ expect(buckets).toEqual(['<1m', '1-3m', '3-5m', '5-10m', '>10m'])
161
+ expect(result.durationHistogram.find((r) => r.bucket === '<1m')?.count).toBe(1)
162
+ expect(result.durationHistogram.find((r) => r.bucket === '1-3m')?.count).toBe(1)
163
+ expect(result.durationHistogram.find((r) => r.bucket === '3-5m')?.count).toBe(0)
164
+ expect(result.durationHistogram.find((r) => r.bucket === '>10m')?.count).toBe(1)
165
+ })
166
+ })
@@ -0,0 +1,318 @@
1
+ import type { DbInstance } from './db'
2
+ import type { AnalyticsOpts, AnalyticsResponse } from './types'
3
+
4
+ // ─── Period resolution ────────────────────────────────────────────────────────
5
+
6
+ interface DateBounds {
7
+ from: string | null
8
+ to: string | null
9
+ }
10
+
11
+ function resolveBounds(opts: AnalyticsOpts): { current: DateBounds; previous: DateBounds | null } {
12
+ const now = new Date()
13
+ const toISO = (d: Date) => d.toISOString().slice(0, 10)
14
+
15
+ if (opts.period === 'all') {
16
+ return { current: { from: null, to: null }, previous: null }
17
+ }
18
+
19
+ if (opts.period === 'custom') {
20
+ const from = opts.from!
21
+ const to = opts.to!
22
+ const diffMs = new Date(to).getTime() - new Date(from).getTime()
23
+ const prevTo = new Date(new Date(from).getTime() - 1).toISOString().slice(0, 10)
24
+ const prevFrom = toISO(new Date(new Date(from).getTime() - diffMs - 86400000))
25
+ return {
26
+ current: { from, to },
27
+ previous: { from: prevFrom, to: prevTo },
28
+ }
29
+ }
30
+
31
+ const days = opts.period === '7d' ? 7 : opts.period === '30d' ? 30 : 90
32
+ const currentFrom = toISO(new Date(now.getTime() - days * 86400000))
33
+ const currentTo = toISO(now)
34
+ const prevTo = toISO(new Date(new Date(currentFrom).getTime() - 86400000))
35
+ const prevFrom = toISO(new Date(new Date(currentFrom).getTime() - days * 86400000))
36
+
37
+ return {
38
+ current: { from: currentFrom, to: currentTo },
39
+ previous: { from: prevFrom, to: prevTo },
40
+ }
41
+ }
42
+
43
+ function buildWhere(bounds: DateBounds): { clause: string; params: unknown[] } {
44
+ if (!bounds.from && !bounds.to) return { clause: '', params: [] }
45
+ if (bounds.from && bounds.to) {
46
+ // Use < next_day instead of <= to, because started_at is a full ISO timestamp
47
+ // e.g. '2026-03-15T14:00:00Z' > '2026-03-15' lexicographically
48
+ const nextDay = new Date(new Date(bounds.to).getTime() + 86400000).toISOString().slice(0, 10)
49
+ return {
50
+ clause: "WHERE started_at >= ? AND started_at < ?",
51
+ params: [bounds.from, nextDay],
52
+ }
53
+ }
54
+ if (bounds.from) return { clause: 'WHERE started_at >= ?', params: [bounds.from] }
55
+ const nextDay = new Date(new Date(bounds.to!).getTime() + 86400000).toISOString().slice(0, 10)
56
+ return { clause: 'WHERE started_at < ?', params: [nextDay] }
57
+ }
58
+
59
+ // ─── Percentile helpers ───────────────────────────────────────────────────────
60
+
61
+ function percentile(sorted: number[], p: number): number | null {
62
+ if (sorted.length === 0) return null
63
+ const idx = Math.floor(sorted.length * p)
64
+ return sorted[Math.min(idx, sorted.length - 1)]
65
+ }
66
+
67
+ // ─── Date series zero-fill ────────────────────────────────────────────────────
68
+
69
+ function fillDateSeries(
70
+ data: Array<{ date: string; [key: string]: unknown }>,
71
+ from: string,
72
+ to: string,
73
+ keys: string[]
74
+ ): Array<Record<string, unknown>> {
75
+ const byDate = new Map(data.map((row) => [row.date, row]))
76
+ const result: Array<Record<string, unknown>> = []
77
+ const start = new Date(from)
78
+ const end = new Date(to)
79
+ for (const d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
80
+ const date = d.toISOString().slice(0, 10)
81
+ const row = byDate.get(date) ?? { date }
82
+ const filled: Record<string, unknown> = { date }
83
+ for (const key of keys) {
84
+ filled[key] = (row as Record<string, unknown>)[key] ?? 0
85
+ }
86
+ result.push(filled)
87
+ }
88
+ return result
89
+ }
90
+
91
+ // ─── Main export ─────────────────────────────────────────────────────────────
92
+
93
+ export function getAnalytics(db: DbInstance, opts: AnalyticsOpts): AnalyticsResponse {
94
+ const { current, previous } = resolveBounds(opts)
95
+ const { clause: curWhere, params: curParams } = buildWhere(current)
96
+
97
+ const periodLabel = opts.period === '7d' ? 'Last 7 days'
98
+ : opts.period === '30d' ? 'Last 30 days'
99
+ : opts.period === '90d' ? 'Last 90 days'
100
+ : opts.period === 'all' ? 'All time'
101
+ : `${opts.from} to ${opts.to}`
102
+
103
+ // ── KPI aggregate ──────────────────────────────────────────────────────────
104
+ const kpiRow = db.prepare(`
105
+ SELECT
106
+ COUNT(*) as totalJobs,
107
+ COALESCE(SUM(total_cost_usd), 0) as totalCostUsd,
108
+ AVG(duration_ms) as avgDurationMs,
109
+ SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as successCount
110
+ FROM jobs ${curWhere}
111
+ `).get(...curParams) as {
112
+ totalJobs: number
113
+ totalCostUsd: number
114
+ avgDurationMs: number | null
115
+ successCount: number
116
+ }
117
+
118
+ const successRate = kpiRow.totalJobs > 0 ? kpiRow.successCount / kpiRow.totalJobs : 0
119
+
120
+ let prevKpi: typeof kpiRow | null = null
121
+ let prevSuccessRate = 0
122
+ if (previous) {
123
+ const { clause: prevWhere, params: prevParams } = buildWhere(previous)
124
+ prevKpi = db.prepare(`
125
+ SELECT
126
+ COUNT(*) as totalJobs,
127
+ COALESCE(SUM(total_cost_usd), 0) as totalCostUsd,
128
+ AVG(duration_ms) as avgDurationMs,
129
+ SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as successCount
130
+ FROM jobs ${prevWhere}
131
+ `).get(...prevParams) as typeof kpiRow
132
+ prevSuccessRate = prevKpi.totalJobs > 0 ? prevKpi.successCount / prevKpi.totalJobs : 0
133
+ }
134
+
135
+ // ── Cost timeline ──────────────────────────────────────────────────────────
136
+ const rawTimeline = db.prepare(`
137
+ SELECT
138
+ strftime('%Y-%m-%d', started_at) as date,
139
+ COALESCE(SUM(total_cost_usd), 0) as costUsd
140
+ FROM jobs ${curWhere}
141
+ GROUP BY date
142
+ ORDER BY date ASC
143
+ `).all(...curParams) as Array<{ date: string; costUsd: number }>
144
+
145
+ const costTimeline = current.from && current.to
146
+ ? fillDateSeries(rawTimeline, current.from, current.to, ['costUsd']) as Array<{ date: string; costUsd: number }>
147
+ : rawTimeline
148
+
149
+ // ── Status breakdown ───────────────────────────────────────────────────────
150
+ const statusBreakdown = db.prepare(`
151
+ SELECT status, COUNT(*) as count
152
+ FROM jobs ${curWhere}
153
+ GROUP BY status
154
+ `).all(...curParams) as Array<{ status: string; count: number }>
155
+
156
+ // ── Duration histogram ─────────────────────────────────────────────────────
157
+ const durationWhere = curWhere
158
+ ? `${curWhere} AND duration_ms IS NOT NULL AND status = 'completed'`
159
+ : "WHERE duration_ms IS NOT NULL AND status = 'completed'"
160
+
161
+ const rawHistogram = db.prepare(`
162
+ SELECT
163
+ CASE
164
+ WHEN duration_ms < 60000 THEN '<1m'
165
+ WHEN duration_ms < 180000 THEN '1-3m'
166
+ WHEN duration_ms < 300000 THEN '3-5m'
167
+ WHEN duration_ms < 600000 THEN '5-10m'
168
+ ELSE '>10m'
169
+ END as bucket,
170
+ COUNT(*) as count
171
+ FROM jobs ${durationWhere}
172
+ GROUP BY bucket
173
+ `).all(...curParams) as Array<{ bucket: string; count: number }>
174
+
175
+ const BUCKET_ORDER = ['<1m', '1-3m', '3-5m', '5-10m', '>10m']
176
+ const bucketMap = new Map(rawHistogram.map((r) => [r.bucket, r.count]))
177
+ const durationHistogram = BUCKET_ORDER.map((bucket) => ({
178
+ bucket,
179
+ count: bucketMap.get(bucket) ?? 0,
180
+ }))
181
+
182
+ // Percentiles computed in JS from sorted duration array
183
+ const durRows = db.prepare(`
184
+ SELECT duration_ms FROM jobs ${durationWhere} ORDER BY duration_ms ASC
185
+ `).all(...curParams) as Array<{ duration_ms: number }>
186
+ const sortedDurations = durRows.map((r) => r.duration_ms)
187
+
188
+ // ── Token efficiency ───────────────────────────────────────────────────────
189
+ const tokenEfficiency = db.prepare(`
190
+ SELECT
191
+ command,
192
+ COALESCE(SUM(tokens_out), 0) as tokensOut,
193
+ COALESCE(SUM(tokens_cache_read), 0) as tokensCacheRead,
194
+ COALESCE(SUM(tokens_in) + SUM(tokens_out), 0) as totalTokens
195
+ FROM jobs ${curWhere}
196
+ GROUP BY command
197
+ ORDER BY totalTokens DESC
198
+ LIMIT 10
199
+ `).all(...curParams) as Array<{
200
+ command: string
201
+ tokensOut: number
202
+ tokensCacheRead: number
203
+ totalTokens: number
204
+ }>
205
+
206
+ // ── Command performance ────────────────────────────────────────────────────
207
+ const commandPerformance = db.prepare(`
208
+ SELECT
209
+ command,
210
+ COUNT(*) as totalRuns,
211
+ SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as successCount,
212
+ AVG(CASE WHEN total_cost_usd IS NOT NULL THEN total_cost_usd END) as avgCostUsd,
213
+ AVG(CASE WHEN duration_ms IS NOT NULL THEN duration_ms END) as avgDurationMs,
214
+ COALESCE(SUM(total_cost_usd), 0) as totalCostUsd
215
+ FROM jobs ${curWhere}
216
+ GROUP BY command
217
+ ORDER BY totalCostUsd DESC
218
+ `).all(...curParams) as Array<{
219
+ command: string
220
+ totalRuns: number
221
+ successCount: number
222
+ avgCostUsd: number | null
223
+ avgDurationMs: number | null
224
+ totalCostUsd: number
225
+ }>
226
+
227
+ // ── Daily throughput ───────────────────────────────────────────────────────
228
+ const rawThroughput = db.prepare(`
229
+ SELECT
230
+ strftime('%Y-%m-%d', started_at) as date,
231
+ SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
232
+ SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed,
233
+ SUM(CASE WHEN status = 'canceled' THEN 1 ELSE 0 END) as canceled
234
+ FROM jobs ${curWhere}
235
+ GROUP BY date
236
+ ORDER BY date ASC
237
+ `).all(...curParams) as Array<{ date: string; completed: number; failed: number; canceled: number }>
238
+
239
+ const dailyThroughput = current.from && current.to
240
+ ? fillDateSeries(rawThroughput, current.from, current.to, ['completed', 'failed', 'canceled']) as typeof rawThroughput
241
+ : rawThroughput
242
+
243
+ // ── Cost per command ───────────────────────────────────────────────────────
244
+ const costPerCommand = db.prepare(`
245
+ SELECT
246
+ command,
247
+ COALESCE(SUM(total_cost_usd), 0) as totalCostUsd,
248
+ COUNT(*) as jobCount
249
+ FROM jobs ${curWhere}
250
+ GROUP BY command
251
+ ORDER BY totalCostUsd DESC
252
+ `).all(...curParams) as Array<{ command: string; totalCostUsd: number; jobCount: number }>
253
+
254
+ // ── Bonus metrics ──────────────────────────────────────────────────────────
255
+ const successCount = kpiRow.successCount
256
+ const failureCostRow = db.prepare(`
257
+ SELECT COALESCE(SUM(total_cost_usd), 0) as failureCostUsd
258
+ FROM jobs ${curWhere ? `${curWhere} AND` : 'WHERE'} status = 'failed'
259
+ `).get(...curParams) as { failureCostUsd: number }
260
+
261
+ // API efficiency: only for jobs that have both duration fields
262
+ const efficiencyRow = db.prepare(`
263
+ SELECT AVG(CAST(duration_api_ms AS REAL) / CAST(duration_ms AS REAL)) as ratio
264
+ FROM jobs ${curWhere ? `${curWhere} AND` : 'WHERE'} duration_ms > 0 AND duration_api_ms IS NOT NULL
265
+ `).get(...curParams) as { ratio: number | null }
266
+
267
+ const modelBreakdown = db.prepare(`
268
+ SELECT
269
+ COALESCE(model, 'unknown') as model,
270
+ COUNT(*) as jobCount,
271
+ COALESCE(SUM(total_cost_usd), 0) as totalCostUsd
272
+ FROM jobs ${curWhere}
273
+ GROUP BY model
274
+ ORDER BY totalCostUsd DESC
275
+ `).all(...curParams) as Array<{ model: string; jobCount: number; totalCostUsd: number }>
276
+
277
+ return {
278
+ period: {
279
+ label: periodLabel,
280
+ from: current.from,
281
+ to: current.to,
282
+ },
283
+ kpi: {
284
+ totalCostUsd: kpiRow.totalCostUsd,
285
+ totalJobs: kpiRow.totalJobs,
286
+ successRate,
287
+ avgDurationMs: kpiRow.avgDurationMs,
288
+ costDelta: prevKpi !== null ? kpiRow.totalCostUsd - prevKpi.totalCostUsd : null,
289
+ jobsDelta: prevKpi !== null ? kpiRow.totalJobs - prevKpi.totalJobs : null,
290
+ successRateDelta: prevKpi !== null ? successRate - prevSuccessRate : null,
291
+ avgDurationDelta:
292
+ prevKpi !== null && kpiRow.avgDurationMs !== null && prevKpi.avgDurationMs !== null
293
+ ? kpiRow.avgDurationMs - prevKpi.avgDurationMs
294
+ : null,
295
+ },
296
+ costTimeline,
297
+ statusBreakdown,
298
+ durationHistogram,
299
+ durationPercentiles: {
300
+ p50: percentile(sortedDurations, 0.5),
301
+ p75: percentile(sortedDurations, 0.75),
302
+ p95: percentile(sortedDurations, 0.95),
303
+ },
304
+ tokenEfficiency,
305
+ commandPerformance: commandPerformance.map((r) => ({
306
+ ...r,
307
+ successRate: r.totalRuns > 0 ? r.successCount / r.totalRuns : 0,
308
+ })),
309
+ dailyThroughput,
310
+ costPerCommand,
311
+ bonusMetrics: {
312
+ costPerSuccess: successCount > 0 ? kpiRow.totalCostUsd / successCount : null,
313
+ apiEfficiencyPct: efficiencyRow.ratio !== null ? efficiencyRow.ratio * 100 : null,
314
+ failureCostUsd: failureCostRow.failureCostUsd,
315
+ modelBreakdown,
316
+ },
317
+ }
318
+ }