1use std::fmt::Write as _;
2
3use crate::analysis::{OverviewResult, ProjectResult, SessionResult, TrendResult};
4use crate::pricing::calculator::PricingCalculator;
5
6const COLORS: &[&str] = &[
9 "#3b82f6", "#8b5cf6", "#06b6d4", "#22c55e", "#f59e0b", "#ef4444", "#ec4899", "#a78bfa",
10 "#2dd4bf", "#fb923c",
11];
12
13pub struct ReportData {
17 pub overview: OverviewResult,
18 pub projects: ProjectResult,
19 pub trend: TrendResult,
20}
21
22fn escape_html(s: &str) -> String {
26 s.replace('&', "&")
27 .replace('<', "<")
28 .replace('>', ">")
29 .replace('"', """)
30 .replace('\'', "'")
31}
32
33fn format_number(n: u64) -> String {
35 let s = n.to_string();
36 let mut result = String::with_capacity(s.len() + s.len() / 3);
37 for (i, ch) in s.chars().rev().enumerate() {
38 if i > 0 && i % 3 == 0 {
39 result.push(',');
40 }
41 result.push(ch);
42 }
43 result.chars().rev().collect()
44}
45
46fn format_compact(n: u64) -> String {
48 if n >= 1_000_000_000 {
49 format!("{:.2}B", n as f64 / 1_000_000_000.0)
50 } else if n >= 1_000_000 {
51 format!("{:.2}M", n as f64 / 1_000_000.0)
52 } else if n >= 10_000 {
53 format!("{:.1}K", n as f64 / 1_000.0)
54 } else {
55 format_number(n)
56 }
57}
58
59fn format_cost(c: f64) -> String {
61 let abs = c.abs();
62 let whole = abs as u64;
63 let cents = ((abs - whole as f64) * 100.0).round() as u64;
64 let sign = if c < 0.0 { "-" } else { "" };
65 format!("{}${}.{:02}", sign, format_number(whole), cents)
66}
67
68fn format_cost_int(c: f64) -> String {
70 let abs = c.abs().round() as u64;
71 let sign = if c < 0.0 { "-" } else { "" };
72 format!("{}${}", sign, format_number(abs))
73}
74
75fn color(i: usize) -> &'static str {
77 COLORS[i % COLORS.len()]
78}
79
80fn short_model(name: &str) -> String {
82 let s = name.strip_prefix("claude-").unwrap_or(name);
83 if s.len() > 9 {
85 let last_dash = s.rfind('-').unwrap_or(s.len());
86 let suffix = &s[last_dash + 1..];
87 if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_digit()) {
88 return s[..last_dash].to_string();
89 }
90 }
91 s.to_string()
92}
93
94fn format_duration(minutes: f64) -> String {
96 if minutes < 1.0 {
97 format!("{:.0}s", minutes * 60.0)
98 } else if minutes < 60.0 {
99 format!("{:.0}m", minutes)
100 } else {
101 let h = (minutes / 60.0).floor();
102 let m = (minutes % 60.0).round();
103 format!("{:.0}h{:.0}m", h, m)
104 }
105}
106
107fn css() -> &'static str {
110 r#"
111:root {
112 --bg-primary: #0a0a0b;
113 --bg-secondary: #111113;
114 --bg-tertiary: #18181b;
115 --bg-deep: #27272a;
116 --border-color: #27272a;
117 --text-primary: #fafafa;
118 --text-secondary: #a1a1aa;
119 --text-tertiary: #71717a;
120 --text-accent: #3b82f6;
121 --footer-color: #71717a;
122 --session-detail-bg: #0a0a0b;
123 --project-session-bg: #111113;
124 --compact-row-bg: #2d1b1b;
125 --agent-badge-bg: #1e3a5f;
126}
127[data-theme="light"] {
128 --bg-primary: #ffffff;
129 --bg-secondary: #fafafa;
130 --bg-tertiary: #f4f4f5;
131 --bg-deep: #e4e4e7;
132 --border-color: #e4e4e7;
133 --text-primary: #09090b;
134 --text-secondary: #52525b;
135 --text-tertiary: #a1a1aa;
136 --text-accent: #2563eb;
137 --footer-color: #a1a1aa;
138 --session-detail-bg: #ffffff;
139 --project-session-bg: #fafafa;
140 --compact-row-bg: #fff0f0;
141 --agent-badge-bg: #dbeafe;
142}
143* { box-sizing: border-box; margin: 0; padding: 0; font-variant-numeric: tabular-nums; }
144body {
145 font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
146 background: var(--bg-primary); color: var(--text-primary);
147 max-width: 1200px; margin: 0 auto; padding: 20px;
148 -webkit-font-smoothing: antialiased;
149}
150.card { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; padding: 20px 24px; box-shadow: none; }
151[data-theme="light"] .card { box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 1px 2px rgba(0,0,0,0.06); }
152.card > h2:first-child { margin-top: 0; }
153.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; margin: 16px 0; }
154.kpi-grid .card { padding: 14px 16px; }
155.kpi-value { font-size: 1.35rem; font-weight: 600; color: var(--text-primary); line-height: 1.1; }
156.kpi-label { font-size: 0.75rem; font-weight: 500; color: var(--text-tertiary); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.05em; }
157nav { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
158nav button {
159 padding: 8px 20px; border: 1px solid var(--border-color); border-radius: 6px;
160 background: transparent; color: var(--text-tertiary); cursor: pointer; font-size: 14px;
161 transition: all 0.15s ease;
162}
163nav button:hover { color: var(--text-primary); border-color: var(--text-secondary); }
164nav button.active { color: var(--text-primary); background: var(--bg-tertiary); border-color: var(--border-color); }
165.tab-content { display: none; }
166.tab-content.active { display: block; }
167h1 { color: var(--text-primary); font-size: 1.5em; font-weight: 600; margin-bottom: 16px; }
168h2 { color: var(--text-primary); font-size: 1.2em; margin: 16px 0 12px; }
169table { width: 100%; border-collapse: collapse; font-size: 13px; }
170th {
171 padding: 10px 12px; text-align: right; border-bottom: 1px solid var(--border-color);
172 font-size: 0.75rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.04em;
173 color: var(--text-tertiary); cursor: pointer; user-select: none; white-space: nowrap;
174 position: sticky; top: 0; background: var(--bg-secondary); z-index: 2;
175}
176th.text-left { text-align: left; }
177th:hover { color: var(--text-accent); }
178td { padding: 10px 12px; text-align: right; border-bottom: 1px solid var(--border-color); color: var(--text-secondary); }
179td.text-left { text-align: left; }
180tr:hover { background: var(--bg-tertiary); }
181.sort-asc::after { content: ' \25b2'; color: var(--text-accent); }
182.sort-desc::after { content: ' \25bc'; color: var(--text-accent); }
183.expandable { cursor: pointer; }
184.session-detail { background: var(--session-detail-bg); }
185.session-detail td { padding: 0; }
186.session-detail:hover { background: var(--session-detail-bg); }
187.detail-content { padding: 16px; overflow-x: auto; }
188.detail-content table { font-size: 12px; }
189.compact-row { background: var(--compact-row-bg) !important; }
190.chart-container { position: relative; height: 350px; margin: 16px 0; }
191.grid-2x2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
192.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
193.grid-2 > * { min-width: 0; overflow: hidden; }
194.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 16px 0; }
195.footer { color: var(--footer-color); font-size: 12px; margin-top: 30px; padding-top: 16px; border-top: 1px solid var(--bg-deep); }
196.header-row { display: flex; align-items: baseline; gap: 16px; margin-bottom: 16px; flex-wrap: wrap; }
197.subtitle { color: var(--text-secondary); font-size: 0.85em; }
198.expand-btn { background: none; border: none; color: var(--text-secondary); cursor: pointer; font-size: 14px; padding: 2px 6px; }
199.expand-btn:hover { color: var(--text-accent); }
200.project-session-row { background: var(--project-session-bg); }
201.project-session-row:hover { background: var(--bg-tertiary); }
202.project-row { background: var(--bg-secondary); font-weight: 600; }
203.progress-bar { display: inline-block; width: 80px; height: 14px; background: var(--bg-deep); border-radius: 7px; overflow: hidden; vertical-align: middle; }
204.progress-fill { height: 100%; border-radius: 7px; transition: width 0.15s ease; }
205.progress-text { display: inline-block; width: 45px; text-align: right; margin-left: 4px; font-size: 12px; }
206.stale-warning { color: #ef4444; margin-bottom: 8px; }
207.top-nav { display: flex; gap: 8px; margin-bottom: 12px; }
208.top-nav button { padding: 10px 24px; border: 1px solid var(--border-color); border-radius: 8px; background: transparent; color: var(--text-tertiary); cursor: pointer; font-size: 15px; font-weight: 600; transition: all 0.15s ease; }
209.top-nav button:hover { color: var(--text-primary); border-color: var(--text-secondary); }
210.top-nav button.active { color: var(--text-primary); background: var(--bg-tertiary); border-color: var(--border-color); }
211.sub-nav { display: flex; gap: 8px; margin-bottom: 16px; }
212.sub-nav button { padding: 6px 16px; border: 1px solid var(--border-color); border-radius: 6px; background: transparent; color: var(--text-tertiary); cursor: pointer; font-size: 13px; transition: all 0.15s ease; }
213.sub-nav button:hover { color: var(--text-primary); border-color: var(--text-secondary); }
214.sub-nav button.active { color: var(--text-primary); background: var(--bg-tertiary); border-color: var(--border-color); }
215.source-content { display: none; }
216.source-content.active { display: block; }
217.sub-tab-content { display: none; }
218.sub-tab-content.active { display: block; }
219.tool-tag { display: inline-block; padding: 1px 6px; margin: 1px 2px; border-radius: 4px; background: var(--bg-deep); color: var(--text-secondary); font-size: 11px; white-space: nowrap; }
220.tool-tag .tool-count { color: var(--text-accent); font-weight: 600; margin-left: 2px; }
221.session-tools-cell { max-width: 260px; line-height: 1.8; }
222.agent-badge { display: inline-block; padding: 1px 5px; border-radius: 3px; background: var(--agent-badge-bg); color: var(--text-accent); font-size: 11px; font-weight: 600; margin-left: 4px; }
223.turn-count-cell { white-space: nowrap; }
224.grid-1-2 { display: grid; grid-template-columns: 1fr 2fr; gap: 16px; }
225.chart-container-sm { position: relative; height: 250px; margin: 12px 0; }
226.model-legend { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
227.model-legend-item { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--text-secondary); }
228.model-legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
229.data-table th { cursor: default; }
230.data-table th:hover { color: var(--text-secondary); }
231.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
232.glossary { color: var(--text-secondary); font-size: 12px; margin-bottom: 16px; line-height: 1.7; padding: 12px 16px; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 8px; }
233.heatmap-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
234.heatmap-wrap canvas { display: block; }
235.theme-btn { background: none; border: 1px solid var(--border-color); border-radius: 4px; padding: 4px 10px; cursor: pointer; font-size: 16px; line-height: 1; }
236@media (max-width: 1100px) {
237 .grid-4 { grid-template-columns: repeat(2, 1fr); }
238}
239@media (max-width: 900px) {
240 .grid-2x2 { grid-template-columns: 1fr; }
241 .grid-2 { grid-template-columns: 1fr; }
242 .grid-1-2 { grid-template-columns: 1fr; }
243 .grid-4 { grid-template-columns: repeat(2, 1fr); }
244 .kpi-grid { grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); }
245}
246@media (max-width: 600px) {
247 body { padding: 12px; }
248 .grid-4 { grid-template-columns: 1fr; }
249 .kpi-grid { grid-template-columns: 1fr 1fr; }
250 .kpi-value { font-size: 1.2em; }
251 .header-row { flex-direction: column; gap: 8px; }
252 .header-row button { margin-left: 0 !important; }
253}
254"#
255}
256
257fn js_head() -> &'static str {
260 r#"
261// ── Theme init (must run before charts) ─────────────────────────────────────
262var _chartInstances = [];
263(function() {
264 var saved = localStorage.getItem('cc-theme') || 'dark';
265 document.documentElement.setAttribute('data-theme', saved);
266})();
267
268function getThemeColors() {
269 var isDark = (document.documentElement.getAttribute('data-theme') || 'dark') === 'dark';
270 return {
271 text: isDark ? '#fafafa' : '#09090b',
272 textSecondary: isDark ? '#a1a1aa' : '#52525b',
273 grid: isDark ? '#27272a' : '#e4e4e7',
274 accent: isDark ? '#3b82f6' : '#2563eb'
275 };
276}
277
278function toggleTheme() {
279 var current = document.documentElement.getAttribute('data-theme') || 'dark';
280 var next = current === 'dark' ? 'light' : 'dark';
281 document.documentElement.setAttribute('data-theme', next);
282 localStorage.setItem('cc-theme', next);
283 document.querySelectorAll('.theme-btn').forEach(function(b) {
284 b.textContent = next === 'dark' ? '\u2600\uFE0F' : '\uD83C\uDF19';
285 });
286 updateChartColors();
287 // Redraw heatmaps
288 for (var key of Object.keys(window)) {
289 if (key.startsWith('_heatmapData_')) {
290 var pfx = key.replace('_heatmapData_', '');
291 drawHeatmap('heatmap-' + pfx, window[key]);
292 }
293 }
294}
295
296function updateChartColors() {
297 var tc = getThemeColors();
298 _chartInstances.forEach(function(chart) {
299 if (!chart || !chart.options || !chart.options.scales) return;
300 var scales = chart.options.scales;
301 ['x','y','y1'].forEach(function(axis) {
302 if (scales[axis]) {
303 if (scales[axis].ticks) {
304 // Don't overwrite specially colored axes like y1 (ffd93d)
305 if (!scales[axis]._preserveColor) {
306 scales[axis].ticks.color = tc.textSecondary;
307 }
308 }
309 if (scales[axis].grid) scales[axis].grid.color = tc.grid;
310 if (scales[axis].title && scales[axis].title.color) {
311 if (!scales[axis]._preserveColor) {
312 scales[axis].title.color = tc.textSecondary;
313 }
314 }
315 }
316 });
317 if (chart.options.plugins && chart.options.plugins.legend && chart.options.plugins.legend.labels) {
318 chart.options.plugins.legend.labels.color = tc.text;
319 }
320 chart.update();
321 });
322}
323
324// Register Chart.js plugin to track instances for theme toggling
325Chart.register({
326 id: 'themeTracker',
327 afterInit: function(chart) {
328 _chartInstances.push(chart);
329 },
330 beforeDestroy: function(chart) {
331 var idx = _chartInstances.indexOf(chart);
332 if (idx >= 0) _chartInstances.splice(idx, 1);
333 }
334});
335"#
336}
337
338fn js_common() -> &'static str {
339 r#"
340function showTab(name) {
341 document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
342 document.querySelectorAll('nav button').forEach(el => el.classList.remove('active'));
343 document.getElementById('tab-' + name).classList.add('active');
344 document.querySelector('nav button[data-tab="' + name + '"]').classList.add('active');
345}
346
347function sortTable(th, tableId) {
348 const table = document.getElementById(tableId);
349 const tbody = table.querySelector('tbody');
350 const rows = Array.from(tbody.querySelectorAll('tr:not(.session-detail)'));
351 const colIndex = th.cellIndex;
352 const isAsc = th.classList.contains('sort-asc');
353
354 table.querySelectorAll('th').forEach(h => {
355 h.classList.remove('sort-asc', 'sort-desc');
356 });
357
358 th.classList.add(isAsc ? 'sort-desc' : 'sort-asc');
359
360 rows.sort((a, b) => {
361 let va = a.cells[colIndex].getAttribute('data-value') || a.cells[colIndex].textContent;
362 let vb = b.cells[colIndex].getAttribute('data-value') || b.cells[colIndex].textContent;
363 const na = parseFloat(va.replace(/[\$,%]/g, ''));
364 const nb = parseFloat(vb.replace(/[\$,%]/g, ''));
365 if (!isNaN(na) && !isNaN(nb)) {
366 return isAsc ? nb - na : na - nb;
367 }
368 return isAsc ? vb.localeCompare(va) : va.localeCompare(vb);
369 });
370
371 rows.forEach(row => {
372 const detail = row.nextElementSibling;
373 tbody.appendChild(row);
374 if (detail && detail.classList.contains('session-detail')) {
375 tbody.appendChild(detail);
376 }
377 });
378}
379
380function toggleSession(btn) {
381 const row = btn.closest('tr');
382 const detail = row.nextElementSibling;
383 if (detail && detail.classList.contains('session-detail')) {
384 const isHidden = detail.style.display === 'none';
385 detail.style.display = isHidden ? 'table-row' : 'none';
386 btn.textContent = isHidden ? '\u25bc' : '\u25b6';
387 }
388}
389
390function toggleProject(btn, projectId) {
391 const sessionRows = document.querySelectorAll('.project-session-row.project-sessions-' + projectId);
392 const detailRows = document.querySelectorAll('.session-detail.project-sessions-' + projectId);
393 const isHidden = sessionRows.length > 0 && sessionRows[0].style.display === 'none';
394
395 if (isHidden) {
396 // Expand: show session rows only (not turn details)
397 sessionRows.forEach(r => r.style.display = 'table-row');
398 } else {
399 // Collapse: hide session rows AND any open turn details
400 sessionRows.forEach(r => {
401 r.style.display = 'none';
402 const sbtn = r.querySelector('.expand-btn');
403 if (sbtn) sbtn.textContent = '\u25b6';
404 });
405 detailRows.forEach(r => r.style.display = 'none');
406 }
407 btn.textContent = isHidden ? '\u25bc' : '\u25b6';
408}
409
410// Heatmap data is already in local timezone (converted in Rust).
411// No JS-side timezone shift needed.
412
413function drawHeatmap(canvasId, data) {
414 const canvas = document.getElementById(canvasId);
415 if (!canvas) return;
416 const ctx = canvas.getContext('2d');
417 const localData = data; // already local timezone from Rust
418 const zhDays = ['周一','周二','周三','周四','周五','周六','周日'];
419 const enDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
420 const days = (currentLang === 'zh') ? zhDays : enDays;
421 const cellW = 22, cellH = 22, padL = 38, padT = 26;
422 canvas.width = padL + 24 * cellW + 10;
423 canvas.height = padT + 7 * cellH + 10;
424
425 const isDark = (document.documentElement.getAttribute('data-theme') || 'dark') === 'dark';
426 const max = Math.max(...localData.flat(), 1);
427 const labelColor = isDark ? '#a1a1aa' : '#52525b';
428
429 // Clear canvas with theme background
430 ctx.fillStyle = isDark ? '#111113' : '#fafafa';
431 ctx.fillRect(0, 0, canvas.width, canvas.height);
432
433 for (let d = 0; d < 7; d++) {
434 for (let h = 0; h < 24; h++) {
435 const val = localData[d][h];
436 const intensity = val / max;
437 let r, g, b;
438 if (isDark) {
439 // Dark theme: dark → bright blue (darker = less, brighter = more)
440 r = Math.round(13 + intensity * 75);
441 g = Math.round(17 + intensity * 130);
442 b = Math.round(34 + intensity * 221);
443 } else {
444 // Light theme: white -> blue
445 r = Math.round(235 - intensity * 195);
446 g = Math.round(238 - intensity * 158);
447 b = Math.round(245 - intensity * 27);
448 }
449 ctx.fillStyle = 'rgb(' + r + ',' + g + ',' + b + ')';
450 ctx.fillRect(padL + h * cellW, padT + d * cellH, cellW - 2, cellH - 2);
451
452 if (val > 0) {
453 if (isDark) {
454 ctx.fillStyle = intensity > 0.6 ? '#fafafa' : '#a1a1aa';
455 } else {
456 ctx.fillStyle = intensity > 0.5 ? '#ffffff' : '#09090b';
457 }
458 ctx.font = '8px sans-serif';
459 ctx.textAlign = 'center';
460 ctx.fillText(val, padL + h * cellW + cellW/2, padT + d * cellH + cellH/2 + 3);
461 }
462 }
463 ctx.fillStyle = labelColor;
464 ctx.font = '9px sans-serif';
465 ctx.textAlign = 'right';
466 ctx.fillText(days[d], padL - 4, padT + d * cellH + cellH/2 + 3);
467 }
468 ctx.fillStyle = labelColor;
469 ctx.textAlign = 'center';
470 for (let h = 0; h < 24; h += 2) {
471 ctx.fillText(h.toString().padStart(2, '0'), padL + h * cellW + cellW/2, padT - 8);
472 }
473}
474
475function sortTableSimple(th, tableId) {
476 const table = document.getElementById(tableId);
477 const tbody = table.querySelector('tbody');
478 const rows = Array.from(tbody.querySelectorAll('tr'));
479 const colIndex = th.cellIndex;
480 const isAsc = th.classList.contains('sort-asc');
481
482 table.querySelectorAll('th').forEach(h => {
483 h.classList.remove('sort-asc', 'sort-desc');
484 });
485 th.classList.add(isAsc ? 'sort-desc' : 'sort-asc');
486
487 rows.sort((a, b) => {
488 let va = a.cells[colIndex].getAttribute('data-value') || a.cells[colIndex].textContent;
489 let vb = b.cells[colIndex].getAttribute('data-value') || b.cells[colIndex].textContent;
490 const na = parseFloat(va.replace(/[\$,%]/g, ''));
491 const nb = parseFloat(vb.replace(/[\$,%]/g, ''));
492 if (!isNaN(na) && !isNaN(nb)) {
493 return isAsc ? nb - na : na - nb;
494 }
495 return isAsc ? vb.localeCompare(va) : va.localeCompare(vb);
496 });
497 rows.forEach(row => tbody.appendChild(row));
498}
499
500function switchSource(sourceId) {
501 document.querySelectorAll('.source-content').forEach(el => el.style.display = 'none');
502 document.querySelectorAll('.top-nav button').forEach(el => el.classList.remove('active'));
503 document.getElementById('source-' + sourceId).style.display = 'block';
504 event.target.classList.add('active');
505 // Redraw heatmap for this source
506 if (window['_heatmapData_' + sourceId]) {
507 drawHeatmap('heatmap-' + sourceId, window['_heatmapData_' + sourceId]);
508 }
509}
510
511function showSubTab(sourceId, tabName) {
512 const container = document.getElementById('source-' + sourceId);
513 container.querySelectorAll('.sub-tab-content').forEach(el => el.classList.remove('active'));
514 container.querySelectorAll('.sub-nav button').forEach(el => el.classList.remove('active'));
515 document.getElementById(sourceId + '-tab-' + tabName).classList.add('active');
516 event.target.classList.add('active');
517 // Redraw heatmap when overview tab becomes visible
518 if (tabName === 'overview' && window['_heatmapData_' + sourceId]) {
519 drawHeatmap('heatmap-' + sourceId, window['_heatmapData_' + sourceId]);
520 }
521}
522
523let currentLang = localStorage.getItem('cc-lang') || 'en';
524function toggleLang() {
525 currentLang = currentLang === 'en' ? 'zh' : 'en';
526 localStorage.setItem('cc-lang', currentLang);
527 applyLang();
528}
529function applyLang() {
530 document.querySelectorAll('[data-en]').forEach(el => {
531 el.textContent = el.getAttribute('data-' + currentLang) || el.getAttribute('data-en');
532 });
533 const btn = document.getElementById('lang-btn');
534 if (btn) btn.textContent = currentLang === 'en' ? '中文' : 'EN';
535 // Redraw heatmaps with localized day names
536 for (const key of Object.keys(window)) {
537 if (key.startsWith('_heatmapData_')) {
538 const pfx = key.replace('_heatmapData_', '');
539 drawHeatmap('heatmap-' + pfx, window[key]);
540 }
541 }
542}
543// Convert UTC timestamps to local timezone
544function convertTimestamps() {
545 document.querySelectorAll('[data-utc]').forEach(el => {
546 const utc = el.getAttribute('data-utc');
547 const d = new Date(utc);
548 if (!isNaN(d)) {
549 const pad = n => String(n).padStart(2, '0');
550 el.textContent = pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
551 el.title = d.toLocaleString();
552 }
553 });
554 document.querySelectorAll('[data-utc-datetime]').forEach(el => {
555 const utc = el.getAttribute('data-utc-datetime');
556 const d = new Date(utc);
557 if (!isNaN(d)) {
558 const pad = n => String(n).padStart(2, '0');
559 el.textContent = pad(d.getMonth()+1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
560 el.title = d.toLocaleString();
561 }
562 });
563}
564document.addEventListener('DOMContentLoaded', function() {
565 applyLang();
566 convertTimestamps();
567 // Init theme button text and sync chart colors with saved theme
568 var theme = document.documentElement.getAttribute('data-theme') || 'dark';
569 document.querySelectorAll('.theme-btn').forEach(function(b) {
570 b.textContent = theme === 'dark' ? '\u2600\uFE0F' : '\uD83C\uDF19';
571 });
572 // If theme was loaded as light from localStorage, update chart colors
573 if (theme === 'light') {
574 updateChartColors();
575 // Redraw heatmaps with light colors
576 for (var key of Object.keys(window)) {
577 if (key.startsWith('_heatmapData_')) {
578 var pfx = key.replace('_heatmapData_', '');
579 drawHeatmap('heatmap-' + pfx, window[key]);
580 }
581 }
582 }
583});
584"#
585}
586
587fn render_source_tabs(
592 out: &mut String,
593 pfx: &str,
594 overview: &OverviewResult,
595 projects: &ProjectResult,
596 trend: &TrendResult,
597 calc: &PricingCalculator,
598) {
599 writeln!(out, r#"<nav class="sub-nav">"#).unwrap();
601 writeln!(out, r#"<button class="active" onclick="showSubTab('{pfx}','overview')" data-en="Overview" data-zh="概览">Overview</button>"#,
602 pfx = pfx).unwrap();
603 writeln!(out, r#"<button onclick="showSubTab('{pfx}','monthly')" data-en="Monthly" data-zh="月度">Monthly</button>"#,
604 pfx = pfx).unwrap();
605 writeln!(out, r#"<button onclick="showSubTab('{pfx}','projects')" data-en="Projects" data-zh="项目">Projects</button>"#,
606 pfx = pfx).unwrap();
607 writeln!(out, "</nav>").unwrap();
608
609 writeln!(
611 out,
612 r#"<div id="{pfx}-tab-overview" class="sub-tab-content active">"#,
613 pfx = pfx
614 )
615 .unwrap();
616 render_overview_tab(out, overview, pfx);
617 writeln!(out, "</div>").unwrap();
618
619 writeln!(
621 out,
622 r#"<div id="{pfx}-tab-monthly" class="sub-tab-content">"#,
623 pfx = pfx
624 )
625 .unwrap();
626 render_monthly_tab(out, overview, trend, pfx);
627 writeln!(out, "</div>").unwrap();
628
629 writeln!(
631 out,
632 r#"<div id="{pfx}-tab-projects" class="sub-tab-content">"#,
633 pfx = pfx
634 )
635 .unwrap();
636 render_projects_tab(out, projects, &overview.session_summaries, pfx);
637 writeln!(out, "</div>").unwrap();
638
639 let _ = calc;
640}
641
642pub fn render_full_report_html(
647 overview: &OverviewResult,
648 projects: &ProjectResult,
649 trend: &TrendResult,
650 calc: &PricingCalculator,
651) -> String {
652 let mut out = String::with_capacity(256 * 1024);
653
654 write!(out, r#"<!DOCTYPE html>
656<html lang="zh-CN">
657<head>
658 <meta charset="UTF-8">
659 <meta name="viewport" content="width=device-width, initial-scale=1.0">
660 <title>Claude Code Token Analyzer</title>
661 <link rel="preconnect" href="https://fonts.googleapis.com">
662 <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
663 <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
664 <script>{js_head}</script>
665 <style>{css}</style>
666</head>
667<body>
668"#, css = css(), js_head = js_head()).unwrap();
669
670 writeln!(out, r#"<div class="header-row">"#).unwrap();
672 writeln!(out, r#"<h1>Claude Code Token Analyzer</h1>"#).unwrap();
673 if let Some((start, end)) = &overview.quality.time_range {
674 writeln!(
675 out,
676 r#"<span class="subtitle">{} ~ {}</span>"#,
677 start.format("%Y-%m-%d"),
678 end.format("%Y-%m-%d")
679 )
680 .unwrap();
681 }
682 writeln!(out, r#"<button class="theme-btn" onclick="toggleTheme()" style="margin-left:auto;">☀️</button>"#).unwrap();
683 writeln!(out, r#"<button id="lang-btn" onclick="toggleLang()" style="padding:4px 12px;border:1px solid var(--border-color);border-radius:4px;background:var(--bg-secondary);color:var(--text-primary);cursor:pointer;font-size:13px;">中文</button>"#).unwrap();
684 writeln!(out, "</div>").unwrap();
685
686 writeln!(out, r#"<div class="glossary" data-en="Glossary: Turn = one Claude response (each time you send a message or Claude calls a tool, it produces one turn). Session = one conversation from start to finish. Token = the unit Claude uses to process text (~4 chars = 1 token). Context = all tokens Claude sees per request (your message + history + cached content). Cache Hit = reusing previously processed context (saves cost)." data-zh="术语说明:Turn = 一次 Claude 响应(每次你发消息或 Claude 调用工具,都算一个 turn)。Session = 一次完整对话(从开始到结束)。Token = Claude 处理文本的单位(约 4 个英文字符 = 1 token)。Context = 每次请求 Claude 看到的全部内容(你的消息 + 历史记录 + 缓存内容)。Cache Hit = 复用之前处理过的上下文(节省费用)。">Glossary: Turn = one Claude response (each time you send a message or Claude calls a tool, it produces one turn). Session = one conversation from start to finish. Token = the unit Claude uses to process text (~4 chars = 1 token). Context = all tokens Claude sees per request (your message + history + cached content). Cache Hit = reusing previously processed context (saves cost).</div>"#).unwrap();
688
689 let pfx = "s1";
691 writeln!(
692 out,
693 r#"<div id="source-{pfx}" class="source-content active">"#,
694 pfx = pfx
695 )
696 .unwrap();
697 render_source_tabs(&mut out, pfx, overview, projects, trend, calc);
698 writeln!(out, "</div>").unwrap();
699
700 write!(out, "<script>{}</script>", js_common()).unwrap();
702
703 writeln!(out, "</body>\n</html>").unwrap();
704 out
705}
706
707pub fn render_dual_report_html(
712 source1_name: &str,
713 source1: &ReportData,
714 source2_name: &str,
715 source2: &ReportData,
716 calc: &PricingCalculator,
717) -> String {
718 let mut out = String::with_capacity(512 * 1024);
719
720 write!(out, r#"<!DOCTYPE html>
722<html lang="zh-CN">
723<head>
724 <meta charset="UTF-8">
725 <meta name="viewport" content="width=device-width, initial-scale=1.0">
726 <title>Claude Code Token Analyzer</title>
727 <link rel="preconnect" href="https://fonts.googleapis.com">
728 <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
729 <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
730 <script>{js_head}</script>
731 <style>{css}</style>
732</head>
733<body>
734"#, css = css(), js_head = js_head()).unwrap();
735
736 writeln!(out, r#"<div class="header-row">"#).unwrap();
738 writeln!(out, r#"<h1>Claude Code Token Analyzer</h1>"#).unwrap();
739 let time_range_str = {
741 let mut global_min = None;
742 let mut global_max = None;
743 for q in [&source1.overview.quality, &source2.overview.quality] {
744 if let Some((s, e)) = &q.time_range {
745 global_min =
746 Some(global_min.map_or(*s, |m: chrono::DateTime<chrono::Utc>| m.min(*s)));
747 global_max =
748 Some(global_max.map_or(*e, |m: chrono::DateTime<chrono::Utc>| m.max(*e)));
749 }
750 }
751 match (global_min, global_max) {
752 (Some(s), Some(e)) => format!("{} ~ {}", s.format("%Y-%m-%d"), e.format("%Y-%m-%d")),
753 _ => String::new(),
754 }
755 };
756 if !time_range_str.is_empty() {
757 writeln!(out, r#"<span class="subtitle">{}</span>"#, time_range_str).unwrap();
758 }
759 writeln!(out, r#"<button class="theme-btn" onclick="toggleTheme()" style="margin-left:auto;">☀️</button>"#).unwrap();
760 writeln!(out, r#"<button id="lang-btn" onclick="toggleLang()" style="padding:4px 12px;border:1px solid var(--border-color);border-radius:4px;background:var(--bg-secondary);color:var(--text-primary);cursor:pointer;font-size:13px;">中文</button>"#).unwrap();
761 writeln!(out, "</div>").unwrap();
762
763 writeln!(out, r#"<div class="glossary" data-en="Glossary: Turn = one Claude response (each time you send a message or Claude calls a tool, it produces one turn). Session = one conversation from start to finish. Token = the unit Claude uses to process text (~4 chars = 1 token). Context = all tokens Claude sees per request (your message + history + cached content). Cache Hit = reusing previously processed context (saves cost)." data-zh="术语说明:Turn = 一次 Claude 响应(每次你发消息或 Claude 调用工具,都算一个 turn)。Session = 一次完整对话(从开始到结束)。Token = Claude 处理文本的单位(约 4 个英文字符 = 1 token)。Context = 每次请求 Claude 看到的全部内容(你的消息 + 历史记录 + 缓存内容)。Cache Hit = 复用之前处理过的上下文(节省费用)。">Glossary: Turn = one Claude response (each time you send a message or Claude calls a tool, it produces one turn). Session = one conversation from start to finish. Token = the unit Claude uses to process text (~4 chars = 1 token). Context = all tokens Claude sees per request (your message + history + cached content). Cache Hit = reusing previously processed context (saves cost).</div>"#).unwrap();
765
766 let s1_sessions = source1.overview.total_sessions;
768 let s2_sessions = source2.overview.total_sessions;
769 writeln!(out, r#"<nav class="top-nav">"#).unwrap();
770 writeln!(
771 out,
772 r#"<button class="active" onclick="switchSource('s1')">{} ({} sessions)</button>"#,
773 escape_html(source1_name),
774 s1_sessions
775 )
776 .unwrap();
777 writeln!(
778 out,
779 r#"<button onclick="switchSource('s2')">{} ({} sessions)</button>"#,
780 escape_html(source2_name),
781 s2_sessions
782 )
783 .unwrap();
784 writeln!(out, "</nav>").unwrap();
785
786 writeln!(out, r#"<div id="source-s1" class="source-content active">"#).unwrap();
788 render_source_tabs(
789 &mut out,
790 "s1",
791 &source1.overview,
792 &source1.projects,
793 &source1.trend,
794 calc,
795 );
796 writeln!(out, "</div>").unwrap();
797
798 writeln!(out, r#"<div id="source-s2" class="source-content">"#).unwrap();
800 render_source_tabs(
801 &mut out,
802 "s2",
803 &source2.overview,
804 &source2.projects,
805 &source2.trend,
806 calc,
807 );
808 writeln!(out, "</div>").unwrap();
809
810 write!(out, "<script>{}</script>", js_common()).unwrap();
812
813 writeln!(out, "</body>\n</html>").unwrap();
814 out
815}
816
817fn render_overview_tab(out: &mut String, overview: &OverviewResult, pfx: &str) {
820 writeln!(out, r#"<div class="kpi-grid">"#).unwrap();
822 write_kpi_i18n(
823 out,
824 &format_number(overview.total_sessions as u64),
825 "Sessions",
826 "会话数",
827 );
828 write_kpi_i18n(
829 out,
830 &format_number(overview.total_turns as u64),
831 "Turns",
832 "响应数",
833 );
834 write_kpi_i18n(
835 out,
836 &format_compact(overview.total_output_tokens),
837 "Claude Wrote",
838 "Claude 写了",
839 );
840 write_kpi_i18n(
841 out,
842 &format_compact(overview.total_context_tokens),
843 "Claude Read",
844 "Claude 读了",
845 );
846 write_kpi_i18n(
847 out,
848 &format!("{:.1}%", overview.avg_cache_hit_rate),
849 "Avg Cache Hit Rate",
850 "平均缓存命中率",
851 );
852 write_kpi_i18n(
853 out,
854 &format_cost_int(overview.total_cost),
855 "Token Value (API Rate)",
856 "Token 价值 (API 费率)",
857 );
858 if overview.cache_savings.total_saved > 0.0 {
859 write_kpi_i18n(
860 out,
861 &format_cost_int(overview.cache_savings.total_saved),
862 &format!("Cache Savings ({:.0}%)", overview.cache_savings.savings_pct),
863 &format!("缓存节省 ({:.0}%)", overview.cache_savings.savings_pct),
864 );
865 }
866 writeln!(out, "</div>").unwrap();
867
868 {
870 let summaries = &overview.session_summaries;
871
872 let daily_avg = overview.quality.time_range.map(|(s, e)| {
874 let days = (e - s).num_days().max(1) as f64;
875 (overview.total_cost / days, days as u64)
876 });
877
878 let total_compactions: usize = summaries.iter().map(|s| s.compaction_count).sum();
880
881 let max_ctx = summaries.iter().map(|s| s.max_context).max().unwrap_or(0);
883
884 let durations: Vec<f64> = summaries
886 .iter()
887 .map(|s| s.duration_minutes)
888 .filter(|d| *d > 0.0)
889 .collect();
890 let avg_dur = if !durations.is_empty() {
891 durations.iter().sum::<f64>() / durations.len() as f64
892 } else {
893 0.0
894 };
895
896 writeln!(out, r#"<div class="grid-4">"#).unwrap();
897 if let Some((avg, days)) = daily_avg {
898 write_kpi_i18n(
899 out,
900 &format!("{}/day", format_cost_int(avg)),
901 &format!("Daily Avg ({} days)", days),
902 &format!("日均费用({} 天)", days),
903 );
904 }
905 write_kpi_i18n(out, &format_compact(max_ctx), "Peak Context", "峰值上下文");
906 write_kpi_i18n(
907 out,
908 &format_number(total_compactions as u64),
909 "Compactions",
910 "上下文压缩次数",
911 );
912 write_kpi_i18n(
913 out,
914 &format_duration(avg_dur),
915 "Avg Session",
916 "平均会话时长",
917 );
918 writeln!(out, "</div>").unwrap();
919 }
920
921 writeln!(out, r#"<div class="grid-1-2" style="margin-top:16px;">"#).unwrap();
923
924 {
926 let summaries = &overview.session_summaries;
927 let mut by_cost: Vec<&crate::analysis::SessionSummary> = summaries.iter().collect();
928 by_cost.sort_by(|a, b| {
929 b.cost
930 .partial_cmp(&a.cost)
931 .unwrap_or(std::cmp::Ordering::Equal)
932 });
933 let top5 = &by_cost[..by_cost.len().min(5)];
934 if !top5.is_empty() {
935 writeln!(out, r#"<div class="card">"#).unwrap();
936 writeln!(out, r#"<h2 data-en="Most Expensive Sessions Top 5" data-zh="最贵会话 Top 5">Most Expensive Sessions Top 5</h2>"#).unwrap();
937 writeln!(out, r#"<div class="table-wrap">"#).unwrap();
938 writeln!(
939 out,
940 r#"<table class="data-table"><thead><tr>
941 <th class="text-left" data-en="Session" data-zh="会话">Session</th>
942 <th class="text-left" data-en="Project" data-zh="项目">Project</th>
943 <th style="text-align:right;" data-en="Turns" data-zh="响应数">Turns</th>
944 <th style="text-align:right;" data-en="Cost" data-zh="费用">Cost</th>
945 </tr></thead><tbody>"#
946 )
947 .unwrap();
948 for s in top5 {
949 writeln!(out, "<tr><td class=\"text-left\">{}</td><td class=\"text-left\">{}</td><td style=\"text-align:right;\">{}</td><td style=\"text-align:right;font-weight:600;\">{}</td></tr>",
950 escape_html(&s.session_id[..s.session_id.len().min(8)]),
951 escape_html(&s.project_display_name),
952 s.turn_count,
953 format_cost(s.cost),
954 ).unwrap();
955 }
956 writeln!(out, "</tbody></table></div></div>").unwrap();
957 }
958 }
959
960 {
962 let canvas_id = format!("heatmap-{}", pfx);
963 writeln!(out, r#"<div class="card">"#).unwrap();
964 writeln!(out, r#"<h2 data-en="Activity Heatmap (Local Time)" data-zh="活跃热力图(本地时间)">Activity Heatmap (Local Time)</h2>"#).unwrap();
965 writeln!(
966 out,
967 r#"<div class="heatmap-wrap"><canvas id="{}"></canvas></div>"#,
968 canvas_id
969 )
970 .unwrap();
971
972 let mut matrix_js = String::from("[");
973 for d in 0..7 {
974 if d > 0 {
975 matrix_js.push(',');
976 }
977 matrix_js.push('[');
978 for h in 0..24 {
979 if h > 0 {
980 matrix_js.push(',');
981 }
982 write!(matrix_js, "{}", overview.weekday_hour_matrix[d][h]).unwrap();
983 }
984 matrix_js.push(']');
985 }
986 matrix_js.push(']');
987
988 writeln!(
989 out,
990 r#"<script>
991window._heatmapData_{pfx} = {matrix};
992document.addEventListener('DOMContentLoaded', function() {{
993 drawHeatmap('{canvas_id}', window._heatmapData_{pfx});
994}});
995</script>"#,
996 pfx = pfx,
997 matrix = matrix_js,
998 canvas_id = canvas_id
999 )
1000 .unwrap();
1001 writeln!(out, "</div>").unwrap();
1002 }
1003
1004 writeln!(out, "</div>").unwrap(); {
1008 let chart_id = format!("{}-scatterChart", pfx);
1009 writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
1010 writeln!(out, r#"<h2 data-en="Session Efficiency (Turns vs Cost)" data-zh="会话效率(Turns vs 费用)">Session Efficiency (Turns vs Cost)</h2>"#).unwrap();
1011 writeln!(out, r#"<p style="color:var(--text-secondary);font-size:12px;margin-bottom:8px;" data-en="Each bubble = one session. X = turns, Y = cost. Bubble size = output tokens. Top-right = expensive long sessions." data-zh="每个气泡 = 一个会话。X = turn 数,Y = 费用。气泡大小 = 输出 token。右上角 = 昂贵的长会话。">Each bubble = one session. X = turns, Y = cost. Bubble size = output tokens. Top-right = expensive long sessions.</p>"#).unwrap();
1012 writeln!(
1013 out,
1014 r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#,
1015 chart_id
1016 )
1017 .unwrap();
1018
1019 let max_output: u64 = overview
1020 .session_summaries
1021 .iter()
1022 .map(|s| s.output_tokens)
1023 .max()
1024 .unwrap_or(1);
1025 let mut scatter_data = String::from("[");
1026 for (i, s) in overview.session_summaries.iter().enumerate() {
1027 if i > 0 {
1028 scatter_data.push(',');
1029 }
1030 let radius = if max_output > 0 {
1031 3.0 + (s.output_tokens as f64 / max_output as f64) * 20.0
1032 } else {
1033 3.0
1034 };
1035 let cpt = if s.turn_count > 0 {
1036 s.cost / s.turn_count as f64
1037 } else {
1038 0.0
1039 };
1040 write!(
1041 scatter_data,
1042 "{{x:{},y:{:.4},r:{:.1},cpt:{:.4},out:{}}}",
1043 s.turn_count, s.cost, radius, cpt, s.output_tokens
1044 )
1045 .unwrap();
1046 }
1047 scatter_data.push(']');
1048
1049 writeln!(out, r#"<script>
1050new Chart(document.getElementById('{chart_id}'), {{
1051 type: 'bubble',
1052 data: {{
1053 datasets: [{{
1054 label: 'Sessions',
1055 data: {data},
1056 backgroundColor: 'rgba(59,130,246,0.4)',
1057 borderColor: '#3b82f6',
1058 borderWidth: 1
1059 }}]
1060 }},
1061 options: {{
1062 responsive: true, maintainAspectRatio: false,
1063 plugins: {{
1064 legend: {{ display: false }},
1065 tooltip: {{ callbacks: {{
1066 label: function(ctx) {{
1067 const d = ctx.raw;
1068 return ['Turns: ' + d.x + ' Cost: $' + d.y.toFixed(2), 'Cost/Turn: $' + d.cpt.toFixed(3) + ' Output: ' + d.out.toLocaleString()];
1069 }}
1070 }} }}
1071 }},
1072 scales: {{
1073 x: {{ title: {{ display: true, text: 'Turn Count', color: '#a1a1aa' }}, ticks: {{ color: '#a1a1aa' }}, grid: {{ color: '#27272a' }} }},
1074 y: {{ title: {{ display: true, text: 'Cost ($)', color: '#a1a1aa' }}, ticks: {{ color: '#a1a1aa', callback: function(v) {{ return '$' + v; }} }}, grid: {{ color: '#27272a' }} }}
1075 }}
1076 }}
1077}});
1078</script>"#, chart_id = chart_id, data = scatter_data).unwrap();
1079 writeln!(out, "</div>").unwrap();
1080 }
1081}
1082
1083fn render_monthly_tab(
1086 out: &mut String,
1087 _overview: &OverviewResult,
1088 trend: &TrendResult,
1089 pfx: &str,
1090) {
1091 if trend.entries.is_empty() {
1092 writeln!(out, r#"<div class="card"><p style="color:var(--text-secondary);">No trend data available.</p></div>"#).unwrap();
1093 return;
1094 }
1095
1096 let latest_month = trend.entries.last().map(|e| &e.label[..7]).unwrap_or("");
1098
1099 let mut month_cost = 0.0f64;
1101 let mut month_turns = 0usize;
1102 let mut month_sessions = 0usize;
1103 let mut month_output = 0u64;
1104 let mut month_input = 0u64;
1105
1106 let mut daily_entries: Vec<&crate::analysis::TrendEntry> = Vec::new();
1107
1108 for entry in &trend.entries {
1109 if entry.label.starts_with(latest_month) {
1110 month_cost += entry.cost;
1111 month_turns += entry.turn_count;
1112 month_sessions += entry.session_count;
1113 month_output += entry.tokens.output_tokens;
1114 month_input += entry.tokens.input_tokens
1115 + entry.tokens.cache_creation_tokens
1116 + entry.tokens.cache_read_tokens;
1117 daily_entries.push(entry);
1118 }
1119 }
1120
1121 let _avg_cost_per_turn = if month_turns > 0 {
1122 month_cost / month_turns as f64
1123 } else {
1124 0.0
1125 };
1126
1127 writeln!(
1129 out,
1130 r#"<h2 data-en="Current Period: {m}" data-zh="当前周期:{m}">Current Period: {m}</h2>"#,
1131 m = escape_html(latest_month)
1132 )
1133 .unwrap();
1134 writeln!(out, r#"<div class="kpi-grid">"#).unwrap();
1135 write_kpi_i18n(
1136 out,
1137 &format_number(month_sessions as u64),
1138 "Sessions",
1139 "会话数",
1140 );
1141 write_kpi_i18n(out, &format_number(month_turns as u64), "Turns", "响应数");
1142 write_kpi_i18n(
1143 out,
1144 &format_compact(month_input),
1145 "Input Tokens",
1146 "输入 Token",
1147 );
1148 write_kpi_i18n(
1149 out,
1150 &format_compact(month_output),
1151 "Output Tokens",
1152 "输出 Token",
1153 );
1154 write_kpi_i18n(out, &format_cost(month_cost), "Cost", "费用");
1155 writeln!(out, "</div>").unwrap();
1156
1157 if !daily_entries.is_empty() {
1159 let chart_id = format!("{}-dailyCostChart", pfx);
1160 writeln!(out, r#"<div class="card">"#).unwrap();
1161 writeln!(out, r#"<h2 data-en="Daily Cost & Cost/Turn ({})" data-zh="每日费用 & 每 Turn 费用 ({})">Daily Cost & Cost/Turn ({})</h2>"#,
1162 escape_html(latest_month), escape_html(latest_month), escape_html(latest_month)).unwrap();
1163 writeln!(
1164 out,
1165 r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#,
1166 chart_id
1167 )
1168 .unwrap();
1169
1170 let labels: Vec<String> = daily_entries
1171 .iter()
1172 .map(|e| format!("\"{}\"", &e.label[5..]))
1173 .collect();
1174 let cost_data: Vec<String> = daily_entries
1175 .iter()
1176 .map(|e| format!("{:.2}", e.cost))
1177 .collect();
1178 let cpt_data: Vec<String> = daily_entries
1179 .iter()
1180 .map(|e| {
1181 if e.turn_count > 0 {
1182 format!("{:.4}", e.cost / e.turn_count as f64)
1183 } else {
1184 "0".to_string()
1185 }
1186 })
1187 .collect();
1188 let turn_data: Vec<String> = daily_entries
1189 .iter()
1190 .map(|e| e.turn_count.to_string())
1191 .collect();
1192
1193 writeln!(out, r#"<script>
1194new Chart(document.getElementById('{chart_id}'), {{
1195 type: 'bar',
1196 data: {{
1197 labels: [{labels}],
1198 datasets: [
1199 {{
1200 label: 'Cost ($)',
1201 data: [{cost_data}],
1202 backgroundColor: 'rgba(59,130,246,0.6)',
1203 borderColor: '#3b82f6',
1204 borderWidth: 1,
1205 borderRadius: 4,
1206 yAxisID: 'y',
1207 order: 2
1208 }},
1209 {{
1210 label: 'Cost/Turn ($)',
1211 data: [{cpt_data}],
1212 type: 'line',
1213 borderColor: '#f59e0b',
1214 backgroundColor: 'rgba(245,158,11,0.1)',
1215 pointRadius: 3,
1216 tension: 0.3,
1217 yAxisID: 'y1',
1218 order: 1
1219 }}
1220 ]
1221 }},
1222 options: {{
1223 responsive: true, maintainAspectRatio: false,
1224 plugins: {{
1225 legend: {{ labels: {{ color: '#fafafa' }} }},
1226 tooltip: {{ callbacks: {{
1227 afterLabel: function(ctx) {{
1228 const turns = [{turn_data}];
1229 return 'Turns: ' + turns[ctx.dataIndex];
1230 }}
1231 }} }}
1232 }},
1233 scales: {{
1234 x: {{ ticks: {{ color: '#a1a1aa' }}, grid: {{ color: '#27272a' }} }},
1235 y: {{ position: 'left', ticks: {{ color: '#a1a1aa', callback: function(v) {{ return '$' + v; }} }}, grid: {{ color: '#27272a' }}, title: {{ display: true, text: 'Cost ($)', color: '#a1a1aa' }} }},
1236 y1: {{ position: 'right', ticks: {{ color: '#f59e0b', callback: function(v) {{ return '$' + v.toFixed(3); }} }}, grid: {{ drawOnChartArea: false }}, title: {{ display: true, text: 'Cost/Turn ($)', color: '#f59e0b' }} }}
1237 }}
1238 }}
1239}});
1240</script>"#, chart_id = chart_id,
1241 labels = labels.join(","),
1242 cost_data = cost_data.join(","),
1243 cpt_data = cpt_data.join(","),
1244 turn_data = turn_data.join(","),
1245 ).unwrap();
1246 writeln!(out, "</div>").unwrap();
1247 }
1248
1249 {
1251 let mut all_models: Vec<String> = Vec::new();
1253 for entry in &daily_entries {
1254 for model_name in entry.models.keys() {
1255 let short = short_model(model_name);
1256 if !all_models.contains(&short) {
1257 all_models.push(short);
1258 }
1259 }
1260 }
1261 all_models.sort();
1262
1263 if daily_entries.len() > 1 {
1264 let chart_id = format!("{}-dailyTurnsCostChart", pfx);
1265 writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
1266 writeln!(out, r#"<h2 data-en="Daily Turns & Cost" data-zh="每日响应数与费用">Daily Turns & Cost</h2>"#).unwrap();
1267 writeln!(
1268 out,
1269 r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#,
1270 chart_id
1271 )
1272 .unwrap();
1273
1274 let labels: Vec<String> = daily_entries
1275 .iter()
1276 .map(|e| format!("\"{}\"", &e.label[5..]))
1277 .collect();
1278 let turns_data: Vec<String> = daily_entries
1279 .iter()
1280 .map(|e| e.turn_count.to_string())
1281 .collect();
1282 let cost_data: Vec<String> = daily_entries
1283 .iter()
1284 .map(|e| format!("{:.2}", e.cost))
1285 .collect();
1286
1287 writeln!(out, r#"<script>
1288new Chart(document.getElementById('{chart_id}'), {{
1289 type: 'bar',
1290 data: {{
1291 labels: [{labels}],
1292 datasets: [
1293 {{label:'Turns',data:[{turns}],backgroundColor:'rgba(59,130,246,0.5)',borderColor:'#3b82f6',borderWidth:1,borderRadius:3,yAxisID:'y'}},
1294 {{label:'Cost ($)',data:[{cost}],type:'line',borderColor:'#22c55e',backgroundColor:'rgba(34,197,94,0.1)',fill:true,pointRadius:3,tension:0.3,yAxisID:'y1'}}
1295 ]
1296 }},
1297 options: {{
1298 responsive: true, maintainAspectRatio: false,
1299 plugins: {{ legend: {{ labels: {{ color: 'var(--text-secondary)' }} }} }},
1300 scales: {{
1301 x: {{ ticks: {{ color: 'var(--text-secondary)' }}, grid: {{ color: 'var(--border-color)' }} }},
1302 y: {{ position:'left', ticks: {{ color: '#3b82f6' }}, grid: {{ color: 'var(--border-color)' }}, title: {{ display:true, text:'Turns', color:'#3b82f6' }} }},
1303 y1: {{ position:'right', ticks: {{ color: '#22c55e', callback: function(v){{ return '$'+v; }} }}, grid: {{ drawOnChartArea:false }}, title: {{ display:true, text:'Cost ($)', color:'#22c55e' }} }}
1304 }}
1305 }}
1306}});
1307</script>"#, chart_id = chart_id, labels = labels.join(","), turns = turns_data.join(","), cost = cost_data.join(",")).unwrap();
1308 writeln!(out, "</div>").unwrap();
1309 }
1310 }
1311
1312 {
1314 #[allow(clippy::type_complexity)]
1316 let mut months: std::collections::BTreeMap<
1317 String,
1318 (usize, usize, u64, u64, u64, f64, u64),
1319 > = std::collections::BTreeMap::new();
1320 for entry in &trend.entries {
1321 let month_key = entry.label[..7].to_string();
1322 let e = months.entry(month_key).or_insert((0, 0, 0, 0, 0, 0.0, 0));
1323 e.0 += entry.session_count;
1324 e.1 += entry.turn_count;
1325 e.2 += entry.tokens.output_tokens;
1326 e.3 += entry.tokens.cache_creation_tokens;
1327 e.4 += entry.tokens.cache_read_tokens;
1328 e.5 += entry.cost;
1329 e.6 += entry.tokens.input_tokens
1330 + entry.tokens.cache_creation_tokens
1331 + entry.tokens.cache_read_tokens;
1332 }
1333
1334 if months.len() > 1 {
1335 let tbl_id = format!("{}-tbl-monthly", pfx);
1336 writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
1337 writeln!(
1338 out,
1339 r#"<h2 data-en="Monthly Summary" data-zh="月度汇总">Monthly Summary</h2>"#
1340 )
1341 .unwrap();
1342 writeln!(out, r#"<div class="table-wrap">"#).unwrap();
1343 writeln!(out, r#"<table id="{}">"#, tbl_id).unwrap();
1344 writeln!(out, "<thead><tr>\
1345 <th class=\"text-left\" onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Month\" data-zh=\"月份\">Month</th>\
1346 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Sessions\" data-zh=\"会话\">Sessions</th>\
1347 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Turns\" data-zh=\"响应数\">Turns</th>\
1348 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Input Tokens\" data-zh=\"输入 Token\">Input Tokens</th>\
1349 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Output Tokens\" data-zh=\"输出 Token\">Output Tokens</th>\
1350 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Cost\" data-zh=\"费用\">Cost</th>\
1351 </tr></thead>", id = tbl_id).unwrap();
1352 writeln!(out, "<tbody>").unwrap();
1353
1354 for (month, (sessions, turns, output, _cache_write, _cache_read, cost, input_ctx)) in
1355 &months
1356 {
1357 writeln!(
1358 out,
1359 "<tr>\
1360 <td class=\"text-left\" data-value=\"{}\">{}</td>\
1361 <td data-value=\"{}\">{}</td>\
1362 <td data-value=\"{}\">{}</td>\
1363 <td data-value=\"{}\">{}</td>\
1364 <td data-value=\"{}\">{}</td>\
1365 <td data-value=\"{:.4}\">{}</td>\
1366 </tr>",
1367 escape_html(month),
1368 escape_html(month),
1369 sessions,
1370 format_number(*sessions as u64),
1371 turns,
1372 format_number(*turns as u64),
1373 input_ctx,
1374 format_compact(*input_ctx),
1375 output,
1376 format_compact(*output),
1377 cost,
1378 format_cost(*cost),
1379 )
1380 .unwrap();
1381 }
1382
1383 writeln!(out, "</tbody></table></div></div>").unwrap();
1384 }
1385 }
1386
1387 {
1389 let tbl_id = format!("{}-tbl-daily", pfx);
1390 let group_zh = match trend.group_label.as_str() {
1391 "Day" => "每日",
1392 "Week" => "每周",
1393 "Month" => "每月",
1394 other => other,
1395 };
1396 let group_col_zh = match trend.group_label.as_str() {
1397 "Day" => "日期",
1398 "Week" => "周",
1399 "Month" => "月份",
1400 other => other,
1401 };
1402 writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
1403 writeln!(
1404 out,
1405 r#"<h2 data-en="{} Breakdown" data-zh="{}明细">{} Breakdown</h2>"#,
1406 escape_html(&trend.group_label),
1407 escape_html(group_zh),
1408 escape_html(&trend.group_label)
1409 )
1410 .unwrap();
1411 writeln!(out, r#"<div class="table-wrap">"#).unwrap();
1412 writeln!(out, r#"<table id="{}">"#, tbl_id).unwrap();
1413 writeln!(out, "<thead><tr>\
1414 <th class=\"text-left\" onclick=\"sortTableSimple(this,'{id}')\" data-en=\"{en}\" data-zh=\"{zh}\">{en}</th>\
1415 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Sessions\" data-zh=\"会话\">Sessions</th>\
1416 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Turns\" data-zh=\"响应数\">Turns</th>\
1417 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Input Tokens\" data-zh=\"输入 Token\">Input Tokens</th>\
1418 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Output Tokens\" data-zh=\"输出 Token\">Output Tokens</th>\
1419 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Cost\" data-zh=\"费用\">Cost</th>\
1420 <th class=\"text-left\" data-en=\"Models\" data-zh=\"模型\">Models</th>\
1421 </tr></thead>", en = escape_html(&trend.group_label), zh = escape_html(group_col_zh), id = tbl_id).unwrap();
1422 writeln!(out, "<tbody>").unwrap();
1423
1424 for entry in &trend.entries {
1425 let input_tokens = entry.tokens.input_tokens
1426 + entry.tokens.cache_creation_tokens
1427 + entry.tokens.cache_read_tokens;
1428 let mut model_list: Vec<(&String, &u64)> = entry.models.iter().collect();
1430 model_list.sort_by(|a, b| b.1.cmp(a.1));
1431 let models_html: String = model_list
1432 .iter()
1433 .take(3)
1434 .map(|(m, tokens)| {
1435 format!(
1436 "<span class=\"tool-tag\">{} <span class=\"tool-count\">{}</span></span>",
1437 escape_html(&short_model(m)),
1438 format_compact(**tokens)
1439 )
1440 })
1441 .collect::<Vec<_>>()
1442 .join("");
1443
1444 writeln!(
1445 out,
1446 "<tr>\
1447 <td class=\"text-left\" data-value=\"{}\">{}</td>\
1448 <td data-value=\"{}\">{}</td>\
1449 <td data-value=\"{}\">{}</td>\
1450 <td data-value=\"{}\">{}</td>\
1451 <td data-value=\"{}\">{}</td>\
1452 <td data-value=\"{:.4}\">{}</td>\
1453 <td class=\"text-left\">{}</td>\
1454 </tr>",
1455 escape_html(&entry.label),
1456 escape_html(&entry.label),
1457 entry.session_count,
1458 format_number(entry.session_count as u64),
1459 entry.turn_count,
1460 format_number(entry.turn_count as u64),
1461 input_tokens,
1462 format_compact(input_tokens),
1463 entry.tokens.output_tokens,
1464 format_compact(entry.tokens.output_tokens),
1465 entry.cost,
1466 format_cost(entry.cost),
1467 models_html,
1468 )
1469 .unwrap();
1470 }
1471
1472 writeln!(out, "</tbody></table></div></div>").unwrap();
1473 }
1474}
1475
1476fn render_projects_tab(
1479 out: &mut String,
1480 projects: &ProjectResult,
1481 sessions: &[crate::analysis::SessionSummary],
1482 pfx: &str,
1483) {
1484 {
1486 let top_n = projects.projects.iter().take(10).collect::<Vec<_>>();
1487 if !top_n.is_empty() {
1488 let chart_id = format!("{}-projectCostChart", pfx);
1489 writeln!(out, r#"<div class="card">"#).unwrap();
1490 writeln!(out, r#"<h2 data-en="Project Cost Top 10" data-zh="项目费用 Top 10">Project Cost Top 10</h2>"#).unwrap();
1491 writeln!(
1492 out,
1493 r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#,
1494 chart_id
1495 )
1496 .unwrap();
1497
1498 let labels: Vec<String> = top_n
1499 .iter()
1500 .map(|p| format!("\"{}\"", escape_html(&p.display_name)))
1501 .collect();
1502 let data: Vec<String> = top_n.iter().map(|p| format!("{:.2}", p.cost)).collect();
1503 let colors_list: Vec<String> = (0..top_n.len())
1504 .map(|i| format!("\"{}\"", color(i)))
1505 .collect();
1506
1507 writeln!(out, r#"<script>
1508new Chart(document.getElementById('{chart_id}'), {{
1509 type: 'bar',
1510 data: {{
1511 labels: [{labels}],
1512 datasets: [{{ label: 'Cost ($)', data: [{data}], backgroundColor: [{colors}], borderWidth: 0, borderRadius: 4 }}]
1513 }},
1514 options: {{
1515 indexAxis: 'y', responsive: true, maintainAspectRatio: false,
1516 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx) {{ return '$' + ctx.raw.toFixed(2); }} }} }} }},
1517 scales: {{
1518 x: {{ ticks: {{ color: '#a1a1aa', callback: function(v) {{ return '$' + v; }} }}, grid: {{ color: '#27272a' }} }},
1519 y: {{ ticks: {{ color: '#fafafa' }}, grid: {{ color: '#27272a' }} }}
1520 }}
1521 }}
1522}});
1523</script>"#, chart_id = chart_id, labels = labels.join(","), data = data.join(","), colors = colors_list.join(",")).unwrap();
1524 writeln!(out, "</div>").unwrap();
1525 }
1526 }
1527
1528 let tbl_id = format!("{}-tbl-projects-drill", pfx);
1530 writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
1531 writeln!(
1532 out,
1533 r#"<h2 data-en="Project Drill-Down" data-zh="项目钻取">Project Drill-Down</h2>"#
1534 )
1535 .unwrap();
1536 writeln!(out, r#"<div class="table-wrap">"#).unwrap();
1537 writeln!(out, r#"<table id="{}">"#, tbl_id).unwrap();
1538 writeln!(out, "<thead><tr>\
1539 <th class=\"text-left\"></th>\
1540 <th class=\"text-left\" data-en=\"Project / Session\" data-zh=\"项目 / 会话\">Project / Session</th>\
1541 <th data-en=\"Sessions\" data-zh=\"会话\">Sessions</th>\
1542 <th data-en=\"Turns (Agent)\" data-zh=\"响应数 (Agent)\">Turns (Agent)</th>\
1543 <th data-en=\"Output\" data-zh=\"输出\">Output</th>\
1544 <th data-en=\"CacheHit\" data-zh=\"缓存命中率\">CacheHit</th>\
1545 <th class=\"text-left\" data-en=\"Tools\" data-zh=\"工具\">Tools</th>\
1546 <th data-en=\"Cost\" data-zh=\"费用\">Cost</th>\
1547 </tr></thead>").unwrap();
1548 writeln!(out, "<tbody>").unwrap();
1549
1550 let mut sessions_by_project: std::collections::HashMap<
1552 String,
1553 Vec<&crate::analysis::SessionSummary>,
1554 > = std::collections::HashMap::new();
1555 for s in sessions {
1556 sessions_by_project
1557 .entry(s.project_display_name.clone())
1558 .or_default()
1559 .push(s);
1560 }
1561
1562 for (i, proj) in projects.projects.iter().enumerate() {
1563 let cache_hit = if proj.tokens.context_tokens() > 0 {
1564 proj.tokens.cache_read_tokens as f64 / proj.tokens.context_tokens() as f64 * 100.0
1565 } else {
1566 0.0
1567 };
1568 let pid = format!("{}-p{}", pfx, i);
1569
1570 let hit_bar = html_progress(cache_hit);
1572 let turns_display = if proj.agent_turns > 0 {
1573 format!(
1574 "{} <span class=\"agent-badge\">+{} agent</span>",
1575 format_number(proj.total_turns as u64),
1576 proj.agent_turns
1577 )
1578 } else {
1579 format_number(proj.total_turns as u64)
1580 };
1581 writeln!(out, r#"<tr class="project-row expandable">"#).unwrap();
1582 writeln!(out, r#"<td class="text-left"><button class="expand-btn" onclick="toggleProject(this,'{pid}')">{arrow}</button></td>"#,
1583 pid = pid, arrow = "\u{25b6}").unwrap();
1584 writeln!(
1585 out,
1586 "\
1587 <td class=\"text-left\"><strong>{name}</strong></td>\
1588 <td data-value=\"{sess}\">{sess_fmt}</td>\
1589 <td class=\"turn-count-cell\" data-value=\"{turns}\">{turns_display}</td>\
1590 <td data-value=\"{out}\">{out_fmt}</td>\
1591 <td data-value=\"{hit:.1}\">{hit_bar}</td>\
1592 <td class=\"text-left\"></td>\
1593 <td data-value=\"{cost:.4}\">{cost_fmt}</td>",
1594 name = escape_html(&proj.display_name),
1595 sess = proj.session_count,
1596 sess_fmt = format_number(proj.session_count as u64),
1597 turns = proj.total_turns,
1598 turns_display = turns_display,
1599 out = proj.tokens.output_tokens,
1600 out_fmt = format_compact(proj.tokens.output_tokens),
1601 hit = cache_hit,
1602 hit_bar = hit_bar,
1603 cost = proj.cost,
1604 cost_fmt = format_cost(proj.cost),
1605 )
1606 .unwrap();
1607 writeln!(out, "</tr>").unwrap();
1608
1609 if let Some(proj_sessions) = sessions_by_project.get(&proj.display_name) {
1611 let mut sorted = proj_sessions.clone();
1612 sorted.sort_by(|a, b| {
1613 b.cost
1614 .partial_cmp(&a.cost)
1615 .unwrap_or(std::cmp::Ordering::Equal)
1616 });
1617
1618 for s in sorted.iter().filter(|s| s.turn_count > 0) {
1619 let utc_iso = s
1620 .first_timestamp
1621 .map(|t| t.to_rfc3339())
1622 .unwrap_or_default();
1623 let date_fallback = s
1624 .first_timestamp
1625 .map(|t| t.format("%m-%d %H:%M").to_string())
1626 .unwrap_or_default();
1627 let s_hit = html_progress(s.cache_hit_rate);
1628
1629 writeln!(out, r#"<tr class="project-session-row project-sessions-{pid} expandable" style="display:none">"#,
1631 pid = pid).unwrap();
1632
1633 let has_detail = s.turn_details.is_some();
1634 if has_detail {
1635 writeln!(out, r#"<td class="text-left"><button class="expand-btn" onclick="toggleSession(this)">{}</button></td>"#, "\u{25b6}").unwrap();
1636 } else {
1637 writeln!(out, r#"<td class="text-left"></td>"#).unwrap();
1638 }
1639
1640 let s_turns_display = if s.agent_turn_count > 0 {
1642 format!(
1643 "{} <span class=\"agent-badge\">+{} agent</span>",
1644 format_number(s.turn_count as u64),
1645 s.agent_turn_count
1646 )
1647 } else {
1648 format_number(s.turn_count as u64)
1649 };
1650
1651 let tools_html: String = s.top_tools.iter().take(5).map(|(name, count)| {
1653 format!("<span class=\"tool-tag\">{} <span class=\"tool-count\">{}</span></span>",
1654 escape_html(name), count)
1655 }).collect::<Vec<_>>().join("");
1656
1657 let duration_str = format_duration(s.duration_minutes);
1658 let short_sid = &s.session_id[..s.session_id.len().min(10)];
1659
1660 writeln!(out, "\
1661 <td class=\"text-left\" style=\"padding-left:30px;\">{sid} <span style=\"color:var(--text-tertiary);font-size:11px;\">(<span data-utc-datetime=\"{utc}\">{date}</span> · {dur})</span></td>\
1662 <td></td>\
1663 <td class=\"turn-count-cell\" data-value=\"{turns}\">{turns_display}</td>\
1664 <td data-value=\"{out}\">{out_fmt}</td>\
1665 <td data-value=\"{hit:.1}\">{hit_bar}</td>\
1666 <td class=\"text-left session-tools-cell\">{tools}</td>\
1667 <td data-value=\"{cost:.4}\">{cost_fmt}</td>",
1668 sid = escape_html(short_sid),
1669 utc = utc_iso,
1670 date = date_fallback,
1671 dur = duration_str,
1672 turns = s.turn_count, turns_display = s_turns_display,
1673 out = s.output_tokens, out_fmt = format_compact(s.output_tokens),
1674 hit = s.cache_hit_rate, hit_bar = s_hit,
1675 tools = tools_html,
1676 cost = s.cost, cost_fmt = format_cost(s.cost),
1677 ).unwrap();
1678 writeln!(out, "</tr>").unwrap();
1679
1680 if let Some(ref details) = s.turn_details {
1682 writeln!(out, r#"<tr class="session-detail project-sessions-{pid}" style="display:none"><td colspan="8"><div class="detail-content">"#,
1683 pid = pid).unwrap();
1684 render_turn_detail_table(
1685 out,
1686 details,
1687 &format!("{}-detail-proj-{}", pfx, escape_html(&s.session_id)),
1688 );
1689 writeln!(out, "</div></td></tr>").unwrap();
1690 }
1691 }
1692 }
1693 }
1694
1695 writeln!(out, "</tbody></table></div></div>").unwrap();
1696}
1697
1698fn render_turn_detail_table(
1701 out: &mut String,
1702 turns: &[crate::analysis::TurnDetail],
1703 table_id: &str,
1704) {
1705 render_turn_table_impl(out, turns, table_id);
1706}
1707
1708fn write_kpi(out: &mut String, value: &str, label: &str) {
1711 writeln!(out, r#"<div class="card" style="text-align:center;"><div class="kpi-value">{}</div><div class="kpi-label">{}</div></div>"#,
1712 value, label).unwrap();
1713}
1714
1715fn write_kpi_i18n(out: &mut String, value: &str, en: &str, zh: &str) {
1717 writeln!(out, r#"<div class="card" style="text-align:center;"><div class="kpi-value">{}</div><div class="kpi-label" data-en="{}" data-zh="{}">{}</div></div>"#,
1718 value, en, zh, en).unwrap();
1719}
1720
1721fn html_progress(pct: f64) -> String {
1723 let bar_color = if pct >= 90.0 {
1724 "#22c55e"
1725 } else if pct >= 70.0 {
1726 "#f59e0b"
1727 } else {
1728 "#ef4444"
1729 };
1730 format!(
1731 r#"<div class="progress-bar"><div class="progress-fill" style="width:{:.1}%;background:{};"></div></div><span class="progress-text">{:.1}%</span>"#,
1732 pct, bar_color, pct
1733 )
1734}
1735
1736pub fn render_session_html(result: &SessionResult) -> String {
1740 let mut out = String::with_capacity(64 * 1024);
1741
1742 let short_id = &result.session_id[..result.session_id.len().min(12)];
1743
1744 write!(out, r#"<!DOCTYPE html>
1746<html lang="zh-CN">
1747<head>
1748 <meta charset="UTF-8">
1749 <meta name="viewport" content="width=device-width, initial-scale=1.0">
1750 <title>Session {short_id} - Claude Code Token Analyzer</title>
1751 <link rel="preconnect" href="https://fonts.googleapis.com">
1752 <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
1753 <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
1754 <script>{js_head}</script>
1755 <style>{css}</style>
1756</head>
1757<body>
1758"#, short_id = escape_html(short_id), css = css(), js_head = js_head()).unwrap();
1759
1760 writeln!(out, r#"<div class="header-row">"#).unwrap();
1762 writeln!(out, "<h1>Session Analysis</h1>").unwrap();
1763 writeln!(out, r#"<button class="theme-btn" onclick="toggleTheme()" style="margin-left:auto;">☀️</button>"#).unwrap();
1764 writeln!(
1765 out,
1766 r#"<span class="subtitle">{} · {}</span>"#,
1767 escape_html(&result.session_id),
1768 escape_html(&result.project)
1769 )
1770 .unwrap();
1771 writeln!(out, "</div>").unwrap();
1772
1773 let cache_hit_rate = {
1775 let total_ctx = result.total_tokens.context_tokens();
1776 if total_ctx > 0 {
1777 result.total_tokens.cache_read_tokens as f64 / total_ctx as f64 * 100.0
1778 } else {
1779 0.0
1780 }
1781 };
1782
1783 writeln!(out, r#"<div class="kpi-grid">"#).unwrap();
1784 write_kpi(
1785 &mut out,
1786 &format_duration(result.duration_minutes),
1787 "Duration",
1788 );
1789 write_kpi(&mut out, &short_model(&result.model), "Model");
1790 write_kpi(&mut out, &format_number(result.max_context), "Max Context");
1791 write_kpi(
1792 &mut out,
1793 &format!("{:.1}%", cache_hit_rate),
1794 "Cache Hit Rate",
1795 );
1796 write_kpi(
1797 &mut out,
1798 &format_number(result.compaction_count as u64),
1799 "Compactions",
1800 );
1801 write_kpi(&mut out, &format_cost(result.total_cost), "Total Cost");
1802 writeln!(out, "</div>").unwrap();
1803
1804 if !result.turn_details.is_empty() {
1806 writeln!(out, r#"<div class="grid-2">"#).unwrap();
1807
1808 {
1810 writeln!(out, r#"<div class="card">"#).unwrap();
1811 writeln!(out, "<h2>Context Growth</h2>").unwrap();
1812 writeln!(
1813 out,
1814 r#"<div class="chart-container"><canvas id="contextChart"></canvas></div>"#
1815 )
1816 .unwrap();
1817
1818 let turn_nums: Vec<String> = result
1819 .turn_details
1820 .iter()
1821 .map(|t| t.turn_number.to_string())
1822 .collect();
1823 let ctx_sizes: Vec<String> = result
1824 .turn_details
1825 .iter()
1826 .map(|t| t.context_size.to_string())
1827 .collect();
1828 let pr = if result.turn_details.len() > 50 { 0 } else { 3 };
1829
1830 writeln!(out, r#"<script>
1831new Chart(document.getElementById('contextChart'), {{
1832 type: 'line',
1833 data: {{
1834 labels: [{turns}],
1835 datasets: [{{
1836 label: 'Context Size',
1837 data: [{sizes}],
1838 borderColor: '#3b82f6',
1839 backgroundColor: 'rgba(59,130,246,0.1)',
1840 fill: true, tension: 0.3, pointRadius: {pr}
1841 }}]
1842 }},
1843 options: {{
1844 responsive: true, maintainAspectRatio: false,
1845 plugins: {{ legend: {{ labels: {{ color: '#fafafa' }} }} }},
1846 scales: {{
1847 x: {{ title: {{ display: true, text: 'Turn', color: '#a1a1aa' }}, ticks: {{ color: '#a1a1aa' }}, grid: {{ color: '#27272a' }} }},
1848 y: {{ title: {{ display: true, text: 'Context Tokens', color: '#a1a1aa' }}, ticks: {{ color: '#a1a1aa' }}, grid: {{ color: '#27272a' }} }}
1849 }}
1850 }}
1851}});
1852</script>"#,
1853 turns = turn_nums.join(","),
1854 sizes = ctx_sizes.join(","),
1855 pr = pr,
1856 ).unwrap();
1857 writeln!(out, "</div>").unwrap();
1858 }
1859
1860 {
1862 writeln!(out, r#"<div class="card">"#).unwrap();
1863 writeln!(out, "<h2>Cache Hit Rate</h2>").unwrap();
1864 writeln!(
1865 out,
1866 r#"<div class="chart-container"><canvas id="cacheChart"></canvas></div>"#
1867 )
1868 .unwrap();
1869
1870 let turn_nums: Vec<String> = result
1871 .turn_details
1872 .iter()
1873 .map(|t| t.turn_number.to_string())
1874 .collect();
1875 let cache_rates: Vec<String> = result
1876 .turn_details
1877 .iter()
1878 .map(|t| format!("{:.2}", t.cache_hit_rate))
1879 .collect();
1880 let pr = if result.turn_details.len() > 50 { 0 } else { 3 };
1881
1882 writeln!(out, r#"<script>
1883new Chart(document.getElementById('cacheChart'), {{
1884 type: 'line',
1885 data: {{
1886 labels: [{turns}],
1887 datasets: [{{
1888 label: 'Cache Hit Rate (%)',
1889 data: [{rates}],
1890 borderColor: '#f59e0b',
1891 backgroundColor: 'rgba(245,158,11,0.1)',
1892 fill: true, tension: 0.3, pointRadius: {pr}
1893 }}]
1894 }},
1895 options: {{
1896 responsive: true, maintainAspectRatio: false,
1897 plugins: {{ legend: {{ labels: {{ color: '#fafafa' }} }} }},
1898 scales: {{
1899 x: {{ title: {{ display: true, text: 'Turn', color: '#a1a1aa' }}, ticks: {{ color: '#a1a1aa' }}, grid: {{ color: '#27272a' }} }},
1900 y: {{ title: {{ display: true, text: 'Hit Rate (%)', color: '#a1a1aa' }}, ticks: {{ color: '#a1a1aa' }}, grid: {{ color: '#27272a' }}, min: 0, max: 100 }}
1901 }}
1902 }}
1903}});
1904</script>"#,
1905 turns = turn_nums.join(","),
1906 rates = cache_rates.join(","),
1907 pr = pr,
1908 ).unwrap();
1909 writeln!(out, "</div>").unwrap();
1910 }
1911
1912 writeln!(out, "</div>").unwrap(); }
1914
1915 if !result.stop_reason_counts.is_empty() {
1917 writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
1918 writeln!(out, "<h2>Stop Reason Distribution</h2>").unwrap();
1919 writeln!(out, r#"<div class="chart-container" style="max-width:400px;margin:0 auto;"><canvas id="stopReasonChart"></canvas></div>"#).unwrap();
1920
1921 let mut reasons: Vec<(&String, &usize)> = result.stop_reason_counts.iter().collect();
1922 reasons.sort_by(|a, b| b.1.cmp(a.1));
1923
1924 let labels: Vec<String> = reasons
1925 .iter()
1926 .map(|(r, _)| format!("\"{}\"", escape_html(r)))
1927 .collect();
1928 let data: Vec<String> = reasons.iter().map(|(_, c)| c.to_string()).collect();
1929 let colors_list: Vec<String> = (0..reasons.len())
1930 .map(|i| format!("\"{}\"", color(i)))
1931 .collect();
1932
1933 writeln!(
1934 out,
1935 r#"<script>
1936new Chart(document.getElementById('stopReasonChart'), {{
1937 type: 'doughnut',
1938 data: {{
1939 labels: [{labels}],
1940 datasets: [{{ data: [{data}], backgroundColor: [{colors}], borderWidth: 0 }}]
1941 }},
1942 options: {{
1943 responsive: true, maintainAspectRatio: false,
1944 plugins: {{ legend: {{ position: 'bottom', labels: {{ color: '#fafafa' }} }} }}
1945 }}
1946}});
1947</script>"#,
1948 labels = labels.join(","),
1949 data = data.join(","),
1950 colors = colors_list.join(",")
1951 )
1952 .unwrap();
1953 writeln!(out, "</div>").unwrap();
1954 }
1955
1956 writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
1958 writeln!(out, "<h2>Turn Details</h2>").unwrap();
1959 writeln!(out, r#"<div class="table-wrap">"#).unwrap();
1960 render_turn_table_impl(&mut out, &result.turn_details, "tbl-session-turns");
1961 writeln!(out, "</div></div>").unwrap();
1962
1963 write!(out, "<script>{}</script>", js_common()).unwrap();
1965
1966 writeln!(out, "</body>\n</html>").unwrap();
1967 out
1968}
1969
1970fn render_turn_table_impl(out: &mut String, turns: &[crate::analysis::TurnDetail], table_id: &str) {
1972 writeln!(out, r#"<table id="{}" style="font-size:12px;">"#, table_id).unwrap();
1973 writeln!(out, "<thead><tr>\
1974 <th onclick=\"sortTableSimple(this,'{id}')\">Turn</th>\
1975 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Time\" data-zh=\"时间\">Time</th>\
1976 <th class=\"text-left\" data-en=\"Model\" data-zh=\"模型\">Model</th>\
1977 <th class=\"text-left\" data-en=\"User\" data-zh=\"用户\">User</th>\
1978 <th class=\"text-left\" data-en=\"Assistant\" data-zh=\"助手\">Assistant</th>\
1979 <th class=\"text-left\" data-en=\"Tools\" data-zh=\"工具\">Tools</th>\
1980 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Output\" data-zh=\"输出\">Output</th>\
1981 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Context\" data-zh=\"上下文\">Context</th>\
1982 <th onclick=\"sortTableSimple(this,'{id}')\">Hit%</th>\
1983 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Cost\" data-zh=\"费用\">Cost</th>\
1984 <th class=\"text-left\">Stop</th>\
1985 <th class=\"text-left\">\u{26a1}</th>\
1986 </tr></thead>", id = table_id).unwrap();
1987 writeln!(out, "<tbody>").unwrap();
1988
1989 for t in turns {
1990 let row_class = if t.is_compaction {
1991 " class=\"compact-row\""
1992 } else if t.is_agent {
1993 " style=\"border-left:2px solid var(--text-accent);\""
1994 } else {
1995 ""
1996 };
1997 let stop = t.stop_reason.as_deref().unwrap_or("-");
1998 let compact_mark = if t.is_compaction {
1999 "\u{26a1}"
2000 } else if t.is_agent {
2001 "\u{1f916}"
2002 } else {
2003 ""
2004 };
2005
2006 let user_text = t.user_text.as_deref().unwrap_or("");
2007 let user_preview = if user_text.len() > 80 {
2008 format!("{}...", &user_text[..user_text.floor_char_boundary(80)])
2009 } else {
2010 user_text.to_string()
2011 };
2012 let asst_text = t.assistant_text.as_deref().unwrap_or("");
2013 let asst_preview = if asst_text.len() > 80 {
2014 format!("{}...", &asst_text[..asst_text.floor_char_boundary(80)])
2015 } else {
2016 asst_text.to_string()
2017 };
2018
2019 let tools_html: String = if t.tool_names.is_empty() {
2021 String::new()
2022 } else {
2023 t.tool_names
2024 .iter()
2025 .map(|name| format!("<span class=\"tool-tag\">{}</span>", escape_html(name)))
2026 .collect::<Vec<_>>()
2027 .join("")
2028 };
2029 let hit_bar = html_progress(t.cache_hit_rate);
2030
2031 let model_short = short_model(&t.model);
2032 let utc_iso = t.timestamp.to_rfc3339();
2033 let time_fallback = t.timestamp.format("%H:%M:%S").to_string();
2034
2035 writeln!(out, "<tr{cls}>\
2036 <td data-value=\"{turn}\">{turn}</td>\
2037 <td><span data-utc=\"{utc}\">{time}</span></td>\
2038 <td class=\"text-left\">{model}</td>\
2039 <td class=\"text-left\" style=\"max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;\" title=\"{user_full}\">{user}</td>\
2040 <td class=\"text-left\" style=\"max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;\" title=\"{asst_full}\">{asst}</td>\
2041 <td class=\"text-left\" style=\"max-width:160px;line-height:1.6;\">{tools}</td>\
2042 <td data-value=\"{out_val}\">{out_fmt}</td>\
2043 <td data-value=\"{ctx_val}\">{ctx_fmt}</td>\
2044 <td data-value=\"{hit:.1}\">{hit_bar}</td>\
2045 <td data-value=\"{cost:.6}\">{cost_fmt}</td>\
2046 <td class=\"text-left\">{stop}</td>\
2047 <td class=\"text-left\">{compact}</td>\
2048 </tr>",
2049 cls = row_class,
2050 turn = t.turn_number,
2051 utc = utc_iso,
2052 time = time_fallback,
2053 model = model_short,
2054 user_full = escape_html(user_text),
2055 user = escape_html(&user_preview),
2056 asst_full = escape_html(asst_text),
2057 asst = escape_html(&asst_preview),
2058 tools = tools_html,
2059 out_val = t.output_tokens, out_fmt = format_compact(t.output_tokens),
2060 ctx_val = t.context_size, ctx_fmt = format_compact(t.context_size),
2061 hit = t.cache_hit_rate, hit_bar = hit_bar,
2062 cost = t.cost, cost_fmt = format_cost(t.cost),
2063 stop = escape_html(stop),
2064 compact = compact_mark,
2065 ).unwrap();
2066 }
2067
2068 writeln!(out, "</tbody></table>").unwrap();
2069}