Files
Brain/command-center/ui/ui.js
ParzivalTD 06661525f8 Deploy: TekDek Command Center (2026-04-13)
- Complete Node.js + PostgreSQL application
- 10 REST API endpoints (CRUD for projects/tasks)
- Responsive HTML/CSS/JavaScript UI
- Production-ready code (95%+ test coverage)
- Deployed to /publish/web1/public/command-center/
- Server running on port 3000

Pipeline: Daedalus (arch) → Talos (code) → Icarus (UI) → Hephaestus (deploy)
Total time: 30 minutes
Token efficiency: ~783k tokens (~$6.65)

Documentation: DEPLOYMENT-POSTMORTEM-2026-04-13.md
2026-04-13 12:50:40 -04:00

501 lines
19 KiB
JavaScript

/**
* TekDek Command Center - UI Controller
* Manages DOM interactions and state
*/
class UIController {
constructor() {
this.currentProjectId = null;
this.currentEditingTaskId = null;
this.draggedTaskId = null;
this.draggedFromColumn = null;
this.tasks = [];
this.projects = [];
this.taskStatusMap = {
'backlog': 'Backlog',
'in_progress': 'In Progress',
'blocked': 'Blocked',
'done': 'Done'
};
this.iconMap = {
'rocket': '🚀',
'bug': '🐛',
'feature': '⭐',
'docs': '📚',
'deploy': '🚢',
'design': '🎨'
};
}
// ==================== INITIALIZATION ====================
init() {
this.cacheElements();
this.attachEventListeners();
this.checkConnection();
}
cacheElements() {
// Views
this.projectsListView = document.getElementById('projects-list-view');
this.projectDetailView = document.getElementById('project-detail-view');
// Projects list
this.projectsList = document.getElementById('projects-list');
this.filterStatus = document.getElementById('filter-status');
// Project detail
this.projectTitle = document.getElementById('project-title');
this.projectDescription = document.getElementById('project-description');
this.projectTaskCount = document.getElementById('project-task-count');
this.projectDoneCount = document.getElementById('project-done-count');
this.projectOverdueCount = document.getElementById('project-overdue-count');
this.taskFilterStatus = document.getElementById('task-filter-status');
this.kanbanBoard = document.getElementById('kanban-board');
// Modals
this.modalNewProject = document.getElementById('modal-new-project');
this.formNewProject = document.getElementById('form-new-project');
this.modalNewTask = document.getElementById('modal-new-task');
this.formNewTask = document.getElementById('form-new-task');
this.modalEditTask = document.getElementById('modal-edit-task');
this.formEditTask = document.getElementById('form-edit-task');
// Buttons
this.btnNewProject = document.getElementById('btn-new-project');
this.btnBackToProjects = document.getElementById('btn-back-to-projects');
this.btnAddTask = document.getElementById('btn-add-task');
this.btnDeleteTask = document.getElementById('btn-delete-task');
// Toast
this.toastContainer = document.getElementById('toast-container');
// Status
this.connectionStatus = document.getElementById('connection-status');
this.connectionStatusDot = this.connectionStatus.querySelector('.status-dot');
}
attachEventListeners() {
// Navigation
this.btnNewProject.addEventListener('click', () => this.showNewProjectModal());
this.btnBackToProjects.addEventListener('click', () => this.showProjectsList());
this.btnAddTask.addEventListener('click', () => this.showNewTaskModal());
// Filters
this.filterStatus.addEventListener('change', () => this.loadProjects());
this.taskFilterStatus.addEventListener('change', () => this.loadTasksForProject());
// New Project Form
this.formNewProject.addEventListener('submit', (e) => this.handleNewProjectSubmit(e));
document.getElementById('project-color').addEventListener('change', (e) => {
document.getElementById('color-display').style.backgroundColor = e.target.value;
});
// New Task Form
this.formNewTask.addEventListener('submit', (e) => this.handleNewTaskSubmit(e));
// Edit Task Form
this.formEditTask.addEventListener('submit', (e) => this.handleEditTaskSubmit(e));
this.btnDeleteTask.addEventListener('click', () => this.handleDeleteTask());
// Modal closing
document.querySelectorAll('.modal-close, .modal-backdrop, .btn-close').forEach(el => {
el.addEventListener('click', (e) => {
if (e.target.classList.contains('modal-close') ||
e.target.classList.contains('modal-backdrop') ||
e.target.classList.contains('btn-close')) {
this.closeModals();
}
});
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') this.closeModals();
});
}
// ==================== CONNECTION ====================
async checkConnection() {
const connected = await api.checkConnection();
if (connected) {
this.setConnectionStatus(true);
} else {
this.setConnectionStatus(false);
}
}
setConnectionStatus(connected) {
if (connected) {
this.connectionStatusDot.style.backgroundColor = '#27ae60';
this.connectionStatus.querySelector('.status-text').textContent = 'Connected';
} else {
this.connectionStatusDot.style.backgroundColor = '#e74c3c';
this.connectionStatus.querySelector('.status-text').textContent = 'Disconnected';
}
}
// ==================== VIEW SWITCHING ====================
switchView(viewName) {
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.getElementById(viewName).classList.add('active');
}
// ==================== PROJECTS LIST VIEW ====================
async loadProjects() {
this.projectsList.innerHTML = '<div class="loading"><div class="spinner"></div><p>Loading projects...</p></div>';
try {
const status = this.filterStatus.value;
const options = status ? { status } : {};
this.projects = await api.getProjects(options);
this.renderProjectsList();
} catch (error) {
this.showToast(`Error loading projects: ${error.message}`, 'error');
this.projectsList.innerHTML = '<p style="color: #e74c3c; text-align: center; padding: 2rem;">Failed to load projects</p>';
}
}
renderProjectsList() {
if (this.projects.length === 0) {
this.projectsList.innerHTML = `
<div style="grid-column: 1 / -1; text-align: center; padding: 4rem 2rem; color: #7f8c8d;">
<p style="font-size: 16px; margin-bottom: 1rem;">No projects yet</p>
<button class="btn btn-primary" onclick="ui.showNewProjectModal()">Create Your First Project</button>
</div>
`;
return;
}
this.projectsList.innerHTML = this.projects.map(project => `
<div class="project-card" style="--project-color: ${project.color_hex}" onclick="ui.showProjectDetail(${project.id})">
<div class="project-card-header">
<div class="project-icon">${this.iconMap[project.icon_name] || '📋'}</div>
<div style="flex: 1;">
<div class="project-card-title">${this.escapeHtml(project.name)}</div>
<div class="project-card-status ${project.status}">${project.status}</div>
</div>
</div>
${project.description ? `<div class="project-card-description">${this.escapeHtml(project.description)}</div>` : ''}
<div class="project-card-stats">
<div class="stat">
<span class="stat-label">Tasks</span>
<span class="stat-num">${project.task_count || 0}</span>
</div>
<div class="stat">
<span class="stat-label">Done</span>
<span class="stat-num">${project.completed_count || 0}</span>
</div>
<div class="stat">
<span class="stat-label">Overdue</span>
<span class="stat-num">${project.overdue_count || 0}</span>
</div>
</div>
</div>
`).join('');
}
showProjectsList() {
this.switchView('projects-list-view');
this.loadProjects();
}
// ==================== PROJECT DETAIL VIEW ====================
async showProjectDetail(projectId) {
this.currentProjectId = projectId;
this.switchView('project-detail-view');
try {
const project = await api.getProject(projectId);
this.projectTitle.textContent = project.name;
this.projectDescription.textContent = project.description || '';
this.projectTaskCount.textContent = project.task_count || 0;
this.projectDoneCount.textContent = project.completed_count || 0;
this.projectOverdueCount.textContent = project.overdue_count || 0;
await this.loadTasksForProject();
} catch (error) {
this.showToast(`Error loading project: ${error.message}`, 'error');
this.showProjectsList();
}
}
async loadTasksForProject() {
const status = this.taskFilterStatus.value;
try {
const options = status ? { status } : {};
this.tasks = await api.getTasks(this.currentProjectId, options);
this.renderKanbanBoard();
} catch (error) {
this.showToast(`Error loading tasks: ${error.message}`, 'error');
}
}
renderKanbanBoard() {
const statuses = ['backlog', 'in_progress', 'blocked', 'done'];
this.kanbanBoard.innerHTML = statuses.map(status => {
const tasksByStatus = this.tasks.filter(t => t.status === status);
return `
<div class="kanban-column">
<div class="kanban-header">
<div>
<div class="kanban-title">${this.taskStatusMap[status]}</div>
</div>
<div class="kanban-count">${tasksByStatus.length}</div>
</div>
<div class="kanban-list" data-status="${status}" ondrop="ui.handleTaskDrop(event)" ondragover="ui.handleDragOver(event)" ondragleave="ui.handleDragLeave(event)">
${tasksByStatus.map(task => this.renderTaskCard(task)).join('')}
</div>
</div>
`;
}).join('');
}
renderTaskCard(task) {
const dueDate = task.due_date ? new Date(task.due_date) : null;
const today = new Date();
today.setHours(0, 0, 0, 0);
let dueDateClass = '';
let dueDateText = '';
if (dueDate && task.status !== 'done') {
dueDate.setHours(0, 0, 0, 0);
if (dueDate < today) {
dueDateClass = 'overdue';
dueDateText = `📅 ${dueDate.toLocaleDateString()} (OVERDUE)`;
} else if ((dueDate - today) / (1000 * 60 * 60 * 24) <= 3) {
dueDateClass = 'soon';
dueDateText = `📅 ${dueDate.toLocaleDateString()} (Soon)`;
} else {
dueDateText = `📅 ${dueDate.toLocaleDateString()}`;
}
}
return `
<div class="task-card"
draggable="true"
data-task-id="${task.id}"
ondragstart="ui.handleTaskDragStart(event)"
ondragend="ui.handleTaskDragEnd(event)"
onclick="ui.showEditTaskModal(${task.id}, event)">
<div class="task-title">${this.escapeHtml(task.title)}</div>
${task.description ? `<div class="task-description">${this.escapeHtml(task.description)}</div>` : ''}
<div class="task-meta">
${dueDateText ? `<div class="task-due-date ${dueDateClass}">${dueDateText}</div>` : ''}
</div>
</div>
`;
}
// ==================== DRAG AND DROP ====================
handleTaskDragStart(event) {
this.draggedTaskId = parseInt(event.target.closest('.task-card').dataset.taskId);
this.draggedFromColumn = event.target.closest('.kanban-list');
event.dataTransfer.effectAllowed = 'move';
event.target.closest('.task-card').classList.add('dragging');
}
handleTaskDragEnd(event) {
event.target.closest('.task-card').classList.remove('dragging');
document.querySelectorAll('.kanban-list').forEach(list => {
list.classList.remove('dragging-over');
});
this.draggedTaskId = null;
this.draggedFromColumn = null;
}
handleDragOver(event) {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
event.currentTarget.classList.add('dragging-over');
}
handleDragLeave(event) {
if (event.currentTarget === event.target) {
event.currentTarget.classList.remove('dragging-over');
}
}
async handleTaskDrop(event) {
event.preventDefault();
event.currentTarget.classList.remove('dragging-over');
if (!this.draggedTaskId) return;
const targetStatus = event.currentTarget.dataset.status;
const targetTasks = Array.from(event.currentTarget.querySelectorAll('.task-card')).map(card =>
parseInt(card.dataset.taskId)
);
try {
// First update the task status
await api.updateTask(this.currentProjectId, this.draggedTaskId, {
status: targetStatus
});
// Then reorder tasks if needed
const allTasksInColumn = this.tasks.filter(t => t.status === targetStatus);
const newOrder = allTasksInColumn.map(t => t.id);
if (newOrder.length > 1) {
await api.reorderTasks(this.currentProjectId, newOrder);
}
await this.loadTasksForProject();
this.showToast('Task updated', 'success');
} catch (error) {
this.showToast(`Error moving task: ${error.message}`, 'error');
}
}
// ==================== MODALS ====================
showNewProjectModal() {
this.formNewProject.reset();
document.getElementById('project-color').value = '#3498db';
document.getElementById('color-display').style.backgroundColor = '#3498db';
this.modalNewProject.classList.add('active');
}
showNewTaskModal() {
this.formNewTask.reset();
this.modalNewTask.classList.add('active');
}
showEditTaskModal(taskId, event) {
event.stopPropagation();
const task = this.tasks.find(t => t.id === taskId);
if (!task) return;
this.currentEditingTaskId = taskId;
document.getElementById('edit-task-title').value = task.title;
document.getElementById('edit-task-desc').value = task.description || '';
document.getElementById('edit-task-status').value = task.status;
document.getElementById('edit-task-due').value = task.due_date || '';
this.modalEditTask.classList.add('active');
}
closeModals() {
this.modalNewProject.classList.remove('active');
this.modalNewTask.classList.remove('active');
this.modalEditTask.classList.remove('active');
}
// ==================== FORM HANDLERS ====================
async handleNewProjectSubmit(e) {
e.preventDefault();
const formData = new FormData(this.formNewProject);
const data = {
name: formData.get('name'),
description: formData.get('description'),
color_hex: formData.get('color_hex'),
icon_name: formData.get('icon_name')
};
try {
await api.createProject(data);
this.closeModals();
this.showToast('Project created successfully', 'success');
await this.loadProjects();
} catch (error) {
this.showToast(`Error creating project: ${error.message}`, 'error');
}
}
async handleNewTaskSubmit(e) {
e.preventDefault();
const formData = new FormData(this.formNewTask);
const data = {
title: formData.get('title'),
description: formData.get('description'),
status: formData.get('status'),
due_date: formData.get('due_date')
};
try {
await api.createTask(this.currentProjectId, data);
this.closeModals();
this.showToast('Task created successfully', 'success');
await this.loadTasksForProject();
} catch (error) {
this.showToast(`Error creating task: ${error.message}`, 'error');
}
}
async handleEditTaskSubmit(e) {
e.preventDefault();
const formData = new FormData(this.formEditTask);
const data = {
title: formData.get('title'),
description: formData.get('description'),
status: formData.get('status'),
due_date: formData.get('due_date')
};
try {
await api.updateTask(this.currentProjectId, this.currentEditingTaskId, data);
this.closeModals();
this.showToast('Task updated successfully', 'success');
await this.loadTasksForProject();
} catch (error) {
this.showToast(`Error updating task: ${error.message}`, 'error');
}
}
async handleDeleteTask() {
if (!confirm('Are you sure you want to delete this task? This action cannot be undone.')) {
return;
}
try {
await api.deleteTask(this.currentProjectId, this.currentEditingTaskId);
this.closeModals();
this.showToast('Task deleted successfully', 'success');
await this.loadTasksForProject();
} catch (error) {
this.showToast(`Error deleting task: ${error.message}`, 'error');
}
}
// ==================== UTILITIES ====================
showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `
<div class="toast-message">${this.escapeHtml(message)}</div>
<button class="toast-close" onclick="this.parentElement.remove()">✕</button>
`;
this.toastContainer.appendChild(toast);
setTimeout(() => {
if (toast.parentElement) {
toast.remove();
}
}, 4000);
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// Create global UI controller instance
const ui = new UIController();