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 +21 -0
- package/README.md +47 -0
- package/package.json +35 -0
- package/src/lib/adapters.mjs +137 -0
- package/src/lib/aggregate.mjs +159 -0
- package/src/lib/pricing.mjs +28 -0
- package/src/public/app.js +240 -0
- package/src/public/index.html +84 -0
- package/src/server.mjs +64 -0
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
+
});
|