ai-gains 1.3.4 → 1.4.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.4",
3
+ "version": "1.4.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
@@ -15,17 +15,24 @@ function mergeSettings(existing, template) {
15
15
  continue;
16
16
  }
17
17
 
18
- // Collect all labels already registered for this hook type
19
- const existingLabels = new Set();
18
+ // Build a map of label -> hook object for existing hooks so we can update in-place
19
+ const existingByLabel = new Map();
20
20
  for (const entry of result.hooks[hookType]) {
21
21
  for (const hook of (entry.hooks || [])) {
22
- if (hook.label) existingLabels.add(hook.label);
22
+ if (hook.label) existingByLabel.set(hook.label, hook);
23
23
  }
24
24
  }
25
25
 
26
- // Append only hooks whose label isn't already present
26
+ // Update existing hooks in-place; append hooks whose label isn't already present
27
27
  for (const templateEntry of templateEntries) {
28
- const newHooks = (templateEntry.hooks || []).filter(h => !existingLabels.has(h.label));
28
+ const newHooks = [];
29
+ for (const templateHook of (templateEntry.hooks || [])) {
30
+ if (existingByLabel.has(templateHook.label)) {
31
+ Object.assign(existingByLabel.get(templateHook.label), templateHook);
32
+ } else {
33
+ newHooks.push(templateHook);
34
+ }
35
+ }
29
36
  if (newHooks.length > 0) {
30
37
  result.hooks[hookType].push({ hooks: newHooks });
31
38
  }
@@ -6,7 +6,7 @@
6
6
  {
7
7
  "type": "command",
8
8
  "label": "ai-gains",
9
- "command": "node .claude/scripts/ai-gains/session-start.js"
9
+ "command": "node .claude/scripts/ai-gains/session-start.cjs"
10
10
  }
11
11
  ]
12
12
  }
@@ -17,7 +17,7 @@
17
17
  {
18
18
  "type": "command",
19
19
  "label": "ai-gains",
20
- "command": "node .claude/scripts/ai-gains/user-prompt-submit.js"
20
+ "command": "node .claude/scripts/ai-gains/user-prompt-submit.cjs"
21
21
  }
22
22
  ]
23
23
  }
@@ -28,7 +28,7 @@
28
28
  {
29
29
  "type": "command",
30
30
  "label": "ai-gains",
31
- "command": "node .claude/scripts/ai-gains/stop.js"
31
+ "command": "node .claude/scripts/ai-gains/stop.cjs"
32
32
  }
33
33
  ]
34
34
  }
@@ -49,7 +49,18 @@ When the user invokes `/ai-gains` or confirms they want to update the log:
49
49
 
50
50
  6. For each achievement, estimate how long a human developer would take to do the same work without AI assistance.
51
51
 
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:
52
+ 7. For each achievement, assign a `category` from this list pick the one that best describes the primary nature of the work:
53
+ - `bug-fix` — fixing a broken or incorrect behaviour
54
+ - `debugging` — diagnosing, tracing or reproducing a problem (without necessarily fixing it yet)
55
+ - `enhancement` — adding or improving a feature, refactoring, or extending functionality
56
+ - `research` — investigating options, reviewing code/docs, evaluating approaches, visual review
57
+ - `documentation` — writing or updating docs, comments, specs, READMEs
58
+ - `testing` — writing or executing tests, validating functionality
59
+ - `refactoring` — restructuring existing code without changing its external behavior
60
+ - `ui-ux` — designing or improving user interfaces and user experiences
61
+ - `other` — anything that doesn't fit the above
62
+
63
+ 8. Write the updated JSON back to `.ai-gains/<start_utc_timestamp>_<uuid>.json`, preserving existing fields. The JSON structure should look like this:
53
64
 
54
65
  ```json
55
66
  {
@@ -61,7 +72,8 @@ When the user invokes `/ai-gains` or confirms they want to update the log:
61
72
  "achievements": [
62
73
  {
63
74
  "description": "<what was done>",
64
- "estimated_human_time_minutes": "<number>"
75
+ "estimated_human_time_minutes": "<number>",
76
+ "category": "<bug-fix | debugging | enhancement | research | documentation | testing | refactoring | ui-ux | other>"
65
77
  }
66
78
  ],
67
79
  "ai_speedup": "<summary of time saved vs human>"