agentlytics 0.1.10 → 0.1.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlytics",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Comprehensive analytics dashboard for AI coding agents — Cursor, Windsurf, Claude Code, VS Code Copilot, Zed, Antigravity, OpenCode, Command Code",
5
5
  "main": "index.js",
6
6
  "bin": {
package/server.js CHANGED
@@ -266,7 +266,16 @@ app.get('/api/share-image', (req, res) => {
266
266
  try {
267
267
  const overview = cache.getCachedOverview();
268
268
  const stats = cache.getCachedDashboardStats();
269
- const svg = generateShareSvg(overview, stats);
269
+ const costs = cache.getCostAnalytics({ hiddenFolders: getHiddenFolders() });
270
+ const opts = {};
271
+ if (req.query.showEditors !== undefined) opts.showEditors = req.query.showEditors !== 'false';
272
+ if (req.query.showModels !== undefined) opts.showModels = req.query.showModels !== 'false';
273
+ if (req.query.showCosts !== undefined) opts.showCosts = req.query.showCosts !== 'false';
274
+ if (req.query.showTokens !== undefined) opts.showTokens = req.query.showTokens !== 'false';
275
+ if (req.query.showHours !== undefined) opts.showHours = req.query.showHours !== 'false';
276
+ if (req.query.username) opts.username = req.query.username;
277
+ if (req.query.theme) opts.theme = req.query.theme;
278
+ const svg = generateShareSvg(overview, stats, costs, opts);
270
279
  res.setHeader('Content-Type', 'image/svg+xml');
271
280
  res.send(svg);
272
281
  } catch (err) {
package/share-image.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Generates a shareable SVG stats card from cached data.
3
+ * Accepts an `opts` object to toggle sections on/off.
3
4
  */
4
5
 
5
6
  function fmt(n) {
@@ -9,6 +10,14 @@ function fmt(n) {
9
10
  return n.toLocaleString();
10
11
  }
11
12
 
13
+ function fmtCost(n) {
14
+ if (n == null || n === 0) return '$0';
15
+ if (n < 0.01) return '<$0.01';
16
+ if (n >= 1000) return '$' + (n / 1000).toFixed(1) + 'K';
17
+ if (n >= 100) return '$' + Math.round(n);
18
+ return '$' + n.toFixed(2);
19
+ }
20
+
12
21
  const EDITOR_COLORS = {
13
22
  'cursor': '#f59e0b',
14
23
  'windsurf': '#06b6d4',
@@ -20,9 +29,11 @@ const EDITOR_COLORS = {
20
29
  'vscode-insiders': '#60a5fa',
21
30
  'zed': '#10b981',
22
31
  'opencode': '#ec4899',
32
+ 'codex': '#0f766e',
23
33
  'gemini-cli': '#4285f4',
24
34
  'copilot-cli': '#8957e5',
25
35
  'cursor-agent': '#f59e0b',
36
+ 'commandcode': '#e11d48',
26
37
  };
27
38
 
28
39
  const EDITOR_LABELS = {
@@ -36,107 +47,309 @@ const EDITOR_LABELS = {
36
47
  'vscode-insiders': 'VS Code Ins.',
37
48
  'zed': 'Zed',
38
49
  'opencode': 'OpenCode',
50
+ 'codex': 'Codex',
39
51
  'gemini-cli': 'Gemini CLI',
40
52
  'copilot-cli': 'Copilot CLI',
41
53
  'cursor-agent': 'Cursor Agent',
54
+ 'commandcode': 'Cmd Code',
42
55
  };
43
56
 
44
- function generateShareSvg(overview, stats) {
45
- const W = 800, H = 440;
46
- const F = "Menlo, Monaco, Cascadia Code, Courier New, monospace";
57
+ /**
58
+ * @param {object} overview — from getCachedOverview()
59
+ * @param {object} stats — from getCachedDashboardStats()
60
+ * @param {object} costs — from getCostBreakdown()
61
+ * @param {object} [opts] — toggle sections
62
+ * @param {boolean} [opts.showEditors=true]
63
+ * @param {boolean} [opts.showModels=true]
64
+ * @param {boolean} [opts.showCosts=true]
65
+ * @param {boolean} [opts.showTokens=true]
66
+ * @param {boolean} [opts.showHours=true]
67
+ * @param {boolean} [opts.showProjects=true]
68
+ * @param {string} [opts.username]
69
+ * @param {string} [opts.theme='dark'] — 'dark' or 'light'
70
+ */
71
+ const THEMES = {
72
+ dark: {
73
+ bg: '#09090f', bg2: '#111118', card: '#111', border: '#1e1e2a',
74
+ text: '#fff', text2: '#888', text3: '#666', text4: '#555', text5: '#444',
75
+ titleText: '#555',
76
+ hourHigh: '#818cf8', hourMed: '#6366f1', hourLow: '#4f46e5', hourMin: '#1e1b4b',
77
+ },
78
+ light: {
79
+ bg: '#f8f8fa', bg2: '#eeeef2', card: '#e8e8ee', border: '#d0d0d8',
80
+ text: '#111', text2: '#555', text3: '#888', text4: '#999', text5: '#aaa',
81
+ titleText: '#888',
82
+ hourHigh: '#6366f1', hourMed: '#818cf8', hourLow: '#a5b4fc', hourMin: '#e0e7ff',
83
+ },
84
+ };
85
+
86
+ function generateShareSvg(overview, stats, costs, opts = {}) {
87
+ const show = {
88
+ editors: opts.showEditors !== false,
89
+ models: opts.showModels !== false,
90
+ costs: opts.showCosts !== false,
91
+ tokens: opts.showTokens !== false,
92
+ hours: opts.showHours !== false,
93
+ };
94
+ const username = opts.username || '';
95
+ const t = THEMES[opts.theme] || THEMES.dark;
96
+
97
+ const W = 1200;
98
+ const H_FIXED = 675;
99
+ const F = "'Menlo','Monaco','Cascadia Code','Courier New',monospace";
47
100
  const editors = overview.editors || [];
48
101
  const tk = stats.tokens || {};
49
102
  const streaks = stats.streaks || {};
50
103
  const topModels = (stats.topModels || []).slice(0, 5);
104
+ const costData = costs || {};
105
+ const totalCost = costData.totalCost || 0;
106
+ const costByEditor = (costData.byEditor || []).slice(0, 6);
107
+ const totalTokens = (tk.input || 0) + (tk.output || 0);
108
+ const now = new Date();
109
+ const dateStr = now.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
110
+
111
+ // ── Compute layout ──
112
+ let y = 0;
113
+ const pad = 28;
114
+
115
+ // Title bar
116
+ y += 40;
117
+ // Header area (prompt + branding)
118
+ y += 54;
119
+ // KPI row
120
+ y += 80;
121
+ // Sections
122
+ const midSections = [];
123
+ if (show.editors) midSections.push('editors');
124
+ if (show.costs && totalCost > 0) midSections.push('costs');
125
+ if (show.hours) midSections.push('hours');
126
+ if (show.models && topModels.length > 0) midSections.push('models');
127
+
128
+ // Layout: 2 columns, each section ~row
129
+ const leftSections = [];
130
+ const rightSections = [];
131
+ midSections.forEach((s, i) => {
132
+ if (i % 2 === 0) leftSections.push(s);
133
+ else rightSections.push(s);
134
+ });
135
+
136
+ function sectionHeight(name) {
137
+ if (name === 'editors') return Math.max(editors.slice(0, 8).length * 26 + 34, 70);
138
+ if (name === 'costs') return Math.max(costByEditor.length * 26 + 34, 70);
139
+ if (name === 'hours') return 110;
140
+ if (name === 'models') return topModels.length * 22 + 34;
141
+ return 70;
142
+ }
143
+
144
+ // Mid-section pairs: compute max height of each row
145
+ const rowCount = Math.max(leftSections.length, rightSections.length);
146
+ let midHeight = 0;
147
+ for (let i = 0; i < rowCount; i++) {
148
+ const lh = leftSections[i] ? sectionHeight(leftSections[i]) : 0;
149
+ const rh = rightSections[i] ? sectionHeight(rightSections[i]) : 0;
150
+ midHeight += Math.max(lh, rh) + 8;
151
+ }
152
+
153
+ // Token footer
154
+ const tokenFooterH = show.tokens ? 44 : 0;
155
+ // Footer
156
+ const footerH = 36;
157
+
158
+ const naturalH = y + midHeight + tokenFooterH + footerH + 20;
159
+ // Stretch: if content is shorter than 675, distribute extra space into sections
160
+ const extraSpace = Math.max(0, H_FIXED - naturalH);
161
+ const extraPerRow = rowCount > 0 ? extraSpace / rowCount : 0;
51
162
 
52
- // Editor bar chart
163
+ // ── KPI cards ──
164
+ const kpiY = 94;
165
+ const kpiCards = [];
166
+ const kpiItems = [
167
+ { label: 'sessions', value: fmt(overview.totalChats) },
168
+ { label: 'tokens', value: fmt(totalTokens) },
169
+ { label: 'active days', value: String(streaks.totalDays || 0) },
170
+ { label: 'streak', value: `${streaks.current || 0}d` },
171
+ ];
172
+ if (show.costs && totalCost > 0) {
173
+ kpiItems.push({ label: 'est. cost', value: fmtCost(totalCost) });
174
+ }
175
+ const kpiW = (W - pad * 2 - (kpiItems.length - 1) * 8) / kpiItems.length;
176
+ kpiItems.forEach((item, i) => {
177
+ const x = pad + i * (kpiW + 8);
178
+ kpiCards.push(`
179
+ <rect x="${x}" y="${kpiY}" width="${kpiW}" height="64" rx="6" fill="${t.card}"/>
180
+ <text x="${x + 14}" y="${kpiY + 22}" fill="${t.text3}" font-size="11" font-family=${F}>${esc(item.label)}</text>
181
+ <text x="${x + 14}" y="${kpiY + 50}" fill="${t.text}" font-size="24" font-weight="bold" font-family=${F}>${esc(item.value)}</text>
182
+ `);
183
+ });
184
+
185
+ // ── Editor bar chart ──
53
186
  const maxEditorCount = Math.max(...editors.map(e => e.count), 1);
54
- const editorBars = editors.slice(0, 8).map((e, i) => {
55
- const barW = Math.max((e.count / maxEditorCount) * 180, 4);
187
+ const editorBarsArr = editors.slice(0, 8).map((e, i) => {
188
+ const maxBarW = W / 2 - pad - 140;
189
+ const barW = Math.max((e.count / maxEditorCount) * maxBarW, 4);
56
190
  const color = EDITOR_COLORS[e.id] || '#6b7280';
57
- const label = (EDITOR_LABELS[e.id] || e.id).padEnd(12);
58
- const y = 170 + i * 22;
59
- return `
60
- <text x="30" y="${y + 12}" fill="#888" font-size="10" font-family="${F}">${esc(label)}</text>
61
- <rect x="140" y="${y + 1}" width="${barW}" height="14" fill="${color}" opacity="0.8"/>
62
- <text x="${146 + barW}" y="${y + 12}" fill="#aaa" font-size="9" font-family="${F}">${e.count}</text>
63
- `;
64
- }).join('');
65
-
66
- // Activity sparkline from hourly data
191
+ const label = (EDITOR_LABELS[e.id] || e.id);
192
+ return { label, barW, color, count: e.count };
193
+ });
194
+
195
+ // ── Cost bar chart ──
196
+ const maxCostVal = costByEditor.length > 0 ? Math.max(...costByEditor.map(c => c.cost), 0.01) : 1;
197
+ const costBarsArr = costByEditor.map(c => {
198
+ const maxBarW = W / 2 - pad - 140;
199
+ const barW = Math.max((c.cost / maxCostVal) * maxBarW, 4);
200
+ const color = EDITOR_COLORS[c.editor] || '#6b7280';
201
+ const label = EDITOR_LABELS[c.editor] || c.editor;
202
+ return { label, barW, color, value: fmtCost(c.cost) };
203
+ });
204
+
205
+ // ── Hourly sparkline ──
67
206
  const hourly = stats.hourly || new Array(24).fill(0);
68
- const maxH = Math.max(...hourly, 1);
69
- const sparkW = 180, sparkH = 40;
70
- const sparkPoints = hourly.map((v, i) => {
71
- const x = 590 + (i / 23) * sparkW;
72
- const y = 180 + sparkH - (v / maxH) * sparkH;
73
- return `${x},${y}`;
74
- }).join(' ');
75
-
76
- // Top models list
77
- const modelsList = topModels.map((m, i) => {
78
- const y = 274 + i * 16;
79
- const name = m.name.length > 24 ? m.name.substring(0, 24) : m.name;
80
- return `<text x="590" y="${y}" fill="#888" font-size="9" font-family="${F}">${esc(name)} <tspan fill="#555">${m.count}</tspan></text>`;
81
- }).join('');
82
-
83
- const dateStr = new Date().toISOString().split('T')[0];
84
-
85
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
86
- <!-- Background -->
87
- <rect width="${W}" height="${H}" fill="#000"/>
88
- <rect x="0.5" y="0.5" width="${W - 1}" height="${H - 1}" fill="none" stroke="#222" stroke-width="1"/>
207
+ const maxHourly = Math.max(...hourly, 1);
89
208
 
90
- <!-- Terminal title bar -->
91
- <rect x="0" y="0" width="${W}" height="32" fill="#111"/>
92
- <text x="${W / 2}" y="20" fill="#555" font-size="11" font-family="${F}" text-anchor="middle">agentlytics</text>
209
+ // ── Build sections ──
210
+ let curY = kpiY + 64 + 18;
211
+ const sectionSvgs = [];
212
+ const colW = (W - pad * 2 - 20) / 2;
93
213
 
94
- <!-- Prompt line -->
95
- <text x="24" y="58" fill="#666" font-size="12" font-family="${F}">$</text>
96
- <text x="40" y="58" fill="#ccc" font-size="12" font-family="${F}">npx agentlytics</text>
214
+ for (let row = 0; row < rowCount; row++) {
215
+ const lName = leftSections[row];
216
+ const rName = rightSections[row];
217
+ const lh = lName ? sectionHeight(lName) : 0;
218
+ const rh = rName ? sectionHeight(rName) : 0;
219
+ const rowH = Math.max(lh, rh) + Math.round(extraPerRow);
97
220
 
98
- <!-- Divider -->
99
- <line x1="24" y1="68" x2="${W - 24}" y2="68" stroke="#222" stroke-width="1"/>
221
+ if (lName) sectionSvgs.push(renderSection(lName, pad, curY, colW, rowH));
222
+ if (rName) sectionSvgs.push(renderSection(rName, pad + colW + 20, curY, colW, rowH));
223
+
224
+ curY += rowH + 10;
225
+ }
226
+
227
+ function renderSection(name, sx, sy, sw, sh) {
228
+ let out = '';
229
+ if (name === 'editors') {
230
+ out += `<text x="${sx}" y="${sy + 14}" fill="${t.text4}" font-size="11" font-family=${F}># editors</text>`;
231
+ editorBarsArr.forEach((e, i) => {
232
+ const by = sy + 26 + i * 26;
233
+ out += `<text x="${sx}" y="${by + 14}" fill="${t.text2}" font-size="12" font-family=${F}>${esc(e.label)}</text>`;
234
+ out += `<rect x="${sx + 120}" y="${by + 1}" width="${e.barW}" height="16" rx="3" fill="${e.color}" opacity="0.85"/>`;
235
+ out += `<text x="${sx + 128 + e.barW}" y="${by + 14}" fill="${t.text3}" font-size="11" font-family=${F}>${e.count}</text>`;
236
+ });
237
+ } else if (name === 'costs') {
238
+ out += `<text x="${sx}" y="${sy + 14}" fill="${t.text4}" font-size="11" font-family=${F}># est. costs by editor</text>`;
239
+ costBarsArr.forEach((c, i) => {
240
+ const by = sy + 26 + i * 26;
241
+ out += `<text x="${sx}" y="${by + 14}" fill="${t.text2}" font-size="12" font-family=${F}>${esc(c.label)}</text>`;
242
+ out += `<rect x="${sx + 120}" y="${by + 1}" width="${c.barW}" height="16" rx="3" fill="${c.color}" opacity="0.85"/>`;
243
+ out += `<text x="${sx + 128 + c.barW}" y="${by + 14}" fill="${t.text3}" font-size="11" font-family=${F}>${c.value}</text>`;
244
+ });
245
+ } else if (name === 'hours') {
246
+ out += `<text x="${sx}" y="${sy + 14}" fill="${t.text4}" font-size="11" font-family=${F}># peak hours</text>`;
247
+ const sparkW = sw - 8;
248
+ const sparkH = 56;
249
+ const baseY = sy + 28;
250
+ const barW = sparkW / 24 - 1;
251
+ hourly.forEach((v, i) => {
252
+ const bh = Math.max((v / maxHourly) * sparkH, 1);
253
+ const bx = sx + i * (barW + 1);
254
+ const by = baseY + sparkH - bh;
255
+ const intensity = v / maxHourly;
256
+ const color = intensity > 0.75 ? t.hourHigh : intensity > 0.5 ? t.hourMed : intensity > 0.25 ? t.hourLow : t.hourMin;
257
+ out += `<rect x="${bx}" y="${by}" width="${barW}" height="${bh}" rx="1" fill="${color}" opacity="0.9"/>`;
258
+ });
259
+ out += `<text x="${sx}" y="${baseY + sparkH + 14}" fill="${t.text5}" font-size="9" font-family=${F}>00</text>`;
260
+ out += `<text x="${sx + sparkW / 4}" y="${baseY + sparkH + 14}" fill="${t.text5}" font-size="9" font-family=${F}>06</text>`;
261
+ out += `<text x="${sx + sparkW / 2}" y="${baseY + sparkH + 14}" fill="${t.text5}" font-size="9" font-family=${F}>12</text>`;
262
+ out += `<text x="${sx + sparkW * 3 / 4}" y="${baseY + sparkH + 14}" fill="${t.text5}" font-size="9" font-family=${F}>18</text>`;
263
+ out += `<text x="${sx + sparkW - 8}" y="${baseY + sparkH + 14}" fill="${t.text5}" font-size="9" font-family=${F}>23</text>`;
264
+ } else if (name === 'models') {
265
+ out += `<text x="${sx}" y="${sy + 14}" fill="${t.text4}" font-size="11" font-family=${F}># top models</text>`;
266
+ topModels.forEach((m, i) => {
267
+ const my = sy + 32 + i * 22;
268
+ const mName = m.name.length > 28 ? m.name.substring(0, 28) + '…' : m.name;
269
+ out += `<text x="${sx}" y="${my}" fill="${t.text2}" font-size="11" font-family=${F}>${esc(mName)}</text>`;
270
+ out += `<text x="${sx + sw - 4}" y="${my}" fill="${t.text4}" font-size="11" font-family=${F} text-anchor="end">${m.count}</text>`;
271
+ });
272
+ }
273
+ return out;
274
+ }
100
275
 
101
- <!-- KPI row -->
102
- <rect x="24" y="78" width="175" height="58" fill="#111"/>
103
- <text x="36" y="96" fill="#666" font-size="9" font-family="${F}">sessions</text>
104
- <text x="36" y="122" fill="#fff" font-size="22" font-weight="bold" font-family="${F}">${fmt(overview.totalChats)}</text>
276
+ // ── Token footer line ──
277
+ let tokenLine = '';
278
+ if (show.tokens) {
279
+ const ty = curY + 4;
280
+ const tkPairs = [
281
+ ['input', fmt(tk.input)],
282
+ ['output', fmt(tk.output)],
283
+ ['cache read', fmt(tk.cacheRead)],
284
+ ['cache write', fmt(tk.cacheWrite)],
285
+ ['tools', fmt(stats.totalToolCalls || 0)],
286
+ ['editors', String(editors.length)],
287
+ ];
288
+ let tkX = pad;
289
+ let tkSvg = `<line x1="${pad}" y1="${ty}" x2="${W - pad}" y2="${ty}" stroke="${t.border}" stroke-width="1"/>`;
290
+ tkPairs.forEach(([label, val]) => {
291
+ tkSvg += `<text x="${tkX}" y="${ty + 20}" fill="${t.text5}" font-size="10" font-family=${F}>${label}</text>`;
292
+ tkSvg += `<text x="${tkX}" y="${ty + 34}" fill="${t.text2}" font-size="12" font-weight="600" font-family=${F}>${val}</text>`;
293
+ tkX += (W - pad * 2) / tkPairs.length;
294
+ });
295
+ tokenLine = tkSvg;
296
+ curY = ty + tokenFooterH;
297
+ }
105
298
 
106
- <rect x="210" y="78" width="175" height="58" fill="#111"/>
107
- <text x="222" y="96" fill="#666" font-size="9" font-family="${F}">tokens</text>
108
- <text x="222" y="122" fill="#fff" font-size="22" font-weight="bold" font-family="${F}">${fmt((tk.input || 0) + (tk.output || 0))}</text>
299
+ // ── Final footer pin to bottom of canvas ──
300
+ const footerY = H_FIXED - 28;
109
301
 
110
- <rect x="396" y="78" width="175" height="58" fill="#111"/>
111
- <text x="408" y="96" fill="#666" font-size="9" font-family="${F}">active_days</text>
112
- <text x="408" y="122" fill="#fff" font-size="22" font-weight="bold" font-family="${F}">${streaks.totalDays || 0}</text>
302
+ const usernameText = username ? `<text x="${W / 2}" y="${footerY + 14}" fill="${t.text5}" font-size="11" font-family=${F} text-anchor="middle">${esc(username)}</text>` : '';
113
303
 
114
- <rect x="582" y="78" width="194" height="58" fill="#111"/>
115
- <text x="594" y="96" fill="#666" font-size="9" font-family="${F}">streak <tspan fill="#555">longest:${streaks.longest || 0}</tspan></text>
116
- <text x="594" y="122" fill="#fff" font-size="22" font-weight="bold" font-family="${F}">${streaks.current || 0} <tspan font-size="11" fill="#666">day${(streaks.current || 0) !== 1 ? 's' : ''}</tspan></text>
304
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="675" viewBox="0 0 ${W} ${H_FIXED}">
305
+ <defs>
306
+ <linearGradient id="accentGrad" x1="0" y1="0" x2="1" y2="0">
307
+ <stop offset="0%" stop-color="#818cf8"/>
308
+ <stop offset="50%" stop-color="#a78bfa"/>
309
+ <stop offset="100%" stop-color="#c084fc"/>
310
+ </linearGradient>
311
+ </defs>
117
312
 
118
- <!-- Editors section -->
119
- <text x="24" y="160" fill="#666" font-size="10" font-family="${F}"># editors</text>
120
- ${editorBars}
313
+ <!-- Background -->
314
+ <rect width="${W}" height="${H_FIXED}" rx="12" fill="${t.bg}"/>
315
+ <rect x="0.5" y="0.5" width="${W - 1}" height="${H_FIXED - 1}" rx="12" fill="none" stroke="${t.border}" stroke-width="1"/>
316
+
317
+ <!-- Title bar -->
318
+ <rect x="1" y="1" width="${W - 2}" height="38" rx="12" fill="${t.bg2}"/>
319
+ <rect x="1" y="22" width="${W - 2}" height="17" fill="${t.bg2}"/>
320
+ <circle cx="20" cy="20" r="5" fill="#ef4444" opacity="0.7"/>
321
+ <circle cx="36" cy="20" r="5" fill="#f59e0b" opacity="0.7"/>
322
+ <circle cx="52" cy="20" r="5" fill="#22c55e" opacity="0.7"/>
323
+ <text x="${W / 2}" y="25" fill="${t.text2}" font-size="13" font-weight="600" font-family=${F} text-anchor="middle">agentlytics.io</text>
324
+
325
+ <!-- Accent line -->
326
+ <rect x="${pad}" y="44" width="60" height="2" rx="1" fill="url(#accentGrad)"/>
327
+
328
+ <!-- Branding -->
329
+ <g transform="translate(${pad}, 52) scale(0.8)" stroke="#818cf8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none">
330
+ <path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/>
331
+ </g>
332
+ <text x="${pad + 26}" y="68" fill="${t.text}" font-size="16" font-weight="bold" font-family=${F}>Agentlytics</text>
333
+ <text x="${pad + 148}" y="68" fill="${t.text4}" font-size="13" font-family=${F}>Your AI coding stats</text>
334
+ <text x="${W - pad}" y="68" fill="${t.text5}" font-size="12" font-family=${F} text-anchor="end">${esc(dateStr)}</text>
335
+
336
+ <!-- Divider -->
337
+ <line x1="${pad}" y1="78" x2="${W - pad}" y2="78" stroke="${t.border}" stroke-width="1"/>
121
338
 
122
- <!-- Right column: Peak Hours -->
123
- <text x="590" y="160" fill="#666" font-size="10" font-family="${F}"># peak_hours</text>
124
- <polyline points="${sparkPoints}" fill="none" stroke="#888" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>
125
- <text x="590" y="${180 + sparkH + 14}" fill="#555" font-size="8" font-family="${F}">00:00</text>
126
- <text x="${590 + sparkW - 28}" y="${180 + sparkH + 14}" fill="#555" font-size="8" font-family="${F}">23:00</text>
339
+ <!-- KPI cards -->
340
+ ${kpiCards.join('')}
127
341
 
128
- <!-- Top Models -->
129
- <text x="590" y="258" fill="#666" font-size="10" font-family="${F}"># models</text>
130
- ${modelsList}
342
+ <!-- Sections -->
343
+ ${sectionSvgs.join('')}
131
344
 
132
- <!-- Token breakdown -->
133
- <line x1="24" y1="${H - 62}" x2="${W - 24}" y2="${H - 62}" stroke="#222" stroke-width="1"/>
134
- <text x="24" y="${H - 44}" fill="#666" font-size="9" font-family="${F}">in:${fmt(tk.input)} out:${fmt(tk.output)} cache:${fmt(tk.cacheRead)} tools:${fmt(stats.totalToolCalls || 0)} editors:${editors.length}</text>
345
+ <!-- Token footer -->
346
+ ${tokenLine}
135
347
 
136
348
  <!-- Footer -->
137
- <line x1="24" y1="${H - 28}" x2="${W - 24}" y2="${H - 28}" stroke="#222" stroke-width="1"/>
138
- <text x="24" y="${H - 10}" fill="#555" font-size="9" font-family="${F}">github.com/f/agentlytics</text>
139
- <text x="${W - 24}" y="${H - 10}" fill="#555" font-size="9" font-family="${F}" text-anchor="end">${esc(dateStr)}</text>
349
+ <line x1="${pad}" y1="${footerY}" x2="${W - pad}" y2="${footerY}" stroke="${t.border}" stroke-width="1"/>
350
+ <text x="${pad}" y="${footerY + 14}" fill="${t.text5}" font-size="11" font-family=${F}>github.com/f/agentlytics</text>
351
+ ${usernameText}
352
+ <text x="${W - pad}" y="${footerY + 14}" fill="${t.text5}" font-size="11" font-family=${F} text-anchor="end">npx agentlytics</text>
140
353
  </svg>`;
141
354
 
142
355
  return svg;
@@ -0,0 +1,273 @@
1
+ import { useState, useEffect, useRef, useCallback } from 'react'
2
+ import { X, Download, Share2, BarChart3, DollarSign, Clock, Cpu, Braces, User, Sun, Moon } from 'lucide-react'
3
+ import { fetchShareImage } from '../lib/api'
4
+
5
+ const TOGGLE_ITEMS = [
6
+ { key: 'showEditors', label: 'Editors', icon: BarChart3 },
7
+ { key: 'showCosts', label: 'Est. Costs', icon: DollarSign },
8
+ { key: 'showHours', label: 'Peak Hours', icon: Clock },
9
+ { key: 'showModels', label: 'Top Models', icon: Cpu },
10
+ { key: 'showTokens', label: 'Token Footer', icon: Braces },
11
+ ]
12
+
13
+ export default function ShareModal({ open, onClose }) {
14
+ const [opts, setOpts] = useState({
15
+ showEditors: true,
16
+ showCosts: true,
17
+ showHours: true,
18
+ showModels: true,
19
+ showTokens: true,
20
+ username: '',
21
+ theme: 'dark',
22
+ })
23
+ const [svg, setSvg] = useState('')
24
+ const [loading, setLoading] = useState(false)
25
+ const [downloading, setDownloading] = useState(false)
26
+ const debounceRef = useRef(null)
27
+ const backdropRef = useRef(null)
28
+
29
+ const loadPreview = useCallback(async (currentOpts) => {
30
+ setLoading(true)
31
+ try {
32
+ const result = await fetchShareImage(currentOpts)
33
+ if (result && !result.startsWith('{')) setSvg(result)
34
+ } catch (e) {
35
+ console.error('Preview failed:', e)
36
+ }
37
+ setLoading(false)
38
+ }, [])
39
+
40
+ useEffect(() => {
41
+ if (!open) return
42
+ loadPreview(opts)
43
+ }, [open])
44
+
45
+ const updateOpt = (key, value) => {
46
+ const next = { ...opts, [key]: value }
47
+ setOpts(next)
48
+ if (debounceRef.current) clearTimeout(debounceRef.current)
49
+ debounceRef.current = setTimeout(() => loadPreview(next), 300)
50
+ }
51
+
52
+ const handleDownloadPng = async () => {
53
+ if (!svg) return
54
+ setDownloading(true)
55
+ try {
56
+ const canvas = document.createElement('canvas')
57
+ canvas.width = 1200
58
+ canvas.height = 675
59
+ const ctx = canvas.getContext('2d')
60
+ const img = new Image()
61
+ const svgB64 = btoa(unescape(encodeURIComponent(svg)))
62
+ const dataUrl = `data:image/svg+xml;base64,${svgB64}`
63
+ await new Promise((resolve, reject) => {
64
+ img.onload = resolve
65
+ img.onerror = reject
66
+ img.src = dataUrl
67
+ })
68
+ ctx.drawImage(img, 0, 0, 1200, 675)
69
+ const pngUrl = canvas.toDataURL('image/png')
70
+ const a = document.createElement('a')
71
+ a.href = pngUrl
72
+ a.download = 'agentlytics.png'
73
+ a.click()
74
+ } catch {
75
+ const blob = new Blob([svg], { type: 'image/svg+xml' })
76
+ const a = document.createElement('a')
77
+ a.href = URL.createObjectURL(blob)
78
+ a.download = 'agentlytics.svg'
79
+ a.click()
80
+ URL.revokeObjectURL(a.href)
81
+ }
82
+ setDownloading(false)
83
+ }
84
+
85
+ const handleDownloadSvg = () => {
86
+ if (!svg) return
87
+ const blob = new Blob([svg], { type: 'image/svg+xml' })
88
+ const a = document.createElement('a')
89
+ a.href = URL.createObjectURL(blob)
90
+ a.download = 'agentlytics.svg'
91
+ a.click()
92
+ URL.revokeObjectURL(a.href)
93
+ }
94
+
95
+ const handleShareTwitter = async () => {
96
+ await handleDownloadPng()
97
+ const text = encodeURIComponent("Here's my agentic coding stats using github.com/f/agentlytics")
98
+ window.open(`https://x.com/intent/post?text=${text}`, '_blank')
99
+ }
100
+
101
+ if (!open) return null
102
+
103
+ return (
104
+ <div
105
+ ref={backdropRef}
106
+ className="fixed inset-0 z-50 flex items-center justify-center p-4"
107
+ style={{ background: 'rgba(0,0,0,0.85)', backdropFilter: 'blur(8px)' }}
108
+ onClick={(e) => e.target === backdropRef.current && onClose()}
109
+ >
110
+ <div
111
+ className="w-full relative flex flex-col"
112
+ style={{
113
+ maxWidth: 960,
114
+ maxHeight: '90vh',
115
+ background: 'var(--c-bg)',
116
+ border: '1px solid var(--c-border)',
117
+ borderRadius: 12,
118
+ }}
119
+ >
120
+ {/* Header */}
121
+ <div className="flex items-center justify-between px-5 py-3" style={{ borderBottom: '1px solid var(--c-border)' }}>
122
+ <div className="flex items-center gap-2">
123
+ <Share2 size={14} style={{ color: 'var(--c-accent)' }} />
124
+ <span className="text-[13px] font-semibold" style={{ color: 'var(--c-white)' }}>Share Stats</span>
125
+ </div>
126
+ <button onClick={onClose} className="p-1 rounded hover:opacity-70 transition" style={{ color: 'var(--c-text2)' }}>
127
+ <X size={16} />
128
+ </button>
129
+ </div>
130
+
131
+ {/* Body */}
132
+ <div className="flex-1 overflow-auto p-5" style={{ minHeight: 0 }}>
133
+ <div className="flex gap-5" style={{ flexDirection: 'row' }}>
134
+
135
+ {/* Sidebar: toggles */}
136
+ <div className="flex-shrink-0" style={{ width: 200 }}>
137
+ <div className="text-[11px] font-medium mb-3" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
138
+ Customize
139
+ </div>
140
+
141
+ <div className="space-y-1.5">
142
+ {TOGGLE_ITEMS.map(({ key, label, icon: Icon }) => {
143
+ const active = opts[key]
144
+ return (
145
+ <button
146
+ key={key}
147
+ onClick={() => updateOpt(key, !active)}
148
+ className="flex items-center gap-2 w-full px-2.5 py-2 rounded-md text-[12px] transition"
149
+ style={{
150
+ background: active ? 'var(--c-accent-bg, rgba(99,102,241,0.1))' : 'transparent',
151
+ border: `1px solid ${active ? 'var(--c-accent, #6366f1)' : 'var(--c-border)'}`,
152
+ color: active ? 'var(--c-accent, #818cf8)' : 'var(--c-text2)',
153
+ opacity: active ? 1 : 0.6,
154
+ }}
155
+ >
156
+ <Icon size={12} />
157
+ {label}
158
+ </button>
159
+ )
160
+ })}
161
+ </div>
162
+
163
+ <div className="mt-4">
164
+ <div className="text-[11px] font-medium mb-2" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
165
+ Theme
166
+ </div>
167
+ <div className="flex gap-1.5">
168
+ {[{ key: 'dark', icon: Moon, label: 'Dark' }, { key: 'light', icon: Sun, label: 'Light' }].map(({ key, icon: Icon, label }) => {
169
+ const active = opts.theme === key
170
+ return (
171
+ <button
172
+ key={key}
173
+ onClick={() => updateOpt('theme', key)}
174
+ className="flex items-center gap-1.5 flex-1 justify-center px-2 py-1.5 rounded-md text-[11px] transition"
175
+ style={{
176
+ background: active ? 'var(--c-accent-bg, rgba(99,102,241,0.1))' : 'transparent',
177
+ border: `1px solid ${active ? 'var(--c-accent, #6366f1)' : 'var(--c-border)'}`,
178
+ color: active ? 'var(--c-accent, #818cf8)' : 'var(--c-text2)',
179
+ opacity: active ? 1 : 0.6,
180
+ }}
181
+ >
182
+ <Icon size={11} />
183
+ {label}
184
+ </button>
185
+ )
186
+ })}
187
+ </div>
188
+ </div>
189
+
190
+ <div className="mt-4">
191
+ <div className="text-[11px] font-medium mb-2" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
192
+ Username
193
+ </div>
194
+ <div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md" style={{ border: '1px solid var(--c-border)', background: 'var(--c-bg)' }}>
195
+ <User size={11} style={{ color: 'var(--c-text3)' }} />
196
+ <input
197
+ type="text"
198
+ placeholder="optional"
199
+ value={opts.username}
200
+ onChange={(e) => updateOpt('username', e.target.value)}
201
+ className="bg-transparent outline-none text-[12px] w-full"
202
+ style={{ color: 'var(--c-text)' }}
203
+ />
204
+ </div>
205
+ </div>
206
+ </div>
207
+
208
+ {/* Preview */}
209
+ <div className="flex-1 min-w-0">
210
+ <div className="text-[11px] font-medium mb-3" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
211
+ Preview
212
+ </div>
213
+ <div
214
+ className="rounded-lg overflow-hidden relative"
215
+ style={{
216
+ border: '1px solid var(--c-border)',
217
+ background: '#09090f',
218
+ minHeight: 200,
219
+ }}
220
+ >
221
+ {loading && (
222
+ <div className="absolute inset-0 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.5)', zIndex: 2 }}>
223
+ <span className="text-[12px]" style={{ color: 'var(--c-text2)' }}>Generating...</span>
224
+ </div>
225
+ )}
226
+ {svg && (
227
+ <div
228
+ className="w-full [&>svg]:w-full [&>svg]:h-auto [&>svg]:block"
229
+ dangerouslySetInnerHTML={{ __html: svg }}
230
+ />
231
+ )}
232
+ </div>
233
+ </div>
234
+ </div>
235
+ </div>
236
+
237
+ {/* Footer: actions */}
238
+ <div className="flex items-center justify-between px-5 py-3" style={{ borderTop: '1px solid var(--c-border)' }}>
239
+ <span className="text-[11px]" style={{ color: 'var(--c-text3)' }}>
240
+ Tip: Toggle sections to customize your share card
241
+ </span>
242
+ <div className="flex items-center gap-2">
243
+ <button
244
+ onClick={handleDownloadSvg}
245
+ className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md transition hover:opacity-80"
246
+ style={{ border: '1px solid var(--c-border)', color: 'var(--c-text)' }}
247
+ >
248
+ <Download size={12} />
249
+ SVG
250
+ </button>
251
+ <button
252
+ onClick={handleDownloadPng}
253
+ disabled={downloading}
254
+ className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md transition hover:opacity-80"
255
+ style={{ border: '1px solid var(--c-border)', color: 'var(--c-text)', opacity: downloading ? 0.5 : 1 }}
256
+ >
257
+ <Download size={12} />
258
+ {downloading ? 'Converting...' : 'PNG'}
259
+ </button>
260
+ <button
261
+ onClick={handleShareTwitter}
262
+ className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md transition hover:opacity-80"
263
+ style={{ background: '#6366f1', color: '#fff' }}
264
+ >
265
+ <Share2 size={12} />
266
+ Share on X
267
+ </button>
268
+ </div>
269
+ </div>
270
+ </div>
271
+ </div>
272
+ )
273
+ }
package/ui/src/lib/api.js CHANGED
@@ -180,8 +180,17 @@ export async function fetchSchema() {
180
180
  return res.json();
181
181
  }
182
182
 
183
- export async function fetchShareImage() {
184
- const res = await fetch(`${BASE}/api/share-image`);
183
+ export async function fetchShareImage(opts = {}) {
184
+ const q = new URLSearchParams();
185
+ if (opts.showEditors !== undefined) q.set('showEditors', opts.showEditors);
186
+ if (opts.showModels !== undefined) q.set('showModels', opts.showModels);
187
+ if (opts.showCosts !== undefined) q.set('showCosts', opts.showCosts);
188
+ if (opts.showTokens !== undefined) q.set('showTokens', opts.showTokens);
189
+ if (opts.showHours !== undefined) q.set('showHours', opts.showHours);
190
+ if (opts.username) q.set('username', opts.username);
191
+ if (opts.theme) q.set('theme', opts.theme);
192
+ const qs = q.toString();
193
+ const res = await fetch(`${BASE}/api/share-image${qs ? '?' + qs : ''}`);
185
194
  return res.text();
186
195
  }
187
196
 
@@ -8,8 +8,9 @@ import ActivityHeatmap from '../components/ActivityHeatmap'
8
8
  import DateRangePicker from '../components/DateRangePicker'
9
9
  import { editorColor, editorLabel, formatNumber, formatCost, dateRangeToApiParams } from '../lib/constants'
10
10
  import EditorIcon from '../components/EditorIcon'
11
- import { fetchDailyActivity, fetchOverview as fetchOverviewApi, fetchDashboardStats, fetchShareImage, fetchChats, fetchCosts } from '../lib/api'
11
+ import { fetchDailyActivity, fetchOverview as fetchOverviewApi, fetchDashboardStats, fetchChats, fetchCosts } from '../lib/api'
12
12
  import ChatSidebar from '../components/ChatSidebar'
13
+ import ShareModal from '../components/ShareModal'
13
14
  import { useTheme } from '../lib/theme'
14
15
  import SectionTitle from '../components/SectionTitle'
15
16
 
@@ -32,7 +33,7 @@ export default function Dashboard({ overview }) {
32
33
  const [dateRange, setDateRange] = useState(null)
33
34
  const { dark } = useTheme()
34
35
  const [costs, setCosts] = useState(null)
35
- const [sharing, setSharing] = useState(false)
36
+ const [shareOpen, setShareOpen] = useState(false)
36
37
  const [largeContextChats, setLargeContextChats] = useState(null)
37
38
  const [selectedChatId, setSelectedChatId] = useState(null)
38
39
  const txtColor = dark ? '#888' : '#555'
@@ -169,54 +170,6 @@ export default function Dashboard({ overview }) {
169
170
  return s + v * midpoints[i]
170
171
  }, 0) / tk.sessions).toFixed(1) : '—') : '—'
171
172
 
172
- const handleShare = async () => {
173
- setSharing(true)
174
- try {
175
- const svg = await fetchShareImage()
176
- if (!svg || svg.startsWith('{')) throw new Error('Failed to fetch image')
177
-
178
- // Try PNG conversion via canvas, fallback to SVG download
179
- let downloaded = false
180
- try {
181
- const canvas = document.createElement('canvas')
182
- canvas.width = 1600
183
- canvas.height = 880
184
- const ctx = canvas.getContext('2d')
185
- const img = new Image()
186
- const svgB64 = btoa(unescape(encodeURIComponent(svg)))
187
- const dataUrl = `data:image/svg+xml;base64,${svgB64}`
188
- await new Promise((resolve, reject) => {
189
- img.onload = resolve
190
- img.onerror = reject
191
- img.src = dataUrl
192
- })
193
- ctx.drawImage(img, 0, 0, 1600, 880)
194
- const pngUrl = canvas.toDataURL('image/png')
195
- const a = document.createElement('a')
196
- a.href = pngUrl
197
- a.download = 'agentlytics.png'
198
- a.click()
199
- downloaded = true
200
- } catch {
201
- // Fallback: download SVG directly
202
- const blob = new Blob([svg], { type: 'image/svg+xml' })
203
- const a = document.createElement('a')
204
- a.href = URL.createObjectURL(blob)
205
- a.download = 'agentlytics.svg'
206
- a.click()
207
- URL.revokeObjectURL(a.href)
208
- downloaded = true
209
- }
210
-
211
- if (downloaded) {
212
- const text = encodeURIComponent("Here's my agentic coding stats using github.com/f/agentlytics")
213
- window.open(`https://x.com/intent/post?text=${text}`, '_blank')
214
- }
215
- } catch (e) {
216
- console.error('Share failed:', e)
217
- }
218
- setSharing(false)
219
- }
220
173
 
221
174
  return (
222
175
  <div className="fade-in space-y-3">
@@ -224,13 +177,12 @@ export default function Dashboard({ overview }) {
224
177
  <div className="flex items-center justify-end gap-3">
225
178
  <DateRangePicker value={dateRange} onChange={setDateRange} />
226
179
  <button
227
- onClick={handleShare}
228
- disabled={sharing}
180
+ onClick={() => setShareOpen(true)}
229
181
  className="flex items-center gap-1.5 px-3 py-1 text-[12px] rounded-md transition hover:opacity-80"
230
- style={{ background: '#6366f1', color: '#fff', opacity: sharing ? 0.5 : 1 }}
182
+ style={{ background: '#6366f1', color: '#fff' }}
231
183
  >
232
184
  <Share2 size={12} />
233
- {sharing ? 'Generating...' : 'Share Stats'}
185
+ Share Stats
234
186
  </button>
235
187
  </div>
236
188
 
@@ -561,6 +513,7 @@ export default function Dashboard({ overview }) {
561
513
  )}
562
514
  </div>
563
515
  <ChatSidebar chatId={selectedChatId} onClose={() => setSelectedChatId(null)} />
516
+ <ShareModal open={shareOpen} onClose={() => setShareOpen(false)} />
564
517
  </div>
565
518
  )
566
519
  }