Clone https://github.com/arsenalawi2/claude-dashboard.git, run npm run setup, then start the backend with my name
  and the leaderboard URL. Replace MY_NAME with my actual name (no spaces).

  ▎ cd claude-dashboard/backend
  ▎ PLAYER_NAME=MY_NAME LEADERBOARD_URL=https://hadis-mac-mini.tailf8f871.ts.net:10000 python app.py


## plugin:context7:context7
Use this server to fetch current documentation whenever the user asks about a library, framework, SDK, API, CLI tool, or cloud service -- even well-known ones like React, Next.js, Prisma, Express, Tailwind, Django, or Spring Boot. This includes API syntax, configuration, version migration, library-specific debugging, setup instructions, and CLI tool usage. Use even when you think you know the answer -- your training data may not reflect recent changes. Prefer this over web search for library docs.

Do not use for: refactoring, writing scripts from scratch, debugging business logic, code review, or general programming concepts.


Let me check your name from memory first.


Error: File does not exist. Note: your current working directory is /Users/khalidbinkalban.


Your username is `khalidbinkalban` — I'll use `khalidbinkalban` as `PLAYER_NAME`. Let me clone and set up now.


Bash

git clone https://github.com/arsenalawi2/claude-dashboard.git 2>&1

Clone claude-dashboard repo


Cloning into 'claude-dashboard'...


cd /Users/khalidbinkalban/claude-dashboard && npm run setup 2>&1

Run npm run setup


Error: Exit code 1

> setup
> cd frontend && npm install && cd ../backend && pip install -r requirements.txt

npm error code ERESOLVE
npm error ERESOLVE could not resolve
npm error
npm error While resolving: @vitejs/plugin-vue@5.2.4
npm error Found: vite@7.3.1
npm error node_modules/vite
npm error   dev vite@"^7.2.4" from the root project
npm error
npm error Could not resolve dependency:
npm error peer vite@"^5.0.0 || ^6.0.0" from @vitejs/plugin-vue@5.2.4
npm error node_modules/@vitejs/plugin-vue
npm error   dev @vitejs/plugin-vue@"^5.2.3" from the root project
npm error
npm error Conflicting peer dependency: vite@6.4.1
npm error node_modules/vite
npm error   peer vite@"^5.0.0 || ^6.0.0" from @vitejs/plugin-vue@5.2.4
npm error   node_modules/@vitejs/plugin-vue
npm error     dev @vitejs/plugin-vue@"^5.2.3" from the root project
npm error
npm error Fix the upstream dependency conflict, or retry
npm error this command with --force or --legacy-peer-deps
npm error to accept an incorrect (and potentially broken) dependency resolution.
npm error
npm error
npm error For a full report see:
npm error /Users/khalidbinkalban/.npm/_logs/2026-04-05T06_09_06_773Z-eresolve-report.txt
npm error A complete log of this run can be found in: /Users/khalidbinkalban/.npm/_logs/2026-04-05T06_09_06_773Z-debug-0.log


Frontend has a peer dependency conflict. I'll use `--legacy-peer-deps` and also install the backend deps:


cd /Users/khalidbinkalban/claude-dashboard/frontend && npm install --legacy-peer-deps 2>&1

Install frontend deps with legacy peer deps


added 57 packages, and audited 58 packages in 2s

12 packages are looking for funding
  run `npm fund` for details

1 high severity vulnerability

To address all issues, run:
  npm audit fix

Run `npm audit` for details.


cd /Users/khalidbinkalban/claude-dashboard/backend && pip install -r requirements.txt 2>&1

Install backend Python dependencies


Error: Exit code 127
(eval):1: command not found: pip


cd /Users/khalidbinkalban/claude-dashboard/backend && pip3 install -r requirements.txt 2>&1

Install backend Python dependencies with pip3


Defaulting to user installation because normal site-packages is not writeable
Requirement already satisfied: fastapi>=0.115.0 in /Users/khalidbinkalban/Library/Python/3.9/lib/python/site-packages (from -r requirements.txt (line 1)) (0.128.8)
Collecting uvicorn>=0.34.0
  Downloading uvicorn-0.39.0-py3-none-any.whl (68 kB)
Requirement already satisfied: pydantic>=2.7.0 in /Users/khalidbinkalban/Library/Python/3.9/lib/python/site-packages (from fastapi>=0.115.0->-r requirements.txt (line 1)) (2.12.5)
Requirement already satisfied: annotated-doc>=0.0.2 in /Users/khalidbinkalban/Library/Python/3.9/lib/python/site-packages (from fastapi>=0.115.0->-r requirements.txt (line 1)) (0.0.4)
Requirement already satisfied: typing-inspection>=0.4.2 in /Users/khalidbinkalban/Library/Python/3.9/lib/python/site-packages (from fastapi>=0.115.0->-r requirements.txt (line 1)) (0.4.2)
Requirement already satisfied: starlette<1.0.0,>=0.40.0 in /Users/khalidbinkalban/Library/Python/3.9/lib/python/site-packages (from fastapi>=0.115.0->-r requirements.txt (line 1)) (0.49.3)
Requirement already satisfied: typing-extensions>=4.8.0 in /Users/khalidbinkalban/Library/Python/3.9/lib/python/site-packages (from fastapi>=0.115.0->-r requirements.txt (line 1)) (4.15.0)
Requirement already satisfied: h11>=0.8 in /Users/khalidbinkalban/Library/Python/3.9/lib/python/site-packages (from uvicorn>=0.34.0->-r requirements.txt (line 2)) (0.16.0)
Collecting click>=7.0
  Using cached click-8.1.8-py3-none-any.whl (98 kB)
Requirement already satisfied: pydantic-core==2.41.5 in /Users/khalidbinkalban/Library/Python/3.9/lib/python/site-packages (from pydantic>=2.7.0->fastapi>=0.115.0->-r requirements.txt (line 1)) (2.41.5)
Requirement already satisfied: annotated-types>=0.6.0 in /Users/khalidbinkalban/Library/Python/3.9/lib/python/site-packages (from pydantic>=2.7.0->fastapi>=0.115.0->-r requirements.txt (line 1)) (0.7.0)
Requirement already satisfied: anyio<5,>=3.6.2 in /Users/khalidbinkalban/Library/Python/3.9/lib/python/site-packages (from starlette<1.0.0,>=0.40.0->fastapi>=0.115.0->-r requirements.txt (line 1)) (4.12.1)
Requirement already satisfied: exceptiongroup>=1.0.2 in /Users/khalidbinkalban/Library/Python/3.9/lib/python/site-packages (from anyio<5,>=3.6.2->starlette<1.0.0,>=0.40.0->fastapi>=0.115.0->-r requirements.txt (line 1)) (1.3.1)
Requirement already satisfied: idna>=2.8 in /Users/khalidbinkalban/Library/Python/3.9/lib/python/site-packages (from anyio<5,>=3.6.2->starlette<1.0.0,>=0.40.0->fastapi>=0.115.0->-r requirements.txt (line 1)) (3.11)
Installing collected packages: click, uvicorn
  WARNING: The script uvicorn is installed in '/Users/khalidbinkalban/Library/Python/3.9/bin' which is not on PATH.
  Consider adding this directory to PATH or, if you prefer to suppress this warning, use --no-warn-script-location.
Successfully installed click-8.1.8 uvicorn-0.39.0
WARNING: You are using pip version 21.2.4; however, version 26.0.1 is available.
You should consider upgrading via the '/Library/Developer/CommandLineTools/usr/bin/python3 -m pip install --upgrade pip' command.


Setup done. Now starting the backend:


PLAYER_NAME=khalidbinkalban LEADERBOARD_URL=https://hadis-mac-mini.tailf8f871.ts.net:10000 python3 app.py 2>&1 &
echo "PID: $!"

Start backend in background


Command running in background with ID: bd79kx0y6. Output is being written to: /private/tmp/claude-501/-Users-khalidbinkalban/28892ad8-bdf8-4f33-82c8-f0983e35341f/tasks/bd79kx0y6.output


<task-notification>
<task-id>bd79kx0y6</task-id>
<tool-use-id>toolu_01CTnz9bqVouWQJj8ScQixyi</tool-use-id>
<output-file>/private/tmp/claude-501/-Users-khalidbinkalban/28892ad8-bdf8-4f33-82c8-f0983e35341f/tasks/bd79kx0y6.output</output-file>
<status>completed</status>
<summary>Background command "Start backend in background" completed (exit code 0)</summary>
</task-notification>


sleep 3 && cat /private/tmp/claude-501/-Users-khalidbinkalban/28892ad8-bdf8-4f33-82c8-f0983e35341f/tasks/bd79kx0y6.output 2>&1

Check backend startup output


PID: 76303
Traceback (most recent call last):
  File "/Users/khalidbinkalban/claude-dashboard/backend/app.py", line 27, in <module>
    class PlayerStats(BaseModel):
  File "/Users/khalidbinkalban/claude-dashboard/backend/app.py", line 42, in PlayerStats
    earliest_session: str | None = None
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'


Python 3.9 doesn't support the `X | None` union syntax (requires 3.10+). Let me check and fix the app:


1	"""
2	Claude Code Usage Dashboard — FastAPI Backend
3	Parses ~/.claude/ session data, serves stats via REST API.
4	Leaderboard: friends' dashboards auto-push stats here.
5	"""
6
7	import json
8	import os
9	from contextlib import asynccontextmanager
10	from datetime import datetime, timezone
11	from pathlib import Path
12
13	from fastapi import FastAPI
14	from fastapi.middleware.cors import CORSMiddleware
15	from fastapi.staticfiles import StaticFiles
16	from pydantic import BaseModel
17
18	from database import get_db, init_db
19	from parser import full_parse, estimate_cost_for_tokens, PRICING
20
21	# Leaderboard config — friends set these env vars
22	PLAYER_NAME = os.environ.get("PLAYER_NAME", "")
23	LEADERBOARD_URL = os.environ.get("LEADERBOARD_URL", "")
24	LEADERBOARD_ONLY = os.environ.get("LEADERBOARD_ONLY", "").lower() in ("1", "true", "yes")
25
26
27	class PlayerStats(BaseModel):
28	    name: str
29	    total_prompts: int = 0
30	    total_api_calls: int = 0
31	    total_active_hours: float = 0
32	    total_input_tokens: int = 0
33	    total_output_tokens: int = 0
34	    total_cache_read: int = 0
35	    total_cache_write: int = 0
36	    total_lines_written: int = 0
37	    total_cost: float = 0
38	    total_sessions: int = 0
39	    total_projects: int = 0
40	    model_usage: dict = {}
41	    projects_data: list = []
42	    earliest_session: str | None = None
43	    latest_session: str | None = None
44
45
46	def _get_my_stats() -> dict:
47	    """Build stats payload for leaderboard submission."""
48	    conn = get_db()
49	    row = conn.execute("""
50	        SELECT COUNT(*) as total_sessions,
51	            COALESCE(SUM(human_prompts), 0) as total_prompts,
52	            COALESCE(SUM(api_calls + subagent_api_calls), 0) as total_api_calls,
53	            COALESCE(SUM(active_seconds), 0) as total_active_seconds,
54	            COALESCE(SUM(input_tokens), 0) as total_input_tokens,
55	            COALESCE(SUM(output_tokens), 0) as total_output_tokens,
56	            COALESCE(SUM(cache_read_tokens), 0) as total_cache_read,
57	            COALESCE(SUM(cache_write_tokens), 0) as total_cache_write,
58	            COALESCE(SUM(lines_written), 0) as total_lines_written,
59	            COALESCE(SUM(cost), 0) as total_cost,
60	            MIN(first_msg_at) as earliest_session,
61	            MAX(last_msg_at) as latest_session
62	        FROM sessions
63	    """).fetchone()
64	    total_projects = conn.execute("SELECT COUNT(*) as c FROM projects").fetchone()["c"]
65	    all_sessions = conn.execute("SELECT model_usage FROM sessions").fetchall()
66	    model_totals = {}
67	    for s in all_sessions:
68	        for m, c in json.loads(s["model_usage"]).items():
69	            model_totals[m] = model_totals.get(m, 0) + c
70
71	    # Per-project breakdown
72	    projects = conn.execute("""
73	        SELECT p.name, COUNT(s.id) as sessions,
74	            COALESCE(SUM(s.human_prompts), 0) as prompts,
75	            COALESCE(SUM(s.api_calls + s.subagent_api_calls), 0) as api_calls,
76	            COALESCE(SUM(s.active_seconds), 0) as active_seconds,
77	            COALESCE(SUM(s.input_tokens), 0) as input_tokens,
78	            COALESCE(SUM(s.output_tokens), 0) as output_tokens,
79	            COALESCE(SUM(s.cache_read_tokens), 0) as cache_read,
80	            COALESCE(SUM(s.cache_write_tokens), 0) as cache_write,
81	            COALESCE(SUM(s.lines_written), 0) as lines_written,
82	            COALESCE(SUM(s.cost), 0) as cost
83	        FROM projects p
84	        LEFT JOIN sessions s ON s.project_id = p.id
85	        GROUP BY p.id
86	        ORDER BY cost DESC
87	    """).fetchall()
88
89	    projects_data = [
90	        {
91	            "name": p["name"], "sessions": p["sessions"], "prompts": p["prompts"],
92	            "api_calls": p["api_calls"], "active_hours": round(p["active_seconds"] / 3600, 2),
93	            "input_tokens": p["input_tokens"], "output_tokens": p["output_tokens"],
94	            "cache_read": p["cache_read"], "cache_write": p["cache_write"],
95	            "lines_written": p["lines_written"], "cost": round(p["cost"], 2),
96	        }
97	        for p in projects
98	    ]
99
100	    conn.close()
101	    return {
102	        "total_prompts": row["total_prompts"],
103	        "total_api_calls": row["total_api_calls"],
104	        "total_active_hours": round(row["total_active_seconds"] / 3600, 2),
105	        "total_input_tokens": row["total_input_tokens"],
106	        "total_output_tokens": row["total_output_tokens"],
107	        "total_cache_read": row["total_cache_read"],
108	        "total_cache_write": row["total_cache_write"],
109	        "total_lines_written": row["total_lines_written"],
110	        "total_cost": round(row["total_cost"], 2),
111	        "total_sessions": row["total_sessions"],
112	        "total_projects": total_projects,
113	        "model_usage": model_totals,
114	        "projects_data": projects_data,
115	        "earliest_session": row["earliest_session"],
116	        "latest_session": row["latest_session"],
117	    }
118
119
120	def _push_to_leaderboard():
121	    """Push stats to the central leaderboard server."""
122	    if not LEADERBOARD_URL or not PLAYER_NAME:
123	        return
124	    try:
125	        import urllib.request
126	        stats = _get_my_stats()
127	        stats["name"] = PLAYER_NAME
128	        data = json.dumps(stats).encode()
129	        req = urllib.request.Request(
130	            f"{LEADERBOARD_URL.rstrip('/')}/api/leaderboard/submit",
131	            data=data,
132	            headers={"Content-Type": "application/json"},
133	            method="POST",
134	        )
135	        urllib.request.urlopen(req, timeout=10)
136	        print(f"Leaderboard push OK → {LEADERBOARD_URL} as '{PLAYER_NAME}'")
137	    except Exception as e:
138	        print(f"Leaderboard push failed: {e}")
139
140
141	def _upsert_player(conn, name, stats):
142	    """Insert or update a player in the leaderboard table."""
143	    conn.execute("""
144	        INSERT INTO players (name, total_prompts, total_api_calls, total_active_hours,
145	            total_input_tokens, total_output_tokens, total_cache_read, total_cache_write,
146	            total_lines_written, total_cost, total_sessions, total_projects,
147	            model_usage, projects_data, earliest_session, latest_session, last_seen)
148	        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
149	        ON CONFLICT(name) DO UPDATE SET
150	            total_prompts=excluded.total_prompts, total_api_calls=excluded.total_api_calls,
151	            total_active_hours=excluded.total_active_hours,
152	            total_input_tokens=excluded.total_input_tokens, total_output_tokens=excluded.total_output_tokens,
153	            total_cache_read=excluded.total_cache_read, total_cache_write=excluded.total_cache_write,
154	            total_lines_written=excluded.total_lines_written, total_cost=excluded.total_cost,
155	            total_sessions=excluded.total_sessions, total_projects=excluded.total_projects,
156	            model_usage=excluded.model_usage, projects_data=excluded.projects_data,
157	            earliest_session=excluded.earliest_session, latest_session=excluded.latest_session,
158	            last_seen=excluded.last_seen
159	    """, (
160	        name, stats["total_prompts"], stats["total_api_calls"], stats["total_active_hours"],
161	        stats["total_input_tokens"], stats["total_output_tokens"],
162	        stats["total_cache_read"], stats["total_cache_write"],
163	        stats["total_lines_written"], stats["total_cost"],
164	        stats["total_sessions"], stats["total_projects"],
165	        json.dumps(stats.get("model_usage", {})),
166	        json.dumps(stats.get("projects_data", [])),
167	        stats["earliest_session"], stats["latest_session"],
168	        datetime.now(timezone.utc).isoformat(),
169	    ))
170	    conn.commit()
171
172
173	def _submit_self_to_leaderboard():
174	    """Submit own stats to the local leaderboard table (if PLAYER_NAME is set)."""
175	    if not PLAYER_NAME:
176	        return
177	    stats = _get_my_stats()
178	    conn = get_db()
179	    _upsert_player(conn, PLAYER_NAME, stats)
180	    conn.commit()
181	    conn.close()
182
183
184	@asynccontextmanager
185	async def lifespan(app: FastAPI):
186	    init_db()
187
188	    if not LEADERBOARD_ONLY:
189	        conn = get_db()
190	        full_parse(conn)
191	        conn.close()
192	        _submit_self_to_leaderboard()
193	        _push_to_leaderboard()
194	    elif PLAYER_NAME:
195	        # Leaderboard-only mode still registers self if PLAYER_NAME is set
196	        _submit_self_to_leaderboard()
197
198	    import asyncio
199	    async def auto_refresh():
200	        while True:
201	            await asyncio.sleep(3600)
202	            try:
203	                if not LEADERBOARD_ONLY:
204	                    conn = get_db()
205	                    full_parse(conn)
206	                    conn.close()
207	                    _submit_self_to_leaderboard()
208	                    _push_to_leaderboard()
209	                    print("Hourly auto-refresh complete")
210	            except Exception as e:
211	                print(f"Auto-refresh failed: {e}")
212
213	    task = asyncio.create_task(auto_refresh())
214	    yield
215	    task.cancel()
216
217
218	app = FastAPI(title="Claude Dashboard", lifespan=lifespan)
219
220	app.add_middleware(
221	    CORSMiddleware,
222	    allow_origins=["*"],
223	    allow_methods=["*"],
224	    allow_headers=["*"],
225	)
226
227
228	from fastapi.responses import JSONResponse
229
230	def _blocked():
231	    return JSONResponse({"error": "not available"}, status_code=404)
232
233
234	@app.get("/api/overview")
235	def overview():
236	    if LEADERBOARD_ONLY:
237	        return _blocked()
238	    """Global stats across all projects."""
239	    conn = get_db()
240	    row = conn.execute("""
241	        SELECT
242	            COUNT(*) as total_sessions,
243	            COALESCE(SUM(human_prompts), 0) as total_prompts,
244	            COALESCE(SUM(api_calls + subagent_api_calls), 0) as total_api_calls,
245	            COALESCE(SUM(active_seconds), 0) as total_active_seconds,
246	            COALESCE(SUM(wall_seconds), 0) as total_wall_seconds,
247	            COALESCE(SUM(input_tokens), 0) as total_input_tokens,
248	            COALESCE(SUM(output_tokens), 0) as total_output_tokens,
249	            COALESCE(SUM(cache_read_tokens), 0) as total_cache_read,
250	            COALESCE(SUM(cache_write_tokens), 0) as total_cache_write,
251	            COALESCE(SUM(lines_written), 0) as total_lines_written,
252	            COALESCE(SUM(cost), 0) as total_cost,
253	            MIN(first_msg_at) as earliest_session,
254	            MAX(last_msg_at) as latest_session
255	        FROM sessions
256	    """).fetchone()
257
258	    projects = conn.execute("""
259	        SELECT p.id, p.name, COUNT(s.id) as session_count,
260	            COALESCE(SUM(s.human_prompts), 0) as prompts,
261	            COALESCE(SUM(s.api_calls + s.subagent_api_calls), 0) as api_calls,
262	            COALESCE(SUM(s.active_seconds), 0) as active_seconds,
263	            COALESCE(SUM(s.input_tokens), 0) as input_tokens,
264	            COALESCE(SUM(s.output_tokens), 0) as output_tokens,
265	            COALESCE(SUM(s.cache_read_tokens), 0) as cache_read,
266	            COALESCE(SUM(s.cache_write_tokens), 0) as cache_write,
267	            COALESCE(SUM(s.lines_written), 0) as lines_written,
268	            COALESCE(SUM(s.cost), 0) as cost
269	        FROM projects p
270	        LEFT JOIN sessions s ON s.project_id = p.id
271	        GROUP BY p.id
272	        ORDER BY active_seconds DESC
273	    """).fetchall()
274
275	    project_list = []
276	    for p in projects:
277	        project_list.append({
278	            "id": p["id"],
279	            "name": p["name"],
280	            "sessions": p["session_count"],
281	            "prompts": p["prompts"],
282	            "api_calls": p["api_calls"],
283	            "active_hours": round(p["active_seconds"] / 3600, 2),
284	            "input_tokens": p["input_tokens"],
285	            "output_tokens": p["output_tokens"],
286	            "cache_read": p["cache_read"],
287	            "cache_write": p["cache_write"],
288	            "lines_written": p["lines_written"],
289	            "cost": round(p["cost"], 2),
290	        })
291
292	    conn.close()
293	    return {
294	        "total_sessions": row["total_sessions"],
295	        "total_prompts": row["total_prompts"],
296	        "total_api_calls": row["total_api_calls"],
297	        "total_active_hours": round(row["total_active_seconds"] / 3600, 2),
298	        "total_wall_hours": round(row["total_wall_seconds"] / 3600, 2),
299	        "total_input_tokens": row["total_input_tokens"],
300	        "total_output_tokens": row["total_output_tokens"],
301	        "total_cache_read": row["total_cache_read"],
302	        "total_cache_write": row["total_cache_write"],
303	        "total_lines_written": row["total_lines_written"],
304	        "total_cost": round(row["total_cost"], 2),
305	        "earliest_session": row["earliest_session"],
306	        "latest_session": row["latest_session"],
307	        "projects": project_list,
308	    }
309
310
311	@app.get("/api/projects/{project_id}")
312	def project_detail(project_id: int):
313	    """Detailed stats for a single project."""
314	    if LEADERBOARD_ONLY:
315	        return _blocked()
316	    conn = get_db()
317	    proj = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone()
318	    if not proj:
319	        conn.close()
320	        return {"error": "Project not found"}
321
322	    sessions = conn.execute("""
323	        SELECT * FROM sessions WHERE project_id = ?
324	        ORDER BY first_msg_at DESC
325	    """, (project_id,)).fetchall()
326
327	    # Daily activity — compute cost per-row using prompts table (assistant rows have model)
328	    daily = conn.execute("""
329	        SELECT DATE(timestamp) as day,
330	            COUNT(CASE WHEN type='human' THEN 1 END) as prompts,
331	            COUNT(CASE WHEN type='assistant' THEN 1 END) as api_calls,
332	            SUM(input_tokens) as input_tokens,
333	            SUM(output_tokens) as output_tokens,
334	            SUM(cache_read_tokens) as cache_read,
335	            SUM(cache_write_tokens) as cache_write
336	        FROM prompts
337	        WHERE project_id = ?
338	        GROUP BY DATE(timestamp)
339	        ORDER BY day
340	    """, (project_id,)).fetchall()
341
342	    # Recent prompts
343	    recent = conn.execute("""
344	        SELECT timestamp, type, model, content_preview,
345	            input_tokens, output_tokens, cache_read_tokens, cache_write_tokens
346	        FROM prompts
347	        WHERE project_id = ? AND type = 'human'
348	        ORDER BY timestamp DESC
349	        LIMIT 50
350	    """, (project_id,)).fetchall()
351
352	    session_list = []
353	    for s in sessions:
354	        session_list.append({
355	            "id": s["id"],
356	            "first_msg_at": s["first_msg_at"],
357	            "last_msg_at": s["last_msg_at"],
358	            "wall_hours": round(s["wall_seconds"] / 3600, 2),
359	            "active_hours": round(s["active_seconds"] / 3600, 2),
360	            "human_prompts": s["human_prompts"],
361	            "api_calls": s["api_calls"],
362	            "subagent_api_calls": s["subagent_api_calls"],
363	            "input_tokens": s["input_tokens"],
364	            "output_tokens": s["output_tokens"],
365	            "cache_read": s["cache_read_tokens"],
366	            "cache_write": s["cache_write_tokens"],
367	            "lines_written": s["lines_written"] if "lines_written" in s.keys() else 0,
368	            "model_usage": json.loads(s["model_usage"]),
369	            "cost": round(s["cost"], 2),
370	        })
371
372	    daily_list = []
373	    for d in daily:
374	        # Daily cost: sum from per-prompt assistant rows for that day
375	        day_cost_row = conn.execute("""
376	            SELECT SUM(
377	                CASE
378	                    WHEN model LIKE '%haiku%' THEN
379	                        input_tokens * ? + output_tokens * ? +
380	                        cache_read_tokens * ? + cache_write_tokens * ?
381	                    WHEN model LIKE '%sonnet%' THEN
382	                        input_tokens * ? + output_tokens * ? +
383	                        cache_read_tokens * ? + cache_write_tokens * ?
384	                    ELSE
385	                        input_tokens * ? + output_tokens * ? +
386	                        cache_read_tokens * ? + cache_write_tokens * ?
387	                END
388	            ) as cost
389	            FROM prompts
390	            WHERE project_id = ? AND DATE(timestamp) = ? AND type = 'assistant'
391	        """, (
392	            PRICING["haiku"]["input"], PRICING["haiku"]["output"],
393	            PRICING["haiku"]["cache_read"], PRICING["haiku"]["cache_write"],
394	            PRICING["sonnet"]["input"], PRICING["sonnet"]["output"],
395	            PRICING["sonnet"]["cache_read"], PRICING["sonnet"]["cache_write"],
396	            PRICING["opus"]["input"], PRICING["opus"]["output"],
397	            PRICING["opus"]["cache_read"], PRICING["opus"]["cache_write"],
398	            project_id, d["day"],
399	        )).fetchone()
400	        daily_list.append({
401	            "day": d["day"],
402	            "prompts": d["prompts"],
403	            "api_calls": d["api_calls"],
404	            "input_tokens": d["input_tokens"] or 0,
405	            "output_tokens": d["output_tokens"] or 0,
406	            "cost": round(day_cost_row["cost"] or 0, 2),
407	        })
408
409	    conn.close()
410	    return {
411	        "project": {"id": proj["id"], "name": proj["name"]},
412	        "sessions": session_list,
413	        "daily_activity": daily_list,
414	        "recent_prompts": [dict(r) for r in recent],
415	    }
416
417
418	@app.get("/api/vibe-stats/{project_name}")
419	def vibe_stats(project_name: str):
420	    """Pre-formatted vibe code stats for embedding in project pages."""
421	    if LEADERBOARD_ONLY:
422	        return _blocked()
423	    conn = get_db()
424	    proj = conn.execute("SELECT * FROM projects WHERE name = ?", (project_name,)).fetchone()
425	    if not proj:
426	        conn.close()
427	        return {"error": f"Project not found: {project_name}"}
428
429	    sessions = conn.execute("""
430	        SELECT * FROM sessions WHERE project_id = ? ORDER BY first_msg_at ASC
431	    """, (proj["id"],)).fetchall()
432
433	    total_prompts = sum(s["human_prompts"] for s in sessions)
434	    total_api = sum(s["api_calls"] + s["subagent_api_calls"] for s in sessions)
435	    total_active = sum(s["active_seconds"] for s in sessions)
436	    total_input = sum(s["input_tokens"] for s in sessions)
437	    total_output = sum(s["output_tokens"] for s in sessions)
438	    total_cache_read = sum(s["cache_read_tokens"] for s in sessions)
439	    total_cache_write = sum(s["cache_write_tokens"] for s in sessions)
440	    total_lines = sum((s["lines_written"] if "lines_written" in s.keys() else 0) for s in sessions)
441	    total_cost = sum(s["cost"] for s in sessions)
442
443	    # Cost breakdown using per-model pricing from prompts table
444	    breakdown_rows = conn.execute("""
445	        SELECT
446	            SUM(CASE
447	                WHEN model LIKE '%haiku%' THEN cache_read_tokens * ?
448	                WHEN model LIKE '%sonnet%' THEN cache_read_tokens * ?
449	                ELSE cache_read_tokens * ?
450	            END) as cache_read_cost,
451	            SUM(CASE
452	                WHEN model LIKE '%haiku%' THEN cache_write_tokens * ?
453	                WHEN model LIKE '%sonnet%' THEN cache_write_tokens * ?
454	                ELSE cache_write_tokens * ?
455	            END) as cache_write_cost,
456	            SUM(CASE
457	                WHEN model LIKE '%haiku%' THEN output_tokens * ?
458	                WHEN model LIKE '%sonnet%' THEN output_tokens * ?
459	                ELSE output_tokens * ?
460	            END) as output_cost,
461	            SUM(CASE
462	                WHEN model LIKE '%haiku%' THEN input_tokens * ?
463	                WHEN model LIKE '%sonnet%' THEN input_tokens * ?
464	                ELSE input_tokens * ?
465	            END) as input_cost
466	        FROM prompts
467	        WHERE project_id = ? AND type = 'assistant'
468	    """, (
469	        PRICING["haiku"]["cache_read"], PRICING["sonnet"]["cache_read"], PRICING["opus"]["cache_read"],
470	        PRICING["haiku"]["cache_write"], PRICING["sonnet"]["cache_write"], PRICING["opus"]["cache_write"],
471	        PRICING["haiku"]["output"], PRICING["sonnet"]["output"], PRICING["opus"]["output"],
472	        PRICING["haiku"]["input"], PRICING["sonnet"]["input"], PRICING["opus"]["input"],
473	        proj["id"],
474	    )).fetchone()
475
476	    cost_breakdown = [
477	        {"label": "Cache reads", "tokens": total_cache_read,
478	         "cost": round(breakdown_rows["cache_read_cost"] or 0)},
479	        {"label": "Cache writes", "tokens": total_cache_write,
480	         "cost": round(breakdown_rows["cache_write_cost"] or 0)},
481	        {"label": "Output", "tokens": total_output,
482	         "cost": round(breakdown_rows["output_cost"] or 0)},
483	        {"label": "Input", "tokens": total_input,
484	         "cost": round(breakdown_rows["input_cost"] or 0)},
485	    ]
486	    max_cost = max((c["cost"] for c in cost_breakdown), default=1)
487	    for c in cost_breakdown:
488	        c["pct"] = round(c["cost"] / max_cost * 100) if max_cost else 0
489
490	    session_list = []
491	    for s in sessions:
492	        session_list.append({
493	            "first_msg_at": s["first_msg_at"],
494	            "last_msg_at": s["last_msg_at"],
495	            "active_hours": round(s["active_seconds"] / 3600, 2),
496	            "prompts": s["human_prompts"],
497	            "api_calls": s["api_calls"] + s["subagent_api_calls"],
498	            "cost": round(s["cost"], 2),
499	        })
500
501	    conn.close()
502	    return {
503	        "project": project_name,
504	        "prompts": total_prompts,
505	        "api_calls": total_api,
506	        "active_hours": round(total_active / 3600, 1),
507	        "total_tokens": total_input + total_output,
508	        "input_tokens": total_input,
509	        "output_tokens": total_output,
510	        "cache_read": total_cache_read,
511	        "cache_write": total_cache_write,
512	        "lines_written": total_lines,
513	        "cost": round(total_cost),
514	        "cost_per_prompt": round(total_cost / total_prompts, 2) if total_prompts else 0,
515	        "cost_per_line": round(total_cost / total_lines, 4) if total_lines else 0,
516	        "sessions": len(sessions),
517	        "cost_breakdown": cost_breakdown,
518	        "session_list": session_list,
519	        "last_updated": sessions[-1]["last_msg_at"] if sessions else None,
520	    }
521
522
523	@app.get("/api/my-stats")
524	def my_stats(name: str = "Anonymous"):
525	    """Export stats for leaderboard sharing."""
526	    if LEADERBOARD_ONLY:
527	        return _blocked()
528	    conn = get_db()
529	    import socket
530	    from datetime import datetime, timezone
531
532	    row = conn.execute("""
533	        SELECT
534	            COUNT(*) as total_sessions,
535	            COALESCE(SUM(human_prompts), 0) as total_prompts,
536	            COALESCE(SUM(api_calls + subagent_api_calls), 0) as total_api_calls,
537	            COALESCE(SUM(active_seconds), 0) as total_active_seconds,
538	            COALESCE(SUM(input_tokens), 0) as total_input_tokens,
539	            COALESCE(SUM(output_tokens), 0) as total_output_tokens,
540	            COALESCE(SUM(cache_read_tokens), 0) as total_cache_read,
541	            COALESCE(SUM(cache_write_tokens), 0) as total_cache_write,
542	            COALESCE(SUM(lines_written), 0) as total_lines_written,
543	            COALESCE(SUM(cost), 0) as total_cost,
544	            MIN(first_msg_at) as earliest_session,
545	            MAX(last_msg_at) as latest_session
546	        FROM sessions
547	    """).fetchone()
548
549	    total_projects = conn.execute("SELECT COUNT(*) as c FROM projects").fetchone()["c"]
550
551	    # Aggregate model usage across all sessions
552	    all_sessions = conn.execute("SELECT model_usage FROM sessions").fetchall()
553	    model_totals = {}
554	    for s in all_sessions:
555	        import json as _json
556	        for m, c in _json.loads(s["model_usage"]).items():
557	            model_totals[m] = model_totals.get(m, 0) + c
558
559	    conn.close()
560	    return {
561	        "name": name,
562	        "total_prompts": row["total_prompts"],
563	        "total_api_calls": row["total_api_calls"],
564	        "total_active_hours": round(row["total_active_seconds"] / 3600, 2),
565	        "total_input_tokens": row["total_input_tokens"],
566	        "total_output_tokens": row["total_output_tokens"],
567	        "total_cache_read": row["total_cache_read"],
568	        "total_cache_write": row["total_cache_write"],
569	        "total_lines_written": row["total_lines_written"],
570	        "total_cost": round(row["total_cost"], 2),
571	        "total_sessions": row["total_sessions"],
572	        "total_projects": total_projects,
573	        "model_usage": model_totals,
574	        "earliest_session": row["earliest_session"],
575	        "latest_session": row["latest_session"],
576	        "exported_at": datetime.now(timezone.utc).isoformat(),
577	    }
578
579
580	@app.post("/api/leaderboard/submit")
581	def leaderboard_submit(stats: PlayerStats):
582	    """Accept stats from a friend's dashboard."""
583	    conn = get_db()
584	    _upsert_player(conn, stats.name, stats.model_dump())
585	    conn.close()
586	    return {"status": "ok", "player": stats.name}
587
588
589	@app.get("/api/leaderboard")
590	def leaderboard():
591	    """Return all players for the leaderboard."""
592	    conn = get_db()
593	    rows = conn.execute("""
594	        SELECT * FROM players ORDER BY total_cost DESC
595	    """).fetchall()
596	    conn.close()
597	    return [
598	        {
599	            "name": r["name"],
600	            "total_prompts": r["total_prompts"],
601	            "total_api_calls": r["total_api_calls"],
602	            "total_active_hours": r["total_active_hours"],
603	            "total_input_tokens": r["total_input_tokens"],
604	            "total_output_tokens": r["total_output_tokens"],
605	            "total_cache_read": r["total_cache_read"],
606	            "total_cache_write": r["total_cache_write"],
607	            "total_lines_written": r["total_lines_written"],
608	            "total_cost": round(r["total_cost"], 2),
609	            "total_sessions": r["total_sessions"],
610	            "total_projects": r["total_projects"],
611	            "model_usage": json.loads(r["model_usage"]),
612	            "last_seen": r["last_seen"],
613	        }
614	        for r in rows
615	    ]
616
617
618	@app.get("/api/leaderboard/{player_name}")
619	def leaderboard_player(player_name: str):
620	    """Return a player's per-project breakdown. Only available on personal dashboard."""
621	    if LEADERBOARD_ONLY:
622	        return _blocked()
623	    conn = get_db()
624	    row = conn.execute("SELECT * FROM players WHERE name = ?", (player_name,)).fetchone()
625	    conn.close()
626	    if not row:
627	        return JSONResponse({"error": "Player not found"}, status_code=404)
628	    return {
629	        "name": row["name"],
630	        "projects": json.loads(row["projects_data"] or "[]"),
631	        "model_usage": json.loads(row["model_usage"]),
632	        "last_seen": row["last_seen"],
633	    }
634
635
636	@app.post("/api/refresh")
637	def refresh():
638	    """Re-parse all session files (incremental — skips unchanged files)."""
639	    if LEADERBOARD_ONLY:
640	        return _blocked()
641	    conn = get_db()
642	    result = full_parse(conn)
643	    conn.close()
644	    _submit_self_to_leaderboard()
645	    _push_to_leaderboard()
646	    return result
647
648
649	@app.get("/api/timeline")
650	def timeline():
651	    """All prompts across all projects, for timeline view."""
652	    if LEADERBOARD_ONLY:
653	        return _blocked()
654	    conn = get_db()
655	    rows = conn.execute("""
656	        SELECT p.timestamp, p.type, p.model, p.content_preview,
657	            p.input_tokens, p.output_tokens, p.cache_read_tokens, p.cache_write_tokens,
658	            pr.name as project_name
659	        FROM prompts p
660	        JOIN projects pr ON pr.id = p.project_id
661	        WHERE p.type = 'human'
662	        ORDER BY p.timestamp DESC
663	        LIMIT 200
664	    """).fetchall()
665	    conn.close()
666	    return [dict(r) for r in rows]
667
668
669	# Serve frontend static files
670	if LEADERBOARD_ONLY:
671	    # Leaderboard-only mode: serve the standalone leaderboard page
672	    leaderboard_dir = Path(__file__).parent.parent / "leaderboard"
673	    if leaderboard_dir.exists():
674	        app.mount("/", StaticFiles(directory=str(leaderboard_dir), html=True), name="leaderboard")
675	else:
676	    # Full dashboard mode: serve the Vue app build
677	    frontend_dist = Path(__file__).parent.parent / "frontend" / "dist"
678	    if frontend_dist.exists():
679	        app.mount("/", StaticFiles(directory=str(frontend_dist), html=True), name="frontend")
680
681
682	if __name__ == "__main__":
683	    import uvicorn
684	    port = int(os.environ.get("PORT", 3400))
685	    uvicorn.run(app, host="0.0.0.0", port=port)
686

"""
Claude Code Usage Dashboard — FastAPI Backend
Parses ~/.claude/ session data, serves stats via REST API.
Leaderboard: friends' dashboards auto-push stats here.
"""

import json
import os
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel

from database import get_db, init_db
from parser import full_parse, estimate_cost_for_tokens, PRICING

# Leaderboard config — friends set these env vars
PLAYER_NAME = os.environ.get("PLAYER_NAME", "")
LEADERBOARD_URL = os.environ.get("LEADERBOARD_URL", "")
LEADERBOARD_ONLY = os.environ.get("LEADERBOARD_ONLY", "").lower() in ("1", "true", "yes")

class PlayerStats(BaseModel):
    name: str
    total_prompts: int = 0
    total_api_calls: int = 0
    total_active_hours: float = 0
    total_input_tokens: int = 0
    total_output_tokens: int = 0
    total_cache_read: int = 0
    total_cache_write: int = 0
    total_lines_written: int = 0
    total_cost: float = 0
    total_sessions: int = 0
    total_projects: int = 0
    model_usage: dict = {}
    projects_data: list = []
    earliest_session: str | None = None
    latest_session: str | None = None

def _get_my_stats() -> dict:
    """Build stats payload for leaderboard submission."""
    conn = get_db()
    row = conn.execute("""
        SELECT COUNT(*) as total_sessions,
            COALESCE(SUM(human_prompts), 0) as total_prompts,
            COALESCE(SUM(api_calls + subagent_api_calls), 0) as total_api_calls,
            COALESCE(SUM(active_seconds), 0) as total_active_seconds,
            COALESCE(SUM(input_tokens), 0) as total_input_tokens,
            COALESCE(SUM(output_tokens), 0) as total_output_tokens,
            COALESCE(SUM(cache_read_tokens), 0) as total_cache_read,
            COALESCE(SUM(cache_write_tokens), 0) as total_cache_write,
            COALESCE(SUM(lines_written), 0) as total_lines_written,
            COALESCE(SUM(cost), 0) as total_cost,
            MIN(first_msg_at) as earliest_session,
            MAX(last_msg_at) as latest_session
        FROM sessions
    """).fetchone()
    total_projects = conn.execute("SELECT COUNT(*) as c FROM projects").fetchone()["c"]
    all_sessions = conn.execute("SELECT model_usage FROM sessions").fetchall()
    model_totals = {}
    for s in all_sessions:
        for m, c in json.loads(s["model_usage"]).items():
            model_totals[m] = model_totals.get(m, 0) + c

    # Per-project breakdown
    projects = conn.execute("""
        SELECT p.name, COUNT(s.id) as sessions,
            COALESCE(SUM(s.human_prompts), 0) as prompts,
            COALESCE(SUM(s.api_calls + s.subagent_api_calls), 0) as api_calls,
            COALESCE(SUM(s.active_seconds), 0) as active_seconds,
            COALESCE(SUM(s.input_tokens), 0) as input_tokens,
            COALESCE(SUM(s.output_tokens), 0) as output_tokens,
            COALESCE(SUM(s.cache_read_tokens), 0) as cache_read,
            COALESCE(SUM(s.cache_write_tokens), 0) as cache_write,
            COALESCE(SUM(s.lines_written), 0) as lines_written,
            COALESCE(SUM(s.cost), 0) as cost
        FROM projects p
        LEFT JOIN sessions s ON s.project_id = p.id
        GROUP BY p.id
        ORDER BY cost DESC
    """).fetchall()

    projects_data = [
        {
            "name": p["name"], "sessions": p["sessions"], "prompts": p["prompts"],
            "api_calls": p["api_calls"], "active_hours": round(p["active_seconds"] / 3600, 2),
            "input_tokens": p["input_tokens"], "output_tokens": p["output_tokens"],
            "cache_read": p["cache_read"], "cache_write": p["cache_write"],
            "lines_written": p["lines_written"], "cost": round(p["cost"], 2),
        }
        for p in projects
    ]

    conn.close()
    return {
        "total_prompts": row["total_prompts"],
        "total_api_calls": row["total_api_calls"],
        "total_active_hours": round(row["total_active_seconds"] / 3600, 2),
        "total_input_tokens": row["total_input_tokens"],
        "total_output_tokens": row["total_output_tokens"],
        "total_cache_read": row["total_cache_read"],
        "total_cache_write": row["total_cache_write"],
        "total_lines_written": row["total_lines_written"],
        "total_cost": round(row["total_cost"], 2),
        "total_sessions": row["total_sessions"],
        "total_projects": total_projects,
        "model_usage": model_totals,
        "projects_data": projects_data,
        "earliest_session": row["earliest_session"],
        "latest_session": row["latest_session"],
    }

def _push_to_leaderboard():
    """Push stats to the central leaderboard server."""
    if not LEADERBOARD_URL or not PLAYER_NAME:
        return
    try:
        import urllib.request
        stats = _get_my_stats()
        stats["name"] = PLAYER_NAME
        data = json.dumps(stats).encode()
        req = urllib.request.Request(
            f"{LEADERBOARD_URL.rstrip('/')}/api/leaderboard/submit",
            data=data,
            headers={"Content-Type": "application/json"},
            method="POST",
        )
        urllib.request.urlopen(req, timeout=10)
        print(f"Leaderboard push OK → {LEADERBOARD_URL} as '{PLAYER_NAME}'")
    except Exception as e:
        print(f"Leaderboard push failed: {e}")

def _upsert_player(conn, name, stats):
    """Insert or update a player in the leaderboard table."""
    conn.execute("""
        INSERT INTO players (name, total_prompts, total_api_calls, total_active_hours,
            total_input_tokens, total_output_tokens, total_cache_read, total_cache_write,
            total_lines_written, total_cost, total_sessions, total_projects,
            model_usage, projects_data, earliest_session, latest_session, last_seen)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        ON CONFLICT(name) DO UPDATE SET
            total_prompts=excluded.total_prompts, total_api_calls=excluded.total_api_calls,
            total_active_hours=excluded.total_active_hours,
            total_input_tokens=excluded.total_input_tokens, total_output_tokens=excluded.total_output_tokens,
            total_cache_read=excluded.total_cache_read, total_cache_write=excluded.total_cache_write,
            total_lines_written=excluded.total_lines_written, total_cost=excluded.total_cost,
            total_sessions=excluded.total_sessions, total_projects=excluded.total_projects,
            model_usage=excluded.model_usage, projects_data=excluded.projects_data,
            earliest_session=excluded.earliest_session, latest_session=excluded.latest_session,
            last_seen=excluded.last_seen
    """, (
        name, stats["total_prompts"], stats["total_api_calls"], stats["total_active_hours"],
        stats["total_input_tokens"], stats["total_output_tokens"],
        stats["total_cache_read"], stats["total_cache_write"],
        stats["total_lines_written"], stats["total_cost"],
        stats["total_sessions"], stats["total_projects"],
        json.dumps(stats.get("model_usage", {})),
        json.dumps(stats.get("projects_data", [])),
        stats["earliest_session"], stats["latest_session"],
        datetime.now(timezone.utc).isoformat(),
    ))
    conn.commit()

def _submit_self_to_leaderboard():
    """Submit own stats to the local leaderboard table (if PLAYER_NAME is set)."""
    if not PLAYER_NAME:
        return
    stats = _get_my_stats()
    conn = get_db()
    _upsert_player(conn, PLAYER_NAME, stats)
    conn.commit()
    conn.close()

@asynccontextmanager
async def lifespan(app: FastAPI):
    init_db()

    if not LEADERBOARD_ONLY:
        conn = get_db()
        full_parse(conn)
        conn.close()
        _submit_self_to_leaderboard()
        _push_to_leaderboard()
    elif PLAYER_NAME:
        # Leaderboard-only mode still registers self if PLAYER_NAME is set
        _submit_self_to_leaderboard()

    import asyncio
    async def auto_refresh():
        while True:
            await asyncio.sleep(3600)
            try:
                if not LEADERBOARD_ONLY:
                    conn = get_db()
                    full_parse(conn)
                    conn.close()
                    _submit_self_to_leaderboard()
                    _push_to_leaderboard()
                    print("Hourly auto-refresh complete")
            except Exception as e:
                print(f"Auto-refresh failed: {e}")

    task = asyncio.create_task(auto_refresh())
    yield
    task.cancel()

app = FastAPI(title="Claude Dashboard", lifespan=lifespan)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)

from fastapi.responses import JSONResponse

def _blocked():
    return JSONResponse({"error": "not available"}, status_code=404)

@app.get("/api/overview")
def overview():
    if LEADERBOARD_ONLY:
        return _blocked()
    """Global stats across all projects."""
    conn = get_db()
    row = conn.execute("""
        SELECT
            COUNT(*) as total_sessions,
            COALESCE(SUM(human_prompts), 0) as total_prompts,
            COALESCE(SUM(api_calls + subagent_api_calls), 0) as total_api_calls,
            COALESCE(SUM(active_seconds), 0) as total_active_seconds,
            COALESCE(SUM(wall_seconds), 0) as total_wall_seconds,
            COALESCE(SUM(input_tokens), 0) as total_input_tokens,
            COALESCE(SUM(output_tokens), 0) as total_output_tokens,
            COALESCE(SUM(cache_read_tokens), 0) as total_cache_read,
            COALESCE(SUM(cache_write_tokens), 0) as total_cache_write,
            COALESCE(SUM(lines_written), 0) as total_lines_written,
            COALESCE(SUM(cost), 0) as total_cost,
            MIN(first_msg_at) as earliest_session,
            MAX(last_msg_at) as latest_session
        FROM sessions
    """).fetchone()

    projects = conn.execute("""
        SELECT p.id, p.name, COUNT(s.id) as session_count,
            COALESCE(SUM(s.human_prompts), 0) as prompts,
            COALESCE(SUM(s.api_calls + s.subagent_api_calls), 0) as api_calls,
            COALESCE(SUM(s.active_seconds), 0) as active_seconds,
            COALESCE(SUM(s.input_tokens), 0) as input_tokens,
            COALESCE(SUM(s.output_tokens), 0) as output_tokens,
            COALESCE(SUM(s.cache_read_tokens), 0) as cache_read,
            COALESCE(SUM(s.cache_write_tokens), 0) as cache_write,
            COALESCE(SUM(s.lines_written), 0) as lines_written,
            COALESCE(SUM(s.cost), 0) as cost
        FROM projects p
        LEFT JOIN sessions s ON s.project_id = p.id
        GROUP BY p.id
        ORDER BY active_seconds DESC
    """).fetchall()

    project_list = []
    for p in projects:
        project_list.append({
            "id": p["id"],
            "name": p["name"],
            "sessions": p["session_count"],
            "prompts": p["prompts"],
            "api_calls": p["api_calls"],
            "active_hours": round(p["active_seconds"] / 3600, 2),
            "input_tokens": p["input_tokens"],
            "output_tokens": p["output_tokens"],
            "cache_read": p["cache_read"],
            "cache_write": p["cache_write"],
            "lines_written": p["lines_written"],
            "cost": round(p["cost"], 2),
        })

    conn.close()
    return {
        "total_sessions": row["total_sessions"],
        "total_prompts": row["total_prompts"],
        "total_api_calls": row["total_api_calls"],
        "total_active_hours": round(row["total_active_seconds"] / 3600, 2),
        "total_wall_hours": round(row["total_wall_seconds"] / 3600, 2),
        "total_input_tokens": row["total_input_tokens"],
        "total_output_tokens": row["total_output_tokens"],
        "total_cache_read": row["total_cache_read"],
        "total_cache_write": row["total_cache_write"],
        "total_lines_written": row["total_lines_written"],
        "total_cost": round(row["total_cost"], 2),
        "earliest_session": row["earliest_session"],
        "latest_session": row["latest_session"],
        "projects": project_list,
    }

@app.get("/api/projects/{project_id}")
def project_detail(project_id: int):
    """Detailed stats for a single project."""
    if LEADERBOARD_ONLY:
        return _blocked()
    conn = get_db()
    proj = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone()
    if not proj:
        conn.close()
        return {"error": "Project not found"}

    sessions = conn.execute("""
        SELECT * FROM sessions WHERE project_id = ?
        ORDER BY first_msg_at DESC
    """, (project_id,)).fetchall()

    # Daily activity — compute cost per-row using prompts table (assistant rows have model)
    daily = conn.execute("""
        SELECT DATE(timestamp) as day,
            COUNT(CASE WHEN type='human' THEN 1 END) as prompts,
            COUNT(CASE WHEN type='assistant' THEN 1 END) as api_calls,
            SUM(input_tokens) as input_tokens,
            SUM(output_tokens) as output_tokens,
            SUM(cache_read_tokens) as cache_read,
            SUM(cache_write_tokens) as cache_write
        FROM prompts
        WHERE project_id = ?
        GROUP BY DATE(timestamp)
        ORDER BY day
    """, (project_id,)).fetchall()

    # Recent prompts
    recent = conn.execute("""
        SELECT timestamp, type, model, content_preview,
            input_tokens, output_tokens, cache_read_tokens, cache_write_tokens
        FROM prompts
        WHERE project_id = ? AND type = 'human'
        ORDER BY timestamp DESC
        LIMIT 50
    """, (project_id,)).fetchall()

    session_list = []
    for s in sessions:
        session_list.append({
            "id": s["id"],
            "first_msg_at": s["first_msg_at"],
            "last_msg_at": s["last_msg_at"],
            "wall_hours": round(s["wall_seconds"] / 3600, 2),
            "active_hours": round(s["active_seconds"] / 3600, 2),
            "human_prompts": s["human_prompts"],
            "api_calls": s["api_calls"],
            "subagent_api_calls": s["subagent_api_calls"],
            "input_tokens": s["input_tokens"],
            "output_tokens": s["output_tokens"],
            "cache_read": s["cache_read_tokens"],
            "cache_write": s["cache_write_tokens"],
            "lines_written": s["lines_written"] if "lines_written" in s.keys() else 0,
            "model_usage": json.loads(s["model_usage"]),
            "cost": round(s["cost"], 2),
        })

    daily_list = []
    for d in daily:
        # Daily cost: sum from per-prompt assistant rows for that day
        day_cost_row = conn.execute("""
            SELECT SUM(
                CASE
                    WHEN model LIKE '%haiku%' THEN
                        input_tokens * ? + output_tokens * ? +
                        cache_read_tokens * ? + cache_write_tokens * ?
                    WHEN model LIKE '%sonnet%' THEN
                        input_tokens * ? + output_tokens * ? +
                        cache_read_tokens * ? + cache_write_tokens * ?
                    ELSE
                        input_tokens * ? + output_tokens * ? +
                        cache_read_tokens * ? + cache_write_tokens * ?
                END
            ) as cost
            FROM prompts
            WHERE project_id = ? AND DATE(timestamp) = ? AND type = 'assistant'
        """, (
            PRICING["haiku"]["input"], PRICING["haiku"]["output"],
            PRICING["haiku"]["cache_read"], PRICING["haiku"]["cache_write"],
            PRICING["sonnet"]["input"], PRICING["sonnet"]["output"],
            PRICING["sonnet"]["cache_read"], PRICING["sonnet"]["cache_write"],
            PRICING["opus"]["input"], PRICING["opus"]["output"],
            PRICING["opus"]["cache_read"], PRICING["opus"]["cache_write"],
            project_id, d["day"],
        )).fetchone()
        daily_list.append({
            "day": d["day"],
            "prompts": d["prompts"],
            "api_calls": d["api_calls"],
            "input_tokens": d["input_tokens"] or 0,
            "output_tokens": d["output_tokens"] or 0,
            "cost": round(day_cost_row["cost"] or 0, 2),
        })

    conn.close()
    return {
        "project": {"id": proj["id"], "name": proj["name"]},
        "sessions": session_list,
        "daily_activity": daily_list,
        "recent_prompts": [dict(r) for r in recent],
    }

@app.get("/api/vibe-stats/{project_name}")
def vibe_stats(project_name: str):
    """Pre-formatted vibe code stats for embedding in project pages."""
    if LEADERBOARD_ONLY:
        return _blocked()
    conn = get_db()
    proj = conn.execute("SELECT * FROM projects WHERE name = ?", (project_name,)).fetchone()
    if not proj:
        conn.close()
        return {"error": f"Project not found: {project_name}"}

    sessions = conn.execute("""
        SELECT * FROM sessions WHERE project_id = ? ORDER BY first_msg_at ASC
    """, (proj["id"],)).fetchall()

    total_prompts = sum(s["human_prompts"] for s in sessions)
    total_api = sum(s["api_calls"] + s["subagent_api_calls"] for s in sessions)
    total_active = sum(s["active_seconds"] for s in sessions)
    total_input = sum(s["input_tokens"] for s in sessions)
    total_output = sum(s["output_tokens"] for s in sessions)
    total_cache_read = sum(s["cache_read_tokens"] for s in sessions)
    total_cache_write = sum(s["cache_write_tokens"] for s in sessions)
    total_lines = sum((s["lines_written"] if "lines_written" in s.keys() else 0) for s in sessions)
    total_cost = sum(s["cost"] for s in sessions)

    # Cost breakdown using per-model pricing from prompts table
    breakdown_rows = conn.execute("""
        SELECT
            SUM(CASE
                WHEN model LIKE '%haiku%' THEN cache_read_tokens * ?
                WHEN model LIKE '%sonnet%' THEN cache_read_tokens * ?
                ELSE cache_read_tokens * ?
            END) as cache_read_cost,
            SUM(CASE
                WHEN model LIKE '%haiku%' THEN cache_write_tokens * ?
                WHEN model LIKE '%sonnet%' THEN cache_write_tokens * ?
                ELSE cache_write_tokens * ?
            END) as cache_write_cost,
            SUM(CASE
                WHEN model LIKE '%haiku%' THEN output_tokens * ?
                WHEN model LIKE '%sonnet%' THEN output_tokens * ?
                ELSE output_tokens * ?
            END) as output_cost,
            SUM(CASE
                WHEN model LIKE '%haiku%' THEN input_tokens * ?
                WHEN model LIKE '%sonnet%' THEN input_tokens * ?
                ELSE input_tokens * ?
            END) as input_cost
        FROM prompts
        WHERE project_id = ? AND type = 'assistant'
    """, (
        PRICING["haiku"]["cache_read"], PRICING["sonnet"]["cache_read"], PRICING["opus"]["cache_read"],
        PRICING["haiku"]["cache_write"], PRICING["sonnet"]["cache_write"], PRICING["opus"]["cache_write"],
        PRICING["haiku"]["output"], PRICING["sonnet"]["output"], PRICING["opus"]["output"],
        PRICING["haiku"]["input"], PRICING["sonnet"]["input"], PRICING["opus"]["input"],
        proj["id"],
    )).fetchone()

    cost_breakdown = [
        {"label": "Cache reads", "tokens": total_cache_read,
         "cost": round(breakdown_rows["cache_read_cost"] or 0)},
        {"label": "Cache writes", "tokens": total_cache_write,
         "cost": round(breakdown_rows["cache_write_cost"] or 0)},
        {"label": "Output", "tokens": total_output,
         "cost": round(breakdown_rows["output_cost"] or 0)},
        {"label": "Input", "tokens": total_input,
         "cost": round(breakdown_rows["input_cost"] or 0)},
    ]
    max_cost = max((c["cost"] for c in cost_breakdown), default=1)
    for c in cost_breakdown:
        c["pct"] = round(c["cost"] / max_cost * 100) if max_cost else 0

    session_list = []
    for s in sessions:
        session_list.append({
            "first_msg_at": s["first_msg_at"],
            "last_msg_at": s["last_msg_at"],
            "active_hours": round(s["active_seconds"] / 3600, 2),
            "prompts": s["human_prompts"],
            "api_calls": s["api_calls"] + s["subagent_api_calls"],
            "cost": round(s["cost"], 2),
        })

    conn.close()
    return {
        "project": project_name,
        "prompts": total_prompts,
        "api_calls": total_api,
        "active_hours": round(total_active / 3600, 1),
        "total_tokens": total_input + total_output,
        "input_tokens": total_input,
        "output_tokens": total_output,
        "cache_read": total_cache_read,
        "cache_write": total_cache_write,
        "lines_written": total_lines,
        "cost": round(total_cost),
        "cost_per_prompt": round(total_cost / total_prompts, 2) if total_prompts else 0,
        "cost_per_line": round(total_cost / total_lines, 4) if total_lines else 0,
        "sessions": len(sessions),
        "cost_breakdown": cost_breakdown,
        "session_list": session_list,
        "last_updated": sessions[-1]["last_msg_at"] if sessions else None,
    }

@app.get("/api/my-stats")
def my_stats(name: str = "Anonymous"):
    """Export stats for leaderboard sharing."""
    if LEADERBOARD_ONLY:
        return _blocked()
    conn = get_db()
    import socket
    from datetime import datetime, timezone

    row = conn.execute("""
        SELECT
            COUNT(*) as total_sessions,
            COALESCE(SUM(human_prompts), 0) as total_prompts,
            COALESCE(SUM(api_calls + subagent_api_calls), 0) as total_api_calls,
            COALESCE(SUM(active_seconds), 0) as total_active_seconds,
            COALESCE(SUM(input_tokens), 0) as total_input_tokens,
            COALESCE(SUM(output_tokens), 0) as total_output_tokens,
            COALESCE(SUM(cache_read_tokens), 0) as total_cache_read,
            COALESCE(SUM(cache_write_tokens), 0) as total_cache_write,
            COALESCE(SUM(lines_written), 0) as total_lines_written,
            COALESCE(SUM(cost), 0) as total_cost,
            MIN(first_msg_at) as earliest_session,
            MAX(last_msg_at) as latest_session
        FROM sessions
    """).fetchone()

    total_projects = conn.execute("SELECT COUNT(*) as c FROM projects").fetchone()["c"]

    # Aggregate model usage across all sessions
    all_sessions = conn.execute("SELECT model_usage FROM sessions").fetchall()
    model_totals = {}
    for s in all_sessions:
        import json as _json
        for m, c in _json.loads(s["model_usage"]).items():
            model_totals[m] = model_totals.get(m, 0) + c

    conn.close()
    return {
        "name": name,
        "total_prompts": row["total_prompts"],
        "total_api_calls": row["total_api_calls"],
        "total_active_hours": round(row["total_active_seconds"] / 3600, 2),
        "total_input_tokens": row["total_input_tokens"],
        "total_output_tokens": row["total_output_tokens"],
        "total_cache_read": row["total_cache_read"],
        "total_cache_write": row["total_cache_write"],
        "total_lines_written": row["total_lines_written"],
        "total_cost": round(row["total_cost"], 2),
        "total_sessions": row["total_sessions"],
        "total_projects": total_projects,
        "model_usage": model_totals,
        "earliest_session": row["earliest_session"],
        "latest_session": row["latest_session"],
        "exported_at": datetime.now(timezone.utc).isoformat(),
    }

@app.post("/api/leaderboard/submit")
def leaderboard_submit(stats: PlayerStats):
    """Accept stats from a friend's dashboard."""
    conn = get_db()
    _upsert_player(conn, stats.name, stats.model_dump())
    conn.close()
    return {"status": "ok", "player": stats.name}

@app.get("/api/leaderboard")
def leaderboard():
    """Return all players for the leaderboard."""
    conn = get_db()
    rows = conn.execute("""
        SELECT * FROM players ORDER BY total_cost DESC
    """).fetchall()
    conn.close()
    return [
        {
            "name": r["name"],
            "total_prompts": r["total_prompts"],
            "total_api_calls": r["total_api_calls"],
            "total_active_hours": r["total_active_hours"],
            "total_input_tokens": r["total_input_tokens"],
            "total_output_tokens": r["total_output_tokens"],
            "total_cache_read": r["total_cache_read"],
            "total_cache_write": r["total_cache_write"],
            "total_lines_written": r["total_lines_written"],
            "total_cost": round(r["total_cost"], 2),
            "total_sessions": r["total_sessions"],
            "total_projects": r["total_projects"],
            "model_usage": json.loads(r["model_usage"]),
            "last_seen": r["last_seen"],
        }
        for r in rows
    ]

@app.get("/api/leaderboard/{player_name}")
def leaderboard_player(player_name: str):
    """Return a player's per-project breakdown. Only available on personal dashboard."""
    if LEADERBOARD_ONLY:
        return _blocked()
    conn = get_db()
    row = conn.execute("SELECT * FROM players WHERE name = ?", (player_name,)).fetchone()
    conn.close()
    if not row:
        return JSONResponse({"error": "Player not found"}, status_code=404)
    return {
        "name": row["name"],
        "projects": json.loads(row["projects_data"] or "[]"),
        "model_usage": json.loads(row["model_usage"]),
        "last_seen": row["last_seen"],
    }

@app.post("/api/refresh")
def refresh():
    """Re-parse all session files (incremental — skips unchanged files)."""
    if LEADERBOARD_ONLY:
        return _blocked()
    conn = get_db()
    result = full_parse(conn)
    conn.close()
    _submit_self_to_leaderboard()
    _push_to_leaderboard()
    return result

@app.get("/api/timeline")
def timeline():
    """All prompts across all projects, for timeline view."""
    if LEADERBOARD_ONLY:
        return _blocked()
    conn = get_db()
    rows = conn.execute("""
        SELECT p.timestamp, p.type, p.model, p.content_preview,
            p.input_tokens, p.output_tokens, p.cache_read_tokens, p.cache_write_tokens,
            pr.name as project_name
        FROM prompts p
        JOIN projects pr ON pr.id = p.project_id
        WHERE p.type = 'human'
        ORDER BY p.timestamp DESC
        LIMIT 200
    """).fetchall()
    conn.close()
    return [dict(r) for r in rows]

# Serve frontend static files
if LEADERBOARD_ONLY:
    # Leaderboard-only mode: serve the standalone leaderboard page
    leaderboard_dir = Path(__file__).parent.parent / "leaderboard"
    if leaderboard_dir.exists():
        app.mount("/", StaticFiles(directory=str(leaderboard_dir), html=True), name="leaderboard")
else:
    # Full dashboard mode: serve the Vue app build
    frontend_dist = Path(__file__).parent.parent / "frontend" / "dist"
    if frontend_dist.exists():
        app.mount("/", StaticFiles(directory=str(frontend_dist), html=True), name="frontend")

if __name__ == "__main__":
    import uvicorn
    port = int(os.environ.get("PORT", 3400))
    uvicorn.run(app, host="0.0.0.0", port=port)


The issue is on lines 42-43: `str | None` requires Python 3.10+. I'll fix it with `Optional`:


Edit

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel

from typing import Optional

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel


The file /Users/khalidbinkalban/claude-dashboard/backend/app.py has been updated successfully.

from typing import Optional

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel

"""
Claude Code Usage Dashboard — FastAPI Backend
Parses ~/.claude/ session data, serves stats via REST API.
Leaderboard: friends' dashboards auto-push stats here.
"""

import json
import os
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel

from database import get_db, init_db
from parser import full_parse, estimate_cost_for_tokens, PRICING

# Leaderboard config — friends set these env vars
PLAYER_NAME = os.environ.get("PLAYER_NAME", "")
LEADERBOARD_URL = os.environ.get("LEADERBOARD_URL", "")
LEADERBOARD_ONLY = os.environ.get("LEADERBOARD_ONLY", "").lower() in ("1", "true", "yes")

class PlayerStats(BaseModel):
    name: str
    total_prompts: int = 0
    total_api_calls: int = 0
    total_active_hours: float = 0
    total_input_tokens: int = 0
    total_output_tokens: int = 0
    total_cache_read: int = 0
    total_cache_write: int = 0
    total_lines_written: int = 0
    total_cost: float = 0
    total_sessions: int = 0
    total_projects: int = 0
    model_usage: dict = {}
    projects_data: list = []
    earliest_session: str | None = None
    latest_session: str | None = None

def _get_my_stats() -> dict:
    """Build stats payload for leaderboard submission."""
    conn = get_db()
    row = conn.execute("""
        SELECT COUNT(*) as total_sessions,
            COALESCE(SUM(human_prompts), 0) as total_prompts,
            COALESCE(SUM(api_calls + subagent_api_calls), 0) as total_api_calls,
            COALESCE(SUM(active_seconds), 0) as total_active_seconds,
            COALESCE(SUM(input_tokens), 0) as total_input_tokens,
            COALESCE(SUM(output_tokens), 0) as total_output_tokens,
            COALESCE(SUM(cache_read_tokens), 0) as total_cache_read,
            COALESCE(SUM(cache_write_tokens), 0) as total_cache_write,
            COALESCE(SUM(lines_written), 0) as total_lines_written,
            COALESCE(SUM(cost), 0) as total_cost,
            MIN(first_msg_at) as earliest_session,
            MAX(last_msg_at) as latest_session
        FROM sessions
    """).fetchone()
    total_projects = conn.execute("SELECT COUNT(*) as c FROM projects").fetchone()["c"]
    all_sessions = conn.execute("SELECT model_usage FROM sessions").fetchall()
    model_totals = {}
    for s in all_sessions:
        for m, c in json.loads(s["model_usage"]).items():
            model_totals[m] = model_totals.get(m, 0) + c

    # Per-project breakdown
    projects = conn.execute("""
        SELECT p.name, COUNT(s.id) as sessions,
            COALESCE(SUM(s.human_prompts), 0) as prompts,
            COALESCE(SUM(s.api_calls + s.subagent_api_calls), 0) as api_calls,
            COALESCE(SUM(s.active_seconds), 0) as active_seconds,
            COALESCE(SUM(s.input_tokens), 0) as input_tokens,
            COALESCE(SUM(s.output_tokens), 0) as output_tokens,
            COALESCE(SUM(s.cache_read_tokens), 0) as cache_read,
            COALESCE(SUM(s.cache_write_tokens), 0) as cache_write,
            COALESCE(SUM(s.lines_written), 0) as lines_written,
            COALESCE(SUM(s.cost), 0) as cost
        FROM projects p
        LEFT JOIN sessions s ON s.project_id = p.id
        GROUP BY p.id
        ORDER BY cost DESC
    """).fetchall()

    projects_data = [
        {
            "name": p["name"], "sessions": p["sessions"], "prompts": p["prompts"],
            "api_calls": p["api_calls"], "active_hours": round(p["active_seconds"] / 3600, 2),
            "input_tokens": p["input_tokens"], "output_tokens": p["output_tokens"],
            "cache_read": p["cache_read"], "cache_write": p["cache_write"],
            "lines_written": p["lines_written"], "cost": round(p["cost"], 2),
        }
        for p in projects
    ]

    conn.close()
    return {
        "total_prompts": row["total_prompts"],
        "total_api_calls": row["total_api_calls"],
        "total_active_hours": round(row["total_active_seconds"] / 3600, 2),
        "total_input_tokens": row["total_input_tokens"],
        "total_output_tokens": row["total_output_tokens"],
        "total_cache_read": row["total_cache_read"],
        "total_cache_write": row["total_cache_write"],
        "total_lines_written": row["total_lines_written"],
        "total_cost": round(row["total_cost"], 2),
        "total_sessions": row["total_sessions"],
        "total_projects": total_projects,
        "model_usage": model_totals,
        "projects_data": projects_data,
        "earliest_session": row["earliest_session"],
        "latest_session": row["latest_session"],
    }

def _push_to_leaderboard():
    """Push stats to the central leaderboard server."""
    if not LEADERBOARD_URL or not PLAYER_NAME:
        return
    try:
        import urllib.request
        stats = _get_my_stats()
        stats["name"] = PLAYER_NAME
        data = json.dumps(stats).encode()
        req = urllib.request.Request(
            f"{LEADERBOARD_URL.rstrip('/')}/api/leaderboard/submit",
            data=data,
            headers={"Content-Type": "application/json"},
            method="POST",
        )
        urllib.request.urlopen(req, timeout=10)
        print(f"Leaderboard push OK → {LEADERBOARD_URL} as '{PLAYER_NAME}'")
    except Exception as e:
        print(f"Leaderboard push failed: {e}")

def _upsert_player(conn, name, stats):
    """Insert or update a player in the leaderboard table."""
    conn.execute("""
        INSERT INTO players (name, total_prompts, total_api_calls, total_active_hours,
            total_input_tokens, total_output_tokens, total_cache_read, total_cache_write,
            total_lines_written, total_cost, total_sessions, total_projects,
            model_usage, projects_data, earliest_session, latest_session, last_seen)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        ON CONFLICT(name) DO UPDATE SET
            total_prompts=excluded.total_prompts, total_api_calls=excluded.total_api_calls,
            total_active_hours=excluded.total_active_hours,
            total_input_tokens=excluded.total_input_tokens, total_output_tokens=excluded.total_output_tokens,
            total_cache_read=excluded.total_cache_read, total_cache_write=excluded.total_cache_write,
            total_lines_written=excluded.total_lines_written, total_cost=excluded.total_cost,
            total_sessions=excluded.total_sessions, total_projects=excluded.total_projects,
            model_usage=excluded.model_usage, projects_data=excluded.projects_data,
            earliest_session=excluded.earliest_session, latest_session=excluded.latest_session,
            last_seen=excluded.last_seen
    """, (
        name, stats["total_prompts"], stats["total_api_calls"], stats["total_active_hours"],
        stats["total_input_tokens"], stats["total_output_tokens"],
        stats["total_cache_read"], stats["total_cache_write"],
        stats["total_lines_written"], stats["total_cost"],
        stats["total_sessions"], stats["total_projects"],
        json.dumps(stats.get("model_usage", {})),
        json.dumps(stats.get("projects_data", [])),
        stats["earliest_session"], stats["latest_session"],
        datetime.now(timezone.utc).isoformat(),
    ))
    conn.commit()

def _submit_self_to_leaderboard():
    """Submit own stats to the local leaderboard table (if PLAYER_NAME is set)."""
    if not PLAYER_NAME:
        return
    stats = _get_my_stats()
    conn = get_db()
    _upsert_player(conn, PLAYER_NAME, stats)
    conn.commit()
    conn.close()

@asynccontextmanager
async def lifespan(app: FastAPI):
    init_db()

    if not LEADERBOARD_ONLY:
        conn = get_db()
        full_parse(conn)
        conn.close()
        _submit_self_to_leaderboard()
        _push_to_leaderboard()
    elif PLAYER_NAME:
        # Leaderboard-only mode still registers self if PLAYER_NAME is set
        _submit_self_to_leaderboard()

    import asyncio
    async def auto_refresh():
        while True:
            await asyncio.sleep(3600)
            try:
                if not LEADERBOARD_ONLY:
                    conn = get_db()
                    full_parse(conn)
                    conn.close()
                    _submit_self_to_leaderboard()
                    _push_to_leaderboard()
                    print("Hourly auto-refresh complete")
            except Exception as e:
                print(f"Auto-refresh failed: {e}")

    task = asyncio.create_task(auto_refresh())
    yield
    task.cancel()

app = FastAPI(title="Claude Dashboard", lifespan=lifespan)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)

from fastapi.responses import JSONResponse

def _blocked():
    return JSONResponse({"error": "not available"}, status_code=404)

@app.get("/api/overview")
def overview():
    if LEADERBOARD_ONLY:
        return _blocked()
    """Global stats across all projects."""
    conn = get_db()
    row = conn.execute("""
        SELECT
            COUNT(*) as total_sessions,
            COALESCE(SUM(human_prompts), 0) as total_prompts,
            COALESCE(SUM(api_calls + subagent_api_calls), 0) as total_api_calls,
            COALESCE(SUM(active_seconds), 0) as total_active_seconds,
            COALESCE(SUM(wall_seconds), 0) as total_wall_seconds,
            COALESCE(SUM(input_tokens), 0) as total_input_tokens,
            COALESCE(SUM(output_tokens), 0) as total_output_tokens,
            COALESCE(SUM(cache_read_tokens), 0) as total_cache_read,
            COALESCE(SUM(cache_write_tokens), 0) as total_cache_write,
            COALESCE(SUM(lines_written), 0) as total_lines_written,
            COALESCE(SUM(cost), 0) as total_cost,
            MIN(first_msg_at) as earliest_session,
            MAX(last_msg_at) as latest_session
        FROM sessions
    """).fetchone()

    projects = conn.execute("""
        SELECT p.id, p.name, COUNT(s.id) as session_count,
            COALESCE(SUM(s.human_prompts), 0) as prompts,
            COALESCE(SUM(s.api_calls + s.subagent_api_calls), 0) as api_calls,
            COALESCE(SUM(s.active_seconds), 0) as active_seconds,
            COALESCE(SUM(s.input_tokens), 0) as input_tokens,
            COALESCE(SUM(s.output_tokens), 0) as output_tokens,
            COALESCE(SUM(s.cache_read_tokens), 0) as cache_read,
            COALESCE(SUM(s.cache_write_tokens), 0) as cache_write,
            COALESCE(SUM(s.lines_written), 0) as lines_written,
            COALESCE(SUM(s.cost), 0) as cost
        FROM projects p
        LEFT JOIN sessions s ON s.project_id = p.id
        GROUP BY p.id
        ORDER BY active_seconds DESC
    """).fetchall()

    project_list = []
    for p in projects:
        project_list.append({
            "id": p["id"],
            "name": p["name"],
            "sessions": p["session_count"],
            "prompts": p["prompts"],
            "api_calls": p["api_calls"],
            "active_hours": round(p["active_seconds"] / 3600, 2),
            "input_tokens": p["input_tokens"],
            "output_tokens": p["output_tokens"],
            "cache_read": p["cache_read"],
            "cache_write": p["cache_write"],
            "lines_written": p["lines_written"],
            "cost": round(p["cost"], 2),
        })

    conn.close()
    return {
        "total_sessions": row["total_sessions"],
        "total_prompts": row["total_prompts"],
        "total_api_calls": row["total_api_calls"],
        "total_active_hours": round(row["total_active_seconds"] / 3600, 2),
        "total_wall_hours": round(row["total_wall_seconds"] / 3600, 2),
        "total_input_tokens": row["total_input_tokens"],
        "total_output_tokens": row["total_output_tokens"],
        "total_cache_read": row["total_cache_read"],
        "total_cache_write": row["total_cache_write"],
        "total_lines_written": row["total_lines_written"],
        "total_cost": round(row["total_cost"], 2),
        "earliest_session": row["earliest_session"],
        "latest_session": row["latest_session"],
        "projects": project_list,
    }

@app.get("/api/projects/{project_id}")
def project_detail(project_id: int):
    """Detailed stats for a single project."""
    if LEADERBOARD_ONLY:
        return _blocked()
    conn = get_db()
    proj = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone()
    if not proj:
        conn.close()
        return {"error": "Project not found"}

    sessions = conn.execute("""
        SELECT * FROM sessions WHERE project_id = ?
        ORDER BY first_msg_at DESC
    """, (project_id,)).fetchall()

    # Daily activity — compute cost per-row using prompts table (assistant rows have model)
    daily = conn.execute("""
        SELECT DATE(timestamp) as day,
            COUNT(CASE WHEN type='human' THEN 1 END) as prompts,
            COUNT(CASE WHEN type='assistant' THEN 1 END) as api_calls,
            SUM(input_tokens) as input_tokens,
            SUM(output_tokens) as output_tokens,
            SUM(cache_read_tokens) as cache_read,
            SUM(cache_write_tokens) as cache_write
        FROM prompts
        WHERE project_id = ?
        GROUP BY DATE(timestamp)
        ORDER BY day
    """, (project_id,)).fetchall()

    # Recent prompts
    recent = conn.execute("""
        SELECT timestamp, type, model, content_preview,
            input_tokens, output_tokens, cache_read_tokens, cache_write_tokens
        FROM prompts
        WHERE project_id = ? AND type = 'human'
        ORDER BY timestamp DESC
        LIMIT 50
    """, (project_id,)).fetchall()

    session_list = []
    for s in sessions:
        session_list.append({
            "id": s["id"],
            "first_msg_at": s["first_msg_at"],
            "last_msg_at": s["last_msg_at"],
            "wall_hours": round(s["wall_seconds"] / 3600, 2),
            "active_hours": round(s["active_seconds"] / 3600, 2),
            "human_prompts": s["human_prompts"],
            "api_calls": s["api_calls"],
            "subagent_api_calls": s["subagent_api_calls"],
            "input_tokens": s["input_tokens"],
            "output_tokens": s["output_tokens"],
            "cache_read": s["cache_read_tokens"],
            "cache_write": s["cache_write_tokens"],
            "lines_written": s["lines_written"] if "lines_written" in s.keys() else 0,
            "model_usage": json.loads(s["model_usage"]),
            "cost": round(s["cost"], 2),
        })

    daily_list = []
    for d in daily:
        # Daily cost: sum from per-prompt assistant rows for that day
        day_cost_row = conn.execute("""
            SELECT SUM(
                CASE
                    WHEN model LIKE '%haiku%' THEN
                        input_tokens * ? + output_tokens * ? +
                        cache_read_tokens * ? + cache_write_tokens * ?
                    WHEN model LIKE '%sonnet%' THEN
                        input_tokens * ? + output_tokens * ? +
                        cache_read_tokens * ? + cache_write_tokens * ?
                    ELSE
                        input_tokens * ? + output_tokens * ? +
                        cache_read_tokens * ? + cache_write_tokens * ?
                END
            ) as cost
            FROM prompts
            WHERE project_id = ? AND DATE(timestamp) = ? AND type = 'assistant'
        """, (
            PRICING["haiku"]["input"], PRICING["haiku"]["output"],
            PRICING["haiku"]["cache_read"], PRICING["haiku"]["cache_write"],
            PRICING["sonnet"]["input"], PRICING["sonnet"]["output"],
            PRICING["sonnet"]["cache_read"], PRICING["sonnet"]["cache_write"],
            PRICING["opus"]["input"], PRICING["opus"]["output"],
            PRICING["opus"]["cache_read"], PRICING["opus"]["cache_write"],
            project_id, d["day"],
        )).fetchone()
        daily_list.append({
            "day": d["day"],
            "prompts": d["prompts"],
            "api_calls": d["api_calls"],
            "input_tokens": d["input_tokens"] or 0,
            "output_tokens": d["output_tokens"] or 0,
            "cost": round(day_cost_row["cost"] or 0, 2),
        })

    conn.close()
    return {
        "project": {"id": proj["id"], "name": proj["name"]},
        "sessions": session_list,
        "daily_activity": daily_list,
        "recent_prompts": [dict(r) for r in recent],
    }

@app.get("/api/vibe-stats/{project_name}")
def vibe_stats(project_name: str):
    """Pre-formatted vibe code stats for embedding in project pages."""
    if LEADERBOARD_ONLY:
        return _blocked()
    conn = get_db()
    proj = conn.execute("SELECT * FROM projects WHERE name = ?", (project_name,)).fetchone()
    if not proj:
        conn.close()
        return {"error": f"Project not found: {project_name}"}

    sessions = conn.execute("""
        SELECT * FROM sessions WHERE project_id = ? ORDER BY first_msg_at ASC
    """, (proj["id"],)).fetchall()

    total_prompts = sum(s["human_prompts"] for s in sessions)
    total_api = sum(s["api_calls"] + s["subagent_api_calls"] for s in sessions)
    total_active = sum(s["active_seconds"] for s in sessions)
    total_input = sum(s["input_tokens"] for s in sessions)
    total_output = sum(s["output_tokens"] for s in sessions)
    total_cache_read = sum(s["cache_read_tokens"] for s in sessions)
    total_cache_write = sum(s["cache_write_tokens"] for s in sessions)
    total_lines = sum((s["lines_written"] if "lines_written" in s.keys() else 0) for s in sessions)
    total_cost = sum(s["cost"] for s in sessions)

    # Cost breakdown using per-model pricing from prompts table
    breakdown_rows = conn.execute("""
        SELECT
            SUM(CASE
                WHEN model LIKE '%haiku%' THEN cache_read_tokens * ?
                WHEN model LIKE '%sonnet%' THEN cache_read_tokens * ?
                ELSE cache_read_tokens * ?
            END) as cache_read_cost,
            SUM(CASE
                WHEN model LIKE '%haiku%' THEN cache_write_tokens * ?
                WHEN model LIKE '%sonnet%' THEN cache_write_tokens * ?
                ELSE cache_write_tokens * ?
            END) as cache_write_cost,
            SUM(CASE
                WHEN model LIKE '%haiku%' THEN output_tokens * ?
                WHEN model LIKE '%sonnet%' THEN output_tokens * ?
                ELSE output_tokens * ?
            END) as output_cost,
            SUM(CASE
                WHEN model LIKE '%haiku%' THEN input_tokens * ?
                WHEN model LIKE '%sonnet%' THEN input_tokens * ?
                ELSE input_tokens * ?
            END) as input_cost
        FROM prompts
        WHERE project_id = ? AND type = 'assistant'
    """, (
        PRICING["haiku"]["cache_read"], PRICING["sonnet"]["cache_read"], PRICING["opus"]["cache_read"],
        PRICING["haiku"]["cache_write"], PRICING["sonnet"]["cache_write"], PRICING["opus"]["cache_write"],
        PRICING["haiku"]["output"], PRICING["sonnet"]["output"], PRICING["opus"]["output"],
        PRICING["haiku"]["input"], PRICING["sonnet"]["input"], PRICING["opus"]["input"],
        proj["id"],
    )).fetchone()

    cost_breakdown = [
        {"label": "Cache reads", "tokens": total_cache_read,
         "cost": round(breakdown_rows["cache_read_cost"] or 0)},
        {"label": "Cache writes", "tokens": total_cache_write,
         "cost": round(breakdown_rows["cache_write_cost"] or 0)},
        {"label": "Output", "tokens": total_output,
         "cost": round(breakdown_rows["output_cost"] or 0)},
        {"label": "Input", "tokens": total_input,
         "cost": round(breakdown_rows["input_cost"] or 0)},
    ]
    max_cost = max((c["cost"] for c in cost_breakdown), default=1)
    for c in cost_breakdown:
        c["pct"] = round(c["cost"] / max_cost * 100) if max_cost else 0

    session_list = []
    for s in sessions:
        session_list.append({
            "first_msg_at": s["first_msg_at"],
            "last_msg_at": s["last_msg_at"],
            "active_hours": round(s["active_seconds"] / 3600, 2),
            "prompts": s["human_prompts"],
            "api_calls": s["api_calls"] + s["subagent_api_calls"],
            "cost": round(s["cost"], 2),
        })

    conn.close()
    return {
        "project": project_name,
        "prompts": total_prompts,
        "api_calls": total_api,
        "active_hours": round(total_active / 3600, 1),
        "total_tokens": total_input + total_output,
        "input_tokens": total_input,
        "output_tokens": total_output,
        "cache_read": total_cache_read,
        "cache_write": total_cache_write,
        "lines_written": total_lines,
        "cost": round(total_cost),
        "cost_per_prompt": round(total_cost / total_prompts, 2) if total_prompts else 0,
        "cost_per_line": round(total_cost / total_lines, 4) if total_lines else 0,
        "sessions": len(sessions),
        "cost_breakdown": cost_breakdown,
        "session_list": session_list,
        "last_updated": sessions[-1]["last_msg_at"] if sessions else None,
    }

@app.get("/api/my-stats")
def my_stats(name: str = "Anonymous"):
    """Export stats for leaderboard sharing."""
    if LEADERBOARD_ONLY:
        return _blocked()
    conn = get_db()
    import socket
    from datetime import datetime, timezone

    row = conn.execute("""
        SELECT
            COUNT(*) as total_sessions,
            COALESCE(SUM(human_prompts), 0) as total_prompts,
            COALESCE(SUM(api_calls + subagent_api_calls), 0) as total_api_calls,
            COALESCE(SUM(active_seconds), 0) as total_active_seconds,
            COALESCE(SUM(input_tokens), 0) as total_input_tokens,
            COALESCE(SUM(output_tokens), 0) as total_output_tokens,
            COALESCE(SUM(cache_read_tokens), 0) as total_cache_read,
            COALESCE(SUM(cache_write_tokens), 0) as total_cache_write,
            COALESCE(SUM(lines_written), 0) as total_lines_written,
            COALESCE(SUM(cost), 0) as total_cost,
            MIN(first_msg_at) as earliest_session,
            MAX(last_msg_at) as latest_session
        FROM sessions
    """).fetchone()

    total_projects = conn.execute("SELECT COUNT(*) as c FROM projects").fetchone()["c"]

    # Aggregate model usage across all sessions
    all_sessions = conn.execute("SELECT model_usage FROM sessions").fetchall()
    model_totals = {}
    for s in all_sessions:
        import json as _json
        for m, c in _json.loads(s["model_usage"]).items():
            model_totals[m] = model_totals.get(m, 0) + c

    conn.close()
    return {
        "name": name,
        "total_prompts": row["total_prompts"],
        "total_api_calls": row["total_api_calls"],
        "total_active_hours": round(row["total_active_seconds"] / 3600, 2),
        "total_input_tokens": row["total_input_tokens"],
        "total_output_tokens": row["total_output_tokens"],
        "total_cache_read": row["total_cache_read"],
        "total_cache_write": row["total_cache_write"],
        "total_lines_written": row["total_lines_written"],
        "total_cost": round(row["total_cost"], 2),
        "total_sessions": row["total_sessions"],
        "total_projects": total_projects,
        "model_usage": model_totals,
        "earliest_session": row["earliest_session"],
        "latest_session": row["latest_session"],
        "exported_at": datetime.now(timezone.utc).isoformat(),
    }

@app.post("/api/leaderboard/submit")
def leaderboard_submit(stats: PlayerStats):
    """Accept stats from a friend's dashboard."""
    conn = get_db()
    _upsert_player(conn, stats.name, stats.model_dump())
    conn.close()
    return {"status": "ok", "player": stats.name}

@app.get("/api/leaderboard")
def leaderboard():
    """Return all players for the leaderboard."""
    conn = get_db()
    rows = conn.execute("""
        SELECT * FROM players ORDER BY total_cost DESC
    """).fetchall()
    conn.close()
    return [
        {
            "name": r["name"],
            "total_prompts": r["total_prompts"],
            "total_api_calls": r["total_api_calls"],
            "total_active_hours": r["total_active_hours"],
            "total_input_tokens": r["total_input_tokens"],
            "total_output_tokens": r["total_output_tokens"],
            "total_cache_read": r["total_cache_read"],
            "total_cache_write": r["total_cache_write"],
            "total_lines_written": r["total_lines_written"],
            "total_cost": round(r["total_cost"], 2),
            "total_sessions": r["total_sessions"],
            "total_projects": r["total_projects"],
            "model_usage": json.loads(r["model_usage"]),
            "last_seen": r["last_seen"],
        }
        for r in rows
    ]

@app.get("/api/leaderboard/{player_name}")
def leaderboard_player(player_name: str):
    """Return a player's per-project breakdown. Only available on personal dashboard."""
    if LEADERBOARD_ONLY:
        return _blocked()
    conn = get_db()
    row = conn.execute("SELECT * FROM players WHERE name = ?", (player_name,)).fetchone()
    conn.close()
    if not row:
        return JSONResponse({"error": "Player not found"}, status_code=404)
    return {
        "name": row["name"],
        "projects": json.loads(row["projects_data"] or "[]"),
        "model_usage": json.loads(row["model_usage"]),
        "last_seen": row["last_seen"],
    }

@app.post("/api/refresh")
def refresh():
    """Re-parse all session files (incremental — skips unchanged files)."""
    if LEADERBOARD_ONLY:
        return _blocked()
    conn = get_db()
    result = full_parse(conn)
    conn.close()
    _submit_self_to_leaderboard()
    _push_to_leaderboard()
    return result

@app.get("/api/timeline")
def timeline():
    """All prompts across all projects, for timeline view."""
    if LEADERBOARD_ONLY:
        return _blocked()
    conn = get_db()
    rows = conn.execute("""
        SELECT p.timestamp, p.type, p.model, p.content_preview,
            p.input_tokens, p.output_tokens, p.cache_read_tokens, p.cache_write_tokens,
            pr.name as project_name
        FROM prompts p
        JOIN projects pr ON pr.id = p.project_id
        WHERE p.type = 'human'
        ORDER BY p.timestamp DESC
        LIMIT 200
    """).fetchall()
    conn.close()
    return [dict(r) for r in rows]

# Serve frontend static files
if LEADERBOARD_ONLY:
    # Leaderboard-only mode: serve the standalone leaderboard page
    leaderboard_dir = Path(__file__).parent.parent / "leaderboard"
    if leaderboard_dir.exists():
        app.mount("/", StaticFiles(directory=str(leaderboard_dir), html=True), name="leaderboard")
else:
    # Full dashboard mode: serve the Vue app build
    frontend_dist = Path(__file__).parent.parent / "frontend" / "dist"
    if frontend_dist.exists():
        app.mount("/", StaticFiles(directory=str(frontend_dist), html=True), name="frontend")

if __name__ == "__main__":
    import uvicorn
    port = int(os.environ.get("PORT", 3400))
    uvicorn.run(app, host="0.0.0.0", port=port)


    earliest_session: str | None = None
    latest_session: str | None = None

    earliest_session: Optional[str] = None
    latest_session: Optional[str] = None