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 = `
${this.escapeHtml(task.title)}
-
${task.size}
+
+
${task.size}
+
- ${task.description ? `
${this.escapeHtml(task.description)}
` : ''} -
-
- Progress - ${progress}% -
+ ${task.description ? `
${this.escapeHtml(task.description)}
` : ''} +
-
- ${progress > 15 ? progress + '%' : ''} -
+
+
${progress}%
${task.reward ? `
- 🎁 Reward: - ${isCompleted + 🎁 + ${isCompleted ? `${this.escapeHtml(task.reward)}` : `Complete to reveal` } @@ -566,6 +703,7 @@ class App { + ${isCompleted ? `` : ''}
`; @@ -574,7 +712,7 @@ class App { card.appendChild(this.renderSubtasks(task)); } - // Add drop zone + // Drop zone const dropZone = document.createElement('div'); dropZone.className = 'drop-zone'; dropZone.textContent = 'Drop tasks here to add as subtasks'; @@ -597,50 +735,58 @@ class App { e.stopPropagation(); this.handleDragStart(e, subtask, true, task, index); }); - div.addEventListener('dragend', (e) => { e.stopPropagation(); this.handleDragEnd(e); }); - - div.addEventListener('dragover', (e) => { - e.preventDefault(); + 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); }); + div.addEventListener('dblclick', (e) => { + e.stopPropagation(); + if (!e.target.closest('button')) { + this.openEditModal(subtask, task); + } + }); const progress = subtask.calculateProgress(); + const stDescClass = subtask.expandDescription ? 'task-description expanded' : 'task-description'; div.innerHTML = `
${this.escapeHtml(subtask.title)}
${subtask.size}
- ${subtask.description ? `
${this.escapeHtml(subtask.description)}
` : ''} -
-
+ ${subtask.description ? `
${this.escapeHtml(subtask.description)}
` : ''} +
+
+
+
+
${progress}%
-
${progress}%
- ${subtask.reward && subtask.completed ? ` -
- 🎁 ${this.escapeHtml(subtask.reward)} + ${subtask.reward ? ` +
+ 🎁 + ${subtask.completed + ? `${this.escapeHtml(subtask.reward)}` + : `Complete to reveal` + }
` : ''}
@@ -675,6 +821,139 @@ class App { }); } + // ─── Render: Archive View ─── + + 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.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)}
+
+
${task.size}
+
+
+ ${task.description ? `
${this.escapeHtml(task.description)}
` : ''} +
+
+
+
+
${progress}%
+
+ ${task.reward ? ` +
+ 🎁 + ${isCompleted + ? `${this.escapeHtml(task.reward)}` + : `Complete to reveal` + } +
+ ` : ''} +
+ + +
+ `; + + // Subtasks (read-only, no drag) + 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 stDescClass = subtask.expandDescription ? 'task-description expanded' : 'task-description'; + + div.innerHTML = ` +
+
${this.escapeHtml(subtask.title)}
+
${subtask.size}
+
+ ${subtask.description ? `
${this.escapeHtml(subtask.description)}
` : ''} +
+
+
+
+
${progress}%
+
+ ${subtask.reward ? ` +
+ 🎁 + ${subtask.completed + ? `${this.escapeHtml(subtask.reward)}` + : `Complete to reveal` + } +
+ ` : ''} + `; + + container.appendChild(div); + }); + + return container; + } + + 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)); + }); + } + + // ─── Utility ─── + showError() { const container = document.getElementById('tasks-container'); container.innerHTML = ` diff --git a/public/index.html b/public/index.html index abf2167..faea51f 100644 --- a/public/index.html +++ b/public/index.html @@ -9,9 +9,11 @@
-
✏️ Edit
- -
🗑️ Delete
+
✎ Edit
+ + + +
🗑 Delete
@@ -33,7 +35,11 @@
- + +
@@ -61,10 +67,19 @@

Learning Progress Tracker

- +
+ +
+ + +
+
- +
Overall Progress diff --git a/public/styles.css b/public/styles.css index 970b5ae..390ede6 100644 --- a/public/styles.css +++ b/public/styles.css @@ -8,7 +8,7 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; background: #0f1419; min-height: 100vh; - padding: 20px; + padding: 16px; color: #e4e6eb; } @@ -17,20 +17,77 @@ body { margin: 0 auto; } +/* ─── Header ─── */ header { - margin-bottom: 30px; + margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; + flex-wrap: wrap; + gap: 12px; } h1 { - font-size: 1.8em; + font-size: 1.4em; color: #e4e6eb; font-weight: 600; } -/* Context Menu */ +.header-actions { + display: flex; + align-items: center; + gap: 10px; +} + +/* ─── View Toggle ─── */ +.view-toggle { + display: flex; + background: #1a2332; + border-radius: 8px; + border: 1px solid #2d3748; + overflow: hidden; +} + +.toggle-btn { + padding: 7px 16px; + font-size: 0.85em; + background: transparent; + color: #8b96a5; + border: none; + cursor: pointer; + transition: all 0.2s; + font-weight: 500; + display: flex; + align-items: center; + gap: 6px; +} + +.toggle-btn:hover { + color: #e4e6eb; + background: rgba(255,255,255,0.04); +} + +.toggle-btn.active { + background: #4299e1; + color: white; +} + +.archive-count { + background: rgba(255,255,255,0.2); + padding: 1px 7px; + border-radius: 10px; + font-size: 0.8em; + font-weight: 600; + min-width: 20px; + text-align: center; +} + +.toggle-btn:not(.active) .archive-count { + background: #2d3748; + color: #a0aec0; +} + +/* ─── Context Menu ─── */ .context-menu { position: fixed; background: #1e2936; @@ -38,8 +95,9 @@ h1 { border-radius: 8px; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5); z-index: 2000; - min-width: 150px; + min-width: 160px; display: none; + overflow: hidden; } .context-menu.show { @@ -47,11 +105,11 @@ h1 { } .context-menu-item { - padding: 10px 16px; + padding: 9px 14px; cursor: pointer; - transition: background 0.2s; - font-size: 0.9em; - border-bottom: 1px solid #2d3748; + transition: background 0.15s; + font-size: 0.85em; + border-bottom: 1px solid #232e3c; display: flex; align-items: center; gap: 8px; @@ -71,7 +129,7 @@ h1 { color: #feb2b2; } -/* Modal Styles */ +/* ─── Modal ─── */ .modal { display: none; position: fixed; @@ -93,7 +151,7 @@ h1 { background: #1a2332; border-radius: 12px; width: 90%; - max-width: 500px; + max-width: 460px; max-height: 90vh; overflow-y: auto; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5); @@ -104,12 +162,12 @@ h1 { display: flex; justify-content: space-between; align-items: center; - padding: 20px; + padding: 16px 20px; border-bottom: 1px solid #2d3748; } .modal-header h2 { - font-size: 1.25em; + font-size: 1.1em; font-weight: 600; color: #e4e6eb; } @@ -117,11 +175,11 @@ h1 { .close-btn { background: none; border: none; - font-size: 1.8em; + font-size: 1.6em; color: #8b96a5; cursor: pointer; - width: 32px; - height: 32px; + width: 28px; + height: 28px; display: flex; align-items: center; justify-content: center; @@ -136,36 +194,36 @@ h1 { } .modal-content form { - padding: 20px; + padding: 16px 20px; } -/* Form Styles */ +/* ─── Form ─── */ .form-group { - margin-bottom: 16px; + margin-bottom: 12px; } .form-row { display: grid; grid-template-columns: 1fr 1fr; - gap: 12px; + gap: 10px; } label { display: block; - margin-bottom: 6px; + margin-bottom: 4px; font-weight: 500; color: #a0aec0; - font-size: 0.875em; + font-size: 0.82em; } input[type="text"], select, textarea { width: 100%; - padding: 10px 12px; + padding: 8px 10px; border: 1px solid #2d3748; border-radius: 6px; - font-size: 0.95em; + font-size: 0.9em; font-family: inherit; transition: all 0.2s; background: #0f1419; @@ -184,12 +242,36 @@ textarea { resize: vertical; } +.checkbox-label { + display: flex; + align-items: center; + gap: 6px; + margin-top: 6px; + cursor: pointer; + font-size: 0.82em; + color: #a0aec0; + font-weight: 400; +} + +.checkbox-label input[type="checkbox"] { + width: 14px; + height: 14px; + cursor: pointer; + accent-color: #4299e1; +} + +.task-description.expanded { + -webkit-line-clamp: unset; + display: block; + overflow: visible; +} + select option { background: #1a2332; color: #e4e6eb; } -/* Button Styles */ +/* ─── Buttons ─── */ button { font-family: inherit; cursor: pointer; @@ -201,8 +283,8 @@ button { .btn-primary { width: 100%; - padding: 12px; - font-size: 1em; + padding: 10px; + font-size: 0.95em; background: #4299e1; color: white; } @@ -212,8 +294,8 @@ button { } .btn-add { - padding: 10px 20px; - font-size: 0.95em; + padding: 7px 16px; + font-size: 0.85em; background: #4299e1; color: white; } @@ -223,8 +305,8 @@ button { } .btn-small { - padding: 6px 12px; - font-size: 0.85em; + padding: 4px 10px; + font-size: 0.78em; } .btn-complete { @@ -236,6 +318,24 @@ button { background: #38a169; } +.btn-archive { + background: #805ad5; + color: white; +} + +.btn-archive:hover { + background: #6b46c1; +} + +.btn-restore { + background: #4299e1; + color: white; +} + +.btn-restore:hover { + background: #3182ce; +} + .btn-delete { background: #f56565; color: white; @@ -254,21 +354,26 @@ button { background: #3182ce; } -/* Overall Progress */ +/* ─── Overall Progress ─── */ .overall-progress { background: #1a2332; - padding: 20px; + padding: 14px 18px; border-radius: 10px; - margin-bottom: 24px; + margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); border: 1px solid #2d3748; } +.overall-progress.hidden { + display: none; +} + .overall-header { display: flex; justify-content: space-between; - margin-bottom: 10px; + margin-bottom: 8px; font-weight: 600; + font-size: 0.9em; color: #e4e6eb; } @@ -277,22 +382,10 @@ button { } .task-counter { - margin-top: 12px; + margin-top: 8px; text-align: center; color: #a0aec0; - font-size: 0.9em; -} - -#completed-count { - color: #4299e1; - font-weight: 600; -} - -.task-counter { - margin-top: 12px; - text-align: center; - color: #a0aec0; - font-size: 0.9em; + font-size: 0.82em; } .task-counter span { @@ -300,25 +393,26 @@ button { color: #e4e6eb; } -/* Tasks Container */ +/* ─── Tasks Container ─── */ .tasks-container { display: grid; - gap: 16px; + gap: 8px; } +/* ─── Task Cards — Compact ─── */ .task-card { background: #1a2332; - border-radius: 10px; - padding: 16px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + border-radius: 8px; + padding: 12px 14px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25); cursor: grab; transition: all 0.2s; - border: 2px solid transparent; + border: 1px solid transparent; position: relative; } .task-card:hover { - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); border-color: #2d3748; } @@ -335,27 +429,45 @@ button { background: #1e3a5f; } +.task-card.archived-card { + cursor: default; + border-left: 3px solid #805ad5; +} + .task-header { display: flex; justify-content: space-between; - align-items: flex-start; - margin-bottom: 12px; + align-items: center; + margin-bottom: 6px; + gap: 8px; } .task-title { - font-size: 1.1em; + font-size: 0.95em; font-weight: 600; color: #e4e6eb; flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-meta { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; } .task-size-badge { - padding: 4px 10px; - border-radius: 12px; - font-size: 0.75em; + padding: 2px 8px; + border-radius: 10px; + font-size: 0.7em; font-weight: 600; - margin-left: 10px; white-space: nowrap; + text-transform: uppercase; + letter-spacing: 0.3px; } .task-size-badge.small { @@ -364,42 +476,41 @@ button { } .task-size-badge.medium { - background: #7c2d12; - color: #fed7aa; + background: #744210; + color: #fbd38d; } .task-size-badge.large { - background: #7c2d12; - color: #fbb6ce; + background: #742a2a; + color: #feb2b2; } .task-description { - color: #a0aec0; - margin-bottom: 12px; - font-size: 0.9em; - line-height: 1.5; + color: #8b96a5; + margin-bottom: 8px; + font-size: 0.82em; + line-height: 1.4; white-space: pre-wrap; word-wrap: break-word; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; } -/* Progress Bar */ -.progress-section { - margin-bottom: 12px; -} - -.progress-label { +/* ─── Inline Progress ─── */ +.progress-row { display: flex; - justify-content: space-between; + align-items: center; + gap: 8px; margin-bottom: 6px; - font-size: 0.85em; - color: #a0aec0; } .progress-bar { - width: 100%; - height: 20px; + flex: 1; + height: 6px; background: #0f1419; - border-radius: 10px; + border-radius: 3px; overflow: hidden; } @@ -407,22 +518,44 @@ button { height: 100%; background: #4299e1; transition: width 0.3s ease; + border-radius: 3px; +} + +.progress-text { + font-size: 0.78em; + font-weight: 600; + color: #a0aec0; + min-width: 32px; + text-align: right; +} + +/* Overall progress bar is taller */ +.overall-progress .progress-bar { + height: 16px; + border-radius: 8px; +} + +.overall-progress .progress-fill { display: flex; align-items: center; justify-content: center; color: white; font-weight: 600; - font-size: 0.75em; + font-size: 0.72em; + border-radius: 8px; } -/* Reward Section */ +/* ─── Reward ─── */ .reward-section { - background: #2d3748; - padding: 12px; - border-radius: 8px; - margin-top: 12px; - font-size: 0.95em; - border: 1px solid #4a5568; + background: #1e2936; + padding: 6px 10px; + border-radius: 6px; + margin-bottom: 6px; + font-size: 0.82em; + border: 1px solid #2d3748; + display: flex; + align-items: center; + gap: 6px; } .reward-hidden { @@ -433,36 +566,40 @@ button { .reward-revealed { color: #68d391; font-weight: 600; - font-size: 1em; } -/* Task Actions */ +/* ─── Task Actions — Compact row ─── */ .task-actions { display: flex; - gap: 8px; - margin-top: 12px; + gap: 6px; + margin-top: 6px; } -/* Subtasks */ +/* ─── Subtasks ─── */ .subtasks-container { - margin-top: 12px; - padding-left: 16px; + margin-top: 8px; + padding-left: 12px; border-left: 2px solid #2d3748; } .subtask { background: #0f1419; - padding: 10px; + padding: 8px 10px; border-radius: 6px; - margin-bottom: 8px; + margin-bottom: 4px; cursor: grab; - font-size: 0.9em; - border: 1px solid #2d3748; + font-size: 0.85em; + border: 1px solid #1e2936; position: relative; + transition: border-color 0.15s; +} + +.subtask:last-child { + margin-bottom: 0; } .subtask:hover { - border-color: #4a5568; + border-color: #3a4a5c; } .subtask:active { @@ -477,56 +614,70 @@ button { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 6px; + margin-bottom: 4px; + gap: 6px; } .subtask-title { font-weight: 600; color: #e4e6eb; + font-size: 0.92em; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.subtask-progress-row { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; } .subtask-progress { - width: 100%; - height: 16px; + flex: 1; + height: 4px; background: #1a2332; - border-radius: 8px; + border-radius: 2px; overflow: hidden; - margin-bottom: 8px; } .subtask-progress-fill { height: 100%; background: #48bb78; transition: width 0.3s ease; + border-radius: 2px; } .subtask-actions { display: flex; - gap: 6px; - margin-top: 8px; + gap: 4px; + margin-top: 4px; } -/* Drop Zone */ +/* ─── Drop Zone ─── */ .drop-zone { border: 2px dashed #4a5568; border-radius: 6px; - padding: 12px; + padding: 10px; text-align: center; color: #718096; - margin-top: 12px; - font-size: 0.85em; + font-size: 0.8em; transition: all 0.2s; opacity: 0; max-height: 0; overflow: hidden; padding: 0; + margin-top: 0; } .drop-zone.visible { opacity: 1; - max-height: 100px; - padding: 12px; - margin-top: 12px; + max-height: 60px; + padding: 10px; + margin-top: 8px; } .drop-zone.drag-over { @@ -535,46 +686,37 @@ button { color: #4299e1; } -/* Completed State */ +/* ─── Completed ─── */ +.completed .task-title, +.completed .subtask-title { + text-decoration: line-through; + color: #718096; +} + .completed { opacity: 0.85; } -.completed .task-title { - text-decoration: line-through; - color: #a0aec0; -} - -/* Checkbox */ -.checkbox-group { - display: flex; - align-items: center; - gap: 6px; -} - -.checkbox-group input[type="checkbox"] { - width: 16px; - height: 16px; - cursor: pointer; -} - -/* Empty State */ +/* ─── Empty / Loading State ─── */ .empty-state { text-align: center; - padding: 60px 20px; + padding: 50px 20px; color: #a0aec0; } .empty-state h2 { - font-size: 1.5em; - margin-bottom: 8px; + font-size: 1.2em; + margin-bottom: 6px; color: #cbd5e0; } -/* Loading State */ +.empty-state p { + font-size: 0.9em; +} + .loading { text-align: center; - padding: 60px 20px; + padding: 50px 20px; color: #a0aec0; } @@ -582,10 +724,10 @@ button { border: 3px solid #2d3748; border-top: 3px solid #4299e1; border-radius: 50%; - width: 40px; - height: 40px; + width: 32px; + height: 32px; animation: spin 1s linear infinite; - margin: 0 auto 16px; + margin: 0 auto 12px; } @keyframes spin { @@ -593,36 +735,26 @@ button { 100% { transform: rotate(360deg); } } -/* Responsive */ -@media (max-width: 768px) { +/* ─── Responsive ─── */ +@media (max-width: 640px) { h1 { - font-size: 1.5em; + font-size: 1.2em; } header { flex-direction: column; - align-items: flex-start; - gap: 12px; + align-items: stretch; + } + + .header-actions { + justify-content: space-between; } .form-row { grid-template-columns: 1fr; } - .task-header { - flex-direction: column; - gap: 6px; - } - - .task-size-badge { - margin-left: 0; - } - - .task-actions { - flex-direction: column; - } - - .task-actions button { - width: 100%; + .task-card { + padding: 10px 12px; } } \ No newline at end of file diff --git a/server/server.js b/server/server.js index a97db08..364c511 100644 --- a/server/server.js +++ b/server/server.js @@ -37,19 +37,27 @@ function initDatabase() { size TEXT NOT NULL, reward TEXT, completed INTEGER DEFAULT 0, + archived INTEGER DEFAULT 0, + expand_description INTEGER DEFAULT 0, position INTEGER, subtasks TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ) - `); + `, (err) => { + if (err) return; + // Migrate: add columns if missing on existing databases + db.run(`ALTER TABLE tasks ADD COLUMN archived INTEGER DEFAULT 0`, () => {}); + db.run(`ALTER TABLE tasks ADD COLUMN expand_description INTEGER DEFAULT 0`, () => {}); + }); } // API Routes -// Get all tasks +// Get tasks — ?archived=1 returns archived, default returns active app.get('/api/tasks', (req, res) => { - db.all('SELECT * FROM tasks ORDER BY position ASC, created_at ASC', [], (err, rows) => { + const archived = req.query.archived === '1' ? 1 : 0; + db.all('SELECT * FROM tasks WHERE archived = ? ORDER BY position ASC, created_at ASC', [archived], (err, rows) => { if (err) { console.error('Error fetching tasks:', err); return res.status(500).json({ error: 'Failed to fetch tasks' }); @@ -62,6 +70,8 @@ app.get('/api/tasks', (req, res) => { size: row.size, reward: row.reward, completed: row.completed === 1, + archived: row.archived === 1, + expandDescription: row.expand_description === 1, subtasks: row.subtasks ? JSON.parse(row.subtasks) : [] })); @@ -71,13 +81,13 @@ app.get('/api/tasks', (req, res) => { // Create a new task app.post('/api/tasks', (req, res) => { - const { title, description, size, reward } = req.body; + const { title, description, size, reward, expandDescription } = req.body; const id = Date.now().toString(); db.run( - `INSERT INTO tasks (id, title, description, size, reward, position) - VALUES (?, ?, ?, ?, ?, (SELECT COALESCE(MAX(position), 0) + 1 FROM tasks))`, - [id, title, description || '', size, reward || ''], + `INSERT INTO tasks (id, title, description, size, reward, expand_description, archived, position) + VALUES (?, ?, ?, ?, ?, ?, 0, (SELECT COALESCE(MAX(position), 0) + 1 FROM tasks WHERE archived = 0))`, + [id, title, description || '', size, reward || '', expandDescription ? 1 : 0], function(err) { if (err) { console.error('Error creating task:', err); @@ -91,6 +101,8 @@ app.post('/api/tasks', (req, res) => { size, reward: reward || '', completed: false, + archived: false, + expandDescription: !!expandDescription, subtasks: [] }); } @@ -100,31 +112,47 @@ app.post('/api/tasks', (req, res) => { // Update a task app.put('/api/tasks/:id', (req, res) => { const { id } = req.params; - const { title, description, size, reward, completed, subtasks } = req.body; + const { title, description, size, reward, completed, expandDescription, subtasks } = req.body; db.run( `UPDATE tasks - SET title = ?, description = ?, size = ?, reward = ?, completed = ?, subtasks = ?, updated_at = CURRENT_TIMESTAMP + SET title = ?, description = ?, size = ?, reward = ?, completed = ?, expand_description = ?, subtasks = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, - [ - title, - description || '', - size, - reward || '', - completed ? 1 : 0, - JSON.stringify(subtasks || []), - id - ], + [title, description || '', size, reward || '', completed ? 1 : 0, expandDescription ? 1 : 0, JSON.stringify(subtasks || []), id], function(err) { if (err) { console.error('Error updating task:', err); return res.status(500).json({ error: 'Failed to update task' }); } + if (this.changes === 0) return res.status(404).json({ error: 'Task not found' }); + res.json({ success: true }); + } + ); +}); - if (this.changes === 0) { - return res.status(404).json({ error: 'Task not found' }); - } +// Archive a task +app.put('/api/tasks/:id/archive', (req, res) => { + const { id } = req.params; + db.run( + `UPDATE tasks SET archived = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + [id], + function(err) { + if (err) return res.status(500).json({ error: 'Failed to archive task' }); + if (this.changes === 0) return res.status(404).json({ error: 'Task not found' }); + res.json({ success: true }); + } + ); +}); +// Unarchive (restore) a task +app.put('/api/tasks/:id/unarchive', (req, res) => { + const { id } = req.params; + db.run( + `UPDATE tasks SET archived = 0, position = (SELECT COALESCE(MAX(position), 0) + 1 FROM tasks WHERE archived = 0), updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + [id], + function(err) { + if (err) return res.status(500).json({ error: 'Failed to unarchive task' }); + if (this.changes === 0) return res.status(404).json({ error: 'Task not found' }); res.json({ success: true }); } ); @@ -133,17 +161,9 @@ app.put('/api/tasks/:id', (req, res) => { // Delete a task app.delete('/api/tasks/:id', (req, res) => { const { id } = req.params; - db.run('DELETE FROM tasks WHERE id = ?', [id], function(err) { - if (err) { - console.error('Error deleting task:', err); - return res.status(500).json({ error: 'Failed to delete task' }); - } - - if (this.changes === 0) { - return res.status(404).json({ error: 'Task not found' }); - } - + if (err) return res.status(500).json({ error: 'Failed to delete task' }); + if (this.changes === 0) return res.status(404).json({ error: 'Task not found' }); res.json({ success: true }); }); }); @@ -151,10 +171,7 @@ app.delete('/api/tasks/:id', (req, res) => { // Reorder tasks app.post('/api/tasks/reorder', (req, res) => { const taskIds = req.body; - - if (!Array.isArray(taskIds)) { - return res.status(400).json({ error: 'Invalid task order data' }); - } + if (!Array.isArray(taskIds)) return res.status(400).json({ error: 'Invalid task order data' }); const promises = taskIds.map((id, index) => { return new Promise((resolve, reject) => { @@ -178,19 +195,14 @@ app.get('/', (req, res) => { res.sendFile(path.join(__dirname, '../public/index.html')); }); -// Start server app.listen(PORT, () => { - console.log(`🚀 Learning Progress Tracker running on http://localhost:${PORT}`); + console.log(`Learning Progress Tracker running on http://localhost:${PORT}`); }); -// Graceful shutdown process.on('SIGINT', () => { db.close((err) => { - if (err) { - console.error('Error closing database:', err); - } else { - console.log('Database connection closed'); - } + if (err) console.error('Error closing database:', err); + else console.log('Database connection closed'); process.exit(0); }); }); \ No newline at end of file