@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
package/src/cache.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { mergeDay, parseFile } from "./parser";
|
|
5
|
+
import type { CachePayload, DayAgg, SerializedDayAgg } from "./types";
|
|
6
|
+
|
|
7
|
+
// ---- Signature ----
|
|
8
|
+
|
|
9
|
+
export async function computeSignature(sessionsDir: string): Promise<string> {
|
|
10
|
+
const hash = createHash("sha256");
|
|
11
|
+
const entries: Array<{ path: string; size: number; mtimeMs: number }> = [];
|
|
12
|
+
|
|
13
|
+
async function walk(dir: string): Promise<void> {
|
|
14
|
+
let dirents;
|
|
15
|
+
try {
|
|
16
|
+
dirents = await readdir(dir, { withFileTypes: true });
|
|
17
|
+
} catch {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
for (const d of dirents) {
|
|
21
|
+
const full = join(dir, d.name);
|
|
22
|
+
if (d.isDirectory()) {
|
|
23
|
+
await walk(full);
|
|
24
|
+
} else if (d.isFile() && d.name.endsWith(".jsonl")) {
|
|
25
|
+
const s = await stat(full);
|
|
26
|
+
entries.push({ path: full, size: s.size, mtimeMs: s.mtimeMs });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
await walk(sessionsDir);
|
|
32
|
+
|
|
33
|
+
if (entries.length === 0) return "";
|
|
34
|
+
|
|
35
|
+
// Sort by path for deterministic hashing
|
|
36
|
+
entries.sort((a, b) => a.path.localeCompare(b.path));
|
|
37
|
+
|
|
38
|
+
for (const e of entries) {
|
|
39
|
+
hash.update(`${e.path}\n${e.size}\n${e.mtimeMs}\n`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return hash.digest("hex");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---- Serialization ----
|
|
46
|
+
|
|
47
|
+
function serializeDay(d: DayAgg): SerializedDayAgg {
|
|
48
|
+
return {
|
|
49
|
+
...d,
|
|
50
|
+
sessionIds: [...d.sessionIds],
|
|
51
|
+
projectSessions: Object.fromEntries(
|
|
52
|
+
Object.entries(d.projectSessions).map(([k, v]) => [k, [...v]]),
|
|
53
|
+
),
|
|
54
|
+
modelToProvider: Object.fromEntries(d.modelToProvider),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function deserializeDay(s: SerializedDayAgg): DayAgg {
|
|
59
|
+
return {
|
|
60
|
+
...s,
|
|
61
|
+
sessionIds: new Set(s.sessionIds),
|
|
62
|
+
projectSessions: Object.fromEntries(
|
|
63
|
+
Object.entries(s.projectSessions).map(([k, v]) => [k, new Set(v)]),
|
|
64
|
+
),
|
|
65
|
+
modelToProvider: new Map(Object.entries(s.modelToProvider)),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---- Cache I/O ----
|
|
70
|
+
|
|
71
|
+
export async function writeCache(
|
|
72
|
+
cachePath: string,
|
|
73
|
+
signature: string,
|
|
74
|
+
days: DayAgg[],
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
const payload: CachePayload = {
|
|
77
|
+
signature,
|
|
78
|
+
generatedAt: new Date().toISOString(),
|
|
79
|
+
days: days.map(serializeDay),
|
|
80
|
+
};
|
|
81
|
+
await writeFile(cachePath, JSON.stringify(payload), "utf-8");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function readCache(cachePath: string): Promise<CachePayload | null> {
|
|
85
|
+
try {
|
|
86
|
+
const raw = await readFile(cachePath, "utf-8");
|
|
87
|
+
const payload = JSON.parse(raw) as CachePayload;
|
|
88
|
+
if (!payload.signature || !Array.isArray(payload.days)) return null;
|
|
89
|
+
return payload;
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function getCacheTimestamp(cachePath: string): Promise<string | null> {
|
|
96
|
+
const payload = await readCache(cachePath);
|
|
97
|
+
return payload?.generatedAt ?? null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Check if cache is still valid against current directory signature */
|
|
101
|
+
export async function isCacheValid(cachePath: string, sessionsDir: string): Promise<boolean> {
|
|
102
|
+
const cached = await readCache(cachePath);
|
|
103
|
+
if (!cached) return false;
|
|
104
|
+
const currentSig = await computeSignature(sessionsDir);
|
|
105
|
+
return cached.signature === currentSig;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---- Aggregate loading ----
|
|
109
|
+
|
|
110
|
+
async function findAllJsonlFiles(dir: string): Promise<string[]> {
|
|
111
|
+
const result: string[] = [];
|
|
112
|
+
async function walk(d: string) {
|
|
113
|
+
let entries;
|
|
114
|
+
try {
|
|
115
|
+
entries = await readdir(d, { withFileTypes: true });
|
|
116
|
+
} catch {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
for (const e of entries) {
|
|
120
|
+
const full = join(d, e.name);
|
|
121
|
+
if (e.isDirectory()) await walk(full);
|
|
122
|
+
else if (e.isFile() && e.name.endsWith(".jsonl")) result.push(full);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
await walk(dir);
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function loadAggregate(
|
|
130
|
+
cachePath: string,
|
|
131
|
+
sessionsDir: string,
|
|
132
|
+
force = false,
|
|
133
|
+
onProgress?: (p: number) => void,
|
|
134
|
+
): Promise<DayAgg[]> {
|
|
135
|
+
// Try cache first
|
|
136
|
+
if (!force) {
|
|
137
|
+
const valid = await isCacheValid(cachePath, sessionsDir);
|
|
138
|
+
if (valid) {
|
|
139
|
+
const cached = await readCache(cachePath);
|
|
140
|
+
if (cached) return cached.days.map(deserializeDay);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Parse all JSONL files
|
|
145
|
+
const files = await findAllJsonlFiles(sessionsDir);
|
|
146
|
+
const map = new Map<string, DayAgg>();
|
|
147
|
+
let totalCorrupt = 0;
|
|
148
|
+
|
|
149
|
+
for (let i = 0; i < files.length; i++) {
|
|
150
|
+
let lastCount = 0;
|
|
151
|
+
const fileMap = parseFile(files[i]!, (count) => {
|
|
152
|
+
lastCount = count;
|
|
153
|
+
});
|
|
154
|
+
totalCorrupt += lastCount;
|
|
155
|
+
for (const [date, day] of fileMap) {
|
|
156
|
+
const existing = map.get(date);
|
|
157
|
+
if (existing) {
|
|
158
|
+
// mergeDay is used inline in engine's loadAggregate; import it
|
|
159
|
+
mergeDay(existing, day);
|
|
160
|
+
} else {
|
|
161
|
+
map.set(date, day);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (onProgress) onProgress(Math.round(((i + 1) / files.length) * 100));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (totalCorrupt > 0) {
|
|
168
|
+
console.error(`pi-atlas: skipped ${totalCorrupt} corrupt JSONL line(s)`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const days = [...map.values()].sort((a, b) => a.date.localeCompare(b.date));
|
|
172
|
+
|
|
173
|
+
// Write cache
|
|
174
|
+
const sig = await computeSignature(sessionsDir);
|
|
175
|
+
await writeCache(cachePath, sig, days);
|
|
176
|
+
|
|
177
|
+
return days;
|
|
178
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import chalk, { type ChalkInstance } from "chalk";
|
|
2
|
+
|
|
3
|
+
export class ColorPalette {
|
|
4
|
+
constructor(private mapping: Record<string, ChalkInstance>) {}
|
|
5
|
+
|
|
6
|
+
getColor(name: string): ChalkInstance {
|
|
7
|
+
return this.mapping[name] ?? chalk.white;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const langPalette = new ColorPalette({
|
|
12
|
+
Astro: chalk.hex("#FF5A03"),
|
|
13
|
+
C: chalk.hex("#555555"),
|
|
14
|
+
"C#": chalk.hex("#178600"),
|
|
15
|
+
"C++": chalk.hex("#F34B7D"),
|
|
16
|
+
Clojure: chalk.hex("#DB5855"),
|
|
17
|
+
CoffeeScript: chalk.hex("#244776"),
|
|
18
|
+
Crystal: chalk.hex("#000100"),
|
|
19
|
+
CSS: chalk.hex("#663399"),
|
|
20
|
+
Dart: chalk.hex("#00B4AB"),
|
|
21
|
+
Dockerfile: chalk.hex("#384D54"),
|
|
22
|
+
EJS: chalk.hex("#A91E50"),
|
|
23
|
+
Elixir: chalk.hex("#6E4A7E"),
|
|
24
|
+
Elm: chalk.hex("#60B5CC"),
|
|
25
|
+
Env: chalk.hex("#E5D559"),
|
|
26
|
+
Erlang: chalk.hex("#B83998"),
|
|
27
|
+
"F#": chalk.hex("#B845FC"),
|
|
28
|
+
Gitignore: chalk.hex("#F44D27"),
|
|
29
|
+
Gleam: chalk.hex("#FFAFF3"),
|
|
30
|
+
Go: chalk.hex("#00ADD8"),
|
|
31
|
+
GraphQL: chalk.hex("#E10098"),
|
|
32
|
+
Groovy: chalk.hex("#4298B8"),
|
|
33
|
+
HTML: chalk.hex("#E34C26"),
|
|
34
|
+
Handlebars: chalk.hex("#F7931E"),
|
|
35
|
+
Haskell: chalk.hex("#5E5086"),
|
|
36
|
+
Java: chalk.hex("#B07219"),
|
|
37
|
+
JavaScript: chalk.hex("#F1E05A"),
|
|
38
|
+
Jinja: chalk.hex("#A52A22"),
|
|
39
|
+
JSON: chalk.hex("#292929"),
|
|
40
|
+
Julia: chalk.hex("#A270BA"),
|
|
41
|
+
Kotlin: chalk.hex("#A97BFF"),
|
|
42
|
+
Less: chalk.hex("#1D365D"),
|
|
43
|
+
Liquid: chalk.hex("#67B8DE"),
|
|
44
|
+
Lua: chalk.hex("#000080"),
|
|
45
|
+
Markdown: chalk.hex("#083FA1"),
|
|
46
|
+
Mustache: chalk.hex("#724B3B"),
|
|
47
|
+
Nix: chalk.hex("#7E7EFF"),
|
|
48
|
+
Nunjucks: chalk.hex("#3D8137"),
|
|
49
|
+
"Objective-C": chalk.hex("#438EFF"),
|
|
50
|
+
PHP: chalk.hex("#4F5D95"),
|
|
51
|
+
Perl: chalk.hex("#0298C3"),
|
|
52
|
+
PowerShell: chalk.hex("#012456"),
|
|
53
|
+
Prisma: chalk.hex("#0C344B"),
|
|
54
|
+
Protobuf: chalk.hex("#652D90"),
|
|
55
|
+
Pug: chalk.hex("#A86454"),
|
|
56
|
+
Python: chalk.hex("#3572A5"),
|
|
57
|
+
QML: chalk.hex("#44A51C"),
|
|
58
|
+
R: chalk.hex("#198CE7"),
|
|
59
|
+
Raku: chalk.hex("#0000FB"),
|
|
60
|
+
Ruby: chalk.hex("#701516"),
|
|
61
|
+
Rust: chalk.hex("#DEA584"),
|
|
62
|
+
SCSS: chalk.hex("#C6538C"),
|
|
63
|
+
SQL: chalk.hex("#E38C00"),
|
|
64
|
+
Scala: chalk.hex("#C22D40"),
|
|
65
|
+
Shell: chalk.hex("#89E051"),
|
|
66
|
+
Solidity: chalk.hex("#AA6746"),
|
|
67
|
+
Svelte: chalk.hex("#FF3E00"),
|
|
68
|
+
Stylus: chalk.hex("#FF6347"),
|
|
69
|
+
Swift: chalk.hex("#F05138"),
|
|
70
|
+
TOML: chalk.hex("#9C4221"),
|
|
71
|
+
Terraform: chalk.hex("#844FBA"),
|
|
72
|
+
TypeScript: chalk.hex("#3178C6"),
|
|
73
|
+
Twig: chalk.hex("#C1D026"),
|
|
74
|
+
Vue: chalk.hex("#41B883"),
|
|
75
|
+
"Visual Basic .NET": chalk.hex("#945DB7"),
|
|
76
|
+
WebAssembly: chalk.hex("#04133B"),
|
|
77
|
+
XML: chalk.hex("#0060AC"),
|
|
78
|
+
YAML: chalk.hex("#CB171E"),
|
|
79
|
+
Zig: chalk.hex("#EC915C"),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// based on https://pi.dev/models
|
|
83
|
+
export const modelPalette = new ColorPalette({
|
|
84
|
+
"amazon-bedrock": chalk.hex("#FF9900"),
|
|
85
|
+
"ant-ling": chalk.hex("#0d66fd"),
|
|
86
|
+
anthropic: chalk.hex("#d66545"),
|
|
87
|
+
"azure-openai-responses": chalk.hex("#0078D4"),
|
|
88
|
+
cerebras: chalk.hex("#7CFF00"),
|
|
89
|
+
"cloudflare-ai-gateway": chalk.hex("#F38020"),
|
|
90
|
+
"cloudflare-workers-ai": chalk.hex("#F38020"),
|
|
91
|
+
deepseek: chalk.hex("#4D6BFE"),
|
|
92
|
+
fireworks: chalk.hex("#FD6F3E"),
|
|
93
|
+
"github-copilot": chalk.hex("#93f5eb"),
|
|
94
|
+
google: chalk.hex("#4285F4"),
|
|
95
|
+
"google-vertex": chalk.hex("#4285F4"),
|
|
96
|
+
groq: chalk.hex("#F55036"),
|
|
97
|
+
huggingface: chalk.hex("#FFD21E"),
|
|
98
|
+
"kimi-coding": chalk.hex("#1F6BFF"),
|
|
99
|
+
minimax: chalk.hex("#5B6CFF"),
|
|
100
|
+
"minimax-cn": chalk.hex("#5B6CFF"),
|
|
101
|
+
mistral: chalk.hex("#FA520F"),
|
|
102
|
+
moonshotai: chalk.hex("#4F46E5"),
|
|
103
|
+
"moonshotai-cn": chalk.hex("#4F46E5"),
|
|
104
|
+
nvidia: chalk.hex("#76B900"),
|
|
105
|
+
openai: chalk.hex("#0f9f7c"),
|
|
106
|
+
"openai-codex": chalk.hex("#009aa2"),
|
|
107
|
+
opencode: chalk.hex("#d0cfce"),
|
|
108
|
+
"opencode-go": chalk.hex("#d0cfce"),
|
|
109
|
+
openrouter: chalk.hex("#7C3AED"),
|
|
110
|
+
together: chalk.hex("#0F6FFF"),
|
|
111
|
+
"vercel-ai-gateway": chalk.hex("#d1caf3"),
|
|
112
|
+
xai: chalk.hex("#ffffff"),
|
|
113
|
+
xiaomi: chalk.hex("#FF6900"),
|
|
114
|
+
"xiaomi-token-plan-cn": chalk.hex("#FF6900"),
|
|
115
|
+
"xiaomi-token-plan-ams": chalk.hex("#FF6900"),
|
|
116
|
+
"xiaomi-token-plan-sgp": chalk.hex("#FF6900"),
|
|
117
|
+
zai: chalk.hex("#2563EB"),
|
|
118
|
+
"zai-coding-cn": chalk.hex("#2563EB"),
|
|
119
|
+
});
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type Component, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { MONTH_NAMES, formatCost } from "../format";
|
|
4
|
+
import type { DaySpend, HourSpend, TimeRange } from "../types";
|
|
5
|
+
|
|
6
|
+
/** Number of hours displayed in a full day. */
|
|
7
|
+
const HOURS_PER_DAY = 24;
|
|
8
|
+
|
|
9
|
+
const DAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
10
|
+
|
|
11
|
+
/** Minimum bar area height (rows) — prevents degenerate chart at tiny maxHeight. */
|
|
12
|
+
const MIN_BAR_AREA = 3;
|
|
13
|
+
/** Padding rows subtracted from maxHeight: 1 for x-axis labels + 1 for top padding. */
|
|
14
|
+
const CHART_VERTICAL_PADDING = 2;
|
|
15
|
+
/** Minimum column width per bar — prevents invisible bars on narrow terminals. */
|
|
16
|
+
const MIN_COL_WIDTH = 2;
|
|
17
|
+
/** Floor cost to avoid divide-by-zero when computing bar proportions. */
|
|
18
|
+
const COST_FLOOR = 0.01;
|
|
19
|
+
/** Threshold for half-block character: barH extension beyond integer row. */
|
|
20
|
+
const HALF_BLOCK_THRESHOLD = 0.5;
|
|
21
|
+
/** Y-axis separator string " │ " — space, box-drawing line, space. */
|
|
22
|
+
const Y_AXIS_SEPARATOR = " │ ";
|
|
23
|
+
|
|
24
|
+
/** Space between bar columns in characters. */
|
|
25
|
+
const BAR_GAP = 1;
|
|
26
|
+
|
|
27
|
+
/** Auto-density: every row when barAreaH ≤ this. */
|
|
28
|
+
const DENSE_MAX_HEIGHT = 6;
|
|
29
|
+
/** Auto-density: every other row when barAreaH ≤ this. Otherwise every 3rd. */
|
|
30
|
+
const SPREAD_MAX_HEIGHT = 14;
|
|
31
|
+
|
|
32
|
+
/** Minimum number of bars — prevents degenerate chart at tiny widths. */
|
|
33
|
+
const MIN_BARS = 2;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Aggregate data into at most `target` buckets by grouping consecutive items.
|
|
37
|
+
* Costs are summed per bucket; the first item's other fields are preserved.
|
|
38
|
+
*/
|
|
39
|
+
function aggregate<T extends { cost: number }>(data: T[], target: number): T[] {
|
|
40
|
+
if (data.length <= target) return data;
|
|
41
|
+
const n = data.length;
|
|
42
|
+
const q = Math.floor(n / target);
|
|
43
|
+
const r = n % target;
|
|
44
|
+
const result: T[] = [];
|
|
45
|
+
let idx = 0;
|
|
46
|
+
for (let i = 0; i < target; i++) {
|
|
47
|
+
const size = i < r ? q + 1 : q;
|
|
48
|
+
let cost = 0;
|
|
49
|
+
for (let j = 0; j < size; j++) {
|
|
50
|
+
cost += data[idx + j]!.cost;
|
|
51
|
+
}
|
|
52
|
+
result.push({ ...data[idx]!, cost });
|
|
53
|
+
idx += size;
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class BarChart implements Component {
|
|
59
|
+
private data: DaySpend[];
|
|
60
|
+
private range: TimeRange;
|
|
61
|
+
private maxHeight: number;
|
|
62
|
+
private theme: Theme;
|
|
63
|
+
private yAxisSpacing: number | undefined;
|
|
64
|
+
private hourlyData: HourSpend[];
|
|
65
|
+
private cachedLines: string[] | null = null;
|
|
66
|
+
private cachedWidth = -1;
|
|
67
|
+
|
|
68
|
+
constructor(
|
|
69
|
+
data: DaySpend[],
|
|
70
|
+
range: TimeRange,
|
|
71
|
+
maxHeight: number,
|
|
72
|
+
theme: Theme,
|
|
73
|
+
yAxisSpacing?: number,
|
|
74
|
+
hourlyData?: HourSpend[],
|
|
75
|
+
) {
|
|
76
|
+
this.data = data;
|
|
77
|
+
this.range = range;
|
|
78
|
+
this.maxHeight = maxHeight;
|
|
79
|
+
this.theme = theme;
|
|
80
|
+
this.yAxisSpacing = yAxisSpacing;
|
|
81
|
+
this.hourlyData = hourlyData ?? [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
render(width: number): string[] {
|
|
85
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
86
|
+
|
|
87
|
+
if (this.data.length === 0 && this.hourlyData.length === 0) {
|
|
88
|
+
this.cachedLines = [this.theme.fg("muted", "No data")];
|
|
89
|
+
this.cachedWidth = width;
|
|
90
|
+
return this.cachedLines;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let lines: string[] = [];
|
|
94
|
+
if (this.range === "1d" && this.hourlyData.length === HOURS_PER_DAY) {
|
|
95
|
+
lines = this.renderHourly(width);
|
|
96
|
+
} else {
|
|
97
|
+
lines = this.renderDaily(width);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
this.cachedLines = lines;
|
|
101
|
+
this.cachedWidth = width;
|
|
102
|
+
return lines;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private renderDaily(width: number): string[] {
|
|
106
|
+
return this.renderBars(
|
|
107
|
+
this.data,
|
|
108
|
+
(plotData, _cellW) =>
|
|
109
|
+
plotData.map((d, i) => formatDateLabel(d.date, i, plotData as DaySpend[], this.range)),
|
|
110
|
+
(plotData, n) =>
|
|
111
|
+
plotData.length === n ? "Daily" : "~" + (n / plotData.length).toFixed(1) + "d avg",
|
|
112
|
+
width,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Render per-hour bars for 1d range. */
|
|
117
|
+
private renderHourly(width: number): string[] {
|
|
118
|
+
return this.renderBars(
|
|
119
|
+
this.hourlyData,
|
|
120
|
+
(plotData, cellW) => {
|
|
121
|
+
const interval = computeHourLabelInterval(plotData.length, cellW);
|
|
122
|
+
return plotData.map((h, i) =>
|
|
123
|
+
formatHourLabel(h.hour, i, plotData as HourSpend[], interval),
|
|
124
|
+
);
|
|
125
|
+
},
|
|
126
|
+
(plotData, n) =>
|
|
127
|
+
plotData.length === n ? "Hourly" : `~${(HOURS_PER_DAY / plotData.length).toFixed(1)}h avg`,
|
|
128
|
+
width,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Shared bar-rendering engine. Handles layout, y-axis, bar drawing,
|
|
134
|
+
* x-axis label line, and granularity footer.
|
|
135
|
+
*
|
|
136
|
+
* @param sourceData — raw data before potential downsampling
|
|
137
|
+
* @param getLabels — computes x-axis labels from the (possibly downsampled) plotData and cell width
|
|
138
|
+
* @param getGranularity — returns the granularity label string from (plotData, sourceLength)
|
|
139
|
+
*/
|
|
140
|
+
private renderBars<T extends { cost: number }>(
|
|
141
|
+
sourceData: T[],
|
|
142
|
+
getLabels: (plotData: T[], cellWidth: number) => string[],
|
|
143
|
+
getGranularity: (plotData: T[], sourceLength: number) => string,
|
|
144
|
+
width: number,
|
|
145
|
+
): string[] {
|
|
146
|
+
const barAreaH = Math.max(MIN_BAR_AREA, this.maxHeight - CHART_VERTICAL_PADDING);
|
|
147
|
+
const maxCost = Math.max(...sourceData.map((d) => d.cost), COST_FLOOR);
|
|
148
|
+
|
|
149
|
+
const step = this.yAxisSpacing != null ? Math.max(1, this.yAxisSpacing) : densityStep(barAreaH);
|
|
150
|
+
const yLabelPad = computeLabelWidth(maxCost, barAreaH, step);
|
|
151
|
+
const yAxisW = yLabelPad + Y_AXIS_SEPARATOR.length;
|
|
152
|
+
|
|
153
|
+
const availW = width - yAxisW;
|
|
154
|
+
const maxBars = Math.max(MIN_BARS, Math.floor(availW / (MIN_COL_WIDTH + BAR_GAP)));
|
|
155
|
+
const plotData = sourceData.length > maxBars ? aggregate(sourceData, maxBars) : sourceData;
|
|
156
|
+
|
|
157
|
+
const totalGaps = plotData.length * BAR_GAP;
|
|
158
|
+
const colW = Math.max(MIN_COL_WIDTH, Math.floor((availW - totalGaps) / plotData.length));
|
|
159
|
+
const cellW = colW + BAR_GAP;
|
|
160
|
+
|
|
161
|
+
// Pre-compute bar heights once (not per-row)
|
|
162
|
+
const barHeights = plotData.map((d) => (d.cost / maxCost) * barAreaH);
|
|
163
|
+
const labels = getLabels(plotData, cellW);
|
|
164
|
+
const granularityLabel = getGranularity(plotData, sourceData.length);
|
|
165
|
+
|
|
166
|
+
const lines: string[] = [];
|
|
167
|
+
|
|
168
|
+
for (let row = barAreaH - 1; row >= 0; row--) {
|
|
169
|
+
let line = "";
|
|
170
|
+
|
|
171
|
+
const isLabelRow = row === 0 || row % step === 0;
|
|
172
|
+
if (isLabelRow) {
|
|
173
|
+
const val = (row / (barAreaH - 1)) * maxCost;
|
|
174
|
+
line += formatCost(val).padStart(yLabelPad) + Y_AXIS_SEPARATOR;
|
|
175
|
+
} else {
|
|
176
|
+
line += " ".repeat(yLabelPad) + Y_AXIS_SEPARATOR;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (let bi = 0; bi < plotData.length; bi++) {
|
|
180
|
+
const barH = barHeights[bi]!;
|
|
181
|
+
if (barH > row + HALF_BLOCK_THRESHOLD) {
|
|
182
|
+
line += "█".repeat(colW);
|
|
183
|
+
} else if (barH > row) {
|
|
184
|
+
line += "▄".repeat(colW);
|
|
185
|
+
} else {
|
|
186
|
+
line += " ".repeat(colW);
|
|
187
|
+
}
|
|
188
|
+
line += " ".repeat(BAR_GAP);
|
|
189
|
+
}
|
|
190
|
+
lines.push(line);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// X-axis labels with y-axis bottom corner
|
|
194
|
+
let labelLine = " ".repeat(yLabelPad + 1) + "└─";
|
|
195
|
+
for (let i = 0; i < plotData.length; i++) {
|
|
196
|
+
const lbl = labels[i]!;
|
|
197
|
+
const cellW = colW + BAR_GAP;
|
|
198
|
+
if (lbl.length > 0) {
|
|
199
|
+
if (lbl.length <= cellW) {
|
|
200
|
+
const pad = cellW - lbl.length;
|
|
201
|
+
const padLeft = Math.floor(pad / 2);
|
|
202
|
+
const padRight = pad - padLeft;
|
|
203
|
+
labelLine += "─".repeat(padLeft) + lbl + "─".repeat(padRight);
|
|
204
|
+
} else {
|
|
205
|
+
labelLine += lbl + "─".repeat(Math.max(0, cellW - lbl.length));
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
labelLine += "─".repeat(cellW);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
lines.push(labelLine);
|
|
212
|
+
|
|
213
|
+
// Granularity footer (right-aligned)
|
|
214
|
+
const granularityText = this.theme.italic(granularityLabel);
|
|
215
|
+
lines.push(
|
|
216
|
+
this.theme.fg("dim", " ".repeat(width - visibleWidth(granularityText)) + granularityText),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
return lines;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
invalidate(): void {
|
|
223
|
+
this.cachedLines = null;
|
|
224
|
+
this.cachedWidth = -1;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function densityStep(barAreaH: number): number {
|
|
229
|
+
if (barAreaH <= DENSE_MAX_HEIGHT) return 1;
|
|
230
|
+
if (barAreaH <= SPREAD_MAX_HEIGHT) return 2;
|
|
231
|
+
return 3;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function computeLabelWidth(maxCost: number, barAreaH: number, step: number): number {
|
|
235
|
+
let maxW = 0;
|
|
236
|
+
for (let row = 0; row < barAreaH; row += step) {
|
|
237
|
+
const val = (row / (barAreaH - 1)) * maxCost;
|
|
238
|
+
maxW = Math.max(maxW, formatCost(val).length);
|
|
239
|
+
}
|
|
240
|
+
// Baseline $0.00 is always shown but already covered by row=0
|
|
241
|
+
return maxW;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function formatDateLabel(
|
|
245
|
+
dateStr: string,
|
|
246
|
+
index: number,
|
|
247
|
+
data: DaySpend[],
|
|
248
|
+
range: TimeRange,
|
|
249
|
+
): string {
|
|
250
|
+
const d = new Date(dateStr + "T00:00:00Z");
|
|
251
|
+
if (range === "1d" || range === "7d") {
|
|
252
|
+
return DAY_NAMES[d.getUTCDay()] || "NaN";
|
|
253
|
+
}
|
|
254
|
+
if (range === "30d") {
|
|
255
|
+
const day = d.getUTCDate();
|
|
256
|
+
if (day === 1 || day % 5 === 0 || index === 0 || index === data.length - 1) {
|
|
257
|
+
return String(day);
|
|
258
|
+
}
|
|
259
|
+
return "";
|
|
260
|
+
}
|
|
261
|
+
// All — show month when it changes or first entry
|
|
262
|
+
const month = MONTH_NAMES[d.getUTCMonth()];
|
|
263
|
+
if (index === 0) return month || "NaN";
|
|
264
|
+
const prevD = new Date(data[index - 1]!.date + "T00:00:00Z");
|
|
265
|
+
if (prevD.getUTCMonth() !== d.getUTCMonth()) return month || "NaN";
|
|
266
|
+
return "";
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function computeHourLabelInterval(count: number, cellWidth: number): number {
|
|
270
|
+
// Try intervals — pick the smallest that fits labels in cells
|
|
271
|
+
const candidates = [4, 6, 12];
|
|
272
|
+
for (const interval of candidates) {
|
|
273
|
+
const labelsOnRow = Math.ceil(count / interval);
|
|
274
|
+
// Rough estimate: each label needs cellWidth minus a gap for a space on each side
|
|
275
|
+
const neededPerLabel = cellWidth;
|
|
276
|
+
if (labelsOnRow * neededPerLabel <= count * cellWidth) {
|
|
277
|
+
return interval;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return candidates[candidates.length - 1]!;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function formatHourLabel(hour: number, index: number, data: HourSpend[], interval: number): string {
|
|
284
|
+
if (index === 0 || index === data.length - 1 || hour % interval === 0) {
|
|
285
|
+
return `${hour}h`;
|
|
286
|
+
}
|
|
287
|
+
return "";
|
|
288
|
+
}
|