feat(forecast): prévisions au débit réel du journal (issue #7)
app/forecast.py: fermetures hebdo (8 sem.), débit moy/min/max sur 4 semaines complètes, ETA en fourchette, silence sans historique. Dashboard (épuisement du backlog), bandeau milestone (alerte échéance), page /forecast (sparkline SVG, verdicts), /api/metrics étendu (weekly_closures, throughput_4w).
This commit is contained in:
+119
@@ -0,0 +1,119 @@
|
|||||||
|
"""Forecasts (roadmap #7): honest ETAs from the event journal.
|
||||||
|
|
||||||
|
Throughput = issues closed per week, counted from board_events.
|
||||||
|
Projections are deliberately given as a RANGE (best/worst weekly
|
||||||
|
rate over the last 4 complete weeks) rather than a fake-precise
|
||||||
|
date, and are withheld entirely when the journal has no closures
|
||||||
|
to learn from — "not enough history" is an answer, too.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
|
MONTHS_FR = [
|
||||||
|
"janvier", "février", "mars", "avril", "mai", "juin",
|
||||||
|
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
|
||||||
|
]
|
||||||
|
MONTHS_FR_SHORT = [
|
||||||
|
"janv.", "févr.", "mars", "avr.", "mai", "juin",
|
||||||
|
"juil.", "août", "sept.", "oct.", "nov.", "déc.",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def french_date(d: date, short: bool = False) -> str:
|
||||||
|
months = MONTHS_FR_SHORT if short else MONTHS_FR
|
||||||
|
return f"{d.day} {months[d.month - 1]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _event_date(event: dict) -> date | None:
|
||||||
|
raw = str(event.get("created_at") or "")
|
||||||
|
try:
|
||||||
|
return datetime.strptime(raw[:10], "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def weekly_closures(events: list[dict], weeks: int = 8,
|
||||||
|
today: date | None = None) -> list[dict]:
|
||||||
|
"""Closure counts per ISO week, oldest first, current week last."""
|
||||||
|
today = today or date.today()
|
||||||
|
current_monday = today - timedelta(days=today.weekday())
|
||||||
|
buckets: list[dict] = []
|
||||||
|
for i in range(weeks - 1, -1, -1):
|
||||||
|
start = current_monday - timedelta(weeks=i)
|
||||||
|
buckets.append({"start": start, "count": 0})
|
||||||
|
by_start = {b["start"]: b for b in buckets}
|
||||||
|
for event in events:
|
||||||
|
if event.get("event_type") != "closed":
|
||||||
|
continue
|
||||||
|
day = _event_date(event)
|
||||||
|
if day is None:
|
||||||
|
continue
|
||||||
|
monday = day - timedelta(days=day.weekday())
|
||||||
|
if monday in by_start:
|
||||||
|
by_start[monday]["count"] += 1
|
||||||
|
return buckets
|
||||||
|
|
||||||
|
|
||||||
|
def throughput_stats(weekly: list[dict], last: int = 4) -> dict | None:
|
||||||
|
"""avg/min/max closures per week over the last COMPLETE weeks.
|
||||||
|
|
||||||
|
The current (partial) week is excluded: on Monday morning it
|
||||||
|
would crush the average for no reason. Returns None when the
|
||||||
|
complete weeks contain no closure at all.
|
||||||
|
"""
|
||||||
|
complete = weekly[:-1][-last:] if len(weekly) > 1 else []
|
||||||
|
if not complete:
|
||||||
|
return None
|
||||||
|
counts = [b["count"] for b in complete]
|
||||||
|
if sum(counts) == 0:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"avg": sum(counts) / len(counts),
|
||||||
|
"min": min(counts),
|
||||||
|
"max": max(counts),
|
||||||
|
"weeks": len(counts),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def estimate(remaining: int, weekly: list[dict],
|
||||||
|
today: date | None = None) -> dict | None:
|
||||||
|
"""Projected completion for `remaining` items at the real pace.
|
||||||
|
|
||||||
|
Returns {"done": True} when nothing remains, None when there is
|
||||||
|
no throughput to project from, else dates: central (avg rate),
|
||||||
|
earliest (best week), latest (worst nonzero week, if any).
|
||||||
|
"""
|
||||||
|
today = today or date.today()
|
||||||
|
if remaining <= 0:
|
||||||
|
return {"done": True}
|
||||||
|
stats = throughput_stats(weekly)
|
||||||
|
if stats is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def at_rate(rate: float) -> date:
|
||||||
|
days = remaining / rate * 7
|
||||||
|
return today + timedelta(days=max(1, round(days)))
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"done": False,
|
||||||
|
"avg_per_week": round(stats["avg"], 1),
|
||||||
|
"central": at_rate(stats["avg"]),
|
||||||
|
"earliest": at_rate(stats["max"]) if stats["max"] > 0 else None,
|
||||||
|
"latest": at_rate(stats["min"]) if stats["min"] > 0 else None,
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def eta_label(est: dict | None, done_text: str = "terminé") -> str | None:
|
||||||
|
"""One-line French label for banners and cards (or None)."""
|
||||||
|
if est is None:
|
||||||
|
return None
|
||||||
|
if est.get("done"):
|
||||||
|
return f"✅ {done_text}"
|
||||||
|
label = f"à ce rythme ≈ {french_date(est['central'], short=True)}"
|
||||||
|
if est.get("earliest") and est.get("latest"):
|
||||||
|
label += (
|
||||||
|
f" (fourchette {french_date(est['earliest'], short=True)}"
|
||||||
|
f" – {french_date(est['latest'], short=True)})"
|
||||||
|
)
|
||||||
|
return label
|
||||||
@@ -16,12 +16,14 @@
|
|||||||
<nav class="nav-pills">
|
<nav class="nav-pills">
|
||||||
<a href="/" class="nav-pill active">🏠 Projets</a>
|
<a href="/" class="nav-pill active">🏠 Projets</a>
|
||||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||||
|
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||||
|
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -86,6 +88,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<span>{{ repo.open_issues_count or 0 }} issues ouvertes</span>
|
<span>{{ repo.open_issues_count or 0 }} issues ouvertes</span>
|
||||||
|
{% if repo.forecast_label %}
|
||||||
|
<span class="forecast-line" title="Prévision d'après le débit réel du journal (fermetures par semaine)">🔮 {{ repo.forecast_label }}</span>
|
||||||
|
{% endif %}
|
||||||
{% if full in stats %}
|
{% if full in stats %}
|
||||||
<div class="mini-progress">
|
<div class="mini-progress">
|
||||||
{% for col in ['backlog','todo','in_progress','qa','review','done'] %}
|
{% for col in ['backlog','todo','in_progress','qa','review','done'] %}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||||
|
<title>Prévisions — Kanbante</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="app-header">
|
||||||
|
<a href="/" class="logo">📋 Kanbante</a>
|
||||||
|
<nav class="nav-pills">
|
||||||
|
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||||
|
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||||
|
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||||
|
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||||
|
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||||
|
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||||
|
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||||
|
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||||
|
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||||
|
<a href="/forecast" class="nav-pill active">🔮 Prévisions</a>
|
||||||
|
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||||
|
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="focus-main">
|
||||||
|
<section class="briefing-card">
|
||||||
|
<h1>🔮 Prévisions — « on finit quand ? »</h1>
|
||||||
|
<p>Réponse honnête, calculée depuis le journal : fermetures réelles par semaine (8 dernières), débit moyen/min/max sur les 4 dernières semaines complètes, et fourchette plutôt que faux chiffre précis. Sans historique de fermetures, aucune prévision n'est affichée.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% for project in projects %}
|
||||||
|
<section class="briefing-card forecast-card">
|
||||||
|
<h2>📂 {{ project.full_name }}</h2>
|
||||||
|
<div class="forecast-flex">
|
||||||
|
<svg class="sparkline" viewBox="0 0 168 48" role="img" aria-label="Fermetures par semaine">
|
||||||
|
{% for bar in project.bars %}
|
||||||
|
{% set px = (bar.h * 0.42)|round|int %}
|
||||||
|
<rect x="{{ loop.index0 * 21 }}" y="{{ 44 - px }}" width="15" height="{{ px if px > 0 else 1 }}" rx="2">
|
||||||
|
<title>{{ bar.label }} : {{ bar.count }} fermée(s)</title>
|
||||||
|
</rect>
|
||||||
|
{% endfor %}
|
||||||
|
</svg>
|
||||||
|
<div class="forecast-nums">
|
||||||
|
{% if project.stats %}
|
||||||
|
<p>Débit : <strong>{{ project.stats.avg }} / semaine</strong> en moyenne (min {{ project.stats.min }}, max {{ project.stats.max }})</p>
|
||||||
|
{% else %}
|
||||||
|
<p>Débit : <strong>pas assez d'historique</strong> — aucune fermeture récente dans le journal.</p>
|
||||||
|
{% endif %}
|
||||||
|
<p>Restant ouvert : <strong>{{ project.remaining }}</strong>
|
||||||
|
{% if project.eta_label %} · 🔮 backlog {{ project.eta_label }}{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if project.milestones %}
|
||||||
|
<ul class="forecast-milestones">
|
||||||
|
{% for ms in project.milestones %}
|
||||||
|
<li>🎯 <strong>{{ ms.title }}</strong> — {{ ms.remaining }} restante(s){% if ms.due_on %} · échéance {{ ms.due_on }}{% endif %}{% if ms.eta %} · 🔮 {{ ms.eta }}{% endif %}{% if ms.verdict %} · {{ ms.verdict }}{% endif %}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% else %}
|
||||||
|
<div class="briefing-card"><p>Aucun projet actif.</p></div>
|
||||||
|
{% endfor %}
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
<span>Kanbante v{{ version }} · prévisions = journal des fermetures, pas boule de cristal</span>
|
||||||
|
</footer>
|
||||||
|
{% include "_palette.html" %}
|
||||||
|
<script src="/static/js/palette.js" defer></script>
|
||||||
|
<script src="/static/js/live.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user