const API_URL = '/api'; class Task { constructor(id, title, description, size, reward) { this.id = id; this.title = title; this.description = description; this.size = size; this.reward = reward; this.completed = false; this.expandDescription = false; this.dueDate = null; this.color = '#6366f1'; this.subtasks = []; } getProgressWeight() { const weights = { small: 10, medium: 25, large: 50 }; return weights[this.size] || 25; } calculateProgress() { if (this.subtasks.length === 0) return this.completed ? 100 : 0; const totalWeight = this.subtasks.reduce((sum, st) => sum + st.getProgressWeight(), 0); const completedWeight = this.subtasks.reduce((sum, st) => sum + (st.completed ? st.getProgressWeight() : 0), 0); return totalWeight > 0 ? Math.round((completedWeight / totalWeight) * 100) : 0; } } class App { constructor() { this.tasks = []; this.archivedTasks = []; this.viewMode = 'active'; 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._previousOverallProgress = 0; this._celebrateTaskId = null; this._initialized = false; this._collapsedTasks = new Set(JSON.parse(localStorage.getItem('collapsedTasks') || '[]')); this.init(); } async init() { this.setupGreeting(); this.setupEventListeners(); this.setupContainerDrag(); await this.loadTasks(); await this.loadArchivedCount(); } // ─── Greeting ─── setupGreeting() { const hour = new Date().getHours(); let emoji, greeting, sub; if (hour >= 5 && hour < 12) { emoji = '☀️'; greeting = 'Good morning, Marc'; sub = 'Ready to learn something new today?'; } else if (hour >= 12 && hour < 17) { emoji = '🌞'; greeting = 'Good afternoon, Marc'; sub = 'Keep up the momentum!'; } else if (hour >= 17 && hour < 21) { emoji = '🌇'; greeting = 'Good evening, Marc'; sub = 'Great time to wrap up some goals.'; } else { emoji = '🌙'; greeting = 'Hey there, Marc'; sub = 'Burning the midnight oil?'; } document.getElementById('greeting-banner').innerHTML = `
${emoji} ${greeting}
${sub}
`; } setupEventListeners() { const form = document.getElementById('task-form'); 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)); 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.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()); document.getElementById('view-active').addEventListener('click', () => this.switchView('active')); document.getElementById('view-archive').addEventListener('click', () => this.switchView('archive')); // Color preset dots document.getElementById('color-presets').addEventListener('click', (e) => { const dot = e.target.closest('.color-dot'); if (!dot) return; const color = dot.dataset.color; document.getElementById('task-color').value = color; this._updateColorDotActive(color); }); document.getElementById('task-color').addEventListener('input', (e) => { this._updateColorDotActive(e.target.value); }); } _updateColorDotActive(color) { document.querySelectorAll('#color-presets .color-dot').forEach(d => { d.classList.toggle('active', d.dataset.color === color); }); } // ─── Due Date Helpers ─── getDueDateInfo(dueDate) { if (!dueDate) return null; const now = new Date(); now.setHours(0, 0, 0, 0); const due = new Date(dueDate + 'T00:00:00'); const diffMs = due - now; const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); const opts = { month: 'short', day: 'numeric' }; if (due.getFullYear() !== now.getFullYear()) opts.year = 'numeric'; const label = due.toLocaleDateString(undefined, opts); if (diffDays < 0) return { label, className: 'due-overdue', text: `${label} (overdue)` }; if (diffDays === 0) return { label, className: 'due-overdue', text: `${label} (today)` }; if (diffDays <= 7) return { label, className: 'due-soon', text: `${label} (${diffDays}d)` }; return { label, className: 'due-ok', text: `${label} (${diffDays}d)` }; } renderDueBadge(dueDate, isCompleted) { if (!dueDate) return ''; if (isCompleted) { const info = this.getDueDateInfo(dueDate); return `${info.label}`; } const info = this.getDueDateInfo(dueDate); return `${info.text}`; } // ─── Animated Counter ─── animateCounter(element, from, to, suffix = '%', duration = 400) { if (from === to) { element.textContent = to + suffix; return; } const start = performance.now(); const step = (now) => { const elapsed = now - start; const progress = Math.min(elapsed / duration, 1); // ease out quad const eased = 1 - (1 - progress) * (1 - progress); const current = Math.round(from + (to - from) * eased); element.textContent = current + suffix; if (progress < 1) requestAnimationFrame(step); }; requestAnimationFrame(step); } // ─── 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'); 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; document.getElementById('task-due-date').value = ''; document.getElementById('task-color').value = '#6366f1'; this._updateColorDotActive('#6366f1'); 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 || ''; document.getElementById('task-expand-desc').checked = !!task.expandDescription; document.getElementById('task-due-date').value = task.dueDate || ''; document.getElementById('task-color').value = task.color || '#6366f1'; this._updateColorDotActive(task.color || '#6366f1'); this.toggleModal(true); } toggleModal(open) { const modal = document.getElementById('modal'); 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'); this.contextMenuTarget = target; this.contextMenuType = type; this.contextMenuParent = parentTask; document.getElementById('context-promote').style.display = (type === 'subtask' && this.viewMode === 'active') ? 'flex' : 'none'; const isCompleted = target.completed || target.calculateProgress() === 100; document.getElementById('context-archive').style.display = (type === 'task' && this.viewMode === 'active' && isCompleted) ? 'flex' : 'none'; document.getElementById('context-restore').style.display = (type === 'task' && this.viewMode === 'archive') ? 'flex' : 'none'; let x = e.clientX, y = e.clientY; contextMenu.style.left = '-9999px'; contextMenu.style.top = '-9999px'; contextMenu.classList.add('show'); if (x + contextMenu.offsetWidth > window.innerWidth) x = window.innerWidth - contextMenu.offsetWidth - 5; if (y + contextMenu.offsetHeight > window.innerHeight) y = window.innerHeight - contextMenu.offsetHeight - 5; if (x < 0) x = 5; if (y < 0) y = 5; contextMenu.style.left = x + 'px'; contextMenu.style.top = y + 'px'; } hideContextMenu() { document.getElementById('context-menu').classList.remove('show'); } // ─── Confirm Dialog ─── showConfirm(message, icon = '🗑') { return new Promise((resolve) => { const overlay = document.getElementById('confirm-overlay'); document.getElementById('confirm-message').innerHTML = message; document.getElementById('confirm-icon').innerHTML = icon; overlay.classList.add('open'); const cancel = document.getElementById('confirm-cancel'); const del = document.getElementById('confirm-delete'); const cleanup = () => { overlay.classList.remove('open'); cancel.replaceWith(cancel.cloneNode(true)); del.replaceWith(del.cloneNode(true)); }; document.getElementById('confirm-cancel').addEventListener('click', () => { cleanup(); resolve(false); }); document.getElementById('confirm-delete').addEventListener('click', () => { cleanup(); resolve(true); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) { cleanup(); resolve(false); } }, { once: true }); }); } handleContextEdit() { if (this.contextMenuTarget) this.openEditModal(this.contextMenuTarget, this.contextMenuParent); this.hideContextMenu(); } async handleContextDelete() { if (!this.contextMenuTarget) return; const type = this.contextMenuType; this.hideContextMenu(); const confirmed = await this.showConfirm( `Delete this ${type}?
This action cannot be undone.` ); if (!confirmed) return; if (type === 'task') this.deleteTask(this.contextMenuTarget.id); else if (type === 'subtask' && this.contextMenuParent) this.deleteSubtask(this.contextMenuParent.id, this.contextMenuTarget.id); } async handleContextPromote() { if (!this.contextMenuTarget || this.contextMenuType !== 'subtask' || !this.contextMenuParent) { this.hideContextMenu(); return; } const parentTask = this.getCurrentTasks().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); // 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`, { 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(); } 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 ─── async handleSubmit(e) { e.preventDefault(); 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.trim(); const expandDescription = document.getElementById('task-expand-desc').checked; const dueDate = document.getElementById('task-due-date').value || null; const color = document.getElementById('task-color').value || '#6366f1'; const editId = document.getElementById('edit-task-id').value; const parentId = document.getElementById('edit-parent-id').value; const taskList = this.getCurrentTasks(); if (editId) { if (parentId) { const parentTask = taskList.find(t => t.id === parentId); if (parentTask) { const subtask = parentTask.subtasks.find(st => st.id === editId); if (subtask) { Object.assign(subtask, { title, description, size, reward, expandDescription, dueDate, color }); this._syncToServer(parentTask); } } } else { const task = taskList.find(t => t.id === editId); if (task) { Object.assign(task, { title, description, size, reward, expandDescription, dueDate, color }); 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) { 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); } } e.target.reset(); document.getElementById('task-color').value = '#6366f1'; this._updateColorDotActive('#6366f1'); this.toggleModal(false); } // ─── Data ─── getCurrentTasks() { return this.viewMode === 'archive' ? this.archivedTasks : this.tasks; } _mapTaskData(taskData) { const task = new Task(taskData.id, taskData.title, taskData.description, taskData.size, taskData.reward); task.completed = taskData.completed; task.expandDescription = !!taskData.expandDescription; task.dueDate = taskData.dueDate || null; task.color = taskData.color || '#6366f1'; if (taskData.subtasks && Array.isArray(taskData.subtasks)) { task.subtasks = taskData.subtasks.map(stData => { const st = new Task(stData.id, stData.title, stData.description, stData.size, stData.reward); st.completed = stData.completed; st.expandDescription = !!stData.expandDescription; st.dueDate = stData.dueDate || null; st.color = stData.color || '#6366f1'; return st; }); } return task; } async loadTasks() { try { 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(); this._initialized = true; } 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(d => this._mapTaskData(d)); 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(d => this._mapTaskData(d)); this.updateArchiveCount(); } catch (error) { /* non-critical */ } } updateArchiveCount() { const badge = document.getElementById('archive-count'); badge.textContent = this.archivedTasks.length; badge.style.display = this.archivedTasks.length > 0 ? 'inline-block' : 'none'; } updateOverallProgress() { const totalTasks = this.tasks.length; const completedTasks = this.tasks.filter(t => t.calculateProgress() === 100).length; // Animate completed count const completedEl = document.getElementById('completed-count'); const prevCompleted = parseInt(completedEl.textContent) || 0; if (prevCompleted !== completedTasks) this.animateCounter(completedEl, prevCompleted, completedTasks, ''); else completedEl.textContent = completedTasks; document.getElementById('total-count').textContent = totalTasks; if (totalTasks === 0) { const pctEl = document.getElementById('overall-percentage'); pctEl.textContent = '0%'; document.getElementById('overall-progress-fill').style.width = '0%'; this._previousOverallProgress = 0; return; } const totalWeight = this.tasks.reduce((sum, t) => sum + t.getProgressWeight(), 0); const completedWeight = this.tasks.reduce((sum, t) => sum + (t.getProgressWeight() * t.calculateProgress() / 100), 0); const overallProgress = totalWeight > 0 ? Math.round((completedWeight / totalWeight) * 100) : 0; // Animate percentage const pctEl = document.getElementById('overall-percentage'); const prevPct = this._previousOverallProgress; this.animateCounter(pctEl, prevPct, overallProgress, '%'); this._previousOverallProgress = overallProgress; document.getElementById('overall-progress-fill').style.width = overallProgress + '%'; } // ─── Server Sync (fire-and-forget) ─── _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)); } _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(); } } async confirmDeleteTask(taskId) { const confirmed = await this.showConfirm( 'Delete permanently?
This action cannot be undone.' ); if (confirmed) this.deleteTask(taskId); } 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; this.updateOverallProgress(); this.render(); this._checkCelebrations(prevProgress); this._syncToServer(task); } 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; this.updateOverallProgress(); this.render(); this._checkCelebrations(prevProgress); this._syncToServer(task); } deleteSubtask(taskId, subtaskId) { const task = this.getCurrentTasks().find(t => t.id === taskId); if (!task) return; task.subtasks = task.subtasks.filter(st => st.id !== subtaskId); 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 { 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 { await fetch(`${API_URL}/tasks/${taskId}/unarchive`, { method: 'PUT' }); } catch (error) { console.error('Error restoring task:', error); } } // ─── Collapse / Expand ─── toggleCollapse(taskId) { if (this._collapsedTasks.has(taskId)) this._collapsedTasks.delete(taskId); else this._collapsedTasks.add(taskId); localStorage.setItem('collapsedTasks', JSON.stringify([...this._collapsedTasks])); // Toggle inline without full re-render const card = document.querySelector(`[data-task-id="${taskId}"]`); if (!card) return; const container = card.querySelector('.subtasks-container'); const toggle = card.querySelector('.subtask-toggle'); if (container) container.classList.toggle('collapsed'); if (toggle) { toggle.classList.toggle('collapsed'); const count = toggle.dataset.count; const isCollapsed = toggle.classList.contains('collapsed'); toggle.querySelector('.toggle-label').textContent = isCollapsed ? `Show ${count} subtask${count > 1 ? 's' : ''}` : 'Hide subtasks'; } } // ─── Drag & Drop (insertion indicator system) ─── _createDropIndicator() { const el = document.createElement('div'); el.className = 'drop-indicator'; el.style.display = 'none'; document.body.appendChild(el); return el; } _showDropIndicator(container, y) { if (!this._dropIndicator) this._dropIndicator = this._createDropIndicator(); const cards = [...container.querySelectorAll(':scope > .task-card')]; let insertIndex = cards.length; let indicatorY = 0; for (let i = 0; i < cards.length; i++) { const rect = cards[i].getBoundingClientRect(); const midY = rect.top + rect.height / 2; if (y < midY) { insertIndex = i; indicatorY = rect.top - 4; break; } } // If after all cards if (insertIndex === cards.length && cards.length > 0) { const lastRect = cards[cards.length - 1].getBoundingClientRect(); indicatorY = lastRect.bottom + 4; } else if (cards.length === 0) { const containerRect = container.getBoundingClientRect(); indicatorY = containerRect.top + 8; } const containerRect = container.getBoundingClientRect(); this._dropIndicator.style.display = 'block'; this._dropIndicator.style.top = indicatorY + 'px'; this._dropIndicator.style.left = containerRect.left + 'px'; this._dropIndicator.style.width = containerRect.width + 'px'; this._dropInsertIndex = insertIndex; } _hideDropIndicator() { if (this._dropIndicator) this._dropIndicator.style.display = 'none'; this._dropInsertIndex = null; } _showSubtaskDropIndicator(subtaskContainer, y) { if (!this._subtaskDropIndicator) { this._subtaskDropIndicator = document.createElement('div'); this._subtaskDropIndicator.className = 'drop-indicator drop-indicator-subtask'; this._subtaskDropIndicator.style.display = 'none'; document.body.appendChild(this._subtaskDropIndicator); } const subtasks = [...subtaskContainer.querySelectorAll(':scope > .subtask')]; let insertIndex = subtasks.length; let indicatorY = 0; for (let i = 0; i < subtasks.length; i++) { const rect = subtasks[i].getBoundingClientRect(); if (y < rect.top + rect.height / 2) { insertIndex = i; indicatorY = rect.top - 2; break; } } if (insertIndex === subtasks.length && subtasks.length > 0) { const lastRect = subtasks[subtasks.length - 1].getBoundingClientRect(); indicatorY = lastRect.bottom + 2; } const cRect = subtaskContainer.getBoundingClientRect(); this._subtaskDropIndicator.style.display = 'block'; this._subtaskDropIndicator.style.top = indicatorY + 'px'; this._subtaskDropIndicator.style.left = cRect.left + 'px'; this._subtaskDropIndicator.style.width = cRect.width + 'px'; this._subtaskDropInsertIndex = insertIndex; } _hideSubtaskDropIndicator() { if (this._subtaskDropIndicator) this._subtaskDropIndicator.style.display = 'none'; this._subtaskDropInsertIndex = null; } setupContainerDrag() { const container = document.getElementById('tasks-container'); container.addEventListener('dragover', (e) => { e.preventDefault(); if (!this.draggedTask) return; this._showDropIndicator(container, e.clientY); }); container.addEventListener('dragleave', (e) => { // Only hide if leaving the container entirely if (!container.contains(e.relatedTarget)) { this._hideDropIndicator(); } }); container.addEventListener('drop', (e) => { e.preventDefault(); // Handle nesting via drop zone const dropZone = e.target.closest('.drop-zone'); if (dropZone) { const taskCard = dropZone.closest('.task-card'); if (taskCard) { const taskId = taskCard.getAttribute('data-task-id'); const targetTask = this.tasks.find(t => t.id === taskId); if (targetTask) this._handleNestDrop(targetTask); } this._hideDropIndicator(); return; } // Handle reorder via indicator if (this.draggedTask && this._dropInsertIndex != null) { const currentIndex = this.tasks.findIndex(t => t.id === this.draggedTask.id); let targetIndex = this._dropInsertIndex; // Adjust index since we're removing before inserting if (currentIndex !== -1 && currentIndex < targetIndex) targetIndex--; if (currentIndex !== -1 && currentIndex !== targetIndex) { this.tasks.splice(currentIndex, 1); this.tasks.splice(targetIndex, 0, this.draggedTask); this._syncReorder(); this.render(); } } this._hideDropIndicator(); }); } handleDragStart(e, item, isSubtask = false, parentTask = null, index = null) { if (isSubtask) { this.draggedSubtask = item; this.draggedParent = parentTask; this.draggedSubtaskIndex = index; this.draggedTask = null; } else { this.draggedTask = item; this.draggedSubtask = null; this.draggedParent = null; this.draggedSubtaskIndex = null; } e.currentTarget.classList.add('dragging'); // Delay to let browser capture drag image requestAnimationFrame(() => e.currentTarget.style.opacity = '0.4'); } handleDragEnd(e) { e.currentTarget.classList.remove('dragging'); e.currentTarget.style.opacity = ''; document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over')); document.querySelectorAll('.drop-zone').forEach(el => el.classList.remove('visible')); this._hideDropIndicator(); this._hideSubtaskDropIndicator(); if (this.dropZoneTimer) { clearTimeout(this.dropZoneTimer); this.dropZoneTimer = null; } this.draggedTask = null; this.draggedSubtask = null; this.draggedParent = null; this.draggedSubtaskIndex = null; } handleDragEnterCard(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); } _handleNestDrop(targetTask) { if (this.draggedTask && this.draggedTask.id !== targetTask.id) { targetTask.subtasks.push(this.draggedTask); 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); this._syncToServer(this.draggedParent); this._syncToServer(targetTask); this.updateOverallProgress(); this.render(); } } handleSubtaskContainerDragOver(e, task) { e.preventDefault(); e.stopPropagation(); if (!this.draggedSubtask || !this.draggedParent || this.draggedParent.id !== task.id) return; const subtaskContainer = e.currentTarget; this._showSubtaskDropIndicator(subtaskContainer, e.clientY); } handleSubtaskContainerDrop(e, task) { e.preventDefault(); e.stopPropagation(); if (!this.draggedSubtask || !this.draggedParent || this.draggedParent.id !== task.id) { this._hideSubtaskDropIndicator(); return; } if (this._subtaskDropInsertIndex == null) { this._hideSubtaskDropIndicator(); return; } let targetIndex = this._subtaskDropInsertIndex; const currentIndex = this.draggedSubtaskIndex; if (currentIndex < targetIndex) targetIndex--; if (currentIndex !== targetIndex) { const subtask = task.subtasks[currentIndex]; task.subtasks.splice(currentIndex, 1); task.subtasks.splice(targetIndex, 0, subtask); this._syncToServer(task); this.render(); } this._hideSubtaskDropIndicator(); } // ─── Render: Active ─── renderTask(task, index) { const progress = task.calculateProgress(); const isCompleted = progress === 100 || task.completed; const card = document.createElement('div'); card.className = `task-card ${isCompleted ? 'completed' : ''}`; card.draggable = true; card.setAttribute('data-task-id', task.id); card.style.borderLeftColor = task.color || '#6366f1'; card.addEventListener('dragstart', (e) => { if (!e.target.closest('.subtask')) this.handleDragStart(e, task, false); }); card.addEventListener('dragend', (e) => this.handleDragEnd(e)); card.addEventListener('dragenter', (e) => this.handleDragEnterCard(e, task.id)); card.addEventListener('contextmenu', (e) => { if (!e.target.closest('.subtask')) 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 = `
${this.escapeHtml(task.title)}
${this.renderDueBadge(task.dueDate, isCompleted)}
${task.size}
${task.description ? `
${this.escapeHtml(task.description)}
` : ''}
${progress}%
${task.reward ? `
🎁 ${isCompleted ? `${this.escapeHtml(task.reward)}` : `Complete to reveal` }
` : ''}
${isCompleted ? `` : ''}
`; // Subtasks with collapse toggle if (task.subtasks.length > 0) { const isCollapsed = this._collapsedTasks.has(task.id); const count = task.subtasks.length; const toggleBtn = document.createElement('button'); toggleBtn.className = `subtask-toggle ${isCollapsed ? 'collapsed' : ''}`; toggleBtn.dataset.count = count; toggleBtn.innerHTML = `▼ ${isCollapsed ? `Show ${count} subtask${count > 1 ? 's' : ''}` : 'Hide subtasks'}`; toggleBtn.addEventListener('click', (e) => { e.stopPropagation(); this.toggleCollapse(task.id); }); card.appendChild(toggleBtn); const subtasksEl = this.renderSubtasks(task); if (isCollapsed) subtasksEl.classList.add('collapsed'); card.appendChild(subtasksEl); } const dropZone = document.createElement('div'); dropZone.className = 'drop-zone'; dropZone.textContent = 'Drop tasks here to add as subtasks'; card.appendChild(dropZone); return card; } renderSubtasks(task) { const container = document.createElement('div'); container.className = 'subtasks-container'; // Container-level drag events for subtask reorder container.addEventListener('dragover', (e) => this.handleSubtaskContainerDragOver(e, task)); container.addEventListener('drop', (e) => this.handleSubtaskContainerDrop(e, task)); container.addEventListener('dragleave', (e) => { if (!container.contains(e.relatedTarget)) this._hideSubtaskDropIndicator(); }); task.subtasks.forEach((subtask, index) => { const div = document.createElement('div'); div.className = 'subtask'; div.draggable = true; div.setAttribute('data-subtask-id', subtask.id); div.addEventListener('dragstart', (e) => { e.stopPropagation(); this.handleDragStart(e, subtask, true, task, index); }); div.addEventListener('dragend', (e) => { e.stopPropagation(); this.handleDragEnd(e); }); div.addEventListener('contextmenu', (e) => { e.stopPropagation(); this.showContextMenu(e, subtask, 'subtask', task); }); div.addEventListener('dblclick', (e) => { e.stopPropagation(); if (!e.target.closest('button')) this.openEditModal(subtask, task); }); const progress = subtask.calculateProgress(); const stIsCompleted = subtask.completed || progress === 100; const stDescClass = subtask.expandDescription ? 'task-description expanded' : 'task-description'; div.innerHTML = `
${this.escapeHtml(subtask.title)}
${this.renderDueBadge(subtask.dueDate, stIsCompleted)}
${subtask.size}
${subtask.description ? `
${this.escapeHtml(subtask.description)}
` : ''}
${progress}%
${subtask.reward ? `
🎁 ${stIsCompleted ? `${this.escapeHtml(subtask.reward)}` : `Complete to reveal` }
` : ''}
`; container.appendChild(div); }); return container; } render() { const container = document.getElementById('tasks-container'); if (this.tasks.length === 0) { container.innerHTML = `

No goals yet

Click "+ New Goal" to get started

`; return; } const fragment = document.createDocumentFragment(); this.tasks.forEach((task, index) => fragment.appendChild(this.renderTask(task, index))); container.replaceChildren(fragment); } // ─── Render: Archive ─── renderArchivedTask(task) { const progress = task.calculateProgress(); const isCompleted = progress === 100 || task.completed; const card = document.createElement('div'); card.className = `task-card archived-card ${isCompleted ? 'completed' : ''}`; card.setAttribute('data-task-id', task.id); card.style.borderLeftColor = task.color || '#6366f1'; card.addEventListener('contextmenu', (e) => { if (!e.target.closest('.subtask')) 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 = `
${this.escapeHtml(task.title)}
${this.renderDueBadge(task.dueDate, isCompleted)}
${task.size}
${task.description ? `
${this.escapeHtml(task.description)}
` : ''}
${progress}%
${task.reward ? `
🎁 ${isCompleted ? `${this.escapeHtml(task.reward)}` : `Complete to reveal` }
` : ''}
`; if (task.subtasks.length > 0) card.appendChild(this.renderArchivedSubtasks(task)); return card; } renderArchivedSubtasks(task) { const container = document.createElement('div'); container.className = 'subtasks-container'; task.subtasks.forEach((subtask) => { const div = document.createElement('div'); div.className = 'subtask'; div.style.cursor = 'default'; div.addEventListener('contextmenu', (e) => { e.stopPropagation(); this.showContextMenu(e, subtask, 'subtask', task); }); div.addEventListener('dblclick', (e) => { e.stopPropagation(); if (!e.target.closest('button')) this.openEditModal(subtask, task); }); const progress = subtask.calculateProgress(); const stIsCompleted = subtask.completed || progress === 100; const stDescClass = subtask.expandDescription ? 'task-description expanded' : 'task-description'; div.innerHTML = `
${this.escapeHtml(subtask.title)}
${this.renderDueBadge(subtask.dueDate, stIsCompleted)}
${subtask.size}
${subtask.description ? `
${this.escapeHtml(subtask.description)}
` : ''}
${progress}%
${subtask.reward ? `
🎁 ${stIsCompleted ? `${this.escapeHtml(subtask.reward)}` : `Complete to reveal` }
` : ''} `; container.appendChild(div); }); return container; } renderArchive() { const container = document.getElementById('tasks-container'); if (this.archivedTasks.length === 0) { container.innerHTML = `

Archive is empty

Completed tasks you archive will appear here

`; return; } const fragment = document.createDocumentFragment(); this.archivedTasks.forEach(task => fragment.appendChild(this.renderArchivedTask(task))); container.replaceChildren(fragment); } // ─── Celebrations ─── _checkCelebrations(prevProgress) { if (this._celebrateTaskId) { const card = document.querySelector(`[data-task-id="${this._celebrateTaskId}"]`); if (card) { card.classList.add('just-completed'); const rect = card.getBoundingClientRect(); this.fireConfetti((rect.left + rect.width / 2) / window.innerWidth, (rect.top + rect.height / 2) / window.innerHeight, 35); setTimeout(() => card.classList.remove('just-completed'), 700); } this._celebrateTaskId = null; } if (this.tasks.length > 0 && this._previousOverallProgress === 100 && prevProgress < 100) { this.fireBigCelebration(); } } fireBigCelebration() { const canvas = document.createElement('canvas'); canvas.className = 'confetti-canvas'; canvas.width = window.innerWidth; canvas.height = window.innerHeight; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); const palettes = [ ['#6366f1', '#818cf8', '#a5b4fc'], ['#f43f5e', '#fb7185', '#fda4af'], ['#f59e0b', '#fbbf24', '#fde68a'], ['#10b981', '#34d399', '#6ee7b7'], ['#0ea5e9', '#38bdf8', '#7dd3fc'], ['#ec4899', '#f472b6', '#f9a8d4'], ['#8b5cf6', '#a78bfa', '#c4b5fd'], ]; const particles = []; const rockets = []; let startTime = performance.now(); const duration = 4500; const launchTimes = [0, 300, 550, 900, 1200, 1600, 2000, 2400, 2800, 3200]; const launchRocket = () => { const x = 0.15 + Math.random() * 0.7; const targetY = 0.15 + Math.random() * 0.35; rockets.push({ x: x * canvas.width, y: canvas.height, targetY: targetY * canvas.height, speed: 6 + Math.random() * 4, palette: palettes[Math.floor(Math.random() * palettes.length)], trail: [], alive: true }); }; const explode = (x, y, palette) => { const count = 50 + Math.floor(Math.random() * 40); for (let i = 0; i < count; i++) { const angle = Math.random() * Math.PI * 2; const speed = 1.5 + Math.random() * 5; particles.push({ x, y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, color: palette[Math.floor(Math.random() * palette.length)], size: 1.5 + Math.random() * 2.5, life: 1, decay: 0.012 + Math.random() * 0.01, trail: [], sparkle: Math.random() > 0.6 }); } }; let launchIndex = 0; let frame; const animate = (now) => { const elapsed = now - startTime; while (launchIndex < launchTimes.length && elapsed >= launchTimes[launchIndex]) { launchRocket(); launchIndex++; } // Clear to transparent each frame ctx.clearRect(0, 0, canvas.width, canvas.height); // Draw rockets for (const r of rockets) { if (!r.alive) continue; r.trail.push({ x: r.x, y: r.y }); if (r.trail.length > 8) r.trail.shift(); r.y -= r.speed; for (let i = 0; i < r.trail.length; i++) { const t = r.trail[i]; const alpha = (i / r.trail.length) * 0.7; ctx.globalAlpha = alpha; ctx.fillStyle = r.palette[0]; ctx.beginPath(); ctx.arc(t.x, t.y, 2, 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; ctx.fillStyle = '#fff'; ctx.shadowColor = r.palette[0]; ctx.shadowBlur = 8; ctx.beginPath(); ctx.arc(r.x, r.y, 3, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; if (r.y <= r.targetY) { r.alive = false; explode(r.x, r.y, r.palette); } } // Draw particles with trails for (const p of particles) { if (p.life <= 0) continue; p.trail.push({ x: p.x, y: p.y, life: p.life }); if (p.trail.length > 5) p.trail.shift(); p.x += p.vx; p.y += p.vy; p.vy += 0.04; p.vx *= 0.985; p.vy *= 0.985; p.life -= p.decay; // Trail for (let i = 0; i < p.trail.length; i++) { const t = p.trail[i]; ctx.globalAlpha = (i / p.trail.length) * t.life * 0.35; ctx.fillStyle = p.color; ctx.beginPath(); ctx.arc(t.x, t.y, p.size * 0.5, 0, Math.PI * 2); ctx.fill(); } // Main particle with glow ctx.globalAlpha = p.life; if (p.sparkle && Math.random() > 0.5) { ctx.globalAlpha = p.life * (0.5 + Math.random() * 0.5); } ctx.shadowColor = p.color; ctx.shadowBlur = 6; ctx.fillStyle = p.color; ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; } ctx.globalAlpha = 1; const allDone = elapsed > duration && rockets.every(r => !r.alive) && particles.every(p => p.life <= 0); if (allDone) { cancelAnimationFrame(frame); canvas.remove(); } else { frame = requestAnimationFrame(animate); } }; frame = requestAnimationFrame(animate); } fireConfetti(originX = 0.5, originY = 0.5, count = 50) { const canvas = document.createElement('canvas'); canvas.className = 'confetti-canvas'; canvas.width = window.innerWidth; canvas.height = window.innerHeight; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); const colors = ['#6366f1', '#a78bfa', '#f59e0b', '#10b981', '#f43f5e', '#0ea5e9', '#ec4899', '#8b5cf6']; const particles = []; for (let i = 0; i < count; i++) { const angle = Math.random() * Math.PI * 2; const speed = 4 + Math.random() * 8; particles.push({ x: originX * canvas.width, y: originY * canvas.height, vx: Math.cos(angle) * speed * (0.6 + Math.random() * 0.8), vy: Math.sin(angle) * speed * (0.6 + Math.random() * 0.8) - 3, color: colors[Math.floor(Math.random() * colors.length)], size: 3 + Math.random() * 5, rotation: Math.random() * 360, rotationSpeed: (Math.random() - 0.5) * 12, life: 1, decay: 0.008 + Math.random() * 0.012, shape: Math.random() > 0.5 ? 'rect' : 'circle' }); } let frame; const animate = () => { ctx.clearRect(0, 0, canvas.width, canvas.height); let alive = false; for (const p of particles) { if (p.life <= 0) continue; alive = true; p.x += p.vx; p.y += p.vy; p.vy += 0.18; p.vx *= 0.99; p.rotation += p.rotationSpeed; p.life -= p.decay; ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rotation * Math.PI / 180); ctx.globalAlpha = Math.max(0, p.life); ctx.fillStyle = p.color; if (p.shape === 'rect') ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6); else { ctx.beginPath(); ctx.arc(0, 0, p.size / 2, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); } if (alive) frame = requestAnimationFrame(animate); else { cancelAnimationFrame(frame); canvas.remove(); } }; animate(); } // ─── Utility ─── showError() { document.getElementById('tasks-container').innerHTML = `

Connection Error

Cannot connect to server

`; } escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } } const app = new App();