1use std::fmt::Write as _;
2
3use chrono::Local;
4use crate::analysis::{OverviewResult, ProjectResult, SessionResult, TrendResult};
5use crate::pricing::calculator::{PricingCalculator, PRICING_FETCH_DATE, PRICING_SOURCE};
6
7const COLORS: &[&str] = &[
10 "#58a6ff", "#ff6b6b", "#ffd93d", "#6bcb77", "#4d96ff", "#9b59b6",
11 "#e17055", "#00cec9", "#fd79a8", "#fdcb6e",
12];
13
14pub struct ReportData {
18 pub overview: OverviewResult,
19 pub projects: ProjectResult,
20 pub trend: TrendResult,
21}
22
23fn escape_html(s: &str) -> String {
27 s.replace('&', "&")
28 .replace('<', "<")
29 .replace('>', ">")
30 .replace('"', """)
31 .replace('\'', "'")
32}
33
34fn format_number(n: u64) -> String {
36 let s = n.to_string();
37 let mut result = String::with_capacity(s.len() + s.len() / 3);
38 for (i, ch) in s.chars().rev().enumerate() {
39 if i > 0 && i % 3 == 0 {
40 result.push(',');
41 }
42 result.push(ch);
43 }
44 result.chars().rev().collect()
45}
46
47fn format_compact(n: u64) -> String {
49 if n >= 1_000_000_000 {
50 format!("{:.2}B", n as f64 / 1_000_000_000.0)
51 } else if n >= 1_000_000 {
52 format!("{:.2}M", n as f64 / 1_000_000.0)
53 } else if n >= 10_000 {
54 format!("{:.1}K", n as f64 / 1_000.0)
55 } else {
56 format_number(n)
57 }
58}
59
60fn format_cost(c: f64) -> String {
62 let abs = c.abs();
63 let whole = abs as u64;
64 let cents = ((abs - whole as f64) * 100.0).round() as u64;
65 let sign = if c < 0.0 { "-" } else { "" };
66 format!("{}${}.{:02}", sign, format_number(whole), cents)
67}
68
69fn color(i: usize) -> &'static str {
71 COLORS[i % COLORS.len()]
72}
73
74fn short_model(name: &str) -> String {
76 let s = name.strip_prefix("claude-").unwrap_or(name);
77 if s.len() > 9 {
79 let last_dash = s.rfind('-').unwrap_or(s.len());
80 let suffix = &s[last_dash + 1..];
81 if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_digit()) {
82 return s[..last_dash].to_string();
83 }
84 }
85 s.to_string()
86}
87
88fn format_duration(minutes: f64) -> String {
90 if minutes < 1.0 {
91 format!("{:.0}s", minutes * 60.0)
92 } else if minutes < 60.0 {
93 format!("{:.0}m", minutes)
94 } else {
95 let h = (minutes / 60.0).floor();
96 let m = (minutes % 60.0).round();
97 format!("{:.0}h{:.0}m", h, m)
98 }
99}
100
101fn css() -> &'static str {
104 r#"
105* { box-sizing: border-box; margin: 0; padding: 0; }
106body {
107 font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', sans-serif;
108 background: #0d1117; color: #c9d1d9;
109 max-width: 1400px; margin: 0 auto; padding: 20px;
110}
111.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
112.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; margin: 16px 0; }
113.kpi-value { font-size: 1.8em; font-weight: 700; color: #58a6ff; }
114.kpi-label { font-size: 0.85em; color: #8b949e; margin-top: 4px; }
115nav { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
116nav button {
117 padding: 8px 20px; border: 1px solid #30363d; border-radius: 6px;
118 background: #161b22; color: #c9d1d9; cursor: pointer; font-size: 14px;
119}
120nav button.active { background: #1f6feb; border-color: #1f6feb; color: #fff; }
121.tab-content { display: none; }
122.tab-content.active { display: block; }
123h1 { color: #58a6ff; font-size: 1.5em; margin-bottom: 16px; }
124h2 { color: #c9d1d9; font-size: 1.2em; margin: 20px 0 12px; }
125table { width: 100%; border-collapse: collapse; font-size: 13px; }
126th {
127 padding: 8px 10px; text-align: left; border-bottom: 2px solid #30363d;
128 color: #8b949e; cursor: pointer; user-select: none; white-space: nowrap;
129}
130th:hover { color: #58a6ff; }
131td { padding: 6px 10px; text-align: left; border-bottom: 1px solid #21262d; }
132tr:hover { background: #1c2128; }
133.sort-asc::after { content: ' \25b2'; color: #58a6ff; }
134.sort-desc::after { content: ' \25bc'; color: #58a6ff; }
135.expandable { cursor: pointer; }
136.session-detail { background: #0d1117; }
137.session-detail td { padding: 0; }
138.session-detail:hover { background: #0d1117; }
139.detail-content { padding: 16px; overflow-x: auto; }
140.detail-content table { font-size: 12px; }
141.compact-row { background: #2d1b1b !important; }
142.chart-container { position: relative; height: 350px; margin: 16px 0; }
143.grid-2x2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
144.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
145.footer { color: #484f58; font-size: 12px; margin-top: 30px; padding-top: 16px; border-top: 1px solid #21262d; }
146.header-row { display: flex; align-items: baseline; gap: 16px; margin-bottom: 16px; }
147.subtitle { color: #8b949e; font-size: 0.85em; }
148.expand-btn { background: none; border: none; color: #8b949e; cursor: pointer; font-size: 14px; padding: 2px 6px; }
149.expand-btn:hover { color: #58a6ff; }
150.project-session-row td:first-child { padding-left: 30px; }
151.project-session-row { background: #111822; }
152.project-session-row:hover { background: #1c2128; }
153.project-row { background: #161b22; font-weight: 600; }
154.progress-bar { display: inline-block; width: 80px; height: 14px; background: #21262d; border-radius: 7px; overflow: hidden; vertical-align: middle; }
155.progress-fill { height: 100%; border-radius: 7px; transition: width 0.3s; }
156.progress-text { display: inline-block; width: 45px; text-align: right; margin-left: 4px; font-size: 12px; }
157.stale-warning { color: #ff6b6b; margin-bottom: 8px; }
158.top-nav { display: flex; gap: 8px; margin-bottom: 12px; }
159.top-nav button { padding: 10px 24px; border: 2px solid #30363d; border-radius: 8px; background: #161b22; color: #c9d1d9; cursor: pointer; font-size: 15px; font-weight: 600; }
160.top-nav button.active { background: #1f6feb; border-color: #1f6feb; color: #fff; }
161.sub-nav { display: flex; gap: 8px; margin-bottom: 16px; }
162.sub-nav button { padding: 6px 16px; border: 1px solid #30363d; border-radius: 6px; background: #161b22; color: #c9d1d9; cursor: pointer; font-size: 13px; }
163.sub-nav button.active { background: #238636; border-color: #238636; color: #fff; }
164.source-content { display: none; }
165.source-content.active { display: block; }
166.sub-tab-content { display: none; }
167.sub-tab-content.active { display: block; }
168.tool-tag { display: inline-block; padding: 1px 6px; margin: 1px 2px; border-radius: 4px; background: #21262d; color: #8b949e; font-size: 11px; white-space: nowrap; }
169.tool-tag .tool-count { color: #58a6ff; font-weight: 600; margin-left: 2px; }
170.session-tools-cell { max-width: 260px; line-height: 1.8; }
171.agent-badge { display: inline-block; padding: 1px 5px; border-radius: 3px; background: #1f3a5f; color: #58a6ff; font-size: 11px; font-weight: 600; margin-left: 4px; }
172.turn-count-cell { white-space: nowrap; }
173.grid-1-2 { display: grid; grid-template-columns: 1fr 2fr; gap: 16px; }
174.chart-container-sm { position: relative; height: 250px; margin: 12px 0; }
175.model-legend { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
176.model-legend-item { display: flex; align-items: center; gap: 4px; font-size: 12px; color: #8b949e; }
177.model-legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
178@media (max-width: 900px) {
179 .grid-2x2 { grid-template-columns: 1fr; }
180 .grid-2 { grid-template-columns: 1fr; }
181 .grid-1-2 { grid-template-columns: 1fr; }
182 .kpi-grid { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
183}
184"#
185}
186
187fn js_common() -> &'static str {
190 r#"
191function showTab(name) {
192 document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
193 document.querySelectorAll('nav button').forEach(el => el.classList.remove('active'));
194 document.getElementById('tab-' + name).classList.add('active');
195 document.querySelector('nav button[data-tab="' + name + '"]').classList.add('active');
196}
197
198function sortTable(th, tableId) {
199 const table = document.getElementById(tableId);
200 const tbody = table.querySelector('tbody');
201 const rows = Array.from(tbody.querySelectorAll('tr:not(.session-detail)'));
202 const colIndex = th.cellIndex;
203 const isAsc = th.classList.contains('sort-asc');
204
205 table.querySelectorAll('th').forEach(h => {
206 h.classList.remove('sort-asc', 'sort-desc');
207 });
208
209 th.classList.add(isAsc ? 'sort-desc' : 'sort-asc');
210
211 rows.sort((a, b) => {
212 let va = a.cells[colIndex].getAttribute('data-value') || a.cells[colIndex].textContent;
213 let vb = b.cells[colIndex].getAttribute('data-value') || b.cells[colIndex].textContent;
214 const na = parseFloat(va.replace(/[\$,%]/g, ''));
215 const nb = parseFloat(vb.replace(/[\$,%]/g, ''));
216 if (!isNaN(na) && !isNaN(nb)) {
217 return isAsc ? nb - na : na - nb;
218 }
219 return isAsc ? vb.localeCompare(va) : va.localeCompare(vb);
220 });
221
222 rows.forEach(row => {
223 const detail = row.nextElementSibling;
224 tbody.appendChild(row);
225 if (detail && detail.classList.contains('session-detail')) {
226 tbody.appendChild(detail);
227 }
228 });
229}
230
231function toggleSession(btn) {
232 const row = btn.closest('tr');
233 const detail = row.nextElementSibling;
234 if (detail && detail.classList.contains('session-detail')) {
235 const isHidden = detail.style.display === 'none';
236 detail.style.display = isHidden ? 'table-row' : 'none';
237 btn.textContent = isHidden ? '\u25bc' : '\u25b6';
238 }
239}
240
241function toggleProject(btn, projectId) {
242 const sessionRows = document.querySelectorAll('.project-session-row.project-sessions-' + projectId);
243 const detailRows = document.querySelectorAll('.session-detail.project-sessions-' + projectId);
244 const isHidden = sessionRows.length > 0 && sessionRows[0].style.display === 'none';
245
246 if (isHidden) {
247 // Expand: show session rows only (not turn details)
248 sessionRows.forEach(r => r.style.display = 'table-row');
249 } else {
250 // Collapse: hide session rows AND any open turn details
251 sessionRows.forEach(r => {
252 r.style.display = 'none';
253 const sbtn = r.querySelector('.expand-btn');
254 if (sbtn) sbtn.textContent = '\u25b6';
255 });
256 detailRows.forEach(r => r.style.display = 'none');
257 }
258 btn.textContent = isHidden ? '\u25bc' : '\u25b6';
259}
260
261function shiftHeatmapToLocal(data) {
262 // Shift heatmap data from UTC to local timezone
263 const offset = -(new Date().getTimezoneOffset() / 60); // e.g. +8 for CST
264 const out = Array.from({length:7}, () => new Array(24).fill(0));
265 for (let d = 0; d < 7; d++) {
266 for (let h = 0; h < 24; h++) {
267 let newH = h + offset;
268 let newD = d;
269 if (newH >= 24) { newH -= 24; newD = (newD + 1) % 7; }
270 else if (newH < 0) { newH += 24; newD = (newD + 6) % 7; }
271 out[newD][newH] += data[d][h];
272 }
273 }
274 return out;
275}
276
277function drawHeatmap(canvasId, data) {
278 const canvas = document.getElementById(canvasId);
279 if (!canvas) return;
280 const ctx = canvas.getContext('2d');
281 const localData = shiftHeatmapToLocal(data);
282 const zhDays = ['周一','周二','周三','周四','周五','周六','周日'];
283 const enDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
284 const days = (currentLang === 'zh') ? zhDays : enDays;
285 const cellW = 28, cellH = 28, padL = 44, padT = 30;
286 canvas.width = padL + 24 * cellW + 10;
287 canvas.height = padT + 7 * cellH + 10;
288
289 const max = Math.max(...localData.flat(), 1);
290
291 for (let d = 0; d < 7; d++) {
292 for (let h = 0; h < 24; h++) {
293 const val = localData[d][h];
294 const intensity = val / max;
295 const r = Math.round(13 + intensity * 75);
296 const g = Math.round(17 + intensity * 130);
297 const b = Math.round(34 + intensity * 221);
298 ctx.fillStyle = 'rgb(' + r + ',' + g + ',' + b + ')';
299 ctx.fillRect(padL + h * cellW, padT + d * cellH, cellW - 2, cellH - 2);
300
301 if (val > 0) {
302 ctx.fillStyle = intensity > 0.6 ? '#fff' : '#8b949e';
303 ctx.font = '10px sans-serif';
304 ctx.textAlign = 'center';
305 ctx.fillText(val, padL + h * cellW + cellW/2 - 1, padT + d * cellH + cellH/2 + 3);
306 }
307 }
308 ctx.fillStyle = '#8b949e';
309 ctx.font = '11px sans-serif';
310 ctx.textAlign = 'right';
311 ctx.fillText(days[d], padL - 5, padT + d * cellH + cellH/2 + 3);
312 }
313 ctx.textAlign = 'center';
314 for (let h = 0; h < 24; h += 2) {
315 ctx.fillText(h.toString().padStart(2, '0'), padL + h * cellW + cellW/2, padT - 8);
316 }
317}
318
319function sortTableSimple(th, tableId) {
320 const table = document.getElementById(tableId);
321 const tbody = table.querySelector('tbody');
322 const rows = Array.from(tbody.querySelectorAll('tr'));
323 const colIndex = th.cellIndex;
324 const isAsc = th.classList.contains('sort-asc');
325
326 table.querySelectorAll('th').forEach(h => {
327 h.classList.remove('sort-asc', 'sort-desc');
328 });
329 th.classList.add(isAsc ? 'sort-desc' : 'sort-asc');
330
331 rows.sort((a, b) => {
332 let va = a.cells[colIndex].getAttribute('data-value') || a.cells[colIndex].textContent;
333 let vb = b.cells[colIndex].getAttribute('data-value') || b.cells[colIndex].textContent;
334 const na = parseFloat(va.replace(/[\$,%]/g, ''));
335 const nb = parseFloat(vb.replace(/[\$,%]/g, ''));
336 if (!isNaN(na) && !isNaN(nb)) {
337 return isAsc ? nb - na : na - nb;
338 }
339 return isAsc ? vb.localeCompare(va) : va.localeCompare(vb);
340 });
341 rows.forEach(row => tbody.appendChild(row));
342}
343
344function switchSource(sourceId) {
345 document.querySelectorAll('.source-content').forEach(el => el.style.display = 'none');
346 document.querySelectorAll('.top-nav button').forEach(el => el.classList.remove('active'));
347 document.getElementById('source-' + sourceId).style.display = 'block';
348 event.target.classList.add('active');
349 // Redraw heatmap for this source
350 if (window['_heatmapData_' + sourceId]) {
351 drawHeatmap('heatmap-' + sourceId, window['_heatmapData_' + sourceId]);
352 }
353}
354
355function showSubTab(sourceId, tabName) {
356 const container = document.getElementById('source-' + sourceId);
357 container.querySelectorAll('.sub-tab-content').forEach(el => el.classList.remove('active'));
358 container.querySelectorAll('.sub-nav button').forEach(el => el.classList.remove('active'));
359 document.getElementById(sourceId + '-tab-' + tabName).classList.add('active');
360 event.target.classList.add('active');
361 // Redraw heatmap when overview tab becomes visible
362 if (tabName === 'overview' && window['_heatmapData_' + sourceId]) {
363 drawHeatmap('heatmap-' + sourceId, window['_heatmapData_' + sourceId]);
364 }
365}
366
367let currentLang = localStorage.getItem('cc-lang') || 'en';
368function toggleLang() {
369 currentLang = currentLang === 'en' ? 'zh' : 'en';
370 localStorage.setItem('cc-lang', currentLang);
371 applyLang();
372}
373function applyLang() {
374 document.querySelectorAll('[data-en]').forEach(el => {
375 el.textContent = el.getAttribute('data-' + currentLang) || el.getAttribute('data-en');
376 });
377 const btn = document.getElementById('lang-btn');
378 if (btn) btn.textContent = currentLang === 'en' ? '中文' : 'EN';
379 // Redraw heatmaps with localized day names
380 for (const key of Object.keys(window)) {
381 if (key.startsWith('_heatmapData_')) {
382 const pfx = key.replace('_heatmapData_', '');
383 drawHeatmap('heatmap-' + pfx, window[key]);
384 }
385 }
386}
387// Convert UTC timestamps to local timezone
388function convertTimestamps() {
389 document.querySelectorAll('[data-utc]').forEach(el => {
390 const utc = el.getAttribute('data-utc');
391 const d = new Date(utc);
392 if (!isNaN(d)) {
393 const pad = n => String(n).padStart(2, '0');
394 el.textContent = pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
395 el.title = d.toLocaleString();
396 }
397 });
398 document.querySelectorAll('[data-utc-datetime]').forEach(el => {
399 const utc = el.getAttribute('data-utc-datetime');
400 const d = new Date(utc);
401 if (!isNaN(d)) {
402 const pad = n => String(n).padStart(2, '0');
403 el.textContent = pad(d.getMonth()+1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
404 el.title = d.toLocaleString();
405 }
406 });
407}
408document.addEventListener('DOMContentLoaded', function() { applyLang(); convertTimestamps(); });
409"#
410}
411
412fn render_source_tabs(
417 out: &mut String,
418 pfx: &str,
419 overview: &OverviewResult,
420 projects: &ProjectResult,
421 trend: &TrendResult,
422 calc: &PricingCalculator,
423) {
424 writeln!(out, r#"<nav class="sub-nav">"#).unwrap();
426 writeln!(out, r#"<button class="active" onclick="showSubTab('{pfx}','overview')" data-en="Overview" data-zh="概览">Overview</button>"#,
427 pfx = pfx).unwrap();
428 writeln!(out, r#"<button onclick="showSubTab('{pfx}','monthly')" data-en="Monthly" data-zh="月度">Monthly</button>"#,
429 pfx = pfx).unwrap();
430 writeln!(out, r#"<button onclick="showSubTab('{pfx}','projects')" data-en="Projects" data-zh="项目">Projects</button>"#,
431 pfx = pfx).unwrap();
432 writeln!(out, "</nav>").unwrap();
433
434 writeln!(out, r#"<div id="{pfx}-tab-overview" class="sub-tab-content active">"#, pfx = pfx).unwrap();
436 render_overview_tab(out, overview, pfx);
437 writeln!(out, "</div>").unwrap();
438
439 writeln!(out, r#"<div id="{pfx}-tab-monthly" class="sub-tab-content">"#, pfx = pfx).unwrap();
441 render_monthly_tab(out, overview, trend, pfx);
442 writeln!(out, "</div>").unwrap();
443
444 writeln!(out, r#"<div id="{pfx}-tab-projects" class="sub-tab-content">"#, pfx = pfx).unwrap();
446 render_projects_tab(out, projects, &overview.session_summaries, pfx);
447 writeln!(out, "</div>").unwrap();
448
449 writeln!(out, r#"<p style="color:#484f58;font-size:11px;margin-top:12px;">Price data: {} ({})</p>"#,
451 PRICING_SOURCE, PRICING_FETCH_DATE).unwrap();
452
453 let _ = calc;
454}
455
456pub fn render_full_report_html(
461 overview: &OverviewResult,
462 projects: &ProjectResult,
463 trend: &TrendResult,
464 calc: &PricingCalculator,
465) -> String {
466 let mut out = String::with_capacity(256 * 1024);
467
468 write!(out, r#"<!DOCTYPE html>
470<html lang="zh-CN">
471<head>
472 <meta charset="UTF-8">
473 <meta name="viewport" content="width=device-width, initial-scale=1.0">
474 <title>Claude Code Token Analyzer</title>
475 <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
476 <style>{css}</style>
477</head>
478<body>
479"#, css = css()).unwrap();
480
481 writeln!(out, r#"<div class="header-row">"#).unwrap();
483 writeln!(out, r#"<h1>Claude Code Token Analyzer</h1>"#).unwrap();
484 if let Some((start, end)) = &overview.quality.time_range {
485 writeln!(out, r#"<span class="subtitle">{} ~ {}</span>"#,
486 start.format("%Y-%m-%d"), end.format("%Y-%m-%d")).unwrap();
487 }
488 writeln!(out, r#"<button id="lang-btn" onclick="toggleLang()" style="margin-left:auto;padding:4px 12px;border:1px solid #30363d;border-radius:4px;background:#161b22;color:#c9d1d9;cursor:pointer;font-size:13px;">中文</button>"#).unwrap();
489 writeln!(out, "</div>").unwrap();
490
491 writeln!(out, r#"<div style="color:#8b949e;font-size:12px;margin-bottom:12px;line-height:1.6;" 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();
493
494 let pfx = "s1";
496 writeln!(out, r#"<div id="source-{pfx}" class="source-content active">"#, pfx = pfx).unwrap();
497 render_source_tabs(&mut out, pfx, overview, projects, trend, calc);
498 writeln!(out, "</div>").unwrap();
499
500 write!(out, "<script>{}</script>", js_common()).unwrap();
502
503 render_footer(&mut out, calc);
505
506 writeln!(out, "</body>\n</html>").unwrap();
507 out
508}
509
510pub fn render_dual_report_html(
515 source1_name: &str,
516 source1: &ReportData,
517 source2_name: &str,
518 source2: &ReportData,
519 calc: &PricingCalculator,
520) -> String {
521 let mut out = String::with_capacity(512 * 1024);
522
523 write!(out, r#"<!DOCTYPE html>
525<html lang="zh-CN">
526<head>
527 <meta charset="UTF-8">
528 <meta name="viewport" content="width=device-width, initial-scale=1.0">
529 <title>Claude Code Token Analyzer</title>
530 <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
531 <style>{css}</style>
532</head>
533<body>
534"#, css = css()).unwrap();
535
536 writeln!(out, r#"<div class="header-row">"#).unwrap();
538 writeln!(out, r#"<h1>Claude Code Token Analyzer</h1>"#).unwrap();
539 let time_range_str = {
541 let mut global_min = None;
542 let mut global_max = None;
543 for q in [&source1.overview.quality, &source2.overview.quality] {
544 if let Some((s, e)) = &q.time_range {
545 global_min = Some(global_min.map_or(*s, |m: chrono::DateTime<chrono::Utc>| m.min(*s)));
546 global_max = Some(global_max.map_or(*e, |m: chrono::DateTime<chrono::Utc>| m.max(*e)));
547 }
548 }
549 match (global_min, global_max) {
550 (Some(s), Some(e)) => format!("{} ~ {}", s.format("%Y-%m-%d"), e.format("%Y-%m-%d")),
551 _ => String::new(),
552 }
553 };
554 if !time_range_str.is_empty() {
555 writeln!(out, r#"<span class="subtitle">{}</span>"#, time_range_str).unwrap();
556 }
557 writeln!(out, r#"<button id="lang-btn" onclick="toggleLang()" style="margin-left:auto;padding:4px 12px;border:1px solid #30363d;border-radius:4px;background:#161b22;color:#c9d1d9;cursor:pointer;font-size:13px;">中文</button>"#).unwrap();
558 writeln!(out, "</div>").unwrap();
559
560 writeln!(out, r#"<div style="color:#8b949e;font-size:12px;margin-bottom:12px;line-height:1.6;" 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();
562
563 let s1_sessions = source1.overview.total_sessions;
565 let s2_sessions = source2.overview.total_sessions;
566 writeln!(out, r#"<nav class="top-nav">"#).unwrap();
567 writeln!(out, r#"<button class="active" onclick="switchSource('s1')">{} ({} sessions)</button>"#,
568 escape_html(source1_name), s1_sessions).unwrap();
569 writeln!(out, r#"<button onclick="switchSource('s2')">{} ({} sessions)</button>"#,
570 escape_html(source2_name), s2_sessions).unwrap();
571 writeln!(out, "</nav>").unwrap();
572
573 writeln!(out, r#"<div id="source-s1" class="source-content active">"#).unwrap();
575 render_source_tabs(&mut out, "s1", &source1.overview, &source1.projects, &source1.trend, calc);
576 writeln!(out, "</div>").unwrap();
577
578 writeln!(out, r#"<div id="source-s2" class="source-content">"#).unwrap();
580 render_source_tabs(&mut out, "s2", &source2.overview, &source2.projects, &source2.trend, calc);
581 writeln!(out, "</div>").unwrap();
582
583 write!(out, "<script>{}</script>", js_common()).unwrap();
585
586 render_footer(&mut out, calc);
588
589 writeln!(out, "</body>\n</html>").unwrap();
590 out
591}
592
593fn render_overview_tab(out: &mut String, overview: &OverviewResult, pfx: &str) {
596 writeln!(out, r#"<div class="kpi-grid">"#).unwrap();
598 write_kpi_i18n(out, &format_number(overview.total_sessions as u64), "Sessions / 会话", "会话数");
599 write_kpi_i18n(out, &format_number(overview.total_turns as u64), "Turns / 响应", "Turn 总数");
600 write_kpi_i18n(out, &format_compact(overview.total_output_tokens), "Claude Wrote", "Claude 写了");
601 write_kpi_i18n(out, &format_compact(overview.total_context_tokens), "Claude Read", "Claude 读了");
602 write_kpi_progress(out, overview.avg_cache_hit_rate, "Avg Cache Hit Rate");
603 write_kpi_i18n(out, &format_cost(overview.total_cost), "Token Value (API Rate)", "Token 价值 (API 费率)");
604 if overview.cache_savings.total_saved > 0.0 {
605 write_kpi_i18n(out, &format_cost(overview.cache_savings.total_saved),
606 &format!("Cache Savings ({:.0}%)", overview.cache_savings.savings_pct),
607 &format!("缓存节省 ({:.0}%)", overview.cache_savings.savings_pct));
608 }
609 writeln!(out, "</div>").unwrap();
610
611 writeln!(out, r#"<div class="grid-2">"#).unwrap();
613
614 if !overview.tool_counts.is_empty() {
616 let chart_id = format!("{}-toolRankChart", pfx);
617 let top_tools: Vec<&(String, usize)> = overview.tool_counts.iter().take(15).collect();
618 writeln!(out, r#"<div class="card">"#).unwrap();
619 writeln!(out, r#"<h2 data-en="Tool Usage Ranking" data-zh="工具使用排行">Tool Usage Ranking</h2>"#).unwrap();
620 writeln!(out, r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#, chart_id).unwrap();
621
622 let labels: Vec<String> = top_tools.iter().map(|(name, _)| format!("\"{}\"", escape_html(name))).collect();
623 let data: Vec<String> = top_tools.iter().map(|(_, count)| count.to_string()).collect();
624 let colors_list: Vec<String> = (0..top_tools.len()).map(|i| format!("\"{}\"", color(i))).collect();
625
626 writeln!(out, r#"<script>
627new Chart(document.getElementById('{chart_id}'), {{
628 type: 'bar',
629 data: {{
630 labels: [{labels}],
631 datasets: [{{ label: 'Count', data: [{data}], backgroundColor: [{colors}], borderWidth: 0, borderRadius: 4 }}]
632 }},
633 options: {{
634 indexAxis: 'y', responsive: true, maintainAspectRatio: false,
635 plugins: {{ legend: {{ display: false }} }},
636 scales: {{
637 x: {{ ticks: {{ color: '#8b949e' }}, grid: {{ color: '#21262d' }} }},
638 y: {{ ticks: {{ color: '#c9d1d9', font: {{ size: 11 }} }}, grid: {{ color: '#21262d' }} }}
639 }}
640 }}
641}});
642</script>"#, chart_id = chart_id, labels = labels.join(","), data = data.join(","), colors = colors_list.join(",")).unwrap();
643 writeln!(out, "</div>").unwrap();
644 }
645
646 if !overview.session_summaries.is_empty() {
648 let chart_id = format!("{}-costDistChart", pfx);
649 writeln!(out, r#"<div class="card">"#).unwrap();
650 writeln!(out, r#"<h2 data-en="Session Cost Distribution" data-zh="会话费用分布">Session Cost Distribution</h2>"#).unwrap();
651 writeln!(out, r#"<p style="color:#8b949e;font-size:12px;margin-bottom:8px;" data-en="Number of sessions in each cost range. Helps identify your typical session cost and expensive outliers." data-zh="每个费用区间的会话数量。帮助识别典型会话费用和昂贵的异常值。">Number of sessions in each cost range. Helps identify your typical session cost and expensive outliers.</p>"#).unwrap();
652 writeln!(out, r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#, chart_id).unwrap();
653
654 let costs: Vec<f64> = overview.session_summaries.iter().map(|s| s.cost).collect();
656 let max_cost = costs.iter().cloned().fold(0.0f64, f64::max);
657 let bucket_count = 12usize;
658 let bucket_size = if max_cost > 0.0 { (max_cost / bucket_count as f64).max(0.01) } else { 0.1 };
659 let mut buckets = vec![0usize; bucket_count + 1];
660 for c in &costs {
661 let idx = (c / bucket_size).floor() as usize;
662 let idx = idx.min(bucket_count);
663 buckets[idx] += 1;
664 }
665 while buckets.len() > 1 && *buckets.last().unwrap() == 0 {
667 buckets.pop();
668 }
669
670 let labels: Vec<String> = (0..buckets.len()).map(|i| {
671 let lo = i as f64 * bucket_size;
672 let hi = lo + bucket_size;
673 format!("\"${:.2}-${:.2}\"", lo, hi)
674 }).collect();
675 let data: Vec<String> = buckets.iter().map(|c| c.to_string()).collect();
676
677 writeln!(out, r#"<script>
678new Chart(document.getElementById('{chart_id}'), {{
679 type: 'bar',
680 data: {{
681 labels: [{labels}],
682 datasets: [{{
683 label: 'Sessions',
684 data: [{data}],
685 backgroundColor: 'rgba(107,203,119,0.6)',
686 borderColor: '#6bcb77',
687 borderWidth: 1,
688 borderRadius: 4
689 }}]
690 }},
691 options: {{
692 responsive: true, maintainAspectRatio: false,
693 plugins: {{ legend: {{ display: false }} }},
694 scales: {{
695 x: {{ ticks: {{ color: '#8b949e', maxRotation: 45 }}, grid: {{ color: '#21262d' }} }},
696 y: {{ ticks: {{ color: '#8b949e', stepSize: 1 }}, grid: {{ color: '#21262d' }}, title: {{ display: true, text: 'Sessions', color: '#8b949e' }} }}
697 }}
698 }}
699}});
700</script>"#, chart_id = chart_id, labels = labels.join(","), data = data.join(",")).unwrap();
701 writeln!(out, "</div>").unwrap();
702 }
703
704 writeln!(out, "</div>").unwrap(); writeln!(out, r#"<div class="grid-2" style="margin-top:16px;">"#).unwrap();
708
709 {
711 let canvas_id = format!("heatmap-{}", pfx);
712 writeln!(out, r#"<div class="card">"#).unwrap();
713 writeln!(out, r#"<h2 data-en="Activity Heatmap (Local Time)" data-zh="活跃热力图(本地时间)">Activity Heatmap (Local Time)</h2>"#).unwrap();
714 writeln!(out, r#"<p style="color:#8b949e;font-size:12px;margin-bottom:8px;" data-en="Each cell = number of turns in that hour slot (local timezone). Rows = weekdays, columns = hours (00-23). Darker = more active." data-zh="每个格子 = 该时段的 turn 数量(本地时区)。行 = 星期几,列 = 小时(00-23)。颜色越深 = 越活跃。">Each cell = number of turns in that hour slot (local timezone). Rows = weekdays, columns = hours (00-23). Darker = more active.</p>"#).unwrap();
715 writeln!(out, r#"<canvas id="{}"></canvas>"#, canvas_id).unwrap();
716
717 let mut matrix_js = String::from("[");
718 for d in 0..7 {
719 if d > 0 { matrix_js.push(','); }
720 matrix_js.push('[');
721 for h in 0..24 {
722 if h > 0 { matrix_js.push(','); }
723 write!(matrix_js, "{}", overview.weekday_hour_matrix[d][h]).unwrap();
724 }
725 matrix_js.push(']');
726 }
727 matrix_js.push(']');
728
729 writeln!(out, r#"<script>
730window._heatmapData_{pfx} = {matrix};
731document.addEventListener('DOMContentLoaded', function() {{
732 drawHeatmap('{canvas_id}', window._heatmapData_{pfx});
733}});
734</script>"#, pfx = pfx, matrix = matrix_js, canvas_id = canvas_id).unwrap();
735 writeln!(out, "</div>").unwrap();
736 }
737
738 {
740 let chart_id = format!("{}-scatterChart", pfx);
741 writeln!(out, r#"<div class="card">"#).unwrap();
742 writeln!(out, r#"<h2 data-en="Session Efficiency (Turns vs Cost)" data-zh="会话效率(Turns vs 费用)">Session Efficiency (Turns vs Cost)</h2>"#).unwrap();
743 writeln!(out, r#"<p style="color:#8b949e;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();
744 writeln!(out, r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#, chart_id).unwrap();
745
746 let max_output: u64 = overview.session_summaries.iter().map(|s| s.output_tokens).max().unwrap_or(1);
747 let mut scatter_data = String::from("[");
748 for (i, s) in overview.session_summaries.iter().enumerate() {
749 if i > 0 { scatter_data.push(','); }
750 let radius = if max_output > 0 {
751 3.0 + (s.output_tokens as f64 / max_output as f64) * 20.0
752 } else { 3.0 };
753 let cpt = if s.turn_count > 0 { s.cost / s.turn_count as f64 } else { 0.0 };
754 write!(scatter_data, "{{x:{},y:{:.4},r:{:.1},cpt:{:.4},out:{}}}", s.turn_count, s.cost, radius, cpt, s.output_tokens).unwrap();
755 }
756 scatter_data.push(']');
757
758 writeln!(out, r#"<script>
759new Chart(document.getElementById('{chart_id}'), {{
760 type: 'bubble',
761 data: {{
762 datasets: [{{
763 label: 'Sessions',
764 data: {data},
765 backgroundColor: 'rgba(88,166,255,0.4)',
766 borderColor: '#58a6ff',
767 borderWidth: 1
768 }}]
769 }},
770 options: {{
771 responsive: true, maintainAspectRatio: false,
772 plugins: {{
773 legend: {{ display: false }},
774 tooltip: {{ callbacks: {{
775 label: function(ctx) {{
776 const d = ctx.raw;
777 return ['Turns: ' + d.x + ' Cost: $' + d.y.toFixed(2), 'Cost/Turn: $' + d.cpt.toFixed(3) + ' Output: ' + d.out.toLocaleString()];
778 }}
779 }} }}
780 }},
781 scales: {{
782 x: {{ title: {{ display: true, text: 'Turn Count', color: '#8b949e' }}, ticks: {{ color: '#8b949e' }}, grid: {{ color: '#21262d' }} }},
783 y: {{ title: {{ display: true, text: 'Cost ($)', color: '#8b949e' }}, ticks: {{ color: '#8b949e', callback: function(v) {{ return '$' + v; }} }}, grid: {{ color: '#21262d' }} }}
784 }}
785 }}
786}});
787</script>"#, chart_id = chart_id, data = scatter_data).unwrap();
788 writeln!(out, "</div>").unwrap();
789 }
790
791 writeln!(out, "</div>").unwrap(); }
793
794fn render_monthly_tab(out: &mut String, _overview: &OverviewResult, trend: &TrendResult, pfx: &str) {
797 if trend.entries.is_empty() {
798 writeln!(out, r#"<div class="card"><p style="color:#8b949e;">No trend data available.</p></div>"#).unwrap();
799 return;
800 }
801
802 let latest_month = trend.entries.last().map(|e| &e.label[..7]).unwrap_or("");
804
805 let mut month_cost = 0.0f64;
807 let mut month_turns = 0usize;
808 let mut month_sessions = 0usize;
809 let mut month_output = 0u64;
810
811 let mut daily_entries: Vec<&crate::analysis::TrendEntry> = Vec::new();
812
813 for entry in &trend.entries {
814 if entry.label.starts_with(latest_month) {
815 month_cost += entry.cost;
816 month_turns += entry.turn_count;
817 month_sessions += entry.session_count;
818 month_output += entry.tokens.output_tokens;
819 daily_entries.push(entry);
820 }
821 }
822
823 let avg_cost_per_turn = if month_turns > 0 { month_cost / month_turns as f64 } else { 0.0 };
824
825 writeln!(out, r#"<h2 data-en="Current Period: {m}" data-zh="当前周期:{m}">Current Period: {m}</h2>"#, m = escape_html(latest_month)).unwrap();
827 writeln!(out, r#"<div class="kpi-grid">"#).unwrap();
828 write_kpi_i18n(out, &format_number(month_sessions as u64), "Sessions", "会话数");
829 write_kpi_i18n(out, &format_number(month_turns as u64), "Turns", "Turn 数");
830 write_kpi_i18n(out, &format_compact(month_output), "Output Tokens", "输出 Token");
831 write_kpi_i18n(out, &format_cost(month_cost), "Cost", "费用");
832 write_kpi_i18n(out, &format!("${:.4}", avg_cost_per_turn), "Avg Cost/Turn", "平均每 Turn 费用");
833 writeln!(out, "</div>").unwrap();
834
835 if !daily_entries.is_empty() {
837 let chart_id = format!("{}-dailyCostChart", pfx);
838 writeln!(out, r#"<div class="card">"#).unwrap();
839 writeln!(out, r#"<h2 data-en="Daily Cost & Cost/Turn ({})" data-zh="每日费用 & 每 Turn 费用 ({})">Daily Cost & Cost/Turn ({})</h2>"#,
840 escape_html(latest_month), escape_html(latest_month), escape_html(latest_month)).unwrap();
841 writeln!(out, r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#, chart_id).unwrap();
842
843 let labels: Vec<String> = daily_entries.iter().map(|e| format!("\"{}\"", &e.label[5..])).collect();
844 let cost_data: Vec<String> = daily_entries.iter().map(|e| format!("{:.2}", e.cost)).collect();
845 let cpt_data: Vec<String> = daily_entries.iter().map(|e| {
846 if e.turn_count > 0 { format!("{:.4}", e.cost / e.turn_count as f64) } else { "0".to_string() }
847 }).collect();
848 let turn_data: Vec<String> = daily_entries.iter().map(|e| e.turn_count.to_string()).collect();
849
850 writeln!(out, r#"<script>
851new Chart(document.getElementById('{chart_id}'), {{
852 type: 'bar',
853 data: {{
854 labels: [{labels}],
855 datasets: [
856 {{
857 label: 'Cost ($)',
858 data: [{cost_data}],
859 backgroundColor: 'rgba(88,166,255,0.6)',
860 borderColor: '#58a6ff',
861 borderWidth: 1,
862 borderRadius: 4,
863 yAxisID: 'y',
864 order: 2
865 }},
866 {{
867 label: 'Cost/Turn ($)',
868 data: [{cpt_data}],
869 type: 'line',
870 borderColor: '#ffd93d',
871 backgroundColor: 'rgba(255,217,61,0.1)',
872 pointRadius: 3,
873 tension: 0.3,
874 yAxisID: 'y1',
875 order: 1
876 }}
877 ]
878 }},
879 options: {{
880 responsive: true, maintainAspectRatio: false,
881 plugins: {{
882 legend: {{ labels: {{ color: '#c9d1d9' }} }},
883 tooltip: {{ callbacks: {{
884 afterLabel: function(ctx) {{
885 const turns = [{turn_data}];
886 return 'Turns: ' + turns[ctx.dataIndex];
887 }}
888 }} }}
889 }},
890 scales: {{
891 x: {{ ticks: {{ color: '#8b949e' }}, grid: {{ color: '#21262d' }} }},
892 y: {{ position: 'left', ticks: {{ color: '#8b949e', callback: function(v) {{ return '$' + v; }} }}, grid: {{ color: '#21262d' }}, title: {{ display: true, text: 'Cost ($)', color: '#8b949e' }} }},
893 y1: {{ position: 'right', ticks: {{ color: '#ffd93d', callback: function(v) {{ return '$' + v.toFixed(3); }} }}, grid: {{ drawOnChartArea: false }}, title: {{ display: true, text: 'Cost/Turn ($)', color: '#ffd93d' }} }}
894 }}
895 }}
896}});
897</script>"#, chart_id = chart_id,
898 labels = labels.join(","),
899 cost_data = cost_data.join(","),
900 cpt_data = cpt_data.join(","),
901 turn_data = turn_data.join(","),
902 ).unwrap();
903 writeln!(out, "</div>").unwrap();
904 }
905
906 {
908 let mut all_models: Vec<String> = Vec::new();
910 for entry in &daily_entries {
911 for model_name in entry.models.keys() {
912 let short = short_model(model_name);
913 if !all_models.contains(&short) {
914 all_models.push(short);
915 }
916 }
917 }
918 all_models.sort();
919
920 if all_models.len() > 1 || (!all_models.is_empty() && daily_entries.len() > 1) {
921 let chart_id = format!("{}-modelDistChart", pfx);
922 writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
923 writeln!(out, r#"<h2 data-en="Model Usage per Day (Output Tokens)" data-zh="每日模型使用(输出 Token)">Model Usage per Day (Output Tokens)</h2>"#).unwrap();
924 writeln!(out, r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#, chart_id).unwrap();
925
926 let labels: Vec<String> = daily_entries.iter().map(|e| format!("\"{}\"", &e.label[5..])).collect();
927
928 let mut datasets = String::new();
929 for (mi, model_short) in all_models.iter().enumerate() {
930 if mi > 0 { datasets.push(','); }
931 let values: Vec<String> = daily_entries.iter().map(|e| {
932 let total: u64 = e.models.iter()
934 .filter(|(k, _)| short_model(k) == *model_short)
935 .map(|(_, v)| *v)
936 .sum();
937 total.to_string()
938 }).collect();
939 write!(datasets, "{{label:\"{}\",data:[{}],backgroundColor:\"{}\",borderWidth:0,borderRadius:2}}",
940 escape_html(model_short), values.join(","), color(mi)).unwrap();
941 }
942
943 writeln!(out, r#"<script>
944new Chart(document.getElementById('{chart_id}'), {{
945 type: 'bar',
946 data: {{
947 labels: [{labels}],
948 datasets: [{datasets}]
949 }},
950 options: {{
951 responsive: true, maintainAspectRatio: false,
952 plugins: {{ legend: {{ labels: {{ color: '#c9d1d9' }} }} }},
953 scales: {{
954 x: {{ stacked: true, ticks: {{ color: '#8b949e' }}, grid: {{ color: '#21262d' }} }},
955 y: {{ stacked: true, ticks: {{ color: '#8b949e' }}, grid: {{ color: '#21262d' }}, title: {{ display: true, text: 'Output Tokens', color: '#8b949e' }} }}
956 }}
957 }}
958}});
959</script>"#, chart_id = chart_id, labels = labels.join(","), datasets = datasets).unwrap();
960 writeln!(out, "</div>").unwrap();
961 }
962 }
963
964 {
966 let mut months: std::collections::BTreeMap<String, (usize, usize, u64, u64, u64, f64)> = std::collections::BTreeMap::new();
968 for entry in &trend.entries {
969 let month_key = entry.label[..7].to_string();
970 let e = months.entry(month_key).or_insert((0, 0, 0, 0, 0, 0.0));
971 e.0 += entry.session_count;
972 e.1 += entry.turn_count;
973 e.2 += entry.tokens.output_tokens;
974 e.3 += entry.tokens.cache_creation_tokens;
975 e.4 += entry.tokens.cache_read_tokens;
976 e.5 += entry.cost;
977 }
978
979 if months.len() > 1 {
980 let tbl_id = format!("{}-tbl-monthly", pfx);
981 writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
982 writeln!(out, r#"<h2 data-en="Monthly Summary" data-zh="月度汇总">Monthly Summary</h2>"#).unwrap();
983 writeln!(out, r#"<div style="overflow-x:auto;">"#).unwrap();
984 writeln!(out, r#"<table id="{}">"#, tbl_id).unwrap();
985 writeln!(out, "<thead><tr>\
986 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Month\" data-zh=\"月份\">Month</th>\
987 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Sessions\" data-zh=\"会话\">Sessions</th>\
988 <th onclick=\"sortTableSimple(this,'{id}')\">Turns</th>\
989 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Output Tokens\" data-zh=\"输出 Token\">Output Tokens</th>\
990 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Cost/Turn\" data-zh=\"每 Turn 费用\">Cost/Turn</th>\
991 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Cost\" data-zh=\"费用\">Cost</th>\
992 </tr></thead>", id = tbl_id).unwrap();
993 writeln!(out, "<tbody>").unwrap();
994
995 for (month, (sessions, turns, output, _cache_write, _cache_read, cost)) in &months {
996 let cpt = if *turns > 0 { cost / *turns as f64 } else { 0.0 };
997 writeln!(out, "<tr>\
998 <td data-value=\"{}\">{}</td>\
999 <td data-value=\"{}\">{}</td>\
1000 <td data-value=\"{}\">{}</td>\
1001 <td data-value=\"{}\">{}</td>\
1002 <td data-value=\"{:.6}\">${:.4}</td>\
1003 <td data-value=\"{:.4}\">{}</td>\
1004 </tr>",
1005 escape_html(month), escape_html(month),
1006 sessions, format_number(*sessions as u64),
1007 turns, format_number(*turns as u64),
1008 output, format_compact(*output),
1009 cpt, cpt,
1010 cost, format_cost(*cost),
1011 ).unwrap();
1012 }
1013
1014 writeln!(out, "</tbody></table></div></div>").unwrap();
1015 }
1016 }
1017
1018 {
1020 let tbl_id = format!("{}-tbl-daily", pfx);
1021 writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
1022 writeln!(out, r#"<h2 data-en="{} Breakdown" data-zh="{}明细">{} Breakdown</h2>"#,
1023 escape_html(&trend.group_label), escape_html(&trend.group_label), escape_html(&trend.group_label)).unwrap();
1024 writeln!(out, r#"<div style="overflow-x:auto;">"#).unwrap();
1025 writeln!(out, r#"<table id="{}">"#, tbl_id).unwrap();
1026 writeln!(out, "<thead><tr>\
1027 <th onclick=\"sortTableSimple(this,'{id}')\">{}</th>\
1028 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Sessions\" data-zh=\"会话\">Sessions</th>\
1029 <th onclick=\"sortTableSimple(this,'{id}')\">Turns</th>\
1030 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Output Tokens\" data-zh=\"输出 Token\">Output Tokens</th>\
1031 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Cost/Turn\" data-zh=\"每 Turn 费用\">Cost/Turn</th>\
1032 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Cost\" data-zh=\"费用\">Cost</th>\
1033 <th data-en=\"Models\" data-zh=\"模型\">Models</th>\
1034 </tr></thead>", escape_html(&trend.group_label), id = tbl_id).unwrap();
1035 writeln!(out, "<tbody>").unwrap();
1036
1037 for entry in &trend.entries {
1038 let cpt = if entry.turn_count > 0 { entry.cost / entry.turn_count as f64 } else { 0.0 };
1039 let mut model_list: Vec<(&String, &u64)> = entry.models.iter().collect();
1041 model_list.sort_by(|a, b| b.1.cmp(a.1));
1042 let models_html: String = model_list.iter().take(3).map(|(m, tokens)| {
1043 format!("<span class=\"tool-tag\">{} <span class=\"tool-count\">{}</span></span>",
1044 escape_html(&short_model(m)), format_compact(**tokens))
1045 }).collect::<Vec<_>>().join("");
1046
1047 writeln!(out, "<tr>\
1048 <td data-value=\"{}\">{}</td>\
1049 <td data-value=\"{}\">{}</td>\
1050 <td data-value=\"{}\">{}</td>\
1051 <td data-value=\"{}\">{}</td>\
1052 <td data-value=\"{:.6}\">${:.4}</td>\
1053 <td data-value=\"{:.4}\">{}</td>\
1054 <td>{}</td>\
1055 </tr>",
1056 escape_html(&entry.label), escape_html(&entry.label),
1057 entry.session_count, format_number(entry.session_count as u64),
1058 entry.turn_count, format_number(entry.turn_count as u64),
1059 entry.tokens.output_tokens, format_compact(entry.tokens.output_tokens),
1060 cpt, cpt,
1061 entry.cost, format_cost(entry.cost),
1062 models_html,
1063 ).unwrap();
1064 }
1065
1066 writeln!(out, "</tbody></table></div></div>").unwrap();
1067 }
1068}
1069
1070fn render_projects_tab(out: &mut String, projects: &ProjectResult, sessions: &[crate::analysis::SessionSummary], pfx: &str) {
1073 {
1075 let top_n = projects.projects.iter().take(10).collect::<Vec<_>>();
1076 if !top_n.is_empty() {
1077 let chart_id = format!("{}-projectCostChart", pfx);
1078 writeln!(out, r#"<div class="card">"#).unwrap();
1079 writeln!(out, "<h2>Project Cost Top 10</h2>").unwrap();
1080 writeln!(out, r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#, chart_id).unwrap();
1081
1082 let labels: Vec<String> = top_n.iter().map(|p| format!("\"{}\"", escape_html(&p.display_name))).collect();
1083 let data: Vec<String> = top_n.iter().map(|p| format!("{:.2}", p.cost)).collect();
1084 let colors_list: Vec<String> = (0..top_n.len()).map(|i| format!("\"{}\"", color(i))).collect();
1085
1086 writeln!(out, r#"<script>
1087new Chart(document.getElementById('{chart_id}'), {{
1088 type: 'bar',
1089 data: {{
1090 labels: [{labels}],
1091 datasets: [{{ label: 'Cost ($)', data: [{data}], backgroundColor: [{colors}], borderWidth: 0, borderRadius: 4 }}]
1092 }},
1093 options: {{
1094 indexAxis: 'y', responsive: true, maintainAspectRatio: false,
1095 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx) {{ return '$' + ctx.raw.toFixed(2); }} }} }} }},
1096 scales: {{
1097 x: {{ ticks: {{ color: '#8b949e', callback: function(v) {{ return '$' + v; }} }}, grid: {{ color: '#21262d' }} }},
1098 y: {{ ticks: {{ color: '#c9d1d9' }}, grid: {{ color: '#21262d' }} }}
1099 }}
1100 }}
1101}});
1102</script>"#, chart_id = chart_id, labels = labels.join(","), data = data.join(","), colors = colors_list.join(",")).unwrap();
1103 writeln!(out, "</div>").unwrap();
1104 }
1105 }
1106
1107 let tbl_id = format!("{}-tbl-projects-drill", pfx);
1109 writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
1110 writeln!(out, r#"<h2 data-en="Project Drill-Down" data-zh="项目钻取">Project Drill-Down</h2>"#).unwrap();
1111 writeln!(out, r#"<div style="overflow-x:auto;">"#).unwrap();
1112 writeln!(out, r#"<table id="{}">"#, tbl_id).unwrap();
1113 writeln!(out, "<thead><tr>\
1114 <th></th>\
1115 <th data-en=\"Project / Session\" data-zh=\"项目 / 会话\">Project / Session</th>\
1116 <th data-en=\"Sessions\" data-zh=\"会话\">Sessions</th>\
1117 <th data-en=\"Turns (Agent)\" data-zh=\"Turns (Agent)\">Turns (Agent)</th>\
1118 <th>Output</th>\
1119 <th>CacheHit%</th>\
1120 <th data-en=\"Tools\" data-zh=\"工具\">Tools</th>\
1121 <th data-en=\"Cost\" data-zh=\"费用\">Cost</th>\
1122 </tr></thead>").unwrap();
1123 writeln!(out, "<tbody>").unwrap();
1124
1125 let mut sessions_by_project: std::collections::HashMap<String, Vec<&crate::analysis::SessionSummary>> = std::collections::HashMap::new();
1127 for s in sessions {
1128 sessions_by_project.entry(s.project_display_name.clone()).or_default().push(s);
1129 }
1130
1131 for (i, proj) in projects.projects.iter().enumerate() {
1132 let cache_hit = if proj.tokens.context_tokens() > 0 {
1133 proj.tokens.cache_read_tokens as f64 / proj.tokens.context_tokens() as f64 * 100.0
1134 } else { 0.0 };
1135 let pid = format!("{}-p{}", pfx, i);
1136
1137 let hit_bar = html_progress(cache_hit);
1139 let turns_display = if proj.agent_turns > 0 {
1140 format!("{} <span class=\"agent-badge\">+{} agent</span>",
1141 format_number(proj.total_turns as u64), proj.agent_turns)
1142 } else {
1143 format_number(proj.total_turns as u64)
1144 };
1145 writeln!(out, r#"<tr class="project-row expandable">"#).unwrap();
1146 writeln!(out, r#"<td><button class="expand-btn" onclick="toggleProject(this,'{pid}')">{arrow}</button></td>"#,
1147 pid = pid, arrow = "\u{25b6}").unwrap();
1148 writeln!(out, "\
1149 <td><strong>{name}</strong></td>\
1150 <td data-value=\"{sess}\">{sess_fmt}</td>\
1151 <td class=\"turn-count-cell\" data-value=\"{turns}\">{turns_display}</td>\
1152 <td data-value=\"{out}\">{out_fmt}</td>\
1153 <td data-value=\"{hit:.1}\">{hit_bar}</td>\
1154 <td></td>\
1155 <td data-value=\"{cost:.4}\">{cost_fmt}</td>",
1156 name = escape_html(&proj.display_name),
1157 sess = proj.session_count, sess_fmt = format_number(proj.session_count as u64),
1158 turns = proj.total_turns, turns_display = turns_display,
1159 out = proj.tokens.output_tokens, out_fmt = format_compact(proj.tokens.output_tokens),
1160 hit = cache_hit, hit_bar = hit_bar,
1161 cost = proj.cost, cost_fmt = format_cost(proj.cost),
1162 ).unwrap();
1163 writeln!(out, "</tr>").unwrap();
1164
1165 if let Some(proj_sessions) = sessions_by_project.get(&proj.display_name) {
1167 let mut sorted = proj_sessions.clone();
1168 sorted.sort_by(|a, b| b.cost.partial_cmp(&a.cost).unwrap_or(std::cmp::Ordering::Equal));
1169
1170 for s in &sorted {
1171 let utc_iso = s.first_timestamp.map(|t| t.to_rfc3339()).unwrap_or_default();
1172 let date_fallback = s.first_timestamp.map(|t| t.format("%m-%d %H:%M").to_string()).unwrap_or_default();
1173 let s_hit = html_progress(s.cache_hit_rate);
1174
1175 writeln!(out, r#"<tr class="project-session-row project-sessions-{pid} expandable" style="display:none">"#,
1177 pid = pid).unwrap();
1178
1179 let has_detail = s.turn_details.is_some();
1180 if has_detail {
1181 writeln!(out, r#"<td><button class="expand-btn" onclick="toggleSession(this)">{}</button></td>"#, "\u{25b6}").unwrap();
1182 } else {
1183 writeln!(out, "<td></td>").unwrap();
1184 }
1185
1186 let s_turns_display = if s.agent_turn_count > 0 {
1188 format!("{} <span class=\"agent-badge\">+{} agent</span>",
1189 format_number(s.turn_count as u64), s.agent_turn_count)
1190 } else {
1191 format_number(s.turn_count as u64)
1192 };
1193
1194 let tools_html: String = s.top_tools.iter().take(5).map(|(name, count)| {
1196 format!("<span class=\"tool-tag\">{} <span class=\"tool-count\">{}</span></span>",
1197 escape_html(name), count)
1198 }).collect::<Vec<_>>().join("");
1199
1200 let duration_str = format_duration(s.duration_minutes);
1201 let short_sid = &s.session_id[..s.session_id.len().min(10)];
1202
1203 writeln!(out, "\
1204 <td style=\"padding-left:30px;text-align:left;\">{sid} <span style=\"color:#8b949e;font-size:11px;\">(<span data-utc-datetime=\"{utc}\">{date}</span> · {dur})</span></td>\
1205 <td></td>\
1206 <td class=\"turn-count-cell\" data-value=\"{turns}\">{turns_display}</td>\
1207 <td data-value=\"{out}\">{out_fmt}</td>\
1208 <td data-value=\"{hit:.1}\">{hit_bar}</td>\
1209 <td class=\"session-tools-cell\">{tools}</td>\
1210 <td data-value=\"{cost:.4}\">{cost_fmt}</td>",
1211 sid = escape_html(short_sid),
1212 utc = utc_iso,
1213 date = date_fallback,
1214 dur = duration_str,
1215 turns = s.turn_count, turns_display = s_turns_display,
1216 out = s.output_tokens, out_fmt = format_compact(s.output_tokens),
1217 hit = s.cache_hit_rate, hit_bar = s_hit,
1218 tools = tools_html,
1219 cost = s.cost, cost_fmt = format_cost(s.cost),
1220 ).unwrap();
1221 writeln!(out, "</tr>").unwrap();
1222
1223 if let Some(ref details) = s.turn_details {
1225 writeln!(out, r#"<tr class="session-detail project-sessions-{pid}" style="display:none"><td colspan="8"><div class="detail-content">"#,
1226 pid = pid).unwrap();
1227 render_turn_detail_table(out, details, &format!("{}-detail-proj-{}", pfx, escape_html(&s.session_id)));
1228 writeln!(out, "</div></td></tr>").unwrap();
1229 }
1230 }
1231 }
1232 }
1233
1234 writeln!(out, "</tbody></table></div></div>").unwrap();
1235}
1236
1237fn render_turn_detail_table(out: &mut String, turns: &[crate::analysis::TurnDetail], table_id: &str) {
1240 render_turn_table_impl(out, turns, table_id);
1241}
1242
1243fn render_footer(out: &mut String, calc: &PricingCalculator) {
1246 let stale_warning = if PricingCalculator::is_pricing_stale() {
1247 format!(r#"<p class="stale-warning">Warning: Price data is {} days old, costs may be inaccurate!</p>"#,
1248 PricingCalculator::pricing_age_days())
1249 } else { String::new() };
1250 let _ = calc;
1251
1252 let now_local = Local::now().format("%Y-%m-%d %H:%M");
1253 writeln!(out, r#"<div class="footer">
1254 {}
1255 <p>Price data: {} ({}) | Generated by cc-token-analyzer at {}</p>
1256</div>"#, stale_warning, PRICING_SOURCE, PRICING_FETCH_DATE, now_local).unwrap();
1257}
1258
1259fn write_kpi(out: &mut String, value: &str, label: &str) {
1262 writeln!(out, r#"<div class="card" style="text-align:center;"><div class="kpi-value">{}</div><div class="kpi-label">{}</div></div>"#,
1263 value, label).unwrap();
1264}
1265
1266fn write_kpi_i18n(out: &mut String, value: &str, en: &str, zh: &str) {
1268 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>"#,
1269 value, en, zh, en).unwrap();
1270}
1271
1272fn write_kpi_progress(out: &mut String, pct: f64, label: &str) {
1274 let bar_color = if pct >= 90.0 { "#6bcb77" } else if pct >= 70.0 { "#ffd93d" } else { "#ff6b6b" };
1275 writeln!(out, r#"<div class="card" style="text-align:center;">
1276 <div class="kpi-value">{:.1}%</div>
1277 <div style="margin:8px auto;width:120px;"><div class="progress-bar" style="width:120px;">
1278 <div class="progress-fill" style="width:{:.1}%;background:{};"></div>
1279 </div></div>
1280 <div class="kpi-label">{}</div>
1281 </div>"#, pct, pct, bar_color, label).unwrap();
1282}
1283
1284fn html_progress(pct: f64) -> String {
1286 let bar_color = if pct >= 90.0 { "#6bcb77" } else if pct >= 70.0 { "#ffd93d" } else { "#ff6b6b" };
1287 format!(r#"<div class="progress-bar"><div class="progress-fill" style="width:{:.1}%;background:{};"></div></div><span class="progress-text">{:.1}%</span>"#,
1288 pct, bar_color, pct)
1289}
1290
1291pub fn render_session_html(result: &SessionResult) -> String {
1295 let mut out = String::with_capacity(64 * 1024);
1296
1297 let short_id = &result.session_id[..result.session_id.len().min(12)];
1298
1299 write!(out, r#"<!DOCTYPE html>
1301<html lang="zh-CN">
1302<head>
1303 <meta charset="UTF-8">
1304 <meta name="viewport" content="width=device-width, initial-scale=1.0">
1305 <title>Session {short_id} - Claude Code Token Analyzer</title>
1306 <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
1307 <style>{css}</style>
1308</head>
1309<body>
1310"#, short_id = escape_html(short_id), css = css()).unwrap();
1311
1312 writeln!(out, r#"<div class="header-row">"#).unwrap();
1314 writeln!(out, "<h1>Session Analysis</h1>").unwrap();
1315 writeln!(out, r#"<span class="subtitle">{} · {}</span>"#,
1316 escape_html(&result.session_id), escape_html(&result.project)).unwrap();
1317 writeln!(out, "</div>").unwrap();
1318
1319 let cache_hit_rate = {
1321 let total_ctx = result.total_tokens.context_tokens();
1322 if total_ctx > 0 {
1323 result.total_tokens.cache_read_tokens as f64 / total_ctx as f64 * 100.0
1324 } else { 0.0 }
1325 };
1326
1327 writeln!(out, r#"<div class="kpi-grid">"#).unwrap();
1328 write_kpi(&mut out, &format_duration(result.duration_minutes), "Duration");
1329 write_kpi(&mut out, &short_model(&result.model), "Model");
1330 write_kpi(&mut out, &format_number(result.max_context), "Max Context");
1331 write_kpi(&mut out, &format!("{:.1}%", cache_hit_rate), "Cache Hit Rate");
1332 write_kpi(&mut out, &format_number(result.compaction_count as u64), "Compactions");
1333 write_kpi(&mut out, &format_cost(result.total_cost), "Total Cost");
1334 writeln!(out, "</div>").unwrap();
1335
1336 if !result.turn_details.is_empty() {
1338 writeln!(out, r#"<div class="grid-2">"#).unwrap();
1339
1340 {
1342 writeln!(out, r#"<div class="card">"#).unwrap();
1343 writeln!(out, "<h2>Context Growth</h2>").unwrap();
1344 writeln!(out, r#"<div class="chart-container"><canvas id="contextChart"></canvas></div>"#).unwrap();
1345
1346 let turn_nums: Vec<String> = result.turn_details.iter().map(|t| t.turn_number.to_string()).collect();
1347 let ctx_sizes: Vec<String> = result.turn_details.iter().map(|t| t.context_size.to_string()).collect();
1348 let pr = if result.turn_details.len() > 50 { 0 } else { 3 };
1349
1350 writeln!(out, r#"<script>
1351new Chart(document.getElementById('contextChart'), {{
1352 type: 'line',
1353 data: {{
1354 labels: [{turns}],
1355 datasets: [{{
1356 label: 'Context Size',
1357 data: [{sizes}],
1358 borderColor: '#58a6ff',
1359 backgroundColor: 'rgba(88,166,255,0.1)',
1360 fill: true, tension: 0.3, pointRadius: {pr}
1361 }}]
1362 }},
1363 options: {{
1364 responsive: true, maintainAspectRatio: false,
1365 plugins: {{ legend: {{ labels: {{ color: '#c9d1d9' }} }} }},
1366 scales: {{
1367 x: {{ title: {{ display: true, text: 'Turn', color: '#8b949e' }}, ticks: {{ color: '#8b949e' }}, grid: {{ color: '#21262d' }} }},
1368 y: {{ title: {{ display: true, text: 'Context Tokens', color: '#8b949e' }}, ticks: {{ color: '#8b949e' }}, grid: {{ color: '#21262d' }} }}
1369 }}
1370 }}
1371}});
1372</script>"#,
1373 turns = turn_nums.join(","),
1374 sizes = ctx_sizes.join(","),
1375 pr = pr,
1376 ).unwrap();
1377 writeln!(out, "</div>").unwrap();
1378 }
1379
1380 {
1382 writeln!(out, r#"<div class="card">"#).unwrap();
1383 writeln!(out, "<h2>Cache Hit Rate</h2>").unwrap();
1384 writeln!(out, r#"<div class="chart-container"><canvas id="cacheChart"></canvas></div>"#).unwrap();
1385
1386 let turn_nums: Vec<String> = result.turn_details.iter().map(|t| t.turn_number.to_string()).collect();
1387 let cache_rates: Vec<String> = result.turn_details.iter().map(|t| format!("{:.2}", t.cache_hit_rate)).collect();
1388 let pr = if result.turn_details.len() > 50 { 0 } else { 3 };
1389
1390 writeln!(out, r#"<script>
1391new Chart(document.getElementById('cacheChart'), {{
1392 type: 'line',
1393 data: {{
1394 labels: [{turns}],
1395 datasets: [{{
1396 label: 'Cache Hit Rate (%)',
1397 data: [{rates}],
1398 borderColor: '#ffd93d',
1399 backgroundColor: 'rgba(255,217,61,0.1)',
1400 fill: true, tension: 0.3, pointRadius: {pr}
1401 }}]
1402 }},
1403 options: {{
1404 responsive: true, maintainAspectRatio: false,
1405 plugins: {{ legend: {{ labels: {{ color: '#c9d1d9' }} }} }},
1406 scales: {{
1407 x: {{ title: {{ display: true, text: 'Turn', color: '#8b949e' }}, ticks: {{ color: '#8b949e' }}, grid: {{ color: '#21262d' }} }},
1408 y: {{ title: {{ display: true, text: 'Hit Rate (%)', color: '#8b949e' }}, ticks: {{ color: '#8b949e' }}, grid: {{ color: '#21262d' }}, min: 0, max: 100 }}
1409 }}
1410 }}
1411}});
1412</script>"#,
1413 turns = turn_nums.join(","),
1414 rates = cache_rates.join(","),
1415 pr = pr,
1416 ).unwrap();
1417 writeln!(out, "</div>").unwrap();
1418 }
1419
1420 writeln!(out, "</div>").unwrap(); }
1422
1423 if !result.stop_reason_counts.is_empty() {
1425 writeln!(out, r#"<div class="card">"#).unwrap();
1426 writeln!(out, "<h2>Stop Reason Distribution</h2>").unwrap();
1427 writeln!(out, r#"<div class="chart-container" style="max-width:400px;margin:0 auto;"><canvas id="stopReasonChart"></canvas></div>"#).unwrap();
1428
1429 let mut reasons: Vec<(&String, &usize)> = result.stop_reason_counts.iter().collect();
1430 reasons.sort_by(|a, b| b.1.cmp(a.1));
1431
1432 let labels: Vec<String> = reasons.iter().map(|(r, _)| format!("\"{}\"", escape_html(r))).collect();
1433 let data: Vec<String> = reasons.iter().map(|(_, c)| c.to_string()).collect();
1434 let colors_list: Vec<String> = (0..reasons.len()).map(|i| format!("\"{}\"", color(i))).collect();
1435
1436 writeln!(out, r#"<script>
1437new Chart(document.getElementById('stopReasonChart'), {{
1438 type: 'doughnut',
1439 data: {{
1440 labels: [{labels}],
1441 datasets: [{{ data: [{data}], backgroundColor: [{colors}], borderWidth: 0 }}]
1442 }},
1443 options: {{
1444 responsive: true, maintainAspectRatio: false,
1445 plugins: {{ legend: {{ position: 'bottom', labels: {{ color: '#c9d1d9' }} }} }}
1446 }}
1447}});
1448</script>"#,
1449 labels = labels.join(","), data = data.join(","), colors = colors_list.join(",")).unwrap();
1450 writeln!(out, "</div>").unwrap();
1451 }
1452
1453 writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
1455 writeln!(out, "<h2>Turn Details</h2>").unwrap();
1456 writeln!(out, r#"<div style="overflow-x:auto;">"#).unwrap();
1457 render_turn_table_impl(&mut out, &result.turn_details, "tbl-session-turns");
1458 writeln!(out, "</div></div>").unwrap();
1459
1460 write!(out, "<script>{}</script>", js_common()).unwrap();
1462
1463 let now = Local::now().format("%Y-%m-%d %H:%M");
1465 writeln!(out, r#"<div class="footer">
1466 <p>Session: {} | Generated by cc-token-analyzer at {}</p>
1467</div>"#, escape_html(&result.session_id), now).unwrap();
1468
1469 writeln!(out, "</body>\n</html>").unwrap();
1470 out
1471}
1472
1473fn render_turn_table_impl(out: &mut String, turns: &[crate::analysis::TurnDetail], table_id: &str) {
1475 writeln!(out, r#"<table id="{}" style="font-size:12px;">"#, table_id).unwrap();
1476 writeln!(out, "<thead><tr>\
1477 <th onclick=\"sortTableSimple(this,'{id}')\">Turn</th>\
1478 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Time\" data-zh=\"时间\">Time</th>\
1479 <th>Model</th>\
1480 <th style=\"text-align:left\" data-en=\"User\" data-zh=\"用户\">User</th>\
1481 <th style=\"text-align:left\" data-en=\"Assistant\" data-zh=\"助手\">Assistant</th>\
1482 <th style=\"text-align:left\" data-en=\"Tools\" data-zh=\"工具\">Tools</th>\
1483 <th onclick=\"sortTableSimple(this,'{id}')\">Output</th>\
1484 <th onclick=\"sortTableSimple(this,'{id}')\">Context</th>\
1485 <th onclick=\"sortTableSimple(this,'{id}')\">Hit%</th>\
1486 <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Cost\" data-zh=\"费用\">Cost</th>\
1487 <th>Stop</th>\
1488 <th>\u{26a1}</th>\
1489 </tr></thead>", id = table_id).unwrap();
1490 writeln!(out, "<tbody>").unwrap();
1491
1492 for t in turns {
1493 let row_class = if t.is_compaction {
1494 " class=\"compact-row\""
1495 } else if t.is_agent {
1496 " style=\"border-left:2px solid #58a6ff;\""
1497 } else {
1498 ""
1499 };
1500 let stop = t.stop_reason.as_deref().unwrap_or("-");
1501 let compact_mark = if t.is_compaction { "\u{26a1}" } else if t.is_agent { "\u{1f916}" } else { "" };
1502
1503 let user_text = t.user_text.as_deref().unwrap_or("");
1504 let user_preview = if user_text.len() > 80 {
1505 format!("{}...", &user_text[..user_text.floor_char_boundary(80)])
1506 } else {
1507 user_text.to_string()
1508 };
1509 let asst_text = t.assistant_text.as_deref().unwrap_or("");
1510 let asst_preview = if asst_text.len() > 80 {
1511 format!("{}...", &asst_text[..asst_text.floor_char_boundary(80)])
1512 } else {
1513 asst_text.to_string()
1514 };
1515
1516 let tools_html: String = if t.tool_names.is_empty() {
1518 String::new()
1519 } else {
1520 t.tool_names.iter().map(|name| {
1521 format!("<span class=\"tool-tag\">{}</span>", escape_html(name))
1522 }).collect::<Vec<_>>().join("")
1523 };
1524 let hit_bar = html_progress(t.cache_hit_rate);
1525
1526 let model_short = short_model(&t.model);
1527 let utc_iso = t.timestamp.to_rfc3339();
1528 let time_fallback = t.timestamp.format("%H:%M:%S").to_string();
1529
1530 writeln!(out, "<tr{cls}>\
1531 <td data-value=\"{turn}\">{turn}</td>\
1532 <td><span data-utc=\"{utc}\">{time}</span></td>\
1533 <td>{model}</td>\
1534 <td style=\"text-align:left;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;\" title=\"{user_full}\">{user}</td>\
1535 <td style=\"text-align:left;max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;\" title=\"{asst_full}\">{asst}</td>\
1536 <td style=\"text-align:left;max-width:160px;line-height:1.6;\">{tools}</td>\
1537 <td data-value=\"{out_val}\">{out_fmt}</td>\
1538 <td data-value=\"{ctx_val}\">{ctx_fmt}</td>\
1539 <td data-value=\"{hit:.1}\">{hit_bar}</td>\
1540 <td data-value=\"{cost:.6}\">{cost_fmt}</td>\
1541 <td>{stop}</td>\
1542 <td>{compact}</td>\
1543 </tr>",
1544 cls = row_class,
1545 turn = t.turn_number,
1546 utc = utc_iso,
1547 time = time_fallback,
1548 model = model_short,
1549 user_full = escape_html(user_text),
1550 user = escape_html(&user_preview),
1551 asst_full = escape_html(asst_text),
1552 asst = escape_html(&asst_preview),
1553 tools = tools_html,
1554 out_val = t.output_tokens, out_fmt = format_compact(t.output_tokens),
1555 ctx_val = t.context_size, ctx_fmt = format_compact(t.context_size),
1556 hit = t.cache_hit_rate, hit_bar = hit_bar,
1557 cost = t.cost, cost_fmt = format_cost(t.cost),
1558 stop = escape_html(stop),
1559 compact = compact_mark,
1560 ).unwrap();
1561 }
1562
1563 writeln!(out, "</tbody></table>").unwrap();
1564}