Skip to main content

cc_token_usage/output/
html.rs

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