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", "#ef4444", "#ec4899", "#a78bfa",
10    "#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!(
611        out,
612        r#"<div id="{pfx}-tab-overview" class="sub-tab-content active">"#,
613        pfx = pfx
614    )
615    .unwrap();
616    render_overview_tab(out, overview, pfx);
617    writeln!(out, "</div>").unwrap();
618
619    // Tab 2: Monthly
620    writeln!(
621        out,
622        r#"<div id="{pfx}-tab-monthly" class="sub-tab-content">"#,
623        pfx = pfx
624    )
625    .unwrap();
626    render_monthly_tab(out, overview, trend, pfx);
627    writeln!(out, "</div>").unwrap();
628
629    // Tab 3: Projects
630    writeln!(
631        out,
632        r#"<div id="{pfx}-tab-projects" class="sub-tab-content">"#,
633        pfx = pfx
634    )
635    .unwrap();
636    render_projects_tab(out, projects, &overview.session_summaries, pfx);
637    writeln!(out, "</div>").unwrap();
638
639    let _ = calc;
640}
641
642// ─── 1. Full Report (single source) ─────────────────────────────────────────
643
644/// Generate a comprehensive HTML dashboard with 3 tabs, charts, and sortable tables.
645/// Single data source — no top-level source switcher.
646pub fn render_full_report_html(
647    overview: &OverviewResult,
648    projects: &ProjectResult,
649    trend: &TrendResult,
650    calc: &PricingCalculator,
651) -> String {
652    let mut out = String::with_capacity(256 * 1024);
653
654    // ── HTML head ────────────────────────────────────────────────────────────
655    write!(out, r#"<!DOCTYPE html>
656<html lang="zh-CN">
657<head>
658  <meta charset="UTF-8">
659  <meta name="viewport" content="width=device-width, initial-scale=1.0">
660  <title>Claude Code Token Analyzer</title>
661  <link rel="preconnect" href="https://fonts.googleapis.com">
662  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
663  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
664  <script>{js_head}</script>
665  <style>{css}</style>
666</head>
667<body>
668"#, css = css(), js_head = js_head()).unwrap();
669
670    // ── Header ───────────────────────────────────────────────────────────────
671    writeln!(out, r#"<div class="header-row">"#).unwrap();
672    writeln!(out, r#"<h1>Claude Code Token Analyzer</h1>"#).unwrap();
673    if let Some((start, end)) = &overview.quality.time_range {
674        writeln!(
675            out,
676            r#"<span class="subtitle">{} ~ {}</span>"#,
677            start.format("%Y-%m-%d"),
678            end.format("%Y-%m-%d")
679        )
680        .unwrap();
681    }
682    writeln!(out, r#"<button class="theme-btn" onclick="toggleTheme()" style="margin-left:auto;">&#x2600;&#xFE0F;</button>"#).unwrap();
683    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();
684    writeln!(out, "</div>").unwrap();
685
686    // ── Glossary ──────────────────────────────────────────────────────────────
687    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();
688
689    // ── Single source: use sub-nav directly (no top-nav) ─────────────────────
690    let pfx = "s1";
691    writeln!(
692        out,
693        r#"<div id="source-{pfx}" class="source-content active">"#,
694        pfx = pfx
695    )
696    .unwrap();
697    render_source_tabs(&mut out, pfx, overview, projects, trend, calc);
698    writeln!(out, "</div>").unwrap();
699
700    // ── JavaScript ───────────────────────────────────────────────────────────
701    write!(out, "<script>{}</script>", js_common()).unwrap();
702
703    writeln!(out, "</body>\n</html>").unwrap();
704    out
705}
706
707// ─── 1b. Dual Report (two sources) ──────────────────────────────────────────
708
709/// Generate a dual-source HTML dashboard with top-level source switcher.
710/// Each source gets its own sub-nav with 3 tabs.
711pub fn render_dual_report_html(
712    source1_name: &str,
713    source1: &ReportData,
714    source2_name: &str,
715    source2: &ReportData,
716    calc: &PricingCalculator,
717) -> String {
718    let mut out = String::with_capacity(512 * 1024);
719
720    // ── HTML head ────────────────────────────────────────────────────────────
721    write!(out, r#"<!DOCTYPE html>
722<html lang="zh-CN">
723<head>
724  <meta charset="UTF-8">
725  <meta name="viewport" content="width=device-width, initial-scale=1.0">
726  <title>Claude Code Token Analyzer</title>
727  <link rel="preconnect" href="https://fonts.googleapis.com">
728  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
729  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
730  <script>{js_head}</script>
731  <style>{css}</style>
732</head>
733<body>
734"#, css = css(), js_head = js_head()).unwrap();
735
736    // ── Header ───────────────────────────────────────────────────────────────
737    writeln!(out, r#"<div class="header-row">"#).unwrap();
738    writeln!(out, r#"<h1>Claude Code Token Analyzer</h1>"#).unwrap();
739    // Show combined time range
740    let time_range_str = {
741        let mut global_min = None;
742        let mut global_max = None;
743        for q in [&source1.overview.quality, &source2.overview.quality] {
744            if let Some((s, e)) = &q.time_range {
745                global_min =
746                    Some(global_min.map_or(*s, |m: chrono::DateTime<chrono::Utc>| m.min(*s)));
747                global_max =
748                    Some(global_max.map_or(*e, |m: chrono::DateTime<chrono::Utc>| m.max(*e)));
749            }
750        }
751        match (global_min, global_max) {
752            (Some(s), Some(e)) => format!("{} ~ {}", s.format("%Y-%m-%d"), e.format("%Y-%m-%d")),
753            _ => String::new(),
754        }
755    };
756    if !time_range_str.is_empty() {
757        writeln!(out, r#"<span class="subtitle">{}</span>"#, time_range_str).unwrap();
758    }
759    writeln!(out, r#"<button class="theme-btn" onclick="toggleTheme()" style="margin-left:auto;">&#x2600;&#xFE0F;</button>"#).unwrap();
760    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();
761    writeln!(out, "</div>").unwrap();
762
763    // ── Glossary ──────────────────────────────────────────────────────────────
764    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();
765
766    // ── Top-level source switcher ────────────────────────────────────────────
767    let s1_sessions = source1.overview.total_sessions;
768    let s2_sessions = source2.overview.total_sessions;
769    writeln!(out, r#"<nav class="top-nav">"#).unwrap();
770    writeln!(
771        out,
772        r#"<button class="active" onclick="switchSource('s1')">{} ({} sessions)</button>"#,
773        escape_html(source1_name),
774        s1_sessions
775    )
776    .unwrap();
777    writeln!(
778        out,
779        r#"<button onclick="switchSource('s2')">{} ({} sessions)</button>"#,
780        escape_html(source2_name),
781        s2_sessions
782    )
783    .unwrap();
784    writeln!(out, "</nav>").unwrap();
785
786    // ── Source 1 ─────────────────────────────────────────────────────────────
787    writeln!(out, r#"<div id="source-s1" class="source-content active">"#).unwrap();
788    render_source_tabs(
789        &mut out,
790        "s1",
791        &source1.overview,
792        &source1.projects,
793        &source1.trend,
794        calc,
795    );
796    writeln!(out, "</div>").unwrap();
797
798    // ── Source 2 ─────────────────────────────────────────────────────────────
799    writeln!(out, r#"<div id="source-s2" class="source-content">"#).unwrap();
800    render_source_tabs(
801        &mut out,
802        "s2",
803        &source2.overview,
804        &source2.projects,
805        &source2.trend,
806        calc,
807    );
808    writeln!(out, "</div>").unwrap();
809
810    // ── JavaScript ───────────────────────────────────────────────────────────
811    write!(out, "<script>{}</script>", js_common()).unwrap();
812
813    writeln!(out, "</body>\n</html>").unwrap();
814    out
815}
816
817// ─── Tab 1: Overview ─────────────────────────────────────────────────────────
818
819fn render_overview_tab(out: &mut String, overview: &OverviewResult, pfx: &str) {
820    // KPI cards
821    writeln!(out, r#"<div class="kpi-grid">"#).unwrap();
822    write_kpi_i18n(
823        out,
824        &format_number(overview.total_sessions as u64),
825        "Sessions",
826        "会话数",
827    );
828    write_kpi_i18n(
829        out,
830        &format_number(overview.total_turns as u64),
831        "Turns",
832        "响应数",
833    );
834    write_kpi_i18n(
835        out,
836        &format_compact(overview.total_output_tokens),
837        "Claude Wrote",
838        "Claude 写了",
839    );
840    write_kpi_i18n(
841        out,
842        &format_compact(overview.total_context_tokens),
843        "Claude Read",
844        "Claude 读了",
845    );
846    write_kpi_i18n(
847        out,
848        &format!("{:.1}%", overview.avg_cache_hit_rate),
849        "Avg Cache Hit Rate",
850        "平均缓存命中率",
851    );
852    write_kpi_i18n(
853        out,
854        &format_cost_int(overview.total_cost),
855        "Token Value (API Rate)",
856        "Token 价值 (API 费率)",
857    );
858    if overview.cache_savings.total_saved > 0.0 {
859        write_kpi_i18n(
860            out,
861            &format_cost_int(overview.cache_savings.total_saved),
862            &format!("Cache Savings ({:.0}%)", overview.cache_savings.savings_pct),
863            &format!("缓存节省 ({:.0}%)", overview.cache_savings.savings_pct),
864        );
865    }
866    writeln!(out, "</div>").unwrap();
867
868    // Row 1: Usage Insights KPI cards
869    {
870        let summaries = &overview.session_summaries;
871
872        // Daily avg cost
873        let daily_avg = overview.quality.time_range.map(|(s, e)| {
874            let days = (e - s).num_days().max(1) as f64;
875            (overview.total_cost / days, days as u64)
876        });
877
878        // Compaction stats
879        let total_compactions: usize = summaries.iter().map(|s| s.compaction_count).sum();
880
881        // Max context
882        let max_ctx = summaries.iter().map(|s| s.max_context).max().unwrap_or(0);
883
884        // Average session duration
885        let durations: Vec<f64> = summaries
886            .iter()
887            .map(|s| s.duration_minutes)
888            .filter(|d| *d > 0.0)
889            .collect();
890        let avg_dur = if !durations.is_empty() {
891            durations.iter().sum::<f64>() / durations.len() as f64
892        } else {
893            0.0
894        };
895
896        writeln!(out, r#"<div class="grid-4">"#).unwrap();
897        if let Some((avg, days)) = daily_avg {
898            write_kpi_i18n(
899                out,
900                &format!("{}/day", format_cost_int(avg)),
901                &format!("Daily Avg ({} days)", days),
902                &format!("日均费用({} 天)", days),
903            );
904        }
905        write_kpi_i18n(out, &format_compact(max_ctx), "Peak Context", "峰值上下文");
906        write_kpi_i18n(
907            out,
908            &format_number(total_compactions as u64),
909            "Compactions",
910            "上下文压缩次数",
911        );
912        write_kpi_i18n(
913            out,
914            &format_duration(avg_dur),
915            "Avg Session",
916            "平均会话时长",
917        );
918        writeln!(out, "</div>").unwrap();
919    }
920
921    // Row: Most Expensive Sessions (left, narrow) + Heatmap (right, wide)
922    writeln!(out, r#"<div class="grid-1-2" style="margin-top:16px;">"#).unwrap();
923
924    // Left: Most Expensive Sessions Top 5
925    {
926        let summaries = &overview.session_summaries;
927        let mut by_cost: Vec<&crate::analysis::SessionSummary> = summaries.iter().collect();
928        by_cost.sort_by(|a, b| {
929            b.cost
930                .partial_cmp(&a.cost)
931                .unwrap_or(std::cmp::Ordering::Equal)
932        });
933        let top5 = &by_cost[..by_cost.len().min(5)];
934        if !top5.is_empty() {
935            writeln!(out, r#"<div class="card">"#).unwrap();
936            writeln!(out, r#"<h2 data-en="Most Expensive Sessions Top 5" data-zh="最贵会话 Top 5">Most Expensive Sessions Top 5</h2>"#).unwrap();
937            writeln!(out, r#"<div class="table-wrap">"#).unwrap();
938            writeln!(
939                out,
940                r#"<table class="data-table"><thead><tr>
941                <th class="text-left" data-en="Session" data-zh="会话">Session</th>
942                <th class="text-left" data-en="Project" data-zh="项目">Project</th>
943                <th style="text-align:right;" data-en="Turns" data-zh="响应数">Turns</th>
944                <th style="text-align:right;" data-en="Cost" data-zh="费用">Cost</th>
945            </tr></thead><tbody>"#
946            )
947            .unwrap();
948            for s in top5 {
949                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>",
950                    escape_html(&s.session_id[..s.session_id.len().min(8)]),
951                    escape_html(&s.project_display_name),
952                    s.turn_count,
953                    format_cost(s.cost),
954                ).unwrap();
955            }
956            writeln!(out, "</tbody></table></div></div>").unwrap();
957        }
958    }
959
960    // Right: Heatmap
961    {
962        let canvas_id = format!("heatmap-{}", pfx);
963        writeln!(out, r#"<div class="card">"#).unwrap();
964        writeln!(out, r#"<h2 data-en="Activity Heatmap (Local Time)" data-zh="活跃热力图(本地时间)">Activity Heatmap (Local Time)</h2>"#).unwrap();
965        writeln!(
966            out,
967            r#"<div class="heatmap-wrap"><canvas id="{}"></canvas></div>"#,
968            canvas_id
969        )
970        .unwrap();
971
972        let mut matrix_js = String::from("[");
973        for d in 0..7 {
974            if d > 0 {
975                matrix_js.push(',');
976            }
977            matrix_js.push('[');
978            for h in 0..24 {
979                if h > 0 {
980                    matrix_js.push(',');
981                }
982                write!(matrix_js, "{}", overview.weekday_hour_matrix[d][h]).unwrap();
983            }
984            matrix_js.push(']');
985        }
986        matrix_js.push(']');
987
988        writeln!(
989            out,
990            r#"<script>
991window._heatmapData_{pfx} = {matrix};
992document.addEventListener('DOMContentLoaded', function() {{
993  drawHeatmap('{canvas_id}', window._heatmapData_{pfx});
994}});
995</script>"#,
996            pfx = pfx,
997            matrix = matrix_js,
998            canvas_id = canvas_id
999        )
1000        .unwrap();
1001        writeln!(out, "</div>").unwrap();
1002    }
1003
1004    writeln!(out, "</div>").unwrap(); // close grid-2
1005
1006    // Bubble chart (full width, separate row)
1007    {
1008        let chart_id = format!("{}-scatterChart", pfx);
1009        writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
1010        writeln!(out, r#"<h2 data-en="Session Efficiency (Turns vs Cost)" data-zh="会话效率(Turns vs 费用)">Session Efficiency (Turns vs Cost)</h2>"#).unwrap();
1011        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();
1012        writeln!(
1013            out,
1014            r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#,
1015            chart_id
1016        )
1017        .unwrap();
1018
1019        let max_output: u64 = overview
1020            .session_summaries
1021            .iter()
1022            .map(|s| s.output_tokens)
1023            .max()
1024            .unwrap_or(1);
1025        let mut scatter_data = String::from("[");
1026        for (i, s) in overview.session_summaries.iter().enumerate() {
1027            if i > 0 {
1028                scatter_data.push(',');
1029            }
1030            let radius = if max_output > 0 {
1031                3.0 + (s.output_tokens as f64 / max_output as f64) * 20.0
1032            } else {
1033                3.0
1034            };
1035            let cpt = if s.turn_count > 0 {
1036                s.cost / s.turn_count as f64
1037            } else {
1038                0.0
1039            };
1040            write!(
1041                scatter_data,
1042                "{{x:{},y:{:.4},r:{:.1},cpt:{:.4},out:{}}}",
1043                s.turn_count, s.cost, radius, cpt, s.output_tokens
1044            )
1045            .unwrap();
1046        }
1047        scatter_data.push(']');
1048
1049        writeln!(out, r#"<script>
1050new Chart(document.getElementById('{chart_id}'), {{
1051  type: 'bubble',
1052  data: {{
1053    datasets: [{{
1054      label: 'Sessions',
1055      data: {data},
1056      backgroundColor: 'rgba(59,130,246,0.4)',
1057      borderColor: '#3b82f6',
1058      borderWidth: 1
1059    }}]
1060  }},
1061  options: {{
1062    responsive: true, maintainAspectRatio: false,
1063    plugins: {{
1064      legend: {{ display: false }},
1065      tooltip: {{ callbacks: {{
1066        label: function(ctx) {{
1067          const d = ctx.raw;
1068          return ['Turns: ' + d.x + '  Cost: $' + d.y.toFixed(2), 'Cost/Turn: $' + d.cpt.toFixed(3) + '  Output: ' + d.out.toLocaleString()];
1069        }}
1070      }} }}
1071    }},
1072    scales: {{
1073      x: {{ title: {{ display: true, text: 'Turn Count', color: '#a1a1aa' }}, ticks: {{ color: '#a1a1aa' }}, grid: {{ color: '#27272a' }} }},
1074      y: {{ title: {{ display: true, text: 'Cost ($)', color: '#a1a1aa' }}, ticks: {{ color: '#a1a1aa', callback: function(v) {{ return '$' + v; }} }}, grid: {{ color: '#27272a' }} }}
1075    }}
1076  }}
1077}});
1078</script>"#, chart_id = chart_id, data = scatter_data).unwrap();
1079        writeln!(out, "</div>").unwrap();
1080    }
1081}
1082
1083// ─── Tab 2: Monthly ──────────────────────────────────────────────────────────
1084
1085fn render_monthly_tab(
1086    out: &mut String,
1087    _overview: &OverviewResult,
1088    trend: &TrendResult,
1089    pfx: &str,
1090) {
1091    if trend.entries.is_empty() {
1092        writeln!(out, r#"<div class="card"><p style="color:var(--text-secondary);">No trend data available.</p></div>"#).unwrap();
1093        return;
1094    }
1095
1096    // Determine the latest month from trend entries
1097    let latest_month = trend.entries.last().map(|e| &e.label[..7]).unwrap_or("");
1098
1099    // Aggregate current month data
1100    let mut month_cost = 0.0f64;
1101    let mut month_turns = 0usize;
1102    let mut month_sessions = 0usize;
1103    let mut month_output = 0u64;
1104    let mut month_input = 0u64;
1105
1106    let mut daily_entries: Vec<&crate::analysis::TrendEntry> = Vec::new();
1107
1108    for entry in &trend.entries {
1109        if entry.label.starts_with(latest_month) {
1110            month_cost += entry.cost;
1111            month_turns += entry.turn_count;
1112            month_sessions += entry.session_count;
1113            month_output += entry.tokens.output_tokens;
1114            month_input += entry.tokens.input_tokens
1115                + entry.tokens.cache_creation_tokens
1116                + entry.tokens.cache_read_tokens;
1117            daily_entries.push(entry);
1118        }
1119    }
1120
1121    let _avg_cost_per_turn = if month_turns > 0 {
1122        month_cost / month_turns as f64
1123    } else {
1124        0.0
1125    };
1126
1127    // KPI cards for current month
1128    writeln!(
1129        out,
1130        r#"<h2 data-en="Current Period: {m}" data-zh="当前周期:{m}">Current Period: {m}</h2>"#,
1131        m = escape_html(latest_month)
1132    )
1133    .unwrap();
1134    writeln!(out, r#"<div class="kpi-grid">"#).unwrap();
1135    write_kpi_i18n(
1136        out,
1137        &format_number(month_sessions as u64),
1138        "Sessions",
1139        "会话数",
1140    );
1141    write_kpi_i18n(out, &format_number(month_turns as u64), "Turns", "响应数");
1142    write_kpi_i18n(
1143        out,
1144        &format_compact(month_input),
1145        "Input Tokens",
1146        "输入 Token",
1147    );
1148    write_kpi_i18n(
1149        out,
1150        &format_compact(month_output),
1151        "Output Tokens",
1152        "输出 Token",
1153    );
1154    write_kpi_i18n(out, &format_cost(month_cost), "Cost", "费用");
1155    writeln!(out, "</div>").unwrap();
1156
1157    // Chart: Daily Cost + Cost/Turn combo chart
1158    if !daily_entries.is_empty() {
1159        let chart_id = format!("{}-dailyCostChart", pfx);
1160        writeln!(out, r#"<div class="card">"#).unwrap();
1161        writeln!(out, r#"<h2 data-en="Daily Cost &amp; Cost/Turn ({})" data-zh="每日费用 &amp; 每 Turn 费用 ({})">Daily Cost &amp; Cost/Turn ({})</h2>"#,
1162            escape_html(latest_month), escape_html(latest_month), escape_html(latest_month)).unwrap();
1163        writeln!(
1164            out,
1165            r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#,
1166            chart_id
1167        )
1168        .unwrap();
1169
1170        let labels: Vec<String> = daily_entries
1171            .iter()
1172            .map(|e| format!("\"{}\"", &e.label[5..]))
1173            .collect();
1174        let cost_data: Vec<String> = daily_entries
1175            .iter()
1176            .map(|e| format!("{:.2}", e.cost))
1177            .collect();
1178        let cpt_data: Vec<String> = daily_entries
1179            .iter()
1180            .map(|e| {
1181                if e.turn_count > 0 {
1182                    format!("{:.4}", e.cost / e.turn_count as f64)
1183                } else {
1184                    "0".to_string()
1185                }
1186            })
1187            .collect();
1188        let turn_data: Vec<String> = daily_entries
1189            .iter()
1190            .map(|e| e.turn_count.to_string())
1191            .collect();
1192
1193        writeln!(out, r#"<script>
1194new Chart(document.getElementById('{chart_id}'), {{
1195  type: 'bar',
1196  data: {{
1197    labels: [{labels}],
1198    datasets: [
1199      {{
1200        label: 'Cost ($)',
1201        data: [{cost_data}],
1202        backgroundColor: 'rgba(59,130,246,0.6)',
1203        borderColor: '#3b82f6',
1204        borderWidth: 1,
1205        borderRadius: 4,
1206        yAxisID: 'y',
1207        order: 2
1208      }},
1209      {{
1210        label: 'Cost/Turn ($)',
1211        data: [{cpt_data}],
1212        type: 'line',
1213        borderColor: '#f59e0b',
1214        backgroundColor: 'rgba(245,158,11,0.1)',
1215        pointRadius: 3,
1216        tension: 0.3,
1217        yAxisID: 'y1',
1218        order: 1
1219      }}
1220    ]
1221  }},
1222  options: {{
1223    responsive: true, maintainAspectRatio: false,
1224    plugins: {{
1225      legend: {{ labels: {{ color: '#fafafa' }} }},
1226      tooltip: {{ callbacks: {{
1227        afterLabel: function(ctx) {{
1228          const turns = [{turn_data}];
1229          return 'Turns: ' + turns[ctx.dataIndex];
1230        }}
1231      }} }}
1232    }},
1233    scales: {{
1234      x: {{ ticks: {{ color: '#a1a1aa' }}, grid: {{ color: '#27272a' }} }},
1235      y: {{ position: 'left', ticks: {{ color: '#a1a1aa', callback: function(v) {{ return '$' + v; }} }}, grid: {{ color: '#27272a' }}, title: {{ display: true, text: 'Cost ($)', color: '#a1a1aa' }} }},
1236      y1: {{ position: 'right', ticks: {{ color: '#f59e0b', callback: function(v) {{ return '$' + v.toFixed(3); }} }}, grid: {{ drawOnChartArea: false }}, title: {{ display: true, text: 'Cost/Turn ($)', color: '#f59e0b' }} }}
1237    }}
1238  }}
1239}});
1240</script>"#, chart_id = chart_id,
1241            labels = labels.join(","),
1242            cost_data = cost_data.join(","),
1243            cpt_data = cpt_data.join(","),
1244            turn_data = turn_data.join(","),
1245        ).unwrap();
1246        writeln!(out, "</div>").unwrap();
1247    }
1248
1249    // Chart: Model distribution per day (stacked bar)
1250    {
1251        // Collect all unique model names
1252        let mut all_models: Vec<String> = Vec::new();
1253        for entry in &daily_entries {
1254            for model_name in entry.models.keys() {
1255                let short = short_model(model_name);
1256                if !all_models.contains(&short) {
1257                    all_models.push(short);
1258                }
1259            }
1260        }
1261        all_models.sort();
1262
1263        if daily_entries.len() > 1 {
1264            let chart_id = format!("{}-dailyTurnsCostChart", pfx);
1265            writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
1266            writeln!(out, r#"<h2 data-en="Daily Turns &amp; Cost" data-zh="每日响应数与费用">Daily Turns &amp; Cost</h2>"#).unwrap();
1267            writeln!(
1268                out,
1269                r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#,
1270                chart_id
1271            )
1272            .unwrap();
1273
1274            let labels: Vec<String> = daily_entries
1275                .iter()
1276                .map(|e| format!("\"{}\"", &e.label[5..]))
1277                .collect();
1278            let turns_data: Vec<String> = daily_entries
1279                .iter()
1280                .map(|e| e.turn_count.to_string())
1281                .collect();
1282            let cost_data: Vec<String> = daily_entries
1283                .iter()
1284                .map(|e| format!("{:.2}", e.cost))
1285                .collect();
1286
1287            writeln!(out, r#"<script>
1288new Chart(document.getElementById('{chart_id}'), {{
1289  type: 'bar',
1290  data: {{
1291    labels: [{labels}],
1292    datasets: [
1293      {{label:'Turns',data:[{turns}],backgroundColor:'rgba(59,130,246,0.5)',borderColor:'#3b82f6',borderWidth:1,borderRadius:3,yAxisID:'y'}},
1294      {{label:'Cost ($)',data:[{cost}],type:'line',borderColor:'#22c55e',backgroundColor:'rgba(34,197,94,0.1)',fill:true,pointRadius:3,tension:0.3,yAxisID:'y1'}}
1295    ]
1296  }},
1297  options: {{
1298    responsive: true, maintainAspectRatio: false,
1299    plugins: {{ legend: {{ labels: {{ color: 'var(--text-secondary)' }} }} }},
1300    scales: {{
1301      x: {{ ticks: {{ color: 'var(--text-secondary)' }}, grid: {{ color: 'var(--border-color)' }} }},
1302      y: {{ position:'left', ticks: {{ color: '#3b82f6' }}, grid: {{ color: 'var(--border-color)' }}, title: {{ display:true, text:'Turns', color:'#3b82f6' }} }},
1303      y1: {{ position:'right', ticks: {{ color: '#22c55e', callback: function(v){{ return '$'+v; }} }}, grid: {{ drawOnChartArea:false }}, title: {{ display:true, text:'Cost ($)', color:'#22c55e' }} }}
1304    }}
1305  }}
1306}});
1307</script>"#, chart_id = chart_id, labels = labels.join(","), turns = turns_data.join(","), cost = cost_data.join(",")).unwrap();
1308            writeln!(out, "</div>").unwrap();
1309        }
1310    }
1311
1312    // Table: Monthly summary (aggregate by month if multi-month data)
1313    {
1314        // Group trend entries by month
1315        #[allow(clippy::type_complexity)]
1316        let mut months: std::collections::BTreeMap<
1317            String,
1318            (usize, usize, u64, u64, u64, f64, u64),
1319        > = std::collections::BTreeMap::new();
1320        for entry in &trend.entries {
1321            let month_key = entry.label[..7].to_string();
1322            let e = months.entry(month_key).or_insert((0, 0, 0, 0, 0, 0.0, 0));
1323            e.0 += entry.session_count;
1324            e.1 += entry.turn_count;
1325            e.2 += entry.tokens.output_tokens;
1326            e.3 += entry.tokens.cache_creation_tokens;
1327            e.4 += entry.tokens.cache_read_tokens;
1328            e.5 += entry.cost;
1329            e.6 += entry.tokens.input_tokens
1330                + entry.tokens.cache_creation_tokens
1331                + entry.tokens.cache_read_tokens;
1332        }
1333
1334        if months.len() > 1 {
1335            let tbl_id = format!("{}-tbl-monthly", pfx);
1336            writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
1337            writeln!(
1338                out,
1339                r#"<h2 data-en="Monthly Summary" data-zh="月度汇总">Monthly Summary</h2>"#
1340            )
1341            .unwrap();
1342            writeln!(out, r#"<div class="table-wrap">"#).unwrap();
1343            writeln!(out, r#"<table id="{}">"#, tbl_id).unwrap();
1344            writeln!(out, "<thead><tr>\
1345                <th class=\"text-left\" onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Month\" data-zh=\"月份\">Month</th>\
1346                <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Sessions\" data-zh=\"会话\">Sessions</th>\
1347                <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Turns\" data-zh=\"响应数\">Turns</th>\
1348                <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Input Tokens\" data-zh=\"输入 Token\">Input Tokens</th>\
1349                <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Output Tokens\" data-zh=\"输出 Token\">Output Tokens</th>\
1350                <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Cost\" data-zh=\"费用\">Cost</th>\
1351            </tr></thead>", id = tbl_id).unwrap();
1352            writeln!(out, "<tbody>").unwrap();
1353
1354            for (month, (sessions, turns, output, _cache_write, _cache_read, cost, input_ctx)) in
1355                &months
1356            {
1357                writeln!(
1358                    out,
1359                    "<tr>\
1360                    <td class=\"text-left\" data-value=\"{}\">{}</td>\
1361                    <td data-value=\"{}\">{}</td>\
1362                    <td data-value=\"{}\">{}</td>\
1363                    <td data-value=\"{}\">{}</td>\
1364                    <td data-value=\"{}\">{}</td>\
1365                    <td data-value=\"{:.4}\">{}</td>\
1366                </tr>",
1367                    escape_html(month),
1368                    escape_html(month),
1369                    sessions,
1370                    format_number(*sessions as u64),
1371                    turns,
1372                    format_number(*turns as u64),
1373                    input_ctx,
1374                    format_compact(*input_ctx),
1375                    output,
1376                    format_compact(*output),
1377                    cost,
1378                    format_cost(*cost),
1379                )
1380                .unwrap();
1381            }
1382
1383            writeln!(out, "</tbody></table></div></div>").unwrap();
1384        }
1385    }
1386
1387    // Table: Daily detail with cost/turn
1388    {
1389        let tbl_id = format!("{}-tbl-daily", pfx);
1390        let group_zh = match trend.group_label.as_str() {
1391            "Day" => "每日",
1392            "Week" => "每周",
1393            "Month" => "每月",
1394            other => other,
1395        };
1396        let group_col_zh = match trend.group_label.as_str() {
1397            "Day" => "日期",
1398            "Week" => "周",
1399            "Month" => "月份",
1400            other => other,
1401        };
1402        writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
1403        writeln!(
1404            out,
1405            r#"<h2 data-en="{} Breakdown" data-zh="{}明细">{} Breakdown</h2>"#,
1406            escape_html(&trend.group_label),
1407            escape_html(group_zh),
1408            escape_html(&trend.group_label)
1409        )
1410        .unwrap();
1411        writeln!(out, r#"<div class="table-wrap">"#).unwrap();
1412        writeln!(out, r#"<table id="{}">"#, tbl_id).unwrap();
1413        writeln!(out, "<thead><tr>\
1414            <th class=\"text-left\" onclick=\"sortTableSimple(this,'{id}')\" data-en=\"{en}\" data-zh=\"{zh}\">{en}</th>\
1415            <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Sessions\" data-zh=\"会话\">Sessions</th>\
1416            <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Turns\" data-zh=\"响应数\">Turns</th>\
1417            <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Input Tokens\" data-zh=\"输入 Token\">Input Tokens</th>\
1418            <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Output Tokens\" data-zh=\"输出 Token\">Output Tokens</th>\
1419            <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Cost\" data-zh=\"费用\">Cost</th>\
1420            <th class=\"text-left\" data-en=\"Models\" data-zh=\"模型\">Models</th>\
1421        </tr></thead>", en = escape_html(&trend.group_label), zh = escape_html(group_col_zh), id = tbl_id).unwrap();
1422        writeln!(out, "<tbody>").unwrap();
1423
1424        for entry in &trend.entries {
1425            let input_tokens = entry.tokens.input_tokens
1426                + entry.tokens.cache_creation_tokens
1427                + entry.tokens.cache_read_tokens;
1428            // Model summary for this day
1429            let mut model_list: Vec<(&String, &u64)> = entry.models.iter().collect();
1430            model_list.sort_by(|a, b| b.1.cmp(a.1));
1431            let models_html: String = model_list
1432                .iter()
1433                .take(3)
1434                .map(|(m, tokens)| {
1435                    format!(
1436                        "<span class=\"tool-tag\">{} <span class=\"tool-count\">{}</span></span>",
1437                        escape_html(&short_model(m)),
1438                        format_compact(**tokens)
1439                    )
1440                })
1441                .collect::<Vec<_>>()
1442                .join("");
1443
1444            writeln!(
1445                out,
1446                "<tr>\
1447                <td class=\"text-left\" data-value=\"{}\">{}</td>\
1448                <td data-value=\"{}\">{}</td>\
1449                <td data-value=\"{}\">{}</td>\
1450                <td data-value=\"{}\">{}</td>\
1451                <td data-value=\"{}\">{}</td>\
1452                <td data-value=\"{:.4}\">{}</td>\
1453                <td class=\"text-left\">{}</td>\
1454            </tr>",
1455                escape_html(&entry.label),
1456                escape_html(&entry.label),
1457                entry.session_count,
1458                format_number(entry.session_count as u64),
1459                entry.turn_count,
1460                format_number(entry.turn_count as u64),
1461                input_tokens,
1462                format_compact(input_tokens),
1463                entry.tokens.output_tokens,
1464                format_compact(entry.tokens.output_tokens),
1465                entry.cost,
1466                format_cost(entry.cost),
1467                models_html,
1468            )
1469            .unwrap();
1470        }
1471
1472        writeln!(out, "</tbody></table></div></div>").unwrap();
1473    }
1474}
1475
1476// ─── Tab 3: Projects ─────────────────────────────────────────────────────────
1477
1478fn render_projects_tab(
1479    out: &mut String,
1480    projects: &ProjectResult,
1481    sessions: &[crate::analysis::SessionSummary],
1482    pfx: &str,
1483) {
1484    // Chart: Project Cost Top 10
1485    {
1486        let top_n = projects.projects.iter().take(10).collect::<Vec<_>>();
1487        if !top_n.is_empty() {
1488            let chart_id = format!("{}-projectCostChart", pfx);
1489            writeln!(out, r#"<div class="card">"#).unwrap();
1490            writeln!(out, r#"<h2 data-en="Project Cost Top 10" data-zh="项目费用 Top 10">Project Cost Top 10</h2>"#).unwrap();
1491            writeln!(
1492                out,
1493                r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#,
1494                chart_id
1495            )
1496            .unwrap();
1497
1498            let labels: Vec<String> = top_n
1499                .iter()
1500                .map(|p| format!("\"{}\"", escape_html(&p.display_name)))
1501                .collect();
1502            let data: Vec<String> = top_n.iter().map(|p| format!("{:.2}", p.cost)).collect();
1503            let colors_list: Vec<String> = (0..top_n.len())
1504                .map(|i| format!("\"{}\"", color(i)))
1505                .collect();
1506
1507            writeln!(out, r#"<script>
1508new Chart(document.getElementById('{chart_id}'), {{
1509  type: 'bar',
1510  data: {{
1511    labels: [{labels}],
1512    datasets: [{{ label: 'Cost ($)', data: [{data}], backgroundColor: [{colors}], borderWidth: 0, borderRadius: 4 }}]
1513  }},
1514  options: {{
1515    indexAxis: 'y', responsive: true, maintainAspectRatio: false,
1516    plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx) {{ return '$' + ctx.raw.toFixed(2); }} }} }} }},
1517    scales: {{
1518      x: {{ ticks: {{ color: '#a1a1aa', callback: function(v) {{ return '$' + v; }} }}, grid: {{ color: '#27272a' }} }},
1519      y: {{ ticks: {{ color: '#fafafa' }}, grid: {{ color: '#27272a' }} }}
1520    }}
1521  }}
1522}});
1523</script>"#, chart_id = chart_id, labels = labels.join(","), data = data.join(","), colors = colors_list.join(",")).unwrap();
1524            writeln!(out, "</div>").unwrap();
1525        }
1526    }
1527
1528    // Three-level drill-down table: Project → Session → Turn
1529    let tbl_id = format!("{}-tbl-projects-drill", pfx);
1530    writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
1531    writeln!(
1532        out,
1533        r#"<h2 data-en="Project Drill-Down" data-zh="项目钻取">Project Drill-Down</h2>"#
1534    )
1535    .unwrap();
1536    writeln!(out, r#"<div class="table-wrap">"#).unwrap();
1537    writeln!(out, r#"<table id="{}">"#, tbl_id).unwrap();
1538    writeln!(out, "<thead><tr>\
1539        <th class=\"text-left\"></th>\
1540        <th class=\"text-left\" data-en=\"Project / Session\" data-zh=\"项目 / 会话\">Project / Session</th>\
1541        <th data-en=\"Sessions\" data-zh=\"会话\">Sessions</th>\
1542        <th data-en=\"Turns (Agent)\" data-zh=\"响应数 (Agent)\">Turns (Agent)</th>\
1543        <th data-en=\"Output\" data-zh=\"输出\">Output</th>\
1544        <th data-en=\"CacheHit\" data-zh=\"缓存命中率\">CacheHit</th>\
1545        <th class=\"text-left\" data-en=\"Tools\" data-zh=\"工具\">Tools</th>\
1546        <th data-en=\"Cost\" data-zh=\"费用\">Cost</th>\
1547    </tr></thead>").unwrap();
1548    writeln!(out, "<tbody>").unwrap();
1549
1550    // Group sessions by project_display_name
1551    let mut sessions_by_project: std::collections::HashMap<
1552        String,
1553        Vec<&crate::analysis::SessionSummary>,
1554    > = std::collections::HashMap::new();
1555    for s in sessions {
1556        sessions_by_project
1557            .entry(s.project_display_name.clone())
1558            .or_default()
1559            .push(s);
1560    }
1561
1562    for (i, proj) in projects.projects.iter().enumerate() {
1563        let cache_hit = if proj.tokens.context_tokens() > 0 {
1564            proj.tokens.cache_read_tokens as f64 / proj.tokens.context_tokens() as f64 * 100.0
1565        } else {
1566            0.0
1567        };
1568        let pid = format!("{}-p{}", pfx, i);
1569
1570        // Level 1: Project row (expandable)
1571        let hit_bar = html_progress(cache_hit);
1572        let turns_display = if proj.agent_turns > 0 {
1573            format!(
1574                "{} <span class=\"agent-badge\">+{} agent</span>",
1575                format_number(proj.total_turns as u64),
1576                proj.agent_turns
1577            )
1578        } else {
1579            format_number(proj.total_turns as u64)
1580        };
1581        writeln!(out, r#"<tr class="project-row expandable">"#).unwrap();
1582        writeln!(out, r#"<td class="text-left"><button class="expand-btn" onclick="toggleProject(this,'{pid}')">{arrow}</button></td>"#,
1583            pid = pid, arrow = "\u{25b6}").unwrap();
1584        writeln!(
1585            out,
1586            "\
1587            <td class=\"text-left\"><strong>{name}</strong></td>\
1588            <td data-value=\"{sess}\">{sess_fmt}</td>\
1589            <td class=\"turn-count-cell\" data-value=\"{turns}\">{turns_display}</td>\
1590            <td data-value=\"{out}\">{out_fmt}</td>\
1591            <td data-value=\"{hit:.1}\">{hit_bar}</td>\
1592            <td class=\"text-left\"></td>\
1593            <td data-value=\"{cost:.4}\">{cost_fmt}</td>",
1594            name = escape_html(&proj.display_name),
1595            sess = proj.session_count,
1596            sess_fmt = format_number(proj.session_count as u64),
1597            turns = proj.total_turns,
1598            turns_display = turns_display,
1599            out = proj.tokens.output_tokens,
1600            out_fmt = format_compact(proj.tokens.output_tokens),
1601            hit = cache_hit,
1602            hit_bar = hit_bar,
1603            cost = proj.cost,
1604            cost_fmt = format_cost(proj.cost),
1605        )
1606        .unwrap();
1607        writeln!(out, "</tr>").unwrap();
1608
1609        // Level 2: Session rows (hidden by default, belong to this project)
1610        if let Some(proj_sessions) = sessions_by_project.get(&proj.display_name) {
1611            let mut sorted = proj_sessions.clone();
1612            sorted.sort_by(|a, b| {
1613                b.cost
1614                    .partial_cmp(&a.cost)
1615                    .unwrap_or(std::cmp::Ordering::Equal)
1616            });
1617
1618            for s in sorted.iter().filter(|s| s.turn_count > 0) {
1619                let utc_iso = s
1620                    .first_timestamp
1621                    .map(|t| t.to_rfc3339())
1622                    .unwrap_or_default();
1623                let date_fallback = s
1624                    .first_timestamp
1625                    .map(|t| t.format("%m-%d %H:%M").to_string())
1626                    .unwrap_or_default();
1627                let s_hit = html_progress(s.cache_hit_rate);
1628
1629                // Session summary row
1630                writeln!(out, r#"<tr class="project-session-row project-sessions-{pid} expandable" style="display:none">"#,
1631                    pid = pid).unwrap();
1632
1633                let has_detail = s.turn_details.is_some();
1634                if has_detail {
1635                    writeln!(out, r#"<td class="text-left"><button class="expand-btn" onclick="toggleSession(this)">{}</button></td>"#, "\u{25b6}").unwrap();
1636                } else {
1637                    writeln!(out, r#"<td class="text-left"></td>"#).unwrap();
1638                }
1639
1640                // Turns with agent badge
1641                let s_turns_display = if s.agent_turn_count > 0 {
1642                    format!(
1643                        "{} <span class=\"agent-badge\">+{} agent</span>",
1644                        format_number(s.turn_count as u64),
1645                        s.agent_turn_count
1646                    )
1647                } else {
1648                    format_number(s.turn_count as u64)
1649                };
1650
1651                // Top tools as tags
1652                let tools_html: String = s.top_tools.iter().take(5).map(|(name, count)| {
1653                    format!("<span class=\"tool-tag\">{} <span class=\"tool-count\">{}</span></span>",
1654                        escape_html(name), count)
1655                }).collect::<Vec<_>>().join("");
1656
1657                let duration_str = format_duration(s.duration_minutes);
1658                let short_sid = &s.session_id[..s.session_id.len().min(10)];
1659
1660                writeln!(out, "\
1661                    <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>\
1662                    <td></td>\
1663                    <td class=\"turn-count-cell\" data-value=\"{turns}\">{turns_display}</td>\
1664                    <td data-value=\"{out}\">{out_fmt}</td>\
1665                    <td data-value=\"{hit:.1}\">{hit_bar}</td>\
1666                    <td class=\"text-left session-tools-cell\">{tools}</td>\
1667                    <td data-value=\"{cost:.4}\">{cost_fmt}</td>",
1668                    sid = escape_html(short_sid),
1669                    utc = utc_iso,
1670                    date = date_fallback,
1671                    dur = duration_str,
1672                    turns = s.turn_count, turns_display = s_turns_display,
1673                    out = s.output_tokens, out_fmt = format_compact(s.output_tokens),
1674                    hit = s.cache_hit_rate, hit_bar = s_hit,
1675                    tools = tools_html,
1676                    cost = s.cost, cost_fmt = format_cost(s.cost),
1677                ).unwrap();
1678                writeln!(out, "</tr>").unwrap();
1679
1680                // Level 3: Turn detail (hidden, shown when session is expanded)
1681                if let Some(ref details) = s.turn_details {
1682                    writeln!(out, r#"<tr class="session-detail project-sessions-{pid}" style="display:none"><td colspan="8"><div class="detail-content">"#,
1683                        pid = pid).unwrap();
1684                    render_turn_detail_table(
1685                        out,
1686                        details,
1687                        &format!("{}-detail-proj-{}", pfx, escape_html(&s.session_id)),
1688                    );
1689                    writeln!(out, "</div></td></tr>").unwrap();
1690                }
1691            }
1692        }
1693    }
1694
1695    writeln!(out, "</tbody></table></div></div>").unwrap();
1696}
1697
1698// ─── Turn Detail Sub-table ───────────────────────────────────────────────────
1699
1700fn render_turn_detail_table(
1701    out: &mut String,
1702    turns: &[crate::analysis::TurnDetail],
1703    table_id: &str,
1704) {
1705    render_turn_table_impl(out, turns, table_id);
1706}
1707
1708// ─── KPI Card Helper ─────────────────────────────────────────────────────────
1709
1710fn write_kpi(out: &mut String, value: &str, label: &str) {
1711    writeln!(out, r#"<div class="card" style="text-align:center;"><div class="kpi-value">{}</div><div class="kpi-label">{}</div></div>"#,
1712        value, label).unwrap();
1713}
1714
1715/// KPI card with bilingual label.
1716fn write_kpi_i18n(out: &mut String, value: &str, en: &str, zh: &str) {
1717    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>"#,
1718        value, en, zh, en).unwrap();
1719}
1720
1721/// Render a progress bar inline for table cells.
1722fn html_progress(pct: f64) -> String {
1723    let bar_color = if pct >= 90.0 {
1724        "#22c55e"
1725    } else if pct >= 70.0 {
1726        "#f59e0b"
1727    } else {
1728        "#ef4444"
1729    };
1730    format!(
1731        r#"<div class="progress-bar"><div class="progress-fill" style="width:{:.1}%;background:{};"></div></div><span class="progress-text">{:.1}%</span>"#,
1732        pct, bar_color, pct
1733    )
1734}
1735
1736// ─── 2. Session Report ───────────────────────────────────────────────────────
1737
1738/// Generate a detailed HTML report for a single session.
1739pub fn render_session_html(result: &SessionResult) -> String {
1740    let mut out = String::with_capacity(64 * 1024);
1741
1742    let short_id = &result.session_id[..result.session_id.len().min(12)];
1743
1744    // ── HTML head ────────────────────────────────────────────────────────────
1745    write!(out, r#"<!DOCTYPE html>
1746<html lang="zh-CN">
1747<head>
1748  <meta charset="UTF-8">
1749  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1750  <title>Session {short_id} - Claude Code Token Analyzer</title>
1751  <link rel="preconnect" href="https://fonts.googleapis.com">
1752  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
1753  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
1754  <script>{js_head}</script>
1755  <style>{css}</style>
1756</head>
1757<body>
1758"#, short_id = escape_html(short_id), css = css(), js_head = js_head()).unwrap();
1759
1760    // Header
1761    writeln!(out, r#"<div class="header-row">"#).unwrap();
1762    writeln!(out, "<h1>Session Analysis</h1>").unwrap();
1763    writeln!(out, r#"<button class="theme-btn" onclick="toggleTheme()" style="margin-left:auto;">&#x2600;&#xFE0F;</button>"#).unwrap();
1764    writeln!(
1765        out,
1766        r#"<span class="subtitle">{} &middot; {}</span>"#,
1767        escape_html(&result.session_id),
1768        escape_html(&result.project)
1769    )
1770    .unwrap();
1771    writeln!(out, "</div>").unwrap();
1772
1773    // ── KPI cards ────────────────────────────────────────────────────────────
1774    let cache_hit_rate = {
1775        let total_ctx = result.total_tokens.context_tokens();
1776        if total_ctx > 0 {
1777            result.total_tokens.cache_read_tokens as f64 / total_ctx as f64 * 100.0
1778        } else {
1779            0.0
1780        }
1781    };
1782
1783    writeln!(out, r#"<div class="kpi-grid">"#).unwrap();
1784    write_kpi(
1785        &mut out,
1786        &format_duration(result.duration_minutes),
1787        "Duration",
1788    );
1789    write_kpi(&mut out, &short_model(&result.model), "Model");
1790    write_kpi(&mut out, &format_number(result.max_context), "Max Context");
1791    write_kpi(
1792        &mut out,
1793        &format!("{:.1}%", cache_hit_rate),
1794        "Cache Hit Rate",
1795    );
1796    write_kpi(
1797        &mut out,
1798        &format_number(result.compaction_count as u64),
1799        "Compactions",
1800    );
1801    write_kpi(&mut out, &format_cost(result.total_cost), "Total Cost");
1802    writeln!(out, "</div>").unwrap();
1803
1804    // ── Charts (Context Growth + Cache Hit Rate) ─────────────────────────────
1805    if !result.turn_details.is_empty() {
1806        writeln!(out, r#"<div class="grid-2">"#).unwrap();
1807
1808        // Context Growth Line Chart
1809        {
1810            writeln!(out, r#"<div class="card">"#).unwrap();
1811            writeln!(out, "<h2>Context Growth</h2>").unwrap();
1812            writeln!(
1813                out,
1814                r#"<div class="chart-container"><canvas id="contextChart"></canvas></div>"#
1815            )
1816            .unwrap();
1817
1818            let turn_nums: Vec<String> = result
1819                .turn_details
1820                .iter()
1821                .map(|t| t.turn_number.to_string())
1822                .collect();
1823            let ctx_sizes: Vec<String> = result
1824                .turn_details
1825                .iter()
1826                .map(|t| t.context_size.to_string())
1827                .collect();
1828            let pr = if result.turn_details.len() > 50 { 0 } else { 3 };
1829
1830            writeln!(out, r#"<script>
1831new Chart(document.getElementById('contextChart'), {{
1832  type: 'line',
1833  data: {{
1834    labels: [{turns}],
1835    datasets: [{{
1836      label: 'Context Size',
1837      data: [{sizes}],
1838      borderColor: '#3b82f6',
1839      backgroundColor: 'rgba(59,130,246,0.1)',
1840      fill: true, tension: 0.3, pointRadius: {pr}
1841    }}]
1842  }},
1843  options: {{
1844    responsive: true, maintainAspectRatio: false,
1845    plugins: {{ legend: {{ labels: {{ color: '#fafafa' }} }} }},
1846    scales: {{
1847      x: {{ title: {{ display: true, text: 'Turn', color: '#a1a1aa' }}, ticks: {{ color: '#a1a1aa' }}, grid: {{ color: '#27272a' }} }},
1848      y: {{ title: {{ display: true, text: 'Context Tokens', color: '#a1a1aa' }}, ticks: {{ color: '#a1a1aa' }}, grid: {{ color: '#27272a' }} }}
1849    }}
1850  }}
1851}});
1852</script>"#,
1853                turns = turn_nums.join(","),
1854                sizes = ctx_sizes.join(","),
1855                pr = pr,
1856            ).unwrap();
1857            writeln!(out, "</div>").unwrap();
1858        }
1859
1860        // Cache Hit Rate Line Chart
1861        {
1862            writeln!(out, r#"<div class="card">"#).unwrap();
1863            writeln!(out, "<h2>Cache Hit Rate</h2>").unwrap();
1864            writeln!(
1865                out,
1866                r#"<div class="chart-container"><canvas id="cacheChart"></canvas></div>"#
1867            )
1868            .unwrap();
1869
1870            let turn_nums: Vec<String> = result
1871                .turn_details
1872                .iter()
1873                .map(|t| t.turn_number.to_string())
1874                .collect();
1875            let cache_rates: Vec<String> = result
1876                .turn_details
1877                .iter()
1878                .map(|t| format!("{:.2}", t.cache_hit_rate))
1879                .collect();
1880            let pr = if result.turn_details.len() > 50 { 0 } else { 3 };
1881
1882            writeln!(out, r#"<script>
1883new Chart(document.getElementById('cacheChart'), {{
1884  type: 'line',
1885  data: {{
1886    labels: [{turns}],
1887    datasets: [{{
1888      label: 'Cache Hit Rate (%)',
1889      data: [{rates}],
1890      borderColor: '#f59e0b',
1891      backgroundColor: 'rgba(245,158,11,0.1)',
1892      fill: true, tension: 0.3, pointRadius: {pr}
1893    }}]
1894  }},
1895  options: {{
1896    responsive: true, maintainAspectRatio: false,
1897    plugins: {{ legend: {{ labels: {{ color: '#fafafa' }} }} }},
1898    scales: {{
1899      x: {{ title: {{ display: true, text: 'Turn', color: '#a1a1aa' }}, ticks: {{ color: '#a1a1aa' }}, grid: {{ color: '#27272a' }} }},
1900      y: {{ title: {{ display: true, text: 'Hit Rate (%)', color: '#a1a1aa' }}, ticks: {{ color: '#a1a1aa' }}, grid: {{ color: '#27272a' }}, min: 0, max: 100 }}
1901    }}
1902  }}
1903}});
1904</script>"#,
1905                turns = turn_nums.join(","),
1906                rates = cache_rates.join(","),
1907                pr = pr,
1908            ).unwrap();
1909            writeln!(out, "</div>").unwrap();
1910        }
1911
1912        writeln!(out, "</div>").unwrap(); // close grid-2
1913    }
1914
1915    // ── Stop Reason Doughnut ─────────────────────────────────────────────────
1916    if !result.stop_reason_counts.is_empty() {
1917        writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
1918        writeln!(out, "<h2>Stop Reason Distribution</h2>").unwrap();
1919        writeln!(out, r#"<div class="chart-container" style="max-width:400px;margin:0 auto;"><canvas id="stopReasonChart"></canvas></div>"#).unwrap();
1920
1921        let mut reasons: Vec<(&String, &usize)> = result.stop_reason_counts.iter().collect();
1922        reasons.sort_by(|a, b| b.1.cmp(a.1));
1923
1924        let labels: Vec<String> = reasons
1925            .iter()
1926            .map(|(r, _)| format!("\"{}\"", escape_html(r)))
1927            .collect();
1928        let data: Vec<String> = reasons.iter().map(|(_, c)| c.to_string()).collect();
1929        let colors_list: Vec<String> = (0..reasons.len())
1930            .map(|i| format!("\"{}\"", color(i)))
1931            .collect();
1932
1933        writeln!(
1934            out,
1935            r#"<script>
1936new Chart(document.getElementById('stopReasonChart'), {{
1937  type: 'doughnut',
1938  data: {{
1939    labels: [{labels}],
1940    datasets: [{{ data: [{data}], backgroundColor: [{colors}], borderWidth: 0 }}]
1941  }},
1942  options: {{
1943    responsive: true, maintainAspectRatio: false,
1944    plugins: {{ legend: {{ position: 'bottom', labels: {{ color: '#fafafa' }} }} }}
1945  }}
1946}});
1947</script>"#,
1948            labels = labels.join(","),
1949            data = data.join(","),
1950            colors = colors_list.join(",")
1951        )
1952        .unwrap();
1953        writeln!(out, "</div>").unwrap();
1954    }
1955
1956    // ── Turn Detail Table ────────────────────────────────────────────────────
1957    writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
1958    writeln!(out, "<h2>Turn Details</h2>").unwrap();
1959    writeln!(out, r#"<div class="table-wrap">"#).unwrap();
1960    render_turn_table_impl(&mut out, &result.turn_details, "tbl-session-turns");
1961    writeln!(out, "</div></div>").unwrap();
1962
1963    // ── JavaScript ───────────────────────────────────────────────────────────
1964    write!(out, "<script>{}</script>", js_common()).unwrap();
1965
1966    writeln!(out, "</body>\n</html>").unwrap();
1967    out
1968}
1969
1970/// Shared turn detail table -- used by both expandable session detail and single session report.
1971fn render_turn_table_impl(out: &mut String, turns: &[crate::analysis::TurnDetail], table_id: &str) {
1972    writeln!(out, r#"<table id="{}" style="font-size:12px;">"#, table_id).unwrap();
1973    writeln!(out, "<thead><tr>\
1974        <th onclick=\"sortTableSimple(this,'{id}')\">Turn</th>\
1975        <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Time\" data-zh=\"时间\">Time</th>\
1976        <th class=\"text-left\" data-en=\"Model\" data-zh=\"模型\">Model</th>\
1977        <th class=\"text-left\" data-en=\"User\" data-zh=\"用户\">User</th>\
1978        <th class=\"text-left\" data-en=\"Assistant\" data-zh=\"助手\">Assistant</th>\
1979        <th class=\"text-left\" data-en=\"Tools\" data-zh=\"工具\">Tools</th>\
1980        <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Output\" data-zh=\"输出\">Output</th>\
1981        <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Context\" data-zh=\"上下文\">Context</th>\
1982        <th onclick=\"sortTableSimple(this,'{id}')\">Hit%</th>\
1983        <th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Cost\" data-zh=\"费用\">Cost</th>\
1984        <th class=\"text-left\">Stop</th>\
1985        <th class=\"text-left\">\u{26a1}</th>\
1986    </tr></thead>", id = table_id).unwrap();
1987    writeln!(out, "<tbody>").unwrap();
1988
1989    for t in turns {
1990        let row_class = if t.is_compaction {
1991            " class=\"compact-row\""
1992        } else if t.is_agent {
1993            " style=\"border-left:2px solid var(--text-accent);\""
1994        } else {
1995            ""
1996        };
1997        let stop = t.stop_reason.as_deref().unwrap_or("-");
1998        let compact_mark = if t.is_compaction {
1999            "\u{26a1}"
2000        } else if t.is_agent {
2001            "\u{1f916}"
2002        } else {
2003            ""
2004        };
2005
2006        let user_text = t.user_text.as_deref().unwrap_or("");
2007        let user_preview = if user_text.len() > 80 {
2008            format!("{}...", &user_text[..user_text.floor_char_boundary(80)])
2009        } else {
2010            user_text.to_string()
2011        };
2012        let asst_text = t.assistant_text.as_deref().unwrap_or("");
2013        let asst_preview = if asst_text.len() > 80 {
2014            format!("{}...", &asst_text[..asst_text.floor_char_boundary(80)])
2015        } else {
2016            asst_text.to_string()
2017        };
2018
2019        // Tools as tags instead of plain text
2020        let tools_html: String = if t.tool_names.is_empty() {
2021            String::new()
2022        } else {
2023            t.tool_names
2024                .iter()
2025                .map(|name| format!("<span class=\"tool-tag\">{}</span>", escape_html(name)))
2026                .collect::<Vec<_>>()
2027                .join("")
2028        };
2029        let hit_bar = html_progress(t.cache_hit_rate);
2030
2031        let model_short = short_model(&t.model);
2032        let utc_iso = t.timestamp.to_rfc3339();
2033        let time_fallback = t.timestamp.format("%H:%M:%S").to_string();
2034
2035        writeln!(out, "<tr{cls}>\
2036            <td data-value=\"{turn}\">{turn}</td>\
2037            <td><span data-utc=\"{utc}\">{time}</span></td>\
2038            <td class=\"text-left\">{model}</td>\
2039            <td class=\"text-left\" style=\"max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;\" title=\"{user_full}\">{user}</td>\
2040            <td class=\"text-left\" style=\"max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;\" title=\"{asst_full}\">{asst}</td>\
2041            <td class=\"text-left\" style=\"max-width:160px;line-height:1.6;\">{tools}</td>\
2042            <td data-value=\"{out_val}\">{out_fmt}</td>\
2043            <td data-value=\"{ctx_val}\">{ctx_fmt}</td>\
2044            <td data-value=\"{hit:.1}\">{hit_bar}</td>\
2045            <td data-value=\"{cost:.6}\">{cost_fmt}</td>\
2046            <td class=\"text-left\">{stop}</td>\
2047            <td class=\"text-left\">{compact}</td>\
2048        </tr>",
2049            cls = row_class,
2050            turn = t.turn_number,
2051            utc = utc_iso,
2052            time = time_fallback,
2053            model = model_short,
2054            user_full = escape_html(user_text),
2055            user = escape_html(&user_preview),
2056            asst_full = escape_html(asst_text),
2057            asst = escape_html(&asst_preview),
2058            tools = tools_html,
2059            out_val = t.output_tokens, out_fmt = format_compact(t.output_tokens),
2060            ctx_val = t.context_size, ctx_fmt = format_compact(t.context_size),
2061            hit = t.cache_hit_rate, hit_bar = hit_bar,
2062            cost = t.cost, cost_fmt = format_cost(t.cost),
2063            stop = escape_html(stop),
2064            compact = compact_mark,
2065        ).unwrap();
2066    }
2067
2068    writeln!(out, "</tbody></table>").unwrap();
2069}