ai-usage-analyzer 0.5.0 → 0.5.1
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/README.md +10 -6
- package/bin/ai-usage.js +2 -5
- package/package.json +1 -3
- package/src/aggregate.js +17 -2
- package/src/loaders.js +1 -4
- package/src/markdown.js +1 -0
- package/src/render.js +3 -2
- package/src/server.js +1 -0
- package/src/tools.js +0 -44
- package/web/app.js +131 -24
- package/web/dates.js +35 -0
- package/web/index.html +43 -9
- package/web/style.css +46 -1
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# ai-usage-analyzer
|
|
2
2
|
|
|
3
3
|
A local web dashboard for AI coding agent token consumption. Auto-detects
|
|
4
|
-
**Claude Code**, **Codex**, **OpenCode**,
|
|
4
|
+
**Claude Code**, **Codex**, **OpenCode**,
|
|
5
5
|
**Antigravity**, and **Gemini CLI** data directories.
|
|
6
6
|
|
|
7
7
|
The default command starts a local server and opens the dashboard in your browser.
|
|
@@ -39,15 +39,13 @@ ai-usage-analyzer --json --quota # include a live quota snapshot
|
|
|
39
39
|
| Claude Code | `~/.claude/projects` | yes (estimated cost) |
|
|
40
40
|
| Codex | `~/.codex/sessions/` and `archived_sessions/` | yes (estimated cost) |
|
|
41
41
|
| OpenCode | `~/.local/share/opencode/opencode.db` | yes (+cost) |
|
|
42
|
-
| MimoCode | `~/.local/share/mimocode/mimocode.db` | yes (+cost) |
|
|
43
|
-
| GitHub Copilot | `~/.copilot/session-state/` | presence only |
|
|
44
42
|
| Antigravity | `~/Library/Application Support/Antigravity` | presence only |
|
|
45
43
|
| Gemini CLI | `~/.gemini/antigravity/conversations` | presence only |
|
|
46
44
|
|
|
47
45
|
## Path overrides
|
|
48
46
|
|
|
49
47
|
Override any tool's base path with an env var (`CLAUDE_HOME`, `CODEX_HOME`,
|
|
50
|
-
`OPENCODE_HOME`, `
|
|
48
|
+
`OPENCODE_HOME`, `ANTIGRAVITY_HOME`,
|
|
51
49
|
`GEMINI_HOME`), or pass all at once via JSON:
|
|
52
50
|
|
|
53
51
|
```bash
|
|
@@ -60,6 +58,12 @@ For tools that record token data, the analyzer shows input, output, cache
|
|
|
60
58
|
read, cache write, and reasoning tokens. Cache hits are cheap; reasoning is
|
|
61
59
|
the extended-thinking/chain-of-thought cost.
|
|
62
60
|
|
|
61
|
+
Average daily tokens divides total tokens by calendar days, including days
|
|
62
|
+
without usage. The dashboard uses the selected date range in your local time.
|
|
63
|
+
All time uses the first through last recorded day. Terminal, Markdown, and JSON
|
|
64
|
+
reports use the first through last recorded day in UTC; JSON exposes the value
|
|
65
|
+
as `summary.avgDailyTokens`.
|
|
66
|
+
|
|
63
67
|
## Quotas
|
|
64
68
|
|
|
65
69
|
Reports stay offline unless `--quota` is supplied. This requests Claude and Codex
|
|
@@ -71,8 +75,8 @@ change. Codex credentials may be refreshed and written back to `auth.json`.
|
|
|
71
75
|
Claude and Codex costs use bundled API rates per model and are estimates,
|
|
72
76
|
not subscription charges or invoices. `~` marks an estimate, `*` marks a
|
|
73
77
|
partial total, and `n/a` means unavailable. Unknown models are listed rather
|
|
74
|
-
than assigned a fallback price. OpenCode
|
|
75
|
-
|
|
78
|
+
than assigned a fallback price. OpenCode costs are read from
|
|
79
|
+
its local database. Pricing uses standard public API rates and the
|
|
76
80
|
five-minute Claude cache-write rate; regional, batch, and other modifiers may
|
|
77
81
|
differ. Rates are versioned with the app and can become outdated.
|
|
78
82
|
|
package/bin/ai-usage.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// AI Usage Analyzer - TUI for local AI coding agent token consumption
|
|
3
|
-
// Auto-detects: Claude Code, Codex, OpenCode, MimoCode, Copilot, Antigravity, Gemini
|
|
4
3
|
|
|
5
4
|
import { overall } from "../src/aggregate.js";
|
|
6
5
|
import { detectAll } from "../src/detectors.js";
|
|
@@ -66,16 +65,14 @@ Examples:
|
|
|
66
65
|
ai-usage --static --top 20 # show top 20 sessions
|
|
67
66
|
|
|
68
67
|
Environment overrides (per-tool data path):
|
|
69
|
-
CLAUDE_HOME, CODEX_HOME, OPENCODE_HOME,
|
|
70
|
-
|
|
68
|
+
CLAUDE_HOME, CODEX_HOME, OPENCODE_HOME,
|
|
69
|
+
ANTIGRAVITY_HOME, GEMINI_HOME,
|
|
71
70
|
AI_USAGE_PATHS_JSON='{"codex":"/custom/path",...}'
|
|
72
71
|
|
|
73
72
|
Supported tools:
|
|
74
73
|
• Claude Code — ~/.claude/projects (tokens from per-message usage)
|
|
75
74
|
• Codex — ~/.codex/sessions (tokens from token_count events)
|
|
76
75
|
• OpenCode — ~/.local/share/opencode/opencode.db (tokens + cost)
|
|
77
|
-
• MimoCode — ~/.local/share/mimocode/mimocode.db (tokens + cost)
|
|
78
|
-
• GitHub Copilot — ~/.copilot/session-state (presence only)
|
|
79
76
|
• Antigravity — ~/Library/Application Support/Antigravity (presence only)
|
|
80
77
|
• Gemini CLI — ~/.gemini/antigravity/conversations (presence only)
|
|
81
78
|
`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-usage-analyzer",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Local web dashboard for AI coding agent token usage.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@10.33.0",
|
|
@@ -28,8 +28,6 @@
|
|
|
28
28
|
"claude-code",
|
|
29
29
|
"codex",
|
|
30
30
|
"opencode",
|
|
31
|
-
"mimocode",
|
|
32
|
-
"copilot",
|
|
33
31
|
"antigravity",
|
|
34
32
|
"gemini",
|
|
35
33
|
"cli",
|
package/src/aggregate.js
CHANGED
|
@@ -128,8 +128,23 @@ export function perToolPerMonth(records) {
|
|
|
128
128
|
return out.sort((a, b) => b.tokensTotal - a.tokensTotal);
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
export function overall(records) {
|
|
132
|
-
|
|
131
|
+
export function overall(records, dayCount) {
|
|
132
|
+
const totals = summarize(records);
|
|
133
|
+
if (dayCount === undefined) {
|
|
134
|
+
let first = Infinity;
|
|
135
|
+
let last = -Infinity;
|
|
136
|
+
for (const record of records) {
|
|
137
|
+
if (!Number.isFinite(record.ts) || !Number.isFinite(new Date(record.ts).getTime())) continue;
|
|
138
|
+
const day = Math.floor(record.ts / 86400000);
|
|
139
|
+
first = Math.min(first, day);
|
|
140
|
+
last = Math.max(last, day);
|
|
141
|
+
}
|
|
142
|
+
dayCount = last - first + 1;
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
...totals,
|
|
146
|
+
avgDailyTokens: Number.isFinite(dayCount) && dayCount > 0 ? totals.tokensTotal / dayCount : 0,
|
|
147
|
+
};
|
|
133
148
|
}
|
|
134
149
|
|
|
135
150
|
export function topSessions(records, n = 5) {
|
package/src/loaders.js
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Unified record shape:
|
|
4
4
|
// {
|
|
5
|
-
// tool: 'opencode' | 'codex' | 'mimocode' | string,
|
|
6
5
|
// sessionId: string,
|
|
7
6
|
// project: string, // working directory or '(unknown)'
|
|
8
7
|
// title: string,
|
|
@@ -83,7 +82,6 @@ function compactHome(p) {
|
|
|
83
82
|
}
|
|
84
83
|
|
|
85
84
|
// ---------------------------------------------------------------------------
|
|
86
|
-
// SQLite loader (OpenCode + MimoCode share schema)
|
|
87
85
|
// ---------------------------------------------------------------------------
|
|
88
86
|
|
|
89
87
|
function loadOpencodeStyleSqlite(dbPath, toolKey) {
|
|
@@ -94,7 +92,6 @@ function loadOpencodeStyleSqlite(dbPath, toolKey) {
|
|
|
94
92
|
return { records: [], error: `cannot open ${dbPath}: ${e.message}` };
|
|
95
93
|
}
|
|
96
94
|
|
|
97
|
-
// Introspect session table for token columns (MimoCode schema is a subset)
|
|
98
95
|
const cols = db.prepare(`PRAGMA table_info(session)`).all();
|
|
99
96
|
const colSet = new Set(cols.map((c) => c.name));
|
|
100
97
|
const hasTokens =
|
|
@@ -464,7 +461,7 @@ export async function loadAll(detections) {
|
|
|
464
461
|
const tasks = detections
|
|
465
462
|
.filter((d) => d.status === "present")
|
|
466
463
|
.map((d) => {
|
|
467
|
-
if (d.key === "opencode"
|
|
464
|
+
if (d.key === "opencode") {
|
|
468
465
|
return Promise.resolve({
|
|
469
466
|
d,
|
|
470
467
|
r: loadOpencodeStyleSqlite(d.path, d.key),
|
package/src/markdown.js
CHANGED
|
@@ -132,6 +132,7 @@ export function renderMarkdown({
|
|
|
132
132
|
out.push(`**Range**: ${dateRange[0] ?? "—"} → ${dateRange[1] ?? "—"} `);
|
|
133
133
|
out.push(`**Sessions**: ${fmtInt(records.length)} `);
|
|
134
134
|
out.push(`**Total tokens**: ${fmtCompact(tot.tokensTotal)} `);
|
|
135
|
+
out.push(`**Average daily tokens**: ${fmtCompact(tot.avgDailyTokens)} `);
|
|
135
136
|
out.push(`**Cost**: ${fmtCost(tot.cost, tot.costStatus)} `);
|
|
136
137
|
if (tot.unknownModels.length) out.push(`**Unpriced models**: ${tot.unknownModels.map(mdEscape).join(", ")} `);
|
|
137
138
|
} else {
|
package/src/render.js
CHANGED
|
@@ -353,6 +353,7 @@ export function renderOverview(records, detections) {
|
|
|
353
353
|
|
|
354
354
|
const body =
|
|
355
355
|
pt.toString() +
|
|
356
|
+
"\n\n" + `Average daily tokens: ${fmtCompact(t.avgDailyTokens)}` +
|
|
356
357
|
"\n\n" +
|
|
357
358
|
chalk.bold.underline("Token Breakdown") +
|
|
358
359
|
"\n" +
|
|
@@ -907,9 +908,9 @@ export function renderNotes(detections, errors) {
|
|
|
907
908
|
lines.push("");
|
|
908
909
|
lines.push(chalk.bold.underline("Env overrides"));
|
|
909
910
|
lines.push(
|
|
910
|
-
" $CLAUDE_HOME $CODEX_HOME $OPENCODE_HOME
|
|
911
|
+
" $CLAUDE_HOME $CODEX_HOME $OPENCODE_HOME",
|
|
911
912
|
);
|
|
912
|
-
lines.push(" $
|
|
913
|
+
lines.push(" $ANTIGRAVITY_HOME $GEMINI_HOME");
|
|
913
914
|
lines.push(
|
|
914
915
|
" $AI_USAGE_PATHS_JSON = " + chalk.italic('\'{"codex":"/custom/path"}\''),
|
|
915
916
|
);
|
package/src/server.js
CHANGED
|
@@ -7,6 +7,7 @@ import { loadAll } from './loaders.js';
|
|
|
7
7
|
const assets = new Map([
|
|
8
8
|
['/', ['../web/index.html', 'text/html; charset=utf-8']],
|
|
9
9
|
['/app.js', ['../web/app.js', 'text/javascript; charset=utf-8']],
|
|
10
|
+
['/dates.js', ['../web/dates.js', 'text/javascript; charset=utf-8']],
|
|
10
11
|
['/aggregate.js', ['./aggregate.js', 'text/javascript; charset=utf-8']],
|
|
11
12
|
['/style.css', ['../web/style.css', 'text/css; charset=utf-8']],
|
|
12
13
|
['/favicon.svg', ['../web/favicon.svg', 'image/svg+xml']],
|
package/src/tools.js
CHANGED
|
@@ -141,50 +141,6 @@ export const TOOLS = [
|
|
|
141
141
|
description: '~/.local/share/opencode/opencode.db (tokens + cost)',
|
|
142
142
|
},
|
|
143
143
|
|
|
144
|
-
// -----------------------------------------------------------------------
|
|
145
|
-
// MimoCode (same schema as OpenCode)
|
|
146
|
-
// -----------------------------------------------------------------------
|
|
147
|
-
{
|
|
148
|
-
key: 'mimocode',
|
|
149
|
-
name: 'MimoCode',
|
|
150
|
-
label: 'mimocode',
|
|
151
|
-
color: '#f1fa8c', // yellow
|
|
152
|
-
barChar: '░',
|
|
153
|
-
kind: 'sqlite',
|
|
154
|
-
envVar: 'MIMOCODE_HOME',
|
|
155
|
-
candidatePaths: () => [
|
|
156
|
-
env.MIMOCODE_HOME
|
|
157
|
-
? join(env.MIMOCODE_HOME, 'mimocode.db')
|
|
158
|
-
: join(HOME, '.local', 'share', 'mimocode', 'mimocode.db'),
|
|
159
|
-
],
|
|
160
|
-
count: countSqliteRows,
|
|
161
|
-
hasTokens: true,
|
|
162
|
-
description: '~/.local/share/mimocode/mimocode.db (tokens + cost)',
|
|
163
|
-
},
|
|
164
|
-
|
|
165
|
-
// -----------------------------------------------------------------------
|
|
166
|
-
// GitHub Copilot CLI (presence only)
|
|
167
|
-
// -----------------------------------------------------------------------
|
|
168
|
-
{
|
|
169
|
-
key: 'copilot',
|
|
170
|
-
name: 'GitHub Copilot',
|
|
171
|
-
label: 'copilot',
|
|
172
|
-
color: '#3b82f6', // deep blue
|
|
173
|
-
barChar: '·',
|
|
174
|
-
kind: 'jsonl-events',
|
|
175
|
-
envVar: 'COPILOT_HOME',
|
|
176
|
-
candidatePaths: () => {
|
|
177
|
-
const base = env.COPILOT_HOME || join(HOME, '.copilot');
|
|
178
|
-
return [
|
|
179
|
-
join(base, 'session-state'),
|
|
180
|
-
base,
|
|
181
|
-
];
|
|
182
|
-
},
|
|
183
|
-
count: countJsonlRecursive,
|
|
184
|
-
hasTokens: false,
|
|
185
|
-
description: '~/.copilot/session-state/*/events.jsonl (no token data)',
|
|
186
|
-
},
|
|
187
|
-
|
|
188
144
|
// -----------------------------------------------------------------------
|
|
189
145
|
// Antigravity (VS Code variant) - mostly cache; no token data
|
|
190
146
|
// -----------------------------------------------------------------------
|
package/web/app.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { overall, perProject, perTool, perMonth, topSessions } from '/aggregate.js';
|
|
2
|
+
import { dayNumber, periodBounds as boundsForPeriod, dateValue, parseDate, calendarDays } from '/dates.js';
|
|
2
3
|
|
|
3
4
|
const $ = id => document.getElementById(id);
|
|
4
5
|
const root = document.documentElement;
|
|
@@ -26,6 +27,9 @@ const brandIcons = new Set(['claude', 'codex', 'opencode']);
|
|
|
26
27
|
const state = { data: null, page: 0, project: null, period: { key: 'all' }, tool: 'all', sort: 'tokens', allProjects: false, totalTarget: 0, previousTotal: 0, tapeKey: '', tapeFresh: false, tape: null };
|
|
27
28
|
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
|
|
28
29
|
let animation;
|
|
30
|
+
let dateDraft;
|
|
31
|
+
let calendarMonth;
|
|
32
|
+
let pickingEnd = false;
|
|
29
33
|
|
|
30
34
|
function readTheme() {
|
|
31
35
|
try {
|
|
@@ -169,24 +173,73 @@ function dated(ts, formatter = dayDate) {
|
|
|
169
173
|
return Number.isFinite(ts) && !Number.isNaN(new Date(ts).getTime()) ? formatter.format(ts) : 'Unknown date';
|
|
170
174
|
}
|
|
171
175
|
|
|
172
|
-
function dayNumber(ts) {
|
|
173
|
-
const date = new Date(ts);
|
|
174
|
-
return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86400000);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
176
|
function dayLabel(day, formatter = utcDayLong) {
|
|
178
177
|
return formatter.format(day * 86400000);
|
|
179
178
|
}
|
|
180
179
|
|
|
181
180
|
function periodBounds() {
|
|
182
|
-
|
|
181
|
+
return boundsForPeriod(state.period);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function periodLabel(period) {
|
|
185
|
+
if (period.key !== 'custom') return periodLabels[period.key];
|
|
186
|
+
return period.from === period.to ? dayLabel(period.from, utcDayShort) : `${dayLabel(period.from)} to ${dayLabel(period.to)}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function renderDatePicker(focusDay) {
|
|
190
|
+
const bounds = boundsForPeriod(dateDraft);
|
|
191
|
+
const [from, to] = bounds || [];
|
|
192
|
+
const valid = !bounds || (from != null && to != null && from <= to);
|
|
193
|
+
const month = new Date(calendarMonth * 86400000);
|
|
183
194
|
const today = dayNumber(Date.now());
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
195
|
+
$('calendar-month').textContent = new Intl.DateTimeFormat('en', { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(month);
|
|
196
|
+
for (const button of document.querySelectorAll('[data-period]')) button.setAttribute('aria-pressed', String(dateDraft.key === button.dataset.period));
|
|
197
|
+
const days = calendarDays(calendarMonth);
|
|
198
|
+
const tabDay = focusDay ?? (days.includes(from) ? from : days.includes(today) ? today : calendarMonth);
|
|
199
|
+
const container = clear('calendar-days');
|
|
200
|
+
for (const day of days) {
|
|
201
|
+
const date = new Date(day * 86400000);
|
|
202
|
+
const button = el('button', 'calendar-day', date.getUTCDate());
|
|
203
|
+
button.type = 'button';
|
|
204
|
+
button.dataset.day = day;
|
|
205
|
+
button.tabIndex = day === tabDay ? 0 : -1;
|
|
206
|
+
button.setAttribute('aria-label', dayLabel(day));
|
|
207
|
+
button.setAttribute('aria-pressed', String(valid && from != null && day >= from && day <= to));
|
|
208
|
+
if (day === today) button.setAttribute('aria-current', 'date');
|
|
209
|
+
button.classList.toggle('outside-month', date.getUTCMonth() !== month.getUTCMonth());
|
|
210
|
+
button.classList.toggle('range-start', day === from);
|
|
211
|
+
button.classList.toggle('range-end', day === to);
|
|
212
|
+
container.append(button);
|
|
213
|
+
}
|
|
214
|
+
$('date-from').value = dateValue(from);
|
|
215
|
+
$('date-to').value = dateValue(to);
|
|
216
|
+
$('date-from').max = dateValue(to);
|
|
217
|
+
$('date-to').min = dateValue(from);
|
|
218
|
+
$('apply-date').disabled = !valid;
|
|
219
|
+
$('date-help').textContent = from != null && to != null && from > to
|
|
220
|
+
? 'End date must be on or after the start date.'
|
|
221
|
+
: pickingEnd ? 'Now select an end date.' : 'Select a start date, then an end date. Dates use your local time.';
|
|
222
|
+
$('date-preview').textContent = valid ? periodLabel(dateDraft) : 'Choose a complete date range';
|
|
223
|
+
if (focusDay != null) container.querySelector(`[data-day="${focusDay}"]`)?.focus();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function openDatePicker() {
|
|
227
|
+
dateDraft = { ...state.period };
|
|
228
|
+
const day = periodBounds()?.[0] ?? dayNumber(Date.now());
|
|
229
|
+
const month = new Date(day * 86400000);
|
|
230
|
+
month.setUTCDate(1);
|
|
231
|
+
calendarMonth = month.getTime() / 86400000;
|
|
232
|
+
pickingEnd = false;
|
|
233
|
+
renderDatePicker();
|
|
234
|
+
$('date-dialog').showModal();
|
|
235
|
+
$('date-filter').setAttribute('aria-expanded', 'true');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function moveCalendarMonth(step) {
|
|
239
|
+
const date = new Date(calendarMonth * 86400000);
|
|
240
|
+
date.setUTCMonth(date.getUTCMonth() + step);
|
|
241
|
+
calendarMonth = date.getTime() / 86400000;
|
|
242
|
+
renderDatePicker();
|
|
190
243
|
}
|
|
191
244
|
|
|
192
245
|
function periodRecords() {
|
|
@@ -272,7 +325,11 @@ function insightText(totals) {
|
|
|
272
325
|
}
|
|
273
326
|
|
|
274
327
|
function renderSummary(records) {
|
|
275
|
-
const
|
|
328
|
+
const times = records.filter(validTs).map(record => record.ts);
|
|
329
|
+
const range = times.reduce((result, ts) => [Math.min(result[0], ts), Math.max(result[1], ts)], [Infinity, -Infinity]);
|
|
330
|
+
const bounds = periodBounds();
|
|
331
|
+
const dayCount = bounds ? bounds[1] - bounds[0] + 1 : times.length ? dayNumber(range[1]) - dayNumber(range[0]) + 1 : 0;
|
|
332
|
+
const totals = overall(records, dayCount);
|
|
276
333
|
state.previousTotal = state.totalTarget;
|
|
277
334
|
state.totalTarget = totals.tokensTotal;
|
|
278
335
|
setTotal(totals.tokensTotal);
|
|
@@ -281,13 +338,12 @@ function renderSummary(records) {
|
|
|
281
338
|
$('session-count').textContent = number.format(totals.n);
|
|
282
339
|
$('project-count').textContent = number.format(new Set(records.map(record => record.project)).size);
|
|
283
340
|
$('average').textContent = totals.n ? compact.format(totals.avg) : 'None';
|
|
341
|
+
$('daily-average').textContent = compact.format(totals.avgDailyTokens);
|
|
284
342
|
$('cost').textContent = costLabel(totals);
|
|
285
343
|
$('cost-detail').textContent = costDescription(totals.costStatus);
|
|
286
344
|
const days = dailyTotals(records);
|
|
287
345
|
const busiest = [...days].reduce((best, entry) => !best || entry[1] > best[1] ? entry : best, null);
|
|
288
346
|
$('busiest-day').textContent = busiest ? `${dayLabel(busiest[0], utcDayShort)} · ${compact.format(busiest[1])}` : 'None';
|
|
289
|
-
const times = records.filter(validTs).map(record => record.ts);
|
|
290
|
-
const range = times.reduce((result, ts) => [Math.min(result[0], ts), Math.max(result[1], ts)], [Infinity, -Infinity]);
|
|
291
347
|
$('range-label').textContent = times.length ? `${dated(range[0])} to ${dated(range[1])}` : '';
|
|
292
348
|
renderComposition(totals);
|
|
293
349
|
}
|
|
@@ -803,11 +859,7 @@ function buildToolFilter() {
|
|
|
803
859
|
}
|
|
804
860
|
|
|
805
861
|
function renderFilters(records) {
|
|
806
|
-
|
|
807
|
-
const key = button.dataset.period;
|
|
808
|
-
button.setAttribute('aria-pressed', String(state.period.key === key));
|
|
809
|
-
if (key === 'custom') button.hidden = state.period.key !== 'custom';
|
|
810
|
-
}
|
|
862
|
+
$('date-filter-label').textContent = periodLabel(state.period);
|
|
811
863
|
for (const button of $('tool-filter').children) button.setAttribute('aria-pressed', String(button.dataset.tool === state.tool));
|
|
812
864
|
$('scope').textContent = `${number.format(records.length)} ${records.length === 1 ? 'session' : 'sessions'} in view`;
|
|
813
865
|
}
|
|
@@ -901,12 +953,67 @@ document.querySelector('.skip-link').addEventListener('click', event => {
|
|
|
901
953
|
});
|
|
902
954
|
for (const button of document.querySelectorAll('[data-period]')) {
|
|
903
955
|
button.addEventListener('click', () => {
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
956
|
+
const key = button.dataset.period;
|
|
957
|
+
const [from, to] = boundsForPeriod(dateDraft) || [dayNumber(Date.now()), dayNumber(Date.now())];
|
|
958
|
+
dateDraft = key === 'custom' ? { key, from, to } : { key };
|
|
959
|
+
pickingEnd = false;
|
|
960
|
+
if (key !== 'all') {
|
|
961
|
+
const month = new Date(boundsForPeriod(dateDraft)[0] * 86400000);
|
|
962
|
+
month.setUTCDate(1);
|
|
963
|
+
calendarMonth = month.getTime() / 86400000;
|
|
964
|
+
}
|
|
965
|
+
renderDatePicker();
|
|
908
966
|
});
|
|
909
967
|
}
|
|
968
|
+
$('date-filter').addEventListener('click', openDatePicker);
|
|
969
|
+
for (const id of ['close-date', 'cancel-date']) $(id).addEventListener('click', () => $('date-dialog').close());
|
|
970
|
+
$('date-dialog').addEventListener('close', () => $('date-filter').setAttribute('aria-expanded', 'false'));
|
|
971
|
+
$('date-dialog').addEventListener('click', event => {
|
|
972
|
+
if (event.target !== event.currentTarget) return;
|
|
973
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
974
|
+
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) event.currentTarget.close();
|
|
975
|
+
});
|
|
976
|
+
$('calendar-previous').addEventListener('click', () => moveCalendarMonth(-1));
|
|
977
|
+
$('calendar-next').addEventListener('click', () => moveCalendarMonth(1));
|
|
978
|
+
$('calendar-days').addEventListener('click', event => {
|
|
979
|
+
const button = event.target.closest('[data-day]');
|
|
980
|
+
if (!button) return;
|
|
981
|
+
const day = Number(button.dataset.day);
|
|
982
|
+
dateDraft = pickingEnd
|
|
983
|
+
? { key: 'custom', from: Math.min(dateDraft.from, day), to: Math.max(dateDraft.from, day) }
|
|
984
|
+
: { key: 'custom', from: day, to: null };
|
|
985
|
+
pickingEnd = !pickingEnd;
|
|
986
|
+
renderDatePicker(day);
|
|
987
|
+
});
|
|
988
|
+
$('calendar-days').addEventListener('keydown', event => {
|
|
989
|
+
const button = event.target.closest('[data-day]');
|
|
990
|
+
const step = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }[event.key];
|
|
991
|
+
if (!button || step == null) return;
|
|
992
|
+
event.preventDefault();
|
|
993
|
+
const day = Number(button.dataset.day) + step;
|
|
994
|
+
const month = new Date(day * 86400000);
|
|
995
|
+
month.setUTCDate(1);
|
|
996
|
+
calendarMonth = month.getTime() / 86400000;
|
|
997
|
+
renderDatePicker(day);
|
|
998
|
+
});
|
|
999
|
+
for (const id of ['date-from', 'date-to']) $(id).addEventListener('change', () => {
|
|
1000
|
+
dateDraft = { key: 'custom', from: parseDate($('date-from').value), to: parseDate($('date-to').value) };
|
|
1001
|
+
pickingEnd = false;
|
|
1002
|
+
const day = dateDraft[id === 'date-from' ? 'from' : 'to'];
|
|
1003
|
+
if (day != null) {
|
|
1004
|
+
const month = new Date(day * 86400000);
|
|
1005
|
+
month.setUTCDate(1);
|
|
1006
|
+
calendarMonth = month.getTime() / 86400000;
|
|
1007
|
+
}
|
|
1008
|
+
renderDatePicker();
|
|
1009
|
+
});
|
|
1010
|
+
$('apply-date').addEventListener('click', () => {
|
|
1011
|
+
if ($('apply-date').disabled) return;
|
|
1012
|
+
state.period = { ...dateDraft };
|
|
1013
|
+
state.page = 0;
|
|
1014
|
+
render();
|
|
1015
|
+
$('date-dialog').close();
|
|
1016
|
+
});
|
|
910
1017
|
$('tool-filter').addEventListener('click', event => {
|
|
911
1018
|
const button = event.target.closest('button');
|
|
912
1019
|
if (!button) return;
|
package/web/dates.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export function dayNumber(ts) {
|
|
2
|
+
const date = new Date(ts);
|
|
3
|
+
return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86400000);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function periodBounds(period, now = Date.now()) {
|
|
7
|
+
const { key, from, to } = period;
|
|
8
|
+
const today = dayNumber(now);
|
|
9
|
+
const date = new Date(now);
|
|
10
|
+
if (key === 'all') return null;
|
|
11
|
+
if (key === 'custom') return [from, to];
|
|
12
|
+
if (key === 'today') return [today, today];
|
|
13
|
+
if (key === 'week') return [today - (date.getDay() + 6) % 7, today];
|
|
14
|
+
if (key === 'year') return [dayNumber(new Date(date.getFullYear(), 0, 1).getTime()), today];
|
|
15
|
+
return [today - Number(key) + 1, today];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function dateValue(day) {
|
|
19
|
+
return day == null ? '' : new Date(day * 86400000).toISOString().slice(0, 10);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseDate(value) {
|
|
23
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
|
|
24
|
+
const ts = Date.parse(`${value}T00:00:00Z`);
|
|
25
|
+
if (!Number.isFinite(ts)) return null;
|
|
26
|
+
const day = ts / 86400000;
|
|
27
|
+
return dateValue(day) === value ? day : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function calendarDays(month) {
|
|
31
|
+
const date = new Date(month * 86400000);
|
|
32
|
+
date.setUTCDate(1);
|
|
33
|
+
const first = date.getTime() / 86400000 - (date.getUTCDay() + 6) % 7;
|
|
34
|
+
return Array.from({ length: 42 }, (_, index) => first + index);
|
|
35
|
+
}
|
package/web/index.html
CHANGED
|
@@ -42,15 +42,11 @@
|
|
|
42
42
|
|
|
43
43
|
<div id="dashboard" hidden>
|
|
44
44
|
<div id="filters" class="filters">
|
|
45
|
-
<
|
|
46
|
-
<
|
|
47
|
-
<
|
|
48
|
-
<
|
|
49
|
-
|
|
50
|
-
<button type="button" data-period="year" aria-pressed="false">This year</button>
|
|
51
|
-
<button type="button" data-period="all" aria-pressed="true">All time</button>
|
|
52
|
-
<button type="button" data-period="custom" aria-pressed="false" hidden>Custom range</button>
|
|
53
|
-
</div>
|
|
45
|
+
<button id="date-filter" class="button quiet date-trigger" type="button" aria-haspopup="dialog" aria-controls="date-dialog" aria-expanded="false">
|
|
46
|
+
<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="3" y="4.5" width="14" height="12" rx="1"/><path d="M6.5 3v3M13.5 3v3M3 8.5h14"/></svg>
|
|
47
|
+
<span id="date-filter-label">All time</span>
|
|
48
|
+
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m6 8 4 4 4-4"/></svg>
|
|
49
|
+
</button>
|
|
54
50
|
<div id="tool-filter" class="filter-group tool-filter" role="group" aria-label="Tool"></div>
|
|
55
51
|
<p id="scope" class="scope"></p>
|
|
56
52
|
</div>
|
|
@@ -67,6 +63,7 @@
|
|
|
67
63
|
<div><dt>Sessions</dt><dd id="session-count"></dd></div>
|
|
68
64
|
<div><dt>Projects</dt><dd id="project-count"></dd></div>
|
|
69
65
|
<div><dt>Average session</dt><dd id="average"></dd></div>
|
|
66
|
+
<div title="Total tokens divided by calendar days in the selected range, including days without usage. All time uses the first through last recorded day."><dt>Average daily tokens</dt><dd id="daily-average"></dd></div>
|
|
70
67
|
<div><dt>Busiest day</dt><dd id="busiest-day"></dd></div>
|
|
71
68
|
<div class="slip-cost"><dt>API cost</dt><dd id="cost"></dd><p id="cost-detail"></p></div>
|
|
72
69
|
</dl>
|
|
@@ -183,6 +180,43 @@
|
|
|
183
180
|
<p id="updated"></p>
|
|
184
181
|
</footer>
|
|
185
182
|
|
|
183
|
+
<dialog id="date-dialog" class="date-dialog" aria-labelledby="date-title" aria-describedby="date-help">
|
|
184
|
+
<div class="date-heading">
|
|
185
|
+
<h2 id="date-title">Date range</h2>
|
|
186
|
+
<button id="close-date" class="button quiet" type="button" aria-label="Close date picker">Close</button>
|
|
187
|
+
</div>
|
|
188
|
+
<div class="date-body">
|
|
189
|
+
<div class="date-presets" role="group" aria-label="Date presets">
|
|
190
|
+
<button type="button" data-period="today" aria-pressed="false">Today</button>
|
|
191
|
+
<button type="button" data-period="week" aria-pressed="false">This week</button>
|
|
192
|
+
<button type="button" data-period="30" aria-pressed="false">Last 30 days</button>
|
|
193
|
+
<button type="button" data-period="90" aria-pressed="false">Last 90 days</button>
|
|
194
|
+
<button type="button" data-period="year" aria-pressed="false">This year</button>
|
|
195
|
+
<button type="button" data-period="all" aria-pressed="true">All time</button>
|
|
196
|
+
<button type="button" data-period="custom" aria-pressed="false">Custom range</button>
|
|
197
|
+
</div>
|
|
198
|
+
<div class="date-calendar">
|
|
199
|
+
<div class="calendar-heading">
|
|
200
|
+
<button id="calendar-previous" class="button quiet" type="button" aria-label="Previous month">‹</button>
|
|
201
|
+
<h3 id="calendar-month" aria-live="polite"></h3>
|
|
202
|
+
<button id="calendar-next" class="button quiet" type="button" aria-label="Next month">›</button>
|
|
203
|
+
</div>
|
|
204
|
+
<div class="calendar-weekdays" aria-hidden="true"><span>Mon</span><span>Tue</span><span>Wed</span><span>Thu</span><span>Fri</span><span>Sat</span><span>Sun</span></div>
|
|
205
|
+
<div id="calendar-days" class="calendar-days" role="group" aria-labelledby="calendar-month"></div>
|
|
206
|
+
<p id="date-help" class="date-help" aria-live="polite">Select a start date, then an end date.</p>
|
|
207
|
+
<div class="date-fields">
|
|
208
|
+
<label>Start date<input id="date-from" type="date" required></label>
|
|
209
|
+
<label>End date<input id="date-to" type="date" required></label>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
<div class="date-footer">
|
|
214
|
+
<p id="date-preview" aria-live="polite"></p>
|
|
215
|
+
<button id="cancel-date" class="button quiet" type="button">Cancel</button>
|
|
216
|
+
<button id="apply-date" class="button solid" type="button">Apply</button>
|
|
217
|
+
</div>
|
|
218
|
+
</dialog>
|
|
219
|
+
|
|
186
220
|
<dialog id="session-dialog" class="drawer" aria-labelledby="session-title">
|
|
187
221
|
<div class="drawer-body">
|
|
188
222
|
<div class="drawer-top">
|
package/web/style.css
CHANGED
|
@@ -107,6 +107,51 @@ h3 { font-size: 15px; font-weight: 700; letter-spacing: -.01em; }
|
|
|
107
107
|
.tool-filter button { display: inline-flex; align-items: center; gap: 8px; }
|
|
108
108
|
.scope { margin-left: auto; color: var(--ink-2); font-weight: 600; }
|
|
109
109
|
|
|
110
|
+
.date-trigger { justify-content: space-between; max-width: 100%; text-align: left; }
|
|
111
|
+
.date-trigger svg { flex: none; }
|
|
112
|
+
.date-trigger[aria-expanded='true'] { background: var(--paper-2); }
|
|
113
|
+
.date-dialog { width: min(660px, calc(100% - 24px)); max-width: none; max-height: calc(100dvh - 24px); margin: auto; padding: 0; border: 1.5px solid var(--ink); border-radius: 3px; background: var(--raised); color: var(--ink); overflow-y: auto; }
|
|
114
|
+
.date-dialog::backdrop { background: var(--scrim); }
|
|
115
|
+
.date-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px; border-bottom: 1px solid var(--rule); }
|
|
116
|
+
.date-heading h2 { font-size: 22px; }
|
|
117
|
+
.date-body { display: grid; }
|
|
118
|
+
.date-presets { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; padding: 12px; border-bottom: 1px solid var(--rule); }
|
|
119
|
+
.date-presets button { min-height: 44px; padding: 6px 8px; border: 0; border-radius: 3px; background: transparent; font-size: 13px; font-weight: 600; }
|
|
120
|
+
.date-presets button:hover { background: var(--paper-2); }
|
|
121
|
+
.date-presets button[aria-pressed='true'] { background: var(--ink); color: var(--paper); font-weight: 800; }
|
|
122
|
+
.date-calendar { min-width: 0; padding: 12px; }
|
|
123
|
+
.calendar-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
|
|
124
|
+
.calendar-heading .button { width: 44px; padding: 0; border-color: transparent; font-size: 26px; }
|
|
125
|
+
.calendar-heading h3 { font-size: 17px; }
|
|
126
|
+
.calendar-weekdays, .calendar-days { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); text-align: center; }
|
|
127
|
+
.calendar-weekdays { margin-bottom: 6px; color: var(--ink-2); font-size: 12px; font-weight: 600; }
|
|
128
|
+
.calendar-day { min-height: 44px; padding: 0; border: 1px solid transparent; background: transparent; font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
129
|
+
.calendar-day.outside-month { color: var(--ink-2); font-weight: 400; }
|
|
130
|
+
.calendar-day[aria-current='date'] { text-decoration: underline; text-underline-offset: 5px; }
|
|
131
|
+
.calendar-day[aria-pressed='true'] { background: var(--paper-2); }
|
|
132
|
+
.calendar-day.range-start, .calendar-day.range-end { background: var(--accent-ink); color: var(--paper); font-weight: 800; }
|
|
133
|
+
.calendar-day.range-start { border-radius: 3px 0 0 3px; }
|
|
134
|
+
.calendar-day.range-end { border-radius: 0 3px 3px 0; }
|
|
135
|
+
.calendar-day:hover { border-color: var(--ink); }
|
|
136
|
+
.calendar-day:focus-visible { position: relative; z-index: 1; outline-offset: -3px; }
|
|
137
|
+
.date-help { min-height: 38px; margin-top: 12px; color: var(--ink-2); font-size: 12px; }
|
|
138
|
+
.date-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
|
139
|
+
.date-fields label { display: grid; gap: 4px; color: var(--ink-2); font-size: 12px; font-weight: 700; }
|
|
140
|
+
.date-fields input { width: 100%; min-width: 0; min-height: 44px; padding: 6px; border: 1px solid var(--ink); border-radius: 3px; background: var(--paper); color: var(--ink); font-size: 13px; }
|
|
141
|
+
.date-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 10px; padding: 16px; border-top: 1px solid var(--rule); }
|
|
142
|
+
.date-footer p { flex-basis: 100%; margin-right: auto; color: var(--ink-2); font-size: 13px; font-weight: 600; }
|
|
143
|
+
@media (max-width: 359px) {
|
|
144
|
+
.date-dialog { width: calc(100% - 4px); }
|
|
145
|
+
.date-calendar { padding-inline: 2px; }
|
|
146
|
+
}
|
|
147
|
+
@media (min-width: 620px) {
|
|
148
|
+
.date-body { grid-template-columns: 170px minmax(0, 1fr); }
|
|
149
|
+
.date-presets { grid-template-columns: 1fr; align-content: start; border-bottom: 0; border-right: 1px solid var(--rule); }
|
|
150
|
+
.date-presets button { padding-inline: 12px; text-align: left; font-size: 14px; }
|
|
151
|
+
.date-calendar { padding: 20px; }
|
|
152
|
+
.date-footer p { flex: 1; }
|
|
153
|
+
}
|
|
154
|
+
|
|
110
155
|
.ic { display: inline-block; flex: none; width: 18px; height: 18px; background: currentColor; -webkit-mask: var(--icon) center / contain no-repeat; mask: var(--icon) center / contain no-repeat; }
|
|
111
156
|
.ic-claude { --icon: url('/icons/claude.svg'); }
|
|
112
157
|
.ic-codex { --icon: url('/icons/openai.svg'); }
|
|
@@ -176,7 +221,7 @@ h3 { font-size: 15px; font-weight: 700; letter-spacing: -.01em; }
|
|
|
176
221
|
.tape { position: relative; }
|
|
177
222
|
.tape-plot { position: relative; display: flex; align-items: flex-end; gap: 1px; height: clamp(150px, 22vw, 240px); border-bottom: 2px solid var(--ink); cursor: crosshair; touch-action: pan-y; user-select: none; -webkit-user-select: none; }
|
|
178
223
|
.tape-plot:focus-visible { outline-offset: 8px; }
|
|
179
|
-
.tick { flex: 1 1 0; min-width:
|
|
224
|
+
.tick { flex: 1 1 0; min-width: 0; height: var(--h); background: var(--ink); transform-origin: bottom; }
|
|
180
225
|
.tick.zero { height: 2px; background: var(--ink-3); }
|
|
181
226
|
.tick.peak { background: var(--accent); }
|
|
182
227
|
.veil { position: absolute; top: 0; bottom: 0; background: var(--veil); pointer-events: none; }
|