Skip to main content

max / goingson

6.2 KB · 167 lines History Blame Raw
1 /**
2 * GoingsOn - Tasks Kanban Module
3 * Board view with drag-and-drop status changes
4 */
5
6 (function() {
7 'use strict';
8 const esc = GoingsOn.utils.escapeHtml;
9 const escAttr = GoingsOn.utils.escapeAttr;
10
11 const COLUMNS = [
12 { status: 'Pending', label: 'Pending' },
13 { status: 'Started', label: 'Started' },
14 { status: 'Completed', label: 'Completed' },
15 ];
16
17 // ============ Rendering ============
18
19 function render(tasks) {
20 const board = document.getElementById('task-kanban-board');
21 if (!board) return;
22
23 // Group tasks by status
24 const grouped = { Pending: [], Started: [], Completed: [] };
25 for (const task of tasks) {
26 const status = task.status || 'Pending';
27 if (grouped[status]) {
28 grouped[status].push(task);
29 }
30 }
31
32 board.innerHTML = COLUMNS.map(col => {
33 const colTasks = grouped[col.status] || [];
34 return `
35 <div class="kanban-column" data-status="${col.status}"
36 ondragover="GoingsOn.tasksKanban.onDragOver(event)"
37 ondragleave="GoingsOn.tasksKanban.onDragLeave(event)"
38 ondrop="GoingsOn.tasksKanban.onDrop(event)">
39 <div class="kanban-column-header">
40 <span>${col.label}</span>
41 <span class="kanban-column-count">${colTasks.length}</span>
42 </div>
43 <div class="kanban-column-body">
44 ${colTasks.length === 0
45 ? '<div class="empty-state empty-state--compact">No tasks</div>'
46 : colTasks.map(t => renderCard(t)).join('')}
47 </div>
48 </div>`;
49 }).join('');
50 }
51
52 function renderCard(task) {
53 const displayDesc = task.displayDescription || task.description;
54 const progress = task.subtaskProgress ?? 0;
55
56 return `
57 <div class="card kanban-card priority-${task.priority.toLowerCase()}"
58 draggable="true" data-task-id="${escAttr(task.id)}" data-status="${task.status}"
59 ondragstart="GoingsOn.tasksKanban.onDragStart(event)"
60 ondragend="GoingsOn.tasksKanban.onDragEnd(event)"
61 onclick="GoingsOn.tasks.openSubtasks('${escAttr(task.id)}')"
62 oncontextmenu="GoingsOn.contextMenus.showTask(event, '${escAttr(task.id)}')">
63 <div class="kanban-card-title">${esc(displayDesc)}</div>
64 <div class="kanban-card-meta">
65 ${task.projectName ? `<span class="kanban-card-project">${esc(task.projectName)}</span>` : ''}
66 ${task.dueFormatted ? `<span class="kanban-card-due ${task.isOverdue ? 'overdue' : ''}">${esc(task.dueFormatted)}</span>` : ''}
67 </div>
68 ${task.subtaskCount > 0 ? `<div class="progress-bar-mini"><div class="progress-fill" style="width:${progress}%"></div></div>` : ''}
69 </div>`;
70 }
71
72 // ============ Drag and Drop ============
73
74 let draggedTaskId = null;
75
76 function onDragStart(e) {
77 draggedTaskId = e.target.dataset.taskId;
78 e.target.classList.add('dragging');
79 e.dataTransfer.effectAllowed = 'move';
80 e.dataTransfer.setData('text/plain', draggedTaskId);
81 }
82
83 function onDragEnd(e) {
84 e.target.classList.remove('dragging');
85 draggedTaskId = null;
86 // Remove all drag-over highlights
87 document.querySelectorAll('.kanban-column.drag-over').forEach(col => {
88 col.classList.remove('drag-over');
89 });
90 }
91
92 function onDragOver(e) {
93 e.preventDefault();
94 e.dataTransfer.dropEffect = 'move';
95 const column = e.target.closest('.kanban-column');
96 if (column) column.classList.add('drag-over');
97 }
98
99 function onDragLeave(e) {
100 const column = e.target.closest('.kanban-column');
101 if (column && !column.contains(e.relatedTarget)) {
102 column.classList.remove('drag-over');
103 }
104 }
105
106 async function onDrop(e) {
107 e.preventDefault();
108 const column = e.target.closest('.kanban-column');
109 if (!column) return;
110 column.classList.remove('drag-over');
111
112 const taskId = e.dataTransfer.getData('text/plain');
113 if (!taskId) return;
114
115 const newStatus = column.dataset.status;
116 const tasks = GoingsOn.state.tasks || [];
117 const task = tasks.find(t => t.id === taskId);
118 if (!task || task.status === newStatus) return;
119
120 await handleDrop(taskId, task, newStatus);
121 }
122
123 async function handleDrop(taskId, task, newStatus) {
124 try {
125 if (newStatus === 'Started') {
126 await GoingsOn.api.tasks.start(taskId);
127 GoingsOn.ui.showToast('Task started!', 'success');
128 } else if (newStatus === 'Completed') {
129 await GoingsOn.api.tasks.complete(taskId);
130 GoingsOn.ui.showToast('Task completed!', 'success');
131 } else if (newStatus === 'Pending') {
132 // Move back to pending via update
133 await GoingsOn.api.tasks.update(taskId, {
134 description: task.description,
135 projectId: task.project_id || task.projectId || null,
136 status: 'Pending',
137 priority: task.priority,
138 due: task.due || null,
139 tags: task.tags || [],
140 recurrence: task.recurrence || 'None',
141 contactId: task.contactId || null,
142 milestoneId: task.milestoneId || null,
143 });
144 GoingsOn.ui.showToast('Task moved to Pending', 'success');
145 }
146 // Re-fetch to get accurate server state (recurrence, etc.)
147 await GoingsOn.tasks.load();
148 } catch (err) {
149 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to update task'), 'error');
150 // Re-render to revert visual position
151 await GoingsOn.tasks.load();
152 }
153 }
154
155 // ============ Populate Namespace ============
156
157 GoingsOn.tasksKanban = {
158 render,
159 onDragStart,
160 onDragEnd,
161 onDragOver,
162 onDragLeave,
163 onDrop,
164 };
165
166 })();
167