use serde::{Deserialize, Serialize};
use std::fs;
use std::io::BufRead;
use std::path::PathBuf;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct StatsRecord {
pub ts: String,
#[serde(default)]
pub agent: String,
#[serde(default)]
pub project: String,
pub file: String,
#[allow(dead_code)]
pub format: String,
pub fidelity: String,
pub input_tok: usize,
pub output_tok: usize,
pub reduction_pct: f64,
pub saved: usize,
}
pub fn cmd_stats_report(
days: u32,
agent_filter: Option<String>,
project_filter: Option<String>,
out: &str,
no_open: bool,
) -> i32 {
let days = days.min(90);
let home = match std::env::var("HOME") {
Ok(h) => h,
Err(_) => {
eprintln!("error: HOME environment variable not set");
return 1;
}
};
let stats_dir = PathBuf::from(&home).join(".agents/transpile/stats");
println!("▶ stats report");
println!(" dir : {}", stats_dir.display());
println!(" days : {days}");
let records = load_stats(&stats_dir, days);
let mut filtered = records;
if let Some(ref a) = agent_filter {
filtered.retain(|r| r.agent == *a);
}
if let Some(ref p) = project_filter {
filtered.retain(|r| r.project == *p);
}
if filtered.is_empty() {
eprintln!("ERROR: no stats found for the given range. Run transpile on some files first.");
return 1;
}
println!(" records : {}", filtered.len());
let out = expand_tilde(out);
if let Some(parent) = std::path::Path::new(&out).parent()
&& !parent.as_os_str().is_empty()
&& let Err(e) = fs::create_dir_all(parent)
{
eprintln!("ERROR: cannot create directory {}: {e}", parent.display());
return 1;
}
generate_html(&filtered, &out);
println!(" ✓ {out}");
if !no_open {
let _ = std::process::Command::new("open").arg(&out).spawn();
}
0
}
fn load_stats(stats_dir: &std::path::Path, days: u32) -> Vec<StatsRecord> {
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let today_days = now_secs / 86400;
let mut all: Vec<StatsRecord> = Vec::new();
for offset in 0..days as u64 {
let day = today_days.saturating_sub(offset);
let (y, m, d) = epoch_days_to_ymd(day);
let date_str = format!("{y:04}-{m:02}-{d:02}");
let path = stats_dir.join(format!("{date_str}.jsonl"));
if let Ok(f) = fs::File::open(&path) {
for line in std::io::BufReader::new(f).lines().map_while(|r| r.ok()) {
if let Ok(rec) = serde_json::from_str::<StatsRecord>(&line) {
all.push(rec);
}
}
}
}
all
}
fn epoch_days_to_ymd(days: u64) -> (u64, u64, u64) {
let z = days + 719_468;
let era = z / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
fn esc(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn js_safe(json: &str) -> String {
json.replace("</", "<\\/")
.replace('\u{2028}', "\\u2028")
.replace('\u{2029}', "\\u2029")
}
fn expand_tilde(path: &str) -> String {
if let Some(rest) = path.strip_prefix("~/")
&& let Ok(home) = std::env::var("HOME")
{
return format!("{home}/{rest}");
}
path.to_string()
}
fn generate_html(records: &[StatsRecord], out_path: &str) {
let records_json = js_safe(&serde_json::to_string(records).unwrap());
let mut projects: Vec<String> = records
.iter()
.map(|r| {
if r.project.is_empty() {
"unknown".to_string()
} else {
r.project.clone()
}
})
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
projects.sort();
let mut agents: Vec<String> = records
.iter()
.map(|r| {
if r.agent.is_empty() {
"unknown".to_string()
} else {
r.agent.clone()
}
})
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
agents.sort();
let project_options = projects
.iter()
.map(|p| format!("<option>{}</option>", esc(p)))
.collect::<Vec<_>>()
.join("");
let agent_options = agents
.iter()
.map(|a| format!("<option>{}</option>", esc(a)))
.collect::<Vec<_>>()
.join("");
let html = format!(
r##"<!DOCTYPE html>
<html lang="en" data-lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>stats — llm-transpile usage report</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js" integrity="sha384-e6cc9LaIG7xZ3XD5B+jtr1NhTWPQGQdRCh6xiZ+ZFUtWCpg4ycv3Sh+SkZoopvUY" crossorigin="anonymous"></script>
<style>
:root{{--bg:#0f1117;--surf:#1a1d27;--bdr:#2e3147;--txt:#e2e8f0;--mut:#8892a4;
--acc:#6366f1;--grn:#22c55e;--ylw:#eab308;--red:#ef4444;--thead:#1e2235;}}
:root[data-theme="light"]{{--bg:#f5f5f7;--surf:#ffffff;--bdr:#d1d5db;--txt:#1e293b;--mut:#374151;
--acc:#4f46e5;--grn:#16a34a;--ylw:#ca8a04;--red:#dc2626;--thead:#f1f5f9;}}
*{{box-sizing:border-box;margin:0;padding:0;}}
body{{background:var(--bg);color:var(--txt);font-family:system-ui,sans-serif;font-size:14px;}}
header{{padding:16px 28px;border-bottom:1px solid var(--bdr);display:flex;align-items:center;gap:12px;flex-wrap:wrap;}}
header h1{{font-size:20px;font-weight:700;}}
.badge{{background:var(--acc);color:#fff;font-size:11px;padding:2px 8px;border-radius:99px;font-weight:600;}}
.lang-btn{{background:transparent;border:1px solid var(--bdr);color:var(--mut);
border-radius:7px;padding:4px 12px;font-size:12px;cursor:pointer;font-weight:600;transition:all .15s;}}
.lang-btn:hover{{border-color:var(--acc);color:var(--txt);}}
.hdr-actions{{display:flex;gap:6px;flex-shrink:0;}}
.hdr-meta{{font-size:12px;color:var(--mut);}}
.filters{{background:var(--surf);border-bottom:1px solid var(--bdr);padding:10px 28px;
display:flex;gap:10px;flex-wrap:wrap;align-items:center;position:sticky;top:0;z-index:50;}}
.filters label{{font-size:11px;color:var(--mut);text-transform:uppercase;letter-spacing:.3px;font-weight:600;}}
.preset{{background:var(--surf);border:1px solid var(--bdr);color:var(--mut);
border-radius:7px;padding:4px 10px;font-size:11px;cursor:pointer;font-weight:600;transition:all .15s;}}
.preset:hover{{border-color:var(--acc);color:var(--txt);}}
.preset.active{{background:var(--acc);border-color:var(--acc);color:#fff;}}
.sep{{width:1px;height:20px;background:var(--bdr);flex-shrink:0;}}
/* ── Date range picker ── */
.dr{{position:relative;display:inline-block;}}
.dr-input{{background:var(--surf);border:1px solid var(--bdr);border-radius:7px;
padding:4px 10px 4px 28px;font-size:12px;color:var(--txt);cursor:pointer;font-family:inherit;
white-space:nowrap;position:relative;}}
.dr-input::before{{content:"📅";position:absolute;left:8px;top:50%;transform:translateY(-50%);font-size:13px;}}
.dr-input:hover{{border-color:var(--acc);}}
.dr-input.picking{{border-color:var(--acc);box-shadow:0 0 0 2px rgba(99,102,241,.25);}}
.dr-cal{{display:none;position:absolute;top:calc(100% + 4px);left:0;z-index:99;
background:var(--surf);border:1px solid var(--bdr);border-radius:10px;padding:14px;
box-shadow:0 8px 24px rgba(0,0,0,.25);user-select:none;min-width:280px;}}
.dr-cal.open{{display:block;}}
.dr-hdr{{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;}}
.dr-hdr span{{font-size:13px;font-weight:600;min-width:120px;text-align:center;}}
.dr-hdr button{{background:transparent;border:1px solid var(--bdr);color:var(--mut);
border-radius:5px;width:26px;height:26px;cursor:pointer;font-size:14px;display:flex;align-items:center;justify-content:center;}}
.dr-hdr button:hover{{border-color:var(--acc);color:var(--txt);}}
.dr-hint{{font-size:10px;color:var(--acc);text-align:center;margin-bottom:8px;min-height:14px;}}
.dr-grid{{display:grid;grid-template-columns:repeat(7,1fr);gap:2px;text-align:center;}}
.dr-grid .dh{{font-size:10px;color:var(--mut);font-weight:600;padding:4px 0;}}
.dr-grid .dc{{font-size:12px;padding:5px 0;border-radius:5px;cursor:pointer;transition:background .1s;}}
.dr-grid .dc:hover{{background:rgba(99,102,241,.15);}}
.dr-grid .dc.today{{font-weight:700;}}
.dr-grid .dc.sel-start{{background:var(--acc);color:#fff;border-radius:5px 0 0 5px;}}
.dr-grid .dc.sel-end{{background:var(--acc);color:#fff;border-radius:0 5px 5px 0;}}
.dr-grid .dc.sel-start.sel-end{{border-radius:5px;}}
.dr-grid .dc.range{{background:rgba(99,102,241,.12);border-radius:0;}}
.dr-grid .dc.preview{{background:rgba(99,102,241,.08);border-radius:0;}}
.dr-grid .dc.out{{color:var(--mut);opacity:.4;cursor:default;}}
.dr-grid .dc.out:hover{{background:transparent;}}
.dr-footer{{display:flex;justify-content:flex-end;gap:6px;margin-top:10px;border-top:1px solid var(--bdr);padding-top:10px;}}
.dr-footer button{{font-size:11px;padding:4px 10px;}}
.dr-footer .btn-clear{{background:transparent;border:1px solid var(--bdr);color:var(--mut);}}
.dr-footer .btn-clear:hover{{border-color:var(--red);color:var(--red);opacity:1;}}
.wrap{{max-width:1400px;margin:0 auto;padding:20px 28px;}}
.cards{{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:14px;margin-bottom:28px;}}
.card{{background:var(--surf);border:1px solid var(--bdr);border-radius:10px;padding:18px;cursor:help;}}
.card .lbl{{font-size:11px;color:var(--mut);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px;}}
.card .val{{font-size:26px;font-weight:700;}}
.card .sub{{font-size:11px;color:var(--mut);margin-top:3px;}}
.charts{{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:28px;}}
@media(max-width:1200px){{.charts{{grid-template-columns:1fr 1fr;gap:14px;}}}}
@media(max-width:768px){{
.charts{{grid-template-columns:1fr;gap:12px;}}
.cards{{grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;}}
header{{padding:12px 16px;flex-wrap:wrap;}}
header h1{{font-size:16px;}}
.hdr-meta{{display:none;}}
.filters{{padding:8px 16px;gap:6px;}}
.wrap{{padding:14px 16px;}}
.cbox{{padding:14px;}}
.cbox canvas{{max-height:200px;}}
table{{font-size:12px;}}
th,td{{padding:5px 6px;}}
}}
@media(max-width:480px){{
.cards{{grid-template-columns:1fr 1fr;}}
.card .val{{font-size:22px;}}
.filters{{flex-direction:column;align-items:stretch;gap:6px;}}
.filters label{{display:none;}}
.sep{{display:none;}}
header h1{{font-size:14px;}}
.badge{{font-size:10px;padding:2px 6px;}}
table{{display:block;overflow-x:auto;}}
}}
.cbox{{background:var(--surf);border:1px solid var(--bdr);border-radius:10px;padding:18px;}}
.cbox h3{{font-size:11px;font-weight:600;color:var(--mut);text-transform:uppercase;letter-spacing:.5px;margin-bottom:14px;}}
.cbox canvas{{max-height:240px;}}
section{{margin-bottom:28px;}}
section h2{{font-size:15px;font-weight:600;margin-bottom:12px;padding-bottom:7px;border-bottom:1px solid var(--bdr);}}
table{{width:100%;border-collapse:collapse;background:var(--surf);border-radius:10px;overflow:hidden;border:1px solid var(--bdr);}}
thead tr{{background:var(--thead);}}
th{{padding:9px 12px;text-align:left;font-size:11px;font-weight:600;color:var(--mut);
text-transform:uppercase;letter-spacing:.5px;white-space:nowrap;}}
td{{padding:8px 12px;border-top:1px solid var(--bdr);font-size:13px;white-space:nowrap;}}
td.n{{text-align:right;font-variant-numeric:tabular-nums;}}
tr:hover td{{background:rgba(255,255,255,.02);}}
.good{{color:var(--grn);}} .ok{{color:var(--ylw);}} .low{{color:var(--red);}}
input[type=text],input[type=date],select{{background:var(--surf);border:1px solid var(--bdr);border-radius:7px;
padding:5px 10px;color:var(--txt);font-size:13px;outline:none;}}
input:focus,select:focus{{border-color:var(--acc);}}
button{{background:var(--acc);border:none;color:#fff;border-radius:7px;
padding:5px 12px;font-size:13px;cursor:pointer;font-weight:600;}}
button:hover{{opacity:.85;}}
.legend{{display:flex;gap:16px;flex-wrap:wrap;margin-bottom:10px;font-size:12px;color:var(--mut);}}
.legend span{{display:flex;align-items:center;gap:5px;}}
.dot{{width:10px;height:10px;border-radius:50%;display:inline-block;}}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>stats</h1>
<span class="badge">llm-transpile</span>
<span class="hdr-meta" id="hdrMeta"></span>
<span class="hdr-actions">
<button class="lang-btn" onclick="toggleLang()" id="langBtn">한국어</button>
<button class="lang-btn" onclick="toggleTheme()" id="themeBtn">☀</button>
</span>
</header>
<!-- ── Global filters ── -->
<div class="filters" style="margin-bottom:18px;">
<button class="preset" data-days="1" onclick="setPreset(this)" data-i18n="preset_today">Today</button>
<button class="preset active" data-days="7" onclick="setPreset(this)">1W</button>
<button class="preset" data-days="14" onclick="setPreset(this)">2W</button>
<button class="preset" data-days="30" onclick="setPreset(this)">1M</button>
<button class="preset" data-days="90" onclick="setPreset(this)">90D</button>
<span class="sep"></span>
<div class="dr">
<input type="hidden" id="ffrom">
<input type="hidden" id="fto">
<div class="dr-input" id="drDisplay" onclick="toggleCal();event.stopPropagation()">Date range</div>
<div class="dr-cal" id="drCal" onclick="event.stopPropagation()">
<div class="dr-hdr">
<button onclick="calNav(-1)">◀</button>
<span id="calTitle"></span>
<button onclick="calNav(1)">▶</button>
</div>
<div class="dr-hint" id="drHint" data-i18n="dr_hint_start"></div>
<div class="dr-grid" id="calGrid"></div>
<div class="dr-footer">
<button class="btn-clear" onclick="clearDateRange()" data-i18n="dr_clear"></button>
<button onclick="closeCal()" data-i18n="dr_apply"></button>
</div>
</div>
</div>
<span class="sep"></span>
<select id="fproj" onchange="applyFilter()">
<option value="" data-i18n="all_projects">All projects</option>
{project_options}
</select>
<select id="fagent" onchange="applyFilter()">
<option value="" data-i18n="all_agents">All agents</option>
{agent_options}
</select>
<button onclick="exportCsv()" data-i18n="btn_csv">⬇ CSV</button>
</div>
<!-- ── KPI Cards ── -->
<div class="cards" id="kpiCards"></div>
<!-- ── Legend ── -->
<div class="legend" style="margin-bottom:18px;">
<span><span class="dot" style="background:var(--grn)"></span><span data-i18n="legend_good">≥20% — good</span></span>
<span><span class="dot" style="background:var(--ylw)"></span><span data-i18n="legend_ok">10–20% — ok</span></span>
<span><span class="dot" style="background:var(--red)"></span><span data-i18n="legend_low"><10% — low</span></span>
</div>
<!-- ── Charts ── -->
<div class="charts">
<div class="cbox"><h3 data-i18n="chart_daily_title">Daily Token Usage</h3><canvas id="dailyChart"></canvas></div>
<div class="cbox"><h3 data-i18n="chart_fidelity_title">Daily Reduction by Fidelity (%)</h3><canvas id="fidelityChart"></canvas></div>
<div class="cbox"><h3 data-i18n="chart_tok_trend_title">Daily Input vs Output Tokens</h3><canvas id="tokTrendChart"></canvas></div>
<div class="cbox"><h3 data-i18n="chart_agent_title">Calls by Agent</h3><canvas id="agentChart"></canvas></div>
<div class="cbox"><h3 data-i18n="chart_hourly_title">Hourly Pattern</h3><canvas id="hourlyChart"></canvas></div>
<div class="cbox"><h3 data-i18n="chart_hist_title">Reduction Distribution</h3><canvas id="histChart"></canvas></div>
<div class="cbox"><h3 data-i18n="chart_project_title">Calls by Project</h3><canvas id="projectChart"></canvas></div>
</div>
<!-- ── Daily summary ── -->
<section>
<h2 data-i18n="sec_daily">Daily Summary</h2>
<table><thead><tr>
<th data-i18n="col_date">date</th><th data-i18n="col_calls">calls</th>
<th data-i18n="col_input">input tok</th><th data-i18n="col_saved">saved</th><th>reduction</th>
</tr></thead><tbody id="dailyTbody"></tbody></table>
</section>
<!-- ── All records ── -->
<section>
<h2 data-i18n="sec_records">All Records</h2>
<div style="display:flex;gap:8px;margin-bottom:12px;align-items:center;">
<input type="text" id="ftxt" placeholder="Filter file…">
</div>
<table id="tbl"><thead><tr>
<th data-i18n="col_date">date</th><th data-i18n="col_project">project</th>
<th data-i18n="col_agent">agent</th><th data-i18n="col_file">file</th>
<th data-i18n="col_fidelity">fidelity</th><th data-i18n="col_intok">in tok</th>
<th>reduction</th><th data-i18n="col_saved">saved</th>
</tr></thead><tbody id="tbody"></tbody></table>
</section>
</div>
<script>
// ── Raw data ─────────────────────────────────────────────────────────────────
const ALL={records_json};
// ── i18n ─────────────────────────────────────────────────────────────────────
const I18N = {{
en: {{
lbl_from:'From',lbl_to:'To',
dr_placeholder:'Date range',dr_hint_start:'Select start date',dr_hint_end:'Select end date',
dr_clear:'Reset',dr_apply:'Apply',
preset_today:'Today',
kpi_calls:'Total Calls',kpi_saved:'Tokens Saved',kpi_reduction:'Avg Reduction',
kpi_files:'Unique Files',kpi_agents:'Agents',kpi_days:'Active Days',
kpi_period:'in selected period',kpi_vs_input:'vs input',
kpi_across:'across all files',kpi_processed:'processed',
kpi_active:'active',kpi_in_range:'in range',
legend_good:'≥20% — good',legend_ok:'10–20% — ok',legend_low:'<10% — low',
chart_daily_title:'Daily Token Usage (input vs saved)',
chart_fidelity_title:'Daily Reduction by Fidelity (%)',
chart_tok_trend_title:'Daily Input vs Output Tokens',
chart_agent_title:'Calls by Agent',
chart_hourly_title:'Hourly Usage Pattern',
chart_hist_title:'Reduction % Distribution',
chart_project_title:'Calls by Project',
sec_daily:'Daily Summary',sec_records:'All Records',
col_date:'date',col_calls:'calls',col_input:'input tok',col_saved:'saved',
col_project:'project',col_agent:'agent',col_file:'file',
col_fidelity:'fidelity',col_intok:'in tok',
all_projects:'All projects',all_agents:'All agents',btn_csv:'⬇ CSV',
hdr_meta:'{{n}} calls · {{d}} days',
}},
ko: {{
lbl_from:'시작',lbl_to:'종료',
dr_placeholder:'날짜 범위',dr_hint_start:'시작일을 선택하세요',dr_hint_end:'종료일을 선택하세요',
dr_clear:'초기화',dr_apply:'확인',
preset_today:'오늘',
kpi_calls:'총 호출',kpi_saved:'절약 토큰',kpi_reduction:'평균 압축률',
kpi_files:'고유 파일',kpi_agents:'에이전트',kpi_days:'활용 일수',
kpi_period:'선택 기간',kpi_vs_input:'입력 대비',
kpi_across:'전체 파일',kpi_processed:'처리됨',
kpi_active:'활성',kpi_in_range:'범위 내',
legend_good:'≥20% — 양호',legend_ok:'10~20% — 보통',legend_low:'<10% — 낮음',
chart_daily_title:'일별 토큰 사용량 (입력 vs 절약)',
chart_fidelity_title:'일별 충실도별 압축률 (%)',
chart_tok_trend_title:'일별 입력 vs 출력 토큰',
chart_agent_title:'에이전트별 호출 수',
chart_hourly_title:'시간대별 사용 패턴',
chart_hist_title:'압축률 분포',
chart_project_title:'프로젝트별 호출 수',
sec_daily:'일별 요약',sec_records:'전체 기록',
col_date:'날짜',col_calls:'호출',col_input:'입력 토큰',col_saved:'절약',
col_project:'프로젝트',col_agent:'에이전트',col_file:'파일',
col_fidelity:'충실도',col_intok:'입력 토큰',
all_projects:'전체 프로젝트',all_agents:'전체 에이전트',btn_csv:'⬇ CSV 내보내기',
hdr_meta:'{{n}}회 호출 · {{d}}일',
}},
}};
let LANG='en';
function t(k){{return(I18N[LANG]||I18N.en)[k]||k;}}
function applyLang(){{
document.documentElement.setAttribute('lang',LANG);
document.querySelectorAll('[data-i18n]').forEach(el=>{{el.textContent=t(el.getAttribute('data-i18n'));}});
document.getElementById('langBtn').textContent=LANG==='en'?'한국어':'English';
}}
function toggleLang(){{LANG=LANG==='en'?'ko':'en';applyLang();render();}}
function toggleTheme(){{
const r=document.documentElement;
const cur=r.getAttribute('data-theme')||'dark';
const next=cur==='dark'?'light':'dark';
r.setAttribute('data-theme',next);
document.getElementById('themeBtn').textContent=next==='dark'?'☀':'🌙';
localStorage.setItem('stats-theme',next);
render();
}}
(function(){{const s=localStorage.getItem('stats-theme');if(s){{document.documentElement.setAttribute('data-theme',s);document.getElementById('themeBtn').textContent=s==='dark'?'☀':'🌙';}}}})();
if(navigator.language&&navigator.language.startsWith('ko')){{LANG='ko';}}
applyLang();
// ── Helpers ──────────────────────────────────────────────────────────────────
function fmtDate(d){{return d.toISOString().substring(0,10);}}
function parseD(s){{const p=s.split('-');return new Date(+p[0],+p[1]-1,+p[2]);}}
function updateDrDisplay(){{
const f=document.getElementById('ffrom').value;
const tv=document.getElementById('fto').value;
const el=document.getElementById('drDisplay');
el.textContent=f&&tv?`${{f}} ~ ${{tv}}`:t('dr_placeholder');
}}
function setPreset(btn){{
document.querySelectorAll('.preset').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');
const days=parseInt(btn.getAttribute('data-days'));
const now=new Date();
const to=fmtDate(now);
const from=new Date(now);from.setDate(from.getDate()-days+1);
document.getElementById('ffrom').value=fmtDate(from);
document.getElementById('fto').value=to;
updateDrDisplay();
applyFilter();
}}
// ── Date range calendar ──────────────────────────────────────────────────────
let calMonth=new Date().getMonth(),calYear=new Date().getFullYear();
let pickPhase=0; // 0=pick start, 1=pick end (start selected, waiting for end)
let hoverDate=null;
function toggleCal(){{
const c=document.getElementById('drCal');
const isOpen=c.classList.contains('open');
if(isOpen){{closeCal();}}else{{
pickPhase=0;hoverDate=null;
document.getElementById('drDisplay').classList.add('picking');
c.classList.add('open');
drawCal();
}}
}}
function closeCal(){{
document.getElementById('drCal').classList.remove('open');
document.getElementById('drDisplay').classList.remove('picking');
pickPhase=0;hoverDate=null;
}}
function clearDateRange(){{
document.getElementById('ffrom').value='';
document.getElementById('fto').value='';
pickPhase=0;hoverDate=null;
updateDrDisplay();
drawCal();
applyFilter();
}}
function calNav(dir){{
calMonth+=dir;
if(calMonth<0){{calMonth=11;calYear--;}}else if(calMonth>11){{calMonth=0;calYear++;}}
drawCal();
}}
function drawCal(){{
const mn=new Intl.DateTimeFormat(LANG==='ko'?'ko':'en',{{month:'long'}});
document.getElementById('calTitle').textContent=`${{calYear}} ${{mn.format(new Date(calYear,calMonth,1))}}`;
document.getElementById('drHint').textContent=pickPhase===0?t('dr_hint_start'):t('dr_hint_end');
const grid=document.getElementById('calGrid');
const dn=['Su','Mo','Tu','We','Th','Fr','Sa'];
let html=dn.map(d=>`<div class="dh">${{d}}</div>`).join('');
const startDay=new Date(calYear,calMonth,1).getDay();
const daysInMonth=new Date(calYear,calMonth+1,0).getDate();
const prevDays=new Date(calYear,calMonth,0).getDate();
const todayStr=fmtDate(new Date());
for(let i=0;i<startDay;i++)html+=`<div class="dc out">${{prevDays-startDay+i+1}}</div>`;
for(let d=1;d<=daysInMonth;d++){{
const ds=`${{String(calYear).padStart(4,'0')}}-${{String(calMonth+1).padStart(2,'0')}}-${{String(d).padStart(2,'0')}}`;
let cls='dc';
if(ds===todayStr)cls+=' today';
html+=`<div class="${{cls}}" data-date="${{ds}}">${{d}}</div>`;
}}
const total=startDay+daysInMonth;
const rem=total%7===0?0:7-total%7;
for(let i=1;i<=rem;i++)html+=`<div class="dc out">${{i}}</div>`;
grid.innerHTML=html;
updateCalStyles();
}}
function updateCalStyles(){{
const from=document.getElementById('ffrom').value;
const to=document.getElementById('fto').value;
// phase 1: 시작일 고정, hover로 end 미리보기
const end=pickPhase===1?(hoverDate||from):to;
const lo=from&&end?(from<=end?from:end):'';
const hi=from&&end?(from<=end?end:from):'';
document.querySelectorAll('#calGrid .dc[data-date]').forEach(el=>{{
const ds=el.getAttribute('data-date');
el.classList.remove('sel-start','sel-end','range','preview');
if(!lo)return;
if(ds===lo&&ds===hi){{el.classList.add('sel-start','sel-end');}}
else if(ds===lo){{el.classList.add('sel-start');}}
else if(ds===hi){{el.classList.add('sel-end');}}
else if(ds>lo&&ds<hi){{el.classList.add(pickPhase===1?'preview':'range');}}
}});
}}
// calGrid 이벤트 위임 — stopPropagation으로 document 핸들러 차단
document.getElementById('calGrid').addEventListener('click',e=>{{
e.stopPropagation();
const dc=e.target.closest('.dc[data-date]');
if(dc)pickDate(dc.getAttribute('data-date'));
}});
document.getElementById('calGrid').addEventListener('mouseover',e=>{{
const dc=e.target.closest('.dc[data-date]');
if(dc&&pickPhase===1){{hoverDate=dc.getAttribute('data-date');updateCalStyles();}}
}});
// 외부 클릭 시 달력 닫기
document.addEventListener('click',e=>{{
if(!e.target.closest('.dr')&&document.getElementById('drCal').classList.contains('open'))closeCal();
}});
function pickDate(ds){{
if(pickPhase===0){{
document.getElementById('ffrom').value=ds;
document.getElementById('fto').value='';
pickPhase=1;
hoverDate=null;
}}else{{
const from=document.getElementById('ffrom').value;
if(ds<from){{document.getElementById('ffrom').value=ds;document.getElementById('fto').value=from;}}
else if(ds===from){{document.getElementById('fto').value=ds;}}
else{{document.getElementById('fto').value=ds;}}
pickPhase=0;
hoverDate=null;
// 달력을 닫지 않음 — 사용자가 확인 버튼 또는 외부 클릭으로 닫음
}}
syncPresets();
updateDrDisplay();
drawCal();
applyFilter();
}}
function syncPresets(){{
const from=document.getElementById('ffrom').value;
const tv=document.getElementById('fto').value;
const now=new Date();const today=fmtDate(now);
document.querySelectorAll('.preset').forEach(b=>{{
const days=parseInt(b.getAttribute('data-days'));
const d=new Date(now);d.setDate(d.getDate()-days+1);
b.classList.toggle('active',fmtDate(d)===from&&today===tv);
}});
}}
const G={{color:'rgba(128,128,128,.08)'}},F={{color:'var(--mut)'}};
function getChartTextColor(){{return getComputedStyle(document.documentElement).getPropertyValue('--mut').trim()||'#8892a4';}}
function getChartGridColor(){{return getComputedStyle(document.documentElement).getPropertyValue('--bdr').trim()||'rgba(128,128,128,.15)';}}
const AC=['#6366f1','#22c55e','#eab308','#ef4444','#06b6d4','#f97316','#a855f7','#ec4899'];
function pcls(p){{return p>=20?'good':p>=10?'ok':'low';}}
function esc(s){{const d=document.createElement('div');d.textContent=s;return d.innerHTML;}}
function fmt(n){{return n.toString().replace(/\B(?=(\d{{3}})+(?!\d))/g,' ');}}
function dateOf(ts){{return ts.substring(0,10);}}
function hourOf(ts){{const p=ts.split('T')[1];return p?parseInt(p.substring(0,2),10):-1;}}
function r2(v){{return Math.round(v*100)/100;}}
function recProj(r){{return r.project||'unknown';}}
function recAgent(r){{return r.agent||'unknown';}}
// ── Chart instances ──────────────────────────────────────────────────────────
let charts={{}};
function mkChart(id,cfg){{if(charts[id]){{charts[id].destroy();}}charts[id]=new Chart(document.getElementById(id),cfg);}}
// ── Render everything from filtered data ─────────────────────────────────────
function render(){{
const tc=getChartTextColor(),gc=getChartGridColor();
const F={{color:tc}},G={{color:gc}};
const from=document.getElementById('ffrom').value;
const to=document.getElementById('fto').value;
const proj=document.getElementById('fproj').value;
const agent=document.getElementById('fagent').value;
const txt=document.getElementById('ftxt').value.toLowerCase();
const recs=ALL.filter(r=>{{
const d=dateOf(r.ts);
const rp=recProj(r),ra=recAgent(r);
return(!from||d>=from)&&(!to||d<=to)&&(!proj||rp===proj)&&(!agent||ra===agent)&&(!txt||r.file.toLowerCase().includes(txt));
}});
// KPI
const calls=recs.length;
const inputTok=recs.reduce((s,r)=>s+r.input_tok,0);
const savedTok=recs.reduce((s,r)=>s+r.saved,0);
const avgRed=inputTok>0?r2(savedTok/inputTok*100):0;
const uniqFiles=new Set(recs.map(r=>r.file)).size;
const uniqAgents=new Set(recs.map(recAgent)).size;
const uniqDays=new Set(recs.map(r=>dateOf(r.ts))).size;
document.getElementById('hdrMeta').textContent=t('hdr_meta').replace('{{n}}',calls).replace('{{d}}',uniqDays);
document.getElementById('kpiCards').innerHTML=[
['kpi_calls',calls,'var(--acc)','kpi_period'],
['kpi_saved',fmt(savedTok),'var(--grn)','kpi_vs_input',fmt(inputTok)],
['kpi_reduction',avgRed.toFixed(1)+'%','var(--grn)','kpi_across'],
['kpi_files',uniqFiles,'var(--txt)','kpi_processed'],
['kpi_agents',uniqAgents,'var(--txt)','kpi_active'],
['kpi_days',uniqDays,'var(--txt)','kpi_in_range'],
].map(([lbl,val,col,sub,subVal])=>`<div class="card"><div class="lbl">${{t(lbl)}}</div><div class="val" style="color:${{col}}">${{val}}</div><div class="sub">${{t(sub).replace('input',subVal||'')}}</div></div>`).join('');
// Daily aggregates
const dailyMap={{}};
const dailySem={{}},dailyCmp={{}};
recs.forEach(r=>{{
const d=dateOf(r.ts);
dailyMap[d]??={{c:0,i:0,o:0,s:0}};const e=dailyMap[d];
e.c++;e.i+=r.input_tok;e.o+=r.output_tok;e.s+=r.saved;
if(r.fidelity==='semantic'){{dailySem[d]??={{s:0,c:0}};const s=dailySem[d];s.s+=r.reduction_pct;s.c++;}}
if(r.fidelity==='compressed'){{dailyCmp[d]??={{s:0,c:0}};const s=dailyCmp[d];s.s+=r.reduction_pct;s.c++;}}
}});
const days=Object.keys(dailyMap).sort();
const dInput=days.map(d=>dailyMap[d].i);
const dOutput=days.map(d=>dailyMap[d].o);
const dSaved=days.map(d=>dailyMap[d].s);
const dSemPct=days.map(d=>{{const x=dailySem[d];return x?r2(x.s/x.c):null;}});
const dCmpPct=days.map(d=>{{const x=dailyCmp[d];return x?r2(x.s/x.c):null;}});
// Agent / Project counts
const agentMap={{}},projMap={{}},hourly=new Array(24).fill(0);
const histB=['<0%','0-5%','5-10%','10-20%','20-30%','30-50%','50%+'];
const histV=[0,0,0,0,0,0,0];
const histBounds=[[-Infinity,0],[0,5],[5,10],[10,20],[20,30],[30,50],[50,Infinity]];
recs.forEach(r=>{{
const ra=recAgent(r),rp=recProj(r);
agentMap[ra]=(agentMap[ra]||0)+1;
projMap[rp]=(projMap[rp]||0)+1;
const h=hourOf(r.ts);if(h>=0&&h<24)hourly[h]++;
for(let i=0;i<histBounds.length;i++){{if(r.reduction_pct>=histBounds[i][0]&&r.reduction_pct<histBounds[i][1]){{histV[i]++;break;}}}}
}});
const aLabels=Object.keys(agentMap).sort(),aData=aLabels.map(k=>agentMap[k]);
const pLabels=Object.keys(projMap).sort((a,b)=>projMap[b]-projMap[a]),pData=pLabels.map(k=>projMap[k]);
// ── Charts ──
mkChart('dailyChart',{{type:'bar',data:{{labels:days,datasets:[
{{label:'Input',data:dInput,backgroundColor:'rgba(99,102,241,.5)',borderRadius:3,stack:'a'}},
{{label:'Output',data:dOutput,backgroundColor:'rgba(34,197,94,.6)',borderRadius:3,stack:'a'}},
]}},options:{{plugins:{{legend:{{labels:{{color:tc}}}}}},scales:{{x:{{ticks:F,grid:G,stacked:true}},y:{{ticks:F,grid:G,stacked:true,title:{{display:true,text:'tokens',color:tc}}}}}}}}}});
mkChart('fidelityChart',{{type:'line',data:{{labels:days,datasets:[
{{label:'Semantic %',data:dSemPct,borderColor:'#22c55e',backgroundColor:'rgba(34,197,94,.08)',tension:.3,pointRadius:4,spanGaps:true}},
{{label:'Compressed %',data:dCmpPct,borderColor:'#ef4444',backgroundColor:'rgba(239,68,68,.08)',tension:.3,pointRadius:4,spanGaps:true}},
]}},options:{{plugins:{{legend:{{labels:{{color:tc}}}}}},scales:{{x:{{ticks:F,grid:G}},y:{{ticks:F,grid:G,title:{{display:true,text:'reduction %',color:tc}}}}}}}}}});
mkChart('tokTrendChart',{{type:'line',data:{{labels:days,datasets:[
{{label:'Input',data:dInput,borderColor:'#6366f1',backgroundColor:'rgba(99,102,241,.1)',fill:true,tension:.3,pointRadius:3}},
{{label:'Output',data:dOutput,borderColor:'#22c55e',backgroundColor:'rgba(34,197,94,.1)',fill:true,tension:.3,pointRadius:3}},
]}},options:{{plugins:{{legend:{{labels:{{color:tc}}}}}},scales:{{x:{{ticks:F,grid:G}},y:{{ticks:F,grid:G,title:{{display:true,text:'tokens',color:tc}}}}}}}}}});
mkChart('agentChart',{{type:'doughnut',data:{{labels:aLabels,datasets:[{{data:aData,backgroundColor:AC.slice(0,aLabels.length),borderWidth:2}}]}},
options:{{plugins:{{legend:{{labels:{{color:tc,padding:14}}}}}},cutout:'55%'}}}});
mkChart('hourlyChart',{{type:'bar',data:{{labels:Array.from({{length:24}},(_,i)=>String(i).padStart(2,'0')),
datasets:[{{data:hourly,backgroundColor:'rgba(99,102,241,.6)',borderRadius:3}}]}},
options:{{plugins:{{legend:{{display:false}}}},scales:{{
x:{{ticks:F,grid:G,title:{{display:true,text:'hour (UTC)',color:tc}}}},
y:{{ticks:{{...F,stepSize:1}},grid:G,title:{{display:true,text:'calls',color:tc}}}}
}}}}}});
mkChart('histChart',{{type:'bar',data:{{labels:histB,datasets:[{{data:histV,backgroundColor:'rgba(34,197,94,.6)',borderRadius:3}}]}},
options:{{plugins:{{legend:{{display:false}}}},scales:{{
x:{{ticks:F,grid:G,title:{{display:true,text:'reduction %',color:tc}}}},
y:{{ticks:{{...F,stepSize:1}},grid:G,title:{{display:true,text:'count',color:tc}}}}
}}}}}});
mkChart('projectChart',{{type:'bar',data:{{labels:pLabels,datasets:[{{data:pData,backgroundColor:'rgba(234,179,8,.6)',borderRadius:3}}]}},
options:{{indexAxis:'y',plugins:{{legend:{{display:false}}}},scales:{{
x:{{ticks:F,grid:G,title:{{display:true,text:'calls',color:tc}}}},y:{{ticks:F,grid:G}}
}}}}}});
// ── Daily table ──
document.getElementById('dailyTbody').innerHTML=days.map(d=>{{
const e=dailyMap[d];const pct=e.i>0?r2(e.s/e.i*100):0;
return `<tr><td>${{esc(d)}}</td><td class="n">${{e.c}}</td><td class="n">${{fmt(e.i)}}</td><td class="n">${{fmt(e.s)}}</td><td class="n ${{pcls(pct)}}">${{pct.toFixed(1)}}%</td></tr>`;
}}).join('');
// ── All records table ──
document.getElementById('tbody').innerHTML=recs.map(r=>{{
const pct=r.reduction_pct;
return `<tr><td>${{esc(dateOf(r.ts))}}</td><td>${{esc(recProj(r))}}</td><td>${{esc(recAgent(r))}}</td><td>${{esc(r.file)}}</td><td>${{esc(r.fidelity)}}</td><td class="n">${{r.input_tok}}</td><td class="n ${{pcls(pct)}}">${{pct.toFixed(1)}}%</td><td class="n">${{r.saved}}</td></tr>`;
}}).join('');
}}
function applyFilter(){{
syncPresets();
render();
}}
function exportCsv(){{
const hdr=['date','project','agent','file','fidelity','in_tok','reduction','saved'];
const rows=[hdr];
document.querySelectorAll('#tbody tr').forEach(row=>{{
if(row.style.display==='none')return;
rows.push([...row.querySelectorAll('td')].map(td=>td.textContent));
}});
const a=document.createElement('a');
a.href=URL.createObjectURL(new Blob([rows.map(r=>r.join(',')).join('\n')],{{type:'text/csv'}}));
a.download='stats.csv';a.click();
}}
// ftxt debounce — 200ms
(function(){{
let _t;
document.getElementById('ftxt').addEventListener('input',()=>{{
clearTimeout(_t);_t=setTimeout(applyFilter,200);
}});
}})();
// Initial render — default to 1 week
(function(){{
const now=new Date();
const from=new Date(now);from.setDate(from.getDate()-6);
document.getElementById('ffrom').value=fmtDate(from);
document.getElementById('fto').value=fmtDate(now);
}})();
render();
</script>
</body>
</html>
"##,
records_json = records_json,
project_options = project_options,
agent_options = agent_options,
);
if let Err(e) = fs::write(out_path, &html) {
eprintln!("ERROR: cannot write HTML to {out_path}: {e}");
std::process::exit(1);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn esc_prevents_xss() {
let s = esc("<img onerror=alert(1)>");
assert!(!s.contains('<'));
assert!(s.contains("<"));
}
#[test]
fn js_safe_escapes_script_close() {
assert_eq!(js_safe("</script>"), "<\\/script>");
}
#[test]
fn deserialize_stats_record_without_project() {
let json = r#"{"ts":"2026-05-12T00:00:00Z","agent":"claude","file":"a.rs","format":"markdown","fidelity":"semantic","input_tok":100,"output_tok":80,"reduction_pct":20.0,"saved":20}"#;
let rec: StatsRecord = serde_json::from_str(json).unwrap();
assert_eq!(rec.project, "");
assert_eq!(rec.agent, "claude");
assert_eq!(rec.saved, 20);
}
#[test]
fn deserialize_stats_record_with_project() {
let json = r#"{"ts":"2026-05-12T00:00:00Z","agent":"claude","project":"llm-transpile","file":"a.rs","format":"markdown","fidelity":"semantic","input_tok":100,"output_tok":80,"reduction_pct":20.0,"saved":20}"#;
let rec: StatsRecord = serde_json::from_str(json).unwrap();
assert_eq!(rec.project, "llm-transpile");
}
#[test]
fn epoch_days_to_ymd_epoch() {
assert_eq!(epoch_days_to_ymd(0), (1970, 1, 1));
}
}