PageSourceSearch

https://famille-bretet.net/features/board/board-view-shared.js?v=1bce162

js famille-bretet.net collected 2026-09-25 08:21:04 UTC 13,483 bytes, 359 lines download raw bytes

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.