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
This commit is contained in:
500
command-center/ui/ui.js
Normal file
500
command-center/ui/ui.js
Normal file
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* 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();
|
||||
Reference in New Issue
Block a user