diff --git a/.gitignore b/.gitignore index 487fb9b4..88102f32 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ __pycache__/ server.log +.DS_Store +*.pyc diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e713747..f7a49d29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 2026-05-29 + +- Fix weekly model usage miscount: `_billable` now applies per-model cost weights (Opus ~5×, Sonnet 1×, Haiku ~0.25×) anchored to Sonnet-equivalent plan caps. Previously summed all model tokens 1:1, skewing the all-models bar and per-model breakdown on Opus-heavy weeks (cause of recurring manual edits) +- `cache_creation` now weighted 1.25× input; `cache_read` stays 0.1× +- Add accuracy disclaimer under the weekly card: estimate may drift ±1–2pp from Claude Settings → Usage; use Sync/Edit to recalibrate + +## 2026-05-27 + +- Fix weekly all-models bar not reflecting Anthropic's per-user reset: the previous rolling 7d sum drifted after Anthropic's actual reset fired +- Anchor weekly window per install (persisted at `~/.claude/usage-weekly-anchor.json`), auto-advance after expiry by the first turn observed in the new window +- Add `Sync reset to now` button on the weekly card for manual override (use when Claude Settings → Usage shows a fresh 0%) +- Replace "7d rolling" footer text with a live countdown to the next reset, plus anchor source ("manual" / "auto") +- New POST `/api/weekly/sync-reset` endpoint + ## 2026-04-09 - Fix token counts inflated ~2x by deduplicating streaming events that share the same message ID diff --git a/DISCLAIMER.md b/DISCLAIMER.md new file mode 100644 index 00000000..6f76a142 --- /dev/null +++ b/DISCLAIMER.md @@ -0,0 +1,35 @@ +# Disclaimer + +This is an **unofficial, community-maintained fork** of [phuryn/claude-usage](https://github.com/phuryn/claude-usage). It is **not affiliated with, endorsed by, or sponsored by Anthropic, PBC**. + +## Trademarks + +"Claude" and "Anthropic" are trademarks of Anthropic, PBC. Their use in this project is purely descriptive — to identify the product whose local usage logs this tool reads. No endorsement is implied. + +## Plan budgets are approximations + +Anthropic does not publish exact token caps for Pro, Max 5×, or Max 20× plans. The values in `limits.py` (`PLAN_BUDGETS`) are best-effort estimates calibrated against observed usage. They may be wrong. Do not rely on them for billing, capacity planning, or any decision with real financial impact. + +If your real session is throttled before the bar shows full — or runs longer than the bar predicts — that is expected. The bar is a directional indicator, not a contract. + +## API endpoint probing + +`limits.py` attempts to read plan metadata from undocumented Anthropic endpoints (`/api/oauth/profile`, `/api/account`) using your locally-stored OAuth token. These calls: + +- Are wrapped in try/except and fail silently if the endpoints change or refuse the request +- Make at most one request per 24 hours (cached at `~/.claude/usage-plan-cache.json`) +- Are skipped entirely if `CLAUDE_USAGE_PLAN` is set in your environment + +If you prefer zero network calls to Anthropic, set `CLAUDE_USAGE_PLAN=pro` (or your plan) in your shell profile or launchd plist. + +## No data leaves your machine + +This tool reads only local files (`~/.claude/projects/*.jsonl`) and writes only to a local SQLite database (`~/.claude/usage.db`). The HTTP server binds to `localhost` by default. No telemetry. No analytics. No remote logging. + +## Cost estimates + +Costs shown are calculated using public Anthropic API list pricing. If you use Claude Code via a Pro or Max subscription, your actual cost is the flat subscription fee — the displayed "API equivalent cost" is informational only. + +## No warranty + +Provided as-is under the MIT License. See [LICENSE](LICENSE). diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 00000000..b7ad1dcb --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,121 @@ +# Claude Usage Dashboard — Setup Handoff + +Self-contained guide to run this tracker on a fresh Mac. No build step, no +third-party packages — Python standard library only. + +--- + +## 1. Prerequisites + +- **macOS** with Claude Code installed and used at least once (so local JSONL + logs exist under `~/.claude/`). +- **Python 3.8+** — check with: + ``` + python3 --version + ``` + Ships with macOS. If missing: `brew install python`. + +That's it. No `pip install`, no virtualenv. + +--- + +## 2. Get the code + +### Option A — clone from GitHub (recommended, stays updatable) +``` +git clone https://github.com/changsunglim/claude-usage.git +cd claude-usage +``` + +### Option B — from the handoff archive +Copy `claude-usage-handoff.tar.gz` to the new Mac, then: +``` +tar -xzf claude-usage-handoff.tar.gz +cd claude-usage +``` + +--- + +## 3. Run it + +``` +python3 cli.py dashboard +``` + +- Scans `~/.claude/` logs → builds local SQLite DB → opens browser at + `http://localhost:8080`. +- Custom port: `python3 cli.py dashboard --port 9000` +- Custom projects dir: `python3 cli.py dashboard --projects-dir /path/to/.claude/projects` + +CLI-only views (no browser): +``` +python3 cli.py week # last 7 days, per-day + by-model +python3 cli.py today # today's usage +``` + +--- + +## 4. Weekly limit accuracy (read this) + +The **Weekly · All Models** card estimates your Anthropic plan usage from +local logs. It is a **heuristic**, not Anthropic's official meter: + +- Token cost-weights are approximate: **Opus ~5×, Sonnet 1×, Haiku ~0.25×** + (anchored to Sonnet-equivalent plan caps in `limits.py` → `MODEL_WEIGHTS`). +- `cache_creation` weighted 1.25× input; `cache_read` 0.1×. +- Expect **±1–2 percentage points** of drift vs **Claude Settings → Usage**. + +### Recalibrate when it drifts +On the weekly card: +- **Sync reset to now** — click right after Claude Settings shows a fresh 0%. + Sets the 7-day anchor to this moment. +- **Edit…** — manually enter the anchor time + the % Claude Settings shows + right now, if you missed the reset moment. +- **Clear** — drop the manual override, return to auto-detection. + +Anchor persists at `~/.claude/usage-weekly-anchor.json`. + +### Tuning the weights (optional) +If your plan consistently reads high/low on Opus-heavy or Haiku-heavy weeks, +edit `MODEL_WEIGHTS` at the top of `limits.py`. Compare the dashboard % to +`claude /usage` over a known-model week and nudge the `"in"`/`"out"` factors. + +--- + +## 5. Plan detection + +Auto-detects Pro / Max 5× / Max 20× via macOS keychain OAuth token +(`Claude Code-credentials`). If detection fails it defaults to **Pro** +(23M weekly Sonnet-equivalent tokens). Caps live in `PLAN_BUDGETS` in +`limits.py`. + +--- + +## 6. Updating later + +``` +cd claude-usage +git pull origin main +``` +Then re-run `python3 cli.py dashboard` (auto-rescans). Use the **Rescan** +button in the UI to force a full DB rebuild. + +--- + +## 7. Files + +| File | Purpose | +|------|---------| +| `cli.py` | Entry point: `dashboard`, `week`, `today` commands | +| `dashboard.py` | HTTP server + HTML/JS dashboard + `/api/*` endpoints | +| `scanner.py` | Reads `~/.claude/` JSONL → SQLite DB | +| `limits.py` | Weekly window math, plan detection, cost weights | +| `tests/` | `python3 -m unittest discover tests` | + +DB and anchor live under `~/.claude/` — **not** in this repo, so they carry +per-machine state and are safe to delete to reset. + +--- + +*Unofficial fork of [phuryn/claude-usage](https://github.com/phuryn/claude-usage). +Not affiliated with Anthropic. See DISCLAIMER.md.* diff --git a/README.md b/README.md index cc3d0a85..fae18abd 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,16 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](LICENSE) [![claude-code](https://img.shields.io/badge/claude--code-black?style=flat-square)](https://claude.ai/code) +> **Fork notice:** This is an unofficial fork of [phuryn/claude-usage](https://github.com/phuryn/claude-usage). Not affiliated with Anthropic. See [DISCLAIMER.md](DISCLAIMER.md). Added features: +> - Live limits widget (weekly stacked bar, per-model breakdown, plan auto-detect) +> - Session Health card with new-session recommendations +> - Multi-metric Token Efficiency grader (Cache Hit + Reuse Ratio + Output Discipline + Cost Efficiency) +> - Per-message live session view (click row → see each prompt with token totals) +> - Session titles extracted from first user prompt (replaces opaque project IDs) +> - Local timezone auto-detect (was UTC-only) +> - Noise filter: skill invocations / system reminders excluded from live view +> - Bug fix: `cutoff` → `start`/`end` ReferenceError that killed charts + **Pro and Max subscribers get a progress bar. This gives you the full picture.** Claude Code writes detailed usage logs locally — token counts, models, sessions, projects — regardless of your plan. This dashboard reads those logs and turns them into charts and cost estimates. Works on API, Pro, and Max plans. @@ -119,10 +129,62 @@ Costs are calculated using **Anthropic API pricing as of April 2026** ([claude.c --- +## Fork features + +### Live limits widget +Top banner shows a **weekly all-models stacked bar** (Opus orange, Sonnet blue, Haiku green) with per-model breakdown beneath: `opus X% · 5.5M sonnet Y% · 550k haiku Z%`. + +Plan auto-detection order: +1. macOS keychain → OAuth token → `api.anthropic.com/api/oauth/profile` (undocumented, may return None) +2. `CLAUDE_USAGE_PLAN` env var +3. Default `pro` + +Plan budgets (approximate — Anthropic does not publish exact caps): + +| Plan | Session 5h tokens | Weekly all-models | +|------|-------------------|-------------------| +| `pro` | 613k | 23M | +| `max_5x` | 3.07M | 115M | +| `max_20x` | 12.26M | 460M | + +Override via `CLAUDE_USAGE_PLAN=max_5x python cli.py dashboard` or `/api/limits?plan=max_5x`. + +Cache-read tokens weighted `0.1×` in billable totals to match Anthropic's cost-equivalent counting (~25% deviation in calibration). + +### Session Health card +Heuristic warnings — "start new session" surfaces when any of: +- Context > 150k tokens +- Cache hit rate < 40% +- Avg > 60k tokens/turn +- Session age > 3h + +States: `ok` / `info` / `warn` with colored border. + +### Token Efficiency grader +A/B/C/D grade from weighted formula: +- Cache Hit 40% +- Reuse Ratio 25% +- Cost Efficiency 20% +- Output Discipline 15% + +`?` info button in chart header reveals the formula. Recalcs every 30s with `/api/data`, respects date-range filter. + +### Per-message live view +Click any session row → polls `/api/session/` every 5s. Each user prompt shows token totals (input / output / cache_read / cache_creation), model, turn count, and tools used. Skill invocations and system reminders are filtered out so the list shows real human turns only. + +### New endpoints +- `GET /api/limits?plan=` — weekly + session 5h budgets, plan, used/remaining +- `GET /api/session/` — per-message breakdown for a session + +Server uses `ThreadingHTTPServer` so concurrent `/api/data` + `/api/limits` calls don't block. + +--- + ## Files | File | Purpose | |------|---------| | `scanner.py` | Parses JSONL transcripts, writes to `~/.claude/usage.db` | | `dashboard.py` | HTTP server + single-page HTML/JS dashboard | +| `limits.py` | Plan detection, session 5h window, rolling weekly budgets | | `cli.py` | `scan`, `today`, `stats`, `dashboard` commands | diff --git a/cli.py b/cli.py index 01cf3627..22925b39 100644 --- a/cli.py +++ b/cli.py @@ -357,7 +357,7 @@ def cmd_stats(): conn.close() -def cmd_dashboard(projects_dir=None, host=None, port=None): +def cmd_dashboard(projects_dir=None, host=None, port=None, open_browser=True): import webbrowser import threading import time @@ -371,12 +371,13 @@ def cmd_dashboard(projects_dir=None, host=None, port=None): host = host or os.environ.get("HOST", "localhost") port = int(port or os.environ.get("PORT", "8080")) - def open_browser(): - time.sleep(1.0) - webbrowser.open(f"http://{host}:{port}") + if open_browser: + def _open(): + time.sleep(1.0) + webbrowser.open(f"http://{host}:{port}") - t = threading.Thread(target=open_browser, daemon=True) - t.start() + t = threading.Thread(target=_open, daemon=True) + t.start() serve(host=host, port=port) @@ -390,8 +391,9 @@ def open_browser(): python cli.py today Show today's usage summary python cli.py week Show last 7 days (per-day + by-model) python cli.py stats Show all-time statistics - python cli.py dashboard [--projects-dir PATH] [--host HOST] [--port PORT] + python cli.py dashboard [--projects-dir PATH] [--host HOST] [--port PORT] [--no-browser] Scan + start dashboard + (--no-browser: don't auto-open a tab; for launchd/background) """ COMMANDS = { @@ -423,6 +425,7 @@ def parse_named_arg(args, flag): projects_dir=projects_dir, host=parse_named_arg(rest, "--host"), port=parse_named_arg(rest, "--port"), + open_browser="--no-browser" not in rest, ) elif command == "scan" and projects_dir: cmd_scan(projects_dir=projects_dir) diff --git a/dashboard.py b/dashboard.py index ebf8d5f2..65ef5b9f 100644 --- a/dashboard.py +++ b/dashboard.py @@ -4,20 +4,150 @@ import json import os +import re +import glob import sqlite3 -from http.server import HTTPServer, BaseHTTPRequestHandler +import threading +from http.server import HTTPServer, ThreadingHTTPServer, BaseHTTPRequestHandler from pathlib import Path -from datetime import datetime +from datetime import datetime, timedelta, timezone DB_PATH = Path.home() / ".claude" / "usage.db" +def _detect_tz_offset_hours(): + """Auto-detect system timezone offset in hours. Override with env TZ_OFFSET_HOURS.""" + env = os.environ.get("TZ_OFFSET_HOURS") + if env not in (None, ""): + try: + return int(env) + except ValueError: + pass + try: + off = datetime.now().astimezone().utcoffset() + if off is None: + return 0 + return int(off.total_seconds() // 3600) + except Exception: + return 0 + + +TZ_OFFSET_HOURS = _detect_tz_offset_hours() +TZ_NAME = datetime.now().astimezone().tzname() or f"UTC{TZ_OFFSET_HOURS:+d}" +SQL_TZ_SHIFT = f"'+{TZ_OFFSET_HOURS} hours'" if TZ_OFFSET_HOURS >= 0 else f"'{TZ_OFFSET_HOURS} hours'" + +PROJECTS_DIRS = [ + Path.home() / ".claude" / "projects", + Path.home() / "Library" / "Developer" / "Xcode" / "CodingAssistant" / "ClaudeAgentConfig" / "projects", +] + + +def _shift_iso(ts, hours=TZ_OFFSET_HOURS): + if not ts: + return "" + try: + dt = datetime.fromisoformat(ts.replace("Z", "+00:00")) + return (dt + timedelta(hours=hours)).strftime("%Y-%m-%dT%H:%M:%S") + except Exception: + return ts + + +_TITLE_CACHE = {} # session_id -> (mtime, title, project_path, jsonl_path) + + +def _find_session_jsonl(session_id): + for d in PROJECTS_DIRS: + if not d.exists(): + continue + for p in d.rglob(f"{session_id}.jsonl"): + return p + return None + + +def _extract_title(jsonl_path): + try: + with open(jsonl_path, "r", encoding="utf-8", errors="ignore") as f: + for line in f: + try: + d = json.loads(line) + except Exception: + continue + if d.get("type") != "user": + continue + msg = d.get("message") + if not isinstance(msg, dict) or msg.get("role") != "user": + continue + c = msg.get("content") + text = None + if isinstance(c, str): + text = c + elif isinstance(c, list): + for it in c: + if isinstance(it, dict) and it.get("type") == "text": + text = it.get("text") + break + if not text: + continue + t = text.strip() + if t.startswith("<") or t.startswith("Caveat:") or t.startswith("[Request"): + continue + t = " ".join(t.split()) + return t[:80] + ("..." if len(t) > 80 else "") + except Exception: + pass + return "" + + +def get_session_titles(session_ids): + out = {} + for sid in session_ids: + p = _find_session_jsonl(sid) + if not p: + out[sid] = "" + continue + try: + mtime = p.stat().st_mtime + except OSError: + out[sid] = "" + continue + cached = _TITLE_CACHE.get(sid) + if cached and cached[0] == mtime: + out[sid] = cached[1] + else: + title = _extract_title(p) + _TITLE_CACHE[sid] = (mtime, title, str(p.parent), str(p)) + out[sid] = title + return out + + +# Only one scan may write the DB at a time. ThreadingHTTPServer can fire +# several /api/data + /api/session polls concurrently; without this they +# launch overlapping write-scans and collide ("database is locked"). +_SCAN_LOCK = threading.Lock() + + +def _safe_rescan(): + """Incremental rescan, serialized and never fatal to the request.""" + if not _SCAN_LOCK.acquire(blocking=False): + return # a scan is already running — its result will be read below + try: + import scanner + scanner.scan(db_path=DB_PATH, + projects_dirs=scanner.DEFAULT_PROJECTS_DIRS, + verbose=False) + except Exception: + pass + finally: + _SCAN_LOCK.release() + + def get_dashboard_data(db_path=DB_PATH): if not db_path.exists(): return {"error": "Database not found. Run: python cli.py scan"} - conn = sqlite3.connect(db_path) + conn = sqlite3.connect(db_path, timeout=10) conn.row_factory = sqlite3.Row + conn.execute("PRAGMA busy_timeout=10000") # ── All models (for filter UI) ──────────────────────────────────────────── model_rows = conn.execute(""" @@ -29,9 +159,9 @@ def get_dashboard_data(db_path=DB_PATH): all_models = [r["model"] for r in model_rows] # ── Daily per-model, ALL history (client filters by range) ──────────────── - daily_rows = conn.execute(""" + daily_rows = conn.execute(f""" SELECT - substr(timestamp, 1, 10) as day, + substr(datetime(timestamp, {SQL_TZ_SHIFT}), 1, 10) as day, COALESCE(model, 'unknown') as model, SUM(input_tokens) as input, SUM(output_tokens) as output, @@ -55,10 +185,10 @@ def get_dashboard_data(db_path=DB_PATH): # ── Hourly per-day per-model (client filters by range + TZ-shifts) ──────── # Timestamps are ISO8601 UTC (e.g. "2026-04-08T09:30:00Z"); chars 12-13 = hour. - hourly_rows = conn.execute(""" + hourly_rows = conn.execute(f""" SELECT - substr(timestamp, 1, 10) as day, - CAST(substr(timestamp, 12, 2) AS INTEGER) as hour, + substr(datetime(timestamp, {SQL_TZ_SHIFT}), 1, 10) as day, + CAST(substr(datetime(timestamp, {SQL_TZ_SHIFT}), 12, 2) AS INTEGER) as hour, COALESCE(model, 'unknown') as model, SUM(output_tokens) as output, COUNT(*) as turns @@ -88,6 +218,8 @@ def get_dashboard_data(db_path=DB_PATH): """).fetchall() sessions_all = [] + raw_ids = [r["session_id"] for r in session_rows] + titles = get_session_titles(raw_ids[:50]) # title lookup limited to recent 50 for r in session_rows: try: t1 = datetime.fromisoformat(r["first_timestamp"].replace("Z", "+00:00")) @@ -95,12 +227,16 @@ def get_dashboard_data(db_path=DB_PATH): duration_min = round((t2 - t1).total_seconds() / 60, 1) except Exception: duration_min = 0 + last_shifted = _shift_iso(r["last_timestamp"]) sessions_all.append({ "session_id": r["session_id"][:8], + "session_id_full": r["session_id"], + "title": titles.get(r["session_id"], ""), "project": r["project_name"] or "unknown", "branch": r["git_branch"] or "", - "last": (r["last_timestamp"] or "")[:16].replace("T", " "), - "last_date": (r["last_timestamp"] or "")[:10], + "last": last_shifted[:16].replace("T", " "), + "last_date": last_shifted[:10], + "last_iso": r["last_timestamp"], "duration_min": duration_min, "model": r["model"] or "unknown", "turns": r["turn_count"] or 0, @@ -112,15 +248,182 @@ def get_dashboard_data(db_path=DB_PATH): conn.close() + # Project label map: prefer cleaner names. For opaque project IDs (UUIDs/paths + # ending in random hex), use the most recent session title. + project_labels = {} + import re as _re + uuid_re = _re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", _re.I) + for s in sessions_all: + proj = s["project"] + if proj in project_labels: + continue + looks_opaque = ( + uuid_re.search(proj) is not None + or proj.count("/") > 1 + or proj == "unknown" + ) + if looks_opaque and s.get("title"): + project_labels[proj] = s["title"][:50] + else: + # Use basename of path + base = proj.rstrip("/").split("/")[-1] if "/" in proj else proj + project_labels[proj] = base or proj + return { "all_models": all_models, "daily_by_model": daily_by_model, "hourly_by_model": hourly_by_model, "sessions_all": sessions_all, + "project_labels": project_labels, + "tz_name": TZ_NAME, + "tz_offset_hours": TZ_OFFSET_HOURS, "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), } +def _is_real_user_message(msg): + """True if message is an actual human prompt (not tool_result).""" + if not isinstance(msg, dict): + return False + if msg.get("role") != "user": + return False + c = msg.get("content") + if isinstance(c, str): + return c.strip() != "" + if isinstance(c, list): + for it in c: + if isinstance(it, dict): + if it.get("type") == "tool_result": + return False + if it.get("type") == "text" and it.get("text", "").strip(): + return True + return False + return False + + +def _msg_text(msg): + c = msg.get("content") if isinstance(msg, dict) else None + if isinstance(c, str): + return c + if isinstance(c, list): + parts = [] + for it in c: + if isinstance(it, dict) and it.get("type") == "text": + parts.append(it.get("text", "")) + return " ".join(parts) + return "" + + +_NOISE_TAG_RE = re.compile( + r"<(command-message|command-name|command-args|command-stdout|command-output|" + r"local-command-stdout|local-command-output|system-reminder|bash-input|bash-stdout|" + r"bash-stderr|user-prompt-submit-hook)\b[^>]*>.*?", + re.DOTALL, +) + + +def _is_noise_prompt(text): + """True if prompt body is only Claude Code wrapper tags (skill invocations, hook injections, system reminders).""" + stripped = _NOISE_TAG_RE.sub("", text or "").strip() + return stripped == "" + + +def parse_session_messages(jsonl_path): + """Group turns by user message. Returns list of {prompt, timestamp, input, output, cache_read, cache_creation, model, turn_count}.""" + groups = [] + current = None + try: + with open(jsonl_path, "r", encoding="utf-8", errors="ignore") as f: + for line in f: + try: + d = json.loads(line) + except Exception: + continue + t = d.get("type") + ts = d.get("timestamp", "") + msg = d.get("message") + if t == "user" and _is_real_user_message(msg): + text = _msg_text(msg).strip() + if _is_noise_prompt(text): + # Skill invocations / system reminders aren't real user turns — + # let following assistant tokens accumulate on prior prompt. + continue + text = " ".join(text.split()) + current = { + "prompt": text[:200] + ("..." if len(text) > 200 else ""), + "full_prompt": text, + "timestamp": _shift_iso(ts), + "input": 0, + "output": 0, + "cache_read": 0, + "cache_creation": 0, + "model": "", + "turn_count": 0, + "tools": [], + } + groups.append(current) + elif t == "assistant" and current is not None and isinstance(msg, dict): + usage = msg.get("usage") or {} + current["input"] += usage.get("input_tokens", 0) or 0 + current["output"] += usage.get("output_tokens", 0) or 0 + current["cache_read"] += usage.get("cache_read_input_tokens", 0) or 0 + current["cache_creation"] += usage.get("cache_creation_input_tokens", 0) or 0 + current["turn_count"] += 1 + if not current["model"]: + current["model"] = msg.get("model", "") or "" + c = msg.get("content") + if isinstance(c, list): + for it in c: + if isinstance(it, dict) and it.get("type") == "tool_use": + name = it.get("name", "") + if name and name not in current["tools"]: + current["tools"].append(name) + except Exception: + pass + return groups + + +def get_session_live(session_id, db_path=DB_PATH): + if not db_path.exists(): + return {"error": "Database not found"} + conn = sqlite3.connect(db_path, timeout=10) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA busy_timeout=10000") + # Resolve full id from prefix + row = conn.execute( + "SELECT session_id, project_name, model, turn_count, first_timestamp, last_timestamp, " + "total_input_tokens, total_output_tokens, total_cache_read, total_cache_creation, git_branch " + "FROM sessions WHERE session_id = ? OR session_id LIKE ? LIMIT 1", + (session_id, session_id + "%"), + ).fetchone() + if not row: + conn.close() + return {"error": f"Session not found: {session_id}"} + full_id = row["session_id"] + conn.close() + jsonl = _find_session_jsonl(full_id) + messages = parse_session_messages(jsonl) if jsonl else [] + titles = get_session_titles([full_id]) + return { + "session_id": full_id, + "title": titles.get(full_id, ""), + "project": row["project_name"] or "unknown", + "branch": row["git_branch"] or "", + "model": row["model"] or "unknown", + "turn_count": row["turn_count"] or 0, + "first": _shift_iso(row["first_timestamp"]), + "last": _shift_iso(row["last_timestamp"]), + "totals": { + "input": row["total_input_tokens"] or 0, + "output": row["total_output_tokens"] or 0, + "cache_read": row["total_cache_read"] or 0, + "cache_creation": row["total_cache_creation"] or 0, + }, + "messages": messages, + "generated_at": (datetime.utcnow() + timedelta(hours=TZ_OFFSET_HOURS)).strftime("%Y-%m-%d %H:%M:%S"), + } + + HTML_TEMPLATE = r""" @@ -147,6 +450,33 @@ def get_dashboard_data(db_path=DB_PATH): header .meta { color: var(--muted); font-size: 12px; } #rescan-btn { background: var(--card); border: 1px solid var(--border); color: var(--muted); padding: 4px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; margin-top: 4px; } #rescan-btn:hover { color: var(--text); border-color: var(--accent); } + + /* Live limits banner */ + #limits-banner { background: var(--card); border-bottom: 1px solid var(--border); padding: 12px 24px; display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 14px; align-items: stretch; } + .limit-card { border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; position: relative; } + .limit-card .lc-head { display: flex; justify-content: space-between; align-items: baseline; font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; } + .limit-card .lc-pct { color: var(--text); font-weight: 600; font-size: 12px; letter-spacing: 0; text-transform: none; } + .limit-card .lc-bar { height: 8px; background: rgba(255,255,255,0.05); border-radius: 4px; overflow: hidden; display: flex; } + .limit-card .lc-fill { height: 100%; width: 0%; background: var(--green); transition: width 0.4s ease, background 0.4s; } + .limit-card .lc-fill.warn { background: #f59e0b; } + .limit-card .lc-fill.danger { background: #ef4444; } + .limit-card .lc-seg { height: 100%; transition: width 0.4s ease; } + .limit-card .lc-foot { display: flex; justify-content: space-between; font-size: 11px; color: var(--muted); margin-top: 6px; } + .limit-card .lc-foot strong { color: var(--text); font-weight: 600; } + .lc-models { display: flex; gap: 10px; margin-top: 6px; font-size: 10px; color: var(--muted); flex-wrap: wrap; } + .lc-models .lc-model { display: inline-flex; align-items: center; gap: 4px; } + .lc-models .lc-swatch { width: 8px; height: 8px; border-radius: 2px; display: inline-block; } + #lc-health { padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; font-size: 12px; line-height: 1.4; } + #lc-health.health-ok { border-color: rgba(74,222,128,0.3); } + #lc-health.health-info { border-color: #f59e0b; background: rgba(245,158,11,0.06); } + #lc-health.health-warn { border-color: #ef4444; background: rgba(239,68,68,0.08); } + #lc-health .lc-head { display: flex; justify-content: space-between; font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; } + #lc-health .lc-msg { color: var(--text); } + #lc-health.health-ok .lc-msg { color: var(--muted); } + #lc-health .lc-stats { font-size: 10px; color: var(--muted); margin-top: 6px; display: flex; gap: 10px; flex-wrap: wrap; } + #limits-meta { padding: 6px 24px 0; font-size: 11px; color: var(--muted); display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; align-items: center; } + #plan-select { background: var(--card); border: 1px solid var(--border); color: var(--text); border-radius: 4px; padding: 2px 6px; font-size: 11px; } + .lc-disabled { opacity: 0.5; } #rescan-btn:disabled { opacity: 0.5; cursor: not-allowed; } #filter-bar { background: var(--card); border-bottom: 1px solid var(--border); padding: 10px 24px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } @@ -227,6 +557,63 @@ def get_dashboard_data(db_path=DB_PATH): +
+
+ Plan: + + +
+
+ Local Claude Code usage only · web usage not trackable locally +
+
+
+
+
Weekly · All Models
+
+
— / —
+
+
+ Estimate may drift ±1–2pp from Claude Settings → Usage (cost-weighting heuristics; cache/model multipliers approximate). Use Sync/Edit to recalibrate. +
+
+ + + + +
+
+
+
+
Session Health
+
Loading…
+
+
+
+
Models
@@ -242,6 +629,21 @@ def get_dashboard_data(db_path=DB_PATH): + +
+ @@ -267,34 +669,37 @@ def get_dashboard_data(db_path=DB_PATH):
-

By Model

-
+

Token Efficiency + + +

+ +

Top Projects by Tokens

-
-
Cost by Model
- - - - - - - - - - - -
ModelTurns Input Output Cache Read Cache Creation Est. Cost
+
+
Efficiency Breakdown
+
-
Recent Sessions
+
Recent Sessions (click row to watch live)
+ @@ -307,6 +712,32 @@ def get_dashboard_data(db_path=DB_PATH):
SessionTitle Project Last Active Duration
+ +
Cost by Project
@@ -484,9 +915,32 @@ def get_dashboard_data(db_path=DB_PATH): const RANGE_LABELS = { 'week': 'This Week', 'month': 'This Month', 'prev-month': 'Previous Month', '7d': 'Last 7 Days', '30d': 'Last 30 Days', '90d': 'Last 90 Days', 'all': 'All Time' }; const RANGE_TICKS = { 'week': 7, 'month': 15, 'prev-month': 15, '7d': 7, '30d': 15, '90d': 13, 'all': 12 }; const VALID_RANGES = Object.keys(RANGE_LABELS); +// Custom range stored as 'custom:'. Default 60 min. +let customRangeMinutes = 60; + +function isCustomRange(r) { return typeof r === 'string' && r.startsWith('custom:'); } +function customRangeFromKey(r) { + if (!isCustomRange(r)) return null; + const n = parseInt(r.slice(7), 10); + return Number.isFinite(n) && n > 0 ? n : null; +} +function fmtCustomLabel(mins) { + if (mins < 60) return `Last ${mins} min`; + if (mins < 1440) { + const h = mins / 60; + return `Last ${Number.isInteger(h) ? h : h.toFixed(1)} h`; + } + const d = mins / 1440; + return `Last ${Number.isInteger(d) ? d : d.toFixed(1)} d`; +} +function rangeLabel(range) { + if (isCustomRange(range)) return fmtCustomLabel(customRangeFromKey(range) || 60); + return RANGE_LABELS[range] || range; +} function rangeIncludesToday(range) { if (range === 'all') return true; + if (isCustomRange(range)) return true; // custom always ends at now const { start, end } = getRangeBounds(range); const today = new Date().toISOString().slice(0, 10); if (start && today < start) return false; @@ -495,42 +949,94 @@ def get_dashboard_data(db_path=DB_PATH): } function getRangeBounds(range) { - if (range === 'all') return { start: null, end: null }; + if (range === 'all') return { start: null, end: null, startMs: null, endMs: null }; const today = new Date(); const iso = d => d.toISOString().slice(0, 10); + if (isCustomRange(range)) { + const mins = customRangeFromKey(range) || 60; + const endMs = Date.now(); + const startMs = endMs - mins * 60 * 1000; + const startDate = new Date(startMs); + return { + start: iso(startDate), + end: null, + startMs: startMs, + endMs: endMs, + }; + } if (range === 'week') { const day = today.getDay(); const diffToMon = day === 0 ? 6 : day - 1; const mon = new Date(today); mon.setDate(today.getDate() - diffToMon); const sun = new Date(mon); sun.setDate(mon.getDate() + 6); - return { start: iso(mon), end: iso(sun) }; + return { start: iso(mon), end: iso(sun), startMs: null, endMs: null }; } if (range === 'month') { const start = new Date(today.getFullYear(), today.getMonth(), 1); const end = new Date(today.getFullYear(), today.getMonth() + 1, 0); - return { start: iso(start), end: iso(end) }; + return { start: iso(start), end: iso(end), startMs: null, endMs: null }; } if (range === 'prev-month') { const start = new Date(today.getFullYear(), today.getMonth() - 1, 1); const end = new Date(today.getFullYear(), today.getMonth(), 0); - return { start: iso(start), end: iso(end) }; + return { start: iso(start), end: iso(end), startMs: null, endMs: null }; } const days = range === '7d' ? 7 : range === '30d' ? 30 : 90; const d = new Date(); d.setDate(d.getDate() - days); - return { start: iso(d), end: null }; + return { start: iso(d), end: null, startMs: null, endMs: null }; +} + +function openCustomRange() { + const panel = document.getElementById('custom-range-panel'); + if (!panel) return; + panel.style.display = ''; + const amt = document.getElementById('custom-range-amount'); + if (amt) amt.focus(); +} +function applyCustomRange() { + const amt = parseInt(document.getElementById('custom-range-amount').value, 10); + const unit = document.getElementById('custom-range-unit').value; + if (!Number.isFinite(amt) || amt < 1) { alert('Enter a positive number.'); return; } + const mins = unit === 'd' ? amt * 1440 : unit === 'h' ? amt * 60 : amt; + if (mins > 525600) { alert('Max is 1 year.'); return; } + customRangeMinutes = mins; + setRange('custom:' + mins); +} + +// Number of calendar days the selected range spans (used as denominator +// for "average per day" charts so inactive days still count). +function rangeCalendarDays(range, startISO, endISO) { + if (range === 'all') return 0; // unknown — caller falls back to active-days + const MS = 86400000; + const today = new Date(); + const todayISO = today.toISOString().slice(0, 10); + const effectiveEndISO = endISO || todayISO; + if (!startISO) return 0; + const s = new Date(startISO + 'T00:00:00Z'); + const e = new Date(effectiveEndISO + 'T00:00:00Z'); + return Math.max(1, Math.round((e - s) / MS) + 1); } function readURLRange() { const p = new URLSearchParams(window.location.search).get('range'); + if (p && isCustomRange(p) && customRangeFromKey(p)) { + customRangeMinutes = customRangeFromKey(p); + return p; + } return VALID_RANGES.includes(p) ? p : '30d'; } function setRange(range) { selectedRange = range; - document.querySelectorAll('.range-btn').forEach(btn => - btn.classList.toggle('active', btn.dataset.range === range) - ); + const isCustom = isCustomRange(range); + document.querySelectorAll('.range-btn').forEach(btn => { + const target = isCustom ? 'custom' : range; + btn.classList.toggle('active', btn.dataset.range === target); + }); + // Show/hide custom panel based on whether range is custom. + const panel = document.getElementById('custom-range-panel'); + if (panel) panel.style.display = isCustom ? '' : 'none'; updateURL(); applyFilter(); scheduleAutoRefresh(); @@ -655,7 +1161,10 @@ def get_dashboard_data(db_path=DB_PATH): function applyFilter() { if (!rawData) return; - const { start, end } = getRangeBounds(selectedRange); + const { start, end, startMs, endMs } = getRangeBounds(selectedRange); + // Sub-day custom range: daily rows are day-bucketed, so we filter sessions + // by minute-precision last_iso and recompute by-model totals from sessions. + const subDay = startMs != null && (endMs - startMs) < 86400000; // Filter daily rows by model + date range const filteredDaily = rawData.daily_by_model.filter(r => @@ -686,10 +1195,34 @@ def get_dashboard_data(db_path=DB_PATH): m.turns += r.turns; } - // Filter sessions by model + date range - const filteredSessions = rawData.sessions_all.filter(s => - selectedModels.has(s.model) && (!start || s.last_date >= start) && (!end || s.last_date <= end) - ); + // Filter sessions by model + date range (minute-precision when startMs set) + const filteredSessions = rawData.sessions_all.filter(s => { + if (!selectedModels.has(s.model)) return false; + if (startMs != null) { + const ts = s.last_iso ? Date.parse(s.last_iso) : NaN; + if (!Number.isFinite(ts)) return false; + if (ts < startMs) return false; + if (endMs != null && ts > endMs) return false; + return true; + } + if (start && s.last_date < start) return false; + if (end && s.last_date > end) return false; + return true; + }); + + if (subDay) { + // Rebuild byModel from session totals (daily-bucketed rows are too coarse). + for (const k of Object.keys(modelMap)) delete modelMap[k]; + for (const s of filteredSessions) { + if (!modelMap[s.model]) modelMap[s.model] = { model: s.model, input: 0, output: 0, cache_read: 0, cache_creation: 0, turns: 0, sessions: 0 }; + const m = modelMap[s.model]; + m.input += s.input; + m.output += s.output; + m.cache_read += s.cache_read; + m.cache_creation += s.cache_creation; + m.turns += s.turns; + } + } // Add session counts into modelMap for (const s of filteredSessions) { @@ -740,38 +1273,43 @@ def get_dashboard_data(db_path=DB_PATH): cost: byModel.reduce((s, m) => s + calcCost(m.model, m.input, m.output, m.cache_read, m.cache_creation), 0), }; - // Hourly aggregation (filtered by model + range, then bucketed by UTC hour) + // Hourly aggregation (filtered by model + range, bucketed by local hour) const hourlySrc = (rawData.hourly_by_model || []).filter(r => - selectedModels.has(r.model) && (!cutoff || r.day >= cutoff) + selectedModels.has(r.model) && (!start || r.day >= start) && (!end || r.day <= end) ); - const hourlyAgg = aggregateHourly(hourlySrc, hourlyTZ); + const rangeDayCount = rangeCalendarDays(selectedRange, start, end); + const hourlyAgg = aggregateHourly(hourlySrc, hourlyTZ, rangeDayCount); // Update daily chart title - document.getElementById('daily-chart-title').textContent = 'Daily Token Usage \u2014 ' + RANGE_LABELS[selectedRange]; - document.getElementById('hourly-chart-title').textContent = 'Average Hourly Distribution \u2014 ' + RANGE_LABELS[selectedRange]; + document.getElementById('daily-chart-title').textContent = 'Daily Token Usage \u2014 ' + rangeLabel(selectedRange); + document.getElementById('hourly-chart-title').textContent = 'Average Hourly Distribution \u2014 ' + rangeLabel(selectedRange); renderStats(totals); renderDailyChart(daily); renderHourlyChart(hourlyAgg); - renderModelChart(byModel); + renderEfficiencyChart(totals); renderProjectChart(byProject); lastFilteredSessions = sortSessions(filteredSessions); lastByProject = sortProjects(byProject); lastByProjectBranch = sortProjectBranch(byProjectBranch); renderSessionsTable(lastFilteredSessions.slice(0, 20)); - renderModelCostTable(byModel); renderProjectCostTable(lastByProject.slice(0, 20)); renderProjectBranchCostTable(lastByProjectBranch.slice(0, 20)); } +function projectLabel(proj) { + const m = (rawData && rawData.project_labels) || {}; + return m[proj] || proj; +} + // ── Renderers ────────────────────────────────────────────────────────────── function renderStats(t) { - const rangeLabel = RANGE_LABELS[selectedRange].toLowerCase(); + const subLabel = rangeLabel(selectedRange).toLowerCase(); const stats = [ - { label: 'Sessions', value: t.sessions.toLocaleString(), sub: rangeLabel }, - { label: 'Turns', value: fmt(t.turns), sub: rangeLabel }, - { label: 'Input Tokens', value: fmt(t.input), sub: rangeLabel }, - { label: 'Output Tokens', value: fmt(t.output), sub: rangeLabel }, + { label: 'Sessions', value: t.sessions.toLocaleString(), sub: subLabel }, + { label: 'Turns', value: fmt(t.turns), sub: subLabel }, + { label: 'Input Tokens', value: fmt(t.input), sub: subLabel }, + { label: 'Output Tokens', value: fmt(t.output), sub: subLabel }, { label: 'Cache Read', value: fmt(t.cache_read), sub: 'from prompt cache' }, { label: 'Cache Creation', value: fmt(t.cache_creation), sub: 'writes to prompt cache' }, { label: 'Est. Cost', value: fmtCostBig(t.cost), sub: 'API pricing, Apr 2026', color: '#4ade80' }, @@ -786,18 +1324,25 @@ def get_dashboard_data(db_path=DB_PATH): } // Bucket rows into 24 hours (display-TZ), summing turns + output, and count -// the unique days in the input so the caller can compute per-day averages. -function aggregateHourly(rows, tzMode) { +// the calendar days in the selected range so averages divide by window size, +// not by active-days-only. +// +// NOTE: server pre-applies SQL_TZ_SHIFT, so r.hour is already in local time. +// For 'local' display, use as-is. For 'utc', subtract the local offset. +function aggregateHourly(rows, tzMode, rangeDayCount) { const byHour = {}; for (let h = 0; h < 24; h++) byHour[h] = { turns: 0, output: 0 }; const days = new Set(); for (const r of rows) { - const displayHour = utcHourToDisplay(r.hour, tzMode); + const displayHour = tzMode === 'local' + ? r.hour + : ((r.hour - localOffsetHours()) % 24 + 24) % 24; byHour[displayHour].turns += r.turns || 0; byHour[displayHour].output += r.output || 0; if (r.day) days.add(r.day); } - const dayCount = days.size; + // Prefer the range's calendar-day count; fall back to active days for 'all'. + const dayCount = rangeDayCount && rangeDayCount > 0 ? rangeDayCount : days.size; const hours = []; for (let h = 0; h < 24; h++) { hours.push({ @@ -814,7 +1359,7 @@ def get_dashboard_data(db_path=DB_PATH): function renderHourlyChart(agg) { const dayCountEl = document.getElementById('hourly-day-count'); dayCountEl.textContent = agg.dayCount - ? agg.dayCount + ' day' + (agg.dayCount === 1 ? '' : 's') + ' averaged · ' + tzDisplayName(hourlyTZ) + ? 'avg / day over ' + agg.dayCount + ' day' + (agg.dayCount === 1 ? '' : 's') + ' · ' + tzDisplayName(hourlyTZ) : 'No data · ' + tzDisplayName(hourlyTZ); const ctx = document.getElementById('chart-hourly').getContext('2d'); @@ -909,24 +1454,159 @@ def get_dashboard_data(db_path=DB_PATH): }); } -function renderModelChart(byModel) { - const ctx = document.getElementById('chart-model').getContext('2d'); +// Efficiency model (based on Anthropic prompt-caching pricing, Apr 2026): +// cache_write = 1.25x base input price +// cache_read = 0.10x base input price +// output = ~5x input price (varies by model) +// Composite efficiency score weights 4 sub-metrics: +// 1. Cache Hit Rate — reused / (reused + new_input) +// 2. Reuse Ratio — cache_read / cache_creation (writes paying off) +// 3. Output Discipline — penalize runaway output vs input +// 4. Cost Efficiency — actual_cost / hypothetical_no_cache_cost +function computeEfficiency(t) { + const reused = t.cache_read || 0; + const cacheWrite = t.cache_creation || 0; + const freshInput = t.input || 0; + const output = t.output || 0; + const newInput = freshInput + cacheWrite; + const totalInput = reused + newInput; + + // 1. Cache hit rate (0–100) + const cacheHitPct = totalInput > 0 ? (reused / totalInput) * 100 : 0; + + // 2. Reuse ratio: how many times each cached write gets read back. + // 2.0+ = excellent, 1.0 = breakeven (cache cost recouped), <1 = wasted writes + const reuseRatio = cacheWrite > 0 ? reused / cacheWrite : (reused > 0 ? 10 : 0); + + // 3. Output discipline: output should be small fraction of input you fed. + // Healthy range 0.05–0.3. Above 0.5 = chatty. + const outputRatio = totalInput > 0 ? output / totalInput : 0; + + // 4. Cost vs hypothetical no-cache. Saved = cache_read * 0.9 of base input price + // (since cache_read = 0.1x). cache_write = 0.25x premium over base. + // Effective rate vs no-cache baseline: + const noCacheCost = (reused + newInput) * 1.0; // all at 1.0x + const actualCost = reused * 0.10 + cacheWrite * 1.25 + freshInput * 1.0; + const costEffPct = noCacheCost > 0 ? (1 - actualCost / noCacheCost) * 100 : 0; + + // Composite score 0–100 (weighted) + const cacheScore = Math.min(100, cacheHitPct); // weight 0.40 + const reuseScore = Math.min(100, reuseRatio * 25); // weight 0.25 (4x reuse=100) + const outputScore = Math.max(0, 100 - Math.min(100, outputRatio*200));// weight 0.15 + const costScore = Math.max(0, Math.min(100, costEffPct)); // weight 0.20 + const composite = cacheScore*0.40 + reuseScore*0.25 + outputScore*0.15 + costScore*0.20; + + let grade = 'F', color = '#ef4444'; + if (composite >= 90) { grade = 'A+'; color = '#22c55e'; } + else if (composite >= 80) { grade = 'A'; color = '#4ade80'; } + else if (composite >= 70) { grade = 'B'; color = '#84cc16'; } + else if (composite >= 60) { grade = 'C'; color = '#facc15'; } + else if (composite >= 50) { grade = 'D'; color = '#fb923c'; } + + // $ saved estimate using blended input price ($5/M ballpark across opus/sonnet/haiku) + const BLENDED_INPUT_PRICE_PER_M = 5; + const savedUSD = (noCacheCost - actualCost) * BLENDED_INPUT_PRICE_PER_M / 1_000_000; + + return { + reused, cacheWrite, freshInput, newInput, output, totalInput, + cacheHitPct, reuseRatio, outputRatio, costEffPct, + cacheScore, reuseScore, outputScore, costScore, + composite, grade, color, savedUSD, + }; +} + +function toggleEffHelp() { + const el = document.getElementById('eff-help'); + if (!el) return; + el.style.display = el.style.display === 'none' ? 'block' : 'none'; +} +function renderEfficiencyChart(totals) { + const ctx = document.getElementById('chart-efficiency').getContext('2d'); if (charts.model) charts.model.destroy(); - if (!byModel.length) { charts.model = null; return; } + const e = computeEfficiency(totals); + const totalEff = e.reused + e.newInput + e.output; + if (totalEff === 0) { + charts.model = null; + document.getElementById('efficiency-grade').textContent=''; + renderEfficiencyBreakdown(e); + return; + } + document.getElementById('efficiency-grade').innerHTML = + `Grade ${e.grade} · ${e.composite.toFixed(0)}/100`; charts.model = new Chart(ctx, { type: 'doughnut', data: { - labels: byModel.map(m => m.model), - datasets: [{ data: byModel.map(m => m.input + m.output), backgroundColor: MODEL_COLORS, borderWidth: 2, borderColor: '#1a1d27' }] + labels: ['Cache Reused (0.10x price)', 'Cache Write (1.25x price)', 'Fresh Input (1.0x)', 'Output (generated)'], + datasets: [{ + data: [e.reused, e.cacheWrite, e.freshInput, e.output], + backgroundColor: ['#22c55e', '#facc15', '#fb923c', '#4f8ef7'], + borderWidth: 2, borderColor: '#1a1d27' + }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { color: '#8892a4', boxWidth: 12, font: { size: 11 } } }, - tooltip: { callbacks: { label: ctx => ` ${ctx.label}: ${fmt(ctx.raw)} tokens` } } + tooltip: { callbacks: { label: ctx => ` ${ctx.label}: ${fmt(ctx.raw)} (${((ctx.raw/totalEff)*100).toFixed(1)}%)` } } } } }); + renderEfficiencyBreakdown(e); +} + +function renderEfficiencyBreakdown(e) { + const tips = []; + if (e.cacheHitPct < 70) tips.push(`Cache hit rate ${e.cacheHitPct.toFixed(0)}% — keep sessions running. Each new \`claude\` session restarts cache. Cache TTL ~5 min, so flurries of activity within 5 min reuse best.`); + if (e.reuseRatio < 1.5 && e.cacheWrite > 0) tips.push(`Reuse ratio ${e.reuseRatio.toFixed(2)}x — cache writes barely paying off. Either you exit sessions too fast, or context churns (lots of edits invalidating cache).`); + if (e.outputRatio > 0.3) tips.push(`Output/input ratio ${(e.outputRatio*100).toFixed(0)}% — model generating heavy. Add "respond in under 200 words" or "report concisely" to prompts.`); + if (e.freshInput > 500000) tips.push(`${fmt(e.freshInput)} fresh-input tokens — files re-read often. Read specific line ranges instead of whole files.`); + if (e.composite >= 80) tips.push(`Strong efficiency overall. Score ${e.composite.toFixed(0)}/100.`); + if (!tips.length) tips.push('Solid efficiency — keep current habits.'); + + const subScores = [ + { label: 'Cache Hit', v: e.cacheHitPct.toFixed(1)+'%', score: e.cacheScore, weight: '40%', hint: 'reused / total input' }, + { label: 'Reuse Ratio', v: e.reuseRatio.toFixed(2)+'x', score: e.reuseScore, weight: '25%', hint: 'reads per write (4x = 100)' }, + { label: 'Output Discipline', v: (e.outputRatio*100).toFixed(0)+'%', score: e.outputScore, weight: '15%', hint: 'output / total input — lower better' }, + { label: 'Cost Efficiency', v: e.costEffPct.toFixed(0)+'%', score: e.costScore, weight: '20%', hint: '$ saved vs no-cache baseline' }, + ]; + + const headerCards = [ + { label: 'Composite Grade', value: e.grade, sub: `${e.composite.toFixed(0)}/100 weighted`, color: e.color }, + { label: 'Est. $ Saved', value: '$' + e.savedUSD.toFixed(2), sub: 'vs running without cache', color: '#4ade80' }, + { label: 'Reused Tokens', value: fmt(e.reused), sub: 'pulled from prompt cache' }, + { label: 'New Input Tokens', value: fmt(e.newInput), sub: 'fresh + cache writes (full price)' }, + ]; + + const headerHTML = headerCards.map(c => ` +
+
${c.label}
+
${c.value}
+
${c.sub}
+
`).join(''); + + const subScoreHTML = subScores.map(s => { + const bar = Math.max(0, Math.min(100, s.score)); + const barColor = bar>=70?'#4ade80':bar>=50?'#facc15':'#ef4444'; + return ` +
+
+ ${s.label} + weight ${s.weight} +
+
${s.v}
+
+
+
+
${s.hint}
+
`; + }).join(''); + + document.getElementById('efficiency-body').innerHTML = + headerHTML + subScoreHTML + + `
+
Recommendations
+
    ${tips.map(t=>`
  • ${esc(t)}
  • `).join('')}
+
`; } function renderProjectChart(byProject) { @@ -937,7 +1617,7 @@ def get_dashboard_data(db_path=DB_PATH): charts.project = new Chart(ctx, { type: 'bar', data: { - labels: top.map(p => p.project.length > 22 ? '\u2026' + p.project.slice(-20) : p.project), + labels: top.map(p => { const lbl = projectLabel(p.project); return lbl.length > 30 ? lbl.slice(0,28)+'\u2026' : lbl; }), datasets: [ { label: 'Input', data: top.map(p => p.input), backgroundColor: TOKEN_COLORS.input }, { label: 'Output', data: top.map(p => p.output), backgroundColor: TOKEN_COLORS.output }, @@ -960,8 +1640,11 @@ def get_dashboard_data(db_path=DB_PATH): const costCell = isBillable(s.model) ? `
` : ``; - return ` + const fullId = s.session_id_full || s.session_id; + const title = s.title || '(no prompt)'; + return ` + @@ -974,6 +1657,70 @@ def get_dashboard_data(db_path=DB_PATH): }).join(''); } +// ── Live session view ──────────────────────────────────────────────────────── +let liveSessionId = null; +let liveTimer = null; + +function startLiveSession(sid) { + liveSessionId = sid; + document.getElementById('live-session-card').style.display = ''; + document.getElementById('live-session-card').scrollIntoView({behavior:'smooth', block:'start'}); + fetchLiveSession(); + if (liveTimer) clearInterval(liveTimer); + liveTimer = setInterval(fetchLiveSession, 5000); +} + +function stopLiveSession() { + liveSessionId = null; + if (liveTimer) { clearInterval(liveTimer); liveTimer = null; } + document.getElementById('live-session-card').style.display = 'none'; +} + +async function fetchLiveSession() { + if (!liveSessionId) return; + try { + const res = await fetch('/api/session/' + encodeURIComponent(liveSessionId)); + const d = await res.json(); + if (d.error) { + document.getElementById('live-summary').textContent = d.error; + return; + } + const cost = calcCost(d.model, d.totals.input, d.totals.output, d.totals.cache_read, d.totals.cache_creation); + document.getElementById('live-title').textContent = d.title || '(no prompt) — ' + d.session_id.slice(0,8); + document.getElementById('live-summary').innerHTML = + `${esc(d.project)} · ${esc(d.model)} · ${d.turn_count} turns · ` + + `Input ${fmt(d.totals.input)} · Output ${fmt(d.totals.output)} · ` + + `Cache R/W ${fmt(d.totals.cache_read)}/${fmt(d.totals.cache_creation)} · ` + + `Est. ${fmtCost(cost)} · ` + + `Last: ${esc((d.last||'').replace('T',' ').slice(0,19))}`; + const msgs = d.messages || []; + if (rawData && rawData.tz_name) { + const tzEl = document.getElementById('live-tz-label'); + if (tzEl) tzEl.textContent = '(' + rawData.tz_name + ')'; + } + const rows = msgs.slice().reverse().map((m, idx) => { + const realIdx = msgs.length - idx; + const c = calcCost(m.model || d.model, m.input, m.output, m.cache_read, m.cache_creation); + const tools = (m.tools||[]).join(', '); + return ` + + + + + + + + + + + `; + }).join(''); + document.getElementById('live-turns-body').innerHTML = rows || ''; + } catch (e) { + document.getElementById('live-summary').textContent = 'Error: ' + e.message; + } +} + function setModelSort(col) { if (modelSortCol === col) { modelSortDir = modelSortDir === 'desc' ? 'asc' : 'desc'; @@ -1056,7 +1803,7 @@ def get_dashboard_data(db_path=DB_PATH): function renderProjectCostTable(byProject) { document.getElementById('project-cost-body').innerHTML = sortProjects(byProject).map(p => { return ` - + @@ -1200,9 +1947,22 @@ def get_dashboard_data(db_path=DB_PATH): if (isFirstLoad) { // Restore range from URL, mark active button selectedRange = readURLRange(); + const activeKey = isCustomRange(selectedRange) ? 'custom' : selectedRange; document.querySelectorAll('.range-btn').forEach(btn => - btn.classList.toggle('active', btn.dataset.range === selectedRange) + btn.classList.toggle('active', btn.dataset.range === activeKey) ); + if (isCustomRange(selectedRange)) { + const panel = document.getElementById('custom-range-panel'); + if (panel) panel.style.display = ''; + const amtEl = document.getElementById('custom-range-amount'); + const unitEl = document.getElementById('custom-range-unit'); + const m = customRangeMinutes; + if (amtEl && unitEl) { + if (m % 1440 === 0) { amtEl.value = m / 1440; unitEl.value = 'd'; } + else if (m % 60 === 0) { amtEl.value = m / 60; unitEl.value = 'h'; } + else { amtEl.value = m; unitEl.value = 'm'; } + } + } // Mark default TZ button active document.querySelectorAll('.tz-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tz === hourlyTZ) @@ -1231,6 +1991,272 @@ def get_dashboard_data(db_path=DB_PATH): loadData(); scheduleAutoRefresh(); + +// ── Live limits banner ───────────────────────────────────────────────────── +function fmtTokens(n) { + if (n == null) return '—'; + if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'; + if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k'; + return String(n); +} +function fmtCountdown(resetIso) { + if (!resetIso) return '—'; + const ms = new Date(resetIso).getTime() - Date.now(); + if (ms <= 0) return 'resets now'; + const m = Math.floor(ms / 60000); + const h = Math.floor(m / 60); + const mm = m % 60; + if (h > 0) return `resets in ${h}h ${mm}m`; + if (m > 0) return `resets in ${m}m`; + return `resets in <1m`; +} +function applyBar(cardId, used, cap, pct, footRight) { + const el = document.getElementById(cardId); + if (!el) return; + const fill = el.querySelector('.lc-fill'); + const pctEl = el.querySelector('.lc-pct'); + const usedEl = el.querySelector('.lc-used'); + const resetEl = el.querySelector('.lc-reset'); + if (!cap) { + el.classList.add('lc-disabled'); + fill.style.width = '0%'; + pctEl.textContent = 'n/a'; + usedEl.innerHTML = '' + fmtTokens(used) + ' used'; + resetEl.textContent = 'not included in plan'; + return; + } + el.classList.remove('lc-disabled'); + const p = pct == null ? 0 : pct; + fill.style.width = Math.min(100, p) + '%'; + fill.classList.remove('warn', 'danger'); + if (p >= 90) fill.classList.add('danger'); + else if (p >= 70) fill.classList.add('warn'); + pctEl.textContent = p.toFixed(1) + '%'; + usedEl.innerHTML = '' + fmtTokens(used) + ' / ' + fmtTokens(cap); + resetEl.textContent = footRight; +} +const LIMITS_MODEL_COLORS = { opus: '#d97757', sonnet: '#4f8ef7', haiku: '#4ade80', other: '#8892a4' }; +// Neutral purple so the official fill is never mistaken for a model color +// (green collided with haiku's swatch). Shifts to amber/red as it fills. +function barColor(p) { return p >= 90 ? '#e5484d' : p >= 70 ? '#f5a623' : '#7c5cff'; } +function renderWeeklyBar(wk, official) { + const el = document.getElementById('lc-weekly'); + if (!el) return; + const bar = el.querySelector('.lc-bar'); + const pctEl = el.querySelector('.lc-pct'); + const usedEl = el.querySelector('.lc-used'); + const totalPct = wk.percent == null ? 0 : wk.percent; + const isOfficial = (wk.source || '').startsWith('official_api'); + pctEl.textContent = totalPct.toFixed(1) + '%'; + + const disc = el.querySelector('.lc-disclaimer'); + const syncBtns = ['lc-weekly-sync','lc-weekly-edit','lc-weekly-clear'] + .map(id => document.getElementById(id)); + const srcEl = document.getElementById('lc-weekly-anchor-src'); + + if (isOfficial) { + // Ground truth from Anthropic — solid fill at the real percent. No + // model segments (the real per-model split isn't returned; local + // token math wouldn't sum to this number). + usedEl.innerHTML = 'live from Anthropic'; + bar.innerHTML = `
`; + if (disc) disc.style.display = 'none'; + syncBtns.forEach(b => { if (b) b.style.display = 'none'; }); + if (srcEl) { + const extras = []; + if (official && official.five_hour) + extras.push(`5h ${official.five_hour.percent.toFixed(0)}%`); + if (official && official.seven_day_sonnet) + extras.push(`Sonnet 7d ${official.seven_day_sonnet.percent.toFixed(0)}%`); + const stale = wk.source === 'official_api_stale' ? ' (cached)' : ''; + srcEl.innerHTML = ` Live from Anthropic${stale}` + + (extras.length ? ` · ${extras.join(' · ')}` : ''); + } + } else { + // Fallback: local cost-weighted estimate with manual sync controls. + const cap = wk.cap || 0; + const used = wk.used || 0; + usedEl.innerHTML = '' + fmtTokens(used) + ' / ' + fmtTokens(cap); + const order = ['opus', 'sonnet', 'haiku', 'other']; + const byTokens = wk.by_model || {}; + const segs = order + .filter(k => (byTokens[k] || 0) > 0) + .map(k => { + const pct = cap > 0 ? (byTokens[k] / cap) * 100 : 0; + return `
`; + }); + bar.innerHTML = segs.join('') || '
'; + if (disc) disc.style.display = ''; + syncBtns.forEach(b => { if (b) b.style.display = ''; }); + if (srcEl) { + const src = wk.anchor_source || 'auto'; + const anchorTxt = wk.anchor_at ? new Date(wk.anchor_at).toLocaleString() : '—'; + const reason = (official && official.reason) ? ` · live API unavailable (${official.reason})` : ''; + srcEl.textContent = `estimate · anchor: ${anchorTxt} (${src})${reason}`; + } + } + const resetEl = document.getElementById('lc-weekly-reset'); + if (resetEl) resetEl.textContent = wk.reset_at ? fmtCountdown(wk.reset_at) : '—'; +} +async function postWeekly(body) { + const r = await fetch('/api/weekly/sync-reset', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(body || {}), + }); + if (!r.ok) throw new Error('sync failed'); + await loadLimits(); +} +function toIsoUtc(localStr) { + if (!localStr) return new Date().toISOString(); + return new Date(localStr).toISOString(); +} +function wireWeeklyCard() { + const sync = document.getElementById('lc-weekly-sync'); + const edit = document.getElementById('lc-weekly-edit'); + const save = document.getElementById('lc-weekly-save'); + const cancel = document.getElementById('lc-weekly-cancel'); + const clear = document.getElementById('lc-weekly-clear'); + const form = document.getElementById('lc-weekly-edit-form'); + const anchorIn = document.getElementById('lc-weekly-anchor-input'); + const percentIn = document.getElementById('lc-weekly-percent-input'); + + const closeForm = () => { if (form) form.style.display = 'none'; }; + const fillNow = () => { + if (!anchorIn) return; + const d = new Date(); + const pad = n => String(n).padStart(2, '0'); + anchorIn.value = `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; + }; + if (sync) sync.addEventListener('click', async () => { + if (!confirm('Set the weekly reset anchor to NOW?\\n\\nOnly click after Claude shows a fresh 0%.')) return; + sync.disabled = true; sync.textContent = 'Syncing…'; + try { await postWeekly({}); } + catch (e) { alert('Failed: ' + e.message); } + finally { sync.disabled = false; sync.textContent = 'Sync reset to now'; } + }); + if (edit) edit.addEventListener('click', () => { + if (!form) return; + const opening = form.style.display === 'none'; + form.style.display = opening ? 'block' : 'none'; + if (opening) { fillNow(); if (percentIn) { percentIn.value = ''; percentIn.focus(); } } + }); + if (cancel) cancel.addEventListener('click', closeForm); + const doSave = async () => { + const payload = { + anchor_at: anchorIn && anchorIn.value ? toIsoUtc(anchorIn.value) : new Date().toISOString(), + percent: percentIn && percentIn.value !== '' ? parseFloat(percentIn.value) : 0, + }; + if (!save) return; + save.disabled = true; save.textContent = 'Saving…'; + try { await postWeekly(payload); closeForm(); } + catch (e) { alert('Failed: ' + e.message); } + finally { save.disabled = false; save.textContent = 'Save'; } + }; + if (save) save.addEventListener('click', doSave); + [anchorIn, percentIn].forEach(inp => { + if (!inp) return; + inp.addEventListener('keydown', ev => { + if (ev.key === 'Enter') { ev.preventDefault(); doSave(); } + else if (ev.key === 'Escape') { ev.preventDefault(); closeForm(); } + }); + }); + if (clear) clear.addEventListener('click', async () => { + if (!confirm('Clear manual override and return to auto-detection?')) return; + try { await postWeekly({clear: true}); } + catch (e) { alert('Failed: ' + e.message); } + }); +} +document.addEventListener('DOMContentLoaded', wireWeeklyCard); +function renderWeeklyModels(wk) { + const host = document.getElementById('lc-weekly-models'); + if (!host) return; + const isOfficial = (wk.source || '').startsWith('official_api'); + const byPct = wk.by_model_pct || {}; + const byTokens = wk.by_model || {}; + // Sort by token volume desc so the dominant model is always first and + // a small background model (haiku from memory/summarization) never + // shows alone or on top. + const order = ['opus', 'sonnet', 'haiku', 'other'] + .filter(k => (byTokens[k] || 0) > 0) + .sort((a, b) => (byTokens[b] || 0) - (byTokens[a] || 0)); + // Under official mode the cap-relative percents are local estimates that + // don't reconcile with Anthropic's number — show raw token split only. + const label = isOfficial + ? 'local activity split: ' + : ''; + const parts = order + .map(k => isOfficial + ? `${k} ${fmtTokens(byTokens[k]||0)}` + : `${k} ${byPct[k]||0}% · ${fmtTokens(byTokens[k]||0)}`); + host.innerHTML = label + parts.join(''); +} +function renderHealth(h) { + const card = document.getElementById('lc-health'); + const msg = document.getElementById('health-msg'); + const sid = document.getElementById('health-session-id'); + const stats = document.getElementById('health-stats'); + if (!card || !msg) return; + card.classList.remove('health-ok', 'health-info', 'health-warn'); + if (!h.active) { + card.classList.add('health-ok'); + msg.textContent = 'No active session in the last 30 minutes.'; + sid.textContent = '—'; + stats.innerHTML = ''; + return; + } + card.classList.add('health-' + (h.level || 'ok')); + msg.textContent = h.message || ''; + sid.textContent = h.session_id || ''; + const parts = []; + if (h.context_size != null) parts.push(`context ${fmtTokens(h.context_size)}`); + if (h.cache_hit_rate != null) parts.push(`cache hit ${(h.cache_hit_rate*100).toFixed(0)}%`); + if (h.avg_billable_per_turn != null) parts.push(`avg ${fmtTokens(h.avg_billable_per_turn)}/turn`); + if (h.session_age_hours != null) parts.push(`age ${h.session_age_hours}h`); + stats.innerHTML = parts.map(p => `${p}`).join(' · '); +} +let limitsTimer = null; +let _userPlanOverride = localStorage.getItem('claudeUsagePlanOverride') || ''; +function onPlanOverride() { + const v = document.getElementById('plan-select').value; + _userPlanOverride = v; + if (v) localStorage.setItem('claudeUsagePlanOverride', v); + else localStorage.removeItem('claudeUsagePlanOverride'); + loadLimits(); +} +async function loadLimits() { + try { + const url = _userPlanOverride + ? '/api/limits?plan=' + encodeURIComponent(_userPlanOverride) + : '/api/limits'; + const resp = await fetch(url); + const data = await resp.json(); + if (data.error) return; + const sel = document.getElementById('plan-select'); + if (sel && sel.value !== _userPlanOverride) sel.value = _userPlanOverride; + const planSrc = document.getElementById('plan-source'); + if (planSrc) { + const label = (data.plan && data.plan.label) || '—'; + const src = (data.plan && data.plan.source) || 'default'; + planSrc.textContent = `${label} (${src})`; + } + const wk = data.weekly_all || {}; + renderWeeklyBar(wk, data.official || {}); + renderWeeklyModels(wk); + renderHealth(data.session_health || {}); + } catch (e) { /* network blip */ } +} +function scheduleLimits() { + if (limitsTimer) clearInterval(limitsTimer); + limitsTimer = setInterval(loadLimits, 10000); +} +// Restore override into select before first fetch. +(function () { + const sel = document.getElementById('plan-select'); + if (sel && _userPlanOverride) sel.value = _userPlanOverride; +})(); +loadLimits(); +scheduleLimits(); @@ -1245,10 +2271,16 @@ def do_GET(self): if self.path in ("/", "/index.html"): self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") + # JS is inlined in the doc — without no-store the browser serves + # a stale page after every code change (caused "still green", + # "haiku only" confusion). Force a fresh fetch each load. + self.send_header("Cache-Control", "no-store, must-revalidate") self.end_headers() self.wfile.write(HTML_TEMPLATE.encode("utf-8")) elif self.path == "/api/data": + # Incremental scan so data stays live without manual rescan. + _safe_rescan() data = get_dashboard_data() body = json.dumps(data).encode("utf-8") self.send_response(200) @@ -1257,11 +2289,113 @@ def do_GET(self): self.end_headers() self.wfile.write(body) + elif self.path.startswith("/api/limits"): + override = None + if "?" in self.path: + from urllib.parse import parse_qs + qs = parse_qs(self.path.split("?", 1)[1]) + override = (qs.get("plan") or [None])[0] + try: + import limits + if override and override in limits.PLAN_BUDGETS: + os.environ["CLAUDE_USAGE_PLAN"] = override + data = limits.get_limits(db_path=DB_PATH) + except Exception as e: + data = {"error": str(e)} + body = json.dumps(data).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + elif self.path.startswith("/api/session/"): + sid = self.path[len("/api/session/"):].split("?")[0].split("/")[0] + _safe_rescan() + data = get_session_live(sid) + body = json.dumps(data).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + else: self.send_response(404) self.end_headers() def do_POST(self): + if self.path == "/api/weekly/sync-reset": + from limits import ( + set_weekly_anchor, clear_weekly_anchor, detect_plan, + ) + length = int(self.headers.get("Content-Length", "0") or 0) + payload = {} + if length: + try: + payload = json.loads(self.rfile.read(length).decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + payload = {} + + if payload.get("clear"): + clear_weekly_anchor() + body = json.dumps({"cleared": True}).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + + now = datetime.now(timezone.utc) + anchor_at = None + if payload.get("anchor_at"): + try: + anchor_at = datetime.fromisoformat( + str(payload["anchor_at"]).replace("Z", "+00:00") + ) + if anchor_at.tzinfo is None: + anchor_at = anchor_at.replace(tzinfo=timezone.utc) + if anchor_at > now: + anchor_at = now + except (ValueError, AttributeError): + anchor_at = None + + budgets = detect_plan()["budgets"] + cap = budgets["weekly_all_tokens"] + baseline = 0 + if payload.get("baseline_used") is not None: + try: + baseline = max(0, int(payload["baseline_used"])) + except (TypeError, ValueError): + baseline = 0 + elif payload.get("percent") is not None: + try: + baseline = max( + 0, + int(round(float(payload["percent"]) / 100.0 * cap)) + ) + except (TypeError, ValueError): + baseline = 0 + + # save_at=now whenever a non-zero baseline is supplied so the + # delta computation starts from the save moment, not the anchor + # (otherwise turns between anchor_at and now get double-counted). + save_at = now if baseline > 0 else None + anchor = set_weekly_anchor( + anchor_at, baseline_used=baseline, save_at=save_at, + ) + + body = json.dumps({ + "anchor_at": anchor.isoformat(), + "baseline_used": baseline, + }).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return if self.path == "/api/rescan": # Full rebuild: delete DB and rescan from scratch. # Pass DB_PATH / DEFAULT_PROJECTS_DIRS explicitly so tests that @@ -1269,13 +2403,16 @@ def do_POST(self): # frozen at def time and would otherwise target the real paths). import scanner db_path = DB_PATH - if db_path.exists(): - db_path.unlink() - result = scanner.scan( - db_path=db_path, - projects_dirs=scanner.DEFAULT_PROJECTS_DIRS, - verbose=False, - ) + # Block on the scan lock: a full delete+rebuild must never race + # an in-flight incremental scan or a reader mid-query. + with _SCAN_LOCK: + if db_path.exists(): + db_path.unlink() + result = scanner.scan( + db_path=db_path, + projects_dirs=scanner.DEFAULT_PROJECTS_DIRS, + verbose=False, + ) body = json.dumps(result).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "application/json") @@ -1290,7 +2427,23 @@ def do_POST(self): def serve(host=None, port=None): host = host or os.environ.get("HOST", "localhost") port = port or int(os.environ.get("PORT", "8080")) - server = HTTPServer((host, port), DashboardHandler) + + # Warm the DB before accepting requests so the first page load isn't blank. + # Runs in a background thread so the server starts immediately. + import threading as _threading + def _startup_scan(): + try: + import scanner as _scanner + _scanner.scan(db_path=DB_PATH, projects_dirs=_scanner.DEFAULT_PROJECTS_DIRS, verbose=False) + except Exception: + pass + _threading.Thread(target=_startup_scan, daemon=True).start() + + # Default listen backlog is 5; a page load fires several concurrent + # fetches (/api/data, /api/limits, /api/session) so give the accept + # queue headroom to avoid connection-refused under burst. + ThreadingHTTPServer.request_queue_size = 128 + server = ThreadingHTTPServer((host, port), DashboardHandler) print(f"Dashboard running at http://{host}:{port}") print("Press Ctrl+C to stop.") try: diff --git a/docs/screenshot.png b/docs/screenshot.png deleted file mode 100644 index 8ce5067b..00000000 Binary files a/docs/screenshot.png and /dev/null differ diff --git a/limits.py b/limits.py new file mode 100644 index 00000000..aff22c04 --- /dev/null +++ b/limits.py @@ -0,0 +1,718 @@ +""" +limits.py - Compute live remaining usage for Claude Code plan windows. + +Anthropic enforces two windows on Claude subscriptions: + - 5-hour rolling session ("session limit") + - 7-day rolling weekly window (separate caps per model family) + +This module reads the local `turns` table (populated by scanner.py from +JSONL transcripts) and computes used/remaining/reset for both windows. + +Plan caps are approximate and community-derived. Anthropic does not +publish exact token budgets, and they drift over time. The dashboard +labels values as estimates. + +Claude.ai web usage shares the same plan quota but is NOT written to +local JSONL — these numbers may understate consumption for heavy web +users. +""" + +import json +import os +import ssl +import subprocess +import sqlite3 +import threading +import time +import urllib.request +import urllib.error +from datetime import datetime, timedelta, timezone +from pathlib import Path + +DB_PATH = Path.home() / ".claude" / "usage.db" +PLAN_CACHE_PATH = Path.home() / ".claude" / "usage-plan-cache.json" +PLAN_CACHE_TTL_SECONDS = 24 * 3600 +WEEKLY_ANCHOR_PATH = Path.home() / ".claude" / "usage-weekly-anchor.json" +WEEKLY_WINDOW = timedelta(days=7) + +# Anthropic's own usage endpoint — same source `claude /usage` and the +# ClaudeKarma browser extension read. Returns ground-truth utilization +# percentages and reset times for the 5-hour and 7-day windows, so we no +# longer have to approximate them from local JSONL cost-weighting. +OFFICIAL_USAGE_URL = "https://api.anthropic.com/api/oauth/usage" +OFFICIAL_USAGE_TTL_SECONDS = 60 # don't hammer the API on every 10s poll +_OFFICIAL_CACHE = {"data": None, "at": 0.0} +_OFFICIAL_LOCK = threading.Lock() + +PLAN_BUDGETS = { + "pro": {"label": "Pro", "weekly_all_tokens": 23_000_000}, + "max_5x": {"label": "Max 5×", "weekly_all_tokens": 115_000_000}, + "max_20x":{"label": "Max 20×", "weekly_all_tokens": 460_000_000}, +} + +# Anthropic's session/weekly limits use cost-weighted tokens. Weights +# are anchored to Sonnet input = 1.0 (matches PLAN_BUDGETS Sonnet-equiv). +# Opus ~5× Sonnet, Haiku ~0.25×. cache_creation = 1.25× input, +# cache_read = 0.1× input. Reproduces `claude /usage` percentages. +CACHE_READ_WEIGHT = 0.1 + +MODEL_WEIGHTS = { + "opus": {"in": 5.0, "out": 5.0, "cc": 6.25, "cr": 0.5}, + "sonnet": {"in": 1.0, "out": 1.0, "cc": 1.25, "cr": 0.1}, + "haiku": {"in": 0.25, "out": 0.25, "cc": 0.3125, "cr": 0.025}, + "other": {"in": 1.0, "out": 1.0, "cc": 1.25, "cr": 0.1}, +} + +DEFAULT_PLAN = "pro" + + +# ── Plan detection ───────────────────────────────────────────────────────── + +_SSL_CTX = None + + +def _ssl_context(): + """SSL context that actually verifies api.anthropic.com. + + python.org / pyenv builds don't trust the macOS system keychain, so a + plain urlopen raises CERTIFICATE_VERIFY_FAILED (which is why earlier + API probes silently returned None). Prefer certifi's CA bundle; fall + back to the stdlib default. Verification is never disabled — this hits + an OAuth-bearing endpoint. + """ + global _SSL_CTX + if _SSL_CTX is not None: + return _SSL_CTX + try: + import certifi + _SSL_CTX = ssl.create_default_context(cafile=certifi.where()) + except Exception: + _SSL_CTX = ssl.create_default_context() + return _SSL_CTX + + +def _read_keychain_oauth(): + """Read Claude Code OAuth credentials from macOS keychain.""" + try: + out = subprocess.run( + ["security", "find-generic-password", "-s", + "Claude Code-credentials", "-w"], + capture_output=True, text=True, timeout=3, + ) + if out.returncode != 0: + return None + return json.loads(out.stdout.strip()) + except (subprocess.SubprocessError, json.JSONDecodeError, OSError): + return None + + +def _fetch_plan_from_api(access_token, timeout=4): + """Call Anthropic profile endpoint, return raw plan string or None. + + Endpoint is undocumented and may change. We try a couple of known + paths and return the first that gives an organization_type-like field. + """ + candidates = [ + "https://api.anthropic.com/api/oauth/profile", + "https://api.anthropic.com/api/account", + ] + headers = { + "Authorization": f"Bearer {access_token}", + "anthropic-beta": "oauth-2025-04-20", + "User-Agent": "claude-usage-dashboard/1.0", + } + for url in candidates: + try: + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen( + req, timeout=timeout, context=_ssl_context() + ) as resp: + data = json.loads(resp.read().decode("utf-8")) + for path in ( + ("organization", "rate_limit_tier"), + ("organization", "organization_type"), + ("subscription", "tier"), + ("plan",), + ("account", "plan"), + ): + cur = data + ok = True + for k in path: + if isinstance(cur, dict) and k in cur: + cur = cur[k] + else: + ok = False + break + if ok and isinstance(cur, str): + return cur + except (urllib.error.URLError, urllib.error.HTTPError, + json.JSONDecodeError, TimeoutError, OSError): + continue + return None + + +def _normalize_plan(raw): + if not raw: + return None + r = raw.lower().replace("-", "_").replace(" ", "_") + if "20" in r and "max" in r: + return "max_20x" + if "5" in r and "max" in r: + return "max_5x" + if "max" in r: + return "max_5x" + if "pro" in r or "claude_pro" in r: + return "pro" + return None + + +def _load_cached_plan(): + try: + with open(PLAN_CACHE_PATH) as f: + data = json.load(f) + if time.time() - data.get("fetched_at", 0) < PLAN_CACHE_TTL_SECONDS: + return data.get("plan") + except (OSError, json.JSONDecodeError): + pass + return None + + +def _save_cached_plan(plan): + try: + PLAN_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(PLAN_CACHE_PATH, "w") as f: + json.dump({"plan": plan, "fetched_at": time.time()}, f) + except OSError: + pass + + +def detect_plan(): + """Resolve the active Claude plan. + + Priority: + 1. CLAUDE_USAGE_PLAN env var (manual override) + 2. 24h-cached lookup result + 3. Live keychain + API lookup + 4. DEFAULT_PLAN + + Returns dict with keys: plan, label, source, budgets, detected_raw. + """ + override = os.environ.get("CLAUDE_USAGE_PLAN", "").strip().lower() + if override in PLAN_BUDGETS: + return _plan_response(override, "env_override", raw=override) + + cached = _load_cached_plan() + if cached in PLAN_BUDGETS: + return _plan_response(cached, "cache", raw=cached) + + creds = _read_keychain_oauth() + raw = None + if creds: + token = (creds.get("claudeAiOauth") or {}).get("accessToken") + if token: + raw = _fetch_plan_from_api(token) + + normalized = _normalize_plan(raw) + if normalized in PLAN_BUDGETS: + _save_cached_plan(normalized) + return _plan_response(normalized, "api", raw=raw) + + return _plan_response(DEFAULT_PLAN, "default", raw=raw) + + +def _plan_response(plan, source, raw=None): + b = PLAN_BUDGETS[plan] + return { + "plan": plan, + "label": b["label"], + "source": source, + "detected_raw": raw, + "budgets": b, + } + + +# ── Official usage (Anthropic ground truth) ──────────────────────────────── + +def _parse_window(node): + """Normalize one usage window (five_hour / seven_day / *_sonnet) into + {percent, reset_at, severity}. Returns None if the node is absent.""" + if not isinstance(node, dict): + return None + util = node.get("utilization") + if util is None and "percent" in node: + util = node.get("percent") + if util is None: + return None + return { + "percent": round(float(util), 1), + "reset_at": node.get("resets_at") or node.get("reset_at"), + "severity": node.get("severity"), + } + + +def _fetch_official_usage_uncached(timeout=8): + """Hit Anthropic's real usage endpoint with the Claude Code OAuth token. + + Returns a dict with `available` plus normalized 5h / 7-day / scoped + windows, or {available: False, reason: ...} on any failure (no token, + expired token, network error, schema drift). Callers fall back to the + local JSONL estimate when unavailable. + """ + creds = _read_keychain_oauth() + token = (creds or {}).get("claudeAiOauth", {}).get("accessToken") if creds else None + if not token: + return {"available": False, "reason": "no_oauth_token"} + + headers = { + "Authorization": f"Bearer {token}", + "anthropic-beta": "oauth-2025-04-20", + "Content-Type": "application/json", + "User-Agent": "claude-usage-dashboard/1.0", + } + try: + req = urllib.request.Request(OFFICIAL_USAGE_URL, headers=headers) + with urllib.request.urlopen( + req, timeout=timeout, context=_ssl_context() + ) as resp: + raw = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + reason = "token_expired" if e.code in (401, 403) else f"http_{e.code}" + return {"available": False, "reason": reason} + except (urllib.error.URLError, json.JSONDecodeError, + TimeoutError, OSError) as e: + return {"available": False, "reason": f"fetch_error:{type(e).__name__}"} + + five_hour = _parse_window(raw.get("five_hour")) + seven_day = _parse_window(raw.get("seven_day")) + sonnet = _parse_window(raw.get("seven_day_sonnet")) + opus = _parse_window(raw.get("seven_day_opus")) + + # Fallback: pull from the typed `limits` array if top-level keys drift. + if seven_day is None or five_hour is None: + for lim in raw.get("limits") or []: + kind = lim.get("kind") + parsed = _parse_window(lim) + if kind == "weekly_all" and seven_day is None: + seven_day = parsed + elif kind == "session" and five_hour is None: + five_hour = parsed + + if seven_day is None: + return {"available": False, "reason": "no_weekly_in_response"} + + return { + "available": True, + "five_hour": five_hour, + "seven_day": seven_day, + "seven_day_sonnet": sonnet, + "seven_day_opus": opus, + } + + +def fetch_official_usage(force=False): + """Cached wrapper around the official usage endpoint (60s TTL). + + The dashboard polls /api/limits every 10s; without caching that would + fire 6 API calls/min. One process-wide cache, guarded by a lock so the + ThreadingHTTPServer's worker threads don't stampede the endpoint. + """ + with _OFFICIAL_LOCK: + now = time.time() + cached = _OFFICIAL_CACHE["data"] + fresh = cached and (now - _OFFICIAL_CACHE["at"]) < OFFICIAL_USAGE_TTL_SECONDS + if fresh and not force: + return cached + data = _fetch_official_usage_uncached() + # Keep serving a previously-good payload if a refresh transiently + # fails — better a 60s-stale real number than dropping to estimate. + if not data.get("available") and cached and cached.get("available"): + stale = dict(cached) + stale["stale"] = True + stale["refresh_reason"] = data.get("reason") + return stale + _OFFICIAL_CACHE["data"] = data + _OFFICIAL_CACHE["at"] = now + return data + + +# ── Window calculations ──────────────────────────────────────────────────── + +def _connect(db_path=DB_PATH): + conn = sqlite3.connect(db_path, timeout=10) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA busy_timeout=10000") + return conn + + +def _model_family(model): + if not model: + return "other" + m = model.lower() + if "opus" in m: + return "opus" + if "sonnet" in m: + return "sonnet" + if "haiku" in m: + return "haiku" + return "other" + + +def _billable(row): + # Per-model cost-weighted formula. PLAN_BUDGETS caps are in + # Sonnet-equivalent units, so Opus tokens count ~5× and Haiku ~0.25×. + w = MODEL_WEIGHTS[_model_family(row["model"])] + return int( + (row["input_tokens"] or 0) * w["in"] + + (row["output_tokens"] or 0) * w["out"] + + (row["cache_creation_tokens"] or 0) * w["cc"] + + (row["cache_read_tokens"] or 0) * w["cr"] + ) + + +def _load_weekly_anchor(): + """Read persisted weekly anchor. Returns dict or None.""" + try: + with open(WEEKLY_ANCHOR_PATH, "r") as f: + data = json.load(f) + if "anchor_at" in data: + return data + except (OSError, json.JSONDecodeError): + pass + return None + + +def _save_weekly_anchor(anchor_at, source, baseline_used=0, save_at=None): + """Persist weekly anchor to disk. `save_at` is the moment the baseline + was captured; tokens at-or-after save_at accumulate ON TOP of baseline. + Without save_at, baseline_used double-counts turns between anchor_at + and the save moment.""" + try: + WEEKLY_ANCHOR_PATH.parent.mkdir(parents=True, exist_ok=True) + payload = { + "anchor_at": anchor_at.isoformat(), + "source": source, + "baseline_used": int(baseline_used or 0), + } + if save_at is not None: + payload["save_at"] = save_at.isoformat() + with open(WEEKLY_ANCHOR_PATH, "w") as f: + json.dump(payload, f) + except OSError: + pass + + +def set_weekly_anchor(anchor_at=None, baseline_used=0, source="manual", + save_at=None): + """Manually set weekly anchor. `baseline_used` is the token count + already consumed at the moment of `save_at`. When `save_at` is set, + delta accumulates from save_at forward to avoid double-counting turns + between anchor_at and save_at. When `save_at` is None, delta starts + from anchor_at (legacy behavior — callers managing manual percent + overrides should pass save_at=now() to prevent double-count).""" + anchor_at = anchor_at or datetime.now(timezone.utc) + _save_weekly_anchor(anchor_at, source, baseline_used, save_at) + return anchor_at + + +def clear_weekly_anchor(): + """Remove manual weekly override; recomputes auto-anchored on next call.""" + try: + WEEKLY_ANCHOR_PATH.unlink() + except OSError: + pass + + +def _earliest_turn_ts(conn, since): + """Earliest turn timestamp at or after `since`. None if no rows.""" + row = conn.execute( + "SELECT MIN(timestamp) AS t FROM turns WHERE timestamp >= ?", + (since.isoformat(),), + ).fetchone() + if not row or not row["t"]: + return None + try: + return datetime.fromisoformat(row["t"].replace("Z", "+00:00")) + except (ValueError, AttributeError): + return None + + +def _resolve_weekly_anchor(conn, now): + """Return (anchor_at, source, baseline_used, save_at). + + Strategy: + 1. Load persisted anchor. If still within window (anchor + 7d > now), + use it as-is including baseline_used. + 2. If expired, auto-advance: find first turn at-or-after anchor + 7d + and treat as new anchor; baseline_used resets to 0. + 3. If no persisted anchor, fall back to earliest turn in last 7d. + """ + persisted = _load_weekly_anchor() + anchor = None + source = "auto" + baseline = 0 + save_at = None + + if persisted: + try: + anchor = datetime.fromisoformat( + persisted["anchor_at"].replace("Z", "+00:00") + ) + source = persisted.get("source", "auto") + baseline = int(persisted.get("baseline_used", 0) or 0) + except (ValueError, AttributeError, KeyError): + anchor = None + sa_raw = persisted.get("save_at") if persisted else None + if sa_raw: + try: + save_at = datetime.fromisoformat( + sa_raw.replace("Z", "+00:00") + ) + except (ValueError, AttributeError): + save_at = None + + if anchor is None: + fallback = _earliest_turn_ts(conn, now - WEEKLY_WINDOW) + anchor = fallback or now + _save_weekly_anchor(anchor, "auto", 0) + return anchor, "auto", 0, None + + advanced = False + while anchor + WEEKLY_WINDOW <= now: + next_window_start = anchor + WEEKLY_WINDOW + next_anchor = _earliest_turn_ts(conn, next_window_start) + anchor = next_anchor if next_anchor else next_window_start + source = "auto" + baseline = 0 + save_at = None + advanced = True + + if advanced: + _save_weekly_anchor(anchor, source, baseline) + return anchor, source, baseline, save_at + + +def compute_weekly(conn, now=None): + """Compute weekly usage anchored to Anthropic's per-user reset. + + Anthropic's weekly window opens with the user's first message of + the cycle and closes 7 days later. The anchor is per-account, so we + can't hardcode it — we persist it locally and auto-advance when it + expires. + """ + now = now or datetime.now(timezone.utc) + anchor_at, anchor_source, baseline, save_at = _resolve_weekly_anchor(conn, now) + reset_at = anchor_at + WEEKLY_WINDOW + delta_since = save_at or anchor_at + + rows = conn.execute( + "SELECT model, input_tokens, output_tokens, cache_creation_tokens, " + " cache_read_tokens " + "FROM turns WHERE timestamp >= ?", + (delta_since.isoformat(),), + ).fetchall() + + delta = 0 + by_model = {"opus": 0, "sonnet": 0, "haiku": 0, "other": 0} + for r in rows: + t = _billable(r) + delta += t + by_model[_model_family(r["model"])] += t + + total = baseline + delta + + return { + "window_start": anchor_at.isoformat(), + "window_end": now.isoformat(), + "anchor_at": anchor_at.isoformat(), + "anchor_source": anchor_source, + "baseline_used": baseline, + "reset_at": reset_at.isoformat(), + "total": total, + "by_model": by_model, + } + + +# ── Public API ───────────────────────────────────────────────────────────── + +def compute_efficiency_warning(conn, now=None): + """Inspect the most-recent active session and decide whether the user + should consider starting a fresh conversation. + + Inefficiency signals (any one triggers a warning): + - Latest turn input_tokens > 150k → context near 200k limit + - Last 5 turns avg cache hit rate < 40% → cache keeps invalidating + - Last 5 turns avg billable tokens per turn > 60k → bloated context + - Session active > 3h → diminishing returns from a single thread + """ + now = now or datetime.now(timezone.utc) + cutoff = (now - timedelta(minutes=30)).isoformat() + + row = conn.execute( + "SELECT session_id, MAX(timestamp) AS last_ts " + "FROM turns WHERE timestamp >= ? " + "GROUP BY session_id ORDER BY last_ts DESC LIMIT 1", + (cutoff,), + ).fetchone() + if not row: + return {"active": False, "level": "ok", "message": None} + + session_id = row["session_id"] + turns = conn.execute( + "SELECT timestamp, model, input_tokens, output_tokens, " + " cache_creation_tokens, cache_read_tokens " + "FROM turns WHERE session_id = ? " + "ORDER BY timestamp DESC LIMIT 10", + (session_id,), + ).fetchall() + if not turns: + return {"active": False, "level": "ok", "message": None} + + latest = turns[0] + latest_input = latest["input_tokens"] or 0 + latest_cache_read = latest["cache_read_tokens"] or 0 + context_size = latest_input + latest_cache_read + + recent = turns[:5] + total_cache_read = sum((t["cache_read_tokens"] or 0) for t in recent) + total_input = sum((t["input_tokens"] or 0) for t in recent) + total_cache_creation = sum((t["cache_creation_tokens"] or 0) for t in recent) + total_billable = sum(_billable(t) for t in recent) + cache_hit_rate = ( + total_cache_read / (total_cache_read + total_input + total_cache_creation) + if (total_cache_read + total_input + total_cache_creation) else 0 + ) + avg_billable = total_billable / len(recent) + + first_ts = conn.execute( + "SELECT MIN(timestamp) AS t FROM turns WHERE session_id = ?", + (session_id,), + ).fetchone()["t"] + try: + anchor = datetime.fromisoformat(first_ts.replace("Z", "+00:00")) + session_age_hours = (now - anchor).total_seconds() / 3600 + except (ValueError, AttributeError): + session_age_hours = 0 + + reasons = [] + level = "ok" + if context_size > 150_000: + level = "warn" + reasons.append( + f"Context at ~{context_size//1000}k tokens (near 200k limit). " + "Start a new session to reclaim headroom." + ) + if cache_hit_rate < 0.40 and total_cache_creation > 50_000: + level = "warn" + reasons.append( + f"Cache hit rate only {cache_hit_rate*100:.0f}% over last 5 " + "turns — context keeps invalidating. New session will " + "rebuild a clean cache." + ) + if avg_billable > 60_000: + if level == "ok": + level = "info" + reasons.append( + f"Avg {int(avg_billable/1000)}k tokens/turn over last 5 " + "turns. Conversation is heavy — consider summarizing into " + "a new session." + ) + if session_age_hours > 3: + if level == "ok": + level = "info" + reasons.append( + f"Session running {session_age_hours:.1f}h. Long threads " + "drift and re-process the same history repeatedly." + ) + + return { + "active": True, + "session_id": session_id[:8], + "level": level, + "context_size": context_size, + "cache_hit_rate": round(cache_hit_rate, 3), + "avg_billable_per_turn": int(avg_billable), + "session_age_hours": round(session_age_hours, 2), + "turns_inspected": len(recent), + "reasons": reasons, + "message": ( + "Healthy session — keep going." if level == "ok" + else " ".join(reasons) + ), + } + + +def get_limits(db_path=DB_PATH): + """Return the full limits payload for the dashboard.""" + plan_info = detect_plan() + budgets = plan_info["budgets"] + + try: + conn = _connect(db_path) + weekly = compute_weekly(conn) + warning = compute_efficiency_warning(conn) + conn.close() + except sqlite3.Error as e: + return {"error": str(e), "plan": plan_info} + + def pct(used, cap): + if not cap: + return None + return min(100.0, round(100.0 * used / cap, 1)) + + weekly_cap = budgets["weekly_all_tokens"] + weekly_used = weekly["total"] + + weekly_all = { + "used": weekly_used, + "cap": weekly_cap, + "remaining": max(0, weekly_cap - weekly_used), + "percent": pct(weekly_used, weekly_cap), + "by_model": weekly["by_model"], + "by_model_pct": { + k: pct(v, weekly_cap) for k, v in weekly["by_model"].items() + }, + "anchor_at": weekly["anchor_at"], + "anchor_source": weekly["anchor_source"], + "baseline_used": weekly.get("baseline_used", 0), + "reset_at": weekly["reset_at"], + "source": "local_estimate", + } + + # Prefer Anthropic's ground-truth number when the OAuth endpoint + # answers. The local cost-weighted estimate stays as `local_percent` + # for comparison and as the fallback when offline / token expired. + official = fetch_official_usage() + if official.get("available") and official.get("seven_day"): + sd = official["seven_day"] + weekly_all["local_percent"] = weekly_all["percent"] + weekly_all["percent"] = sd["percent"] + if sd.get("reset_at"): + weekly_all["reset_at"] = sd["reset_at"] + weekly_all["severity"] = sd.get("severity") + weekly_all["source"] = "official_api" + if official.get("stale"): + weekly_all["source"] = "official_api_stale" + + return { + "plan": plan_info, + "weekly_all": weekly_all, + "official": official, + "session_health": warning, + "weekly_claude_design": { + "trackable": False, + "note": "Claude Design usage is a Claude.ai web feature and " + "is not written to local Claude Code JSONL. Check " + "Settings → Usage in the Claude desktop app.", + }, + "weekly_window_start": weekly["window_start"], + "note": ( + "Estimates only. Anthropic does not publish exact token " + "budgets. Claude.ai web usage (incl. Claude Design) shares " + "the same plan quota but is not tracked locally — heavy " + "web use will under-report here." + ), + } + + +if __name__ == "__main__": + print(json.dumps(get_limits(), indent=2)) diff --git a/scanner.py b/scanner.py index e40e100c..e6366445 100644 --- a/scanner.py +++ b/scanner.py @@ -33,8 +33,14 @@ def get_db(db_path=DB_PATH): # Ensure the parent directory exists — on a fresh install or CI runner # ~/.claude may not yet exist, and sqlite3.connect needs the parent dir. Path(db_path).parent.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(db_path) + conn = sqlite3.connect(db_path, timeout=10) conn.row_factory = sqlite3.Row + # The dashboard is a ThreadingHTTPServer that re-scans (writes) on every + # /api/data poll. With the default rollback journal, overlapping + # reads/writes throw "database is locked". WAL lets readers run during a + # write; busy_timeout makes any contender wait instead of erroring. + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=10000") return conn diff --git a/tests/test_weekly_anchor.py b/tests/test_weekly_anchor.py new file mode 100644 index 00000000..b890e62f --- /dev/null +++ b/tests/test_weekly_anchor.py @@ -0,0 +1,132 @@ +"""Tests for anchored weekly reset window in limits.py.""" + +import json +import sqlite3 +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest.mock import patch + +import limits +from scanner import get_db, init_db, insert_turns, upsert_sessions + + +def _mk_db(): + f = tempfile.NamedTemporaryFile(suffix=".db", delete=False) + f.close() + path = Path(f.name) + conn = get_db(path) + init_db(conn) + return conn, path + + +def _add_turns(conn, session_id, project, timestamps_with_tokens): + upsert_sessions(conn, [{ + "session_id": session_id, + "project_name": project, + "first_timestamp": timestamps_with_tokens[0][0], + "last_timestamp": timestamps_with_tokens[-1][0], + "git_branch": "main", + "model": "claude-sonnet-4-6", + "total_input_tokens": 0, "total_output_tokens": 0, + "total_cache_read": 0, "total_cache_creation": 0, + "turn_count": len(timestamps_with_tokens), + }]) + turns = [] + for i, (ts, toks) in enumerate(timestamps_with_tokens): + turns.append({ + "session_id": session_id, + "turn_index": i, + "timestamp": ts, + "model": "claude-sonnet-4-6", + "input_tokens": toks, + "output_tokens": 0, + "cache_creation_tokens": 0, + "cache_read_tokens": 0, + "project_name": project, + "git_branch": "main", + "tool_name": None, + "cwd": None, + "message_id": "", + }) + insert_turns(conn, turns) + + +class TestWeeklyAnchor(unittest.TestCase): + def setUp(self): + self.anchor_file = tempfile.NamedTemporaryFile( + suffix=".json", delete=False + ) + self.anchor_file.close() + self.anchor_path = Path(self.anchor_file.name) + self.anchor_path.unlink() # start fresh + self._patch = patch.object(limits, "WEEKLY_ANCHOR_PATH", self.anchor_path) + self._patch.start() + self.conn, self.db_path = _mk_db() + + def tearDown(self): + self._patch.stop() + self.conn.close() + self.db_path.unlink(missing_ok=True) + self.anchor_path.unlink(missing_ok=True) + + def test_first_run_uses_earliest_turn_as_anchor(self): + now = datetime(2026, 5, 27, 12, 0, tzinfo=timezone.utc) + _add_turns(self.conn, "s1", "p", [ + ((now - timedelta(days=3)).isoformat(), 1000), + ((now - timedelta(days=1)).isoformat(), 2000), + ]) + result = limits.compute_weekly(self.conn, now=now) + self.assertEqual(result["total"], 3000) + anchor = datetime.fromisoformat(result["anchor_at"]) + self.assertEqual(anchor, now - timedelta(days=3)) + self.assertEqual(result["anchor_source"], "auto") + + def test_manual_anchor_persists(self): + now = datetime(2026, 5, 27, 12, 0, tzinfo=timezone.utc) + limits.set_weekly_anchor(anchor_at=now) + _add_turns(self.conn, "s1", "p", [ + ((now - timedelta(days=2)).isoformat(), 5000), # before anchor + ((now + timedelta(hours=1)).isoformat(), 7000), # after anchor + ]) + result = limits.compute_weekly(self.conn, now=now + timedelta(hours=2)) + self.assertEqual(result["total"], 7000) + self.assertEqual(result["anchor_source"], "manual") + + def test_auto_advance_after_expiry(self): + t0 = datetime(2026, 5, 1, 0, 0, tzinfo=timezone.utc) + limits.set_weekly_anchor(anchor_at=t0) + # mark anchor as manual; auto-advance should still bump it + # 8 days later: first turn after expiry is at t0+7d+2h + next_turn = t0 + timedelta(days=7, hours=2) + _add_turns(self.conn, "s2", "p", [ + (t0.isoformat(), 1000), + (next_turn.isoformat(), 3000), + ]) + now = t0 + timedelta(days=8) + result = limits.compute_weekly(self.conn, now=now) + anchor = datetime.fromisoformat(result["anchor_at"]) + self.assertEqual(anchor, next_turn) + self.assertEqual(result["total"], 3000) + + def test_baseline_used_added_to_total(self): + now = datetime(2026, 5, 27, 12, 0, tzinfo=timezone.utc) + limits.set_weekly_anchor(anchor_at=now, baseline_used=10_000_000) + _add_turns(self.conn, "s1", "p", [ + ((now + timedelta(hours=1)).isoformat(), 500_000), + ]) + result = limits.compute_weekly(self.conn, now=now + timedelta(hours=2)) + self.assertEqual(result["baseline_used"], 10_000_000) + self.assertEqual(result["total"], 10_500_000) + + def test_reset_at_equals_anchor_plus_7d(self): + now = datetime(2026, 5, 27, 12, 0, tzinfo=timezone.utc) + anchor = limits.set_weekly_anchor(anchor_at=now) + result = limits.compute_weekly(self.conn, now=now) + reset = datetime.fromisoformat(result["reset_at"]) + self.assertEqual(reset - anchor, timedelta(days=7)) + + +if __name__ == "__main__": + unittest.main()
${fmtCost(cost)}n/a
${esc(s.session_id)}…${typeof s.title === 'string' && s.title ? esc(s.title) : '(no prompt)'} ${esc(s.project)} ${esc(s.last)} ${esc(s.duration_min)}m
#${realIdx}${esc((m.timestamp||'').replace('T',' ').slice(0,19))}${esc(m.prompt||'(empty)')}${m.turn_count}${esc(tools)}${fmt(m.input)}${fmt(m.output)}${fmt(m.cache_read)}${fmt(m.cache_creation)}${fmtCost(c)}
No messages parsed
${esc(p.project)}${esc(projectLabel(p.project))} ${esc(p.project)} ${p.sessions} ${fmt(p.turns)} ${fmt(p.input)}