Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7918b54943 | ||
|
|
3695bfac8a | ||
|
|
57d93c4f4b | ||
|
|
900590efed | ||
|
|
0aa991047f | ||
|
|
79122c20d0 | ||
|
|
3e3187d597 |
@@ -1,5 +1,60 @@
|
||||
# Changelog
|
||||
|
||||
## 0.20.0 — 2026-10-12
|
||||
|
||||
### Refonte de l'en-tête de la page Projets (demande de Bruno)
|
||||
|
||||
- **Titre sobre** : fini le « Bonjour 👋 Voici vos projets
|
||||
Gitea… » — un « Projets » net, un sous-titre discret, et
|
||||
l'en-tête occupe deux fois moins de hauteur qu'avant.
|
||||
- **Bande KPI unifiée** : les 4 statistiques (projets actifs,
|
||||
fermées · 30 j, déplacements · 30 j, temps de cycle moyen)
|
||||
vivent dans UNE barre bordée à séparateurs fins, chiffres en
|
||||
largeur tabulaire ; les indicateurs du journal mènent à la
|
||||
page Activité au clic.
|
||||
- **Barre d'outils unique** : onglets, filtre, tri,
|
||||
regroupement et bascule Grille/Liste — auparavant éparpillés
|
||||
sur trois rangées — sont alignés dans un seul cadre. Les
|
||||
onglets deviennent un contrôle segmenté avec pastilles de
|
||||
compte (plus de dégradé violet ni d'emojis 🟢💤🗄️), le filtre
|
||||
passe en rectangle sobre.
|
||||
- Aucun crochet fonctionnel touché (ids et liaisons Alpine
|
||||
inchangés) ; responsive vérifié en captures 1600 px et
|
||||
430 px sur les données réelles : aucun débordement, onglets
|
||||
fonctionnels. Les tuiles `.stat-tile` restent utilisées par
|
||||
le briefing quotidien.
|
||||
|
||||
## 0.19.0 — 2026-10-12
|
||||
|
||||
### Performance + navigation « Pilotage » (chantiers A + C, décidés par Bruno)
|
||||
|
||||
- **Parallélisation (A1)** : les pages inter-projets (dashboard,
|
||||
/health, /forecast, /triage, /wall) attendaient chaque dépôt à
|
||||
la queue leu leu ; elles utilisent maintenant un `asyncio.gather`
|
||||
borné (16) — le patron que /focus appliquait déjà.
|
||||
- **Cache stale-while-revalidate (A2)** : les listes Gitea
|
||||
(dépôts, issues, jalons, étoiles) sont servies immédiatement
|
||||
même périmées, et rafraîchies en tâche de fond. Fini le
|
||||
« mur » de rechargement à chaque expiration du cache (30 s) :
|
||||
seul le tout premier chargement après un démarrage paie le
|
||||
coût complet. Les mutations vident le cache et désarment les
|
||||
rafraîchissements en vol (aucune donnée périmée ressuscitée).
|
||||
- **Prévisions refondues (C1)** : /forecast devient un tableau de
|
||||
bord des jalons **tous projets** — une ligne par jalon (projet,
|
||||
échéance, progression, ETA, **écart**, débit), triée par risque,
|
||||
filtrable, colonnes triables ; le bloc jalon des cartes du
|
||||
dashboard y pointe directement (ancres). Le débit par projet
|
||||
reste en dessous, en tableau compact.
|
||||
- **Santé refondue (C2)** : /health devient un tableau trié par
|
||||
sévérité (pastille, ouvertes, zombies, goulot) ; la page santé
|
||||
d'un projet gagne un **sélecteur de projet** pour basculer sans
|
||||
repasser par l'index. Le tri des tableaux est mutualisé
|
||||
(`static/js/tablesort.js`, dashboard inclus).
|
||||
- Mesuré sur l'instance réelle (depuis le réseau de test) :
|
||||
pages chaudes 0,04–0,16 s ; le premier chargement à froid
|
||||
reste de quelques secondes — le chantier B (instantanés
|
||||
d'arrière-plan) demeure le levier pour le supprimer, si voulu.
|
||||
|
||||
## 0.18.0 — 2026-10-11
|
||||
|
||||
### Nouveautés — vague 2, suite (4 cartes de Bruno)
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
APP_VERSION = "0.18.0"
|
||||
APP_VERSION = "0.20.0"
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
|
||||
+1
-1
@@ -143,7 +143,7 @@ async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None:
|
||||
{
|
||||
"protocolVersion": MCP_PROTOCOL_VERSION,
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "kanbante", "version": "0.18.0"},
|
||||
"clientInfo": {"name": "kanbante", "version": "0.20.0"},
|
||||
},
|
||||
req_id=1,
|
||||
)
|
||||
|
||||
+79
-44
@@ -17,8 +17,10 @@ Changes from the 0.1.0 client:
|
||||
- .env loading targets the project root only (no hardcoded home path).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -60,6 +62,8 @@ class GiteaClient:
|
||||
)
|
||||
self._user: dict | None = None
|
||||
self._cache: dict[str, tuple[float, Any]] = {}
|
||||
self._refreshing: set[str] = set()
|
||||
self._cache_gen = 0
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self.client.aclose()
|
||||
@@ -105,24 +109,62 @@ class GiteaClient:
|
||||
page += 1
|
||||
return items
|
||||
|
||||
def _cache_get(self, key: str) -> Any | None:
|
||||
if self.cache_ttl <= 0:
|
||||
return None
|
||||
hit = self._cache.get(key)
|
||||
if hit and (time.monotonic() - hit[0]) < self.cache_ttl:
|
||||
return hit[1]
|
||||
return None
|
||||
|
||||
def _cache_put(self, key: str, value: Any) -> None:
|
||||
if self.cache_ttl > 0:
|
||||
self._cache[key] = (time.monotonic(), value)
|
||||
|
||||
def _cache_clear(self) -> None:
|
||||
self._cache.clear()
|
||||
# Invalidate in-flight background refreshes: a refresh that
|
||||
# started before a mutation must not resurrect stale data.
|
||||
self._cache_gen += 1
|
||||
|
||||
async def _cached_list(
|
||||
self, key: str, fetcher: Callable[[], Awaitable[Any]]
|
||||
) -> Any:
|
||||
"""Stale-while-revalidate cache for list endpoints (0.19.0).
|
||||
|
||||
Fresh (< TTL): serve the cache. Stale: serve the cached
|
||||
value IMMEDIATELY and refresh it in the background (once
|
||||
per key) — the user never waits on Gitea for a page when
|
||||
any earlier answer exists. Absent: fetch and wait.
|
||||
Mutations clear the cache (see _cache_clear), so writes are
|
||||
never hidden by a stale entry.
|
||||
"""
|
||||
if self.cache_ttl <= 0:
|
||||
return await fetcher()
|
||||
hit = self._cache.get(key)
|
||||
if hit is not None:
|
||||
if (time.monotonic() - hit[0]) < self.cache_ttl:
|
||||
return hit[1]
|
||||
if key not in self._refreshing:
|
||||
self._refreshing.add(key)
|
||||
asyncio.create_task(
|
||||
self._refresh_entry(key, fetcher, self._cache_gen)
|
||||
)
|
||||
return hit[1]
|
||||
value = await fetcher()
|
||||
self._cache_put(key, value)
|
||||
return value
|
||||
|
||||
async def _refresh_entry(
|
||||
self,
|
||||
key: str,
|
||||
fetcher: Callable[[], Awaitable[Any]],
|
||||
generation: int,
|
||||
) -> None:
|
||||
try:
|
||||
value = await fetcher()
|
||||
if generation == self._cache_gen:
|
||||
self._cache_put(key, value)
|
||||
except Exception as exc:
|
||||
logger.debug("background refresh failed for %s: %s", key, exc)
|
||||
finally:
|
||||
self._refreshing.discard(key)
|
||||
|
||||
def invalidate_cache(self) -> None:
|
||||
"""Drop every cached list response (called on webhook events)."""
|
||||
self._cache.clear()
|
||||
self._cache_clear()
|
||||
|
||||
# ── API ────────────────────────────────────────────────
|
||||
|
||||
@@ -141,20 +183,21 @@ class GiteaClient:
|
||||
return self._user
|
||||
|
||||
async def search_repos(self) -> list[dict]:
|
||||
"""All repos visible to the token owner (paginated, cached)."""
|
||||
cached = self._cache_get("search_repos")
|
||||
if cached is not None:
|
||||
return cached
|
||||
params: dict[str, Any] = {}
|
||||
try:
|
||||
user = await self.get_user()
|
||||
if user and user.get("id"):
|
||||
params["uid"] = user["id"]
|
||||
except Exception as exc: # no/invalid token → public search
|
||||
logger.debug("get_user failed, falling back to public search: %s", exc)
|
||||
repos = await self._get_all_pages("/repos/search", **params)
|
||||
self._cache_put("search_repos", repos)
|
||||
return repos
|
||||
"""All repos visible to the token owner (paginated, SWR-cached)."""
|
||||
|
||||
async def _fetch() -> list[dict]:
|
||||
params: dict[str, Any] = {}
|
||||
try:
|
||||
user = await self.get_user()
|
||||
if user and user.get("id"):
|
||||
params["uid"] = user["id"]
|
||||
except Exception as exc: # no/invalid token → public search
|
||||
logger.debug(
|
||||
"get_user failed, falling back to public search: %s", exc
|
||||
)
|
||||
return await self._get_all_pages("/repos/search", **params)
|
||||
|
||||
return await self._cached_list("search_repos", _fetch)
|
||||
|
||||
async def get_repo(self, owner: str, repo: str) -> dict:
|
||||
return await self._get(f"/repos/{owner}/{repo}")
|
||||
@@ -178,14 +221,12 @@ class GiteaClient:
|
||||
(see app.main._is_issue).
|
||||
"""
|
||||
key = f"issues:{owner}/{repo}:{state}"
|
||||
cached = self._cache_get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
issues = await self._get_all_pages(
|
||||
f"/repos/{owner}/{repo}/issues", state=state
|
||||
return await self._cached_list(
|
||||
key,
|
||||
lambda: self._get_all_pages(
|
||||
f"/repos/{owner}/{repo}/issues", state=state
|
||||
),
|
||||
)
|
||||
self._cache_put(key, issues)
|
||||
return issues
|
||||
|
||||
async def get_all_issues(
|
||||
self, repos: list[dict], state: str = "open"
|
||||
@@ -308,25 +349,19 @@ class GiteaClient:
|
||||
self, owner: str, repo: str, state: str = "open"
|
||||
) -> list[dict]:
|
||||
key = f"milestones:{owner}/{repo}:{state}"
|
||||
cached = self._cache_get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
milestones = await self._get_all_pages(
|
||||
f"/repos/{owner}/{repo}/milestones", state=state
|
||||
return await self._cached_list(
|
||||
key,
|
||||
lambda: self._get_all_pages(
|
||||
f"/repos/{owner}/{repo}/milestones", state=state
|
||||
),
|
||||
)
|
||||
self._cache_put(key, milestones)
|
||||
return milestones
|
||||
|
||||
async def list_starred(self) -> list[dict]:
|
||||
"""Repos starred by the token user (issue #20: pinning rides
|
||||
on Gitea stars — the forge stays the source of truth)."""
|
||||
key = "user:starred"
|
||||
cached = self._cache_get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
repos = await self._get_all_pages("/user/starred")
|
||||
self._cache_put(key, repos)
|
||||
return repos
|
||||
return await self._cached_list(
|
||||
"user:starred", lambda: self._get_all_pages("/user/starred")
|
||||
)
|
||||
|
||||
async def set_starred(self, owner: str, repo: str, starred: bool) -> None:
|
||||
"""Star / unstar a repo for the token user."""
|
||||
|
||||
+181
-70
@@ -378,6 +378,27 @@ def _parse_datetime(value: str | None) -> datetime | None:
|
||||
return None
|
||||
|
||||
|
||||
async def _parallel_map(items, fn, limit: int = 16) -> list:
|
||||
"""Run an async fn over items with bounded concurrency (0.19.0).
|
||||
|
||||
Cross-project pages used to await each repo in sequence, so the
|
||||
page took the SUM of every Gitea round trip. Gathering with a
|
||||
per-call semaphore (created here, bound to the current loop)
|
||||
cuts that to roughly the slowest single repo, without letting
|
||||
a big instance stampede Gitea. Exceptions come back in place —
|
||||
callers decide what a failed repo means for them.
|
||||
"""
|
||||
semaphore = asyncio.Semaphore(limit)
|
||||
|
||||
async def _run(item):
|
||||
async with semaphore:
|
||||
return await fn(item)
|
||||
|
||||
return await asyncio.gather(
|
||||
*(_run(item) for item in items), return_exceptions=True
|
||||
)
|
||||
|
||||
|
||||
def classify_repos(
|
||||
repos: list[dict], now: datetime | None = None
|
||||
) -> tuple[list[dict], list[dict], list[dict]]:
|
||||
@@ -585,7 +606,7 @@ async def dashboard(request: Request):
|
||||
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:
|
||||
async def _enrich_active(repo_info: dict) -> None:
|
||||
full_name = repo_info["full_name"]
|
||||
owner, _, repo = full_name.partition("/")
|
||||
repo_info["forecast_label"] = _repo_forecast_label(
|
||||
@@ -599,6 +620,8 @@ async def dashboard(request: Request):
|
||||
logger.debug("health dot failed for %s: %s", full_name, exc)
|
||||
repo_info["health_dot"] = None
|
||||
repo_info["starred"] = full_name in starred_names
|
||||
|
||||
await _parallel_map(active, _enrich_active)
|
||||
for repo_info in inactive:
|
||||
repo_info["starred"] = repo_info["full_name"] in starred_names
|
||||
activity_by_repo = get_project_activity()
|
||||
@@ -1013,19 +1036,18 @@ async def triage_picker(request: Request):
|
||||
"""Triage mode (issue #11): pick a project by its backlog size."""
|
||||
repos = await gitea.search_repos()
|
||||
active, _inactive, _archived = classify_repos(repos)
|
||||
projects = []
|
||||
for repo_info in active:
|
||||
|
||||
async def _one_triage(repo_info: dict) -> dict | None:
|
||||
owner, _, name = repo_info["full_name"].partition("/")
|
||||
try:
|
||||
all_items = await gitea.get_repo_issues(owner, name, state="all")
|
||||
except Exception:
|
||||
continue
|
||||
all_items = await gitea.get_repo_issues(owner, name, state="all")
|
||||
columns, _display, _state = _board_columns_for(owner, name, all_items)
|
||||
backlog = len(columns["backlog"])
|
||||
if backlog:
|
||||
projects.append(
|
||||
{"full_name": repo_info["full_name"], "backlog": backlog}
|
||||
)
|
||||
return {"full_name": repo_info["full_name"], "backlog": backlog}
|
||||
return None
|
||||
|
||||
results = await _parallel_map(active, _one_triage)
|
||||
projects = [r for r in results if isinstance(r, dict)]
|
||||
projects.sort(key=lambda p: -p["backlog"])
|
||||
return render(request, "triage.html", projects=projects)
|
||||
|
||||
@@ -1554,30 +1576,52 @@ async def health_index(request: Request):
|
||||
"""Flow health, global summary (issue #9)."""
|
||||
repos = await gitea.search_repos()
|
||||
active, _inactive, _archived = classify_repos(repos)
|
||||
projects = []
|
||||
for repo_info in active:
|
||||
|
||||
async def _one_health(repo_info: dict) -> dict:
|
||||
owner, _, name = repo_info["full_name"].partition("/")
|
||||
try:
|
||||
projects.append(await _project_health(owner, name))
|
||||
except Exception as exc:
|
||||
logger.warning("Health failed for %s: %s", repo_info["full_name"], exc)
|
||||
return await _project_health(owner, name)
|
||||
|
||||
results = await _parallel_map(active, _one_health)
|
||||
projects = []
|
||||
for repo_info, result in zip(active, results):
|
||||
if isinstance(result, Exception):
|
||||
logger.warning(
|
||||
"Health failed for %s: %s", repo_info["full_name"], result
|
||||
)
|
||||
continue
|
||||
result["dot"] = _health_dot(result)
|
||||
projects.append(result)
|
||||
severity_rank = {"red": 0, "orange": 1, "green": 2}
|
||||
projects.sort(
|
||||
key=lambda p: (
|
||||
severity_rank.get(p["dot"]["level"], 3),
|
||||
-len(p["zombies"]),
|
||||
)
|
||||
)
|
||||
return render(request, "health.html", projects=projects)
|
||||
|
||||
|
||||
@app.get("/health/{owner}/{repo}", response_class=HTMLResponse)
|
||||
async def health_project(request: Request, owner: str, repo: str):
|
||||
"""Flow health of one project: dwell per column + zombies."""
|
||||
"""Flow health of one project: dwell per column + zombies.
|
||||
|
||||
Passes the active project list so the page can offer a project
|
||||
switcher — no more back-to-the-index round trip (0.19.0, C).
|
||||
"""
|
||||
repos = await gitea.search_repos()
|
||||
known = {r["full_name"] for r in repos}
|
||||
if f"{owner}/{repo}" not in known:
|
||||
active, _inactive, _archived = classify_repos(repos)
|
||||
project_names = [r["full_name"] for r in active]
|
||||
if f"{owner}/{repo}" not in {r["full_name"] for r in repos}:
|
||||
return render(
|
||||
request, "health_project.html",
|
||||
health=None, full_name=f"{owner}/{repo}",
|
||||
project_names=project_names,
|
||||
)
|
||||
health = await _project_health(owner, repo)
|
||||
return render(
|
||||
request, "health_project.html",
|
||||
health=health, full_name=f"{owner}/{repo}",
|
||||
project_names=project_names,
|
||||
)
|
||||
|
||||
|
||||
@@ -1604,11 +1648,29 @@ async def standup_page(request: Request):
|
||||
|
||||
@app.get("/forecast", response_class=HTMLResponse)
|
||||
async def forecast_page(request: Request):
|
||||
"""Forecasts (issue #7): real-pace ETAs per project + milestone."""
|
||||
"""Forecasts (issue #7; portfolio table in 0.19.0, chantier C).
|
||||
|
||||
One row per open milestone across every active project,
|
||||
sorted by risk (largest ETA-vs-deadline gap first) — the
|
||||
cross-project question the old per-project card wall could
|
||||
not answer without reading everything. Per-project pace
|
||||
(weekly bars + backlog ETA) stays as a compact second table.
|
||||
"""
|
||||
repos = await gitea.search_repos()
|
||||
active, _inactive, _archived = classify_repos(repos)
|
||||
projects = []
|
||||
for repo_info in active:
|
||||
|
||||
async def _fetch_milestones(repo_info: dict) -> list[dict]:
|
||||
owner, _, name = repo_info["full_name"].partition("/")
|
||||
try:
|
||||
return await gitea.get_milestones(owner, name)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
ms_results = await _parallel_map(active, _fetch_milestones)
|
||||
|
||||
rows: list[dict] = []
|
||||
projects: list[dict] = []
|
||||
for repo_info, ms_list in zip(active, ms_results):
|
||||
full = repo_info["full_name"]
|
||||
owner, _, name = full.partition("/")
|
||||
weekly = forecast_service.weekly_closures(
|
||||
@@ -1623,50 +1685,100 @@ async def forecast_page(request: Request):
|
||||
}
|
||||
for b in weekly
|
||||
]
|
||||
stats = forecast_service.throughput_stats(weekly)
|
||||
remaining = repo_info.get("open_issues_count") or 0
|
||||
est = forecast_service.estimate(remaining, weekly)
|
||||
milestones = []
|
||||
try:
|
||||
ms_list = await gitea.get_milestones(owner, name)
|
||||
except Exception:
|
||||
ms_list = []
|
||||
for ms in ms_list:
|
||||
if ms.get("state") not in (None, "open"):
|
||||
continue
|
||||
rem = ms.get("open_issues") or 0
|
||||
if rem == 0 and not (ms.get("closed_issues") or 0):
|
||||
continue
|
||||
m_est = forecast_service.estimate(rem, weekly)
|
||||
verdict = None
|
||||
due_raw = ms.get("due_on")
|
||||
if m_est and not m_est.get("done") and due_raw:
|
||||
due = _parse_datetime(str(due_raw))
|
||||
if due is not None:
|
||||
verdict = (
|
||||
"⚠️ en retard à ce rythme"
|
||||
if m_est["central"] > due.date()
|
||||
else "✅ dans les temps à ce rythme"
|
||||
)
|
||||
milestones.append(
|
||||
{
|
||||
"title": ms.get("title", ""),
|
||||
"due_on": (due_raw or "")[:10],
|
||||
"remaining": rem,
|
||||
"eta": forecast_service.eta_label(m_est),
|
||||
"verdict": verdict,
|
||||
}
|
||||
)
|
||||
projects.append(
|
||||
{
|
||||
"full_name": full,
|
||||
"bars": bars,
|
||||
"stats": forecast_service.throughput_stats(weekly),
|
||||
"stats": stats,
|
||||
"remaining": remaining,
|
||||
"eta_label": forecast_service.eta_label(est),
|
||||
"milestones": milestones,
|
||||
}
|
||||
)
|
||||
return render(request, "forecast.html", projects=projects)
|
||||
if isinstance(ms_list, Exception):
|
||||
ms_list = []
|
||||
next_title = None
|
||||
next_due = None
|
||||
for ms in ms_list:
|
||||
if ms.get("state") not in (None, "open"):
|
||||
continue
|
||||
rem = ms.get("open_issues") or 0
|
||||
closed_n = ms.get("closed_issues") or 0
|
||||
if rem == 0 and not closed_n:
|
||||
continue
|
||||
total = rem + closed_n
|
||||
m_est = forecast_service.estimate(rem, weekly)
|
||||
due_raw = ms.get("due_on")
|
||||
due_dt = _parse_datetime(str(due_raw)) if due_raw else None
|
||||
due_date = due_dt.date() if due_dt else None
|
||||
gap_days = None
|
||||
verdict = "noeta"
|
||||
if due_date is None:
|
||||
verdict = "nodue"
|
||||
elif m_est and not m_est.get("done"):
|
||||
gap_days = (m_est["central"] - due_date).days
|
||||
verdict = "late" if gap_days > 0 else "ontime"
|
||||
elif m_est and m_est.get("done"):
|
||||
verdict = "ontime"
|
||||
rows.append(
|
||||
{
|
||||
"project": full,
|
||||
"owner": owner,
|
||||
"repo": name,
|
||||
"title": ms.get("title", ""),
|
||||
"due_label": (
|
||||
forecast_service.french_date(due_date, short=True)
|
||||
if due_date else "—"
|
||||
),
|
||||
"due_sort": due_date.isoformat() if due_date else "9999",
|
||||
"remaining": rem,
|
||||
"closed": closed_n,
|
||||
"total": total,
|
||||
"pct": round(100 * closed_n / total) if total else 0,
|
||||
"eta": forecast_service.eta_label(m_est),
|
||||
"gap_days": gap_days,
|
||||
"verdict": verdict,
|
||||
"throughput": stats["avg"] if stats else None,
|
||||
"anchor": False,
|
||||
}
|
||||
)
|
||||
if (
|
||||
due_date is not None
|
||||
and (next_due is None or due_date < next_due)
|
||||
):
|
||||
next_due, next_title = due_date, ms.get("title", "")
|
||||
for row in rows:
|
||||
if (
|
||||
row["project"] == full
|
||||
and row["title"] == next_title
|
||||
and next_title is not None
|
||||
):
|
||||
row["anchor"] = True
|
||||
|
||||
def _risk_key(row: dict):
|
||||
# Late first (worst gap first), then dated on-time by due
|
||||
# date, then no-ETA, then no due date.
|
||||
order = {"late": 0, "ontime": 1, "noeta": 2, "nodue": 3}
|
||||
return (
|
||||
order.get(row["verdict"], 4),
|
||||
-(row["gap_days"] or 0) if row["verdict"] == "late" else 0,
|
||||
row["due_sort"],
|
||||
row["project"],
|
||||
row["title"],
|
||||
)
|
||||
|
||||
rows.sort(key=_risk_key)
|
||||
summary = {
|
||||
"late": sum(1 for r in rows if r["verdict"] == "late"),
|
||||
"ontime": sum(1 for r in rows if r["verdict"] == "ontime"),
|
||||
"total": len(rows),
|
||||
}
|
||||
return render(
|
||||
request, "forecast.html",
|
||||
rows=rows, projects=projects, summary=summary,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/focus", response_class=HTMLResponse)
|
||||
@@ -1739,15 +1851,14 @@ async def _collect_wall_data(only_repo: str | None) -> list[dict]:
|
||||
closed_today.get(event["repo_full_name"], 0) + 1
|
||||
)
|
||||
|
||||
slides = []
|
||||
for repo in repos:
|
||||
async def _one_slide(repo: dict) -> dict | None:
|
||||
full_name = repo["full_name"]
|
||||
owner, _, name = full_name.partition("/")
|
||||
try:
|
||||
items = await gitea.get_repo_issues(owner, name, state="open")
|
||||
except Exception as exc:
|
||||
logger.debug("wall: issues failed for %s: %s", full_name, exc)
|
||||
continue
|
||||
return None
|
||||
open_issues = [i for i in items if _is_issue(i)]
|
||||
_adopt_label_columns(full_name, open_issues, get_board_state(full_name))
|
||||
board_state = get_board_state(full_name)
|
||||
@@ -1769,17 +1880,17 @@ async def _collect_wall_data(only_repo: str | None) -> list[dict]:
|
||||
"aging": days is not None and days >= aging_days,
|
||||
}
|
||||
)
|
||||
slides.append(
|
||||
{
|
||||
"full_name": full_name,
|
||||
"counts": {col: len(columns[col]) for col in KANBAN_COLUMNS},
|
||||
"open_total": len(open_issues),
|
||||
"cards": cards[:9],
|
||||
"cards_total": len(cards),
|
||||
"closed_today": closed_today.get(full_name, 0),
|
||||
}
|
||||
)
|
||||
return slides
|
||||
return {
|
||||
"full_name": full_name,
|
||||
"counts": {col: len(columns[col]) for col in KANBAN_COLUMNS},
|
||||
"open_total": len(open_issues),
|
||||
"cards": cards[:9],
|
||||
"cards_total": len(cards),
|
||||
"closed_today": closed_today.get(full_name, 0),
|
||||
}
|
||||
|
||||
results = await _parallel_map(repos, _one_slide)
|
||||
return [r for r in results if isinstance(r, dict)]
|
||||
|
||||
|
||||
@app.get("/wall", response_class=HTMLResponse)
|
||||
|
||||
@@ -11,16 +11,21 @@
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<section class="hero">
|
||||
<div class="hero-text">
|
||||
<h2>Bonjour 👋 Voici vos projets Gitea, en un coup d'œil.</h2>
|
||||
<p>Glissez les cartes, suivez ce qui avance, et laissez Gitea rester la source de vérité.</p>
|
||||
</div>
|
||||
<div class="stat-tiles">
|
||||
<div class="stat-tile"><span class="stat-num">{{ active_repos|length }}</span><span class="stat-label">projets actifs</span></div>
|
||||
<div class="stat-tile"><span class="stat-num">{{ metrics.closed }}</span><span class="stat-label">issues fermées · 30 j</span></div>
|
||||
<div class="stat-tile"><span class="stat-num">{{ metrics.moved }}</span><span class="stat-label">déplacements · 30 j</span></div>
|
||||
<div class="stat-tile"><span class="stat-num">{% if metrics.avg_cycle_days is not none %}{{ metrics.avg_cycle_days }} j{% else %}—{% endif %}</span><span class="stat-label">temps de cycle moyen</span></div>
|
||||
<!-- En-tête de page (refonte 0.20.0) : titre sobre, bande KPI
|
||||
unifiée, puis UNE barre d'outils (dans <main>) qui regroupe
|
||||
onglets, filtre, tri et vue — fini les rangées éparses. -->
|
||||
<section class="page-head">
|
||||
<div class="page-head-row">
|
||||
<div class="page-title">
|
||||
<h1>Projets</h1>
|
||||
<p class="page-sub">Vue d'ensemble de vos dépôts Gitea — Gitea reste la source de vérité.</p>
|
||||
</div>
|
||||
<div class="kpi-strip" aria-label="Indicateurs des 30 derniers jours">
|
||||
<div class="kpi"><span class="kpi-value">{{ active_repos|length }}</span><span class="kpi-label">Projets actifs</span></div>
|
||||
<a class="kpi" href="/activity"><span class="kpi-value">{{ metrics.closed }}</span><span class="kpi-label">Issues fermées · 30 j</span></a>
|
||||
<a class="kpi" href="/activity"><span class="kpi-value">{{ metrics.moved }}</span><span class="kpi-label">Déplacements · 30 j</span></a>
|
||||
<div class="kpi"><span class="kpi-value">{% if metrics.avg_cycle_days is not none %}{{ metrics.avg_cycle_days }}<span class="kpi-unit"> j</span>{% else %}—{% endif %}</span><span class="kpi-label">Temps de cycle moyen</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -40,31 +45,22 @@
|
||||
}
|
||||
</script>
|
||||
<main x-data="dashState()">
|
||||
<!-- Tabs: "actif" = mis à jour dans la fenêtre glissante;
|
||||
"archivé" = le vrai drapeau Gitea (repo.archived). -->
|
||||
<div class="tabs">
|
||||
<button :class="{ active: tab === 'active' }" @click="tab = 'active'">
|
||||
🟢 Actifs ({{ active_repos|length }})
|
||||
</button>
|
||||
<button :class="{ active: tab === 'inactive' }" @click="tab = 'inactive'">
|
||||
💤 Inactifs ({{ inactive_repos|length }})
|
||||
</button>
|
||||
<button :class="{ active: tab === 'archived' }" @click="tab = 'archived'">
|
||||
🗄️ Archivés ({{ archived_repos|length }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search: the repo name lives in a data-* attribute (HTML-escaped
|
||||
by Jinja), never interpolated into a JS string literal. -->
|
||||
<div class="search-bar">
|
||||
<input type="text" id="project-search" placeholder="🔍 Filtrer les projets..." x-model="search" autocomplete="off">
|
||||
<kbd class="kbd">/</kbd>
|
||||
</div>
|
||||
|
||||
<!-- Outils d'affichage (#21 tri/regroupement, #22 vue liste) :
|
||||
pilotés par dashboard.js, préférences en localStorage. -->
|
||||
<div class="dash-tools">
|
||||
<label class="dash-tool">↕️ Tri
|
||||
<!-- Barre d'outils unique (0.20.0) : onglets segmentés,
|
||||
filtre, tri/regroupement (#21) et vue (#22) alignés.
|
||||
Onglets : "actif" = fenêtre glissante ; "archivé" =
|
||||
le vrai drapeau Gitea (repo.archived). Le filtre lit
|
||||
des data-* échappés par Jinja, jamais des littéraux JS. -->
|
||||
<div class="dash-toolbar">
|
||||
<div class="seg-tabs" role="tablist" aria-label="État des projets">
|
||||
<button type="button" role="tab" :class="{ active: tab === 'active' }" @click="tab = 'active'">Actifs <span class="seg-count">{{ active_repos|length }}</span></button>
|
||||
<button type="button" role="tab" :class="{ active: tab === 'inactive' }" @click="tab = 'inactive'">Inactifs <span class="seg-count">{{ inactive_repos|length }}</span></button>
|
||||
<button type="button" role="tab" :class="{ active: tab === 'archived' }" @click="tab = 'archived'">Archivés <span class="seg-count">{{ archived_repos|length }}</span></button>
|
||||
</div>
|
||||
<div class="search-bar toolbar-search">
|
||||
<input type="text" id="project-search" placeholder="Filtrer les projets..." x-model="search" autocomplete="off">
|
||||
<kbd class="kbd">/</kbd>
|
||||
</div>
|
||||
<label class="dash-tool">Tri
|
||||
<select id="dash-sort">
|
||||
<option value="default">Par défaut</option>
|
||||
<option value="updated">Activité Gitea récente</option>
|
||||
@@ -74,7 +70,7 @@
|
||||
</select>
|
||||
</label>
|
||||
<label class="dash-tool dash-tool-check">
|
||||
<input type="checkbox" id="dash-group"> 🗂️ Grouper par propriétaire
|
||||
<input type="checkbox" id="dash-group"> Grouper par propriétaire
|
||||
</label>
|
||||
<div class="dash-view-toggle" role="group" aria-label="Mode d'affichage">
|
||||
<button type="button" :class="{ active: view === 'grid' }" @click="setView('grid')">▦ Grille</button>
|
||||
@@ -131,7 +127,7 @@
|
||||
{% set ms = repo.next_milestone %}
|
||||
<div class="ms-block">
|
||||
<div class="ms-line">
|
||||
<span class="ms-title">🎯 {{ ms.title }}</span>
|
||||
<a href="/forecast#ms-{{ repo.owner.login }}-{{ repo.name }}" class="ms-title" title="Voir ce jalon dans le tableau des prévisions">🎯 {{ ms.title }}</a>
|
||||
{% 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 %}
|
||||
@@ -260,6 +256,7 @@
|
||||
<script>window.KANBANTE_CSRF = "{{ csrf_token }}";</script>
|
||||
<script src="/static/js/stars.js" defer></script>
|
||||
<script src="/static/js/dashboard.js" defer></script>
|
||||
<script src="/static/js/tablesort.js" defer></script>
|
||||
<script>
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === '/' && !/INPUT|TEXTAREA/.test(document.activeElement?.tagName || '')) {
|
||||
|
||||
+78
-18
@@ -13,13 +13,72 @@
|
||||
<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>
|
||||
<p>Le tableau de bord des jalons, <strong>tous projets confondus</strong>, trié par risque : l'écart entre la date estimée (au débit réel du journal) et l'échéance. Réponse honnête — fourchettes plutôt que faux chiffres précis, et aucune prévision sans historique de fermetures.</p>
|
||||
<p class="forecast-summary">
|
||||
<span class="chip chip-total">🎯 {{ summary.total }} jalon(s) ouvert(s)</span>
|
||||
{% if summary.late %}<span class="chip chip-late">⚠️ {{ summary.late }} en retard à ce rythme</span>{% endif %}
|
||||
{% if summary.ontime %}<span class="chip chip-ok">✅ {{ summary.ontime }} dans les temps</span>{% endif %}
|
||||
</p>
|
||||
<div class="search-bar forecast-filter">
|
||||
<input type="text" id="ms-filter" placeholder="🔍 Filtrer par projet ou jalon..." autocomplete="off">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% for project in projects %}
|
||||
<section class="briefing-card forecast-card">
|
||||
<h2>📂 {{ project.full_name }}</h2>
|
||||
{% if rows %}
|
||||
<table class="proj-table pilot-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="project">Projet</th>
|
||||
<th data-sort="title">Jalon</th>
|
||||
<th data-sort="due">Échéance</th>
|
||||
<th>Progression</th>
|
||||
<th>ETA</th>
|
||||
<th data-sort="gap">Écart</th>
|
||||
<th data-sort="throughput">Débit / sem.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr{% if row.anchor %} id="ms-{{ row.owner }}-{{ row.repo }}"{% endif %}
|
||||
data-project="{{ row.project|lower }}" data-title="{{ row.title|lower }}"
|
||||
data-due="{{ row.due_sort }}" data-gap="{{ row.gap_days if row.gap_days is not none else '' }}"
|
||||
data-throughput="{{ row.throughput if row.throughput is not none else '' }}"
|
||||
data-search="{{ (row.project ~ ' ' ~ row.title)|lower }}">
|
||||
<td><a href="/project/{{ row.project }}">{{ row.project }}</a></td>
|
||||
<td><a href="/board/{{ row.project }}?milestone={{ row.title|urlencode }}"><strong>{{ row.title }}</strong></a></td>
|
||||
<td>{{ row.due_label }}</td>
|
||||
<td>
|
||||
{% if row.total %}
|
||||
<div class="progress ms-progress pilot-progress"><span style="width: {{ row.pct }}%"></span></div>
|
||||
<span class="ms-meta">{{ row.closed }}/{{ row.total }}</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>{{ row.eta or '—' }}</td>
|
||||
<td>
|
||||
{% if row.verdict == 'late' %}
|
||||
<span class="verdict verdict-late">⚠️ +{{ row.gap_days }} j</span>
|
||||
{% elif row.verdict == 'ontime' %}
|
||||
<span class="verdict verdict-ok">✅ dans les temps{% if row.gap_days is not none and row.gap_days < 0 %} ({{ -row.gap_days }} j d'avance){% endif %}</span>
|
||||
{% elif row.verdict == 'nodue' %}
|
||||
<span class="verdict">sans échéance</span>
|
||||
{% else %}
|
||||
<span class="verdict">pas d'ETA</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ row.throughput if row.throughput is not none else '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<section class="briefing-card"><p>Aucun jalon ouvert dans les projets actifs. Crée des jalons dans Gitea pour piloter par échéances.</p></section>
|
||||
{% endif %}
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>📈 Débit par projet</h2>
|
||||
{% for project in projects %}
|
||||
<div class="forecast-flex">
|
||||
<div class="forecast-proj-name">📂 <a href="/project/{{ project.full_name }}">{{ project.full_name }}</a></div>
|
||||
<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 %}
|
||||
@@ -30,25 +89,17 @@
|
||||
</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>
|
||||
<p>Débit : <strong>{{ project.stats.avg }} / semaine</strong> (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>
|
||||
<p>Débit : <strong>pas assez d'historique</strong>.</p>
|
||||
{% endif %}
|
||||
<p>Restant ouvert : <strong>{{ project.remaining }}</strong>
|
||||
{% if project.eta_label %} · 🔮 backlog {{ project.eta_label }}{% endif %}</p>
|
||||
<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 %}
|
||||
{% else %}
|
||||
<p>Aucun projet actif.</p>
|
||||
{% endfor %}
|
||||
</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>
|
||||
@@ -56,5 +107,14 @@
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
<script src="/static/js/tablesort.js" defer></script>
|
||||
<script>
|
||||
document.getElementById('ms-filter')?.addEventListener('input', function (e) {
|
||||
var q = e.target.value.toLowerCase();
|
||||
document.querySelectorAll('.pilot-table tbody tr').forEach(function (row) {
|
||||
row.style.display = !q || (row.dataset.search || '').includes(q) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+36
-14
@@ -13,23 +13,44 @@
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
<h1>🩺 Santé du flux</h1>
|
||||
<p>Les boards montrent l'état ; cette page montre la <strong>santé</strong> : quelle colonne accumule (temps médian passé dans chaque colonne, d'après le journal) et quelles cartes sont devenues des <strong>zombies</strong> (aucune activité depuis le seuil configuré).</p>
|
||||
<p>Tous les projets actifs, <strong>triés par sévérité</strong> — le rouge d'abord. Quelle colonne accumule (temps médian par colonne, d'après le journal) et quelles cartes sont devenues des <strong>zombies</strong>. Cliquez un projet pour le détail et les actions rapides.</p>
|
||||
</section>
|
||||
|
||||
{% for project in projects %}
|
||||
<section class="briefing-card forecast-card">
|
||||
<h2>📂 {{ project.full_name }} <small>({{ project.open_count }} ouverte(s))</small></h2>
|
||||
{% if project.slowest %}
|
||||
<p>🐌 Colonne la plus lente : <strong>{{ project.column_labels[project.slowest.column] }}</strong> — médiane {{ project.slowest.median_days }} j ({{ project.slowest.count }} séjours mesurés)</p>
|
||||
{% else %}
|
||||
<p>🐌 Pas encore assez de séjours mesurés pour nommer un goulot.</p>
|
||||
{% endif %}
|
||||
<p>🧟 Zombies (≥ {{ project.zombie_days }} j sans activité) : <strong>{{ project.zombies|length }}</strong>
|
||||
— <a href="/health/{{ project.full_name }}">voir le détail et agir →</a></p>
|
||||
</section>
|
||||
{% if projects %}
|
||||
<table class="proj-table pilot-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="severity">Santé</th>
|
||||
<th data-sort="project">Projet</th>
|
||||
<th data-sort="open">Ouvertes</th>
|
||||
<th data-sort="zombies">🧟 Zombies</th>
|
||||
<th data-sort="slowest">🐌 Goulot</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for project in projects %}
|
||||
<tr data-severity="{{ {'red': 0, 'orange': 1, 'green': 2}.get(project.dot.level, 3) }}"
|
||||
data-project="{{ project.full_name|lower }}" data-open="{{ project.open_count }}"
|
||||
data-zombies="{{ project.zombies|length }}"
|
||||
data-slowest="{{ project.slowest.median_days if project.slowest else '' }}">
|
||||
<td><span class="health-dot {{ project.dot.level }}" title="{{ project.dot.title }}"></span></td>
|
||||
<td><a href="/health/{{ project.full_name }}"><strong>{{ project.full_name }}</strong></a></td>
|
||||
<td>{{ project.open_count }}</td>
|
||||
<td>{{ project.zombies|length }}{% if project.zombies %} <span class="ms-meta">(≥ {{ project.zombie_days }} j)</span>{% endif %}</td>
|
||||
<td>
|
||||
{% if project.slowest %}
|
||||
{{ project.column_labels[project.slowest.column] }} — médiane {{ project.slowest.median_days }} j
|
||||
{% else %}<span class="ms-meta">pas assez de données</span>{% endif %}
|
||||
</td>
|
||||
<td><a href="/health/{{ project.full_name }}">détail →</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="briefing-card"><p>Aucun projet actif.</p></div>
|
||||
{% endfor %}
|
||||
<section class="briefing-card"><p>Aucun projet actif.</p></section>
|
||||
{% endif %}
|
||||
</main>
|
||||
<footer>
|
||||
<span>Kanbante v{{ version }} · l'hygiène du flux, mesurée sur le journal — pas au feeling</span>
|
||||
@@ -37,5 +58,6 @@
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
<script src="/static/js/tablesort.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -11,6 +11,18 @@
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
{% if project_names %}
|
||||
<div class="health-switch-bar">
|
||||
<label for="health-switch">🩺 Projet :</label>
|
||||
<select id="health-switch">
|
||||
{% for name in project_names %}
|
||||
<option value="{{ name }}"{% if name == full_name %} selected{% endif %}>{{ name }}</option>
|
||||
{% endfor %}
|
||||
{% if full_name not in project_names %}<option value="{{ full_name }}" selected>{{ full_name }}</option>{% endif %}
|
||||
</select>
|
||||
<a href="/health" class="health-back">← tous les projets</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if health is none %}
|
||||
<section class="briefing-card">
|
||||
<h1>🩺 {{ full_name }}</h1>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "kanbante"
|
||||
version = "0.18.0"
|
||||
version = "0.20.0"
|
||||
description = "Kanban project manager integrated with Gitea"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
+116
-46
@@ -150,17 +150,44 @@ header h1 { font-size: 1.2rem; font-weight: 600; }
|
||||
.btn-sm:hover { background: var(--surface-3); border-color: var(--border-strong); text-decoration: none; }
|
||||
.btn-sm:active { transform: scale(0.97); }
|
||||
|
||||
/* ── Hero (dashboard) ───────────────────────── */
|
||||
.hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
padding: 30px 24px 6px;
|
||||
/* ── En-tête de page Projets (0.20.0) ─────────
|
||||
Titre sobre + bande KPI unifiée ; les contrôles vivent dans
|
||||
.dash-toolbar (plus bas). Les .stat-tile restent utilisés par
|
||||
le briefing quotidien. */
|
||||
.page-head { padding: 22px 24px 0; }
|
||||
.page-head-row {
|
||||
display: flex; justify-content: space-between; align-items: flex-end;
|
||||
gap: 20px; flex-wrap: wrap;
|
||||
}
|
||||
.page-title h1 {
|
||||
font-size: 1.55rem; font-weight: 700; letter-spacing: -0.02em;
|
||||
line-height: 1.15; margin: 0;
|
||||
}
|
||||
.page-sub { color: var(--text-2); font-size: 0.92rem; margin-top: 5px; }
|
||||
.kpi-strip {
|
||||
display: flex; align-items: stretch;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.kpi {
|
||||
display: flex; flex-direction: column; gap: 1px;
|
||||
padding: 9px 18px 10px;
|
||||
color: var(--text);
|
||||
}
|
||||
.kpi + .kpi { border-left: 1px solid var(--border); }
|
||||
a.kpi { transition: background 0.15s; }
|
||||
a.kpi:hover { background: var(--surface-2); text-decoration: none; }
|
||||
.kpi-value {
|
||||
font-size: 1.22rem; font-weight: 700; letter-spacing: -0.01em;
|
||||
font-variant-numeric: tabular-nums; line-height: 1.2;
|
||||
}
|
||||
.kpi-unit { font-size: 0.85rem; font-weight: 600; color: var(--text-2); }
|
||||
.kpi-label {
|
||||
font-size: 0.68rem; color: var(--text-2);
|
||||
text-transform: uppercase; letter-spacing: 0.55px; white-space: nowrap;
|
||||
}
|
||||
.hero-text h2 { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.01em; margin-bottom: 6px; }
|
||||
.hero-text p { color: var(--text-2); font-size: 0.95rem; max-width: 520px; }
|
||||
.stat-tiles { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.stat-tile {
|
||||
background: linear-gradient(180deg, var(--surface-2), var(--surface));
|
||||
@@ -175,38 +202,13 @@ header h1 { font-size: 1.2rem; font-weight: 600; }
|
||||
.stat-num { font-size: 1.35rem; font-weight: 700; letter-spacing: -0.01em; }
|
||||
.stat-label { font-size: 0.72rem; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.4px; }
|
||||
|
||||
/* ── Tabs + search (dashboard) ──────────────── */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 18px 24px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tabs button {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.tabs button.active {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, rgba(124,92,252,.9), rgba(91,138,247,.9));
|
||||
border-color: transparent;
|
||||
}
|
||||
.tabs button:hover { color: var(--text); }
|
||||
.tabs button.active:hover { color: #fff; }
|
||||
|
||||
/* ── Recherche (dashboard + filtre prévisions) ── */
|
||||
.search-bar { padding: 14px 24px 2px; display: flex; align-items: center; gap: 10px; }
|
||||
.search-bar input {
|
||||
width: 100%;
|
||||
max-width: 430px;
|
||||
padding: 11px 16px;
|
||||
border-radius: 999px;
|
||||
padding: 9px 13px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
@@ -869,13 +871,18 @@ footer {
|
||||
|
||||
/* ── Responsive ─────────────────────────────── */
|
||||
@media (max-width: 900px) {
|
||||
.hero { flex-direction: column; align-items: flex-start; }
|
||||
.page-head-row { flex-direction: column; align-items: flex-start; }
|
||||
.kpi-strip { width: 100%; }
|
||||
.kpi { flex: 1 1 0; padding: 9px 12px 10px; }
|
||||
.kpi-label { white-space: normal; }
|
||||
.kanban-column { max-height: none; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.app-header, header { padding: 10px 14px; }
|
||||
.crumb { display: none; }
|
||||
.hero, .tabs, .search-bar, .project-grid, .board-toolbar, .milestone-bar { padding-left: 14px; padding-right: 14px; }
|
||||
.page-head, .project-grid, .board-toolbar, .milestone-bar { padding-left: 14px; padding-right: 14px; }
|
||||
.dash-toolbar { margin-left: 14px; margin-right: 14px; }
|
||||
.search-bar { padding-left: 14px; padding-right: 14px; }
|
||||
.milestone-banner { margin-left: 14px; margin-right: 14px; }
|
||||
.board-container { flex-direction: column; overflow: visible; }
|
||||
.notes-panel { width: 100%; max-height: 240px; border-left: none; border-top: 1px solid var(--border); }
|
||||
@@ -1374,20 +1381,51 @@ body.sidebar-collapsed .side-foot { justify-content: center; }
|
||||
|
||||
/* ── Dashboard vague 2 suite (0.18.0, #19 #21 #22 #23) ── */
|
||||
.activity-line { margin-top: 8px; font-size: 0.82rem; color: var(--text-2); }
|
||||
.dash-tools {
|
||||
display: flex; align-items: center; gap: 18px; flex-wrap: wrap;
|
||||
padding: 0 24px; margin: 4px 0 14px;
|
||||
/* Barre d'outils unique du dashboard (0.20.0) : onglets
|
||||
segmentés, filtre, tri, regroupement et vue dans UN cadre. */
|
||||
.dash-toolbar {
|
||||
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
|
||||
margin: 16px 24px 18px; padding: 8px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.dash-tool { display: inline-flex; align-items: center; gap: 8px; font-size: 0.88rem; color: var(--text-2); }
|
||||
.seg-tabs {
|
||||
display: inline-flex; gap: 2px; padding: 3px;
|
||||
background: var(--surface-2); border-radius: 9px;
|
||||
}
|
||||
.seg-tabs button {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 6px 13px; border: none; border-radius: 7px;
|
||||
background: transparent; color: var(--text-2);
|
||||
cursor: pointer; font-size: 0.88rem; font-weight: 500;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
min-height: 32px;
|
||||
}
|
||||
.seg-tabs button:hover { color: var(--text); }
|
||||
.seg-tabs button.active {
|
||||
background: var(--surface); color: var(--text); font-weight: 600;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.seg-count {
|
||||
font-size: 0.74rem; font-weight: 600; font-variant-numeric: tabular-nums;
|
||||
background: rgba(255, 255, 255, 0.08); color: var(--text-2);
|
||||
border-radius: 999px; padding: 1px 7px;
|
||||
}
|
||||
.seg-tabs button.active .seg-count { background: rgba(124, 92, 252, 0.22); color: var(--text); }
|
||||
.dash-toolbar .toolbar-search { padding: 0; flex: 1 1 200px; }
|
||||
.dash-toolbar .toolbar-search input { max-width: none; background: var(--surface-2); }
|
||||
.dash-tool { display: inline-flex; align-items: center; gap: 8px; font-size: 0.88rem; color: var(--text-2); white-space: nowrap; }
|
||||
.dash-tool select {
|
||||
background: var(--surface-2); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 8px; padding: 6px 8px;
|
||||
min-height: 36px;
|
||||
}
|
||||
.dash-tool-check input { width: 17px; height: 17px; accent-color: #7c5cfc; }
|
||||
.dash-view-toggle { margin-left: auto; display: inline-flex; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
||||
.dash-view-toggle { margin-left: auto; display: inline-flex; border: 1px solid var(--border); border-radius: 9px; overflow: hidden; }
|
||||
.dash-view-toggle button {
|
||||
background: var(--surface-1); color: var(--text-2); border: none;
|
||||
padding: 7px 14px; cursor: pointer; font-size: 0.88rem;
|
||||
padding: 7px 14px; cursor: pointer; font-size: 0.88rem; min-height: 36px;
|
||||
}
|
||||
.dash-view-toggle button.active { background: var(--surface-3, #1d2942); color: var(--text); font-weight: 700; }
|
||||
.owner-group { grid-column: 1 / -1; }
|
||||
@@ -1443,3 +1481,35 @@ body.sidebar-collapsed .side-foot { justify-content: center; }
|
||||
.proj-table { display: block; overflow-x: auto; }
|
||||
.dash-view-toggle { margin-left: 0; }
|
||||
}
|
||||
|
||||
/* ── Pilotage 0.19.0 : tableaux Prévisions/Santé (chantier C) ── */
|
||||
.pilot-table { margin-bottom: 18px; }
|
||||
.pilot-table tr[id] { scroll-margin-top: 76px; }
|
||||
.pilot-progress { min-width: 90px; }
|
||||
.forecast-summary { display: flex; gap: 8px; flex-wrap: wrap; margin: 10px 0 4px; }
|
||||
.forecast-summary .chip {
|
||||
font-size: 12.5px; padding: 3px 10px; border-radius: 999px;
|
||||
background: rgba(255, 255, 255, .07); border: 1px solid var(--border, #333);
|
||||
}
|
||||
.forecast-summary .chip-late { background: rgba(248, 81, 73, .15); }
|
||||
.forecast-summary .chip-ok { background: rgba(63, 185, 80, .15); }
|
||||
.forecast-filter { margin-top: 10px; max-width: 420px; }
|
||||
.forecast-filter input { width: 100%; }
|
||||
.forecast-proj-name { font-weight: 600; min-width: 180px; }
|
||||
.verdict {
|
||||
font-size: 12.5px; padding: 2px 9px; border-radius: 999px; white-space: nowrap;
|
||||
background: rgba(255, 255, 255, .06);
|
||||
}
|
||||
.verdict-late { background: rgba(248, 81, 73, .18); }
|
||||
.verdict-ok { background: rgba(63, 185, 80, .16); }
|
||||
a.ms-title { color: inherit; text-decoration: none; }
|
||||
a.ms-title:hover { text-decoration: underline; }
|
||||
.health-switch-bar {
|
||||
display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap;
|
||||
}
|
||||
.health-switch-bar select {
|
||||
padding: 8px 10px; border-radius: 8px; font-size: 14px; min-height: 38px;
|
||||
background: var(--panel-2, #1c2330); color: inherit;
|
||||
border: 1px solid var(--border, #333);
|
||||
}
|
||||
.health-back { font-size: 13.5px; }
|
||||
|
||||
+2
-29
@@ -154,33 +154,6 @@
|
||||
if (e.key === 'Escape') closeMenus();
|
||||
});
|
||||
|
||||
/* ── Tri des tableaux denses (#22) ─────────────────────── */
|
||||
document.querySelectorAll('.proj-table').forEach(function (table) {
|
||||
var tbody = table.querySelector('tbody');
|
||||
table.querySelectorAll('th[data-sort]').forEach(function (th) {
|
||||
th.classList.add('sortable');
|
||||
th.addEventListener('click', function () {
|
||||
var key = th.getAttribute('data-sort');
|
||||
var dir = th.getAttribute('data-dir') === 'asc' ? 'desc' : 'asc';
|
||||
table.querySelectorAll('th').forEach(function (h) {
|
||||
h.removeAttribute('data-dir');
|
||||
});
|
||||
th.setAttribute('data-dir', dir);
|
||||
var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
|
||||
rows.sort(function (a, b) {
|
||||
var va = a.getAttribute('data-' + key) || '';
|
||||
var vb = b.getAttribute('data-' + key) || '';
|
||||
var na = parseFloat(va), nb = parseFloat(vb);
|
||||
var cmp;
|
||||
if (!isNaN(na) && !isNaN(nb) && va !== '' && vb !== '') {
|
||||
cmp = na - nb;
|
||||
} else {
|
||||
cmp = va < vb ? -1 : (va > vb ? 1 : 0);
|
||||
}
|
||||
return dir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
rows.forEach(function (r) { tbody.appendChild(r); });
|
||||
});
|
||||
});
|
||||
});
|
||||
/* Le tri des tableaux denses (#22) vit dans tablesort.js,
|
||||
partagé avec /forecast et /health (0.19.0). */
|
||||
})();
|
||||
|
||||
@@ -5,6 +5,15 @@
|
||||
(function () {
|
||||
var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
|
||||
|
||||
/* Project switcher (0.19.0): jump between projects without
|
||||
going back to the health index. */
|
||||
var switcher = document.getElementById('health-switch');
|
||||
if (switcher) {
|
||||
switcher.addEventListener('change', function () {
|
||||
window.location.href = '/health/' + switcher.value;
|
||||
});
|
||||
}
|
||||
|
||||
function post(url, fields) {
|
||||
var data = new FormData();
|
||||
Object.keys(fields).forEach(function (k) { data.append(k, fields[k]); });
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Kanbante — tri des tableaux « pilotage » (.proj-table), partagé
|
||||
* entre le dashboard, /forecast et /health (0.19.0).
|
||||
* Tri client par colonne : les lignes portent leurs valeurs dans
|
||||
* des attributs data-<clé> ; cliquer un <th data-sort="clé">
|
||||
* trie dans un sens puis dans l'autre.
|
||||
*/
|
||||
(function () {
|
||||
document.querySelectorAll('.proj-table').forEach(function (table) {
|
||||
var tbody = table.querySelector('tbody');
|
||||
if (!tbody) return;
|
||||
table.querySelectorAll('th[data-sort]').forEach(function (th) {
|
||||
th.classList.add('sortable');
|
||||
th.addEventListener('click', function () {
|
||||
var key = th.getAttribute('data-sort');
|
||||
var dir = th.getAttribute('data-dir') === 'asc' ? 'desc' : 'asc';
|
||||
table.querySelectorAll('th').forEach(function (h) {
|
||||
h.removeAttribute('data-dir');
|
||||
});
|
||||
th.setAttribute('data-dir', dir);
|
||||
var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
|
||||
rows.sort(function (a, b) {
|
||||
var va = a.getAttribute('data-' + key) || '';
|
||||
var vb = b.getAttribute('data-' + key) || '';
|
||||
var na = parseFloat(va), nb = parseFloat(vb);
|
||||
var cmp;
|
||||
if (!isNaN(na) && !isNaN(nb) && va !== '' && vb !== '') {
|
||||
cmp = na - nb;
|
||||
} else {
|
||||
cmp = va < vb ? -1 : (va > vb ? 1 : 0);
|
||||
}
|
||||
return dir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
rows.forEach(function (r) { tbody.appendChild(r); });
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Palier 15 (0.19.0) — Performance + navigation Pilotage.
|
||||
|
||||
- A : les boucles par dépôt sont parallélisées (le comportement
|
||||
visible ne change pas — couvert par les paliers précédents) et
|
||||
le cache du client passe en stale-while-revalidate (testé dans
|
||||
test_gitea_client.py).
|
||||
- C : /forecast devient un tableau de bord des jalons (une ligne
|
||||
par jalon, ancres depuis le dashboard) et /health un tableau
|
||||
trié par sévérité, avec sélecteur de projet sur le détail.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
from app.gitea_client import GiteaClient
|
||||
|
||||
|
||||
def test_forecast_is_a_portfolio_table(client):
|
||||
r = client.get("/forecast")
|
||||
assert r.status_code == 200
|
||||
assert "pilot-table" in r.text
|
||||
assert "jalon(s) ouvert(s)" in r.text
|
||||
# One row for the demo milestone, anchored for dashboard links.
|
||||
assert 'id="ms-Projets-demo"' in r.text
|
||||
assert "Sprint 1" in r.text
|
||||
# The per-project pace table is still there.
|
||||
assert "Débit par projet" in r.text
|
||||
|
||||
|
||||
def test_dashboard_milestone_links_into_forecast(client):
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert 'href="/forecast#ms-Projets-demo"' in r.text
|
||||
|
||||
|
||||
def test_health_index_is_a_severity_table(client):
|
||||
r = client.get("/health")
|
||||
assert r.status_code == 200
|
||||
assert "pilot-table" in r.text
|
||||
assert "data-severity" in r.text
|
||||
assert "Projets/demo" in r.text
|
||||
|
||||
|
||||
def test_health_detail_has_project_switcher(client):
|
||||
r = client.get("/health/Projets/demo")
|
||||
assert r.status_code == 200
|
||||
assert 'id="health-switch"' in r.text
|
||||
assert '<option value="Projets/demo" selected>' in r.text
|
||||
assert "tous les projets" in r.text
|
||||
|
||||
|
||||
async def test_swr_serves_stale_then_refreshes_in_background():
|
||||
"""Stale entries are served instantly; the refresh lands
|
||||
asynchronously and the next read sees the fresh value."""
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(request):
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"id": 7})
|
||||
calls["n"] += 1
|
||||
return httpx.Response(200, json={"data": [{"id": calls["n"]}]})
|
||||
|
||||
client = GiteaClient(
|
||||
base_url="http://gitea.test",
|
||||
token="",
|
||||
cache_ttl=60,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
first = await client.search_repos()
|
||||
assert first == [{"id": 1}]
|
||||
# Fresh cache: no new fetch.
|
||||
assert await client.search_repos() == [{"id": 1}]
|
||||
assert calls["n"] == 1
|
||||
# Age the entry beyond the TTL: the stale value comes back
|
||||
# immediately (same content, no wait)…
|
||||
ts, value = client._cache["search_repos"]
|
||||
client._cache["search_repos"] = (ts - 120, value)
|
||||
assert await client.search_repos() == [{"id": 1}]
|
||||
# …and the background refresh replaces it.
|
||||
await asyncio.sleep(0.3)
|
||||
assert await client.search_repos() == [{"id": 2}]
|
||||
await client.aclose()
|
||||
|
||||
|
||||
async def test_swr_mutation_clear_blocks_stale_resurrection():
|
||||
"""A refresh started before a cache clear (mutation) must not
|
||||
put its now-stale result back."""
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(request):
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"id": 7})
|
||||
calls["n"] += 1
|
||||
return httpx.Response(200, json={"data": [{"id": calls["n"]}]})
|
||||
|
||||
client = GiteaClient(
|
||||
base_url="http://gitea.test",
|
||||
token="",
|
||||
cache_ttl=60,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
await client.search_repos()
|
||||
ts, value = client._cache["search_repos"]
|
||||
client._cache["search_repos"] = (ts - 120, value)
|
||||
await client.search_repos() # triggers background refresh
|
||||
client._cache_clear() # a mutation lands first
|
||||
await asyncio.sleep(0.3)
|
||||
# The cache was cleared and the in-flight refresh was disarmed:
|
||||
# the next read fetches fresh instead of serving the old value.
|
||||
assert await client.search_repos() == [{"id": 3}]
|
||||
await client.aclose()
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Palier 16 (0.20.0) — refonte professionnelle de l'entête Projets.
|
||||
|
||||
Fini le « Bonjour 👋 » et les rangées éparses : titre sobre,
|
||||
bande KPI unifiée, et une seule barre d'outils (onglets
|
||||
segmentés, filtre, tri, regroupement, vue). Les crochets
|
||||
fonctionnels (ids, liaisons Alpine) sont inchangés.
|
||||
"""
|
||||
|
||||
|
||||
def test_dashboard_professional_header(client):
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
# Titre sobre + sous-titre, plus de formule familière.
|
||||
assert "<h1>Projets</h1>" in r.text
|
||||
assert "Bonjour" not in r.text
|
||||
# Bande KPI unifiée : valeurs + libellés.
|
||||
assert "kpi-strip" in r.text
|
||||
assert "Projets actifs" in r.text
|
||||
assert "Issues fermées · 30 j" in r.text
|
||||
assert "Temps de cycle moyen" in r.text
|
||||
# Les KPI journal mènent à l'activité.
|
||||
assert 'href="/activity"' in r.text
|
||||
|
||||
|
||||
def test_dashboard_single_toolbar_keeps_its_hooks(client):
|
||||
r = client.get("/")
|
||||
assert "dash-toolbar" in r.text
|
||||
# Onglets segmentés avec compteurs, mêmes liaisons Alpine.
|
||||
assert "seg-tabs" in r.text
|
||||
assert "seg-count" in r.text
|
||||
assert "tab = 'inactive'" in r.text
|
||||
# Filtre, tri, regroupement et vue : ids préservés pour
|
||||
# dashboard.js et l'état Alpine.
|
||||
for hook in ('id="project-search"', 'id="dash-sort"',
|
||||
'id="dash-group"', "setView('list')"):
|
||||
assert hook in r.text, hook
|
||||
@@ -198,5 +198,7 @@ def test_dashboard_uses_new_design_system(client):
|
||||
r = client.get("/")
|
||||
assert "app-header" in r.text
|
||||
assert "nav-pill" in r.text
|
||||
assert "stat-tile" in r.text
|
||||
# En-tête refondu en 0.20.0 : bande KPI unifiée (les stat-tile
|
||||
# ne servent plus qu'au briefing).
|
||||
assert "kpi-strip" in r.text
|
||||
assert "live.js" in r.text
|
||||
|
||||
Reference in New Issue
Block a user