ai-usage-analyzer 0.5.0 → 0.6.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/README.md +15 -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 +2 -0
- package/src/tools.js +0 -44
- package/web/app.js +182 -24
- package/web/card.js +216 -0
- package/web/dates.js +35 -0
- package/web/index.html +64 -9
- package/web/style.css +49 -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,17 @@ 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
|
+
Share card in the dashboard header draws a PNG of the selected range and
|
|
62
|
+
tool filter in link-preview (1.91:1), 4:5, or 9:16 format: sessions, projects, active days, best streak, a daily tape, and a
|
|
63
|
+
working-hours persona. Download it or copy it to the clipboard. Project names
|
|
64
|
+
and paths are never drawn on it, and nothing is uploaded.
|
|
65
|
+
|
|
66
|
+
Average daily tokens divides total tokens by calendar days, including days
|
|
67
|
+
without usage. The dashboard uses the selected date range in your local time.
|
|
68
|
+
All time uses the first through last recorded day. Terminal, Markdown, and JSON
|
|
69
|
+
reports use the first through last recorded day in UTC; JSON exposes the value
|
|
70
|
+
as `summary.avgDailyTokens`.
|
|
71
|
+
|
|
63
72
|
## Quotas
|
|
64
73
|
|
|
65
74
|
Reports stay offline unless `--quota` is supplied. This requests Claude and Codex
|
|
@@ -71,8 +80,8 @@ change. Codex credentials may be refreshed and written back to `auth.json`.
|
|
|
71
80
|
Claude and Codex costs use bundled API rates per model and are estimates,
|
|
72
81
|
not subscription charges or invoices. `~` marks an estimate, `*` marks a
|
|
73
82
|
partial total, and `n/a` means unavailable. Unknown models are listed rather
|
|
74
|
-
than assigned a fallback price. OpenCode
|
|
75
|
-
|
|
83
|
+
than assigned a fallback price. OpenCode costs are read from
|
|
84
|
+
its local database. Pricing uses standard public API rates and the
|
|
76
85
|
five-minute Claude cache-write rate; regional, batch, and other modifiers may
|
|
77
86
|
differ. Rates are versioned with the app and can become outdated.
|
|
78
87
|
|
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.
|
|
3
|
+
"version": "0.6.0",
|
|
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,8 @@ 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']],
|
|
11
|
+
['/card.js', ['../web/card.js', 'text/javascript; charset=utf-8']],
|
|
10
12
|
['/aggregate.js', ['./aggregate.js', 'text/javascript; charset=utf-8']],
|
|
11
13
|
['/style.css', ['../web/style.css', 'text/css; charset=utf-8']],
|
|
12
14
|
['/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,6 @@
|
|
|
1
1
|
import { overall, perProject, perTool, perMonth, topSessions } from '/aggregate.js';
|
|
2
|
+
import { CARD_FORMATS, cardStats, cardSummary, drawCard, cardBlob } from '/card.js';
|
|
3
|
+
import { dayNumber, periodBounds as boundsForPeriod, dateValue, parseDate, calendarDays } from '/dates.js';
|
|
2
4
|
|
|
3
5
|
const $ = id => document.getElementById(id);
|
|
4
6
|
const root = document.documentElement;
|
|
@@ -26,6 +28,9 @@ const brandIcons = new Set(['claude', 'codex', 'opencode']);
|
|
|
26
28
|
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
29
|
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
|
|
28
30
|
let animation;
|
|
31
|
+
let dateDraft;
|
|
32
|
+
let calendarMonth;
|
|
33
|
+
let pickingEnd = false;
|
|
29
34
|
|
|
30
35
|
function readTheme() {
|
|
31
36
|
try {
|
|
@@ -169,24 +174,73 @@ function dated(ts, formatter = dayDate) {
|
|
|
169
174
|
return Number.isFinite(ts) && !Number.isNaN(new Date(ts).getTime()) ? formatter.format(ts) : 'Unknown date';
|
|
170
175
|
}
|
|
171
176
|
|
|
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
177
|
function dayLabel(day, formatter = utcDayLong) {
|
|
178
178
|
return formatter.format(day * 86400000);
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
function periodBounds() {
|
|
182
|
-
|
|
182
|
+
return boundsForPeriod(state.period);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function periodLabel(period) {
|
|
186
|
+
if (period.key !== 'custom') return periodLabels[period.key];
|
|
187
|
+
return period.from === period.to ? dayLabel(period.from, utcDayShort) : `${dayLabel(period.from)} to ${dayLabel(period.to)}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function renderDatePicker(focusDay) {
|
|
191
|
+
const bounds = boundsForPeriod(dateDraft);
|
|
192
|
+
const [from, to] = bounds || [];
|
|
193
|
+
const valid = !bounds || (from != null && to != null && from <= to);
|
|
194
|
+
const month = new Date(calendarMonth * 86400000);
|
|
183
195
|
const today = dayNumber(Date.now());
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
196
|
+
$('calendar-month').textContent = new Intl.DateTimeFormat('en', { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(month);
|
|
197
|
+
for (const button of document.querySelectorAll('[data-period]')) button.setAttribute('aria-pressed', String(dateDraft.key === button.dataset.period));
|
|
198
|
+
const days = calendarDays(calendarMonth);
|
|
199
|
+
const tabDay = focusDay ?? (days.includes(from) ? from : days.includes(today) ? today : calendarMonth);
|
|
200
|
+
const container = clear('calendar-days');
|
|
201
|
+
for (const day of days) {
|
|
202
|
+
const date = new Date(day * 86400000);
|
|
203
|
+
const button = el('button', 'calendar-day', date.getUTCDate());
|
|
204
|
+
button.type = 'button';
|
|
205
|
+
button.dataset.day = day;
|
|
206
|
+
button.tabIndex = day === tabDay ? 0 : -1;
|
|
207
|
+
button.setAttribute('aria-label', dayLabel(day));
|
|
208
|
+
button.setAttribute('aria-pressed', String(valid && from != null && day >= from && day <= to));
|
|
209
|
+
if (day === today) button.setAttribute('aria-current', 'date');
|
|
210
|
+
button.classList.toggle('outside-month', date.getUTCMonth() !== month.getUTCMonth());
|
|
211
|
+
button.classList.toggle('range-start', day === from);
|
|
212
|
+
button.classList.toggle('range-end', day === to);
|
|
213
|
+
container.append(button);
|
|
214
|
+
}
|
|
215
|
+
$('date-from').value = dateValue(from);
|
|
216
|
+
$('date-to').value = dateValue(to);
|
|
217
|
+
$('date-from').max = dateValue(to);
|
|
218
|
+
$('date-to').min = dateValue(from);
|
|
219
|
+
$('apply-date').disabled = !valid;
|
|
220
|
+
$('date-help').textContent = from != null && to != null && from > to
|
|
221
|
+
? 'End date must be on or after the start date.'
|
|
222
|
+
: pickingEnd ? 'Now select an end date.' : 'Select a start date, then an end date. Dates use your local time.';
|
|
223
|
+
$('date-preview').textContent = valid ? periodLabel(dateDraft) : 'Choose a complete date range';
|
|
224
|
+
if (focusDay != null) container.querySelector(`[data-day="${focusDay}"]`)?.focus();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function openDatePicker() {
|
|
228
|
+
dateDraft = { ...state.period };
|
|
229
|
+
const day = periodBounds()?.[0] ?? dayNumber(Date.now());
|
|
230
|
+
const month = new Date(day * 86400000);
|
|
231
|
+
month.setUTCDate(1);
|
|
232
|
+
calendarMonth = month.getTime() / 86400000;
|
|
233
|
+
pickingEnd = false;
|
|
234
|
+
renderDatePicker();
|
|
235
|
+
$('date-dialog').showModal();
|
|
236
|
+
$('date-filter').setAttribute('aria-expanded', 'true');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function moveCalendarMonth(step) {
|
|
240
|
+
const date = new Date(calendarMonth * 86400000);
|
|
241
|
+
date.setUTCMonth(date.getUTCMonth() + step);
|
|
242
|
+
calendarMonth = date.getTime() / 86400000;
|
|
243
|
+
renderDatePicker();
|
|
190
244
|
}
|
|
191
245
|
|
|
192
246
|
function periodRecords() {
|
|
@@ -272,7 +326,11 @@ function insightText(totals) {
|
|
|
272
326
|
}
|
|
273
327
|
|
|
274
328
|
function renderSummary(records) {
|
|
275
|
-
const
|
|
329
|
+
const times = records.filter(validTs).map(record => record.ts);
|
|
330
|
+
const range = times.reduce((result, ts) => [Math.min(result[0], ts), Math.max(result[1], ts)], [Infinity, -Infinity]);
|
|
331
|
+
const bounds = periodBounds();
|
|
332
|
+
const dayCount = bounds ? bounds[1] - bounds[0] + 1 : times.length ? dayNumber(range[1]) - dayNumber(range[0]) + 1 : 0;
|
|
333
|
+
const totals = overall(records, dayCount);
|
|
276
334
|
state.previousTotal = state.totalTarget;
|
|
277
335
|
state.totalTarget = totals.tokensTotal;
|
|
278
336
|
setTotal(totals.tokensTotal);
|
|
@@ -281,13 +339,12 @@ function renderSummary(records) {
|
|
|
281
339
|
$('session-count').textContent = number.format(totals.n);
|
|
282
340
|
$('project-count').textContent = number.format(new Set(records.map(record => record.project)).size);
|
|
283
341
|
$('average').textContent = totals.n ? compact.format(totals.avg) : 'None';
|
|
342
|
+
$('daily-average').textContent = compact.format(totals.avgDailyTokens);
|
|
284
343
|
$('cost').textContent = costLabel(totals);
|
|
285
344
|
$('cost-detail').textContent = costDescription(totals.costStatus);
|
|
286
345
|
const days = dailyTotals(records);
|
|
287
346
|
const busiest = [...days].reduce((best, entry) => !best || entry[1] > best[1] ? entry : best, null);
|
|
288
347
|
$('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
348
|
$('range-label').textContent = times.length ? `${dated(range[0])} to ${dated(range[1])}` : '';
|
|
292
349
|
renderComposition(totals);
|
|
293
350
|
}
|
|
@@ -803,11 +860,7 @@ function buildToolFilter() {
|
|
|
803
860
|
}
|
|
804
861
|
|
|
805
862
|
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
|
-
}
|
|
863
|
+
$('date-filter-label').textContent = periodLabel(state.period);
|
|
811
864
|
for (const button of $('tool-filter').children) button.setAttribute('aria-pressed', String(button.dataset.tool === state.tool));
|
|
812
865
|
$('scope').textContent = `${number.format(records.length)} ${records.length === 1 ? 'session' : 'sessions'} in view`;
|
|
813
866
|
}
|
|
@@ -872,6 +925,7 @@ async function load() {
|
|
|
872
925
|
buildToolFilter();
|
|
873
926
|
$('loading').hidden = true;
|
|
874
927
|
$('dashboard').hidden = false;
|
|
928
|
+
$('share').disabled = false;
|
|
875
929
|
setMessage('');
|
|
876
930
|
if (data.errors?.length) setMessage(`${data.errors.length} read ${data.errors.length === 1 ? 'issue needs' : 'issues need'} attention. Totals may be incomplete.`, { link: ['View read issues', '#sources'] });
|
|
877
931
|
$('updated').textContent = `Read at ${new Date(data.generatedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
|
@@ -901,12 +955,116 @@ document.querySelector('.skip-link').addEventListener('click', event => {
|
|
|
901
955
|
});
|
|
902
956
|
for (const button of document.querySelectorAll('[data-period]')) {
|
|
903
957
|
button.addEventListener('click', () => {
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
958
|
+
const key = button.dataset.period;
|
|
959
|
+
const [from, to] = boundsForPeriod(dateDraft) || [dayNumber(Date.now()), dayNumber(Date.now())];
|
|
960
|
+
dateDraft = key === 'custom' ? { key, from, to } : { key };
|
|
961
|
+
pickingEnd = false;
|
|
962
|
+
if (key !== 'all') {
|
|
963
|
+
const month = new Date(boundsForPeriod(dateDraft)[0] * 86400000);
|
|
964
|
+
month.setUTCDate(1);
|
|
965
|
+
calendarMonth = month.getTime() / 86400000;
|
|
966
|
+
}
|
|
967
|
+
renderDatePicker();
|
|
908
968
|
});
|
|
909
969
|
}
|
|
970
|
+
let cardFormat = CARD_FORMATS[0];
|
|
971
|
+
async function paintShareCard() {
|
|
972
|
+
const canvas = $('share-canvas');
|
|
973
|
+
const stats = cardStats(selectedRecords());
|
|
974
|
+
const rangeLabel = state.period.key === 'all' ? 'All time' : periodLabel(state.period);
|
|
975
|
+
const toolLabel = state.tool === 'all' ? '' : toolName(state.tool);
|
|
976
|
+
canvas.setAttribute('aria-label', cardSummary(stats, [rangeLabel, toolLabel].filter(Boolean).join(', ')));
|
|
977
|
+
for (const button of $('share-format').children) button.setAttribute('aria-pressed', String(button.dataset.format === cardFormat.key));
|
|
978
|
+
await drawCard(canvas, stats, { format: cardFormat, rangeLabel, toolLabel, toolName });
|
|
979
|
+
}
|
|
980
|
+
async function openShareCard() {
|
|
981
|
+
$('share-status').textContent = '';
|
|
982
|
+
$('share-dialog').showModal();
|
|
983
|
+
await paintShareCard();
|
|
984
|
+
}
|
|
985
|
+
for (const format of CARD_FORMATS) {
|
|
986
|
+
const button = el('button', '', `${format.label} ${format.ratio}`);
|
|
987
|
+
button.type = 'button';
|
|
988
|
+
button.dataset.format = format.key;
|
|
989
|
+
$('share-format').append(button);
|
|
990
|
+
}
|
|
991
|
+
$('share-format').addEventListener('click', event => {
|
|
992
|
+
const key = event.target.closest('button')?.dataset.format;
|
|
993
|
+
if (!key) return;
|
|
994
|
+
cardFormat = CARD_FORMATS.find(format => format.key === key);
|
|
995
|
+
$('share-status').textContent = '';
|
|
996
|
+
paintShareCard();
|
|
997
|
+
});
|
|
998
|
+
async function shareAction(task, done) {
|
|
999
|
+
try {
|
|
1000
|
+
await task(await cardBlob($('share-canvas')));
|
|
1001
|
+
$('share-status').textContent = done;
|
|
1002
|
+
} catch (error) {
|
|
1003
|
+
$('share-status').textContent = error.name === 'AbortError' ? '' : 'Could not do that in this browser. Download the PNG instead.';
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
$('share').addEventListener('click', openShareCard);
|
|
1007
|
+
$('close-share').addEventListener('click', () => $('share-dialog').close());
|
|
1008
|
+
$('share-dialog').addEventListener('click', event => {
|
|
1009
|
+
if (event.target === event.currentTarget) event.currentTarget.close();
|
|
1010
|
+
});
|
|
1011
|
+
$('download-card').addEventListener('click', () => shareAction(blob => {
|
|
1012
|
+
const link = el('a');
|
|
1013
|
+
link.href = URL.createObjectURL(blob);
|
|
1014
|
+
link.download = `ai-usage-card-${cardFormat.ratio.replace(':', 'x')}.png`;
|
|
1015
|
+
link.click();
|
|
1016
|
+
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
|
|
1017
|
+
}, 'Saved.'));
|
|
1018
|
+
$('copy-card').addEventListener('click', () => shareAction(blob => navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]), 'Copied. Paste it into your post.'));
|
|
1019
|
+
$('date-filter').addEventListener('click', openDatePicker);
|
|
1020
|
+
for (const id of ['close-date', 'cancel-date']) $(id).addEventListener('click', () => $('date-dialog').close());
|
|
1021
|
+
$('date-dialog').addEventListener('close', () => $('date-filter').setAttribute('aria-expanded', 'false'));
|
|
1022
|
+
$('date-dialog').addEventListener('click', event => {
|
|
1023
|
+
if (event.target !== event.currentTarget) return;
|
|
1024
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
1025
|
+
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) event.currentTarget.close();
|
|
1026
|
+
});
|
|
1027
|
+
$('calendar-previous').addEventListener('click', () => moveCalendarMonth(-1));
|
|
1028
|
+
$('calendar-next').addEventListener('click', () => moveCalendarMonth(1));
|
|
1029
|
+
$('calendar-days').addEventListener('click', event => {
|
|
1030
|
+
const button = event.target.closest('[data-day]');
|
|
1031
|
+
if (!button) return;
|
|
1032
|
+
const day = Number(button.dataset.day);
|
|
1033
|
+
dateDraft = pickingEnd
|
|
1034
|
+
? { key: 'custom', from: Math.min(dateDraft.from, day), to: Math.max(dateDraft.from, day) }
|
|
1035
|
+
: { key: 'custom', from: day, to: null };
|
|
1036
|
+
pickingEnd = !pickingEnd;
|
|
1037
|
+
renderDatePicker(day);
|
|
1038
|
+
});
|
|
1039
|
+
$('calendar-days').addEventListener('keydown', event => {
|
|
1040
|
+
const button = event.target.closest('[data-day]');
|
|
1041
|
+
const step = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }[event.key];
|
|
1042
|
+
if (!button || step == null) return;
|
|
1043
|
+
event.preventDefault();
|
|
1044
|
+
const day = Number(button.dataset.day) + step;
|
|
1045
|
+
const month = new Date(day * 86400000);
|
|
1046
|
+
month.setUTCDate(1);
|
|
1047
|
+
calendarMonth = month.getTime() / 86400000;
|
|
1048
|
+
renderDatePicker(day);
|
|
1049
|
+
});
|
|
1050
|
+
for (const id of ['date-from', 'date-to']) $(id).addEventListener('change', () => {
|
|
1051
|
+
dateDraft = { key: 'custom', from: parseDate($('date-from').value), to: parseDate($('date-to').value) };
|
|
1052
|
+
pickingEnd = false;
|
|
1053
|
+
const day = dateDraft[id === 'date-from' ? 'from' : 'to'];
|
|
1054
|
+
if (day != null) {
|
|
1055
|
+
const month = new Date(day * 86400000);
|
|
1056
|
+
month.setUTCDate(1);
|
|
1057
|
+
calendarMonth = month.getTime() / 86400000;
|
|
1058
|
+
}
|
|
1059
|
+
renderDatePicker();
|
|
1060
|
+
});
|
|
1061
|
+
$('apply-date').addEventListener('click', () => {
|
|
1062
|
+
if ($('apply-date').disabled) return;
|
|
1063
|
+
state.period = { ...dateDraft };
|
|
1064
|
+
state.page = 0;
|
|
1065
|
+
render();
|
|
1066
|
+
$('date-dialog').close();
|
|
1067
|
+
});
|
|
910
1068
|
$('tool-filter').addEventListener('click', event => {
|
|
911
1069
|
const button = event.target.closest('button');
|
|
912
1070
|
if (!button) return;
|
package/web/card.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { dayNumber } from './dates.js';
|
|
2
|
+
|
|
3
|
+
export const CARD_FORMATS = [
|
|
4
|
+
{ key: 'link', label: 'Link preview', ratio: '1.91:1', w: 1200, h: 630, pad: 64, stacked: false, headY: 74, headSize: 20, numSize: 190, numY: 270, nameSize: 40, subSize: 28, personaY: 330, personaSize: 34, personaSubSize: 20, mixY: 362, tapeTop: 390, tapeH: 74, figCols: 4, figY: 530, figRow: 0, figSize: 44, figLabelSize: 15, footY: 606, footSize: 15 },
|
|
5
|
+
{ key: 'portrait', label: 'Portrait', ratio: '4:5', w: 1080, h: 1350, pad: 72, stacked: true, headY: 110, headSize: 24, numSize: 300, numY: 420, nameSize: 56, subSize: 30, personaY: 680, personaSize: 48, personaSubSize: 26, mixY: 770, tapeTop: 830, tapeH: 200, figCols: 2, figY: 1130, figRow: 100, figSize: 56, figLabelSize: 17, footY: 1306, footSize: 20 },
|
|
6
|
+
{ key: 'story', label: 'Story', ratio: '9:16', w: 1080, h: 1920, pad: 72, stacked: true, headY: 230, headSize: 26, numSize: 360, numY: 560, nameSize: 64, subSize: 34, personaY: 860, personaSize: 56, personaSubSize: 30, mixY: 960, tapeTop: 1050, tapeH: 280, figCols: 2, figY: 1450, figRow: 118, figSize: 64, figLabelSize: 19, footY: 1664, footSize: 24 },
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
const BRAND = { cli: 'npx ai-usage-analyzer', repo: 'github.com/adetxt/ai-usage-analyzer' };
|
|
10
|
+
|
|
11
|
+
const PERSONAS = [
|
|
12
|
+
['night', 'Night owl', 'Most sessions start after dark.'],
|
|
13
|
+
['morning', 'Early shipper', 'Most sessions start before noon.'],
|
|
14
|
+
['afternoon', 'Daytime builder', 'Most sessions start in the afternoon.'],
|
|
15
|
+
['evening', 'Evening grinder', 'Most sessions start after work hours.'],
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
function bucket(hour) {
|
|
19
|
+
if (hour >= 22 || hour < 5) return 'night';
|
|
20
|
+
if (hour < 12) return 'morning';
|
|
21
|
+
if (hour < 18) return 'afternoon';
|
|
22
|
+
return 'evening';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function persona(times) {
|
|
26
|
+
if (!times.length) return null;
|
|
27
|
+
const weekend = times.filter(ts => [0, 6].includes(new Date(ts).getDay())).length / times.length;
|
|
28
|
+
if (weekend >= 0.35) return ['Weekend warrior', 'A third of the work lands on weekends.'];
|
|
29
|
+
const counts = {};
|
|
30
|
+
for (const ts of times) {
|
|
31
|
+
const key = bucket(new Date(ts).getHours());
|
|
32
|
+
counts[key] = (counts[key] || 0) + 1;
|
|
33
|
+
}
|
|
34
|
+
const [key] = Object.entries(counts).sort((a, b) => b[1] - a[1])[0];
|
|
35
|
+
const [, name, line] = PERSONAS.find(entry => entry[0] === key);
|
|
36
|
+
return [name, line];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function cardStats(records) {
|
|
40
|
+
const dated = records.filter(record => Number.isFinite(record.ts));
|
|
41
|
+
const days = new Map();
|
|
42
|
+
for (const record of dated) {
|
|
43
|
+
const day = dayNumber(record.ts);
|
|
44
|
+
days.set(day, (days.get(day) || 0) + (record.tokensTotal || 0));
|
|
45
|
+
}
|
|
46
|
+
const sorted = [...days.keys()].sort((a, b) => a - b);
|
|
47
|
+
let longest = 0;
|
|
48
|
+
let run = 0;
|
|
49
|
+
sorted.forEach((day, index) => {
|
|
50
|
+
run = index && day === sorted[index - 1] + 1 ? run + 1 : 1;
|
|
51
|
+
longest = Math.max(longest, run);
|
|
52
|
+
});
|
|
53
|
+
const busiest = [...days].reduce((best, entry) => !best || entry[1] > best[1] ? entry : best, null);
|
|
54
|
+
const tools = {};
|
|
55
|
+
for (const record of records) tools[record.tool] = (tools[record.tool] || 0) + (record.tokensTotal || 0);
|
|
56
|
+
const tokens = Object.values(tools).reduce((a, b) => a + b, 0);
|
|
57
|
+
return {
|
|
58
|
+
sessions: records.length,
|
|
59
|
+
projects: new Set(records.map(record => record.project)).size,
|
|
60
|
+
tokens,
|
|
61
|
+
activeDays: days.size,
|
|
62
|
+
longestStreak: longest,
|
|
63
|
+
busiest: busiest ? { day: busiest[0], tokens: busiest[1] } : null,
|
|
64
|
+
tape: sorted.length ? { from: sorted[0], to: sorted.at(-1), days } : null,
|
|
65
|
+
tools: Object.entries(tools).filter(entry => entry[1] > 0).sort((a, b) => b[1] - a[1]),
|
|
66
|
+
persona: persona(dated.map(record => record.ts)),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const compact = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 });
|
|
71
|
+
const whole = new Intl.NumberFormat('en');
|
|
72
|
+
const dayFormat = new Intl.DateTimeFormat('en', { month: 'short', day: 'numeric', timeZone: 'UTC' });
|
|
73
|
+
|
|
74
|
+
export function cardSummary(stats, rangeLabel) {
|
|
75
|
+
if (!stats.sessions) return 'No sessions in this selection.';
|
|
76
|
+
const parts = [
|
|
77
|
+
`${whole.format(stats.sessions)} AI coding sessions`,
|
|
78
|
+
`${whole.format(stats.projects)} projects`,
|
|
79
|
+
`${whole.format(stats.activeDays)} active days`,
|
|
80
|
+
`longest streak ${whole.format(stats.longestStreak)} days`,
|
|
81
|
+
`${compact.format(stats.tokens)} tokens`,
|
|
82
|
+
];
|
|
83
|
+
return `${parts.join(', ')}. ${rangeLabel}.${stats.persona ? ` ${stats.persona[0]}.` : ''}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function colors() {
|
|
87
|
+
const style = getComputedStyle(document.documentElement);
|
|
88
|
+
const read = name => style.getPropertyValue(name).trim();
|
|
89
|
+
return { paper: read('--paper'), ink: read('--ink'), ink2: read('--ink-2'), ink3: read('--ink-3'), rule: read('--rule'), accent: read('--accent') };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function text(ctx, value, x, y, { size, weight = 700, color, align = 'left', track = 0 }) {
|
|
93
|
+
ctx.font = `${weight} ${size}px Manrope, sans-serif`;
|
|
94
|
+
ctx.fillStyle = color;
|
|
95
|
+
ctx.textAlign = align;
|
|
96
|
+
ctx.letterSpacing = `${track}px`;
|
|
97
|
+
ctx.fillText(value, x, y);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function fit(ctx, value, maxWidth) {
|
|
101
|
+
if (ctx.measureText(value).width <= maxWidth) return value;
|
|
102
|
+
let out = value;
|
|
103
|
+
while (out.length > 1 && ctx.measureText(`${out}…`).width > maxWidth) out = out.slice(0, -1);
|
|
104
|
+
return `${out}…`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function dots(ctx, c, w, h) {
|
|
108
|
+
const gap = Math.round(w / 52);
|
|
109
|
+
ctx.fillStyle = c.rule;
|
|
110
|
+
ctx.globalAlpha = .75;
|
|
111
|
+
for (let y = gap; y < h; y += gap) {
|
|
112
|
+
for (let x = gap; x < w; x += gap) {
|
|
113
|
+
ctx.beginPath();
|
|
114
|
+
ctx.arc(x, y, 1.6, 0, Math.PI * 2);
|
|
115
|
+
ctx.fill();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
ctx.globalAlpha = 1;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function drawCard(canvas, stats, { format, rangeLabel, toolLabel, toolName }) {
|
|
122
|
+
await document.fonts.load('800 100px Manrope');
|
|
123
|
+
await document.fonts.load('600 20px Manrope');
|
|
124
|
+
const L = format;
|
|
125
|
+
const scale = 2;
|
|
126
|
+
canvas.width = L.w * scale;
|
|
127
|
+
canvas.height = L.h * scale;
|
|
128
|
+
const ctx = canvas.getContext('2d');
|
|
129
|
+
ctx.scale(scale, scale);
|
|
130
|
+
const c = colors();
|
|
131
|
+
const { pad } = L;
|
|
132
|
+
const right = L.w - pad;
|
|
133
|
+
|
|
134
|
+
ctx.fillStyle = c.paper;
|
|
135
|
+
ctx.fillRect(0, 0, L.w, L.h);
|
|
136
|
+
dots(ctx, c, L.w, L.h);
|
|
137
|
+
ctx.fillStyle = c.ink;
|
|
138
|
+
ctx.fillRect(0, 0, L.w, 10);
|
|
139
|
+
|
|
140
|
+
text(ctx, 'AI USAGE', pad, L.headY, { size: L.headSize, weight: 800, color: c.ink, track: 3 });
|
|
141
|
+
text(ctx, 'ANALYZER', pad + ctx.measureText('AI USAGE').width + 12, L.headY, { size: L.headSize, weight: 400, color: c.ink2, track: 3 });
|
|
142
|
+
const scope = [rangeLabel, toolLabel].filter(Boolean).join(' · ');
|
|
143
|
+
ctx.font = `600 ${L.headSize}px Manrope, sans-serif`;
|
|
144
|
+
text(ctx, fit(ctx, scope, L.w * .52), right, L.headY, { size: L.headSize, weight: 600, color: c.ink2, align: 'right' });
|
|
145
|
+
|
|
146
|
+
if (!stats.sessions) {
|
|
147
|
+
text(ctx, 'No sessions yet.', pad, L.h / 2, { size: L.numSize / 2.6, weight: 800, color: c.ink, track: -2 });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const count = whole.format(stats.sessions);
|
|
152
|
+
const noun = stats.sessions === 1 ? 'session shipped' : 'sessions shipped';
|
|
153
|
+
const across = `across ${whole.format(stats.projects)} ${stats.projects === 1 ? 'project' : 'projects'}`;
|
|
154
|
+
text(ctx, count, pad - L.numSize / 48, L.numY, { size: L.numSize, weight: 800, color: c.accent, track: -L.numSize / 24 });
|
|
155
|
+
if (L.stacked) {
|
|
156
|
+
text(ctx, noun, pad, L.numY + L.nameSize * 1.5, { size: L.nameSize, weight: 800, color: c.ink, track: -1 });
|
|
157
|
+
text(ctx, across, pad, L.numY + L.nameSize * 1.5 + L.subSize * 1.5, { size: L.subSize, weight: 600, color: c.ink2 });
|
|
158
|
+
} else {
|
|
159
|
+
ctx.font = `800 ${L.numSize}px Manrope, sans-serif`;
|
|
160
|
+
ctx.letterSpacing = `${-L.numSize / 24}px`;
|
|
161
|
+
const x = pad + ctx.measureText(count).width + 18;
|
|
162
|
+
text(ctx, noun, x, L.numY, { size: L.nameSize, weight: 800, color: c.ink, track: -1 });
|
|
163
|
+
text(ctx, across, x, L.numY - 52, { size: L.subSize, weight: 600, color: c.ink2 });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (stats.persona) {
|
|
167
|
+
text(ctx, stats.persona[0], pad, L.personaY, { size: L.personaSize, weight: 800, color: c.ink, track: -.5 });
|
|
168
|
+
text(ctx, stats.persona[1], pad, L.personaY + L.personaSize * .95, { size: L.personaSubSize, weight: 600, color: c.ink2 });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const total = stats.tools.reduce((sum, entry) => sum + entry[1], 0) || 1;
|
|
172
|
+
const shares = stats.tools.slice(0, 3).map(([name, value]) => `${toolName(name)} ${Math.round(value / total * 100)}%`);
|
|
173
|
+
const mixWidth = L.stacked ? L.w - pad * 2 : 520;
|
|
174
|
+
ctx.font = `600 ${L.personaSubSize}px Manrope, sans-serif`;
|
|
175
|
+
while (shares.length > 1 && ctx.measureText(shares.join(' · ')).width > mixWidth) shares.pop();
|
|
176
|
+
text(ctx, fit(ctx, shares.join(' · '), mixWidth), L.stacked ? pad : right, L.mixY, { size: L.personaSubSize, weight: 600, color: c.ink2, align: L.stacked ? 'left' : 'right' });
|
|
177
|
+
|
|
178
|
+
const tape = stats.tape;
|
|
179
|
+
const tapeWidth = L.w - pad * 2;
|
|
180
|
+
const span = tape.to - tape.from + 1;
|
|
181
|
+
const columns = Math.min(span, 120);
|
|
182
|
+
const slot = tapeWidth / columns;
|
|
183
|
+
const bars = new Array(columns).fill(0);
|
|
184
|
+
for (const [day, value] of tape.days) {
|
|
185
|
+
bars[Math.min(columns - 1, Math.floor((day - tape.from) / span * columns))] += value;
|
|
186
|
+
}
|
|
187
|
+
const columnPeak = Math.max(...bars, 1);
|
|
188
|
+
bars.forEach((value, index) => {
|
|
189
|
+
const height = value ? Math.max(6, value / columnPeak * L.tapeH) : 2;
|
|
190
|
+
ctx.fillStyle = value && value === columnPeak ? c.accent : value ? c.ink : c.rule;
|
|
191
|
+
ctx.fillRect(pad + index * slot + slot * .2, L.tapeTop + L.tapeH - height, Math.max(1.5, slot * .6), height);
|
|
192
|
+
});
|
|
193
|
+
ctx.fillStyle = c.ink;
|
|
194
|
+
ctx.fillRect(pad, L.tapeTop + L.tapeH + 6, tapeWidth, 2);
|
|
195
|
+
|
|
196
|
+
const figures = [
|
|
197
|
+
[whole.format(stats.activeDays), 'active days'],
|
|
198
|
+
[whole.format(stats.longestStreak), 'day streak'],
|
|
199
|
+
[compact.format(stats.tokens), 'tokens'],
|
|
200
|
+
[stats.busiest ? dayFormat.format(stats.busiest.day * 86400000) : '–', 'busiest day'],
|
|
201
|
+
];
|
|
202
|
+
const cell = tapeWidth / L.figCols;
|
|
203
|
+
figures.forEach(([value, label], index) => {
|
|
204
|
+
const x = pad + index % L.figCols * cell;
|
|
205
|
+
const y = L.figY + Math.floor(index / L.figCols) * L.figRow;
|
|
206
|
+
text(ctx, value, x, y, { size: L.figSize, weight: 800, color: c.ink, track: -1.5 });
|
|
207
|
+
text(ctx, label.toUpperCase(), x, y + L.figLabelSize * 2, { size: L.figLabelSize, weight: 700, color: c.ink2, track: 1.5 });
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
text(ctx, BRAND.cli, pad, L.footY, { size: L.footSize, weight: 700, color: c.ink, track: .3 });
|
|
211
|
+
text(ctx, BRAND.repo, right, L.footY, { size: L.footSize, weight: 600, color: c.ink2, align: 'right', track: .3 });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function cardBlob(canvas) {
|
|
215
|
+
return new Promise((resolve, reject) => canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('Could not render the card.')), 'image/png'));
|
|
216
|
+
}
|
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
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
<svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="6.5"/><path class="fill" d="M10 3.5a6.5 6.5 0 0 0 0 13z"/></svg>
|
|
26
26
|
<span>Dark</span>
|
|
27
27
|
</button>
|
|
28
|
+
<button id="share" class="button quiet" type="button" aria-haspopup="dialog" aria-controls="share-dialog" disabled>
|
|
29
|
+
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 12.5V3.5M6.5 7 10 3.5 13.5 7M4 11v5h12v-5"/></svg>
|
|
30
|
+
<span>Share card</span>
|
|
31
|
+
</button>
|
|
28
32
|
<button id="refresh" class="button solid" type="button">
|
|
29
33
|
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M16.5 8a6.7 6.7 0 1 0-.4 5M16.5 3.5V8H12"/></svg>
|
|
30
34
|
<span id="refresh-label">Refresh records</span>
|
|
@@ -42,15 +46,11 @@
|
|
|
42
46
|
|
|
43
47
|
<div id="dashboard" hidden>
|
|
44
48
|
<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>
|
|
49
|
+
<button id="date-filter" class="button quiet date-trigger" type="button" aria-haspopup="dialog" aria-controls="date-dialog" aria-expanded="false">
|
|
50
|
+
<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>
|
|
51
|
+
<span id="date-filter-label">All time</span>
|
|
52
|
+
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m6 8 4 4 4-4"/></svg>
|
|
53
|
+
</button>
|
|
54
54
|
<div id="tool-filter" class="filter-group tool-filter" role="group" aria-label="Tool"></div>
|
|
55
55
|
<p id="scope" class="scope"></p>
|
|
56
56
|
</div>
|
|
@@ -67,6 +67,7 @@
|
|
|
67
67
|
<div><dt>Sessions</dt><dd id="session-count"></dd></div>
|
|
68
68
|
<div><dt>Projects</dt><dd id="project-count"></dd></div>
|
|
69
69
|
<div><dt>Average session</dt><dd id="average"></dd></div>
|
|
70
|
+
<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
71
|
<div><dt>Busiest day</dt><dd id="busiest-day"></dd></div>
|
|
71
72
|
<div class="slip-cost"><dt>API cost</dt><dd id="cost"></dd><p id="cost-detail"></p></div>
|
|
72
73
|
</dl>
|
|
@@ -183,6 +184,60 @@
|
|
|
183
184
|
<p id="updated"></p>
|
|
184
185
|
</footer>
|
|
185
186
|
|
|
187
|
+
<dialog id="date-dialog" class="date-dialog" aria-labelledby="date-title" aria-describedby="date-help">
|
|
188
|
+
<div class="date-heading">
|
|
189
|
+
<h2 id="date-title">Date range</h2>
|
|
190
|
+
<button id="close-date" class="button quiet" type="button" aria-label="Close date picker">Close</button>
|
|
191
|
+
</div>
|
|
192
|
+
<div class="date-body">
|
|
193
|
+
<div class="date-presets" role="group" aria-label="Date presets">
|
|
194
|
+
<button type="button" data-period="today" aria-pressed="false">Today</button>
|
|
195
|
+
<button type="button" data-period="week" aria-pressed="false">This week</button>
|
|
196
|
+
<button type="button" data-period="30" aria-pressed="false">Last 30 days</button>
|
|
197
|
+
<button type="button" data-period="90" aria-pressed="false">Last 90 days</button>
|
|
198
|
+
<button type="button" data-period="year" aria-pressed="false">This year</button>
|
|
199
|
+
<button type="button" data-period="all" aria-pressed="true">All time</button>
|
|
200
|
+
<button type="button" data-period="custom" aria-pressed="false">Custom range</button>
|
|
201
|
+
</div>
|
|
202
|
+
<div class="date-calendar">
|
|
203
|
+
<div class="calendar-heading">
|
|
204
|
+
<button id="calendar-previous" class="button quiet" type="button" aria-label="Previous month">‹</button>
|
|
205
|
+
<h3 id="calendar-month" aria-live="polite"></h3>
|
|
206
|
+
<button id="calendar-next" class="button quiet" type="button" aria-label="Next month">›</button>
|
|
207
|
+
</div>
|
|
208
|
+
<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>
|
|
209
|
+
<div id="calendar-days" class="calendar-days" role="group" aria-labelledby="calendar-month"></div>
|
|
210
|
+
<p id="date-help" class="date-help" aria-live="polite">Select a start date, then an end date.</p>
|
|
211
|
+
<div class="date-fields">
|
|
212
|
+
<label>Start date<input id="date-from" type="date" required></label>
|
|
213
|
+
<label>End date<input id="date-to" type="date" required></label>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
<div class="date-footer">
|
|
218
|
+
<p id="date-preview" aria-live="polite"></p>
|
|
219
|
+
<button id="cancel-date" class="button quiet" type="button">Cancel</button>
|
|
220
|
+
<button id="apply-date" class="button solid" type="button">Apply</button>
|
|
221
|
+
</div>
|
|
222
|
+
</dialog>
|
|
223
|
+
|
|
224
|
+
<dialog id="share-dialog" class="date-dialog share-dialog" aria-labelledby="share-title">
|
|
225
|
+
<div class="date-heading">
|
|
226
|
+
<h2 id="share-title">Share card</h2>
|
|
227
|
+
<button id="close-share" class="button quiet" type="button" aria-label="Close share card">Close</button>
|
|
228
|
+
</div>
|
|
229
|
+
<div class="share-body">
|
|
230
|
+
<div id="share-format" class="filter-group" role="group" aria-label="Card format"></div>
|
|
231
|
+
<canvas id="share-canvas" role="img"></canvas>
|
|
232
|
+
<p class="date-help">Built from the current date range and tool filter. Project names and paths are never drawn on the card.</p>
|
|
233
|
+
</div>
|
|
234
|
+
<div class="date-footer">
|
|
235
|
+
<p id="share-status" aria-live="polite"></p>
|
|
236
|
+
<button id="copy-card" class="button quiet" type="button">Copy image</button>
|
|
237
|
+
<button id="download-card" class="button solid" type="button">Download PNG</button>
|
|
238
|
+
</div>
|
|
239
|
+
</dialog>
|
|
240
|
+
|
|
186
241
|
<dialog id="session-dialog" class="drawer" aria-labelledby="session-title">
|
|
187
242
|
<div class="drawer-body">
|
|
188
243
|
<div class="drawer-top">
|
package/web/style.css
CHANGED
|
@@ -107,6 +107,54 @@ 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
|
+
.share-dialog { width: min(820px, calc(100% - 24px)); }
|
|
144
|
+
.share-body { display: grid; gap: 12px; padding: 16px; }
|
|
145
|
+
.share-body canvas { display: block; width: auto; height: auto; max-width: 100%; max-height: 58dvh; margin-inline: auto; border: 1px solid var(--rule); }
|
|
146
|
+
@media (max-width: 359px) {
|
|
147
|
+
.date-dialog { width: calc(100% - 4px); }
|
|
148
|
+
.date-calendar { padding-inline: 2px; }
|
|
149
|
+
}
|
|
150
|
+
@media (min-width: 620px) {
|
|
151
|
+
.date-body { grid-template-columns: 170px minmax(0, 1fr); }
|
|
152
|
+
.date-presets { grid-template-columns: 1fr; align-content: start; border-bottom: 0; border-right: 1px solid var(--rule); }
|
|
153
|
+
.date-presets button { padding-inline: 12px; text-align: left; font-size: 14px; }
|
|
154
|
+
.date-calendar { padding: 20px; }
|
|
155
|
+
.date-footer p { flex: 1; }
|
|
156
|
+
}
|
|
157
|
+
|
|
110
158
|
.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
159
|
.ic-claude { --icon: url('/icons/claude.svg'); }
|
|
112
160
|
.ic-codex { --icon: url('/icons/openai.svg'); }
|
|
@@ -176,7 +224,7 @@ h3 { font-size: 15px; font-weight: 700; letter-spacing: -.01em; }
|
|
|
176
224
|
.tape { position: relative; }
|
|
177
225
|
.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
226
|
.tape-plot:focus-visible { outline-offset: 8px; }
|
|
179
|
-
.tick { flex: 1 1 0; min-width:
|
|
227
|
+
.tick { flex: 1 1 0; min-width: 0; height: var(--h); background: var(--ink); transform-origin: bottom; }
|
|
180
228
|
.tick.zero { height: 2px; background: var(--ink-3); }
|
|
181
229
|
.tick.peak { background: var(--accent); }
|
|
182
230
|
.veil { position: absolute; top: 0; bottom: 0; background: var(--veil); pointer-events: none; }
|