From 8c889878eeda6cb738b182b0918c56c79090a4c9 Mon Sep 17 00:00:00 2001 From: Spark Date: Mon, 26 Oct 2026 10:48:45 -0400 Subject: [PATCH] =?UTF-8?q?feat(0.34.0):=20vue=20=C2=AB=20En=20vol=20parto?= =?UTF-8?q?ut=20=C2=BB=20/inflight=20(issue=20#24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - L'encours (colonnes en vol de l'espace) tous projets actifs, regroupé PAR colonne, trié par âge décroissant dans la colonne (pipeline _card_ages du board). - En-tête : total + compteurs par colonne + limite WIP globale (somme des projets qui en définissent). - Entrées Pilotage (menu latéral) et palette Ctrl+K ; SSE /api/stream via live.js comme les autres vues. - Réutilise _collect_focus_groups : aucun appel forge en plus. 286 tests verts (5 nouveaux, palier 23) ; vérifié en Chromium réel (356 cartes en vol sur les vraies données, dont la carte #24 elle-même). --- CHANGELOG.md | 28 +++++++++++++ app/config.py | 2 +- app/main.py | 83 +++++++++++++++++++++++++++++++++++++ app/templates/_header.html | 1 + app/templates/inflight.html | 81 ++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- static/css/style.css | 6 +++ static/js/palette.js | 1 + tests/test_palier23.py | 72 ++++++++++++++++++++++++++++++++ 9 files changed, 274 insertions(+), 2 deletions(-) create mode 100644 app/templates/inflight.html create mode 100644 tests/test_palier23.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f84ceb..4bbe012 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## 0.34.0 — 2026-10-26 + +### Ajouté — vue « En vol partout » /inflight (issue #24) + +Dernière carte du backlog de la feuille de route « Gestion +de projets », première du classement big picture (pilotage +quotidien des agents avant gestion de projet). + +- **Page /inflight** : tout l'encours (colonnes en vol de + l'espace — En cours, QA, Révision par défaut) **tous + projets actifs confondus, regroupé par colonne** — là où + /focus groupe par projet et /my par assignation. +- Chaque carte : projet (lien board), titre, étiquettes, + assigné, et **âge dans la colonne** (badge ⏳, teinté + selon les seuils d'ancienneté) — les groupes sont **triés + par âge décroissant** : les plus vieilles cartes, le vrai + signal, en premier. +- En-tête : total en vol + compteurs par colonne ; + **limite WIP globale** affichée quand des projets en + définissent (somme par colonne). +- Entrées : menu latéral **Pilotage → 🛩️ En vol** et + palette Ctrl+K. Rafraîchissement doux par le SSE + `/api/stream` comme les autres vues (fermer une issue la + retire au rechargement). +- Réutilise le pipeline du Focus (`_collect_focus_groups`, + `_card_ages`) : aucun appel forge supplémentaire. + + ## 0.33.0 — 2026-10-26 ### Ajouté — filtrage professionnel sur « Mes issues » (/my) diff --git a/app/config.py b/app/config.py index 2508b7a..0626c88 100644 --- a/app/config.py +++ b/app/config.py @@ -177,7 +177,7 @@ from urllib.parse import urlparse BASE_DIR = Path(__file__).resolve().parent.parent -APP_VERSION = "0.33.0" +APP_VERSION = "0.34.0" def _env_bool(name: str, default: bool) -> bool: diff --git a/app/main.py b/app/main.py index 9deeacc..87b60d3 100644 --- a/app/main.py +++ b/app/main.py @@ -2118,6 +2118,7 @@ _NAV_BY_TEMPLATE = { "capture.html": "capture", "digest.html": "digest", "forecast.html": "forecast", + "inflight.html": "inflight", "rules.html": "rules", "health.html": "health", "health_project.html": "health", @@ -4030,6 +4031,88 @@ async def focus(request: Request): ) +async def _collect_inflight(ws_repos, ws_id: int) -> dict: + """The work in flight, grouped BY COLUMN (issue #24). + + Reuses the Focus pipeline (per-project groups of cards in + the workspace's in-flight columns), then pivots: one group + per column, cards sorted by age in column, oldest first — + the oldest cards are the signal. Ages come from the same + _card_ages pipeline as the board. + """ + per_repo = await _collect_focus_groups(ws_repos, ws_id) + columns: dict[str, list[dict]] = { + key: [] for key in bcols.focus_keys(ws_id) + } + for group in per_repo: + full_name = group["full_name"] + cid = group.get("conn_id", registry.default_id) + open_issues = [c["issue"] for c in group["cards"]] + board = get_board_state( + full_name, workspace_id=ws_id, connection_id=cid + ) + ages = _card_ages( + full_name, open_issues, board, + workspace_id=ws_id, connection_id=cid, + ) + for card in group["cards"]: + age = ages.get(card["issue"]["number"]) or {} + columns.setdefault(card["column"], []).append( + { + "issue": card["issue"], + "repo": full_name, + "column": card["column"], + "age_days": age.get("days", 0), + "age_since": age.get("since", ""), + } + ) + groups = [] + total = 0 + for key in bcols.keys(ws_id): + cards = columns.get(key) + if not cards: + continue + cards.sort(key=lambda c: (-c["age_days"], c["repo"], c["issue"]["number"])) + groups.append({"column": key, "cards": cards}) + total += len(cards) + for key, cards in columns.items(): + if key not in {g["column"] for g in groups} and cards: + cards.sort(key=lambda c: (-c["age_days"], c["repo"], c["issue"]["number"])) + groups.append({"column": key, "cards": cards}) + total += len(cards) + # Limites WIP globales lisibles : somme, par colonne, des + # limites définies dans les projets (0.33.0 : lecture seule). + settings = get_settings() + wip_globals: dict[str, int] = {} + for _repo, limits in (settings.wip_limits or {}).items(): + for col, limit in (limits or {}).items(): + try: + wip_globals[col] = wip_globals.get(col, 0) + int(limit) + except (TypeError, ValueError): + continue + wip_globals = {c: v for c, v in wip_globals.items() if v > 0} + return {"groups": groups, "total": total, "wip_globals": wip_globals} + + +@app.get("/inflight", response_class=HTMLResponse) +async def inflight(request: Request): + """Everything in flight, all projects, grouped by column (#24).""" + workspace, _role = _current_workspace(request) + ws_id = workspace["id"] if workspace else 1 + ws_repos, _ws = await _workspace_repos(request) + data = await _collect_inflight(ws_repos, ws_id) + return render( + request, + "inflight.html", + groups=data["groups"], + total=data["total"], + wip_globals=data["wip_globals"], + column_labels=bcols.names(ws_id), + column_colors=bcols.colors(ws_id), + aging_days=get_settings().aging_days, + ) + + def _group_issue_results(issues: list[dict]) -> list[dict]: """Group search results by repo, annotated with the board column.""" by_repo: dict[str, list[dict]] = {} diff --git a/app/templates/_header.html b/app/templates/_header.html index ac96a8e..77b64c9 100644 --- a/app/templates/_header.html +++ b/app/templates/_header.html @@ -20,6 +20,7 @@ ("digest", "/digest", "🧠", "Digest"), ]), ("pilotage", "Pilotage", [ + ("inflight", "/inflight", "🛩️", "En vol"), ("forecast", "/forecast", "🔮", "Prévisions"), ("health", "/health", "🩺", "Santé"), ("rules", "/rules", "⚙️", "Règles"), diff --git a/app/templates/inflight.html b/app/templates/inflight.html new file mode 100644 index 0000000..af63974 --- /dev/null +++ b/app/templates/inflight.html @@ -0,0 +1,81 @@ + + + + + + En vol partout — Kanbante + + + + + {% include "_header.html" %} + +
+

Tout ce qui est en vol, tous projets actifs confondus — + regroupé par colonne, les cartes les plus anciennes d'abord.

+ +
+ 🛩️ {{ total }} carte{{ 's' if total != 1 }} en vol + {% for group in groups %} + + {{ column_labels[group.column] }} · {{ group.cards|length }}{% if wip_globals.get(group.column) %} / limite globale {{ wip_globals[group.column] }}{% endif %} + + {% endfor %} +
+ + {% if not groups %} +

🎉 Rien en vol pour le moment — aucun encours, aucune révision en attente.

+ {% endif %} + + {% for group in groups %} +
+

+ + {{ column_labels[group.column] }} + {{ group.cards|length }} + {% if wip_globals.get(group.column) %} + limite WIP globale : {{ wip_globals[group.column] }} (somme des projets qui en définissent une) + {% endif %} +

+
+ {% for card in group.cards %} +
+
+ #{{ card.issue.number }} + {{ card.repo }} + {% if card.issue.html_url %} + ↗ + {% endif %} +
+
{{ card.issue.title }}
+ {% if card.issue.labels %} +
+ {% for label in card.issue.labels %} + + {{ label.name }} + + {% endfor %} +
+ {% endif %} +
+ {% if card.issue.assignee %} + {{ card.issue.assignee.login }} + {% endif %} + ⏳ {{ card.age_days }} j dans la colonne +
+
+ {% endfor %} +
+
+ {% endfor %} +
+ + + +{% include "_palette.html" %} + + + diff --git a/pyproject.toml b/pyproject.toml index 2ca35c9..5b6ce32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kanbante" -version = "0.33.0" +version = "0.34.0" description = "Kanban project manager integrated with Gitea" requires-python = ">=3.11" dependencies = [ diff --git a/static/css/style.css b/static/css/style.css index 6652623..0fdf329 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1732,3 +1732,9 @@ body.sidebar-collapsed .ws-switch { display: none; } .filter-chip-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } .filter-chip-count { opacity: 0.7; font-size: 0.78rem; } .my-count { color: var(--muted, #9aa3b5); font-size: 0.88rem; white-space: nowrap; } + +/* ── En vol partout (0.34.0, issue #24) ── */ +.inflight-summary { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 10px 0 18px; } +.inflight-total { font-weight: 700; margin-right: 4px; } +.inflight-repo { color: var(--accent-2); font-weight: 600; font-size: 0.85rem; } +.inflight-wip { color: var(--muted, #9aa3b5); font-size: 0.8rem; font-weight: 400; margin-left: 8px; } diff --git a/static/js/palette.js b/static/js/palette.js index 107f2fb..09e53d5 100644 --- a/static/js/palette.js +++ b/static/js/palette.js @@ -13,6 +13,7 @@ var ACTIONS = [ { icon: '🏠', label: 'Projets — dashboard', href: '/' }, { icon: '🎯', label: 'Focus — en cours, QA, révision', href: '/focus' }, + { icon: '🛩️', label: 'En vol partout — encours tous projets, par colonne', href: '/inflight' }, { icon: '🌅', label: "Focus du jour — les 3 cartes d'aujourd'hui", href: '/today' }, { icon: '🔮', label: 'Prévisions — on finit quand ?', href: '/forecast' }, { icon: '🗣️', label: 'Standup automatique — hier, aujourd\'hui, blocages', href: '/standup' }, diff --git a/tests/test_palier23.py b/tests/test_palier23.py new file mode 100644 index 0000000..931fd58 --- /dev/null +++ b/tests/test_palier23.py @@ -0,0 +1,72 @@ +"""Palier 23 (0.34.0, issue #24): vue « En vol partout » — +l'encours tous projets, regroupé par colonne, plus vieilles +cartes d'abord.""" + +from datetime import datetime, timedelta, timezone + +from app import models + + +def _days_ago(days: int) -> str: + return (datetime.now(timezone.utc) - timedelta(days=days)).strftime( + "%Y-%m-%d %H:%M:%S" + ) + + +def test_inflight_groups_by_column_with_project(client): + models.move_issue("Projets/demo", 5, "in_progress") + models.move_issue("Projets/demo", 1, "review") + r = client.get("/inflight") + assert r.status_code == 200 + assert "2 cartes en vol" in r.text + assert "Tâche focus" in r.text + assert "Tâche un" in r.text + # Le projet est indiqué sur la carte. + assert "Projets/demo" in r.text + # Groupes par colonne, pas par projet. + assert "🚧 En cours" in r.text + assert "🔍 Révision" in r.text + # L'âge dans la colonne est affiché. + assert "dans la colonne" in r.text + + +def test_inflight_excludes_backlog_and_todo(client): + models.move_issue("Projets/demo", 5, "todo") + r = client.get("/inflight") + assert r.status_code == 200 + assert "Tâche focus" not in r.text + assert "Rien en vol" in r.text + + +def test_inflight_oldest_first(client): + # Deux cartes en cours : entrée il y a 10 j et il y a 2 j. + models.move_issue("Projets/demo", 5, "in_progress") + models.move_issue("Projets/demo", 1, "in_progress") + models.log_event( + "Projets/demo", 5, "moved", to_column="in_progress", + created_at=_days_ago(10), + ) + models.log_event( + "Projets/demo", 1, "moved", to_column="in_progress", + created_at=_days_ago(2), + ) + r = client.get("/inflight") + assert r.status_code == 200 + assert r.text.index("Tâche focus") < r.text.index("Tâche un") + + +def test_inflight_closed_issue_disappears(client, fake_gitea): + models.move_issue("Projets/demo", 5, "in_progress") + issue = next( + i for i in fake_gitea.issues["Projets/demo"] if i["number"] == 5 + ) + issue["state"] = "closed" + r = client.get("/inflight") + assert r.status_code == 200 + assert "Tâche focus" not in r.text + + +def test_inflight_nav_and_page_chrome(client): + r = client.get("/inflight") + assert 'href="/inflight"' in r.text # menu latéral Pilotage + assert "En vol partout" in r.text