feat(dashboard): prochain jalon et pastille de santé sur les cartes (#17, #18)

Bloc jalon (progression Gitea, échéance FR, retard, ETA journal
avec signal après-échéance — mêmes définitions que le bandeau
du board) et point santé vert/orange/rouge dérivé de
_project_health, info-bulle et lien vers /health du projet.
This commit is contained in:
Spark
2026-10-11 22:52:22 -04:00
parent b4dc8f0d4f
commit b33b67fcb4
3 changed files with 291 additions and 9 deletions
+200 -1
View File
@@ -489,21 +489,133 @@ def _parse_order(raw: str) -> list[int]:
# ── Routes: Pages ─────────────────────────────────────────
async def _next_milestone(owner: str, repo: str) -> dict | None:
"""Next open milestone for a project card (issue #17).
The dated milestone with the nearest deadline wins (undated
ones last). Progress and counts come from Gitea's own milestone
counters; the ETA reuses the repo's journal throughput, exactly
like the board banner — one definition of "late", everywhere.
"""
try:
milestones = await gitea.get_milestones(owner, repo, state="open")
except Exception as exc:
logger.debug("next milestone failed for %s/%s: %s", owner, repo, exc)
return None
open_ms = [m for m in milestones if m.get("state", "open") == "open"]
if not open_ms:
return None
def _key(m: dict):
due = _parse_datetime(str(m.get("due_on") or ""))
return (0, due) if due is not None else (1, None)
open_ms.sort(key=_key)
m = open_ms[0]
opened = m.get("open_issues") or 0
closed = m.get("closed_issues") or 0
total = opened + closed
due_dt = _parse_datetime(str(m.get("due_on") or ""))
due_date = due_dt.date() if due_dt else None
today = datetime.now(timezone.utc).date()
weekly = forecast_service.weekly_closures(
get_events(repo_full_name=f"{owner}/{repo}", limit=2000)
)
est = forecast_service.estimate(opened, weekly)
return {
"title": m.get("title", ""),
"total": total,
"closed": closed,
"pct": round(100 * closed / total) if total else 0,
"due_label": (
forecast_service.french_date(due_date, short=True) if due_date else None
),
"late": bool(due_date and due_date < today and opened > 0),
"eta_label": forecast_service.eta_label(est) if est else None,
"eta_late": bool(
est and due_date and not est.get("done") and est["central"] > due_date
),
}
def _health_dot(health: dict) -> dict:
"""Traffic-light summary of a project's health (issue #18).
Rules (single definition, shared with /health): red when
zombies pile up (≥3, or ≥2 stuck in QA/Review); orange on any
zombie or when the slowest column's median dwell reaches
twice the aging threshold; green otherwise.
"""
zombies = health.get("zombies") or []
stuck = [z for z in zombies if z.get("column") in ("qa", "review")]
slowest = health.get("slowest")
median = slowest.get("median_days") if slowest else None
aging = get_settings().aging_days
if len(zombies) >= 3 or len(stuck) >= 2:
level = "red"
elif zombies or (median is not None and median >= 2 * aging):
level = "orange"
else:
level = "green"
parts = []
if zombies:
parts.append(f"{len(zombies)} zombie(s)")
if slowest:
parts.append(
f"goulot : {COLUMN_LABELS[slowest['column']]}"
f" (médiane {median} j)"
)
return {
"level": level,
"tooltip": " · ".join(parts) if parts
else "Aucun signal — voir la santé du projet",
}
@app.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):
"""Main dashboard — all projects at a glance."""
repos = await gitea.search_repos()
active, inactive, archived = classify_repos(repos)
try:
starred_names = {
r.get("full_name") for r in await gitea.list_starred()
}
except Exception as exc: # stars are decoration, never fatal (#20)
logger.debug("starred list failed: %s", exc)
starred_names = set()
for repo_info in active:
full_name = repo_info["full_name"]
owner, _, repo = full_name.partition("/")
repo_info["forecast_label"] = _repo_forecast_label(
repo_info["full_name"], repo_info.get("open_issues_count") or 0
full_name, repo_info.get("open_issues_count") or 0
)
repo_info["next_milestone"] = await _next_milestone(owner, repo)
try:
health = await _project_health(owner, repo)
repo_info["health_dot"] = _health_dot(health)
except Exception as exc:
logger.debug("health dot failed for %s: %s", full_name, exc)
repo_info["health_dot"] = None
repo_info["starred"] = full_name in starred_names
for repo_info in inactive:
repo_info["starred"] = repo_info["full_name"] in starred_names
for repo_info in active:
repo_info["home_tab"] = "active"
for repo_info in inactive:
repo_info["home_tab"] = "inactive"
for repo_info in archived:
repo_info["home_tab"] = "archived"
pinned = [r for r in active + inactive if r.get("starred")]
return render(
request,
"dashboard.html",
active_repos=active,
inactive_repos=inactive,
archived_repos=archived,
active_listed=[r for r in active if not r.get("starred")],
inactive_listed=[r for r in inactive if not r.get("starred")],
pinned_repos=pinned,
stats=get_stats(),
COLUMN_LABELS=COLUMN_LABELS,
COLUMN_COLORS=COLUMN_COLORS,
@@ -512,6 +624,22 @@ async def dashboard(request: Request):
)
@app.post("/api/projects/{owner}/{repo}/star")
async def api_project_star(
request: Request,
owner: str,
repo: str,
starred: str = Form(...),
csrf_token: str = Form(""),
):
"""Pin / unpin a project via its Gitea star (issue #20)."""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
want = starred == "1"
await gitea.set_starred(owner, repo, want)
return JSONResponse({"ok": True, "starred": want})
@app.get("/board/{owner}/{repo}", response_class=HTMLResponse)
async def board(request: Request, owner: str, repo: str, milestone: str = ""):
"""Kanban board for a specific repo (optionally one milestone)."""
@@ -790,6 +918,77 @@ def _excerpt(body: str | None, limit: int = 280) -> str:
return text[:limit] + ("…" if len(text) > limit else "")
@app.get("/project/{owner}/{repo}", response_class=HTMLResponse)
async def project_page(request: Request, owner: str, repo: str):
"""Project home (issue #25): milestones, health summary,
forecast, recent journal activity and notes for one repo —
an assembly of existing bricks, one truth per block."""
full_name = f"{owner}/{repo}"
repo_info = await gitea.get_repo(owner, repo)
try:
raw_milestones = await gitea.get_milestones(owner, repo, state="open")
except Exception as exc:
logger.debug("project milestones failed for %s: %s", full_name, exc)
raw_milestones = []
weekly = forecast_service.weekly_closures(
get_events(repo_full_name=full_name, limit=2000)
)
throughput = forecast_service.throughput_stats(weekly)
today = datetime.now(timezone.utc).date()
milestones = []
for m in raw_milestones:
if m.get("state", "open") != "open":
continue
opened = m.get("open_issues") or 0
closed = m.get("closed_issues") or 0
total = opened + closed
due_dt = _parse_datetime(str(m.get("due_on") or ""))
due_date = due_dt.date() if due_dt else None
est = forecast_service.estimate(opened, weekly)
milestones.append(
{
"title": m.get("title", ""),
"total": total,
"closed": closed,
"open": opened,
"pct": round(100 * closed / total) if total else 0,
"due_label": (
forecast_service.french_date(due_date, short=True)
if due_date else None
),
"late": bool(due_date and due_date < today and opened > 0),
"eta_label": forecast_service.eta_label(est) if est else None,
}
)
try:
health = await _project_health(owner, repo)
health_dot = _health_dot(health)
except Exception as exc:
logger.debug("project health failed for %s: %s", full_name, exc)
health, health_dot = None, None
events = [
e for e in get_events(limit=300)
if e["repo_full_name"] == full_name
][:10]
return render(
request,
"project.html",
repo_info=repo_info,
full_name=full_name,
owner=owner,
repo=repo,
milestones=milestones,
throughput=throughput,
health=health,
health_dot=health_dot,
events=events,
notes=get_notes(full_name),
EVENT_ICONS=EVENT_ICONS,
COLUMN_LABELS=COLUMN_LABELS,
COLUMN_COLORS=COLUMN_COLORS,
)
@app.get("/triage", response_class=HTMLResponse)
async def triage_picker(request: Request):
"""Triage mode (issue #11): pick a project by its backlog size."""