// ═══════════════════════════════════════════════════════════════════ // diff.js — Безопасное diff-обновление списка заявок // // Подключить в index.html ПОСЛЕ state.js и keyboard.js: // // // Заменяет прямое присваивание allData = [...] в auth.js. // Вместо loadTasksFromServer присваивает данные через applyTasksDiff(). // ═══════════════════════════════════════════════════════════════════ // ── Утилита: Map задач по id ─────────────────────────────────────── function buildTaskMap(dataArr) { const map = new Map(); for (const item of dataArr) { map.set(String(item.task.id), item); } return map; } /** * Применяет новые данные к allData без потери состояния интерфейса. * * @param {Array} newRawTasks — массив tasks с сервера (до map/extractLastComment) * @param {boolean} silent — если true, тост об изменениях не показывается * @returns {{ added: number, changed: number, removed: number }} */ function applyTasksDiff(newRawTasks, silent = false) { // Собираем новый массив в том же формате, что и allData const newData = (newRawTasks || []).map(task => { const lc = extractLastComment(task); if (!lc && typeof forceComment !== 'undefined' && forceComment) { const tid = String(task.id); const cached = forcedCommentCache.get(tid); if (cached && cached.updatedat === task.updatedat) { return { task, lastComment: cached.lastComment }; } } return { task, lastComment: lc }; }); // ── Diff ─────────────────────────────────────────────────────────── const oldMap = buildTaskMap(allData); const newMap = buildTaskMap(newData); let added = 0, changed = 0, removed = 0; for (const [id, newItem] of newMap) { if (!oldMap.has(id)) { added++; } else { const old = oldMap.get(id); if (old.task.updatedat !== newItem.task.updatedat) { changed++; } } } for (const id of oldMap.keys()) { if (!newMap.has(id)) removed++; } // ── Сохраняем позицию активной строки ───────────────────────────── // activeRowIdx указывает на позицию в отфильтрованном/отображаемом списке. // После обновления allData порядок может измениться → ищем задачу по id. let preservedTaskId = null; if (typeof activeRowIdx !== 'undefined' && activeRowIdx !== null && allData[activeRowIdx]) { preservedTaskId = String(allData[activeRowIdx].task.id); } // Аналогично для открытой боковой панели let preservedModalTaskId = null; if (typeof currentModalIdx !== 'undefined' && currentModalIdx !== null && allData[currentModalIdx]) { preservedModalTaskId = String(allData[currentModalIdx].task.id); } // ── Применяем новый массив ───────────────────────────────────────── allData = newData; // ── Восстанавливаем activeRowIdx ─────────────────────────────────── if (preservedTaskId !== null) { const newIdx = allData.findIndex(i => String(i.task.id) === preservedTaskId); if (newIdx !== -1) { activeRowIdx = newIdx; } else { // Задача удалена — сдвигаем курсор к ближайшей activeRowIdx = Math.min(activeRowIdx, allData.length - 1); if (activeRowIdx < 0) activeRowIdx = null; } } // ── Восстанавливаем currentModalIdx ─────────────────────────────── if (preservedModalTaskId !== null) { const newModalIdx = allData.findIndex(i => String(i.task.id) === preservedModalTaskId); if (newModalIdx !== -1) { currentModalIdx = newModalIdx; } else { // Задача из открытого меню исчезла — закрываем меню currentModalIdx = null; const dc = document.getElementById('detail-col'); if (dc) dc.classList.add('hidden'); } } // ── Показать уведомление об изменениях ──────────────────────────── if (!silent && (added || changed || removed)) { const parts = []; if (added) parts.push(`+${added} новых`); if (changed) parts.push(`~${changed} изм.`); if (removed) parts.push(`−${removed} удалено`); if (typeof showToast === 'function') { showToast(`📋 Обновление: ${parts.join(', ')}`, 'info', 3500); } } return { added, changed, removed }; } // ═══════════════════════════════════════════════════════════════════ // Патч loadTasksFromServer: вместо прямого allData = [...] — applyTasksDiff // ═══════════════════════════════════════════════════════════════════ (function patchLoadTasksFromServer() { const _orig = window.loadTasksFromServer; if (typeof _orig !== 'function') { document.addEventListener('DOMContentLoaded', patchLoadTasksFromServer); return; } window.loadTasksFromServer = async function (silent = false) { if (!silent && !allData.length) { if (typeof showLoading === 'function') showLoading(); } const data = await apiGet('/api/tasks'); if (!data) return; const { added, changed, removed } = applyTasksDiff(data.tasks || [], silent); // Обновляем мета-данные (те же строки, что были в оригинале) lastKnownUpdatedAt = data.updated_at; if (data.cache_version !== undefined && data.cache_version !== null) { lastKnownCacheVersion = Number(data.cache_version) || lastKnownCacheVersion; } const el = id => document.getElementById(id); if (el('total-loaded')) el('total-loaded').textContent = allData.length; if (el('total-count')) el('total-count').textContent = allData.length; if (el('last-update')) el('last-update').textContent = data.updated_at ? new Date(data.updated_at).toLocaleTimeString('ru-RU') : '—'; document.getElementById('update-banner')?.classList.remove('visible'); if (typeof onTasksLoaded === 'function') onTasksLoaded(); if (typeof renderTable === 'function') renderTable(); if (typeof forceComment !== 'undefined' && forceComment) { if (typeof fetchMissingComments === 'function') fetchMissingComments(); } // Если открыта боковая панель — обновить её содержимое (данные задачи могли измениться) if (currentModalIdx !== null && typeof renderSidePanel === 'function') { renderSidePanel(currentModalIdx); } }; })();