Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
018bde0fa5 | ||
|
|
0883191f57 | ||
|
|
ba47b1a9ac | ||
|
|
cf58bb9d0c | ||
|
|
b33b67fcb4 | ||
|
|
b4dc8f0d4f | ||
|
|
e810f5668a | ||
|
|
2290560627 |
@@ -1,5 +1,47 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.17.0 — 2026-10-11
|
||||||
|
|
||||||
|
### Nouveautés — vague 2 « Gestion de projets » (4 cartes de Bruno)
|
||||||
|
- **Prochain jalon sur la carte projet** (#17) : le jalon ouvert le
|
||||||
|
plus proche de son échéance s'affiche sur la carte du dashboard —
|
||||||
|
progression (compteurs Gitea), échéance en français, alerte si
|
||||||
|
en retard, et ETA du journal en regard avec signal « après
|
||||||
|
l'échéance » quand la prévision dépasse la date.
|
||||||
|
- **Pastille de santé par projet** (#18) : point vert/orange/rouge
|
||||||
|
sur chaque carte active, règles uniques partagées avec /health
|
||||||
|
(zombies, cartes coincées en QA/Révision, médiane du goulot),
|
||||||
|
info-bulle détaillée, clic vers la santé du projet.
|
||||||
|
- **Projets épinglés via les étoiles Gitea** (#20) : section
|
||||||
|
« ⭐ Épinglés » en tête du dashboard pour les dépôts étoilés ;
|
||||||
|
bouton ☆/⭐ sur chaque carte (PUT/DELETE /user/starred) — la
|
||||||
|
vérité vit dans Gitea, aucune table locale ; bascule sans
|
||||||
|
rechargement (stars.js). Échec de l'API : dashboard intact.
|
||||||
|
- **Page Projet** (#25) : `/project/{owner}/{repo}` — accueil du
|
||||||
|
projet : jalons ouverts avec progression/échéances/ETA, santé en
|
||||||
|
résumé (goulot, zombies), débit des prévisions, activité
|
||||||
|
récente du journal, notes du projet et actions (board, capture,
|
||||||
|
triage, santé, wallboard figé, Gitea). Le titre des cartes du
|
||||||
|
dashboard pointe désormais ici ; le board reste à un clic
|
||||||
|
(lien 📋 Board sur chaque carte et bouton principal).
|
||||||
|
|
||||||
|
7 tests ajoutés (178 au total).
|
||||||
|
|
||||||
|
## 0.16.1 — 2026-10-11
|
||||||
|
|
||||||
|
### Améliorations (retour de Bruno sur la barre latérale)
|
||||||
|
- Le bouton « Recherche & commandes » devient une **icône 🔍
|
||||||
|
seule**, placée **à droite du titre** dans l'en-tête de la barre
|
||||||
|
latérale (info-bulle et Ctrl+K inchangés) ; en rail replié, elle
|
||||||
|
passe sous le logo, toujours accessible.
|
||||||
|
- Les groupes du menu deviennent des **sections accordéon** :
|
||||||
|
en-têtes cliquables au look moderne (micro-libellés, chevron
|
||||||
|
animé), ouverture/fermeture en transition douce (technique
|
||||||
|
`grid-template-rows`), **état mémorisé par navigateur** appliqué
|
||||||
|
avant le premier rendu. La section de la page courante s'ouvre
|
||||||
|
toujours automatiquement. En rail replié, l'accordéon s'efface :
|
||||||
|
toutes les icônes restent visibles.
|
||||||
|
|
||||||
## 0.16.0 — 2026-10-11
|
## 0.16.0 — 2026-10-11
|
||||||
|
|
||||||
### Nouveautés
|
### Nouveautés
|
||||||
|
|||||||
+1
-1
@@ -89,7 +89,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
APP_VERSION = "0.16.0"
|
APP_VERSION = "0.17.0"
|
||||||
|
|
||||||
|
|
||||||
def _env_bool(name: str, default: bool) -> bool:
|
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,
|
"protocolVersion": MCP_PROTOCOL_VERSION,
|
||||||
"capabilities": {},
|
"capabilities": {},
|
||||||
"clientInfo": {"name": "kanbante", "version": "0.16.0"},
|
"clientInfo": {"name": "kanbante", "version": "0.17.0"},
|
||||||
},
|
},
|
||||||
req_id=1,
|
req_id=1,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -317,6 +317,26 @@ class GiteaClient:
|
|||||||
self._cache_put(key, milestones)
|
self._cache_put(key, milestones)
|
||||||
return 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
|
||||||
|
|
||||||
|
async def set_starred(self, owner: str, repo: str, starred: bool) -> None:
|
||||||
|
"""Star / unstar a repo for the token user."""
|
||||||
|
if starred:
|
||||||
|
r = await self.client.put(f"/user/starred/{owner}/{repo}")
|
||||||
|
else:
|
||||||
|
r = await self.client.delete(f"/user/starred/{owner}/{repo}")
|
||||||
|
r.raise_for_status()
|
||||||
|
self._cache_clear()
|
||||||
|
|
||||||
async def create_issue(
|
async def create_issue(
|
||||||
self, owner: str, repo: str, title: str, body: str = ""
|
self, owner: str, repo: str, title: str, body: str = ""
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
|||||||
+200
-1
@@ -489,21 +489,133 @@ def _parse_order(raw: str) -> list[int]:
|
|||||||
|
|
||||||
# ── Routes: Pages ─────────────────────────────────────────
|
# ── 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)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def dashboard(request: Request):
|
async def dashboard(request: Request):
|
||||||
"""Main dashboard — all projects at a glance."""
|
"""Main dashboard — all projects at a glance."""
|
||||||
repos = await gitea.search_repos()
|
repos = await gitea.search_repos()
|
||||||
active, inactive, archived = classify_repos(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:
|
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["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(
|
return render(
|
||||||
request,
|
request,
|
||||||
"dashboard.html",
|
"dashboard.html",
|
||||||
active_repos=active,
|
active_repos=active,
|
||||||
inactive_repos=inactive,
|
inactive_repos=inactive,
|
||||||
archived_repos=archived,
|
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(),
|
stats=get_stats(),
|
||||||
COLUMN_LABELS=COLUMN_LABELS,
|
COLUMN_LABELS=COLUMN_LABELS,
|
||||||
COLUMN_COLORS=COLUMN_COLORS,
|
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)
|
@app.get("/board/{owner}/{repo}", response_class=HTMLResponse)
|
||||||
async def board(request: Request, owner: str, repo: str, milestone: str = ""):
|
async def board(request: Request, owner: str, repo: str, milestone: str = ""):
|
||||||
"""Kanban board for a specific repo (optionally one milestone)."""
|
"""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 "")
|
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)
|
@app.get("/triage", response_class=HTMLResponse)
|
||||||
async def triage_picker(request: Request):
|
async def triage_picker(request: Request):
|
||||||
"""Triage mode (issue #11): pick a project by its backlog size."""
|
"""Triage mode (issue #11): pick a project by its backlog size."""
|
||||||
|
|||||||
+32
-21
@@ -12,51 +12,62 @@
|
|||||||
if (localStorage.getItem('kanbante-sidebar') === 'collapsed') {
|
if (localStorage.getItem('kanbante-sidebar') === 'collapsed') {
|
||||||
document.body.classList.add('sidebar-collapsed');
|
document.body.classList.add('sidebar-collapsed');
|
||||||
}
|
}
|
||||||
|
JSON.parse(localStorage.getItem('kanbante-nav-closed') || '[]')
|
||||||
|
.forEach(function (k) {
|
||||||
|
document.body.classList.add('nav-closed-' + k);
|
||||||
|
});
|
||||||
} catch (e) { /* stockage indisponible : état par défaut */ }
|
} catch (e) { /* stockage indisponible : état par défaut */ }
|
||||||
</script>
|
</script>
|
||||||
{% set nav_groups = [
|
{% set nav_groups = [
|
||||||
("Principal", [
|
("principal", "Principal", [
|
||||||
("home", "/", "🏠", "Projets"),
|
("home", "/", "🏠", "Projets"),
|
||||||
("focus", "/focus", "🎯", "Focus"),
|
("focus", "/focus", "🎯", "Focus"),
|
||||||
("today", "/today", "🌅", "Aujourd'hui"),
|
("today", "/today", "🌅", "Aujourd'hui"),
|
||||||
("my", "/my", "👤", "Mes issues"),
|
("my", "/my", "👤", "Mes issues"),
|
||||||
]),
|
]),
|
||||||
("Rituels", [
|
("rituels", "Rituels", [
|
||||||
("briefing", "/briefing", "📊", "Briefing"),
|
("briefing", "/briefing", "📊", "Briefing"),
|
||||||
("standup", "/standup", "🗣️", "Standup"),
|
("standup", "/standup", "🗣️", "Standup"),
|
||||||
("digest", "/digest", "🧠", "Digest"),
|
("digest", "/digest", "🧠", "Digest"),
|
||||||
]),
|
]),
|
||||||
("Pilotage", [
|
("pilotage", "Pilotage", [
|
||||||
("forecast", "/forecast", "🔮", "Prévisions"),
|
("forecast", "/forecast", "🔮", "Prévisions"),
|
||||||
("health", "/health", "🩺", "Santé"),
|
("health", "/health", "🩺", "Santé"),
|
||||||
("rules", "/rules", "⚙️", "Règles"),
|
("rules", "/rules", "⚙️", "Règles"),
|
||||||
("activity", "/activity", "📰", "Activité"),
|
("activity", "/activity", "📰", "Activité"),
|
||||||
]),
|
]),
|
||||||
("Outils", [
|
("outils", "Outils", [
|
||||||
("search", "/search", "🔍", "Recherche"),
|
("search", "/search", "🔍", "Recherche"),
|
||||||
("capture", "/capture", "⚡", "Capture"),
|
("capture", "/capture", "⚡", "Capture"),
|
||||||
("wall", "/wall", "🖥️", "Wallboard"),
|
("wall", "/wall", "🖥️", "Wallboard"),
|
||||||
]),
|
]),
|
||||||
] %}
|
] %}
|
||||||
<aside class="sidebar" id="sidebar" aria-label="Navigation principale">
|
<aside class="sidebar" id="sidebar" aria-label="Navigation principale">
|
||||||
<a href="/" class="side-logo" aria-label="Kanbante — accueil">
|
<div class="side-head">
|
||||||
<span class="logo-mark">📋</span>
|
<a href="/" class="side-logo" aria-label="Kanbante — accueil">
|
||||||
<span class="side-label gradient-text">Kanbante</span>
|
<span class="logo-mark">📋</span>
|
||||||
</a>
|
<span class="side-label gradient-text">Kanbante</span>
|
||||||
<button type="button" class="side-search palette-trigger" title="Palette de commandes (Ctrl+K)">
|
|
||||||
<span class="side-ico">⌨️</span>
|
|
||||||
<span class="side-label">Recherche & commandes</span>
|
|
||||||
<kbd class="side-label">Ctrl K</kbd>
|
|
||||||
</button>
|
|
||||||
<nav class="side-nav">
|
|
||||||
{% for group, links in nav_groups %}
|
|
||||||
<div class="side-group">{{ group }}</div>
|
|
||||||
{% for key, href, icon, label in links %}
|
|
||||||
<a href="{{ href }}" class="side-link{% if active_nav == key %} active{% endif %}" title="{{ label }}">
|
|
||||||
<span class="side-ico">{{ icon }}</span>
|
|
||||||
<span class="side-label">{{ label }}</span>
|
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
<button type="button" class="side-search palette-trigger" title="Recherche & commandes (Ctrl+K)" aria-label="Recherche et commandes (Ctrl+K)">🔍</button>
|
||||||
|
</div>
|
||||||
|
<nav class="side-nav">
|
||||||
|
{% for gkey, group, links in nav_groups %}
|
||||||
|
<div class="side-section" data-group="{{ gkey }}">
|
||||||
|
<button type="button" class="side-group-btn" data-group-btn="{{ gkey }}" aria-expanded="true">
|
||||||
|
<span>{{ group }}</span>
|
||||||
|
<span class="side-chevron" aria-hidden="true">▾</span>
|
||||||
|
</button>
|
||||||
|
<div class="side-acc">
|
||||||
|
<div class="side-acc-inner">
|
||||||
|
{% for key, href, icon, label in links %}
|
||||||
|
<a href="{{ href }}" class="side-link{% if active_nav == key %} active{% endif %}" title="{{ label }}">
|
||||||
|
<span class="side-ico">{{ icon }}</span>
|
||||||
|
<span class="side-label">{{ label }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</nav>
|
</nav>
|
||||||
<div class="side-foot">
|
<div class="side-foot">
|
||||||
|
|||||||
@@ -49,14 +49,21 @@
|
|||||||
{% macro project_card(repo, archived=False) %}
|
{% macro project_card(repo, archived=False) %}
|
||||||
{% set full = repo.full_name %}
|
{% set full = repo.full_name %}
|
||||||
<div class="project-card{% if archived %} archived{% endif %}"
|
<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 }}"
|
data-search="{{ (repo.name ~ ' ' ~ repo.full_name ~ ' ' ~ (repo.description or ''))|lower }}"
|
||||||
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
|
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="org-tag">{{ repo.owner.login }}</span>
|
<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>
|
<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>
|
</div>
|
||||||
<h3>
|
<h3>
|
||||||
<a href="/board/{{ repo.owner.login }}/{{ repo.name }}">{{ repo.name }}</a>
|
<a href="/project/{{ repo.owner.login }}/{{ repo.name }}">{{ repo.name }}</a>
|
||||||
</h3>
|
</h3>
|
||||||
<p class="desc">{{ repo.description or '—' }}</p>
|
<p class="desc">{{ repo.description or '—' }}</p>
|
||||||
{% if full in stats %}
|
{% if full in stats %}
|
||||||
@@ -68,8 +75,22 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% 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">
|
<div class="card-footer">
|
||||||
<span>{{ repo.open_issues_count or 0 }} issues ouvertes</span>
|
<span>{{ repo.open_issues_count or 0 }} issues ouvertes</span>
|
||||||
|
<a href="/board/{{ full }}" class="card-board-link">📋 Board</a>
|
||||||
{% if repo.forecast_label %}
|
{% 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>
|
<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 %}
|
{% endif %}
|
||||||
@@ -89,21 +110,29 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
<div x-show="tab === 'active'" class="project-grid">
|
<!-- Épinglés (#20) : les dépôts étoilés dans Gitea, hors onglets. -->
|
||||||
{% for repo in active_repos %}{{ project_card(repo) }}{% endfor %}
|
<section id="pinned-section"{% if not pinned_repos %} hidden{% endif %}>
|
||||||
{% if not active_repos %}
|
<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>
|
<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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div x-show="tab === 'inactive'" class="project-grid">
|
<div x-show="tab === 'inactive'" class="project-grid" id="grid-inactive">
|
||||||
{% for repo in inactive_repos %}{{ project_card(repo) }}{% endfor %}
|
{% for repo in inactive_listed %}{{ project_card(repo) }}{% endfor %}
|
||||||
{% if not inactive_repos %}
|
{% if not inactive_listed %}
|
||||||
<div class="empty-state">✨ Rien d'endormi : tous vos projets ont eu de l'activité récemment.</div>
|
<div class="empty-state">✨ Rien d'endormi : tous vos projets ont eu de l'activité récemment.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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 %}
|
{% for repo in archived_repos %}{{ project_card(repo, archived=True) }}{% endfor %}
|
||||||
{% if not archived_repos %}
|
{% if not archived_repos %}
|
||||||
<div class="empty-state">🗄️ Aucun projet archivé. L'archivage se fait dans Gitea (Réglages du dépôt).</div>
|
<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
|
Kanbante v{{ version }} · {{ active_repos|length + inactive_repos|length + archived_repos|length }} projets sur Gitea · actifs = mis à jour depuis {{ active_window_days }} jours
|
||||||
</footer>
|
</footer>
|
||||||
<script src="/static/js/live.js" defer></script>
|
<script src="/static/js/live.js" defer></script>
|
||||||
|
<script>window.KANBANTE_CSRF = "{{ csrf_token }}";</script>
|
||||||
|
<script src="/static/js/stars.js" defer></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('keydown', function (e) {
|
document.addEventListener('keydown', function (e) {
|
||||||
if (e.key === '/' && !/INPUT|TEXTAREA/.test(document.activeElement?.tagName || '')) {
|
if (e.key === '/' && !/INPUT|TEXTAREA/.test(document.activeElement?.tagName || '')) {
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ full_name }} — Projet · Kanbante</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📋</text></svg>">
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include "_header.html" %}
|
||||||
|
|
||||||
|
<main class="project-main">
|
||||||
|
<div class="proj-head">
|
||||||
|
<div>
|
||||||
|
<h1>📦 {{ repo_info.name }}</h1>
|
||||||
|
<p class="proj-sub">{{ owner }} · {{ repo_info.description or 'Aucune description dans Gitea.' }}</p>
|
||||||
|
</div>
|
||||||
|
{% if health_dot %}
|
||||||
|
<a href="/health/{{ full_name }}" class="health-chip" title="{{ health_dot.tooltip }}">
|
||||||
|
<span class="health-dot {{ health_dot.level }}"></span> Santé du flux
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="proj-actions">
|
||||||
|
<a href="/board/{{ full_name }}" class="nav-pill proj-primary">📋 Ouvrir le board</a>
|
||||||
|
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||||
|
<a href="/triage/{{ full_name }}" class="nav-pill">📥 Triage</a>
|
||||||
|
<a href="/health/{{ full_name }}" class="nav-pill">🩺 Santé</a>
|
||||||
|
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||||
|
<a href="/wall?repo={{ full_name }}" class="nav-pill">🖥️ Wallboard</a>
|
||||||
|
{% if repo_info.html_url %}
|
||||||
|
<a href="{{ repo_info.html_url }}" target="_blank" rel="noopener" class="nav-pill">🔗 Gitea ↗</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="proj-grid">
|
||||||
|
<section class="proj-panel">
|
||||||
|
<h2>🎯 Jalons ouverts</h2>
|
||||||
|
{% if milestones %}
|
||||||
|
{% for ms in milestones %}
|
||||||
|
<div class="proj-ms">
|
||||||
|
<div class="ms-line">
|
||||||
|
<a href="/board/{{ full_name }}?milestone={{ ms.title|urlencode }}"><strong>{{ ms.title }}</strong></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 %}
|
||||||
|
<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 }}{% endif %}</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="ms-meta">Aucune issue dans ce jalon pour le moment.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p class="proj-empty">Aucun jalon ouvert. Les jalons se créent dans Gitea — le board et le récit de sprint s'appuient dessus.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="proj-panel">
|
||||||
|
<h2>🩺 Santé du flux</h2>
|
||||||
|
{% if health %}
|
||||||
|
<ul class="proj-list">
|
||||||
|
<li>📂 <strong>{{ health.open_count }}</strong> cartes ouvertes</li>
|
||||||
|
{% if health.slowest %}
|
||||||
|
<li>🐢 Goulot : <strong>{{ COLUMN_LABELS[health.slowest.column] }}</strong> — séjour médian {{ health.slowest.median_days }} j</li>
|
||||||
|
{% else %}
|
||||||
|
<li>🐢 Pas encore de goulot mesurable (historique trop court).</li>
|
||||||
|
{% endif %}
|
||||||
|
<li>🧟 Zombies (≥ {{ health.zombie_days }} j sans activité) : <strong>{{ health.zombies|length }}</strong></li>
|
||||||
|
</ul>
|
||||||
|
{% if health.zombies %}
|
||||||
|
<ul class="proj-list proj-zombies">
|
||||||
|
{% for z in health.zombies[:5] %}
|
||||||
|
<li><a href="{{ z.html_url }}" target="_blank" rel="noopener">#{{ z.number }}</a> {{ z.title }} <span class="proj-dim">· {{ z.column_label }} · {{ z.days_inactive }} j</span></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
<p><a href="/health/{{ full_name }}">Détail de la santé →</a></p>
|
||||||
|
{% else %}
|
||||||
|
<p class="proj-empty">Santé indisponible pour le moment.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="proj-panel">
|
||||||
|
<h2>🔮 Prévisions</h2>
|
||||||
|
{% if throughput %}
|
||||||
|
<ul class="proj-list">
|
||||||
|
<li>Débit : <strong>{{ throughput.avg }}</strong> fermetures / semaine en moyenne (min {{ throughput.min }} · max {{ throughput.max }}, 4 dernières semaines complètes)</li>
|
||||||
|
<li>Issues ouvertes : <strong>{{ repo_info.open_issues_count or 0 }}</strong></li>
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="proj-empty">Pas assez d'historique de fermetures pour prévoir — le journal se remplit au fil des cartes terminées.</p>
|
||||||
|
{% endif %}
|
||||||
|
<p><a href="/forecast">Toutes les prévisions →</a></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="proj-panel">
|
||||||
|
<h2>🕘 Activité récente</h2>
|
||||||
|
{% if events %}
|
||||||
|
<ul class="proj-list">
|
||||||
|
{% for e in events %}
|
||||||
|
<li>{{ EVENT_ICONS.get(e.event_type, '•') }} <a href="/board/{{ full_name }}">#{{ e.issue_number }}</a> — {{ e.event_type }}{% if e.to_column %} → {{ COLUMN_LABELS.get(e.to_column, e.to_column) }}{% endif %} <span class="proj-dim">· {{ e.created_at[:16].replace('T', ' ') }}</span></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="proj-empty">Aucun événement du journal pour ce projet pour le moment.</p>
|
||||||
|
{% endif %}
|
||||||
|
<p><a href="/activity">Journal complet →</a></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="proj-panel proj-notes">
|
||||||
|
<h2>📝 Notes du projet</h2>
|
||||||
|
{% if notes %}
|
||||||
|
<div class="proj-notes-body">{{ notes }}</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="proj-empty">Aucune note. Les notes s'écrivent depuis le panneau « 🗒️ Notes » du board et vivent avec le projet.</p>
|
||||||
|
{% endif %}
|
||||||
|
<p><a href="/board/{{ full_name }}">Ouvrir le board pour éditer →</a></p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>Kanbante v{{ version }} · Page projet — jalons, santé, prévisions, activité et notes en un coup d'œil.</footer>
|
||||||
|
{% include "_palette.html" %}
|
||||||
|
<script src="/static/js/palette.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "kanbante"
|
name = "kanbante"
|
||||||
version = "0.16.0"
|
version = "0.17.0"
|
||||||
description = "Kanban project manager integrated with Gitea"
|
description = "Kanban project manager integrated with Gitea"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
+109
-21
@@ -1217,23 +1217,61 @@ body.with-sidebar.sidebar-collapsed { padding-left: 68px; }
|
|||||||
body.sidebar-collapsed .sidebar { width: 68px; }
|
body.sidebar-collapsed .sidebar { width: 68px; }
|
||||||
.side-logo { display: flex; align-items: center; gap: 10px; padding: 4px 8px 12px; color: var(--text); font-size: 1.18rem; font-weight: 700; white-space: nowrap; }
|
.side-logo { display: flex; align-items: center; gap: 10px; padding: 4px 8px 12px; color: var(--text); font-size: 1.18rem; font-weight: 700; white-space: nowrap; }
|
||||||
.side-logo:hover { text-decoration: none; }
|
.side-logo:hover { text-decoration: none; }
|
||||||
|
.side-head {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 4px 6px 12px;
|
||||||
|
}
|
||||||
|
.side-head .side-logo { padding: 0; }
|
||||||
.side-search {
|
.side-search {
|
||||||
display: flex; align-items: center; gap: 10px; width: 100%;
|
margin-left: auto; flex-shrink: 0;
|
||||||
padding: 9px 10px; margin-bottom: 8px; cursor: pointer;
|
width: 38px; height: 38px; border-radius: 10px; cursor: pointer;
|
||||||
background: var(--surface-2); color: var(--text-2);
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
border: 1px solid var(--border); border-radius: 10px; font-size: 0.9rem;
|
background: var(--surface-2); color: var(--text-2); font-size: 1.02rem;
|
||||||
white-space: nowrap; overflow: hidden;
|
border: 1px solid var(--border);
|
||||||
|
transition: border-color 0.15s, color 0.15s, transform 0.15s;
|
||||||
}
|
}
|
||||||
.side-search:hover { color: var(--text); border-color: var(--accent, #58a6ff); }
|
.side-search:hover {
|
||||||
.side-search kbd {
|
color: var(--text); border-color: var(--accent, #58a6ff);
|
||||||
margin-left: auto; font-size: 0.72rem; background: var(--surface-3, #1d2942);
|
transform: translateY(-1px);
|
||||||
border: 1px solid var(--border); border-radius: 5px; padding: 1px 6px;
|
|
||||||
}
|
}
|
||||||
|
body.sidebar-collapsed .side-head { flex-direction: column; padding-left: 0; padding-right: 0; }
|
||||||
|
body.sidebar-collapsed .side-search { margin-left: 0; }
|
||||||
.side-nav { display: flex; flex-direction: column; gap: 2px; }
|
.side-nav { display: flex; flex-direction: column; gap: 2px; }
|
||||||
.side-group {
|
.side-section { margin-top: 10px; }
|
||||||
margin: 14px 10px 5px; font-size: 0.7rem; font-weight: 700;
|
.side-section:first-child { margin-top: 2px; }
|
||||||
letter-spacing: 0.14em; text-transform: uppercase; color: var(--text-3);
|
.side-group-btn {
|
||||||
white-space: nowrap;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
width: 100%; padding: 7px 10px; cursor: pointer;
|
||||||
|
background: transparent; border: none; border-radius: 8px;
|
||||||
|
font-size: 0.7rem; font-weight: 700; letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase; color: var(--text-3); white-space: nowrap;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.side-group-btn:hover { color: var(--text); background: var(--surface-2); }
|
||||||
|
.side-chevron {
|
||||||
|
font-size: 0.62rem; transition: transform 0.25s ease; opacity: 0.8;
|
||||||
|
}
|
||||||
|
.side-acc {
|
||||||
|
display: grid; grid-template-rows: 1fr;
|
||||||
|
transition: grid-template-rows 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.side-acc-inner {
|
||||||
|
overflow: hidden; min-height: 0;
|
||||||
|
display: flex; flex-direction: column; gap: 2px;
|
||||||
|
}
|
||||||
|
body.nav-closed-principal [data-group="principal"] .side-acc,
|
||||||
|
body.nav-closed-rituels [data-group="rituels"] .side-acc,
|
||||||
|
body.nav-closed-pilotage [data-group="pilotage"] .side-acc,
|
||||||
|
body.nav-closed-outils [data-group="outils"] .side-acc { grid-template-rows: 0fr; }
|
||||||
|
body.nav-closed-principal [data-group="principal"] .side-chevron,
|
||||||
|
body.nav-closed-rituels [data-group="rituels"] .side-chevron,
|
||||||
|
body.nav-closed-pilotage [data-group="pilotage"] .side-chevron,
|
||||||
|
body.nav-closed-outils [data-group="outils"] .side-chevron { transform: rotate(-90deg); }
|
||||||
|
@media (min-width: 901px) {
|
||||||
|
/* Rail replié : pas d'accordéon, toutes les icônes visibles. */
|
||||||
|
body.sidebar-collapsed .side-acc { grid-template-rows: 1fr !important; }
|
||||||
|
body.sidebar-collapsed .side-group-btn { display: none; }
|
||||||
|
body.sidebar-collapsed .side-section { margin-top: 4px; }
|
||||||
}
|
}
|
||||||
.side-link {
|
.side-link {
|
||||||
display: flex; align-items: center; gap: 11px;
|
display: flex; align-items: center; gap: 11px;
|
||||||
@@ -1260,10 +1298,8 @@ body.sidebar-collapsed .sidebar { width: 68px; }
|
|||||||
.side-collapse-ico { display: inline-block; transition: transform 0.18s ease; }
|
.side-collapse-ico { display: inline-block; transition: transform 0.18s ease; }
|
||||||
body.sidebar-collapsed .side-collapse-ico { transform: rotate(180deg); }
|
body.sidebar-collapsed .side-collapse-ico { transform: rotate(180deg); }
|
||||||
.side-version { color: var(--text-3); font-size: 0.8rem; }
|
.side-version { color: var(--text-3); font-size: 0.8rem; }
|
||||||
body.sidebar-collapsed .side-label,
|
body.sidebar-collapsed .side-label { display: none; }
|
||||||
body.sidebar-collapsed .side-group { display: none; }
|
body.sidebar-collapsed .side-link { justify-content: center; padding-left: 0; padding-right: 0; }
|
||||||
body.sidebar-collapsed .side-link,
|
|
||||||
body.sidebar-collapsed .side-search { justify-content: center; padding-left: 0; padding-right: 0; }
|
|
||||||
body.sidebar-collapsed .side-logo { justify-content: center; padding-left: 0; padding-right: 0; }
|
body.sidebar-collapsed .side-logo { justify-content: center; padding-left: 0; padding-right: 0; }
|
||||||
body.sidebar-collapsed .side-foot { justify-content: center; }
|
body.sidebar-collapsed .side-foot { justify-content: center; }
|
||||||
.side-backdrop {
|
.side-backdrop {
|
||||||
@@ -1276,10 +1312,62 @@ body.sidebar-collapsed .side-foot { justify-content: center; }
|
|||||||
body.with-sidebar, body.with-sidebar.sidebar-collapsed { padding-left: 0; }
|
body.with-sidebar, body.with-sidebar.sidebar-collapsed { padding-left: 0; }
|
||||||
.sidebar { width: 264px; transform: translateX(-105%); box-shadow: none; }
|
.sidebar { width: 264px; transform: translateX(-105%); box-shadow: none; }
|
||||||
body.sidebar-open .sidebar { transform: none; box-shadow: 18px 0 44px rgba(0,0,0,.5); }
|
body.sidebar-open .sidebar { transform: none; box-shadow: 18px 0 44px rgba(0,0,0,.5); }
|
||||||
body.sidebar-collapsed .side-label,
|
body.sidebar-collapsed .side-label { display: initial; }
|
||||||
body.sidebar-collapsed .side-group { display: initial; }
|
body.sidebar-collapsed .side-link { justify-content: flex-start; padding-left: 10px; padding-right: 10px; }
|
||||||
body.sidebar-collapsed .side-link,
|
body.sidebar-collapsed .side-head { flex-direction: row; padding: 4px 6px 12px; }
|
||||||
body.sidebar-collapsed .side-search { justify-content: flex-start; padding-left: 10px; padding-right: 10px; }
|
body.sidebar-collapsed .side-search { margin-left: auto; }
|
||||||
.side-foot { display: none; }
|
.side-foot { display: none; }
|
||||||
.topbar-search { display: inline-flex; }
|
.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; }
|
||||||
|
|||||||
@@ -42,4 +42,47 @@
|
|||||||
} catch (e) { /* private mode: state lasts for the page only */ }
|
} catch (e) { /* private mode: state lasts for the page only */ }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Accordion sections: the closed set lives in body classes
|
||||||
|
(applied pre-paint by the bootstrap script in _header.html)
|
||||||
|
and is persisted here on every toggle. */
|
||||||
|
var groupBtns = document.querySelectorAll('[data-group-btn]');
|
||||||
|
function persistClosedGroups() {
|
||||||
|
var closed = [];
|
||||||
|
groupBtns.forEach(function (btn) {
|
||||||
|
if (document.body.classList.contains('nav-closed-' + btn.getAttribute('data-group-btn'))) {
|
||||||
|
closed.push(btn.getAttribute('data-group-btn'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
localStorage.setItem('kanbante-nav-closed', JSON.stringify(closed));
|
||||||
|
} catch (e) { /* non-fatal */ }
|
||||||
|
}
|
||||||
|
function syncGroupBtn(btn) {
|
||||||
|
var closed = document.body.classList.contains(
|
||||||
|
'nav-closed-' + btn.getAttribute('data-group-btn'));
|
||||||
|
btn.setAttribute('aria-expanded', closed ? 'false' : 'true');
|
||||||
|
}
|
||||||
|
groupBtns.forEach(function (btn) {
|
||||||
|
syncGroupBtn(btn);
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
document.body.classList.toggle(
|
||||||
|
'nav-closed-' + btn.getAttribute('data-group-btn'));
|
||||||
|
syncGroupBtn(btn);
|
||||||
|
persistClosedGroups();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
/* The section holding the current page is always opened. */
|
||||||
|
var activeLink = document.querySelector('.sidebar .side-link.active');
|
||||||
|
if (activeLink) {
|
||||||
|
var section = activeLink.closest('.side-section');
|
||||||
|
if (section) {
|
||||||
|
var key = section.getAttribute('data-group');
|
||||||
|
if (document.body.classList.contains('nav-closed-' + key)) {
|
||||||
|
document.body.classList.remove('nav-closed-' + key);
|
||||||
|
groupBtns.forEach(syncGroupBtn);
|
||||||
|
persistClosedGroups();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Kanbante — pin projects via Gitea stars (issue #20).
|
||||||
|
* The star lives in Gitea (source of truth); this only toggles it
|
||||||
|
* through the API and moves the card between the pinned section
|
||||||
|
* and its home tab grid, without a page reload.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
var pinnedSection = document.getElementById('pinned-section');
|
||||||
|
var pinnedGrid = document.getElementById('pinned-grid');
|
||||||
|
|
||||||
|
function refreshPinnedVisibility() {
|
||||||
|
if (!pinnedSection || !pinnedGrid) return;
|
||||||
|
pinnedSection.hidden = pinnedGrid.querySelectorAll('.project-card').length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.star-btn').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
var full = btn.getAttribute('data-full');
|
||||||
|
var parts = full.split('/');
|
||||||
|
var want = btn.getAttribute('data-starred') !== '1';
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('starred', want ? '1' : '0');
|
||||||
|
fd.append('csrf_token', window.KANBANTE_CSRF || '');
|
||||||
|
var res = await fetch('/api/projects/' + parts[0] + '/' + parts[1] + '/star', {
|
||||||
|
method: 'POST', body: fd,
|
||||||
|
});
|
||||||
|
var data = await res.json();
|
||||||
|
if (!data.ok) throw new Error(data.error || 'échec');
|
||||||
|
btn.setAttribute('data-starred', want ? '1' : '0');
|
||||||
|
btn.textContent = want ? '⭐' : '☆';
|
||||||
|
btn.title = want ? 'Désépingler (étoile Gitea)' : 'Épingler (étoile Gitea)';
|
||||||
|
var card = btn.closest('.project-card');
|
||||||
|
if (card) {
|
||||||
|
if (want && pinnedGrid) {
|
||||||
|
pinnedGrid.appendChild(card);
|
||||||
|
} else {
|
||||||
|
var home = document.getElementById('grid-' + (card.getAttribute('data-home') || 'active'));
|
||||||
|
if (home) home.appendChild(card);
|
||||||
|
}
|
||||||
|
refreshPinnedVisibility();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
btn.textContent = '⚠️';
|
||||||
|
setTimeout(function () {
|
||||||
|
btn.textContent = btn.getAttribute('data-starred') === '1' ? '⭐' : '☆';
|
||||||
|
}, 1600);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -68,6 +68,7 @@ class FakeGitea:
|
|||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
self.starred = set()
|
||||||
self.repos = [
|
self.repos = [
|
||||||
_repo("demo", now - timedelta(days=1)),
|
_repo("demo", now - timedelta(days=1)),
|
||||||
_repo("vieux", now - timedelta(days=800)),
|
_repo("vieux", now - timedelta(days=800)),
|
||||||
@@ -270,6 +271,15 @@ class FakeGitea:
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
async def list_starred(self):
|
||||||
|
return [r for r in self.repos if r["full_name"] in self.starred]
|
||||||
|
|
||||||
|
async def set_starred(self, owner, repo, starred):
|
||||||
|
if starred:
|
||||||
|
self.starred.add(f"{owner}/{repo}")
|
||||||
|
else:
|
||||||
|
self.starred.discard(f"{owner}/{repo}")
|
||||||
|
|
||||||
async def get_milestones(self, owner, repo, state="open"):
|
async def get_milestones(self, owner, repo, state="open"):
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
+16
-1
@@ -13,11 +13,24 @@ def test_sidebar_on_app_pages(client, fake_gitea):
|
|||||||
assert page.text.count('id="nav-burger"') == 1, path
|
assert page.text.count('id="nav-burger"') == 1, path
|
||||||
assert "with-sidebar" in page.text, path
|
assert "with-sidebar" in page.text, path
|
||||||
assert "/static/js/nav.js" in page.text, path
|
assert "/static/js/nav.js" in page.text, path
|
||||||
# Les quatre groupes de la barre latérale.
|
# Les quatre sections accordéon de la barre latérale.
|
||||||
for group in ("Principal", "Rituels", "Pilotage", "Outils"):
|
for group in ("Principal", "Rituels", "Pilotage", "Outils"):
|
||||||
assert group in page.text, (path, group)
|
assert group in page.text, (path, group)
|
||||||
|
for gkey in ("principal", "rituels", "pilotage", "outils"):
|
||||||
|
assert f'data-group-btn="{gkey}"' in page.text, (path, gkey)
|
||||||
|
assert page.text.count('class="side-acc"') == 4, path
|
||||||
|
assert "kanbante-nav-closed" in page.text # bootstrap pré-rendu
|
||||||
assert 'href="/health"' in page.text
|
assert 'href="/health"' in page.text
|
||||||
assert 'id="side-collapse"' in page.text
|
assert 'id="side-collapse"' in page.text
|
||||||
|
# Bouton palette : icône seule, à droite du titre — pas de
|
||||||
|
# libellé texte « Recherche & commandes » visible.
|
||||||
|
assert 'class="side-head"' in page.text
|
||||||
|
assert ">🔍</button>" in page.text
|
||||||
|
# Le libellé de l'ancien bouton palette a disparu (le lien de
|
||||||
|
# navigation « Recherche », lui, existe toujours — c'est le
|
||||||
|
# texte complet « Recherche & commandes » qui doit manquer).
|
||||||
|
assert 'side-label">Recherche & commandes' not in page.text
|
||||||
|
assert "Recherche & commandes</span>" not in page.text
|
||||||
|
|
||||||
|
|
||||||
def test_sidebar_active_states(client, fake_gitea):
|
def test_sidebar_active_states(client, fake_gitea):
|
||||||
@@ -51,6 +64,8 @@ def test_nav_js_contract(client, fake_gitea):
|
|||||||
assert "localStorage" in js.text
|
assert "localStorage" in js.text
|
||||||
assert "side-backdrop" in js.text
|
assert "side-backdrop" in js.text
|
||||||
assert "Escape" in js.text
|
assert "Escape" in js.text
|
||||||
|
assert "kanbante-nav-closed" in js.text # accordéons persistés
|
||||||
|
assert "data-group-btn" in js.text
|
||||||
|
|
||||||
|
|
||||||
def test_wall_more_cards_line(client, fake_gitea, monkeypatch):
|
def test_wall_more_cards_line(client, fake_gitea, monkeypatch):
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Palier 13 (0.17.0): vague 2 — jalon sur la carte (#17), pastille
|
||||||
|
de santé (#18), épinglés via étoiles Gitea (#20), page Projet (#25)."""
|
||||||
|
|
||||||
|
from tests.conftest import csrf_from
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_shows_next_milestone(client, fake_gitea):
|
||||||
|
page = client.get("/")
|
||||||
|
assert page.status_code == 200
|
||||||
|
# Faux : Sprint 1, échéance 2026-10-20, 1 fermée / 2 au total.
|
||||||
|
assert "🎯 Sprint 1" in page.text
|
||||||
|
assert "1/2 terminées" in page.text
|
||||||
|
assert "échéance" in page.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_health_dot(client, fake_gitea):
|
||||||
|
page = client.get("/")
|
||||||
|
assert 'class="health-dot ' in page.text
|
||||||
|
assert 'href="/health/Projets/demo"' in page.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_starred_section_by_default(client, fake_gitea):
|
||||||
|
page = client.get("/")
|
||||||
|
assert 'id="pinned-section" hidden' in page.text
|
||||||
|
assert 'data-starred="0"' in page.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_starred_repo_is_pinned(client, fake_gitea):
|
||||||
|
fake_gitea.starred.add("Projets/demo")
|
||||||
|
page = client.get("/")
|
||||||
|
assert 'id="pinned-section" hidden' not in page.text
|
||||||
|
assert 'data-starred="1"' in page.text
|
||||||
|
# La carte épinglée sort de la grille de son onglet d'origine :
|
||||||
|
# une seule carte (div) pour demo — le bouton étoile porte aussi
|
||||||
|
# un data-full, on compte donc le motif de la carte elle-même.
|
||||||
|
assert page.text.count('data-full="Projets/demo" data-home') == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_star_toggle_endpoint(client, fake_gitea):
|
||||||
|
csrf = csrf_from(client)
|
||||||
|
denied = client.post(
|
||||||
|
"/api/projects/Projets/demo/star", data={"starred": "1"}
|
||||||
|
)
|
||||||
|
assert denied.status_code == 403
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/projects/Projets/demo/star",
|
||||||
|
data={"starred": "1", "csrf_token": csrf},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == {"ok": True, "starred": True}
|
||||||
|
assert "Projets/demo" in fake_gitea.starred
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/projects/Projets/demo/star",
|
||||||
|
data={"starred": "0", "csrf_token": csrf},
|
||||||
|
)
|
||||||
|
assert resp.json() == {"ok": True, "starred": False}
|
||||||
|
assert "Projets/demo" not in fake_gitea.starred
|
||||||
|
|
||||||
|
|
||||||
|
def test_card_title_links_to_project_page(client, fake_gitea):
|
||||||
|
page = client.get("/")
|
||||||
|
assert 'href="/project/Projets/demo"' in page.text
|
||||||
|
assert 'href="/board/Projets/demo"' in page.text # accès board conservé
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_page(client, fake_gitea):
|
||||||
|
from app import models
|
||||||
|
|
||||||
|
models.log_event("Projets/demo", 1, "created", to_column="backlog")
|
||||||
|
models.log_event(
|
||||||
|
"Projets/demo", 1, "moved",
|
||||||
|
from_column="backlog", to_column="in_progress",
|
||||||
|
)
|
||||||
|
page = client.get("/project/Projets/demo")
|
||||||
|
assert page.status_code == 200
|
||||||
|
assert "🎯 Jalons ouverts" in page.text
|
||||||
|
assert "Sprint 1" in page.text
|
||||||
|
assert "🩺 Santé du flux" in page.text
|
||||||
|
assert "🔮 Prévisions" in page.text
|
||||||
|
assert "🕘 Activité récente" in page.text
|
||||||
|
assert "📝 Notes du projet" in page.text
|
||||||
|
assert 'href="/board/Projets/demo"' in page.text
|
||||||
|
assert "/wall?repo=Projets/demo" in page.text
|
||||||
|
# Titre du projet et lien Gitea du faux.
|
||||||
|
assert "demo" in page.text
|
||||||
Reference in New Issue
Block a user