diff --git a/public/app.js b/public/app.js index de1cdc2..b8e16e7 100644 --- a/public/app.js +++ b/public/app.js @@ -8,6 +8,7 @@ class Task { this.size = size; this.reward = reward; this.completed = false; + this.expandDescription = false; this.subtasks = []; } @@ -33,6 +34,8 @@ class Task { class App { constructor() { this.tasks = []; + this.archivedTasks = []; + this.viewMode = 'active'; // 'active' or 'archive' this.draggedTask = null; this.draggedSubtask = null; this.draggedParent = null; @@ -47,6 +50,7 @@ class App { async init() { this.setupEventListeners(); await this.loadTasks(); + await this.loadArchivedCount(); } setupEventListeners() { @@ -59,25 +63,55 @@ class App { form.addEventListener('submit', (e) => this.handleSubmit(e)); openModal.addEventListener('click', () => this.openCreateModal()); closeModal.addEventListener('click', () => this.toggleModal(false)); - + modal.addEventListener('click', (e) => { if (e.target === modal) this.toggleModal(false); }); + // Context menu actions document.getElementById('context-edit').addEventListener('click', () => this.handleContextEdit()); document.getElementById('context-delete').addEventListener('click', () => this.handleContextDelete()); document.getElementById('context-promote').addEventListener('click', () => this.handleContextPromote()); + document.getElementById('context-archive').addEventListener('click', () => this.handleContextArchive()); + document.getElementById('context-restore').addEventListener('click', () => this.handleContextRestore()); document.addEventListener('click', () => this.hideContextMenu()); contextMenu.addEventListener('click', (e) => e.stopPropagation()); + + // View toggle + document.getElementById('view-active').addEventListener('click', () => this.switchView('active')); + document.getElementById('view-archive').addEventListener('click', () => this.switchView('archive')); } + // ─── View Switching ─── + + async switchView(mode) { + if (this.viewMode === mode) return; + this.viewMode = mode; + + document.getElementById('view-active').classList.toggle('active', mode === 'active'); + document.getElementById('view-archive').classList.toggle('active', mode === 'archive'); + + // Show/hide elements based on view + document.getElementById('overall-progress').classList.toggle('hidden', mode === 'archive'); + document.getElementById('open-modal').style.display = mode === 'archive' ? 'none' : ''; + + if (mode === 'archive') { + await this.loadArchivedTasks(); + } else { + await this.loadTasks(); + } + } + + // ─── Modal ─── + openCreateModal() { document.getElementById('modal-title').textContent = 'Create New Goal'; document.getElementById('submit-btn').textContent = 'Add Goal'; document.getElementById('task-form').reset(); document.getElementById('edit-task-id').value = ''; document.getElementById('edit-parent-id').value = ''; + document.getElementById('task-expand-desc').checked = false; this.toggleModal(true); } @@ -90,59 +124,54 @@ class App { document.getElementById('task-description').value = task.description || ''; document.getElementById('task-size').value = task.size; document.getElementById('task-reward').value = task.reward || ''; - + document.getElementById('task-expand-desc').checked = !!task.expandDescription; this.toggleModal(true); } toggleModal(open) { const modal = document.getElementById('modal'); - if (open) { - modal.classList.add('open'); - } else { - modal.classList.remove('open'); - } + if (open) modal.classList.add('open'); + else modal.classList.remove('open'); } + // ─── Context Menu ─── + showContextMenu(e, target, type, parentTask = null) { e.preventDefault(); e.stopPropagation(); - + const contextMenu = document.getElementById('context-menu'); const promoteOption = document.getElementById('context-promote'); - + const archiveOption = document.getElementById('context-archive'); + const restoreOption = document.getElementById('context-restore'); + this.contextMenuTarget = target; this.contextMenuType = type; this.contextMenuParent = parentTask; - - // Show "Promote to Task" only for subtasks - if (type === 'subtask') { - promoteOption.style.display = 'block'; - } else { - promoteOption.style.display = 'none'; - } - - // Use clientX/clientY for fixed positioning (viewport-relative) + + // Show/hide context-specific options + promoteOption.style.display = (type === 'subtask' && this.viewMode === 'active') ? 'flex' : 'none'; + + const isCompleted = target.completed || target.calculateProgress() === 100; + archiveOption.style.display = (type === 'task' && this.viewMode === 'active' && isCompleted) ? 'flex' : 'none'; + restoreOption.style.display = (type === 'task' && this.viewMode === 'archive') ? 'flex' : 'none'; + + // Position using clientX/clientY for fixed menu, with viewport clamping let x = e.clientX; let y = e.clientY; - - // Temporarily show offscreen to measure dimensions + contextMenu.style.left = '-9999px'; contextMenu.style.top = '-9999px'; contextMenu.classList.add('show'); - + const menuWidth = contextMenu.offsetWidth; const menuHeight = contextMenu.offsetHeight; - - // Clamp to viewport boundaries - if (x + menuWidth > window.innerWidth) { - x = window.innerWidth - menuWidth - 5; - } - if (y + menuHeight > window.innerHeight) { - y = window.innerHeight - menuHeight - 5; - } + + if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 5; + if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 5; if (x < 0) x = 5; if (y < 0) y = 5; - + contextMenu.style.left = x + 'px'; contextMenu.style.top = y + 'px'; } @@ -161,7 +190,8 @@ class App { async handleContextDelete() { if (!this.contextMenuTarget) return; - if (!confirm('Delete this ' + (this.contextMenuType === 'task' ? 'task' : 'subtask') + '?')) { + const label = this.contextMenuType === 'task' ? 'task' : 'subtask'; + if (!confirm(`Delete this ${label}?`)) { this.hideContextMenu(); return; } @@ -171,7 +201,7 @@ class App { } else if (this.contextMenuType === 'subtask' && this.contextMenuParent) { await this.deleteSubtask(this.contextMenuParent.id, this.contextMenuTarget.id); } - + this.hideContextMenu(); } @@ -181,8 +211,7 @@ class App { return; } - // Remove from parent's subtasks - const parentTask = this.tasks.find(t => t.id === this.contextMenuParent.id); + const parentTask = this.getCurrentTasks().find(t => t.id === this.contextMenuParent.id); if (!parentTask) { this.hideContextMenu(); return; @@ -192,14 +221,12 @@ class App { parentTask.subtasks = parentTask.subtasks.filter(st => st.id !== subtask.id); try { - // Update parent task await fetch(`${API_URL}/tasks/${parentTask.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(parentTask) }); - // Create new top-level task await fetch(`${API_URL}/tasks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -219,6 +246,26 @@ class App { this.hideContextMenu(); } + async handleContextArchive() { + if (!this.contextMenuTarget || this.contextMenuType !== 'task') { + this.hideContextMenu(); + return; + } + await this.archiveTask(this.contextMenuTarget.id); + this.hideContextMenu(); + } + + async handleContextRestore() { + if (!this.contextMenuTarget || this.contextMenuType !== 'task') { + this.hideContextMenu(); + return; + } + await this.unarchiveTask(this.contextMenuTarget.id); + this.hideContextMenu(); + } + + // ─── Form Submit ─── + async handleSubmit(e) { e.preventDefault(); @@ -226,13 +273,15 @@ class App { const description = document.getElementById('task-description').value.trim(); const size = document.getElementById('task-size').value; const reward = document.getElementById('task-reward').value.trim(); + const expandDescription = document.getElementById('task-expand-desc').checked; const editId = document.getElementById('edit-task-id').value; const parentId = document.getElementById('edit-parent-id').value; + const taskList = this.getCurrentTasks(); + if (editId) { if (parentId) { - // Editing a subtask - const parentTask = this.tasks.find(t => t.id === parentId); + const parentTask = taskList.find(t => t.id === parentId); if (parentTask) { const subtask = parentTask.subtasks.find(st => st.id === editId); if (subtask) { @@ -240,29 +289,28 @@ class App { subtask.description = description; subtask.size = size; subtask.reward = reward; + subtask.expandDescription = expandDescription; await this.updateTask(parentTask); } } } else { - // Editing a main task - const task = this.tasks.find(t => t.id === editId); + const task = taskList.find(t => t.id === editId); if (task) { task.title = title; task.description = description; task.size = size; task.reward = reward; + task.expandDescription = expandDescription; await this.updateTask(task); } } } else { - // Creating new task try { const response = await fetch(`${API_URL}/tasks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title, description, size, reward }) + body: JSON.stringify({ title, description, size, reward, expandDescription }) }); - if (response.ok) await this.loadTasks(); } catch (error) { console.error('Error creating task:', error); @@ -273,34 +321,91 @@ class App { this.toggleModal(false); } + // ─── Data Loading ─── + + getCurrentTasks() { + return this.viewMode === 'archive' ? this.archivedTasks : this.tasks; + } + async loadTasks() { try { const response = await fetch(`${API_URL}/tasks`); const data = await response.json(); - + this.tasks = data.map(taskData => { const task = new Task(taskData.id, taskData.title, taskData.description, taskData.size, taskData.reward); task.completed = taskData.completed; - + task.expandDescription = !!taskData.expandDescription; if (taskData.subtasks && Array.isArray(taskData.subtasks)) { task.subtasks = taskData.subtasks.map(stData => { const subtask = new Task(stData.id, stData.title, stData.description, stData.size, stData.reward); subtask.completed = stData.completed; + subtask.expandDescription = !!stData.expandDescription; return subtask; }); } - return task; }); this.updateOverallProgress(); - this.render(); + if (this.viewMode === 'active') this.render(); + this.loadArchivedCount(); } catch (error) { console.error('Error loading tasks:', error); this.showError(); } } + async loadArchivedTasks() { + try { + const response = await fetch(`${API_URL}/tasks?archived=1`); + const data = await response.json(); + + this.archivedTasks = data.map(taskData => { + const task = new Task(taskData.id, taskData.title, taskData.description, taskData.size, taskData.reward); + task.completed = taskData.completed; + task.expandDescription = !!taskData.expandDescription; + if (taskData.subtasks && Array.isArray(taskData.subtasks)) { + task.subtasks = taskData.subtasks.map(stData => { + const subtask = new Task(stData.id, stData.title, stData.description, stData.size, stData.reward); + subtask.completed = stData.completed; + subtask.expandDescription = !!stData.expandDescription; + return subtask; + }); + } + return task; + }); + + if (this.viewMode === 'archive') this.renderArchive(); + this.updateArchiveCount(); + } catch (error) { + console.error('Error loading archived tasks:', error); + this.showError(); + } + } + + async loadArchivedCount() { + try { + const response = await fetch(`${API_URL}/tasks?archived=1`); + const data = await response.json(); + this.archivedTasks = data.map(taskData => { + const task = new Task(taskData.id, taskData.title, taskData.description, taskData.size, taskData.reward); + task.completed = taskData.completed; + return task; + }); + this.updateArchiveCount(); + } catch (error) { + // Silently fail — count is non-critical + } + } + + updateArchiveCount() { + const badge = document.getElementById('archive-count'); + const count = this.archivedTasks.length; + badge.textContent = count; + badge.style.display = count > 0 ? 'inline-block' : 'none'; + } + updateOverallProgress() { const totalTasks = this.tasks.length; const completedTasks = this.tasks.filter(t => t.calculateProgress() === 100).length; @@ -321,36 +426,37 @@ class App { }, 0); const overallProgress = totalWeight > 0 ? Math.round((completedWeight / totalWeight) * 100) : 0; - document.getElementById('overall-percentage').textContent = overallProgress + '%'; document.getElementById('overall-progress-fill').style.width = overallProgress + '%'; } + // ─── Task Actions ─── + async deleteTask(taskId) { try { const response = await fetch(`${API_URL}/tasks/${taskId}`, { method: 'DELETE' }); - if (response.ok) await this.loadTasks(); + if (response.ok) { + if (this.viewMode === 'archive') await this.loadArchivedTasks(); + else await this.loadTasks(); + } } catch (error) { console.error('Error deleting task:', error); } } async toggleTaskComplete(taskId) { - const task = this.tasks.find(t => t.id === taskId); + const task = this.getCurrentTasks().find(t => t.id === taskId); if (!task) return; task.completed = !task.completed; - - // When completing or uncompleting a task, also update all subtasks if (task.subtasks.length > 0) { task.subtasks.forEach(st => st.completed = task.completed); } - await this.updateTask(task); } async toggleSubtaskComplete(taskId, subtaskId) { - const task = this.tasks.find(t => t.id === taskId); + const task = this.getCurrentTasks().find(t => t.id === taskId); if (!task) return; const subtask = task.subtasks.find(st => st.id === subtaskId); @@ -361,7 +467,7 @@ class App { } async deleteSubtask(taskId, subtaskId) { - const task = this.tasks.find(t => t.id === taskId); + const task = this.getCurrentTasks().find(t => t.id === taskId); if (!task) return; task.subtasks = task.subtasks.filter(st => st.id !== subtaskId); @@ -375,12 +481,41 @@ class App { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(task) }); - if (response.ok) await this.loadTasks(); + if (response.ok) { + if (this.viewMode === 'archive') await this.loadArchivedTasks(); + else await this.loadTasks(); + } } catch (error) { console.error('Error updating task:', error); } } + async archiveTask(taskId) { + try { + const response = await fetch(`${API_URL}/tasks/${taskId}/archive`, { method: 'PUT' }); + if (response.ok) { + await this.loadTasks(); + await this.loadArchivedCount(); + } + } catch (error) { + console.error('Error archiving task:', error); + } + } + + async unarchiveTask(taskId) { + try { + const response = await fetch(`${API_URL}/tasks/${taskId}/unarchive`, { method: 'PUT' }); + if (response.ok) { + await this.loadArchivedTasks(); + await this.loadTasks(); + } + } catch (error) { + console.error('Error restoring task:', error); + } + } + + // ─── Drag & Drop ─── + handleDragStart(e, item, isSubtask = false, parentTask = null, index = null) { if (isSubtask) { this.draggedSubtask = item; @@ -400,12 +535,12 @@ class App { e.currentTarget.classList.remove('dragging'); document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over')); document.querySelectorAll('.drop-zone').forEach(el => el.classList.remove('visible')); - + if (this.dropZoneTimer) { clearTimeout(this.dropZoneTimer); this.dropZoneTimer = null; } - + this.draggedTask = null; this.draggedSubtask = null; this.draggedParent = null; @@ -424,9 +559,7 @@ class App { handleDragEnter(e, taskId) { e.preventDefault(); - if (!this.draggedTask && !this.draggedSubtask) return; - if (this.dropZoneTimer) clearTimeout(this.dropZoneTimer); this.dropZoneTimer = setTimeout(() => { @@ -443,7 +576,6 @@ class App { e.currentTarget.classList.remove('drag-over'); if (this.draggedTask && this.draggedTask.id !== targetTask.id) { - // Move task as subtask targetTask.subtasks.push(this.draggedTask); try { await fetch(`${API_URL}/tasks/${this.draggedTask.id}`, { method: 'DELETE' }); @@ -452,7 +584,6 @@ class App { console.error('Error moving task:', error); } } else if (this.draggedSubtask && this.draggedParent && targetTask.id !== this.draggedParent.id) { - // Move subtask between different parent tasks this.draggedParent.subtasks = this.draggedParent.subtasks.filter(st => st.id !== this.draggedSubtask.id); targetTask.subtasks.push(this.draggedSubtask); try { @@ -499,16 +630,17 @@ class App { if (this.draggedParent.id !== parentTask.id) return; if (this.draggedSubtaskIndex === targetIndex) return; - // Reorder within same parent const currentIndex = this.draggedSubtaskIndex; const subtask = parentTask.subtasks[currentIndex]; - + parentTask.subtasks.splice(currentIndex, 1); parentTask.subtasks.splice(targetIndex, 0, subtask); await this.updateTask(parentTask); } + // ─── Render: Active Tasks ─── + renderTask(task, index) { const progress = task.calculateProgress(); const isCompleted = progress === 100 || task.completed; @@ -518,6 +650,7 @@ class App { card.draggable = true; card.setAttribute('data-task-id', task.id); + // Drag events card.addEventListener('dragstart', (e) => this.handleDragStart(e, task, false)); card.addEventListener('dragend', (e) => this.handleDragEnd(e)); card.addEventListener('dragover', (e) => this.handleCardDragOver(e)); @@ -535,28 +668,32 @@ class App { this.showContextMenu(e, task, 'task'); } }); + card.addEventListener('dblclick', (e) => { + if (!e.target.closest('.subtask') && !e.target.closest('button')) { + this.openEditModal(task); + } + }); + + const descClass = task.expandDescription ? 'task-description expanded' : 'task-description'; card.innerHTML = `
Completed tasks you archive will appear here
+