@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,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
|
+
});
|