diff --git a/app/digest.py b/app/digest.py index 229ae7f..7977a17 100644 --- a/app/digest.py +++ b/app/digest.py @@ -143,7 +143,7 @@ async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None: { "protocolVersion": MCP_PROTOCOL_VERSION, "capabilities": {}, - "clientInfo": {"name": "kanbante", "version": "0.8.0"}, + "clientInfo": {"name": "kanbante", "version": "0.9.0"}, }, req_id=1, ) diff --git a/app/main.py b/app/main.py index 0fbe265..7d99d10 100644 --- a/app/main.py +++ b/app/main.py @@ -258,6 +258,16 @@ class LiveHub: hub = LiveHub() +async def _publish_board_changed(repo_full_name: str) -> None: + """Tell every connected client this repo's board changed (#15). + + Called after each mutation Kanbante knows about (move, create, + capture, label toggle). Webhooks broadcast separately with a + richer payload; board clients accept both shapes. + """ + await hub.broadcast({"repo": repo_full_name, "type": "board-changed"}) + + # ── Templates ────────────────────────────────────────────── _tpl_env = Environment( @@ -554,6 +564,67 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""): ) +def _board_columns_for( + owner: str, repo: str, all_items: list[dict], milestone: str = "" +) -> tuple[dict[str, list[dict]], list[dict], dict]: + """Reconcile + merge pipeline shared by the board page and its + JSON twin (issue #15). Returns (columns, display_issues, state).""" + full_name = f"{owner}/{repo}" + issues = [i for i in all_items if _is_issue(i)] + open_issues = [i for i in issues if i.get("state") == "open"] + closed_numbers = {i["number"] for i in issues if i.get("state") != "open"} + open_numbers = {i["number"] for i in open_issues} + changes = reconcile_board_state(full_name, open_numbers, closed_numbers) + for number in changes["closed_numbers"]: + log_event(full_name, number, "closed", to_column="done", source="gitea") + _adopt_label_columns(full_name, open_issues, get_board_state(full_name)) + display_issues = open_issues + if milestone: + def _in_ms(issue: dict) -> bool: + ms = issue.get("milestone") or {} + return bool(ms) and ( + str(ms.get("id")) == milestone or ms.get("title") == milestone + ) + + display_issues = [i for i in open_issues if _in_ms(i)] + board_state = get_board_state(full_name) + columns = _merge_issues_with_board(display_issues, board_state) + return columns, display_issues, board_state + + +@app.get("/api/board/{owner}/{repo}") +async def api_board(owner: str, repo: str, milestone: str = ""): + """The board as JSON + server-rendered card fragments (issue #15). + + The board page swaps these fragments in place when the board + changes elsewhere — same merge/reconcile pipeline as the HTML + page, so label adoptions and closures are reflected identically. + """ + full_name = f"{owner}/{repo}" + all_items = await gitea.get_repo_issues(owner, repo, state="all") + columns, display_issues, board_state = _board_columns_for( + owner, repo, all_items, milestone + ) + settings = get_settings() + ages = _card_ages(full_name, display_issues, board_state) + card_template = _tpl_env.get_template("_card.html") + payload: dict[str, dict] = {} + for col in KANBAN_COLUMNS: + cards = columns[col] + if cards: + html = "".join( + card_template.render( + issue=issue, ages=ages, + aging_days=settings.aging_days, full_name=full_name, + ) + for issue in cards + ) + else: + html = '

Glissez une carte ici

' + payload[col] = {"count": len(cards), "html": html} + return {"repo": full_name, "columns": payload} + + def _card_ages( full_name: str, open_issues: list[dict], board: dict[str, list[dict]] ) -> dict[int, dict]: @@ -1052,6 +1123,7 @@ async def _do_capture( repo_full_name, issue["number"], "created", to_column=column, source="capture", ) + await _publish_board_changed(repo_full_name) return { "number": issue["number"], "title": issue.get("title", title), @@ -1500,6 +1572,7 @@ async def api_move( "Label sync failed for %s#%s: %s", repo_full_name, issue_number, exc ) result["label_sync"] = "failed" + await _publish_board_changed(repo_full_name) return result @@ -1534,6 +1607,7 @@ async def api_create_issue( issue = await gitea.create_issue(owner, repo, title, body) move_issue(repo_full_name, issue["number"], column) log_event(repo_full_name, issue["number"], "created", to_column=column) + await _publish_board_changed(repo_full_name) return JSONResponse( { "ok": True, @@ -1736,6 +1810,7 @@ async def api_toggle_issue_label( await gitea.add_issue_label(owner, repo, number, label_id) else: await gitea.remove_issue_label(owner, repo, number, label_id) + await _publish_board_changed(f"{owner}/{repo}") issue = await gitea.get_issue(owner, repo, number) return { "ok": True, diff --git a/app/templates/_card.html b/app/templates/_card.html new file mode 100644 index 0000000..63db4e7 --- /dev/null +++ b/app/templates/_card.html @@ -0,0 +1,33 @@ +
+
+ #{{ issue.number }} + {% if issue.html_url %} + ↗ + {% endif %} +
+
{{ issue.title }}
+ {% if issue.labels %} +
+ {% for label in issue.labels %} + + {{ label.name }} + + {% endfor %} +
+ {% endif %} +
+ {% if issue.assignee %} + {{ issue.assignee.login }} + {% endif %} + {{ issue.created_at[:10] }} + {% set age = ages.get(issue.number) %} + {% if age and age.days >= aging_days %} + ⏳ {{ age.days }} j + {% endif %} +
+
diff --git a/app/templates/board.html b/app/templates/board.html index cda4938..c7406b1 100644 --- a/app/templates/board.html +++ b/app/templates/board.html @@ -90,39 +90,7 @@

Glissez une carte ici

{% endif %} {% for issue in columns[col] %} -
-
- #{{ issue.number }} - {% if issue.html_url %} - ↗ - {% endif %} -
-
{{ issue.title }}
- {% if issue.labels %} -
- {% for label in issue.labels %} - - {{ label.name }} - - {% endfor %} -
- {% endif %} -
- {% if issue.assignee %} - {{ issue.assignee.login }} - {% endif %} - {{ issue.created_at[:10] }} - {% set age = ages.get(issue.number) %} - {% if age and age.days >= aging_days %} - ⏳ {{ age.days }} j - {% endif %} -
-
+{% include "_card.html" %} {% endfor %} @@ -206,7 +174,7 @@ Mis à jour {{ repo_info.updated_at[:10] }} {% endif %} - + {% include "_palette.html" %} diff --git a/static/js/board-live.js b/static/js/board-live.js new file mode 100644 index 0000000..a087e52 --- /dev/null +++ b/static/js/board-live.js @@ -0,0 +1,148 @@ +/** + * 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(); + }); +})(); diff --git a/static/js/board.js b/static/js/board.js index 5195854..16ca88f 100644 --- a/static/js/board.js +++ b/static/js/board.js @@ -152,6 +152,12 @@ document.addEventListener('DOMContentLoaded', () => { }); } + // Exposed for board-live.js (issue #15): after a remote refresh + // swaps column fragments, filters/counts/hints must be recomputed. + window.kanbanteApplyFilters = applyFilters; + window.kanbanteRefreshHints = refreshEmptyHints; + window.kanbanteUpdateCounts = updateCounts; + // Keyboard: "/" jumps to the filter box. document.addEventListener('keydown', (e) => { if (e.key === '/' && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName || '')) {