Skip to main content

cc_token_usage/output/
html.rs

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
7// ─── Chart Colors ────────────────────────────────────────────────────────────
8
9const COLORS: &[&str] = &[
10    "#58a6ff", "#ff6b6b", "#ffd93d", "#6bcb77", "#4d96ff", "#9b59b6",
11    "#e17055", "#00cec9", "#fd79a8", "#fdcb6e",
12];
13
14// ─── ReportData ──────────────────────────────────────────────────────────────
15
16/// Bundled analysis results for one data source.
17pub struct ReportData {
18    pub overview: OverviewResult,
19    pub projects: ProjectResult,
20    pub trend: TrendResult,
21}
22
23// ─── Helpers ─────────────────────────────────────────────────────────────────
24
25/// Escape HTML special characters.
26fn escape_html(s: &str) -> String {
27    s.replace('&', "&")
28        .replace('<', "&lt;")
29        .replace('>', "&gt;")
30        .replace('"', "&quot;")
31        .replace('\'', "&#x27;")
32}
33
34/// Format a number with thousands separators for display.
35fn 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
47/// Format large numbers with M/B/K suffixes for compact display.
48fn 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
60/// Format a cost value: 1234.5 -> "$1,234.50"
61fn 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
69/// Pick a color from the palette by index.
70fn color(i: usize) -> &'static str {
71    COLORS[i % COLORS.len()]
72}
73
74/// Shorten model name: claude-haiku-4-5-20251001 → haiku-4-5
75fn short_model(name: &str) -> String {
76    let s = name.strip_prefix("claude-").unwrap_or(name);
77    // Remove date suffix like -20251001 or -20250929
78    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
88/// Format duration in minutes to a human-readable string.
89fn 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
101// ─── CSS ─────────────────────────────────────────────────────────────────────
102
103fn 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.card > h2:first-child { margin-top: 0; }
113.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin: 16px 0; }
114.kpi-value { font-size: 1.6em; font-weight: 700; color: #58a6ff; line-height: 1; }
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  transition: background 0.15s, border-color 0.15s;
121}
122nav button:hover { border-color: #58a6ff; }
123nav button.active { background: #1f6feb; border-color: #1f6feb; color: #fff; }
124.tab-content { display: none; }
125.tab-content.active { display: block; }
126h1 { color: #58a6ff; font-size: 1.5em; margin-bottom: 16px; }
127h2 { color: #c9d1d9; font-size: 1.2em; margin: 16px 0 12px; }
128table { width: 100%; border-collapse: collapse; font-size: 13px; }
129th {
130  padding: 8px 10px; text-align: right; border-bottom: 2px solid #30363d;
131  color: #8b949e; cursor: pointer; user-select: none; white-space: nowrap;
132  position: sticky; top: 0; background: #161b22; z-index: 2;
133}
134th.text-left { text-align: left; }
135th:hover { color: #58a6ff; }
136td { padding: 6px 10px; text-align: right; border-bottom: 1px solid #21262d; }
137td.text-left { text-align: left; }
138tr:hover { background: #1c2128; }
139.sort-asc::after { content: ' \25b2'; color: #58a6ff; }
140.sort-desc::after { content: ' \25bc'; color: #58a6ff; }
141.expandable { cursor: pointer; }
142.session-detail { background: #0d1117; }
143.session-detail td { padding: 0; }
144.session-detail:hover { background: #0d1117; }
145.detail-content { padding: 16px; overflow-x: auto; }
146.detail-content table { font-size: 12px; }
147.compact-row { background: #2d1b1b !important; }
148.chart-container { position: relative; height: 350px; margin: 16px 0; }
149.grid-2x2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
150.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
151.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 16px 0; }
152.footer { color: #484f58; font-size: 12px; margin-top: 30px; padding-top: 16px; border-top: 1px solid #21262d; }
153.header-row { display: flex; align-items: baseline; gap: 16px; margin-bottom: 16px; flex-wrap: wrap; }
154.subtitle { color: #8b949e; font-size: 0.85em; }
155.expand-btn { background: none; border: none; color: #8b949e; cursor: pointer; font-size: 14px; padding: 2px 6px; }
156.expand-btn:hover { color: #58a6ff; }
157.project-session-row { background: #111822; }
158.project-session-row:hover { background: #1c2128; }
159.project-row { background: #161b22; font-weight: 600; }
160.progress-bar { display: inline-block; width: 80px; height: 14px; background: #21262d; border-radius: 7px; overflow: hidden; vertical-align: middle; }
161.progress-fill { height: 100%; border-radius: 7px; transition: width 0.3s; }
162.progress-text { display: inline-block; width: 45px; text-align: right; margin-left: 4px; font-size: 12px; }
163.stale-warning { color: #ff6b6b; margin-bottom: 8px; }
164.top-nav { display: flex; gap: 8px; margin-bottom: 12px; }
165.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; transition: background 0.15s, border-color 0.15s; }
166.top-nav button:hover { border-color: #58a6ff; }
167.top-nav button.active { background: #1f6feb; border-color: #1f6feb; color: #fff; }
168.sub-nav { display: flex; gap: 8px; margin-bottom: 16px; }
169.sub-nav button { padding: 6px 16px; border: 1px solid #30363d; border-radius: 6px; background: #161b22; color: #c9d1d9; cursor: pointer; font-size: 13px; transition: background 0.15s, border-color 0.15s; }
170.sub-nav button:hover { border-color: #238636; }
171.sub-nav button.active { background: #238636; border-color: #238636; color: #fff; }
172.source-content { display: none; }
173.source-content.active { display: block; }
174.sub-tab-content { display: none; }
175.sub-tab-content.active { display: block; }
176.tool-tag { display: inline-block; padding: 1px 6px; margin: 1px 2px; border-radius: 4px; background: #21262d; color: #8b949e; font-size: 11px; white-space: nowrap; }
177.tool-tag .tool-count { color: #58a6ff; font-weight: 600; margin-left: 2px; }
178.session-tools-cell { max-width: 260px; line-height: 1.8; }
179.agent-badge { display: inline-block; padding: 1px 5px; border-radius: 3px; background: #1f3a5f; color: #58a6ff; font-size: 11px; font-weight: 600; margin-left: 4px; }
180.turn-count-cell { white-space: nowrap; }
181.grid-1-2 { display: grid; grid-template-columns: 1fr 2fr; gap: 16px; }
182.chart-container-sm { position: relative; height: 250px; margin: 12px 0; }
183.model-legend { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
184.model-legend-item { display: flex; align-items: center; gap: 4px; font-size: 12px; color: #8b949e; }
185.model-legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
186.data-table th { cursor: default; }
187.data-table th:hover { color: #8b949e; }
188.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
189.glossary { color: #8b949e; font-size: 12px; margin-bottom: 16px; line-height: 1.7; padding: 12px 16px; background: #161b22; border: 1px solid #30363d; border-radius: 8px; }
190.heatmap-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
191.heatmap-wrap canvas { display: block; }
192@media (max-width: 1100px) {
193  .grid-4 { grid-template-columns: repeat(2, 1fr); }
194}
195@media (max-width: 900px) {
196  .grid-2x2 { grid-template-columns: 1fr; }
197  .grid-2 { grid-template-columns: 1fr; }
198  .grid-1-2 { grid-template-columns: 1fr; }
199  .grid-4 { grid-template-columns: repeat(2, 1fr); }
200  .kpi-grid { grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); }
201}
202@media (max-width: 600px) {
203  body { padding: 12px; }
204  .grid-4 { grid-template-columns: 1fr; }
205  .kpi-grid { grid-template-columns: 1fr 1fr; }
206  .kpi-value { font-size: 1.2em; }
207  .header-row { flex-direction: column; gap: 8px; }
208  .header-row button { margin-left: 0 !important; }
209}
210"#
211}
212
213// ─── JavaScript ──────────────────────────────────────────────────────────────
214
215fn js_common() -> &'static str {
216    r#"
217function showTab(name) {
218  document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
219  document.querySelectorAll('nav button').forEach(el => el.classList.remove('active'));
220  document.getElementById('tab-' + name).classList.add('active');
221  document.querySelector('nav button[data-tab="' + name + '"]').classList.add('active');
222}
223
224function sortTable(th, tableId) {
225  const table = document.getElementById(tableId);
226  const tbody = table.querySelector('tbody');
227  const rows = Array.from(tbody.querySelectorAll('tr:not(.session-detail)'));
228  const colIndex = th.cellIndex;
229  const isAsc = th.classList.contains('sort-asc');
230
231  table.querySelectorAll('th').forEach(h => {
232    h.classList.remove('sort-asc', 'sort-desc');
233  });
234
235  th.classList.add(isAsc ? 'sort-desc' : 'sort-asc');
236
237  rows.sort((a, b) => {
238    let va = a.cells[colIndex].getAttribute('data-value') || a.cells[colIndex].textContent;
239    let vb = b.cells[colIndex].getAttribute('data-value') || b.cells[colIndex].textContent;
240    const na = parseFloat(va.replace(/[\$,%]/g, ''));
241    const nb = parseFloat(vb.replace(/[\$,%]/g, ''));
242    if (!isNaN(na) && !isNaN(nb)) {
243      return isAsc ? nb - na : na - nb;
244    }
245    return isAsc ? vb.localeCompare(va) : va.localeCompare(vb);
246  });
247
248  rows.forEach(row => {
249    const detail = row.nextElementSibling;
250    tbody.appendChild(row);
251    if (detail && detail.classList.contains('session-detail')) {
252      tbody.appendChild(detail);
253    }
254  });
255}
256
257function toggleSession(btn) {
258  const row = btn.closest('tr');
259  const detail = row.nextElementSibling;
260  if (detail && detail.classList.contains('session-detail')) {
261    const isHidden = detail.style.display === 'none';
262    detail.style.display = isHidden ? 'table-row' : 'none';
263    btn.textContent = isHidden ? '\u25bc' : '\u25b6';
264  }
265}
266
267function toggleProject(btn, projectId) {
268  const sessionRows = document.querySelectorAll('.project-session-row.project-sessions-' + projectId);
269  const detailRows = document.querySelectorAll('.session-detail.project-sessions-' + projectId);
270  const isHidden = sessionRows.length > 0 && sessionRows[0].style.display === 'none';
271
272  if (isHidden) {
273    // Expand: show session rows only (not turn details)
274    sessionRows.forEach(r => r.style.display = 'table-row');
275  } else {
276    // Collapse: hide session rows AND any open turn details
277    sessionRows.forEach(r => {
278      r.style.display = 'none';
279      const sbtn = r.querySelector('.expand-btn');
280      if (sbtn) sbtn.textContent = '\u25b6';
281    });
282    detailRows.forEach(r => r.style.display = 'none');
283  }
284  btn.textContent = isHidden ? '\u25bc' : '\u25b6';
285}
286
287// Heatmap data is already in local timezone (converted in Rust).
288// No JS-side timezone shift needed.
289
290function drawHeatmap(canvasId, data) {
291  const canvas = document.getElementById(canvasId);
292  if (!canvas) return;
293  const ctx = canvas.getContext('2d');
294  const localData = data; // already local timezone from Rust
295  const zhDays = ['周一','周二','周三','周四','周五','周六','周日'];
296  const enDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
297  const days = (currentLang === 'zh') ? zhDays : enDays;
298  const cellW = 28, cellH = 28, padL = 44, padT = 30;
299  canvas.width = padL + 24 * cellW + 10;
300  canvas.height = padT + 7 * cellH + 10;
301
302  const max = Math.max(...localData.flat(), 1);
303
304  for (let d = 0; d < 7; d++) {
305    for (let h = 0; h < 24; h++) {
306      const val = localData[d][h];
307      const intensity = val / max;
308      const r = Math.round(13 + intensity * 75);
309      const g = Math.round(17 + intensity * 130);
310      const b = Math.round(34 + intensity * 221);
311      ctx.fillStyle = 'rgb(' + r + ',' + g + ',' + b + ')';
312      ctx.fillRect(padL + h * cellW, padT + d * cellH, cellW - 2, cellH - 2);
313
314      if (val > 0) {
315        ctx.fillStyle = intensity > 0.6 ? '#fff' : '#8b949e';
316        ctx.font = '10px sans-serif';
317        ctx.textAlign = 'center';
318        ctx.fillText(val, padL + h * cellW + cellW/2 - 1, padT + d * cellH + cellH/2 + 3);
319      }
320    }
321    ctx.fillStyle = '#8b949e';
322    ctx.font = '11px sans-serif';
323    ctx.textAlign = 'right';
324    ctx.fillText(days[d], padL - 5, padT + d * cellH + cellH/2 + 3);
325  }
326  ctx.textAlign = 'center';
327  for (let h = 0; h < 24; h += 2) {
328    ctx.fillText(h.toString().padStart(2, '0'), padL + h * cellW + cellW/2, padT - 8);
329  }
330}
331
332function sortTableSimple(th, tableId) {
333  const table = document.getElementById(tableId);
334  const tbody = table.querySelector('tbody');
335  const rows = Array.from(tbody.querySelectorAll('tr'));
336  const colIndex = th.cellIndex;
337  const isAsc = th.classList.contains('sort-asc');
338
339  table.querySelectorAll('th').forEach(h => {
340    h.classList.remove('sort-asc', 'sort-desc');
341  });
342  th.classList.add(isAsc ? 'sort-desc' : 'sort-asc');
343
344  rows.sort((a, b) => {
345    let va = a.cells[colIndex].getAttribute('data-value') || a.cells[colIndex].textContent;
346    let vb = b.cells[colIndex].getAttribute('data-value') || b.cells[colIndex].textContent;
347    const na = parseFloat(va.replace(/[\$,%]/g, ''));
348    const nb = parseFloat(vb.replace(/[\$,%]/g, ''));
349    if (!isNaN(na) && !isNaN(nb)) {
350      return isAsc ? nb - na : na - nb;
351    }
352    return isAsc ? vb.localeCompare(va) : va.localeCompare(vb);
353  });
354  rows.forEach(row => tbody.appendChild(row));
355}
356
357function switchSource(sourceId) {
358  document.querySelectorAll('.source-content').forEach(el => el.style.display = 'none');
359  document.querySelectorAll('.top-nav button').forEach(el => el.classList.remove('active'));
360  document.getElementById('source-' + sourceId).style.display = 'block';
361  event.target.classList.add('active');
362  // Redraw heatmap for this source
363  if (window['_heatmapData_' + sourceId]) {
364    drawHeatmap('heatmap-' + sourceId, window['_heatmapData_' + sourceId]);
365  }
366}
367
368function showSubTab(sourceId, tabName) {
369  const container = document.getElementById('source-' + sourceId);
370  container.querySelectorAll('.sub-tab-content').forEach(el => el.classList.remove('active'));
371  container.querySelectorAll('.sub-nav button').forEach(el => el.classList.remove('active'));
372  document.getElementById(sourceId + '-tab-' + tabName).classList.add('active');
373  event.target.classList.add('active');
374  // Redraw heatmap when overview tab becomes visible
375  if (tabName === 'overview' && window['_heatmapData_' + sourceId]) {
376    drawHeatmap('heatmap-' + sourceId, window['_heatmapData_' + sourceId]);
377  }
378}
379
380let currentLang = localStorage.getItem('cc-lang') || 'en';
381function toggleLang() {
382  currentLang = currentLang === 'en' ? 'zh' : 'en';
383  localStorage.setItem('cc-lang', currentLang);
384  applyLang();
385}
386function applyLang() {
387  document.querySelectorAll('[data-en]').forEach(el => {
388    el.textContent = el.getAttribute('data-' + currentLang) || el.getAttribute('data-en');
389  });
390  const btn = document.getElementById('lang-btn');
391  if (btn) btn.textContent = currentLang === 'en' ? '中文' : 'EN';
392  // Redraw heatmaps with localized day names
393  for (const key of Object.keys(window)) {
394    if (key.startsWith('_heatmapData_')) {
395      const pfx = key.replace('_heatmapData_', '');
396      drawHeatmap('heatmap-' + pfx, window[key]);
397    }
398  }
399}
400// Convert UTC timestamps to local timezone
401function convertTimestamps() {
402  document.querySelectorAll('[data-utc]').forEach(el => {
403    const utc = el.getAttribute('data-utc');
404    const d = new Date(utc);
405    if (!isNaN(d)) {
406      const pad = n => String(n).padStart(2, '0');
407      el.textContent = pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
408      el.title = d.toLocaleString();
409    }
410  });
411  document.querySelectorAll('[data-utc-datetime]').forEach(el => {
412    const utc = el.getAttribute('data-utc-datetime');
413    const d = new Date(utc);
414    if (!isNaN(d)) {
415      const pad = n => String(n).padStart(2, '0');
416      el.textContent = pad(d.getMonth()+1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
417      el.title = d.toLocaleString();
418    }
419  });
420}
421document.addEventListener('DOMContentLoaded', function() { applyLang(); convertTimestamps(); });
422"#
423}
424
425// ─── Source Tabs Renderer ────────────────────────────────────────────────────
426
427/// Render sub-nav + 3 tab contents (overview, monthly, projects) for one data source.
428/// All element IDs are prefixed with `pfx` to avoid conflicts in dual-source mode.
429fn render_source_tabs(
430    out: &mut String,
431    pfx: &str,
432    overview: &OverviewResult,
433    projects: &ProjectResult,
434    trend: &TrendResult,
435    calc: &PricingCalculator,
436) {
437    // Sub-navigation
438    writeln!(out, r#"<nav class="sub-nav">"#).unwrap();
439    writeln!(out, r#"<button class="active" onclick="showSubTab('{pfx}','overview')" data-en="Overview" data-zh="概览">Overview</button>"#,
440        pfx = pfx).unwrap();
441    writeln!(out, r#"<button onclick="showSubTab('{pfx}','monthly')" data-en="Monthly" data-zh="月度">Monthly</button>"#,
442        pfx = pfx).unwrap();
443    writeln!(out, r#"<button onclick="showSubTab('{pfx}','projects')" data-en="Projects" data-zh="项目">Projects</button>"#,
444        pfx = pfx).unwrap();
445    writeln!(out, "</nav>").unwrap();
446
447    // Tab 1: Overview
448    writeln!(out, r#"<div id="{pfx}-tab-overview" class="sub-tab-content active">"#, pfx = pfx).unwrap();
449    render_overview_tab(out, overview, pfx);
450    writeln!(out, "</div>").unwrap();
451
452    // Tab 2: Monthly
453    writeln!(out, r#"<div id="{pfx}-tab-monthly" class="sub-tab-content">"#, pfx = pfx).unwrap();
454    render_monthly_tab(out, overview, trend, pfx);
455    writeln!(out, "</div>").unwrap();
456
457    // Tab 3: Projects
458    writeln!(out, r#"<div id="{pfx}-tab-projects" class="sub-tab-content">"#, pfx = pfx).unwrap();
459    render_projects_tab(out, projects, &overview.session_summaries, pfx);
460    writeln!(out, "</div>").unwrap();
461
462    // Pricing source note
463    writeln!(out, r#"<p style="color:#484f58;font-size:11px;margin-top:12px;">Price data: {} ({})</p>"#,
464        PRICING_SOURCE, PRICING_FETCH_DATE).unwrap();
465
466    let _ = calc;
467}
468
469// ─── 1. Full Report (single source) ─────────────────────────────────────────
470
471/// Generate a comprehensive HTML dashboard with 3 tabs, charts, and sortable tables.
472/// Single data source — no top-level source switcher.
473pub fn render_full_report_html(
474    overview: &OverviewResult,
475    projects: &ProjectResult,
476    trend: &TrendResult,
477    calc: &PricingCalculator,
478) -> String {
479    let mut out = String::with_capacity(256 * 1024);
480
481    // ── HTML head ────────────────────────────────────────────────────────────
482    write!(out, r#"<!DOCTYPE html>
483<html lang="zh-CN">
484<head>
485  <meta charset="UTF-8">
486  <meta name="viewport" content="width=device-width, initial-scale=1.0">
487  <title>Claude Code Token Analyzer</title>
488  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
489  <style>{css}</style>
490</head>
491<body>
492"#, css = css()).unwrap();
493
494    // ── Header ───────────────────────────────────────────────────────────────
495    writeln!(out, r#"<div class="header-row">"#).unwrap();
496    writeln!(out, r#"<h1>Claude Code Token Analyzer</h1>"#).unwrap();
497    if let Some((start, end)) = &overview.quality.time_range {
498        writeln!(out, r#"<span class="subtitle">{} ~ {}</span>"#,
499            start.format("%Y-%m-%d"), end.format("%Y-%m-%d")).unwrap();
500    }
501    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();
502    writeln!(out, "</div>").unwrap();
503
504    // ── Glossary ──────────────────────────────────────────────────────────────
505    writeln!(out, r#"<div class="glossary" data-en="Glossary: Turn = one Claude response (each time you send a message or Claude calls a tool, it produces one turn). Session = one conversation from start to finish. Token = the unit Claude uses to process text (~4 chars = 1 token). Context = all tokens Claude sees per request (your message + history + cached content). Cache Hit = reusing previously processed context (saves cost)." data-zh="术语说明:Turn = 一次 Claude 响应(每次你发消息或 Claude 调用工具,都算一个 turn)。Session = 一次完整对话(从开始到结束)。Token = Claude 处理文本的单位(约 4 个英文字符 = 1 token)。Context = 每次请求 Claude 看到的全部内容(你的消息 + 历史记录 + 缓存内容)。Cache Hit = 复用之前处理过的上下文(节省费用)。">Glossary: Turn = one Claude response (each time you send a message or Claude calls a tool, it produces one turn). Session = one conversation from start to finish. Token = the unit Claude uses to process text (~4 chars = 1 token). Context = all tokens Claude sees per request (your message + history + cached content). Cache Hit = reusing previously processed context (saves cost).</div>"#).unwrap();
506
507    // ── Single source: use sub-nav directly (no top-nav) ─────────────────────
508    let pfx = "s1";
509    writeln!(out, r#"<div id="source-{pfx}" class="source-content active">"#, pfx = pfx).unwrap();
510    render_source_tabs(&mut out, pfx, overview, projects, trend, calc);
511    writeln!(out, "</div>").unwrap();
512
513    // ── JavaScript ───────────────────────────────────────────────────────────
514    write!(out, "<script>{}</script>", js_common()).unwrap();
515
516    // ── Footer ───────────────────────────────────────────────────────────────
517    render_footer(&mut out, calc);
518
519    writeln!(out, "</body>\n</html>").unwrap();
520    out
521}
522
523// ─── 1b. Dual Report (two sources) ──────────────────────────────────────────
524
525/// Generate a dual-source HTML dashboard with top-level source switcher.
526/// Each source gets its own sub-nav with 3 tabs.
527pub fn render_dual_report_html(
528    source1_name: &str,
529    source1: &ReportData,
530    source2_name: &str,
531    source2: &ReportData,
532    calc: &PricingCalculator,
533) -> String {
534    let mut out = String::with_capacity(512 * 1024);
535
536    // ── HTML head ────────────────────────────────────────────────────────────
537    write!(out, r#"<!DOCTYPE html>
538<html lang="zh-CN">
539<head>
540  <meta charset="UTF-8">
541  <meta name="viewport" content="width=device-width, initial-scale=1.0">
542  <title>Claude Code Token Analyzer</title>
543  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
544  <style>{css}</style>
545</head>
546<body>
547"#, css = css()).unwrap();
548
549    // ── Header ───────────────────────────────────────────────────────────────
550    writeln!(out, r#"<div class="header-row">"#).unwrap();
551    writeln!(out, r#"<h1>Claude Code Token Analyzer</h1>"#).unwrap();
552    // Show combined time range
553    let time_range_str = {
554        let mut global_min = None;
555        let mut global_max = None;
556        for q in [&source1.overview.quality, &source2.overview.quality] {
557            if let Some((s, e)) = &q.time_range {
558                global_min = Some(global_min.map_or(*s, |m: chrono::DateTime<chrono::Utc>| m.min(*s)));
559                global_max = Some(global_max.map_or(*e, |m: chrono::DateTime<chrono::Utc>| m.max(*e)));
560            }
561        }
562        match (global_min, global_max) {
563            (Some(s), Some(e)) => format!("{} ~ {}", s.format("%Y-%m-%d"), e.format("%Y-%m-%d")),
564            _ => String::new(),
565        }
566    };
567    if !time_range_str.is_empty() {
568        writeln!(out, r#"<span class="subtitle">{}</span>"#, time_range_str).unwrap();
569    }
570    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();
571    writeln!(out, "</div>").unwrap();
572
573    // ── Glossary ──────────────────────────────────────────────────────────────
574    writeln!(out, r#"<div class="glossary" data-en="Glossary: Turn = one Claude response (each time you send a message or Claude calls a tool, it produces one turn). Session = one conversation from start to finish. Token = the unit Claude uses to process text (~4 chars = 1 token). Context = all tokens Claude sees per request (your message + history + cached content). Cache Hit = reusing previously processed context (saves cost)." data-zh="术语说明:Turn = 一次 Claude 响应(每次你发消息或 Claude 调用工具,都算一个 turn)。Session = 一次完整对话(从开始到结束)。Token = Claude 处理文本的单位(约 4 个英文字符 = 1 token)。Context = 每次请求 Claude 看到的全部内容(你的消息 + 历史记录 + 缓存内容)。Cache Hit = 复用之前处理过的上下文(节省费用)。">Glossary: Turn = one Claude response (each time you send a message or Claude calls a tool, it produces one turn). Session = one conversation from start to finish. Token = the unit Claude uses to process text (~4 chars = 1 token). Context = all tokens Claude sees per request (your message + history + cached content). Cache Hit = reusing previously processed context (saves cost).</div>"#).unwrap();
575
576    // ── Top-level source switcher ────────────────────────────────────────────
577    let s1_sessions = source1.overview.total_sessions;
578    let s2_sessions = source2.overview.total_sessions;
579    writeln!(out, r#"<nav class="top-nav">"#).unwrap();
580    writeln!(out, r#"<button class="active" onclick="switchSource('s1')">{} ({} sessions)</button>"#,
581        escape_html(source1_name), s1_sessions).unwrap();
582    writeln!(out, r#"<button onclick="switchSource('s2')">{} ({} sessions)</button>"#,
583        escape_html(source2_name), s2_sessions).unwrap();
584    writeln!(out, "</nav>").unwrap();
585
586    // ── Source 1 ─────────────────────────────────────────────────────────────
587    writeln!(out, r#"<div id="source-s1" class="source-content active">"#).unwrap();
588    render_source_tabs(&mut out, "s1", &source1.overview, &source1.projects, &source1.trend, calc);
589    writeln!(out, "</div>").unwrap();
590
591    // ── Source 2 ─────────────────────────────────────────────────────────────
592    writeln!(out, r#"<div id="source-s2" class="source-content">"#).unwrap();
593    render_source_tabs(&mut out, "s2", &source2.overview, &source2.projects, &source2.trend, calc);
594    writeln!(out, "</div>").unwrap();
595
596    // ── JavaScript ───────────────────────────────────────────────────────────
597    write!(out, "<script>{}</script>", js_common()).unwrap();
598
599    // ── Footer ───────────────────────────────────────────────────────────────
600    render_footer(&mut out, calc);
601
602    writeln!(out, "</body>\n</html>").unwrap();
603    out
604}
605
606// ─── Tab 1: Overview ─────────────────────────────────────────────────────────
607
608fn render_overview_tab(out: &mut String, overview: &OverviewResult, pfx: &str) {
609    // KPI cards
610    writeln!(out, r#"<div class="kpi-grid">"#).unwrap();
611    write_kpi_i18n(out, &format_number(overview.total_sessions as u64), "Sessions", "会话数");
612    write_kpi_i18n(out, &format_number(overview.total_turns as u64), "Turns", "响应数");
613    write_kpi_i18n(out, &format_compact(overview.total_output_tokens), "Claude Wrote", "Claude 写了");
614    write_kpi_i18n(out, &format_compact(overview.total_context_tokens), "Claude Read", "Claude 读了");
615    write_kpi_progress(out, overview.avg_cache_hit_rate, "Avg Cache Hit Rate", "平均缓存命中率");
616    write_kpi_i18n(out, &format_cost(overview.total_cost), "Token Value (API Rate)", "Token 价值 (API 费率)");
617    if overview.cache_savings.total_saved > 0.0 {
618        write_kpi_i18n(out, &format_cost(overview.cache_savings.total_saved),
619            &format!("Cache Savings ({:.0}%)", overview.cache_savings.savings_pct),
620            &format!("缓存节省 ({:.0}%)", overview.cache_savings.savings_pct));
621    }
622    writeln!(out, "</div>").unwrap();
623
624    // Row 1: Usage Insights KPI cards
625    {
626        let summaries = &overview.session_summaries;
627
628        // Daily avg cost
629        let daily_avg = overview.quality.time_range.map(|(s, e)| {
630            let days = (e - s).num_days().max(1) as f64;
631            (overview.total_cost / days, days as u64)
632        });
633
634        // Compaction stats
635        let total_compactions: usize = summaries.iter().map(|s| s.compaction_count).sum();
636
637        // Max context
638        let max_ctx = summaries.iter().map(|s| s.max_context).max().unwrap_or(0);
639
640        // Average session duration
641        let durations: Vec<f64> = summaries.iter()
642            .map(|s| s.duration_minutes).filter(|d| *d > 0.0).collect();
643        let avg_dur = if !durations.is_empty() { durations.iter().sum::<f64>() / durations.len() as f64 } else { 0.0 };
644
645        writeln!(out, r#"<div class="grid-4">"#).unwrap();
646        if let Some((avg, days)) = daily_avg {
647            write_kpi_i18n(out,
648                &format!("{}/day", format_cost(avg)),
649                &format!("Daily Avg ({} days)", days),
650                &format!("日均费用({} 天)", days));
651        }
652        write_kpi_i18n(out,
653            &format_number(max_ctx),
654            "Peak Context",
655            "峰值上下文");
656        write_kpi_i18n(out,
657            &format_number(total_compactions as u64),
658            "Compactions",
659            "上下文压缩次数");
660        write_kpi_i18n(out,
661            &format_duration(avg_dur),
662            "Avg Session",
663            "平均会话时长");
664        writeln!(out, "</div>").unwrap();
665
666        // Top 3 most expensive sessions
667        let mut by_cost: Vec<&crate::analysis::SessionSummary> = summaries.iter().collect();
668        by_cost.sort_by(|a, b| b.cost.partial_cmp(&a.cost).unwrap_or(std::cmp::Ordering::Equal));
669        let top3 = &by_cost[..by_cost.len().min(3)];
670        if !top3.is_empty() {
671            writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
672            writeln!(out, r#"<h2 data-en="Most Expensive Sessions" data-zh="最贵会话 Top 3">Most Expensive Sessions</h2>"#).unwrap();
673            writeln!(out, r#"<div class="table-wrap">"#).unwrap();
674            writeln!(out, r#"<table class="data-table"><thead><tr>
675                <th class="text-left" data-en="Session" data-zh="会话">Session</th>
676                <th class="text-left" data-en="Project" data-zh="项目">Project</th>
677                <th data-en="Turns" data-zh="响应数">Turns</th>
678                <th data-en="Duration" data-zh="时长">Duration</th>
679                <th data-en="Cost" data-zh="费用">Cost</th>
680            </tr></thead><tbody>"#).unwrap();
681            for s in top3 {
682                writeln!(out, "<tr><td class=\"text-left\">{}</td><td class=\"text-left\">{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
683                    escape_html(&s.session_id[..s.session_id.len().min(8)]),
684                    escape_html(&s.project_display_name),
685                    s.turn_count,
686                    format_duration(s.duration_minutes),
687                    format_cost(s.cost),
688                ).unwrap();
689            }
690            writeln!(out, "</tbody></table></div></div>").unwrap();
691        }
692    }
693
694    // Row 2: Heatmap
695    writeln!(out, r#"<div class="grid-2" style="margin-top:16px;">"#).unwrap();
696
697    // Chart 3: Heatmap (Weekday x Hour) - now with local timezone
698    {
699        let canvas_id = format!("heatmap-{}", pfx);
700        writeln!(out, r#"<div class="card">"#).unwrap();
701        writeln!(out, r#"<h2 data-en="Activity Heatmap (Local Time)" data-zh="活跃热力图(本地时间)">Activity Heatmap (Local Time)</h2>"#).unwrap();
702        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();
703        writeln!(out, r#"<div class="heatmap-wrap"><canvas id="{}"></canvas></div>"#, canvas_id).unwrap();
704
705        let mut matrix_js = String::from("[");
706        for d in 0..7 {
707            if d > 0 { matrix_js.push(','); }
708            matrix_js.push('[');
709            for h in 0..24 {
710                if h > 0 { matrix_js.push(','); }
711                write!(matrix_js, "{}", overview.weekday_hour_matrix[d][h]).unwrap();
712            }
713            matrix_js.push(']');
714        }
715        matrix_js.push(']');
716
717        writeln!(out, r#"<script>
718window._heatmapData_{pfx} = {matrix};
719document.addEventListener('DOMContentLoaded', function() {{
720  drawHeatmap('{canvas_id}', window._heatmapData_{pfx});
721}});
722</script>"#, pfx = pfx, matrix = matrix_js, canvas_id = canvas_id).unwrap();
723        writeln!(out, "</div>").unwrap();
724    }
725
726    // Chart 4: Efficiency Scatter (Bubble, turns vs cost)
727    {
728        let chart_id = format!("{}-scatterChart", pfx);
729        writeln!(out, r#"<div class="card">"#).unwrap();
730        writeln!(out, r#"<h2 data-en="Session Efficiency (Turns vs Cost)" data-zh="会话效率(Turns vs 费用)">Session Efficiency (Turns vs Cost)</h2>"#).unwrap();
731        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();
732        writeln!(out, r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#, chart_id).unwrap();
733
734        let max_output: u64 = overview.session_summaries.iter().map(|s| s.output_tokens).max().unwrap_or(1);
735        let mut scatter_data = String::from("[");
736        for (i, s) in overview.session_summaries.iter().enumerate() {
737            if i > 0 { scatter_data.push(','); }
738            let radius = if max_output > 0 {
739                3.0 + (s.output_tokens as f64 / max_output as f64) * 20.0
740            } else { 3.0 };
741            let cpt = if s.turn_count > 0 { s.cost / s.turn_count as f64 } else { 0.0 };
742            write!(scatter_data, "{{x:{},y:{:.4},r:{:.1},cpt:{:.4},out:{}}}", s.turn_count, s.cost, radius, cpt, s.output_tokens).unwrap();
743        }
744        scatter_data.push(']');
745
746        writeln!(out, r#"<script>
747new Chart(document.getElementById('{chart_id}'), {{
748  type: 'bubble',
749  data: {{
750    datasets: [{{
751      label: 'Sessions',
752      data: {data},
753      backgroundColor: 'rgba(88,166,255,0.4)',
754      borderColor: '#58a6ff',
755      borderWidth: 1
756    }}]
757  }},
758  options: {{
759    responsive: true, maintainAspectRatio: false,
760    plugins: {{
761      legend: {{ display: false }},
762      tooltip: {{ callbacks: {{
763        label: function(ctx) {{
764          const d = ctx.raw;
765          return ['Turns: ' + d.x + '  Cost: $' + d.y.toFixed(2), 'Cost/Turn: $' + d.cpt.toFixed(3) + '  Output: ' + d.out.toLocaleString()];
766        }}
767      }} }}
768    }},
769    scales: {{
770      x: {{ title: {{ display: true, text: 'Turn Count', color: '#8b949e' }}, ticks: {{ color: '#8b949e' }}, grid: {{ color: '#21262d' }} }},
771      y: {{ title: {{ display: true, text: 'Cost ($)', color: '#8b949e' }}, ticks: {{ color: '#8b949e', callback: function(v) {{ return '$' + v; }} }}, grid: {{ color: '#21262d' }} }}
772    }}
773  }}
774}});
775</script>"#, chart_id = chart_id, data = scatter_data).unwrap();
776        writeln!(out, "</div>").unwrap();
777    }
778
779    writeln!(out, "</div>").unwrap(); // close grid-2
780}
781
782// ─── Tab 2: Monthly ──────────────────────────────────────────────────────────
783
784fn render_monthly_tab(out: &mut String, _overview: &OverviewResult, trend: &TrendResult, pfx: &str) {
785    if trend.entries.is_empty() {
786        writeln!(out, r#"<div class="card"><p style="color:#8b949e;">No trend data available.</p></div>"#).unwrap();
787        return;
788    }
789
790    // Determine the latest month from trend entries
791    let latest_month = trend.entries.last().map(|e| &e.label[..7]).unwrap_or("");
792
793    // Aggregate current month data
794    let mut month_cost = 0.0f64;
795    let mut month_turns = 0usize;
796    let mut month_sessions = 0usize;
797    let mut month_output = 0u64;
798
799    let mut daily_entries: Vec<&crate::analysis::TrendEntry> = Vec::new();
800
801    for entry in &trend.entries {
802        if entry.label.starts_with(latest_month) {
803            month_cost += entry.cost;
804            month_turns += entry.turn_count;
805            month_sessions += entry.session_count;
806            month_output += entry.tokens.output_tokens;
807            daily_entries.push(entry);
808        }
809    }
810
811    let avg_cost_per_turn = if month_turns > 0 { month_cost / month_turns as f64 } else { 0.0 };
812
813    // KPI cards for current month
814    writeln!(out, r#"<h2 data-en="Current Period: {m}" data-zh="当前周期:{m}">Current Period: {m}</h2>"#, m = escape_html(latest_month)).unwrap();
815    writeln!(out, r#"<div class="kpi-grid">"#).unwrap();
816    write_kpi_i18n(out, &format_number(month_sessions as u64), "Sessions", "会话数");
817    write_kpi_i18n(out, &format_number(month_turns as u64), "Turns", "响应数");
818    write_kpi_i18n(out, &format_compact(month_output), "Output Tokens", "输出 Token");
819    write_kpi_i18n(out, &format_cost(month_cost), "Cost", "费用");
820    write_kpi_i18n(out, &format!("${:.4}", avg_cost_per_turn), "Avg Cost/Turn", "平均每 Turn 费用");
821    writeln!(out, "</div>").unwrap();
822
823    // Chart: Daily Cost + Cost/Turn combo chart
824    if !daily_entries.is_empty() {
825        let chart_id = format!("{}-dailyCostChart", pfx);
826        writeln!(out, r#"<div class="card">"#).unwrap();
827        writeln!(out, r#"<h2 data-en="Daily Cost &amp; Cost/Turn ({})" data-zh="每日费用 &amp; 每 Turn 费用 ({})">Daily Cost &amp; Cost/Turn ({})</h2>"#,
828            escape_html(latest_month), escape_html(latest_month), escape_html(latest_month)).unwrap();
829        writeln!(out, r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#, chart_id).unwrap();
830
831        let labels: Vec<String> = daily_entries.iter().map(|e| format!("\"{}\"", &e.label[5..])).collect();
832        let cost_data: Vec<String> = daily_entries.iter().map(|e| format!("{:.2}", e.cost)).collect();
833        let cpt_data: Vec<String> = daily_entries.iter().map(|e| {
834            if e.turn_count > 0 { format!("{:.4}", e.cost / e.turn_count as f64) } else { "0".to_string() }
835        }).collect();
836        let turn_data: Vec<String> = daily_entries.iter().map(|e| e.turn_count.to_string()).collect();
837
838        writeln!(out, r#"<script>
839new Chart(document.getElementById('{chart_id}'), {{
840  type: 'bar',
841  data: {{
842    labels: [{labels}],
843    datasets: [
844      {{
845        label: 'Cost ($)',
846        data: [{cost_data}],
847        backgroundColor: 'rgba(88,166,255,0.6)',
848        borderColor: '#58a6ff',
849        borderWidth: 1,
850        borderRadius: 4,
851        yAxisID: 'y',
852        order: 2
853      }},
854      {{
855        label: 'Cost/Turn ($)',
856        data: [{cpt_data}],
857        type: 'line',
858        borderColor: '#ffd93d',
859        backgroundColor: 'rgba(255,217,61,0.1)',
860        pointRadius: 3,
861        tension: 0.3,
862        yAxisID: 'y1',
863        order: 1
864      }}
865    ]
866  }},
867  options: {{
868    responsive: true, maintainAspectRatio: false,
869    plugins: {{
870      legend: {{ labels: {{ color: '#c9d1d9' }} }},
871      tooltip: {{ callbacks: {{
872        afterLabel: function(ctx) {{
873          const turns = [{turn_data}];
874          return 'Turns: ' + turns[ctx.dataIndex];
875        }}
876      }} }}
877    }},
878    scales: {{
879      x: {{ ticks: {{ color: '#8b949e' }}, grid: {{ color: '#21262d' }} }},
880      y: {{ position: 'left', ticks: {{ color: '#8b949e', callback: function(v) {{ return '$' + v; }} }}, grid: {{ color: '#21262d' }}, title: {{ display: true, text: 'Cost ($)', color: '#8b949e' }} }},
881      y1: {{ position: 'right', ticks: {{ color: '#ffd93d', callback: function(v) {{ return '$' + v.toFixed(3); }} }}, grid: {{ drawOnChartArea: false }}, title: {{ display: true, text: 'Cost/Turn ($)', color: '#ffd93d' }} }}
882    }}
883  }}
884}});
885</script>"#, chart_id = chart_id,
886            labels = labels.join(","),
887            cost_data = cost_data.join(","),
888            cpt_data = cpt_data.join(","),
889            turn_data = turn_data.join(","),
890        ).unwrap();
891        writeln!(out, "</div>").unwrap();
892    }
893
894    // Chart: Model distribution per day (stacked bar)
895    {
896        // Collect all unique model names
897        let mut all_models: Vec<String> = Vec::new();
898        for entry in &daily_entries {
899            for model_name in entry.models.keys() {
900                let short = short_model(model_name);
901                if !all_models.contains(&short) {
902                    all_models.push(short);
903                }
904            }
905        }
906        all_models.sort();
907
908        if all_models.len() > 1 || (!all_models.is_empty() && daily_entries.len() > 1) {
909            let chart_id = format!("{}-modelDistChart", pfx);
910            writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
911            writeln!(out, r#"<h2 data-en="Model Usage per Day (Output Tokens)" data-zh="每日模型使用(输出 Token)">Model Usage per Day (Output Tokens)</h2>"#).unwrap();
912            writeln!(out, r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#, chart_id).unwrap();
913
914            let labels: Vec<String> = daily_entries.iter().map(|e| format!("\"{}\"", &e.label[5..])).collect();
915
916            let mut datasets = String::new();
917            for (mi, model_short) in all_models.iter().enumerate() {
918                if mi > 0 { datasets.push(','); }
919                let values: Vec<String> = daily_entries.iter().map(|e| {
920                    // Sum output tokens for all variants of this short model name
921                    let total: u64 = e.models.iter()
922                        .filter(|(k, _)| short_model(k) == *model_short)
923                        .map(|(_, v)| *v)
924                        .sum();
925                    total.to_string()
926                }).collect();
927                write!(datasets, "{{label:\"{}\",data:[{}],backgroundColor:\"{}\",borderWidth:0,borderRadius:2}}",
928                    escape_html(model_short), values.join(","), color(mi)).unwrap();
929            }
930
931            writeln!(out, r#"<script>
932new Chart(document.getElementById('{chart_id}'), {{
933  type: 'bar',
934  data: {{
935    labels: [{labels}],
936    datasets: [{datasets}]
937  }},
938  options: {{
939    responsive: true, maintainAspectRatio: false,
940    plugins: {{ legend: {{ labels: {{ color: '#c9d1d9' }} }} }},
941    scales: {{
942      x: {{ stacked: true, ticks: {{ color: '#8b949e' }}, grid: {{ color: '#21262d' }} }},
943      y: {{ stacked: true, ticks: {{ color: '#8b949e' }}, grid: {{ color: '#21262d' }}, title: {{ display: true, text: 'Output Tokens', color: '#8b949e' }} }}
944    }}
945  }}
946}});
947</script>"#, chart_id = chart_id, labels = labels.join(","), datasets = datasets).unwrap();
948            writeln!(out, "</div>").unwrap();
949        }
950    }
951
952    // Table: Monthly summary (aggregate by month if multi-month data)
953    {
954        // Group trend entries by month
955        let mut months: std::collections::BTreeMap<String, (usize, usize, u64, u64, u64, f64)> = std::collections::BTreeMap::new();
956        for entry in &trend.entries {
957            let month_key = entry.label[..7].to_string();
958            let e = months.entry(month_key).or_insert((0, 0, 0, 0, 0, 0.0));
959            e.0 += entry.session_count;
960            e.1 += entry.turn_count;
961            e.2 += entry.tokens.output_tokens;
962            e.3 += entry.tokens.cache_creation_tokens;
963            e.4 += entry.tokens.cache_read_tokens;
964            e.5 += entry.cost;
965        }
966
967        if months.len() > 1 {
968            let tbl_id = format!("{}-tbl-monthly", pfx);
969            writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
970            writeln!(out, r#"<h2 data-en="Monthly Summary" data-zh="月度汇总">Monthly Summary</h2>"#).unwrap();
971            writeln!(out, r#"<div class="table-wrap">"#).unwrap();
972            writeln!(out, r#"<table id="{}">"#, tbl_id).unwrap();
973            writeln!(out, "<thead><tr>\
974                <th class=\"text-left\" onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Month\" data-zh=\"月份\">Month</th>\
975                <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Sessions\" data-zh=\"会话\">Sessions</th>\
976                <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Turns\" data-zh=\"响应数\">Turns</th>\
977                <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Output Tokens\" data-zh=\"输出 Token\">Output Tokens</th>\
978                <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Cost/Turn\" data-zh=\"每 Turn 费用\">Cost/Turn</th>\
979                <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Cost\" data-zh=\"费用\">Cost</th>\
980            </tr></thead>", id = tbl_id).unwrap();
981            writeln!(out, "<tbody>").unwrap();
982
983            for (month, (sessions, turns, output, _cache_write, _cache_read, cost)) in &months {
984                let cpt = if *turns > 0 { cost / *turns as f64 } else { 0.0 };
985                writeln!(out, "<tr>\
986                    <td class=\"text-left\" data-value=\"{}\">{}</td>\
987                    <td data-value=\"{}\">{}</td>\
988                    <td data-value=\"{}\">{}</td>\
989                    <td data-value=\"{}\">{}</td>\
990                    <td data-value=\"{:.6}\">${:.4}</td>\
991                    <td data-value=\"{:.4}\">{}</td>\
992                </tr>",
993                    escape_html(month), escape_html(month),
994                    sessions, format_number(*sessions as u64),
995                    turns, format_number(*turns as u64),
996                    output, format_compact(*output),
997                    cpt, cpt,
998                    cost, format_cost(*cost),
999                ).unwrap();
1000            }
1001
1002            writeln!(out, "</tbody></table></div></div>").unwrap();
1003        }
1004    }
1005
1006    // Table: Daily detail with cost/turn
1007    {
1008        let tbl_id = format!("{}-tbl-daily", pfx);
1009        let group_zh = match trend.group_label.as_str() {
1010            "Day" => "每日",
1011            "Week" => "每周",
1012            "Month" => "每月",
1013            other => other,
1014        };
1015        let group_col_zh = match trend.group_label.as_str() {
1016            "Day" => "日期",
1017            "Week" => "周",
1018            "Month" => "月份",
1019            other => other,
1020        };
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(group_zh), escape_html(&trend.group_label)).unwrap();
1024        writeln!(out, r#"<div class="table-wrap">"#).unwrap();
1025        writeln!(out, r#"<table id="{}">"#, tbl_id).unwrap();
1026        writeln!(out, "<thead><tr>\
1027            <th class=\"text-left\" onclick=\"sortTableSimple(this,'{id}')\" data-en=\"{en}\" data-zh=\"{zh}\">{en}</th>\
1028            <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Sessions\" data-zh=\"会话\">Sessions</th>\
1029            <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Turns\" data-zh=\"响应数\">Turns</th>\
1030            <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Input Tokens\" data-zh=\"输入 Token\">Input Tokens</th>\
1031            <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Output Tokens\" data-zh=\"输出 Token\">Output Tokens</th>\
1032            <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Cost\" data-zh=\"费用\">Cost</th>\
1033            <th class=\"text-left\" data-en=\"Models\" data-zh=\"模型\">Models</th>\
1034        </tr></thead>", en = escape_html(&trend.group_label), zh = escape_html(group_col_zh), id = tbl_id).unwrap();
1035        writeln!(out, "<tbody>").unwrap();
1036
1037        for entry in &trend.entries {
1038            let input_tokens = entry.tokens.input_tokens + entry.tokens.cache_creation_tokens + entry.tokens.cache_read_tokens;
1039            // Model summary for this day
1040            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 class=\"text-left\" data-value=\"{}\">{}</td>\
1049                <td data-value=\"{}\">{}</td>\
1050                <td data-value=\"{}\">{}</td>\
1051                <td data-value=\"{}\">{}</td>\
1052                <td data-value=\"{}\">{}</td>\
1053                <td data-value=\"{:.4}\">{}</td>\
1054                <td class=\"text-left\">{}</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                input_tokens, format_compact(input_tokens),
1060                entry.tokens.output_tokens, format_compact(entry.tokens.output_tokens),
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
1070// ─── Tab 3: Projects ─────────────────────────────────────────────────────────
1071
1072fn render_projects_tab(out: &mut String, projects: &ProjectResult, sessions: &[crate::analysis::SessionSummary], pfx: &str) {
1073    // Chart: Project Cost Top 10
1074    {
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, r#"<h2 data-en="Project Cost Top 10" data-zh="项目费用 Top 10">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    // Three-level drill-down table: Project → Session → Turn
1108    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 class="table-wrap">"#).unwrap();
1112    writeln!(out, r#"<table id="{}">"#, tbl_id).unwrap();
1113    writeln!(out, "<thead><tr>\
1114        <th class=\"text-left\"></th>\
1115        <th class=\"text-left\" 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=\"响应数 (Agent)\">Turns (Agent)</th>\
1118        <th data-en=\"Output\" data-zh=\"输出\">Output</th>\
1119        <th data-en=\"CacheHit\" data-zh=\"缓存命中率\">CacheHit</th>\
1120        <th class=\"text-left\" 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    // Group sessions by project_display_name
1126    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        // Level 1: Project row (expandable)
1138        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 class="text-left"><button class="expand-btn" onclick="toggleProject(this,'{pid}')">{arrow}</button></td>"#,
1147            pid = pid, arrow = "\u{25b6}").unwrap();
1148        writeln!(out, "\
1149            <td class=\"text-left\"><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 class=\"text-left\"></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        // Level 2: Session rows (hidden by default, belong to this project)
1166        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.iter().filter(|s| s.turn_count > 0) {
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                // Session summary row
1176                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 class="text-left"><button class="expand-btn" onclick="toggleSession(this)">{}</button></td>"#, "\u{25b6}").unwrap();
1182                } else {
1183                    writeln!(out, r#"<td class="text-left"></td>"#).unwrap();
1184                }
1185
1186                // Turns with agent badge
1187                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                // Top tools as tags
1195                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 class=\"text-left\" style=\"padding-left:30px;\">{sid} <span style=\"color:#8b949e;font-size:11px;\">(<span data-utc-datetime=\"{utc}\">{date}</span> &middot; {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=\"text-left 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                // Level 3: Turn detail (hidden, shown when session is expanded)
1224                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
1237// ─── Turn Detail Sub-table ───────────────────────────────────────────────────
1238
1239fn 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
1243// ─── Footer ──────────────────────────────────────────────────────────────────
1244
1245fn 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
1259// ─── KPI Card Helper ─────────────────────────────────────────────────────────
1260
1261fn 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
1266/// KPI card with bilingual label.
1267fn 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
1272/// KPI card with a progress bar for percentage values, bilingual label.
1273fn write_kpi_progress(out: &mut String, pct: f64, en: &str, zh: &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:4px 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" data-en="{}" data-zh="{}">{}</div>
1281    </div>"#, pct, pct, bar_color, en, zh, en).unwrap();
1282}
1283
1284/// Render a progress bar inline for table cells.
1285fn 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
1291// ─── 2. Session Report ───────────────────────────────────────────────────────
1292
1293/// Generate a detailed HTML report for a single session.
1294pub 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    // ── HTML head ────────────────────────────────────────────────────────────
1300    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    // Header
1313    writeln!(out, r#"<div class="header-row">"#).unwrap();
1314    writeln!(out, "<h1>Session Analysis</h1>").unwrap();
1315    writeln!(out, r#"<span class="subtitle">{} &middot; {}</span>"#,
1316        escape_html(&result.session_id), escape_html(&result.project)).unwrap();
1317    writeln!(out, "</div>").unwrap();
1318
1319    // ── KPI cards ────────────────────────────────────────────────────────────
1320    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    // ── Charts (Context Growth + Cache Hit Rate) ─────────────────────────────
1337    if !result.turn_details.is_empty() {
1338        writeln!(out, r#"<div class="grid-2">"#).unwrap();
1339
1340        // Context Growth Line Chart
1341        {
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        // Cache Hit Rate Line Chart
1381        {
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(); // close grid-2
1421    }
1422
1423    // ── Stop Reason Doughnut ─────────────────────────────────────────────────
1424    if !result.stop_reason_counts.is_empty() {
1425        writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).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    // ── Turn Detail Table ────────────────────────────────────────────────────
1454    writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
1455    writeln!(out, "<h2>Turn Details</h2>").unwrap();
1456    writeln!(out, r#"<div class="table-wrap">"#).unwrap();
1457    render_turn_table_impl(&mut out, &result.turn_details, "tbl-session-turns");
1458    writeln!(out, "</div></div>").unwrap();
1459
1460    // ── JavaScript ───────────────────────────────────────────────────────────
1461    write!(out, "<script>{}</script>", js_common()).unwrap();
1462
1463    // ── Footer ───────────────────────────────────────────────────────────────
1464    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
1473/// Shared turn detail table -- used by both expandable session detail and single session report.
1474fn 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 class=\"text-left\" data-en=\"Model\" data-zh=\"模型\">Model</th>\
1480        <th class=\"text-left\" data-en=\"User\" data-zh=\"用户\">User</th>\
1481        <th class=\"text-left\" data-en=\"Assistant\" data-zh=\"助手\">Assistant</th>\
1482        <th class=\"text-left\" data-en=\"Tools\" data-zh=\"工具\">Tools</th>\
1483        <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Output\" data-zh=\"输出\">Output</th>\
1484        <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Context\" data-zh=\"上下文\">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 class=\"text-left\">Stop</th>\
1488        <th class=\"text-left\">\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        // Tools as tags instead of plain text
1517        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 class=\"text-left\">{model}</td>\
1534            <td class=\"text-left\" style=\"max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;\" title=\"{user_full}\">{user}</td>\
1535            <td class=\"text-left\" style=\"max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;\" title=\"{asst_full}\">{asst}</td>\
1536            <td class=\"text-left\" style=\"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 class=\"text-left\">{stop}</td>\
1542            <td class=\"text-left\">{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}