feat(live): board dynamique sans refresh (issue #15)

Diffusion SSE sur toutes les mutations (move, création, capture,
étiquettes) via _publish_board_changed; GET /api/board/{o}/{r}
(fragments _card.html rendus serveur, même pipeline que la page);
board-live.js: diff en place par colonne avec animation FLIP,
application différée pendant un drag, filtres/compteurs réappliqués,
repli par sondage 45 s sans webhooks. Carte extraite en partiel
_card.html partagé page/API.
This commit is contained in:
Spark
2026-10-11 12:59:54 -04:00
parent 1743c8c101
commit 1088dd0b48
6 changed files with 265 additions and 35 deletions
+1 -1
View File
@@ -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,
)
+75
View File
@@ -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 = '<p class="column-empty">Glissez une carte ici</p>'
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,
+33
View File
@@ -0,0 +1,33 @@
<div class="kanban-card"
data-issue="{{ issue.number }}"
data-repo="{{ full_name }}"
data-title="{{ issue.title|lower }}"
data-labels="{{ issue.labels|map(attribute='name')|join(',')|lower }}">
<div class="card-id">
#{{ issue.number }}
{% if issue.html_url %}
<a href="{{ issue.html_url }}" target="_blank" title="Ouvrir dans Gitea">↗</a>
{% endif %}
</div>
<div class="card-title">{{ issue.title }}</div>
{% if issue.labels %}
<div class="card-labels">
{% for label in issue.labels %}
<span class="label-tag" style="background: #{{ label.color }}30; color: #{{ label.color }}">
{{ label.name }}
</span>
{% endfor %}
</div>
{% endif %}
<div class="card-meta">
{% if issue.assignee %}
<img src="{{ issue.assignee.avatar_url }}" class="avatar" alt="{{ issue.assignee.login }}">
{% endif %}
<span class="card-date">{{ issue.created_at[:10] }}</span>
{% set age = ages.get(issue.number) %}
{% if age and age.days >= aging_days %}
<span class="age-badge{% if age.days >= aging_days * 2 %} stale{% endif %}"
title="Dans cette colonne depuis le {{ age.since[:10] }}">⏳ {{ age.days }} j</span>
{% endif %}
</div>
</div>
+2 -34
View File
@@ -90,39 +90,7 @@
<p class="column-empty">Glissez une carte ici</p>
{% endif %}
{% for issue in columns[col] %}
<div class="kanban-card"
data-issue="{{ issue.number }}"
data-repo="{{ full_name }}"
data-title="{{ issue.title|lower }}"
data-labels="{{ issue.labels|map(attribute='name')|join(',')|lower }}">
<div class="card-id">
#{{ issue.number }}
{% if issue.html_url %}
<a href="{{ issue.html_url }}" target="_blank" title="Ouvrir dans Gitea">↗</a>
{% endif %}
</div>
<div class="card-title">{{ issue.title }}</div>
{% if issue.labels %}
<div class="card-labels">
{% for label in issue.labels %}
<span class="label-tag" style="background: #{{ label.color }}30; color: #{{ label.color }}">
{{ label.name }}
</span>
{% endfor %}
</div>
{% endif %}
<div class="card-meta">
{% if issue.assignee %}
<img src="{{ issue.assignee.avatar_url }}" class="avatar" alt="{{ issue.assignee.login }}">
{% endif %}
<span class="card-date">{{ issue.created_at[:10] }}</span>
{% set age = ages.get(issue.number) %}
{% if age and age.days >= aging_days %}
<span class="age-badge{% if age.days >= aging_days * 2 %} stale{% endif %}"
title="Dans cette colonne depuis le {{ age.since[:10] }}">⏳ {{ age.days }} j</span>
{% endif %}
</div>
</div>
{% include "_card.html" %}
{% endfor %}
</div>
</div>
@@ -206,7 +174,7 @@
<span>Mis à jour {{ repo_info.updated_at[:10] }}</span>
{% endif %}
</footer>
<script src="/static/js/live.js" defer></script>
<script src="/static/js/board-live.js" defer></script>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
+148
View File
@@ -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/<repo> 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();
});
})();
+6
View File
@@ -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 || '')) {