@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.
Files changed (66) hide show
  1. package/.pi/extensions/guardrails.json +10 -0
  2. package/.pi/extensions/guardrails.v0.json +8 -0
  3. package/AGENTS.md +13 -0
  4. package/CONTEXT.md +119 -0
  5. package/LICENSE +21 -0
  6. package/README.md +40 -0
  7. package/bun.lock +325 -0
  8. package/docs/ARCHITECTURE.md +66 -0
  9. package/docs/adr/0001-global-session-project-map.md +9 -0
  10. package/docs/adr/0002-precomputed-summaries.md +9 -0
  11. package/docs/agents/domain.md +42 -0
  12. package/docs/agents/issue-tracker.md +22 -0
  13. package/docs/agents/triage-labels.md +14 -0
  14. package/package.json +49 -0
  15. package/src/__tests__/cache.test.ts +388 -0
  16. package/src/__tests__/components.fixtures.ts +54 -0
  17. package/src/__tests__/compute.fixtures.ts +49 -0
  18. package/src/__tests__/compute.test.ts +336 -0
  19. package/src/__tests__/e2e.test.ts +182 -0
  20. package/src/__tests__/format.test.ts +232 -0
  21. package/src/__tests__/parser.test.ts +1396 -0
  22. package/src/cache.ts +178 -0
  23. package/src/colorPalette.ts +119 -0
  24. package/src/components/BarChart.ts +288 -0
  25. package/src/components/Dashboard.ts +222 -0
  26. package/src/components/Header.ts +40 -0
  27. package/src/components/KpiCards.ts +104 -0
  28. package/src/components/LoadingView.ts +38 -0
  29. package/src/components/MarqueeText.ts +79 -0
  30. package/src/components/RangeSelector.ts +63 -0
  31. package/src/components/RankedBarList.ts +71 -0
  32. package/src/components/SortedTable.ts +221 -0
  33. package/src/components/StatCard.ts +64 -0
  34. package/src/components/TabBar.ts +59 -0
  35. package/src/components/UsageRow.ts +55 -0
  36. package/src/components/__tests__/Bar.test.ts +66 -0
  37. package/src/components/__tests__/BarChart.test.ts +224 -0
  38. package/src/components/__tests__/Dashboard.test.ts +452 -0
  39. package/src/components/__tests__/KpiCards.test.ts +83 -0
  40. package/src/components/__tests__/LoadingView.test.ts +26 -0
  41. package/src/components/__tests__/MarqueeText.test.ts +75 -0
  42. package/src/components/__tests__/RangeSelector.test.ts +34 -0
  43. package/src/components/__tests__/RankedBarList.test.ts +110 -0
  44. package/src/components/__tests__/SortedTable.integration.test.ts +228 -0
  45. package/src/components/__tests__/SortedTable.test.ts +723 -0
  46. package/src/components/__tests__/TabBar.test.ts +62 -0
  47. package/src/components/__tests__/cells.test.ts +193 -0
  48. package/src/components/cells.ts +108 -0
  49. package/src/components/shared/Bar.ts +22 -0
  50. package/src/components/shared/GridRow.ts +22 -0
  51. package/src/compute.ts +210 -0
  52. package/src/format.ts +219 -0
  53. package/src/index.ts +88 -0
  54. package/src/parser.ts +363 -0
  55. package/src/tabs/Languages.ts +102 -0
  56. package/src/tabs/Models.ts +108 -0
  57. package/src/tabs/Overview.ts +152 -0
  58. package/src/tabs/Projects.ts +92 -0
  59. package/src/tabs/Usage.ts +181 -0
  60. package/src/tabs/__tests__/Languages.test.ts +158 -0
  61. package/src/tabs/__tests__/Models.test.ts +143 -0
  62. package/src/tabs/__tests__/Overview.test.ts +92 -0
  63. package/src/tabs/__tests__/Projects.test.ts +142 -0
  64. package/src/tabs/__tests__/Usage.test.ts +174 -0
  65. package/src/types.ts +99 -0
  66. package/tsconfig.json +30 -0
@@ -0,0 +1,221 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { matchesKey, truncateToWidth, type Component, type TUI } from "@earendil-works/pi-tui";
3
+ import type { CellComponent } from "./cells";
4
+
5
+ export interface ColumnDef {
6
+ header: CellComponent;
7
+ width: number | string;
8
+ }
9
+
10
+ export interface SortConfig {
11
+ column: number;
12
+ direction: "asc" | "desc";
13
+ }
14
+
15
+ export interface CursorOptions {
16
+ enabled?: boolean;
17
+ char?: string;
18
+ }
19
+
20
+ export interface SortedTableConfig {
21
+ columns: ColumnDef[];
22
+ rows: CellComponent[][];
23
+ maxHeight: number;
24
+ sort?: SortConfig;
25
+ cursor?: CursorOptions;
26
+ /** TUI reference — passed through to cells that need it (e.g. marquee cells). */
27
+ tui: TUI;
28
+ }
29
+
30
+ export class SortedTable implements Component {
31
+ static readonly DEFAULT_CURSOR_CHAR = "▌";
32
+ static readonly CURSOR_SUFFIX = " ";
33
+ private columns: ColumnDef[];
34
+ private rows: CellComponent[][];
35
+ private maxHeight: number;
36
+ private theme: Theme;
37
+ private sort?: SortConfig;
38
+ private scrollOffset = 0;
39
+ private focusedRow = -1;
40
+ private cursorPrefix: string;
41
+ private padPrefix: string;
42
+ private tui: TUI;
43
+
44
+ constructor(config: SortedTableConfig, theme: Theme) {
45
+ const fillCount = config.columns.filter((c) => c.width === "fill").length;
46
+ if (fillCount > 1) throw new Error("Cannot have more than one fill column");
47
+
48
+ for (const col of config.columns) {
49
+ const w = col.width;
50
+ if (typeof w === "string" && w !== "fill" && !/^\d+%$/.test(w)) {
51
+ throw new Error(`Invalid column width: "${w}"`);
52
+ }
53
+ }
54
+
55
+ this.columns = config.columns;
56
+ this.rows = config.rows;
57
+ this.maxHeight = config.maxHeight;
58
+ this.theme = theme;
59
+ this.sort = config.sort;
60
+ this.focusedRow = this.rows.length > 0 ? 0 : -1;
61
+
62
+ this.tui = config.tui;
63
+
64
+ const cursorOpts = config.cursor;
65
+ const cursorEnabled = cursorOpts?.enabled ?? true;
66
+ const cursorChar = cursorOpts?.char ?? SortedTable.DEFAULT_CURSOR_CHAR;
67
+ this.cursorPrefix = cursorEnabled ? cursorChar + SortedTable.CURSOR_SUFFIX : "";
68
+ this.padPrefix = cursorEnabled ? " ".repeat(this.cursorPrefix.length) : "";
69
+ }
70
+
71
+ private get visibleRows(): number {
72
+ return Math.max(1, this.maxHeight - 1); // 1 row for header
73
+ }
74
+
75
+ private get maxScroll(): number {
76
+ return Math.max(0, this.rows.length - this.visibleRows);
77
+ }
78
+
79
+ private resolveWidths(width: number): number[] {
80
+ const gapCount = this.columns.length - 1;
81
+ const contentWidth = Math.max(0, width - gapCount);
82
+
83
+ const resolved = new Array(this.columns.length).fill(-1);
84
+ let fixedUsed = 0;
85
+ let pctUsed = 0;
86
+ const fillIdx = this.columns.findIndex((c) => c.width === "fill");
87
+
88
+ // Pass 1: fixed widths
89
+ for (let i = 0; i < this.columns.length; i++) {
90
+ const w = this.columns[i]!.width;
91
+ if (typeof w === "number") {
92
+ resolved[i] = w;
93
+ fixedUsed += w;
94
+ }
95
+ }
96
+
97
+ // Pass 2: percentage widths
98
+ for (let i = 0; i < this.columns.length; i++) {
99
+ if (resolved[i] >= 0) continue;
100
+ const w = this.columns[i]!.width;
101
+ if (typeof w === "string" && /^\d+%$/.test(w)) {
102
+ const pct = parseInt(w) / 100;
103
+ resolved[i] = Math.floor(contentWidth * pct);
104
+ pctUsed += resolved[i];
105
+ }
106
+ }
107
+
108
+ // Pass 3: fill column gets remainder
109
+ if (fillIdx >= 0) {
110
+ const remaining = contentWidth - fixedUsed - pctUsed;
111
+ resolved[fillIdx] = Math.max(1, remaining);
112
+ }
113
+
114
+ // Pass 4: overflow — if total > contentWidth, shrink non-fill proportionally
115
+ const totalAllocated = resolved.reduce((sum, w) => sum + w, 0);
116
+ if (totalAllocated > contentWidth) {
117
+ const scale = contentWidth / totalAllocated;
118
+ let nonFillSum = 0;
119
+ for (let i = 0; i < resolved.length; i++) {
120
+ if (i === fillIdx) continue;
121
+ resolved[i] = Math.max(1, Math.floor(resolved[i] * scale));
122
+ nonFillSum += resolved[i];
123
+ }
124
+ if (fillIdx >= 0) {
125
+ resolved[fillIdx] = Math.max(1, contentWidth - nonFillSum);
126
+ }
127
+ }
128
+
129
+ return resolved;
130
+ }
131
+
132
+ private padToWidth(line: string, width: number): string {
133
+ return truncateToWidth(line, width, "", true);
134
+ }
135
+
136
+ render(width: number): string[] {
137
+ const colWidths = this.resolveWidths(width);
138
+
139
+ const lines: string[] = [];
140
+ const gap = " ";
141
+
142
+ // Clamp scroll offset
143
+ if (this.scrollOffset > this.maxScroll) this.scrollOffset = this.maxScroll;
144
+ if (this.scrollOffset < 0) this.scrollOffset = 0;
145
+
146
+ // Header row
147
+ let header = "";
148
+ for (let i = 0; i < this.columns.length; i++) {
149
+ const cw = colWidths[i] ?? width;
150
+ const sortDirection = this.sort?.column === i ? this.sort.direction : null;
151
+ const headerText = this.columns[i]!.header.render(cw, { sortDirection });
152
+ header += truncateToWidth(headerText, cw, "", true) + gap;
153
+ }
154
+ header = this.padPrefix + header.trimEnd();
155
+ header = this.padToWidth(header, width);
156
+ lines.push(this.theme.bold(this.theme.fg("accent", header)));
157
+
158
+ // Data rows
159
+ const end = Math.min(this.scrollOffset + this.visibleRows, this.rows.length);
160
+ for (let i = this.scrollOffset; i < end; i++) {
161
+ let row = "";
162
+ const dataRow = this.rows[i]!;
163
+ for (let j = 0; j < this.columns.length; j++) {
164
+ const c = dataRow[j];
165
+ if (!c) continue;
166
+ const cw = colWidths[j] ?? width;
167
+ const val = c.render(cw, { isFocused: i === this.focusedRow });
168
+ row += truncateToWidth(val, cw, "", true);
169
+ if (j < this.columns.length - 1) row += gap;
170
+ }
171
+ const prefix = i === this.focusedRow ? this.cursorPrefix : this.padPrefix;
172
+ row = this.padToWidth(row, width - prefix.length);
173
+ row = prefix + row;
174
+ if (i === this.focusedRow) {
175
+ row = this.theme.bg("selectedBg", row);
176
+ }
177
+ lines.push(row);
178
+ }
179
+
180
+ return lines;
181
+ }
182
+
183
+ handleInput(data: string): void {
184
+ if (matchesKey(data, "up")) {
185
+ if (this.focusedRow > 0) {
186
+ this.focusedRow--;
187
+ this.followFocus();
188
+ this.invalidate();
189
+ }
190
+ return;
191
+ }
192
+ if (matchesKey(data, "down")) {
193
+ if (this.focusedRow < this.rows.length - 1) {
194
+ this.focusedRow++;
195
+ this.followFocus();
196
+ this.invalidate();
197
+ }
198
+ }
199
+ }
200
+
201
+ private followFocus(): void {
202
+ if (this.focusedRow < this.scrollOffset) {
203
+ this.scrollOffset = this.focusedRow;
204
+ } else if (this.focusedRow >= this.scrollOffset + this.visibleRows) {
205
+ this.scrollOffset = this.focusedRow - this.visibleRows + 1;
206
+ }
207
+ }
208
+
209
+ invalidate(): void {
210
+ // Propagate to all header cells
211
+ for (const col of this.columns) {
212
+ col.header.invalidate();
213
+ }
214
+ // Propagate to all data cells
215
+ for (const row of this.rows) {
216
+ for (const c of row) {
217
+ c.invalidate();
218
+ }
219
+ }
220
+ }
221
+ }
@@ -0,0 +1,64 @@
1
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
+ import { Box, type Component, Text } from "@earendil-works/pi-tui";
3
+ import { type ChalkInstance } from "chalk";
4
+
5
+ interface Label {
6
+ text: string;
7
+ color?: ThemeColor | ChalkInstance;
8
+ }
9
+
10
+ interface Value {
11
+ text: string;
12
+ color: ThemeColor | ChalkInstance;
13
+ }
14
+
15
+ interface StatCardParams {
16
+ label: Label;
17
+ value: Value;
18
+ paddingX?: number;
19
+ paddingY?: number;
20
+ }
21
+
22
+ export class StatCard implements Component {
23
+ private box: Box;
24
+ private DEFAULT_PADDING_X = 0;
25
+ private DEFAULT_PADDING_Y = 0;
26
+
27
+ constructor(
28
+ params: StatCardParams,
29
+ private theme: Theme,
30
+ ) {
31
+ this.box = new Box(
32
+ params.paddingX || this.DEFAULT_PADDING_X,
33
+ params.paddingY || this.DEFAULT_PADDING_Y,
34
+ );
35
+ this.box.addChild(
36
+ new Text(
37
+ params.label.color
38
+ ? typeof params.label.color === "string"
39
+ ? this.theme.fg(params.label.color, params.label.text)
40
+ : params.label.color(params.label.text)
41
+ : params.label.text,
42
+ 0,
43
+ 0,
44
+ ),
45
+ );
46
+ this.box.addChild(
47
+ new Text(
48
+ typeof params.value.color === "string"
49
+ ? this.theme.fg(params.value.color, params.value.text)
50
+ : params.value.color(params.value.text),
51
+ 0,
52
+ 0,
53
+ ),
54
+ );
55
+ }
56
+
57
+ render(width: number): string[] {
58
+ return this.box.render(width);
59
+ }
60
+
61
+ invalidate(): void {
62
+ this.box.invalidate();
63
+ }
64
+ }
@@ -0,0 +1,59 @@
1
+ import { matchesKey, type Component } from "@earendil-works/pi-tui";
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+
4
+ export class TabBar implements Component {
5
+ private tabs: string[];
6
+ private theme: Theme;
7
+ activeIndex: number;
8
+ private cachedLines: string[] | null = null;
9
+ private cachedWidth = -1;
10
+
11
+ constructor(tabs: string[], theme: Theme, activeIndex = 0) {
12
+ this.tabs = tabs;
13
+ this.theme = theme;
14
+ this.activeIndex = activeIndex;
15
+ }
16
+
17
+ render(width: number): string[] {
18
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
19
+
20
+ const parts: string[] = [];
21
+ for (let i = 0; i < this.tabs.length; i++) {
22
+ const label = this.tabs[i];
23
+ if (i === this.activeIndex) {
24
+ parts.push(this.theme.bg("selectedBg", this.theme.fg("accent", ` ${label} `)));
25
+ } else {
26
+ parts.push(this.theme.fg("muted", ` ${label} `));
27
+ }
28
+ }
29
+
30
+ let line = parts.join(" ");
31
+ const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").replace(/<[^>]+>/g, "").length;
32
+ if (visLen > width) line = line.slice(0, width);
33
+
34
+ this.cachedLines = [line];
35
+ this.cachedWidth = width;
36
+ return this.cachedLines;
37
+ }
38
+
39
+ handleInput(data: string): void {
40
+ if (matchesKey(data, "left")) {
41
+ if (this.activeIndex > 0) {
42
+ this.activeIndex--;
43
+ this.invalidate();
44
+ }
45
+ return;
46
+ }
47
+ if (matchesKey(data, "right")) {
48
+ if (this.activeIndex < this.tabs.length - 1) {
49
+ this.activeIndex++;
50
+ this.invalidate();
51
+ }
52
+ }
53
+ }
54
+
55
+ invalidate(): void {
56
+ this.cachedLines = null;
57
+ this.cachedWidth = -1;
58
+ }
59
+ }
@@ -0,0 +1,55 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, visibleWidth } from "@earendil-works/pi-tui";
3
+ import type { ChalkInstance } from "chalk";
4
+ import { renderBar } from "./shared/Bar";
5
+
6
+ export class UsageRow implements Component {
7
+ constructor(
8
+ private lang: {
9
+ name: string;
10
+ mainValueText: string;
11
+ secondaryValueText?: string;
12
+ barPct: number;
13
+ pct: number;
14
+ },
15
+ private color: ChalkInstance,
16
+ private theme: Theme,
17
+ ) {}
18
+
19
+ render(width: number): string[] {
20
+ const { name, mainValueText, secondaryValueText } = this.lang;
21
+ let { barPct, pct } = this.lang;
22
+
23
+ barPct = Math.max(0, barPct);
24
+ pct = Math.max(0, pct);
25
+
26
+ // Line 1: name (left) + [secondary(?) - mainStr ] (right)
27
+ const nameStr = this.theme.bold(name);
28
+
29
+ let valueStr = "";
30
+ if (secondaryValueText) {
31
+ valueStr += this.theme.fg("muted", secondaryValueText) + " · ";
32
+ }
33
+ valueStr += this.theme.bold(mainValueText);
34
+
35
+ const firstLineGap = " ".repeat(
36
+ Math.max(0, width - visibleWidth(nameStr) - visibleWidth(valueStr)),
37
+ );
38
+
39
+ // Line 2: progress bar - [percentage]
40
+ const pctString = this.theme.fg("dim", `${pct.toFixed(2)}%`);
41
+ const pctStringWidth = visibleWidth(pctString);
42
+ const secondLineGap = " ";
43
+
44
+ const barWidth = width - pctStringWidth - visibleWidth(secondLineGap);
45
+ const bar = renderBar(barWidth, barPct, this.color, (s) => this.theme.fg("dim", s));
46
+
47
+ return [
48
+ nameStr + firstLineGap + valueStr,
49
+ bar + secondLineGap + pctString,
50
+ "", // spacer between rows
51
+ ];
52
+ }
53
+
54
+ invalidate(): void {}
55
+ }
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { renderBar } from "../shared/Bar";
3
+
4
+ describe("renderBar", () => {
5
+ const filledStyle = (s: string) => s; // identity (no styling)
6
+ const emptyStyle = (s: string) => s;
7
+
8
+ it("renders full bar at 100%", () => {
9
+ const result = renderBar(10, 100, filledStyle, emptyStyle);
10
+ expect(result).toBe("■■■■■■■■■■");
11
+ });
12
+
13
+ it("renders empty bar at 0%", () => {
14
+ const result = renderBar(10, 0, filledStyle, emptyStyle);
15
+ expect(result).toBe("■■■■■■■■■■"); // all empty (no style difference with identity)
16
+ });
17
+
18
+ it("renders half bar at 50%", () => {
19
+ // 50% of 10 = 5 filled, 5 empty
20
+ const filled = (s: string) => `[${s}]`;
21
+ const empty = (s: string) => `(${s})`;
22
+ const result = renderBar(10, 50, filled, empty);
23
+ expect(result).toBe("[■■■■■](■■■■■)");
24
+ });
25
+
26
+ it("applies filled style to filled portion and empty style to empty portion", () => {
27
+ const filled = (s: string) => `F{${s}}`;
28
+ const empty = (s: string) => `E{${s}}`;
29
+ const result = renderBar(8, 75, filled, empty);
30
+ expect(result).toBe("F{■■■■■■}E{■■}");
31
+ });
32
+
33
+ it("clamps fillPct to 0 when negative", () => {
34
+ const result = renderBar(10, -20, filledStyle, emptyStyle);
35
+ expect(result).toBe("■■■■■■■■■■"); // all empty
36
+ });
37
+
38
+ it("clamps fillPct to 100 when above 100", () => {
39
+ const result = renderBar(10, 200, filledStyle, emptyStyle);
40
+ expect(result).toBe("■■■■■■■■■■"); // all filled
41
+ });
42
+
43
+ it("clamps fillPct to 100 at exactly 100", () => {
44
+ const filled = (s: string) => s;
45
+ const empty = (s: string) => "x".repeat(s.length);
46
+ const result = renderBar(5, 100, filled, empty);
47
+ expect(result).toBe("■■■■■"); // all filled, no empty characters
48
+ });
49
+
50
+ it("handles zero width gracefully", () => {
51
+ const result = renderBar(0, 50, filledStyle, emptyStyle);
52
+ expect(result).toBe("");
53
+ });
54
+
55
+ it("handles negative width gracefully", () => {
56
+ const result = renderBar(-5, 50, filledStyle, emptyStyle);
57
+ expect(result).toBe("");
58
+ });
59
+
60
+ it("rounds fill count to nearest integer", () => {
61
+ // 33.33% of 30 = 10 filled
62
+ const result = renderBar(30, 33.33, filledStyle, emptyStyle);
63
+ expect(result).toHaveLength(30);
64
+ expect(result.slice(0, 10)).toBe("■■■■■■■■■■");
65
+ });
66
+ });
@@ -0,0 +1,224 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { makeTheme } from "../../__tests__/components.fixtures";
3
+ import { BarChart } from "../BarChart";
4
+ import type { HourSpend } from "../../types";
5
+
6
+ describe("BarChart", () => {
7
+ const dailySpend = [
8
+ { date: "2026-06-01", cost: 1.0 },
9
+ { date: "2026-06-02", cost: 0.0 },
10
+ { date: "2026-06-03", cost: 2.5 },
11
+ { date: "2026-06-04", cost: 0.5 },
12
+ { date: "2026-06-05", cost: 0.0 },
13
+ { date: "2026-06-06", cost: 1.2 },
14
+ { date: "2026-06-07", cost: 3.0 },
15
+ ];
16
+
17
+ it("renders bar chart with X-axis labels", () => {
18
+ const chart = new BarChart(dailySpend, "7d", 15, makeTheme());
19
+ const lines = chart.render(80);
20
+ // Should have some visual output (bars)
21
+ expect(lines.length).toBeGreaterThan(0);
22
+ const text = lines.join("\n");
23
+ // X-axis labels should include day abbreviations
24
+ expect(text).toContain("Mon");
25
+ });
26
+
27
+ it("renders within width", () => {
28
+ const chart = new BarChart(dailySpend, "7d", 15, makeTheme());
29
+ const lines = chart.render(50);
30
+ for (const line of lines) {
31
+ expect(line.length).toBeLessThanOrEqual(50);
32
+ }
33
+ });
34
+
35
+ it("handles empty daily spend", () => {
36
+ const chart = new BarChart([], "7d", 15, makeTheme());
37
+ const lines = chart.render(80);
38
+ expect(lines.length).toBeGreaterThan(0);
39
+ // Should show empty state or just labels
40
+ const text = lines.join("\n");
41
+ expect(text).toContain("No data");
42
+ });
43
+
44
+ it("auto-scales bars to available height", () => {
45
+ const chart = new BarChart(dailySpend, "7d", 10, makeTheme());
46
+ const lines = chart.render(80);
47
+ // Should have at most maxHeight rows of bar content
48
+ expect(lines.length).toBeLessThanOrEqual(12); // 10 bars + 2 labels
49
+ });
50
+
51
+ it("uses block characters for bars", () => {
52
+ const chart = new BarChart(dailySpend, "7d", 15, makeTheme());
53
+ const lines = chart.render(80);
54
+ const text = lines.join("\n");
55
+ expect(text).toContain("█");
56
+ });
57
+
58
+ it("30d labels are sparse and fit within width", () => {
59
+ // Build 30 days of data spanning a month
60
+ const spend: { date: string; cost: number }[] = [];
61
+ for (let i = 1; i <= 30; i++) {
62
+ spend.push({ date: `2026-06-${String(i).padStart(2, "0")}`, cost: i });
63
+ }
64
+ const chart = new BarChart(spend, "30d", 10, makeTheme());
65
+ const lines = chart.render(80);
66
+ // All lines must fit within width
67
+ for (const line of lines) {
68
+ expect(line.length).toBeLessThanOrEqual(80);
69
+ }
70
+ // Last line is the label row
71
+ const labelLine = lines[lines.length - 1]!;
72
+ const visible = labelLine.replace(/\x1b\[[0-9;]*m/g, "");
73
+ // Should contain day numbers (first and last day)
74
+ expect(visible).toContain("1");
75
+ // At least some numeric labels visible (aggregation may shift which ones)
76
+ expect(visible).toMatch(/\d/);
77
+ // Labels should be sparse: not all 30 days get labels
78
+ const labels = visible.trim().split(/\s+/).filter(Boolean);
79
+ expect(labels.length).toBeLessThan(30);
80
+ });
81
+
82
+ it("All range labels show month on month change", () => {
83
+ const spend: { date: string; cost: number }[] = [
84
+ { date: "2026-01-15", cost: 1 },
85
+ { date: "2026-02-20", cost: 2 },
86
+ { date: "2026-03-10", cost: 3 },
87
+ { date: "2026-03-25", cost: 4 },
88
+ { date: "2026-04-05", cost: 5 },
89
+ ];
90
+ const chart = new BarChart(spend, "All", 10, makeTheme());
91
+ const lines = chart.render(80);
92
+ const labelLine = lines[lines.length - 2]!;
93
+ const visible = labelLine.replace(/\x1b\[[0-9;]*m/g, "");
94
+ // First entry gets a month label
95
+ expect(visible).toContain("Jan");
96
+ // Month changes get labels
97
+ expect(visible).toContain("Feb");
98
+ expect(visible).toContain("Mar");
99
+ expect(visible).toContain("Apr");
100
+ });
101
+
102
+ it("y-axis shows cost labels with separator", () => {
103
+ const chart = new BarChart(dailySpend, "7d", 15, makeTheme());
104
+ const lines = chart.render(80);
105
+ const text = lines.join("\n");
106
+ // Y-axis has $ labels
107
+ expect(text).toContain("$0.00");
108
+ expect(text).toContain("$3.00");
109
+ // Y-axis separator present
110
+ expect(text).toContain("│");
111
+ });
112
+
113
+ it("y-axis auto-dense: every row when barAreaH ≤ 6", () => {
114
+ // barAreaH = maxHeight - 2 = 5, so step=1
115
+ const chart = new BarChart(dailySpend, "7d", 7, makeTheme());
116
+ const lines = chart.render(80);
117
+ const barLines = lines.slice(1, -2); // exclude granularity + x-axis label
118
+ // All bar rows should have a dot separator or label content
119
+ for (const line of barLines) {
120
+ expect(line).toContain("│");
121
+ }
122
+ });
123
+
124
+ it("y-axis auto-dense: every-other row when barAreaH ≤ 14", () => {
125
+ // barAreaH = maxHeight - 2 = 10, so step=2
126
+ const chart = new BarChart(dailySpend, "7d", 12, makeTheme());
127
+ const lines = chart.render(80);
128
+ const barLines = lines.slice(1, -2); // exclude granularity + x-axis label
129
+ // At max cost $3.00, row 8 (80% height) should be $2.40, top row 9 should not have label
130
+ // But bottom row 0 always has label
131
+ expect(barLines[barLines.length - 1]).toContain("$0.00");
132
+ expect(barLines[0]).toMatch(/\$\d/); // top row no label
133
+ });
134
+
135
+ it("y-axis labels are right-aligned", () => {
136
+ const chart = new BarChart(dailySpend, "7d", 15, makeTheme());
137
+ const lines = chart.render(80);
138
+ const text = lines.join("\n");
139
+ // Labels should have a space before the separator
140
+ expect(text).toMatch(/ │/);
141
+ });
142
+
143
+ it("still renders within width with y-axis reserved", () => {
144
+ const chart = new BarChart(dailySpend, "7d", 15, makeTheme());
145
+ const lines = chart.render(40);
146
+ for (const line of lines) {
147
+ expect(line.length).toBeLessThanOrEqual(40);
148
+ }
149
+ });
150
+
151
+ it("yAxisSpacing overrides auto-density", () => {
152
+ // barAreaH = 15-2 = 13, auto would be step=2 (every other)
153
+ // but yAxisSpacing=1 forces every row
154
+ const chart = new BarChart(dailySpend, "7d", 15, makeTheme(), 1);
155
+ const lines = chart.render(80);
156
+ const barLines = lines.slice(1, -2); // exclude granularity + x-axis label
157
+ // Every bar row should have $ labels (step=1)
158
+ for (const line of barLines) {
159
+ expect(line).toContain("$");
160
+ }
161
+ });
162
+
163
+ it("x-axis has bottom └ corner and ─ filler between labels", () => {
164
+ const chart = new BarChart(dailySpend, "7d", 15, makeTheme());
165
+ const lines = chart.render(80);
166
+ // Last line is the x-axis label row
167
+ const labelLine = lines[lines.length - 2]!;
168
+ const visible = labelLine.replace(/\x1b\[[0-9;]*m/g, "");
169
+ // └ at the y-axis position (corner)
170
+ expect(visible).toContain("└");
171
+ // Labels are centered — dashes on both sides
172
+ expect(visible).toMatch(/─+Mon─+/);
173
+ expect(visible).toMatch(/─+Tue─+/);
174
+ });
175
+
176
+ it("invalidates cache", () => {
177
+ const chart = new BarChart(dailySpend, "7d", 15, makeTheme());
178
+ chart.render(80);
179
+ chart.invalidate();
180
+ const lines = chart.render(60);
181
+ for (const line of lines) {
182
+ expect(line.length).toBeLessThanOrEqual(60);
183
+ }
184
+ });
185
+
186
+ describe("hourly mode (1d range)", () => {
187
+ const hourlySpend: HourSpend[] = Array.from({ length: 24 }, (_, i) => ({
188
+ hour: i,
189
+ cost: i === 10 ? 2.5 : i === 14 ? 1.5 : 0,
190
+ }));
191
+
192
+ it("renders 24 bars with hourly labels", () => {
193
+ const chart = new BarChart([], "1d", 15, makeTheme(), undefined, hourlySpend);
194
+ const lines = chart.render(120);
195
+ const text = lines.join("\n");
196
+ // Should have auto-dense hour labels (at interval determined by width)
197
+ expect(text).toContain("0h");
198
+ expect(text).toContain("12h");
199
+ expect(text).toContain("23h");
200
+ // Should show cost on y-axis
201
+ expect(text).toContain("$2.50");
202
+ expect(text).toContain("Hourly");
203
+ });
204
+
205
+ it("fits within width", () => {
206
+ const chart = new BarChart([], "1d", 15, makeTheme(), undefined, hourlySpend);
207
+ const lines = chart.render(80);
208
+ for (const line of lines) {
209
+ expect(line.length).toBeLessThanOrEqual(80);
210
+ }
211
+ });
212
+
213
+ it("downsamples hours on narrow terminals", () => {
214
+ const chart = new BarChart([], "1d", 10, makeTheme(), undefined, hourlySpend);
215
+ const lines = chart.render(30);
216
+ for (const line of lines) {
217
+ expect(line.length).toBeLessThanOrEqual(30);
218
+ }
219
+ // Should still have some hour labels
220
+ const text = lines.join("\n");
221
+ expect(text).toContain("h");
222
+ });
223
+ });
224
+ });