ccus-cli 0.1.0

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,749 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.summarizePeople = summarizePeople;
4
+ exports.summarizeOverall = summarizeOverall;
5
+ exports.buildAggregateDashboardHtml = buildAggregateDashboardHtml;
6
+ const time_1 = require("./time");
7
+ /** 折线 / 图例统一的调色板,保证同一个人在不同图表里颜色一致。 */
8
+ const CHART_PALETTE = ["#5eead4", "#f59e0b", "#a855f7", "#22c55e", "#f87171", "#60a5fa", "#fbbf24", "#34d399"];
9
+ /** 所有插入到 HTML 的文本字段都要先转义,避免本地页面被注入。 */
10
+ function escapeHtml(value) {
11
+ return value
12
+ .replaceAll("&", "&")
13
+ .replaceAll("<", "&lt;")
14
+ .replaceAll(">", "&gt;")
15
+ .replaceAll('"', "&quot;")
16
+ .replaceAll("'", "&#39;");
17
+ }
18
+ function maxOrNull(values) {
19
+ const numbers = values.filter((value) => value !== null);
20
+ return numbers.length > 0 ? Math.max(...numbers) : null;
21
+ }
22
+ /**
23
+ * 按 personKey 聚合 daily 行,得到每个人的“整段时间”账单。
24
+ *
25
+ * 这里只信任 daily.csv 的口径,确保数字和 aggregate 输出的 CSV 一致。
26
+ */
27
+ function summarizePeople(dailyRows) {
28
+ const grouped = new Map();
29
+ for (const row of dailyRows) {
30
+ const items = grouped.get(row.personKey);
31
+ if (items) {
32
+ items.push(row);
33
+ }
34
+ else {
35
+ grouped.set(row.personKey, [row]);
36
+ }
37
+ }
38
+ const summaries = [];
39
+ for (const [personKey, items] of grouped.entries()) {
40
+ const sortedByDate = [...items].sort((left, right) => left.date.localeCompare(right.date));
41
+ const latestRowWithUsage = [...sortedByDate]
42
+ .reverse()
43
+ .find((row) => row.fiveHourLatestUsagePct !== null);
44
+ const latestRowWithSevenDay = [...sortedByDate]
45
+ .reverse()
46
+ .find((row) => row.sevenDayLatestUsagePct !== null);
47
+ const activeDays = items.filter((row) => row.sampleCount > 0 || row.userMessageCount > 0 || row.apiRequestCount > 0).length;
48
+ summaries.push({
49
+ personKey,
50
+ userMessageCount: items.reduce((sum, row) => sum + row.userMessageCount, 0),
51
+ apiRequestCount: items.reduce((sum, row) => sum + row.apiRequestCount, 0),
52
+ inputTokens: items.reduce((sum, row) => sum + row.inputTokens, 0),
53
+ outputTokens: items.reduce((sum, row) => sum + row.outputTokens, 0),
54
+ cacheReadInputTokens: items.reduce((sum, row) => sum + row.cacheReadInputTokens, 0),
55
+ sampleCount: items.reduce((sum, row) => sum + row.sampleCount, 0),
56
+ uniqueSessions: items.reduce((sum, row) => sum + row.uniqueSessions, 0),
57
+ uniqueWorkspaces: items.reduce((sum, row) => sum + row.uniqueWorkspaces, 0),
58
+ fiveHourPeakUsagePct: maxOrNull(items.map((row) => row.fiveHourPeakUsagePct)),
59
+ fiveHourLatestUsagePct: latestRowWithUsage?.fiveHourLatestUsagePct ?? null,
60
+ sevenDayPeakUsagePct: maxOrNull(items.map((row) => row.sevenDayPeakUsagePct)),
61
+ sevenDayLatestUsagePct: latestRowWithSevenDay?.sevenDayLatestUsagePct ?? null,
62
+ activeDays,
63
+ firstDate: sortedByDate[0]?.date ?? null,
64
+ lastDate: sortedByDate.at(-1)?.date ?? null,
65
+ });
66
+ }
67
+ return summaries.sort((left, right) => {
68
+ if (right.userMessageCount !== left.userMessageCount) {
69
+ return right.userMessageCount - left.userMessageCount;
70
+ }
71
+ return left.personKey.localeCompare(right.personKey);
72
+ });
73
+ }
74
+ /** 把整体摘要算出来,方便顶部卡片直接展示。 */
75
+ function summarizeOverall(dailyRows, people) {
76
+ const dates = dailyRows.map((row) => row.date).sort((left, right) => left.localeCompare(right));
77
+ return {
78
+ personCount: people.length,
79
+ totalUserMessageCount: people.reduce((sum, person) => sum + person.userMessageCount, 0),
80
+ totalApiRequestCount: people.reduce((sum, person) => sum + person.apiRequestCount, 0),
81
+ totalInputTokens: people.reduce((sum, person) => sum + person.inputTokens, 0),
82
+ totalOutputTokens: people.reduce((sum, person) => sum + person.outputTokens, 0),
83
+ totalCacheReadInputTokens: people.reduce((sum, person) => sum + person.cacheReadInputTokens, 0),
84
+ totalSampleCount: people.reduce((sum, person) => sum + person.sampleCount, 0),
85
+ startDate: dates[0] ?? null,
86
+ endDate: dates.at(-1) ?? null,
87
+ };
88
+ }
89
+ /** 列出所有日期键,用于在按日柱状图上对齐 X 轴。 */
90
+ function collectDateAxis(dailyRows) {
91
+ const set = new Set(dailyRows.map((row) => row.date));
92
+ return [...set].sort((left, right) => left.localeCompare(right));
93
+ }
94
+ /** 按 (personKey, date) 索引 daily 行,便于在不同视图里 O(1) 拿到原始数据。 */
95
+ function indexDailyRows(dailyRows) {
96
+ const map = new Map();
97
+ for (const row of dailyRows) {
98
+ map.set(`${row.personKey}|${row.date}`, row);
99
+ }
100
+ return map;
101
+ }
102
+ function formatNumber(value) {
103
+ return new Intl.NumberFormat("en-US").format(value);
104
+ }
105
+ /** token 数统一换算成百万展示,避免长串数字铺满表格列。 */
106
+ function formatTokensM(value) {
107
+ const millions = value / 1_000_000;
108
+ return `${millions.toFixed(2)} M`;
109
+ }
110
+ function statValue(value, suffix = "%") {
111
+ return value === null ? "--" : `${value.toFixed(1)}${suffix}`;
112
+ }
113
+ /**
114
+ * 把每个人“按天用户请求数”渲染成 SVG 折线图。
115
+ *
116
+ * 让多个人的活跃度一眼可比,重点在节奏对比而不是绝对值精度。
117
+ * 这里的“用户请求数”指 transcript 里的 `type:"user"` 事件,已剔除 tool_result 工具结果回填;
118
+ * sidechain(子 agent)会话里的用户提示仍计入。
119
+ */
120
+ function renderDailyUserRequestChart(people, dailyIndex, dateAxis) {
121
+ if (people.length === 0 || dateAxis.length === 0) {
122
+ return `
123
+ <section class="panel chart-panel">
124
+ <div class="panel-header">
125
+ <div>
126
+ <p class="eyebrow">Daily User Requests</p>
127
+ <h2>每日用户请求数对比</h2>
128
+ </div>
129
+ <p class="muted">没有可绘制的日度数据。</p>
130
+ </div>
131
+ </section>
132
+ `;
133
+ }
134
+ const width = 920;
135
+ const height = 320;
136
+ const paddingX = 56;
137
+ const paddingY = 32;
138
+ const innerWidth = width - paddingX * 2;
139
+ const innerHeight = height - paddingY * 2;
140
+ const seriesData = people.map((person) => ({
141
+ person,
142
+ values: dateAxis.map((date) => dailyIndex.get(`${person.personKey}|${date}`)?.userMessageCount ?? 0),
143
+ }));
144
+ const maxValue = Math.max(1, ...seriesData.flatMap((series) => series.values));
145
+ const palette = CHART_PALETTE;
146
+ const xFor = (index) => paddingX + (index / Math.max(dateAxis.length - 1, 1)) * innerWidth;
147
+ const yFor = (value) => paddingY + (1 - value / maxValue) * innerHeight;
148
+ const ticks = [0, 0.25, 0.5, 0.75, 1].map((fraction) => {
149
+ const tickValue = Math.round(maxValue * fraction);
150
+ const y = paddingY + (1 - fraction) * innerHeight;
151
+ return `<g><line x1="${paddingX}" x2="${width - paddingX}" y1="${y}" y2="${y}" class="chart-grid" /><text x="8" y="${y + 4}" class="chart-axis">${formatNumber(tickValue)}</text></g>`;
152
+ }).join("");
153
+ const stride = Math.max(1, Math.floor(dateAxis.length / 7));
154
+ const xLabels = dateAxis
155
+ .map((date, index) => ({ date, index }))
156
+ .filter(({ index }) => index === dateAxis.length - 1 || index % stride === 0)
157
+ .map(({ date, index }) => {
158
+ const x = xFor(index);
159
+ return `<g><line x1="${x}" x2="${x}" y1="${height - paddingY}" y2="${height - paddingY + 6}" class="chart-axis-line" /><text x="${x}" y="${height - 4}" text-anchor="middle" class="chart-axis">${escapeHtml(date.slice(5))}</text></g>`;
160
+ })
161
+ .join("");
162
+ const seriesPaths = seriesData
163
+ .map((series, seriesIndex) => {
164
+ const color = palette[seriesIndex % palette.length];
165
+ const path = series.values
166
+ .map((value, index) => `${index === 0 ? "M" : "L"}${xFor(index).toFixed(2)} ${yFor(value).toFixed(2)}`)
167
+ .join(" ");
168
+ const points = series.values
169
+ .map((value, index) => `<circle cx="${xFor(index).toFixed(2)}" cy="${yFor(value).toFixed(2)}" r="3" fill="${color}"><title>${escapeHtml(series.person.personKey)} · ${escapeHtml(dateAxis[index])} · ${formatNumber(value)} 用户请求</title></circle>`)
170
+ .join("");
171
+ return `<g><path d="${path}" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />${points}</g>`;
172
+ })
173
+ .join("");
174
+ const legend = seriesData
175
+ .map((series, seriesIndex) => {
176
+ const color = palette[seriesIndex % palette.length];
177
+ return `<span class="legend-chip"><span class="legend-dot" style="background:${color}"></span>${escapeHtml(series.person.personKey)}</span>`;
178
+ })
179
+ .join("");
180
+ return `
181
+ <section class="panel chart-panel">
182
+ <div class="panel-header">
183
+ <div>
184
+ <p class="eyebrow">Daily User Requests</p>
185
+ <h2>每日用户请求数对比</h2>
186
+ </div>
187
+ <p class="muted">基于每人 daily 汇总中的 userMessageCount(已剔除 tool_result 工具回填;sidechain 子 agent 提示保留)。</p>
188
+ </div>
189
+ <div class="legend">${legend}</div>
190
+ <svg viewBox="0 0 ${width} ${height}" class="chart" role="img" aria-label="每日用户请求数对比">
191
+ ${ticks}
192
+ ${xLabels}
193
+ ${seriesPaths}
194
+ </svg>
195
+ </section>
196
+ `;
197
+ }
198
+ /**
199
+ * 用横向条形图对比每个人的「周使用量(7 天额度)峰值」。
200
+ *
201
+ * 7 天额度是 Claude 给出的滚动周额度使用率,峰值代表这段时间里每个人最接近用满周额度的程度。
202
+ * 条长按当前样本里的最大峰值做归一,方便在数值都偏小时也能横向比较;右侧仍标注绝对百分比。
203
+ */
204
+ function renderSevenDayPeakChart(people) {
205
+ const ranked = people
206
+ .filter((person) => person.sevenDayPeakUsagePct !== null)
207
+ .sort((left, right) => (right.sevenDayPeakUsagePct ?? 0) - (left.sevenDayPeakUsagePct ?? 0));
208
+ if (ranked.length === 0) {
209
+ return `
210
+ <section class="panel chart-panel">
211
+ <div class="panel-header">
212
+ <div>
213
+ <p class="eyebrow">Weekly Peak Usage</p>
214
+ <h2>周使用量峰值对比</h2>
215
+ </div>
216
+ <p class="muted">还没有 7 天额度使用率样本。</p>
217
+ </div>
218
+ </section>
219
+ `;
220
+ }
221
+ const rowHeight = 38;
222
+ const paddingTop = 18;
223
+ const paddingBottom = 18;
224
+ const labelWidth = 150;
225
+ const valueWidth = 64;
226
+ const width = 920;
227
+ const height = paddingTop + paddingBottom + ranked.length * rowHeight;
228
+ const trackX = labelWidth;
229
+ const trackWidth = width - labelWidth - valueWidth - 16;
230
+ const maxValue = Math.max(...ranked.map((person) => person.sevenDayPeakUsagePct ?? 0), 1);
231
+ const bars = ranked
232
+ .map((person, index) => {
233
+ const pct = person.sevenDayPeakUsagePct ?? 0;
234
+ const rowTop = paddingTop + index * rowHeight;
235
+ const barHeight = 18;
236
+ const barY = rowTop + (rowHeight - barHeight) / 2;
237
+ const barWidth = Math.max(2, (pct / maxValue) * trackWidth);
238
+ const textY = barY + barHeight / 2 + 4;
239
+ return `
240
+ <g>
241
+ <text x="8" y="${textY}" class="bar-label">${escapeHtml(person.personKey)}</text>
242
+ <rect x="${trackX}" y="${barY}" width="${trackWidth}" height="${barHeight}" rx="6" class="bar-track" />
243
+ <rect x="${trackX}" y="${barY}" width="${barWidth.toFixed(2)}" height="${barHeight}" rx="6" class="bar-fill" />
244
+ <text x="${trackX + trackWidth + 10}" y="${textY}" class="bar-value">${pct.toFixed(1)}%</text>
245
+ </g>`;
246
+ })
247
+ .join("");
248
+ return `
249
+ <section class="panel chart-panel">
250
+ <div class="panel-header">
251
+ <div>
252
+ <p class="eyebrow">Weekly Peak Usage</p>
253
+ <h2>周使用量峰值对比</h2>
254
+ </div>
255
+ <p class="muted">每个人 7 天额度使用率(sevenDayPeakUsagePct)的峰值,条越长越接近用满周额度。</p>
256
+ </div>
257
+ <svg viewBox="0 0 ${width} ${height}" class="chart" role="img" aria-label="周使用量峰值对比">
258
+ ${bars}
259
+ </svg>
260
+ </section>
261
+ `;
262
+ }
263
+ /** 把时间戳格式化成图表 X 轴用的本地「MM-DD HH:mm」短标签。 */
264
+ function formatTickTime(t) {
265
+ const d = new Date(t);
266
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
267
+ const dd = String(d.getDate()).padStart(2, "0");
268
+ const hh = String(d.getHours()).padStart(2, "0");
269
+ const mi = String(d.getMinutes()).padStart(2, "0");
270
+ return `${mm}-${dd} ${hh}:${mi}`;
271
+ }
272
+ /**
273
+ * 用事件级 detail 行画出 5h 额度使用率(usagePct)的详细曲线。
274
+ *
275
+ * 与按天聚合的图不同,这里直接用每条 statusline 采样的真实时间戳,粒度最细,
276
+ * 能看出每个人 5 小时额度在一天里的爬升与重置节奏。X 轴是连续时间,Y 轴是百分比。
277
+ */
278
+ function renderFiveHourUsageChart(people, detailRows) {
279
+ const series = people
280
+ .map((person, index) => ({
281
+ person,
282
+ index,
283
+ points: detailRows
284
+ .filter((row) => row.personKey === person.personKey && row.usagePct !== null)
285
+ .map((row) => ({ t: new Date(row.timestamp).getTime(), v: row.usagePct }))
286
+ .filter((point) => Number.isFinite(point.t))
287
+ .sort((left, right) => left.t - right.t),
288
+ }))
289
+ .filter((entry) => entry.points.length > 0);
290
+ const allPoints = series.flatMap((entry) => entry.points);
291
+ if (allPoints.length === 0) {
292
+ return `
293
+ <section class="panel chart-panel">
294
+ <div class="panel-header">
295
+ <div>
296
+ <p class="eyebrow">5h Usage Detail</p>
297
+ <h2>5h 使用率详细曲线</h2>
298
+ </div>
299
+ <p class="muted">还没有带 5h 使用率的 statusline 采样。</p>
300
+ </div>
301
+ </section>
302
+ `;
303
+ }
304
+ const width = 920;
305
+ const height = 320;
306
+ const paddingX = 56;
307
+ const paddingY = 32;
308
+ const innerWidth = width - paddingX * 2;
309
+ const innerHeight = height - paddingY * 2;
310
+ const minT = Math.min(...allPoints.map((point) => point.t));
311
+ const maxT = Math.max(...allPoints.map((point) => point.t));
312
+ const maxValue = Math.max(1, ...allPoints.map((point) => point.v));
313
+ const spanT = maxT - minT;
314
+ const xFor = (t) => paddingX + (spanT === 0 ? 0.5 : (t - minT) / spanT) * innerWidth;
315
+ const yFor = (value) => paddingY + (1 - value / maxValue) * innerHeight;
316
+ const ticks = [0, 0.25, 0.5, 0.75, 1]
317
+ .map((fraction) => {
318
+ const tickValue = maxValue * fraction;
319
+ const y = paddingY + (1 - fraction) * innerHeight;
320
+ return `<g><line x1="${paddingX}" x2="${width - paddingX}" y1="${y}" y2="${y}" class="chart-grid" /><text x="8" y="${y + 4}" class="chart-axis">${tickValue.toFixed(1)}%</text></g>`;
321
+ })
322
+ .join("");
323
+ const labelCount = spanT === 0 ? 1 : 5;
324
+ const xLabels = Array.from({ length: labelCount + 1 }, (_, i) => minT + (spanT * i) / labelCount)
325
+ .map((t) => {
326
+ const x = xFor(t);
327
+ return `<g><line x1="${x}" x2="${x}" y1="${height - paddingY}" y2="${height - paddingY + 6}" class="chart-axis-line" /><text x="${x}" y="${height - 4}" text-anchor="middle" class="chart-axis">${escapeHtml(formatTickTime(t))}</text></g>`;
328
+ })
329
+ .join("");
330
+ const seriesPaths = series
331
+ .map((entry) => {
332
+ const color = CHART_PALETTE[entry.index % CHART_PALETTE.length];
333
+ const path = entry.points
334
+ .map((point, pointIndex) => `${pointIndex === 0 ? "M" : "L"}${xFor(point.t).toFixed(2)} ${yFor(point.v).toFixed(2)}`)
335
+ .join(" ");
336
+ return `<g><path d="${path}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><title>${escapeHtml(entry.person.personKey)}</title></path></g>`;
337
+ })
338
+ .join("");
339
+ const legend = series
340
+ .map((entry) => {
341
+ const color = CHART_PALETTE[entry.index % CHART_PALETTE.length];
342
+ return `<span class="legend-chip"><span class="legend-dot" style="background:${color}"></span>${escapeHtml(entry.person.personKey)}</span>`;
343
+ })
344
+ .join("");
345
+ return `
346
+ <section class="panel chart-panel">
347
+ <div class="panel-header">
348
+ <div>
349
+ <p class="eyebrow">5h Usage Detail</p>
350
+ <h2>5h 使用率详细曲线</h2>
351
+ </div>
352
+ <p class="muted">每条 statusline 采样的 5 小时额度使用率(usagePct),按真实时间戳绘制。</p>
353
+ </div>
354
+ <div class="legend">${legend}</div>
355
+ <svg viewBox="0 0 ${width} ${height}" class="chart" role="img" aria-label="5h 使用率详细曲线">
356
+ ${ticks}
357
+ ${xLabels}
358
+ ${seriesPaths}
359
+ </svg>
360
+ </section>
361
+ `;
362
+ }
363
+ /** 按人渲染汇总卡片,给出请求 / 消息 / token / usage 几个核心指标。 */
364
+ function renderPeopleLeaderboard(people) {
365
+ if (people.length === 0) {
366
+ return `
367
+ <section class="panel table-panel">
368
+ <div class="panel-header">
369
+ <div>
370
+ <p class="eyebrow">People</p>
371
+ <h2>多人对比</h2>
372
+ </div>
373
+ <p class="muted">还没有匹配的导出 bundle。</p>
374
+ </div>
375
+ </section>
376
+ `;
377
+ }
378
+ const rows = people
379
+ .map((person, index) => `
380
+ <tr>
381
+ <td class="rank">${index + 1}</td>
382
+ <td><strong>${escapeHtml(person.personKey)}</strong></td>
383
+ <td>${formatNumber(person.userMessageCount)}</td>
384
+ <td>${formatTokensM(person.inputTokens)}</td>
385
+ <td>${formatTokensM(person.outputTokens)}</td>
386
+ <td>${formatTokensM(person.cacheReadInputTokens)}</td>
387
+ <td>${escapeHtml(statValue(person.fiveHourPeakUsagePct))}</td>
388
+ <td>${escapeHtml(statValue(person.fiveHourLatestUsagePct))}</td>
389
+ <td>${escapeHtml(statValue(person.sevenDayPeakUsagePct))}</td>
390
+ <td>${escapeHtml(statValue(person.sevenDayLatestUsagePct))}</td>
391
+ <td>${person.activeDays}</td>
392
+ <td class="muted-col">${formatNumber(person.apiRequestCount)}</td>
393
+ </tr>`)
394
+ .join("");
395
+ return `
396
+ <section class="panel table-panel">
397
+ <div class="panel-header">
398
+ <div>
399
+ <p class="eyebrow">People</p>
400
+ <h2>多人对比</h2>
401
+ </div>
402
+ <p class="muted">按用户消息数降序排列,所有数字直接来自 daily/weekly 汇总。</p>
403
+ </div>
404
+ <div class="table-wrap">
405
+ <table>
406
+ <thead>
407
+ <tr>
408
+ <th>#</th>
409
+ <th>personKey</th>
410
+ <th>消息</th>
411
+ <th>Input tokens</th>
412
+ <th>Output tokens</th>
413
+ <th>Cache read tokens</th>
414
+ <th>5h Peak</th>
415
+ <th>5h Latest</th>
416
+ <th>7d Peak</th>
417
+ <th>7d Latest</th>
418
+ <th>活跃天数</th>
419
+ <th class="muted-col">API 请求</th>
420
+ </tr>
421
+ </thead>
422
+ <tbody>${rows}</tbody>
423
+ </table>
424
+ </div>
425
+ </section>
426
+ `;
427
+ }
428
+ /** 把 daily 行按 personKey × date 排成一个矩阵表。 */
429
+ function renderDailyMatrix(people, dailyIndex, dateAxis) {
430
+ if (people.length === 0 || dateAxis.length === 0) {
431
+ return "";
432
+ }
433
+ const headerCells = dateAxis.map((date) => `<th>${escapeHtml(date.slice(5))}</th>`).join("");
434
+ const bodyRows = people
435
+ .map((person) => {
436
+ const cells = dateAxis
437
+ .map((date) => {
438
+ const row = dailyIndex.get(`${person.personKey}|${date}`);
439
+ const requests = row?.apiRequestCount ?? 0;
440
+ const messages = row?.userMessageCount ?? 0;
441
+ if (requests === 0 && messages === 0) {
442
+ return `<td class="muted-cell">·</td>`;
443
+ }
444
+ return `<td><span class="cell-primary">${formatNumber(messages)}</span><span class="cell-secondary">${formatNumber(requests)} req</span></td>`;
445
+ })
446
+ .join("");
447
+ return `<tr><th class="row-head">${escapeHtml(person.personKey)}</th>${cells}</tr>`;
448
+ })
449
+ .join("");
450
+ return `
451
+ <section class="panel table-panel">
452
+ <div class="panel-header">
453
+ <div>
454
+ <p class="eyebrow">Daily Matrix</p>
455
+ <h2>按天 × 人 矩阵</h2>
456
+ </div>
457
+ <p class="muted">单元格上方为用户消息数,下方为 API 请求数。</p>
458
+ </div>
459
+ <div class="table-wrap">
460
+ <table class="matrix">
461
+ <thead>
462
+ <tr>
463
+ <th class="row-head">personKey</th>
464
+ ${headerCells}
465
+ </tr>
466
+ </thead>
467
+ <tbody>${bodyRows}</tbody>
468
+ </table>
469
+ </div>
470
+ </section>
471
+ `;
472
+ }
473
+ /** 把 weekly 行也照搬出来,便于看每个人每个 ISO 周的总账。 */
474
+ function renderWeeklyTable(weeklyRows) {
475
+ if (weeklyRows.length === 0) {
476
+ return "";
477
+ }
478
+ const sorted = [...weeklyRows].sort((left, right) => {
479
+ if (left.week !== right.week) {
480
+ return left.week.localeCompare(right.week);
481
+ }
482
+ return left.personKey.localeCompare(right.personKey);
483
+ });
484
+ const rows = sorted
485
+ .map((row) => `
486
+ <tr>
487
+ <td>${escapeHtml(row.week)}</td>
488
+ <td>${escapeHtml(row.personKey)}</td>
489
+ <td>${formatNumber(row.userMessageCount)}</td>
490
+ <td>${formatTokensM(row.inputTokens)}</td>
491
+ <td>${formatTokensM(row.outputTokens)}</td>
492
+ <td>${formatTokensM(row.cacheReadInputTokens)}</td>
493
+ <td>${escapeHtml(statValue(row.fiveHourPeakUsagePct))}</td>
494
+ <td>${escapeHtml(statValue(row.fiveHourLatestUsagePct))}</td>
495
+ <td>${escapeHtml(statValue(row.sevenDayPeakUsagePct))}</td>
496
+ <td>${escapeHtml(statValue(row.sevenDayLatestUsagePct))}</td>
497
+ <td class="muted-col">${formatNumber(row.apiRequestCount)}</td>
498
+ </tr>`)
499
+ .join("");
500
+ return `
501
+ <section class="panel table-panel">
502
+ <div class="panel-header">
503
+ <div>
504
+ <p class="eyebrow">Weekly Rollup</p>
505
+ <h2>按周聚合</h2>
506
+ </div>
507
+ <p class="muted">直接来源于每个 bundle 的 weeklySummary。</p>
508
+ </div>
509
+ <div class="table-wrap">
510
+ <table>
511
+ <thead>
512
+ <tr>
513
+ <th>周起始</th>
514
+ <th>personKey</th>
515
+ <th>消息</th>
516
+ <th>Input tokens</th>
517
+ <th>Output tokens</th>
518
+ <th>Cache read tokens</th>
519
+ <th>5h Peak</th>
520
+ <th>5h Latest</th>
521
+ <th>7d Peak</th>
522
+ <th>7d Latest</th>
523
+ <th class="muted-col">API 请求</th>
524
+ </tr>
525
+ </thead>
526
+ <tbody>${rows}</tbody>
527
+ </table>
528
+ </div>
529
+ </section>
530
+ `;
531
+ }
532
+ /**
533
+ * 生成完整的多人 aggregate dashboard HTML。
534
+ *
535
+ * 这是一份单文件静态页面,所有数据已经内联,可直接打开或拷贝分享。
536
+ */
537
+ function buildAggregateDashboardHtml(detailRows, dailyRows, weeklyRows, generatedAt = new Date()) {
538
+ const people = summarizePeople(dailyRows);
539
+ const overall = summarizeOverall(dailyRows, people);
540
+ const dateAxis = collectDateAxis(dailyRows);
541
+ const dailyIndex = indexDailyRows(dailyRows);
542
+ const totalTokens = overall.totalInputTokens + overall.totalOutputTokens;
543
+ const peakUsage = maxOrNull(people.map((person) => person.fiveHourPeakUsagePct));
544
+ const peakSevenDay = maxOrNull(people.map((person) => person.sevenDayPeakUsagePct));
545
+ const rangeLabel = overall.startDate && overall.endDate ? `${overall.startDate} → ${overall.endDate}` : "--";
546
+ return `<!doctype html>
547
+ <html lang="zh-CN">
548
+ <head>
549
+ <meta charset="utf-8" />
550
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
551
+ <title>ccus team dashboard</title>
552
+ <style>
553
+ :root {
554
+ --bg: #0a0d12;
555
+ --panel: rgba(16, 21, 31, 0.84);
556
+ --panel-border: rgba(120, 141, 173, 0.18);
557
+ --text: #ecf3ff;
558
+ --muted: #91a0b8;
559
+ --accent: #5eead4;
560
+ --accent-strong: #22c55e;
561
+ --warning: #f59e0b;
562
+ --grid: rgba(145, 160, 184, 0.15);
563
+ --shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
564
+ }
565
+ * { box-sizing: border-box; }
566
+ body {
567
+ margin: 0;
568
+ font-family: Georgia, "Times New Roman", serif;
569
+ color: var(--text);
570
+ background:
571
+ radial-gradient(circle at top left, rgba(34, 197, 94, 0.18), transparent 30%),
572
+ radial-gradient(circle at top right, rgba(94, 234, 212, 0.16), transparent 28%),
573
+ linear-gradient(160deg, #06080c 0%, #0a0d12 48%, #101520 100%);
574
+ min-height: 100vh;
575
+ }
576
+ .shell {
577
+ max-width: 1240px;
578
+ margin: 0 auto;
579
+ padding: 40px 24px 64px;
580
+ }
581
+ .hero {
582
+ display: grid;
583
+ gap: 16px;
584
+ padding: 28px 0 18px;
585
+ }
586
+ .eyebrow {
587
+ margin: 0 0 8px;
588
+ color: var(--accent);
589
+ text-transform: uppercase;
590
+ letter-spacing: 0.14em;
591
+ font-size: 12px;
592
+ }
593
+ h1, h2, p { margin: 0; }
594
+ h1 {
595
+ font-size: clamp(36px, 5vw, 60px);
596
+ line-height: 0.95;
597
+ font-weight: 600;
598
+ }
599
+ .subtitle {
600
+ max-width: 820px;
601
+ color: var(--muted);
602
+ font-size: 16px;
603
+ line-height: 1.6;
604
+ }
605
+ .hero-meta {
606
+ display: flex;
607
+ flex-wrap: wrap;
608
+ gap: 12px;
609
+ color: var(--muted);
610
+ font-size: 14px;
611
+ }
612
+ .hero-chip {
613
+ padding: 8px 12px;
614
+ border-radius: 999px;
615
+ border: 1px solid var(--panel-border);
616
+ background: rgba(9, 12, 18, 0.56);
617
+ }
618
+ .stats {
619
+ display: grid;
620
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
621
+ gap: 16px;
622
+ margin: 24px 0 28px;
623
+ }
624
+ .panel {
625
+ background: var(--panel);
626
+ backdrop-filter: blur(18px);
627
+ border: 1px solid var(--panel-border);
628
+ border-radius: 24px;
629
+ box-shadow: var(--shadow);
630
+ }
631
+ .stat-card { padding: 20px; min-height: 128px; }
632
+ .stat-card h2 { font-size: 14px; color: var(--muted); font-weight: 500; }
633
+ .stat-value { margin-top: 16px; font-size: 36px; line-height: 0.95; }
634
+ .stat-note { margin-top: 12px; color: var(--muted); font-size: 13px; }
635
+ .panel-header {
636
+ display: flex;
637
+ align-items: end;
638
+ justify-content: space-between;
639
+ gap: 16px;
640
+ padding: 24px 24px 0;
641
+ }
642
+ .panel-header h2 { font-size: 28px; line-height: 1; }
643
+ .muted { color: var(--muted); font-size: 14px; }
644
+ .chart-panel { padding-bottom: 22px; margin-top: 22px; }
645
+ .chart { width: 100%; height: auto; display: block; padding: 12px 20px 6px; }
646
+ .chart-grid { stroke: var(--grid); stroke-width: 1; }
647
+ .chart-axis { fill: var(--muted); font-size: 11px; }
648
+ .chart-axis-line { stroke: var(--grid); stroke-width: 1; }
649
+ .legend {
650
+ display: flex;
651
+ flex-wrap: wrap;
652
+ gap: 10px 18px;
653
+ padding: 14px 24px 0;
654
+ color: var(--muted);
655
+ font-size: 13px;
656
+ }
657
+ .legend-chip { display: inline-flex; align-items: center; gap: 8px; }
658
+ .legend-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; }
659
+ .table-panel { margin-top: 22px; }
660
+ .table-wrap { overflow: auto; padding: 16px 20px 22px; }
661
+ table { width: 100%; border-collapse: collapse; min-width: 760px; }
662
+ th, td {
663
+ text-align: left;
664
+ padding: 12px 10px;
665
+ border-bottom: 1px solid rgba(145, 160, 184, 0.12);
666
+ vertical-align: top;
667
+ }
668
+ th {
669
+ color: var(--muted);
670
+ font-size: 12px;
671
+ text-transform: uppercase;
672
+ letter-spacing: 0.08em;
673
+ font-weight: 500;
674
+ }
675
+ td { font-size: 14px; }
676
+ td.rank { color: var(--muted); width: 32px; }
677
+ .muted-col { color: var(--muted); font-size: 12px; }
678
+ .bar-label { fill: var(--text); font-size: 13px; }
679
+ .bar-value { fill: var(--accent); font-size: 13px; font-weight: 600; }
680
+ .bar-track { fill: rgba(145, 160, 184, 0.14); }
681
+ .bar-fill { fill: var(--accent); }
682
+ table.matrix th, table.matrix td { text-align: center; padding: 8px 10px; }
683
+ table.matrix th.row-head { text-align: left; }
684
+ table.matrix .cell-primary { display: block; color: var(--text); }
685
+ table.matrix .cell-secondary { display: block; color: var(--muted); font-size: 11px; }
686
+ table.matrix .muted-cell { color: rgba(145, 160, 184, 0.45); }
687
+ @media (max-width: 720px) {
688
+ .shell { padding-inline: 16px; }
689
+ .panel-header { flex-direction: column; align-items: start; }
690
+ }
691
+ </style>
692
+ </head>
693
+ <body>
694
+ <main class="shell">
695
+ <section class="hero">
696
+ <div>
697
+ <p class="eyebrow">Claude Code · Team Surface</p>
698
+ <h1>ccus team dashboard</h1>
699
+ </div>
700
+ <p class="subtitle">把目录里所有 export bundle 聚合到一起,看团队里每个人的 Claude Code 使用节奏:消息数、API 请求数、token 用量,以及 5 小时与 7 天额度使用率。</p>
701
+ <div class="hero-meta">
702
+ <span class="hero-chip">人数:${overall.personCount}</span>
703
+ <span class="hero-chip">时间范围:${escapeHtml(rangeLabel)}</span>
704
+ <span class="hero-chip">生成时间:${escapeHtml(generatedAt.toISOString())}</span>
705
+ </div>
706
+ </section>
707
+ <section class="stats">
708
+ <article class="panel stat-card">
709
+ <h2>Total user messages</h2>
710
+ <p class="stat-value">${formatNumber(overall.totalUserMessageCount)}</p>
711
+ <p class="stat-note">所有人发给 Claude 的非 meta 消息</p>
712
+ </article>
713
+ <article class="panel stat-card">
714
+ <h2>Total tokens (in+out)</h2>
715
+ <p class="stat-value">${formatTokensM(totalTokens)}</p>
716
+ <p class="stat-note">${formatTokensM(overall.totalInputTokens)} input / ${formatTokensM(overall.totalOutputTokens)} output</p>
717
+ </article>
718
+ <article class="panel stat-card">
719
+ <h2>Cache read tokens</h2>
720
+ <p class="stat-value">${formatTokensM(overall.totalCacheReadInputTokens)}</p>
721
+ <p class="stat-note">来自 assistant usage 的 cache_read_input_tokens</p>
722
+ </article>
723
+ <article class="panel stat-card">
724
+ <h2>Total API requests</h2>
725
+ <p class="stat-value">${formatNumber(overall.totalApiRequestCount)}</p>
726
+ <p class="stat-note">所有人 API 请求数合计(次要参考)</p>
727
+ </article>
728
+ <article class="panel stat-card">
729
+ <h2>Peak 5h usage</h2>
730
+ <p class="stat-value">${escapeHtml((0, time_1.roundNumber)(peakUsage, 1) === null ? "--" : statValue((0, time_1.roundNumber)(peakUsage, 1)))}</p>
731
+ <p class="stat-note">团队内观测到的 5 小时使用率峰值</p>
732
+ </article>
733
+ <article class="panel stat-card">
734
+ <h2>Peak 7d usage</h2>
735
+ <p class="stat-value">${escapeHtml((0, time_1.roundNumber)(peakSevenDay, 1) === null ? "--" : statValue((0, time_1.roundNumber)(peakSevenDay, 1)))}</p>
736
+ <p class="stat-note">团队内观测到的 7 天额度峰值</p>
737
+ </article>
738
+ </section>
739
+ ${renderPeopleLeaderboard(people)}
740
+ ${renderSevenDayPeakChart(people)}
741
+ ${renderFiveHourUsageChart(people, detailRows)}
742
+ ${renderDailyUserRequestChart(people, dailyIndex, dateAxis)}
743
+ ${renderDailyMatrix(people, dailyIndex, dateAxis)}
744
+ ${renderWeeklyTable(weeklyRows)}
745
+ </main>
746
+ </body>
747
+ </html>`;
748
+ }
749
+ //# sourceMappingURL=aggregate-dashboard.js.map