Skip to main content

max / goingson

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