Files
TaskTracker/TaskTracker.Api/wwwroot/js/app.js
AJ Isaacs d784f9fea8 feat(web): add real-time activity feed via SignalR
- Add ActivityHub and wire up SignalR in Program.cs
- Broadcast new context events from ContextController
- Connect SignalR client on Analytics page for live feed updates
- Restructure activity feed HTML to support live prepending
- Add slide-in animation for new activity items
- Update CORS to allow credentials for SignalR

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 23:30:01 -05:00

332 lines
12 KiB
JavaScript

// TaskTracker app.js — drag-and-drop, detail panel, keyboard shortcuts
(function() {
'use strict';
// ========================================
// Drag-and-Drop (SortableJS)
// ========================================
function initKanban() {
document.querySelectorAll('.kanban-column-body').forEach(function(el) {
// Destroy existing Sortable instance if re-initializing
if (el._sortable) {
el._sortable.destroy();
}
el._sortable = new Sortable(el, {
group: 'kanban',
animation: 150,
ghostClass: 'task-card--ghost',
dragClass: 'task-card--dragging',
handle: '.task-card', // Only drag by the card itself
filter: '.create-task-form', // Don't drag the create form
onEnd: function(evt) {
var taskId = evt.item.dataset.taskId;
var fromStatus = evt.from.dataset.status;
var toStatus = evt.to.dataset.status;
// No-op if dropped in same column
if (fromStatus === toStatus) return;
// Determine the handler based on transition
var handler = null;
if (toStatus === 'Active' && fromStatus === 'Paused') {
handler = 'Resume';
} else if (toStatus === 'Active') {
handler = 'Start';
} else if (toStatus === 'Paused' && fromStatus === 'Active') {
handler = 'Pause';
} else if (toStatus === 'Completed') {
handler = 'Complete';
} else {
// Invalid transition — revert by moving the item back
evt.from.appendChild(evt.item);
return;
}
// Fire htmx request to update the task status
htmx.ajax('PUT', '/board?handler=' + handler + '&id=' + taskId, {
target: '#kanban-board',
swap: 'innerHTML'
});
}
});
});
}
// ========================================
// Detail Panel helpers
// ========================================
// Defined on window so inline onclick handlers and partial scripts can call them.
// The _TaskDetail partial also defines these inline; whichever loads last wins,
// but the behavior is identical.
window.closeDetailPanel = function() {
var overlay = document.querySelector('.detail-overlay');
var panel = document.querySelector('.detail-panel');
if (overlay) overlay.classList.remove('detail-overlay--open');
if (panel) panel.classList.remove('detail-panel--open');
// Clear content after CSS transition completes
setTimeout(function() {
var container = document.getElementById('detail-panel');
if (container && !document.querySelector('.detail-panel--open')) {
container.innerHTML = '';
}
}, 300);
};
window.startEdit = function(fieldId) {
var container = document.getElementById('edit-' + fieldId);
if (!container) return;
var display = container.querySelector('.inline-edit-display');
var form = container.querySelector('.inline-edit-form');
if (display) display.style.display = 'none';
if (form) {
form.style.display = '';
var input = form.querySelector('input, textarea');
if (input) {
input.focus();
if (input.type === 'text' || input.tagName === 'TEXTAREA') {
input.setSelectionRange(input.value.length, input.value.length);
}
}
}
};
window.cancelEdit = function(fieldId) {
var container = document.getElementById('edit-' + fieldId);
if (!container) return;
var display = container.querySelector('.inline-edit-display');
var form = container.querySelector('.inline-edit-form');
if (display) display.style.display = '';
if (form) form.style.display = 'none';
};
// ========================================
// Initialization
// ========================================
// Initialize Kanban drag-and-drop on page load
document.addEventListener('DOMContentLoaded', function() {
initKanban();
// "New Task" header button — scroll to and focus the create-task input in the Pending column
var newTaskBtn = document.getElementById('new-task-trigger');
if (newTaskBtn) {
newTaskBtn.addEventListener('click', function() {
var input = document.querySelector('.create-task-form input[name="title"]');
if (input) {
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
input.focus();
}
});
}
});
// Re-initialize after htmx swaps that affect the kanban board.
// This is critical because htmx replaces DOM nodes, destroying Sortable instances.
document.addEventListener('htmx:afterSwap', function(evt) {
var target = evt.detail.target;
if (target && (target.id === 'kanban-board' || target.id === 'board-content' || (target.closest && target.closest('#kanban-board')))) {
initKanban();
}
});
// Also handle htmx:afterSettle for cases where the DOM isn't fully ready on afterSwap
document.addEventListener('htmx:afterSettle', function(evt) {
var target = evt.detail.target;
if (target && (target.id === 'kanban-board' || target.id === 'board-content')) {
initKanban();
}
});
// Open detail panel when its content is loaded via htmx
document.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target && evt.detail.target.id === 'detail-panel') {
// Panel content was just loaded — trigger open animation
requestAnimationFrame(function() {
var overlay = document.querySelector('.detail-overlay');
var panel = document.querySelector('.detail-panel');
if (overlay) overlay.classList.add('detail-overlay--open');
if (panel) panel.classList.add('detail-panel--open');
});
}
});
// Global Escape key handler for closing the detail panel
// (Search modal Escape is handled separately with stopPropagation)
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
// Don't close detail panel if search modal handled Escape
var searchModal = document.getElementById('search-modal');
if (searchModal && searchModal.classList.contains('search-backdrop--open')) return;
var panel = document.querySelector('.detail-panel--open');
if (panel) {
closeDetailPanel();
e.preventDefault();
}
}
});
// ========================================
// Search Modal (Ctrl+K)
// ========================================
var searchSelectedIndex = 0;
window.openSearch = function() {
var modal = document.getElementById('search-modal');
if (!modal) return;
modal.classList.add('search-backdrop--open');
var input = document.getElementById('search-input');
if (input) {
input.value = '';
input.focus();
// Trigger initial load (show recent tasks when empty)
htmx.trigger(input, 'search');
}
searchSelectedIndex = 0;
};
window.closeSearch = function() {
var modal = document.getElementById('search-modal');
if (modal) modal.classList.remove('search-backdrop--open');
};
window.selectSearchResult = function(taskId) {
closeSearch();
// Load the task detail panel
htmx.ajax('GET', '/board?handler=TaskDetail&id=' + taskId, {
target: '#detail-panel',
swap: 'innerHTML'
});
};
window.highlightResult = function(el) {
var results = document.querySelectorAll('.search-result');
results.forEach(function(r) { r.classList.remove('search-result--selected'); });
el.classList.add('search-result--selected');
// Update index
searchSelectedIndex = Array.from(results).indexOf(el);
};
// Keyboard navigation in search
document.addEventListener('keydown', function(e) {
var modal = document.getElementById('search-modal');
if (!modal || !modal.classList.contains('search-backdrop--open')) {
// Ctrl+K / Cmd+K to open
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
openSearch();
}
return;
}
// Modal is open
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
closeSearch();
return;
}
var results = document.querySelectorAll('.search-result');
if (results.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
searchSelectedIndex = Math.min(searchSelectedIndex + 1, results.length - 1);
updateSearchSelection(results);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
searchSelectedIndex = Math.max(searchSelectedIndex - 1, 0);
updateSearchSelection(results);
} else if (e.key === 'Enter') {
e.preventDefault();
var selected = results[searchSelectedIndex];
if (selected) {
var taskId = selected.dataset.taskId;
selectSearchResult(taskId);
}
}
});
function updateSearchSelection(results) {
results.forEach(function(r, i) {
if (i === searchSelectedIndex) {
r.classList.add('search-result--selected');
r.scrollIntoView({ block: 'nearest' });
} else {
r.classList.remove('search-result--selected');
}
});
}
// Reset selection when search results are updated
document.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target && evt.detail.target.id === 'search-results') {
searchSelectedIndex = 0;
}
});
// ========================================
// SignalR — Real-time Activity Feed
// ========================================
function initActivityHub() {
var feed = document.getElementById('activity-feed');
if (!feed) return; // Not on the Analytics page
var connection = new signalR.HubConnectionBuilder()
.withUrl('/hubs/activity')
.withAutomaticReconnect()
.build();
connection.on('NewActivity', function(data) {
// Remove the empty-state placeholder if present
var empty = document.getElementById('activity-empty');
if (empty) empty.remove();
var item = document.createElement('div');
item.className = 'activity-item activity-item--new';
var displayText = data.url || data.windowTitle || '';
// Escape HTML to prevent XSS
var div = document.createElement('div');
div.textContent = data.appName || '';
var safeApp = div.innerHTML;
div.textContent = displayText;
var safeTitle = div.innerHTML;
item.innerHTML =
'<span class="activity-dot" style="background: var(--color-accent)"></span>' +
'<div class="activity-line"></div>' +
'<div class="activity-info">' +
'<span class="activity-app">' + safeApp + '</span>' +
'<span class="activity-title">' + safeTitle + '</span>' +
'<span class="activity-time">just now</span>' +
'</div>';
feed.insertBefore(item, feed.firstChild);
// Cap visible items at 100 to prevent memory bloat
var items = feed.querySelectorAll('.activity-item');
if (items.length > 100) {
items[items.length - 1].remove();
}
});
connection.start().catch(function(err) {
console.error('SignalR connection error:', err);
});
}
document.addEventListener('DOMContentLoaded', function() {
initActivityHub();
});
})();