usagelens 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aayush Jain
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # usagelens
2
+
3
+ A local dashboard for your AI coding usage — unified across **Claude Code**, **Codex CLI**, and **Cursor**. It reads the usage data those tools already store on your machine, and serves an interactive dashboard on `localhost`. Read-only, offline, zero runtime dependencies.
4
+
5
+ ```bash
6
+ npx usagelens
7
+ ```
8
+
9
+ That's it — it scans your local data, starts a server on <http://localhost:4319>, and opens your browser.
10
+
11
+ ## What you get
12
+
13
+ - **Tokens, est. cost, sessions, cache hit-rate, streak** — with deltas vs the prior period
14
+ - **Daily activity** bar chart and a **when-you-work** hour × weekday heatmap (hover for details)
15
+ - **Tool breakdown** (Claude / Codex / Cursor) and **model mix** with per-model estimated cost
16
+ - **Top projects** — which repos consume the most AI
17
+ - **Session shape** (quick / standard / deep / marathon) with averages
18
+ - **Cache effectiveness** trend and estimated savings
19
+ - **Code authorship** (from Cursor) — AI-authored vs human lines over time
20
+
21
+ ## Options
22
+
23
+ ```bash
24
+ usagelens # start on :4319 and open the browser
25
+ usagelens --port 8080 # custom port
26
+ usagelens --no-open # don't auto-open the browser
27
+ ```
28
+
29
+ ## Data sources
30
+
31
+ Read-only, from your machine:
32
+
33
+ - `~/.claude/projects/**/*.jsonl` — Claude Code (tokens, models, projects, cache)
34
+ - `~/.codex/sessions/**` — Codex CLI (per-turn token usage)
35
+ - `~/.cursor/ai-tracking/ai-code-tracking.db` — Cursor (AI vs human code authorship)
36
+
37
+ Tools detected without usable local usage data (Gemini, GitHub Copilot, Ollama) are shown as detected-without-metrics rather than fabricated numbers.
38
+
39
+ ## Notes
40
+
41
+ - **Cost is estimated** — list price × tokens ("API-equivalent value"), not a bill. If you're on a subscription, treat it as a proxy for usage.
42
+ - Nothing is written to your tool directories, and nothing leaves your machine.
43
+ - Requires **Node ≥ 22.5** (uses the built-in `node:sqlite`).
44
+
45
+ ## License
46
+
47
+ MIT
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "usagelens",
3
+ "version": "0.1.0",
4
+ "description": "Local dashboard for your AI coding usage across Claude Code, Codex CLI, and Cursor. Reads your machine's local data and serves an interactive dashboard on localhost. Read-only, offline, zero runtime dependencies.",
5
+ "type": "module",
6
+ "bin": {
7
+ "usagelens": "src/server.mjs"
8
+ },
9
+ "engines": {
10
+ "node": ">=22.5.0"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "start": "node src/server.mjs"
19
+ },
20
+ "keywords": [
21
+ "ai",
22
+ "usage",
23
+ "dashboard",
24
+ "claude",
25
+ "claude-code",
26
+ "codex",
27
+ "cursor",
28
+ "tokens",
29
+ "cost",
30
+ "analytics",
31
+ "cli"
32
+ ],
33
+ "author": "Aayush Jain",
34
+ "license": "MIT"
35
+ }
@@ -0,0 +1,137 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { basename, join } from 'node:path';
4
+ import { costOf } from './pricing.mjs';
5
+
6
+ const HOME = homedir();
7
+
8
+ function walk(dir) {
9
+ if (!existsSync(dir)) return [];
10
+ const out = [];
11
+ for (const name of readdirSync(dir)) {
12
+ let p = join(dir, name), st;
13
+ try { st = statSync(p); } catch { continue; }
14
+ if (st.isDirectory()) out.push(...walk(p));
15
+ else if (name.endsWith('.jsonl')) out.push(p);
16
+ }
17
+ return out;
18
+ }
19
+
20
+ // ---------- Claude ----------
21
+ export function loadClaude() {
22
+ const root = join(HOME, '.claude', 'projects');
23
+ if (!existsSync(root)) return { events: [], commits: [], status: { tool: 'claude', available: false, note: 'no ~/.claude/projects' } };
24
+ const events = [];
25
+ for (const f of walk(root)) {
26
+ let text;
27
+ try { text = readFileSync(f, 'utf8'); } catch { continue; }
28
+ for (const line of text.split('\n')) {
29
+ if (!line.trim()) continue;
30
+ let row;
31
+ try { row = JSON.parse(line); } catch { continue; }
32
+ if (row.type !== 'assistant') continue;
33
+ const m = row.message, u = m?.usage;
34
+ if (!u || !m?.model) continue;
35
+ const cwd = row.cwd ?? 'unknown';
36
+ const t = {
37
+ inputTokens: u.input_tokens ?? 0,
38
+ outputTokens: u.output_tokens ?? 0,
39
+ cacheReadTokens: u.cache_read_input_tokens ?? 0,
40
+ cacheCreateTokens: u.cache_creation_input_tokens ?? 0,
41
+ };
42
+ events.push({
43
+ tool: 'claude', model: m.model, project: basename(cwd), projectPath: cwd,
44
+ gitBranch: row.gitBranch || undefined, timestamp: row.timestamp,
45
+ sessionId: row.sessionId ?? basename(f, '.jsonl'), ...t, costUsd: costOf(m.model, t),
46
+ });
47
+ }
48
+ }
49
+ const last = events.reduce((a, e) => (!a || e.timestamp > a ? e.timestamp : a), undefined);
50
+ return { events, commits: [], status: { tool: 'claude', available: true, lastActivity: last, count: events.length } };
51
+ }
52
+
53
+ // ---------- Codex ----------
54
+ export function loadCodex() {
55
+ const base = join(HOME, '.codex');
56
+ const files = ['sessions', 'archived_sessions'].flatMap((r) => walk(join(base, r)));
57
+ if (files.length === 0) return { events: [], commits: [], status: { tool: 'codex', available: false, note: 'no ~/.codex sessions' } };
58
+ const events = [];
59
+ for (const f of files) {
60
+ let text;
61
+ try { text = readFileSync(f, 'utf8'); } catch { continue; }
62
+ let model = 'gpt-5', cwd = 'unknown', sessionId = basename(f, '.jsonl');
63
+ for (const line of text.split('\n')) {
64
+ if (!line.trim()) continue;
65
+ let row;
66
+ try { row = JSON.parse(line); } catch { continue; }
67
+ const p = row.payload ?? row;
68
+ // Codex sessions nest the real event type inside event_msg/response_item payloads.
69
+ const kind = (row.type === 'event_msg' || row.type === 'response_item') ? (p.type ?? row.type) : row.type;
70
+ if (kind === 'session_meta') { cwd = p.cwd ?? cwd; sessionId = p.id ?? sessionId; }
71
+ else if (kind === 'turn_context') { if (p.model) model = p.model; if (p.cwd) cwd = p.cwd; }
72
+ else if (kind === 'token_count') {
73
+ const last = p.info?.last_token_usage;
74
+ if (!last) continue;
75
+ const cached = last.cached_input_tokens ?? 0;
76
+ const t = {
77
+ inputTokens: Math.max(0, (last.input_tokens ?? 0) - cached),
78
+ outputTokens: last.output_tokens ?? 0,
79
+ cacheReadTokens: cached, cacheCreateTokens: 0,
80
+ };
81
+ if (t.inputTokens + t.outputTokens + t.cacheReadTokens === 0) continue;
82
+ events.push({
83
+ tool: 'codex', model, project: basename(cwd), projectPath: cwd,
84
+ timestamp: row.timestamp, sessionId, ...t, costUsd: costOf(model, t),
85
+ });
86
+ }
87
+ }
88
+ }
89
+ const last = events.reduce((a, e) => (!a || e.timestamp > a ? e.timestamp : a), undefined);
90
+ return { events, commits: [], status: { tool: 'codex', available: true, lastActivity: last, count: events.length } };
91
+ }
92
+
93
+ // ---------- Cursor ----------
94
+ export function loadCursor() {
95
+ const path = join(HOME, '.cursor', 'ai-tracking', 'ai-code-tracking.db');
96
+ if (!existsSync(path)) return { events: [], commits: [], status: { tool: 'cursor', available: false, note: 'no cursor ai-tracking db' } };
97
+ const commits = [];
98
+ try {
99
+ const { DatabaseSync } = require('node:sqlite');
100
+ const db = new DatabaseSync(path, { readOnly: true });
101
+ const rows = db.prepare(
102
+ 'SELECT commitHash, branchName, scoredAt, linesAdded, v1AiPercentage, v2AiPercentage FROM scored_commits'
103
+ ).all();
104
+ for (const r of rows) {
105
+ const total = r.linesAdded ?? 0;
106
+ const aiPct = Number(r.v2AiPercentage ?? r.v1AiPercentage ?? 0) || 0;
107
+ const aiLines = Math.round((total * aiPct) / 100);
108
+ commits.push({
109
+ tool: 'cursor', timestamp: new Date(Number(r.scoredAt)).toISOString(),
110
+ commitHash: r.commitHash, branchName: r.branchName ?? 'unknown',
111
+ totalLines: total, aiLines, humanLines: Math.max(0, total - aiLines), aiPct,
112
+ });
113
+ }
114
+ db.close();
115
+ } catch (e) {
116
+ return { events: [], commits: [], status: { tool: 'cursor', available: false, note: 'cursor db unreadable: ' + e.message } };
117
+ }
118
+ const last = commits.reduce((a, c) => (!a || c.timestamp > a ? c.timestamp : a), undefined);
119
+ return { events: [], commits, status: { tool: 'cursor', available: true, lastActivity: last, count: commits.length } };
120
+ }
121
+
122
+ // require shim for ESM (node:sqlite import)
123
+ import { createRequire } from 'node:module';
124
+ const require = createRequire(import.meta.url);
125
+
126
+ export function loadAll() {
127
+ const events = [], commits = [], statuses = [];
128
+ for (const fn of [loadClaude, loadCodex, loadCursor]) {
129
+ try { const r = fn(); events.push(...r.events); commits.push(...r.commits); statuses.push(r.status); }
130
+ catch (e) { statuses.push({ tool: fn.name.replace('load', '').toLowerCase(), available: false, note: e.message }); }
131
+ }
132
+ // detect-only tools with no parseable usage
133
+ for (const [tool, rel] of [['gemini', '.gemini'], ['copilot', '.copilot'], ['ollama', '.ollama']]) {
134
+ if (existsSync(join(HOME, rel))) statuses.push({ tool, available: false, note: 'detected, no usage metrics exposed' });
135
+ }
136
+ return { events, commits, statuses };
137
+ }
@@ -0,0 +1,159 @@
1
+ import { savingsOf } from './pricing.mjs';
2
+
3
+ const MS_DAY = 86_400_000;
4
+ const DAYS = { '7d': 7, '30d': 30, '90d': 90 };
5
+
6
+ export function rangeWindow(range, now) { return { start: new Date(now.getTime() - DAYS[range] * MS_DAY), end: now }; }
7
+ export function priorWindow(range, now) { const c = rangeWindow(range, now); return { start: new Date(c.start.getTime() - DAYS[range] * MS_DAY), end: c.start }; }
8
+ const inWin = (iso, w) => { const t = new Date(iso).getTime(); return t >= w.start.getTime() && t <= w.end.getTime(); };
9
+
10
+ export function localDayKey(iso) {
11
+ const d = typeof iso === 'string' ? new Date(iso) : iso;
12
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
13
+ }
14
+ function eachDay(start, end) {
15
+ const out = [], cur = new Date(start.getFullYear(), start.getMonth(), start.getDate());
16
+ const last = new Date(end.getFullYear(), end.getMonth(), end.getDate());
17
+ while (cur <= last) { out.push(localDayKey(cur)); cur.setDate(cur.getDate() + 1); }
18
+ return out;
19
+ }
20
+ const tokensOf = (e) => e.inputTokens + e.outputTokens + e.cacheReadTokens + e.cacheCreateTokens;
21
+ const pct = (cur, prior) => (prior === 0 ? (cur === 0 ? 0 : 100) : ((cur - prior) / prior) * 100);
22
+
23
+ function streaks(days) {
24
+ if (days.length === 0) return { current: 0, longest: 0 };
25
+ const s = [...new Set(days)].sort();
26
+ let longest = 1, run = 1;
27
+ for (let i = 1; i < s.length; i++) {
28
+ const gap = Math.round((new Date(s[i] + 'T00:00:00') - new Date(s[i - 1] + 'T00:00:00')) / MS_DAY);
29
+ run = gap === 1 ? run + 1 : 1;
30
+ longest = Math.max(longest, run);
31
+ }
32
+ return { current: run, longest };
33
+ }
34
+
35
+ function summarize(events, prior) {
36
+ const sum = (arr, f) => arr.reduce((a, e) => a + f(e), 0);
37
+ const byDay = new Map();
38
+ for (const e of events) { const k = localDayKey(e.timestamp); byDay.set(k, (byDay.get(k) ?? 0) + tokensOf(e)); }
39
+ let peakDay = { day: '', tokens: 0 };
40
+ for (const [day, tokens] of byDay) if (tokens > peakDay.tokens) peakDay = { day, tokens };
41
+ const input = sum(events, (e) => e.inputTokens);
42
+ const cacheRead = sum(events, (e) => e.cacheReadTokens);
43
+ const cacheCreate = sum(events, (e) => e.cacheCreateTokens);
44
+ const denom = cacheRead + cacheCreate + input;
45
+ const st = streaks([...byDay.keys()]);
46
+ const curTokens = sum(events, tokensOf), curCost = sum(events, (e) => e.costUsd);
47
+ return {
48
+ totalTokens: curTokens, inputTokens: input, outputTokens: sum(events, (e) => e.outputTokens),
49
+ cacheReadTokens: cacheRead, cacheCreateTokens: cacheCreate, costUsd: curCost,
50
+ sessions: new Set(events.map((e) => e.sessionId)).size, messages: events.length,
51
+ activeDays: byDay.size, currentStreak: st.current, longestStreak: st.longest, peakDay,
52
+ cacheHitRate: denom === 0 ? 0 : cacheRead / denom,
53
+ cacheSavingsUsd: events.reduce((a, e) => a + savingsOf(e.model, e.cacheReadTokens), 0),
54
+ deltas: {
55
+ tokensPct: pct(curTokens, sum(prior, tokensOf)),
56
+ costPct: pct(curCost, sum(prior, (e) => e.costUsd)),
57
+ sessionsAbs: new Set(events.map((e) => e.sessionId)).size - new Set(prior.map((e) => e.sessionId)).size,
58
+ },
59
+ };
60
+ }
61
+
62
+ function group(events, keyFn, cap) {
63
+ const map = new Map(); let total = 0;
64
+ for (const e of events) {
65
+ const k = keyFn(e), t = tokensOf(e); total += t;
66
+ const cur = map.get(k) ?? { tokens: 0, costUsd: 0, sessions: new Set() };
67
+ cur.tokens += t; cur.costUsd += e.costUsd; cur.sessions.add(e.sessionId); map.set(k, cur);
68
+ }
69
+ const slices = [...map.entries()]
70
+ .map(([key, v]) => ({ key, tokens: v.tokens, costUsd: v.costUsd, sessions: v.sessions.size, sharePct: total ? (v.tokens / total) * 100 : 0 }))
71
+ .sort((a, b) => b.tokens - a.tokens);
72
+ return cap ? slices.slice(0, cap) : slices;
73
+ }
74
+
75
+ function dailySeries(events, w) {
76
+ const agg = new Map();
77
+ for (const e of events) {
78
+ const k = localDayKey(e.timestamp);
79
+ const cur = agg.get(k) ?? { tokens: 0, costUsd: 0, read: 0, base: 0 };
80
+ cur.tokens += tokensOf(e); cur.costUsd += e.costUsd;
81
+ cur.read += e.cacheReadTokens; cur.base += e.cacheReadTokens + e.cacheCreateTokens + e.inputTokens;
82
+ agg.set(k, cur);
83
+ }
84
+ return eachDay(w.start, w.end).map((day) => {
85
+ const a = agg.get(day);
86
+ return { day, tokens: a?.tokens ?? 0, costUsd: a?.costUsd ?? 0, cacheHitRate: a && a.base ? a.read / a.base : 0 };
87
+ });
88
+ }
89
+
90
+ function heatmap(events) {
91
+ const cells = Array.from({ length: 7 }, () => Array.from({ length: 24 }, () => ({ count: 0, tokens: 0, cost: 0, sessions: new Set() })));
92
+ for (const e of events) {
93
+ const d = new Date(e.timestamp);
94
+ const c = cells[(d.getDay() + 6) % 7][d.getHours()];
95
+ c.count += 1; c.tokens += tokensOf(e); c.cost += e.costUsd; c.sessions.add(e.sessionId);
96
+ }
97
+ return cells.map((row) => row.map((c) => ({ count: c.count, tokens: c.tokens, cost: c.cost, sessions: c.sessions.size })));
98
+ }
99
+
100
+ const BUCKETS = [{ label: 'Quick', max: 1e4 }, { label: 'Standard', max: 1e5 }, { label: 'Deep', max: 1e6 }, { label: 'Marathon', max: Infinity }];
101
+ function sessionShape(events) {
102
+ const sessions = new Map();
103
+ for (const e of events) {
104
+ const t = new Date(e.timestamp).getTime();
105
+ const cur = sessions.get(e.sessionId) ?? { tokens: 0, msgs: 0, first: t, last: t };
106
+ cur.tokens += tokensOf(e); cur.msgs += 1; cur.first = Math.min(cur.first, t); cur.last = Math.max(cur.last, t);
107
+ sessions.set(e.sessionId, cur);
108
+ }
109
+ const list = [...sessions.values()];
110
+ const counts = BUCKETS.map((b) => ({ label: b.label, count: 0, pct: 0 }));
111
+ for (const s of list) { const i = BUCKETS.findIndex((b) => s.tokens < b.max); counts[i === -1 ? 3 : i].count += 1; }
112
+ for (const c of counts) c.pct = list.length ? (c.count / list.length) * 100 : 0;
113
+ return {
114
+ buckets: counts,
115
+ avgMessages: list.length ? list.reduce((a, s) => a + s.msgs, 0) / list.length : 0,
116
+ avgDurationMs: list.length ? list.reduce((a, s) => a + (s.last - s.first), 0) / list.length : 0,
117
+ medianTokens: median(list.map((s) => s.tokens)),
118
+ };
119
+ }
120
+ function median(arr) { if (!arr.length) return 0; const s = [...arr].sort((a, b) => a - b); const m = Math.floor(s.length / 2); return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; }
121
+
122
+ function isoWeek(iso) {
123
+ const d = new Date(iso), day = (d.getDay() + 6) % 7;
124
+ const thu = new Date(d.getFullYear(), d.getMonth(), d.getDate() - day + 3);
125
+ const firstThu = new Date(thu.getFullYear(), 0, 4);
126
+ const week = 1 + Math.round(((thu - firstThu) / MS_DAY - 3 + ((firstThu.getDay() + 6) % 7)) / 7);
127
+ return `${thu.getFullYear()}-W${String(week).padStart(2, '0')}`;
128
+ }
129
+ function authorship(commits) {
130
+ const weekly = new Map(); let aiLines = 0, humanLines = 0;
131
+ for (const c of commits) {
132
+ aiLines += c.aiLines; humanLines += c.humanLines;
133
+ const wk = isoWeek(c.timestamp);
134
+ const cur = weekly.get(wk) ?? { aiLines: 0, humanLines: 0 };
135
+ cur.aiLines += c.aiLines; cur.humanLines += c.humanLines; weekly.set(wk, cur);
136
+ }
137
+ const total = aiLines + humanLines;
138
+ return {
139
+ totalCommits: commits.length, aiLines, humanLines, aiPct: total ? (aiLines / total) * 100 : 0,
140
+ weekly: [...weekly.entries()].map(([week, v]) => ({ week, ...v, aiPct: v.aiLines + v.humanLines ? (v.aiLines / (v.aiLines + v.humanLines)) * 100 : 0 })).sort((a, b) => a.week.localeCompare(b.week)),
141
+ };
142
+ }
143
+
144
+ export function buildPayload(ds, range, now) {
145
+ const win = rangeWindow(range, now), prior = priorWindow(range, now);
146
+ const cur = ds.events.filter((e) => inWin(e.timestamp, win));
147
+ const prev = ds.events.filter((e) => inWin(e.timestamp, prior));
148
+ const commits = ds.commits.filter((c) => inWin(c.timestamp, win));
149
+ return {
150
+ summary: summarize(cur, prev),
151
+ breakdowns: { byTool: group(cur, (e) => e.tool), byModel: group(cur, (e) => e.model), byProject: group(cur, (e) => e.project, 12) },
152
+ series: { daily: dailySeries(cur, win) },
153
+ heatmap: heatmap(cur),
154
+ sessions: sessionShape(cur),
155
+ authorship: authorship(commits),
156
+ tools: ds.statuses,
157
+ meta: { range, generatedAt: now.toISOString(), lastActivity: cur.reduce((a, e) => (!a || e.timestamp > a ? e.timestamp : a), undefined) },
158
+ };
159
+ }
@@ -0,0 +1,28 @@
1
+ // Per-Mtok USD list prices. ESTIMATED, API-equivalent. Not a bill.
2
+ const TABLE = [
3
+ { m: /opus/i, p: { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 } },
4
+ { m: /sonnet/i, p: { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 } },
5
+ { m: /haiku/i, p: { input: 0.8, output: 4, cacheWrite: 1.0, cacheRead: 0.08 } },
6
+ { m: /fable/i, p: { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 } },
7
+ { m: /gpt-5|codex|o[134]/i, p: { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.125 } },
8
+ ];
9
+ const DEFAULT = { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 };
10
+
11
+ export function priceFor(model) {
12
+ return (TABLE.find((r) => r.m.test(model || ''))?.p) ?? DEFAULT;
13
+ }
14
+
15
+ export function costOf(model, t) {
16
+ const p = priceFor(model);
17
+ return (
18
+ t.inputTokens * p.input +
19
+ t.outputTokens * p.output +
20
+ t.cacheCreateTokens * p.cacheWrite +
21
+ t.cacheReadTokens * p.cacheRead
22
+ ) / 1_000_000;
23
+ }
24
+
25
+ export function savingsOf(model, cacheReadTokens) {
26
+ const p = priceFor(model);
27
+ return (cacheReadTokens * (p.input - p.cacheRead)) / 1_000_000;
28
+ }
@@ -0,0 +1,240 @@
1
+ 'use strict';
2
+ const $ = (s) => document.querySelector(s);
3
+ const el = (t, a = {}, kids = []) => {
4
+ const n = document.createElement(t);
5
+ for (const k in a) { if (k === 'style') n.style.cssText = a[k]; else if (k === 'html') n.innerHTML = a[k]; else n.setAttribute(k, a[k]); }
6
+ for (const c of [].concat(kids)) n.append(c && c.nodeType ? c : document.createTextNode(c ?? ''));
7
+ return n;
8
+ };
9
+ // ---- floating tooltip (shared by every chart) ----
10
+ const tipEl = el('div', { style: 'position:fixed;z-index:80;pointer-events:none;background:#12151d;color:#fff;font-family:var(--mono);font-size:11px;line-height:1.55;padding:9px 11px;border-radius:9px;box-shadow:0 8px 30px rgba(0,0,0,.22);opacity:0;transition:opacity .09s;max-width:260px;white-space:nowrap' });
11
+ document.body.append(tipEl);
12
+ function moveTip(x, y) {
13
+ const pad = 14, r = tipEl.getBoundingClientRect();
14
+ let left = x + pad, top = y + pad;
15
+ if (left + r.width > innerWidth - 8) left = x - r.width - pad;
16
+ if (top + r.height > innerHeight - 8) top = y - r.height - pad;
17
+ tipEl.style.left = Math.max(8, left) + 'px'; tipEl.style.top = Math.max(8, top) + 'px';
18
+ }
19
+ function bindTip(node, htmlFn) {
20
+ node.style.cursor = 'default';
21
+ node.addEventListener('mousemove', (e) => { tipEl.innerHTML = htmlFn(); tipEl.style.opacity = '1'; moveTip(e.clientX, e.clientY); });
22
+ node.addEventListener('mouseleave', () => { tipEl.style.opacity = '0'; });
23
+ return node;
24
+ }
25
+ // escape any data-derived string before it enters an innerHTML/html: template
26
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
27
+ const SERIES = ['var(--c0)', 'var(--c1)', 'var(--c2)', 'var(--c3)', 'var(--c4)', 'var(--c5)'];
28
+ const TOOLCOLOR = { claude: 'var(--c0)', codex: 'var(--c1)', cursor: 'var(--c2)' };
29
+
30
+ const fmtTok = (n) => n >= 1e9 ? (n / 1e9).toFixed(2) + 'B' : n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'K' : String(Math.round(n));
31
+ const fmtUsd = (n) => n >= 1000 ? '$' + (n / 1000).toFixed(1) + 'K' : n >= 1 ? '$' + n.toFixed(2) : '$' + n.toFixed(3);
32
+ const fmtInt = (n) => new Intl.NumberFormat('en-US').format(Math.round(n));
33
+ const fmtPct = (n) => (n >= 0 ? '+' : '') + n.toFixed(n <= -100 || n >= 1000 ? 0 : 1) + '%';
34
+ const ago = (iso) => { if (!iso) return '—'; const d = (Date.now() - new Date(iso)) / 86400000; return d < 1 ? 'today' : d < 2 ? '1d ago' : Math.floor(d) + 'd ago'; };
35
+
36
+ function card(label, val, opts = {}) {
37
+ const kids = [el('div', { class: 'clabel' }, label), el('div', { class: 'cval' }, val)];
38
+ if (opts.delta !== undefined) {
39
+ const up = opts.delta >= 0;
40
+ kids.push(el('div', { class: 'delta ' + (up ? 'up' : 'down') }, (up ? '▲ ' : '▼ ') + fmtPct(opts.delta)));
41
+ }
42
+ if (opts.sub) kids.push(el('div', { class: 'csub' }, opts.sub));
43
+ return el('div', { class: 'card' }, kids);
44
+ }
45
+
46
+ function barRow(label, right, pctWidth, color) {
47
+ return el('div', { class: 'barRow' }, [
48
+ el('div', { class: 'top' }, [el('span', {}, label), el('span', { class: 'r' }, right)]),
49
+ el('div', { class: 'track' }, [el('div', { class: 'fill', style: `width:${Math.max(1, pctWidth)}%;background:${color}` })]),
50
+ ]);
51
+ }
52
+
53
+ // SVG bar chart (daily tokens)
54
+ function barChart(data, h = 170) {
55
+ const w = 1000, pad = 4, max = Math.max(1, ...data.map((d) => d.tokens));
56
+ const bw = w / data.length;
57
+ const svg = mk('svg', { viewBox: `0 0 ${w} ${h}`, preserveAspectRatio: 'none' });
58
+ svg.style.height = h + 'px'; svg.style.width = '100%';
59
+ data.forEach((d, i) => {
60
+ const bh = (d.tokens / max) * (h - 22);
61
+ const x = i * bw, y = (h - 20) - bh;
62
+ const color = d.tokens > 0 ? 'var(--accent)' : 'var(--line)';
63
+ const r = mk('rect', { x: x + pad / 2, y: d.tokens > 0 ? y : h - 21, width: Math.max(1, bw - pad), height: d.tokens > 0 ? bh : 1, rx: 2, fill: color });
64
+ const wd = new Date(d.day + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'short' });
65
+ bindTip(r, () => `<b>${wd} ${d.day}</b><br>${fmtInt(d.tokens)} tokens (${fmtTok(d.tokens)})<br>${fmtUsd(d.costUsd)} est · ${(d.cacheHitRate * 100).toFixed(0)}% cache`);
66
+ svg.append(r);
67
+ });
68
+ data.forEach((d, i) => {
69
+ if (i % Math.ceil(data.length / 6) === 0 || i === data.length - 1)
70
+ svg.append(mk('text', { class: 'tip', x: i * bw + bw / 2, y: h - 6, 'text-anchor': 'middle' }, d.day.slice(5)));
71
+ });
72
+ return svg;
73
+ }
74
+
75
+ // SVG donut
76
+ function donut(slices) {
77
+ const size = 132, r = 52, sw = 20, cx = size / 2, cy = size / 2, C = 2 * Math.PI * r;
78
+ const total = slices.reduce((a, s) => a + s.tokens, 0) || 1;
79
+ let off = 0;
80
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
81
+ svg.setAttribute('viewBox', `0 0 ${size} ${size}`); svg.style.width = size + 'px'; svg.style.height = size + 'px';
82
+ svg.append(mk('circle', { cx, cy, r, fill: 'none', stroke: 'var(--line)', 'stroke-width': sw }));
83
+ slices.forEach((s, i) => {
84
+ const len = (s.tokens / total) * C;
85
+ const c = mk('circle', { cx, cy, r, fill: 'none', stroke: SERIES[i % 6], 'stroke-width': sw,
86
+ 'stroke-dasharray': `${len} ${C - len}`, 'stroke-dashoffset': -off, transform: `rotate(-90 ${cx} ${cy})` });
87
+ bindTip(c, () => `<b>${esc(s.key)}</b><br>${s.sharePct.toFixed(1)}% of tokens · ${fmtTok(s.tokens)}<br>${fmtUsd(s.costUsd)} est${s.sessions != null ? ` · ${s.sessions} sess` : ''}`);
88
+ svg.append(c); off += len;
89
+ });
90
+ svg.append(mk('text', { x: cx, y: cy - 2, 'text-anchor': 'middle', 'font-size': 22, 'font-weight': 700, fill: 'var(--ink)' }, String(slices.length)));
91
+ svg.append(mk('text', { x: cx, y: cy + 14, 'text-anchor': 'middle', 'font-size': 9, fill: 'var(--sub)', 'letter-spacing': 1 }, 'MODELS'));
92
+ return svg;
93
+ }
94
+ function mk(t, a, txt) { const n = document.createElementNS('http://www.w3.org/2000/svg', t); for (const k in a) n.setAttribute(k, a[k]); if (txt != null) n.textContent = txt; return n; }
95
+
96
+ // area sparkline for cache hit rate
97
+ function area(data, h = 70) {
98
+ const w = 600, max = 100, pts = data.map((d, i) => [i / Math.max(1, data.length - 1) * w, h - (d.cacheHitRate * 100 / max) * (h - 8) - 4]);
99
+ const line = pts.map((p, i) => (i ? 'L' : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' ');
100
+ const fill = line + ` L ${w} ${h} L 0 ${h} Z`;
101
+ const svg = mk('svg', { viewBox: `0 0 ${w} ${h}`, preserveAspectRatio: 'none' }); svg.style.height = h + 'px'; svg.style.width = '100%';
102
+ svg.append(mk('path', { d: fill, fill: 'var(--accent)', 'fill-opacity': .12 }));
103
+ svg.append(mk('path', { d: line, fill: 'none', stroke: 'var(--accent)', 'stroke-width': 2 }));
104
+ return svg;
105
+ }
106
+
107
+ // stacked authorship bars
108
+ function stacked(weekly, h = 190) {
109
+ const w = 1000, max = Math.max(1, ...weekly.map((d) => d.aiLines + d.humanLines)), bw = w / weekly.length;
110
+ const svg = mk('svg', { viewBox: `0 0 ${w} ${h}`, preserveAspectRatio: 'none' }); svg.style.height = h + 'px';
111
+ weekly.forEach((d, i) => {
112
+ const total = d.aiLines + d.humanLines, x = i * bw + 3, bwi = Math.max(1, bw - 6);
113
+ const aih = (d.aiLines / max) * (h - 24), hh = (d.humanLines / max) * (h - 24);
114
+ const aiRect = mk('rect', { x, y: (h - 18) - aih, width: bwi, height: aih, fill: 'var(--accent)', rx: 1 });
115
+ const huRect = mk('rect', { x, y: (h - 18) - aih - hh, width: bwi, height: hh, fill: 'var(--line)', rx: 1 });
116
+ const tip = () => `<b>${d.week}</b><br>${d.aiPct.toFixed(0)}% AI-authored<br>${fmtInt(d.aiLines)} AI · ${fmtInt(d.humanLines)} human lines`;
117
+ bindTip(aiRect, tip); bindTip(huRect, tip);
118
+ svg.append(huRect); svg.append(aiRect);
119
+ if (i % Math.ceil(weekly.length / 8) === 0) svg.append(mk('text', { class: 'tip', x: x + bwi / 2, y: h - 4, 'text-anchor': 'middle' }, d.week.slice(5)));
120
+ });
121
+ return svg;
122
+ }
123
+
124
+ function heatCell(v, max) {
125
+ const op = v === 0 ? 0.55 : 0.2 + 0.8 * (v / max);
126
+ const bg = v === 0 ? 'var(--line)' : 'var(--accent)';
127
+ const c = el('div', { class: 'cell', style: `background:${bg};opacity:${op}` });
128
+ return c;
129
+ }
130
+
131
+ function panel(titleText, meta, body) {
132
+ const head = el('div', { class: 'panelHead' }, [el('div', { class: 't' }, titleText), meta ? el('div', { class: 'm' }, meta) : '']);
133
+ return el('div', { class: 'card' }, [head, body]);
134
+ }
135
+
136
+ function render(d) {
137
+ const app = $('#app'); app.innerHTML = '';
138
+ const s = d.summary, rng = d.meta.range;
139
+ $('#eyebrow').textContent = `ANALYTICS · LAST ${rng.toUpperCase()}`;
140
+
141
+ // hero
142
+ const hero = el('div', {}, [
143
+ el('h1', { class: 'hero', html: `You wrote <b>${fmtTok(s.totalTokens)}</b> tokens across <b>${fmtInt(s.sessions)}</b> sessions.` }),
144
+ el('p', { class: 'heroSub', html:
145
+ `<b>${s.activeDays}</b> active days · last activity <b>${ago(d.meta.lastActivity)}</b> · est. <b>${fmtUsd(s.costUsd)}</b> · peak day <b>${s.peakDay.day ? fmtTok(s.peakDay.tokens) : '—'}</b>` }),
146
+ ]);
147
+ app.append(hero);
148
+
149
+ // KPIs
150
+ const kpis = el('div', { class: 'grid kpis' }, [
151
+ card('TOKENS', fmtTok(s.totalTokens), { delta: s.deltas.tokensPct, sub: `${fmtInt(s.messages)} msgs` }),
152
+ card('COST (EST.)', fmtUsd(s.costUsd), { delta: s.deltas.costPct, sub: 'API-equivalent' }),
153
+ card('SESSIONS', fmtInt(s.sessions), { sub: `${s.deltas.sessionsAbs >= 0 ? '+' : ''}${s.deltas.sessionsAbs} vs prior` }),
154
+ card('CACHE HIT', (s.cacheHitRate * 100).toFixed(0) + '%', { sub: `saved ${fmtUsd(s.cacheSavingsUsd)}` }),
155
+ card('STREAK', s.currentStreak + 'd', { sub: `longest ${s.longestStreak}d` }),
156
+ ]);
157
+ app.append(kpis);
158
+
159
+ // activity
160
+ app.append(el('div', { class: 'grid' }, [panel('DAILY ACTIVITY · TOKENS', `peak ${s.peakDay.day || '—'}`, barChart(d.series.daily))]));
161
+
162
+ // tool + model
163
+ const maxTool = d.breakdowns.byTool[0]?.tokens || 1;
164
+ const toolBody = el('div', {}, d.breakdowns.byTool.map((t) =>
165
+ barRow(t.key, `${fmtTok(t.tokens)} · ${fmtUsd(t.costUsd)} · ${t.sharePct.toFixed(0)}%`, (t.tokens / maxTool) * 100, TOOLCOLOR[t.key] || 'var(--accent)')));
166
+ const modelLegend = el('div', { class: 'legend' }, d.breakdowns.byModel.slice(0, 6).map((m, i) =>
167
+ el('div', { class: 'li' }, [
168
+ el('span', { html: `<span class="dot" style="background:${SERIES[i % 6]}"></span>${esc(m.key)}` }),
169
+ el('span', { class: 'num', style: 'color:var(--sub)' }, `${m.sharePct.toFixed(1)}% · ${fmtUsd(m.costUsd)}`),
170
+ ])));
171
+ const modelBody = el('div', { style: 'display:flex;gap:18px;align-items:center' }, [donut(d.breakdowns.byModel.slice(0, 6)), el('div', { style: 'flex:1' }, [modelLegend])]);
172
+ app.append(el('div', { class: 'grid two' }, [panel('TOOL BREAKDOWN', 'tokens · cost · share', toolBody), panel('MODEL MIX', `${d.breakdowns.byModel.length} models`, modelBody)]));
173
+
174
+ // projects + heatmap
175
+ const maxProj = d.breakdowns.byProject[0]?.tokens || 1;
176
+ const projBody = el('div', {}, d.breakdowns.byProject.map((p) =>
177
+ barRow(p.key, `${fmtTok(p.tokens)} · ${fmtUsd(p.costUsd)}`, (p.tokens / maxProj) * 100, 'var(--accent)')));
178
+ const dayName = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
179
+ const days = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'];
180
+ const maxHeat = Math.max(1, ...d.heatmap.flat().map((c) => c.count));
181
+ const hr = (h) => String(h).padStart(2, '0');
182
+ const heatGrid = el('div', { class: 'heat' });
183
+ d.heatmap.forEach((rowv, r) => {
184
+ heatGrid.append(el('div', { class: 'dl' }, days[r]));
185
+ rowv.forEach((cell, h) => {
186
+ const c = heatCell(cell.count, maxHeat);
187
+ if (cell.count > 0) bindTip(c, () =>
188
+ `<b>${dayName[r]} ${hr(h)}:00–${hr((h + 1) % 24)}:00</b><br>${fmtInt(cell.count)} msgs · ${cell.sessions} session${cell.sessions === 1 ? '' : 's'}<br>${fmtTok(cell.tokens)} tokens · ${fmtUsd(cell.cost)} est`);
189
+ heatGrid.append(c);
190
+ });
191
+ });
192
+ const hoursRow = el('div', { class: 'heatHours' }, [el('span', {}, '')].concat([0, 3, 6, 9, 12, 15, 18, 21].flatMap((hh) => { const arr = [el('span', { style: 'grid-column:span 3' }, String(hh).padStart(2, '0'))]; return arr; })));
193
+ app.append(el('div', { class: 'grid two' }, [panel('TOP PROJECTS', 'by tokens', projBody), panel('WHEN · BY HOUR & WEEKDAY', 'local time', el('div', {}, [heatGrid, hoursRow]))]));
194
+
195
+ // session shape + cache
196
+ const shBody = el('div', {}, [
197
+ ...d.sessions.buckets.map((b) => barRow(b.label, `${b.count} (${b.pct.toFixed(0)}%)`, b.pct, 'var(--accent)')),
198
+ el('div', { class: 'statPair' }, [
199
+ el('div', {}, [el('div', { class: 'k' }, 'AVG MSGS'), el('div', { class: 'v' }, fmtInt(d.sessions.avgMessages))]),
200
+ el('div', {}, [el('div', { class: 'k' }, 'AVG DURATION'), el('div', { class: 'v' }, (d.sessions.avgDurationMs / 3600000).toFixed(1) + 'h')]),
201
+ el('div', {}, [el('div', { class: 'k' }, 'MEDIAN TOKENS'), el('div', { class: 'v' }, fmtTok(d.sessions.medianTokens))]),
202
+ ]),
203
+ ]);
204
+ const cacheBody = el('div', {}, [
205
+ el('div', { style: 'font-size:34px;font-weight:700;color:var(--accent);font-variant-numeric:tabular-nums' }, (s.cacheHitRate * 100).toFixed(0) + '%'),
206
+ area(d.series.daily),
207
+ el('div', { class: 'csub' }, `Tokens served from cache: ${fmtTok(s.cacheReadTokens)} · est. saved ${fmtUsd(s.cacheSavingsUsd)}`),
208
+ ]);
209
+ app.append(el('div', { class: 'grid two' }, [panel('SESSION SHAPE', 'by token size', shBody), panel('CACHE EFFECTIVENESS', 'daily hit rate', cacheBody)]));
210
+
211
+ // authorship
212
+ let authBody;
213
+ if (d.authorship.totalCommits === 0) authBody = el('div', { class: 'csub' }, 'No Cursor commit data in this range.');
214
+ else authBody = el('div', {}, [
215
+ el('div', { class: 'csub', style: 'margin-bottom:8px' }, `AI wrote ${d.authorship.aiPct.toFixed(0)}% of ${fmtInt(d.authorship.aiLines + d.authorship.humanLines)} lines across ${fmtInt(d.authorship.totalCommits)} commits · AI ▮ Human ▯`),
216
+ stacked(d.authorship.weekly),
217
+ ]);
218
+ app.append(el('div', { class: 'grid' }, [panel('CODE AUTHORSHIP · CURSOR', 'weekly AI vs human lines', authBody)]));
219
+
220
+ // tool chips
221
+ const chips = el('div', { class: 'tools' }, d.tools.map((t) =>
222
+ el('div', { class: 'chip ' + (t.available ? 'on' : ''), html: `<span class="d" style="background:${t.available ? 'var(--good)' : 'var(--line)'};width:8px;height:8px;border-radius:50%;display:inline-block"></span><b>${esc(t.tool)}</b> ${t.available ? (t.count != null ? fmtInt(t.count) + ' records' : 'active') : esc(t.note || 'no data')}` })));
223
+ app.append(el('div', {}, [el('div', { class: 'clabel', style: 'margin-top:8px' }, 'TOOLS DETECTED'), chips]));
224
+ app.append(el('div', { class: 'foot' }, `Generated ${new Date(d.meta.generatedAt).toLocaleString()} · read-only from ~/.claude, ~/.codex, ~/.cursor · cost is estimated API-equivalent, not a bill`));
225
+ }
226
+
227
+ let range = '30d';
228
+ async function load() {
229
+ try {
230
+ const r = await fetch('/api/dashboard?range=' + range);
231
+ render(await r.json());
232
+ } catch (e) { $('#app').innerHTML = '<div class="loading">Failed: ' + e.message + '</div>'; }
233
+ }
234
+ $('#toggle').addEventListener('click', (e) => {
235
+ const b = e.target.closest('button'); if (!b) return;
236
+ range = b.dataset.r;
237
+ [...$('#toggle').children].forEach((c) => c.classList.toggle('on', c === b));
238
+ $('#app').innerHTML = '<div class="loading">Loading…</div>'; load();
239
+ });
240
+ load();
@@ -0,0 +1,84 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>usagelens — My AI Usage</title>
7
+ <style>
8
+ :root{
9
+ --bg:#f5f6f8; --panel:#fff; --ink:#191d28; --sub:#6b7280; --line:#e7e9ee;
10
+ --accent:#5b5bd6; --accent2:#8b5cf6; --good:#16a34a; --warn:#dc2626;
11
+ --c0:#5b5bd6; --c1:#16a34a; --c2:#f59e0b; --c3:#e5484d; --c4:#0ea5e9; --c5:#8b5cf6;
12
+ --mono:ui-monospace,SFMono-Regular,Menlo,monospace;
13
+ }
14
+ *{box-sizing:border-box}
15
+ body{margin:0;background:var(--bg);color:var(--ink);
16
+ font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;-webkit-font-smoothing:antialiased}
17
+ .wrap{max-width:1240px;margin:0 auto;padding:28px 28px 80px}
18
+ .row{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap}
19
+ .eyebrow{font-size:11px;letter-spacing:1.5px;color:var(--sub);font-weight:700;font-family:var(--mono)}
20
+ h1.hero{font-family:Georgia,"Times New Roman",serif;font-weight:500;font-size:clamp(26px,3.4vw,42px);
21
+ line-height:1.15;margin:14px 0 6px}
22
+ h1.hero b{color:var(--accent);font-weight:600;font-variant-numeric:tabular-nums}
23
+ .heroSub{color:var(--sub);font-size:14px;margin:0}
24
+ .heroSub b{color:var(--ink);font-weight:600}
25
+ .toggle{display:inline-flex;gap:2px;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:3px}
26
+ .toggle button{border:0;background:transparent;color:var(--sub);font-weight:600;font-size:13px;
27
+ padding:6px 14px;border-radius:7px;cursor:pointer;font-family:var(--mono)}
28
+ .toggle button.on{background:var(--accent);color:#fff}
29
+ .grid{display:grid;gap:14px;margin-top:14px}
30
+ .kpis{grid-template-columns:repeat(5,1fr)}
31
+ .two{grid-template-columns:1fr 1fr}
32
+ @media(max-width:880px){.kpis{grid-template-columns:repeat(2,1fr)}.two{grid-template-columns:1fr}}
33
+ .card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:16px 18px}
34
+ .clabel{font-size:11px;letter-spacing:1.2px;color:var(--sub);font-weight:700;font-family:var(--mono)}
35
+ .cval{font-size:30px;font-weight:650;margin-top:6px;font-variant-numeric:tabular-nums;letter-spacing:-.5px}
36
+ .csub{font-size:12px;color:var(--sub);margin-top:5px}
37
+ .delta{font-size:12px;font-weight:600;font-family:var(--mono)}
38
+ .delta.up{color:var(--good)} .delta.down{color:var(--warn)}
39
+ .panelHead{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:12px}
40
+ .panelHead .t{font-size:12px;letter-spacing:1px;color:var(--sub);font-weight:700;font-family:var(--mono)}
41
+ .panelHead .m{font-size:12px;color:var(--sub);font-family:var(--mono)}
42
+ .barRow{margin-bottom:11px}
43
+ .barRow .top{display:flex;justify-content:space-between;font-size:13px;margin-bottom:5px}
44
+ .barRow .top .r{color:var(--sub);font-variant-numeric:tabular-nums;font-family:var(--mono)}
45
+ .track{height:7px;background:var(--line);border-radius:4px;overflow:hidden}
46
+ .fill{height:100%;border-radius:4px}
47
+ .legend{display:flex;flex-direction:column;gap:7px;font-size:13px}
48
+ .legend .li{display:flex;justify-content:space-between;align-items:center}
49
+ .dot{width:9px;height:9px;border-radius:50%;display:inline-block;margin-right:8px}
50
+ .num{font-variant-numeric:tabular-nums;font-family:var(--mono)}
51
+ .heat{display:grid;grid-template-columns:34px repeat(24,1fr);gap:3px}
52
+ .heat .dl{font-size:9px;color:var(--sub);align-self:center;font-family:var(--mono)}
53
+ .heat .cell{aspect-ratio:1;border-radius:3px;transition:box-shadow .08s}
54
+ .heat .cell:hover{box-shadow:0 0 0 2px var(--ink)}
55
+ svg rect{transition:opacity .08s} svg rect:hover{opacity:.8}
56
+ .heatHours{display:grid;grid-template-columns:34px repeat(24,1fr);gap:3px;margin-top:5px}
57
+ .heatHours span{font-size:8px;color:var(--sub);text-align:center;font-family:var(--mono)}
58
+ .statPair{display:flex;gap:32px;margin-top:6px}
59
+ .statPair .k{font-size:11px;letter-spacing:1px;color:var(--sub);font-weight:700;font-family:var(--mono)}
60
+ .statPair .v{font-size:22px;font-weight:600;font-variant-numeric:tabular-nums}
61
+ .tools{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}
62
+ .chip{font-size:12px;font-family:var(--mono);padding:5px 10px;border-radius:20px;border:1px solid var(--line);background:var(--panel);color:var(--sub)}
63
+ .chip b{color:var(--ink)} .chip.on b{color:var(--good)} .chip .d{margin-right:6px}
64
+ .foot{margin-top:22px;font-size:12px;color:var(--sub);font-family:var(--mono)}
65
+ .loading{padding:60px;text-align:center;color:var(--sub)}
66
+ svg{display:block;width:100%;overflow:visible}
67
+ .tip{fill:var(--sub);font-size:9px;font-family:var(--mono)}
68
+ </style>
69
+ </head>
70
+ <body>
71
+ <div class="wrap">
72
+ <div class="row">
73
+ <div class="eyebrow" id="eyebrow">ANALYTICS · LAST 30 DAYS</div>
74
+ <div class="toggle" id="toggle">
75
+ <button data-r="7d">7d</button>
76
+ <button data-r="30d" class="on">30d</button>
77
+ <button data-r="90d">90d</button>
78
+ </div>
79
+ </div>
80
+ <div id="app"><div class="loading">Reading your local AI data…</div></div>
81
+ </div>
82
+ <script src="/app.js"></script>
83
+ </body>
84
+ </html>
package/src/server.mjs ADDED
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ // Silence the experimental warning from the built-in node:sqlite (used read-only for Cursor).
3
+ const _emitWarning = process.emitWarning.bind(process);
4
+ process.emitWarning = (warning, ...rest) => {
5
+ const type = typeof rest[0] === 'object' && rest[0] ? rest[0].type : rest[0];
6
+ if (type === 'ExperimentalWarning' && /SQLite/i.test(String(warning))) return;
7
+ return _emitWarning(warning, ...rest);
8
+ };
9
+ import { createReadStream, existsSync, statSync } from 'node:fs';
10
+ import { createServer } from 'node:http';
11
+ import { spawn } from 'node:child_process';
12
+ import { platform } from 'node:os';
13
+ import { extname, join } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { loadAll } from './lib/adapters.mjs';
16
+ import { buildPayload } from './lib/aggregate.mjs';
17
+
18
+ const PUBLIC = join(fileURLToPath(new URL('.', import.meta.url)), 'public');
19
+ const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml' };
20
+
21
+ function parseArgs(argv) {
22
+ const o = { port: 4319, open: true };
23
+ for (let i = 0; i < argv.length; i++) {
24
+ if (argv[i] === '--port') o.port = Number(argv[++i]) || o.port;
25
+ else if (argv[i] === '--no-open') o.open = false;
26
+ }
27
+ return o;
28
+ }
29
+
30
+ const { port, open } = parseArgs(process.argv.slice(2));
31
+
32
+ process.stdout.write('usagelens · scanning ~/.claude, ~/.codex, ~/.cursor …\n');
33
+ const t0 = Date.now();
34
+ const dataset = loadAll();
35
+ process.stdout.write(`usagelens · loaded ${dataset.events.length} usage events + ${dataset.commits.length} commits in ${Date.now() - t0}ms\n`);
36
+ for (const s of dataset.statuses) process.stdout.write(` ${s.available ? '✓' : '·'} ${s.tool}${s.count != null ? ` (${s.count})` : ''}${s.note ? ' — ' + s.note : ''}\n`);
37
+
38
+ const server = createServer((req, res) => {
39
+ const url = new URL(req.url ?? '/', 'http://localhost');
40
+ if (url.pathname === '/api/dashboard') {
41
+ const rq = url.searchParams.get('range');
42
+ const range = ['7d', '30d', '90d'].includes(rq) ? rq : '30d';
43
+ const body = JSON.stringify(buildPayload(dataset, range, new Date()));
44
+ res.writeHead(200, { 'content-type': 'application/json' }).end(body);
45
+ return;
46
+ }
47
+ const name = url.pathname === '/' ? 'index.html' : url.pathname.replace(/^\/+/, '').replace(/\.\./g, '');
48
+ const file = join(PUBLIC, name);
49
+ if (existsSync(file) && statSync(file).isFile()) {
50
+ res.writeHead(200, { 'content-type': MIME[extname(file)] ?? 'application/octet-stream' });
51
+ createReadStream(file).pipe(res);
52
+ } else {
53
+ res.writeHead(404).end('not found');
54
+ }
55
+ });
56
+
57
+ server.listen(port, () => {
58
+ const link = `http://localhost:${port}`;
59
+ process.stdout.write(`\nusagelens · dashboard → ${link}\n`);
60
+ if (open) {
61
+ const cmd = platform() === 'darwin' ? 'open' : platform() === 'win32' ? 'start' : 'xdg-open';
62
+ try { spawn(cmd, [link], { stdio: 'ignore', detached: true }).unref(); } catch {}
63
+ }
64
+ });