Files
ai-podcast/docs/plans/2026-04-02-cost-dashboard-implementation.md
T
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

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 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
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/costs endpoints 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) 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:

<!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
  • .cardbackground: 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 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

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

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