12 Commits
Author SHA1 Message Date
Spark 593f076555 chore(release): Kanbante 0.18.0 — vague 2 suite : activité, actions rapides, tri, liste dense
CI / test (push) Successful in 1m47s
2026-10-11 23:18:01 -04:00
Spark 311a14090d test(0.18.0): activité, outils d'affichage, liste dense, actions rapides — 7 tests, 185 au total 2026-10-11 23:18:01 -04:00
Spark c67a14da65 feat(dashboard): ligne d'activité, menu ⋯, tri/regroupement, vue liste (#19, #21, #22, #23)
Menu d'actions rapides par carte; barre d'outils tri +
regroupement par propriétaire (sections repliables, préférences
localStorage); bascule Grille/Liste persistée avec tableaux
triables réutilisant jalon/santé des cartes; dashboard.js dédié.
2026-10-11 23:18:01 -04:00
Spark 7ec34245c5 feat(dashboard): activité 7 jours agrégée et capture pré-remplie (#19, #23)
get_project_activity(): fermetures/déplacements 7 j et dernier
événement par dépôt en une seule requête sur board_events;
la page /capture accepte ?repo= pour pré-remplir le dépôt.
2026-10-11 23:18:01 -04:00
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
18 changed files with 1318 additions and 59 deletions
+69
View File
@@ -1,5 +1,74 @@
# Changelog
## 0.18.0 — 2026-10-11
### Nouveautés — vague 2, suite (4 cartes de Bruno)
- **Activité récente par projet** (#19) : ligne sur la carte du
dashboard calculée par UNE requête agrégée sur le journal —
« 📈 X fermées · Y déplacements cette semaine », ou « 💤 aucune
activité kanban depuis N j » pour les projets endormis.
- **Actions rapides sur la carte** (#23) : menu ⋯ sur chaque
carte — Board, ⚡ Capturer ici (la page capture accepte
`?repo=` et pré-remplit le dépôt), Triage du backlog, Santé,
Récit de sprint, Gitea. Clavier et tactile soignés (Échap,
clic extérieur).
- **Tri et regroupement** (#21) : tri par activité Gitea,
activité kanban, ouvertes ou nom ; regroupement par
propriétaire en sections repliables ; préférences mémorisées
par navigateur (localStorage).
- **Vue liste dense** (#22) : bascule Grille/Liste persistée —
tableau triable par colonne (Projet, Propriétaire, Ouvertes,
En vol, Prochain jalon, Santé, Dernière activité) réutilisant
les enrichissements des cartes, mêmes onglets et même filtre.
Note : le lien Triage du menu ⋯ est constant — la taille du
backlog « stats » du dashboard ne compte que les cartes placées,
pas le backlog réel ; la page Triage affiche l'état exact.
7 tests ajoutés (185 au total).
## 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.18.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.18.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:
+226 -4
View File
@@ -58,6 +58,7 @@ from app.models import (
get_metrics,
get_notes,
get_picks,
get_project_activity,
get_stats,
init_db,
log_event,
@@ -489,21 +490,151 @@ 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
activity_by_repo = get_project_activity()
today_utc = datetime.now(timezone.utc).date()
for repo_info in active + inactive + archived:
act = activity_by_repo.get(repo_info["full_name"])
if act is None:
repo_info["activity"] = None
continue
idle_days = None
if act["last_at"]:
last_dt = _parse_datetime(act["last_at"])
if last_dt is not None:
idle_days = (today_utc - last_dt.date()).days
repo_info["activity"] = {
"closed": act["closed"],
"moved": act["moved"],
"idle_days": idle_days,
"last_at": act["last_at"] or "",
}
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 +643,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 +937,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."""
@@ -2106,13 +2324,17 @@ async def api_capture(request: Request):
@app.get("/capture", response_class=HTMLResponse)
async def capture_page(request: Request):
"""Quick-capture page: form, status and usage instructions."""
async def capture_page(request: Request, repo: str = ""):
"""Quick-capture page: form, status and usage instructions.
`?repo=Owner/name` pre-fills the target repo (issue #23: the
project card's quick actions capture straight into a project).
"""
settings = get_settings()
return render(
request,
"capture.html",
capture_repo=settings.capture_repo,
capture_repo=repo.strip() or settings.capture_repo,
api_enabled=bool(settings.capture_token),
ntfy_topic=settings.capture_ntfy_topic,
ntfy_url=settings.ntfy_url,
+32
View File
@@ -368,6 +368,38 @@ def column_entry_times(repo_full_name: str) -> dict[int, str]:
return {row["issue_number"]: row["entered_at"] for row in rows}
def get_project_activity(days: int = 7) -> dict:
"""Per-repo journal activity in ONE aggregated pass (issue #19).
For every repo present in the journal: closures and moves of
the last `days` days (journal stamps are UTC strings), plus
the most recent event ever seen for that repo.
"""
from datetime import datetime, timedelta, timezone
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).strftime(
"%Y-%m-%d %H:%M:%S"
)
with get_db() as conn:
rows = conn.execute(
"""
SELECT repo_full_name,
SUM(CASE WHEN event_type = 'closed' AND created_at >= ?
THEN 1 ELSE 0 END),
SUM(CASE WHEN event_type = 'moved' AND created_at >= ?
THEN 1 ELSE 0 END),
MAX(created_at)
FROM board_events
GROUP BY repo_full_name
""",
(cutoff, cutoff),
).fetchall()
return {
r[0]: {"closed": r[1] or 0, "moved": r[2] or 0, "last_at": r[3]}
for r in rows
}
def get_metrics(repo_full_name: str | None = None, window_days: int = 30) -> dict:
"""Throughput and cycle time computed from the journal.
+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">
+154 -9
View File
@@ -24,7 +24,22 @@
</div>
</section>
<main x-data="{ tab: 'active', search: '' }">
<script>
// État du dashboard (#21/#22) : la vue grille/liste survit au
// rechargement (localStorage), lue avant qu'Alpine démarre.
function dashState() {
var view = 'grid';
try { view = localStorage.getItem('kanbante-dash-view') || 'grid'; } catch (e) { /* défaut */ }
return {
tab: 'active', search: '', view: view,
setView: function (v) {
this.view = v;
try { localStorage.setItem('kanbante-dash-view', v); } catch (e) { /* non-fatal */ }
}
};
}
</script>
<main x-data="dashState()">
<!-- Tabs: "actif" = mis à jour dans la fenêtre glissante;
"archivé" = le vrai drapeau Gitea (repo.archived). -->
<div class="tabs">
@@ -46,17 +61,61 @@
<kbd class="kbd">/</kbd>
</div>
<!-- Outils d'affichage (#21 tri/regroupement, #22 vue liste) :
pilotés par dashboard.js, préférences en localStorage. -->
<div class="dash-tools">
<label class="dash-tool">↕️ Tri
<select id="dash-sort">
<option value="default">Par défaut</option>
<option value="updated">Activité Gitea récente</option>
<option value="activity">Activité kanban récente</option>
<option value="open">Issues ouvertes ↓</option>
<option value="name">Nom A → Z</option>
</select>
</label>
<label class="dash-tool dash-tool-check">
<input type="checkbox" id="dash-group"> 🗂️ Grouper par propriétaire
</label>
<div class="dash-view-toggle" role="group" aria-label="Mode d'affichage">
<button type="button" :class="{ active: view === 'grid' }" @click="setView('grid')">▦ Grille</button>
<button type="button" :class="{ active: view === 'list' }" @click="setView('list')">☰ Liste</button>
</div>
</div>
{% 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-name="{{ repo.name|lower }}" data-owner="{{ repo.owner.login }}"
data-updated="{{ repo.updated_at }}" data-open="{{ repo.open_issues_count or 0 }}"
data-activity="{{ repo.activity.last_at if repo.activity else '' }}"
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>
<div class="card-menu-wrap">
<button type="button" class="card-menu-btn" data-menu-btn aria-haspopup="true" aria-expanded="false" title="Actions rapides pour {{ repo.name }}" aria-label="Actions rapides pour {{ repo.name }}">⋯</button>
<div class="card-menu" hidden>
<a href="/board/{{ full }}">📋 Board</a>
<a href="/capture?repo={{ full|urlencode }}">⚡ Capturer ici</a>
<a href="/triage/{{ full }}">📥 Triage du backlog</a>
<a href="/health/{{ full }}">🩺 Santé</a>
<a href="/sprint/{{ full }}">📖 Récit de sprint</a>
{% if repo.html_url %}
<a href="{{ repo.html_url }}" target="_blank" rel="noopener">🔗 Gitea ↗</a>
{% endif %}
</div>
</div>
{% 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 +127,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 %}
@@ -86,35 +159,107 @@
</div>
{% endif %}
</div>
{% if repo.activity %}
<div class="activity-line">
{% if repo.activity.closed or repo.activity.moved %}
📈 {{ repo.activity.closed }} fermée(s) · {{ repo.activity.moved }} déplacement(s) cette semaine
{% elif repo.activity.idle_days is not none and repo.activity.idle_days > 7 %}
💤 aucune activité kanban depuis {{ repo.activity.idle_days }} j
{% endif %}
</div>
{% endif %}
</div>
{% endmacro %}
<div x-show="tab === 'active'" class="project-grid">
{% for repo in active_repos %}{{ project_card(repo) }}{% endfor %}
{% if not active_repos %}
{% macro project_row(repo, archived=False) %}
{% set full = repo.full_name %}
{% set st = stats.get(full, {}) %}
<tr data-name="{{ repo.name|lower }}" data-owner="{{ repo.owner.login|lower }}"
data-open="{{ repo.open_issues_count or 0 }}"
data-inflight="{{ (st.get('in_progress', 0) + st.get('qa', 0) + st.get('review', 0)) }}"
data-updated="{{ repo.updated_at }}"
data-search="{{ (repo.name ~ ' ' ~ repo.full_name ~ ' ' ~ (repo.description or ''))|lower }}"
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
<td><a href="/project/{{ full }}"><strong>{{ repo.name }}</strong></a></td>
<td>{{ repo.owner.login }}</td>
<td>{{ repo.open_issues_count or 0 }}</td>
<td>{{ st.get('in_progress', 0) + st.get('qa', 0) + st.get('review', 0) }}</td>
<td>{% if repo.next_milestone %}🎯 {{ repo.next_milestone.title }}{% if repo.next_milestone.late %} ⚠️{% endif %}{% else %}—{% endif %}</td>
<td>{% if repo.health_dot %}<a href="/health/{{ full }}" class="health-dot {{ repo.health_dot.level }}" title="{{ repo.health_dot.tooltip }}" aria-label="Santé : {{ repo.health_dot.tooltip }}"></a>{% else %}—{% endif %}</td>
<td>{{ repo.updated_at[:10] }}</td>
</tr>
{% endmacro %}
{% macro project_table(repos, tab_name, archived=False) %}
<div x-show="tab === '{{ tab_name }}'">
<table class="proj-table">
<thead>
<tr>
<th data-sort="name">Projet</th>
<th data-sort="owner">Propriétaire</th>
<th data-sort="open">Ouvertes</th>
<th data-sort="inflight">En vol</th>
<th>Prochain jalon</th>
<th>Santé</th>
<th data-sort="updated">Dernière activité</th>
</tr>
</thead>
<tbody>
{% for repo in repos %}{{ project_row(repo, archived) }}{% endfor %}
</tbody>
</table>
{% if not repos %}
<div class="empty-state">Aucun projet dans cette vue.</div>
{% endif %}
</div>
{% endmacro %}
<div x-show="view === 'grid'">
<!-- É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>
{% endif %}
</div>
</div>
<!-- Vue liste dense (#22) : mêmes onglets, mêmes données. -->
<div x-show="view === 'list'">
{{ project_table(active_repos, 'active') }}
{{ project_table(inactive_repos, 'inactive') }}
{{ project_table(archived_repos, 'archived', archived=True) }}
</div>
</main>
<footer>
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 src="/static/js/dashboard.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.18.0"
description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11"
dependencies = [
+181 -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,134 @@ 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; }
/* ── Dashboard vague 2 suite (0.18.0, #19 #21 #22 #23) ── */
.activity-line { margin-top: 8px; font-size: 0.82rem; color: var(--text-2); }
.dash-tools {
display: flex; align-items: center; gap: 18px; flex-wrap: wrap;
padding: 0 24px; margin: 4px 0 14px;
}
.dash-tool { display: inline-flex; align-items: center; gap: 8px; font-size: 0.88rem; color: var(--text-2); }
.dash-tool select {
background: var(--surface-2); color: var(--text);
border: 1px solid var(--border); border-radius: 8px; padding: 6px 8px;
}
.dash-tool-check input { width: 17px; height: 17px; accent-color: #7c5cfc; }
.dash-view-toggle { margin-left: auto; display: inline-flex; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
.dash-view-toggle button {
background: var(--surface-1); color: var(--text-2); border: none;
padding: 7px 14px; cursor: pointer; font-size: 0.88rem;
}
.dash-view-toggle button.active { background: var(--surface-3, #1d2942); color: var(--text); font-weight: 700; }
.owner-group { grid-column: 1 / -1; }
.owner-head {
display: flex; justify-content: space-between; align-items: center;
width: 100%; padding: 9px 12px; margin-bottom: 10px; cursor: pointer;
background: var(--surface-1); color: var(--text);
border: 1px solid var(--border); border-radius: 10px;
font-size: 0.95rem; font-weight: 700;
}
.owner-head:hover { border-color: var(--accent, #58a6ff); }
.owner-head .owner-chevron { transition: transform 0.2s ease; font-size: 0.7rem; }
.owner-head[aria-expanded="false"] .owner-chevron { transform: rotate(-90deg); }
.owner-grid { padding: 0 !important; }
.card-menu-wrap { position: relative; }
.card-menu-btn {
background: transparent; border: none; cursor: pointer;
color: var(--text-2); font-size: 1.15rem; font-weight: 800;
padding: 2px 7px; border-radius: 8px; line-height: 1;
}
.card-menu-btn:hover { background: var(--surface-2); color: var(--text); }
.card-menu {
position: absolute; right: 0; top: calc(100% + 6px); z-index: 60;
min-width: 210px; display: flex; flex-direction: column; gap: 2px;
background: var(--surface-1); border: 1px solid var(--border);
border-radius: 12px; padding: 6px;
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.45);
}
.card-menu[hidden] { display: none !important; }
.card-menu a {
display: flex; align-items: center; min-height: 32px;
padding: 8px 10px; border-radius: 8px;
color: var(--text-2); font-size: 0.92rem;
}
.card-menu a:hover { background: var(--surface-2); color: var(--text); text-decoration: none; }
.proj-table {
width: calc(100% - 48px); margin: 0 24px 18px;
border-collapse: collapse; font-size: 0.93rem;
background: var(--surface-1); border: 1px solid var(--border);
border-radius: 12px; overflow: hidden;
}
.proj-table th, .proj-table td {
text-align: left; padding: 10px 14px;
border-bottom: 1px solid var(--border);
}
.proj-table thead th { color: var(--text-2); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em; }
.proj-table th.sortable { cursor: pointer; }
.proj-table th.sortable:hover { color: var(--text); }
.proj-table th[data-dir="asc"]::after { content: " ↑"; }
.proj-table th[data-dir="desc"]::after { content: " ↓"; }
.proj-table tbody tr:hover { background: var(--surface-2); }
@media (max-width: 900px) {
.proj-table { display: block; overflow-x: auto; }
.dash-view-toggle { margin-left: 0; }
}
+186
View File
@@ -0,0 +1,186 @@
/**
* Kanbante — dashboard display tools (issues #21, #22, #23).
* Sort + group-by-owner for the card grids, sortable dense tables,
* and the per-card quick-actions menu. Preferences live in
* localStorage; the server markup stays the single source.
*/
(function () {
var LS_SORT = 'kanbante-dash-sort';
var LS_GROUP = 'kanbante-dash-group';
var LS_OWNERS = 'kanbante-dash-owners-closed';
function lsGet(key, fallback) {
try {
var v = localStorage.getItem(key);
return v === null ? fallback : v;
} catch (e) { return fallback; }
}
function lsSet(key, value) {
try { localStorage.setItem(key, value); } catch (e) { /* non-fatal */ }
}
/* ── Tri des grilles de cartes ─────────────────────────── */
var sortSelect = document.getElementById('dash-sort');
var grids = ['grid-active', 'grid-inactive', 'grid-archived']
.map(function (id) { return document.getElementById(id); })
.filter(Boolean);
function cardKey(card, mode) {
if (mode === 'name') return (card.getAttribute('data-name') || '').toLowerCase();
if (mode === 'open') return -(parseInt(card.getAttribute('data-open') || '0', 10));
if (mode === 'updated') return card.getAttribute('data-updated') || '';
if (mode === 'activity') return card.getAttribute('data-activity') || '';
return null; // 'default' : ordre serveur, on ne touche à rien
}
function sortCards(container, mode) {
var cards = Array.prototype.slice.call(
container.querySelectorAll('.project-card'));
if (mode === 'default' || !cards.length) return;
cards.sort(function (a, b) {
var ka = cardKey(a, mode), kb = cardKey(b, mode);
if (ka < kb) return mode === 'name' ? -1 : 1;
if (ka > kb) return mode === 'name' ? 1 : -1;
return 0;
});
cards.forEach(function (c) { container.appendChild(c); });
}
function applySort() {
var mode = sortSelect ? sortSelect.value : 'default';
lsSet(LS_SORT, mode);
grids.forEach(function (grid) {
if (groupToggle && groupToggle.checked) {
grid.querySelectorAll('.owner-grid').forEach(function (og) {
sortCards(og, mode);
});
} else {
sortCards(grid, mode);
}
});
}
/* ── Regroupement par propriétaire ─────────────────────── */
var groupToggle = document.getElementById('dash-group');
function closedOwners() {
try { return JSON.parse(lsGet(LS_OWNERS, '[]')); } catch (e) { return []; }
}
function setOwnerClosed(owner, closed) {
var list = closedOwners().filter(function (o) { return o !== owner; });
if (closed) list.push(owner);
lsSet(LS_OWNERS, JSON.stringify(list));
}
function groupGrid(grid) {
var cards = Array.prototype.slice.call(
grid.querySelectorAll(':scope > .project-card'));
if (!cards.length) return;
var byOwner = {};
cards.forEach(function (c) {
var o = c.getAttribute('data-owner') || '—';
(byOwner[o] = byOwner[o] || []).push(c);
});
Object.keys(byOwner).sort().forEach(function (owner) {
var wrap = document.createElement('div');
wrap.className = 'owner-group';
var head = document.createElement('button');
head.type = 'button';
head.className = 'owner-head';
var closed = closedOwners().indexOf(owner) >= 0;
head.setAttribute('aria-expanded', closed ? 'false' : 'true');
head.innerHTML = '<span>🗂️ ' + owner + ' (' + byOwner[owner].length + ')</span><span class="owner-chevron">▾</span>';
var body = document.createElement('div');
body.className = 'project-grid owner-grid';
if (closed) body.hidden = true;
head.addEventListener('click', function () {
body.hidden = !body.hidden;
head.setAttribute('aria-expanded', body.hidden ? 'false' : 'true');
setOwnerClosed(owner, body.hidden);
});
byOwner[owner].forEach(function (c) { body.appendChild(c); });
wrap.appendChild(head);
wrap.appendChild(body);
grid.appendChild(wrap);
});
}
function ungroupGrid(grid) {
grid.querySelectorAll('.owner-group').forEach(function (wrap) {
var body = wrap.querySelector('.owner-grid');
Array.prototype.slice.call(body.querySelectorAll('.project-card'))
.forEach(function (c) { grid.appendChild(c); });
wrap.remove();
});
}
function applyGrouping() {
var on = !!(groupToggle && groupToggle.checked);
lsSet(LS_GROUP, on ? '1' : '0');
grids.forEach(function (grid) {
ungroupGrid(grid);
if (on) groupGrid(grid);
});
applySort();
}
if (sortSelect) {
sortSelect.value = lsGet(LS_SORT, 'default');
sortSelect.addEventListener('change', applySort);
}
if (groupToggle) {
groupToggle.checked = lsGet(LS_GROUP, '0') === '1';
groupToggle.addEventListener('change', applyGrouping);
}
applyGrouping(); // applique regroupement + tri mémorisés
/* ── Menu ⋯ d'actions rapides (#23) ────────────────────── */
function closeMenus() {
document.querySelectorAll('.card-menu').forEach(function (m) {
m.hidden = true;
});
document.querySelectorAll('[data-menu-btn]').forEach(function (b) {
b.setAttribute('aria-expanded', 'false');
});
}
document.querySelectorAll('[data-menu-btn]').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
var menu = btn.parentElement.querySelector('.card-menu');
var willOpen = menu.hidden;
closeMenus();
if (willOpen) {
menu.hidden = false;
btn.setAttribute('aria-expanded', 'true');
}
});
});
document.addEventListener('click', closeMenus);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeMenus();
});
/* ── Tri des tableaux denses (#22) ─────────────────────── */
document.querySelectorAll('.proj-table').forEach(function (table) {
var tbody = table.querySelector('tbody');
table.querySelectorAll('th[data-sort]').forEach(function (th) {
th.classList.add('sortable');
th.addEventListener('click', function () {
var key = th.getAttribute('data-sort');
var dir = th.getAttribute('data-dir') === 'asc' ? 'desc' : 'asc';
table.querySelectorAll('th').forEach(function (h) {
h.removeAttribute('data-dir');
});
th.setAttribute('data-dir', dir);
var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
rows.sort(function (a, b) {
var va = a.getAttribute('data-' + key) || '';
var vb = b.getAttribute('data-' + key) || '';
var na = parseFloat(va), nb = parseFloat(vb);
var cmp;
if (!isNaN(na) && !isNaN(nb) && va !== '' && vb !== '') {
cmp = na - nb;
} else {
cmp = va < vb ? -1 : (va > vb ? 1 : 0);
}
return dir === 'asc' ? cmp : -cmp;
});
rows.forEach(function (r) { tbody.appendChild(r); });
});
});
});
})();
+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
+74
View File
@@ -0,0 +1,74 @@
"""Palier 14 (0.18.0): vague 2 suite — activité 7 jours (#19),
tri/regroupement (#21), vue liste dense (#22), actions rapides (#23)."""
from datetime import datetime, timedelta, timezone
from app import models
def _ts(days_ago: int) -> str:
dt = datetime.now(timezone.utc) - timedelta(days=days_ago)
return dt.strftime("%Y-%m-%d 12:00:00")
def test_activity_line_recent_week(client, fake_gitea):
for _ in range(2):
models.log_event("Projets/demo", 1, "closed",
to_column="done", created_at=_ts(1))
models.log_event("Projets/demo", 5, "moved",
from_column="backlog", to_column="todo",
created_at=_ts(0))
page = client.get("/")
assert "2 fermée(s) · 1 déplacement(s) cette semaine" in page.text
def test_activity_line_idle_project(client, fake_gitea):
models.log_event("Projets/vieux", 9, "moved",
from_column="backlog", to_column="todo",
created_at=_ts(35))
page = client.get("/")
assert "aucune activité kanban depuis 35 j" in page.text
def test_dashboard_display_tools_markup(client, fake_gitea):
page = client.get("/")
assert "dashState" in page.text
assert 'id="dash-sort"' in page.text
assert 'id="dash-group"' in page.text
assert "Grouper par propriétaire" in page.text
assert "setView('grid')" in page.text
assert "setView('list')" in page.text
def test_dense_list_view_tables(client, fake_gitea):
page = client.get("/")
assert page.text.count('class="proj-table"') == 3
assert 'data-sort="open"' in page.text
assert 'data-sort="inflight"' in page.text
# La ligne dense de demo : lien page projet + compteurs.
assert 'href="/project/Projets/demo"' in page.text
def test_dashboard_js_contract(client, fake_gitea):
js = client.get("/static/js/dashboard.js")
assert js.status_code == 200
assert "kanbante-dash-sort" in js.text
assert "kanbante-dash-group" in js.text
assert "owner-group" in js.text
assert "data-menu-btn" in js.text
def test_card_quick_actions_menu(client, fake_gitea):
page = client.get("/")
assert "data-menu-btn" in page.text
# urlencode de Jinja conserve les « / » : le lien est valide tel quel.
assert 'href="/capture?repo=Projets/demo"' in page.text
assert 'href="/triage/Projets/demo"' in page.text
assert 'href="/sprint/Projets/demo"' in page.text
assert 'href="/health/Projets/demo"' in page.text
def test_capture_prefilled_repo(client, fake_gitea):
page = client.get("/capture?repo=Projets/kanbante")
assert page.status_code == 200
assert 'value="Projets/kanbante"' in page.text