@mohndoe/pi-atlas 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pi/extensions/guardrails.json +10 -0
- package/.pi/extensions/guardrails.v0.json +8 -0
- package/AGENTS.md +13 -0
- package/CONTEXT.md +119 -0
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/bun.lock +325 -0
- package/docs/ARCHITECTURE.md +66 -0
- package/docs/adr/0001-global-session-project-map.md +9 -0
- package/docs/adr/0002-precomputed-summaries.md +9 -0
- package/docs/agents/domain.md +42 -0
- package/docs/agents/issue-tracker.md +22 -0
- package/docs/agents/triage-labels.md +14 -0
- package/package.json +49 -0
- package/src/__tests__/cache.test.ts +388 -0
- package/src/__tests__/components.fixtures.ts +54 -0
- package/src/__tests__/compute.fixtures.ts +49 -0
- package/src/__tests__/compute.test.ts +336 -0
- package/src/__tests__/e2e.test.ts +182 -0
- package/src/__tests__/format.test.ts +232 -0
- package/src/__tests__/parser.test.ts +1396 -0
- package/src/cache.ts +178 -0
- package/src/colorPalette.ts +119 -0
- package/src/components/BarChart.ts +288 -0
- package/src/components/Dashboard.ts +222 -0
- package/src/components/Header.ts +40 -0
- package/src/components/KpiCards.ts +104 -0
- package/src/components/LoadingView.ts +38 -0
- package/src/components/MarqueeText.ts +79 -0
- package/src/components/RangeSelector.ts +63 -0
- package/src/components/RankedBarList.ts +71 -0
- package/src/components/SortedTable.ts +221 -0
- package/src/components/StatCard.ts +64 -0
- package/src/components/TabBar.ts +59 -0
- package/src/components/UsageRow.ts +55 -0
- package/src/components/__tests__/Bar.test.ts +66 -0
- package/src/components/__tests__/BarChart.test.ts +224 -0
- package/src/components/__tests__/Dashboard.test.ts +452 -0
- package/src/components/__tests__/KpiCards.test.ts +83 -0
- package/src/components/__tests__/LoadingView.test.ts +26 -0
- package/src/components/__tests__/MarqueeText.test.ts +75 -0
- package/src/components/__tests__/RangeSelector.test.ts +34 -0
- package/src/components/__tests__/RankedBarList.test.ts +110 -0
- package/src/components/__tests__/SortedTable.integration.test.ts +228 -0
- package/src/components/__tests__/SortedTable.test.ts +723 -0
- package/src/components/__tests__/TabBar.test.ts +62 -0
- package/src/components/__tests__/cells.test.ts +193 -0
- package/src/components/cells.ts +108 -0
- package/src/components/shared/Bar.ts +22 -0
- package/src/components/shared/GridRow.ts +22 -0
- package/src/compute.ts +210 -0
- package/src/format.ts +219 -0
- package/src/index.ts +88 -0
- package/src/parser.ts +363 -0
- package/src/tabs/Languages.ts +102 -0
- package/src/tabs/Models.ts +108 -0
- package/src/tabs/Overview.ts +152 -0
- package/src/tabs/Projects.ts +92 -0
- package/src/tabs/Usage.ts +181 -0
- package/src/tabs/__tests__/Languages.test.ts +158 -0
- package/src/tabs/__tests__/Models.test.ts +143 -0
- package/src/tabs/__tests__/Overview.test.ts +92 -0
- package/src/tabs/__tests__/Projects.test.ts +142 -0
- package/src/tabs/__tests__/Usage.test.ts +174 -0
- package/src/types.ts +99 -0
- package/tsconfig.json +30 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type StatsSummary } from "../types";
|
|
2
|
+
|
|
3
|
+
export const makeSummary = (): StatsSummary => ({
|
|
4
|
+
totalCost: 5.0,
|
|
5
|
+
sessionCount: 3,
|
|
6
|
+
totalMessages: 50,
|
|
7
|
+
totalInputTokens: 500,
|
|
8
|
+
totalOutputTokens: 500,
|
|
9
|
+
totalCacheReadTokens: 250,
|
|
10
|
+
totalCacheWriteTokens: 250,
|
|
11
|
+
totalTokens: 10000,
|
|
12
|
+
daysActive: 3,
|
|
13
|
+
avgCostPerDay: 1.67,
|
|
14
|
+
todayCost: 1.0,
|
|
15
|
+
languages: [
|
|
16
|
+
{
|
|
17
|
+
language: "TypeScript",
|
|
18
|
+
lines: 10000,
|
|
19
|
+
edits: 5,
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
models: [
|
|
23
|
+
{
|
|
24
|
+
model: "deeepseek-v4",
|
|
25
|
+
cost: 0.5,
|
|
26
|
+
provider: "deepseek",
|
|
27
|
+
calls: 1000,
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
projects: [
|
|
31
|
+
{
|
|
32
|
+
cost: 0,
|
|
33
|
+
project: "pi-atlas",
|
|
34
|
+
sessions: 12,
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
tools: [
|
|
38
|
+
{
|
|
39
|
+
count: 10,
|
|
40
|
+
name: "bash",
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
dailySpend: [
|
|
44
|
+
{ date: "2026-06-06", cost: 1.0 },
|
|
45
|
+
{ date: "2026-06-07", cost: 2.0 },
|
|
46
|
+
{ date: "2026-06-08", cost: 2.0 },
|
|
47
|
+
],
|
|
48
|
+
hourlySpend: [],
|
|
49
|
+
});
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { summarize } from "../compute";
|
|
3
|
+
import { dateFromISOString } from "../format";
|
|
4
|
+
import { emptyDay, mergeDay } from "../parser";
|
|
5
|
+
|
|
6
|
+
describe("summarize", () => {
|
|
7
|
+
it("returns zeros for empty day list", () => {
|
|
8
|
+
const s = summarize([], "All");
|
|
9
|
+
expect(s.totalCost).toBe(0);
|
|
10
|
+
expect(s.sessionCount).toBe(0);
|
|
11
|
+
expect(s.totalMessages).toBe(0);
|
|
12
|
+
expect(s.totalOutputTokens).toBe(0);
|
|
13
|
+
expect(s.totalInputTokens).toBe(0);
|
|
14
|
+
expect(s.totalCacheWriteTokens).toBe(0);
|
|
15
|
+
expect(s.totalCacheReadTokens).toBe(0);
|
|
16
|
+
expect(s.totalTokens).toBe(0);
|
|
17
|
+
expect(s.daysActive).toBe(0);
|
|
18
|
+
expect(s.avgCostPerDay).toBe(0);
|
|
19
|
+
expect(s.dailySpend).toEqual([]);
|
|
20
|
+
expect(s.languages).toEqual([]);
|
|
21
|
+
expect(s.models).toEqual([]);
|
|
22
|
+
expect(s.projects).toEqual([]);
|
|
23
|
+
expect(s.tools).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("computes KPIs from a single day", () => {
|
|
27
|
+
const today = dateFromISOString(new Date().toISOString());
|
|
28
|
+
const d = emptyDay(today);
|
|
29
|
+
mergeDay(d, {
|
|
30
|
+
...emptyDay(today),
|
|
31
|
+
cost: 1.5,
|
|
32
|
+
sessionIds: new Set(["s1", "s2"]),
|
|
33
|
+
userMsgs: 3,
|
|
34
|
+
asstMsgs: 5,
|
|
35
|
+
toolResults: 4,
|
|
36
|
+
inTok: 1000,
|
|
37
|
+
outTok: 500,
|
|
38
|
+
crTok: 100,
|
|
39
|
+
cwTok: 50,
|
|
40
|
+
modelCost: { sonnet: 1.0, haiku: 0.5 },
|
|
41
|
+
modelCount: { sonnet: 2, haiku: 3 },
|
|
42
|
+
toolCount: { bash: 2, read: 2 },
|
|
43
|
+
langLines: { typescript: 100 },
|
|
44
|
+
langEdits: { typescript: 5 },
|
|
45
|
+
});
|
|
46
|
+
const days = [d];
|
|
47
|
+
|
|
48
|
+
const s = summarize(days, "1d");
|
|
49
|
+
expect(s.totalCost).toBe(1.5);
|
|
50
|
+
expect(s.sessionCount).toBe(2);
|
|
51
|
+
expect(s.totalMessages).toBe(12); // 3+5+4
|
|
52
|
+
expect(s.totalInputTokens).toBe(1000);
|
|
53
|
+
expect(s.totalOutputTokens).toBe(500);
|
|
54
|
+
expect(s.totalCacheReadTokens).toBe(100);
|
|
55
|
+
expect(s.totalCacheWriteTokens).toBe(50);
|
|
56
|
+
expect(s.totalTokens).toBe(1650); // 1000+500+100+50
|
|
57
|
+
expect(s.daysActive).toBe(1);
|
|
58
|
+
expect(s.avgCostPerDay).toBe(1.5);
|
|
59
|
+
|
|
60
|
+
expect(s.models).toHaveLength(2);
|
|
61
|
+
expect(s.models[0]).toEqual({ model: "sonnet", cost: 1.0, calls: 2 });
|
|
62
|
+
expect(s.models[1]).toEqual({ model: "haiku", cost: 0.5, calls: 3 });
|
|
63
|
+
|
|
64
|
+
expect(s.tools).toHaveLength(2);
|
|
65
|
+
expect(s.tools).toContainEqual({ name: "bash", count: 2 });
|
|
66
|
+
expect(s.tools).toContainEqual({ name: "read", count: 2 });
|
|
67
|
+
|
|
68
|
+
expect(s.languages).toEqual([{ language: "typescript", lines: 100, edits: 5 }]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("filters by time range", () => {
|
|
72
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
73
|
+
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
|
|
74
|
+
const eightDaysAgo = new Date(Date.now() - 8 * 86400000).toISOString().slice(0, 10);
|
|
75
|
+
|
|
76
|
+
const d1 = emptyDay(today);
|
|
77
|
+
d1.cost = 1;
|
|
78
|
+
d1.sessionIds = new Set(["a"]);
|
|
79
|
+
const d2 = emptyDay(yesterday);
|
|
80
|
+
d2.cost = 2;
|
|
81
|
+
d2.sessionIds = new Set(["b"]);
|
|
82
|
+
const d3 = emptyDay(eightDaysAgo);
|
|
83
|
+
d3.cost = 3;
|
|
84
|
+
d3.sessionIds = new Set(["c"]);
|
|
85
|
+
const days = [d1, d2, d3];
|
|
86
|
+
|
|
87
|
+
// "1d" - only today
|
|
88
|
+
expect(summarize(days, "1d").totalCost).toBe(1);
|
|
89
|
+
|
|
90
|
+
// "7d" - today + yesterday
|
|
91
|
+
const s7 = summarize(days, "7d");
|
|
92
|
+
expect(s7.totalCost).toBe(3);
|
|
93
|
+
expect(s7.daysActive).toBe(2);
|
|
94
|
+
|
|
95
|
+
// "30d" - all three (since 8 days is within 30)
|
|
96
|
+
const s30 = summarize(days, "30d");
|
|
97
|
+
expect(s30.totalCost).toBe(6);
|
|
98
|
+
expect(s30.daysActive).toBe(3);
|
|
99
|
+
|
|
100
|
+
// "All"
|
|
101
|
+
const sAll = summarize(days, "All");
|
|
102
|
+
expect(sAll.totalCost).toBe(6);
|
|
103
|
+
expect(sAll.daysActive).toBe(3);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("computes daily spend with zero-fill for gaps", () => {
|
|
107
|
+
const today = new Date();
|
|
108
|
+
// day0 = 6 days ago, day3 = 3 days ago (both within 7d range)
|
|
109
|
+
const d0 = new Date(today);
|
|
110
|
+
d0.setUTCDate(d0.getUTCDate() - 6);
|
|
111
|
+
const day0 = dateFromISOString(d0.toISOString());
|
|
112
|
+
const d3 = new Date(today);
|
|
113
|
+
d3.setUTCDate(d3.getUTCDate() - 3);
|
|
114
|
+
const day3 = dateFromISOString(d3.toISOString());
|
|
115
|
+
|
|
116
|
+
const d1 = emptyDay(day0);
|
|
117
|
+
d1.cost = 1;
|
|
118
|
+
const d2 = emptyDay(day3);
|
|
119
|
+
d2.cost = 2;
|
|
120
|
+
const days = [d1, d2];
|
|
121
|
+
|
|
122
|
+
const s = summarize(days, "7d");
|
|
123
|
+
expect(s.dailySpend.length).toBeGreaterThanOrEqual(4);
|
|
124
|
+
// Should include all dates from earliest to latest, with zeros for gaps
|
|
125
|
+
const dates = s.dailySpend.map((d) => d.date);
|
|
126
|
+
expect(dates).toContain(day0);
|
|
127
|
+
expect(dates).toContain(day3);
|
|
128
|
+
|
|
129
|
+
// Check gaps are zero-filled (dates between day0 and day3)
|
|
130
|
+
const spendByDate: Record<string, number> = {};
|
|
131
|
+
for (const ds of s.dailySpend) spendByDate[ds.date] = ds.cost;
|
|
132
|
+
expect(spendByDate[day0]).toBe(1);
|
|
133
|
+
expect(spendByDate[day3]).toBe(2);
|
|
134
|
+
|
|
135
|
+
// Verify zeros in between
|
|
136
|
+
const dMid = new Date(d0);
|
|
137
|
+
dMid.setUTCDate(dMid.getUTCDate() + 1);
|
|
138
|
+
const midStr = dateFromISOString(dMid.toISOString());
|
|
139
|
+
expect(spendByDate[midStr]).toBe(0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("sorts models by cost descending (then calls descending), tools by count descending", () => {
|
|
143
|
+
const d = emptyDay("2026-06-08");
|
|
144
|
+
mergeDay(d, {
|
|
145
|
+
...emptyDay(""),
|
|
146
|
+
modelCost: {
|
|
147
|
+
free: 0,
|
|
148
|
+
secondFree: 0,
|
|
149
|
+
cheap: 0.1,
|
|
150
|
+
duplicatedCheap: 0.1,
|
|
151
|
+
expensive: 5.0,
|
|
152
|
+
mid: 1.0,
|
|
153
|
+
},
|
|
154
|
+
modelCount: { free: 12, secondFree: 15, cheap: 10, duplicatedCheap: 8, expensive: 2, mid: 5 },
|
|
155
|
+
toolCount: { bash: 1, read: 10, edit: 5 },
|
|
156
|
+
});
|
|
157
|
+
const days = [d];
|
|
158
|
+
|
|
159
|
+
const s = summarize(days, "All");
|
|
160
|
+
expect(s.models.map((m) => m.model)).toEqual([
|
|
161
|
+
"expensive",
|
|
162
|
+
"mid",
|
|
163
|
+
"cheap",
|
|
164
|
+
"duplicatedCheap",
|
|
165
|
+
"secondFree",
|
|
166
|
+
"free",
|
|
167
|
+
]);
|
|
168
|
+
expect(s.tools.map((t) => t.name)).toEqual(["read", "edit", "bash"]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("reports todayCost separately", () => {
|
|
172
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
173
|
+
const d1 = emptyDay("2026-01-01");
|
|
174
|
+
d1.cost = 100;
|
|
175
|
+
const todayAgg = emptyDay(today);
|
|
176
|
+
todayAgg.cost = 5;
|
|
177
|
+
const days = [d1, todayAgg];
|
|
178
|
+
|
|
179
|
+
const s = summarize(days, "All");
|
|
180
|
+
expect(s.todayCost).toBe(5);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("returns todayCost 0 when today is not in filtered range", () => {
|
|
184
|
+
const d1 = emptyDay("2026-06-01");
|
|
185
|
+
d1.cost = 10;
|
|
186
|
+
d1.sessionIds = new Set(["s1"]);
|
|
187
|
+
const days = [d1];
|
|
188
|
+
|
|
189
|
+
// 1d range filters to today only, which has no data
|
|
190
|
+
const s = summarize(days, "1d");
|
|
191
|
+
expect(s.todayCost).toBe(0);
|
|
192
|
+
expect(s.totalCost).toBe(0);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("dailySpend for All range is sorted dates without zero-fill", () => {
|
|
196
|
+
const d1 = emptyDay("2026-06-01");
|
|
197
|
+
d1.cost = 1;
|
|
198
|
+
d1.sessionIds = new Set(["a"]);
|
|
199
|
+
const d2 = emptyDay("2026-06-05");
|
|
200
|
+
d2.cost = 5;
|
|
201
|
+
d2.sessionIds = new Set(["b"]);
|
|
202
|
+
const d3 = emptyDay("2026-06-10");
|
|
203
|
+
d3.cost = 10;
|
|
204
|
+
d3.sessionIds = new Set(["c"]);
|
|
205
|
+
const days = [d3, d1, d2]; // unsorted input
|
|
206
|
+
|
|
207
|
+
const s = summarize(days, "All");
|
|
208
|
+
// All range does NOT zero-fill gaps — returns only days with data
|
|
209
|
+
expect(s.dailySpend).toHaveLength(3);
|
|
210
|
+
expect(s.dailySpend[0]).toEqual({ date: "2026-06-01", cost: 1 });
|
|
211
|
+
expect(s.dailySpend[1]).toEqual({ date: "2026-06-05", cost: 5 });
|
|
212
|
+
expect(s.dailySpend[2]).toEqual({ date: "2026-06-10", cost: 10 });
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("computes all KPIs for multiple-day 7d range", () => {
|
|
216
|
+
const today = new Date();
|
|
217
|
+
const day2ago = new Date(today);
|
|
218
|
+
day2ago.setUTCDate(day2ago.getUTCDate() - 1);
|
|
219
|
+
const day1ago = new Date(today);
|
|
220
|
+
day1ago.setUTCDate(day1ago.getUTCDate() - 0);
|
|
221
|
+
|
|
222
|
+
const d1date = dateFromISOString(day2ago.toISOString());
|
|
223
|
+
const d2date = dateFromISOString(day1ago.toISOString());
|
|
224
|
+
|
|
225
|
+
const d1 = emptyDay(d1date);
|
|
226
|
+
mergeDay(d1, {
|
|
227
|
+
...emptyDay(""),
|
|
228
|
+
cost: 2.5,
|
|
229
|
+
sessionIds: new Set(["s1"]),
|
|
230
|
+
userMsgs: 5,
|
|
231
|
+
asstMsgs: 8,
|
|
232
|
+
toolResults: 3,
|
|
233
|
+
inTok: 500,
|
|
234
|
+
outTok: 200,
|
|
235
|
+
crTok: 10,
|
|
236
|
+
cwTok: 5,
|
|
237
|
+
modelCost: { sonnet: 2.0, haiku: 0.5 },
|
|
238
|
+
modelCount: { sonnet: 4, haiku: 2 },
|
|
239
|
+
toolCount: { bash: 3, read: 2 },
|
|
240
|
+
langLines: { TypeScript: 100, Python: 50 },
|
|
241
|
+
langEdits: { TypeScript: 3, Python: 1 },
|
|
242
|
+
});
|
|
243
|
+
const d2 = emptyDay(d2date);
|
|
244
|
+
mergeDay(d2, {
|
|
245
|
+
...emptyDay(""),
|
|
246
|
+
cost: 1.5,
|
|
247
|
+
sessionIds: new Set(["s2"]),
|
|
248
|
+
userMsgs: 3,
|
|
249
|
+
asstMsgs: 4,
|
|
250
|
+
toolResults: 1,
|
|
251
|
+
inTok: 300,
|
|
252
|
+
outTok: 100,
|
|
253
|
+
crTok: 0,
|
|
254
|
+
cwTok: 0,
|
|
255
|
+
modelCost: { sonnet: 1.5 },
|
|
256
|
+
modelCount: { sonnet: 3 },
|
|
257
|
+
toolCount: { edit: 1, write: 1 },
|
|
258
|
+
langLines: { Python: 200 },
|
|
259
|
+
langEdits: { Python: 2 },
|
|
260
|
+
});
|
|
261
|
+
const days = [d1, d2];
|
|
262
|
+
|
|
263
|
+
const s = summarize(days, "7d");
|
|
264
|
+
expect(s.totalCost).toBe(4.0);
|
|
265
|
+
expect(s.sessionCount).toBe(2);
|
|
266
|
+
expect(s.totalMessages).toBe(24); // 5+8+3 + 3+4+1
|
|
267
|
+
expect(s.totalInputTokens).toBe(800); // 500 + 300
|
|
268
|
+
expect(s.totalOutputTokens).toBe(300); // 200 + 100
|
|
269
|
+
expect(s.totalCacheReadTokens).toBe(10); // 10 + 0
|
|
270
|
+
expect(s.totalCacheWriteTokens).toBe(5); // 5 + 0
|
|
271
|
+
expect(s.totalTokens).toBe(1115); // 500+200+10+5 + 300+100
|
|
272
|
+
expect(s.daysActive).toBe(2);
|
|
273
|
+
expect(s.avgCostPerDay).toBeCloseTo(2.0);
|
|
274
|
+
|
|
275
|
+
// Languages sorted by lines descending
|
|
276
|
+
expect(s.languages).toEqual([
|
|
277
|
+
{ language: "Python", lines: 250, edits: 3 },
|
|
278
|
+
{ language: "TypeScript", lines: 100, edits: 3 },
|
|
279
|
+
]);
|
|
280
|
+
|
|
281
|
+
// Models sorted by cost
|
|
282
|
+
expect(s.models).toEqual([
|
|
283
|
+
{ model: "sonnet", cost: 3.5, calls: 7 },
|
|
284
|
+
{ model: "haiku", cost: 0.5, calls: 2 },
|
|
285
|
+
]);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("hourlySpend for 1d range has 24 zero-filled entries when no hourly cost", () => {
|
|
289
|
+
const today = dateFromISOString(new Date().toISOString());
|
|
290
|
+
const d = emptyDay(today);
|
|
291
|
+
d.cost = 5;
|
|
292
|
+
d.sessionIds = new Set(["s1"]);
|
|
293
|
+
const days = [d];
|
|
294
|
+
|
|
295
|
+
const s = summarize(days, "1d");
|
|
296
|
+
expect(s.hourlySpend).toHaveLength(24);
|
|
297
|
+
for (const h of s.hourlySpend) {
|
|
298
|
+
expect(h.cost).toBe(0);
|
|
299
|
+
}
|
|
300
|
+
// Zero-cost days still produce 24 entries
|
|
301
|
+
const d2 = emptyDay(dateFromISOString(new Date().toISOString()));
|
|
302
|
+
d2.cost = 0;
|
|
303
|
+
d2.sessionIds = new Set(["s2"]);
|
|
304
|
+
const s2 = summarize([d2], "1d");
|
|
305
|
+
expect(s2.hourlySpend).toHaveLength(24);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("hourlySpend for 1d maps cost to correct hours", () => {
|
|
309
|
+
const today = dateFromISOString(new Date().toISOString());
|
|
310
|
+
const d = emptyDay(today);
|
|
311
|
+
mergeDay(d, {
|
|
312
|
+
...emptyDay(""),
|
|
313
|
+
cost: 3.5,
|
|
314
|
+
hourCost: { 10: 1.5, 14: 2.0 },
|
|
315
|
+
});
|
|
316
|
+
const days = [d];
|
|
317
|
+
|
|
318
|
+
const s = summarize(days, "1d");
|
|
319
|
+
expect(s.hourlySpend).toHaveLength(24);
|
|
320
|
+
expect(s.hourlySpend[10]!.cost).toBe(1.5);
|
|
321
|
+
expect(s.hourlySpend[14]!.cost).toBe(2.0);
|
|
322
|
+
expect(s.hourlySpend[0]!.cost).toBe(0);
|
|
323
|
+
expect(s.hourlySpend[23]!.cost).toBe(0);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("hourlySpend is empty for 7d, 30d, All ranges", () => {
|
|
327
|
+
const d = emptyDay("2026-06-01");
|
|
328
|
+
d.cost = 5;
|
|
329
|
+
d.sessionIds = new Set(["s1"]);
|
|
330
|
+
const days = [d];
|
|
331
|
+
|
|
332
|
+
expect(summarize(days, "7d").hourlySpend).toEqual([]);
|
|
333
|
+
expect(summarize(days, "30d").hourlySpend).toEqual([]);
|
|
334
|
+
expect(summarize(days, "All").hourlySpend).toEqual([]);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { allRanges } from "../components/__tests__/Dashboard.test";
|
|
6
|
+
import { Dashboard } from "../components/Dashboard";
|
|
7
|
+
import { summarize } from "../compute";
|
|
8
|
+
import { parseFile } from "../parser";
|
|
9
|
+
import { type DayAgg } from "../types";
|
|
10
|
+
import { makeMockTUI, makeRangeSelector, makeTheme } from "./components.fixtures";
|
|
11
|
+
|
|
12
|
+
const mockTui = makeMockTUI();
|
|
13
|
+
|
|
14
|
+
describe("JSONL → Dashboard", () => {
|
|
15
|
+
let tmpDir: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(async () => {
|
|
18
|
+
tmpDir = join(tmpdir(), `pi-atlas-integration-${Date.now()}`);
|
|
19
|
+
await mkdir(tmpDir, { recursive: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function daysFromMap(map: Map<string, DayAgg>): DayAgg[] {
|
|
27
|
+
return [...map.values()].sort((a, b) => a.date.localeCompare(b.date));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
it("end-to-end: JSONL with session and messages → Dashboard Overview shows KPIs", async () => {
|
|
31
|
+
const filePath = join(tmpDir, "session.jsonl");
|
|
32
|
+
const jsonlLines = [
|
|
33
|
+
JSON.stringify({
|
|
34
|
+
type: "session",
|
|
35
|
+
version: 3,
|
|
36
|
+
id: "s1",
|
|
37
|
+
timestamp: "2026-06-08T10:00:00.000Z",
|
|
38
|
+
cwd: "/home/doe/proj",
|
|
39
|
+
}),
|
|
40
|
+
JSON.stringify({
|
|
41
|
+
type: "message",
|
|
42
|
+
id: "m1",
|
|
43
|
+
parentId: "p",
|
|
44
|
+
timestamp: "2026-06-08T10:01:00.000Z",
|
|
45
|
+
message: { role: "user", content: [{ type: "text", text: "hello" }] },
|
|
46
|
+
}),
|
|
47
|
+
JSON.stringify({
|
|
48
|
+
type: "message",
|
|
49
|
+
id: "m2",
|
|
50
|
+
parentId: "m1",
|
|
51
|
+
timestamp: "2026-06-08T10:02:00.000Z",
|
|
52
|
+
message: {
|
|
53
|
+
role: "assistant",
|
|
54
|
+
content: [
|
|
55
|
+
{ type: "text", text: "hi there" },
|
|
56
|
+
{
|
|
57
|
+
type: "toolCall",
|
|
58
|
+
id: "t1",
|
|
59
|
+
name: "read",
|
|
60
|
+
arguments: { path: "/src/foo.ts" },
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
model: "claude-sonnet-4-20250514",
|
|
64
|
+
usage: {
|
|
65
|
+
input: 200,
|
|
66
|
+
output: 100,
|
|
67
|
+
cacheRead: 10,
|
|
68
|
+
cacheWrite: 0,
|
|
69
|
+
totalTokens: 310,
|
|
70
|
+
cost: {
|
|
71
|
+
input: 0.001,
|
|
72
|
+
output: 0.0005,
|
|
73
|
+
cacheRead: 0.00001,
|
|
74
|
+
cacheWrite: 0,
|
|
75
|
+
total: 0.00151,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
}),
|
|
80
|
+
];
|
|
81
|
+
await writeFile(filePath, jsonlLines.join("\n"));
|
|
82
|
+
|
|
83
|
+
// Parse
|
|
84
|
+
const map = parseFile(filePath);
|
|
85
|
+
const days = daysFromMap(map);
|
|
86
|
+
expect(days.length).toBeGreaterThan(0);
|
|
87
|
+
|
|
88
|
+
// Summarize for all ranges
|
|
89
|
+
const ranges = allRanges;
|
|
90
|
+
const summaries = new Map(ranges.map((r) => [r, summarize(days, r)] as const));
|
|
91
|
+
|
|
92
|
+
// Render dashboard
|
|
93
|
+
const dash = new Dashboard(
|
|
94
|
+
summaries,
|
|
95
|
+
makeTheme(),
|
|
96
|
+
mockTui,
|
|
97
|
+
null,
|
|
98
|
+
makeRangeSelector(makeTheme()),
|
|
99
|
+
);
|
|
100
|
+
const rendered = dash.render(80);
|
|
101
|
+
const text = rendered.join("\n");
|
|
102
|
+
|
|
103
|
+
// Should contain dashboard chrome
|
|
104
|
+
expect(text).toContain("Overview");
|
|
105
|
+
expect(text).toContain("Languages");
|
|
106
|
+
expect(text).toContain("Models");
|
|
107
|
+
expect(text).toContain("Projects");
|
|
108
|
+
expect(text).toContain("Usage");
|
|
109
|
+
|
|
110
|
+
// Range selector
|
|
111
|
+
expect(rendered[0]).toContain("Pi Atlas");
|
|
112
|
+
expect(rendered[0]).toContain("All time [r]");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("end-to-end: Navigate to Languages tab shows ranked table from parsed data", async () => {
|
|
116
|
+
const filePath = join(tmpDir, "lang-session.jsonl");
|
|
117
|
+
const jsonlLines = [
|
|
118
|
+
JSON.stringify({
|
|
119
|
+
type: "session",
|
|
120
|
+
version: 3,
|
|
121
|
+
id: "s1",
|
|
122
|
+
timestamp: "2026-06-08T10:00:00.000Z",
|
|
123
|
+
cwd: "/home/doe/proj",
|
|
124
|
+
}),
|
|
125
|
+
JSON.stringify({
|
|
126
|
+
type: "message",
|
|
127
|
+
id: "m1",
|
|
128
|
+
parentId: "p",
|
|
129
|
+
timestamp: "2026-06-08T10:01:00.000Z",
|
|
130
|
+
message: {
|
|
131
|
+
role: "assistant",
|
|
132
|
+
content: [
|
|
133
|
+
{
|
|
134
|
+
type: "toolCall",
|
|
135
|
+
id: "t1",
|
|
136
|
+
name: "edit",
|
|
137
|
+
arguments: { path: "/src/main.ts", edits: [{ newText: "console.log('hi')" }] },
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
type: "toolCall",
|
|
141
|
+
id: "t2",
|
|
142
|
+
name: "write",
|
|
143
|
+
arguments: { path: "/src/lib.rs", content: "fn main() {}" },
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
model: "sonnet",
|
|
147
|
+
usage: {
|
|
148
|
+
input: 0,
|
|
149
|
+
output: 0,
|
|
150
|
+
cacheRead: 0,
|
|
151
|
+
cacheWrite: 0,
|
|
152
|
+
totalTokens: 0,
|
|
153
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
}),
|
|
157
|
+
];
|
|
158
|
+
await writeFile(filePath, jsonlLines.join("\n"));
|
|
159
|
+
|
|
160
|
+
const map = parseFile(filePath);
|
|
161
|
+
const days = daysFromMap(map);
|
|
162
|
+
const ranges = allRanges;
|
|
163
|
+
const summaries = new Map(ranges.map((r) => [r, summarize(days, r)] as const));
|
|
164
|
+
|
|
165
|
+
const dash = new Dashboard(
|
|
166
|
+
summaries,
|
|
167
|
+
makeTheme(),
|
|
168
|
+
mockTui,
|
|
169
|
+
null,
|
|
170
|
+
makeRangeSelector(makeTheme()),
|
|
171
|
+
);
|
|
172
|
+
// Navigate to Languages tab (index 1)
|
|
173
|
+
dash.handleInput("\x1b[C"); // right arrow
|
|
174
|
+
|
|
175
|
+
const rendered = dash.render(80);
|
|
176
|
+
const text = rendered.join("\n");
|
|
177
|
+
|
|
178
|
+
// Should show language data from parsed file
|
|
179
|
+
expect(text).toContain("TypeScript");
|
|
180
|
+
expect(text).toContain("Rust");
|
|
181
|
+
});
|
|
182
|
+
});
|