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 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**, **MimoCode**, **GitHub Copilot**,
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`, `MIMOCODE_HOME`, `COPILOT_HOME`, `ANTIGRAVITY_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 and MimoCode costs are read from
75
- their local databases. Pricing uses standard public API rates and the
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, MIMOCODE_HOME,
70
- COPILOT_HOME, ANTIGRAVITY_HOME, GEMINI_HOME,
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.0",
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
- return summarize(records);
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" || d.key === "mimocode") {
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 $MIMOCODE_HOME",
911
+ " $CLAUDE_HOME $CODEX_HOME $OPENCODE_HOME",
911
912
  );
912
- lines.push(" $COPILOT_HOME $ANTIGRAVITY_HOME $GEMINI_HOME");
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
- const { key, from, to } = state.period;
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
- if (key === 'all') return null;
185
- if (key === 'custom') return [from, to];
186
- if (key === 'today') return [today, today];
187
- if (key === 'week') return [today - (new Date().getDay() + 6) % 7, today];
188
- if (key === 'year') return [dayNumber(new Date(new Date().getFullYear(), 0, 1).getTime()), today];
189
- return [today - Number(key) + 1, today];
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 totals = overall(records);
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
- for (const button of document.querySelectorAll('[data-period]')) {
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
- if (button.dataset.period === 'custom') return;
905
- state.period = { key: button.dataset.period };
906
- state.page = 0;
907
- render();
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
- <div class="filter-group" role="group" aria-label="Period">
46
- <button type="button" data-period="today" aria-pressed="false">Today</button>
47
- <button type="button" data-period="week" aria-pressed="false">This week</button>
48
- <button type="button" data-period="30" aria-pressed="false">30 days</button>
49
- <button type="button" data-period="90" aria-pressed="false">90 days</button>
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: 1px; height: var(--h); background: var(--ink); transform-origin: bottom; }
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; }