ai-gains 1.3.5 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-gains",
3
- "version": "1.3.5",
3
+ "version": "1.5.0",
4
4
  "description": "Interactive browser dashboard for AI development session tracking",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -182,6 +182,7 @@
182
182
  .chart-wrap {
183
183
  position: relative;
184
184
  height: 240px;
185
+ cursor: pointer;
185
186
  }
186
187
 
187
188
  /* ── Session cards ── */
@@ -407,6 +408,93 @@
407
408
  }
408
409
  .placeholder-icon { font-size: 44px; margin-bottom: 14px; }
409
410
 
411
+ /* ── Category breakdown ── */
412
+ .cat-layout {
413
+ display: flex;
414
+ align-items: center;
415
+ gap: 32px;
416
+ }
417
+
418
+ .cat-donut-wrap {
419
+ position: relative;
420
+ width: 180px;
421
+ height: 180px;
422
+ flex-shrink: 0;
423
+ }
424
+
425
+ .cat-legend {
426
+ flex: 1;
427
+ min-width: 0;
428
+ }
429
+
430
+ .cat-legend-row {
431
+ display: grid;
432
+ grid-template-columns: 10px 1fr auto auto;
433
+ align-items: center;
434
+ gap: 8px 12px;
435
+ padding: 6px 0;
436
+ border-bottom: 1px solid var(--border);
437
+ cursor: default;
438
+ transition: background 0.1s;
439
+ border-radius: 4px;
440
+ }
441
+
442
+ .cat-legend-row:last-child { border-bottom: none; }
443
+ .cat-legend-row:hover { background: var(--surface-2); }
444
+
445
+ .cat-dot {
446
+ width: 8px;
447
+ height: 8px;
448
+ border-radius: 50%;
449
+ flex-shrink: 0;
450
+ justify-self: center;
451
+ }
452
+
453
+ .cat-legend-name {
454
+ font-size: 13px;
455
+ font-weight: 500;
456
+ }
457
+
458
+ .cat-legend-time {
459
+ font-size: 13px;
460
+ font-weight: 700;
461
+ text-align: right;
462
+ white-space: nowrap;
463
+ }
464
+
465
+ .cat-legend-meta {
466
+ font-size: 11px;
467
+ color: var(--muted);
468
+ text-align: right;
469
+ white-space: nowrap;
470
+ }
471
+
472
+ @media (max-width: 600px) {
473
+ .cat-layout { flex-direction: column; align-items: flex-start; }
474
+ .cat-donut-wrap { width: 150px; height: 150px; }
475
+ }
476
+
477
+ /* ── Category badge ── */
478
+ .cat-badge {
479
+ display: inline-flex;
480
+ align-items: center;
481
+ gap: 4px;
482
+ border-radius: 4px;
483
+ padding: 2px 7px;
484
+ font-size: 10px;
485
+ font-weight: 600;
486
+ letter-spacing: 0.05em;
487
+ text-transform: uppercase;
488
+ white-space: nowrap;
489
+ }
490
+
491
+ .cat-summary {
492
+ display: flex;
493
+ flex-wrap: wrap;
494
+ gap: 6px;
495
+ margin-bottom: 18px;
496
+ }
497
+
410
498
  /* ── Scrollbar ── */
411
499
  ::-webkit-scrollbar { width: 7px; }
412
500
  ::-webkit-scrollbar-track { background: var(--bg); }
@@ -431,7 +519,6 @@
431
519
  </select>
432
520
  <div class="filter-divider"></div>
433
521
  <div class="period-pills" id="period-pills">
434
- <button class="period-pill active" data-period="all" onclick="setPeriod(this)">All time</button>
435
522
  <button class="period-pill" data-period="today" onclick="setPeriod(this)">Today</button>
436
523
  <button class="period-pill" data-period="3d" onclick="setPeriod(this)">3 days</button>
437
524
  <button class="period-pill" data-period="week" onclick="setPeriod(this)">Week</button>
@@ -439,8 +526,10 @@
439
526
  <button class="period-pill" data-period="3m" onclick="setPeriod(this)">3 months</button>
440
527
  <button class="period-pill" data-period="6m" onclick="setPeriod(this)">6 months</button>
441
528
  <button class="period-pill" data-period="year" onclick="setPeriod(this)">Year</button>
529
+ <button class="period-pill" data-period="all" onclick="setPeriod(this)">All time</button>
442
530
  </div>
443
531
  </div>
532
+ <div id="last-session-note" style="font-size:12px;color:var(--muted);margin-bottom:20px;min-height:16px"></div>
444
533
 
445
534
  <!-- Stats -->
446
535
  <div class="stats-grid">
@@ -458,18 +547,34 @@
458
547
  </div>
459
548
  <div class="stat-card">
460
549
  <div class="stat-label">Avg Speedup</div>
461
- <div class="stat-value c-orange" id="stat-speedup">—</div>
550
+ <div class="stat-value c-orange" style="display:flex;align-items:baseline;gap:8px">
551
+ <span id="stat-speedup">—</span>
552
+ <span id="stat-speedup-trend" style="font-size:14px;font-weight:600;letter-spacing:0"></span>
553
+ </div>
462
554
  </div>
463
555
  </div>
464
556
 
465
557
  <!-- Chart -->
466
558
  <div class="section">
467
- <div class="section-label">AI Time vs Estimated Human Time (minutes per session)</div>
559
+ <div class="section-label">Cumulative Time Saved</div>
468
560
  <div class="chart-wrap">
469
561
  <canvas id="main-chart"></canvas>
470
562
  </div>
471
563
  </div>
472
564
 
565
+ <!-- Category breakdown -->
566
+ <div id="cat-section" style="display:none">
567
+ <div class="section">
568
+ <div class="section-label">By Category</div>
569
+ <div class="cat-layout">
570
+ <div class="cat-donut-wrap">
571
+ <canvas id="cat-chart"></canvas>
572
+ </div>
573
+ <div class="cat-legend" id="cat-legend"></div>
574
+ </div>
575
+ </div>
576
+ </div>
577
+
473
578
  <!-- Sessions list -->
474
579
  <div class="sessions-label">Sessions</div>
475
580
  <div id="sessions-list">
@@ -496,9 +601,94 @@
496
601
  'use strict';
497
602
 
498
603
  let sessions = [];
499
- let chart = null;
604
+ let chart = null;
605
+ let catChart = null;
500
606
  let filterAuthor = '';
501
- let filterPeriod = 'all';
607
+ let filterPeriod = 'all'; // overwritten by pickDefaultPeriod() after load
608
+
609
+ // ── Categories ───────────────────────────────────────────────────────────────
610
+
611
+ const CATEGORIES = [
612
+ {
613
+ id: 'bug-fix',
614
+ label: 'Bug Fix',
615
+ color: '#ef4444',
616
+ dim: 'rgba(239,68,68,0.15)',
617
+ keywords: ['fixed', 'fix ', 'bug', 'broken', 'regression', 'incorrect', 'wrong', 'invalid',
618
+ 'failure', 'failed', 'http 4', 'http 5', '410', '400', '500',
619
+ 'error', 'always showing', 'not working', 'deprecated', 'corrupt'],
620
+ },
621
+ {
622
+ id: 'debugging',
623
+ label: 'Debugging',
624
+ color: '#f59e0b',
625
+ dim: 'rgba(245,158,11,0.15)',
626
+ keywords: ['debug', 'diagnos', 'root cause', 'traced', 'reproduc', 'pinpoint',
627
+ 'found the issue', 'found issue', 'identified the bug'],
628
+ },
629
+ {
630
+ id: 'enhancement',
631
+ label: 'Enhancement',
632
+ color: '#3b82f6',
633
+ dim: 'rgba(59,130,246,0.15)',
634
+ keywords: ['added', 'implemented', 'built', 'created', 'feature', 'improved', 'enhanced',
635
+ 'redesigned', 'extended', 'upgraded', 'integrated', 'new '],
636
+ },
637
+ {
638
+ id: 'research',
639
+ label: 'Research',
640
+ color: '#a855f7',
641
+ dim: 'rgba(168,85,247,0.15)',
642
+ keywords: ['research', 'analyz', 'review', 'explor', 'compar', 'evaluat', 'audit',
643
+ 'investigat', 'survey', 'visual review', 'assess', 'benchmark'],
644
+ },
645
+ {
646
+ id: 'documentation',
647
+ label: 'Documentation',
648
+ color: '#14b8a6',
649
+ dim: 'rgba(20,184,166,0.15)',
650
+ keywords: ['doc', 'readme', 'comment', 'wrote', 'written', 'document', 'spec', 'guide', 'changelog'],
651
+ },
652
+ {
653
+ id: 'testing',
654
+ label: 'Testing',
655
+ color: '#06b6d4',
656
+ dim: 'rgba(6,182,212,0.15)',
657
+ keywords: ['test', 'spec', 'unit test', 'integration test', 'e2e', 'assert', 'coverage', 'validat'],
658
+ },
659
+ {
660
+ id: 'refactoring',
661
+ label: 'Refactoring',
662
+ color: '#8b5cf6',
663
+ dim: 'rgba(139,92,246,0.15)',
664
+ keywords: ['refactor', 'restructur', 'clean up', 'cleanup', 'simplif', 'consolidat', 'reorganiz', 'dedup'],
665
+ },
666
+ {
667
+ id: 'ui-ux',
668
+ label: 'UI/UX',
669
+ color: '#ec4899',
670
+ dim: 'rgba(236,72,153,0.15)',
671
+ keywords: ['ui', 'ux', 'design', 'layout', 'style', 'css', 'component', 'visual', 'interface', 'responsive'],
672
+ },
673
+ ];
674
+
675
+ const CAT_OTHER = { id: 'other', label: 'Other', color: '#94a3b8', dim: 'rgba(148,163,184,0.15)' };
676
+ const CAT_MAP = Object.fromEntries([...CATEGORIES, CAT_OTHER].map(c => [c.id, c]));
677
+
678
+ function classifyByKeywords(description) {
679
+ const lower = description.toLowerCase();
680
+ let best = CAT_OTHER, bestScore = 0;
681
+ for (const cat of CATEGORIES) {
682
+ const score = cat.keywords.filter(k => lower.includes(k)).length;
683
+ if (score > bestScore) { bestScore = score; best = cat; }
684
+ }
685
+ return best;
686
+ }
687
+
688
+ function getCategory(achievement) {
689
+ if (achievement.category) return CAT_MAP[achievement.category] || CAT_OTHER;
690
+ return classifyByKeywords(achievement.description);
691
+ }
502
692
 
503
693
  // ── Filters ──────────────────────────────────────────────────────────────────
504
694
 
@@ -539,7 +729,9 @@
539
729
 
540
730
  function applyFilters() {
541
731
  filterAuthor = document.getElementById('filter-author').value;
732
+ renderLastSessionNote();
542
733
  renderStats();
734
+ renderCategories();
543
735
  renderChart();
544
736
  renderSessions();
545
737
  }
@@ -601,6 +793,33 @@
601
793
  .replace(/"/g, '&quot;');
602
794
  }
603
795
 
796
+ // ── Last session note ────────────────────────────────────────────────────────
797
+
798
+ function renderLastSessionNote() {
799
+ const el = document.getElementById('last-session-note');
800
+ if (!sessions.length) { el.textContent = ''; return; }
801
+
802
+ const latest = sessions.reduce((a, b) =>
803
+ new Date(a.start_time) > new Date(b.start_time) ? a : b);
804
+ const diff = Date.now() - new Date(latest.start_time);
805
+ const mins = Math.floor(diff / 60000);
806
+ const hours = Math.floor(diff / 3600000);
807
+ const days = Math.floor(diff / 86400000);
808
+
809
+ let ago;
810
+ if (mins < 1) ago = 'just now';
811
+ else if (mins < 60) ago = `${mins} minute${mins !== 1 ? 's' : ''} ago`;
812
+ else if (hours < 24) ago = `${hours} hour${hours !== 1 ? 's' : ''} ago`;
813
+ else if (days === 1) ago = 'yesterday';
814
+ else ago = `${days} days ago`;
815
+
816
+ if (filterPeriod === 'today' && days === 0) {
817
+ el.textContent = '';
818
+ } else {
819
+ el.textContent = `Most recent session ${ago}`;
820
+ }
821
+ }
822
+
604
823
  // ── Render stats ────────────────────────────────────────────────────────────
605
824
 
606
825
  function renderStats() {
@@ -615,58 +834,205 @@
615
834
  document.getElementById('stat-achievements').textContent = ach;
616
835
  document.getElementById('stat-saved').textContent = fmtDur(saved);
617
836
  document.getElementById('stat-speedup').textContent = sp.toFixed(1) + '×';
837
+
838
+ // Trend: compare avg speedup of most recent 3 sessions vs prior 3
839
+ const trendEl = document.getElementById('stat-speedup-trend');
840
+ const sorted = [...data].sort((a, b) => new Date(a.start_time) - new Date(b.start_time));
841
+ const avgSp = arr => arr.reduce((s, x) => s + speedup(x), 0) / arr.length;
842
+ if (sorted.length >= 4) {
843
+ const recent = sorted.slice(-3);
844
+ const prior = sorted.slice(-6, -3);
845
+ const diff = avgSp(recent) - avgSp(prior);
846
+ if (diff > 0.1) {
847
+ trendEl.textContent = '↑';
848
+ trendEl.style.color = 'var(--green)';
849
+ trendEl.title = `Recent avg ${avgSp(recent).toFixed(1)}× vs prior ${avgSp(prior).toFixed(1)}×`;
850
+ } else if (diff < -0.1) {
851
+ trendEl.textContent = '↓';
852
+ trendEl.style.color = '#f87171';
853
+ trendEl.title = `Recent avg ${avgSp(recent).toFixed(1)}× vs prior ${avgSp(prior).toFixed(1)}×`;
854
+ } else {
855
+ trendEl.textContent = '→';
856
+ trendEl.style.color = 'var(--muted)';
857
+ trendEl.title = `Recent avg ${avgSp(recent).toFixed(1)}× vs prior ${avgSp(prior).toFixed(1)}×`;
858
+ }
859
+ } else {
860
+ trendEl.textContent = '';
861
+ trendEl.title = '';
862
+ }
863
+ }
864
+
865
+ // ── Render category breakdown ────────────────────────────────────────────────
866
+
867
+ function renderCategories() {
868
+ const data = getFiltered();
869
+ const section = document.getElementById('cat-section');
870
+ if (catChart) { catChart.destroy(); catChart = null; }
871
+ if (!data.length) { section.style.display = 'none'; return; }
872
+
873
+ const tally = {};
874
+ let totalSaved = 0;
875
+ for (const s of data) {
876
+ for (const a of s.achievements) {
877
+ const cat = getCategory(a);
878
+ const mins = a.estimated_human_time_minutes || 0;
879
+ if (!tally[cat.id]) tally[cat.id] = { cat, saved: 0, count: 0 };
880
+ tally[cat.id].saved += mins;
881
+ tally[cat.id].count += 1;
882
+ totalSaved += mins;
883
+ }
884
+ }
885
+
886
+ const sorted = Object.values(tally).sort((a, b) => b.saved - a.saved);
887
+ if (!sorted.length) { section.style.display = 'none'; return; }
888
+
889
+ section.style.display = '';
890
+
891
+ // Centre-text plugin — draws total time saved + label inside the donut hole
892
+ const centerTextPlugin = {
893
+ id: 'centerText',
894
+ afterDatasetsDraw(ch) {
895
+ const { ctx, chartArea: { top, bottom, left, right } } = ch;
896
+ const cx = (left + right) / 2, cy = (top + bottom) / 2;
897
+ ctx.save();
898
+ ctx.textAlign = 'center';
899
+ ctx.fillStyle = '#f1f5f9';
900
+ ctx.font = 'bold 16px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
901
+ ctx.fillText(fmtDur(totalSaved), cx, cy - 9);
902
+ ctx.fillStyle = '#64748b';
903
+ ctx.font = '10px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
904
+ ctx.fillText('TIME SAVED', cx, cy + 10);
905
+ ctx.restore();
906
+ }
907
+ };
908
+
909
+ catChart = new Chart(document.getElementById('cat-chart').getContext('2d'), {
910
+ type: 'doughnut',
911
+ plugins: [centerTextPlugin],
912
+ data: {
913
+ labels: sorted.map(r => r.cat.label),
914
+ datasets: [{
915
+ data: sorted.map(r => r.saved),
916
+ backgroundColor: sorted.map(r => r.cat.color),
917
+ borderColor: '#1e293b',
918
+ borderWidth: 3,
919
+ hoverOffset: 6,
920
+ }]
921
+ },
922
+ options: {
923
+ responsive: true,
924
+ maintainAspectRatio: false,
925
+ cutout: '68%',
926
+ plugins: {
927
+ legend: { display: false },
928
+ tooltip: {
929
+ backgroundColor: '#1e293b',
930
+ borderColor: '#334155',
931
+ borderWidth: 1,
932
+ titleColor: '#f1f5f9',
933
+ bodyColor: '#94a3b8',
934
+ padding: 10,
935
+ callbacks: {
936
+ label(ctx) {
937
+ const { saved, count } = sorted[ctx.dataIndex];
938
+ const pct = totalSaved > 0 ? ((saved / totalSaved) * 100).toFixed(1) : 0;
939
+ return [
940
+ ` ${fmtDur(saved)} saved (${pct}%)`,
941
+ ` ${count} achievement${count !== 1 ? 's' : ''}`,
942
+ ];
943
+ }
944
+ }
945
+ }
946
+ }
947
+ }
948
+ });
949
+
950
+ // Legend table
951
+ document.getElementById('cat-legend').innerHTML = sorted.map(({ cat, saved, count }) => {
952
+ const pct = totalSaved > 0 ? Math.round((saved / totalSaved) * 100) : 0;
953
+ return `
954
+ <div class="cat-legend-row">
955
+ <span class="cat-dot" style="background:${cat.color}"></span>
956
+ <span class="cat-legend-name">${esc(cat.label)}</span>
957
+ <span class="cat-legend-time" style="color:${cat.color}">${esc(fmtDur(saved))}</span>
958
+ <span class="cat-legend-meta">${count} achievement${count !== 1 ? 's' : ''} · ${pct}%</span>
959
+ </div>`;
960
+ }).join('');
618
961
  }
619
962
 
620
963
  // ── Render chart ────────────────────────────────────────────────────────────
621
964
 
622
965
  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));
966
+ const sorted = [...getFiltered()].sort((a, b) => new Date(a.start_time) - new Date(b.start_time));
627
967
 
628
968
  if (chart) chart.destroy();
629
969
 
630
- chart = new Chart(document.getElementById('main-chart').getContext('2d'), {
631
- type: 'bar',
970
+ if (!sorted.length) return;
971
+
972
+ // Build cumulative time-saved values, keeping session reference per point
973
+ let cumulative = 0;
974
+ const points = sorted.map(s => {
975
+ cumulative += (humanTime(s) - s.duration_minutes);
976
+ return { x: fmtDateShort(s.start_time), y: cumulative, session: s };
977
+ });
978
+
979
+ const canvas = document.getElementById('main-chart');
980
+
981
+ chart = new Chart(canvas.getContext('2d'), {
982
+ type: 'line',
632
983
  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
- ]
984
+ labels: points.map(p => p.x),
985
+ datasets: [{
986
+ label: 'Time Saved (min)',
987
+ data: points.map(p => p.y),
988
+ borderColor: 'rgb(34,197,94)',
989
+ backgroundColor: 'rgba(34,197,94,0.08)',
990
+ borderWidth: 2.5,
991
+ pointBackgroundColor: 'rgb(34,197,94)',
992
+ pointBorderColor: '#0f172a',
993
+ pointBorderWidth: 2,
994
+ pointRadius: 5,
995
+ pointHoverRadius: 7,
996
+ fill: true,
997
+ tension: 0.35,
998
+ }]
654
999
  },
655
1000
  options: {
656
1001
  responsive: true,
657
1002
  maintainAspectRatio: false,
1003
+ onClick(_evt, elements) {
1004
+ if (elements.length) {
1005
+ const s = points[elements[0].index].session;
1006
+ showSession(s.uuid || s.session_id);
1007
+ }
1008
+ },
658
1009
  plugins: {
659
- legend: {
660
- labels: { color: '#94a3b8', font: { size: 12 }, boxWidth: 13, padding: 18 }
661
- },
1010
+ legend: { display: false },
662
1011
  tooltip: {
663
1012
  backgroundColor: '#1e293b',
664
1013
  borderColor: '#334155',
665
1014
  borderWidth: 1,
666
1015
  titleColor: '#f1f5f9',
667
1016
  bodyColor: '#94a3b8',
1017
+ padding: 12,
668
1018
  callbacks: {
669
- label: ctx => ` ${ctx.dataset.label}: ${ctx.parsed.y} min`
1019
+ title(items) {
1020
+ const s = points[items[0].dataIndex].session;
1021
+ return fmtDate(s.start_time);
1022
+ },
1023
+ label(ctx) {
1024
+ const s = points[ctx.dataIndex].session;
1025
+ const saved = humanTime(s) - s.duration_minutes;
1026
+ const sp = speedup(s);
1027
+ return [
1028
+ ` Cumulative saved: ${fmtDur(ctx.parsed.y)}`,
1029
+ ` This session: +${fmtDur(saved)}`,
1030
+ ` AI time: ${fmtDur(s.duration_minutes)} · Human est: ${fmtDur(humanTime(s))}`,
1031
+ ` Speedup: ${sp.toFixed(1)}× · ${s.achievements.length} achievement${s.achievements.length !== 1 ? 's' : ''}`,
1032
+ ...(s.author ? [` Author: ${s.author}`] : []),
1033
+ ];
1034
+ },
1035
+ afterBody() { return ['', ' Click to open session ↗']; }
670
1036
  }
671
1037
  }
672
1038
  },
@@ -677,7 +1043,7 @@
677
1043
  },
678
1044
  y: {
679
1045
  grid: { color: 'rgba(51,65,85,0.5)' },
680
- ticks: { color: '#94a3b8', font: { size: 11 }, callback: v => v + ' min' },
1046
+ ticks: { color: '#94a3b8', font: { size: 11 }, callback: v => fmtDur(v) },
681
1047
  beginAtZero: true
682
1048
  }
683
1049
  }
@@ -731,14 +1097,34 @@
731
1097
  const ht = humanTime(s);
732
1098
  const sv = ht - s.duration_minutes;
733
1099
 
1100
+ // Build category summary for this session
1101
+ const catTally = {};
1102
+ for (const a of s.achievements) {
1103
+ const cat = getCategory(a);
1104
+ if (!catTally[cat.id]) catTally[cat.id] = { cat, saved: 0, count: 0 };
1105
+ catTally[cat.id].saved += a.estimated_human_time_minutes || 0;
1106
+ catTally[cat.id].count += 1;
1107
+ }
1108
+ const catSummaryHTML = Object.values(catTally)
1109
+ .sort((a, b) => b.saved - a.saved)
1110
+ .map(({ cat, saved, count }) =>
1111
+ `<span class="cat-badge" style="background:${cat.dim};color:${cat.color}">
1112
+ ● ${esc(cat.label)} · ${esc(fmtDur(saved))}
1113
+ </span>`)
1114
+ .join('');
1115
+
734
1116
  const achievementsHTML = s.achievements.map((a, i) => {
1117
+ const cat = getCategory(a);
735
1118
  const created = (a.files_created || []).map(f => `<span class="file-chip created">+ ${esc(f)}</span>`).join('');
736
1119
  const modified = (a.files_modified || []).map(f => `<span class="file-chip modified">~ ${esc(f)}</span>`).join('');
737
1120
  const hasFiles = created || modified;
738
1121
 
739
1122
  return `
740
1123
  <div class="achievement-card">
741
- <div class="achievement-num">Achievement ${i + 1} of ${s.achievements.length}</div>
1124
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
1125
+ <div class="achievement-num" style="margin-bottom:0">Achievement ${i + 1} of ${s.achievements.length}</div>
1126
+ <span class="cat-badge" style="background:${cat.dim};color:${cat.color}">● ${esc(cat.label)}</span>
1127
+ </div>
742
1128
  <div class="achievement-desc">${esc(a.description)}</div>
743
1129
  <div class="achievement-footer">
744
1130
  ${hasFiles ? `<div class="files-row">${created}${modified}</div>` : ''}
@@ -789,6 +1175,9 @@
789
1175
 
790
1176
  <div class="speedup-banner">⚡ ${esc(s.ai_speedup)}</div>
791
1177
 
1178
+ <div class="section-label" style="margin-bottom:10px;">By Category</div>
1179
+ <div class="cat-summary" style="margin-bottom:24px">${catSummaryHTML}</div>
1180
+
792
1181
  <div class="section-label" style="margin-bottom:14px;">Achievements</div>
793
1182
  ${achievementsHTML}
794
1183
  `;
@@ -806,13 +1195,44 @@
806
1195
 
807
1196
  // ── Init ────────────────────────────────────────────────────────────────────
808
1197
 
1198
+ function pickDefaultPeriod() {
1199
+ const ORDER = ['today', '3d', 'week', 'month', '3m', '6m', 'year', 'all'];
1200
+ const DAY = 86400000;
1201
+ const cutoffs = {
1202
+ today: () => { const d = new Date(); d.setHours(0,0,0,0); return d; },
1203
+ '3d': () => new Date(Date.now() - 3 * DAY),
1204
+ week: () => new Date(Date.now() - 7 * DAY),
1205
+ month: () => new Date(Date.now() - 30 * DAY),
1206
+ '3m': () => new Date(Date.now() - 90 * DAY),
1207
+ '6m': () => new Date(Date.now() - 180 * DAY),
1208
+ year: () => new Date(Date.now() - 365 * DAY),
1209
+ all: () => null,
1210
+ };
1211
+ for (const period of ORDER) {
1212
+ const cutoff = cutoffs[period]();
1213
+ const hasData = cutoff
1214
+ ? sessions.some(s => new Date(s.start_time) >= cutoff)
1215
+ : sessions.length > 0;
1216
+ if (hasData) {
1217
+ filterPeriod = period;
1218
+ const btn = document.querySelector(`.period-pill[data-period="${period}"]`);
1219
+ document.querySelectorAll('.period-pill').forEach(b => b.classList.remove('active'));
1220
+ if (btn) btn.classList.add('active');
1221
+ return;
1222
+ }
1223
+ }
1224
+ }
1225
+
809
1226
  async function init() {
810
1227
  try {
811
1228
  const res = await fetch('/api/sessions');
812
1229
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
813
1230
  sessions = await res.json();
814
1231
  populateAuthors();
1232
+ pickDefaultPeriod();
1233
+ renderLastSessionNote();
815
1234
  renderStats();
1235
+ renderCategories();
816
1236
  renderChart();
817
1237
  renderSessions();
818
1238
  } catch (err) {
package/src/init.js CHANGED
@@ -5,10 +5,25 @@ const path = require('path');
5
5
 
6
6
  const TEMPLATE_DIR = path.join(__dirname, '..', 'template');
7
7
 
8
+ const STALE_HOOK_TYPES = ['SessionStart', 'Stop'];
9
+ const AI_GAINS_LABEL = 'ai-gains';
10
+
8
11
  function mergeSettings(existing, template) {
9
12
  const result = JSON.parse(JSON.stringify(existing));
10
13
  if (!result.hooks) result.hooks = {};
11
14
 
15
+ // Remove stale ai-gains hooks that no longer exist in the template
16
+ for (const hookType of STALE_HOOK_TYPES) {
17
+ if (!result.hooks[hookType]) continue;
18
+ result.hooks[hookType] = result.hooks[hookType]
19
+ .map(entry => ({
20
+ ...entry,
21
+ hooks: (entry.hooks || []).filter(h => h.label !== AI_GAINS_LABEL)
22
+ }))
23
+ .filter(entry => (entry.hooks || []).length > 0);
24
+ if (result.hooks[hookType].length === 0) delete result.hooks[hookType];
25
+ }
26
+
12
27
  for (const [hookType, templateEntries] of Object.entries(template.hooks || {})) {
13
28
  if (!result.hooks[hookType]) {
14
29
  result.hooks[hookType] = templateEntries;
@@ -89,6 +104,16 @@ function initProject(targetDir) {
89
104
  fs.copyFileSync(skillSrc, skillDst);
90
105
  console.log(` ${skillExisted ? 'updated' : 'created'} .claude/skills/ai-gains/SKILL.md`);
91
106
 
107
+ // Remove stale hook scripts that no longer exist in the template
108
+ const staleScripts = ['session-start.cjs', 'stop.cjs'];
109
+ for (const name of staleScripts) {
110
+ const stalePath = path.join(scriptsDst, name);
111
+ if (fs.existsSync(stalePath)) {
112
+ fs.rmSync(stalePath);
113
+ console.log(` removed .claude/scripts/ai-gains/${name}`);
114
+ }
115
+ }
116
+
92
117
  // Copy hook scripts (safe to overwrite)
93
118
  const scriptsExisted = fs.existsSync(scriptsDst);
94
119
  copyDir(scriptsSrc, scriptsDst);
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+
4
+ const transcriptPath = process.argv[2];
5
+ if (!transcriptPath) {
6
+ console.error('Usage: get-session-times.cjs <transcript_path>');
7
+ process.exit(1);
8
+ }
9
+
10
+ const CHUNK_SIZE = 4096;
11
+ const fd = fs.openSync(transcriptPath, 'r');
12
+ const { size } = fs.fstatSync(fd);
13
+
14
+ // Scan from the start for the first entry with a timestamp
15
+ function findFirstTimestamp() {
16
+ const rl = require('readline').createInterface({
17
+ input: fs.createReadStream(transcriptPath),
18
+ crlfDelay: Infinity
19
+ });
20
+ return new Promise(resolve => {
21
+ rl.on('line', line => {
22
+ if (!line.trim()) return;
23
+ try {
24
+ const entry = JSON.parse(line);
25
+ if (entry.timestamp) {
26
+ rl.close();
27
+ resolve(entry.timestamp);
28
+ }
29
+ } catch {}
30
+ });
31
+ rl.on('close', () => resolve(null));
32
+ });
33
+ }
34
+
35
+ // Scan from the end for the last entry with a timestamp
36
+ function findLastTimestamp() {
37
+ let offset = size;
38
+ let remainder = '';
39
+ let lastTimestamp = null;
40
+
41
+ while (offset > 0) {
42
+ const readSize = Math.min(CHUNK_SIZE, offset);
43
+ offset -= readSize;
44
+ const buf = Buffer.alloc(readSize);
45
+ fs.readSync(fd, buf, 0, readSize, offset);
46
+ const chunk = buf.toString('utf8') + remainder;
47
+ const lines = chunk.split('\n');
48
+ remainder = lines.shift(); // incomplete line at chunk boundary
49
+
50
+ for (let i = lines.length - 1; i >= 0; i--) {
51
+ const line = lines[i].trim();
52
+ if (!line) continue;
53
+ try {
54
+ const entry = JSON.parse(line);
55
+ if (entry.timestamp) {
56
+ lastTimestamp = entry.timestamp;
57
+ break;
58
+ }
59
+ } catch {}
60
+ }
61
+ if (lastTimestamp) break;
62
+ }
63
+
64
+ fs.closeSync(fd);
65
+ return lastTimestamp;
66
+ }
67
+
68
+ (async () => {
69
+ const [start_time, end_time] = await Promise.all([
70
+ findFirstTimestamp(),
71
+ Promise.resolve(findLastTimestamp())
72
+ ]);
73
+
74
+ if (!start_time || !end_time) {
75
+ console.error('Could not extract timestamps from transcript');
76
+ process.exit(1);
77
+ }
78
+
79
+ const duration_minutes = Math.round((new Date(end_time) - new Date(start_time)) / 60000);
80
+ console.log(JSON.stringify({ start_time, end_time, duration_minutes }));
81
+ })();
@@ -4,8 +4,8 @@ let raw = '';
4
4
  process.stdin.setEncoding('utf8');
5
5
  process.stdin.on('data', chunk => { raw += chunk; });
6
6
  process.stdin.on('end', () => {
7
- const { session_id } = JSON.parse(raw);
7
+ const { session_id, transcript_path } = JSON.parse(raw);
8
8
  process.stdout.write(
9
- `Session ID: ${session_id}. IMPORTANT: In your final response, remind the user to update the ai-gains session log (the /ai-gains skill).`
9
+ `Session ID: ${session_id}. Transcript: ${transcript_path}. IMPORTANT: In your final response, remind the user to update the ai-gains session log (the /ai-gains skill).`
10
10
  );
11
11
  });
@@ -1,16 +1,5 @@
1
1
  {
2
2
  "hooks": {
3
- "SessionStart": [
4
- {
5
- "hooks": [
6
- {
7
- "type": "command",
8
- "label": "ai-gains",
9
- "command": "node .claude/scripts/ai-gains/session-start.cjs"
10
- }
11
- ]
12
- }
13
- ],
14
3
  "UserPromptSubmit": [
15
4
  {
16
5
  "hooks": [
@@ -21,17 +10,6 @@
21
10
  }
22
11
  ]
23
12
  }
24
- ],
25
- "Stop": [
26
- {
27
- "hooks": [
28
- {
29
- "type": "command",
30
- "label": "ai-gains",
31
- "command": "node .claude/scripts/ai-gains/stop.cjs"
32
- }
33
- ]
34
- }
35
13
  ]
36
14
  }
37
15
  }
@@ -7,17 +7,11 @@ description: Manages the AI gains session log. Session initialization and user p
7
7
 
8
8
  ## Context
9
9
 
10
- Session tracking is managed automatically via hooks configured in `.claude/settings.json`:
10
+ Session tracking is managed automatically via a hook configured in `.claude/settings.json`:
11
11
 
12
- - **SessionStart hook**: Reads Claude Code's `session_id` from stdin and creates `.ai-gains/<start_time>_<session_id>.json` with `start_time` and `author`. The timestamp uses `-` instead of `:` for cross-platform filename compatibility (e.g. `2026-03-02T09-00-00Z_<session_id>.json`).
13
- - **UserPromptSubmit hook**: Reads the `session_id` from stdin and echoes it into context with a lightweight reminder for Claude to prompt the user to update the log at the end of each response. The skill itself is not loaded automatically — only when the user invokes `/ai-gains`.
14
- - **Stop hook**: Reads the `session_id` from stdin, locates the matching `.ai-gains/*_<session_id>.json` file, and updates `end_time` after every Claude response.
12
+ - **UserPromptSubmit hook**: Reads `session_id` and `transcript_path` from stdin and echoes both into context, along with a lightweight reminder for Claude to prompt the user to update the log at the end of each response. The skill itself is not loaded automatically — only when the user invokes `/ai-gains`.
15
13
 
16
- `duration_minutes` is the wall-clock time from `start_time` to `end_time`. This intentionally includes human review time, approval of actions, reading diffs, etc. — giving a true picture of total time spent with AI vs. without.
17
-
18
- Files are named `<start_time>_<session_id>.json` so they sort chronologically and concurrent sessions never conflict.
19
-
20
- Claude should note the Session ID echoed by each UserPromptSubmit hook and use it to locate the session file.
14
+ `duration_minutes` is derived from the first and last timestamped entries in the session transcript. This reflects actual conversation activity from first message to last response, and intentionally includes human review time, approval of actions, reading diffs, etc. — giving a true picture of total time spent with AI vs. without.
21
15
 
22
16
  ## Proactive Log Reminders
23
17
 
@@ -31,41 +25,60 @@ Claude should use judgment about what counts as "meaningful" -- a single quick a
31
25
 
32
26
  When the user invokes `/ai-gains` or confirms they want to update the log:
33
27
 
34
- 1. Get the current UTC time:
28
+ 1. Get `session_id` and `transcript_path` from the context echoed by the UserPromptSubmit hook.
29
+
30
+ 2. Run `get-session-times.cjs` with the transcript path to get accurate start/end times and duration:
31
+ ```bash
32
+ node .claude/scripts/ai-gains/get-session-times.cjs <transcript_path>
33
+ ```
34
+ This outputs `{ start_time, end_time, duration_minutes }` derived from the first and last timestamped entries in the transcript.
35
+
36
+ 3. Get the author from git config:
35
37
  ```bash
36
- node -e "console.log(new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'))"
38
+ git config user.email
37
39
  ```
38
40
 
39
- 2. Use the Session ID echoed by the UserPromptSubmit hook (present in context) to locate the session file:
41
+ 4. Check if a session file already exists for this session:
40
42
  ```
41
43
  .ai-gains/*_<session_id>.json
42
44
  ```
43
-
44
- 3. Read the session file to get `start_time` and `end_time`.
45
-
46
- 4. Calculate `duration_minutes` as the difference between `end_time` and `start_time` in minutes (wall-clock time). This includes all time spent in the session — AI working, human reviewing, approving actions, reading output, etc.
45
+ If it exists, read the existing `achievements` array to use as a starting point for merging.
47
46
 
48
47
  5. Reflect on all work done this session: research done, features built, bugs fixed, problems solved, code reviewed, debugging done, documentation updated, etc.
49
48
 
50
49
  6. For each achievement, estimate how long a human developer would take to do the same work without AI assistance.
51
50
 
52
- 7. Write the updated JSON back to `.ai-gains/<start_utc_timestamp>_<uuid>.json`, preserving existing fields. The JSON structure should look like this:
51
+ 7. For each achievement, assign a `category` from this list pick the one that best describes the primary nature of the work:
52
+ - `bug-fix` — fixing a broken or incorrect behaviour
53
+ - `debugging` — diagnosing, tracing or reproducing a problem (without necessarily fixing it yet)
54
+ - `enhancement` — adding or improving a feature, refactoring, or extending functionality
55
+ - `research` — investigating options, reviewing code/docs, evaluating approaches, visual review
56
+ - `documentation` — writing or updating docs, comments, specs, READMEs
57
+ - `testing` — writing or executing tests, validating functionality
58
+ - `refactoring` — restructuring existing code without changing its external behavior
59
+ - `ui-ux` — designing or improving user interfaces and user experiences
60
+ - `other` — anything that doesn't fit the above
61
+
62
+ 8. Merge new achievements with any existing ones from step 4. If a prior achievement is superseded or refined by new work in the same area, update it in place rather than duplicating it.
63
+
64
+ 9. Write the session file to `.ai-gains/<start_time_with_colons_replaced>_<session_id>.json`. The filename uses `-` instead of `:` in the timestamp for cross-platform compatibility. The JSON structure should look like this:
53
65
 
54
66
  ```json
55
67
  {
56
- "uuid": "<session-uuid>",
68
+ "session_id": "<session-id>",
57
69
  "start_time": "<ISO start time>",
70
+ "end_time": "<ISO end time>",
58
71
  "author": "<git user email>",
59
- "end_time": "<ISO current time>",
60
72
  "duration_minutes": "<number>",
61
73
  "achievements": [
62
74
  {
63
75
  "description": "<what was done>",
64
- "estimated_human_time_minutes": "<number>"
76
+ "estimated_human_time_minutes": "<number>",
77
+ "category": "<bug-fix | debugging | enhancement | research | documentation | testing | refactoring | ui-ux | other>"
65
78
  }
66
79
  ],
67
80
  "ai_speedup": "<summary of time saved vs human>"
68
81
  }
69
82
  ```
70
83
 
71
- 8. Confirm to the user that the log has been updated and summarize the key achievements.
84
+ 10. Confirm to the user that the log has been updated and summarize the key achievements.
@@ -1,24 +0,0 @@
1
- 'use strict';
2
- const fs = require('fs');
3
- const { execSync } = require('child_process');
4
-
5
- let raw = '';
6
- process.stdin.setEncoding('utf8');
7
- process.stdin.on('data', chunk => { raw += chunk; });
8
- process.stdin.on('end', () => {
9
- const { session_id } = JSON.parse(raw);
10
- const start_time = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
11
-
12
- let author = 'unknown';
13
- try {
14
- author = execSync('git config user.email', { stdio: ['pipe', 'pipe', 'pipe'] })
15
- .toString().trim();
16
- } catch {}
17
-
18
- const safeTs = start_time.replace(/:/g, '-');
19
- fs.mkdirSync('.ai-gains', { recursive: true });
20
- fs.writeFileSync(
21
- `.ai-gains/${safeTs}_${session_id}.json`,
22
- JSON.stringify({ session_id, start_time, author }, null, 2) + '\n'
23
- );
24
- });
@@ -1,16 +0,0 @@
1
- 'use strict';
2
- const fs = require('fs');
3
-
4
- let raw = '';
5
- process.stdin.setEncoding('utf8');
6
- process.stdin.on('data', chunk => { raw += chunk; });
7
- process.stdin.on('end', () => {
8
- const { session_id } = JSON.parse(raw);
9
- const match = fs.readdirSync('.ai-gains').find(f => f.endsWith(`_${session_id}.json`));
10
- if (!match) return;
11
- const file = `.ai-gains/${match}`;
12
-
13
- const session = JSON.parse(fs.readFileSync(file, 'utf8'));
14
- session.end_time = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
15
- fs.writeFileSync(file, JSON.stringify(session, null, 2) + '\n');
16
- });