diff --git a/frontend/costs.html b/frontend/costs.html
new file mode 100644
index 0000000..5cf6466
--- /dev/null
+++ b/frontend/costs.html
@@ -0,0 +1,119 @@
+
+
+
+
+
+ Cost Dashboard - Luke at the Roost
+
+
+
+
+
+
+
+
+
+
+
Cost Over Time
+
+
+
+
Cost by Model
+
+
+
+
+
+
Cost by Category
+
+
+
+
Cost Per Session
+
+
+
+
+
+
Most Expensive Calls
+
+
+ | Model | Category | Caller | Tokens | Cost | Latency |
+
+
+
+
+
+
Sessions
+
+
+ | Date | LLM | TTS | Total | Calls | |
+
+
+
+
+
+
+ Session Detail:
+
+
+
+
+
+
+
Most Expensive Calls
+
+ | Category | Model | Caller | Cost | Tokens | Latency |
+
+
+
+
+
+
+
+
+
diff --git a/frontend/css/costs.css b/frontend/css/costs.css
new file mode 100644
index 0000000..7e81d90
--- /dev/null
+++ b/frontend/css/costs.css
@@ -0,0 +1,268 @@
+/* Cost Dashboard */
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ min-height: 100vh;
+}
+
+.costs-header {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 16px 24px;
+ border-bottom: 1px solid rgba(232, 121, 29, 0.12);
+}
+
+.costs-header h1 {
+ font-size: 1.4rem;
+ margin-right: auto;
+}
+
+.back-link {
+ color: var(--accent);
+ text-decoration: none;
+ font-size: 0.9rem;
+ transition: color var(--transition);
+}
+
+.back-link:hover {
+ color: var(--accent-hover);
+}
+
+.period-tabs {
+ display: flex;
+ gap: 4px;
+}
+
+.period-tab {
+ background: transparent;
+ color: var(--text-muted);
+ border: 1px solid rgba(232, 121, 29, 0.15);
+ border-radius: 20px;
+ padding: 6px 14px;
+ font-size: 0.8rem;
+ cursor: pointer;
+ transition: all var(--transition);
+}
+
+.period-tab:hover {
+ color: var(--text);
+ border-color: var(--accent);
+}
+
+.period-tab.active {
+ background: var(--accent);
+ color: var(--bg-dark);
+ border-color: var(--accent);
+ font-weight: 600;
+}
+
+.costs-main {
+ max-width: 1400px;
+ margin: 0 auto;
+ padding: 20px 24px;
+}
+
+/* Summary Cards */
+
+.summary-cards {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 16px;
+ margin-bottom: 20px;
+}
+
+.card {
+ background: var(--bg-light);
+ border: 1px solid rgba(232, 121, 29, 0.08);
+ border-radius: var(--radius);
+ padding: 18px 20px;
+}
+
+.card-label {
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-muted);
+ margin-bottom: 6px;
+}
+
+.card-value {
+ font-size: 1.8rem;
+ font-weight: 700;
+ color: var(--text);
+ line-height: 1.2;
+}
+
+.card-change {
+ font-size: 0.75rem;
+ margin-top: 4px;
+}
+
+.card-change.positive {
+ color: var(--accent-red);
+}
+
+.card-change.negative {
+ color: var(--accent-green);
+}
+
+/* Charts */
+
+.chart-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+ margin-bottom: 20px;
+}
+
+.chart-container {
+ background: var(--bg-light);
+ border: 1px solid rgba(232, 121, 29, 0.08);
+ border-radius: var(--radius);
+ padding: 18px 20px;
+}
+
+.chart-container h3 {
+ font-size: 0.85rem;
+ color: var(--text-muted);
+ margin-bottom: 12px;
+ font-weight: 500;
+}
+
+/* Tables */
+
+.tables-section {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+ margin-bottom: 20px;
+}
+
+.table-container {
+ background: var(--bg-light);
+ border: 1px solid rgba(232, 121, 29, 0.08);
+ border-radius: var(--radius);
+ padding: 18px 20px;
+ overflow-x: auto;
+}
+
+.table-container h3 {
+ font-size: 0.85rem;
+ color: var(--text-muted);
+ margin-bottom: 12px;
+ font-weight: 500;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.82rem;
+}
+
+th {
+ text-transform: uppercase;
+ font-size: 0.68rem;
+ letter-spacing: 0.04em;
+ color: var(--text-muted);
+ border-bottom: 1px solid rgba(232, 121, 29, 0.15);
+ padding: 8px 10px;
+ text-align: left;
+ font-weight: 600;
+}
+
+td {
+ padding: 8px 10px;
+ border-bottom: 1px solid rgba(232, 121, 29, 0.06);
+ color: var(--text);
+}
+
+tr:hover td {
+ background: rgba(232, 121, 29, 0.04);
+}
+
+.clickable {
+ cursor: pointer;
+}
+
+.clickable:hover td {
+ background: rgba(232, 121, 29, 0.08);
+}
+
+/* Session Detail */
+
+.session-detail {
+ margin-top: 20px;
+}
+
+.session-detail.hidden {
+ display: none;
+}
+
+.session-detail h2 {
+ font-size: 1.2rem;
+ margin-bottom: 12px;
+}
+
+.session-detail h2 span {
+ color: var(--accent);
+}
+
+.close-detail {
+ background: var(--bg-light);
+ color: var(--text);
+ border: 1px solid rgba(232, 121, 29, 0.15);
+ border-radius: var(--radius-sm);
+ padding: 6px 16px;
+ font-size: 0.8rem;
+ cursor: pointer;
+ margin-bottom: 16px;
+ transition: all var(--transition);
+}
+
+.close-detail:hover {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.detail-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+}
+
+.view-btn {
+ background: transparent;
+ color: var(--accent);
+ border: 1px solid var(--accent);
+ border-radius: var(--radius-sm);
+ padding: 3px 10px;
+ font-size: 0.72rem;
+ cursor: pointer;
+ transition: all var(--transition);
+}
+
+.view-btn:hover {
+ background: var(--accent);
+ color: var(--bg-dark);
+}
+
+/* Responsive */
+
+@media (max-width: 768px) {
+ .costs-header {
+ flex-wrap: wrap;
+ }
+
+ .summary-cards {
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .chart-row,
+ .tables-section,
+ .detail-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/frontend/js/costs.js b/frontend/js/costs.js
new file mode 100644
index 0000000..cef9e78
--- /dev/null
+++ b/frontend/js/costs.js
@@ -0,0 +1,296 @@
+let currentPeriod = 'all';
+let charts = {};
+
+const COLORS = {
+ accent: '#e8791d',
+ devon: '#c4944a',
+ green: '#5a8a3c',
+ red: '#cc2222',
+ blue: '#4a8ac4',
+ purple: '#8a5ac4',
+ tan: '#c4845a',
+ teal: '#3c8a7a',
+ pink: '#c45a8a',
+ slate: '#6a7a8a',
+};
+const COLOR_LIST = Object.values(COLORS);
+
+Chart.defaults.color = '#9a8b78';
+Chart.defaults.borderColor = 'rgba(245, 240, 229, 0.08)';
+
+// --- Utilities ---
+
+function formatCost(n) {
+ if (n == null) return '--';
+ return n < 0.01 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)}`;
+}
+
+function formatDate(iso) {
+ if (!iso) return '--';
+ const d = new Date(iso);
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +
+ ', ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
+}
+
+function shortenModel(name) {
+ if (!name) return '--';
+ const i = name.indexOf('/');
+ return i >= 0 ? name.slice(i + 1) : name;
+}
+
+function destroyChart(key) {
+ if (charts[key]) {
+ charts[key].destroy();
+ charts[key] = null;
+ }
+}
+
+// --- Data Loading ---
+
+async function loadDashboard() {
+ const p = currentPeriod;
+ try {
+ const [summary, timeline, models, categories, sessions, expensive] = await Promise.all([
+ fetch(`/api/costs/summary?period=${p}`).then(r => r.json()),
+ fetch(`/api/costs/timeline?period=${p}&group_by=session`).then(r => r.json()),
+ fetch(`/api/costs/models?period=${p}`).then(r => r.json()),
+ fetch(`/api/costs/categories?period=${p}`).then(r => r.json()),
+ fetch(`/api/costs/sessions?period=${p}`).then(r => r.json()),
+ fetch(`/api/costs/expensive?period=${p}&limit=10`).then(r => r.json()),
+ ]);
+ renderSummary(summary);
+ renderTimeline(timeline);
+ renderModels(models);
+ renderCategories(categories);
+ renderSessionBars(timeline);
+ renderExpensiveTable(expensive);
+ renderSessionsTable(sessions);
+ } catch (e) {
+ console.error('Dashboard load error:', e);
+ }
+}
+
+// --- Render Functions ---
+
+function renderSummary(data) {
+ document.getElementById('total-spend').textContent = formatCost(data.total_cost);
+ document.getElementById('llm-tts-split').textContent =
+ `${formatCost(data.llm_cost)} / ${formatCost(data.tts_cost)}`;
+ document.getElementById('session-count').textContent = data.sessions;
+ document.getElementById('avg-cost').textContent = formatCost(data.avg_cost_per_session);
+
+ const changeEl = document.getElementById('total-change');
+ if (data.cost_change_pct != null) {
+ const up = data.cost_change_pct > 0;
+ changeEl.textContent = `${up ? '\u2191' : '\u2193'} ${Math.abs(data.cost_change_pct)}% vs prev period`;
+ changeEl.className = 'card-change ' + (up ? 'positive' : 'negative');
+ } else {
+ changeEl.textContent = '';
+ changeEl.className = 'card-change';
+ }
+}
+
+function renderTimeline(data) {
+ destroyChart('timeline');
+ const ctx = document.getElementById('timeline-chart');
+ const labels = data.map(d => d.date || formatDate(d.started_at));
+ charts.timeline = new Chart(ctx, {
+ type: 'line',
+ data: {
+ labels,
+ datasets: [
+ {
+ label: 'LLM',
+ data: data.map(d => d.llm_cost),
+ borderColor: COLORS.accent,
+ backgroundColor: COLORS.accent + '22',
+ fill: true,
+ tension: 0.3,
+ },
+ {
+ label: 'TTS',
+ data: data.map(d => d.tts_cost),
+ borderColor: COLORS.devon,
+ backgroundColor: COLORS.devon + '22',
+ fill: true,
+ tension: 0.3,
+ },
+ ],
+ },
+ options: {
+ responsive: true,
+ plugins: {
+ legend: { position: 'top' },
+ },
+ scales: {
+ y: { beginAtZero: true, ticks: { callback: v => '$' + v.toFixed(2) } },
+ },
+ },
+ });
+}
+
+function renderModels(data) {
+ destroyChart('models');
+ const ctx = document.getElementById('model-chart');
+ charts.models = new Chart(ctx, {
+ type: 'doughnut',
+ data: {
+ labels: data.map(d => shortenModel(d.model)),
+ datasets: [{
+ data: data.map(d => d.cost),
+ backgroundColor: data.map((_, i) => COLOR_LIST[i % COLOR_LIST.length]),
+ borderWidth: 0,
+ }],
+ },
+ options: {
+ responsive: true,
+ plugins: {
+ legend: { position: 'right', labels: { boxWidth: 12, padding: 8, font: { size: 11 } } },
+ tooltip: { callbacks: { label: ctx => `${ctx.label}: ${formatCost(ctx.parsed)}` } },
+ },
+ },
+ });
+}
+
+function renderCategories(data) {
+ destroyChart('categories');
+ const ctx = document.getElementById('category-chart');
+ charts.categories = new Chart(ctx, {
+ type: 'bar',
+ data: {
+ labels: data.map(d => d.category),
+ datasets: [{
+ data: data.map(d => d.cost),
+ backgroundColor: COLORS.accent + 'cc',
+ borderRadius: 4,
+ }],
+ },
+ options: {
+ indexAxis: 'y',
+ responsive: true,
+ plugins: { legend: { display: false } },
+ scales: {
+ x: { beginAtZero: true, ticks: { callback: v => '$' + v.toFixed(2) } },
+ },
+ },
+ });
+}
+
+function renderSessionBars(data) {
+ destroyChart('sessionBars');
+ const ctx = document.getElementById('session-chart');
+ const costs = data.map(d => d.total_cost);
+ const avg = costs.length ? costs.reduce((a, b) => a + b, 0) / costs.length : 0;
+ charts.sessionBars = new Chart(ctx, {
+ type: 'bar',
+ data: {
+ labels: data.map(d => d.date || formatDate(d.started_at)),
+ datasets: [{
+ data: costs,
+ backgroundColor: costs.map(c => c > avg ? COLORS.red + 'cc' : COLORS.green + 'cc'),
+ borderRadius: 4,
+ }],
+ },
+ options: {
+ responsive: true,
+ plugins: { legend: { display: false } },
+ scales: {
+ y: { beginAtZero: true, ticks: { callback: v => '$' + v.toFixed(2) } },
+ },
+ },
+ });
+}
+
+function renderExpensiveTable(data) {
+ const tbody = document.querySelector('#expensive-table tbody');
+ tbody.innerHTML = data.map(d => `
+
+ | ${shortenModel(d.model)} |
+ ${d.category} |
+ ${d.caller_name || '\u2014'} |
+ ${(d.prompt_tokens + d.completion_tokens).toLocaleString()} |
+ ${formatCost(d.cost)} |
+ ${d.latency_ms.toFixed(0)}ms |
+
+ `).join('');
+}
+
+function renderSessionsTable(data) {
+ const tbody = document.querySelector('#sessions-table tbody');
+ tbody.innerHTML = data.map(d => `
+
+ | ${formatDate(d.started_at)} |
+ ${formatCost(d.llm_cost)} |
+ ${formatCost(d.tts_cost)} |
+ ${formatCost(d.total_cost)} |
+ ${d.total_llm_calls} |
+ |
+
+ `).join('');
+
+ tbody.querySelectorAll('.view-btn').forEach(btn => {
+ btn.addEventListener('click', e => {
+ e.stopPropagation();
+ showSessionDetail(btn.dataset.session);
+ });
+ });
+ tbody.querySelectorAll('.clickable').forEach(row => {
+ row.addEventListener('click', () => showSessionDetail(row.dataset.session));
+ });
+}
+
+// --- Session Detail ---
+
+async function showSessionDetail(sessionId) {
+ try {
+ const detail = await fetch(`/api/costs/session/${sessionId}`).then(r => r.json());
+
+ document.getElementById('detail-session-id').textContent = sessionId;
+
+ populateDetailTable('#detail-caller-table tbody', detail.by_caller,
+ d => `${d.caller_name} | ${d.calls} | ${formatCost(d.cost)} | `);
+
+ populateDetailTable('#detail-category-table tbody', detail.by_category,
+ d => `${d.category} | ${d.calls} | ${formatCost(d.cost)} | `);
+
+ populateDetailTable('#detail-model-table tbody', detail.by_model,
+ d => `${shortenModel(d.model)} | ${d.calls} | ${formatCost(d.cost)} | `);
+
+ populateDetailTable('#detail-expensive-table tbody', detail.expensive_calls,
+ d => `${d.category} | ${shortenModel(d.model)} | ${d.caller_name || '\u2014'} | ` +
+ `${formatCost(d.cost)} | ${(d.prompt_tokens + d.completion_tokens).toLocaleString()} | ` +
+ `${d.latency_ms.toFixed(0)}ms | `);
+
+ document.querySelectorAll('.costs-main > section:not(.session-detail)').forEach(
+ s => s.style.display = 'none');
+ document.getElementById('session-detail').classList.remove('hidden');
+ } catch (e) {
+ console.error('Detail load error:', e);
+ }
+}
+
+function populateDetailTable(selector, data, rowFn) {
+ const tbody = document.querySelector(selector);
+ tbody.innerHTML = data.map(d => `${rowFn(d)}
`).join('');
+}
+
+function closeDetail() {
+ document.getElementById('session-detail').classList.add('hidden');
+ document.querySelectorAll('.costs-main > section:not(.session-detail)').forEach(
+ s => s.style.display = '');
+}
+
+// --- Init ---
+
+document.addEventListener('DOMContentLoaded', () => {
+ document.querySelectorAll('.period-tab').forEach(tab => {
+ tab.addEventListener('click', () => {
+ document.querySelector('.period-tab.active').classList.remove('active');
+ tab.classList.add('active');
+ currentPeriod = tab.dataset.period;
+ loadDashboard();
+ });
+ });
+ document.getElementById('close-detail').addEventListener('click', closeDetail);
+ loadDashboard();
+});