Skip to content

Commit 63b36d9

Browse files
phurynclaude
andcommitted
feat(dashboard): v1.3.0 — footer version, extension promo, update check
Add a single source-of-truth version and surface-aware footer to both the standalone web dashboard and the embedded VS Code panel. - scanner.VERSION ("1.3.0") is the runtime version (CHANGELOG isn't bundled into the .vsix), surfaced via `python cli.py --version`. - The dashboard footer now shows the running version linked to its GitHub release tag on both surfaces (closes #135). - The web build additionally promotes the VS Code extension and, when a newer release exists, shows an "Update to vX.Y.Z" link. It learns its surface from a new `--surface` flag; the extension passes `--surface vscode`, which suppresses the promo and the update check (VS Code updates the extension itself, and a GitHub-release check would misfire because the Marketplace publish lags the GitHub release). - The update check is one unauthenticated GET to GitHub's public releases API, cached in localStorage for 24h and fully fail-silent. No usage data is sent; the embedded panel makes no such request. Disclosed in the CHANGELOG. - Version injected server-side via a window.APP_CONFIG placeholder. - Tests: three-way version parity (scanner / CHANGELOG / package.json), `--version` CLI flag, APP_CONFIG placeholder substitution, surface gating. - Bumped vscode-extension/package.json to 1.3.0; documented the VERSION bump in AGENTS.md's release checklist. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 70b56d4 commit 63b36d9

9 files changed

Lines changed: 244 additions & 8 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ This applies to all agents working on this repo, not just Claude Code.
107107

108108
The release flow:
109109
1. While work accumulates on `DEV`, the `## vX.Y.Z — TBD` heading at the top of `CHANGELOG.md` collects bullets. (For automated triage runs, see the routine note below.)
110-
2. When the maintainer is ready to release, they finalize the heading (`TBD` → today's date), **bump `vscode-extension/package.json`'s `version` to match the CHANGELOG version** (the two ship in lockstep — the extension bundles the Python sources), merge `DEV → main` with `merge --no-ff` (so the release boundary is visible in `git log main`), and push `main`.
110+
2. When the maintainer is ready to release, they finalize the heading (`TBD` → today's date), **bump both `scanner.VERSION` and `vscode-extension/package.json`'s `version` to match the CHANGELOG version** (all three ship in lockstep — the extension bundles the Python sources, and `scanner.VERSION` is the runtime version reported by `cli.py --version` and the dashboard footer since the CHANGELOG isn't bundled into the `.vsix`), merge `DEV → main` with `merge --no-ff` (so the release boundary is visible in `git log main`), and push `main`. A parity test (`tests/test_version.py`) fails the suite if the three drift, so in practice they're bumped together on `DEV` when the version heading is written.
111111
3. [`.github/workflows/tag-on-merge.yml`](.github/workflows/tag-on-merge.yml) fires on the push, sees the new `## vX.Y.Z` heading in the CHANGELOG diff, and:
112112
- creates a lightweight tag at the merge commit (**no `git tag` step for the maintainer**), then
113113
- builds the VS Code extension `.vsix` and publishes a **GitHub Release** for that tag — the matching CHANGELOG section as the release notes, the built `.vsix` attached as a release asset.

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Changelog
22

3+
## v1.3.0 — TBD
4+
5+
### Dashboard
6+
7+
- The footer now shows the running version, linked to its GitHub release tag, on both the standalone web dashboard and the embedded VS Code panel (#135).
8+
- The standalone web dashboard now promotes the VS Code extension in the footer and, when a newer release is available, shows an **Update to vX.Y.Z** link to the latest GitHub release. The embedded panel shows neither — VS Code updates the extension itself, so it only displays the version. The dashboard learns its surface from a new `--surface` flag the extension passes (`--surface vscode`).
9+
- **Version check / privacy:** to power the update link, the standalone web dashboard now makes one unauthenticated request to GitHub's public releases API (`api.github.com/repos/phuryn/claude-usage/releases/latest`), cached in the browser for 24 hours and fully fail-silent (offline, blocked, or rate-limited simply hides the link). It sends **none** of your usage data — only a plain GET for the latest release number. The embedded VS Code panel makes no such request. Your transcripts and usage data never leave your machine.
10+
11+
### Scanner / CLI
12+
13+
- Added a single source-of-truth `VERSION` constant (`scanner.VERSION`) surfaced via `python cli.py --version`. It stays in lockstep with the top CHANGELOG heading and the extension's `package.json` (a parity test enforces all three match).
14+
315
## v1.2.6 — 2026-06-15
416

517
### Dashboard

cli.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
from pathlib import Path
1515
from datetime import datetime, date, timedelta
1616

17+
from scanner import VERSION
18+
1719
DB_PATH = Path.home() / ".claude" / "usage.db"
1820

1921
PRICING = {
@@ -364,7 +366,7 @@ def cmd_stats():
364366
conn.close()
365367

366368

367-
def cmd_dashboard(projects_dir=None, host=None, port=None, no_browser=False):
369+
def cmd_dashboard(projects_dir=None, host=None, port=None, no_browser=False, surface=None):
368370
import threading
369371
import time
370372

@@ -403,7 +405,7 @@ def open_browser():
403405

404406
threading.Thread(target=open_browser, daemon=True).start()
405407

406-
serve(host=host, port=port)
408+
serve(host=host, port=port, surface=surface)
407409

408410

409411
# ── Entry point ───────────────────────────────────────────────────────────────
@@ -416,8 +418,9 @@ def open_browser():
416418
python cli.py today Show today's usage summary
417419
python cli.py week Show last 7 days (per-day + by-model)
418420
python cli.py stats Show all-time statistics
419-
python cli.py dashboard [--projects-dir PATH] [--host HOST] [--port PORT] [--no-browser]
421+
python cli.py dashboard [--projects-dir PATH] [--host HOST] [--port PORT] [--no-browser] [--surface SURFACE]
420422
Scan + start dashboard (opens a browser unless --no-browser)
423+
python cli.py --version Print the version and exit
421424
"""
422425

423426
COMMANDS = {
@@ -436,6 +439,10 @@ def parse_named_arg(args, flag):
436439
return None
437440

438441
if __name__ == "__main__":
442+
if len(sys.argv) >= 2 and sys.argv[1] in ("--version", "-V", "version"):
443+
print(VERSION)
444+
sys.exit(0)
445+
439446
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
440447
print(USAGE)
441448
sys.exit(0)
@@ -450,6 +457,7 @@ def parse_named_arg(args, flag):
450457
host=parse_named_arg(rest, "--host"),
451458
port=parse_named_arg(rest, "--port"),
452459
no_browser="--no-browser" in rest,
460+
surface=parse_named_arg(rest, "--surface"),
453461
)
454462
elif command == "scan" and projects_dir:
455463
cmd_scan(projects_dir=projects_dir)

dashboard.py

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,19 @@
1010
from pathlib import Path
1111
from datetime import datetime
1212

13+
from scanner import VERSION
14+
1315
DB_PATH = Path.home() / ".claude" / "usage.db"
1416

17+
# Which surface is rendering the dashboard: "web" (standalone `cli.py dashboard`)
18+
# or "vscode" (embedded in the extension's sidebar webview). serve() sets this
19+
# from the --surface flag the extension passes. The footer reads it to decide
20+
# what to show — the web build promotes the VS Code extension and offers a
21+
# "check GitHub for a newer release" update link; the embedded build shows just
22+
# the version (VS Code updates the extension itself, and a GitHub-release check
23+
# would misfire there because the Marketplace publish lags the GitHub release).
24+
SURFACE = "web"
25+
1526

1627
def get_dashboard_data(db_path=DB_PATH):
1728
if not db_path.exists():
@@ -135,6 +146,7 @@ def get_dashboard_data(db_path=DB_PATH):
135146
<meta name="viewport" content="width=device-width, initial-scale=1.0">
136147
<title>Claude Code Usage Dashboard</title>
137148
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
149+
<script>window.APP_CONFIG = __APP_CONFIG_JSON__;</script>
138150
<style>
139151
:root {
140152
--bg: #161617; /* page base */
@@ -258,6 +270,7 @@ def get_dashboard_data(db_path=DB_PATH):
258270
.footer-content p:last-child { margin-bottom: 0; }
259271
.footer-content a { color: var(--blue); text-decoration: none; }
260272
.footer-content a:hover { text-decoration: underline; }
273+
.footer-content a.update-link { color: var(--accent); font-weight: 600; }
261274
262275
@media (max-width: 768px) { .charts-grid { grid-template-columns: 1fr; } .chart-card.wide { grid-column: 1; } }
263276
</style>
@@ -398,6 +411,7 @@ def get_dashboard_data(db_path=DB_PATH):
398411
&nbsp;&middot;&nbsp;
399412
License: MIT
400413
</p>
414+
<p id="footer-meta"></p>
401415
</div>
402416
</footer>
403417
@@ -1471,6 +1485,82 @@ def get_dashboard_data(db_path=DB_PATH):
14711485
}
14721486
}
14731487
1488+
// ── Footer meta: version, extension promo, update check ──────────────────────
1489+
// APP_CONFIG is injected server-side (see do_GET). { version, surface }.
1490+
const APP_CONFIG = window.APP_CONFIG || { version: '', surface: 'web' };
1491+
const REPO_URL = 'https://github.com/phuryn/claude-usage';
1492+
const MARKETPLACE_URL = 'https://marketplace.visualstudio.com/items?itemName=PawelHuryn.claude-usage-phuryn';
1493+
const UPDATE_CACHE_KEY = 'cu_update_check';
1494+
const UPDATE_CACHE_TTL = 24 * 60 * 60 * 1000; // re-check GitHub at most once a day
1495+
1496+
// Compare dotted numeric versions ("1.3.0"); leading "v" tolerated. Returns
1497+
// true only when `latest` is strictly ahead of `current`.
1498+
function isNewer(latest, current) {
1499+
const a = String(latest).replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0);
1500+
const b = String(current).replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0);
1501+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
1502+
const x = a[i] || 0, y = b[i] || 0;
1503+
if (x > y) return true;
1504+
if (x < y) return false;
1505+
}
1506+
return false;
1507+
}
1508+
1509+
function appendUpdateLink(latest) {
1510+
const el = document.getElementById('footer-meta');
1511+
if (!el || !el.innerHTML) return;
1512+
const a = document.createElement('a');
1513+
a.className = 'update-link';
1514+
a.href = REPO_URL + '/releases/latest';
1515+
a.target = '_blank';
1516+
a.rel = 'noopener';
1517+
a.textContent = 'Update to v' + latest;
1518+
el.insertAdjacentHTML('beforeend', '&nbsp;&middot;&nbsp;');
1519+
el.appendChild(a);
1520+
}
1521+
1522+
// Web only. Asks GitHub's public releases API whether a newer release exists and,
1523+
// if so, appends an "Update to vX.Y.Z" link. Cached in localStorage for 24h and
1524+
// fully fail-silent (offline / rate-limited / blocked -> no link, no error). No
1525+
// usage data is sent; this is a plain unauthenticated GET of release metadata.
1526+
function checkForUpdate(current) {
1527+
let cached = null;
1528+
try { cached = JSON.parse(localStorage.getItem(UPDATE_CACHE_KEY) || 'null'); } catch (e) {}
1529+
if (cached && cached.latest && cached.ts && (Date.now() - cached.ts) < UPDATE_CACHE_TTL) {
1530+
if (isNewer(cached.latest, current)) appendUpdateLink(cached.latest);
1531+
return;
1532+
}
1533+
fetch('https://api.github.com/repos/phuryn/claude-usage/releases/latest', {
1534+
headers: { 'Accept': 'application/vnd.github+json' }
1535+
})
1536+
.then(r => r.ok ? r.json() : null)
1537+
.then(data => {
1538+
if (!data || !data.tag_name) return;
1539+
const latest = String(data.tag_name).replace(/^v/, '');
1540+
try { localStorage.setItem(UPDATE_CACHE_KEY, JSON.stringify({ ts: Date.now(), latest: latest })); } catch (e) {}
1541+
if (isNewer(latest, current)) appendUpdateLink(latest);
1542+
})
1543+
.catch(() => {}); // fail-silent: never let a version check disrupt the dashboard
1544+
}
1545+
1546+
function initFooterMeta() {
1547+
const el = document.getElementById('footer-meta');
1548+
if (!el) return;
1549+
const v = APP_CONFIG.version || '';
1550+
const parts = [];
1551+
if (v) {
1552+
parts.push('Version <a href="' + REPO_URL + '/releases/tag/v' + esc(v) + '" target="_blank" rel="noopener">v' + esc(v) + '</a>');
1553+
}
1554+
// The web build promotes the extension; the embedded build is already in it.
1555+
if (APP_CONFIG.surface !== 'vscode') {
1556+
parts.push('<a href="' + MARKETPLACE_URL + '" target="_blank" rel="noopener">Get the VS Code extension</a>');
1557+
}
1558+
el.innerHTML = parts.join('&nbsp;&middot;&nbsp;');
1559+
// VS Code auto-updates the extension, so only the web build checks for updates.
1560+
if (v && APP_CONFIG.surface !== 'vscode') checkForUpdate(v);
1561+
}
1562+
1563+
initFooterMeta();
14741564
loadData();
14751565
scheduleAutoRefresh();
14761566
</script>
@@ -1510,10 +1600,17 @@ def do_GET(self):
15101600
# URLs don't fall through to 404.
15111601
path = urlparse(self.path).path
15121602
if path in ("/", "/index.html"):
1603+
# Inject runtime config (version + surface) the page can't know at
1604+
# author time. json.dumps produces a valid JS object literal for the
1605+
# `window.APP_CONFIG = __APP_CONFIG_JSON__;` placeholder in the head.
1606+
config = json.dumps({"version": VERSION, "surface": SURFACE})
1607+
html = HTML_TEMPLATE.replace("__APP_CONFIG_JSON__", config)
1608+
body = html.encode("utf-8")
15131609
self.send_response(200)
15141610
self.send_header("Content-Type", "text/html; charset=utf-8")
1611+
self.send_header("Content-Length", str(len(body)))
15151612
self.end_headers()
1516-
self.wfile.write(HTML_TEMPLATE.encode("utf-8"))
1613+
self.wfile.write(body)
15171614

15181615
elif path == "/api/data":
15191616
data = get_dashboard_data()
@@ -1570,7 +1667,10 @@ def do_POST(self):
15701667
self.end_headers()
15711668

15721669

1573-
def serve(host=None, port=None):
1670+
def serve(host=None, port=None, surface=None):
1671+
global SURFACE
1672+
if surface:
1673+
SURFACE = surface
15741674
host = host or os.environ.get("HOST", "localhost")
15751675
port = port or int(os.environ.get("PORT", "8080"))
15761676
server = ThreadingHTTPServer((host, port), DashboardHandler)

scanner.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@
99
from pathlib import Path
1010
from datetime import datetime, timezone
1111

12+
# Single source of truth for the app version reported by the CLI (`--version`)
13+
# and the dashboard footer. CHANGELOG.md is the canonical version reference, but
14+
# it isn't bundled into the .vsix — only the three Python files are — so the
15+
# runtime version has to live here as a constant. Keep this in lockstep with the
16+
# top CHANGELOG heading and vscode-extension/package.json (a parity test guards
17+
# all three; see tests/test_version.py).
18+
VERSION = "1.3.0"
19+
1220
PROJECTS_DIR = Path.home() / ".claude" / "projects"
1321
XCODE_PROJECTS_DIR = Path.home() / "Library" / "Developer" / "Xcode" / "CodingAssistant" / "ClaudeAgentConfig" / "projects"
1422
DB_PATH = Path.home() / ".claude" / "usage.db"

tests/test_dashboard.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,20 @@ def test_404_for_unknown_path(self):
383383
except urllib.error.HTTPError as e:
384384
self.assertEqual(e.code, 404)
385385

386+
def test_index_injects_app_config(self):
387+
# do_GET must substitute the __APP_CONFIG_JSON__ placeholder with a real
388+
# JSON object (version + surface). The raw placeholder must never reach
389+
# the browser, or window.APP_CONFIG would be a syntax error.
390+
from scanner import VERSION
391+
url = f"http://127.0.0.1:{self.port}/"
392+
with urllib.request.urlopen(url) as resp:
393+
body = resp.read().decode("utf-8")
394+
self.assertNotIn("__APP_CONFIG_JSON__", body)
395+
self.assertIn("window.APP_CONFIG =", body)
396+
self.assertIn(VERSION, body)
397+
# The HTTP test server keeps the default surface ("web").
398+
self.assertIn('"surface": "web"', body)
399+
386400

387401
class TestHTMLTemplate(unittest.TestCase):
388402
def test_template_is_valid_html(self):
@@ -426,6 +440,22 @@ def test_today_range_button_present(self):
426440
# Bounds case: today returns start === end === today's ISO date
427441
self.assertIn("range === 'today'", HTML_TEMPLATE)
428442

443+
def test_app_config_placeholder_present(self):
444+
"""The head carries the server-substituted config placeholder and the
445+
footer carries the element + JS the version/update feature drives."""
446+
self.assertIn("__APP_CONFIG_JSON__", HTML_TEMPLATE)
447+
self.assertIn("window.APP_CONFIG", HTML_TEMPLATE)
448+
self.assertIn('id="footer-meta"', HTML_TEMPLATE)
449+
self.assertIn("function initFooterMeta(", HTML_TEMPLATE)
450+
self.assertIn("function checkForUpdate(", HTML_TEMPLATE)
451+
452+
def test_update_check_is_surface_gated(self):
453+
"""The GitHub update check and the extension promo are web-only: both
454+
guard on surface !== 'vscode' so the embedded panel stays quiet."""
455+
self.assertIn("APP_CONFIG.surface !== 'vscode'", HTML_TEMPLATE)
456+
# The update check hits GitHub's public releases API, not any usage data.
457+
self.assertIn("api.github.com/repos/phuryn/claude-usage/releases/latest", HTML_TEMPLATE)
458+
429459

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

tests/test_version.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Tests for the single source-of-truth version (scanner.VERSION).
2+
3+
The runtime version lives in scanner.py because the canonical CHANGELOG.md is
4+
NOT bundled into the .vsix — only the three Python files are. These tests keep
5+
the three places a version is written from drifting: scanner.VERSION, the top
6+
CHANGELOG heading, and vscode-extension/package.json. If you bump one, bump all.
7+
"""
8+
9+
import json
10+
import re
11+
import subprocess
12+
import sys
13+
import unittest
14+
from pathlib import Path
15+
16+
from scanner import VERSION
17+
18+
REPO_ROOT = Path(__file__).resolve().parent.parent
19+
20+
21+
def _changelog_top_version():
22+
"""Return the version from the first '## vX.Y.Z' heading in CHANGELOG.md."""
23+
changelog = (REPO_ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
24+
for line in changelog.splitlines():
25+
m = re.match(r"^## v(\d+\.\d+\.\d+)(\s|$)", line)
26+
if m:
27+
return m.group(1)
28+
return None
29+
30+
31+
def _package_json_version():
32+
pkg = json.loads(
33+
(REPO_ROOT / "vscode-extension" / "package.json").read_text(encoding="utf-8")
34+
)
35+
return pkg["version"]
36+
37+
38+
class TestVersion(unittest.TestCase):
39+
def test_version_is_strict_semver(self):
40+
self.assertRegex(VERSION, r"^\d+\.\d+\.\d+$")
41+
42+
def test_cli_reexports_same_version(self):
43+
# cli.py imports VERSION from scanner so `cli.py --version` reports it.
44+
import cli
45+
self.assertEqual(cli.VERSION, VERSION)
46+
47+
def test_matches_changelog_heading(self):
48+
top = _changelog_top_version()
49+
self.assertEqual(
50+
top, VERSION,
51+
f"scanner.VERSION ({VERSION}) != top CHANGELOG heading ({top}). "
52+
"Bump both in lockstep.",
53+
)
54+
55+
def test_matches_package_json(self):
56+
pkg = _package_json_version()
57+
self.assertEqual(
58+
pkg, VERSION,
59+
f"scanner.VERSION ({VERSION}) != vscode-extension/package.json "
60+
f"version ({pkg}). The .vsix asset filename embeds the package "
61+
"version, so they must match.",
62+
)
63+
64+
def test_cli_version_flag(self):
65+
"""`python cli.py --version` prints the version and exits 0."""
66+
result = subprocess.run(
67+
[sys.executable, "cli.py", "--version"],
68+
cwd=REPO_ROOT, capture_output=True, text=True,
69+
)
70+
self.assertEqual(result.returncode, 0)
71+
self.assertEqual(result.stdout.strip(), VERSION)
72+
73+
74+
if __name__ == "__main__":
75+
unittest.main()

vscode-extension/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "claude-usage-phuryn",
33
"displayName": "Claude Code Usage by Paweł Huryn",
44
"description": "Embed your Claude Code usage dashboard (token counts, costs, sessions, projects) directly inside VS Code. Reads local JSONL transcripts, no API calls.",
5-
"version": "1.2.6",
5+
"version": "1.3.0",
66
"publisher": "PawelHuryn",
77
"author": {
88
"name": "Paweł Huryn",

vscode-extension/src/extension.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,10 @@ class Extension {
113113
const probeUrl = `http://${host}:${port}/api/data`;
114114
// --no-browser: the dashboard is embedded in the webview, so the bundled
115115
// cli.py must not also pop a system browser (it does by default for CLI users).
116-
const spawnArgs = dashboardSpawnArgs(mode, python, ["--no-browser", "--host", host, "--port", String(port)]);
116+
// --surface vscode: tells the dashboard it's embedded so its footer shows the
117+
// version only — no "get the extension" promo (we're already in it) and no
118+
// GitHub update check (VS Code updates the extension itself).
119+
const spawnArgs = dashboardSpawnArgs(mode, python, ["--no-browser", "--host", host, "--port", String(port), "--surface", "vscode"]);
117120
if (!spawnArgs) {
118121
const msg = "Could not assemble a valid command to spawn the dashboard.";
119122
this.output.appendLine(msg);

0 commit comments

Comments
 (0)