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."""
+39 -8
View File
@@ -49,14 +49,21 @@
{% macro project_card(repo, archived=False) %}
{% set full = repo.full_name %}
<div class="project-card{% if archived %} archived{% endif %}"
data-full="{{ full }}" data-home="{{ repo.home_tab or '' }}"
data-search="{{ (repo.name ~ ' ' ~ repo.full_name ~ ' ' ~ (repo.description or ''))|lower }}"
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
<div class="card-header">
<span class="org-tag">{{ repo.owner.login }}</span>
{% if repo.health_dot %}
<a href="/health/{{ full }}" class="health-dot {{ repo.health_dot.level }}" title="Santé du flux — {{ repo.health_dot.tooltip }}" aria-label="Santé du flux : {{ repo.health_dot.tooltip }}"></a>
{% endif %}
<span class="date">{{ repo.updated_at[:10] }}</span>
{% if not archived %}
<button type="button" class="star-btn" data-full="{{ full }}" data-starred="{{ 1 if repo.starred else 0 }}" title="{{ 'Désépingler (étoile Gitea)' if repo.starred else 'Épingler (étoile Gitea)' }}" aria-label="Épingler {{ repo.name }}">{{ '⭐' if repo.starred else '☆' }}</button>
{% endif %}
</div>
<h3>
<a href="/board/{{ repo.owner.login }}/{{ repo.name }}">{{ repo.name }}</a>
<a href="/project/{{ repo.owner.login }}/{{ repo.name }}">{{ repo.name }}</a>
</h3>
<p class="desc">{{ repo.description or '—' }}</p>
{% if full in stats %}
@@ -68,8 +75,22 @@
</div>
{% endif %}
{% endif %}
{% if repo.next_milestone %}
{% set ms = repo.next_milestone %}
<div class="ms-block">
<div class="ms-line">
<span class="ms-title">🎯 {{ ms.title }}</span>
{% if ms.due_label %}<span class="ms-due{% if ms.late %} late{% endif %}">{{ '⚠️ ' if ms.late }}échéance {{ ms.due_label }}</span>{% endif %}
</div>
{% if ms.total %}
<div class="progress ms-progress"><span style="width: {{ ms.pct }}%"></span></div>
<div class="ms-meta">{{ ms.closed }}/{{ ms.total }} terminées{% if ms.eta_label %} · 🔮 {{ ms.eta_label }}{% if ms.eta_late %} — ⚠️ après l'échéance{% endif %}{% endif %}</div>
{% endif %}
</div>
{% endif %}
<div class="card-footer">
<span>{{ repo.open_issues_count or 0 }} issues ouvertes</span>
<a href="/board/{{ full }}" class="card-board-link">📋 Board</a>
{% 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 %}
@@ -89,21 +110,29 @@
</div>
{% endmacro %}
<div x-show="tab === 'active'" class="project-grid">
{% for repo in active_repos %}{{ project_card(repo) }}{% endfor %}
{% if not active_repos %}
<!-- Épinglés (#20) : les dépôts étoilés dans Gitea, hors onglets. -->
<section id="pinned-section"{% if not pinned_repos %} hidden{% endif %}>
<h3 class="pinned-title">⭐ Épinglés</h3>
<div class="project-grid" id="pinned-grid">
{% for repo in pinned_repos %}{{ project_card(repo) }}{% endfor %}
</div>
</section>
<div x-show="tab === 'active'" class="project-grid" id="grid-active">
{% for repo in active_listed %}{{ project_card(repo) }}{% endfor %}
{% if not active_listed and not pinned_repos %}
<div class="empty-state">🌱 Aucun projet actif en ce moment. Les projets bougent dès qu'une issue ou un commit arrive dans Gitea.</div>
{% endif %}
</div>
<div x-show="tab === 'inactive'" class="project-grid">
{% for repo in inactive_repos %}{{ project_card(repo) }}{% endfor %}
{% if not inactive_repos %}
<div x-show="tab === 'inactive'" class="project-grid" id="grid-inactive">
{% for repo in inactive_listed %}{{ project_card(repo) }}{% endfor %}
{% if not inactive_listed %}
<div class="empty-state">✨ Rien d'endormi : tous vos projets ont eu de l'activité récemment.</div>
{% endif %}
</div>
<div x-show="tab === 'archived'" class="project-grid">
<div x-show="tab === 'archived'" class="project-grid" id="grid-archived">
{% for repo in archived_repos %}{{ project_card(repo, archived=True) }}{% endfor %}
{% if not archived_repos %}
<div class="empty-state">🗄️ Aucun projet archivé. L'archivage se fait dans Gitea (Réglages du dépôt).</div>
@@ -115,6 +144,8 @@
Kanbante v{{ version }} · {{ active_repos|length + inactive_repos|length + archived_repos|length }} projets sur Gitea · actifs = mis à jour depuis {{ active_window_days }} jours
</footer>
<script src="/static/js/live.js" defer></script>
<script>window.KANBANTE_CSRF = "{{ csrf_token }}";</script>
<script src="/static/js/stars.js" defer></script>
<script>
document.addEventListener('keydown', function (e) {
if (e.key === '/' && !/INPUT|TEXTAREA/.test(document.activeElement?.tagName || '')) {
+52
View File
@@ -1319,3 +1319,55 @@ body.sidebar-collapsed .side-foot { justify-content: center; }
.side-foot { display: none; }
.topbar-search { display: inline-flex; }
}
/* ── Dashboard vague 2 (0.17.0, #17 #18 #20) + page Projet (#25) ── */
.health-dot {
width: 11px; height: 11px; border-radius: 50%;
display: inline-block; flex-shrink: 0;
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.06);
}
.health-dot.green { background: #34d399; }
.health-dot.orange { background: #f59e0b; }
.health-dot.red { background: #f87171; }
.star-btn {
background: transparent; border: none; cursor: pointer;
font-size: 1rem; line-height: 1; padding: 2px 4px; margin-left: auto;
color: #fbbf24;
}
.star-btn:hover { transform: scale(1.2); }
.star-btn:disabled { opacity: 0.5; }
.card-board-link { font-weight: 600; }
.ms-block { margin: 10px 0 2px; }
.ms-line { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }
.ms-title { font-weight: 600; font-size: 0.92rem; }
.ms-due { font-size: 0.8rem; color: var(--text-2); white-space: nowrap; }
.ms-due.late { color: #f87171; font-weight: 700; }
.ms-progress { margin: 6px 0 4px; }
.ms-meta { font-size: 0.8rem; color: var(--text-2); }
.pinned-title { margin: 18px 0 10px; font-size: 1.05rem; }
.project-main { max-width: 1180px; margin: 0 auto; padding: 26px 22px 10px; }
.proj-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; }
.proj-head h1 { margin: 0; font-size: 1.7rem; }
.proj-sub { color: var(--text-2); margin: 6px 0 0; }
.health-chip {
display: inline-flex; align-items: center; gap: 8px;
border: 1px solid var(--border); border-radius: 999px;
padding: 7px 14px; color: var(--text); background: var(--surface-1);
font-size: 0.88rem; font-weight: 600;
}
.health-chip:hover { text-decoration: none; border-color: var(--accent, #58a6ff); }
.proj-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 18px 0 22px; }
.proj-actions .nav-pill { border: 1px solid var(--border); }
.proj-primary { background: linear-gradient(135deg, rgba(124,92,252,.85), rgba(91,138,247,.85)); color: #fff !important; }
.proj-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
.proj-panel {
background: var(--surface-1); border: 1px solid var(--border);
border-radius: 14px; padding: 18px 20px;
}
.proj-panel h2 { margin: 0 0 12px; font-size: 1.05rem; }
.proj-ms { margin-bottom: 14px; }
.proj-ms:last-child { margin-bottom: 0; }
.proj-list { list-style: none; margin: 0 0 10px; padding: 0; display: flex; flex-direction: column; gap: 7px; font-size: 0.93rem; }
.proj-dim { color: var(--text-3); }
.proj-empty { color: var(--text-2); font-size: 0.92rem; }
.proj-notes-body { white-space: pre-wrap; font-size: 0.93rem; background: var(--surface-2); border-radius: 10px; padding: 12px 14px; }