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 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,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 and MimoCode costs are read from
75
- their local databases. Pricing uses standard public API rates and the
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, 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.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
- 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,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
- const { key, from, to } = state.period;
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
- 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];
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 totals = overall(records);
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
- 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
- }
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
- if (button.dataset.period === 'custom') return;
905
- state.period = { key: button.dataset.period };
906
- state.page = 0;
907
- render();
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
- <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>
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: 1px; height: var(--h); background: var(--ink); transform-origin: bottom; }
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; }