diff --git a/dockerifle b/dockerfile similarity index 100% rename from dockerifle rename to dockerfile diff --git a/public/app.js b/public/app.js index 8049e31..e525968 100644 --- a/public/app.js +++ b/public/app.js @@ -12,11 +12,7 @@ class Task { } getProgressWeight() { - const weights = { - small: 10, - medium: 25, - large: 50 - }; + const weights = { small: 10, medium: 25, large: 50 }; return weights[this.size] || 25; } @@ -25,28 +21,13 @@ class Task { return this.completed ? 100 : 0; } - const totalWeight = this.subtasks.reduce((sum, subtask) => sum + subtask.getProgressWeight(), 0); - const completedWeight = this.subtasks.reduce((sum, subtask) => { - return sum + (subtask.completed ? subtask.getProgressWeight() : 0); + const totalWeight = this.subtasks.reduce((sum, st) => sum + st.getProgressWeight(), 0); + const completedWeight = this.subtasks.reduce((sum, st) => { + return sum + (st.completed ? st.getProgressWeight() : 0); }, 0); return totalWeight > 0 ? Math.round((completedWeight / totalWeight) * 100) : 0; } - - addSubtask(subtask) { - this.subtasks.push(subtask); - } - - removeSubtask(subtaskId) { - this.subtasks = this.subtasks.filter(st => st.id !== subtaskId); - } - - toggleComplete() { - this.completed = !this.completed; - if (this.completed && this.subtasks.length > 0) { - this.subtasks.forEach(st => st.completed = true); - } - } } class App { @@ -55,6 +36,11 @@ class App { this.draggedTask = null; this.draggedSubtask = null; this.draggedParent = null; + this.draggedSubtaskIndex = null; + this.dropZoneTimer = null; + this.contextMenuTarget = null; + this.contextMenuType = null; + this.contextMenuParent = null; this.init(); } @@ -65,55 +51,205 @@ class App { setupEventListeners() { const form = document.getElementById('task-form'); - const openSidebar = document.getElementById('open-sidebar'); - const closeSidebar = document.getElementById('close-sidebar'); - const overlay = document.getElementById('overlay'); + const openModal = document.getElementById('open-modal'); + const closeModal = document.getElementById('close-modal'); + const modal = document.getElementById('modal'); + const contextMenu = document.getElementById('context-menu'); form.addEventListener('submit', (e) => this.handleSubmit(e)); - openSidebar.addEventListener('click', () => this.toggleSidebar(true)); - closeSidebar.addEventListener('click', () => this.toggleSidebar(false)); - overlay.addEventListener('click', () => this.toggleSidebar(false)); + openModal.addEventListener('click', () => this.openCreateModal()); + closeModal.addEventListener('click', () => this.toggleModal(false)); + + modal.addEventListener('click', (e) => { + if (e.target === modal) this.toggleModal(false); + }); + + 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.addEventListener('click', () => this.hideContextMenu()); + contextMenu.addEventListener('click', (e) => e.stopPropagation()); } - toggleSidebar(open) { - const sidebar = document.getElementById('sidebar'); - const overlay = document.getElementById('overlay'); + 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 = ''; + this.toggleModal(true); + } + openEditModal(task, parentTask = null) { + document.getElementById('modal-title').textContent = parentTask ? 'Edit Subtask' : 'Edit Goal'; + document.getElementById('submit-btn').textContent = 'Update'; + document.getElementById('edit-task-id').value = task.id; + document.getElementById('edit-parent-id').value = parentTask ? parentTask.id : ''; + document.getElementById('task-title').value = task.title; + document.getElementById('task-description').value = task.description || ''; + document.getElementById('task-size').value = task.size; + document.getElementById('task-reward').value = task.reward || ''; + + this.toggleModal(true); + } + + toggleModal(open) { + const modal = document.getElementById('modal'); if (open) { - sidebar.classList.add('open'); - overlay.classList.add('active'); + modal.classList.add('open'); } else { - sidebar.classList.remove('open'); - overlay.classList.remove('active'); + modal.classList.remove('open'); } } + showContextMenu(e, target, type, parentTask = null) { + e.preventDefault(); + e.stopPropagation(); + + const contextMenu = document.getElementById('context-menu'); + const promoteOption = document.getElementById('context-promote'); + + 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'; + } + + contextMenu.style.left = e.pageX + 'px'; + contextMenu.style.top = e.pageY + 'px'; + contextMenu.classList.add('show'); + } + + hideContextMenu() { + document.getElementById('context-menu').classList.remove('show'); + } + + handleContextEdit() { + if (this.contextMenuTarget) { + this.openEditModal(this.contextMenuTarget, this.contextMenuParent); + } + this.hideContextMenu(); + } + + async handleContextDelete() { + if (!this.contextMenuTarget) return; + + if (!confirm('Delete this ' + (this.contextMenuType === 'task' ? 'task' : 'subtask') + '?')) { + this.hideContextMenu(); + return; + } + + if (this.contextMenuType === 'task') { + await this.deleteTask(this.contextMenuTarget.id); + } else if (this.contextMenuType === 'subtask' && this.contextMenuParent) { + await this.deleteSubtask(this.contextMenuParent.id, this.contextMenuTarget.id); + } + + this.hideContextMenu(); + } + + async handleContextPromote() { + if (!this.contextMenuTarget || this.contextMenuType !== 'subtask' || !this.contextMenuParent) { + this.hideContextMenu(); + return; + } + + // Remove from parent's subtasks + const parentTask = this.tasks.find(t => t.id === this.contextMenuParent.id); + if (!parentTask) { + this.hideContextMenu(); + return; + } + + const subtask = this.contextMenuTarget; + 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' }, + body: JSON.stringify({ + title: subtask.title, + description: subtask.description, + size: subtask.size, + reward: subtask.reward + }) + }); + + await this.loadTasks(); + } catch (error) { + console.error('Error promoting subtask:', error); + } + + this.hideContextMenu(); + } + async handleSubmit(e) { e.preventDefault(); - const title = document.getElementById('task-title').value; - const description = document.getElementById('task-description').value; + const title = document.getElementById('task-title').value.trim(); + const description = document.getElementById('task-description').value.trim(); const size = document.getElementById('task-size').value; - const reward = document.getElementById('task-reward').value; + const reward = document.getElementById('task-reward').value.trim(); + const editId = document.getElementById('edit-task-id').value; + const parentId = document.getElementById('edit-parent-id').value; - try { - const response = await fetch(`${API_URL}/tasks`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title, description, size, reward }) - }); - - if (response.ok) { - await this.loadTasks(); - e.target.reset(); - this.toggleSidebar(false); + if (editId) { + if (parentId) { + // Editing a subtask + const parentTask = this.tasks.find(t => t.id === parentId); + if (parentTask) { + const subtask = parentTask.subtasks.find(st => st.id === editId); + if (subtask) { + subtask.title = title; + subtask.description = description; + subtask.size = size; + subtask.reward = reward; + await this.updateTask(parentTask); + } + } } else { - alert('Failed to create task'); + // Editing a main task + const task = this.tasks.find(t => t.id === editId); + if (task) { + task.title = title; + task.description = description; + task.size = size; + task.reward = reward; + 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 }) + }); + + if (response.ok) await this.loadTasks(); + } catch (error) { + console.error('Error creating task:', error); } - } catch (error) { - console.error('Error creating task:', error); - alert('Failed to create task'); } + + e.target.reset(); + this.toggleModal(false); } async loadTasks() { @@ -122,24 +258,12 @@ class App { const data = await response.json(); this.tasks = data.map(taskData => { - const task = new Task( - taskData.id, - taskData.title, - taskData.description, - taskData.size, - taskData.reward - ); + const task = new Task(taskData.id, taskData.title, taskData.description, taskData.size, taskData.reward); task.completed = taskData.completed; - if (taskData.subtasks) { + 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 - ); + const subtask = new Task(stData.id, stData.title, stData.description, stData.size, stData.reward); subtask.completed = stData.completed; return subtask; }); @@ -148,6 +272,7 @@ class App { return task; }); + this.updateOverallProgress(); this.render(); } catch (error) { console.error('Error loading tasks:', error); @@ -155,24 +280,37 @@ class App { } } - async deleteTask(taskId) { - if (!confirm('Are you sure you want to delete this task?')) { + updateOverallProgress() { + const totalTasks = this.tasks.length; + const completedTasks = this.tasks.filter(t => t.calculateProgress() === 100).length; + + document.getElementById('completed-count').textContent = completedTasks; + document.getElementById('total-count').textContent = totalTasks; + + if (totalTasks === 0) { + document.getElementById('overall-percentage').textContent = '0%'; + document.getElementById('overall-progress-fill').style.width = '0%'; return; } - try { - const response = await fetch(`${API_URL}/tasks/${taskId}`, { - method: 'DELETE' - }); + const totalWeight = this.tasks.reduce((sum, t) => sum + t.getProgressWeight(), 0); + const completedWeight = this.tasks.reduce((sum, t) => { + const progress = t.calculateProgress(); + return sum + (t.getProgressWeight() * progress / 100); + }, 0); - if (response.ok) { - await this.loadTasks(); - } else { - alert('Failed to delete task'); - } + const overallProgress = totalWeight > 0 ? Math.round((completedWeight / totalWeight) * 100) : 0; + + document.getElementById('overall-percentage').textContent = overallProgress + '%'; + document.getElementById('overall-progress-fill').style.width = overallProgress + '%'; + } + + async deleteTask(taskId) { + try { + const response = await fetch(`${API_URL}/tasks/${taskId}`, { method: 'DELETE' }); + if (response.ok) await this.loadTasks(); } catch (error) { console.error('Error deleting task:', error); - alert('Failed to delete task'); } } @@ -180,24 +318,14 @@ class App { const task = this.tasks.find(t => t.id === taskId); if (!task) return; - task.toggleComplete(); - - try { - const response = await fetch(`${API_URL}/tasks/${taskId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(task) - }); - - if (response.ok) { - await this.loadTasks(); - } else { - alert('Failed to update task'); - } - } catch (error) { - console.error('Error updating task:', error); - alert('Failed to update task'); + 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) { @@ -208,72 +336,64 @@ class App { if (!subtask) return; subtask.completed = !subtask.completed; - - try { - const response = await fetch(`${API_URL}/tasks/${taskId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(task) - }); - - if (response.ok) { - await this.loadTasks(); - } else { - alert('Failed to update subtask'); - } - } catch (error) { - console.error('Error updating subtask:', error); - alert('Failed to update subtask'); - } + await this.updateTask(task); } async deleteSubtask(taskId, subtaskId) { const task = this.tasks.find(t => t.id === taskId); if (!task) return; - task.removeSubtask(subtaskId); + task.subtasks = task.subtasks.filter(st => st.id !== subtaskId); + await this.updateTask(task); + } + async updateTask(task) { try { - const response = await fetch(`${API_URL}/tasks/${taskId}`, { + const response = await fetch(`${API_URL}/tasks/${task.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(task) }); - - if (response.ok) { - await this.loadTasks(); - } else { - alert('Failed to delete subtask'); - } + if (response.ok) await this.loadTasks(); } catch (error) { - console.error('Error deleting subtask:', error); - alert('Failed to delete subtask'); + console.error('Error updating task:', error); } } - handleDragStart(e, task, isSubtask = false, parentTask = null) { + handleDragStart(e, item, isSubtask = false, parentTask = null, index = null) { if (isSubtask) { - this.draggedSubtask = task; + this.draggedSubtask = item; this.draggedParent = parentTask; + this.draggedSubtaskIndex = index; + this.draggedTask = null; } else { - this.draggedTask = task; + this.draggedTask = item; + this.draggedSubtask = null; + this.draggedParent = null; + this.draggedSubtaskIndex = null; } - e.target.classList.add('dragging'); + e.currentTarget.classList.add('dragging'); } handleDragEnd(e) { - e.target.classList.remove('dragging'); + 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; - - document.querySelectorAll('.drag-over').forEach(el => { - el.classList.remove('drag-over'); - }); + this.draggedSubtaskIndex = null; } - handleDragOver(e) { + handleCardDragOver(e) { e.preventDefault(); + e.stopPropagation(); e.currentTarget.classList.add('drag-over'); } @@ -281,103 +401,126 @@ class App { e.currentTarget.classList.remove('drag-over'); } + handleDragEnter(e, taskId) { + e.preventDefault(); + + if (!this.draggedTask && !this.draggedSubtask) return; + + if (this.dropZoneTimer) clearTimeout(this.dropZoneTimer); + + this.dropZoneTimer = setTimeout(() => { + const dropZone = document.querySelector(`[data-task-id="${taskId}"] .drop-zone`); + if (dropZone && (this.draggedTask || this.draggedSubtask)) { + dropZone.classList.add('visible'); + } + }, 1000); + } + async handleDrop(e, targetTask) { e.preventDefault(); + e.stopPropagation(); e.currentTarget.classList.remove('drag-over'); if (this.draggedTask && this.draggedTask.id !== targetTask.id) { - targetTask.addSubtask(this.draggedTask); - this.tasks = this.tasks.filter(t => t.id !== this.draggedTask.id); - + // Move task as subtask + targetTask.subtasks.push(this.draggedTask); try { await fetch(`${API_URL}/tasks/${this.draggedTask.id}`, { method: 'DELETE' }); - await fetch(`${API_URL}/tasks/${targetTask.id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(targetTask) - }); - await this.loadTasks(); + await this.updateTask(targetTask); } catch (error) { console.error('Error moving task:', error); - alert('Failed to move task'); } - } else if (this.draggedSubtask && this.draggedParent) { - if (targetTask.id !== this.draggedParent.id) { - this.draggedParent.removeSubtask(this.draggedSubtask.id); - targetTask.addSubtask(this.draggedSubtask); - - try { - await fetch(`${API_URL}/tasks/${this.draggedParent.id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(this.draggedParent) - }); - await fetch(`${API_URL}/tasks/${targetTask.id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(targetTask) - }); - await this.loadTasks(); - } catch (error) { - console.error('Error moving subtask:', error); - alert('Failed to move subtask'); - } + } 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 { + await this.updateTask(this.draggedParent); + await this.updateTask(targetTask); + } catch (error) { + console.error('Error moving subtask:', error); } } } async handleTaskReorder(e, targetIndex) { e.preventDefault(); + e.stopPropagation(); e.currentTarget.classList.remove('drag-over'); - if (this.draggedTask) { - const currentIndex = this.tasks.findIndex(t => t.id === this.draggedTask.id); - if (currentIndex !== -1 && currentIndex !== targetIndex) { - this.tasks.splice(currentIndex, 1); - this.tasks.splice(targetIndex, 0, this.draggedTask); + if (!this.draggedTask) return; - try { - await fetch(`${API_URL}/tasks/reorder`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(this.tasks.map(t => t.id)) - }); - await this.loadTasks(); - } catch (error) { - console.error('Error reordering tasks:', error); - } - } + const currentIndex = this.tasks.findIndex(t => t.id === this.draggedTask.id); + if (currentIndex === -1 || currentIndex === targetIndex) return; + + this.tasks.splice(currentIndex, 1); + this.tasks.splice(targetIndex, 0, this.draggedTask); + + try { + await fetch(`${API_URL}/tasks/reorder`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(this.tasks.map(t => t.id)) + }); + this.render(); + } catch (error) { + console.error('Error reordering:', error); + await this.loadTasks(); } } + async handleSubtaskReorder(e, parentTask, targetIndex) { + e.preventDefault(); + e.stopPropagation(); + e.currentTarget.classList.remove('drag-over'); + + if (!this.draggedSubtask || !this.draggedParent) return; + 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); + } + renderTask(task, index) { const progress = task.calculateProgress(); const isCompleted = progress === 100 || task.completed; - const taskCard = document.createElement('div'); - taskCard.className = `task-card ${isCompleted ? 'completed' : ''}`; - taskCard.draggable = true; + const card = document.createElement('div'); + card.className = `task-card ${isCompleted ? 'completed' : ''}`; + card.draggable = true; + card.setAttribute('data-task-id', task.id); - taskCard.addEventListener('dragstart', (e) => this.handleDragStart(e, task)); - taskCard.addEventListener('dragend', (e) => this.handleDragEnd(e)); - taskCard.addEventListener('dragover', (e) => this.handleDragOver(e)); - taskCard.addEventListener('dragleave', (e) => this.handleDragLeave(e)); - taskCard.addEventListener('drop', (e) => { - if (this.draggedTask && this.draggedTask.id !== task.id) { + card.addEventListener('dragstart', (e) => this.handleDragStart(e, task, false)); + card.addEventListener('dragend', (e) => this.handleDragEnd(e)); + card.addEventListener('dragover', (e) => this.handleCardDragOver(e)); + card.addEventListener('dragleave', (e) => this.handleDragLeave(e)); + card.addEventListener('dragenter', (e) => this.handleDragEnter(e, task.id)); + card.addEventListener('drop', (e) => { + if (e.target.closest('.drop-zone')) { this.handleDrop(e, task); - } else { + } else if (this.draggedTask && this.draggedTask.id !== task.id) { this.handleTaskReorder(e, index); } }); + card.addEventListener('contextmenu', (e) => { + if (!e.target.closest('.subtask')) { + this.showContextMenu(e, task, 'task'); + } + }); - taskCard.innerHTML = ` + card.innerHTML = `
${this.escapeHtml(task.title)}
${task.size}
- ${task.description ? `
${this.escapeHtml(task.description)}
` : ''} -
Progress @@ -385,88 +528,99 @@ class App {
- ${progress > 20 ? progress + '%' : ''} + ${progress > 15 ? progress + '%' : ''}
- ${task.reward ? `
🎁 Reward: ${isCompleted ? `${this.escapeHtml(task.reward)}` - : `Complete to reveal your reward!` + : `Complete to reveal` }
` : ''} -
-
- ${task.subtasks.length > 0 ? this.renderSubtasks(task) : ''} - -
- đŸ“Ĩ Drop tasks here to create subtasks -
+
Drop tasks here to add as subtasks
`; - return taskCard; + return card; } renderSubtasks(task) { const container = document.createElement('div'); container.className = 'subtasks-container'; - task.subtasks.forEach(subtask => { - const subtaskDiv = document.createElement('div'); - subtaskDiv.className = 'subtask'; - subtaskDiv.draggable = true; + task.subtasks.forEach((subtask, index) => { + const div = document.createElement('div'); + div.className = 'subtask'; + div.draggable = true; + div.setAttribute('data-subtask-id', subtask.id); - subtaskDiv.addEventListener('dragstart', (e) => this.handleDragStart(e, subtask, true, task)); - subtaskDiv.addEventListener('dragend', (e) => this.handleDragEnd(e)); + div.addEventListener('dragstart', (e) => { + e.stopPropagation(); + this.handleDragStart(e, subtask, true, task, index); + }); + + div.addEventListener('dragend', (e) => { + e.stopPropagation(); + this.handleDragEnd(e); + }); + + div.addEventListener('dragover', (e) => { + e.preventDefault(); + e.stopPropagation(); + if (this.draggedSubtask && this.draggedParent && this.draggedParent.id === task.id) { + e.currentTarget.classList.add('drag-over'); + } + }); + + div.addEventListener('dragleave', (e) => { + e.stopPropagation(); + e.currentTarget.classList.remove('drag-over'); + }); + + div.addEventListener('drop', (e) => { + e.stopPropagation(); + this.handleSubtaskReorder(e, task, index); + }); + + div.addEventListener('contextmenu', (e) => { + e.stopPropagation(); + this.showContextMenu(e, subtask, 'subtask', task); + }); const progress = subtask.calculateProgress(); - subtaskDiv.innerHTML = ` + div.innerHTML = `
${this.escapeHtml(subtask.title)}
${subtask.size}
- - ${subtask.description ? `
${this.escapeHtml(subtask.description)}
` : ''} - + ${subtask.description ? `
${this.escapeHtml(subtask.description)}
` : ''}
- -
- - -
- +
${progress}%
${subtask.reward && subtask.completed ? ` -
+
🎁 ${this.escapeHtml(subtask.reward)}
` : ''} - - +
+ +
`; - container.appendChild(subtaskDiv); + container.appendChild(div); }); return container.outerHTML; @@ -479,8 +633,8 @@ class App { if (this.tasks.length === 0) { container.innerHTML = `
-

No goals yet!

-

Click the "+ New Goal" button to create your first learning goal 🚀

+

No goals yet

+

Click "+ New Goal" to get started

`; return; @@ -495,8 +649,8 @@ class App { const container = document.getElementById('tasks-container'); container.innerHTML = `
-

âš ī¸ Connection Error

-

Unable to load your goals. Please check if the server is running.

+

Connection Error

+

Cannot connect to server

`; } diff --git a/public/index.html b/public/index.html index 7f46d49..abf2167 100644 --- a/public/index.html +++ b/public/index.html @@ -7,57 +7,81 @@ - -