/** * Kanbante — dynamic board (issue #15). * * The board reflects reality without a manual refresh: any change * (another session, Gitea via webhook/labels, capture) is pushed * over SSE; the client then fetches server-rendered card fragments * from /api/board/ and swaps only the columns that changed, * animating surviving cards to their new place (FLIP). A discreet * 45 s poll covers installs without Gitea webhooks. Local drags are * never interrupted: a refresh arriving mid-drag waits for its end. * Filters, scroll, the detail drawer and Sortable all survive, * because only column contents are replaced — never the page. */ (function () { var match = window.location.pathname.match(/^\/board\/(.+)$/); if (!match) return; var repoPath = match[1].replace(/\/$/, ''); var milestone = new URLSearchParams(window.location.search).get('milestone') || ''; var apiUrl = '/api/board/' + repoPath + (milestone ? '?milestone=' + encodeURIComponent(milestone) : ''); var repoFullName = repoPath.split('/').map(decodeURIComponent).join('/'); var lastEventAt = Date.now(); var inFlight = false; var queued = false; var debounce = null; var failedOnce = false; function signature(html) { var h = 0; for (var i = 0; i < html.length; i++) { h = ((h << 5) - h + html.charCodeAt(i)) | 0; } return html.length + ':' + h; } function captureRects() { var rects = {}; document.querySelectorAll('.kanban-card').forEach(function (card) { rects[card.getAttribute('data-issue')] = card.getBoundingClientRect(); }); return rects; } function playFlip(before) { if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; document.querySelectorAll('.kanban-card').forEach(function (card) { var key = card.getAttribute('data-issue'); var old = before[key]; if (!old) { card.animate( [{ opacity: 0, transform: 'scale(0.96)' }, { opacity: 1, transform: 'none' }], { duration: 220, easing: 'ease-out' } ); return; } var now = card.getBoundingClientRect(); var dx = old.left - now.left; var dy = old.top - now.top; if (dx || dy) { card.animate( [{ transform: 'translate(' + dx + 'px,' + dy + 'px)' }, { transform: 'none' }], { duration: 280, easing: 'ease-in-out' } ); } }); } function showFallbackPill() { if (document.getElementById('live-pill')) return; var pill = document.createElement('button'); pill.id = 'live-pill'; pill.type = 'button'; pill.textContent = '🔄 Actualisation automatique en échec — cliquer pour recharger'; pill.addEventListener('click', function () { window.location.reload(); }); document.body.appendChild(pill); } function applyData(data) { var before = captureRects(); var changed = false; Object.keys(data.columns).forEach(function (col) { var container = document.querySelector('.column-cards[data-column="' + col + '"]'); if (!container) return; var payload = data.columns[col]; var sig = signature(payload.html); if (container.getAttribute('data-sig') === sig) return; container.innerHTML = payload.html; container.setAttribute('data-sig', sig); changed = true; }); if (!changed) return; playFlip(before); if (window.kanbanteUpdateCounts) window.kanbanteUpdateCounts(); if (window.kanbanteRefreshHints) window.kanbanteRefreshHints(); if (window.kanbanteApplyFilters) window.kanbanteApplyFilters(); } function refresh() { if (window.__kanbanteDragging) { setTimeout(refresh, 700); // retry once the local drag ends return; } if (inFlight) { queued = true; return; } inFlight = true; fetch(apiUrl, { headers: { 'Accept': 'application/json' } }) .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) .then(function (data) { failedOnce = false; applyData(data); }) .catch(function () { if (!failedOnce) { failedOnce = true; showFallbackPill(); } }) .finally(function () { inFlight = false; if (queued) { queued = false; refresh(); } }); } function scheduleRefresh() { clearTimeout(debounce); debounce = setTimeout(refresh, 400); } // 1. Push: SSE events about this repo (internal moves broadcast // {"type": "board-changed"}; webhooks broadcast their payload). if (window.EventSource) { var source = new EventSource('/api/stream'); source.addEventListener('gitea', function (e) { try { var payload = JSON.parse(e.data); if (payload.repo && payload.repo !== repoFullName) return; } catch (err) { /* unparseable -> refresh anyway */ } lastEventAt = Date.now(); scheduleRefresh(); }); source.addEventListener('error', function () { /* EventSource retries itself */ }); } // 2. Fallback poll (no webhooks configured, or SSE missed // something): only when visible and quiet for a full cycle. setInterval(function () { if (document.visibilityState === 'visible' && Date.now() - lastEventAt >= 45000) { lastEventAt = Date.now(); refresh(); } }, 5000); // 3. Coming back to the tab: catch up immediately. document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible') refresh(); }); })();