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:
+1
-1
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user