8 Commits
Author SHA1 Message Date
Spark 018bde0fa5 chore(release): Kanbante 0.17.0 — vague 2 : cartes projets enrichies et page Projet
CI / test (push) Successful in 1m46s
2026-10-11 22:52:22 -04:00
Spark 0883191f57 test(0.17.0): jalon, santé, étoiles, page projet — 7 tests, 178 au total 2026-10-11 22:52:22 -04:00
Spark ba47b1a9ac feat(projets): page Projet /project/{owner}/{repo} (#25)
Accueil du projet assemblé des briques existantes: jalons
ouverts (progression, échéances, ETA), santé en résumé, débit,
activité récente du journal, notes; actions board/capture/
triage/santé/wallboard/Gitea. Le titre des cartes y pointe.
2026-10-11 22:52:22 -04:00
Spark cf58bb9d0c feat(projets): épinglés par étoiles Gitea (#20)
list_starred/set_starred sur /user/starred (vérité dans la
forge), endpoint POST /api/projects/{o}/{r}/star (CSRF), section
Épinglés hors onglets, bouton étoile sur les cartes et bascule
sans rechargement; les épinglés sortent de leur grille d'onglet.
2026-10-11 22:52:22 -04:00
Spark b33b67fcb4 feat(dashboard): prochain jalon et pastille de santé sur les cartes (#17, #18)
Bloc jalon (progression Gitea, échéance FR, retard, ETA journal
avec signal après-échéance — mêmes définitions que le bandeau
du board) et point santé vert/orange/rouge dérivé de
_project_health, info-bulle et lien vers /health du projet.
2026-10-11 22:52:22 -04:00
Spark b4dc8f0d4f chore(release): Kanbante 0.16.1 — icône palette et accordéons du menu
CI / test (push) Successful in 1m50s
2026-10-11 21:12:09 -04:00
Spark e810f5668a test(0.16.1): contrat icône palette et sections accordéon 2026-10-11 21:12:09 -04:00
Spark 2290560627 feat(nav): palette en icône près du titre, sections accordéon (retour #16)
Le bouton Recherche & commandes devient une icône 🔍 seule à
droite du titre (sous le logo en rail replié). Les groupes
deviennent des accordéons: chevron animé, transition douce,
sections fermées mémorisées (appliquées avant le rendu), section
de la page courante toujours ouverte; rail replié = icônes.
2026-10-11 21:12:09 -04:00
15 changed files with 786 additions and 55 deletions
+42
View File
@@ -1,5 +1,47 @@
# 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
### Nouveautés
+1 -1
View File
@@ -89,7 +89,7 @@ from pathlib import Path
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:
+1 -1
View File
@@ -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.16.0"},
"clientInfo": {"name": "kanbante", "version": "0.17.0"},
},
req_id=1,
)
+20
View File
@@ -317,6 +317,26 @@ class GiteaClient:
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
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(
self, owner: str, repo: str, title: str, body: str = ""
) -> dict:
+200 -1
View File
@@ -489,21 +489,133 @@ def _parse_order(raw: str) -> list[int]:
# ── Routes: Pages ─────────────────────────────────────────
async def _next_milestone(owner: str, repo: str) -> dict | None:
"""Next open milestone for a project card (issue #17).
The dated milestone with the nearest deadline wins (undated
ones last). Progress and counts come from Gitea's own milestone
counters; the ETA reuses the repo's journal throughput, exactly
like the board banner — one definition of "late", everywhere.
"""
try:
milestones = await gitea.get_milestones(owner, repo, state="open")
except Exception as exc:
logger.debug("next milestone failed for %s/%s: %s", owner, repo, exc)
return None
open_ms = [m for m in milestones if m.get("state", "open") == "open"]
if not open_ms:
return None
def _key(m: dict):
due = _parse_datetime(str(m.get("due_on") or ""))
return (0, due) if due is not None else (1, None)
open_ms.sort(key=_key)
m = open_ms[0]
opened = m.get("open_issues") or 0
closed = m.get("closed_issues") or 0
total = opened + closed
due_dt = _parse_datetime(str(m.get("due_on") or ""))
due_date = due_dt.date() if due_dt else None
today = datetime.now(timezone.utc).date()
weekly = forecast_service.weekly_closures(
get_events(repo_full_name=f"{owner}/{repo}", limit=2000)
)
est = forecast_service.estimate(opened, weekly)
return {
"title": m.get("title", ""),
"total": total,
"closed": closed,
"pct": round(100 * closed / total) if total else 0,
"due_label": (
forecast_service.french_date(due_date, short=True) if due_date else None
),
"late": bool(due_date and due_date < today and opened > 0),
"eta_label": forecast_service.eta_label(est) if est else None,
"eta_late": bool(
est and due_date and not est.get("done") and est["central"] > due_date
),
}
def _health_dot(health: dict) -> dict:
"""Traffic-light summary of a project's health (issue #18).
Rules (single definition, shared with /health): red when
zombies pile up (≥3, or ≥2 stuck in QA/Review); orange on any
zombie or when the slowest column's median dwell reaches
twice the aging threshold; green otherwise.
"""
zombies = health.get("zombies") or []
stuck = [z for z in zombies if z.get("column") in ("qa", "review")]
slowest = health.get("slowest")
median = slowest.get("median_days") if slowest else None
aging = get_settings().aging_days
if len(zombies) >= 3 or len(stuck) >= 2:
level = "red"
elif zombies or (median is not None and median >= 2 * aging):
level = "orange"
else:
level = "green"
parts = []
if zombies:
parts.append(f"{len(zombies)} zombie(s)")
if slowest:
parts.append(
f"goulot : {COLUMN_LABELS[slowest['column']]}"
f" (médiane {median} j)"
)
return {
"level": level,
"tooltip": " · ".join(parts) if parts
else "Aucun signal — voir la santé du projet",
}
@app.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):
"""Main dashboard — all projects at a glance."""
repos = await gitea.search_repos()
active, inactive, archived = classify_repos(repos)
try:
starred_names = {
r.get("full_name") for r in await gitea.list_starred()
}
except Exception as exc: # stars are decoration, never fatal (#20)
logger.debug("starred list failed: %s", exc)
starred_names = set()
for repo_info in active:
full_name = repo_info["full_name"]
owner, _, repo = full_name.partition("/")
repo_info["forecast_label"] = _repo_forecast_label(
repo_info["full_name"], repo_info.get("open_issues_count") or 0
full_name, repo_info.get("open_issues_count") or 0
)
repo_info["next_milestone"] = await _next_milestone(owner, repo)
try:
health = await _project_health(owner, repo)
repo_info["health_dot"] = _health_dot(health)
except Exception as exc:
logger.debug("health dot failed for %s: %s", full_name, exc)
repo_info["health_dot"] = None
repo_info["starred"] = full_name in starred_names
for repo_info in inactive:
repo_info["starred"] = repo_info["full_name"] in starred_names
for repo_info in active:
repo_info["home_tab"] = "active"
for repo_info in inactive:
repo_info["home_tab"] = "inactive"
for repo_info in archived:
repo_info["home_tab"] = "archived"
pinned = [r for r in active + inactive if r.get("starred")]
return render(
request,
"dashboard.html",
active_repos=active,
inactive_repos=inactive,
archived_repos=archived,
active_listed=[r for r in active if not r.get("starred")],
inactive_listed=[r for r in inactive if not r.get("starred")],
pinned_repos=pinned,
stats=get_stats(),
COLUMN_LABELS=COLUMN_LABELS,
COLUMN_COLORS=COLUMN_COLORS,
@@ -512,6 +624,22 @@ async def dashboard(request: Request):
)
@app.post("/api/projects/{owner}/{repo}/star")
async def api_project_star(
request: Request,
owner: str,
repo: str,
starred: str = Form(...),
csrf_token: str = Form(""),
):
"""Pin / unpin a project via its Gitea star (issue #20)."""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
want = starred == "1"
await gitea.set_starred(owner, repo, want)
return JSONResponse({"ok": True, "starred": want})
@app.get("/board/{owner}/{repo}", response_class=HTMLResponse)
async def board(request: Request, owner: str, repo: str, milestone: str = ""):
"""Kanban board for a specific repo (optionally one milestone)."""
@@ -790,6 +918,77 @@ def _excerpt(body: str | None, limit: int = 280) -> str:
return text[:limit] + ("…" if len(text) > limit else "")
@app.get("/project/{owner}/{repo}", response_class=HTMLResponse)
async def project_page(request: Request, owner: str, repo: str):
"""Project home (issue #25): milestones, health summary,
forecast, recent journal activity and notes for one repo —
an assembly of existing bricks, one truth per block."""
full_name = f"{owner}/{repo}"
repo_info = await gitea.get_repo(owner, repo)
try:
raw_milestones = await gitea.get_milestones(owner, repo, state="open")
except Exception as exc:
logger.debug("project milestones failed for %s: %s", full_name, exc)
raw_milestones = []
weekly = forecast_service.weekly_closures(
get_events(repo_full_name=full_name, limit=2000)
)
throughput = forecast_service.throughput_stats(weekly)
today = datetime.now(timezone.utc).date()
milestones = []
for m in raw_milestones:
if m.get("state", "open") != "open":
continue
opened = m.get("open_issues") or 0
closed = m.get("closed_issues") or 0
total = opened + closed
due_dt = _parse_datetime(str(m.get("due_on") or ""))
due_date = due_dt.date() if due_dt else None
est = forecast_service.estimate(opened, weekly)
milestones.append(
{
"title": m.get("title", ""),
"total": total,
"closed": closed,
"open": opened,
"pct": round(100 * closed / total) if total else 0,
"due_label": (
forecast_service.french_date(due_date, short=True)
if due_date else None
),
"late": bool(due_date and due_date < today and opened > 0),
"eta_label": forecast_service.eta_label(est) if est else None,
}
)
try:
health = await _project_health(owner, repo)
health_dot = _health_dot(health)
except Exception as exc:
logger.debug("project health failed for %s: %s", full_name, exc)
health, health_dot = None, None
events = [
e for e in get_events(limit=300)
if e["repo_full_name"] == full_name
][:10]
return render(
request,
"project.html",
repo_info=repo_info,
full_name=full_name,
owner=owner,
repo=repo,
milestones=milestones,
throughput=throughput,
health=health,
health_dot=health_dot,
events=events,
notes=get_notes(full_name),
EVENT_ICONS=EVENT_ICONS,
COLUMN_LABELS=COLUMN_LABELS,
COLUMN_COLORS=COLUMN_COLORS,
)
@app.get("/triage", response_class=HTMLResponse)
async def triage_picker(request: Request):
"""Triage mode (issue #11): pick a project by its backlog size."""
+32 -21
View File
@@ -12,51 +12,62 @@
if (localStorage.getItem('kanbante-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 */ }
</script>
{% set nav_groups = [
("Principal", [
("principal", "Principal", [
("home", "/", "🏠", "Projets"),
("focus", "/focus", "🎯", "Focus"),
("today", "/today", "🌅", "Aujourd'hui"),
("my", "/my", "👤", "Mes issues"),
]),
("Rituels", [
("rituels", "Rituels", [
("briefing", "/briefing", "📊", "Briefing"),
("standup", "/standup", "🗣️", "Standup"),
("digest", "/digest", "🧠", "Digest"),
]),
("Pilotage", [
("pilotage", "Pilotage", [
("forecast", "/forecast", "🔮", "Prévisions"),
("health", "/health", "🩺", "Santé"),
("rules", "/rules", "⚙️", "Règles"),
("activity", "/activity", "📰", "Activité"),
]),
("Outils", [
("outils", "Outils", [
("search", "/search", "🔍", "Recherche"),
("capture", "/capture", "⚡", "Capture"),
("wall", "/wall", "🖥️", "Wallboard"),
]),
] %}
<aside class="sidebar" id="sidebar" aria-label="Navigation principale">
<a href="/" class="side-logo" aria-label="Kanbante — accueil">
<span class="logo-mark">📋</span>
<span class="side-label gradient-text">Kanbante</span>
</a>
<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>
<div class="side-head">
<a href="/" class="side-logo" aria-label="Kanbante — accueil">
<span class="logo-mark">📋</span>
<span class="side-label gradient-text">Kanbante</span>
</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 %}
</nav>
<div class="side-foot">
+39 -8
View File
@@ -49,14 +49,21 @@
{% macro project_card(repo, archived=False) %}
{% set full = repo.full_name %}
<div class="project-card{% if archived %} archived{% endif %}"
data-full="{{ full }}" data-home="{{ repo.home_tab or '' }}"
data-search="{{ (repo.name ~ ' ' ~ repo.full_name ~ ' ' ~ (repo.description or ''))|lower }}"
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
<div class="card-header">
<span class="org-tag">{{ repo.owner.login }}</span>
{% if repo.health_dot %}
<a href="/health/{{ full }}" class="health-dot {{ repo.health_dot.level }}" title="Santé du flux — {{ repo.health_dot.tooltip }}" aria-label="Santé du flux : {{ repo.health_dot.tooltip }}"></a>
{% endif %}
<span class="date">{{ repo.updated_at[:10] }}</span>
{% if not archived %}
<button type="button" class="star-btn" data-full="{{ full }}" data-starred="{{ 1 if repo.starred else 0 }}" title="{{ 'Désépingler (étoile Gitea)' if repo.starred else 'Épingler (étoile Gitea)' }}" aria-label="Épingler {{ repo.name }}">{{ '⭐' if repo.starred else '☆' }}</button>
{% endif %}
</div>
<h3>
<a href="/board/{{ repo.owner.login }}/{{ repo.name }}">{{ repo.name }}</a>
<a href="/project/{{ repo.owner.login }}/{{ repo.name }}">{{ repo.name }}</a>
</h3>
<p class="desc">{{ repo.description or '—' }}</p>
{% if full in stats %}
@@ -68,8 +75,22 @@
</div>
{% endif %}
{% endif %}
{% if repo.next_milestone %}
{% set ms = repo.next_milestone %}
<div class="ms-block">
<div class="ms-line">
<span class="ms-title">🎯 {{ ms.title }}</span>
{% if ms.due_label %}<span class="ms-due{% if ms.late %} late{% endif %}">{{ '⚠️ ' if ms.late }}échéance {{ ms.due_label }}</span>{% endif %}
</div>
{% if ms.total %}
<div class="progress ms-progress"><span style="width: {{ ms.pct }}%"></span></div>
<div class="ms-meta">{{ ms.closed }}/{{ ms.total }} terminées{% if ms.eta_label %} · 🔮 {{ ms.eta_label }}{% if ms.eta_late %} — ⚠️ après l'échéance{% endif %}{% endif %}</div>
{% endif %}
</div>
{% endif %}
<div class="card-footer">
<span>{{ repo.open_issues_count or 0 }} issues ouvertes</span>
<a href="/board/{{ full }}" class="card-board-link">📋 Board</a>
{% if repo.forecast_label %}
<span class="forecast-line" title="Prévision d'après le débit réel du journal (fermetures par semaine)">🔮 {{ repo.forecast_label }}</span>
{% endif %}
@@ -89,21 +110,29 @@
</div>
{% endmacro %}
<div x-show="tab === 'active'" class="project-grid">
{% for repo in active_repos %}{{ project_card(repo) }}{% endfor %}
{% if not active_repos %}
<!-- Épinglés (#20) : les dépôts étoilés dans Gitea, hors onglets. -->
<section id="pinned-section"{% if not pinned_repos %} hidden{% endif %}>
<h3 class="pinned-title">⭐ Épinglés</h3>
<div class="project-grid" id="pinned-grid">
{% for repo in pinned_repos %}{{ project_card(repo) }}{% endfor %}
</div>
</section>
<div x-show="tab === 'active'" class="project-grid" id="grid-active">
{% for repo in active_listed %}{{ project_card(repo) }}{% endfor %}
{% if not active_listed and not pinned_repos %}
<div class="empty-state">🌱 Aucun projet actif en ce moment. Les projets bougent dès qu'une issue ou un commit arrive dans Gitea.</div>
{% endif %}
</div>
<div x-show="tab === 'inactive'" class="project-grid">
{% for repo in inactive_repos %}{{ project_card(repo) }}{% endfor %}
{% if not inactive_repos %}
<div x-show="tab === 'inactive'" class="project-grid" id="grid-inactive">
{% for repo in inactive_listed %}{{ project_card(repo) }}{% endfor %}
{% if not inactive_listed %}
<div class="empty-state">✨ Rien d'endormi : tous vos projets ont eu de l'activité récemment.</div>
{% endif %}
</div>
<div x-show="tab === 'archived'" class="project-grid">
<div x-show="tab === 'archived'" class="project-grid" id="grid-archived">
{% for repo in archived_repos %}{{ project_card(repo, archived=True) }}{% endfor %}
{% if not archived_repos %}
<div class="empty-state">🗄️ Aucun projet archivé. L'archivage se fait dans Gitea (Réglages du dépôt).</div>
@@ -115,6 +144,8 @@
Kanbante v{{ version }} · {{ active_repos|length + inactive_repos|length + archived_repos|length }} projets sur Gitea · actifs = mis à jour depuis {{ active_window_days }} jours
</footer>
<script src="/static/js/live.js" defer></script>
<script>window.KANBANTE_CSRF = "{{ csrf_token }}";</script>
<script src="/static/js/stars.js" defer></script>
<script>
document.addEventListener('keydown', function (e) {
if (e.key === '/' && !/INPUT|TEXTAREA/.test(document.activeElement?.tagName || '')) {
+129
View File
@@ -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
View File
@@ -1,6 +1,6 @@
[project]
name = "kanbante"
version = "0.16.0"
version = "0.17.0"
description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11"
dependencies = [
+109 -21
View File
@@ -1217,23 +1217,61 @@ body.with-sidebar.sidebar-collapsed { padding-left: 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: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 {
display: flex; align-items: center; gap: 10px; width: 100%;
padding: 9px 10px; margin-bottom: 8px; cursor: pointer;
background: var(--surface-2); color: var(--text-2);
border: 1px solid var(--border); border-radius: 10px; font-size: 0.9rem;
white-space: nowrap; overflow: hidden;
margin-left: auto; flex-shrink: 0;
width: 38px; height: 38px; border-radius: 10px; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
background: var(--surface-2); color: var(--text-2); font-size: 1.02rem;
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 kbd {
margin-left: auto; font-size: 0.72rem; background: var(--surface-3, #1d2942);
border: 1px solid var(--border); border-radius: 5px; padding: 1px 6px;
.side-search:hover {
color: var(--text); border-color: var(--accent, #58a6ff);
transform: translateY(-1px);
}
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-group {
margin: 14px 10px 5px; font-size: 0.7rem; font-weight: 700;
letter-spacing: 0.14em; text-transform: uppercase; color: var(--text-3);
white-space: nowrap;
.side-section { margin-top: 10px; }
.side-section:first-child { margin-top: 2px; }
.side-group-btn {
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 {
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; }
body.sidebar-collapsed .side-collapse-ico { transform: rotate(180deg); }
.side-version { color: var(--text-3); font-size: 0.8rem; }
body.sidebar-collapsed .side-label,
body.sidebar-collapsed .side-group { display: none; }
body.sidebar-collapsed .side-link,
body.sidebar-collapsed .side-search { justify-content: center; padding-left: 0; padding-right: 0; }
body.sidebar-collapsed .side-label { display: none; }
body.sidebar-collapsed .side-link { 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; }
.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; }
.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-collapsed .side-label,
body.sidebar-collapsed .side-group { display: initial; }
body.sidebar-collapsed .side-link,
body.sidebar-collapsed .side-search { justify-content: flex-start; padding-left: 10px; padding-right: 10px; }
body.sidebar-collapsed .side-label { display: initial; }
body.sidebar-collapsed .side-link { justify-content: flex-start; padding-left: 10px; padding-right: 10px; }
body.sidebar-collapsed .side-head { flex-direction: row; padding: 4px 6px 12px; }
body.sidebar-collapsed .side-search { margin-left: auto; }
.side-foot { display: none; }
.topbar-search { display: inline-flex; }
}
/* ── Dashboard vague 2 (0.17.0, #17 #18 #20) + page Projet (#25) ── */
.health-dot {
width: 11px; height: 11px; border-radius: 50%;
display: inline-block; flex-shrink: 0;
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.06);
}
.health-dot.green { background: #34d399; }
.health-dot.orange { background: #f59e0b; }
.health-dot.red { background: #f87171; }
.star-btn {
background: transparent; border: none; cursor: pointer;
font-size: 1rem; line-height: 1; padding: 2px 4px; margin-left: auto;
color: #fbbf24;
}
.star-btn:hover { transform: scale(1.2); }
.star-btn:disabled { opacity: 0.5; }
.card-board-link { font-weight: 600; }
.ms-block { margin: 10px 0 2px; }
.ms-line { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }
.ms-title { font-weight: 600; font-size: 0.92rem; }
.ms-due { font-size: 0.8rem; color: var(--text-2); white-space: nowrap; }
.ms-due.late { color: #f87171; font-weight: 700; }
.ms-progress { margin: 6px 0 4px; }
.ms-meta { font-size: 0.8rem; color: var(--text-2); }
.pinned-title { margin: 18px 0 10px; font-size: 1.05rem; }
.project-main { max-width: 1180px; margin: 0 auto; padding: 26px 22px 10px; }
.proj-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; }
.proj-head h1 { margin: 0; font-size: 1.7rem; }
.proj-sub { color: var(--text-2); margin: 6px 0 0; }
.health-chip {
display: inline-flex; align-items: center; gap: 8px;
border: 1px solid var(--border); border-radius: 999px;
padding: 7px 14px; color: var(--text); background: var(--surface-1);
font-size: 0.88rem; font-weight: 600;
}
.health-chip:hover { text-decoration: none; border-color: var(--accent, #58a6ff); }
.proj-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 18px 0 22px; }
.proj-actions .nav-pill { border: 1px solid var(--border); }
.proj-primary { background: linear-gradient(135deg, rgba(124,92,252,.85), rgba(91,138,247,.85)); color: #fff !important; }
.proj-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
.proj-panel {
background: var(--surface-1); border: 1px solid var(--border);
border-radius: 14px; padding: 18px 20px;
}
.proj-panel h2 { margin: 0 0 12px; font-size: 1.05rem; }
.proj-ms { margin-bottom: 14px; }
.proj-ms:last-child { margin-bottom: 0; }
.proj-list { list-style: none; margin: 0 0 10px; padding: 0; display: flex; flex-direction: column; gap: 7px; font-size: 0.93rem; }
.proj-dim { color: var(--text-3); }
.proj-empty { color: var(--text-2); font-size: 0.92rem; }
.proj-notes-body { white-space: pre-wrap; font-size: 0.93rem; background: var(--surface-2); border-radius: 10px; padding: 12px 14px; }
+43
View File
@@ -42,4 +42,47 @@
} 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();
}
}
}
})();
+56
View File
@@ -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;
}
});
});
})();
+10
View File
@@ -68,6 +68,7 @@ class FakeGitea:
def __init__(self) -> None:
now = datetime.now(timezone.utc)
self.starred = set()
self.repos = [
_repo("demo", now - timedelta(days=1)),
_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"):
return [
{
+16 -1
View File
@@ -13,11 +13,24 @@ def test_sidebar_on_app_pages(client, fake_gitea):
assert page.text.count('id="nav-burger"') == 1, path
assert "with-sidebar" 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"):
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 '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 &amp; commandes' not in page.text
assert "Recherche & commandes</span>" not in page.text
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 "side-backdrop" 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):
+87
View File
@@ -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