Skip to content

Commit 256b3e8

Browse files
committed
Release v1.1.0
Bug-fix release. Full notes in CHANGELOG.md. Contributors: @thomasleveil, @jakduch (x2), @Fruhji (x2), @HaydenHaines. Triage and verification: Claude Code & Codex collab.
2 parents 6c709b7 + e642383 commit 256b3e8

7 files changed

Lines changed: 254 additions & 18 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
__pycache__/
22
server.log
3+
4+
.pr-candidates.md

AGENTS.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,14 @@ The entire UI lives in `HTML_TEMPLATE` as a raw string. Chart.js is loaded from
8989
- `tests/test_scanner.py` and `tests/test_dashboard.py` use `tempfile.NamedTemporaryFile` for an isolated DB; never touch the user's real `~/.claude/usage.db`.
9090
- The `/api/rescan` test patches `dashboard.DB_PATH` and `scanner.DEFAULT_PROJECTS_DIRS` — keep that contract intact (see commit 8ae2664).
9191
- On Windows, `~/.claude/` may not exist on a fresh checkout. `get_db` creates the parent dir (`mkdir(parents=True, exist_ok=True)`) — don't remove that or `sqlite3.connect` will fail in CI / fresh installs (commit b5d1e15).
92+
93+
## Respecting contributors
94+
95+
When merging community PRs, **preserve the original author's commit so they get GitHub contributor credit**. In practice:
96+
97+
- `git fetch origin pull/<N>/head:pr-<N>``git merge --no-ff pr-<N>` keeps the author commit verbatim inside the merge bubble (don't squash, don't rebase-flatten).
98+
- For a partial merge — when only one hunk of a PR is wanted — use `git cherry-pick <commit-sha>` against the specific upstream commit so authorship is preserved. If the diff isn't a clean single commit, fall back to applying the hunk manually + adding a `Co-Authored-By: Name <email>` trailer.
99+
- Improvements that the bot/maintainer makes _on top_ of a contributor's work go in **separate follow-up commits**, not amendments to the contributor's commit.
100+
- When closing duplicate PRs (multiple authors fixed the same bug independently), thank each one and explain that landing the earliest version isn't a quality judgment.
101+
102+
This applies to all agents working on this repo, not just Claude Code.

CHANGELOG.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,27 @@
11
# Changelog
22

3-
## 2026-04-09
3+
## v1.1.0 — 2026-05-28
4+
5+
### Dashboard
6+
7+
- Fix `ReferenceError: cutoff is not defined` in the hourly filter that blanked the entire dashboard (#73, thanks @thomasleveil)
8+
- Fix hourly chart ignoring the range upper bound for `week` / `month` / `prev-month` ranges
9+
- Fix 404 on dashboard URLs containing query strings (`?range=...&models=...`) so reloads and bookmarks work, with regression tests (#81, thanks @jakduch)
10+
- Fix blank dashboard for users whose data only contains non-billable / unknown models, or rows with empty model names: `COALESCE(NULLIF(model, ''), 'unknown')` normalises empty-string models (in both SELECT and GROUP BY so mixed NULL + '' rows collapse to a single "unknown" group), and the default selection falls back to all models when no billable models exist (#109, thanks @HaydenHaines)
11+
- Use `ThreadingHTTPServer` so a slow `/api/data` no longer blocks other dashboard requests (#79, thanks @jakduch)
12+
- Add a `Today` range button (#112, thanks @Fruhji)
13+
14+
### Scanner
15+
16+
- Fix incremental scan not updating `first_timestamp` when a newly discovered session's records arrive out of order (#111, thanks @Fruhji)
17+
18+
### Project / docs
19+
20+
- Adopt `AGENTS.md` as the canonical agent guide (shared with Codex); `CLAUDE.md` is now a thin `@AGENTS.md` import
21+
- Codify the rule that future PR merges must preserve original-author commits (`merge --no-ff` for full merges, `cherry-pick` for partial, `Co-Authored-By` trailers when applying hunks manually)
22+
- Drop unused `.claude/launch.json`
23+
24+
## v1.0.0 — 2026-04-09
425

526
- Fix token counts inflated ~2x by deduplicating streaming events that share the same message ID
627
- Fix session cost totals that were inflated when sessions spanned multiple JSONL files

dashboard.py

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
import json
66
import os
77
import sqlite3
8-
from http.server import HTTPServer, BaseHTTPRequestHandler
8+
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
9+
from urllib.parse import urlparse
910
from pathlib import Path
1011
from datetime import datetime
1112

@@ -20,10 +21,12 @@ def get_dashboard_data(db_path=DB_PATH):
2021
conn.row_factory = sqlite3.Row
2122

2223
# ── All models (for filter UI) ────────────────────────────────────────────
24+
# GROUP BY uses the normalised expression too so NULL and '' don't end up
25+
# as two separate "unknown" rows.
2326
model_rows = conn.execute("""
24-
SELECT COALESCE(model, 'unknown') as model
27+
SELECT COALESCE(NULLIF(model, ''), 'unknown') as model
2528
FROM turns
26-
GROUP BY model
29+
GROUP BY COALESCE(NULLIF(model, ''), 'unknown')
2730
ORDER BY SUM(input_tokens + output_tokens) DESC
2831
""").fetchall()
2932
all_models = [r["model"] for r in model_rows]
@@ -32,14 +35,14 @@ def get_dashboard_data(db_path=DB_PATH):
3235
daily_rows = conn.execute("""
3336
SELECT
3437
substr(timestamp, 1, 10) as day,
35-
COALESCE(model, 'unknown') as model,
38+
COALESCE(NULLIF(model, ''), 'unknown') as model,
3639
SUM(input_tokens) as input,
3740
SUM(output_tokens) as output,
3841
SUM(cache_read_tokens) as cache_read,
3942
SUM(cache_creation_tokens) as cache_creation,
4043
COUNT(*) as turns
4144
FROM turns
42-
GROUP BY day, model
45+
GROUP BY day, COALESCE(NULLIF(model, ''), 'unknown')
4346
ORDER BY day, model
4447
""").fetchall()
4548

@@ -59,12 +62,12 @@ def get_dashboard_data(db_path=DB_PATH):
5962
SELECT
6063
substr(timestamp, 1, 10) as day,
6164
CAST(substr(timestamp, 12, 2) AS INTEGER) as hour,
62-
COALESCE(model, 'unknown') as model,
65+
COALESCE(NULLIF(model, ''), 'unknown') as model,
6366
SUM(output_tokens) as output,
6467
COUNT(*) as turns
6568
FROM turns
6669
WHERE timestamp IS NOT NULL AND length(timestamp) >= 13
67-
GROUP BY day, hour, model
70+
GROUP BY day, hour, COALESCE(NULLIF(model, ''), 'unknown')
6871
ORDER BY day, hour, model
6972
""").fetchall()
7073

@@ -235,6 +238,7 @@ def get_dashboard_data(db_path=DB_PATH):
235238
<div class="filter-sep"></div>
236239
<div class="filter-label">Range</div>
237240
<div class="range-group">
241+
<button class="range-btn" data-range="today" onclick="setRange('today')">Today</button>
238242
<button class="range-btn" data-range="week" onclick="setRange('week')">This Week</button>
239243
<button class="range-btn" data-range="month" onclick="setRange('month')">This Month</button>
240244
<button class="range-btn" data-range="prev-month" onclick="setRange('prev-month')">Prev Month</button>
@@ -481,8 +485,8 @@ def get_dashboard_data(db_path=DB_PATH):
481485
const MODEL_COLORS = ['#d97757','#4f8ef7','#4ade80','#a78bfa','#fbbf24','#f472b6','#34d399','#60a5fa'];
482486
483487
// ── Time range ─────────────────────────────────────────────────────────────
484-
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' };
485-
const RANGE_TICKS = { 'week': 7, 'month': 15, 'prev-month': 15, '7d': 7, '30d': 15, '90d': 13, 'all': 12 };
488+
const RANGE_LABELS = { 'today': 'Today', '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' };
489+
const RANGE_TICKS = { 'today': 1, 'week': 7, 'month': 15, 'prev-month': 15, '7d': 7, '30d': 15, '90d': 13, 'all': 12 };
486490
const VALID_RANGES = Object.keys(RANGE_LABELS);
487491
488492
function rangeIncludesToday(range) {
@@ -498,6 +502,10 @@ def get_dashboard_data(db_path=DB_PATH):
498502
if (range === 'all') return { start: null, end: null };
499503
const today = new Date();
500504
const iso = d => d.toISOString().slice(0, 10);
505+
if (range === 'today') {
506+
const t = iso(today);
507+
return { start: t, end: t };
508+
}
501509
if (range === 'week') {
502510
const day = today.getDay();
503511
const diffToMon = day === 0 ? 6 : day - 1;
@@ -555,15 +563,21 @@ def get_dashboard_data(db_path=DB_PATH):
555563
556564
function readURLModels(allModels) {
557565
const param = new URLSearchParams(window.location.search).get('models');
558-
if (!param) return new Set(allModels.filter(m => isBillable(m)));
566+
if (!param) {
567+
const billable = allModels.filter(m => isBillable(m));
568+
// Fallback: if the user only has non-billable / unknown models (e.g. all
569+
// local-LLM runs), default to all models so the dashboard isn't blank.
570+
return new Set(billable.length ? billable : allModels);
571+
}
559572
const fromURL = new Set(param.split(',').map(s => s.trim()).filter(Boolean));
560573
return new Set(allModels.filter(m => fromURL.has(m)));
561574
}
562575
563576
function isDefaultModelSelection(allModels) {
564577
const billable = allModels.filter(m => isBillable(m));
565-
if (selectedModels.size !== billable.length) return false;
566-
return billable.every(m => selectedModels.has(m));
578+
const expected = billable.length ? billable : allModels;
579+
if (selectedModels.size !== expected.length) return false;
580+
return expected.every(m => selectedModels.has(m));
567581
}
568582
569583
function buildFilterUI(allModels) {
@@ -742,7 +756,7 @@ def get_dashboard_data(db_path=DB_PATH):
742756
743757
// Hourly aggregation (filtered by model + range, then bucketed by UTC hour)
744758
const hourlySrc = (rawData.hourly_by_model || []).filter(r =>
745-
selectedModels.has(r.model) && (!cutoff || r.day >= cutoff)
759+
selectedModels.has(r.model) && (!start || r.day >= start) && (!end || r.day <= end)
746760
);
747761
const hourlyAgg = aggregateHourly(hourlySrc, hourlyTZ);
748762
@@ -1242,13 +1256,17 @@ def log_message(self, format, *args):
12421256
pass
12431257

12441258
def do_GET(self):
1245-
if self.path in ("/", "/index.html"):
1259+
# self.path includes the query string, but every URL the UI emits has
1260+
# one (e.g. "/?range=all"); compare the bare path so bookmarkable
1261+
# URLs don't fall through to 404.
1262+
path = urlparse(self.path).path
1263+
if path in ("/", "/index.html"):
12461264
self.send_response(200)
12471265
self.send_header("Content-Type", "text/html; charset=utf-8")
12481266
self.end_headers()
12491267
self.wfile.write(HTML_TEMPLATE.encode("utf-8"))
12501268

1251-
elif self.path == "/api/data":
1269+
elif path == "/api/data":
12521270
data = get_dashboard_data()
12531271
body = json.dumps(data).encode("utf-8")
12541272
self.send_response(200)
@@ -1262,7 +1280,8 @@ def do_GET(self):
12621280
self.end_headers()
12631281

12641282
def do_POST(self):
1265-
if self.path == "/api/rescan":
1283+
path = urlparse(self.path).path
1284+
if path == "/api/rescan":
12661285
# Full rebuild: delete DB and rescan from scratch.
12671286
# Pass DB_PATH / DEFAULT_PROJECTS_DIRS explicitly so tests that
12681287
# patch the module globals are honored (scan's defaults are
@@ -1290,7 +1309,7 @@ def do_POST(self):
12901309
def serve(host=None, port=None):
12911310
host = host or os.environ.get("HOST", "localhost")
12921311
port = port or int(os.environ.get("PORT", "8080"))
1293-
server = HTTPServer((host, port), DashboardHandler)
1312+
server = ThreadingHTTPServer((host, port), DashboardHandler)
12941313
print(f"Dashboard running at http://{host}:{port}")
12951314
print("Press Ctrl+C to stop.")
12961315
try:

scanner.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,8 @@ def scan(projects_dir=None, projects_dirs=None, db_path=DB_PATH, verbose=True):
419419
meta = new_session_metas[session_id]
420420
if timestamp and (not meta["last_timestamp"] or timestamp > meta["last_timestamp"]):
421421
meta["last_timestamp"] = timestamp
422+
if timestamp and (not meta["first_timestamp"] or timestamp < meta["first_timestamp"]):
423+
meta["first_timestamp"] = timestamp
422424

423425
if rtype == "assistant":
424426
msg = record.get("message", {})

tests/test_dashboard.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,131 @@ def test_hourly_by_model_carries_day_and_model(self):
124124
self.assertTrue(all(r["day"] == "2026-04-08" for r in rows))
125125

126126

127+
class TestEmptyStringModelNormalization(unittest.TestCase):
128+
"""Regression: turns with model='' (empty string) must group as 'unknown'.
129+
COALESCE(model, 'unknown') alone returns '' because empty string isn't NULL;
130+
NULLIF(model, '') is needed first."""
131+
132+
def setUp(self):
133+
self.tmpfile = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
134+
self.tmpfile.close()
135+
self.db_path = Path(self.tmpfile.name)
136+
conn = get_db(self.db_path)
137+
init_db(conn)
138+
upsert_sessions(conn, [{
139+
"session_id": "sess-empty", "project_name": "u/p",
140+
"first_timestamp": "2026-04-08T09:00:00Z",
141+
"last_timestamp": "2026-04-08T09:05:00Z",
142+
"git_branch": "", "model": "",
143+
"total_input_tokens": 100, "total_output_tokens": 50,
144+
"total_cache_read": 0, "total_cache_creation": 0,
145+
"turn_count": 1,
146+
}])
147+
insert_turns(conn, [{
148+
"session_id": "sess-empty", "timestamp": "2026-04-08T09:05:00Z",
149+
"model": "", "input_tokens": 100, "output_tokens": 50,
150+
"cache_read_tokens": 0, "cache_creation_tokens": 0,
151+
"tool_name": None, "cwd": "/tmp",
152+
}])
153+
conn.commit()
154+
conn.close()
155+
156+
def tearDown(self):
157+
os.unlink(self.db_path)
158+
159+
def test_all_models_contains_unknown_not_empty(self):
160+
data = get_dashboard_data(db_path=self.db_path)
161+
self.assertIn("unknown", data["all_models"])
162+
self.assertNotIn("", data["all_models"])
163+
164+
def test_daily_by_model_contains_unknown_not_empty(self):
165+
data = get_dashboard_data(db_path=self.db_path)
166+
models = {r["model"] for r in data["daily_by_model"]}
167+
self.assertIn("unknown", models)
168+
self.assertNotIn("", models)
169+
170+
def test_hourly_by_model_contains_unknown_not_empty(self):
171+
data = get_dashboard_data(db_path=self.db_path)
172+
models = {r["model"] for r in data["hourly_by_model"]}
173+
self.assertIn("unknown", models)
174+
self.assertNotIn("", models)
175+
176+
177+
class TestMixedNullAndEmptyModel(unittest.TestCase):
178+
"""Regression: a mix of model=NULL and model='' rows must collapse into a
179+
SINGLE 'unknown' group across all aggregations. Without `GROUP BY
180+
COALESCE(NULLIF(model, ''), 'unknown')` (matching the SELECT expression),
181+
SQLite groups by raw value and emits two distinct 'unknown' rows."""
182+
183+
def setUp(self):
184+
self.tmpfile = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
185+
self.tmpfile.close()
186+
self.db_path = Path(self.tmpfile.name)
187+
conn = get_db(self.db_path)
188+
init_db(conn)
189+
upsert_sessions(conn, [{
190+
"session_id": "sess-mix", "project_name": "u/p",
191+
"first_timestamp": "2026-04-08T09:00:00Z",
192+
"last_timestamp": "2026-04-08T10:00:00Z",
193+
"git_branch": "", "model": "",
194+
"total_input_tokens": 200, "total_output_tokens": 100,
195+
"total_cache_read": 0, "total_cache_creation": 0,
196+
"turn_count": 2,
197+
}])
198+
# Insert one turn with model='' and one with model=NULL on the same day.
199+
# Use raw INSERT for the NULL row because insert_turns() requires the
200+
# model key to exist (would error on missing key, not on None).
201+
insert_turns(conn, [{
202+
"session_id": "sess-mix", "timestamp": "2026-04-08T09:00:00Z",
203+
"model": "", "input_tokens": 100, "output_tokens": 50,
204+
"cache_read_tokens": 0, "cache_creation_tokens": 0,
205+
"tool_name": None, "cwd": "/tmp",
206+
}])
207+
conn.execute("""
208+
INSERT INTO turns (session_id, timestamp, model, input_tokens,
209+
output_tokens, cache_read_tokens, cache_creation_tokens,
210+
tool_name, cwd)
211+
VALUES ('sess-mix', '2026-04-08T09:30:00Z', NULL, 100, 50, 0, 0, NULL, '/tmp')
212+
""")
213+
conn.commit()
214+
conn.close()
215+
216+
def tearDown(self):
217+
os.unlink(self.db_path)
218+
219+
def test_all_models_collapses_to_single_unknown(self):
220+
data = get_dashboard_data(db_path=self.db_path)
221+
unknowns = [m for m in data["all_models"] if m == "unknown"]
222+
self.assertEqual(len(unknowns), 1, f"got duplicate 'unknown' rows: {data['all_models']}")
223+
224+
def test_daily_collapses_to_single_unknown(self):
225+
data = get_dashboard_data(db_path=self.db_path)
226+
unknown_rows = [r for r in data["daily_by_model"] if r["model"] == "unknown"]
227+
# One day, one model bucket
228+
self.assertEqual(len(unknown_rows), 1, f"got {unknown_rows}")
229+
self.assertEqual(unknown_rows[0]["turns"], 2)
230+
self.assertEqual(unknown_rows[0]["input"], 200)
231+
232+
def test_hourly_collapses_to_single_unknown(self):
233+
data = get_dashboard_data(db_path=self.db_path)
234+
# Both turns are in UTC hour 9 — must be one row, not two
235+
hour9 = [r for r in data["hourly_by_model"]
236+
if r["hour"] == 9 and r["model"] == "unknown"]
237+
self.assertEqual(len(hour9), 1, f"got {hour9}")
238+
self.assertEqual(hour9[0]["turns"], 2)
239+
240+
241+
class TestNonBillableModelFallback(unittest.TestCase):
242+
"""Regression: when the user has only non-billable models (e.g. gemma, glm,
243+
local LLMs) — or all turns lack a model field — the default model selection
244+
must fall back to ALL models so the dashboard isn't blank."""
245+
246+
def test_readurlmodels_fallback_in_html_template(self):
247+
# The fallback logic is JS; we assert the source contains the guard so
248+
# a future refactor doesn't silently remove it.
249+
self.assertIn("billable.length ? billable : allModels", HTML_TEMPLATE)
250+
251+
127252
class TestDashboardHTTP(unittest.TestCase):
128253
"""Integration test: start server and make HTTP requests."""
129254

@@ -166,6 +291,23 @@ def test_index_returns_html(self):
166291
self.assertEqual(resp.status, 200)
167292
self.assertIn("text/html", resp.headers["Content-Type"])
168293

294+
def test_index_with_query_string_returns_html(self):
295+
# Regression: ?range=... and ?models=... must not 404. The dashboard
296+
# itself rewrites the URL with these params via history.replaceState,
297+
# so anything that reloads or bookmarks the page hits this path.
298+
for qs in ("?range=all", "?range=30d&models=claude-opus-4-7"):
299+
with urllib.request.urlopen(f"http://127.0.0.1:{self.port}/{qs}") as resp:
300+
self.assertEqual(resp.status, 200)
301+
self.assertIn(b"Claude Code Usage Dashboard", resp.read())
302+
303+
def test_api_data_with_query_string(self):
304+
# /api/data is fetched without query parameters today, but the route
305+
# should be tolerant if any are tacked on (e.g. cache-busting).
306+
with urllib.request.urlopen(
307+
f"http://127.0.0.1:{self.port}/api/data?_=cachebust"
308+
) as resp:
309+
self.assertEqual(resp.status, 200)
310+
169311
def test_api_data_returns_json(self):
170312
url = f"http://127.0.0.1:{self.port}/api/data"
171313
with urllib.request.urlopen(url) as resp:
@@ -228,6 +370,15 @@ def test_hourly_peak_hour_constants(self):
228370
self.assertIn('PEAK_HOURS_UTC', HTML_TEMPLATE)
229371
self.assertIn('[12, 13, 14, 15, 16, 17]', HTML_TEMPLATE)
230372

373+
def test_today_range_button_present(self):
374+
"""The 'Today' range button is wired into RANGE_LABELS, RANGE_TICKS,
375+
getRangeBounds, and the filter-bar HTML."""
376+
self.assertIn("data-range=\"today\"", HTML_TEMPLATE)
377+
self.assertIn("'today': 'Today'", HTML_TEMPLATE)
378+
self.assertIn("'today': 1", HTML_TEMPLATE)
379+
# Bounds case: today returns start === end === today's ISO date
380+
self.assertIn("range === 'today'", HTML_TEMPLATE)
381+
231382

232383
class TestPricingParity(unittest.TestCase):
233384
"""Verify CLI and dashboard pricing tables stay in sync."""

0 commit comments

Comments
 (0)