ai-usage-analyzer 0.5.1 → 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
@@ -58,6 +58,11 @@ For tools that record token data, the analyzer shows input, output, cache
58
58
  read, cache write, and reasoning tokens. Cache hits are cheap; reasoning is
59
59
  the extended-thinking/chain-of-thought cost.
60
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
+
61
66
  Average daily tokens divides total tokens by calendar days, including days
62
67
  without usage. The dashboard uses the selected date range in your local time.
63
68
  All time uses the first through last recorded day. Terminal, Markdown, and JSON
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-usage-analyzer",
3
- "version": "0.5.1",
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",
package/src/server.js CHANGED
@@ -8,6 +8,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
10
  ['/dates.js', ['../web/dates.js', 'text/javascript; charset=utf-8']],
11
+ ['/card.js', ['../web/card.js', 'text/javascript; charset=utf-8']],
11
12
  ['/aggregate.js', ['./aggregate.js', 'text/javascript; charset=utf-8']],
12
13
  ['/style.css', ['../web/style.css', 'text/css; charset=utf-8']],
13
14
  ['/favicon.svg', ['../web/favicon.svg', 'image/svg+xml']],
package/web/app.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { overall, perProject, perTool, perMonth, topSessions } from '/aggregate.js';
2
+ import { CARD_FORMATS, cardStats, cardSummary, drawCard, cardBlob } from '/card.js';
2
3
  import { dayNumber, periodBounds as boundsForPeriod, dateValue, parseDate, calendarDays } from '/dates.js';
3
4
 
4
5
  const $ = id => document.getElementById(id);
@@ -924,6 +925,7 @@ async function load() {
924
925
  buildToolFilter();
925
926
  $('loading').hidden = true;
926
927
  $('dashboard').hidden = false;
928
+ $('share').disabled = false;
927
929
  setMessage('');
928
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'] });
929
931
  $('updated').textContent = `Read at ${new Date(data.generatedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
@@ -965,6 +967,55 @@ for (const button of document.querySelectorAll('[data-period]')) {
965
967
  renderDatePicker();
966
968
  });
967
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.'));
968
1019
  $('date-filter').addEventListener('click', openDatePicker);
969
1020
  for (const id of ['close-date', 'cancel-date']) $(id).addEventListener('click', () => $('date-dialog').close());
970
1021
  $('date-dialog').addEventListener('close', () => $('date-filter').setAttribute('aria-expanded', 'false'));
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/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>
@@ -217,6 +221,23 @@
217
221
  </div>
218
222
  </dialog>
219
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
+
220
241
  <dialog id="session-dialog" class="drawer" aria-labelledby="session-title">
221
242
  <div class="drawer-body">
222
243
  <div class="drawer-top">
package/web/style.css CHANGED
@@ -140,6 +140,9 @@ h3 { font-size: 15px; font-weight: 700; letter-spacing: -.01em; }
140
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
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
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); }
143
146
  @media (max-width: 359px) {
144
147
  .date-dialog { width: calc(100% - 4px); }
145
148
  .date-calendar { padding-inline: 2px; }