codedash-app 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1048 @@
1
+ // ── codedash frontend ──────────────────────────────────────────
2
+ // Plain browser JS, no modules, no build step.
3
+
4
+ // ── State ──────────────────────────────────────────────────────
5
+
6
+ let allSessions = [];
7
+ let filteredSessions = [];
8
+ let currentView = 'sessions'; // sessions, projects, timeline, activity, starred
9
+ let grouped = true;
10
+ let searchQuery = '';
11
+ let toolFilter = null; // null, 'claude', 'codex'
12
+ let tagFilter = '';
13
+ let dateFrom = '';
14
+ let dateTo = '';
15
+ let selectMode = false;
16
+ let selectedIds = new Set();
17
+ let focusedIndex = -1;
18
+ let availableTerminals = [];
19
+ let pendingDelete = null;
20
+
21
+ // Persisted in localStorage
22
+ let stars = JSON.parse(localStorage.getItem('codedash-stars') || '[]');
23
+ let tags = JSON.parse(localStorage.getItem('codedash-tags') || '{}');
24
+
25
+ // ── Color palette for projects ─────────────────────────────────
26
+
27
+ const PROJECT_COLORS = [
28
+ '#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899',
29
+ '#f43f5e', '#ef4444', '#f97316', '#eab308', '#84cc16',
30
+ '#22c55e', '#14b8a6', '#06b6d4', '#3b82f6', '#2563eb',
31
+ '#7c3aed', '#c026d3', '#e11d48', '#ea580c', '#65a30d',
32
+ ];
33
+ const projectColorMap = {};
34
+ let colorIdx = 0;
35
+
36
+ function getProjectColor(project) {
37
+ if (!project) return '#6b7280';
38
+ if (!projectColorMap[project]) {
39
+ projectColorMap[project] = PROJECT_COLORS[colorIdx % PROJECT_COLORS.length];
40
+ colorIdx++;
41
+ }
42
+ return projectColorMap[project];
43
+ }
44
+
45
+ function getProjectName(fullPath) {
46
+ if (!fullPath) return 'unknown';
47
+ const cleaned = fullPath.replace(/\/+$/, '');
48
+ const parts = cleaned.split('/');
49
+ return parts[parts.length - 1] || 'unknown';
50
+ }
51
+
52
+ // ── Utilities ──────────────────────────────────────────────────
53
+
54
+ function timeAgo(dateStr) {
55
+ if (!dateStr) return '';
56
+ const now = Date.now();
57
+ const ts = typeof dateStr === 'number' ? dateStr : new Date(dateStr).getTime();
58
+ const diff = now - ts;
59
+ const mins = Math.floor(diff / 60000);
60
+ if (mins < 1) return 'just now';
61
+ if (mins < 60) return mins + 'm ago';
62
+ const hrs = Math.floor(mins / 60);
63
+ if (hrs < 24) return hrs + 'h ago';
64
+ const days = Math.floor(hrs / 24);
65
+ if (days < 30) return days + 'd ago';
66
+ const months = Math.floor(days / 30);
67
+ if (months < 12) return months + 'mo ago';
68
+ return Math.floor(months / 12) + 'y ago';
69
+ }
70
+
71
+ function escHtml(s) {
72
+ if (!s) return '';
73
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
74
+ }
75
+
76
+ function showToast(msg) {
77
+ const el = document.getElementById('toast');
78
+ if (!el) return;
79
+ el.textContent = msg;
80
+ el.classList.add('show');
81
+ setTimeout(() => el.classList.remove('show'), 2500);
82
+ }
83
+
84
+ function formatBytes(bytes) {
85
+ if (!bytes || bytes < 1024) return (bytes || 0) + ' B';
86
+ if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
87
+ return (bytes / 1048576).toFixed(1) + ' MB';
88
+ }
89
+
90
+ function estimateCost(fileSize) {
91
+ if (!fileSize) return 0;
92
+ const tokens = fileSize / 4;
93
+ // Rough estimate: 30% input tokens, 70% output tokens
94
+ return tokens * 0.000015 * 0.3 + tokens * 0.000075 * 0.7;
95
+ }
96
+
97
+ // ── Tag system ─────────────────────────────────────────────────
98
+
99
+ const TAG_OPTIONS = ['bug', 'feature', 'research', 'infra', 'deploy', 'review'];
100
+
101
+ function showTagDropdown(event, sessionId) {
102
+ event.stopPropagation();
103
+ document.querySelectorAll('.tag-dropdown').forEach(function(el) { el.remove(); });
104
+ var dd = document.createElement('div');
105
+ dd.className = 'tag-dropdown';
106
+ dd.innerHTML = TAG_OPTIONS.map(function(t) {
107
+ return '<div class="tag-dropdown-item" onclick="event.stopPropagation();addTag(\'' + sessionId + '\',\'' + t + '\')">' + t + '</div>';
108
+ }).join('');
109
+ event.target.parentElement.appendChild(dd);
110
+ setTimeout(function() {
111
+ document.addEventListener('click', function() { dd.remove(); }, { once: true });
112
+ }, 0);
113
+ }
114
+
115
+ function addTag(sessionId, tag) {
116
+ if (!tags[sessionId]) tags[sessionId] = [];
117
+ if (!tags[sessionId].includes(tag)) tags[sessionId].push(tag);
118
+ localStorage.setItem('codedash-tags', JSON.stringify(tags));
119
+ document.querySelectorAll('.tag-dropdown').forEach(function(el) { el.remove(); });
120
+ render();
121
+ }
122
+
123
+ function removeTag(sessionId, tag) {
124
+ if (tags[sessionId]) {
125
+ tags[sessionId] = tags[sessionId].filter(function(t) { return t !== tag; });
126
+ if (!tags[sessionId].length) delete tags[sessionId];
127
+ localStorage.setItem('codedash-tags', JSON.stringify(tags));
128
+ render();
129
+ }
130
+ }
131
+
132
+ // ── Stars ──────────────────────────────────────────────────────
133
+
134
+ function toggleStar(id) {
135
+ var idx = stars.indexOf(id);
136
+ if (idx >= 0) stars.splice(idx, 1);
137
+ else stars.push(id);
138
+ localStorage.setItem('codedash-stars', JSON.stringify(stars));
139
+ render();
140
+ }
141
+
142
+ // ── Data loading ───────────────────────────────────────────────
143
+
144
+ async function loadSessions() {
145
+ try {
146
+ var resp = await fetch('/api/sessions');
147
+ allSessions = await resp.json();
148
+ applyFilters();
149
+ } catch (e) {
150
+ document.getElementById('content').innerHTML = '<div class="empty-state">Failed to load sessions. Is the server running?</div>';
151
+ }
152
+ }
153
+
154
+ function refreshData() {
155
+ loadSessions();
156
+ showToast('Refreshed');
157
+ }
158
+
159
+ async function loadTerminals() {
160
+ try {
161
+ var resp = await fetch('/api/terminals');
162
+ availableTerminals = await resp.json();
163
+ var sel = document.getElementById('terminalSelect');
164
+ if (!sel) return;
165
+ sel.innerHTML = '';
166
+ var saved = localStorage.getItem('codedash-terminal') || '';
167
+ availableTerminals.forEach(function(t) {
168
+ if (!t.available) return;
169
+ var opt = document.createElement('option');
170
+ opt.value = t.id;
171
+ opt.textContent = t.name;
172
+ if (t.id === saved) opt.selected = true;
173
+ sel.appendChild(opt);
174
+ });
175
+ if (!saved && availableTerminals.length > 0) {
176
+ var first = availableTerminals.find(function(t) { return t.available; });
177
+ if (first) sel.value = first.id;
178
+ }
179
+ } catch (e) {
180
+ // terminals not available
181
+ }
182
+ }
183
+
184
+ function saveTerminalPref(val) {
185
+ localStorage.setItem('codedash-terminal', val);
186
+ }
187
+
188
+ // ── Filtering ──────────────────────────────────────────────────
189
+
190
+ function applyFilters() {
191
+ filteredSessions = allSessions.filter(function(s) {
192
+ // Tool filter
193
+ if (toolFilter && s.tool !== toolFilter) return false;
194
+
195
+ // Search
196
+ if (searchQuery) {
197
+ var q = searchQuery.toLowerCase();
198
+ var haystack = (
199
+ (s.first_message || '') + ' ' +
200
+ (s.project || '') + ' ' +
201
+ (s.project_short || '') + ' ' +
202
+ (s.id || '') + ' ' +
203
+ (s.tool || '')
204
+ ).toLowerCase();
205
+ if (haystack.indexOf(q) === -1) return false;
206
+ }
207
+
208
+ // Tag filter
209
+ if (tagFilter) {
210
+ var sessionTags = tags[s.id] || [];
211
+ if (sessionTags.indexOf(tagFilter) === -1) return false;
212
+ }
213
+
214
+ // Date range
215
+ if (dateFrom && s.date < dateFrom) return false;
216
+ if (dateTo && s.date > dateTo) return false;
217
+
218
+ return true;
219
+ });
220
+
221
+ render();
222
+ }
223
+
224
+ function onSearch(val) {
225
+ searchQuery = val;
226
+ applyFilters();
227
+ }
228
+
229
+ function onTagFilter(val) {
230
+ tagFilter = val;
231
+ applyFilters();
232
+ }
233
+
234
+ function onDateFilter() {
235
+ dateFrom = document.getElementById('dateFrom').value || '';
236
+ dateTo = document.getElementById('dateTo').value || '';
237
+ applyFilters();
238
+ }
239
+
240
+ function toggleGroup() {
241
+ grouped = !grouped;
242
+ var btn = document.getElementById('groupBtn');
243
+ if (btn) btn.classList.toggle('active', grouped);
244
+ render();
245
+ }
246
+
247
+ function setView(view) {
248
+ // Handle tool filter views
249
+ if (view === 'claude-only') {
250
+ toolFilter = toolFilter === 'claude' ? null : 'claude';
251
+ currentView = 'sessions';
252
+ } else if (view === 'codex-only') {
253
+ toolFilter = toolFilter === 'codex' ? null : 'codex';
254
+ currentView = 'sessions';
255
+ } else {
256
+ toolFilter = null;
257
+ currentView = view;
258
+ }
259
+
260
+ // Update sidebar active state
261
+ document.querySelectorAll('.sidebar-item').forEach(function(el) {
262
+ el.classList.toggle('active', el.getAttribute('data-view') === view);
263
+ });
264
+
265
+ applyFilters();
266
+ }
267
+
268
+ // Wire up sidebar clicks
269
+ document.querySelectorAll('.sidebar-item').forEach(function(el) {
270
+ el.addEventListener('click', function() {
271
+ setView(el.getAttribute('data-view'));
272
+ });
273
+ });
274
+
275
+ // ── Rendering: Card ────────────────────────────────────────────
276
+
277
+ function renderCard(s, idx) {
278
+ var isStarred = stars.indexOf(s.id) >= 0;
279
+ var isSelected = selectedIds.has(s.id);
280
+ var isFocused = focusedIndex === idx;
281
+ var sessionTags = tags[s.id] || [];
282
+ var cost = estimateCost(s.file_size);
283
+ var costStr = cost > 0 ? '~$' + cost.toFixed(2) : '';
284
+ var projName = getProjectName(s.project);
285
+ var projColor = getProjectColor(projName);
286
+ var toolClass = s.tool === 'codex' ? 'tool-codex' : 'tool-claude';
287
+
288
+ var classes = 'card';
289
+ if (isSelected) classes += ' selected';
290
+ if (isFocused) classes += ' focused';
291
+
292
+ var checkboxStyle = selectMode ? 'display:inline-block' : '';
293
+
294
+ var tagHtml = sessionTags.map(function(t) {
295
+ return '<span class="tag-pill tag-' + escHtml(t) + '" onclick="event.stopPropagation();removeTag(\'' + s.id + '\',\'' + t + '\')">' + escHtml(t) + ' &times;</span>';
296
+ }).join('');
297
+
298
+ var html = '<div class="' + classes + '" data-id="' + s.id + '" onclick="onCardClick(\'' + s.id + '\', event)">';
299
+ html += '<div class="card-top">';
300
+ html += '<input type="checkbox" class="card-checkbox" style="' + checkboxStyle + '" ' + (isSelected ? 'checked' : '') + ' onclick="toggleSelect(\'' + s.id + '\', event)">';
301
+ html += '<span class="tool-badge ' + toolClass + '">' + escHtml(s.tool) + '</span>';
302
+ html += '<span class="card-project" style="color:' + projColor + '">' + escHtml(projName) + '</span>';
303
+ html += '<span class="card-time">' + timeAgo(s.last_ts) + '</span>';
304
+ if (costStr) {
305
+ html += '<span class="cost-badge">' + costStr + '</span>';
306
+ }
307
+ html += '<button class="star-btn' + (isStarred ? ' active' : '') + '" onclick="event.stopPropagation();toggleStar(\'' + s.id + '\')" title="Star">&#9733;</button>';
308
+ html += '</div>';
309
+ html += '<div class="card-body">' + escHtml((s.first_message || '').slice(0, 120)) + '</div>';
310
+ html += '<div class="card-footer">';
311
+ html += '<span class="card-meta">' + s.messages + ' msgs</span>';
312
+ if (s.file_size) {
313
+ html += '<span class="card-meta">' + formatBytes(s.file_size) + '</span>';
314
+ }
315
+ html += '<span class="card-meta">' + escHtml(s.last_time || '') + '</span>';
316
+ html += '<span class="card-id">' + s.id.slice(0, 8) + '</span>';
317
+ // Tags
318
+ if (tagHtml || true) {
319
+ html += '<span class="card-tags">' + tagHtml;
320
+ html += '<button class="tag-add-btn" onclick="showTagDropdown(event, \'' + s.id + '\')" title="Add tag">+</button>';
321
+ html += '</span>';
322
+ }
323
+ html += '</div>';
324
+ html += '</div>';
325
+ return html;
326
+ }
327
+
328
+ function onCardClick(id, event) {
329
+ if (selectMode) {
330
+ toggleSelect(id, event);
331
+ } else {
332
+ var s = allSessions.find(function(x) { return x.id === id; });
333
+ if (s) openDetail(s);
334
+ }
335
+ }
336
+
337
+ // ── Rendering: Main ────────────────────────────────────────────
338
+
339
+ function render() {
340
+ var content = document.getElementById('content');
341
+ var stats = document.getElementById('stats');
342
+ if (!content) return;
343
+
344
+ var sessions = filteredSessions;
345
+
346
+ // Stats
347
+ if (stats) {
348
+ stats.textContent = sessions.length + ' sessions' +
349
+ (toolFilter ? ' (' + toolFilter + ')' : '') +
350
+ (tagFilter ? ' [' + tagFilter + ']' : '');
351
+ }
352
+
353
+ // Route to view
354
+ if (currentView === 'activity') {
355
+ renderHeatmap(content);
356
+ return;
357
+ }
358
+
359
+ if (currentView === 'starred') {
360
+ var starredSessions = sessions.filter(function(s) { return stars.indexOf(s.id) >= 0; });
361
+ if (starredSessions.length === 0) {
362
+ content.innerHTML = '<div class="empty-state">No starred sessions. Click the star on any session to bookmark it.</div>';
363
+ return;
364
+ }
365
+ var idx = 0;
366
+ content.innerHTML = starredSessions.map(function(s) { return renderCard(s, idx++); }).join('');
367
+ return;
368
+ }
369
+
370
+ if (currentView === 'timeline') {
371
+ renderTimeline(content, sessions);
372
+ return;
373
+ }
374
+
375
+ if (currentView === 'projects') {
376
+ renderProjects(content, sessions);
377
+ return;
378
+ }
379
+
380
+ // Default: sessions view
381
+ if (sessions.length === 0) {
382
+ content.innerHTML = '<div class="empty-state">No sessions found.' +
383
+ (searchQuery ? ' Try a different search.' : '') + '</div>';
384
+ return;
385
+ }
386
+
387
+ if (grouped) {
388
+ renderGrouped(content, sessions);
389
+ } else {
390
+ var idx2 = 0;
391
+ content.innerHTML = sessions.map(function(s) { return renderCard(s, idx2++); }).join('');
392
+ }
393
+ }
394
+
395
+ function renderGrouped(container, sessions) {
396
+ var groups = {};
397
+ sessions.forEach(function(s) {
398
+ var key = getProjectName(s.project);
399
+ if (!groups[key]) groups[key] = [];
400
+ groups[key].push(s);
401
+ });
402
+
403
+ var sortedKeys = Object.keys(groups).sort(function(a, b) {
404
+ return groups[b][0].last_ts - groups[a][0].last_ts;
405
+ });
406
+
407
+ var globalIdx = 0;
408
+ var html = '';
409
+ sortedKeys.forEach(function(key) {
410
+ var color = getProjectColor(key);
411
+ html += '<div class="group">';
412
+ html += '<div class="group-header" onclick="this.parentElement.classList.toggle(\'collapsed\')">';
413
+ html += '<span class="group-dot" style="background:' + color + '"></span>';
414
+ html += '<span class="group-name">' + escHtml(key) + '</span>';
415
+ html += '<span class="group-count">' + groups[key].length + '</span>';
416
+ html += '<span class="group-chevron">&#9660;</span>';
417
+ html += '</div>';
418
+ html += '<div class="group-body">';
419
+ groups[key].forEach(function(s) {
420
+ html += renderCard(s, globalIdx++);
421
+ });
422
+ html += '</div></div>';
423
+ });
424
+ container.innerHTML = html;
425
+ }
426
+
427
+ function renderTimeline(container, sessions) {
428
+ // Group by date
429
+ var byDate = {};
430
+ sessions.forEach(function(s) {
431
+ var d = s.date || 'unknown';
432
+ if (!byDate[d]) byDate[d] = [];
433
+ byDate[d].push(s);
434
+ });
435
+
436
+ var dates = Object.keys(byDate).sort().reverse();
437
+ if (dates.length === 0) {
438
+ container.innerHTML = '<div class="empty-state">No sessions to display in timeline.</div>';
439
+ return;
440
+ }
441
+
442
+ var globalIdx = 0;
443
+ var html = '<div class="timeline">';
444
+ dates.forEach(function(d) {
445
+ html += '<div class="timeline-date">';
446
+ html += '<div class="timeline-date-label">' + escHtml(d) + ' <span class="timeline-count">' + byDate[d].length + ' sessions</span></div>';
447
+ byDate[d].forEach(function(s) {
448
+ html += renderCard(s, globalIdx++);
449
+ });
450
+ html += '</div>';
451
+ });
452
+ html += '</div>';
453
+ container.innerHTML = html;
454
+ }
455
+
456
+ function renderProjects(container, sessions) {
457
+ var byProject = {};
458
+ sessions.forEach(function(s) {
459
+ var p = getProjectName(s.project);
460
+ if (!byProject[p]) byProject[p] = { sessions: [], project: s.project };
461
+ byProject[p].sessions.push(s);
462
+ });
463
+
464
+ var sorted = Object.entries(byProject).sort(function(a, b) {
465
+ return b[1].sessions.length - a[1].sessions.length;
466
+ });
467
+
468
+ if (sorted.length === 0) {
469
+ container.innerHTML = '<div class="empty-state">No projects found.</div>';
470
+ return;
471
+ }
472
+
473
+ var html = '<div class="projects-grid">';
474
+ sorted.forEach(function(entry) {
475
+ var name = entry[0];
476
+ var info = entry[1];
477
+ var color = getProjectColor(name);
478
+ var totalMsgs = info.sessions.reduce(function(sum, s) { return sum + (s.messages || 0); }, 0);
479
+ var totalSize = info.sessions.reduce(function(sum, s) { return sum + (s.file_size || 0); }, 0);
480
+ var latest = info.sessions[0];
481
+
482
+ html += '<div class="project-card" onclick="onSearch(\'' + escHtml(name) + '\');document.querySelector(\'.search-box\').value=\'' + escHtml(name) + '\'">';
483
+ html += '<div class="project-card-header">';
484
+ html += '<span class="group-dot" style="background:' + color + '"></span>';
485
+ html += '<span class="project-card-name">' + escHtml(name) + '</span>';
486
+ html += '</div>';
487
+ html += '<div class="project-card-stats">';
488
+ html += '<span>' + info.sessions.length + ' sessions</span>';
489
+ html += '<span>' + totalMsgs + ' msgs</span>';
490
+ html += '<span>' + formatBytes(totalSize) + '</span>';
491
+ html += '</div>';
492
+ html += '<div class="project-card-time">Last: ' + timeAgo(latest.last_ts) + '</div>';
493
+ html += '</div>';
494
+ });
495
+ html += '</div>';
496
+ container.innerHTML = html;
497
+ }
498
+
499
+ // ── Activity Heatmap ───────────────────────────────────────────
500
+
501
+ function renderHeatmap(container) {
502
+ var now = new Date();
503
+ var oneYearAgo = new Date(now);
504
+ oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
505
+
506
+ // Count sessions per day
507
+ var counts = {};
508
+ allSessions.forEach(function(s) {
509
+ var d = s.date;
510
+ if (!d) return;
511
+ counts[d] = (counts[d] || 0) + 1;
512
+ });
513
+
514
+ // Build day array for last 365 days
515
+ var days = [];
516
+ var d = new Date(oneYearAgo);
517
+ // Start from the most recent Sunday before or on oneYearAgo
518
+ d.setDate(d.getDate() - d.getDay());
519
+
520
+ var endDate = new Date(now);
521
+ endDate.setDate(endDate.getDate() + (6 - endDate.getDay())); // end on Saturday
522
+
523
+ while (d <= endDate) {
524
+ var iso = d.toISOString().slice(0, 10);
525
+ var count = counts[iso] || 0;
526
+ var level = 0;
527
+ if (count >= 6) level = 4;
528
+ else if (count >= 4) level = 3;
529
+ else if (count >= 2) level = 2;
530
+ else if (count >= 1) level = 1;
531
+ days.push({ date: iso, count: count, level: level, day: d.getDay() });
532
+ d = new Date(d);
533
+ d.setDate(d.getDate() + 1);
534
+ }
535
+
536
+ // Build weeks (columns)
537
+ var weeks = [];
538
+ var currentWeek = [];
539
+ days.forEach(function(day, i) {
540
+ currentWeek.push(day);
541
+ if (currentWeek.length === 7) {
542
+ weeks.push(currentWeek);
543
+ currentWeek = [];
544
+ }
545
+ });
546
+ if (currentWeek.length > 0) {
547
+ weeks.push(currentWeek);
548
+ }
549
+
550
+ // Month labels
551
+ var monthLabels = [];
552
+ var lastMonth = -1;
553
+ var monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
554
+ weeks.forEach(function(week, wi) {
555
+ var firstDay = week[0];
556
+ var m = parseInt(firstDay.date.slice(5, 7)) - 1;
557
+ if (m !== lastMonth) {
558
+ monthLabels.push({ week: wi, label: monthNames[m] });
559
+ lastMonth = m;
560
+ }
561
+ });
562
+
563
+ // Summary stats
564
+ var totalThisYear = 0;
565
+ var maxDay = '';
566
+ var maxCount = 0;
567
+ Object.keys(counts).forEach(function(d) {
568
+ if (d >= oneYearAgo.toISOString().slice(0, 10)) {
569
+ totalThisYear += counts[d];
570
+ if (counts[d] > maxCount) {
571
+ maxCount = counts[d];
572
+ maxDay = d;
573
+ }
574
+ }
575
+ });
576
+
577
+ // Current streak
578
+ var streak = 0;
579
+ var checkDate = new Date(now);
580
+ while (true) {
581
+ var iso = checkDate.toISOString().slice(0, 10);
582
+ if (counts[iso] && counts[iso] > 0) {
583
+ streak++;
584
+ checkDate.setDate(checkDate.getDate() - 1);
585
+ } else {
586
+ break;
587
+ }
588
+ }
589
+
590
+ // Render
591
+ var html = '<div class="heatmap-container">';
592
+ html += '<h2 class="heatmap-title">Activity</h2>';
593
+
594
+ // Month labels row
595
+ html += '<div class="heatmap-months">';
596
+ html += '<div class="heatmap-day-label"></div>'; // spacer for day labels
597
+ var monthPositions = {};
598
+ monthLabels.forEach(function(ml) { monthPositions[ml.week] = ml.label; });
599
+ for (var wi = 0; wi < weeks.length; wi++) {
600
+ if (monthPositions[wi]) {
601
+ html += '<div class="heatmap-month-label">' + monthPositions[wi] + '</div>';
602
+ } else {
603
+ html += '<div class="heatmap-month-spacer"></div>';
604
+ }
605
+ }
606
+ html += '</div>';
607
+
608
+ // Grid with day labels
609
+ var dayLabels = ['', 'Mon', '', 'Wed', '', 'Fri', ''];
610
+ for (var row = 0; row < 7; row++) {
611
+ html += '<div class="heatmap-row">';
612
+ html += '<div class="heatmap-day-label">' + dayLabels[row] + '</div>';
613
+ for (var col = 0; col < weeks.length; col++) {
614
+ var cell = weeks[col][row];
615
+ if (cell) {
616
+ html += '<div class="heatmap-cell level-' + cell.level + '" title="' + cell.date + ': ' + cell.count + ' sessions"></div>';
617
+ } else {
618
+ html += '<div class="heatmap-cell level-0"></div>';
619
+ }
620
+ }
621
+ html += '</div>';
622
+ }
623
+
624
+ html += '</div>';
625
+
626
+ // Summary
627
+ html += '<div class="heatmap-summary">';
628
+ html += '<div class="heatmap-stat"><span class="heatmap-stat-val">' + totalThisYear + '</span><span class="heatmap-stat-label">sessions this year</span></div>';
629
+ html += '<div class="heatmap-stat"><span class="heatmap-stat-val">' + (maxDay || 'N/A') + '</span><span class="heatmap-stat-label">most active day (' + maxCount + ')</span></div>';
630
+ html += '<div class="heatmap-stat"><span class="heatmap-stat-val">' + streak + '</span><span class="heatmap-stat-label">day streak</span></div>';
631
+ html += '</div>';
632
+
633
+ // Legend
634
+ html += '<div class="heatmap-legend">';
635
+ html += '<span>Less</span>';
636
+ for (var l = 0; l <= 4; l++) {
637
+ html += '<div class="heatmap-cell level-' + l + '"></div>';
638
+ }
639
+ html += '<span>More</span>';
640
+ html += '</div>';
641
+
642
+ container.innerHTML = html;
643
+ }
644
+
645
+ // ── Detail panel ───────────────────────────────────────────────
646
+
647
+ async function openDetail(s) {
648
+ var panel = document.getElementById('detailPanel');
649
+ var overlay = document.getElementById('overlay');
650
+ var title = document.getElementById('detailTitle');
651
+ var body = document.getElementById('detailBody');
652
+ if (!panel || !body) return;
653
+
654
+ title.textContent = escHtml(getProjectName(s.project)) + ' / ' + s.id.slice(0, 12);
655
+
656
+ var cost = estimateCost(s.file_size);
657
+ var costStr = cost > 0 ? '~$' + cost.toFixed(2) : '';
658
+ var isStarred = stars.indexOf(s.id) >= 0;
659
+ var sessionTags = tags[s.id] || [];
660
+ var terminal = localStorage.getItem('codedash-terminal') || '';
661
+
662
+ var infoHtml = '<div class="detail-info">';
663
+ infoHtml += '<div class="detail-row"><span class="detail-label">Tool</span><span class="tool-badge tool-' + s.tool + '">' + escHtml(s.tool) + '</span></div>';
664
+ infoHtml += '<div class="detail-row"><span class="detail-label">Project</span><span>' + escHtml(s.project_short || s.project || '') + '</span></div>';
665
+ infoHtml += '<div class="detail-row"><span class="detail-label">Session ID</span><span class="mono">' + escHtml(s.id) + '</span></div>';
666
+ infoHtml += '<div class="detail-row"><span class="detail-label">First seen</span><span>' + escHtml(s.first_time || '') + '</span></div>';
667
+ infoHtml += '<div class="detail-row"><span class="detail-label">Last seen</span><span>' + escHtml(s.last_time || '') + ' (' + timeAgo(s.last_ts) + ')</span></div>';
668
+ infoHtml += '<div class="detail-row"><span class="detail-label">Messages</span><span>' + (s.detail_messages || s.messages || 0) + '</span></div>';
669
+ infoHtml += '<div class="detail-row"><span class="detail-label">File size</span><span>' + formatBytes(s.file_size) + '</span></div>';
670
+ if (costStr) {
671
+ infoHtml += '<div class="detail-row"><span class="detail-label">Est. cost</span><span class="cost-badge">' + costStr + '</span></div>';
672
+ }
673
+ // Tags
674
+ infoHtml += '<div class="detail-row"><span class="detail-label">Tags</span><span class="card-tags">';
675
+ sessionTags.forEach(function(t) {
676
+ infoHtml += '<span class="tag-pill tag-' + escHtml(t) + '" onclick="removeTag(\'' + s.id + '\',\'' + t + '\')">' + escHtml(t) + ' &times;</span>';
677
+ });
678
+ infoHtml += '<button class="tag-add-btn" onclick="showTagDropdown(event, \'' + s.id + '\')">+</button>';
679
+ infoHtml += '</span></div>';
680
+ infoHtml += '</div>';
681
+
682
+ // Action buttons
683
+ infoHtml += '<div class="detail-actions">';
684
+ infoHtml += '<button class="launch-btn" onclick="launchSession(\'' + s.id + '\',\'' + escHtml(s.tool) + '\',\'' + escHtml(s.project || '') + '\')">Resume in Terminal</button>';
685
+ infoHtml += '<button class="launch-btn btn-secondary" onclick="copyResume(\'' + s.id + '\',\'' + escHtml(s.tool) + '\')">Copy Command</button>';
686
+ if (s.has_detail) {
687
+ infoHtml += '<button class="launch-btn btn-secondary" onclick="exportMd(\'' + s.id + '\',\'' + escHtml(s.project || '') + '\')">Export MD</button>';
688
+ }
689
+ infoHtml += '<button class="star-btn detail-star' + (isStarred ? ' active' : '') + '" onclick="toggleStar(\'' + s.id + '\')">&#9733; ' + (isStarred ? 'Starred' : 'Star') + '</button>';
690
+ infoHtml += '<button class="launch-btn btn-delete" onclick="showDeleteConfirm(\'' + s.id + '\',\'' + escHtml(s.project || '') + '\')">Delete</button>';
691
+ infoHtml += '</div>';
692
+
693
+ body.innerHTML = infoHtml + '<div class="detail-messages"><div class="loading">Loading messages...</div></div><div class="detail-commits"></div>';
694
+
695
+ panel.classList.add('open');
696
+ overlay.classList.add('open');
697
+
698
+ // Load messages
699
+ if (s.has_detail) {
700
+ try {
701
+ var resp = await fetch('/api/session/' + s.id + '?project=' + encodeURIComponent(s.project || ''));
702
+ var data = await resp.json();
703
+ var msgContainer = body.querySelector('.detail-messages');
704
+ if (data.messages && data.messages.length > 0) {
705
+ var msgsHtml = '<h3>Conversation</h3>';
706
+ data.messages.forEach(function(m) {
707
+ var roleClass = m.role === 'user' ? 'msg-user' : 'msg-assistant';
708
+ var roleLabel = m.role === 'user' ? 'You' : 'Assistant';
709
+ msgsHtml += '<div class="message ' + roleClass + '">';
710
+ msgsHtml += '<div class="msg-role">' + roleLabel + '</div>';
711
+ msgsHtml += '<div class="msg-content">' + escHtml(m.content) + '</div>';
712
+ msgsHtml += '</div>';
713
+ });
714
+ msgContainer.innerHTML = msgsHtml;
715
+ } else {
716
+ msgContainer.innerHTML = '<div class="empty-state">No messages found in detail file.</div>';
717
+ }
718
+ } catch (e) {
719
+ body.querySelector('.detail-messages').innerHTML = '<div class="empty-state">Failed to load messages.</div>';
720
+ }
721
+ } else {
722
+ body.querySelector('.detail-messages').innerHTML = '<div class="empty-state">No detail file available for this session.</div>';
723
+ }
724
+
725
+ // Load git commits
726
+ if (s.project) {
727
+ var commits = await loadGitCommits(s.project, s.first_ts, s.last_ts);
728
+ var commitsContainer = body.querySelector('.detail-commits');
729
+ if (commits && commits.length > 0) {
730
+ var cHtml = '<h3>Related Commits</h3><div class="commits-list">';
731
+ commits.forEach(function(c) {
732
+ cHtml += '<div class="commit-item">';
733
+ cHtml += '<span class="commit-hash">' + escHtml(c.hash) + '</span>';
734
+ cHtml += '<span class="commit-msg">' + escHtml(c.message) + '</span>';
735
+ cHtml += '</div>';
736
+ });
737
+ cHtml += '</div>';
738
+ commitsContainer.innerHTML = cHtml;
739
+ }
740
+ }
741
+ }
742
+
743
+ function closeDetail() {
744
+ var panel = document.getElementById('detailPanel');
745
+ var overlay = document.getElementById('overlay');
746
+ if (panel) panel.classList.remove('open');
747
+ if (overlay) overlay.classList.remove('open');
748
+ }
749
+
750
+ async function loadGitCommits(project, fromTs, toTs) {
751
+ try {
752
+ var resp = await fetch('/api/git-commits?project=' + encodeURIComponent(project) + '&from=' + fromTs + '&to=' + toTs);
753
+ return await resp.json();
754
+ } catch (e) {
755
+ return [];
756
+ }
757
+ }
758
+
759
+ function launchSession(sessionId, tool, project) {
760
+ var terminal = localStorage.getItem('codedash-terminal') || '';
761
+ fetch('/api/launch', {
762
+ method: 'POST',
763
+ headers: { 'Content-Type': 'application/json' },
764
+ body: JSON.stringify({
765
+ sessionId: sessionId,
766
+ tool: tool,
767
+ flags: [],
768
+ project: project,
769
+ terminal: terminal
770
+ })
771
+ }).then(function(resp) {
772
+ return resp.json();
773
+ }).then(function(data) {
774
+ if (data.ok) showToast('Launched in terminal');
775
+ else showToast('Launch failed: ' + (data.error || 'unknown'));
776
+ }).catch(function() {
777
+ showToast('Launch failed');
778
+ });
779
+ }
780
+
781
+ function copyResume(sessionId, tool) {
782
+ var cmd = tool === 'codex'
783
+ ? 'codex --resume ' + sessionId
784
+ : 'claude --resume ' + sessionId;
785
+ navigator.clipboard.writeText(cmd).then(function() {
786
+ showToast('Copied: ' + cmd);
787
+ }).catch(function() {
788
+ // Fallback
789
+ prompt('Copy this command:', cmd);
790
+ });
791
+ }
792
+
793
+ function exportMd(sessionId, project) {
794
+ window.open('/api/session/' + sessionId + '/export?project=' + encodeURIComponent(project));
795
+ }
796
+
797
+ // ── Delete ─────────────────────────────────────────────────────
798
+
799
+ function showDeleteConfirm(sessionId, project) {
800
+ pendingDelete = { id: sessionId, project: project };
801
+ var overlay = document.getElementById('confirmOverlay');
802
+ var idEl = document.getElementById('confirmId');
803
+ if (overlay) overlay.style.display = 'flex';
804
+ if (idEl) idEl.textContent = sessionId;
805
+ }
806
+
807
+ function closeConfirm() {
808
+ pendingDelete = null;
809
+ var overlay = document.getElementById('confirmOverlay');
810
+ if (overlay) overlay.style.display = 'none';
811
+ }
812
+
813
+ async function confirmDelete() {
814
+ if (!pendingDelete) return;
815
+ try {
816
+ var resp = await fetch('/api/session/' + pendingDelete.id, {
817
+ method: 'DELETE',
818
+ headers: { 'Content-Type': 'application/json' },
819
+ body: JSON.stringify({ project: pendingDelete.project })
820
+ });
821
+ var data = await resp.json();
822
+ if (data.ok) {
823
+ showToast('Session deleted');
824
+ allSessions = allSessions.filter(function(s) { return s.id !== pendingDelete.id; });
825
+ closeConfirm();
826
+ closeDetail();
827
+ applyFilters();
828
+ } else {
829
+ showToast('Delete failed: ' + (data.error || 'unknown'));
830
+ }
831
+ } catch (e) {
832
+ showToast('Delete failed');
833
+ }
834
+ closeConfirm();
835
+ }
836
+
837
+ // ── Bulk actions ───────────────────────────────────────────────
838
+
839
+ function toggleSelectMode() {
840
+ selectMode = !selectMode;
841
+ if (!selectMode) selectedIds.clear();
842
+ var btn = document.getElementById('selectBtn');
843
+ if (btn) btn.classList.toggle('active', selectMode);
844
+ updateBulkBar();
845
+ render();
846
+ }
847
+
848
+ function toggleSelect(id, event) {
849
+ if (event) event.stopPropagation();
850
+ if (selectedIds.has(id)) selectedIds.delete(id);
851
+ else selectedIds.add(id);
852
+ updateBulkBar();
853
+ render();
854
+ }
855
+
856
+ function updateBulkBar() {
857
+ var bar = document.getElementById('bulkBar');
858
+ if (!bar) return;
859
+ if (selectedIds.size > 0) {
860
+ bar.style.display = 'flex';
861
+ document.getElementById('bulkCount').textContent = selectedIds.size + ' selected';
862
+ } else {
863
+ bar.style.display = 'none';
864
+ }
865
+ }
866
+
867
+ function clearSelection() {
868
+ selectedIds.clear();
869
+ selectMode = false;
870
+ var btn = document.getElementById('selectBtn');
871
+ if (btn) btn.classList.remove('active');
872
+ updateBulkBar();
873
+ render();
874
+ }
875
+
876
+ async function bulkDelete() {
877
+ if (!confirm('Delete ' + selectedIds.size + ' sessions? This cannot be undone.')) return;
878
+ var sessions = [];
879
+ selectedIds.forEach(function(id) {
880
+ var s = allSessions.find(function(x) { return x.id === id; });
881
+ sessions.push({ id: id, project: s ? s.project : '' });
882
+ });
883
+ try {
884
+ var resp = await fetch('/api/bulk-delete', {
885
+ method: 'POST',
886
+ headers: { 'Content-Type': 'application/json' },
887
+ body: JSON.stringify({ sessions: sessions })
888
+ });
889
+ var data = await resp.json();
890
+ if (data.ok) {
891
+ showToast('Deleted ' + sessions.length + ' sessions');
892
+ allSessions = allSessions.filter(function(s) { return !selectedIds.has(s.id); });
893
+ clearSelection();
894
+ applyFilters();
895
+ }
896
+ } catch (e) {
897
+ showToast('Bulk delete failed');
898
+ }
899
+ }
900
+
901
+ // ── Themes ─────────────────────────────────────────────────────
902
+
903
+ function setTheme(theme) {
904
+ if (theme === 'dark') {
905
+ document.body.removeAttribute('data-theme');
906
+ } else if (theme === 'system') {
907
+ var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
908
+ if (prefersDark) {
909
+ document.body.removeAttribute('data-theme');
910
+ } else {
911
+ document.body.setAttribute('data-theme', 'light');
912
+ }
913
+ } else {
914
+ document.body.setAttribute('data-theme', theme);
915
+ }
916
+ localStorage.setItem('codedash-theme', theme);
917
+ }
918
+
919
+ function saveThemePref(val) {
920
+ setTheme(val);
921
+ }
922
+
923
+ // ── Keyboard navigation ────────────────────────────────────────
924
+
925
+ function isInput(e) {
926
+ var tag = document.activeElement ? document.activeElement.tagName : '';
927
+ return ['INPUT', 'SELECT', 'TEXTAREA'].indexOf(tag) >= 0;
928
+ }
929
+
930
+ function moveFocus(delta) {
931
+ var cards = document.querySelectorAll('.card');
932
+ if (cards.length === 0) return;
933
+ focusedIndex = Math.max(0, Math.min(cards.length - 1, focusedIndex + delta));
934
+ cards.forEach(function(c, i) {
935
+ c.classList.toggle('focused', i === focusedIndex);
936
+ });
937
+ if (cards[focusedIndex]) {
938
+ cards[focusedIndex].scrollIntoView({ block: 'nearest' });
939
+ }
940
+ }
941
+
942
+ function openFocusedCard() {
943
+ var cards = document.querySelectorAll('.card');
944
+ if (focusedIndex < 0 || focusedIndex >= cards.length) return;
945
+ var id = cards[focusedIndex].getAttribute('data-id');
946
+ if (!id) return;
947
+ var s = allSessions.find(function(x) { return x.id === id; });
948
+ if (s) {
949
+ if (selectMode) {
950
+ toggleSelect(id);
951
+ } else {
952
+ openDetail(s);
953
+ }
954
+ }
955
+ }
956
+
957
+ function toggleStarFocused() {
958
+ var cards = document.querySelectorAll('.card');
959
+ if (focusedIndex < 0 || focusedIndex >= cards.length) return;
960
+ var id = cards[focusedIndex].getAttribute('data-id');
961
+ if (id) toggleStar(id);
962
+ }
963
+
964
+ function deleteFocused() {
965
+ var cards = document.querySelectorAll('.card');
966
+ if (focusedIndex < 0 || focusedIndex >= cards.length) return;
967
+ var id = cards[focusedIndex].getAttribute('data-id');
968
+ if (!id) return;
969
+ var s = allSessions.find(function(x) { return x.id === id; });
970
+ if (s) showDeleteConfirm(s.id, s.project || '');
971
+ }
972
+
973
+ document.addEventListener('keydown', function(e) {
974
+ if (e.key === 'Escape') {
975
+ if (pendingDelete) {
976
+ closeConfirm();
977
+ } else {
978
+ closeDetail();
979
+ }
980
+ return;
981
+ }
982
+ if (e.key === '/' && !isInput(e)) {
983
+ e.preventDefault();
984
+ var searchBox = document.querySelector('.search-box');
985
+ if (searchBox) searchBox.focus();
986
+ return;
987
+ }
988
+ if (e.key === 'j' && !isInput(e)) {
989
+ e.preventDefault();
990
+ moveFocus(1);
991
+ return;
992
+ }
993
+ if (e.key === 'k' && !isInput(e)) {
994
+ e.preventDefault();
995
+ moveFocus(-1);
996
+ return;
997
+ }
998
+ if (e.key === 'Enter' && !isInput(e) && focusedIndex >= 0) {
999
+ e.preventDefault();
1000
+ openFocusedCard();
1001
+ return;
1002
+ }
1003
+ if (e.key === 'x' && !isInput(e) && focusedIndex >= 0) {
1004
+ e.preventDefault();
1005
+ toggleStarFocused();
1006
+ return;
1007
+ }
1008
+ if (e.key === 'd' && !isInput(e) && focusedIndex >= 0) {
1009
+ e.preventDefault();
1010
+ deleteFocused();
1011
+ return;
1012
+ }
1013
+ if (e.key === 'r' && !isInput(e)) {
1014
+ e.preventDefault();
1015
+ refreshData();
1016
+ return;
1017
+ }
1018
+ if (e.key === 'g' && !isInput(e)) {
1019
+ e.preventDefault();
1020
+ toggleGroup();
1021
+ return;
1022
+ }
1023
+ if (e.key === 's' && !isInput(e)) {
1024
+ e.preventDefault();
1025
+ toggleSelectMode();
1026
+ return;
1027
+ }
1028
+ });
1029
+
1030
+ // ── Initialization ─────────────────────────────────────────────
1031
+
1032
+ (function init() {
1033
+ // Load data
1034
+ loadSessions();
1035
+ loadTerminals();
1036
+
1037
+ // Apply saved theme
1038
+ var savedTheme = localStorage.getItem('codedash-theme') || 'dark';
1039
+ setTheme(savedTheme);
1040
+
1041
+ // Set saved theme in selector
1042
+ var themeSel = document.getElementById('themeSelect');
1043
+ if (themeSel) themeSel.value = savedTheme;
1044
+
1045
+ // Set group button state
1046
+ var groupBtn = document.getElementById('groupBtn');
1047
+ if (groupBtn) groupBtn.classList.toggle('active', grouped);
1048
+ })();