@teamlens/web 0.1.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/public/app.js ADDED
@@ -0,0 +1,769 @@
1
+ /* ============================================================
2
+ TeamLens Dashboard — Vanilla JS SPA
3
+ ============================================================ */
4
+
5
+ (function () {
6
+ 'use strict';
7
+
8
+ // --------------- Helpers ---------------
9
+
10
+ /** Format large numbers with commas */
11
+ function fmtNum(n) {
12
+ if (n == null) return '0';
13
+ return Number(n).toLocaleString('en-US');
14
+ }
15
+
16
+ /** Format a number as compact (1.2k, 3.4M) */
17
+ function fmtCompact(n) {
18
+ if (n == null) return '0';
19
+ const num = Number(n);
20
+ if (num >= 1_000_000) return (num / 1_000_000).toFixed(1) + 'M';
21
+ if (num >= 1_000) return (num / 1_000).toFixed(1) + 'k';
22
+ return num.toString();
23
+ }
24
+
25
+ /** Format duration in seconds to human readable */
26
+ function fmtDuration(seconds) {
27
+ if (!seconds || seconds <= 0) return '0s';
28
+ const h = Math.floor(seconds / 3600);
29
+ const m = Math.floor((seconds % 3600) / 60);
30
+ const s = Math.floor(seconds % 60);
31
+ if (h > 0) return `${h}h ${m}m`;
32
+ if (m > 0) return `${m}m ${s}s`;
33
+ return `${s}s`;
34
+ }
35
+
36
+ /** Relative time formatting (e.g. "2 hours ago") */
37
+ function timeAgo(dateStr) {
38
+ if (!dateStr) return 'unknown';
39
+ const date = new Date(dateStr);
40
+ const now = new Date();
41
+ const diffMs = now - date;
42
+ const diffSec = Math.floor(diffMs / 1000);
43
+ const diffMin = Math.floor(diffSec / 60);
44
+ const diffHr = Math.floor(diffMin / 60);
45
+ const diffDay = Math.floor(diffHr / 24);
46
+ const diffWeek = Math.floor(diffDay / 7);
47
+
48
+ if (diffSec < 60) return 'just now';
49
+ if (diffMin < 60) return `${diffMin} minute${diffMin !== 1 ? 's' : ''} ago`;
50
+ if (diffHr < 24) return `${diffHr} hour${diffHr !== 1 ? 's' : ''} ago`;
51
+ if (diffDay < 7) return `${diffDay} day${diffDay !== 1 ? 's' : ''} ago`;
52
+ if (diffWeek < 5) return `${diffWeek} week${diffWeek !== 1 ? 's' : ''} ago`;
53
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
54
+ }
55
+
56
+ /** Get a CSS class for a category badge */
57
+ function badgeClass(category) {
58
+ const map = {
59
+ architecture: 'badge-decision',
60
+ convention: 'badge-pattern',
61
+ decision: 'badge-decision',
62
+ correction: 'badge-issue',
63
+ active_context: 'badge-context',
64
+ discovery: 'badge-setup',
65
+ gotcha: 'badge-issue',
66
+ dependency: 'badge-preference',
67
+ pattern: 'badge-pattern',
68
+ preference: 'badge-preference',
69
+ issue: 'badge-issue',
70
+ context: 'badge-context',
71
+ setup: 'badge-setup',
72
+ active: 'badge-active',
73
+ completed: 'badge-completed',
74
+ stale: 'badge-stale',
75
+ abandoned: 'badge-stale',
76
+ };
77
+ return map[(category || '').toLowerCase()] || 'badge-default';
78
+ }
79
+
80
+ /** Escape HTML */
81
+ function esc(str) {
82
+ if (!str) return '';
83
+ const div = document.createElement('div');
84
+ div.textContent = str;
85
+ return div.innerHTML;
86
+ }
87
+
88
+ /** Shorten a file path for display */
89
+ function shortPath(p) {
90
+ if (!p) return '';
91
+ const parts = p.split('/');
92
+ if (parts.length > 3) {
93
+ return '.../' + parts.slice(-3).join('/');
94
+ }
95
+ return p;
96
+ }
97
+
98
+ // --------------- API ---------------
99
+
100
+ async function api(path) {
101
+ try {
102
+ const res = await fetch('/api' + path);
103
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
104
+ return await res.json();
105
+ } catch (err) {
106
+ console.error('API error:', path, err);
107
+ return null;
108
+ }
109
+ }
110
+
111
+ // --------------- State ---------------
112
+
113
+ let currentPage = 'overview';
114
+ let refreshTimer = null;
115
+ let sessionDetailId = null; // for session detail view
116
+ let sessionsOffset = 0;
117
+ const SESSIONS_LIMIT = 50;
118
+
119
+ // --------------- DOM ---------------
120
+
121
+ const contentEl = document.getElementById('content');
122
+ const pageTitleEl = document.getElementById('page-title');
123
+ const refreshBtn = document.getElementById('refresh-btn');
124
+ const navItems = document.querySelectorAll('.nav-item');
125
+
126
+ // --------------- Router ---------------
127
+
128
+ const PAGE_TITLES = {
129
+ overview: 'Overview',
130
+ sessions: 'Sessions',
131
+ insights: 'Insights',
132
+ contributors: 'Contributors',
133
+ analytics: 'Analytics',
134
+ };
135
+
136
+ function navigate(page) {
137
+ currentPage = page;
138
+ sessionDetailId = null;
139
+ sessionsOffset = 0;
140
+ pageTitleEl.textContent = PAGE_TITLES[page] || page;
141
+
142
+ // Update nav active state
143
+ navItems.forEach(item => {
144
+ item.classList.toggle('active', item.dataset.page === page);
145
+ });
146
+
147
+ renderPage();
148
+ resetRefreshTimer();
149
+ }
150
+
151
+ // Handle nav clicks
152
+ navItems.forEach(item => {
153
+ item.addEventListener('click', (e) => {
154
+ e.preventDefault();
155
+ navigate(item.dataset.page);
156
+ });
157
+ });
158
+
159
+ // Handle hash changes
160
+ window.addEventListener('hashchange', () => {
161
+ const hash = location.hash.slice(1) || 'overview';
162
+ if (hash !== currentPage) navigate(hash);
163
+ });
164
+
165
+ // Refresh button
166
+ refreshBtn.addEventListener('click', renderPage);
167
+
168
+ // --------------- Auto Refresh ---------------
169
+
170
+ function resetRefreshTimer() {
171
+ if (refreshTimer) clearInterval(refreshTimer);
172
+ refreshTimer = setInterval(renderPage, 30000);
173
+ }
174
+
175
+ // --------------- Render Dispatcher ---------------
176
+
177
+ async function renderPage() {
178
+ showLoading();
179
+ switch (currentPage) {
180
+ case 'overview': await renderOverview(); break;
181
+ case 'sessions': await renderSessions(); break;
182
+ case 'insights': await renderInsights(); break;
183
+ case 'contributors': await renderContributors(); break;
184
+ case 'analytics': await renderAnalytics(); break;
185
+ default: renderNotFound();
186
+ }
187
+ }
188
+
189
+ function showLoading() {
190
+ contentEl.innerHTML = `
191
+ <div class="loading-state">
192
+ <div class="spinner"></div>
193
+ <p>Loading...</p>
194
+ </div>`;
195
+ }
196
+
197
+ function showEmpty(title, message, html) {
198
+ return `
199
+ <div class="empty-state">
200
+ <div class="empty-state-icon">--</div>
201
+ <h3>${esc(title)}</h3>
202
+ ${html ? `<div class="empty-state-body">${message}</div>` : `<p>${esc(message)}</p>`}
203
+ </div>`;
204
+ }
205
+
206
+ function renderNotFound() {
207
+ contentEl.innerHTML = showEmpty('Page not found', 'The page you requested does not exist.');
208
+ }
209
+
210
+ // --------------- Overview Page ---------------
211
+
212
+ async function renderOverview() {
213
+ const [overview, hotFiles] = await Promise.all([
214
+ api('/overview'),
215
+ api('/hot-files?limit=10'),
216
+ ]);
217
+
218
+ if (!overview || (!overview.totalSessions && !overview.totalInsights)) {
219
+ contentEl.innerHTML = showEmpty('Welcome to TeamLens!', `
220
+ <p>Get started in 3 steps:</p>
221
+ <ol class="getting-started-steps">
222
+ <li>Run <code>teamlens init --path .</code> to scan your repo</li>
223
+ <li>Start coding with your AI agent — sessions track automatically</li>
224
+ <li>Share insights with <code>share_insight</code> — your team gets smarter</li>
225
+ </ol>`, true);
226
+ return;
227
+ }
228
+
229
+ const roi = overview.roi || {};
230
+ const hot = (hotFiles && hotFiles.hotFiles) || [];
231
+
232
+ contentEl.innerHTML = `<div class="animate-in">
233
+ <!-- ROI Banner -->
234
+ <div class="roi-grid">
235
+ <div class="roi-card">
236
+ <div class="roi-label">Time Saved</div>
237
+ <div class="roi-value">${fmtDuration(roi.timeSavedSeconds || 0)}</div>
238
+ <div class="roi-sub">estimated via memory reuse</div>
239
+ </div>
240
+ <div class="roi-card">
241
+ <div class="roi-label">Memory Reuses</div>
242
+ <div class="roi-value">${fmtNum(roi.totalReuses || 0)}</div>
243
+ <div class="roi-sub">times context was recalled</div>
244
+ </div>
245
+ <div class="roi-card">
246
+ <div class="roi-label">Unique Insights</div>
247
+ <div class="roi-value">${fmtNum(roi.uniqueInsights || overview.totalInsights || 0)}</div>
248
+ <div class="roi-sub">captured from sessions</div>
249
+ </div>
250
+ </div>
251
+
252
+ <!-- Stats -->
253
+ <div class="stats-grid">
254
+ <div class="stat-card">
255
+ <span class="stat-label">Total Sessions</span>
256
+ <span class="stat-value">${fmtNum(overview.totalSessions || 0)}</span>
257
+ </div>
258
+ <div class="stat-card">
259
+ <span class="stat-label">Active Sessions</span>
260
+ <span class="stat-value accent">${fmtNum(overview.activeSessions || 0)}</span>
261
+ </div>
262
+ <div class="stat-card">
263
+ <span class="stat-label">Contributors</span>
264
+ <span class="stat-value">${fmtNum(overview.totalContributors || 0)}</span>
265
+ </div>
266
+ <div class="stat-card">
267
+ <span class="stat-label">Total Activities</span>
268
+ <span class="stat-value">${fmtNum(overview.totalActivities || 0)}</span>
269
+ </div>
270
+ <div class="stat-card">
271
+ <span class="stat-label">Avg Session Duration</span>
272
+ <span class="stat-value">${fmtDuration(overview.avgSessionDuration || 0)}</span>
273
+ </div>
274
+ <div class="stat-card">
275
+ <span class="stat-label">Total Insights</span>
276
+ <span class="stat-value green">${fmtNum(overview.totalInsights || 0)}</span>
277
+ </div>
278
+ </div>
279
+
280
+ <!-- Hot Files -->
281
+ ${hot.length > 0 ? `
282
+ <div class="card">
283
+ <div class="card-header">
284
+ <h3>Hot Files</h3>
285
+ <span class="section-subtitle">Most frequently touched files</span>
286
+ </div>
287
+ <div class="card-body">
288
+ ${hot.map((f, i) => `
289
+ <div class="hot-file-row">
290
+ <span class="hot-file-rank">${i + 1}</span>
291
+ <span class="hot-file-path" title="${esc(f.filePath || f.file || '')}">${esc(shortPath(f.filePath || f.file || ''))}</span>
292
+ <span class="hot-file-count">${fmtNum(f.insightCount || 0)} insights</span>
293
+ </div>
294
+ `).join('')}
295
+ </div>
296
+ </div>` : ''}
297
+ </div>`;
298
+ }
299
+
300
+ // --------------- Sessions Page ---------------
301
+
302
+ async function renderSessions() {
303
+ if (sessionDetailId) {
304
+ await renderSessionDetail(sessionDetailId);
305
+ return;
306
+ }
307
+
308
+ const data = await api(`/sessions?limit=${SESSIONS_LIMIT}&offset=${sessionsOffset}`);
309
+ if (!data || !data.sessions || data.sessions.length === 0) {
310
+ contentEl.innerHTML = showEmpty('No sessions yet', 'Sessions appear automatically when team members code with AI agents. Start a session by running your AI coding tool in a tracked repo.');
311
+ return;
312
+ }
313
+
314
+ const total = data.total || data.sessions.length;
315
+ const sessions = data.sessions;
316
+
317
+ contentEl.innerHTML = `<div class="animate-in">
318
+ <div class="card">
319
+ <div class="table-wrapper">
320
+ <table>
321
+ <thead>
322
+ <tr>
323
+ <th>Session</th>
324
+ <th>Author</th>
325
+ <th>Status</th>
326
+ <th>Started</th>
327
+ <th>Duration</th>
328
+ <th>Activities</th>
329
+ <th>Insights</th>
330
+ </tr>
331
+ </thead>
332
+ <tbody>
333
+ ${sessions.map(s => `
334
+ <tr>
335
+ <td><a class="table-link session-link" data-id="${esc(s.id)}">${esc(s.id?.slice(0, 8) || 'N/A')}...</a></td>
336
+ <td>${esc(s.developer || 'unknown')}</td>
337
+ <td><span class="badge ${badgeClass(s.status)}">${esc(s.status || 'unknown')}</span></td>
338
+ <td>${timeAgo(s.startedAt || s.createdAt)}</td>
339
+ <td>${fmtDuration(s.durationSeconds || s.duration || 0)}</td>
340
+ <td>${fmtNum(s.activityCount || 0)}</td>
341
+ <td>${fmtNum(s.insightCount || s.memoryCount || 0)}</td>
342
+ </tr>
343
+ `).join('')}
344
+ </tbody>
345
+ </table>
346
+ </div>
347
+ </div>
348
+
349
+ ${total > SESSIONS_LIMIT ? `
350
+ <div class="pagination">
351
+ <button class="btn btn-ghost" id="prev-page" ${sessionsOffset === 0 ? 'disabled' : ''}>Previous</button>
352
+ <span class="pagination-info">${sessionsOffset + 1}--${Math.min(sessionsOffset + SESSIONS_LIMIT, total)} of ${fmtNum(total)}</span>
353
+ <button class="btn btn-ghost" id="next-page" ${sessionsOffset + SESSIONS_LIMIT >= total ? 'disabled' : ''}>Next</button>
354
+ </div>` : ''}
355
+ </div>`;
356
+
357
+ // Bind session links
358
+ contentEl.querySelectorAll('.session-link').forEach(link => {
359
+ link.addEventListener('click', (e) => {
360
+ e.preventDefault();
361
+ sessionDetailId = link.dataset.id;
362
+ renderPage();
363
+ });
364
+ });
365
+
366
+ // Pagination
367
+ const prevBtn = document.getElementById('prev-page');
368
+ const nextBtn = document.getElementById('next-page');
369
+ if (prevBtn) prevBtn.addEventListener('click', () => { sessionsOffset = Math.max(0, sessionsOffset - SESSIONS_LIMIT); renderPage(); });
370
+ if (nextBtn) nextBtn.addEventListener('click', () => { sessionsOffset += SESSIONS_LIMIT; renderPage(); });
371
+ }
372
+
373
+ async function renderSessionDetail(id) {
374
+ const data = await api(`/sessions/${id}`);
375
+ if (!data || !data.session) {
376
+ contentEl.innerHTML = showEmpty('Session not found', 'This session may have been deleted.');
377
+ return;
378
+ }
379
+
380
+ const s = data.session;
381
+ const activities = data.activities || [];
382
+ const insights = data.insights || [];
383
+
384
+ pageTitleEl.textContent = `Session ${id.slice(0, 8)}...`;
385
+
386
+ contentEl.innerHTML = `<div class="animate-in">
387
+ <div class="session-detail">
388
+ <div class="session-detail-header">
389
+ <div>
390
+ <div class="session-detail-title">${esc(s.id)}</div>
391
+ <div style="margin-top:4px;font-size:0.8rem;color:var(--text-tertiary)">
392
+ ${esc(s.developer || 'unknown')} &middot; ${timeAgo(s.startedAt || s.createdAt)} &middot;
393
+ <span class="badge ${badgeClass(s.status)}">${esc(s.status || 'unknown')}</span>
394
+ </div>
395
+ </div>
396
+ <a class="back-link" id="back-to-sessions">&larr; Back to Sessions</a>
397
+ </div>
398
+
399
+ <div class="stats-grid" style="margin-bottom:0">
400
+ <div class="stat-card">
401
+ <span class="stat-label">Duration</span>
402
+ <span class="stat-value">${fmtDuration(s.durationSeconds || s.duration || 0)}</span>
403
+ </div>
404
+ <div class="stat-card">
405
+ <span class="stat-label">Activities</span>
406
+ <span class="stat-value">${fmtNum(activities.length)}</span>
407
+ </div>
408
+ <div class="stat-card">
409
+ <span class="stat-label">Insights</span>
410
+ <span class="stat-value accent">${fmtNum(insights.length)}</span>
411
+ </div>
412
+ </div>
413
+ </div>
414
+
415
+ ${activities.length > 0 ? `
416
+ <div class="card">
417
+ <div class="card-header"><h3>Activities</h3></div>
418
+ <div class="table-wrapper">
419
+ <table>
420
+ <thead>
421
+ <tr>
422
+ <th>Type</th>
423
+ <th>File</th>
424
+ <th>Time</th>
425
+ <th>Details</th>
426
+ </tr>
427
+ </thead>
428
+ <tbody>
429
+ ${activities.map(a => {
430
+ const file = a.filePath || (a.files && a.files[0]) || '';
431
+ return `
432
+ <tr>
433
+ <td><span class="badge badge-default">${esc(a.type || a.activityType || '')}</span></td>
434
+ <td><span class="filepath" title="${esc(file)}">${esc(shortPath(file))}</span></td>
435
+ <td>${timeAgo(a.timestamp || a.createdAt)}</td>
436
+ <td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(a.description || a.details || a.summary || '')}</td>
437
+ </tr>`;
438
+ }).join('')}
439
+ </tbody>
440
+ </table>
441
+ </div>
442
+ </div>` : ''}
443
+
444
+ ${insights.length > 0 ? `
445
+ <div class="section-header"><h2 class="section-title">Session Insights</h2></div>
446
+ ${insights.map(m => `
447
+ <div class="insight-card">
448
+ <div class="insight-header">
449
+ <span class="badge ${badgeClass(m.category)}">${esc(m.category || 'general')}</span>
450
+ <span class="insight-meta">${esc(m.author || '')} &middot; ${timeAgo(m.createdAt)}</span>
451
+ </div>
452
+ <div class="insight-content">${esc(m.content)}</div>
453
+ </div>
454
+ `).join('')}` : ''}
455
+ </div>`;
456
+
457
+ document.getElementById('back-to-sessions').addEventListener('click', (e) => {
458
+ e.preventDefault();
459
+ sessionDetailId = null;
460
+ pageTitleEl.textContent = 'Sessions';
461
+ renderPage();
462
+ });
463
+ }
464
+
465
+ // --------------- Insights Page ---------------
466
+
467
+ async function renderInsights() {
468
+ const data = await api('/insights?limit=50');
469
+ if (!data || !data.insights || data.insights.length === 0) {
470
+ contentEl.innerHTML = showEmpty('No insights yet', `
471
+ <p>Insights are shared automatically during AI coding sessions.</p>
472
+ <p>Your AI agent will use <code>share_insight</code> when it discovers gotchas, conventions, or architecture patterns.</p>`, true);
473
+ return;
474
+ }
475
+
476
+ const insights = data.insights;
477
+
478
+ // Gather unique categories for filter
479
+ const categories = [...new Set(insights.map(m => m.category).filter(Boolean))];
480
+
481
+ contentEl.innerHTML = `<div class="animate-in">
482
+ <div class="filters">
483
+ <select class="filter-input" id="filter-type">
484
+ <option value="">All categories</option>
485
+ ${categories.map(c => `<option value="${esc(c)}">${esc(c)}</option>`).join('')}
486
+ </select>
487
+ <input class="filter-input" type="text" id="filter-author" placeholder="Filter by author..." />
488
+ <input class="filter-input" type="text" id="filter-file" placeholder="Filter by file..." />
489
+ </div>
490
+
491
+ <div id="insights-list">
492
+ ${renderInsightsList(insights)}
493
+ </div>
494
+ </div>`;
495
+
496
+ // Bind filters
497
+ const typeSelect = document.getElementById('filter-type');
498
+ const authorInput = document.getElementById('filter-author');
499
+ const fileInput = document.getElementById('filter-file');
500
+
501
+ async function applyFilters() {
502
+ const params = new URLSearchParams();
503
+ params.set('limit', '50');
504
+ if (typeSelect.value) params.set('type', typeSelect.value);
505
+ if (authorInput.value) params.set('author', authorInput.value);
506
+ if (fileInput.value) params.set('file', fileInput.value);
507
+
508
+ const filtered = await api(`/insights?${params.toString()}`);
509
+ const list = document.getElementById('insights-list');
510
+ if (filtered && filtered.insights) {
511
+ list.innerHTML = renderInsightsList(filtered.insights);
512
+ }
513
+ }
514
+
515
+ typeSelect.addEventListener('change', applyFilters);
516
+ let debounceTimer;
517
+ const debounce = (fn) => { clearTimeout(debounceTimer); debounceTimer = setTimeout(fn, 300); };
518
+ authorInput.addEventListener('input', () => debounce(applyFilters));
519
+ fileInput.addEventListener('input', () => debounce(applyFilters));
520
+ }
521
+
522
+ function renderInsightsList(insights) {
523
+ if (!insights.length) {
524
+ return showEmpty('No matching insights', 'Try adjusting your filters.');
525
+ }
526
+
527
+ return insights.map(m => `
528
+ <div class="insight-card">
529
+ <div class="insight-header">
530
+ <span class="badge ${badgeClass(m.category)}">${esc(m.category || 'general')}</span>
531
+ <span class="insight-meta">${esc(m.author || 'unknown')} &middot; ${timeAgo(m.createdAt)}</span>
532
+ ${m.reuseCount ? `<span class="insight-meta" style="margin-left:auto">reused ${fmtNum(m.reuseCount)}x</span>` : ''}
533
+ </div>
534
+ <div class="insight-content">${esc(m.content)}</div>
535
+ <div class="insight-footer">
536
+ ${(m.relatedFiles || []).length > 0 ? `
537
+ <div class="tags">
538
+ ${m.relatedFiles.slice(0, 5).map(f => `<span class="tag" title="${esc(f)}">${esc(shortPath(f))}</span>`).join('')}
539
+ ${m.relatedFiles.length > 5 ? `<span class="tag">+${m.relatedFiles.length - 5} more</span>` : ''}
540
+ </div>` : ''}
541
+ ${(m.tags || []).length > 0 ? `
542
+ <div class="tags" style="margin-left:8px">
543
+ ${m.tags.map(t => `<span class="tag">#${esc(t)}</span>`).join('')}
544
+ </div>` : ''}
545
+ </div>
546
+ </div>
547
+ `).join('');
548
+ }
549
+
550
+ // --------------- Contributors Page ---------------
551
+
552
+ async function renderContributors() {
553
+ const data = await api('/contributors?limit=20');
554
+ if (!data || !data.contributors || data.contributors.length === 0) {
555
+ contentEl.innerHTML = showEmpty('No contributors yet', `
556
+ <p>Contributors appear when team members share insights during AI sessions.</p>
557
+ <p>Invite a teammate: have them run <code>teamlens setup</code> in this repo.</p>`, true);
558
+ return;
559
+ }
560
+
561
+ const contributors = data.contributors;
562
+
563
+ contentEl.innerHTML = `<div class="animate-in">
564
+ <div class="card">
565
+ <div class="card-header">
566
+ <h3>Contributor Leaderboard</h3>
567
+ <span class="section-subtitle">${fmtNum(contributors.length)} contributors</span>
568
+ </div>
569
+ <div class="card-body">
570
+ <div class="leaderboard">
571
+ ${contributors.map((c, i) => {
572
+ const rankClass = i === 0 ? 'rank-1' : i === 1 ? 'rank-2' : i === 2 ? 'rank-3' : 'rank-default';
573
+ return `
574
+ <div class="leaderboard-item">
575
+ <div class="rank ${rankClass}">${i + 1}</div>
576
+ <div class="leaderboard-info">
577
+ <div class="leaderboard-name">${esc(c.developer || 'unknown')}</div>
578
+ <div class="leaderboard-meta">Last active ${timeAgo(c.lastActiveAt)}</div>
579
+ </div>
580
+ <div class="leaderboard-stats">
581
+ <div class="leaderboard-stat">
582
+ <div class="leaderboard-stat-value">${fmtNum(c.sessionCount || 0)}</div>
583
+ <div class="leaderboard-stat-label">Sessions</div>
584
+ </div>
585
+ <div class="leaderboard-stat">
586
+ <div class="leaderboard-stat-value">${fmtNum(c.insightsShared || 0)}</div>
587
+ <div class="leaderboard-stat-label">Insights</div>
588
+ </div>
589
+ <div class="leaderboard-stat">
590
+ <div class="leaderboard-stat-value">${fmtNum(c.knowledgeReused || 0)}</div>
591
+ <div class="leaderboard-stat-label">Reuses</div>
592
+ </div>
593
+ </div>
594
+ </div>`;
595
+ }).join('')}
596
+ </div>
597
+ </div>
598
+ </div>
599
+
600
+ <!-- Contribution bar chart -->
601
+ ${contributors.length > 0 ? `
602
+ <div class="card" style="margin-top:16px">
603
+ <div class="card-header">
604
+ <h3>Session Distribution</h3>
605
+ </div>
606
+ <div class="card-body">
607
+ <div class="bar-chart">
608
+ ${(() => {
609
+ const maxSessions = Math.max(...contributors.map(c => c.sessionCount || 0), 1);
610
+ const colors = ['green', 'blue', 'purple', 'orange', 'cyan'];
611
+ return contributors.slice(0, 10).map((c, i) => {
612
+ const count = c.sessionCount || 0;
613
+ const pct = (count / maxSessions * 100).toFixed(1);
614
+ return `
615
+ <div class="bar-row">
616
+ <span class="bar-label" title="${esc(c.developer || '')}">${esc(c.developer || 'unknown')}</span>
617
+ <div class="bar-track">
618
+ <div class="bar-fill ${colors[i % colors.length]}" style="width:${pct}%"></div>
619
+ </div>
620
+ <span class="bar-value">${fmtNum(count)}</span>
621
+ </div>`;
622
+ }).join('');
623
+ })()}
624
+ </div>
625
+ </div>
626
+ </div>` : ''}
627
+ </div>`;
628
+ }
629
+
630
+ // --------------- Analytics Page ---------------
631
+
632
+ async function renderAnalytics() {
633
+ const [analytics, roi, trends] = await Promise.all([
634
+ api('/analytics?days=30'),
635
+ api('/roi'),
636
+ api('/trends?days=30'),
637
+ ]);
638
+
639
+ contentEl.innerHTML = `<div class="animate-in">
640
+ <!-- ROI Section -->
641
+ ${roi ? `
642
+ <div class="section-header"><h2 class="section-title">Return on Investment</h2></div>
643
+ <div class="roi-grid">
644
+ <div class="roi-card">
645
+ <div class="roi-label">Estimated Time Saved</div>
646
+ <div class="roi-value">${fmtDuration(roi.timeSavedSeconds || 0)}</div>
647
+ <div class="roi-sub">from memory reuse across sessions</div>
648
+ </div>
649
+ <div class="roi-card">
650
+ <div class="roi-label">Total Memory Reuses</div>
651
+ <div class="roi-value">${fmtNum(roi.totalReuses || 0)}</div>
652
+ <div class="roi-sub">context recalls preventing re-discovery</div>
653
+ </div>
654
+ <div class="roi-card">
655
+ <div class="roi-label">Active Memories</div>
656
+ <div class="roi-value">${fmtNum(roi.activeMemories || roi.uniqueInsights || 0)}</div>
657
+ <div class="roi-sub">non-stale insights in the knowledge base</div>
658
+ </div>
659
+ </div>` : ''}
660
+
661
+ <!-- Team Analytics -->
662
+ ${analytics ? `
663
+ <div class="section-header"><h2 class="section-title">Team Analytics (Last 30 Days)</h2></div>
664
+ <div class="stats-grid">
665
+ <div class="stat-card">
666
+ <span class="stat-label">Sessions</span>
667
+ <span class="stat-value">${fmtNum(analytics.totalSessions || 0)}</span>
668
+ </div>
669
+ <div class="stat-card">
670
+ <span class="stat-label">Insights Created</span>
671
+ <span class="stat-value accent">${fmtNum(analytics.insightsCreated || 0)}</span>
672
+ </div>
673
+ <div class="stat-card">
674
+ <span class="stat-label">Active Contributors</span>
675
+ <span class="stat-value">${fmtNum(analytics.activeContributors || 0)}</span>
676
+ </div>
677
+ <div class="stat-card">
678
+ <span class="stat-label">Files Touched</span>
679
+ <span class="stat-value">${fmtNum(analytics.filesTouched || 0)}</span>
680
+ </div>
681
+ <div class="stat-card">
682
+ <span class="stat-label">Avg Session Length</span>
683
+ <span class="stat-value">${fmtDuration(analytics.avgSessionDuration || 0)}</span>
684
+ </div>
685
+ <div class="stat-card">
686
+ <span class="stat-label">Memory Reuse Rate</span>
687
+ <span class="stat-value green">${analytics.reuseRate != null ? (analytics.reuseRate * 100).toFixed(0) + '%' : 'N/A'}</span>
688
+ </div>
689
+ </div>` : `
690
+ <div class="stats-grid">
691
+ ${showEmpty('No analytics data', 'Analytics will populate as sessions are recorded.')}
692
+ </div>`}
693
+
694
+ <!-- Usage Trends -->
695
+ ${trends && trends.trends && trends.trends.length > 0 ? `
696
+ <div class="card" style="margin-top:8px">
697
+ <div class="card-header">
698
+ <h3>Daily Activity Trend</h3>
699
+ <span class="section-subtitle">Last 30 days</span>
700
+ </div>
701
+ <div class="card-body">
702
+ <div class="trend-row">
703
+ ${(() => {
704
+ const maxVal = Math.max(...trends.trends.map(t => t.count || t.sessions || t.value || 0), 1);
705
+ return trends.trends.map(t => {
706
+ const val = t.count || t.sessions || t.value || 0;
707
+ const pct = (val / maxVal * 100).toFixed(1);
708
+ return `<div class="trend-bar" style="height:${Math.max(pct, 3)}%" title="${esc(t.date || t.day || '')}: ${fmtNum(val)}"></div>`;
709
+ }).join('');
710
+ })()}
711
+ </div>
712
+ <div style="display:flex;justify-content:space-between;margin-top:6px;">
713
+ <span style="font-size:0.7rem;color:var(--text-muted)">${esc(trends.trends[0]?.date || trends.trends[0]?.day || '')}</span>
714
+ <span style="font-size:0.7rem;color:var(--text-muted)">${esc(trends.trends[trends.trends.length - 1]?.date || trends.trends[trends.trends.length - 1]?.day || '')}</span>
715
+ </div>
716
+ </div>
717
+ </div>` : ''}
718
+
719
+ <!-- Category Breakdown -->
720
+ ${analytics && analytics.categoryBreakdown ? `
721
+ <div class="card" style="margin-top:16px">
722
+ <div class="card-header">
723
+ <h3>Insight Categories</h3>
724
+ </div>
725
+ <div class="card-body">
726
+ <div class="bar-chart">
727
+ ${(() => {
728
+ const entries = Object.entries(analytics.categoryBreakdown).sort((a, b) => b[1] - a[1]);
729
+ const maxVal = Math.max(...entries.map(e => e[1]), 1);
730
+ const colors = ['purple', 'blue', 'cyan', 'green', 'orange'];
731
+ return entries.map(([cat, count], i) => `
732
+ <div class="bar-row">
733
+ <span class="bar-label">${esc(cat)}</span>
734
+ <div class="bar-track">
735
+ <div class="bar-fill ${colors[i % colors.length]}" style="width:${(count / maxVal * 100).toFixed(1)}%"></div>
736
+ </div>
737
+ <span class="bar-value">${fmtNum(count)}</span>
738
+ </div>
739
+ `).join('');
740
+ })()}
741
+ </div>
742
+ </div>
743
+ </div>` : ''}
744
+ </div>`;
745
+ }
746
+
747
+ // --------------- Mobile Menu ---------------
748
+
749
+ const mobileMenuBtn = document.getElementById('mobile-menu-btn');
750
+ const sidebar = document.querySelector('.sidebar');
751
+ if (mobileMenuBtn && sidebar) {
752
+ mobileMenuBtn.addEventListener('click', () => {
753
+ sidebar.classList.toggle('open');
754
+ });
755
+ // Close sidebar when a nav item is clicked on mobile
756
+ navItems.forEach(item => {
757
+ item.addEventListener('click', () => {
758
+ sidebar.classList.remove('open');
759
+ });
760
+ });
761
+ }
762
+
763
+ // --------------- Init ---------------
764
+
765
+ // Start on the page indicated by hash, or default to overview
766
+ const initialPage = location.hash.slice(1) || 'overview';
767
+ navigate(initialPage);
768
+
769
+ })();