Files
ai-podcast/docs/plans/2026-04-02-cost-dashboard-implementation.md
lukeandClaude Opus 5 9071ed36d3 Track design and implementation plans
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>
2026-08-14 04:23:53 -05:00

811 lines
30 KiB
Markdown

# 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 connection
- `import_json_reports(db_path, reports_dir)` — scans `data/cost_reports/*.json`, imports any sessions not already in the DB
- `get_db()` — returns a connection to `data/costs.db`, calls `init_db` on first use
```python
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**
```bash
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:
```python
from backend.services import cost_db
```
In `record_llm_call()` (around line 148, after appending to `self.llm_records`), add:
```python
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:
```python
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:
```python
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:
```python
try:
cost_db.update_session_totals(self._session_id)
except Exception:
pass
```
**Step 5: Commit**
```bash
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/costs` endpoints around line 10299)
**Step 1: Add new cost dashboard endpoints**
Add these routes near the existing cost endpoints (around line 10308):
```python
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:
```python
@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**
```bash
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) and `css/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:
```html
<!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**
```bash
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**
```bash
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:**
```javascript
let currentPeriod = 'all';
let charts = {}; // store Chart.js instances for destroy/recreate
```
**Init:**
```javascript
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 color
- `renderTimeline(data)` — Chart.js line chart with LLM and TTS as separate datasets, `--accent` and `--devon` colors
- `renderModels(data)` — Chart.js doughnut chart with model name labels
- `renderCategories(data)` — Chart.js horizontal bar chart
- `renderSessionBars(data)` — Chart.js bar chart, bars colored based on above/below average
- `renderExpensiveTable(data)` — populate table rows
- `renderSessionsTable(data)` — populate table rows with click handler to show detail
- `showSessionDetail(sessionId)` — fetch `/api/costs/session/{id}`, show detail section, populate tables
- `closeDetail()` — hide detail section
**Chart.js config notes:**
- Use dark theme: grid lines `rgba(245, 240, 229, 0.1)` (--text at 10%), tick color `var(--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.XX` or `$X.XXXX` for small amounts
- `formatDate(iso)` — returns readable date
- `shortenModel(name)` — strip provider prefix from model names for chart labels
**Step 2: Commit**
```bash
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`:
1. Page loads with dark theme, no console errors
2. All Time tab is active by default, shows all 18 sessions
3. Summary cards show total spend, LLM/TTS split, session count, avg cost
4. Cost Over Time line chart renders with data points
5. Cost by Model doughnut shows model breakdown
6. Cost by Category bar chart shows category breakdown
7. Cost Per Session bars render with above/below-average coloring
8. Most Expensive Calls table populated
9. Sessions table populated, rows clickable
10. Click a session row → detail view shows with per-caller, per-model, per-category tables
11. Click "Back" → returns to main dashboard
12. Switch to "This Week" tab → all charts update (may show fewer/no data)
13. Switch to "This Month" → charts update with March data
14. Switch back to "All Time" → full data restored
**Step 2: Commit all remaining changes**
```bash
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