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",
10 "#ef4444", "#ec4899", "#a78bfa", "#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!(out, r#"<div id="{pfx}-tab-overview" class="sub-tab-content active">"#, pfx = pfx).unwrap();
611 render_overview_tab(out, overview, pfx);
612 writeln!(out, "</div>").unwrap();
613
614 writeln!(out, r#"<div id="{pfx}-tab-monthly" class="sub-tab-content">"#, pfx = pfx).unwrap();
616 render_monthly_tab(out, overview, trend, pfx);
617 writeln!(out, "</div>").unwrap();
618
619 writeln!(out, r#"<div id="{pfx}-tab-projects" class="sub-tab-content">"#, pfx = pfx).unwrap();
621 render_projects_tab(out, projects, &overview.session_summaries, pfx);
622 writeln!(out, "</div>").unwrap();
623
624 let _ = calc;
625}
626
627pub fn render_full_report_html(
632 overview: &OverviewResult,
633 projects: &ProjectResult,
634 trend: &TrendResult,
635 calc: &PricingCalculator,
636) -> String {
637 let mut out = String::with_capacity(256 * 1024);
638
639 write!(out, r#"<!DOCTYPE html>
641<html lang="zh-CN">
642<head>
643 <meta charset="UTF-8">
644 <meta name="viewport" content="width=device-width, initial-scale=1.0">
645 <title>Claude Code Token Analyzer</title>
646 <link rel="preconnect" href="https://fonts.googleapis.com">
647 <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
648 <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
649 <script>{js_head}</script>
650 <style>{css}</style>
651</head>
652<body>
653"#, css = css(), js_head = js_head()).unwrap();
654
655 writeln!(out, r#"<div class="header-row">"#).unwrap();
657 writeln!(out, r#"<h1>Claude Code Token Analyzer</h1>"#).unwrap();
658 if let Some((start, end)) = &overview.quality.time_range {
659 writeln!(out, r#"<span class="subtitle">{} ~ {}</span>"#,
660 start.format("%Y-%m-%d"), end.format("%Y-%m-%d")).unwrap();
661 }
662 writeln!(out, r#"<button class="theme-btn" onclick="toggleTheme()" style="margin-left:auto;">☀️</button>"#).unwrap();
663 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();
664 writeln!(out, "</div>").unwrap();
665
666 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();
668
669 let pfx = "s1";
671 writeln!(out, r#"<div id="source-{pfx}" class="source-content active">"#, pfx = pfx).unwrap();
672 render_source_tabs(&mut out, pfx, overview, projects, trend, calc);
673 writeln!(out, "</div>").unwrap();
674
675 write!(out, "<script>{}</script>", js_common()).unwrap();
677
678 writeln!(out, "</body>\n</html>").unwrap();
679 out
680}
681
682pub fn render_dual_report_html(
687 source1_name: &str,
688 source1: &ReportData,
689 source2_name: &str,
690 source2: &ReportData,
691 calc: &PricingCalculator,
692) -> String {
693 let mut out = String::with_capacity(512 * 1024);
694
695 write!(out, r#"<!DOCTYPE html>
697<html lang="zh-CN">
698<head>
699 <meta charset="UTF-8">
700 <meta name="viewport" content="width=device-width, initial-scale=1.0">
701 <title>Claude Code Token Analyzer</title>
702 <link rel="preconnect" href="https://fonts.googleapis.com">
703 <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
704 <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
705 <script>{js_head}</script>
706 <style>{css}</style>
707</head>
708<body>
709"#, css = css(), js_head = js_head()).unwrap();
710
711 writeln!(out, r#"<div class="header-row">"#).unwrap();
713 writeln!(out, r#"<h1>Claude Code Token Analyzer</h1>"#).unwrap();
714 let time_range_str = {
716 let mut global_min = None;
717 let mut global_max = None;
718 for q in [&source1.overview.quality, &source2.overview.quality] {
719 if let Some((s, e)) = &q.time_range {
720 global_min = Some(global_min.map_or(*s, |m: chrono::DateTime<chrono::Utc>| m.min(*s)));
721 global_max = Some(global_max.map_or(*e, |m: chrono::DateTime<chrono::Utc>| m.max(*e)));
722 }
723 }
724 match (global_min, global_max) {
725 (Some(s), Some(e)) => format!("{} ~ {}", s.format("%Y-%m-%d"), e.format("%Y-%m-%d")),
726 _ => String::new(),
727 }
728 };
729 if !time_range_str.is_empty() {
730 writeln!(out, r#"<span class="subtitle">{}</span>"#, time_range_str).unwrap();
731 }
732 writeln!(out, r#"<button class="theme-btn" onclick="toggleTheme()" style="margin-left:auto;">☀️</button>"#).unwrap();
733 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();
734 writeln!(out, "</div>").unwrap();
735
736 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();
738
739 let s1_sessions = source1.overview.total_sessions;
741 let s2_sessions = source2.overview.total_sessions;
742 writeln!(out, r#"<nav class="top-nav">"#).unwrap();
743 writeln!(out, r#"<button class="active" onclick="switchSource('s1')">{} ({} sessions)</button>"#,
744 escape_html(source1_name), s1_sessions).unwrap();
745 writeln!(out, r#"<button onclick="switchSource('s2')">{} ({} sessions)</button>"#,
746 escape_html(source2_name), s2_sessions).unwrap();
747 writeln!(out, "</nav>").unwrap();
748
749 writeln!(out, r#"<div id="source-s1" class="source-content active">"#).unwrap();
751 render_source_tabs(&mut out, "s1", &source1.overview, &source1.projects, &source1.trend, calc);
752 writeln!(out, "</div>").unwrap();
753
754 writeln!(out, r#"<div id="source-s2" class="source-content">"#).unwrap();
756 render_source_tabs(&mut out, "s2", &source2.overview, &source2.projects, &source2.trend, calc);
757 writeln!(out, "</div>").unwrap();
758
759 write!(out, "<script>{}</script>", js_common()).unwrap();
761
762 writeln!(out, "</body>\n</html>").unwrap();
763 out
764}
765
766fn render_overview_tab(out: &mut String, overview: &OverviewResult, pfx: &str) {
769 writeln!(out, r#"<div class="kpi-grid">"#).unwrap();
771 write_kpi_i18n(out, &format_number(overview.total_sessions as u64), "Sessions", "会话数");
772 write_kpi_i18n(out, &format_number(overview.total_turns as u64), "Turns", "响应数");
773 write_kpi_i18n(out, &format_compact(overview.total_output_tokens), "Claude Wrote", "Claude 写了");
774 write_kpi_i18n(out, &format_compact(overview.total_context_tokens), "Claude Read", "Claude 读了");
775 write_kpi_i18n(out, &format!("{:.1}%", overview.avg_cache_hit_rate), "Avg Cache Hit Rate", "平均缓存命中率");
776 write_kpi_i18n(out, &format_cost_int(overview.total_cost), "Token Value (API Rate)", "Token 价值 (API 费率)");
777 if overview.cache_savings.total_saved > 0.0 {
778 write_kpi_i18n(out, &format_cost_int(overview.cache_savings.total_saved),
779 &format!("Cache Savings ({:.0}%)", overview.cache_savings.savings_pct),
780 &format!("缓存节省 ({:.0}%)", overview.cache_savings.savings_pct));
781 }
782 writeln!(out, "</div>").unwrap();
783
784 {
786 let summaries = &overview.session_summaries;
787
788 let daily_avg = overview.quality.time_range.map(|(s, e)| {
790 let days = (e - s).num_days().max(1) as f64;
791 (overview.total_cost / days, days as u64)
792 });
793
794 let total_compactions: usize = summaries.iter().map(|s| s.compaction_count).sum();
796
797 let max_ctx = summaries.iter().map(|s| s.max_context).max().unwrap_or(0);
799
800 let durations: Vec<f64> = summaries.iter()
802 .map(|s| s.duration_minutes).filter(|d| *d > 0.0).collect();
803 let avg_dur = if !durations.is_empty() { durations.iter().sum::<f64>() / durations.len() as f64 } else { 0.0 };
804
805 writeln!(out, r#"<div class="grid-4">"#).unwrap();
806 if let Some((avg, days)) = daily_avg {
807 write_kpi_i18n(out,
808 &format!("{}/day", format_cost_int(avg)),
809 &format!("Daily Avg ({} days)", days),
810 &format!("日均费用({} 天)", days));
811 }
812 write_kpi_i18n(out,
813 &format_compact(max_ctx),
814 "Peak Context",
815 "峰值上下文");
816 write_kpi_i18n(out,
817 &format_number(total_compactions as u64),
818 "Compactions",
819 "上下文压缩次数");
820 write_kpi_i18n(out,
821 &format_duration(avg_dur),
822 "Avg Session",
823 "平均会话时长");
824 writeln!(out, "</div>").unwrap();
825 }
826
827 writeln!(out, r#"<div class="grid-1-2" style="margin-top:16px;">"#).unwrap();
829
830 {
832 let summaries = &overview.session_summaries;
833 let mut by_cost: Vec<&crate::analysis::SessionSummary> = summaries.iter().collect();
834 by_cost.sort_by(|a, b| b.cost.partial_cmp(&a.cost).unwrap_or(std::cmp::Ordering::Equal));
835 let top5 = &by_cost[..by_cost.len().min(5)];
836 if !top5.is_empty() {
837 writeln!(out, r#"<div class="card">"#).unwrap();
838 writeln!(out, r#"<h2 data-en="Most Expensive Sessions Top 5" data-zh="最贵会话 Top 5">Most Expensive Sessions Top 5</h2>"#).unwrap();
839 writeln!(out, r#"<div class="table-wrap">"#).unwrap();
840 writeln!(out, r#"<table class="data-table"><thead><tr>
841 <th class="text-left" data-en="Session" data-zh="会话">Session</th>
842 <th class="text-left" data-en="Project" data-zh="项目">Project</th>
843 <th style="text-align:right;" data-en="Turns" data-zh="响应数">Turns</th>
844 <th style="text-align:right;" data-en="Cost" data-zh="费用">Cost</th>
845 </tr></thead><tbody>"#).unwrap();
846 for s in top5 {
847 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>",
848 escape_html(&s.session_id[..s.session_id.len().min(8)]),
849 escape_html(&s.project_display_name),
850 s.turn_count,
851 format_cost(s.cost),
852 ).unwrap();
853 }
854 writeln!(out, "</tbody></table></div></div>").unwrap();
855 }
856 }
857
858 {
860 let canvas_id = format!("heatmap-{}", pfx);
861 writeln!(out, r#"<div class="card">"#).unwrap();
862 writeln!(out, r#"<h2 data-en="Activity Heatmap (Local Time)" data-zh="活跃热力图(本地时间)">Activity Heatmap (Local Time)</h2>"#).unwrap();
863 writeln!(out, r#"<div class="heatmap-wrap"><canvas id="{}"></canvas></div>"#, canvas_id).unwrap();
864
865 let mut matrix_js = String::from("[");
866 for d in 0..7 {
867 if d > 0 { matrix_js.push(','); }
868 matrix_js.push('[');
869 for h in 0..24 {
870 if h > 0 { matrix_js.push(','); }
871 write!(matrix_js, "{}", overview.weekday_hour_matrix[d][h]).unwrap();
872 }
873 matrix_js.push(']');
874 }
875 matrix_js.push(']');
876
877 writeln!(out, r#"<script>
878window._heatmapData_{pfx} = {matrix};
879document.addEventListener('DOMContentLoaded', function() {{
880 drawHeatmap('{canvas_id}', window._heatmapData_{pfx});
881}});
882</script>"#, pfx = pfx, matrix = matrix_js, canvas_id = canvas_id).unwrap();
883 writeln!(out, "</div>").unwrap();
884 }
885
886 writeln!(out, "</div>").unwrap(); {
890 let chart_id = format!("{}-scatterChart", pfx);
891 writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
892 writeln!(out, r#"<h2 data-en="Session Efficiency (Turns vs Cost)" data-zh="会话效率(Turns vs 费用)">Session Efficiency (Turns vs Cost)</h2>"#).unwrap();
893 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();
894 writeln!(out, r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#, chart_id).unwrap();
895
896 let max_output: u64 = overview.session_summaries.iter().map(|s| s.output_tokens).max().unwrap_or(1);
897 let mut scatter_data = String::from("[");
898 for (i, s) in overview.session_summaries.iter().enumerate() {
899 if i > 0 { scatter_data.push(','); }
900 let radius = if max_output > 0 {
901 3.0 + (s.output_tokens as f64 / max_output as f64) * 20.0
902 } else { 3.0 };
903 let cpt = if s.turn_count > 0 { s.cost / s.turn_count as f64 } else { 0.0 };
904 write!(scatter_data, "{{x:{},y:{:.4},r:{:.1},cpt:{:.4},out:{}}}", s.turn_count, s.cost, radius, cpt, s.output_tokens).unwrap();
905 }
906 scatter_data.push(']');
907
908 writeln!(out, r#"<script>
909new Chart(document.getElementById('{chart_id}'), {{
910 type: 'bubble',
911 data: {{
912 datasets: [{{
913 label: 'Sessions',
914 data: {data},
915 backgroundColor: 'rgba(59,130,246,0.4)',
916 borderColor: '#3b82f6',
917 borderWidth: 1
918 }}]
919 }},
920 options: {{
921 responsive: true, maintainAspectRatio: false,
922 plugins: {{
923 legend: {{ display: false }},
924 tooltip: {{ callbacks: {{
925 label: function(ctx) {{
926 const d = ctx.raw;
927 return ['Turns: ' + d.x + ' Cost: $' + d.y.toFixed(2), 'Cost/Turn: $' + d.cpt.toFixed(3) + ' Output: ' + d.out.toLocaleString()];
928 }}
929 }} }}
930 }},
931 scales: {{
932 x: {{ title: {{ display: true, text: 'Turn Count', color: '#a1a1aa' }}, ticks: {{ color: '#a1a1aa' }}, grid: {{ color: '#27272a' }} }},
933 y: {{ title: {{ display: true, text: 'Cost ($)', color: '#a1a1aa' }}, ticks: {{ color: '#a1a1aa', callback: function(v) {{ return '$' + v; }} }}, grid: {{ color: '#27272a' }} }}
934 }}
935 }}
936}});
937</script>"#, chart_id = chart_id, data = scatter_data).unwrap();
938 writeln!(out, "</div>").unwrap();
939 }
940}
941
942fn render_monthly_tab(out: &mut String, _overview: &OverviewResult, trend: &TrendResult, pfx: &str) {
945 if trend.entries.is_empty() {
946 writeln!(out, r#"<div class="card"><p style="color:var(--text-secondary);">No trend data available.</p></div>"#).unwrap();
947 return;
948 }
949
950 let latest_month = trend.entries.last().map(|e| &e.label[..7]).unwrap_or("");
952
953 let mut month_cost = 0.0f64;
955 let mut month_turns = 0usize;
956 let mut month_sessions = 0usize;
957 let mut month_output = 0u64;
958 let mut month_input = 0u64;
959
960 let mut daily_entries: Vec<&crate::analysis::TrendEntry> = Vec::new();
961
962 for entry in &trend.entries {
963 if entry.label.starts_with(latest_month) {
964 month_cost += entry.cost;
965 month_turns += entry.turn_count;
966 month_sessions += entry.session_count;
967 month_output += entry.tokens.output_tokens;
968 month_input += entry.tokens.input_tokens + entry.tokens.cache_creation_tokens + entry.tokens.cache_read_tokens;
969 daily_entries.push(entry);
970 }
971 }
972
973 let _avg_cost_per_turn = if month_turns > 0 { month_cost / month_turns as f64 } else { 0.0 };
974
975 writeln!(out, r#"<h2 data-en="Current Period: {m}" data-zh="当前周期:{m}">Current Period: {m}</h2>"#, m = escape_html(latest_month)).unwrap();
977 writeln!(out, r#"<div class="kpi-grid">"#).unwrap();
978 write_kpi_i18n(out, &format_number(month_sessions as u64), "Sessions", "会话数");
979 write_kpi_i18n(out, &format_number(month_turns as u64), "Turns", "响应数");
980 write_kpi_i18n(out, &format_compact(month_input), "Input Tokens", "输入 Token");
981 write_kpi_i18n(out, &format_compact(month_output), "Output Tokens", "输出 Token");
982 write_kpi_i18n(out, &format_cost(month_cost), "Cost", "费用");
983 writeln!(out, "</div>").unwrap();
984
985 if !daily_entries.is_empty() {
987 let chart_id = format!("{}-dailyCostChart", pfx);
988 writeln!(out, r#"<div class="card">"#).unwrap();
989 writeln!(out, r#"<h2 data-en="Daily Cost & Cost/Turn ({})" data-zh="每日费用 & 每 Turn 费用 ({})">Daily Cost & Cost/Turn ({})</h2>"#,
990 escape_html(latest_month), escape_html(latest_month), escape_html(latest_month)).unwrap();
991 writeln!(out, r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#, chart_id).unwrap();
992
993 let labels: Vec<String> = daily_entries.iter().map(|e| format!("\"{}\"", &e.label[5..])).collect();
994 let cost_data: Vec<String> = daily_entries.iter().map(|e| format!("{:.2}", e.cost)).collect();
995 let cpt_data: Vec<String> = daily_entries.iter().map(|e| {
996 if e.turn_count > 0 { format!("{:.4}", e.cost / e.turn_count as f64) } else { "0".to_string() }
997 }).collect();
998 let turn_data: Vec<String> = daily_entries.iter().map(|e| e.turn_count.to_string()).collect();
999
1000 writeln!(out, r#"<script>
1001new Chart(document.getElementById('{chart_id}'), {{
1002 type: 'bar',
1003 data: {{
1004 labels: [{labels}],
1005 datasets: [
1006 {{
1007 label: 'Cost ($)',
1008 data: [{cost_data}],
1009 backgroundColor: 'rgba(59,130,246,0.6)',
1010 borderColor: '#3b82f6',
1011 borderWidth: 1,
1012 borderRadius: 4,
1013 yAxisID: 'y',
1014 order: 2
1015 }},
1016 {{
1017 label: 'Cost/Turn ($)',
1018 data: [{cpt_data}],
1019 type: 'line',
1020 borderColor: '#f59e0b',
1021 backgroundColor: 'rgba(245,158,11,0.1)',
1022 pointRadius: 3,
1023 tension: 0.3,
1024 yAxisID: 'y1',
1025 order: 1
1026 }}
1027 ]
1028 }},
1029 options: {{
1030 responsive: true, maintainAspectRatio: false,
1031 plugins: {{
1032 legend: {{ labels: {{ color: '#fafafa' }} }},
1033 tooltip: {{ callbacks: {{
1034 afterLabel: function(ctx) {{
1035 const turns = [{turn_data}];
1036 return 'Turns: ' + turns[ctx.dataIndex];
1037 }}
1038 }} }}
1039 }},
1040 scales: {{
1041 x: {{ ticks: {{ color: '#a1a1aa' }}, grid: {{ color: '#27272a' }} }},
1042 y: {{ position: 'left', ticks: {{ color: '#a1a1aa', callback: function(v) {{ return '$' + v; }} }}, grid: {{ color: '#27272a' }}, title: {{ display: true, text: 'Cost ($)', color: '#a1a1aa' }} }},
1043 y1: {{ position: 'right', ticks: {{ color: '#f59e0b', callback: function(v) {{ return '$' + v.toFixed(3); }} }}, grid: {{ drawOnChartArea: false }}, title: {{ display: true, text: 'Cost/Turn ($)', color: '#f59e0b' }} }}
1044 }}
1045 }}
1046}});
1047</script>"#, chart_id = chart_id,
1048 labels = labels.join(","),
1049 cost_data = cost_data.join(","),
1050 cpt_data = cpt_data.join(","),
1051 turn_data = turn_data.join(","),
1052 ).unwrap();
1053 writeln!(out, "</div>").unwrap();
1054 }
1055
1056 {
1058 let mut all_models: Vec<String> = Vec::new();
1060 for entry in &daily_entries {
1061 for model_name in entry.models.keys() {
1062 let short = short_model(model_name);
1063 if !all_models.contains(&short) {
1064 all_models.push(short);
1065 }
1066 }
1067 }
1068 all_models.sort();
1069
1070 if daily_entries.len() > 1 {
1071 let chart_id = format!("{}-dailyTurnsCostChart", pfx);
1072 writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
1073 writeln!(out, r#"<h2 data-en="Daily Turns & Cost" data-zh="每日响应数与费用">Daily Turns & Cost</h2>"#).unwrap();
1074 writeln!(out, r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#, chart_id).unwrap();
1075
1076 let labels: Vec<String> = daily_entries.iter().map(|e| format!("\"{}\"", &e.label[5..])).collect();
1077 let turns_data: Vec<String> = daily_entries.iter().map(|e| e.turn_count.to_string()).collect();
1078 let cost_data: Vec<String> = daily_entries.iter().map(|e| format!("{:.2}", e.cost)).collect();
1079
1080 writeln!(out, r#"<script>
1081new Chart(document.getElementById('{chart_id}'), {{
1082 type: 'bar',
1083 data: {{
1084 labels: [{labels}],
1085 datasets: [
1086 {{label:'Turns',data:[{turns}],backgroundColor:'rgba(59,130,246,0.5)',borderColor:'#3b82f6',borderWidth:1,borderRadius:3,yAxisID:'y'}},
1087 {{label:'Cost ($)',data:[{cost}],type:'line',borderColor:'#22c55e',backgroundColor:'rgba(34,197,94,0.1)',fill:true,pointRadius:3,tension:0.3,yAxisID:'y1'}}
1088 ]
1089 }},
1090 options: {{
1091 responsive: true, maintainAspectRatio: false,
1092 plugins: {{ legend: {{ labels: {{ color: 'var(--text-secondary)' }} }} }},
1093 scales: {{
1094 x: {{ ticks: {{ color: 'var(--text-secondary)' }}, grid: {{ color: 'var(--border-color)' }} }},
1095 y: {{ position:'left', ticks: {{ color: '#3b82f6' }}, grid: {{ color: 'var(--border-color)' }}, title: {{ display:true, text:'Turns', color:'#3b82f6' }} }},
1096 y1: {{ position:'right', ticks: {{ color: '#22c55e', callback: function(v){{ return '$'+v; }} }}, grid: {{ drawOnChartArea:false }}, title: {{ display:true, text:'Cost ($)', color:'#22c55e' }} }}
1097 }}
1098 }}
1099}});
1100</script>"#, chart_id = chart_id, labels = labels.join(","), turns = turns_data.join(","), cost = cost_data.join(",")).unwrap();
1101 writeln!(out, "</div>").unwrap();
1102 }
1103 }
1104
1105 {
1107 #[allow(clippy::type_complexity)]
1109 let mut months: std::collections::BTreeMap<String, (usize, usize, u64, u64, u64, f64, u64)> = std::collections::BTreeMap::new();
1110 for entry in &trend.entries {
1111 let month_key = entry.label[..7].to_string();
1112 let e = months.entry(month_key).or_insert((0, 0, 0, 0, 0, 0.0, 0));
1113 e.0 += entry.session_count;
1114 e.1 += entry.turn_count;
1115 e.2 += entry.tokens.output_tokens;
1116 e.3 += entry.tokens.cache_creation_tokens;
1117 e.4 += entry.tokens.cache_read_tokens;
1118 e.5 += entry.cost;
1119 e.6 += entry.tokens.input_tokens + entry.tokens.cache_creation_tokens + entry.tokens.cache_read_tokens;
1120 }
1121
1122 if months.len() > 1 {
1123 let tbl_id = format!("{}-tbl-monthly", pfx);
1124 writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
1125 writeln!(out, r#"<h2 data-en="Monthly Summary" data-zh="月度汇总">Monthly Summary</h2>"#).unwrap();
1126 writeln!(out, r#"<div class="table-wrap">"#).unwrap();
1127 writeln!(out, r#"<table id="{}">"#, tbl_id).unwrap();
1128 writeln!(out, "<thead><tr>\
1129 <th class=\"text-left\" onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Month\" data-zh=\"月份\">Month</th>\
1130 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Sessions\" data-zh=\"会话\">Sessions</th>\
1131 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Turns\" data-zh=\"响应数\">Turns</th>\
1132 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Input Tokens\" data-zh=\"输入 Token\">Input Tokens</th>\
1133 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Output Tokens\" data-zh=\"输出 Token\">Output Tokens</th>\
1134 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Cost\" data-zh=\"费用\">Cost</th>\
1135 </tr></thead>", id = tbl_id).unwrap();
1136 writeln!(out, "<tbody>").unwrap();
1137
1138 for (month, (sessions, turns, output, _cache_write, _cache_read, cost, input_ctx)) in &months {
1139 writeln!(out, "<tr>\
1140 <td class=\"text-left\" data-value=\"{}\">{}</td>\
1141 <td data-value=\"{}\">{}</td>\
1142 <td data-value=\"{}\">{}</td>\
1143 <td data-value=\"{}\">{}</td>\
1144 <td data-value=\"{}\">{}</td>\
1145 <td data-value=\"{:.4}\">{}</td>\
1146 </tr>",
1147 escape_html(month), escape_html(month),
1148 sessions, format_number(*sessions as u64),
1149 turns, format_number(*turns as u64),
1150 input_ctx, format_compact(*input_ctx),
1151 output, format_compact(*output),
1152 cost, format_cost(*cost),
1153 ).unwrap();
1154 }
1155
1156 writeln!(out, "</tbody></table></div></div>").unwrap();
1157 }
1158 }
1159
1160 {
1162 let tbl_id = format!("{}-tbl-daily", pfx);
1163 let group_zh = match trend.group_label.as_str() {
1164 "Day" => "每日",
1165 "Week" => "每周",
1166 "Month" => "每月",
1167 other => other,
1168 };
1169 let group_col_zh = match trend.group_label.as_str() {
1170 "Day" => "日期",
1171 "Week" => "周",
1172 "Month" => "月份",
1173 other => other,
1174 };
1175 writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
1176 writeln!(out, r#"<h2 data-en="{} Breakdown" data-zh="{}明细">{} Breakdown</h2>"#,
1177 escape_html(&trend.group_label), escape_html(group_zh), escape_html(&trend.group_label)).unwrap();
1178 writeln!(out, r#"<div class="table-wrap">"#).unwrap();
1179 writeln!(out, r#"<table id="{}">"#, tbl_id).unwrap();
1180 writeln!(out, "<thead><tr>\
1181 <th class=\"text-left\" onclick=\"sortTableSimple(this,'{id}')\" data-en=\"{en}\" data-zh=\"{zh}\">{en}</th>\
1182 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Sessions\" data-zh=\"会话\">Sessions</th>\
1183 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Turns\" data-zh=\"响应数\">Turns</th>\
1184 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Input Tokens\" data-zh=\"输入 Token\">Input Tokens</th>\
1185 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Output Tokens\" data-zh=\"输出 Token\">Output Tokens</th>\
1186 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Cost\" data-zh=\"费用\">Cost</th>\
1187 <th class=\"text-left\" data-en=\"Models\" data-zh=\"模型\">Models</th>\
1188 </tr></thead>", en = escape_html(&trend.group_label), zh = escape_html(group_col_zh), id = tbl_id).unwrap();
1189 writeln!(out, "<tbody>").unwrap();
1190
1191 for entry in &trend.entries {
1192 let input_tokens = entry.tokens.input_tokens + entry.tokens.cache_creation_tokens + entry.tokens.cache_read_tokens;
1193 let mut model_list: Vec<(&String, &u64)> = entry.models.iter().collect();
1195 model_list.sort_by(|a, b| b.1.cmp(a.1));
1196 let models_html: String = model_list.iter().take(3).map(|(m, tokens)| {
1197 format!("<span class=\"tool-tag\">{} <span class=\"tool-count\">{}</span></span>",
1198 escape_html(&short_model(m)), format_compact(**tokens))
1199 }).collect::<Vec<_>>().join("");
1200
1201 writeln!(out, "<tr>\
1202 <td class=\"text-left\" data-value=\"{}\">{}</td>\
1203 <td data-value=\"{}\">{}</td>\
1204 <td data-value=\"{}\">{}</td>\
1205 <td data-value=\"{}\">{}</td>\
1206 <td data-value=\"{}\">{}</td>\
1207 <td data-value=\"{:.4}\">{}</td>\
1208 <td class=\"text-left\">{}</td>\
1209 </tr>",
1210 escape_html(&entry.label), escape_html(&entry.label),
1211 entry.session_count, format_number(entry.session_count as u64),
1212 entry.turn_count, format_number(entry.turn_count as u64),
1213 input_tokens, format_compact(input_tokens),
1214 entry.tokens.output_tokens, format_compact(entry.tokens.output_tokens),
1215 entry.cost, format_cost(entry.cost),
1216 models_html,
1217 ).unwrap();
1218 }
1219
1220 writeln!(out, "</tbody></table></div></div>").unwrap();
1221 }
1222}
1223
1224fn render_projects_tab(out: &mut String, projects: &ProjectResult, sessions: &[crate::analysis::SessionSummary], pfx: &str) {
1227 {
1229 let top_n = projects.projects.iter().take(10).collect::<Vec<_>>();
1230 if !top_n.is_empty() {
1231 let chart_id = format!("{}-projectCostChart", pfx);
1232 writeln!(out, r#"<div class="card">"#).unwrap();
1233 writeln!(out, r#"<h2 data-en="Project Cost Top 10" data-zh="项目费用 Top 10">Project Cost Top 10</h2>"#).unwrap();
1234 writeln!(out, r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#, chart_id).unwrap();
1235
1236 let labels: Vec<String> = top_n.iter().map(|p| format!("\"{}\"", escape_html(&p.display_name))).collect();
1237 let data: Vec<String> = top_n.iter().map(|p| format!("{:.2}", p.cost)).collect();
1238 let colors_list: Vec<String> = (0..top_n.len()).map(|i| format!("\"{}\"", color(i))).collect();
1239
1240 writeln!(out, r#"<script>
1241new Chart(document.getElementById('{chart_id}'), {{
1242 type: 'bar',
1243 data: {{
1244 labels: [{labels}],
1245 datasets: [{{ label: 'Cost ($)', data: [{data}], backgroundColor: [{colors}], borderWidth: 0, borderRadius: 4 }}]
1246 }},
1247 options: {{
1248 indexAxis: 'y', responsive: true, maintainAspectRatio: false,
1249 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx) {{ return '$' + ctx.raw.toFixed(2); }} }} }} }},
1250 scales: {{
1251 x: {{ ticks: {{ color: '#a1a1aa', callback: function(v) {{ return '$' + v; }} }}, grid: {{ color: '#27272a' }} }},
1252 y: {{ ticks: {{ color: '#fafafa' }}, grid: {{ color: '#27272a' }} }}
1253 }}
1254 }}
1255}});
1256</script>"#, chart_id = chart_id, labels = labels.join(","), data = data.join(","), colors = colors_list.join(",")).unwrap();
1257 writeln!(out, "</div>").unwrap();
1258 }
1259 }
1260
1261 let tbl_id = format!("{}-tbl-projects-drill", pfx);
1263 writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
1264 writeln!(out, r#"<h2 data-en="Project Drill-Down" data-zh="项目钻取">Project Drill-Down</h2>"#).unwrap();
1265 writeln!(out, r#"<div class="table-wrap">"#).unwrap();
1266 writeln!(out, r#"<table id="{}">"#, tbl_id).unwrap();
1267 writeln!(out, "<thead><tr>\
1268 <th class=\"text-left\"></th>\
1269 <th class=\"text-left\" data-en=\"Project / Session\" data-zh=\"项目 / 会话\">Project / Session</th>\
1270 <th data-en=\"Sessions\" data-zh=\"会话\">Sessions</th>\
1271 <th data-en=\"Turns (Agent)\" data-zh=\"响应数 (Agent)\">Turns (Agent)</th>\
1272 <th data-en=\"Output\" data-zh=\"输出\">Output</th>\
1273 <th data-en=\"CacheHit\" data-zh=\"缓存命中率\">CacheHit</th>\
1274 <th class=\"text-left\" data-en=\"Tools\" data-zh=\"工具\">Tools</th>\
1275 <th data-en=\"Cost\" data-zh=\"费用\">Cost</th>\
1276 </tr></thead>").unwrap();
1277 writeln!(out, "<tbody>").unwrap();
1278
1279 let mut sessions_by_project: std::collections::HashMap<String, Vec<&crate::analysis::SessionSummary>> = std::collections::HashMap::new();
1281 for s in sessions {
1282 sessions_by_project.entry(s.project_display_name.clone()).or_default().push(s);
1283 }
1284
1285 for (i, proj) in projects.projects.iter().enumerate() {
1286 let cache_hit = if proj.tokens.context_tokens() > 0 {
1287 proj.tokens.cache_read_tokens as f64 / proj.tokens.context_tokens() as f64 * 100.0
1288 } else { 0.0 };
1289 let pid = format!("{}-p{}", pfx, i);
1290
1291 let hit_bar = html_progress(cache_hit);
1293 let turns_display = if proj.agent_turns > 0 {
1294 format!("{} <span class=\"agent-badge\">+{} agent</span>",
1295 format_number(proj.total_turns as u64), proj.agent_turns)
1296 } else {
1297 format_number(proj.total_turns as u64)
1298 };
1299 writeln!(out, r#"<tr class="project-row expandable">"#).unwrap();
1300 writeln!(out, r#"<td class="text-left"><button class="expand-btn" onclick="toggleProject(this,'{pid}')">{arrow}</button></td>"#,
1301 pid = pid, arrow = "\u{25b6}").unwrap();
1302 writeln!(out, "\
1303 <td class=\"text-left\"><strong>{name}</strong></td>\
1304 <td data-value=\"{sess}\">{sess_fmt}</td>\
1305 <td class=\"turn-count-cell\" data-value=\"{turns}\">{turns_display}</td>\
1306 <td data-value=\"{out}\">{out_fmt}</td>\
1307 <td data-value=\"{hit:.1}\">{hit_bar}</td>\
1308 <td class=\"text-left\"></td>\
1309 <td data-value=\"{cost:.4}\">{cost_fmt}</td>",
1310 name = escape_html(&proj.display_name),
1311 sess = proj.session_count, sess_fmt = format_number(proj.session_count as u64),
1312 turns = proj.total_turns, turns_display = turns_display,
1313 out = proj.tokens.output_tokens, out_fmt = format_compact(proj.tokens.output_tokens),
1314 hit = cache_hit, hit_bar = hit_bar,
1315 cost = proj.cost, cost_fmt = format_cost(proj.cost),
1316 ).unwrap();
1317 writeln!(out, "</tr>").unwrap();
1318
1319 if let Some(proj_sessions) = sessions_by_project.get(&proj.display_name) {
1321 let mut sorted = proj_sessions.clone();
1322 sorted.sort_by(|a, b| b.cost.partial_cmp(&a.cost).unwrap_or(std::cmp::Ordering::Equal));
1323
1324 for s in sorted.iter().filter(|s| s.turn_count > 0) {
1325 let utc_iso = s.first_timestamp.map(|t| t.to_rfc3339()).unwrap_or_default();
1326 let date_fallback = s.first_timestamp.map(|t| t.format("%m-%d %H:%M").to_string()).unwrap_or_default();
1327 let s_hit = html_progress(s.cache_hit_rate);
1328
1329 writeln!(out, r#"<tr class="project-session-row project-sessions-{pid} expandable" style="display:none">"#,
1331 pid = pid).unwrap();
1332
1333 let has_detail = s.turn_details.is_some();
1334 if has_detail {
1335 writeln!(out, r#"<td class="text-left"><button class="expand-btn" onclick="toggleSession(this)">{}</button></td>"#, "\u{25b6}").unwrap();
1336 } else {
1337 writeln!(out, r#"<td class="text-left"></td>"#).unwrap();
1338 }
1339
1340 let s_turns_display = if s.agent_turn_count > 0 {
1342 format!("{} <span class=\"agent-badge\">+{} agent</span>",
1343 format_number(s.turn_count as u64), s.agent_turn_count)
1344 } else {
1345 format_number(s.turn_count as u64)
1346 };
1347
1348 let tools_html: String = s.top_tools.iter().take(5).map(|(name, count)| {
1350 format!("<span class=\"tool-tag\">{} <span class=\"tool-count\">{}</span></span>",
1351 escape_html(name), count)
1352 }).collect::<Vec<_>>().join("");
1353
1354 let duration_str = format_duration(s.duration_minutes);
1355 let short_sid = &s.session_id[..s.session_id.len().min(10)];
1356
1357 writeln!(out, "\
1358 <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>\
1359 <td></td>\
1360 <td class=\"turn-count-cell\" data-value=\"{turns}\">{turns_display}</td>\
1361 <td data-value=\"{out}\">{out_fmt}</td>\
1362 <td data-value=\"{hit:.1}\">{hit_bar}</td>\
1363 <td class=\"text-left session-tools-cell\">{tools}</td>\
1364 <td data-value=\"{cost:.4}\">{cost_fmt}</td>",
1365 sid = escape_html(short_sid),
1366 utc = utc_iso,
1367 date = date_fallback,
1368 dur = duration_str,
1369 turns = s.turn_count, turns_display = s_turns_display,
1370 out = s.output_tokens, out_fmt = format_compact(s.output_tokens),
1371 hit = s.cache_hit_rate, hit_bar = s_hit,
1372 tools = tools_html,
1373 cost = s.cost, cost_fmt = format_cost(s.cost),
1374 ).unwrap();
1375 writeln!(out, "</tr>").unwrap();
1376
1377 if let Some(ref details) = s.turn_details {
1379 writeln!(out, r#"<tr class="session-detail project-sessions-{pid}" style="display:none"><td colspan="8"><div class="detail-content">"#,
1380 pid = pid).unwrap();
1381 render_turn_detail_table(out, details, &format!("{}-detail-proj-{}", pfx, escape_html(&s.session_id)));
1382 writeln!(out, "</div></td></tr>").unwrap();
1383 }
1384 }
1385 }
1386 }
1387
1388 writeln!(out, "</tbody></table></div></div>").unwrap();
1389}
1390
1391fn render_turn_detail_table(out: &mut String, turns: &[crate::analysis::TurnDetail], table_id: &str) {
1394 render_turn_table_impl(out, turns, table_id);
1395}
1396
1397fn write_kpi(out: &mut String, value: &str, label: &str) {
1400 writeln!(out, r#"<div class="card" style="text-align:center;"><div class="kpi-value">{}</div><div class="kpi-label">{}</div></div>"#,
1401 value, label).unwrap();
1402}
1403
1404fn write_kpi_i18n(out: &mut String, value: &str, en: &str, zh: &str) {
1406 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>"#,
1407 value, en, zh, en).unwrap();
1408}
1409
1410fn html_progress(pct: f64) -> String {
1412 let bar_color = if pct >= 90.0 { "#22c55e" } else if pct >= 70.0 { "#f59e0b" } else { "#ef4444" };
1413 format!(r#"<div class="progress-bar"><div class="progress-fill" style="width:{:.1}%;background:{};"></div></div><span class="progress-text">{:.1}%</span>"#,
1414 pct, bar_color, pct)
1415}
1416
1417pub fn render_session_html(result: &SessionResult) -> String {
1421 let mut out = String::with_capacity(64 * 1024);
1422
1423 let short_id = &result.session_id[..result.session_id.len().min(12)];
1424
1425 write!(out, r#"<!DOCTYPE html>
1427<html lang="zh-CN">
1428<head>
1429 <meta charset="UTF-8">
1430 <meta name="viewport" content="width=device-width, initial-scale=1.0">
1431 <title>Session {short_id} - Claude Code Token Analyzer</title>
1432 <link rel="preconnect" href="https://fonts.googleapis.com">
1433 <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
1434 <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
1435 <script>{js_head}</script>
1436 <style>{css}</style>
1437</head>
1438<body>
1439"#, short_id = escape_html(short_id), css = css(), js_head = js_head()).unwrap();
1440
1441 writeln!(out, r#"<div class="header-row">"#).unwrap();
1443 writeln!(out, "<h1>Session Analysis</h1>").unwrap();
1444 writeln!(out, r#"<button class="theme-btn" onclick="toggleTheme()" style="margin-left:auto;">☀️</button>"#).unwrap();
1445 writeln!(out, r#"<span class="subtitle">{} · {}</span>"#,
1446 escape_html(&result.session_id), escape_html(&result.project)).unwrap();
1447 writeln!(out, "</div>").unwrap();
1448
1449 let cache_hit_rate = {
1451 let total_ctx = result.total_tokens.context_tokens();
1452 if total_ctx > 0 {
1453 result.total_tokens.cache_read_tokens as f64 / total_ctx as f64 * 100.0
1454 } else { 0.0 }
1455 };
1456
1457 writeln!(out, r#"<div class="kpi-grid">"#).unwrap();
1458 write_kpi(&mut out, &format_duration(result.duration_minutes), "Duration");
1459 write_kpi(&mut out, &short_model(&result.model), "Model");
1460 write_kpi(&mut out, &format_number(result.max_context), "Max Context");
1461 write_kpi(&mut out, &format!("{:.1}%", cache_hit_rate), "Cache Hit Rate");
1462 write_kpi(&mut out, &format_number(result.compaction_count as u64), "Compactions");
1463 write_kpi(&mut out, &format_cost(result.total_cost), "Total Cost");
1464 writeln!(out, "</div>").unwrap();
1465
1466 if !result.turn_details.is_empty() {
1468 writeln!(out, r#"<div class="grid-2">"#).unwrap();
1469
1470 {
1472 writeln!(out, r#"<div class="card">"#).unwrap();
1473 writeln!(out, "<h2>Context Growth</h2>").unwrap();
1474 writeln!(out, r#"<div class="chart-container"><canvas id="contextChart"></canvas></div>"#).unwrap();
1475
1476 let turn_nums: Vec<String> = result.turn_details.iter().map(|t| t.turn_number.to_string()).collect();
1477 let ctx_sizes: Vec<String> = result.turn_details.iter().map(|t| t.context_size.to_string()).collect();
1478 let pr = if result.turn_details.len() > 50 { 0 } else { 3 };
1479
1480 writeln!(out, r#"<script>
1481new Chart(document.getElementById('contextChart'), {{
1482 type: 'line',
1483 data: {{
1484 labels: [{turns}],
1485 datasets: [{{
1486 label: 'Context Size',
1487 data: [{sizes}],
1488 borderColor: '#3b82f6',
1489 backgroundColor: 'rgba(59,130,246,0.1)',
1490 fill: true, tension: 0.3, pointRadius: {pr}
1491 }}]
1492 }},
1493 options: {{
1494 responsive: true, maintainAspectRatio: false,
1495 plugins: {{ legend: {{ labels: {{ color: '#fafafa' }} }} }},
1496 scales: {{
1497 x: {{ title: {{ display: true, text: 'Turn', color: '#a1a1aa' }}, ticks: {{ color: '#a1a1aa' }}, grid: {{ color: '#27272a' }} }},
1498 y: {{ title: {{ display: true, text: 'Context Tokens', color: '#a1a1aa' }}, ticks: {{ color: '#a1a1aa' }}, grid: {{ color: '#27272a' }} }}
1499 }}
1500 }}
1501}});
1502</script>"#,
1503 turns = turn_nums.join(","),
1504 sizes = ctx_sizes.join(","),
1505 pr = pr,
1506 ).unwrap();
1507 writeln!(out, "</div>").unwrap();
1508 }
1509
1510 {
1512 writeln!(out, r#"<div class="card">"#).unwrap();
1513 writeln!(out, "<h2>Cache Hit Rate</h2>").unwrap();
1514 writeln!(out, r#"<div class="chart-container"><canvas id="cacheChart"></canvas></div>"#).unwrap();
1515
1516 let turn_nums: Vec<String> = result.turn_details.iter().map(|t| t.turn_number.to_string()).collect();
1517 let cache_rates: Vec<String> = result.turn_details.iter().map(|t| format!("{:.2}", t.cache_hit_rate)).collect();
1518 let pr = if result.turn_details.len() > 50 { 0 } else { 3 };
1519
1520 writeln!(out, r#"<script>
1521new Chart(document.getElementById('cacheChart'), {{
1522 type: 'line',
1523 data: {{
1524 labels: [{turns}],
1525 datasets: [{{
1526 label: 'Cache Hit Rate (%)',
1527 data: [{rates}],
1528 borderColor: '#f59e0b',
1529 backgroundColor: 'rgba(245,158,11,0.1)',
1530 fill: true, tension: 0.3, pointRadius: {pr}
1531 }}]
1532 }},
1533 options: {{
1534 responsive: true, maintainAspectRatio: false,
1535 plugins: {{ legend: {{ labels: {{ color: '#fafafa' }} }} }},
1536 scales: {{
1537 x: {{ title: {{ display: true, text: 'Turn', color: '#a1a1aa' }}, ticks: {{ color: '#a1a1aa' }}, grid: {{ color: '#27272a' }} }},
1538 y: {{ title: {{ display: true, text: 'Hit Rate (%)', color: '#a1a1aa' }}, ticks: {{ color: '#a1a1aa' }}, grid: {{ color: '#27272a' }}, min: 0, max: 100 }}
1539 }}
1540 }}
1541}});
1542</script>"#,
1543 turns = turn_nums.join(","),
1544 rates = cache_rates.join(","),
1545 pr = pr,
1546 ).unwrap();
1547 writeln!(out, "</div>").unwrap();
1548 }
1549
1550 writeln!(out, "</div>").unwrap(); }
1552
1553 if !result.stop_reason_counts.is_empty() {
1555 writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
1556 writeln!(out, "<h2>Stop Reason Distribution</h2>").unwrap();
1557 writeln!(out, r#"<div class="chart-container" style="max-width:400px;margin:0 auto;"><canvas id="stopReasonChart"></canvas></div>"#).unwrap();
1558
1559 let mut reasons: Vec<(&String, &usize)> = result.stop_reason_counts.iter().collect();
1560 reasons.sort_by(|a, b| b.1.cmp(a.1));
1561
1562 let labels: Vec<String> = reasons.iter().map(|(r, _)| format!("\"{}\"", escape_html(r))).collect();
1563 let data: Vec<String> = reasons.iter().map(|(_, c)| c.to_string()).collect();
1564 let colors_list: Vec<String> = (0..reasons.len()).map(|i| format!("\"{}\"", color(i))).collect();
1565
1566 writeln!(out, r#"<script>
1567new Chart(document.getElementById('stopReasonChart'), {{
1568 type: 'doughnut',
1569 data: {{
1570 labels: [{labels}],
1571 datasets: [{{ data: [{data}], backgroundColor: [{colors}], borderWidth: 0 }}]
1572 }},
1573 options: {{
1574 responsive: true, maintainAspectRatio: false,
1575 plugins: {{ legend: {{ position: 'bottom', labels: {{ color: '#fafafa' }} }} }}
1576 }}
1577}});
1578</script>"#,
1579 labels = labels.join(","), data = data.join(","), colors = colors_list.join(",")).unwrap();
1580 writeln!(out, "</div>").unwrap();
1581 }
1582
1583 writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
1585 writeln!(out, "<h2>Turn Details</h2>").unwrap();
1586 writeln!(out, r#"<div class="table-wrap">"#).unwrap();
1587 render_turn_table_impl(&mut out, &result.turn_details, "tbl-session-turns");
1588 writeln!(out, "</div></div>").unwrap();
1589
1590 write!(out, "<script>{}</script>", js_common()).unwrap();
1592
1593 writeln!(out, "</body>\n</html>").unwrap();
1594 out
1595}
1596
1597fn render_turn_table_impl(out: &mut String, turns: &[crate::analysis::TurnDetail], table_id: &str) {
1599 writeln!(out, r#"<table id="{}" style="font-size:12px;">"#, table_id).unwrap();
1600 writeln!(out, "<thead><tr>\
1601 <th onclick=\"sortTableSimple(this,'{id}')\">Turn</th>\
1602 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Time\" data-zh=\"时间\">Time</th>\
1603 <th class=\"text-left\" data-en=\"Model\" data-zh=\"模型\">Model</th>\
1604 <th class=\"text-left\" data-en=\"User\" data-zh=\"用户\">User</th>\
1605 <th class=\"text-left\" data-en=\"Assistant\" data-zh=\"助手\">Assistant</th>\
1606 <th class=\"text-left\" data-en=\"Tools\" data-zh=\"工具\">Tools</th>\
1607 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Output\" data-zh=\"输出\">Output</th>\
1608 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Context\" data-zh=\"上下文\">Context</th>\
1609 <th onclick=\"sortTableSimple(this,'{id}')\">Hit%</th>\
1610 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Cost\" data-zh=\"费用\">Cost</th>\
1611 <th class=\"text-left\">Stop</th>\
1612 <th class=\"text-left\">\u{26a1}</th>\
1613 </tr></thead>", id = table_id).unwrap();
1614 writeln!(out, "<tbody>").unwrap();
1615
1616 for t in turns {
1617 let row_class = if t.is_compaction {
1618 " class=\"compact-row\""
1619 } else if t.is_agent {
1620 " style=\"border-left:2px solid var(--text-accent);\""
1621 } else {
1622 ""
1623 };
1624 let stop = t.stop_reason.as_deref().unwrap_or("-");
1625 let compact_mark = if t.is_compaction { "\u{26a1}" } else if t.is_agent { "\u{1f916}" } else { "" };
1626
1627 let user_text = t.user_text.as_deref().unwrap_or("");
1628 let user_preview = if user_text.len() > 80 {
1629 format!("{}...", &user_text[..user_text.floor_char_boundary(80)])
1630 } else {
1631 user_text.to_string()
1632 };
1633 let asst_text = t.assistant_text.as_deref().unwrap_or("");
1634 let asst_preview = if asst_text.len() > 80 {
1635 format!("{}...", &asst_text[..asst_text.floor_char_boundary(80)])
1636 } else {
1637 asst_text.to_string()
1638 };
1639
1640 let tools_html: String = if t.tool_names.is_empty() {
1642 String::new()
1643 } else {
1644 t.tool_names.iter().map(|name| {
1645 format!("<span class=\"tool-tag\">{}</span>", escape_html(name))
1646 }).collect::<Vec<_>>().join("")
1647 };
1648 let hit_bar = html_progress(t.cache_hit_rate);
1649
1650 let model_short = short_model(&t.model);
1651 let utc_iso = t.timestamp.to_rfc3339();
1652 let time_fallback = t.timestamp.format("%H:%M:%S").to_string();
1653
1654 writeln!(out, "<tr{cls}>\
1655 <td data-value=\"{turn}\">{turn}</td>\
1656 <td><span data-utc=\"{utc}\">{time}</span></td>\
1657 <td class=\"text-left\">{model}</td>\
1658 <td class=\"text-left\" style=\"max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;\" title=\"{user_full}\">{user}</td>\
1659 <td class=\"text-left\" style=\"max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;\" title=\"{asst_full}\">{asst}</td>\
1660 <td class=\"text-left\" style=\"max-width:160px;line-height:1.6;\">{tools}</td>\
1661 <td data-value=\"{out_val}\">{out_fmt}</td>\
1662 <td data-value=\"{ctx_val}\">{ctx_fmt}</td>\
1663 <td data-value=\"{hit:.1}\">{hit_bar}</td>\
1664 <td data-value=\"{cost:.6}\">{cost_fmt}</td>\
1665 <td class=\"text-left\">{stop}</td>\
1666 <td class=\"text-left\">{compact}</td>\
1667 </tr>",
1668 cls = row_class,
1669 turn = t.turn_number,
1670 utc = utc_iso,
1671 time = time_fallback,
1672 model = model_short,
1673 user_full = escape_html(user_text),
1674 user = escape_html(&user_preview),
1675 asst_full = escape_html(asst_text),
1676 asst = escape_html(&asst_preview),
1677 tools = tools_html,
1678 out_val = t.output_tokens, out_fmt = format_compact(t.output_tokens),
1679 ctx_val = t.context_size, ctx_fmt = format_compact(t.context_size),
1680 hit = t.cache_hit_rate, hit_bar = hit_bar,
1681 cost = t.cost, cost_fmt = format_cost(t.cost),
1682 stop = escape_html(stop),
1683 compact = compact_mark,
1684 ).unwrap();
1685 }
1686
1687 writeln!(out, "</tbody></table>").unwrap();
1688}