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:
+200
-1
@@ -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."""
|
||||
|
||||
@@ -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 || '')) {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user