agentlytics 0.1.10 → 0.1.12

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/cache.js CHANGED
@@ -431,14 +431,17 @@ function getCachedOverview(opts = {}) {
431
431
  const hf = hiddenFolderFilter(opts);
432
432
  if (hf.sql) { conditions.push(hf.sql.replace(' AND ', '')); params.push(...hf.params); }
433
433
  if (opts.editor) { conditions.push('source = ?'); params.push(opts.editor); }
434
+ if (opts.folder) { conditions.push('folder = ?'); params.push(opts.folder); }
434
435
  if (opts.dateFrom) { conditions.push('COALESCE(last_updated_at, created_at) >= ?'); params.push(opts.dateFrom); }
435
436
  if (opts.dateTo) { conditions.push('COALESCE(last_updated_at, created_at) <= ?'); params.push(opts.dateTo); }
436
437
  const where = conditions.length > 0 ? ' WHERE ' + conditions.join(' AND ') : '';
437
438
  const whereAnd = conditions.length > 0 ? ' AND ' + conditions.join(' AND ') : '';
438
439
 
439
440
  const totalChats = db.prepare(`SELECT COUNT(*) as cnt FROM chats${where}`).get(...params).cnt;
440
- // Editors list is always unfiltered so the breakdown remains visible
441
- const editors = db.prepare('SELECT source, COUNT(*) as count FROM chats GROUP BY source ORDER BY count DESC').all();
441
+ // When folder-filtered, show only that project's editors; otherwise show all
442
+ const editors = opts.folder
443
+ ? db.prepare(`SELECT source, COUNT(*) as count FROM chats${where} GROUP BY source ORDER BY count DESC`).all(...params)
444
+ : db.prepare('SELECT source, COUNT(*) as count FROM chats GROUP BY source ORDER BY count DESC').all();
442
445
 
443
446
  // By mode
444
447
  const modes = db.prepare(`SELECT mode, COUNT(*) as count FROM chats WHERE mode IS NOT NULL${whereAnd} GROUP BY mode`).all(...params);
@@ -851,6 +854,7 @@ function getCachedDashboardStats(opts = {}) {
851
854
  const hf = hiddenFolderFilter(opts);
852
855
  if (hf.sql) { conditions.push(hf.sql.replace(' AND ', '')); params.push(...hf.params); }
853
856
  if (opts.editor) { conditions.push('source = ?'); params.push(opts.editor); }
857
+ if (opts.folder) { conditions.push('folder = ?'); params.push(opts.folder); }
854
858
  if (opts.dateFrom) { conditions.push('COALESCE(last_updated_at, created_at) >= ?'); params.push(opts.dateFrom); }
855
859
  if (opts.dateTo) { conditions.push('COALESCE(last_updated_at, created_at) <= ?'); params.push(opts.dateTo); }
856
860
  const where = conditions.length > 0 ? ' WHERE ' + conditions.join(' AND ') : '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlytics",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
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
@@ -264,9 +264,21 @@ app.get('/api/schema', (req, res) => {
264
264
 
265
265
  app.get('/api/share-image', (req, res) => {
266
266
  try {
267
- const overview = cache.getCachedOverview();
268
- const stats = cache.getCachedDashboardStats();
269
- const svg = generateShareSvg(overview, stats);
267
+ const filterOpts = { hiddenFolders: getHiddenFolders() };
268
+ if (req.query.folder) filterOpts.folder = req.query.folder;
269
+ const overview = cache.getCachedOverview(filterOpts);
270
+ const stats = cache.getCachedDashboardStats(filterOpts);
271
+ const costs = cache.getCostAnalytics(filterOpts);
272
+ const opts = {};
273
+ if (req.query.showEditors !== undefined) opts.showEditors = req.query.showEditors !== 'false';
274
+ if (req.query.showModels !== undefined) opts.showModels = req.query.showModels !== 'false';
275
+ if (req.query.showCosts !== undefined) opts.showCosts = req.query.showCosts !== 'false';
276
+ if (req.query.showTokens !== undefined) opts.showTokens = req.query.showTokens !== 'false';
277
+ if (req.query.showHours !== undefined) opts.showHours = req.query.showHours !== 'false';
278
+ if (req.query.username) opts.username = req.query.username;
279
+ if (req.query.theme) opts.theme = req.query.theme;
280
+ if (req.query.folder) opts.folder = req.query.folder;
281
+ const svg = generateShareSvg(overview, stats, costs, opts);
270
282
  res.setHeader('Content-Type', 'image/svg+xml');
271
283
  res.send(svg);
272
284
  } 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,310 @@ 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
+ const projectFolder = opts.folder || '';
97
+ const projectName = projectFolder ? projectFolder.split(/[/\\]/).pop() : '';
98
+
99
+ const W = 1200;
100
+ const H_FIXED = 675;
101
+ const F = "\"'Menlo','Monaco','Cascadia Code','Courier New',monospace\"";
47
102
  const editors = overview.editors || [];
48
103
  const tk = stats.tokens || {};
49
104
  const streaks = stats.streaks || {};
50
105
  const topModels = (stats.topModels || []).slice(0, 5);
106
+ const costData = costs || {};
107
+ const totalCost = costData.totalCost || 0;
108
+ const costByEditor = (costData.byEditor || []).slice(0, 6);
109
+ const totalTokens = (tk.input || 0) + (tk.output || 0);
110
+ const now = new Date();
111
+ const dateStr = now.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
112
+
113
+ // ── Compute layout ──
114
+ let y = 0;
115
+ const pad = 28;
116
+
117
+ // Title bar
118
+ y += 40;
119
+ // Header area (prompt + branding)
120
+ y += 54;
121
+ // KPI row
122
+ y += 80;
123
+ // Sections
124
+ const midSections = [];
125
+ if (show.editors) midSections.push('editors');
126
+ if (show.costs && totalCost > 0) midSections.push('costs');
127
+ if (show.hours) midSections.push('hours');
128
+ if (show.models && topModels.length > 0) midSections.push('models');
129
+
130
+ // Layout: 2 columns, each section ~row
131
+ const leftSections = [];
132
+ const rightSections = [];
133
+ midSections.forEach((s, i) => {
134
+ if (i % 2 === 0) leftSections.push(s);
135
+ else rightSections.push(s);
136
+ });
137
+
138
+ function sectionHeight(name) {
139
+ if (name === 'editors') return Math.max(editors.slice(0, 8).length * 26 + 34, 70);
140
+ if (name === 'costs') return Math.max(costByEditor.length * 26 + 34, 70);
141
+ if (name === 'hours') return 110;
142
+ if (name === 'models') return topModels.length * 22 + 34;
143
+ return 70;
144
+ }
145
+
146
+ // Mid-section pairs: compute max height of each row
147
+ const rowCount = Math.max(leftSections.length, rightSections.length);
148
+ let midHeight = 0;
149
+ for (let i = 0; i < rowCount; i++) {
150
+ const lh = leftSections[i] ? sectionHeight(leftSections[i]) : 0;
151
+ const rh = rightSections[i] ? sectionHeight(rightSections[i]) : 0;
152
+ midHeight += Math.max(lh, rh) + 8;
153
+ }
154
+
155
+ // Token footer
156
+ const tokenFooterH = show.tokens ? 44 : 0;
157
+ // Footer
158
+ const footerH = 36;
159
+
160
+ const naturalH = y + midHeight + tokenFooterH + footerH + 20;
161
+ // Stretch: if content is shorter than 675, distribute extra space into sections
162
+ const extraSpace = Math.max(0, H_FIXED - naturalH);
163
+ const extraPerRow = rowCount > 0 ? extraSpace / rowCount : 0;
51
164
 
52
- // Editor bar chart
165
+ // ── KPI cards ──
166
+ const kpiY = 94;
167
+ const kpiCards = [];
168
+ const kpiItems = [
169
+ { label: 'sessions', value: fmt(overview.totalChats) },
170
+ { label: 'tokens', value: fmt(totalTokens) },
171
+ { label: 'active days', value: String(streaks.totalDays || 0) },
172
+ { label: 'streak', value: `${streaks.current || 0}d` },
173
+ ];
174
+ if (show.costs && totalCost > 0) {
175
+ kpiItems.push({ label: 'est. cost', value: fmtCost(totalCost) });
176
+ }
177
+ const kpiW = (W - pad * 2 - (kpiItems.length - 1) * 8) / kpiItems.length;
178
+ kpiItems.forEach((item, i) => {
179
+ const x = pad + i * (kpiW + 8);
180
+ kpiCards.push(`
181
+ <rect x="${x}" y="${kpiY}" width="${kpiW}" height="64" rx="6" fill="${t.card}"/>
182
+ <text x="${x + 14}" y="${kpiY + 22}" fill="${t.text3}" font-size="11" font-family=${F}>${esc(item.label)}</text>
183
+ <text x="${x + 14}" y="${kpiY + 50}" fill="${t.text}" font-size="24" font-weight="bold" font-family=${F}>${esc(item.value)}</text>
184
+ `);
185
+ });
186
+
187
+ // ── Editor bar chart ──
188
+ const colW = (W - pad * 2 - 20) / 2;
189
+ const maxBarW = colW - 120 - 60;
53
190
  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);
191
+ const editorBarsArr = editors.slice(0, 8).map((e, i) => {
192
+ const barW = Math.max((e.count / maxEditorCount) * maxBarW, 4);
56
193
  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
194
+ const label = (EDITOR_LABELS[e.id] || e.id);
195
+ return { label, barW, color, count: e.count };
196
+ });
197
+
198
+ // ── Cost bar chart ──
199
+ const maxCostVal = costByEditor.length > 0 ? Math.max(...costByEditor.map(c => c.cost), 0.01) : 1;
200
+ const costBarsArr = costByEditor.map(c => {
201
+ const barW = Math.max((c.cost / maxCostVal) * maxBarW, 4);
202
+ const color = EDITOR_COLORS[c.editor] || '#6b7280';
203
+ const label = EDITOR_LABELS[c.editor] || c.editor;
204
+ return { label, barW, color, value: fmtCost(c.cost) };
205
+ });
206
+
207
+ // ── Hourly sparkline ──
67
208
  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"/>
209
+ const maxHourly = Math.max(...hourly, 1);
89
210
 
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>
211
+ // ── Build sections ──
212
+ let curY = kpiY + 64 + 18;
213
+ const sectionSvgs = [];
93
214
 
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>
215
+ for (let row = 0; row < rowCount; row++) {
216
+ const lName = leftSections[row];
217
+ const rName = rightSections[row];
218
+ const lh = lName ? sectionHeight(lName) : 0;
219
+ const rh = rName ? sectionHeight(rName) : 0;
220
+ const rowH = Math.max(lh, rh) + Math.round(extraPerRow);
97
221
 
98
- <!-- Divider -->
99
- <line x1="24" y1="68" x2="${W - 24}" y2="68" stroke="#222" stroke-width="1"/>
222
+ if (lName) sectionSvgs.push(renderSection(lName, pad, curY, colW, rowH));
223
+ if (rName) sectionSvgs.push(renderSection(rName, pad + colW + 20, curY, colW, rowH));
224
+
225
+ curY += rowH + 10;
226
+ }
227
+
228
+ function renderSection(name, sx, sy, sw, sh) {
229
+ let out = '';
230
+ if (name === 'editors') {
231
+ out += `<text x="${sx}" y="${sy + 14}" fill="${t.text4}" font-size="11" font-family=${F}># editors</text>`;
232
+ editorBarsArr.forEach((e, i) => {
233
+ const by = sy + 26 + i * 26;
234
+ out += `<text x="${sx}" y="${by + 14}" fill="${t.text2}" font-size="12" font-family=${F}>${esc(e.label)}</text>`;
235
+ out += `<rect x="${sx + 120}" y="${by + 1}" width="${e.barW}" height="16" rx="3" fill="${e.color}" opacity="0.85"/>`;
236
+ out += `<text x="${sx + 128 + e.barW}" y="${by + 14}" fill="${t.text3}" font-size="11" font-family=${F}>${e.count}</text>`;
237
+ });
238
+ } else if (name === 'costs') {
239
+ out += `<text x="${sx}" y="${sy + 14}" fill="${t.text4}" font-size="11" font-family=${F}># est. costs by editor</text>`;
240
+ costBarsArr.forEach((c, i) => {
241
+ const by = sy + 26 + i * 26;
242
+ out += `<text x="${sx}" y="${by + 14}" fill="${t.text2}" font-size="12" font-family=${F}>${esc(c.label)}</text>`;
243
+ out += `<rect x="${sx + 120}" y="${by + 1}" width="${c.barW}" height="16" rx="3" fill="${c.color}" opacity="0.85"/>`;
244
+ out += `<text x="${sx + 128 + c.barW}" y="${by + 14}" fill="${t.text3}" font-size="11" font-family=${F}>${c.value}</text>`;
245
+ });
246
+ } else if (name === 'hours') {
247
+ out += `<text x="${sx}" y="${sy + 14}" fill="${t.text4}" font-size="11" font-family=${F}># peak hours</text>`;
248
+ const sparkW = sw - 8;
249
+ const sparkH = 56;
250
+ const baseY = sy + 28;
251
+ const barW = sparkW / 24 - 1;
252
+ hourly.forEach((v, i) => {
253
+ const bh = Math.max((v / maxHourly) * sparkH, 1);
254
+ const bx = sx + i * (barW + 1);
255
+ const by = baseY + sparkH - bh;
256
+ const intensity = v / maxHourly;
257
+ const color = intensity > 0.75 ? t.hourHigh : intensity > 0.5 ? t.hourMed : intensity > 0.25 ? t.hourLow : t.hourMin;
258
+ out += `<rect x="${bx}" y="${by}" width="${barW}" height="${bh}" rx="1" fill="${color}" opacity="0.9"/>`;
259
+ });
260
+ out += `<text x="${sx}" y="${baseY + sparkH + 14}" fill="${t.text5}" font-size="9" font-family=${F}>00</text>`;
261
+ out += `<text x="${sx + sparkW / 4}" y="${baseY + sparkH + 14}" fill="${t.text5}" font-size="9" font-family=${F}>06</text>`;
262
+ out += `<text x="${sx + sparkW / 2}" y="${baseY + sparkH + 14}" fill="${t.text5}" font-size="9" font-family=${F}>12</text>`;
263
+ out += `<text x="${sx + sparkW * 3 / 4}" y="${baseY + sparkH + 14}" fill="${t.text5}" font-size="9" font-family=${F}>18</text>`;
264
+ out += `<text x="${sx + sparkW - 8}" y="${baseY + sparkH + 14}" fill="${t.text5}" font-size="9" font-family=${F}>23</text>`;
265
+ } else if (name === 'models') {
266
+ out += `<text x="${sx}" y="${sy + 14}" fill="${t.text4}" font-size="11" font-family=${F}># top models</text>`;
267
+ topModels.forEach((m, i) => {
268
+ const my = sy + 32 + i * 22;
269
+ const mName = m.name.length > 28 ? m.name.substring(0, 28) + '…' : m.name;
270
+ out += `<text x="${sx}" y="${my}" fill="${t.text2}" font-size="11" font-family=${F}>${esc(mName)}</text>`;
271
+ out += `<text x="${sx + sw - 4}" y="${my}" fill="${t.text4}" font-size="11" font-family=${F} text-anchor="end">${m.count}</text>`;
272
+ });
273
+ }
274
+ return out;
275
+ }
100
276
 
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>
277
+ // ── Token footer line ──
278
+ let tokenLine = '';
279
+ if (show.tokens) {
280
+ const ty = curY + 4;
281
+ const tkPairs = [
282
+ ['input', fmt(tk.input)],
283
+ ['output', fmt(tk.output)],
284
+ ['cache read', fmt(tk.cacheRead)],
285
+ ['cache write', fmt(tk.cacheWrite)],
286
+ ['tools', fmt(stats.totalToolCalls || 0)],
287
+ ['editors', String(editors.length)],
288
+ ];
289
+ let tkX = pad;
290
+ let tkSvg = `<line x1="${pad}" y1="${ty}" x2="${W - pad}" y2="${ty}" stroke="${t.border}" stroke-width="1"/>`;
291
+ tkPairs.forEach(([label, val]) => {
292
+ tkSvg += `<text x="${tkX}" y="${ty + 20}" fill="${t.text5}" font-size="10" font-family=${F}>${label}</text>`;
293
+ tkSvg += `<text x="${tkX}" y="${ty + 34}" fill="${t.text2}" font-size="12" font-weight="600" font-family=${F}>${val}</text>`;
294
+ tkX += (W - pad * 2) / tkPairs.length;
295
+ });
296
+ tokenLine = tkSvg;
297
+ curY = ty + tokenFooterH;
298
+ }
105
299
 
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>
300
+ // ── Final footer pin to bottom of canvas ──
301
+ const footerY = H_FIXED - 28;
109
302
 
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>
303
+ 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
304
 
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>
305
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="675" viewBox="0 0 ${W} ${H_FIXED}">
306
+ <defs>
307
+ <linearGradient id="accentGrad" x1="0" y1="0" x2="1" y2="0">
308
+ <stop offset="0%" stop-color="#818cf8"/>
309
+ <stop offset="50%" stop-color="#a78bfa"/>
310
+ <stop offset="100%" stop-color="#c084fc"/>
311
+ </linearGradient>
312
+ </defs>
117
313
 
118
- <!-- Editors section -->
119
- <text x="24" y="160" fill="#666" font-size="10" font-family="${F}"># editors</text>
120
- ${editorBars}
314
+ <!-- Background -->
315
+ <rect width="${W}" height="${H_FIXED}" rx="12" fill="${t.bg}"/>
316
+ <rect x="0.5" y="0.5" width="${W - 1}" height="${H_FIXED - 1}" rx="12" fill="none" stroke="${t.border}" stroke-width="1"/>
317
+
318
+ <!-- Title bar -->
319
+ <rect x="1" y="1" width="${W - 2}" height="38" rx="12" fill="${t.bg2}"/>
320
+ <rect x="1" y="22" width="${W - 2}" height="17" fill="${t.bg2}"/>
321
+ <circle cx="20" cy="20" r="5" fill="#ef4444" opacity="0.7"/>
322
+ <circle cx="36" cy="20" r="5" fill="#f59e0b" opacity="0.7"/>
323
+ <circle cx="52" cy="20" r="5" fill="#22c55e" opacity="0.7"/>
324
+ <text x="${W / 2}" y="25" fill="${t.text2}" font-size="13" font-weight="600" font-family=${F} text-anchor="middle">agentlytics.io</text>
325
+
326
+ <!-- Accent line -->
327
+ <rect x="${pad}" y="44" width="60" height="2" rx="1" fill="url(#accentGrad)"/>
328
+
329
+ <!-- Branding -->
330
+ <g transform="translate(${pad}, 52) scale(0.8)" stroke="#818cf8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none">
331
+ <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"/>
332
+ </g>
333
+ <text x="${pad + 26}" y="68" fill="${t.text}" font-size="16" font-weight="bold" font-family=${F}>Agentlytics</text>
334
+ <text x="${pad + 148}" y="68" fill="${t.text4}" font-size="13" font-family=${F}>${projectName ? esc(projectName) : 'Your AI coding stats'}</text>
335
+ <text x="${W - pad}" y="68" fill="${t.text5}" font-size="12" font-family=${F} text-anchor="end">${esc(dateStr)}</text>
336
+
337
+ <!-- Divider -->
338
+ <line x1="${pad}" y1="78" x2="${W - pad}" y2="78" stroke="${t.border}" stroke-width="1"/>
121
339
 
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>
340
+ <!-- KPI cards -->
341
+ ${kpiCards.join('')}
127
342
 
128
- <!-- Top Models -->
129
- <text x="590" y="258" fill="#666" font-size="10" font-family="${F}"># models</text>
130
- ${modelsList}
343
+ <!-- Sections -->
344
+ ${sectionSvgs.join('')}
131
345
 
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>
346
+ <!-- Token footer -->
347
+ ${tokenLine}
135
348
 
136
349
  <!-- 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>
350
+ <line x1="${pad}" y1="${footerY}" x2="${W - pad}" y2="${footerY}" stroke="${t.border}" stroke-width="1"/>
351
+ <text x="${pad}" y="${footerY + 14}" fill="${t.text5}" font-size="11" font-family=${F}>github.com/f/agentlytics</text>
352
+ ${usernameText}
353
+ <text x="${W - pad}" y="${footerY + 14}" fill="${t.text5}" font-size="11" font-family=${F} text-anchor="end">npx agentlytics</text>
140
354
  </svg>`;
141
355
 
142
356
  return svg;
@@ -0,0 +1,305 @@
1
+ import { useState, useEffect, useRef, useCallback } from 'react'
2
+ import { X, Download, Share2, BarChart3, DollarSign, Clock, Cpu, Braces, User, Sun, Moon, FolderOpen } from 'lucide-react'
3
+ import { fetchShareImage, fetchProjects } 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 [projects, setProjects] = useState([])
27
+ const debounceRef = useRef(null)
28
+ const backdropRef = useRef(null)
29
+
30
+ const loadPreview = useCallback(async (currentOpts) => {
31
+ setLoading(true)
32
+ try {
33
+ const result = await fetchShareImage(currentOpts)
34
+ if (result && !result.startsWith('{')) setSvg(result)
35
+ } catch (e) {
36
+ console.error('Preview failed:', e)
37
+ }
38
+ setLoading(false)
39
+ }, [])
40
+
41
+ useEffect(() => {
42
+ if (!open) return
43
+ loadPreview(opts)
44
+ fetchProjects().then(p => setProjects(p || [])).catch(() => {})
45
+ }, [open])
46
+
47
+ const updateOpt = (key, value) => {
48
+ const next = { ...opts, [key]: value }
49
+ setOpts(next)
50
+ if (debounceRef.current) clearTimeout(debounceRef.current)
51
+ debounceRef.current = setTimeout(() => loadPreview(next), 300)
52
+ }
53
+
54
+ const handleDownloadPng = async () => {
55
+ if (!svg) return
56
+ setDownloading(true)
57
+ try {
58
+ const scale = 2
59
+ const svgBlob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' })
60
+ const url = URL.createObjectURL(svgBlob)
61
+ const img = new Image()
62
+ img.width = 1200 * scale
63
+ img.height = 675 * scale
64
+ await new Promise((resolve, reject) => {
65
+ img.onload = resolve
66
+ img.onerror = reject
67
+ img.src = url
68
+ })
69
+ const canvas = document.createElement('canvas')
70
+ canvas.width = 1200 * scale
71
+ canvas.height = 675 * scale
72
+ const ctx = canvas.getContext('2d')
73
+ ctx.drawImage(img, 0, 0, 1200 * scale, 675 * scale)
74
+ URL.revokeObjectURL(url)
75
+ canvas.toBlob((blob) => {
76
+ if (!blob) return
77
+ const a = document.createElement('a')
78
+ a.href = URL.createObjectURL(blob)
79
+ a.download = 'agentlytics.png'
80
+ a.click()
81
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000)
82
+ }, 'image/png')
83
+ } catch (e) {
84
+ console.error('PNG conversion failed:', e)
85
+ const blob = new Blob([svg], { type: 'image/svg+xml' })
86
+ const a = document.createElement('a')
87
+ a.href = URL.createObjectURL(blob)
88
+ a.download = 'agentlytics.svg'
89
+ a.click()
90
+ URL.revokeObjectURL(a.href)
91
+ }
92
+ setDownloading(false)
93
+ }
94
+
95
+ const handleDownloadSvg = () => {
96
+ if (!svg) return
97
+ const blob = new Blob([svg], { type: 'image/svg+xml' })
98
+ const a = document.createElement('a')
99
+ a.href = URL.createObjectURL(blob)
100
+ a.download = 'agentlytics.svg'
101
+ a.click()
102
+ URL.revokeObjectURL(a.href)
103
+ }
104
+
105
+ const handleShareTwitter = async () => {
106
+ await handleDownloadPng()
107
+ const text = encodeURIComponent("Here's my agentic coding stats using github.com/f/agentlytics")
108
+ window.open(`https://x.com/intent/post?text=${text}`, '_blank')
109
+ }
110
+
111
+ if (!open) return null
112
+
113
+ return (
114
+ <div
115
+ ref={backdropRef}
116
+ className="fixed inset-0 z-50 flex items-center justify-center p-4"
117
+ style={{ background: 'rgba(0,0,0,0.85)', backdropFilter: 'blur(8px)' }}
118
+ onClick={(e) => e.target === backdropRef.current && onClose()}
119
+ >
120
+ <div
121
+ className="w-full relative flex flex-col"
122
+ style={{
123
+ maxWidth: 960,
124
+ maxHeight: '90vh',
125
+ background: 'var(--c-bg)',
126
+ border: '1px solid var(--c-border)',
127
+ borderRadius: 12,
128
+ }}
129
+ >
130
+ {/* Header */}
131
+ <div className="flex items-center justify-between px-5 py-3" style={{ borderBottom: '1px solid var(--c-border)' }}>
132
+ <div className="flex items-center gap-2">
133
+ <Share2 size={14} style={{ color: 'var(--c-accent)' }} />
134
+ <span className="text-[13px] font-semibold" style={{ color: 'var(--c-white)' }}>Share Stats</span>
135
+ </div>
136
+ <button onClick={onClose} className="p-1 rounded hover:opacity-70 transition" style={{ color: 'var(--c-text2)' }}>
137
+ <X size={16} />
138
+ </button>
139
+ </div>
140
+
141
+ {/* Body */}
142
+ <div className="flex-1 overflow-auto p-5" style={{ minHeight: 0 }}>
143
+ <div className="flex gap-5" style={{ flexDirection: 'row' }}>
144
+
145
+ {/* Sidebar: toggles */}
146
+ <div className="flex-shrink-0" style={{ width: 200 }}>
147
+ <div className="text-[11px] font-medium mb-3" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
148
+ Customize
149
+ </div>
150
+
151
+ <div className="mb-4">
152
+ <div className="text-[11px] font-medium mb-2" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
153
+ Project
154
+ </div>
155
+ <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)' }}>
156
+ <FolderOpen size={11} style={{ color: 'var(--c-text3)' }} />
157
+ <select
158
+ value={opts.folder || ''}
159
+ onChange={(e) => updateOpt('folder', e.target.value || undefined)}
160
+ className="bg-transparent outline-none text-[12px] w-full cursor-pointer"
161
+ style={{ color: 'var(--c-text)', appearance: 'none' }}
162
+ >
163
+ <option value="">All Projects</option>
164
+ {projects.map(p => (
165
+ <option key={p.folder} value={p.folder}>
166
+ {p.name} ({p.totalSessions})
167
+ </option>
168
+ ))}
169
+ </select>
170
+ </div>
171
+ </div>
172
+
173
+ <div className="space-y-1.5">
174
+ {TOGGLE_ITEMS.map(({ key, label, icon: Icon }) => {
175
+ const active = opts[key]
176
+ return (
177
+ <button
178
+ key={key}
179
+ onClick={() => updateOpt(key, !active)}
180
+ className="flex items-center gap-2 w-full px-2.5 py-2 rounded-md text-[12px] transition"
181
+ style={{
182
+ background: active ? 'var(--c-accent-bg, rgba(99,102,241,0.1))' : 'transparent',
183
+ border: `1px solid ${active ? 'var(--c-accent, #6366f1)' : 'var(--c-border)'}`,
184
+ color: active ? 'var(--c-accent, #818cf8)' : 'var(--c-text2)',
185
+ opacity: active ? 1 : 0.6,
186
+ }}
187
+ >
188
+ <Icon size={12} />
189
+ {label}
190
+ </button>
191
+ )
192
+ })}
193
+ </div>
194
+
195
+ <div className="mt-4">
196
+ <div className="text-[11px] font-medium mb-2" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
197
+ Theme
198
+ </div>
199
+ <div className="flex gap-1.5">
200
+ {[{ key: 'dark', icon: Moon, label: 'Dark' }, { key: 'light', icon: Sun, label: 'Light' }].map(({ key, icon: Icon, label }) => {
201
+ const active = opts.theme === key
202
+ return (
203
+ <button
204
+ key={key}
205
+ onClick={() => updateOpt('theme', key)}
206
+ className="flex items-center gap-1.5 flex-1 justify-center px-2 py-1.5 rounded-md text-[11px] transition"
207
+ style={{
208
+ background: active ? 'var(--c-accent-bg, rgba(99,102,241,0.1))' : 'transparent',
209
+ border: `1px solid ${active ? 'var(--c-accent, #6366f1)' : 'var(--c-border)'}`,
210
+ color: active ? 'var(--c-accent, #818cf8)' : 'var(--c-text2)',
211
+ opacity: active ? 1 : 0.6,
212
+ }}
213
+ >
214
+ <Icon size={11} />
215
+ {label}
216
+ </button>
217
+ )
218
+ })}
219
+ </div>
220
+ </div>
221
+
222
+ <div className="mt-4">
223
+ <div className="text-[11px] font-medium mb-2" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
224
+ Username
225
+ </div>
226
+ <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)' }}>
227
+ <User size={11} style={{ color: 'var(--c-text3)' }} />
228
+ <input
229
+ type="text"
230
+ placeholder="optional"
231
+ value={opts.username}
232
+ onChange={(e) => updateOpt('username', e.target.value)}
233
+ className="bg-transparent outline-none text-[12px] w-full"
234
+ style={{ color: 'var(--c-text)' }}
235
+ />
236
+ </div>
237
+ </div>
238
+ </div>
239
+
240
+ {/* Preview */}
241
+ <div className="flex-1 min-w-0">
242
+ <div className="text-[11px] font-medium mb-3" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
243
+ Preview
244
+ </div>
245
+ <div
246
+ className="rounded-lg overflow-hidden relative"
247
+ style={{
248
+ border: '1px solid var(--c-border)',
249
+ background: '#09090f',
250
+ minHeight: 200,
251
+ }}
252
+ >
253
+ {loading && (
254
+ <div className="absolute inset-0 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.5)', zIndex: 2 }}>
255
+ <span className="text-[12px]" style={{ color: 'var(--c-text2)' }}>Generating...</span>
256
+ </div>
257
+ )}
258
+ {svg && (
259
+ <div
260
+ className="w-full [&>svg]:w-full [&>svg]:h-auto [&>svg]:block"
261
+ dangerouslySetInnerHTML={{ __html: svg }}
262
+ />
263
+ )}
264
+ </div>
265
+ </div>
266
+ </div>
267
+ </div>
268
+
269
+ {/* Footer: actions */}
270
+ <div className="flex items-center justify-between px-5 py-3" style={{ borderTop: '1px solid var(--c-border)' }}>
271
+ <span className="text-[11px]" style={{ color: 'var(--c-text3)' }}>
272
+ Tip: Toggle sections to customize your share card
273
+ </span>
274
+ <div className="flex items-center gap-2">
275
+ <button
276
+ onClick={handleDownloadSvg}
277
+ className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md transition hover:opacity-80"
278
+ style={{ border: '1px solid var(--c-border)', color: 'var(--c-text)' }}
279
+ >
280
+ <Download size={12} />
281
+ SVG
282
+ </button>
283
+ <button
284
+ onClick={handleDownloadPng}
285
+ disabled={downloading}
286
+ className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md transition hover:opacity-80"
287
+ style={{ border: '1px solid var(--c-border)', color: 'var(--c-text)', opacity: downloading ? 0.5 : 1 }}
288
+ >
289
+ <Download size={12} />
290
+ {downloading ? 'Converting...' : 'PNG'}
291
+ </button>
292
+ <button
293
+ onClick={handleShareTwitter}
294
+ className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md transition hover:opacity-80"
295
+ style={{ background: '#6366f1', color: '#fff' }}
296
+ >
297
+ <Share2 size={12} />
298
+ Share on X
299
+ </button>
300
+ </div>
301
+ </div>
302
+ </div>
303
+ </div>
304
+ )
305
+ }
package/ui/src/lib/api.js CHANGED
@@ -180,8 +180,18 @@ 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
+ if (opts.folder) q.set('folder', opts.folder);
193
+ const qs = q.toString();
194
+ const res = await fetch(`${BASE}/api/share-image${qs ? '?' + qs : ''}`);
185
195
  return res.text();
186
196
  }
187
197
 
@@ -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
  }