use std::fmt::Write as _;
use crate::analysis::{OverviewResult, ProjectResult, SessionResult, TrendResult};
use crate::pricing::calculator::PricingCalculator;
// โโโ Chart Colors โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const COLORS: &[&str] = &[
"#3b82f6", "#8b5cf6", "#06b6d4", "#22c55e", "#f59e0b",
"#ef4444", "#ec4899", "#a78bfa", "#2dd4bf", "#fb923c",
];
// โโโ ReportData โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
/// Bundled analysis results for one data source.
pub struct ReportData {
pub overview: OverviewResult,
pub projects: ProjectResult,
pub trend: TrendResult,
}
// โโโ Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
/// Escape HTML special characters.
fn escape_html(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
/// Format a number with thousands separators for display.
fn format_number(n: u64) -> String {
let s = n.to_string();
let mut result = String::with_capacity(s.len() + s.len() / 3);
for (i, ch) in s.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
result.push(',');
}
result.push(ch);
}
result.chars().rev().collect()
}
/// Format large numbers with M/B/K suffixes for compact display.
fn format_compact(n: u64) -> String {
if n >= 1_000_000_000 {
format!("{:.2}B", n as f64 / 1_000_000_000.0)
} else if n >= 1_000_000 {
format!("{:.2}M", n as f64 / 1_000_000.0)
} else if n >= 10_000 {
format!("{:.1}K", n as f64 / 1_000.0)
} else {
format_number(n)
}
}
/// Format a cost value: 1234.5 -> "$1,234.50"
fn format_cost(c: f64) -> String {
let abs = c.abs();
let whole = abs as u64;
let cents = ((abs - whole as f64) * 100.0).round() as u64;
let sign = if c < 0.0 { "-" } else { "" };
format!("{}${}.{:02}", sign, format_number(whole), cents)
}
/// Format a cost as integer: 1234.5 -> "$1,235"
fn format_cost_int(c: f64) -> String {
let abs = c.abs().round() as u64;
let sign = if c < 0.0 { "-" } else { "" };
format!("{}${}", sign, format_number(abs))
}
/// Pick a color from the palette by index.
fn color(i: usize) -> &'static str {
COLORS[i % COLORS.len()]
}
/// Shorten model name: claude-haiku-4-5-20251001 โ haiku-4-5
fn short_model(name: &str) -> String {
let s = name.strip_prefix("claude-").unwrap_or(name);
// Remove date suffix like -20251001 or -20250929
if s.len() > 9 {
let last_dash = s.rfind('-').unwrap_or(s.len());
let suffix = &s[last_dash + 1..];
if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_digit()) {
return s[..last_dash].to_string();
}
}
s.to_string()
}
/// Format duration in minutes to a human-readable string.
fn format_duration(minutes: f64) -> String {
if minutes < 1.0 {
format!("{:.0}s", minutes * 60.0)
} else if minutes < 60.0 {
format!("{:.0}m", minutes)
} else {
let h = (minutes / 60.0).floor();
let m = (minutes % 60.0).round();
format!("{:.0}h{:.0}m", h, m)
}
}
// โโโ CSS โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
fn css() -> &'static str {
r#"
:root {
--bg-primary: #0a0a0b;
--bg-secondary: #111113;
--bg-tertiary: #18181b;
--bg-deep: #27272a;
--border-color: #27272a;
--text-primary: #fafafa;
--text-secondary: #a1a1aa;
--text-tertiary: #71717a;
--text-accent: #3b82f6;
--footer-color: #71717a;
--session-detail-bg: #0a0a0b;
--project-session-bg: #111113;
--compact-row-bg: #2d1b1b;
--agent-badge-bg: #1e3a5f;
}
[data-theme="light"] {
--bg-primary: #ffffff;
--bg-secondary: #fafafa;
--bg-tertiary: #f4f4f5;
--bg-deep: #e4e4e7;
--border-color: #e4e4e7;
--text-primary: #09090b;
--text-secondary: #52525b;
--text-tertiary: #a1a1aa;
--text-accent: #2563eb;
--footer-color: #a1a1aa;
--session-detail-bg: #ffffff;
--project-session-bg: #fafafa;
--compact-row-bg: #fff0f0;
--agent-badge-bg: #dbeafe;
}
* { box-sizing: border-box; margin: 0; padding: 0; font-variant-numeric: tabular-nums; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg-primary); color: var(--text-primary);
max-width: 1200px; margin: 0 auto; padding: 20px;
-webkit-font-smoothing: antialiased;
}
.card { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; padding: 20px 24px; box-shadow: none; }
[data-theme="light"] .card { box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 1px 2px rgba(0,0,0,0.06); }
.card > h2:first-child { margin-top: 0; }
.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; margin: 16px 0; }
.kpi-grid .card { padding: 14px 16px; }
.kpi-value { font-size: 1.35rem; font-weight: 600; color: var(--text-primary); line-height: 1.1; }
.kpi-label { font-size: 0.75rem; font-weight: 500; color: var(--text-tertiary); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.05em; }
nav { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
nav button {
padding: 8px 20px; border: 1px solid var(--border-color); border-radius: 6px;
background: transparent; color: var(--text-tertiary); cursor: pointer; font-size: 14px;
transition: all 0.15s ease;
}
nav button:hover { color: var(--text-primary); border-color: var(--text-secondary); }
nav button.active { color: var(--text-primary); background: var(--bg-tertiary); border-color: var(--border-color); }
.tab-content { display: none; }
.tab-content.active { display: block; }
h1 { color: var(--text-primary); font-size: 1.5em; font-weight: 600; margin-bottom: 16px; }
h2 { color: var(--text-primary); font-size: 1.2em; margin: 16px 0 12px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th {
padding: 10px 12px; text-align: right; border-bottom: 1px solid var(--border-color);
font-size: 0.75rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--text-tertiary); cursor: pointer; user-select: none; white-space: nowrap;
position: sticky; top: 0; background: var(--bg-secondary); z-index: 2;
}
th.text-left { text-align: left; }
th:hover { color: var(--text-accent); }
td { padding: 10px 12px; text-align: right; border-bottom: 1px solid var(--border-color); color: var(--text-secondary); }
td.text-left { text-align: left; }
tr:hover { background: var(--bg-tertiary); }
.sort-asc::after { content: ' \25b2'; color: var(--text-accent); }
.sort-desc::after { content: ' \25bc'; color: var(--text-accent); }
.expandable { cursor: pointer; }
.session-detail { background: var(--session-detail-bg); }
.session-detail td { padding: 0; }
.session-detail:hover { background: var(--session-detail-bg); }
.detail-content { padding: 16px; overflow-x: auto; }
.detail-content table { font-size: 12px; }
.compact-row { background: var(--compact-row-bg) !important; }
.chart-container { position: relative; height: 350px; margin: 16px 0; }
.grid-2x2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.grid-2 > * { min-width: 0; overflow: hidden; }
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 16px 0; }
.footer { color: var(--footer-color); font-size: 12px; margin-top: 30px; padding-top: 16px; border-top: 1px solid var(--bg-deep); }
.header-row { display: flex; align-items: baseline; gap: 16px; margin-bottom: 16px; flex-wrap: wrap; }
.subtitle { color: var(--text-secondary); font-size: 0.85em; }
.expand-btn { background: none; border: none; color: var(--text-secondary); cursor: pointer; font-size: 14px; padding: 2px 6px; }
.expand-btn:hover { color: var(--text-accent); }
.project-session-row { background: var(--project-session-bg); }
.project-session-row:hover { background: var(--bg-tertiary); }
.project-row { background: var(--bg-secondary); font-weight: 600; }
.progress-bar { display: inline-block; width: 80px; height: 14px; background: var(--bg-deep); border-radius: 7px; overflow: hidden; vertical-align: middle; }
.progress-fill { height: 100%; border-radius: 7px; transition: width 0.15s ease; }
.progress-text { display: inline-block; width: 45px; text-align: right; margin-left: 4px; font-size: 12px; }
.stale-warning { color: #ef4444; margin-bottom: 8px; }
.top-nav { display: flex; gap: 8px; margin-bottom: 12px; }
.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; }
.top-nav button:hover { color: var(--text-primary); border-color: var(--text-secondary); }
.top-nav button.active { color: var(--text-primary); background: var(--bg-tertiary); border-color: var(--border-color); }
.sub-nav { display: flex; gap: 8px; margin-bottom: 16px; }
.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; }
.sub-nav button:hover { color: var(--text-primary); border-color: var(--text-secondary); }
.sub-nav button.active { color: var(--text-primary); background: var(--bg-tertiary); border-color: var(--border-color); }
.source-content { display: none; }
.source-content.active { display: block; }
.sub-tab-content { display: none; }
.sub-tab-content.active { display: block; }
.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; }
.tool-tag .tool-count { color: var(--text-accent); font-weight: 600; margin-left: 2px; }
.session-tools-cell { max-width: 260px; line-height: 1.8; }
.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; }
.turn-count-cell { white-space: nowrap; }
.grid-1-2 { display: grid; grid-template-columns: 1fr 2fr; gap: 16px; }
.chart-container-sm { position: relative; height: 250px; margin: 12px 0; }
.model-legend { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.model-legend-item { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--text-secondary); }
.model-legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.data-table th { cursor: default; }
.data-table th:hover { color: var(--text-secondary); }
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.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; }
.heatmap-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.heatmap-wrap canvas { display: block; }
.theme-btn { background: none; border: 1px solid var(--border-color); border-radius: 4px; padding: 4px 10px; cursor: pointer; font-size: 16px; line-height: 1; }
@media (max-width: 1100px) {
.grid-4 { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 900px) {
.grid-2x2 { grid-template-columns: 1fr; }
.grid-2 { grid-template-columns: 1fr; }
.grid-1-2 { grid-template-columns: 1fr; }
.grid-4 { grid-template-columns: repeat(2, 1fr); }
.kpi-grid { grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); }
}
@media (max-width: 600px) {
body { padding: 12px; }
.grid-4 { grid-template-columns: 1fr; }
.kpi-grid { grid-template-columns: 1fr 1fr; }
.kpi-value { font-size: 1.2em; }
.header-row { flex-direction: column; gap: 8px; }
.header-row button { margin-left: 0 !important; }
}
"#
}
// โโโ JavaScript โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
fn js_head() -> &'static str {
r#"
// โโ Theme init (must run before charts) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
var _chartInstances = [];
(function() {
var saved = localStorage.getItem('cc-theme') || 'dark';
document.documentElement.setAttribute('data-theme', saved);
})();
function getThemeColors() {
var isDark = (document.documentElement.getAttribute('data-theme') || 'dark') === 'dark';
return {
text: isDark ? '#fafafa' : '#09090b',
textSecondary: isDark ? '#a1a1aa' : '#52525b',
grid: isDark ? '#27272a' : '#e4e4e7',
accent: isDark ? '#3b82f6' : '#2563eb'
};
}
function toggleTheme() {
var current = document.documentElement.getAttribute('data-theme') || 'dark';
var next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('cc-theme', next);
document.querySelectorAll('.theme-btn').forEach(function(b) {
b.textContent = next === 'dark' ? '\u2600\uFE0F' : '\uD83C\uDF19';
});
updateChartColors();
// Redraw heatmaps
for (var key of Object.keys(window)) {
if (key.startsWith('_heatmapData_')) {
var pfx = key.replace('_heatmapData_', '');
drawHeatmap('heatmap-' + pfx, window[key]);
}
}
}
function updateChartColors() {
var tc = getThemeColors();
_chartInstances.forEach(function(chart) {
if (!chart || !chart.options || !chart.options.scales) return;
var scales = chart.options.scales;
['x','y','y1'].forEach(function(axis) {
if (scales[axis]) {
if (scales[axis].ticks) {
// Don't overwrite specially colored axes like y1 (ffd93d)
if (!scales[axis]._preserveColor) {
scales[axis].ticks.color = tc.textSecondary;
}
}
if (scales[axis].grid) scales[axis].grid.color = tc.grid;
if (scales[axis].title && scales[axis].title.color) {
if (!scales[axis]._preserveColor) {
scales[axis].title.color = tc.textSecondary;
}
}
}
});
if (chart.options.plugins && chart.options.plugins.legend && chart.options.plugins.legend.labels) {
chart.options.plugins.legend.labels.color = tc.text;
}
chart.update();
});
}
// Register Chart.js plugin to track instances for theme toggling
Chart.register({
id: 'themeTracker',
afterInit: function(chart) {
_chartInstances.push(chart);
},
beforeDestroy: function(chart) {
var idx = _chartInstances.indexOf(chart);
if (idx >= 0) _chartInstances.splice(idx, 1);
}
});
"#
}
fn js_common() -> &'static str {
r#"
function showTab(name) {
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
document.querySelectorAll('nav button').forEach(el => el.classList.remove('active'));
document.getElementById('tab-' + name).classList.add('active');
document.querySelector('nav button[data-tab="' + name + '"]').classList.add('active');
}
function sortTable(th, tableId) {
const table = document.getElementById(tableId);
const tbody = table.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr:not(.session-detail)'));
const colIndex = th.cellIndex;
const isAsc = th.classList.contains('sort-asc');
table.querySelectorAll('th').forEach(h => {
h.classList.remove('sort-asc', 'sort-desc');
});
th.classList.add(isAsc ? 'sort-desc' : 'sort-asc');
rows.sort((a, b) => {
let va = a.cells[colIndex].getAttribute('data-value') || a.cells[colIndex].textContent;
let vb = b.cells[colIndex].getAttribute('data-value') || b.cells[colIndex].textContent;
const na = parseFloat(va.replace(/[\$,%]/g, ''));
const nb = parseFloat(vb.replace(/[\$,%]/g, ''));
if (!isNaN(na) && !isNaN(nb)) {
return isAsc ? nb - na : na - nb;
}
return isAsc ? vb.localeCompare(va) : va.localeCompare(vb);
});
rows.forEach(row => {
const detail = row.nextElementSibling;
tbody.appendChild(row);
if (detail && detail.classList.contains('session-detail')) {
tbody.appendChild(detail);
}
});
}
function toggleSession(btn) {
const row = btn.closest('tr');
const detail = row.nextElementSibling;
if (detail && detail.classList.contains('session-detail')) {
const isHidden = detail.style.display === 'none';
detail.style.display = isHidden ? 'table-row' : 'none';
btn.textContent = isHidden ? '\u25bc' : '\u25b6';
}
}
function toggleProject(btn, projectId) {
const sessionRows = document.querySelectorAll('.project-session-row.project-sessions-' + projectId);
const detailRows = document.querySelectorAll('.session-detail.project-sessions-' + projectId);
const isHidden = sessionRows.length > 0 && sessionRows[0].style.display === 'none';
if (isHidden) {
// Expand: show session rows only (not turn details)
sessionRows.forEach(r => r.style.display = 'table-row');
} else {
// Collapse: hide session rows AND any open turn details
sessionRows.forEach(r => {
r.style.display = 'none';
const sbtn = r.querySelector('.expand-btn');
if (sbtn) sbtn.textContent = '\u25b6';
});
detailRows.forEach(r => r.style.display = 'none');
}
btn.textContent = isHidden ? '\u25bc' : '\u25b6';
}
// Heatmap data is already in local timezone (converted in Rust).
// No JS-side timezone shift needed.
function drawHeatmap(canvasId, data) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const ctx = canvas.getContext('2d');
const localData = data; // already local timezone from Rust
const zhDays = ['ๅจไธ','ๅจไบ','ๅจไธ','ๅจๅ','ๅจไบ','ๅจๅ
ญ','ๅจๆฅ'];
const enDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
const days = (currentLang === 'zh') ? zhDays : enDays;
const cellW = 22, cellH = 22, padL = 38, padT = 26;
canvas.width = padL + 24 * cellW + 10;
canvas.height = padT + 7 * cellH + 10;
const isDark = (document.documentElement.getAttribute('data-theme') || 'dark') === 'dark';
const max = Math.max(...localData.flat(), 1);
const labelColor = isDark ? '#a1a1aa' : '#52525b';
// Clear canvas with theme background
ctx.fillStyle = isDark ? '#111113' : '#fafafa';
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (let d = 0; d < 7; d++) {
for (let h = 0; h < 24; h++) {
const val = localData[d][h];
const intensity = val / max;
let r, g, b;
if (isDark) {
// Dark theme: dark โ bright blue (darker = less, brighter = more)
r = Math.round(13 + intensity * 75);
g = Math.round(17 + intensity * 130);
b = Math.round(34 + intensity * 221);
} else {
// Light theme: white -> blue
r = Math.round(235 - intensity * 195);
g = Math.round(238 - intensity * 158);
b = Math.round(245 - intensity * 27);
}
ctx.fillStyle = 'rgb(' + r + ',' + g + ',' + b + ')';
ctx.fillRect(padL + h * cellW, padT + d * cellH, cellW - 2, cellH - 2);
if (val > 0) {
if (isDark) {
ctx.fillStyle = intensity > 0.6 ? '#fafafa' : '#a1a1aa';
} else {
ctx.fillStyle = intensity > 0.5 ? '#ffffff' : '#09090b';
}
ctx.font = '8px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(val, padL + h * cellW + cellW/2, padT + d * cellH + cellH/2 + 3);
}
}
ctx.fillStyle = labelColor;
ctx.font = '9px sans-serif';
ctx.textAlign = 'right';
ctx.fillText(days[d], padL - 4, padT + d * cellH + cellH/2 + 3);
}
ctx.fillStyle = labelColor;
ctx.textAlign = 'center';
for (let h = 0; h < 24; h += 2) {
ctx.fillText(h.toString().padStart(2, '0'), padL + h * cellW + cellW/2, padT - 8);
}
}
function sortTableSimple(th, tableId) {
const table = document.getElementById(tableId);
const tbody = table.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
const colIndex = th.cellIndex;
const isAsc = th.classList.contains('sort-asc');
table.querySelectorAll('th').forEach(h => {
h.classList.remove('sort-asc', 'sort-desc');
});
th.classList.add(isAsc ? 'sort-desc' : 'sort-asc');
rows.sort((a, b) => {
let va = a.cells[colIndex].getAttribute('data-value') || a.cells[colIndex].textContent;
let vb = b.cells[colIndex].getAttribute('data-value') || b.cells[colIndex].textContent;
const na = parseFloat(va.replace(/[\$,%]/g, ''));
const nb = parseFloat(vb.replace(/[\$,%]/g, ''));
if (!isNaN(na) && !isNaN(nb)) {
return isAsc ? nb - na : na - nb;
}
return isAsc ? vb.localeCompare(va) : va.localeCompare(vb);
});
rows.forEach(row => tbody.appendChild(row));
}
function switchSource(sourceId) {
document.querySelectorAll('.source-content').forEach(el => el.style.display = 'none');
document.querySelectorAll('.top-nav button').forEach(el => el.classList.remove('active'));
document.getElementById('source-' + sourceId).style.display = 'block';
event.target.classList.add('active');
// Redraw heatmap for this source
if (window['_heatmapData_' + sourceId]) {
drawHeatmap('heatmap-' + sourceId, window['_heatmapData_' + sourceId]);
}
}
function showSubTab(sourceId, tabName) {
const container = document.getElementById('source-' + sourceId);
container.querySelectorAll('.sub-tab-content').forEach(el => el.classList.remove('active'));
container.querySelectorAll('.sub-nav button').forEach(el => el.classList.remove('active'));
document.getElementById(sourceId + '-tab-' + tabName).classList.add('active');
event.target.classList.add('active');
// Redraw heatmap when overview tab becomes visible
if (tabName === 'overview' && window['_heatmapData_' + sourceId]) {
drawHeatmap('heatmap-' + sourceId, window['_heatmapData_' + sourceId]);
}
}
let currentLang = localStorage.getItem('cc-lang') || 'en';
function toggleLang() {
currentLang = currentLang === 'en' ? 'zh' : 'en';
localStorage.setItem('cc-lang', currentLang);
applyLang();
}
function applyLang() {
document.querySelectorAll('[data-en]').forEach(el => {
el.textContent = el.getAttribute('data-' + currentLang) || el.getAttribute('data-en');
});
const btn = document.getElementById('lang-btn');
if (btn) btn.textContent = currentLang === 'en' ? 'ไธญๆ' : 'EN';
// Redraw heatmaps with localized day names
for (const key of Object.keys(window)) {
if (key.startsWith('_heatmapData_')) {
const pfx = key.replace('_heatmapData_', '');
drawHeatmap('heatmap-' + pfx, window[key]);
}
}
}
// Convert UTC timestamps to local timezone
function convertTimestamps() {
document.querySelectorAll('[data-utc]').forEach(el => {
const utc = el.getAttribute('data-utc');
const d = new Date(utc);
if (!isNaN(d)) {
const pad = n => String(n).padStart(2, '0');
el.textContent = pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
el.title = d.toLocaleString();
}
});
document.querySelectorAll('[data-utc-datetime]').forEach(el => {
const utc = el.getAttribute('data-utc-datetime');
const d = new Date(utc);
if (!isNaN(d)) {
const pad = n => String(n).padStart(2, '0');
el.textContent = pad(d.getMonth()+1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
el.title = d.toLocaleString();
}
});
}
document.addEventListener('DOMContentLoaded', function() {
applyLang();
convertTimestamps();
// Init theme button text and sync chart colors with saved theme
var theme = document.documentElement.getAttribute('data-theme') || 'dark';
document.querySelectorAll('.theme-btn').forEach(function(b) {
b.textContent = theme === 'dark' ? '\u2600\uFE0F' : '\uD83C\uDF19';
});
// If theme was loaded as light from localStorage, update chart colors
if (theme === 'light') {
updateChartColors();
// Redraw heatmaps with light colors
for (var key of Object.keys(window)) {
if (key.startsWith('_heatmapData_')) {
var pfx = key.replace('_heatmapData_', '');
drawHeatmap('heatmap-' + pfx, window[key]);
}
}
}
});
"#
}
// โโโ Source Tabs Renderer โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
/// Render sub-nav + 3 tab contents (overview, monthly, projects) for one data source.
/// All element IDs are prefixed with `pfx` to avoid conflicts in dual-source mode.
fn render_source_tabs(
out: &mut String,
pfx: &str,
overview: &OverviewResult,
projects: &ProjectResult,
trend: &TrendResult,
calc: &PricingCalculator,
) {
// Sub-navigation
writeln!(out, r#"<nav class="sub-nav">"#).unwrap();
writeln!(out, r#"<button class="active" onclick="showSubTab('{pfx}','overview')" data-en="Overview" data-zh="ๆฆ่ง">Overview</button>"#,
pfx = pfx).unwrap();
writeln!(out, r#"<button onclick="showSubTab('{pfx}','monthly')" data-en="Monthly" data-zh="ๆๅบฆ">Monthly</button>"#,
pfx = pfx).unwrap();
writeln!(out, r#"<button onclick="showSubTab('{pfx}','projects')" data-en="Projects" data-zh="้กน็ฎ">Projects</button>"#,
pfx = pfx).unwrap();
writeln!(out, "</nav>").unwrap();
// Tab 1: Overview
writeln!(out, r#"<div id="{pfx}-tab-overview" class="sub-tab-content active">"#, pfx = pfx).unwrap();
render_overview_tab(out, overview, pfx);
writeln!(out, "</div>").unwrap();
// Tab 2: Monthly
writeln!(out, r#"<div id="{pfx}-tab-monthly" class="sub-tab-content">"#, pfx = pfx).unwrap();
render_monthly_tab(out, overview, trend, pfx);
writeln!(out, "</div>").unwrap();
// Tab 3: Projects
writeln!(out, r#"<div id="{pfx}-tab-projects" class="sub-tab-content">"#, pfx = pfx).unwrap();
render_projects_tab(out, projects, &overview.session_summaries, pfx);
writeln!(out, "</div>").unwrap();
let _ = calc;
}
// โโโ 1. Full Report (single source) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
/// Generate a comprehensive HTML dashboard with 3 tabs, charts, and sortable tables.
/// Single data source โ no top-level source switcher.
pub fn render_full_report_html(
overview: &OverviewResult,
projects: &ProjectResult,
trend: &TrendResult,
calc: &PricingCalculator,
) -> String {
let mut out = String::with_capacity(256 * 1024);
// โโ HTML head โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
write!(out, r#"<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Claude Code Token Analyzer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>{js_head}</script>
<style>{css}</style>
</head>
<body>
"#, css = css(), js_head = js_head()).unwrap();
// โโ Header โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
writeln!(out, r#"<div class="header-row">"#).unwrap();
writeln!(out, r#"<h1>Claude Code Token Analyzer</h1>"#).unwrap();
if let Some((start, end)) = &overview.quality.time_range {
writeln!(out, r#"<span class="subtitle">{} ~ {}</span>"#,
start.format("%Y-%m-%d"), end.format("%Y-%m-%d")).unwrap();
}
writeln!(out, r#"<button class="theme-btn" onclick="toggleTheme()" style="margin-left:auto;">☀️</button>"#).unwrap();
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();
writeln!(out, "</div>").unwrap();
// โโ Glossary โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
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();
// โโ Single source: use sub-nav directly (no top-nav) โโโโโโโโโโโโโโโโโโโโโ
let pfx = "s1";
writeln!(out, r#"<div id="source-{pfx}" class="source-content active">"#, pfx = pfx).unwrap();
render_source_tabs(&mut out, pfx, overview, projects, trend, calc);
writeln!(out, "</div>").unwrap();
// โโ JavaScript โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
write!(out, "<script>{}</script>", js_common()).unwrap();
writeln!(out, "</body>\n</html>").unwrap();
out
}
// โโโ 1b. Dual Report (two sources) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
/// Generate a dual-source HTML dashboard with top-level source switcher.
/// Each source gets its own sub-nav with 3 tabs.
pub fn render_dual_report_html(
source1_name: &str,
source1: &ReportData,
source2_name: &str,
source2: &ReportData,
calc: &PricingCalculator,
) -> String {
let mut out = String::with_capacity(512 * 1024);
// โโ HTML head โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
write!(out, r#"<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Claude Code Token Analyzer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>{js_head}</script>
<style>{css}</style>
</head>
<body>
"#, css = css(), js_head = js_head()).unwrap();
// โโ Header โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
writeln!(out, r#"<div class="header-row">"#).unwrap();
writeln!(out, r#"<h1>Claude Code Token Analyzer</h1>"#).unwrap();
// Show combined time range
let time_range_str = {
let mut global_min = None;
let mut global_max = None;
for q in [&source1.overview.quality, &source2.overview.quality] {
if let Some((s, e)) = &q.time_range {
global_min = Some(global_min.map_or(*s, |m: chrono::DateTime<chrono::Utc>| m.min(*s)));
global_max = Some(global_max.map_or(*e, |m: chrono::DateTime<chrono::Utc>| m.max(*e)));
}
}
match (global_min, global_max) {
(Some(s), Some(e)) => format!("{} ~ {}", s.format("%Y-%m-%d"), e.format("%Y-%m-%d")),
_ => String::new(),
}
};
if !time_range_str.is_empty() {
writeln!(out, r#"<span class="subtitle">{}</span>"#, time_range_str).unwrap();
}
writeln!(out, r#"<button class="theme-btn" onclick="toggleTheme()" style="margin-left:auto;">☀️</button>"#).unwrap();
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();
writeln!(out, "</div>").unwrap();
// โโ Glossary โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
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();
// โโ Top-level source switcher โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let s1_sessions = source1.overview.total_sessions;
let s2_sessions = source2.overview.total_sessions;
writeln!(out, r#"<nav class="top-nav">"#).unwrap();
writeln!(out, r#"<button class="active" onclick="switchSource('s1')">{} ({} sessions)</button>"#,
escape_html(source1_name), s1_sessions).unwrap();
writeln!(out, r#"<button onclick="switchSource('s2')">{} ({} sessions)</button>"#,
escape_html(source2_name), s2_sessions).unwrap();
writeln!(out, "</nav>").unwrap();
// โโ Source 1 โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
writeln!(out, r#"<div id="source-s1" class="source-content active">"#).unwrap();
render_source_tabs(&mut out, "s1", &source1.overview, &source1.projects, &source1.trend, calc);
writeln!(out, "</div>").unwrap();
// โโ Source 2 โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
writeln!(out, r#"<div id="source-s2" class="source-content">"#).unwrap();
render_source_tabs(&mut out, "s2", &source2.overview, &source2.projects, &source2.trend, calc);
writeln!(out, "</div>").unwrap();
// โโ JavaScript โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
write!(out, "<script>{}</script>", js_common()).unwrap();
writeln!(out, "</body>\n</html>").unwrap();
out
}
// โโโ Tab 1: Overview โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
fn render_overview_tab(out: &mut String, overview: &OverviewResult, pfx: &str) {
// KPI cards
writeln!(out, r#"<div class="kpi-grid">"#).unwrap();
write_kpi_i18n(out, &format_number(overview.total_sessions as u64), "Sessions", "ไผ่ฏๆฐ");
write_kpi_i18n(out, &format_number(overview.total_turns as u64), "Turns", "ๅๅบๆฐ");
write_kpi_i18n(out, &format_compact(overview.total_output_tokens), "Claude Wrote", "Claude ๅไบ");
write_kpi_i18n(out, &format_compact(overview.total_context_tokens), "Claude Read", "Claude ่ฏปไบ");
write_kpi_i18n(out, &format!("{:.1}%", overview.avg_cache_hit_rate), "Avg Cache Hit Rate", "ๅนณๅ็ผๅญๅฝไธญ็");
write_kpi_i18n(out, &format_cost_int(overview.total_cost), "Token Value (API Rate)", "Token ไปทๅผ (API ่ดน็)");
if overview.cache_savings.total_saved > 0.0 {
write_kpi_i18n(out, &format_cost_int(overview.cache_savings.total_saved),
&format!("Cache Savings ({:.0}%)", overview.cache_savings.savings_pct),
&format!("็ผๅญ่็ ({:.0}%)", overview.cache_savings.savings_pct));
}
writeln!(out, "</div>").unwrap();
// Row 1: Usage Insights KPI cards
{
let summaries = &overview.session_summaries;
// Daily avg cost
let daily_avg = overview.quality.time_range.map(|(s, e)| {
let days = (e - s).num_days().max(1) as f64;
(overview.total_cost / days, days as u64)
});
// Compaction stats
let total_compactions: usize = summaries.iter().map(|s| s.compaction_count).sum();
// Max context
let max_ctx = summaries.iter().map(|s| s.max_context).max().unwrap_or(0);
// Average session duration
let durations: Vec<f64> = summaries.iter()
.map(|s| s.duration_minutes).filter(|d| *d > 0.0).collect();
let avg_dur = if !durations.is_empty() { durations.iter().sum::<f64>() / durations.len() as f64 } else { 0.0 };
writeln!(out, r#"<div class="grid-4">"#).unwrap();
if let Some((avg, days)) = daily_avg {
write_kpi_i18n(out,
&format!("{}/day", format_cost_int(avg)),
&format!("Daily Avg ({} days)", days),
&format!("ๆฅๅ่ดน็จ๏ผ{} ๅคฉ๏ผ", days));
}
write_kpi_i18n(out,
&format_compact(max_ctx),
"Peak Context",
"ๅณฐๅผไธไธๆ");
write_kpi_i18n(out,
&format_number(total_compactions as u64),
"Compactions",
"ไธไธๆๅ็ผฉๆฌกๆฐ");
write_kpi_i18n(out,
&format_duration(avg_dur),
"Avg Session",
"ๅนณๅไผ่ฏๆถ้ฟ");
writeln!(out, "</div>").unwrap();
}
// Row: Most Expensive Sessions (left, narrow) + Heatmap (right, wide)
writeln!(out, r#"<div class="grid-1-2" style="margin-top:16px;">"#).unwrap();
// Left: Most Expensive Sessions Top 5
{
let summaries = &overview.session_summaries;
let mut by_cost: Vec<&crate::analysis::SessionSummary> = summaries.iter().collect();
by_cost.sort_by(|a, b| b.cost.partial_cmp(&a.cost).unwrap_or(std::cmp::Ordering::Equal));
let top5 = &by_cost[..by_cost.len().min(5)];
if !top5.is_empty() {
writeln!(out, r#"<div class="card">"#).unwrap();
writeln!(out, r#"<h2 data-en="Most Expensive Sessions Top 5" data-zh="ๆ่ดตไผ่ฏ Top 5">Most Expensive Sessions Top 5</h2>"#).unwrap();
writeln!(out, r#"<div class="table-wrap">"#).unwrap();
writeln!(out, r#"<table class="data-table"><thead><tr>
<th class="text-left" data-en="Session" data-zh="ไผ่ฏ">Session</th>
<th class="text-left" data-en="Project" data-zh="้กน็ฎ">Project</th>
<th style="text-align:right;" data-en="Turns" data-zh="ๅๅบๆฐ">Turns</th>
<th style="text-align:right;" data-en="Cost" data-zh="่ดน็จ">Cost</th>
</tr></thead><tbody>"#).unwrap();
for s in top5 {
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>",
escape_html(&s.session_id[..s.session_id.len().min(8)]),
escape_html(&s.project_display_name),
s.turn_count,
format_cost(s.cost),
).unwrap();
}
writeln!(out, "</tbody></table></div></div>").unwrap();
}
}
// Right: Heatmap
{
let canvas_id = format!("heatmap-{}", pfx);
writeln!(out, r#"<div class="card">"#).unwrap();
writeln!(out, r#"<h2 data-en="Activity Heatmap (Local Time)" data-zh="ๆดป่ท็ญๅๅพ๏ผๆฌๅฐๆถ้ด๏ผ">Activity Heatmap (Local Time)</h2>"#).unwrap();
writeln!(out, r#"<div class="heatmap-wrap"><canvas id="{}"></canvas></div>"#, canvas_id).unwrap();
let mut matrix_js = String::from("[");
for d in 0..7 {
if d > 0 { matrix_js.push(','); }
matrix_js.push('[');
for h in 0..24 {
if h > 0 { matrix_js.push(','); }
write!(matrix_js, "{}", overview.weekday_hour_matrix[d][h]).unwrap();
}
matrix_js.push(']');
}
matrix_js.push(']');
writeln!(out, r#"<script>
window._heatmapData_{pfx} = {matrix};
document.addEventListener('DOMContentLoaded', function() {{
drawHeatmap('{canvas_id}', window._heatmapData_{pfx});
}});
</script>"#, pfx = pfx, matrix = matrix_js, canvas_id = canvas_id).unwrap();
writeln!(out, "</div>").unwrap();
}
writeln!(out, "</div>").unwrap(); // close grid-2
// Bubble chart (full width, separate row)
{
let chart_id = format!("{}-scatterChart", pfx);
writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
writeln!(out, r#"<h2 data-en="Session Efficiency (Turns vs Cost)" data-zh="ไผ่ฏๆ็๏ผTurns vs ่ดน็จ๏ผ">Session Efficiency (Turns vs Cost)</h2>"#).unwrap();
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();
writeln!(out, r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#, chart_id).unwrap();
let max_output: u64 = overview.session_summaries.iter().map(|s| s.output_tokens).max().unwrap_or(1);
let mut scatter_data = String::from("[");
for (i, s) in overview.session_summaries.iter().enumerate() {
if i > 0 { scatter_data.push(','); }
let radius = if max_output > 0 {
3.0 + (s.output_tokens as f64 / max_output as f64) * 20.0
} else { 3.0 };
let cpt = if s.turn_count > 0 { s.cost / s.turn_count as f64 } else { 0.0 };
write!(scatter_data, "{{x:{},y:{:.4},r:{:.1},cpt:{:.4},out:{}}}", s.turn_count, s.cost, radius, cpt, s.output_tokens).unwrap();
}
scatter_data.push(']');
writeln!(out, r#"<script>
new Chart(document.getElementById('{chart_id}'), {{
type: 'bubble',
data: {{
datasets: [{{
label: 'Sessions',
data: {data},
backgroundColor: 'rgba(59,130,246,0.4)',
borderColor: '#3b82f6',
borderWidth: 1
}}]
}},
options: {{
responsive: true, maintainAspectRatio: false,
plugins: {{
legend: {{ display: false }},
tooltip: {{ callbacks: {{
label: function(ctx) {{
const d = ctx.raw;
return ['Turns: ' + d.x + ' Cost: $' + d.y.toFixed(2), 'Cost/Turn: $' + d.cpt.toFixed(3) + ' Output: ' + d.out.toLocaleString()];
}}
}} }}
}},
scales: {{
x: {{ title: {{ display: true, text: 'Turn Count', color: '#a1a1aa' }}, ticks: {{ color: '#a1a1aa' }}, grid: {{ color: '#27272a' }} }},
y: {{ title: {{ display: true, text: 'Cost ($)', color: '#a1a1aa' }}, ticks: {{ color: '#a1a1aa', callback: function(v) {{ return '$' + v; }} }}, grid: {{ color: '#27272a' }} }}
}}
}}
}});
</script>"#, chart_id = chart_id, data = scatter_data).unwrap();
writeln!(out, "</div>").unwrap();
}
}
// โโโ Tab 2: Monthly โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
fn render_monthly_tab(out: &mut String, _overview: &OverviewResult, trend: &TrendResult, pfx: &str) {
if trend.entries.is_empty() {
writeln!(out, r#"<div class="card"><p style="color:var(--text-secondary);">No trend data available.</p></div>"#).unwrap();
return;
}
// Determine the latest month from trend entries
let latest_month = trend.entries.last().map(|e| &e.label[..7]).unwrap_or("");
// Aggregate current month data
let mut month_cost = 0.0f64;
let mut month_turns = 0usize;
let mut month_sessions = 0usize;
let mut month_output = 0u64;
let mut month_input = 0u64;
let mut daily_entries: Vec<&crate::analysis::TrendEntry> = Vec::new();
for entry in &trend.entries {
if entry.label.starts_with(latest_month) {
month_cost += entry.cost;
month_turns += entry.turn_count;
month_sessions += entry.session_count;
month_output += entry.tokens.output_tokens;
month_input += entry.tokens.input_tokens + entry.tokens.cache_creation_tokens + entry.tokens.cache_read_tokens;
daily_entries.push(entry);
}
}
let _avg_cost_per_turn = if month_turns > 0 { month_cost / month_turns as f64 } else { 0.0 };
// KPI cards for current month
writeln!(out, r#"<h2 data-en="Current Period: {m}" data-zh="ๅฝๅๅจๆ๏ผ{m}">Current Period: {m}</h2>"#, m = escape_html(latest_month)).unwrap();
writeln!(out, r#"<div class="kpi-grid">"#).unwrap();
write_kpi_i18n(out, &format_number(month_sessions as u64), "Sessions", "ไผ่ฏๆฐ");
write_kpi_i18n(out, &format_number(month_turns as u64), "Turns", "ๅๅบๆฐ");
write_kpi_i18n(out, &format_compact(month_input), "Input Tokens", "่พๅ
ฅ Token");
write_kpi_i18n(out, &format_compact(month_output), "Output Tokens", "่พๅบ Token");
write_kpi_i18n(out, &format_cost(month_cost), "Cost", "่ดน็จ");
writeln!(out, "</div>").unwrap();
// Chart: Daily Cost + Cost/Turn combo chart
if !daily_entries.is_empty() {
let chart_id = format!("{}-dailyCostChart", pfx);
writeln!(out, r#"<div class="card">"#).unwrap();
writeln!(out, r#"<h2 data-en="Daily Cost & Cost/Turn ({})" data-zh="ๆฏๆฅ่ดน็จ & ๆฏ Turn ่ดน็จ ({})">Daily Cost & Cost/Turn ({})</h2>"#,
escape_html(latest_month), escape_html(latest_month), escape_html(latest_month)).unwrap();
writeln!(out, r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#, chart_id).unwrap();
let labels: Vec<String> = daily_entries.iter().map(|e| format!("\"{}\"", &e.label[5..])).collect();
let cost_data: Vec<String> = daily_entries.iter().map(|e| format!("{:.2}", e.cost)).collect();
let cpt_data: Vec<String> = daily_entries.iter().map(|e| {
if e.turn_count > 0 { format!("{:.4}", e.cost / e.turn_count as f64) } else { "0".to_string() }
}).collect();
let turn_data: Vec<String> = daily_entries.iter().map(|e| e.turn_count.to_string()).collect();
writeln!(out, r#"<script>
new Chart(document.getElementById('{chart_id}'), {{
type: 'bar',
data: {{
labels: [{labels}],
datasets: [
{{
label: 'Cost ($)',
data: [{cost_data}],
backgroundColor: 'rgba(59,130,246,0.6)',
borderColor: '#3b82f6',
borderWidth: 1,
borderRadius: 4,
yAxisID: 'y',
order: 2
}},
{{
label: 'Cost/Turn ($)',
data: [{cpt_data}],
type: 'line',
borderColor: '#f59e0b',
backgroundColor: 'rgba(245,158,11,0.1)',
pointRadius: 3,
tension: 0.3,
yAxisID: 'y1',
order: 1
}}
]
}},
options: {{
responsive: true, maintainAspectRatio: false,
plugins: {{
legend: {{ labels: {{ color: '#fafafa' }} }},
tooltip: {{ callbacks: {{
afterLabel: function(ctx) {{
const turns = [{turn_data}];
return 'Turns: ' + turns[ctx.dataIndex];
}}
}} }}
}},
scales: {{
x: {{ ticks: {{ color: '#a1a1aa' }}, grid: {{ color: '#27272a' }} }},
y: {{ position: 'left', ticks: {{ color: '#a1a1aa', callback: function(v) {{ return '$' + v; }} }}, grid: {{ color: '#27272a' }}, title: {{ display: true, text: 'Cost ($)', color: '#a1a1aa' }} }},
y1: {{ position: 'right', ticks: {{ color: '#f59e0b', callback: function(v) {{ return '$' + v.toFixed(3); }} }}, grid: {{ drawOnChartArea: false }}, title: {{ display: true, text: 'Cost/Turn ($)', color: '#f59e0b' }} }}
}}
}}
}});
</script>"#, chart_id = chart_id,
labels = labels.join(","),
cost_data = cost_data.join(","),
cpt_data = cpt_data.join(","),
turn_data = turn_data.join(","),
).unwrap();
writeln!(out, "</div>").unwrap();
}
// Chart: Model distribution per day (stacked bar)
{
// Collect all unique model names
let mut all_models: Vec<String> = Vec::new();
for entry in &daily_entries {
for model_name in entry.models.keys() {
let short = short_model(model_name);
if !all_models.contains(&short) {
all_models.push(short);
}
}
}
all_models.sort();
if daily_entries.len() > 1 {
let chart_id = format!("{}-dailyTurnsCostChart", pfx);
writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
writeln!(out, r#"<h2 data-en="Daily Turns & Cost" data-zh="ๆฏๆฅๅๅบๆฐไธ่ดน็จ">Daily Turns & Cost</h2>"#).unwrap();
writeln!(out, r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#, chart_id).unwrap();
let labels: Vec<String> = daily_entries.iter().map(|e| format!("\"{}\"", &e.label[5..])).collect();
let turns_data: Vec<String> = daily_entries.iter().map(|e| e.turn_count.to_string()).collect();
let cost_data: Vec<String> = daily_entries.iter().map(|e| format!("{:.2}", e.cost)).collect();
writeln!(out, r#"<script>
new Chart(document.getElementById('{chart_id}'), {{
type: 'bar',
data: {{
labels: [{labels}],
datasets: [
{{label:'Turns',data:[{turns}],backgroundColor:'rgba(59,130,246,0.5)',borderColor:'#3b82f6',borderWidth:1,borderRadius:3,yAxisID:'y'}},
{{label:'Cost ($)',data:[{cost}],type:'line',borderColor:'#22c55e',backgroundColor:'rgba(34,197,94,0.1)',fill:true,pointRadius:3,tension:0.3,yAxisID:'y1'}}
]
}},
options: {{
responsive: true, maintainAspectRatio: false,
plugins: {{ legend: {{ labels: {{ color: 'var(--text-secondary)' }} }} }},
scales: {{
x: {{ ticks: {{ color: 'var(--text-secondary)' }}, grid: {{ color: 'var(--border-color)' }} }},
y: {{ position:'left', ticks: {{ color: '#3b82f6' }}, grid: {{ color: 'var(--border-color)' }}, title: {{ display:true, text:'Turns', color:'#3b82f6' }} }},
y1: {{ position:'right', ticks: {{ color: '#22c55e', callback: function(v){{ return '$'+v; }} }}, grid: {{ drawOnChartArea:false }}, title: {{ display:true, text:'Cost ($)', color:'#22c55e' }} }}
}}
}}
}});
</script>"#, chart_id = chart_id, labels = labels.join(","), turns = turns_data.join(","), cost = cost_data.join(",")).unwrap();
writeln!(out, "</div>").unwrap();
}
}
// Table: Monthly summary (aggregate by month if multi-month data)
{
// Group trend entries by month
#[allow(clippy::type_complexity)]
let mut months: std::collections::BTreeMap<String, (usize, usize, u64, u64, u64, f64, u64)> = std::collections::BTreeMap::new();
for entry in &trend.entries {
let month_key = entry.label[..7].to_string();
let e = months.entry(month_key).or_insert((0, 0, 0, 0, 0, 0.0, 0));
e.0 += entry.session_count;
e.1 += entry.turn_count;
e.2 += entry.tokens.output_tokens;
e.3 += entry.tokens.cache_creation_tokens;
e.4 += entry.tokens.cache_read_tokens;
e.5 += entry.cost;
e.6 += entry.tokens.input_tokens + entry.tokens.cache_creation_tokens + entry.tokens.cache_read_tokens;
}
if months.len() > 1 {
let tbl_id = format!("{}-tbl-monthly", pfx);
writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
writeln!(out, r#"<h2 data-en="Monthly Summary" data-zh="ๆๅบฆๆฑๆป">Monthly Summary</h2>"#).unwrap();
writeln!(out, r#"<div class="table-wrap">"#).unwrap();
writeln!(out, r#"<table id="{}">"#, tbl_id).unwrap();
writeln!(out, "<thead><tr>\
<th class=\"text-left\" onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Month\" data-zh=\"ๆไปฝ\">Month</th>\
<th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Sessions\" data-zh=\"ไผ่ฏ\">Sessions</th>\
<th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Turns\" data-zh=\"ๅๅบๆฐ\">Turns</th>\
<th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Input Tokens\" data-zh=\"่พๅ
ฅ Token\">Input Tokens</th>\
<th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Output Tokens\" data-zh=\"่พๅบ Token\">Output Tokens</th>\
<th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Cost\" data-zh=\"่ดน็จ\">Cost</th>\
</tr></thead>", id = tbl_id).unwrap();
writeln!(out, "<tbody>").unwrap();
for (month, (sessions, turns, output, _cache_write, _cache_read, cost, input_ctx)) in &months {
writeln!(out, "<tr>\
<td class=\"text-left\" data-value=\"{}\">{}</td>\
<td data-value=\"{}\">{}</td>\
<td data-value=\"{}\">{}</td>\
<td data-value=\"{}\">{}</td>\
<td data-value=\"{}\">{}</td>\
<td data-value=\"{:.4}\">{}</td>\
</tr>",
escape_html(month), escape_html(month),
sessions, format_number(*sessions as u64),
turns, format_number(*turns as u64),
input_ctx, format_compact(*input_ctx),
output, format_compact(*output),
cost, format_cost(*cost),
).unwrap();
}
writeln!(out, "</tbody></table></div></div>").unwrap();
}
}
// Table: Daily detail with cost/turn
{
let tbl_id = format!("{}-tbl-daily", pfx);
let group_zh = match trend.group_label.as_str() {
"Day" => "ๆฏๆฅ",
"Week" => "ๆฏๅจ",
"Month" => "ๆฏๆ",
other => other,
};
let group_col_zh = match trend.group_label.as_str() {
"Day" => "ๆฅๆ",
"Week" => "ๅจ",
"Month" => "ๆไปฝ",
other => other,
};
writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
writeln!(out, r#"<h2 data-en="{} Breakdown" data-zh="{}ๆ็ป">{} Breakdown</h2>"#,
escape_html(&trend.group_label), escape_html(group_zh), escape_html(&trend.group_label)).unwrap();
writeln!(out, r#"<div class="table-wrap">"#).unwrap();
writeln!(out, r#"<table id="{}">"#, tbl_id).unwrap();
writeln!(out, "<thead><tr>\
<th class=\"text-left\" onclick=\"sortTableSimple(this,'{id}')\" data-en=\"{en}\" data-zh=\"{zh}\">{en}</th>\
<th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Sessions\" data-zh=\"ไผ่ฏ\">Sessions</th>\
<th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Turns\" data-zh=\"ๅๅบๆฐ\">Turns</th>\
<th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Input Tokens\" data-zh=\"่พๅ
ฅ Token\">Input Tokens</th>\
<th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Output Tokens\" data-zh=\"่พๅบ Token\">Output Tokens</th>\
<th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Cost\" data-zh=\"่ดน็จ\">Cost</th>\
<th class=\"text-left\" data-en=\"Models\" data-zh=\"ๆจกๅ\">Models</th>\
</tr></thead>", en = escape_html(&trend.group_label), zh = escape_html(group_col_zh), id = tbl_id).unwrap();
writeln!(out, "<tbody>").unwrap();
for entry in &trend.entries {
let input_tokens = entry.tokens.input_tokens + entry.tokens.cache_creation_tokens + entry.tokens.cache_read_tokens;
// Model summary for this day
let mut model_list: Vec<(&String, &u64)> = entry.models.iter().collect();
model_list.sort_by(|a, b| b.1.cmp(a.1));
let models_html: String = model_list.iter().take(3).map(|(m, tokens)| {
format!("<span class=\"tool-tag\">{} <span class=\"tool-count\">{}</span></span>",
escape_html(&short_model(m)), format_compact(**tokens))
}).collect::<Vec<_>>().join("");
writeln!(out, "<tr>\
<td class=\"text-left\" data-value=\"{}\">{}</td>\
<td data-value=\"{}\">{}</td>\
<td data-value=\"{}\">{}</td>\
<td data-value=\"{}\">{}</td>\
<td data-value=\"{}\">{}</td>\
<td data-value=\"{:.4}\">{}</td>\
<td class=\"text-left\">{}</td>\
</tr>",
escape_html(&entry.label), escape_html(&entry.label),
entry.session_count, format_number(entry.session_count as u64),
entry.turn_count, format_number(entry.turn_count as u64),
input_tokens, format_compact(input_tokens),
entry.tokens.output_tokens, format_compact(entry.tokens.output_tokens),
entry.cost, format_cost(entry.cost),
models_html,
).unwrap();
}
writeln!(out, "</tbody></table></div></div>").unwrap();
}
}
// โโโ Tab 3: Projects โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
fn render_projects_tab(out: &mut String, projects: &ProjectResult, sessions: &[crate::analysis::SessionSummary], pfx: &str) {
// Chart: Project Cost Top 10
{
let top_n = projects.projects.iter().take(10).collect::<Vec<_>>();
if !top_n.is_empty() {
let chart_id = format!("{}-projectCostChart", pfx);
writeln!(out, r#"<div class="card">"#).unwrap();
writeln!(out, r#"<h2 data-en="Project Cost Top 10" data-zh="้กน็ฎ่ดน็จ Top 10">Project Cost Top 10</h2>"#).unwrap();
writeln!(out, r#"<div class="chart-container"><canvas id="{}"></canvas></div>"#, chart_id).unwrap();
let labels: Vec<String> = top_n.iter().map(|p| format!("\"{}\"", escape_html(&p.display_name))).collect();
let data: Vec<String> = top_n.iter().map(|p| format!("{:.2}", p.cost)).collect();
let colors_list: Vec<String> = (0..top_n.len()).map(|i| format!("\"{}\"", color(i))).collect();
writeln!(out, r#"<script>
new Chart(document.getElementById('{chart_id}'), {{
type: 'bar',
data: {{
labels: [{labels}],
datasets: [{{ label: 'Cost ($)', data: [{data}], backgroundColor: [{colors}], borderWidth: 0, borderRadius: 4 }}]
}},
options: {{
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx) {{ return '$' + ctx.raw.toFixed(2); }} }} }} }},
scales: {{
x: {{ ticks: {{ color: '#a1a1aa', callback: function(v) {{ return '$' + v; }} }}, grid: {{ color: '#27272a' }} }},
y: {{ ticks: {{ color: '#fafafa' }}, grid: {{ color: '#27272a' }} }}
}}
}}
}});
</script>"#, chart_id = chart_id, labels = labels.join(","), data = data.join(","), colors = colors_list.join(",")).unwrap();
writeln!(out, "</div>").unwrap();
}
}
// Three-level drill-down table: Project โ Session โ Turn
let tbl_id = format!("{}-tbl-projects-drill", pfx);
writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
writeln!(out, r#"<h2 data-en="Project Drill-Down" data-zh="้กน็ฎ้ปๅ">Project Drill-Down</h2>"#).unwrap();
writeln!(out, r#"<div class="table-wrap">"#).unwrap();
writeln!(out, r#"<table id="{}">"#, tbl_id).unwrap();
writeln!(out, "<thead><tr>\
<th class=\"text-left\"></th>\
<th class=\"text-left\" data-en=\"Project / Session\" data-zh=\"้กน็ฎ / ไผ่ฏ\">Project / Session</th>\
<th data-en=\"Sessions\" data-zh=\"ไผ่ฏ\">Sessions</th>\
<th data-en=\"Turns (Agent)\" data-zh=\"ๅๅบๆฐ (Agent)\">Turns (Agent)</th>\
<th data-en=\"Output\" data-zh=\"่พๅบ\">Output</th>\
<th data-en=\"CacheHit\" data-zh=\"็ผๅญๅฝไธญ็\">CacheHit</th>\
<th class=\"text-left\" data-en=\"Tools\" data-zh=\"ๅทฅๅ
ท\">Tools</th>\
<th data-en=\"Cost\" data-zh=\"่ดน็จ\">Cost</th>\
</tr></thead>").unwrap();
writeln!(out, "<tbody>").unwrap();
// Group sessions by project_display_name
let mut sessions_by_project: std::collections::HashMap<String, Vec<&crate::analysis::SessionSummary>> = std::collections::HashMap::new();
for s in sessions {
sessions_by_project.entry(s.project_display_name.clone()).or_default().push(s);
}
for (i, proj) in projects.projects.iter().enumerate() {
let cache_hit = if proj.tokens.context_tokens() > 0 {
proj.tokens.cache_read_tokens as f64 / proj.tokens.context_tokens() as f64 * 100.0
} else { 0.0 };
let pid = format!("{}-p{}", pfx, i);
// Level 1: Project row (expandable)
let hit_bar = html_progress(cache_hit);
let turns_display = if proj.agent_turns > 0 {
format!("{} <span class=\"agent-badge\">+{} agent</span>",
format_number(proj.total_turns as u64), proj.agent_turns)
} else {
format_number(proj.total_turns as u64)
};
writeln!(out, r#"<tr class="project-row expandable">"#).unwrap();
writeln!(out, r#"<td class="text-left"><button class="expand-btn" onclick="toggleProject(this,'{pid}')">{arrow}</button></td>"#,
pid = pid, arrow = "\u{25b6}").unwrap();
writeln!(out, "\
<td class=\"text-left\"><strong>{name}</strong></td>\
<td data-value=\"{sess}\">{sess_fmt}</td>\
<td class=\"turn-count-cell\" data-value=\"{turns}\">{turns_display}</td>\
<td data-value=\"{out}\">{out_fmt}</td>\
<td data-value=\"{hit:.1}\">{hit_bar}</td>\
<td class=\"text-left\"></td>\
<td data-value=\"{cost:.4}\">{cost_fmt}</td>",
name = escape_html(&proj.display_name),
sess = proj.session_count, sess_fmt = format_number(proj.session_count as u64),
turns = proj.total_turns, turns_display = turns_display,
out = proj.tokens.output_tokens, out_fmt = format_compact(proj.tokens.output_tokens),
hit = cache_hit, hit_bar = hit_bar,
cost = proj.cost, cost_fmt = format_cost(proj.cost),
).unwrap();
writeln!(out, "</tr>").unwrap();
// Level 2: Session rows (hidden by default, belong to this project)
if let Some(proj_sessions) = sessions_by_project.get(&proj.display_name) {
let mut sorted = proj_sessions.clone();
sorted.sort_by(|a, b| b.cost.partial_cmp(&a.cost).unwrap_or(std::cmp::Ordering::Equal));
for s in sorted.iter().filter(|s| s.turn_count > 0) {
let utc_iso = s.first_timestamp.map(|t| t.to_rfc3339()).unwrap_or_default();
let date_fallback = s.first_timestamp.map(|t| t.format("%m-%d %H:%M").to_string()).unwrap_or_default();
let s_hit = html_progress(s.cache_hit_rate);
// Session summary row
writeln!(out, r#"<tr class="project-session-row project-sessions-{pid} expandable" style="display:none">"#,
pid = pid).unwrap();
let has_detail = s.turn_details.is_some();
if has_detail {
writeln!(out, r#"<td class="text-left"><button class="expand-btn" onclick="toggleSession(this)">{}</button></td>"#, "\u{25b6}").unwrap();
} else {
writeln!(out, r#"<td class="text-left"></td>"#).unwrap();
}
// Turns with agent badge
let s_turns_display = if s.agent_turn_count > 0 {
format!("{} <span class=\"agent-badge\">+{} agent</span>",
format_number(s.turn_count as u64), s.agent_turn_count)
} else {
format_number(s.turn_count as u64)
};
// Top tools as tags
let tools_html: String = s.top_tools.iter().take(5).map(|(name, count)| {
format!("<span class=\"tool-tag\">{} <span class=\"tool-count\">{}</span></span>",
escape_html(name), count)
}).collect::<Vec<_>>().join("");
let duration_str = format_duration(s.duration_minutes);
let short_sid = &s.session_id[..s.session_id.len().min(10)];
writeln!(out, "\
<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> · {dur})</span></td>\
<td></td>\
<td class=\"turn-count-cell\" data-value=\"{turns}\">{turns_display}</td>\
<td data-value=\"{out}\">{out_fmt}</td>\
<td data-value=\"{hit:.1}\">{hit_bar}</td>\
<td class=\"text-left session-tools-cell\">{tools}</td>\
<td data-value=\"{cost:.4}\">{cost_fmt}</td>",
sid = escape_html(short_sid),
utc = utc_iso,
date = date_fallback,
dur = duration_str,
turns = s.turn_count, turns_display = s_turns_display,
out = s.output_tokens, out_fmt = format_compact(s.output_tokens),
hit = s.cache_hit_rate, hit_bar = s_hit,
tools = tools_html,
cost = s.cost, cost_fmt = format_cost(s.cost),
).unwrap();
writeln!(out, "</tr>").unwrap();
// Level 3: Turn detail (hidden, shown when session is expanded)
if let Some(ref details) = s.turn_details {
writeln!(out, r#"<tr class="session-detail project-sessions-{pid}" style="display:none"><td colspan="8"><div class="detail-content">"#,
pid = pid).unwrap();
render_turn_detail_table(out, details, &format!("{}-detail-proj-{}", pfx, escape_html(&s.session_id)));
writeln!(out, "</div></td></tr>").unwrap();
}
}
}
}
writeln!(out, "</tbody></table></div></div>").unwrap();
}
// โโโ Turn Detail Sub-table โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
fn render_turn_detail_table(out: &mut String, turns: &[crate::analysis::TurnDetail], table_id: &str) {
render_turn_table_impl(out, turns, table_id);
}
// โโโ KPI Card Helper โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
fn write_kpi(out: &mut String, value: &str, label: &str) {
writeln!(out, r#"<div class="card" style="text-align:center;"><div class="kpi-value">{}</div><div class="kpi-label">{}</div></div>"#,
value, label).unwrap();
}
/// KPI card with bilingual label.
fn write_kpi_i18n(out: &mut String, value: &str, en: &str, zh: &str) {
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>"#,
value, en, zh, en).unwrap();
}
/// Render a progress bar inline for table cells.
fn html_progress(pct: f64) -> String {
let bar_color = if pct >= 90.0 { "#22c55e" } else if pct >= 70.0 { "#f59e0b" } else { "#ef4444" };
format!(r#"<div class="progress-bar"><div class="progress-fill" style="width:{:.1}%;background:{};"></div></div><span class="progress-text">{:.1}%</span>"#,
pct, bar_color, pct)
}
// โโโ 2. Session Report โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
/// Generate a detailed HTML report for a single session.
pub fn render_session_html(result: &SessionResult) -> String {
let mut out = String::with_capacity(64 * 1024);
let short_id = &result.session_id[..result.session_id.len().min(12)];
// โโ HTML head โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
write!(out, r#"<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Session {short_id} - Claude Code Token Analyzer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>{js_head}</script>
<style>{css}</style>
</head>
<body>
"#, short_id = escape_html(short_id), css = css(), js_head = js_head()).unwrap();
// Header
writeln!(out, r#"<div class="header-row">"#).unwrap();
writeln!(out, "<h1>Session Analysis</h1>").unwrap();
writeln!(out, r#"<button class="theme-btn" onclick="toggleTheme()" style="margin-left:auto;">☀️</button>"#).unwrap();
writeln!(out, r#"<span class="subtitle">{} · {}</span>"#,
escape_html(&result.session_id), escape_html(&result.project)).unwrap();
writeln!(out, "</div>").unwrap();
// โโ KPI cards โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let cache_hit_rate = {
let total_ctx = result.total_tokens.context_tokens();
if total_ctx > 0 {
result.total_tokens.cache_read_tokens as f64 / total_ctx as f64 * 100.0
} else { 0.0 }
};
writeln!(out, r#"<div class="kpi-grid">"#).unwrap();
write_kpi(&mut out, &format_duration(result.duration_minutes), "Duration");
write_kpi(&mut out, &short_model(&result.model), "Model");
write_kpi(&mut out, &format_number(result.max_context), "Max Context");
write_kpi(&mut out, &format!("{:.1}%", cache_hit_rate), "Cache Hit Rate");
write_kpi(&mut out, &format_number(result.compaction_count as u64), "Compactions");
write_kpi(&mut out, &format_cost(result.total_cost), "Total Cost");
writeln!(out, "</div>").unwrap();
// โโ Charts (Context Growth + Cache Hit Rate) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
if !result.turn_details.is_empty() {
writeln!(out, r#"<div class="grid-2">"#).unwrap();
// Context Growth Line Chart
{
writeln!(out, r#"<div class="card">"#).unwrap();
writeln!(out, "<h2>Context Growth</h2>").unwrap();
writeln!(out, r#"<div class="chart-container"><canvas id="contextChart"></canvas></div>"#).unwrap();
let turn_nums: Vec<String> = result.turn_details.iter().map(|t| t.turn_number.to_string()).collect();
let ctx_sizes: Vec<String> = result.turn_details.iter().map(|t| t.context_size.to_string()).collect();
let pr = if result.turn_details.len() > 50 { 0 } else { 3 };
writeln!(out, r#"<script>
new Chart(document.getElementById('contextChart'), {{
type: 'line',
data: {{
labels: [{turns}],
datasets: [{{
label: 'Context Size',
data: [{sizes}],
borderColor: '#3b82f6',
backgroundColor: 'rgba(59,130,246,0.1)',
fill: true, tension: 0.3, pointRadius: {pr}
}}]
}},
options: {{
responsive: true, maintainAspectRatio: false,
plugins: {{ legend: {{ labels: {{ color: '#fafafa' }} }} }},
scales: {{
x: {{ title: {{ display: true, text: 'Turn', color: '#a1a1aa' }}, ticks: {{ color: '#a1a1aa' }}, grid: {{ color: '#27272a' }} }},
y: {{ title: {{ display: true, text: 'Context Tokens', color: '#a1a1aa' }}, ticks: {{ color: '#a1a1aa' }}, grid: {{ color: '#27272a' }} }}
}}
}}
}});
</script>"#,
turns = turn_nums.join(","),
sizes = ctx_sizes.join(","),
pr = pr,
).unwrap();
writeln!(out, "</div>").unwrap();
}
// Cache Hit Rate Line Chart
{
writeln!(out, r#"<div class="card">"#).unwrap();
writeln!(out, "<h2>Cache Hit Rate</h2>").unwrap();
writeln!(out, r#"<div class="chart-container"><canvas id="cacheChart"></canvas></div>"#).unwrap();
let turn_nums: Vec<String> = result.turn_details.iter().map(|t| t.turn_number.to_string()).collect();
let cache_rates: Vec<String> = result.turn_details.iter().map(|t| format!("{:.2}", t.cache_hit_rate)).collect();
let pr = if result.turn_details.len() > 50 { 0 } else { 3 };
writeln!(out, r#"<script>
new Chart(document.getElementById('cacheChart'), {{
type: 'line',
data: {{
labels: [{turns}],
datasets: [{{
label: 'Cache Hit Rate (%)',
data: [{rates}],
borderColor: '#f59e0b',
backgroundColor: 'rgba(245,158,11,0.1)',
fill: true, tension: 0.3, pointRadius: {pr}
}}]
}},
options: {{
responsive: true, maintainAspectRatio: false,
plugins: {{ legend: {{ labels: {{ color: '#fafafa' }} }} }},
scales: {{
x: {{ title: {{ display: true, text: 'Turn', color: '#a1a1aa' }}, ticks: {{ color: '#a1a1aa' }}, grid: {{ color: '#27272a' }} }},
y: {{ title: {{ display: true, text: 'Hit Rate (%)', color: '#a1a1aa' }}, ticks: {{ color: '#a1a1aa' }}, grid: {{ color: '#27272a' }}, min: 0, max: 100 }}
}}
}}
}});
</script>"#,
turns = turn_nums.join(","),
rates = cache_rates.join(","),
pr = pr,
).unwrap();
writeln!(out, "</div>").unwrap();
}
writeln!(out, "</div>").unwrap(); // close grid-2
}
// โโ Stop Reason Doughnut โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
if !result.stop_reason_counts.is_empty() {
writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
writeln!(out, "<h2>Stop Reason Distribution</h2>").unwrap();
writeln!(out, r#"<div class="chart-container" style="max-width:400px;margin:0 auto;"><canvas id="stopReasonChart"></canvas></div>"#).unwrap();
let mut reasons: Vec<(&String, &usize)> = result.stop_reason_counts.iter().collect();
reasons.sort_by(|a, b| b.1.cmp(a.1));
let labels: Vec<String> = reasons.iter().map(|(r, _)| format!("\"{}\"", escape_html(r))).collect();
let data: Vec<String> = reasons.iter().map(|(_, c)| c.to_string()).collect();
let colors_list: Vec<String> = (0..reasons.len()).map(|i| format!("\"{}\"", color(i))).collect();
writeln!(out, r#"<script>
new Chart(document.getElementById('stopReasonChart'), {{
type: 'doughnut',
data: {{
labels: [{labels}],
datasets: [{{ data: [{data}], backgroundColor: [{colors}], borderWidth: 0 }}]
}},
options: {{
responsive: true, maintainAspectRatio: false,
plugins: {{ legend: {{ position: 'bottom', labels: {{ color: '#fafafa' }} }} }}
}}
}});
</script>"#,
labels = labels.join(","), data = data.join(","), colors = colors_list.join(",")).unwrap();
writeln!(out, "</div>").unwrap();
}
// โโ Turn Detail Table โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
writeln!(out, r#"<div class="card" style="margin-top:16px;">"#).unwrap();
writeln!(out, "<h2>Turn Details</h2>").unwrap();
writeln!(out, r#"<div class="table-wrap">"#).unwrap();
render_turn_table_impl(&mut out, &result.turn_details, "tbl-session-turns");
writeln!(out, "</div></div>").unwrap();
// โโ JavaScript โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
write!(out, "<script>{}</script>", js_common()).unwrap();
writeln!(out, "</body>\n</html>").unwrap();
out
}
/// Shared turn detail table -- used by both expandable session detail and single session report.
fn render_turn_table_impl(out: &mut String, turns: &[crate::analysis::TurnDetail], table_id: &str) {
writeln!(out, r#"<table id="{}" style="font-size:12px;">"#, table_id).unwrap();
writeln!(out, "<thead><tr>\
<th onclick=\"sortTableSimple(this,'{id}')\">Turn</th>\
<th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Time\" data-zh=\"ๆถ้ด\">Time</th>\
<th class=\"text-left\" data-en=\"Model\" data-zh=\"ๆจกๅ\">Model</th>\
<th class=\"text-left\" data-en=\"User\" data-zh=\"็จๆท\">User</th>\
<th class=\"text-left\" data-en=\"Assistant\" data-zh=\"ๅฉๆ\">Assistant</th>\
<th class=\"text-left\" data-en=\"Tools\" data-zh=\"ๅทฅๅ
ท\">Tools</th>\
<th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Output\" data-zh=\"่พๅบ\">Output</th>\
<th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Context\" data-zh=\"ไธไธๆ\">Context</th>\
<th onclick=\"sortTableSimple(this,'{id}')\">Hit%</th>\
<th onclick=\"sortTableSimple(this,'{id}')\" data-en=\"Cost\" data-zh=\"่ดน็จ\">Cost</th>\
<th class=\"text-left\">Stop</th>\
<th class=\"text-left\">\u{26a1}</th>\
</tr></thead>", id = table_id).unwrap();
writeln!(out, "<tbody>").unwrap();
for t in turns {
let row_class = if t.is_compaction {
" class=\"compact-row\""
} else if t.is_agent {
" style=\"border-left:2px solid var(--text-accent);\""
} else {
""
};
let stop = t.stop_reason.as_deref().unwrap_or("-");
let compact_mark = if t.is_compaction { "\u{26a1}" } else if t.is_agent { "\u{1f916}" } else { "" };
let user_text = t.user_text.as_deref().unwrap_or("");
let user_preview = if user_text.len() > 80 {
format!("{}...", &user_text[..user_text.floor_char_boundary(80)])
} else {
user_text.to_string()
};
let asst_text = t.assistant_text.as_deref().unwrap_or("");
let asst_preview = if asst_text.len() > 80 {
format!("{}...", &asst_text[..asst_text.floor_char_boundary(80)])
} else {
asst_text.to_string()
};
// Tools as tags instead of plain text
let tools_html: String = if t.tool_names.is_empty() {
String::new()
} else {
t.tool_names.iter().map(|name| {
format!("<span class=\"tool-tag\">{}</span>", escape_html(name))
}).collect::<Vec<_>>().join("")
};
let hit_bar = html_progress(t.cache_hit_rate);
let model_short = short_model(&t.model);
let utc_iso = t.timestamp.to_rfc3339();
let time_fallback = t.timestamp.format("%H:%M:%S").to_string();
writeln!(out, "<tr{cls}>\
<td data-value=\"{turn}\">{turn}</td>\
<td><span data-utc=\"{utc}\">{time}</span></td>\
<td class=\"text-left\">{model}</td>\
<td class=\"text-left\" style=\"max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;\" title=\"{user_full}\">{user}</td>\
<td class=\"text-left\" style=\"max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;\" title=\"{asst_full}\">{asst}</td>\
<td class=\"text-left\" style=\"max-width:160px;line-height:1.6;\">{tools}</td>\
<td data-value=\"{out_val}\">{out_fmt}</td>\
<td data-value=\"{ctx_val}\">{ctx_fmt}</td>\
<td data-value=\"{hit:.1}\">{hit_bar}</td>\
<td data-value=\"{cost:.6}\">{cost_fmt}</td>\
<td class=\"text-left\">{stop}</td>\
<td class=\"text-left\">{compact}</td>\
</tr>",
cls = row_class,
turn = t.turn_number,
utc = utc_iso,
time = time_fallback,
model = model_short,
user_full = escape_html(user_text),
user = escape_html(&user_preview),
asst_full = escape_html(asst_text),
asst = escape_html(&asst_preview),
tools = tools_html,
out_val = t.output_tokens, out_fmt = format_compact(t.output_tokens),
ctx_val = t.context_size, ctx_fmt = format_compact(t.context_size),
hit = t.cache_hit_rate, hit_bar = hit_bar,
cost = t.cost, cost_fmt = format_cost(t.cost),
stop = escape_html(stop),
compact = compact_mark,
).unwrap();
}
writeln!(out, "</tbody></table>").unwrap();
}