codebuddy-stats 1.2.12 → 1.3.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.
@@ -0,0 +1,97 @@
1
+ import { resolveProjectName } from '../lib/workspace-resolver.js';
2
+ import { formatCost, formatNumber, formatPercent, formatTokens, truncate } from '../lib/utils.js';
3
+ export function renderMonthlyDetail(box, data, month, scrollOffset = 0, width, pageSize) {
4
+ const aggByProject = {};
5
+ let totalCost = 0;
6
+ let totalTokens = 0;
7
+ let totalRequests = 0;
8
+ let cacheHitTokens = 0;
9
+ let cacheMissTokens = 0;
10
+ for (const [date, dayData] of Object.entries(data.dailyData)) {
11
+ if (!date.startsWith(month))
12
+ continue;
13
+ for (const [projectName, models] of Object.entries(dayData ?? {})) {
14
+ aggByProject[projectName] ??= {
15
+ name: projectName,
16
+ shortName: resolveProjectName(projectName, data.workspaceMappings),
17
+ cost: 0,
18
+ tokens: 0,
19
+ requests: 0,
20
+ modelCost: {},
21
+ };
22
+ const p = aggByProject[projectName];
23
+ for (const [modelId, stats] of Object.entries(models ?? {})) {
24
+ const s = stats;
25
+ const cost = Number(s.cost ?? 0);
26
+ const tokens = Number(s.totalTokens ?? 0);
27
+ const requests = Number(s.requests ?? 0);
28
+ const cacheHit = Number(s.cacheHitTokens ?? 0);
29
+ const cacheMiss = Number(s.cacheMissTokens ?? 0);
30
+ p.cost += cost;
31
+ p.tokens += tokens;
32
+ p.requests += requests;
33
+ p.modelCost[modelId] = (p.modelCost[modelId] ?? 0) + cost;
34
+ totalCost += cost;
35
+ totalTokens += tokens;
36
+ totalRequests += requests;
37
+ cacheHitTokens += cacheHit;
38
+ cacheMissTokens += cacheMiss;
39
+ }
40
+ }
41
+ }
42
+ const projects = Object.values(aggByProject)
43
+ .map(p => {
44
+ let topModelId = '-';
45
+ let topModelCost = 0;
46
+ for (const [modelId, c] of Object.entries(p.modelCost)) {
47
+ if (c > topModelCost) {
48
+ topModelCost = c;
49
+ topModelId = modelId;
50
+ }
51
+ }
52
+ return { ...p, topModelId };
53
+ })
54
+ .sort((a, b) => b.cost - a.cost);
55
+ if (!projects.length) {
56
+ box.setContent(`{bold}${month}{/bold}\n\nNo data available for this month.`);
57
+ return;
58
+ }
59
+ const cacheHitRate = cacheHitTokens + cacheMissTokens > 0 ? cacheHitTokens / (cacheHitTokens + cacheMissTokens) : 0;
60
+ // 根据宽度计算列宽
61
+ const availableWidth = width - 6; // padding
62
+ const costCol = 12;
63
+ const tokensCol = 10;
64
+ const modelCol = 22;
65
+ const fixedCols = costCol + tokensCol + modelCol;
66
+ const projectCol = Math.max(25, availableWidth - fixedCols);
67
+ const totalWidth = projectCol + fixedCols;
68
+ let content = `{bold}${month} - Project Usage Details{/bold}\n\n`;
69
+ content +=
70
+ `{green-fg}Total cost:{/green-fg} ${formatCost(totalCost)} {green-fg}Tokens:{/green-fg} ${formatTokens(totalTokens)} {green-fg}Requests:{/green-fg} ${formatNumber(totalRequests)}\n`;
71
+ content += `{green-fg}Cache hit rate:{/green-fg} ${formatPercent(cacheHitRate)}\n\n`;
72
+ content +=
73
+ '{underline}' +
74
+ 'Project'.padEnd(projectCol) +
75
+ '~Cost'.padStart(costCol) +
76
+ 'Tokens'.padStart(tokensCol) +
77
+ 'Top Model'.padStart(modelCol) +
78
+ '{/underline}\n';
79
+ const safePageSize = Math.max(1, Math.floor(pageSize || 1));
80
+ const visibleProjects = projects.slice(scrollOffset, scrollOffset + safePageSize);
81
+ for (const p of visibleProjects) {
82
+ content +=
83
+ truncate(p.shortName, projectCol - 1).padEnd(projectCol) +
84
+ formatCost(p.cost).padStart(costCol) +
85
+ formatTokens(p.tokens).padStart(tokensCol) +
86
+ truncate(p.topModelId, modelCol - 1).padStart(modelCol) +
87
+ '\n';
88
+ }
89
+ if (projects.length > safePageSize) {
90
+ content += `\n{gray-fg}Showing ${scrollOffset + 1}-${Math.min(scrollOffset + safePageSize, projects.length)} of ${projects.length} projects (↑↓ scroll, Esc back){/gray-fg}`;
91
+ }
92
+ else {
93
+ content += `\n{gray-fg}(↑↓ scroll, Esc back){/gray-fg}`;
94
+ }
95
+ content += `\n{gray-fg}${'─'.repeat(Math.min(totalWidth, Math.max(10, availableWidth)))}{/gray-fg}`;
96
+ box.setContent(content);
97
+ }
@@ -0,0 +1,101 @@
1
+ import { resolveProjectName } from '../lib/workspace-resolver.js';
2
+ import { formatCost, formatNumber, formatTokens, truncate } from '../lib/utils.js';
3
+ export function renderMonthly(box, data, scrollOffset = 0, selectedIndex = 0, width, note, pageSize) {
4
+ const aggByMonth = {};
5
+ for (const [date, dayData] of Object.entries(data.dailyData)) {
6
+ const month = date.slice(0, 7);
7
+ if (!month)
8
+ continue;
9
+ aggByMonth[month] ??= {
10
+ cost: 0,
11
+ tokens: 0,
12
+ requests: 0,
13
+ cacheHitTokens: 0,
14
+ cacheMissTokens: 0,
15
+ modelCost: {},
16
+ projectCost: {},
17
+ };
18
+ const monthAgg = aggByMonth[month];
19
+ for (const [projectName, models] of Object.entries(dayData ?? {})) {
20
+ for (const [modelId, stats] of Object.entries(models ?? {})) {
21
+ const s = stats;
22
+ const cost = Number(s.cost ?? 0);
23
+ const tokens = Number(s.totalTokens ?? 0);
24
+ const requests = Number(s.requests ?? 0);
25
+ const cacheHit = Number(s.cacheHitTokens ?? 0);
26
+ const cacheMiss = Number(s.cacheMissTokens ?? 0);
27
+ monthAgg.cost += cost;
28
+ monthAgg.tokens += tokens;
29
+ monthAgg.requests += requests;
30
+ monthAgg.cacheHitTokens += cacheHit;
31
+ monthAgg.cacheMissTokens += cacheMiss;
32
+ monthAgg.modelCost[modelId] = (monthAgg.modelCost[modelId] ?? 0) + cost;
33
+ monthAgg.projectCost[projectName] = (monthAgg.projectCost[projectName] ?? 0) + cost;
34
+ }
35
+ }
36
+ }
37
+ const sortedMonths = Object.keys(aggByMonth).sort().reverse();
38
+ // 根据宽度计算列宽
39
+ const availableWidth = width - 6; // padding
40
+ const monthCol = 9;
41
+ const costCol = 12;
42
+ const tokensCol = 10;
43
+ const reqCol = 10;
44
+ const fixedCols = monthCol + costCol + tokensCol + reqCol;
45
+ const remainingWidth = availableWidth - fixedCols;
46
+ const modelCol = Math.max(15, Math.min(25, Math.floor(remainingWidth * 0.4)));
47
+ const projectCol = Math.max(20, remainingWidth - modelCol);
48
+ let content = '{bold}Monthly Cost Details{/bold}\n\n';
49
+ content +=
50
+ '{underline}' +
51
+ 'Month'.padEnd(monthCol) +
52
+ '~Cost'.padStart(costCol) +
53
+ 'Tokens'.padStart(tokensCol) +
54
+ 'Requests'.padStart(reqCol) +
55
+ 'Top Model'.padStart(modelCol) +
56
+ 'Top Project'.padStart(projectCol) +
57
+ '{/underline}\n';
58
+ const safePageSize = Math.max(1, Math.floor(pageSize || 1));
59
+ const visibleMonths = sortedMonths.slice(scrollOffset, scrollOffset + safePageSize);
60
+ for (let i = 0; i < visibleMonths.length; i++) {
61
+ const month = visibleMonths[i];
62
+ const m = aggByMonth[month];
63
+ if (!m)
64
+ continue;
65
+ // top model / project by cost
66
+ let topModel = { id: '-', cost: 0 };
67
+ let topProject = { name: '-', cost: 0 };
68
+ for (const [modelId, c] of Object.entries(m.modelCost)) {
69
+ if (c > topModel.cost)
70
+ topModel = { id: modelId, cost: c };
71
+ }
72
+ for (const [projectName, c] of Object.entries(m.projectCost)) {
73
+ if (c > topProject.cost)
74
+ topProject = { name: projectName, cost: c };
75
+ }
76
+ const shortProject = resolveProjectName(topProject.name, data.workspaceMappings);
77
+ const isSelected = scrollOffset + i === selectedIndex;
78
+ const rowContent = month.padEnd(monthCol) +
79
+ formatCost(m.cost).padStart(costCol) +
80
+ formatTokens(m.tokens).padStart(tokensCol) +
81
+ formatNumber(m.requests).padStart(reqCol) +
82
+ truncate(topModel.id, modelCol - 1).padStart(modelCol) +
83
+ truncate(shortProject, projectCol - 1).padStart(projectCol);
84
+ if (isSelected) {
85
+ content += `{black-fg}{green-bg}${rowContent}{/green-bg}{/black-fg}\n`;
86
+ }
87
+ else {
88
+ content += rowContent + '\n';
89
+ }
90
+ }
91
+ if (sortedMonths.length > safePageSize) {
92
+ content += `\n{gray-fg}Showing ${scrollOffset + 1}-${Math.min(scrollOffset + safePageSize, sortedMonths.length)} of ${sortedMonths.length} months (↑↓ select, Enter detail){/gray-fg}`;
93
+ }
94
+ else {
95
+ content += `\n{gray-fg}(↑↓ select, Enter detail){/gray-fg}`;
96
+ }
97
+ if (note) {
98
+ content += `\n\n{gray-fg}备注:${note}{/gray-fg}\n`;
99
+ }
100
+ box.setContent(content);
101
+ }
@@ -0,0 +1,216 @@
1
+ import { resolveProjectName } from '../lib/workspace-resolver.js';
2
+ import { formatCost, formatNumber, formatPercent, formatTokens, truncate } from '../lib/utils.js';
3
+ function getHeatChar(cost, maxCost) {
4
+ if (cost === 0)
5
+ return '·';
6
+ const ratio = cost / maxCost;
7
+ if (ratio < 0.25)
8
+ return '░';
9
+ if (ratio < 0.5)
10
+ return '▒';
11
+ if (ratio < 0.75)
12
+ return '▓';
13
+ return '█';
14
+ }
15
+ export function renderOverview(box, data, width, height, note) {
16
+ const { dailySummary, grandTotal, topModel, topProject, cacheHitRate, activeDays } = data;
17
+ const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
18
+ const dayLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
19
+ const stripTags = (s) => s.replace(/\{[^}]+\}/g, '');
20
+ const visibleLen = (s) => stripTags(s).length;
21
+ const padEndVisible = (s, target) => {
22
+ const pad = Math.max(0, target - visibleLen(s));
23
+ return s + ' '.repeat(pad);
24
+ };
25
+ const wrapGrayNoteLines = (text, maxWidth) => {
26
+ const prefix = '{gray-fg}';
27
+ const suffix = '{/gray-fg}';
28
+ const full = `备注:${text}`;
29
+ const w = Math.max(10, Math.floor(maxWidth || 10));
30
+ const lines = [];
31
+ let i = 0;
32
+ while (i < full.length) {
33
+ const chunk = full.slice(i, i + w);
34
+ lines.push(prefix + chunk + suffix);
35
+ i += w;
36
+ }
37
+ return lines;
38
+ };
39
+ const buildHeatmapLines = (heatWidth) => {
40
+ const safeWidth = Math.max(30, Math.floor(heatWidth || 30));
41
+ // 根据宽度计算热力图周数
42
+ const availableWidth = safeWidth - 10;
43
+ const maxWeeks = Math.min(Math.floor(availableWidth / 2), 26); // 最多 26 周 (半年)
44
+ // 生成正确的日期网格 - 从今天往前推算
45
+ const today = new Date();
46
+ const todayStr = today.toISOString().split('T')[0];
47
+ // 找到最近的周六作为结束点(或今天)
48
+ const endDate = new Date(today);
49
+ // 往前推 maxWeeks 周
50
+ const startDate = new Date(endDate);
51
+ startDate.setDate(startDate.getDate() - maxWeeks * 7 + 1);
52
+ // 调整到周一开始(getDay(): 0=Sun, 1=Mon, ..., 6=Sat)
53
+ const dayOfWeekStart = startDate.getDay();
54
+ const offsetToMonday = dayOfWeekStart === 0 ? -6 : 1 - dayOfWeekStart;
55
+ startDate.setDate(startDate.getDate() + offsetToMonday);
56
+ // 构建周数组,每周从周一到周日
57
+ const weeks = [];
58
+ const currentDate = new Date(startDate);
59
+ while (currentDate <= endDate) {
60
+ const week = [];
61
+ for (let d = 0; d < 7; d++) {
62
+ const dateStr = currentDate.toISOString().split('T')[0];
63
+ week.push(dateStr);
64
+ currentDate.setDate(currentDate.getDate() + 1);
65
+ }
66
+ weeks.push(week);
67
+ }
68
+ // 以“当前热力图窗口”的最大值做归一化(避免历史极值导致近期全是浅色)
69
+ const visibleCosts = [];
70
+ for (const week of weeks) {
71
+ for (const date of week) {
72
+ if (!date || date > todayStr)
73
+ continue;
74
+ visibleCosts.push(dailySummary[date]?.cost ?? 0);
75
+ }
76
+ }
77
+ const maxCost = Math.max(...visibleCosts, 0) || 1;
78
+ // 月份标尺(在列上方标注月份变化)
79
+ const colWidth = 2; // 每周一列:字符 + 空格
80
+ const heatStartCol = 4; // 左侧周几标签宽度
81
+ const headerLen = heatStartCol + weeks.length * colWidth;
82
+ const monthHeader = Array.from({ length: headerLen }, () => ' ');
83
+ let lastMonth = -1;
84
+ let lastPlacedAt = -999;
85
+ for (let i = 0; i < weeks.length; i++) {
86
+ const week = weeks[i];
87
+ const repDate = week.find(d => d && d <= todayStr) ?? week[0];
88
+ if (!repDate)
89
+ continue;
90
+ const m = new Date(repDate).getMonth();
91
+ if (m !== lastMonth) {
92
+ const label = monthNames[m];
93
+ const pos = heatStartCol + i * colWidth;
94
+ // 避免月份标签过于拥挤/相互覆盖
95
+ if (pos - lastPlacedAt >= 4 && pos + label.length <= monthHeader.length) {
96
+ for (let k = 0; k < label.length; k++)
97
+ monthHeader[pos + k] = label[k];
98
+ lastPlacedAt = pos;
99
+ }
100
+ lastMonth = m;
101
+ }
102
+ }
103
+ const lines = [];
104
+ lines.push('{bold}Cost Heatmap{/bold}');
105
+ lines.push('');
106
+ lines.push(`{gray-fg}${monthHeader.join('').trimEnd()}{/gray-fg}`);
107
+ for (let dayOfWeek = 0; dayOfWeek < 7; dayOfWeek++) {
108
+ let row = dayLabels[dayOfWeek].padEnd(4);
109
+ for (const week of weeks) {
110
+ const date = week[dayOfWeek];
111
+ if (date && date <= todayStr && dailySummary[date]) {
112
+ row += getHeatChar(dailySummary[date].cost, maxCost) + ' ';
113
+ }
114
+ else if (date && date <= todayStr) {
115
+ row += '· '; // 有日期但无数据
116
+ }
117
+ else {
118
+ row += ' '; // 未来日期
119
+ }
120
+ }
121
+ lines.push(row.trimEnd());
122
+ }
123
+ const rangeStart = weeks[0]?.[0] ?? todayStr;
124
+ lines.push(`{gray-fg}Range: ${rangeStart} → ${todayStr}{/gray-fg}`);
125
+ lines.push(' Less {gray-fg}·░▒▓{/gray-fg}{white-fg}█{/white-fg} More');
126
+ return lines;
127
+ };
128
+ const buildSummaryLines = (summaryWidth, compact) => {
129
+ const avgDailyCost = activeDays > 0 ? grandTotal.cost / activeDays : 0;
130
+ const w = Math.max(24, Math.floor(summaryWidth || 24));
131
+ const maxW = Math.min(w, 80);
132
+ const lines = [];
133
+ lines.push('{bold}Summary{/bold}');
134
+ if (!compact)
135
+ lines.push('─'.repeat(Math.min(Math.max(10, maxW - 2), 70)));
136
+ const twoCol = maxW >= 46;
137
+ if (twoCol) {
138
+ const leftLabelW = 18;
139
+ const rightLabelW = 18;
140
+ const leftValW = 12;
141
+ const rightValW = 8;
142
+ const leftPartW = leftLabelW + leftValW + 4;
143
+ lines.push(padEndVisible(padEndVisible('{green-fg}~Total cost:{/green-fg}', leftLabelW) + formatCost(grandTotal.cost).padStart(leftValW), leftPartW) +
144
+ padEndVisible(' {green-fg}Active days:{/green-fg}', rightLabelW) +
145
+ String(activeDays).padStart(rightValW));
146
+ lines.push(padEndVisible(padEndVisible('{green-fg}Total tokens:{/green-fg}', leftLabelW) +
147
+ formatTokens(grandTotal.tokens).padStart(leftValW), leftPartW) +
148
+ padEndVisible(' {green-fg}Total requests:{/green-fg}', rightLabelW) +
149
+ formatNumber(grandTotal.requests).padStart(rightValW));
150
+ lines.push(padEndVisible(padEndVisible('{green-fg}Cache hit rate:{/green-fg}', leftLabelW) +
151
+ formatPercent(cacheHitRate).padStart(leftValW), leftPartW) +
152
+ padEndVisible(' {green-fg}Avg daily cost:{/green-fg}', rightLabelW) +
153
+ formatCost(avgDailyCost).padStart(rightValW));
154
+ }
155
+ else {
156
+ lines.push(`{green-fg}~Total cost:{/green-fg} ${formatCost(grandTotal.cost)}`);
157
+ lines.push(`{green-fg}Total tokens:{/green-fg} ${formatTokens(grandTotal.tokens)}`);
158
+ lines.push(`{green-fg}Total requests:{/green-fg} ${formatNumber(grandTotal.requests)}`);
159
+ lines.push(`{green-fg}Active days:{/green-fg} ${activeDays}`);
160
+ lines.push(`{green-fg}Cache hit rate:{/green-fg} ${formatPercent(cacheHitRate)}`);
161
+ lines.push(`{green-fg}Avg daily cost:{/green-fg} ${formatCost(avgDailyCost)}`);
162
+ }
163
+ if (!compact)
164
+ lines.push('');
165
+ if (topModel) {
166
+ const label = '{cyan-fg}Top model:{/cyan-fg} ';
167
+ const tail = `(${formatCost(topModel.cost)})`;
168
+ const maxIdLen = Math.max(4, maxW - visibleLen(label) - visibleLen(tail) - 2);
169
+ lines.push(label + truncate(topModel.id, maxIdLen) + ' ' + tail);
170
+ }
171
+ if (topProject) {
172
+ const label = '{cyan-fg}Top project:{/cyan-fg} ';
173
+ const shortName = resolveProjectName(topProject.name, data.workspaceMappings);
174
+ const tail = `(${formatCost(topProject.cost)})`;
175
+ const maxNameLen = Math.max(4, maxW - visibleLen(label) - visibleLen(tail) - 2);
176
+ lines.push(label + truncate(shortName, maxNameLen) + ' ' + tail);
177
+ }
178
+ return lines;
179
+ };
180
+ const noteLines = note ? wrapGrayNoteLines(note, Math.max(20, width - 6)) : [];
181
+ // 尝试默认:热力图在上,Summary 在下
182
+ const verticalHeat = buildHeatmapLines(width);
183
+ const verticalSummary = buildSummaryLines(width, false);
184
+ const verticalLines = [...verticalHeat, '', ...verticalSummary];
185
+ if (noteLines.length)
186
+ verticalLines.push('', ...noteLines);
187
+ if (verticalLines.length <= height) {
188
+ box.setContent(verticalLines.join('\n'));
189
+ return;
190
+ }
191
+ // 终端偏矮:尝试把 Summary 放到右侧(需要足够宽度)
192
+ const gap = 6;
193
+ const minSummaryWidth = 34;
194
+ const leftWidthBudget = Math.max(30, width - minSummaryWidth - gap);
195
+ const leftHeat = buildHeatmapLines(leftWidthBudget);
196
+ const leftVisibleWidth = Math.max(...leftHeat.map(l => visibleLen(l)), 0);
197
+ const rightWidth = Math.max(0, width - leftVisibleWidth - gap);
198
+ if (rightWidth >= minSummaryWidth) {
199
+ const rightSummary = buildSummaryLines(rightWidth, true);
200
+ const rowCount = Math.max(leftHeat.length, rightSummary.length);
201
+ const sideLines = [];
202
+ for (let i = 0; i < rowCount; i++) {
203
+ const l = leftHeat[i] ?? '';
204
+ const r = rightSummary[i] ?? '';
205
+ sideLines.push(padEndVisible(l, leftVisibleWidth) + ' '.repeat(gap) + r);
206
+ }
207
+ if (noteLines.length)
208
+ sideLines.push('', ...noteLines);
209
+ if (sideLines.length <= height) {
210
+ box.setContent(sideLines.join('\n'));
211
+ return;
212
+ }
213
+ }
214
+ // fallback:仍然输出纵向布局(可滚动)
215
+ box.setContent(verticalLines.join('\n'));
216
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebuddy-stats",
3
- "version": "1.2.12",
3
+ "version": "1.3.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "files": [