diff --git a/app/config.py b/app/config.py index 719d56f..705d086 100644 --- a/app/config.py +++ b/app/config.py @@ -78,6 +78,9 @@ Environment variables: column, min_age_days, label, wip_over. Actions: add_label, notify, move_top. A rule can never close an issue. + KANBANTE_ZOMBIE_DAYS Days without any activity after + which an open card is a "zombie" + on /health (issue #9). Default 30. """ import os @@ -86,7 +89,7 @@ from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent -APP_VERSION = "0.13.0" +APP_VERSION = "0.14.0" def _env_bool(name: str, default: bool) -> bool: @@ -154,6 +157,7 @@ class Settings: obsigate_mcp_args: str = '{"title": "{title}", "content": "{content}"}' briefing_include_standup: bool = False rules_raw: str = "" + zombie_days: int = 30 @property def auth_enabled(self) -> bool: @@ -198,4 +202,5 @@ def get_settings() -> Settings: ), briefing_include_standup=_env_bool("KANBANTE_BRIEFING_INCLUDE_STANDUP", False), rules_raw=os.getenv("KANBANTE_RULES", ""), + zombie_days=_env_int("KANBANTE_ZOMBIE_DAYS", 30), ) diff --git a/app/flow.py b/app/flow.py new file mode 100644 index 0000000..4859be7 --- /dev/null +++ b/app/flow.py @@ -0,0 +1,123 @@ +"""Flow health (roadmap #9): bottlenecks and zombie cards. + +Boards show state; this module reads the event journal for health: +how long cards actually stay in each column (dwell samples are the +intervals between an entry and the next move/close), and which +open cards have had no activity for a long time (zombies). +""" + +import statistics +from datetime import datetime + +from app.models import KANBAN_COLUMNS + + +def parse_ts(raw) -> datetime | None: + """Parse journal ('YYYY-MM-DD HH:MM:SS') and Gitea ISO stamps.""" + text = str(raw or "").strip() + if not text: + return None + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"): + try: + return datetime.strptime(text[:19], fmt) + except ValueError: + continue + try: + return datetime.strptime(text[:10], "%Y-%m-%d") + except ValueError: + return None + + +def column_dwell(events: list[dict]) -> dict[str, list[float]]: + """Dwell samples (in days) per column, rebuilt from the journal. + + A stay starts when a card enters a column (created / moved / + reopened) and ends at its next move or close. The first stay of + a card whose journal starts mid-flight is unknowable and is + simply not sampled. + """ + by_issue: dict[tuple[str, int], list[dict]] = {} + for event in events: + by_issue.setdefault( + (event["repo_full_name"], event["issue_number"]), [] + ).append(event) + dwell: dict[str, list[float]] = {} + for issue_events in by_issue.values(): + ordered = sorted( + issue_events, key=lambda e: str(e.get("created_at") or "") + ) + current: str | None = None + since: datetime | None = None + for event in ordered: + at = parse_ts(event.get("created_at")) + if at is None: + continue + kind = event["event_type"] + if kind == "created": + if current is None: + current = event.get("to_column") or "backlog" + since = at + elif kind == "moved": + if current is not None and since is not None: + dwell.setdefault(current, []).append( + (at - since).total_seconds() / 86400 + ) + elif current is None and event.get("from_column"): + # The stay in from_column began before the journal + # window: only the new stay can be timed. + pass + current = event.get("to_column") or current + since = at + elif kind == "closed": + if current is not None and since is not None: + dwell.setdefault(current, []).append( + (at - since).total_seconds() / 86400 + ) + current = "done" + since = at + elif kind == "reopened": + current = event.get("to_column") or "backlog" + since = at + return dwell + + +def summarize_dwell(dwell: dict[str, list[float]]) -> list[dict]: + """Median dwell per column, in board order.""" + summary = [] + for column in KANBAN_COLUMNS: + samples = dwell.get(column) or [] + if not samples: + continue + summary.append( + { + "column": column, + "count": len(samples), + "median_days": round(statistics.median(samples), 1), + "avg_days": round(sum(samples) / len(samples), 1), + } + ) + return summary + + +def slowest_column(summary: list[dict], min_samples: int = 2) -> dict | None: + """The bottleneck: slowest column (done excluded) with data.""" + candidates = [ + row for row in summary + if row["column"] != "done" and row["count"] >= min_samples + ] + if not candidates: + return None + return max(candidates, key=lambda r: r["median_days"]) + + +def last_activity(events: list[dict]) -> dict[tuple[str, int], datetime]: + """Most recent journal timestamp per (repo, issue).""" + latest: dict[tuple[str, int], datetime] = {} + for event in events: + at = parse_ts(event.get("created_at")) + if at is None: + continue + key = (event["repo_full_name"], event["issue_number"]) + if key not in latest or at > latest[key]: + latest[key] = at + return latest diff --git a/app/templates/activity.html b/app/templates/activity.html index 844ca5f..756c875 100644 --- a/app/templates/activity.html +++ b/app/templates/activity.html @@ -26,6 +26,7 @@ 🧠 Digest 🔮 Prévisions ⚙️ Règles + 🩺 Santé 🖥️ Wallboard diff --git a/app/templates/board.html b/app/templates/board.html index bed9f1a..6ea1689 100644 --- a/app/templates/board.html +++ b/app/templates/board.html @@ -28,6 +28,7 @@ 🧠 Digest 🔮 Prévisions ⚙️ Règles + 🩺 Santé 🖥️ Wallboard {% if repo_info.html_url %} diff --git a/app/templates/briefing.html b/app/templates/briefing.html index 6d04274..f0f7dce 100644 --- a/app/templates/briefing.html +++ b/app/templates/briefing.html @@ -25,6 +25,7 @@ 🧠 Digest 🔮 Prévisions ⚙️ Règles + 🩺 Santé 🖥️ Wallboard diff --git a/app/templates/capture.html b/app/templates/capture.html index ecb9f54..fbf97da 100644 --- a/app/templates/capture.html +++ b/app/templates/capture.html @@ -23,6 +23,7 @@ 🧠 Digest 🔮 Prévisions ⚙️ Règles + 🩺 Santé 🖥️ Wallboard diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index fd8f73b..50d2d6e 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -26,6 +26,7 @@ 🧠 Digest 🔮 Prévisions ⚙️ Règles + 🩺 Santé 🖥️ Wallboard diff --git a/app/templates/focus.html b/app/templates/focus.html index dd17d52..7dbed1b 100644 --- a/app/templates/focus.html +++ b/app/templates/focus.html @@ -25,6 +25,7 @@ 🧠 Digest 🔮 Prévisions ⚙️ Règles + 🩺 Santé 🖥️ Wallboard {{ groups|map(attribute='cards')|map('length')|sum }} cartes en cours diff --git a/app/templates/health.html b/app/templates/health.html new file mode 100644 index 0000000..b26f2c1 --- /dev/null +++ b/app/templates/health.html @@ -0,0 +1,53 @@ + + + + + + + Santé du flux — Kanbante + + + +
+ + +
+ +
+
+

🩺 Santé du flux

+

Les boards montrent l'état ; cette page montre la santé : quelle colonne accumule (temps médian passé dans chaque colonne, d'après le journal) et quelles cartes sont devenues des zombies (aucune activité depuis le seuil configuré).

+
+ + {% for project in projects %} +
+

📂 {{ project.full_name }} ({{ project.open_count }} ouverte(s))

+ {% if project.slowest %} +

🐌 Colonne la plus lente : {{ project.column_labels[project.slowest.column] }} — médiane {{ project.slowest.median_days }} j ({{ project.slowest.count }} séjours mesurés)

+ {% else %} +

🐌 Pas encore assez de séjours mesurés pour nommer un goulot.

+ {% endif %} +

🧟 Zombies (≥ {{ project.zombie_days }} j sans activité) : {{ project.zombies|length }} + — voir le détail et agir →

+
+ {% else %} +

Aucun projet actif.

+ {% endfor %} +
+ + {% include "_palette.html" %} + + + + diff --git a/app/templates/health_project.html b/app/templates/health_project.html new file mode 100644 index 0000000..98df4b9 --- /dev/null +++ b/app/templates/health_project.html @@ -0,0 +1,82 @@ + + + + + + + Santé — {{ full_name }} — Kanbante + + + +
+ + +
+ +
+ {% if health is none %} +
+

🩺 {{ full_name }}

+

Projet introuvable.

+
+ {% else %} +
+

🩺 {{ full_name }}

+

⏱️ Temps passé par colonne (journal)

+ {% if health.dwell %} + + + + {% for row in health.dwell %} + + + + + + + {% endfor %} + +
ColonneSéjours mesurésMédianeMoyenne
{{ health.column_labels[row.column] }}{% if health.slowest and row.column == health.slowest.column %} 🐌{% endif %}{{ row.count }}{{ row.median_days }} j{{ row.avg_days }} j
+ {% if health.slowest %} +

🐌 Goulot : {{ health.column_labels[health.slowest.column] }} — c'est là que les cartes dorment le plus longtemps (médiane {{ health.slowest.median_days }} j).

+ {% endif %} + {% else %} +

Pas encore de séjours mesurables dans le journal de ce projet — il faut des déplacements journalisés pour calculer les temps de colonne.

+ {% endif %} +
+ +
+

🧟 Cartes zombies — aucune activité depuis ≥ {{ health.zombie_days }} jours

+ {% if health.zombies %} + +

« Fermer » déplace la carte en Terminé (l'issue est fermée dans Gitea par la synchro habituelle). « Toujours pertinent ? » poste un commentaire du même texte sur l'issue.

+ {% else %} +

Aucune carte zombie. Le flux est propre. ✨

+ {% endif %} +
+ {% endif %} +
+ + {% include "_palette.html" %} + + + + diff --git a/app/templates/my.html b/app/templates/my.html index 346534c..d035dac 100644 --- a/app/templates/my.html +++ b/app/templates/my.html @@ -24,6 +24,7 @@ 🧠 Digest 🔮 Prévisions ⚙️ Règles + 🩺 Santé 🖥️ Wallboard diff --git a/app/templates/search.html b/app/templates/search.html index 3796f4a..0dca2b3 100644 --- a/app/templates/search.html +++ b/app/templates/search.html @@ -24,6 +24,7 @@ 🧠 Digest 🔮 Prévisions ⚙️ Règles + 🩺 Santé 🖥️ Wallboard diff --git a/app/templates/sprint.html b/app/templates/sprint.html index 87d38bd..a3088b7 100644 --- a/app/templates/sprint.html +++ b/app/templates/sprint.html @@ -17,6 +17,7 @@ 🌅 Aujourd'hui 🔮 Prévisions ⚙️ Règles + 🩺 Santé diff --git a/app/templates/today.html b/app/templates/today.html index c304d01..addfcfc 100644 --- a/app/templates/today.html +++ b/app/templates/today.html @@ -23,6 +23,7 @@ 🧠 Digest 🔮 Prévisions ⚙️ Règles + 🩺 Santé 🖥️ Wallboard diff --git a/app/templates/triage.html b/app/templates/triage.html index 8182e32..c93977a 100644 --- a/app/templates/triage.html +++ b/app/templates/triage.html @@ -23,6 +23,7 @@ 🧠 Digest 🔮 Prévisions ⚙️ Règles + 🩺 Santé 🖥️ Wallboard diff --git a/static/js/health.js b/static/js/health.js new file mode 100644 index 0000000..90820c0 --- /dev/null +++ b/static/js/health.js @@ -0,0 +1,45 @@ +/** + * Kanbante — Flow health (issue #9): quick actions on zombie cards. + * Close/backlog reuse /api/move; the nudge reuses the comments API. + */ +(function () { + var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || ''; + + function post(url, fields) { + var data = new FormData(); + Object.keys(fields).forEach(function (k) { data.append(k, fields[k]); }); + data.append('csrf_token', csrf); + return fetch(url, { method: 'POST', body: data }).then(function (r) { + return r.json().catch(function () { return {}; }).then(function (body) { + if (!r.ok || body.ok === false) throw new Error(body.error || ('HTTP ' + r.status)); + return body; + }); + }); + } + + document.addEventListener('click', function (e) { + var btn = e.target.closest('[data-zombie]'); + if (!btn) return; + var repo = btn.getAttribute('data-repo'); + var number = btn.getAttribute('data-number'); + var column = btn.getAttribute('data-column'); + var action = btn.getAttribute('data-zombie'); + var parts = repo.split('/'); + btn.disabled = true; + var done = function () { location.reload(); }; + var fail = function (err) { btn.disabled = false; alert(err.message); }; + if (action === 'comment') { + post('/api/issues/' + parts[0] + '/' + parts[1] + '/' + number + '/comments', { + body: '🧟 Cette carte est inactive depuis un moment — toujours pertinent ?' + }).then(done).catch(fail); + } else { + post('/api/move', { + repo_full_name: repo, + issue_number: number, + from_column: column, + to_column: action === 'close' ? 'done' : 'backlog', + to_order: number + }).then(done).catch(fail); + } + }); +})(); diff --git a/static/js/palette.js b/static/js/palette.js index 8370613..107f2fb 100644 --- a/static/js/palette.js +++ b/static/js/palette.js @@ -17,6 +17,7 @@ { icon: '🔮', label: 'Prévisions — on finit quand ?', href: '/forecast' }, { icon: '🗣️', label: 'Standup automatique — hier, aujourd\'hui, blocages', href: '/standup' }, { icon: '⚙️', label: 'Règles d\'automatisation', href: '/rules' }, + { icon: '🩺', label: 'Santé du flux — goulots et zombies', href: '/health' }, { icon: '👤', label: 'Mes issues', href: '/my' }, { icon: '📰', label: 'Activité — journal des événements', href: '/activity' }, { icon: '📊', label: 'Briefing quotidien', href: '/briefing' },