ai-gains 1.0.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.
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+
3
+ 'use strict';
4
+
5
+ const path = require('path');
6
+ const { startServer } = require('../src/server');
7
+
8
+ const aiGainsDir = path.join(process.cwd(), '.ai-gains');
9
+ startServer(aiGainsDir);
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "ai-gains",
3
+ "version": "1.0.0",
4
+ "description": "Interactive browser dashboard for AI development session tracking",
5
+ "main": "src/server.js",
6
+ "bin": {
7
+ "ai-gains": "bin/ai-gains.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node bin/ai-gains.js"
11
+ },
12
+ "files": [
13
+ "bin/",
14
+ "src/"
15
+ ],
16
+ "dependencies": {
17
+ "express": "^4.18.2"
18
+ },
19
+ "keywords": [
20
+ "ai",
21
+ "productivity",
22
+ "dashboard",
23
+ "developer-tools",
24
+ "analytics"
25
+ ],
26
+ "license": "MIT",
27
+ "engines": {
28
+ "node": ">=16.0.0"
29
+ }
30
+ }
@@ -0,0 +1,830 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>AI Gains</title>
7
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js"></script>
8
+ <style>
9
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
10
+
11
+ :root {
12
+ --bg: #0f172a;
13
+ --surface: #1e293b;
14
+ --surface-2: #263348;
15
+ --border: #334155;
16
+ --text: #f1f5f9;
17
+ --muted: #94a3b8;
18
+ --blue: #3b82f6;
19
+ --blue-dim: rgba(59,130,246,0.12);
20
+ --green: #22c55e;
21
+ --green-dim: rgba(34,197,94,0.12);
22
+ --orange: #f97316;
23
+ --purple: #a855f7;
24
+ --purple-dim: rgba(168,85,247,0.12);
25
+ --yellow: #fbbf24;
26
+ --yellow-dim: rgba(251,191,36,0.12);
27
+ --radius: 10px;
28
+ }
29
+
30
+ body {
31
+ background: var(--bg);
32
+ color: var(--text);
33
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
34
+ min-height: 100vh;
35
+ line-height: 1.6;
36
+ }
37
+
38
+ .container {
39
+ max-width: 1080px;
40
+ margin: 0 auto;
41
+ padding: 0 24px 60px;
42
+ }
43
+
44
+ /* ── Header ── */
45
+ header {
46
+ border-bottom: 1px solid var(--border);
47
+ padding: 18px 0;
48
+ margin-bottom: 36px;
49
+ position: sticky;
50
+ top: 0;
51
+ background: var(--bg);
52
+ z-index: 10;
53
+ }
54
+
55
+ .header-inner {
56
+ max-width: 1080px;
57
+ margin: 0 auto;
58
+ padding: 0 24px;
59
+ display: flex;
60
+ align-items: center;
61
+ gap: 14px;
62
+ }
63
+
64
+ .logo {
65
+ font-size: 20px;
66
+ font-weight: 700;
67
+ letter-spacing: -0.3px;
68
+ }
69
+ .logo .accent { color: var(--blue); }
70
+
71
+ /* ── Filter bar ── */
72
+ .filter-bar {
73
+ display: flex;
74
+ align-items: center;
75
+ gap: 10px;
76
+ flex-wrap: wrap;
77
+ margin-bottom: 20px;
78
+ }
79
+
80
+ .filter-select {
81
+ appearance: none;
82
+ background: var(--surface) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%2394a3b8' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E") no-repeat right 10px center;
83
+ border: 1px solid var(--border);
84
+ border-radius: 7px;
85
+ color: var(--text);
86
+ cursor: pointer;
87
+ font-size: 13px;
88
+ outline: none;
89
+ padding: 6px 30px 6px 11px;
90
+ transition: border-color 0.12s;
91
+ }
92
+ .filter-select:hover, .filter-select:focus { border-color: var(--blue); }
93
+
94
+ .period-pills {
95
+ display: flex;
96
+ gap: 5px;
97
+ flex-wrap: wrap;
98
+ }
99
+
100
+ .period-pill {
101
+ background: none;
102
+ border: 1px solid var(--border);
103
+ border-radius: 6px;
104
+ color: var(--muted);
105
+ cursor: pointer;
106
+ font-size: 12px;
107
+ padding: 5px 11px;
108
+ transition: all 0.12s;
109
+ white-space: nowrap;
110
+ }
111
+ .period-pill:hover { border-color: var(--blue); color: var(--blue); }
112
+ .period-pill.active {
113
+ background: var(--blue-dim);
114
+ border-color: var(--blue);
115
+ color: var(--blue);
116
+ font-weight: 600;
117
+ }
118
+
119
+ .filter-divider {
120
+ width: 1px;
121
+ height: 22px;
122
+ background: var(--border);
123
+ flex-shrink: 0;
124
+ }
125
+
126
+ /* ── Stats ── */
127
+ .stats-grid {
128
+ display: grid;
129
+ grid-template-columns: repeat(4, 1fr);
130
+ gap: 14px;
131
+ margin-bottom: 28px;
132
+ }
133
+
134
+ @media (max-width: 700px) {
135
+ .stats-grid { grid-template-columns: repeat(2, 1fr); }
136
+ }
137
+
138
+ .stat-card {
139
+ background: var(--surface);
140
+ border: 1px solid var(--border);
141
+ border-radius: var(--radius);
142
+ padding: 20px 22px;
143
+ }
144
+
145
+ .stat-label {
146
+ font-size: 11px;
147
+ text-transform: uppercase;
148
+ letter-spacing: 0.09em;
149
+ color: var(--muted);
150
+ margin-bottom: 10px;
151
+ }
152
+
153
+ .stat-value {
154
+ font-size: 30px;
155
+ font-weight: 700;
156
+ line-height: 1;
157
+ letter-spacing: -0.5px;
158
+ }
159
+
160
+ .c-blue { color: var(--blue); }
161
+ .c-green { color: var(--green); }
162
+ .c-orange { color: var(--orange); }
163
+ .c-purple { color: var(--purple); }
164
+
165
+ /* ── Section wrapper ── */
166
+ .section {
167
+ background: var(--surface);
168
+ border: 1px solid var(--border);
169
+ border-radius: var(--radius);
170
+ padding: 22px 24px;
171
+ margin-bottom: 24px;
172
+ }
173
+
174
+ .section-label {
175
+ font-size: 11px;
176
+ text-transform: uppercase;
177
+ letter-spacing: 0.09em;
178
+ color: var(--muted);
179
+ margin-bottom: 18px;
180
+ }
181
+
182
+ .chart-wrap {
183
+ position: relative;
184
+ height: 240px;
185
+ }
186
+
187
+ /* ── Session cards ── */
188
+ .sessions-label {
189
+ font-size: 11px;
190
+ text-transform: uppercase;
191
+ letter-spacing: 0.09em;
192
+ color: var(--muted);
193
+ margin-bottom: 14px;
194
+ }
195
+
196
+ .session-card {
197
+ background: var(--surface);
198
+ border: 1px solid var(--border);
199
+ border-radius: var(--radius);
200
+ padding: 16px 20px;
201
+ margin-bottom: 10px;
202
+ cursor: pointer;
203
+ display: flex;
204
+ align-items: flex-start;
205
+ gap: 14px;
206
+ transition: background 0.12s, border-color 0.12s, transform 0.1s;
207
+ }
208
+
209
+ .session-card:hover {
210
+ background: var(--surface-2);
211
+ border-color: var(--blue);
212
+ transform: translateY(-1px);
213
+ }
214
+
215
+ .session-icon {
216
+ width: 38px;
217
+ height: 38px;
218
+ background: var(--blue-dim);
219
+ border-radius: 8px;
220
+ display: flex;
221
+ align-items: center;
222
+ justify-content: center;
223
+ font-size: 17px;
224
+ flex-shrink: 0;
225
+ margin-top: 1px;
226
+ }
227
+
228
+ .session-body { flex: 1; min-width: 0; }
229
+
230
+ .session-top {
231
+ display: flex;
232
+ flex-wrap: wrap;
233
+ align-items: center;
234
+ gap: 8px;
235
+ margin-bottom: 6px;
236
+ }
237
+
238
+ .session-date {
239
+ font-weight: 600;
240
+ font-size: 14px;
241
+ }
242
+
243
+ .pill {
244
+ display: inline-flex;
245
+ align-items: center;
246
+ gap: 3px;
247
+ border-radius: 5px;
248
+ padding: 2px 8px;
249
+ font-size: 11.5px;
250
+ border: 1px solid var(--border);
251
+ color: var(--muted);
252
+ }
253
+
254
+ .pill.branch { border-color: var(--purple); color: var(--purple); background: var(--purple-dim); font-family: monospace; }
255
+ .pill.speedup { border-color: var(--green); color: var(--green); background: var(--green-dim); font-weight: 600; }
256
+ .pill.author { border-color: var(--border); color: var(--muted); background: transparent; }
257
+
258
+ .session-meta {
259
+ display: flex;
260
+ gap: 14px;
261
+ flex-wrap: wrap;
262
+ font-size: 12.5px;
263
+ color: var(--muted);
264
+ }
265
+
266
+ .files-row {
267
+ display: flex;
268
+ flex-wrap: wrap;
269
+ gap: 5px;
270
+ margin-top: 8px;
271
+ }
272
+
273
+ .file-chip {
274
+ background: var(--blue-dim);
275
+ border: 1px solid rgba(59,130,246,0.25);
276
+ border-radius: 4px;
277
+ padding: 1px 7px;
278
+ font-size: 11px;
279
+ color: var(--blue);
280
+ font-family: 'SF Mono', 'Fira Code', Consolas, monospace;
281
+ }
282
+
283
+ .file-chip.created { background: var(--green-dim); border-color: rgba(34,197,94,0.3); color: var(--green); }
284
+ .file-chip.modified { background: var(--yellow-dim); border-color: rgba(251,191,36,0.3); color: var(--yellow); }
285
+
286
+ .chevron {
287
+ color: var(--muted);
288
+ font-size: 20px;
289
+ align-self: center;
290
+ flex-shrink: 0;
291
+ }
292
+
293
+ /* ── Detail view ── */
294
+ #detail-view { display: none; }
295
+ #detail-view.active { display: block; }
296
+ #dashboard-view.hidden { display: none; }
297
+
298
+ .back-btn {
299
+ display: inline-flex;
300
+ align-items: center;
301
+ gap: 6px;
302
+ background: none;
303
+ border: 1px solid var(--border);
304
+ border-radius: 7px;
305
+ color: var(--muted);
306
+ cursor: pointer;
307
+ font-size: 13px;
308
+ padding: 7px 14px;
309
+ margin-bottom: 26px;
310
+ transition: all 0.12s;
311
+ }
312
+ .back-btn:hover { border-color: var(--blue); color: var(--blue); background: var(--blue-dim); }
313
+
314
+ .detail-title {
315
+ font-size: 24px;
316
+ font-weight: 700;
317
+ letter-spacing: -0.3px;
318
+ margin-bottom: 4px;
319
+ }
320
+
321
+ .detail-uuid {
322
+ font-size: 12px;
323
+ color: var(--muted);
324
+ font-family: 'SF Mono', 'Fira Code', Consolas, monospace;
325
+ margin-bottom: 26px;
326
+ }
327
+
328
+ .detail-stats {
329
+ display: grid;
330
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
331
+ gap: 12px;
332
+ margin-bottom: 24px;
333
+ }
334
+
335
+ .detail-stat {
336
+ background: var(--surface);
337
+ border: 1px solid var(--border);
338
+ border-radius: 8px;
339
+ padding: 14px 16px;
340
+ }
341
+
342
+ .detail-stat-label {
343
+ font-size: 10.5px;
344
+ text-transform: uppercase;
345
+ letter-spacing: 0.09em;
346
+ color: var(--muted);
347
+ margin-bottom: 6px;
348
+ }
349
+
350
+ .detail-stat-value {
351
+ font-size: 20px;
352
+ font-weight: 700;
353
+ }
354
+
355
+ .speedup-banner {
356
+ background: var(--green-dim);
357
+ border: 1px solid rgba(34,197,94,0.2);
358
+ border-radius: 8px;
359
+ padding: 14px 18px;
360
+ color: var(--green);
361
+ font-size: 13.5px;
362
+ line-height: 1.7;
363
+ margin-bottom: 26px;
364
+ }
365
+
366
+ .achievement-card {
367
+ background: var(--surface);
368
+ border: 1px solid var(--border);
369
+ border-radius: var(--radius);
370
+ padding: 20px;
371
+ margin-bottom: 10px;
372
+ }
373
+
374
+ .achievement-num {
375
+ font-size: 10.5px;
376
+ text-transform: uppercase;
377
+ letter-spacing: 0.09em;
378
+ color: var(--muted);
379
+ margin-bottom: 10px;
380
+ }
381
+
382
+ .achievement-desc {
383
+ font-size: 14.5px;
384
+ line-height: 1.65;
385
+ margin-bottom: 14px;
386
+ }
387
+
388
+ .achievement-footer {
389
+ display: flex;
390
+ align-items: center;
391
+ gap: 10px;
392
+ flex-wrap: wrap;
393
+ }
394
+
395
+ .time-est {
396
+ margin-left: auto;
397
+ font-size: 12px;
398
+ color: var(--muted);
399
+ white-space: nowrap;
400
+ }
401
+
402
+ /* ── Empty / Loading ── */
403
+ .placeholder {
404
+ text-align: center;
405
+ padding: 60px 24px;
406
+ color: var(--muted);
407
+ }
408
+ .placeholder-icon { font-size: 44px; margin-bottom: 14px; }
409
+
410
+ /* ── Scrollbar ── */
411
+ ::-webkit-scrollbar { width: 7px; }
412
+ ::-webkit-scrollbar-track { background: var(--bg); }
413
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
414
+ </style>
415
+ </head>
416
+ <body>
417
+
418
+ <!-- ═══════════════════════════════ DASHBOARD ═══════════════════════════════ -->
419
+ <div id="dashboard-view">
420
+ <header>
421
+ <div class="header-inner">
422
+ <div class="logo">AI <span class="accent">Gains</span> ⚡</div>
423
+ </div>
424
+ </header>
425
+
426
+ <div class="container">
427
+ <!-- Filters -->
428
+ <div class="filter-bar" id="filter-bar">
429
+ <select id="filter-author" class="filter-select" onchange="applyFilters()">
430
+ <option value="">All authors</option>
431
+ </select>
432
+ <div class="filter-divider"></div>
433
+ <div class="period-pills" id="period-pills">
434
+ <button class="period-pill active" data-period="all" onclick="setPeriod(this)">All time</button>
435
+ <button class="period-pill" data-period="today" onclick="setPeriod(this)">Today</button>
436
+ <button class="period-pill" data-period="3d" onclick="setPeriod(this)">3 days</button>
437
+ <button class="period-pill" data-period="week" onclick="setPeriod(this)">Week</button>
438
+ <button class="period-pill" data-period="month" onclick="setPeriod(this)">Month</button>
439
+ <button class="period-pill" data-period="3m" onclick="setPeriod(this)">3 months</button>
440
+ <button class="period-pill" data-period="6m" onclick="setPeriod(this)">6 months</button>
441
+ <button class="period-pill" data-period="year" onclick="setPeriod(this)">Year</button>
442
+ </div>
443
+ </div>
444
+
445
+ <!-- Stats -->
446
+ <div class="stats-grid">
447
+ <div class="stat-card">
448
+ <div class="stat-label">Sessions</div>
449
+ <div class="stat-value c-blue" id="stat-sessions">—</div>
450
+ </div>
451
+ <div class="stat-card">
452
+ <div class="stat-label">Achievements</div>
453
+ <div class="stat-value c-purple" id="stat-achievements">—</div>
454
+ </div>
455
+ <div class="stat-card">
456
+ <div class="stat-label">Time Saved</div>
457
+ <div class="stat-value c-green" id="stat-saved">—</div>
458
+ </div>
459
+ <div class="stat-card">
460
+ <div class="stat-label">Avg Speedup</div>
461
+ <div class="stat-value c-orange" id="stat-speedup">—</div>
462
+ </div>
463
+ </div>
464
+
465
+ <!-- Chart -->
466
+ <div class="section">
467
+ <div class="section-label">AI Time vs Estimated Human Time (minutes per session)</div>
468
+ <div class="chart-wrap">
469
+ <canvas id="main-chart"></canvas>
470
+ </div>
471
+ </div>
472
+
473
+ <!-- Sessions list -->
474
+ <div class="sessions-label">Sessions</div>
475
+ <div id="sessions-list">
476
+ <div class="placeholder"><div class="placeholder-icon">⏳</div>Loading…</div>
477
+ </div>
478
+ </div>
479
+ </div>
480
+
481
+ <!-- ════════════════════════════════ DETAIL ══════════════════════════════════ -->
482
+ <div id="detail-view">
483
+ <header>
484
+ <div class="header-inner">
485
+ <div class="logo">AI <span class="accent">Gains</span> ⚡</div>
486
+ </div>
487
+ </header>
488
+
489
+ <div class="container">
490
+ <button class="back-btn" onclick="showDashboard()">← Back to Dashboard</button>
491
+ <div id="detail-content"></div>
492
+ </div>
493
+ </div>
494
+
495
+ <script>
496
+ 'use strict';
497
+
498
+ let sessions = [];
499
+ let chart = null;
500
+ let filterAuthor = '';
501
+ let filterPeriod = 'all';
502
+
503
+ // ── Filters ──────────────────────────────────────────────────────────────────
504
+
505
+ function getFiltered() {
506
+ let result = sessions;
507
+
508
+ if (filterAuthor) {
509
+ result = result.filter(s => s.author === filterAuthor);
510
+ }
511
+
512
+ if (filterPeriod !== 'all') {
513
+ const now = Date.now();
514
+ const DAY = 86400000;
515
+ let cutoff;
516
+ switch (filterPeriod) {
517
+ case 'today': {
518
+ const d = new Date(); d.setHours(0, 0, 0, 0); cutoff = d; break;
519
+ }
520
+ case '3d': cutoff = new Date(now - 3 * DAY); break;
521
+ case 'week': cutoff = new Date(now - 7 * DAY); break;
522
+ case 'month': cutoff = new Date(now - 30 * DAY); break;
523
+ case '3m': cutoff = new Date(now - 90 * DAY); break;
524
+ case '6m': cutoff = new Date(now - 180 * DAY); break;
525
+ case 'year': cutoff = new Date(now - 365 * DAY); break;
526
+ }
527
+ if (cutoff) result = result.filter(s => new Date(s.start_time) >= cutoff);
528
+ }
529
+
530
+ return result;
531
+ }
532
+
533
+ function setPeriod(btn) {
534
+ document.querySelectorAll('.period-pill').forEach(b => b.classList.remove('active'));
535
+ btn.classList.add('active');
536
+ filterPeriod = btn.dataset.period;
537
+ applyFilters();
538
+ }
539
+
540
+ function applyFilters() {
541
+ filterAuthor = document.getElementById('filter-author').value;
542
+ renderStats();
543
+ renderChart();
544
+ renderSessions();
545
+ }
546
+
547
+ function populateAuthors() {
548
+ const authors = [...new Set(sessions.map(s => s.author).filter(Boolean))].sort();
549
+ const sel = document.getElementById('filter-author');
550
+ // hide the author filter entirely if no session has an author
551
+ if (!authors.length) {
552
+ sel.closest('.filter-bar').querySelector('.filter-divider').style.display = 'none';
553
+ sel.style.display = 'none';
554
+ return;
555
+ }
556
+ authors.forEach(a => {
557
+ const opt = document.createElement('option');
558
+ opt.value = a;
559
+ opt.textContent = a;
560
+ sel.appendChild(opt);
561
+ });
562
+ }
563
+
564
+ // ── Formatters ──────────────────────────────────────────────────────────────
565
+
566
+ function fmtDate(iso) {
567
+ const d = new Date(iso);
568
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
569
+ + ' · '
570
+ + d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
571
+ }
572
+
573
+ function fmtDateShort(iso) {
574
+ const d = new Date(iso);
575
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
576
+ + ' '
577
+ + d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
578
+ }
579
+
580
+ function fmtDur(min) {
581
+ if (!min) return '0 min';
582
+ if (min < 60) return min + ' min';
583
+ const h = Math.floor(min / 60), m = min % 60;
584
+ return m ? h + 'h ' + m + 'm' : h + 'h';
585
+ }
586
+
587
+ function humanTime(s) {
588
+ return s.achievements.reduce((sum, a) => sum + (a.estimated_human_time_minutes || 0), 0);
589
+ }
590
+
591
+ function speedup(s) {
592
+ const h = humanTime(s);
593
+ return s.duration_minutes > 0 ? h / s.duration_minutes : 0;
594
+ }
595
+
596
+ function esc(str) {
597
+ return String(str)
598
+ .replace(/&/g, '&amp;')
599
+ .replace(/</g, '&lt;')
600
+ .replace(/>/g, '&gt;')
601
+ .replace(/"/g, '&quot;');
602
+ }
603
+
604
+ // ── Render stats ────────────────────────────────────────────────────────────
605
+
606
+ function renderStats() {
607
+ const data = getFiltered();
608
+ const totalAI = data.reduce((s, x) => s + x.duration_minutes, 0);
609
+ const totalHuman = data.reduce((s, x) => s + humanTime(x), 0);
610
+ const saved = totalHuman - totalAI;
611
+ const sp = totalAI > 0 ? totalHuman / totalAI : 0;
612
+ const ach = data.reduce((s, x) => s + x.achievements.length, 0);
613
+
614
+ document.getElementById('stat-sessions').textContent = data.length;
615
+ document.getElementById('stat-achievements').textContent = ach;
616
+ document.getElementById('stat-saved').textContent = fmtDur(saved);
617
+ document.getElementById('stat-speedup').textContent = sp.toFixed(1) + '×';
618
+ }
619
+
620
+ // ── Render chart ────────────────────────────────────────────────────────────
621
+
622
+ function renderChart() {
623
+ const sorted = [...getFiltered()].sort((a, b) => new Date(a.start_time) - new Date(b.start_time));
624
+ const labels = sorted.map(s => fmtDateShort(s.start_time));
625
+ const aiTimes = sorted.map(s => s.duration_minutes);
626
+ const humanTimes = sorted.map(s => humanTime(s));
627
+
628
+ if (chart) chart.destroy();
629
+
630
+ chart = new Chart(document.getElementById('main-chart').getContext('2d'), {
631
+ type: 'bar',
632
+ data: {
633
+ labels,
634
+ datasets: [
635
+ {
636
+ label: 'AI Time (min)',
637
+ data: aiTimes,
638
+ backgroundColor: 'rgba(59,130,246,0.75)',
639
+ borderColor: 'rgb(59,130,246)',
640
+ borderWidth: 1,
641
+ borderRadius: 5,
642
+ borderSkipped: false,
643
+ },
644
+ {
645
+ label: 'Human Estimate (min)',
646
+ data: humanTimes,
647
+ backgroundColor: 'rgba(34,197,94,0.45)',
648
+ borderColor: 'rgb(34,197,94)',
649
+ borderWidth: 1,
650
+ borderRadius: 5,
651
+ borderSkipped: false,
652
+ }
653
+ ]
654
+ },
655
+ options: {
656
+ responsive: true,
657
+ maintainAspectRatio: false,
658
+ plugins: {
659
+ legend: {
660
+ labels: { color: '#94a3b8', font: { size: 12 }, boxWidth: 13, padding: 18 }
661
+ },
662
+ tooltip: {
663
+ backgroundColor: '#1e293b',
664
+ borderColor: '#334155',
665
+ borderWidth: 1,
666
+ titleColor: '#f1f5f9',
667
+ bodyColor: '#94a3b8',
668
+ callbacks: {
669
+ label: ctx => ` ${ctx.dataset.label}: ${ctx.parsed.y} min`
670
+ }
671
+ }
672
+ },
673
+ scales: {
674
+ x: {
675
+ grid: { color: 'rgba(51,65,85,0.5)' },
676
+ ticks: { color: '#94a3b8', font: { size: 11 } }
677
+ },
678
+ y: {
679
+ grid: { color: 'rgba(51,65,85,0.5)' },
680
+ ticks: { color: '#94a3b8', font: { size: 11 }, callback: v => v + ' min' },
681
+ beginAtZero: true
682
+ }
683
+ }
684
+ }
685
+ });
686
+ }
687
+
688
+ // ── Render session list ─────────────────────────────────────────────────────
689
+
690
+ function renderSessions() {
691
+ const el = document.getElementById('sessions-list');
692
+ const data = [...getFiltered()].sort((a, b) => new Date(b.start_time) - new Date(a.start_time));
693
+
694
+ if (!data.length) {
695
+ el.innerHTML = '<div class="placeholder"><div class="placeholder-icon">📊</div>No sessions match the current filters</div>';
696
+ return;
697
+ }
698
+
699
+ el.innerHTML = data.map(s => {
700
+ const sp = speedup(s);
701
+ const saved = humanTime(s) - s.duration_minutes;
702
+ const authorPill = s.author ? `<span class="pill author">✉ ${esc(s.author)}</span>` : '';
703
+ const branchPill = s.branch ? `<span class="pill branch">⎇ ${esc(s.branch)}</span>` : '';
704
+
705
+ return `
706
+ <div class="session-card" onclick="showSession('${esc(s.uuid)}')">
707
+ <div class="session-icon">⚡</div>
708
+ <div class="session-body">
709
+ <div class="session-top">
710
+ <span class="session-date">${esc(fmtDate(s.start_time))}</span>
711
+ ${authorPill}${branchPill}
712
+ <span class="pill speedup">${sp.toFixed(1)}× faster</span>
713
+ </div>
714
+ <div class="session-meta">
715
+ <span>⏱ ${esc(fmtDur(s.duration_minutes))}</span>
716
+ <span>⚡ ${s.achievements.length} achievement${s.achievements.length !== 1 ? 's' : ''}</span>
717
+ <span>💾 ${esc(fmtDur(saved))} saved</span>
718
+ </div>
719
+ </div>
720
+ <div class="chevron">›</div>
721
+ </div>`;
722
+ }).join('');
723
+ }
724
+
725
+ // ── Session detail ──────────────────────────────────────────────────────────
726
+
727
+ function showSession(uuid) {
728
+ const s = sessions.find(x => x.uuid === uuid);
729
+ if (!s) return;
730
+ const sp = speedup(s);
731
+ const ht = humanTime(s);
732
+ const sv = ht - s.duration_minutes;
733
+
734
+ const achievementsHTML = s.achievements.map((a, i) => {
735
+ const created = (a.files_created || []).map(f => `<span class="file-chip created">+ ${esc(f)}</span>`).join('');
736
+ const modified = (a.files_modified || []).map(f => `<span class="file-chip modified">~ ${esc(f)}</span>`).join('');
737
+ const hasFiles = created || modified;
738
+
739
+ return `
740
+ <div class="achievement-card">
741
+ <div class="achievement-num">Achievement ${i + 1} of ${s.achievements.length}</div>
742
+ <div class="achievement-desc">${esc(a.description)}</div>
743
+ <div class="achievement-footer">
744
+ ${hasFiles ? `<div class="files-row">${created}${modified}</div>` : ''}
745
+ <div class="time-est">≈ ${esc(fmtDur(a.estimated_human_time_minutes))} human est.</div>
746
+ </div>
747
+ </div>`;
748
+ }).join('');
749
+
750
+ const authorStat = s.author ? `
751
+ <div class="detail-stat">
752
+ <div class="detail-stat-label">Author</div>
753
+ <div class="detail-stat-value" style="font-size:13px;font-weight:500;color:var(--muted)">${esc(s.author)}</div>
754
+ </div>` : '';
755
+ const branchStat = s.branch ? `
756
+ <div class="detail-stat">
757
+ <div class="detail-stat-label">Branch</div>
758
+ <div class="detail-stat-value c-purple" style="font-size:16px;font-family:monospace">${esc(s.branch)}</div>
759
+ </div>` : '';
760
+ const filesHTML = (s.files_touched || []).map(f => `<span class="file-chip">${esc(f)}</span>`).join('');
761
+
762
+ document.getElementById('detail-content').innerHTML = `
763
+ <div class="detail-title">${esc(fmtDate(s.start_time))}</div>
764
+ <div class="detail-uuid">${esc(s.uuid)}</div>
765
+
766
+ <div class="detail-stats">
767
+ <div class="detail-stat">
768
+ <div class="detail-stat-label">AI Duration</div>
769
+ <div class="detail-stat-value c-blue">${esc(fmtDur(s.duration_minutes))}</div>
770
+ </div>
771
+ <div class="detail-stat">
772
+ <div class="detail-stat-label">Human Estimate</div>
773
+ <div class="detail-stat-value">${esc(fmtDur(ht))}</div>
774
+ </div>
775
+ <div class="detail-stat">
776
+ <div class="detail-stat-label">Time Saved</div>
777
+ <div class="detail-stat-value c-green">${esc(fmtDur(sv))}</div>
778
+ </div>
779
+ <div class="detail-stat">
780
+ <div class="detail-stat-label">Speedup</div>
781
+ <div class="detail-stat-value c-orange">${sp.toFixed(1)}×</div>
782
+ </div>
783
+ ${authorStat}${branchStat}
784
+ </div>
785
+
786
+ ${filesHTML ? `
787
+ <div class="section-label" style="margin-bottom:10px;">Files Touched</div>
788
+ <div class="files-row" style="margin-bottom:24px;">${filesHTML}</div>` : ''}
789
+
790
+ <div class="speedup-banner">⚡ ${esc(s.ai_speedup)}</div>
791
+
792
+ <div class="section-label" style="margin-bottom:14px;">Achievements</div>
793
+ ${achievementsHTML}
794
+ `;
795
+
796
+ document.getElementById('dashboard-view').classList.add('hidden');
797
+ document.getElementById('detail-view').classList.add('active');
798
+ window.scrollTo(0, 0);
799
+ }
800
+
801
+ function showDashboard() {
802
+ document.getElementById('dashboard-view').classList.remove('hidden');
803
+ document.getElementById('detail-view').classList.remove('active');
804
+ window.scrollTo(0, 0);
805
+ }
806
+
807
+ // ── Init ────────────────────────────────────────────────────────────────────
808
+
809
+ async function init() {
810
+ try {
811
+ const res = await fetch('/api/sessions');
812
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
813
+ sessions = await res.json();
814
+ populateAuthors();
815
+ renderStats();
816
+ renderChart();
817
+ renderSessions();
818
+ } catch (err) {
819
+ document.getElementById('sessions-list').innerHTML = `
820
+ <div class="placeholder">
821
+ <div class="placeholder-icon">⚠️</div>
822
+ Failed to load sessions: ${esc(err.message)}
823
+ </div>`;
824
+ }
825
+ }
826
+
827
+ init();
828
+ </script>
829
+ </body>
830
+ </html>
package/src/server.js ADDED
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ const express = require('express');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { exec } = require('child_process');
7
+
8
+ function openBrowser(url) {
9
+ const platform = process.platform;
10
+ const cmd =
11
+ platform === 'darwin' ? `open "${url}"` :
12
+ platform === 'win32' ? `start "" "${url}"` :
13
+ `xdg-open "${url}"`;
14
+ exec(cmd, (err) => {
15
+ if (err) console.log(` Visit ${url} in your browser`);
16
+ });
17
+ }
18
+
19
+ function readSessions(aiGainsDir) {
20
+ return fs.readdirSync(aiGainsDir)
21
+ .filter(f => f.endsWith('.json'))
22
+ .sort()
23
+ .map(file => {
24
+ try {
25
+ return JSON.parse(fs.readFileSync(path.join(aiGainsDir, file), 'utf8'));
26
+ } catch {
27
+ return null;
28
+ }
29
+ })
30
+ .filter(Boolean);
31
+ }
32
+
33
+ function startServer(aiGainsDir) {
34
+ if (!fs.existsSync(aiGainsDir)) {
35
+ console.error(`\n Error: No .ai-gains directory found in:\n ${process.cwd()}\n`);
36
+ console.error(' Run this command from a project directory containing a .ai-gains folder.\n');
37
+ process.exit(1);
38
+ }
39
+
40
+ const app = express();
41
+
42
+ app.get('/api/sessions', (req, res) => {
43
+ try {
44
+ res.json(readSessions(aiGainsDir));
45
+ } catch (err) {
46
+ res.status(500).json({ error: err.message });
47
+ }
48
+ });
49
+
50
+ app.get('/', (req, res) => {
51
+ res.sendFile(path.join(__dirname, 'dashboard.html'));
52
+ });
53
+
54
+ const PORT = parseInt(process.env.PORT || '3847', 10);
55
+
56
+ const server = app.listen(PORT, '127.0.0.1', () => {
57
+ const url = `http://localhost:${PORT}`;
58
+ console.log(`\n AI Gains ⚡\n`);
59
+ console.log(` Dashboard : ${url}`);
60
+ console.log(` Directory : ${aiGainsDir}`);
61
+ console.log(`\n Press Ctrl+C to stop\n`);
62
+ openBrowser(url);
63
+ });
64
+
65
+ server.on('error', (err) => {
66
+ if (err.code === 'EADDRINUSE') {
67
+ console.error(`\n Port ${PORT} is already in use.`);
68
+ console.error(` Try: PORT=4000 npx ai-gains\n`);
69
+ } else {
70
+ console.error(' Server error:', err.message);
71
+ }
72
+ process.exit(1);
73
+ });
74
+
75
+ process.on('SIGINT', () => {
76
+ process.stdout.write('\n');
77
+ server.close(() => process.exit(0));
78
+ });
79
+ }
80
+
81
+ module.exports = { startServer };