Seven plan documents covering the website JS infrastructure, show theme, caller quality overhaul, cost dashboard, the Alpine relocation, and caller variety plus Devon's search — the record of why the current architecture looks the way it does. Plus the press release draft. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
30 KiB
Cost Dashboard Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Build a cost analytics dashboard at /costs that visualizes LLM and TTS spending across sessions with time-range filtering, charts, and drill-down.
Architecture: SQLite database (data/costs.db) stores all cost records. Existing JSON reports are imported on first run. cost_tracker.py dual-writes to both JSON and SQLite. New API endpoints serve aggregated data. Standalone HTML page with Chart.js renders the dashboard.
Tech Stack: Python/FastAPI, SQLite, Chart.js (CDN), vanilla JS, CSS custom properties matching existing dark theme.
Task 1: SQLite Database Module
Files:
- Create:
backend/services/cost_db.py
Step 1: Create the database module with schema
Create backend/services/cost_db.py with:
init_db(db_path)— creates tables if not exist, returns connectionimport_json_reports(db_path, reports_dir)— scansdata/cost_reports/*.json, imports any sessions not already in the DBget_db()— returns a connection todata/costs.db, callsinit_dbon first use
import json
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
DB_PATH = Path(__file__).parent.parent.parent / "data" / "costs.db"
REPORTS_DIR = Path(__file__).parent.parent.parent / "data" / "cost_reports"
_connection = None
SCHEMA = """
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
started_at TIMESTAMP,
total_cost REAL DEFAULT 0,
llm_cost REAL DEFAULT 0,
tts_cost REAL DEFAULT 0,
total_llm_calls INTEGER DEFAULT 0,
total_tts_calls INTEGER DEFAULT 0,
total_tokens INTEGER DEFAULT 0,
prompt_tokens INTEGER DEFAULT 0,
completion_tokens INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS llm_calls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
timestamp TIMESTAMP,
category TEXT,
model TEXT,
prompt_tokens INTEGER DEFAULT 0,
completion_tokens INTEGER DEFAULT 0,
cost REAL DEFAULT 0,
caller_name TEXT,
latency_ms REAL DEFAULT 0,
FOREIGN KEY (session_id) REFERENCES sessions(id)
);
CREATE TABLE IF NOT EXISTS tts_calls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
timestamp TIMESTAMP,
provider TEXT,
voice TEXT,
char_count INTEGER DEFAULT 0,
cost REAL DEFAULT 0,
FOREIGN KEY (session_id) REFERENCES sessions(id)
);
CREATE INDEX IF NOT EXISTS idx_llm_session ON llm_calls(session_id);
CREATE INDEX IF NOT EXISTS idx_llm_timestamp ON llm_calls(timestamp);
CREATE INDEX IF NOT EXISTS idx_llm_category ON llm_calls(category);
CREATE INDEX IF NOT EXISTS idx_llm_model ON llm_calls(model);
CREATE INDEX IF NOT EXISTS idx_tts_session ON tts_calls(session_id);
CREATE INDEX IF NOT EXISTS idx_tts_timestamp ON tts_calls(timestamp);
"""
def get_db():
global _connection
if _connection is None:
_connection = sqlite3.connect(str(DB_PATH), check_same_thread=False)
_connection.row_factory = sqlite3.Row
_connection.executescript(SCHEMA)
import_json_reports()
return _connection
def import_json_reports():
db = _connection
if not REPORTS_DIR.exists():
return
existing = {row[0] for row in db.execute("SELECT id FROM sessions").fetchall()}
for f in sorted(REPORTS_DIR.glob("*.json")):
try:
data = json.loads(f.read_text())
except (json.JSONDecodeError, OSError):
continue
session_id = data.get("session_id", f.stem)
if session_id in existing:
continue
saved_at = data.get("saved_at")
started_at = datetime.fromtimestamp(saved_at).isoformat() if saved_at else None
db.execute(
"INSERT INTO sessions (id, started_at, total_cost, llm_cost, tts_cost, total_llm_calls, total_tts_calls, total_tokens, prompt_tokens, completion_tokens) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(session_id, started_at, data.get("total_cost_usd", 0), data.get("llm_cost_usd", 0), data.get("tts_cost_usd", 0), data.get("total_llm_calls", 0), len(data.get("raw_tts_records", [])), data.get("total_tokens", 0), data.get("prompt_tokens", 0), data.get("completion_tokens", 0)),
)
for rec in data.get("raw_llm_records", []):
db.execute(
"INSERT INTO llm_calls (session_id, timestamp, category, model, prompt_tokens, completion_tokens, cost, caller_name, latency_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(session_id, rec.get("timestamp"), rec.get("category"), rec.get("model"), rec.get("prompt_tokens", 0), rec.get("completion_tokens", 0), rec.get("cost_usd", 0), rec.get("caller_name"), rec.get("latency_ms", 0)),
)
for rec in data.get("raw_tts_records", []):
db.execute(
"INSERT INTO tts_calls (session_id, timestamp, provider, voice, char_count, cost) VALUES (?, ?, ?, ?, ?, ?)",
(session_id, rec.get("timestamp"), rec.get("provider"), rec.get("voice"), rec.get("char_count", 0), rec.get("cost_usd", 0)),
)
existing.add(session_id)
db.commit()
def record_llm_call(session_id, timestamp, category, model, prompt_tokens, completion_tokens, cost, caller_name, latency_ms):
db = get_db()
db.execute(
"INSERT INTO llm_calls (session_id, timestamp, category, model, prompt_tokens, completion_tokens, cost, caller_name, latency_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(session_id, timestamp, category, model, prompt_tokens, completion_tokens, cost, caller_name, latency_ms),
)
db.commit()
def record_tts_call(session_id, timestamp, provider, voice, char_count, cost):
db = get_db()
db.execute(
"INSERT INTO tts_calls (session_id, timestamp, provider, voice, char_count, cost) VALUES (?, ?, ?, ?, ?, ?)",
(session_id, timestamp, provider, voice, char_count, cost),
)
db.commit()
def ensure_session(session_id, started_at=None):
db = get_db()
existing = db.execute("SELECT id FROM sessions WHERE id = ?", (session_id,)).fetchone()
if not existing:
db.execute(
"INSERT INTO sessions (id, started_at) VALUES (?, ?)",
(session_id, started_at or datetime.now().isoformat()),
)
db.commit()
def update_session_totals(session_id):
db = get_db()
llm = db.execute(
"SELECT COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost, COALESCE(SUM(prompt_tokens), 0) as pt, COALESCE(SUM(completion_tokens), 0) as ct FROM llm_calls WHERE session_id = ?",
(session_id,),
).fetchone()
tts = db.execute(
"SELECT COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost FROM tts_calls WHERE session_id = ?",
(session_id,),
).fetchone()
total_tokens = llm["pt"] + llm["ct"]
total_cost = llm["cost"] + tts["cost"]
db.execute(
"UPDATE sessions SET total_cost=?, llm_cost=?, tts_cost=?, total_llm_calls=?, total_tts_calls=?, total_tokens=?, prompt_tokens=?, completion_tokens=? WHERE id=?",
(total_cost, llm["cost"], tts["cost"], llm["calls"], tts["calls"], total_tokens, llm["pt"], llm["ct"], session_id),
)
db.commit()
def _period_filter(period):
now = datetime.now()
if period == "today":
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
elif period == "week":
start = now - timedelta(days=now.weekday())
start = start.replace(hour=0, minute=0, second=0, microsecond=0)
elif period == "month":
start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
else:
return None
return start.isoformat()
def get_summary(period="all"):
db = get_db()
start = _period_filter(period)
if start:
where = "WHERE started_at >= ?"
params = (start,)
prev_start = _get_previous_period_start(period)
prev_where = "WHERE started_at >= ? AND started_at < ?"
prev_params = (prev_start, start)
else:
where = ""
params = ()
prev_where = None
prev_params = ()
row = db.execute(
f"SELECT COUNT(*) as sessions, COALESCE(SUM(total_cost), 0) as total_cost, COALESCE(SUM(llm_cost), 0) as llm_cost, COALESCE(SUM(tts_cost), 0) as tts_cost, COALESCE(SUM(total_llm_calls), 0) as total_calls, COALESCE(SUM(total_tokens), 0) as total_tokens FROM sessions {where}",
params,
).fetchone()
result = {
"total_cost": round(row["total_cost"], 4),
"llm_cost": round(row["llm_cost"], 4),
"tts_cost": round(row["tts_cost"], 4),
"sessions": row["sessions"],
"total_calls": row["total_calls"],
"total_tokens": row["total_tokens"],
"avg_cost_per_session": round(row["total_cost"] / max(row["sessions"], 1), 4),
}
if prev_where:
prev = db.execute(
f"SELECT COALESCE(SUM(total_cost), 0) as total_cost FROM sessions {prev_where}",
prev_params,
).fetchone()
prev_cost = prev["total_cost"]
if prev_cost > 0:
result["pct_change"] = round((row["total_cost"] - prev_cost) / prev_cost * 100, 1)
else:
result["pct_change"] = None
else:
result["pct_change"] = None
return result
def _get_previous_period_start(period):
now = datetime.now()
if period == "today":
return (now - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
elif period == "week":
start_of_week = now - timedelta(days=now.weekday())
return (start_of_week - timedelta(days=7)).replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
elif period == "month":
first_of_month = now.replace(day=1)
prev_month = first_of_month - timedelta(days=1)
return prev_month.replace(day=1, hour=0, minute=0, second=0, microsecond=0).isoformat()
return None
def get_timeline(period="all", group_by="session"):
db = get_db()
start = _period_filter(period)
if group_by == "day":
if start:
rows = db.execute(
"SELECT DATE(started_at) as date, SUM(llm_cost) as llm_cost, SUM(tts_cost) as tts_cost, SUM(total_cost) as total_cost, COUNT(*) as sessions FROM sessions WHERE started_at >= ? GROUP BY DATE(started_at) ORDER BY date",
(start,),
).fetchall()
else:
rows = db.execute(
"SELECT DATE(started_at) as date, SUM(llm_cost) as llm_cost, SUM(tts_cost) as tts_cost, SUM(total_cost) as total_cost, COUNT(*) as sessions FROM sessions GROUP BY DATE(started_at) ORDER BY date"
).fetchall()
else:
if start:
rows = db.execute(
"SELECT id, started_at, llm_cost, tts_cost, total_cost FROM sessions WHERE started_at >= ? ORDER BY started_at",
(start,),
).fetchall()
else:
rows = db.execute(
"SELECT id, started_at, llm_cost, tts_cost, total_cost FROM sessions ORDER BY started_at"
).fetchall()
return [dict(r) for r in rows]
def get_models(period="all"):
db = get_db()
start = _period_filter(period)
if start:
rows = db.execute(
"SELECT l.model, COUNT(*) as calls, COALESCE(SUM(l.cost), 0) as cost, COALESCE(SUM(l.prompt_tokens), 0) as prompt_tokens, COALESCE(SUM(l.completion_tokens), 0) as completion_tokens FROM llm_calls l JOIN sessions s ON l.session_id = s.id WHERE s.started_at >= ? GROUP BY l.model ORDER BY cost DESC",
(start,),
).fetchall()
else:
rows = db.execute(
"SELECT model, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost, COALESCE(SUM(prompt_tokens), 0) as prompt_tokens, COALESCE(SUM(completion_tokens), 0) as completion_tokens FROM llm_calls GROUP BY model ORDER BY cost DESC"
).fetchall()
return [dict(r) for r in rows]
def get_categories(period="all"):
db = get_db()
start = _period_filter(period)
if start:
rows = db.execute(
"SELECT l.category, COUNT(*) as calls, COALESCE(SUM(l.cost), 0) as cost, COALESCE(SUM(l.prompt_tokens + l.completion_tokens), 0) as tokens FROM llm_calls l JOIN sessions s ON l.session_id = s.id WHERE s.started_at >= ? GROUP BY l.category ORDER BY cost DESC",
(start,),
).fetchall()
else:
rows = db.execute(
"SELECT category, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost, COALESCE(SUM(prompt_tokens + completion_tokens), 0) as tokens FROM llm_calls GROUP BY category ORDER BY cost DESC"
).fetchall()
return [dict(r) for r in rows]
def get_sessions_list(period="all"):
db = get_db()
start = _period_filter(period)
if start:
rows = db.execute(
"SELECT id, started_at, total_cost, llm_cost, tts_cost, total_llm_calls, total_tokens FROM sessions WHERE started_at >= ? ORDER BY started_at DESC",
(start,),
).fetchall()
else:
rows = db.execute(
"SELECT id, started_at, total_cost, llm_cost, tts_cost, total_llm_calls, total_tokens FROM sessions ORDER BY started_at DESC"
).fetchall()
return [dict(r) for r in rows]
def get_session_detail(session_id):
db = get_db()
session = db.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
if not session:
return None
by_caller = db.execute(
"SELECT caller_name, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost, COALESCE(SUM(prompt_tokens + completion_tokens), 0) as tokens FROM llm_calls WHERE session_id = ? AND caller_name != '' GROUP BY caller_name ORDER BY cost DESC",
(session_id,),
).fetchall()
by_model = db.execute(
"SELECT model, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost FROM llm_calls WHERE session_id = ? GROUP BY model ORDER BY cost DESC",
(session_id,),
).fetchall()
by_category = db.execute(
"SELECT category, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost FROM llm_calls WHERE session_id = ? GROUP BY category ORDER BY cost DESC",
(session_id,),
).fetchall()
expensive = db.execute(
"SELECT category, model, caller_name, cost, prompt_tokens, completion_tokens, latency_ms, timestamp FROM llm_calls WHERE session_id = ? ORDER BY cost DESC LIMIT 10",
(session_id,),
).fetchall()
tts = db.execute(
"SELECT provider, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost, COALESCE(SUM(char_count), 0) as chars FROM tts_calls WHERE session_id = ? GROUP BY provider ORDER BY cost DESC",
(session_id,),
).fetchall()
return {
"session": dict(session),
"by_caller": [dict(r) for r in by_caller],
"by_model": [dict(r) for r in by_model],
"by_category": [dict(r) for r in by_category],
"expensive_calls": [dict(r) for r in expensive],
"tts_by_provider": [dict(r) for r in tts],
}
def get_expensive_calls(period="all", limit=10):
db = get_db()
start = _period_filter(period)
if start:
rows = db.execute(
"SELECT l.category, l.model, l.caller_name, l.cost, l.prompt_tokens, l.completion_tokens, l.latency_ms, l.timestamp, l.session_id FROM llm_calls l JOIN sessions s ON l.session_id = s.id WHERE s.started_at >= ? ORDER BY l.cost DESC LIMIT ?",
(start, limit),
).fetchall()
else:
rows = db.execute(
"SELECT category, model, caller_name, cost, prompt_tokens, completion_tokens, latency_ms, timestamp, session_id FROM llm_calls ORDER BY cost DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(r) for r in rows]
Step 2: Verify the module loads
Run: cd /Users/lukemacneil/code/ai-podcast && python -c "from backend.services.cost_db import get_db; db = get_db(); print('OK, sessions:', db.execute('SELECT COUNT(*) FROM sessions').fetchone()[0])"
Expected: OK, sessions: 18 (or however many JSON reports exist)
Step 3: Commit
git add backend/services/cost_db.py
git commit -m "Add SQLite cost database module with JSON import"
Task 2: Integrate SQLite Writes into Cost Tracker
Files:
- Modify:
backend/services/cost_tracker.py
Step 1: Add dual-write to record_llm_call
At the top of cost_tracker.py, add the import:
from backend.services import cost_db
In record_llm_call() (around line 148, after appending to self.llm_records), add:
try:
cost_db.ensure_session(self._session_id)
cost_db.record_llm_call(
self._session_id, record.timestamp, record.category, record.model,
record.prompt_tokens, record.completion_tokens, record.cost_usd,
record.caller_name, record.latency_ms,
)
except Exception:
pass # don't break show over analytics
Step 2: Add dual-write to record_tts_call
In record_tts_call() (around line 166, after appending to self.tts_records), add:
try:
cost_db.record_tts_call(
self._session_id, record.timestamp, record.provider, record.voice,
record.char_count, record.cost_usd,
)
except Exception:
pass
Step 3: Add session_id tracking to __init__
Add self._session_id to the constructor. Generate it from timestamp:
self._session_id = f"session-{datetime.now().strftime('%Y-%m-%d_%H%M%S')}"
Step 4: Update session totals on save()
In the save() method, after writing the JSON file, add:
try:
cost_db.update_session_totals(self._session_id)
except Exception:
pass
Step 5: Commit
git add backend/services/cost_tracker.py
git commit -m "Dual-write cost records to SQLite"
Task 3: API Endpoints
Files:
- Modify:
backend/main.py(add routes near existing/api/costsendpoints around line 10299)
Step 1: Add new cost dashboard endpoints
Add these routes near the existing cost endpoints (around line 10308):
from backend.services import cost_db
@app.get("/api/costs/summary")
async def get_cost_summary(period: str = "all"):
return cost_db.get_summary(period)
@app.get("/api/costs/timeline")
async def get_cost_timeline(period: str = "all", group_by: str = "session"):
return cost_db.get_timeline(period, group_by)
@app.get("/api/costs/models")
async def get_cost_models(period: str = "all"):
return cost_db.get_models(period)
@app.get("/api/costs/categories")
async def get_cost_categories(period: str = "all"):
return cost_db.get_categories(period)
@app.get("/api/costs/sessions")
async def get_cost_sessions(period: str = "all"):
return cost_db.get_sessions_list(period)
@app.get("/api/costs/session/{session_id}")
async def get_cost_session_detail(session_id: str):
detail = cost_db.get_session_detail(session_id)
if not detail:
from fastapi.responses import JSONResponse
return JSONResponse(status_code=404, content={"error": "Session not found"})
return detail
@app.get("/api/costs/expensive")
async def get_expensive_calls(period: str = "all", limit: int = 10):
return cost_db.get_expensive_calls(period, limit)
Step 2: Add route to serve the costs page
Near the existing root route (around line 7654), add:
@app.get("/costs")
async def costs_page():
return FileResponse(frontend_dir / "costs.html")
Step 3: Verify endpoints respond
Run the server: python -m uvicorn backend.main:app --reload --reload-dir backend --host 0.0.0.0 --port 8000
Test: curl -s http://localhost:8000/api/costs/summary?period=all | python -m json.tool
Expected: JSON with total_cost, llm_cost, tts_cost, sessions, etc.
Step 4: Commit
git add backend/main.py
git commit -m "Add cost dashboard API endpoints"
Task 4: Dashboard HTML
Files:
- Create:
frontend/costs.html
Step 1: Create the dashboard page
Create frontend/costs.html — standalone HTML page with:
- Chart.js from CDN (
https://cdn.jsdelivr.net/npm/chart.js) - Link to
css/style.css(shared theme) andcss/costs.css(dashboard-specific) - Script tag for
js/costs.js - Structure: header with time range tabs, 4 summary cards, 4 chart containers, 2 tables
- Use the same CSS variables as the control panel (
--bg,--bg-light,--accent,--text, etc.)
Layout structure:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cost Dashboard - Luke at the Roost</title>
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/costs.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body>
<header class="costs-header">
<h1>Cost Dashboard</h1>
<a href="/" class="back-link">Back to Show</a>
<nav class="period-tabs">
<button class="period-tab active" data-period="all">All Time</button>
<button class="period-tab" data-period="month">This Month</button>
<button class="period-tab" data-period="week">This Week</button>
<button class="period-tab" data-period="today">Today</button>
</nav>
</header>
<main class="costs-main">
<section class="summary-cards">
<div class="card" id="card-total">
<div class="card-label">Total Spend</div>
<div class="card-value" id="total-spend">--</div>
<div class="card-change" id="total-change"></div>
</div>
<div class="card" id="card-llm-tts">
<div class="card-label">LLM / TTS</div>
<div class="card-value" id="llm-tts-split">--</div>
</div>
<div class="card" id="card-sessions">
<div class="card-label">Sessions</div>
<div class="card-value" id="session-count">--</div>
</div>
<div class="card" id="card-avg">
<div class="card-label">Avg / Session</div>
<div class="card-value" id="avg-cost">--</div>
</div>
</section>
<section class="chart-row">
<div class="chart-container">
<h3>Cost Over Time</h3>
<canvas id="timeline-chart"></canvas>
</div>
<div class="chart-container">
<h3>Cost by Model</h3>
<canvas id="model-chart"></canvas>
</div>
</section>
<section class="chart-row">
<div class="chart-container">
<h3>Cost by Category</h3>
<canvas id="category-chart"></canvas>
</div>
<div class="chart-container">
<h3>Cost Per Session</h3>
<canvas id="session-chart"></canvas>
</div>
</section>
<section class="tables-section">
<div class="table-container">
<h3>Most Expensive Calls</h3>
<table id="expensive-table">
<thead>
<tr><th>Model</th><th>Category</th><th>Caller</th><th>Tokens</th><th>Cost</th><th>Latency</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="table-container">
<h3>Sessions</h3>
<table id="sessions-table">
<thead>
<tr><th>Date</th><th>LLM</th><th>TTS</th><th>Total</th><th>Calls</th><th></th></tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<section class="session-detail hidden" id="session-detail">
<h2>Session Detail: <span id="detail-session-id"></span></h2>
<button class="close-detail" id="close-detail">Back</button>
<div class="detail-grid">
<div class="table-container">
<h3>By Caller</h3>
<table id="detail-caller-table">
<thead><tr><th>Caller</th><th>Calls</th><th>Cost</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="table-container">
<h3>By Category</h3>
<table id="detail-category-table">
<thead><tr><th>Category</th><th>Calls</th><th>Cost</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="table-container">
<h3>By Model</h3>
<table id="detail-model-table">
<thead><tr><th>Model</th><th>Calls</th><th>Cost</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="table-container">
<h3>Most Expensive Calls</h3>
<table id="detail-expensive-table">
<thead><tr><th>Category</th><th>Model</th><th>Caller</th><th>Cost</th><th>Tokens</th><th>Latency</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
</section>
</main>
<script src="/js/costs.js"></script>
</body>
</html>
Step 2: Commit
git add frontend/costs.html
git commit -m "Add cost dashboard HTML page"
Task 5: Dashboard CSS
Files:
- Create:
frontend/css/costs.css
Step 1: Create dashboard-specific styles
Create frontend/css/costs.css using the existing CSS variables from style.css. Key styles:
.costs-header— flex row with title, back link, and period tabs.period-tabs/.period-tab— tab buttons, active state uses--accent.summary-cards— 4-column grid.card—background: var(--bg-light), border, rounded corners matching--radius.card-value— large font,color: var(--text).card-change— small text, green for negative (saving), red for positive (increase).chart-row— 2-column grid.chart-container— padded card with canvas.table-container— styled tables matching dark theme.session-detail— full-width detail view- Responsive: single column below 768px
Use var(--bg), var(--bg-light), var(--accent), var(--text), var(--text-muted), var(--radius), var(--radius-sm), var(--transition) throughout.
Step 2: Commit
git add frontend/css/costs.css
git commit -m "Add cost dashboard CSS"
Task 6: Dashboard JavaScript
Files:
- Create:
frontend/js/costs.js
Step 1: Create the dashboard JS
Create frontend/js/costs.js with:
State:
let currentPeriod = 'all';
let charts = {}; // store Chart.js instances for destroy/recreate
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();
});
Data loading — loadDashboard():
- Fetch all endpoints in parallel: summary, timeline, models, categories, sessions, expensive
- Call render functions for each section
Render functions:
renderSummary(data)— populate the 4 summary cards, format as$X.XX, show % change with colorrenderTimeline(data)— Chart.js line chart with LLM and TTS as separate datasets,--accentand--devoncolorsrenderModels(data)— Chart.js doughnut chart with model name labelsrenderCategories(data)— Chart.js horizontal bar chartrenderSessionBars(data)— Chart.js bar chart, bars colored based on above/below averagerenderExpensiveTable(data)— populate table rowsrenderSessionsTable(data)— populate table rows with click handler to show detailshowSessionDetail(sessionId)— fetch/api/costs/session/{id}, show detail section, populate tablescloseDetail()— hide detail section
Chart.js config notes:
- Use dark theme: grid lines
rgba(245, 240, 229, 0.1)(--text at 10%), tick colorvar(--text-muted) - Chart colors palette:
#e8791d(accent),#c4944a(devon),#5a8a3c(green),#cc2222(red),#4a8ac4(blue),#8a5ac4(purple),#c4845a(tan) - Tooltips: dark background, light text
- Destroy existing chart instance before creating new one (prevents memory leaks on period switch)
Utility:
formatCost(n)— returns$X.XXor$X.XXXXfor small amountsformatDate(iso)— returns readable dateshortenModel(name)— strip provider prefix from model names for chart labels
Step 2: Commit
git add frontend/js/costs.js
git commit -m "Add cost dashboard JavaScript with Chart.js"
Task 7: Integration Test
Step 1: Manual verification checklist
Start the server and navigate to http://localhost:8000/costs:
- Page loads with dark theme, no console errors
- All Time tab is active by default, shows all 18 sessions
- Summary cards show total spend, LLM/TTS split, session count, avg cost
- Cost Over Time line chart renders with data points
- Cost by Model doughnut shows model breakdown
- Cost by Category bar chart shows category breakdown
- Cost Per Session bars render with above/below-average coloring
- Most Expensive Calls table populated
- Sessions table populated, rows clickable
- Click a session row → detail view shows with per-caller, per-model, per-category tables
- Click "Back" → returns to main dashboard
- Switch to "This Week" tab → all charts update (may show fewer/no data)
- Switch to "This Month" → charts update with March data
- Switch back to "All Time" → full data restored
Step 2: Commit all remaining changes
git add -A
git commit -m "Cost dashboard complete — SQLite backend, Chart.js frontend"
File Summary
| Action | File |
|---|---|
| Create | backend/services/cost_db.py |
| Modify | backend/services/cost_tracker.py |
| Modify | backend/main.py |
| Create | frontend/costs.html |
| Create | frontend/css/costs.css |
| Create | frontend/js/costs.js |
Dependencies
- Chart.js loaded from CDN (no npm install needed)
- SQLite is stdlib (no pip install needed)
- No new Python packages required