diff --git a/public/app.js b/public/app.js index 7299aef..8bb7f2a 100644 --- a/public/app.js +++ b/public/app.js @@ -276,11 +276,24 @@ class App { if (!parentTask) { this.hideContextMenu(); return; } const subtask = this.contextMenuTarget; parentTask.subtasks = parentTask.subtasks.filter(st => st.id !== subtask.id); + + // Create new task locally + const newTask = new Task(Date.now().toString(), subtask.title, subtask.description, subtask.size, subtask.reward); + newTask.dueDate = subtask.dueDate; + newTask.color = subtask.color; + newTask.completed = subtask.completed; + this.tasks.push(newTask); + + this._syncToServer(parentTask); try { - await fetch(`${API_URL}/tasks/${parentTask.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(parentTask) }); - 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, dueDate: subtask.dueDate, color: subtask.color }) }); - await this.loadTasks(); + 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, dueDate: subtask.dueDate, color: subtask.color }) + }); } catch (error) { console.error('Error promoting subtask:', error); } + + this.updateOverallProgress(); + this.render(); this.hideContextMenu(); } @@ -318,23 +331,32 @@ class App { const subtask = parentTask.subtasks.find(st => st.id === editId); if (subtask) { Object.assign(subtask, { title, description, size, reward, expandDescription, dueDate, color }); - await this.updateTask(parentTask); + this._syncToServer(parentTask); } } } else { const task = taskList.find(t => t.id === editId); if (task) { Object.assign(task, { title, description, size, reward, expandDescription, dueDate, color }); - await this.updateTask(task); + this._syncToServer(task); } } + this.updateOverallProgress(); + if (this.viewMode === 'archive') this.renderArchive(); + else this.render(); } else { try { const response = await fetch(`${API_URL}/tasks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, description, size, reward, expandDescription, dueDate, color }) }); - if (response.ok) await this.loadTasks(); + if (response.ok) { + const newTaskData = await response.json(); + const newTask = this._mapTaskData(newTaskData); + this.tasks.push(newTask); + this.updateOverallProgress(); + this.render(); + } } catch (error) { console.error('Error creating task:', error); } } @@ -369,14 +391,12 @@ class App { async loadTasks() { try { - const prevProgress = this._previousOverallProgress; const response = await fetch(`${API_URL}/tasks`); const data = await response.json(); this.tasks = data.map(d => this._mapTaskData(d)); this.updateOverallProgress(); if (this.viewMode === 'active') this.render(); this.loadArchivedCount(); - if (this._initialized) this._checkCelebrations(prevProgress); this._initialized = true; } catch (error) { console.error('Error loading tasks:', error); @@ -441,72 +461,111 @@ class App { document.getElementById('overall-progress-fill').style.width = overallProgress + '%'; } - // ─── Task Actions ─── + // ─── Server Sync (fire-and-forget) ─── - async deleteTask(taskId) { - try { - const response = await fetch(`${API_URL}/tasks/${taskId}`, { method: 'DELETE' }); - if (response.ok) { - if (this.viewMode === 'archive') await this.loadArchivedTasks(); - else await this.loadTasks(); - } - } catch (error) { console.error('Error deleting task:', error); } + _syncToServer(task) { + fetch(`${API_URL}/tasks/${task.id}`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(task) + }).catch(err => console.error('Sync error:', err)); } - async toggleTaskComplete(taskId) { + _syncDelete(taskId) { + fetch(`${API_URL}/tasks/${taskId}`, { method: 'DELETE' }) + .catch(err => console.error('Sync error:', err)); + } + + _syncReorder() { + fetch(`${API_URL}/tasks/reorder`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(this.tasks.map(t => t.id)) + }).catch(err => console.error('Sync error:', err)); + } + + // ─── Task Actions (optimistic) ─── + + deleteTask(taskId) { + if (this.viewMode === 'archive') { + this.archivedTasks = this.archivedTasks.filter(t => t.id !== taskId); + this._syncDelete(taskId); + this.renderArchive(); + this.updateArchiveCount(); + } else { + this.tasks = this.tasks.filter(t => t.id !== taskId); + this._syncDelete(taskId); + this.updateOverallProgress(); + this.render(); + } + } + + toggleTaskComplete(taskId) { const task = this.getCurrentTasks().find(t => t.id === taskId); if (!task) return; + const prevProgress = this._previousOverallProgress; const wasCompleted = task.completed || task.calculateProgress() === 100; task.completed = !task.completed; if (task.subtasks.length > 0) task.subtasks.forEach(st => st.completed = task.completed); const isNowCompleted = task.completed || task.calculateProgress() === 100; if (!wasCompleted && isNowCompleted && task.reward) this._celebrateTaskId = taskId; - await this.updateTask(task); + + this.updateOverallProgress(); + this.render(); + this._checkCelebrations(prevProgress); + this._syncToServer(task); } - async toggleSubtaskComplete(taskId, subtaskId) { + toggleSubtaskComplete(taskId, subtaskId) { const task = this.getCurrentTasks().find(t => t.id === taskId); if (!task) return; const subtask = task.subtasks.find(st => st.id === subtaskId); if (!subtask) return; + const prevProgress = this._previousOverallProgress; const wasSubCompleted = subtask.completed; subtask.completed = !subtask.completed; if (!wasSubCompleted && subtask.completed && subtask.reward) this._celebrateTaskId = taskId; const parentWillComplete = task.calculateProgress() === 100; if (parentWillComplete && task.reward && !task.completed) this._celebrateTaskId = taskId; - await this.updateTask(task); + + this.updateOverallProgress(); + this.render(); + this._checkCelebrations(prevProgress); + this._syncToServer(task); } - async deleteSubtask(taskId, subtaskId) { + deleteSubtask(taskId, subtaskId) { const task = this.getCurrentTasks().find(t => t.id === taskId); if (!task) return; task.subtasks = task.subtasks.filter(st => st.id !== subtaskId); - await this.updateTask(task); - } - - async updateTask(task) { - try { - const response = await fetch(`${API_URL}/tasks/${task.id}`, { - method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(task) - }); - if (response.ok) { - if (this.viewMode === 'archive') await this.loadArchivedTasks(); - else await this.loadTasks(); - } - } catch (error) { console.error('Error updating task:', error); } + this.updateOverallProgress(); + this.render(); + this._syncToServer(task); } async archiveTask(taskId) { + // Move from active to archived locally + const taskIndex = this.tasks.findIndex(t => t.id === taskId); + if (taskIndex === -1) return; + const [task] = this.tasks.splice(taskIndex, 1); + this.archivedTasks.push(task); + this.updateOverallProgress(); + this.render(); + this.updateArchiveCount(); try { - const response = await fetch(`${API_URL}/tasks/${taskId}/archive`, { method: 'PUT' }); - if (response.ok) { await this.loadTasks(); await this.loadArchivedCount(); } + await fetch(`${API_URL}/tasks/${taskId}/archive`, { method: 'PUT' }); } catch (error) { console.error('Error archiving task:', error); } } async unarchiveTask(taskId) { + // Move from archived back to active + const taskIndex = this.archivedTasks.findIndex(t => t.id === taskId); + if (taskIndex === -1) return; + const [task] = this.archivedTasks.splice(taskIndex, 1); + this.tasks.push(task); + this.renderArchive(); + this.updateArchiveCount(); + // Also update active progress in background + this.updateOverallProgress(); try { - const response = await fetch(`${API_URL}/tasks/${taskId}/unarchive`, { method: 'PUT' }); - if (response.ok) { await this.loadArchivedTasks(); await this.loadTasks(); } + await fetch(`${API_URL}/tasks/${taskId}/unarchive`, { method: 'PUT' }); } catch (error) { console.error('Error restoring task:', error); } } @@ -563,13 +622,18 @@ class App { e.preventDefault(); e.stopPropagation(); e.currentTarget.classList.remove('drag-over'); if (this.draggedTask && this.draggedTask.id !== targetTask.id) { targetTask.subtasks.push(this.draggedTask); - try { await fetch(`${API_URL}/tasks/${this.draggedTask.id}`, { method: 'DELETE' }); await this.updateTask(targetTask); } - catch (error) { console.error('Error moving task:', error); } + this.tasks = this.tasks.filter(t => t.id !== this.draggedTask.id); + this._syncDelete(this.draggedTask.id); + this._syncToServer(targetTask); + this.updateOverallProgress(); + this.render(); } else if (this.draggedSubtask && this.draggedParent && targetTask.id !== this.draggedParent.id) { 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); } + this._syncToServer(this.draggedParent); + this._syncToServer(targetTask); + this.updateOverallProgress(); + this.render(); } } @@ -578,22 +642,53 @@ class App { if (!this.draggedTask) return; const currentIndex = this.tasks.findIndex(t => t.id === this.draggedTask.id); if (currentIndex === -1 || currentIndex === targetIndex) return; + + // Update data 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(); } + + // Swap DOM nodes directly + const container = document.getElementById('tasks-container'); + const cards = [...container.children]; + const draggedCard = cards[currentIndex]; + if (draggedCard) { + draggedCard.remove(); + const referenceCard = container.children[targetIndex]; + if (referenceCard) container.insertBefore(draggedCard, referenceCard); + else container.appendChild(draggedCard); + } + + // Sync to server in background + this._syncReorder(); } async handleSubtaskReorder(e, parentTask, targetIndex) { e.preventDefault(); e.stopPropagation(); e.currentTarget.classList.remove('drag-over'); if (!this.draggedSubtask || !this.draggedParent || this.draggedParent.id !== parentTask.id) return; if (this.draggedSubtaskIndex === targetIndex) return; + + // Update data const subtask = parentTask.subtasks[this.draggedSubtaskIndex]; parentTask.subtasks.splice(this.draggedSubtaskIndex, 1); parentTask.subtasks.splice(targetIndex, 0, subtask); - await this.updateTask(parentTask); + + // Swap subtask DOM nodes + const card = document.querySelector(`[data-task-id="${parentTask.id}"]`); + if (card) { + const subtaskContainer = card.querySelector('.subtasks-container'); + if (subtaskContainer) { + const subtaskNodes = [...subtaskContainer.children]; + const draggedNode = subtaskNodes[this.draggedSubtaskIndex]; + if (draggedNode) { + draggedNode.remove(); + const ref = subtaskContainer.children[targetIndex]; + if (ref) subtaskContainer.insertBefore(draggedNode, ref); + else subtaskContainer.appendChild(draggedNode); + } + } + } + + this._syncToServer(parentTask); } // ─── Render: Active ─── @@ -735,12 +830,13 @@ class App { render() { const container = document.getElementById('tasks-container'); - container.innerHTML = ''; if (this.tasks.length === 0) { container.innerHTML = `

No goals yet

Click "+ New Goal" to get started

`; return; } - this.tasks.forEach((task, index) => container.appendChild(this.renderTask(task, index))); + const fragment = document.createDocumentFragment(); + this.tasks.forEach((task, index) => fragment.appendChild(this.renderTask(task, index))); + container.replaceChildren(fragment); } // ─── Render: Archive ─── @@ -830,12 +926,13 @@ class App { renderArchive() { const container = document.getElementById('tasks-container'); - container.innerHTML = ''; if (this.archivedTasks.length === 0) { container.innerHTML = `

Archive is empty

Completed tasks you archive will appear here

`; return; } - this.archivedTasks.forEach(task => container.appendChild(this.renderArchivedTask(task))); + const fragment = document.createDocumentFragment(); + this.archivedTasks.forEach(task => fragment.appendChild(this.renderArchivedTask(task))); + container.replaceChildren(fragment); } // ─── Celebrations ─── diff --git a/public/styles.css b/public/styles.css index b47743c..21e281a 100644 --- a/public/styles.css +++ b/public/styles.css @@ -9,8 +9,8 @@ --card-border: rgba(255, 255, 255, 0.45); --card-hover: rgba(255, 255, 255, 0.7); --card-shadow: 0 2px 12px rgba(60, 60, 120, 0.1); - --subtask-bg: rgba(255, 255, 255, 0.25); - --subtask-border: rgba(255, 255, 255, 0.2); + --subtask-bg: rgba(255, 255, 255, 0.14); + --subtask-border: rgba(255, 255, 255, 0.12); --text-primary: #1e293b; --text-secondary: #475569; @@ -491,7 +491,6 @@ button { border: 1px solid var(--card-border); border-left: 4px solid var(--accent); position: relative; - animation: cardIn 0.25s ease; } .task-card:hover { @@ -728,8 +727,8 @@ button { .subtask:last-child { margin-bottom: 0; } .subtask:hover { - background: rgba(255, 255, 255, 0.4); - border-color: rgba(99, 102, 241, 0.25); + background: rgba(255, 255, 255, 0.28); + border-color: rgba(99, 102, 241, 0.2); } .subtask:active { cursor: grabbing; }