Skip to main content

cc_token_usage/output/
html.rs

1use std::fmt::Write as _;
2
3use crate::analysis::{
4    AggregatedTokens, OverviewResult, ProjectResult, SessionResult, TrendResult,
5};
6use crate::pricing::calculator::{PricingCalculator, PRICING_FETCH_DATE, PRICING_SOURCE};
7
8// ─── Chart Colors ────────────────────────────────────────────────────────────
9
10const COLORS: &[&str] = &[
11    "#58a6ff", "#ff6b6b", "#ffd93d", "#6bcb77", "#4d96ff", "#9b59b6",
12    "#e17055", "#00cec9", "#fd79a8", "#fdcb6e",
13];
14
15// ─── ReportData ──────────────────────────────────────────────────────────────
16
17/// Bundled analysis results for one data source.
18pub struct ReportData {
19    pub overview: OverviewResult,
20    pub projects: ProjectResult,
21    pub trend: TrendResult,
22}
23
24// ─── Helpers ─────────────────────────────────────────────────────────────────
25
26/// Escape HTML special characters.
27fn escape_html(s: &str) -> String {
28    s.replace('&', "&")
29        .replace('<', "&lt;")
30        .replace('>', "&gt;")
31        .replace('"', "&quot;")
32        .replace('\'', "&#x27;")
33}
34
35/// Format a number with thousands separators for display.
36fn format_number(n: u64) -> String {
37    let s = n.to_string();
38    let mut result = String::with_capacity(s.len() + s.len() / 3);
39    for (i, ch) in s.chars().rev().enumerate() {
40        if i > 0 && i % 3 == 0 {
41            result.push(',');
42        }
43        result.push(ch);
44    }
45    result.chars().rev().collect()
46}
47
48/// Format large numbers with M/B/K suffixes for compact display.
49fn format_compact(n: u64) -> String {
50    if n >= 1_000_000_000 {
51        format!("{:.2}B", n as f64 / 1_000_000_000.0)
52    } else if n >= 1_000_000 {
53        format!("{:.2}M", n as f64 / 1_000_000.0)
54    } else if n >= 10_000 {
55        format!("{:.1}K", n as f64 / 1_000.0)
56    } else {
57        format_number(n)
58    }
59}
60
61/// Format a cost value: 1234.5 -> "$1,234.50"
62fn format_cost(c: f64) -> String {
63    let abs = c.abs();
64    let whole = abs as u64;
65    let cents = ((abs - whole as f64) * 100.0).round() as u64;
66    let sign = if c < 0.0 { "-" } else { "" };
67    format!("{}${}.{:02}", sign, format_number(whole), cents)
68}
69
70/// Pick a color from the palette by index.
71fn color(i: usize) -> &'static str {
72    COLORS[i % COLORS.len()]
73}
74
75/// Shorten model name: claude-haiku-4-5-20251001 → haiku-4-5
76fn short_model(name: &str) -> String {
77    let s = name.strip_prefix("claude-").unwrap_or(name);
78    // Remove date suffix like -20251001 or -20250929
79    if s.len() > 9 {
80        let last_dash = s.rfind('-').unwrap_or(s.len());
81        let suffix = &s[last_dash + 1..];
82        if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_digit()) {
83            return s[..last_dash].to_string();
84        }
85    }
86    s.to_string()
87}
88
89/// Format duration in minutes to a human-readable string.
90fn format_duration(minutes: f64) -> String {
91    if minutes < 1.0 {
92        format!("{:.0}s", minutes * 60.0)
93    } else if minutes < 60.0 {
94        format!("{:.0}m", minutes)
95    } else {
96        let h = (minutes / 60.0).floor();
97        let m = (minutes % 60.0).round();
98        format!("{:.0}h{:.0}m", h, m)
99    }
100}
101
102// ─── CSS ─────────────────────────────────────────────────────────────────────
103
104fn css() -> &'static str {
105    r#"
106* { box-sizing: border-box; margin: 0; padding: 0; }
107body {
108  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', sans-serif;
109  background: #0d1117; color: #c9d1d9;
110  max-width: 1400px; margin: 0 auto; padding: 20px;
111}
112.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
113.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; margin: 16px 0; }
114.kpi-value { font-size: 1.8em; font-weight: 700; color: #58a6ff; }
115.kpi-label { font-size: 0.85em; color: #8b949e; margin-top: 4px; }
116nav { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
117nav button {
118  padding: 8px 20px; border: 1px solid #30363d; border-radius: 6px;
119  background: #161b22; color: #c9d1d9; cursor: pointer; font-size: 14px;
120}
121nav button.active { background: #1f6feb; border-color: #1f6feb; color: #fff; }
122.tab-content { display: none; }
123.tab-content.active { display: block; }
124h1 { color: #58a6ff; font-size: 1.5em; margin-bottom: 16px; }
125h2 { color: #c9d1d9; font-size: 1.2em; margin: 20px 0 12px; }
126table { width: 100%; border-collapse: collapse; font-size: 13px; }
127th {
128  padding: 8px 10px; text-align: left; border-bottom: 2px solid #30363d;
129  color: #8b949e; cursor: pointer; user-select: none; white-space: nowrap;
130}
131th:hover { color: #58a6ff; }
132td { padding: 6px 10px; text-align: left; border-bottom: 1px solid #21262d; }
133tr:hover { background: #1c2128; }
134.sort-asc::after { content: ' \25b2'; color: #58a6ff; }
135.sort-desc::after { content: ' \25bc'; color: #58a6ff; }
136.expandable { cursor: pointer; }
137.session-detail { background: #0d1117; }
138.session-detail td { padding: 0; }
139.session-detail:hover { background: #0d1117; }
140.detail-content { padding: 16px; overflow-x: auto; }
141.detail-content table { font-size: 12px; }
142.compact-row { background: #2d1b1b !important; }
143.chart-container { position: relative; height: 350px; margin: 16px 0; }
144.grid-2x2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
145.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
146.footer { color: #484f58; font-size: 12px; margin-top: 30px; padding-top: 16px; border-top: 1px solid #21262d; }
147.header-row { display: flex; align-items: baseline; gap: 16px; margin-bottom: 16px; }
148.subtitle { color: #8b949e; font-size: 0.85em; }
149.expand-btn { background: none; border: none; color: #8b949e; cursor: pointer; font-size: 14px; padding: 2px 6px; }
150.expand-btn:hover { color: #58a6ff; }
151.project-session-row td:first-child { padding-left: 30px; }
152.project-session-row { background: #111822; }
153.project-session-row:hover { background: #1c2128; }
154.project-row { background: #161b22; font-weight: 600; }
155.progress-bar { display: inline-block; width: 80px; height: 14px; background: #21262d; border-radius: 7px; overflow: hidden; vertical-align: middle; }
156.progress-fill { height: 100%; border-radius: 7px; transition: width 0.3s; }
157.progress-text { display: inline-block; width: 45px; text-align: right; margin-left: 4px; font-size: 12px; }
158.stale-warning { color: #ff6b6b; margin-bottom: 8px; }
159.top-nav { display: flex; gap: 8px; margin-bottom: 12px; }
160.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; }
161.top-nav button.active { background: #1f6feb; border-color: #1f6feb; color: #fff; }
162.sub-nav { display: flex; gap: 8px; margin-bottom: 16px; }
163.sub-nav button { padding: 6px 16px; border: 1px solid #30363d; border-radius: 6px; background: #161b22; color: #c9d1d9; cursor: pointer; font-size: 13px; }
164.sub-nav button.active { background: #238636; border-color: #238636; color: #fff; }
165.source-content { display: none; }
166.source-content.active { display: block; }
167.sub-tab-content { display: none; }
168.sub-tab-content.active { display: block; }
169@media (max-width: 900px) {
170  .grid-2x2 { grid-template-columns: 1fr; }
171  .grid-2 { grid-template-columns: 1fr; }
172  .kpi-grid { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
173}
174"#
175}
176
177// ─── JavaScript ──────────────────────────────────────────────────────────────
178
179fn js_common() -> &'static str {
180    r#"
181function showTab(name) {
182  document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
183  document.querySelectorAll('nav button').forEach(el => el.classList.remove('active'));
184  document.getElementById('tab-' + name).classList.add('active');
185  document.querySelector('nav button[data-tab="' + name + '"]').classList.add('active');
186}
187
188function sortTable(th, tableId) {
189  const table = document.getElementById(tableId);
190  const tbody = table.querySelector('tbody');
191  const rows = Array.from(tbody.querySelectorAll('tr:not(.session-detail)'));
192  const colIndex = th.cellIndex;
193  const isAsc = th.classList.contains('sort-asc');
194
195  table.querySelectorAll('th').forEach(h => {
196    h.classList.remove('sort-asc', 'sort-desc');
197  });
198
199  th.classList.add(isAsc ? 'sort-desc' : 'sort-asc');
200
201  rows.sort((a, b) => {
202    let va = a.cells[colIndex].getAttribute('data-value') || a.cells[colIndex].textContent;
203    let vb = b.cells[colIndex].getAttribute('data-value') || b.cells[colIndex].textContent;
204    const na = parseFloat(va.replace(/[\$,%]/g, ''));
205    const nb = parseFloat(vb.replace(/[\$,%]/g, ''));
206    if (!isNaN(na) && !isNaN(nb)) {
207      return isAsc ? nb - na : na - nb;
208    }
209    return isAsc ? vb.localeCompare(va) : va.localeCompare(vb);
210  });
211
212  rows.forEach(row => {
213    const detail = row.nextElementSibling;
214    tbody.appendChild(row);
215    if (detail && detail.classList.contains('session-detail')) {
216      tbody.appendChild(detail);
217    }
218  });
219}
220
221function toggleSession(btn) {
222  const row = btn.closest('tr');
223  const detail = row.nextElementSibling;
224  if (detail && detail.classList.contains('session-detail')) {
225    const isHidden = detail.style.display === 'none';
226    detail.style.display = isHidden ? 'table-row' : 'none';
227    btn.textContent = isHidden ? '\u25bc' : '\u25b6';
228  }
229}
230
231function toggleProject(btn, projectId) {
232  const sessionRows = document.querySelectorAll('.project-session-row.project-sessions-' + projectId);
233  const detailRows = document.querySelectorAll('.session-detail.project-sessions-' + projectId);
234  const isHidden = sessionRows.length > 0 && sessionRows[0].style.display === 'none';
235
236  if (isHidden) {
237    // Expand: show session rows only (not turn details)
238    sessionRows.forEach(r => r.style.display = 'table-row');
239  } else {
240    // Collapse: hide session rows AND any open turn details
241    sessionRows.forEach(r => {
242      r.style.display = 'none';
243      const sbtn = r.querySelector('.expand-btn');
244      if (sbtn) sbtn.textContent = '\u25b6';
245    });
246    detailRows.forEach(r => r.style.display = 'none');
247  }
248  btn.textContent = isHidden ? '\u25bc' : '\u25b6';
249}
250
251function drawHeatmap(canvasId, data) {
252  const canvas = document.getElementById(canvasId);
253  if (!canvas) return;
254  const ctx = canvas.getContext('2d');
255  const days = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
256  const cellW = 28, cellH = 28, padL = 40, padT = 30;
257  canvas.width = padL + 24 * cellW + 10;
258  canvas.height = padT + 7 * cellH + 10;
259
260  const max = Math.max(...data.flat(), 1);
261
262  for (let d = 0; d < 7; d++) {
263    for (let h = 0; h < 24; h++) {
264      const val = data[d][h];
265      const intensity = val / max;
266      const r = Math.round(13 + intensity * 75);
267      const g = Math.round(17 + intensity * 130);
268      const b = Math.round(34 + intensity * 221);
269      ctx.fillStyle = 'rgb(' + r + ',' + g + ',' + b + ')';
270      ctx.fillRect(padL + h * cellW, padT + d * cellH, cellW - 2, cellH - 2);
271
272      if (val > 0) {
273        ctx.fillStyle = intensity > 0.6 ? '#fff' : '#8b949e';
274        ctx.font = '10px sans-serif';
275        ctx.textAlign = 'center';
276        ctx.fillText(val, padL + h * cellW + cellW/2 - 1, padT + d * cellH + cellH/2 + 3);
277      }
278    }
279    ctx.fillStyle = '#8b949e';
280    ctx.font = '11px sans-serif';
281    ctx.textAlign = 'right';
282    ctx.fillText(days[d], padL - 5, padT + d * cellH + cellH/2 + 3);
283  }
284  ctx.textAlign = 'center';
285  for (let h = 0; h < 24; h += 2) {
286    ctx.fillText(h.toString().padStart(2, '0'), padL + h * cellW + cellW/2, padT - 8);
287  }
288}
289
290function sortTableSimple(th, tableId) {
291  const table = document.getElementById(tableId);
292  const tbody = table.querySelector('tbody');
293  const rows = Array.from(tbody.querySelectorAll('tr'));
294  const colIndex = th.cellIndex;
295  const isAsc = th.classList.contains('sort-asc');
296
297  table.querySelectorAll('th').forEach(h => {
298    h.classList.remove('sort-asc', 'sort-desc');
299  });
300  th.classList.add(isAsc ? 'sort-desc' : 'sort-asc');
301
302  rows.sort((a, b) => {
303    let va = a.cells[colIndex].getAttribute('data-value') || a.cells[colIndex].textContent;
304    let vb = b.cells[colIndex].getAttribute('data-value') || b.cells[colIndex].textContent;
305    const na = parseFloat(va.replace(/[\$,%]/g, ''));
306    const nb = parseFloat(vb.replace(/[\$,%]/g, ''));
307    if (!isNaN(na) && !isNaN(nb)) {
308      return isAsc ? nb - na : na - nb;
309    }
310    return isAsc ? vb.localeCompare(va) : va.localeCompare(vb);
311  });
312  rows.forEach(row => tbody.appendChild(row));
313}
314
315function switchSource(sourceId) {
316  document.querySelectorAll('.source-content').forEach(el => el.style.display = 'none');
317  document.querySelectorAll('.top-nav button').forEach(el => el.classList.remove('active'));
318  document.getElementById('source-' + sourceId).style.display = 'block';
319  event.target.classList.add('active');
320  // Redraw heatmap for this source
321  if (window['_heatmapData_' + sourceId]) {
322    drawHeatmap('heatmap-' + sourceId, window['_heatmapData_' + sourceId]);
323  }
324}
325
326function showSubTab(sourceId, tabName) {
327  const container = document.getElementById('source-' + sourceId);
328  container.querySelectorAll('.sub-tab-content').forEach(el => el.classList.remove('active'));
329  container.querySelectorAll('.sub-nav button').forEach(el => el.classList.remove('active'));
330  document.getElementById(sourceId + '-tab-' + tabName).classList.add('active');
331  event.target.classList.add('active');
332  // Redraw heatmap when overview tab becomes visible
333  if (tabName === 'overview' && window['_heatmapData_' + sourceId]) {
334    drawHeatmap('heatmap-' + sourceId, window['_heatmapData_' + sourceId]);
335  }
336}
337
338let currentLang = localStorage.getItem('cc-lang') || 'en';
339function toggleLang() {
340  currentLang = currentLang === 'en' ? 'zh' : 'en';
341  localStorage.setItem('cc-lang', currentLang);
342  applyLang();
343}
344function applyLang() {
345  document.querySelectorAll('[data-en]').forEach(el => {
346    el.textContent = el.getAttribute('data-' + currentLang) || el.getAttribute('data-en');
347  });
348  const btn = document.getElementById('lang-btn');
349  if (btn) btn.textContent = currentLang === 'en' ? '中文' : 'EN';
350}
351document.addEventListener('DOMContentLoaded', applyLang);
352"#
353}
354
355// ─── Source Tabs Renderer ────────────────────────────────────────────────────
356
357/// Render sub-nav + 3 tab contents (overview, monthly, projects) for one data source.
358/// All element IDs are prefixed with `pfx` to avoid conflicts in dual-source mode.
359fn render_source_tabs(
360    out: &mut String,
361    pfx: &str,
362    overview: &OverviewResult,
363    projects: &ProjectResult,
364    trend: &TrendResult,
365    calc: &PricingCalculator,
366) {
367    // Sub-navigation
368    writeln!(out, r#"<nav class="sub-nav">"#).unwrap();
369    writeln!(out, r#"<button class="active" onclick="showSubTab('{pfx}','overview')" data-en="Overview" data-zh="概览">Overview</button>"#,
370        pfx = pfx).unwrap();
371    writeln!(out, r#"<button onclick="showSubTab('{pfx}','monthly')" data-en="Monthly" data-zh="月度">Monthly</button>"#,
372        pfx = pfx).unwrap();
373    writeln!(out, r#"<button onclick="showSubTab('{pfx}','projects')" data-en="Projects" data-zh="项目">Projects</button>"#,
374        pfx = pfx).unwrap();
375    writeln!(out, "</nav>").unwrap();
376
377    // Tab 1: Overview
378    writeln!(out, r#"<div id="{pfx}-tab-overview" class="sub-tab-content active">"#, pfx = pfx).unwrap();
379    render_overview_tab(out, overview, pfx);
380    writeln!(out, "</div>").unwrap();
381
382    // Tab 2: Monthly
383    writeln!(out, r#"<div id="{pfx}-tab-monthly" class="sub-tab-content">"#, pfx = pfx).unwrap();
384    render_monthly_tab(out, overview, trend, pfx);
385    writeln!(out, "</div>").unwrap();
386
387    // Tab 3: Projects
388    writeln!(out, r#"<div id="{pfx}-tab-projects" class="sub-tab-content">"#, pfx = pfx).unwrap();
389    render_projects_tab(out, projects, &overview.session_summaries, pfx);
390    writeln!(out, "</div>").unwrap();
391
392    // Pricing source note
393    writeln!(out, r#"<p style="color:#484f58;font-size:11px;margin-top:12px;">Price data: {} ({})</p>"#,
394        PRICING_SOURCE, PRICING_FETCH_DATE).unwrap();
395
396    let _ = calc;
397}
398
399// ─── 1. Full Report (single source) ─────────────────────────────────────────
400
401/// Generate a comprehensive HTML dashboard with 3 tabs, charts, and sortable tables.
402/// Single data source — no top-level source switcher.
403pub fn render_full_report_html(
404    overview: &OverviewResult,
405    projects: &ProjectResult,
406    trend: &TrendResult,
407    calc: &PricingCalculator,
408) -> String {
409    let mut out = String::with_capacity(256 * 1024);
410
411    // ── HTML head ────────────────────────────────────────────────────────────
412    write!(out, r#"<!DOCTYPE html>
413<html lang="zh-CN">
414<head>
415  <meta charset="UTF-8">
416  <meta name="viewport" content="width=device-width, initial-scale=1.0">
417  <title>Claude Code Token Analyzer</title>
418  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
419  <style>{css}</style>
420</head>
421<body>
422"#, css = css()).unwrap();
423
424    // ── Header ───────────────────────────────────────────────────────────────
425    writeln!(out, r#"<div class="header-row">"#).unwrap();
426    writeln!(out, r#"<h1>Claude Code Token Analyzer</h1>"#).unwrap();
427    if let Some((start, end)) = &overview.quality.time_range {
428        writeln!(out, r#"<span class="subtitle">{} ~ {}</span>"#,
429            start.format("%Y-%m-%d"), end.format("%Y-%m-%d")).unwrap();
430    }
431    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();
432    writeln!(out, "</div>").unwrap();
433
434    // ── Glossary ──────────────────────────────────────────────────────────────
435    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();
436
437    // ── Single source: use sub-nav directly (no top-nav) ─────────────────────
438    let pfx = "s1";
439    writeln!(out, r#"<div id="source-{pfx}" class="source-content active">"#, pfx = pfx).unwrap();
440    render_source_tabs(&mut out, pfx, overview, projects, trend, calc);
441    writeln!(out, "</div>").unwrap();
442
443    // ── JavaScript ───────────────────────────────────────────────────────────
444    write!(out, "<script>{}</script>", js_common()).unwrap();
445
446    // ── Footer ───────────────────────────────────────────────────────────────
447    render_footer(&mut out, calc);
448
449    writeln!(out, "</body>\n</html>").unwrap();
450    out
451}
452
453// ─── 1b. Dual Report (two sources) ──────────────────────────────────────────
454
455/// Generate a dual-source HTML dashboard with top-level source switcher.
456/// Each source gets its own sub-nav with 3 tabs.
457pub fn render_dual_report_html(
458    source1_name: &str,
459    source1: &ReportData,
460    source2_name: &str,
461    source2: &ReportData,
462    calc: &PricingCalculator,
463) -> String {
464    let mut out = String::with_capacity(512 * 1024);
465
466    // ── HTML head ────────────────────────────────────────────────────────────
467    write!(out, r#"<!DOCTYPE html>
468<html lang="zh-CN">
469<head>
470  <meta charset="UTF-8">
471  <meta name="viewport" content="width=device-width, initial-scale=1.0">
472  <title>Claude Code Token Analyzer</title>
473  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
474  <style>{css}</style>
475</head>
476<body>
477"#, css = css()).unwrap();
478
479    // ── Header ───────────────────────────────────────────────────────────────
480    writeln!(out, r#"<div class="header-row">"#).unwrap();
481    writeln!(out, r#"<h1>Claude Code Token Analyzer</h1>"#).unwrap();
482    // Show combined time range
483    let time_range_str = {
484        let mut global_min = None;
485        let mut global_max = None;
486        for q in [&source1.overview.quality, &source2.overview.quality] {
487            if let Some((s, e)) = &q.time_range {
488                global_min = Some(global_min.map_or(*s, |m: chrono::DateTime<chrono::Utc>| m.min(*s)));
489                global_max = Some(global_max.map_or(*e, |m: chrono::DateTime<chrono::Utc>| m.max(*e)));
490            }
491        }
492        match (global_min, global_max) {
493            (Some(s), Some(e)) => format!("{} ~ {}", s.format("%Y-%m-%d"), e.format("%Y-%m-%d")),
494            _ => String::new(),
495        }
496    };
497    if !time_range_str.is_empty() {
498        writeln!(out, r#"<span class="subtitle">{}</span>"#, time_range_str).unwrap();
499    }
500    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();
501    writeln!(out, "</div>").unwrap();
502
503    // ── Glossary ──────────────────────────────────────────────────────────────
504    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();
505
506    // ── Top-level source switcher ────────────────────────────────────────────
507    let s1_sessions = source1.overview.total_sessions;
508    let s2_sessions = source2.overview.total_sessions;
509    writeln!(out, r#"<nav class="top-nav">"#).unwrap();
510    writeln!(out, r#"<button class="active" onclick="switchSource('s1')">{} ({} sessions)</button>"#,
511        escape_html(source1_name), s1_sessions).unwrap();
512    writeln!(out, r#"<button onclick="switchSource('s2')">{} ({} sessions)</button>"#,
513        escape_html(source2_name), s2_sessions).unwrap();
514    writeln!(out, "</nav>").unwrap();
515
516    // ── Source 1 ─────────────────────────────────────────────────────────────
517    writeln!(out, r#"<div id="source-s1" class="source-content active">"#).unwrap();
518    render_source_tabs(&mut out, "s1", &source1.overview, &source1.projects, &source1.trend, calc);
519    writeln!(out, "</div>").unwrap();
520
521    // ── Source 2 ─────────────────────────────────────────────────────────────
522    writeln!(out, r#"<div id="source-s2" class="source-content">"#).unwrap();
523    render_source_tabs(&mut out, "s2", &source2.overview, &source2.projects, &source2.trend, calc);
524    writeln!(out, "</div>").unwrap();
525
526    // ── JavaScript ───────────────────────────────────────────────────────────
527    write!(out, "<script>{}</script>", js_common()).unwrap();
528
529    // ── Footer ───────────────────────────────────────────────────────────────
530    render_footer(&mut out, calc);
531
532    writeln!(out, "</body>\n</html>").unwrap();
533    out
534}
535
536// ─── Tab 1: Overview ─────────────────────────────────────────────────────────
537
538fn render_overview_tab(out: &mut String, overview: &OverviewResult, pfx: &str) {
539    // KPI cards
540    writeln!(out, r#"<div class="kpi-grid">"#).unwrap();
541    write_kpi_i18n(out, &format_compact(overview.total_output_tokens), "Claude Wrote", "Claude 写了");
542    write_kpi_i18n(out, &format_compact(overview.total_context_tokens), "Claude Read", "Claude 读了");
543    write_kpi_progress(out, overview.avg_cache_hit_rate, "Avg Cache Hit Rate");
544    write_kpi_i18n(out, &format_cost(overview.total_cost), "Token Value (API Rate)", "Token 价值 (API 费率)");
545    if overview.cache_savings.total_saved > 0.0 {
546        write_kpi_i18n(out, &format_cost(overview.cache_savings.total_saved),
547            &format!("Cache Savings ({:.0}%)", overview.cache_savings.savings_pct),
548            &format!("缓存节省 ({:.0}%)", overview.cache_savings.savings_pct));
549    }
550    writeln!(out, "</div>").unwrap();
551
552    // Charts 2x2 grid
553    writeln!(out, r#"<div class="grid-2x2">"#).unwrap();
554
555    // Chart 1: Model Usage Distribution (Doughnut, by output_tokens)
556    {
557        let chart_id = format!("{}-modelUsageChart", pfx);
558        writeln!(out, r#"<div class="card">"#).unwrap();
559        writeln!(out, "<h2>Model Usage (Output Tokens)</h2>").unwrap();
560        writeln!(out, r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#, chart_id).unwrap();
561
562        let mut models: Vec<(&String, &AggregatedTokens)> = overview.tokens_by_model.iter().collect();
563        models.sort_by(|a, b| b.1.output_tokens.cmp(&a.1.output_tokens));
564
565        let labels: Vec<String> = models.iter().map(|(m, _)| format!("\"{}\"", short_model(m))).collect();
566        let data: Vec<String> = models.iter().map(|(_, t)| t.output_tokens.to_string()).collect();
567        let colors_list: Vec<String> = (0..models.len()).map(|i| format!("\"{}\"", color(i))).collect();
568
569        writeln!(out, r#"<script>
570new Chart(document.getElementById('{chart_id}'), {{
571  type: 'doughnut',
572  data: {{
573    labels: [{labels}],
574    datasets: [{{ data: [{data}], backgroundColor: [{colors}], borderWidth: 0 }}]
575  }},
576  options: {{
577    responsive: true, maintainAspectRatio: false,
578    plugins: {{
579      legend: {{ position: 'bottom', labels: {{ color: '#c9d1d9' }} }}
580    }}
581  }}
582}});
583</script>"#,
584            chart_id = chart_id,
585            labels = labels.join(","), data = data.join(","), colors = colors_list.join(",")).unwrap();
586        writeln!(out, "</div>").unwrap();
587    }
588
589    // Chart 2: Cost by Category (Doughnut)
590    {
591        let chart_id = format!("{}-costCatChart", pfx);
592        writeln!(out, r#"<div class="card">"#).unwrap();
593        writeln!(out, "<h2>Cost by Category</h2>").unwrap();
594        writeln!(out, r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#, chart_id).unwrap();
595
596        let cat = &overview.cost_by_category;
597        let labels = r#""Input","Output","Cache Write 5m","Cache Write 1h","Cache Read""#;
598        let data = format!("{:.2},{:.2},{:.2},{:.2},{:.2}",
599            cat.input_cost, cat.output_cost, cat.cache_write_5m_cost,
600            cat.cache_write_1h_cost, cat.cache_read_cost);
601        let colors_str = format!(r#""{}","{}","{}","{}","{}""#,
602            color(0), color(1), color(2), color(3), color(4));
603
604        writeln!(out, r#"<script>
605new Chart(document.getElementById('{chart_id}'), {{
606  type: 'doughnut',
607  data: {{
608    labels: [{labels}],
609    datasets: [{{ data: [{data}], backgroundColor: [{colors}], borderWidth: 0 }}]
610  }},
611  options: {{
612    responsive: true, maintainAspectRatio: false,
613    plugins: {{
614      legend: {{ position: 'bottom', labels: {{ color: '#c9d1d9' }} }},
615      tooltip: {{ callbacks: {{ label: function(ctx) {{ return ctx.label + ': $' + ctx.raw.toFixed(2); }} }} }}
616    }}
617  }}
618}});
619</script>"#, chart_id = chart_id, labels = labels, data = data, colors = colors_str).unwrap();
620        writeln!(out, "</div>").unwrap();
621    }
622
623    // Chart 3: Heatmap (Weekday x Hour)
624    {
625        let canvas_id = format!("heatmap-{}", pfx);
626        writeln!(out, r#"<div class="card">"#).unwrap();
627        writeln!(out, r#"<h2 data-en="Activity Heatmap" data-zh="活跃热力图">Activity Heatmap</h2>"#).unwrap();
628        writeln!(out, r#"<p style="color:#8b949e;font-size:12px;margin-bottom:8px;" data-en="Each cell = number of turns in that hour slot. Rows = weekdays (Mon-Sun), columns = hours (00-23). Darker = more active. Helps identify your peak coding hours and work patterns." data-zh="每个格子 = 该时段的 turn 数量。行 = 星期几(周一到周日),列 = 小时(00-23)。颜色越深 = 越活跃。帮助你识别高峰编码时段和工作模式。">Each cell = number of turns in that hour slot. Rows = weekdays (Mon-Sun), columns = hours (00-23). Darker = more active. Helps identify your peak coding hours and work patterns.</p>"#).unwrap();
629        writeln!(out, r#"<canvas id="{}"></canvas>"#, canvas_id).unwrap();
630
631        let mut matrix_js = String::from("[");
632        for d in 0..7 {
633            if d > 0 { matrix_js.push(','); }
634            matrix_js.push('[');
635            for h in 0..24 {
636                if h > 0 { matrix_js.push(','); }
637                write!(matrix_js, "{}", overview.weekday_hour_matrix[d][h]).unwrap();
638            }
639            matrix_js.push(']');
640        }
641        matrix_js.push(']');
642
643        // Store heatmap data globally; draw on DOMContentLoaded
644        writeln!(out, r#"<script>
645window._heatmapData_{pfx} = {matrix};
646document.addEventListener('DOMContentLoaded', function() {{
647  drawHeatmap('{canvas_id}', window._heatmapData_{pfx});
648}});
649</script>"#, pfx = pfx, matrix = matrix_js, canvas_id = canvas_id).unwrap();
650        writeln!(out, "</div>").unwrap();
651    }
652
653    // Chart 4: Efficiency Scatter (Bubble, turns vs cost)
654    {
655        let chart_id = format!("{}-scatterChart", pfx);
656        writeln!(out, r#"<div class="card">"#).unwrap();
657        writeln!(out, r#"<h2 data-en="Session Efficiency (Turns vs Cost)" data-zh="会话效率(Turns vs 费用)">Session Efficiency (Turns vs Cost)</h2>"#).unwrap();
658        writeln!(out, r#"<p style="color:#8b949e;font-size:12px;margin-bottom:8px;" data-en="Each bubble = one session. X-axis = number of turns (more turns = longer session). Y-axis = API cost ($). Bubble size = output tokens generated. Outliers in the top-right are expensive long sessions — consider splitting them. Dots near the bottom are efficient short sessions." data-zh="每个气泡 = 一个会话。X 轴 = turn 数(越多 = 会话越长)。Y 轴 = API 等效费用($)。气泡大小 = 生成的 output tokens。右上角的离群点是昂贵的长会话——考虑拆分。底部的点是高效的短会话。">Each bubble = one session. X-axis = number of turns (more turns = longer session). Y-axis = API cost ($). Bubble size = output tokens generated. Outliers in the top-right are expensive long sessions — consider splitting them. Dots near the bottom are efficient short sessions.</p>"#).unwrap();
659        writeln!(out, r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#, chart_id).unwrap();
660
661        let max_output: u64 = overview.session_summaries.iter().map(|s| s.output_tokens).max().unwrap_or(1);
662        let mut scatter_data = String::from("[");
663        for (i, s) in overview.session_summaries.iter().enumerate() {
664            if i > 0 { scatter_data.push(','); }
665            let radius = if max_output > 0 {
666                3.0 + (s.output_tokens as f64 / max_output as f64) * 20.0
667            } else { 3.0 };
668            write!(scatter_data, "{{x:{},y:{:.4},r:{:.1}}}", s.turn_count, s.cost, radius).unwrap();
669        }
670        scatter_data.push(']');
671
672        writeln!(out, r#"<script>
673new Chart(document.getElementById('{chart_id}'), {{
674  type: 'bubble',
675  data: {{
676    datasets: [{{
677      label: 'Sessions',
678      data: {data},
679      backgroundColor: 'rgba(88,166,255,0.4)',
680      borderColor: '#58a6ff',
681      borderWidth: 1
682    }}]
683  }},
684  options: {{
685    responsive: true, maintainAspectRatio: false,
686    plugins: {{
687      legend: {{ display: false }},
688      tooltip: {{ callbacks: {{
689        label: function(ctx) {{
690          return 'Turns: ' + ctx.raw.x + ', Cost: $' + ctx.raw.y.toFixed(2);
691        }}
692      }} }}
693    }},
694    scales: {{
695      x: {{ title: {{ display: true, text: 'Turn Count', color: '#8b949e' }}, ticks: {{ color: '#8b949e' }}, grid: {{ color: '#21262d' }} }},
696      y: {{ title: {{ display: true, text: 'Cost ($)', color: '#8b949e' }}, ticks: {{ color: '#8b949e', callback: function(v) {{ return '$' + v; }} }}, grid: {{ color: '#21262d' }} }}
697    }}
698  }}
699}});
700</script>"#, chart_id = chart_id, data = scatter_data).unwrap();
701        writeln!(out, "</div>").unwrap();
702    }
703
704    writeln!(out, "</div>").unwrap(); // close grid-2x2
705}
706
707// ─── Tab 2: Monthly ──────────────────────────────────────────────────────────
708
709fn render_monthly_tab(out: &mut String, _overview: &OverviewResult, trend: &TrendResult, pfx: &str) {
710    if trend.entries.is_empty() {
711        writeln!(out, r#"<div class="card"><p style="color:#8b949e;">No trend data available.</p></div>"#).unwrap();
712        return;
713    }
714
715    // Determine the latest month from trend entries
716    let latest_month = trend.entries.last().map(|e| &e.label[..7]).unwrap_or("");
717
718    // Aggregate current month data
719    let mut month_cost = 0.0f64;
720    let mut month_turns = 0usize;
721    let mut month_sessions = 0usize;
722    let mut month_output = 0u64;
723
724    let mut daily_costs: Vec<(String, f64)> = Vec::new();
725
726    for entry in &trend.entries {
727        if entry.label.starts_with(latest_month) {
728            month_cost += entry.cost;
729            month_turns += entry.turn_count;
730            month_sessions += entry.session_count;
731            month_output += entry.tokens.output_tokens;
732            daily_costs.push((entry.label.clone(), entry.cost));
733        }
734    }
735
736    // KPI cards for current month
737    writeln!(out, "<h2>Current Period: {}</h2>", escape_html(latest_month)).unwrap();
738    writeln!(out, r#"<div class="kpi-grid">"#).unwrap();
739    write_kpi(out, &format_number(month_sessions as u64), "Sessions");
740    write_kpi(out, &format_number(month_turns as u64), "Turns");
741    write_kpi(out, &format_number(month_output), "Output Tokens");
742    write_kpi(out, &format_cost(month_cost), "Cost");
743    writeln!(out, "</div>").unwrap();
744
745    // Chart: Daily Cost Bar Chart
746    if !daily_costs.is_empty() {
747        let chart_id = format!("{}-dailyCostChart", pfx);
748        writeln!(out, r#"<div class="card">"#).unwrap();
749        writeln!(out, "<h2>Daily Cost ({})</h2>", escape_html(latest_month)).unwrap();
750        writeln!(out, r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#, chart_id).unwrap();
751
752        let labels: Vec<String> = daily_costs.iter().map(|(d, _)| format!("\"{}\"", &d[5..])).collect(); // show MM-DD
753        let data: Vec<String> = daily_costs.iter().map(|(_, c)| format!("{:.2}", c)).collect();
754
755        writeln!(out, r#"<script>
756new Chart(document.getElementById('{chart_id}'), {{
757  type: 'bar',
758  data: {{
759    labels: [{labels}],
760    datasets: [{{
761      label: 'Cost ($)',
762      data: [{data}],
763      backgroundColor: 'rgba(88,166,255,0.6)',
764      borderColor: '#58a6ff',
765      borderWidth: 1,
766      borderRadius: 4
767    }}]
768  }},
769  options: {{
770    responsive: true, maintainAspectRatio: false,
771    plugins: {{
772      legend: {{ display: false }},
773      tooltip: {{ callbacks: {{ label: function(ctx) {{ return '$' + ctx.raw.toFixed(2); }} }} }}
774    }},
775    scales: {{
776      x: {{ ticks: {{ color: '#8b949e' }}, grid: {{ color: '#21262d' }} }},
777      y: {{ ticks: {{ color: '#8b949e', callback: function(v) {{ return '$' + v; }} }}, grid: {{ color: '#21262d' }} }}
778    }}
779  }}
780}});
781</script>"#, chart_id = chart_id, labels = labels.join(","), data = data.join(",")).unwrap();
782        writeln!(out, "</div>").unwrap();
783    }
784
785    // Table: Monthly summary (aggregate by month if multi-month data)
786    {
787        // Group trend entries by month
788        let mut months: std::collections::BTreeMap<String, (usize, usize, u64, u64, u64, f64)> = std::collections::BTreeMap::new();
789        for entry in &trend.entries {
790            let month_key = entry.label[..7].to_string();
791            let e = months.entry(month_key).or_insert((0, 0, 0, 0, 0, 0.0));
792            e.0 += entry.session_count;
793            e.1 += entry.turn_count;
794            e.2 += entry.tokens.output_tokens;
795            e.3 += entry.tokens.cache_creation_tokens;
796            e.4 += entry.tokens.cache_read_tokens;
797            e.5 += entry.cost;
798        }
799
800        if months.len() > 1 {
801            let tbl_id = format!("{}-tbl-monthly", pfx);
802            writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
803            writeln!(out, "<h2>Monthly Summary</h2>").unwrap();
804            writeln!(out, r#"<div style="overflow-x:auto;">"#).unwrap();
805            writeln!(out, r#"<table id="{}">"#, tbl_id).unwrap();
806            writeln!(out, "<thead><tr>\
807                <th onclick=\"sortTableSimple(this,'{id}')\">Month</th>\
808                <th onclick=\"sortTableSimple(this,'{id}')\">Sessions</th>\
809                <th onclick=\"sortTableSimple(this,'{id}')\">Turns</th>\
810                <th onclick=\"sortTableSimple(this,'{id}')\">Output Tokens</th>\
811                <th onclick=\"sortTableSimple(this,'{id}')\">Cache Write</th>\
812                <th onclick=\"sortTableSimple(this,'{id}')\">Cache Read</th>\
813                <th onclick=\"sortTableSimple(this,'{id}')\">Cost</th>\
814            </tr></thead>", id = tbl_id).unwrap();
815            writeln!(out, "<tbody>").unwrap();
816
817            for (month, (sessions, turns, output, cache_write, cache_read, cost)) in &months {
818                writeln!(out, "<tr>\
819                    <td data-value=\"{}\">{}</td>\
820                    <td data-value=\"{}\">{}</td>\
821                    <td data-value=\"{}\">{}</td>\
822                    <td data-value=\"{}\">{}</td>\
823                    <td data-value=\"{}\">{}</td>\
824                    <td data-value=\"{}\">{}</td>\
825                    <td data-value=\"{:.4}\">{}</td>\
826                </tr>",
827                    escape_html(month), escape_html(month),
828                    sessions, format_number(*sessions as u64),
829                    turns, format_number(*turns as u64),
830                    output, format_number(*output),
831                    cache_write, format_number(*cache_write),
832                    cache_read, format_number(*cache_read),
833                    cost, format_cost(*cost),
834                ).unwrap();
835            }
836
837            writeln!(out, "</tbody></table></div></div>").unwrap();
838        }
839    }
840
841    // Table: Daily detail
842    {
843        let tbl_id = format!("{}-tbl-daily", pfx);
844        writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
845        writeln!(out, "<h2>{} Breakdown</h2>", escape_html(&trend.group_label)).unwrap();
846        writeln!(out, r#"<div style="overflow-x:auto;">"#).unwrap();
847        writeln!(out, r#"<table id="{}">"#, tbl_id).unwrap();
848        writeln!(out, "<thead><tr>\
849            <th onclick=\"sortTableSimple(this,'{id}')\">{}</th>\
850            <th onclick=\"sortTableSimple(this,'{id}')\">Sessions</th>\
851            <th onclick=\"sortTableSimple(this,'{id}')\">Turns</th>\
852            <th onclick=\"sortTableSimple(this,'{id}')\">Output Tokens</th>\
853            <th onclick=\"sortTableSimple(this,'{id}')\">Cost</th>\
854        </tr></thead>", escape_html(&trend.group_label), id = tbl_id).unwrap();
855        writeln!(out, "<tbody>").unwrap();
856
857        for entry in &trend.entries {
858            writeln!(out, "<tr>\
859                <td data-value=\"{}\">{}</td>\
860                <td data-value=\"{}\">{}</td>\
861                <td data-value=\"{}\">{}</td>\
862                <td data-value=\"{}\">{}</td>\
863                <td data-value=\"{:.4}\">{}</td>\
864            </tr>",
865                escape_html(&entry.label), escape_html(&entry.label),
866                entry.session_count, format_number(entry.session_count as u64),
867                entry.turn_count, format_number(entry.turn_count as u64),
868                entry.tokens.output_tokens, format_number(entry.tokens.output_tokens),
869                entry.cost, format_cost(entry.cost),
870            ).unwrap();
871        }
872
873        writeln!(out, "</tbody></table></div></div>").unwrap();
874    }
875}
876
877// ─── Tab 3: Projects ─────────────────────────────────────────────────────────
878
879fn render_projects_tab(out: &mut String, projects: &ProjectResult, sessions: &[crate::analysis::SessionSummary], pfx: &str) {
880    // Chart: Project Cost Top 10
881    {
882        let top_n = projects.projects.iter().take(10).collect::<Vec<_>>();
883        if !top_n.is_empty() {
884            let chart_id = format!("{}-projectCostChart", pfx);
885            writeln!(out, r#"<div class="card">"#).unwrap();
886            writeln!(out, "<h2>Project Cost Top 10</h2>").unwrap();
887            writeln!(out, r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#, chart_id).unwrap();
888
889            let labels: Vec<String> = top_n.iter().map(|p| format!("\"{}\"", escape_html(&p.display_name))).collect();
890            let data: Vec<String> = top_n.iter().map(|p| format!("{:.2}", p.cost)).collect();
891            let colors_list: Vec<String> = (0..top_n.len()).map(|i| format!("\"{}\"", color(i))).collect();
892
893            writeln!(out, r#"<script>
894new Chart(document.getElementById('{chart_id}'), {{
895  type: 'bar',
896  data: {{
897    labels: [{labels}],
898    datasets: [{{ label: 'Cost ($)', data: [{data}], backgroundColor: [{colors}], borderWidth: 0, borderRadius: 4 }}]
899  }},
900  options: {{
901    indexAxis: 'y', responsive: true, maintainAspectRatio: false,
902    plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx) {{ return '$' + ctx.raw.toFixed(2); }} }} }} }},
903    scales: {{
904      x: {{ ticks: {{ color: '#8b949e', callback: function(v) {{ return '$' + v; }} }}, grid: {{ color: '#21262d' }} }},
905      y: {{ ticks: {{ color: '#c9d1d9' }}, grid: {{ color: '#21262d' }} }}
906    }}
907  }}
908}});
909</script>"#, chart_id = chart_id, labels = labels.join(","), data = data.join(","), colors = colors_list.join(",")).unwrap();
910            writeln!(out, "</div>").unwrap();
911        }
912    }
913
914    // Three-level drill-down table: Project → Session → Turn
915    let tbl_id = format!("{}-tbl-projects-drill", pfx);
916    writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
917    writeln!(out, r#"<h2 data-en="Project Drill-Down" data-zh="项目钻取">Project Drill-Down</h2>"#).unwrap();
918    writeln!(out, r#"<div style="overflow-x:auto;">"#).unwrap();
919    writeln!(out, r#"<table id="{}">"#, tbl_id).unwrap();
920    writeln!(out, "<thead><tr>\
921        <th></th>\
922        <th>Project / Session</th>\
923        <th>Sessions</th>\
924        <th>Turns</th>\
925        <th>Agent%</th>\
926        <th>Output</th>\
927        <th>CacheHit%</th>\
928        <th>Cost</th>\
929    </tr></thead>").unwrap();
930    writeln!(out, "<tbody>").unwrap();
931
932    // Group sessions by project_display_name
933    let mut sessions_by_project: std::collections::HashMap<String, Vec<&crate::analysis::SessionSummary>> = std::collections::HashMap::new();
934    for s in sessions {
935        sessions_by_project.entry(s.project_display_name.clone()).or_default().push(s);
936    }
937
938    for (i, proj) in projects.projects.iter().enumerate() {
939        let agent_pct = if proj.total_turns > 0 {
940            proj.agent_turns as f64 / proj.total_turns as f64 * 100.0
941        } else { 0.0 };
942        let cache_hit = if proj.tokens.context_tokens() > 0 {
943            proj.tokens.cache_read_tokens as f64 / proj.tokens.context_tokens() as f64 * 100.0
944        } else { 0.0 };
945        let pid = format!("{}-p{}", pfx, i);
946
947        // Level 1: Project row (expandable)
948        let hit_bar = html_progress(cache_hit);
949        writeln!(out, r#"<tr class="project-row expandable">"#).unwrap();
950        writeln!(out, r#"<td><button class="expand-btn" onclick="toggleProject(this,'{pid}')">{arrow}</button></td>"#,
951            pid = pid, arrow = "\u{25b6}").unwrap();
952        writeln!(out, "\
953            <td><strong>{name}</strong></td>\
954            <td data-value=\"{sess}\">{sess_fmt}</td>\
955            <td data-value=\"{turns}\">{turns_fmt}</td>\
956            <td data-value=\"{apct:.1}\">{apct:.1}%</td>\
957            <td data-value=\"{out}\">{out_fmt}</td>\
958            <td data-value=\"{hit:.1}\">{hit_bar}</td>\
959            <td data-value=\"{cost:.4}\">{cost_fmt}</td>",
960            name = escape_html(&proj.display_name),
961            sess = proj.session_count, sess_fmt = format_number(proj.session_count as u64),
962            turns = proj.total_turns, turns_fmt = format_number(proj.total_turns as u64),
963            apct = agent_pct,
964            out = proj.tokens.output_tokens, out_fmt = format_compact(proj.tokens.output_tokens),
965            hit = cache_hit, hit_bar = hit_bar,
966            cost = proj.cost, cost_fmt = format_cost(proj.cost),
967        ).unwrap();
968        writeln!(out, "</tr>").unwrap();
969
970        // Level 2: Session rows (hidden by default, belong to this project)
971        if let Some(proj_sessions) = sessions_by_project.get(&proj.display_name) {
972            let mut sorted = proj_sessions.clone();
973            sorted.sort_by(|a, b| b.cost.partial_cmp(&a.cost).unwrap_or(std::cmp::Ordering::Equal));
974
975            for s in &sorted {
976                let date = s.first_timestamp.map(|t| t.format("%m-%d %H:%M").to_string()).unwrap_or_default();
977                let s_hit = html_progress(s.cache_hit_rate);
978
979                // Session summary row
980                writeln!(out, r#"<tr class="project-session-row project-sessions-{pid} expandable" style="display:none">"#,
981                    pid = pid).unwrap();
982
983                let has_detail = s.turn_details.is_some();
984                if has_detail {
985                    writeln!(out, r#"<td><button class="expand-btn" onclick="toggleSession(this)">{}</button></td>"#, "\u{25b6}").unwrap();
986                } else {
987                    writeln!(out, "<td></td>").unwrap();
988                }
989                writeln!(out, "\
990                    <td style=\"padding-left:30px;text-align:left;\">{sid} <span style=\"color:#8b949e;font-size:11px;\">({date})</span></td>\
991                    <td></td>\
992                    <td data-value=\"{turns}\">{turns_fmt}</td>\
993                    <td data-value=\"{agents}\">{agents}</td>\
994                    <td data-value=\"{out}\">{out_fmt}</td>\
995                    <td data-value=\"{hit:.1}\">{hit_bar}</td>\
996                    <td data-value=\"{cost:.4}\">{cost_fmt}</td>",
997                    sid = escape_html(&s.session_id),
998                    date = date,
999                    turns = s.turn_count, turns_fmt = format_number(s.turn_count as u64),
1000                    agents = s.agent_turn_count,
1001                    out = s.output_tokens, out_fmt = format_compact(s.output_tokens),
1002                    hit = s.cache_hit_rate, hit_bar = s_hit,
1003                    cost = s.cost, cost_fmt = format_cost(s.cost),
1004                ).unwrap();
1005                writeln!(out, "</tr>").unwrap();
1006
1007                // Level 3: Turn detail (hidden, shown when session is expanded)
1008                if let Some(ref details) = s.turn_details {
1009                    writeln!(out, r#"<tr class="session-detail project-sessions-{pid}" style="display:none"><td colspan="8"><div class="detail-content">"#,
1010                        pid = pid).unwrap();
1011                    render_turn_detail_table(out, details, &format!("{}-detail-proj-{}", pfx, escape_html(&s.session_id)));
1012                    writeln!(out, "</div></td></tr>").unwrap();
1013                }
1014            }
1015        }
1016    }
1017
1018    writeln!(out, "</tbody></table></div></div>").unwrap();
1019}
1020
1021// ─── Turn Detail Sub-table ───────────────────────────────────────────────────
1022
1023fn render_turn_detail_table(out: &mut String, turns: &[crate::analysis::TurnDetail], table_id: &str) {
1024    render_turn_table_impl(out, turns, table_id);
1025}
1026
1027// ─── Footer ──────────────────────────────────────────────────────────────────
1028
1029fn render_footer(out: &mut String, calc: &PricingCalculator) {
1030    let stale_warning = if PricingCalculator::is_pricing_stale() {
1031        format!(r#"<p class="stale-warning">Warning: Price data is {} days old, costs may be inaccurate!</p>"#,
1032            PricingCalculator::pricing_age_days())
1033    } else { String::new() };
1034    let _ = calc;
1035
1036    let now = chrono::Utc::now().format("%Y-%m-%d %H:%M UTC");
1037    writeln!(out, r#"<div class="footer">
1038  {}
1039  <p>Price data: {} ({}) | Generated by cc-token-analyzer at {}</p>
1040</div>"#, stale_warning, PRICING_SOURCE, PRICING_FETCH_DATE, now).unwrap();
1041}
1042
1043// ─── KPI Card Helper ─────────────────────────────────────────────────────────
1044
1045fn write_kpi(out: &mut String, value: &str, label: &str) {
1046    writeln!(out, r#"<div class="card" style="text-align:center;"><div class="kpi-value">{}</div><div class="kpi-label">{}</div></div>"#,
1047        value, label).unwrap();
1048}
1049
1050/// KPI card with bilingual label.
1051fn write_kpi_i18n(out: &mut String, value: &str, en: &str, zh: &str) {
1052    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>"#,
1053        value, en, zh, en).unwrap();
1054}
1055
1056/// KPI card with a progress bar for percentage values.
1057fn write_kpi_progress(out: &mut String, pct: f64, label: &str) {
1058    let bar_color = if pct >= 90.0 { "#6bcb77" } else if pct >= 70.0 { "#ffd93d" } else { "#ff6b6b" };
1059    writeln!(out, r#"<div class="card" style="text-align:center;">
1060        <div class="kpi-value">{:.1}%</div>
1061        <div style="margin:8px auto;width:120px;"><div class="progress-bar" style="width:120px;">
1062            <div class="progress-fill" style="width:{:.1}%;background:{};"></div>
1063        </div></div>
1064        <div class="kpi-label">{}</div>
1065    </div>"#, pct, pct, bar_color, label).unwrap();
1066}
1067
1068/// Render a progress bar inline for table cells.
1069fn html_progress(pct: f64) -> String {
1070    let bar_color = if pct >= 90.0 { "#6bcb77" } else if pct >= 70.0 { "#ffd93d" } else { "#ff6b6b" };
1071    format!(r#"<div class="progress-bar"><div class="progress-fill" style="width:{:.1}%;background:{};"></div></div><span class="progress-text">{:.1}%</span>"#,
1072        pct, bar_color, pct)
1073}
1074
1075// ─── 2. Session Report ───────────────────────────────────────────────────────
1076
1077/// Generate a detailed HTML report for a single session.
1078pub fn render_session_html(result: &SessionResult) -> String {
1079    let mut out = String::with_capacity(64 * 1024);
1080
1081    let short_id = &result.session_id[..result.session_id.len().min(12)];
1082
1083    // ── HTML head ────────────────────────────────────────────────────────────
1084    write!(out, r#"<!DOCTYPE html>
1085<html lang="zh-CN">
1086<head>
1087  <meta charset="UTF-8">
1088  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1089  <title>Session {short_id} - Claude Code Token Analyzer</title>
1090  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
1091  <style>{css}</style>
1092</head>
1093<body>
1094"#, short_id = escape_html(short_id), css = css()).unwrap();
1095
1096    // Header
1097    writeln!(out, r#"<div class="header-row">"#).unwrap();
1098    writeln!(out, "<h1>Session Analysis</h1>").unwrap();
1099    writeln!(out, r#"<span class="subtitle">{} &middot; {}</span>"#,
1100        escape_html(&result.session_id), escape_html(&result.project)).unwrap();
1101    writeln!(out, "</div>").unwrap();
1102
1103    // ── KPI cards ────────────────────────────────────────────────────────────
1104    let cache_hit_rate = {
1105        let total_ctx = result.total_tokens.context_tokens();
1106        if total_ctx > 0 {
1107            result.total_tokens.cache_read_tokens as f64 / total_ctx as f64 * 100.0
1108        } else { 0.0 }
1109    };
1110
1111    writeln!(out, r#"<div class="kpi-grid">"#).unwrap();
1112    write_kpi(&mut out, &format_duration(result.duration_minutes), "Duration");
1113    write_kpi(&mut out, &short_model(&result.model), "Model");
1114    write_kpi(&mut out, &format_number(result.max_context), "Max Context");
1115    write_kpi(&mut out, &format!("{:.1}%", cache_hit_rate), "Cache Hit Rate");
1116    write_kpi(&mut out, &format_number(result.compaction_count as u64), "Compactions");
1117    write_kpi(&mut out, &format_cost(result.total_cost), "Total Cost");
1118    writeln!(out, "</div>").unwrap();
1119
1120    // ── Charts (Context Growth + Cache Hit Rate) ─────────────────────────────
1121    if !result.turn_details.is_empty() {
1122        writeln!(out, r#"<div class="grid-2">"#).unwrap();
1123
1124        // Context Growth Line Chart
1125        {
1126            writeln!(out, r#"<div class="card">"#).unwrap();
1127            writeln!(out, "<h2>Context Growth</h2>").unwrap();
1128            writeln!(out, r#"<div class="chart-container"><canvas id="contextChart"></canvas></div>"#).unwrap();
1129
1130            let turn_nums: Vec<String> = result.turn_details.iter().map(|t| t.turn_number.to_string()).collect();
1131            let ctx_sizes: Vec<String> = result.turn_details.iter().map(|t| t.context_size.to_string()).collect();
1132            let pr = if result.turn_details.len() > 50 { 0 } else { 3 };
1133
1134            writeln!(out, r#"<script>
1135new Chart(document.getElementById('contextChart'), {{
1136  type: 'line',
1137  data: {{
1138    labels: [{turns}],
1139    datasets: [{{
1140      label: 'Context Size',
1141      data: [{sizes}],
1142      borderColor: '#58a6ff',
1143      backgroundColor: 'rgba(88,166,255,0.1)',
1144      fill: true, tension: 0.3, pointRadius: {pr}
1145    }}]
1146  }},
1147  options: {{
1148    responsive: true, maintainAspectRatio: false,
1149    plugins: {{ legend: {{ labels: {{ color: '#c9d1d9' }} }} }},
1150    scales: {{
1151      x: {{ title: {{ display: true, text: 'Turn', color: '#8b949e' }}, ticks: {{ color: '#8b949e' }}, grid: {{ color: '#21262d' }} }},
1152      y: {{ title: {{ display: true, text: 'Context Tokens', color: '#8b949e' }}, ticks: {{ color: '#8b949e' }}, grid: {{ color: '#21262d' }} }}
1153    }}
1154  }}
1155}});
1156</script>"#,
1157                turns = turn_nums.join(","),
1158                sizes = ctx_sizes.join(","),
1159                pr = pr,
1160            ).unwrap();
1161            writeln!(out, "</div>").unwrap();
1162        }
1163
1164        // Cache Hit Rate Line Chart
1165        {
1166            writeln!(out, r#"<div class="card">"#).unwrap();
1167            writeln!(out, "<h2>Cache Hit Rate</h2>").unwrap();
1168            writeln!(out, r#"<div class="chart-container"><canvas id="cacheChart"></canvas></div>"#).unwrap();
1169
1170            let turn_nums: Vec<String> = result.turn_details.iter().map(|t| t.turn_number.to_string()).collect();
1171            let cache_rates: Vec<String> = result.turn_details.iter().map(|t| format!("{:.2}", t.cache_hit_rate)).collect();
1172            let pr = if result.turn_details.len() > 50 { 0 } else { 3 };
1173
1174            writeln!(out, r#"<script>
1175new Chart(document.getElementById('cacheChart'), {{
1176  type: 'line',
1177  data: {{
1178    labels: [{turns}],
1179    datasets: [{{
1180      label: 'Cache Hit Rate (%)',
1181      data: [{rates}],
1182      borderColor: '#ffd93d',
1183      backgroundColor: 'rgba(255,217,61,0.1)',
1184      fill: true, tension: 0.3, pointRadius: {pr}
1185    }}]
1186  }},
1187  options: {{
1188    responsive: true, maintainAspectRatio: false,
1189    plugins: {{ legend: {{ labels: {{ color: '#c9d1d9' }} }} }},
1190    scales: {{
1191      x: {{ title: {{ display: true, text: 'Turn', color: '#8b949e' }}, ticks: {{ color: '#8b949e' }}, grid: {{ color: '#21262d' }} }},
1192      y: {{ title: {{ display: true, text: 'Hit Rate (%)', color: '#8b949e' }}, ticks: {{ color: '#8b949e' }}, grid: {{ color: '#21262d' }}, min: 0, max: 100 }}
1193    }}
1194  }}
1195}});
1196</script>"#,
1197                turns = turn_nums.join(","),
1198                rates = cache_rates.join(","),
1199                pr = pr,
1200            ).unwrap();
1201            writeln!(out, "</div>").unwrap();
1202        }
1203
1204        writeln!(out, "</div>").unwrap(); // close grid-2
1205    }
1206
1207    // ── Stop Reason Doughnut ─────────────────────────────────────────────────
1208    if !result.stop_reason_counts.is_empty() {
1209        writeln!(out, r#"<div class="card">"#).unwrap();
1210        writeln!(out, "<h2>Stop Reason Distribution</h2>").unwrap();
1211        writeln!(out, r#"<div class="chart-container" style="max-width:400px;margin:0 auto;"><canvas id="stopReasonChart"></canvas></div>"#).unwrap();
1212
1213        let mut reasons: Vec<(&String, &usize)> = result.stop_reason_counts.iter().collect();
1214        reasons.sort_by(|a, b| b.1.cmp(a.1));
1215
1216        let labels: Vec<String> = reasons.iter().map(|(r, _)| format!("\"{}\"", escape_html(r))).collect();
1217        let data: Vec<String> = reasons.iter().map(|(_, c)| c.to_string()).collect();
1218        let colors_list: Vec<String> = (0..reasons.len()).map(|i| format!("\"{}\"", color(i))).collect();
1219
1220        writeln!(out, r#"<script>
1221new Chart(document.getElementById('stopReasonChart'), {{
1222  type: 'doughnut',
1223  data: {{
1224    labels: [{labels}],
1225    datasets: [{{ data: [{data}], backgroundColor: [{colors}], borderWidth: 0 }}]
1226  }},
1227  options: {{
1228    responsive: true, maintainAspectRatio: false,
1229    plugins: {{ legend: {{ position: 'bottom', labels: {{ color: '#c9d1d9' }} }} }}
1230  }}
1231}});
1232</script>"#,
1233            labels = labels.join(","), data = data.join(","), colors = colors_list.join(",")).unwrap();
1234        writeln!(out, "</div>").unwrap();
1235    }
1236
1237    // ── Turn Detail Table ────────────────────────────────────────────────────
1238    writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
1239    writeln!(out, "<h2>Turn Details</h2>").unwrap();
1240    writeln!(out, r#"<div style="overflow-x:auto;">"#).unwrap();
1241    render_turn_table_impl(&mut out, &result.turn_details, "tbl-session-turns");
1242    writeln!(out, "</div></div>").unwrap();
1243
1244    // ── JavaScript ───────────────────────────────────────────────────────────
1245    write!(out, "<script>{}</script>", js_common()).unwrap();
1246
1247    // ── Footer ───────────────────────────────────────────────────────────────
1248    let now = chrono::Utc::now().format("%Y-%m-%d %H:%M UTC");
1249    writeln!(out, r#"<div class="footer">
1250  <p>Session: {} | Generated by cc-token-analyzer at {}</p>
1251</div>"#, escape_html(&result.session_id), now).unwrap();
1252
1253    writeln!(out, "</body>\n</html>").unwrap();
1254    out
1255}
1256
1257/// Shared turn detail table — used by both expandable session detail and single session report.
1258fn render_turn_table_impl(out: &mut String, turns: &[crate::analysis::TurnDetail], table_id: &str) {
1259    writeln!(out, r#"<table id="{}" style="font-size:12px;">"#, table_id).unwrap();
1260    writeln!(out, "<thead><tr>\
1261        <th onclick=\"sortTableSimple(this,'{id}')\">Turn</th>\
1262        <th onclick=\"sortTableSimple(this,'{id}')\">Time</th>\
1263        <th>Model</th>\
1264        <th style=\"text-align:left\">User</th>\
1265        <th style=\"text-align:left\">Assistant</th>\
1266        <th style=\"text-align:left\">Tools</th>\
1267        <th onclick=\"sortTableSimple(this,'{id}')\">Output</th>\
1268        <th onclick=\"sortTableSimple(this,'{id}')\">Context</th>\
1269        <th onclick=\"sortTableSimple(this,'{id}')\">Hit%</th>\
1270        <th onclick=\"sortTableSimple(this,'{id}')\">Cost</th>\
1271        <th>Stop</th>\
1272        <th>\u{26a1}</th>\
1273    </tr></thead>", id = table_id).unwrap();
1274    writeln!(out, "<tbody>").unwrap();
1275
1276    for t in turns {
1277        let row_class = if t.is_compaction { r#" class="compact-row""# } else { "" };
1278        let stop = t.stop_reason.as_deref().unwrap_or("-");
1279        let compact_mark = if t.is_compaction { "\u{26a1}" } else { "" };
1280
1281        let user_text = t.user_text.as_deref().unwrap_or("");
1282        let user_preview = if user_text.len() > 80 {
1283            format!("{}...", &user_text[..user_text.floor_char_boundary(80)])
1284        } else {
1285            user_text.to_string()
1286        };
1287        let asst_text = t.assistant_text.as_deref().unwrap_or("");
1288        let asst_preview = if asst_text.len() > 80 {
1289            format!("{}...", &asst_text[..asst_text.floor_char_boundary(80)])
1290        } else {
1291            asst_text.to_string()
1292        };
1293        let tools = t.tool_names.join(", ");
1294        let hit_bar = html_progress(t.cache_hit_rate);
1295
1296        let model_short = short_model(&t.model);
1297        writeln!(out, "<tr{cls}>\
1298            <td data-value=\"{turn}\">{turn}</td>\
1299            <td>{time}</td>\
1300            <td>{model}</td>\
1301            <td style=\"text-align:left;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;\" title=\"{user_full}\">{user}</td>\
1302            <td style=\"text-align:left;max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;\" title=\"{asst_full}\">{asst}</td>\
1303            <td style=\"text-align:left;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;\">{tools}</td>\
1304            <td data-value=\"{out_val}\">{out_fmt}</td>\
1305            <td data-value=\"{ctx_val}\">{ctx_fmt}</td>\
1306            <td data-value=\"{hit:.1}\">{hit_bar}</td>\
1307            <td data-value=\"{cost:.6}\">{cost_fmt}</td>\
1308            <td>{stop}</td>\
1309            <td>{compact}</td>\
1310        </tr>",
1311            cls = row_class,
1312            turn = t.turn_number,
1313            time = t.timestamp.format("%H:%M:%S"),
1314            model = model_short,
1315            user_full = escape_html(user_text),
1316            user = escape_html(&user_preview),
1317            asst_full = escape_html(asst_text),
1318            asst = escape_html(&asst_preview),
1319            tools = escape_html(&tools),
1320            out_val = t.output_tokens, out_fmt = format_compact(t.output_tokens),
1321            ctx_val = t.context_size, ctx_fmt = format_compact(t.context_size),
1322            hit = t.cache_hit_rate, hit_bar = hit_bar,
1323            cost = t.cost, cost_fmt = format_cost(t.cost),
1324            stop = escape_html(stop),
1325            compact = compact_mark,
1326        ).unwrap();
1327    }
1328
1329    writeln!(out, "</tbody></table>").unwrap();
1330}