1// features/board/board-view-shared.js â extracted from app.js (lines 5196-5528). 2// Classic script: shares the global scope with every other core/ and features/ 3// script. Do NOT wrap in an IIFE and do NOT convert to an ES module â top-level 4// function declarations must attach to window for inline on* handlers, and 5// top-level const/let are shared across files. 6 7function renderBoardViewTabs() { 8 if (tabTableBtn) tabTableBtn.classList.toggle('active', state.boardViewMode === 'table'); 9 if (tabCalendarBtn) tabCalendarBtn.classList.toggle('active', state.boardViewMode === 'calendar'); 10} 11 12function renderFieldWindowControls() { 13 return ''; 14} 15 16function sortTasksForBoardView(tasks) { 17 const rows = [...tasks]; 18 const mode = state.boardSortBy || 'default'; 19 const fieldSortMatch = /^field_(\d+)_(asc|desc)$/.exec(mode); 20 if (fieldSortMatch) { 21 const fieldId = Number(fieldSortMatch[1]); 22 const direction = fieldSortMatch[2] === 'desc' ? -1 : 1; 23 const field = getFieldById(fieldId); 24 rows.sort((a, b) => { 25 const av = getTaskFieldValue(a, fieldId); 26 const bv = getTaskFieldValue(b, fieldId); 27 if (field && ['number', 'formula'].includes(String(field.field_type || ''))) { 28 const an = Number(av); 29 const bn = Number(bv); 30 const avn = Number.isFinite(an) ? an : Number.NEGATIVE_INFINITY; 31 const bvn = Number.isFinite(bn) ? bn : Number.NEGATIVE_INFINITY; 32 return direction * (avn - bvn); 33 } 34 if (field && String(field.field_type || '') === 'date') { 35 const at = av ? new Date(av).getTime() : 0; 36 const bt = bv ? new Date(bv).getTime() : 0; 37 return direction * ((Number.isFinite(at) ? at : 0) - (Number.isFinite(bt) ? bt : 0)); 38 } 39 return direction * String(av || '').localeCompare(String(bv || ''), undefined, { sensitivity: 'base' }); 40 }); 41 return rows; 42 } 43 if (mode === 'title_asc') { 44 rows.sort((a, b) => String(a.title || '').localeCompare(String(b.title || ''))); 45 return rows; 46 } 47 if (mode === 'title_desc') { 48 rows.sort((a, b) => String(b.title || '').localeCompare(String(a.title || ''))); 49 return rows; 50 } 51 if (mode === 'due_asc') { 52 rows.sort((a, b) => { 53 const av = a.due_date ? new Date(a.due_date).getTime() : Number.MAX_SAFE_INTEGER; 54 const bv = b.due_date ? new Date(b.due_date).getTime() : Number.MAX_SAFE_INTEGER; 55 return av - bv; 56 }); 57 return rows; 58 } 59 if (mode === 'due_desc') { 60 rows.sort((a, b) => { 61 const av = a.due_date ? new Date(a.due_date).getTime() : -1; 62 const bv = b.due_date ? new Date(b.due_date).getTime() : -1; 63 return bv - av; 64 }); 65 return rows; 66 } 67 return rows; 68} 69 70function parseGroupSortDate(label) { 71 const value = String(label || '').trim(); 72 if (!value) return null; 73 const direct = Date.parse(value); 74 if (Number.isFinite(direct)) return direct; 75 const currentYear = new Date().getFullYear(); 76 const withYear = Date.parse(`${value} 1, ${currentYear}`); 77 return Number.isFinite(withYear) ? withYear : null; 78} 79 80function sortBoardGroups(groups) { 81 const mode = state.boardGroupSortBy || 'default'; 82 const rows = [...groups]; 83 if (mode === 'group_az') { 84 return rows.sort((a, b) => String(a.label || '').localeCompare(String(b.label || ''), undefined, { numeric: true, sensitivity: 'base' })); 85 } 86 if (mode === 'group_za') { 87 return rows.sort((a, b) => String(b.label || '').localeCompare(String(a.label || ''), undefined, { numeric: true, sensitivity: 'base' })); 88 } 89 if (mode === 'group_date_asc' || mode === 'group_date_desc') { 90 const direction = mode === 'group_date_desc' ? -1 : 1; 91 return rows.sort((a, b) => { 92 const av = parseGroupSortDate(a.label); 93 const bv = parseGroupSortDate(b.label); 94 if (av == null && bv == null) return Number(a.position || 0) - Number(b.position || 0); 95 if (av == null) return 1; 96 if (bv == null) return -1; 97 return direction * (av - bv); 98 }); 99 } 100 return rows; 101} 102 103function taskGroupLabel(task) { 104 const groupBy = state.boardGroupBy || 'group'; 105 if (groupBy === 'none') return 'Items'; 106 return getColumnName(task.column_id); 107} 108 109function isSummarizableField(field) { 110 return ['number', 'formula', 'rollup', 'files', 'checkbox'].includes(String(field?.field_type || '')); 111} 112 113function getNumericFieldValues(rows, field) { 114 return rows 115 .map((row) => Number(getTaskFieldValue(row, field.id))) 116 .filter((value) => Number.isFinite(value)); 117} 118 119function getNumberSummaryValue(values, summaryFunction) { 120 if (summaryFunction === 'none') return null; 121 if (summaryFunction === 'count') return values.length; 122 if (!values.length) return null; 123 if (summaryFunction === 'average') return values.reduce((total, value) => total + value, 0) / values.length; 124 if (summaryFunction === 'median') { 125 const sorted = [...values].sort((a, b) => a - b); 126 const middle = Math.floor(sorted.length / 2); 127 return sorted.length % 2 128 ? sorted[middle]
129 : (sorted[middle - 1] + sorted[middle]) / 2; 130 } 131 if (summaryFunction === 'min') return Math.min(...values); 132 if (summaryFunction === 'max') return Math.max(...values); 133 return values.reduce((total, value) => total + value, 0); 134} 135 136function renderSummarizableFieldSummary(rows, field, filesByTask = getFilesByTaskMap()) { 137 const type = String(field?.field_type || ''); 138 if (type === 'number' || type === 'formula' || type === 'rollup') { 139 const config = normalizeNumberFieldConfig(parseFieldConfig(field)); 140 const summaryFunction = config.summaryFunction; 141 const values = getNumericFieldValues(rows, field); 142 const summaryValue = getNumberSummaryValue(values, summaryFunction); 143 const summaryLabel = NUMBER_SUMMARY_FUNCTIONS.find((option) => option.value === summaryFunction)?.label || 'Sum'; 144 const formatted = (summaryValue == null) ? '-' : (summaryFunction === 'count' ? String(summaryValue) : formatNumberFieldValue(summaryValue, field)); 145 146 return ` 147 <div class="summary-total interactive" onclick="openSummaryFunctionMenu(event, ${field.id})"> 148 <strong>${escapeHtml(formatted)}</strong> 149 <span>${escapeHtml(summaryLabel.toLowerCase())}</span> 150 </div> 151 `; 152 } 153 if (type === 'files') { 154 const count = rows.reduce((total, row) => { 155 const files = (filesByTask.get(String(row.id)) || []) 156 .filter((file) => Number(file.field_id) === Number(field.id) && !file.archived_at); 157 return total + files.length; 158 }, 0); 159 return `<div class="summary-total"><strong>${count}</strong><span>${count === 1 ? 'file' : 'files'}</span></div>`; 160 } 161 if (type === 'checkbox') { 162 const checked = rows.reduce((total, row) => total + (Number(getTaskFieldValue(row, field.id)) ? 1 : 0), 0); 163 return `<div class="summary-total"><strong>${checked}</strong><span>checked</span></div>`; 164 } 165 return ''; 166} 167 168window.openSummaryFunctionMenu = (event, fieldId) => { 169 event.stopPropagation(); 170 const field = getFieldById(fieldId); 171 if (!field) return; 172 const config = normalizeNumberFieldConfig(parseFieldConfig(field)); 173 const currentFunc = config.summaryFunction; 174 175 const existing = document.querySelector('.summary-popover'); 176 if (existing) existing.remove(); 177 178 const popover = document.createElement('div'); 179 popover.className = 'summary-popover'; 180 popover.innerHTML = NUMBER_SUMMARY_FUNCTIONS.map(f => ` 181 <button class="${f.value === currentFunc ? 'active' : ''}" onclick="setFieldSummaryFunction(${fieldId}, '${f.value}')"> 182 ${escapeHtml(f.label)} 183 ${f.value === currentFunc ? '<i class="bi bi-check"></i>' : ''} 184 </button> 185 `).join(''); 186 187 document.body.appendChild(popover); 188 189 const rect = event.currentTarget.getBoundingClientRect(); 190 popover.style.left = `${rect.left}px`; 191 popover.style.top = `${rect.bottom + 5}px`; 192 193 // Prevent popover from going off-screen 194 const popoverRect = popover.getBoundingClientRect(); 195 if (popoverRect.right > window.innerWidth) { 196 popover.style.left = `${window.innerWidth - popoverRect.width - 10}px`; 197 } 198 199 const closer = () => { 200 popover.remove(); 201 document.removeEventListener('click', closer); 202 }; 203 setTimeout(() => document.addEventListener('click', closer), 10); 204}; 205 206window.setFieldSummaryFunction = async (fieldId, func) => { 207 if (!state.currentBoard) return; 208 const field = getFieldById(fieldId); 209 if (!field) return; 210 const config = parseFieldConfig(field); 211 config.summaryFunction = func; 212 213 try { 214 await api(`/api/boards/${state.currentBoard}/fields/${fieldId}`, { 215 method: 'PATCH', 216 body: JSON.stringify({ config }) 217 }); 218 await selectBoard(state.currentBoard); 219 } catch (error) { 220 alert(error.message); 221 } 222}; 223 224function buildGroupedTasks(tasks) { 225 if ((state.boardGroupBy || 'group') === 'group') { 226 const byColumnId = new Map(); 227 tasks.forEach((task) => { 228 const key = Number(task.column_id); 229 if (!byColumnId.has(key)) byColumnId.set(key, []); 230 byColumnId.get(key).push(task); 231 }); 232 const groups = (state.currentBoardData?.columns || []) 233 .filter((c) => !Number(c.hidden)) 234 .map((column) => ({ 235 key: `group_${column.id}`, 236 columnId: Number(column.id), 237 label: column.name, 238 position: Number(column.position || 0), 239 color: column.color, 240 rows: byColumnId.get(Number(column.id)) || [] 241 })); 242 const visibleGroups = hasActiveBoardFiltering() ? groups.filter((group) => group.rows.length > 0) : groups; 243 return sortBoardGroups(visibleGroups); 244 } 245 const map = new Map(); 246 tasks.forEach((task) => {
247 const key = taskGroupLabel(task); 248 if (!map.has(key)) map.set(key, []); 249 map.get(key).push(task); 250 }); 251 return sortBoardGroups([...map.entries()].map(([label, rows], index) => ({ key: label, columnId: null, label, position: index, rows }))); 252} 253 254function getTaskIdsInGroupOrder(columnId) { 255 const numericColumnId = Number(columnId); 256 return (state.currentBoardData?.tasks || []) 257 .filter((task) => Number(task.column_id) === numericColumnId) 258 .sort((a, b) => { 259 const ap = Number(a.position || 0); 260 const bp = Number(b.position || 0); 261 if (ap !== bp) return ap - bp; 262 return Number(a.id || 0) - Number(b.id || 0); 263 }) 264 .map((task) => Number(task.id)) 265 .filter(Boolean); 266} 267 268function applyTaskOrderLocally(columnId, orderedTaskIds) { 269 const numericColumnId = Number(columnId); 270 if (!state.currentBoardData?.tasks || !numericColumnId) return; 271 const orderByTaskId = new Map( 272 orderedTaskIds.map((taskId, index) => [Number(taskId), index]) 273 ); 274 state.currentBoardData.tasks.forEach((task) => { 275 const taskId = Number(task.id); 276 if (!orderByTaskId.has(taskId)) return; 277 task.column_id = numericColumnId; 278 task.position = orderByTaskId.get(taskId); 279 }); 280 markBoardDataChanged(); 281} 282 283// Persist the current board's sort server-side (fire-and-forget): a sort set 284// on a board holds for that board everywhere, always â deploys, devices and 285// other boards' sorts can no longer change it. Viewers without edit rights 286// keep a session-only sort (the save 403s silently). 287function persistBoardSortBy() { 288 if (!state.currentBoard) return; 289 api(`/api/boards/${state.currentBoard}/sort`, { 290 method: 'PUT', 291 body: JSON.stringify({ sortBy: state.boardSortBy || 'default' }) 292 }).catch(() => {}); 293} 294 295function useDefaultTaskOrderForManualReorder() { 296 if ((state.boardSortBy || 'default') === 'default') return; 297 state.boardSortBy = 'default'; 298 localStorage.setItem('atix_board_sort_by', state.boardSortBy); 299 persistBoardSortBy(); 300} 301 302async function saveTaskOrder(columnId, orderedTaskIds, taskId = 0) { 303 if (!state.currentBoard || !columnId || !orderedTaskIds?.length) return; 304 const payload = { 305 columnId: Number(columnId), 306 orderedTaskIds: orderedTaskIds.map((id) => Number(id)).filter(Boolean) 307 }; 308 const numericTaskId = Number(taskId || 0); 309 if (numericTaskId) payload.taskId = numericTaskId; 310 await api(`/api/boards/${state.currentBoard}/task-order`, { 311 method: 'PATCH', 312 body: JSON.stringify(payload) 313 }); 314 applyTaskOrderLocally(columnId, payload.orderedTaskIds); 315} 316 317window.moveTaskToGroupTop = async (taskId) => { 318 const numericTaskId = Number(taskId); 319 const task = getTaskById(numericTaskId); 320 const columnId = Number(task?.column_id || 0); 321 if (!state.currentBoard || !task || !columnId) return; 322 323 useDefaultTaskOrderForManualReorder(); 324 const orderedTaskIds = getTaskIdsInGroupOrder(columnId); 325 const nextOrder = [ 326 numericTaskId, 327 ...orderedTaskIds.filter((id) => Number(id) !== numericTaskId) 328 ]; 329 330 try { 331 await saveTaskOrder(columnId, nextOrder, numericTaskId); 332 await selectBoard(state.currentBoard); 333 showToast('Moved item to the top of its group.', 'success'); 334 } catch (error) { 335 alert(error.message); 336 } 337}; 338 339window.moveTaskToGroupBottom = async (taskId) => { 340 const numericTaskId = Number(taskId); 341 const task = getTaskById(numericTaskId); 342 const columnId = Number(task?.column_id || 0); 343 if (!state.currentBoard || !task || !columnId) return; 344 345 useDefaultTaskOrderForManualReorder(); 346 const orderedTaskIds = getTaskIdsInGroupOrder(columnId); 347 const nextOrder = [ 348 ...orderedTaskIds.filter((id) => Number(id) !== numericTaskId), 349 numericTaskId 350 ]; 351 352 try { 353 await saveTaskOrder(columnId, nextOrder, numericTaskId); 354 await selectBoard(state.currentBoard); 355 showToast('Moved item to the bottom of its group.', 'success'); 356 } catch (error) { 357 alert(error.message); 358 } 359};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.