12 Commits
Author SHA1 Message Date
Spark d4b3b324f5 chore(release): Kanbante 0.16.0 — layout barre latérale et wallboard verrouillé
CI / test (push) Successful in 1m46s
2026-10-11 20:48:46 -04:00
Spark d395642f16 test(0.16.0): contrat barre latérale et mur — 6 tests adaptés/ajoutés, 171 au total 2026-10-11 20:48:46 -04:00
Spark ec3590d25f feat(wall): hauteur verrouillée 100dvh, pied toujours visible
La diapositive ne grandit plus au-delà de l'écran: plus de barre
de défilement de page, « Ouvertes »/« Fermées »/« EN DIRECT »
toujours affichés au-dessus de la navigation des diapos; cartes
tronquées en fondu + ligne « +N autres cartes en vol »; mode
compact sous 760px de hauteur (TV 720p).
2026-10-11 20:48:46 -04:00
Spark 16f03184bf feat(nav): refonte du layout — barre latérale repliable, topbar mince (issue #16)
Retour de Bruno sur la 0.15.0 (disposition pas conviviale, trop
de place, mobile inchangé): la barre horizontale disparaît.
Barre latérale groupée (Principal/Rituels/Pilotage/Outils),
repliable en rail d'icônes mémorisé sur desktop, tiroir
hors-canvas sur mobile; topbar de 52px (fil d'Ariane + actions).
2026-10-11 20:48:46 -04:00
Spark 6fdf39959c chore(release): Kanbante 0.15.0 — header unifié desktop/mobile
CI / test (push) Successful in 1m54s
2026-10-11 20:15:23 -04:00
Spark 18e1dd0f3d test(0.15.0): header partagé, états actifs, exceptions wall — 5 tests, 170 au total 2026-10-11 20:15:23 -04:00
Spark 58204e621e fix(forecast): semaines calées sur le référentiel UTC du journal
Les horodatages board_events sont en UTC (SQLite): une fermeture
en soirée locale tombait hors des semaines locales et faussait
le débit hebdomadaire.
2026-10-11 20:15:23 -04:00
Spark 2fde103e93 feat(nav): header unifié desktop/mobile (issue #16)
Partiel _header.html unique pour les 19 pages (fin des copies qui
dérivaient): desktop une ligne — primaires + menu Plus + Ctrl K,
page active déduite du gabarit dans render(); mobile ≤900px
burger + panneau ≥44px, Échap/clic extérieur; wallboard sans
header et triage sur le même partiel. nav.js partagé.
2026-10-11 20:15:23 -04:00
Spark 24a1ebb9a6 chore(release): Kanbante 0.14.0 — branche depuis la carte, santé du flux
CI / test (push) Successful in 1m49s
2026-10-11 14:49:38 -04:00
Spark 4c807a4344 test(0.14.0): branche depuis la carte, dwell/zombies — 8 tests, 165 au total 2026-10-11 14:49:38 -04:00
Spark 6453b53df4 feat(health): santé du flux — goulots et cartes zombies (issue #9)
app/flow.py: séjours par colonne reconstruits du journal,
médianes, colonne la plus lente; zombies = aucune activité
(journal/Gitea) depuis KANBANTE_ZOMBIE_DAYS (30). Pages /health
et /health/{o}/{r} + actions rapides (commenter/backlog/fermer).
2026-10-11 14:49:38 -04:00
Spark 1932d11457 feat(branch): créer la branche depuis la carte (issue #3)
slugify + branch_name_for (issue-<n>-<slug>, reconnu par #1/#2);
client create_branch; POST /api/issues/{o}/{r}/{n}/branch (nom
libre validé, idempotent si existante); la carte passe en En
cours, journalisée source branch, étiquette synchronisée;
formulaire dans la section Code lié du panneau détail.
2026-10-11 14:49:38 -04:00
39 changed files with 1228 additions and 313 deletions
+72
View File
@@ -1,5 +1,77 @@
# Changelog
## 0.16.0 — 2026-10-11
### Nouveautés
- **Layout repensé** (issue #16, retour de Bruno sur la 0.15.0 :
disposition pas conviviale, trop de place, mobile inchangé) —
la barre de pilules horizontale disparaît au profit d'une
**barre latérale** : navigation groupée (Principal, Rituels,
Pilotage, Outils), bouton palette en tête, **repliable en rail
d'icônes** sur desktop (état mémorisé par navigateur), et
**tiroir hors-canvas** sur mobile (burger dans une topbar mince
de 52 px : fil d'Ariane + actions contextuelles seulement).
Le partiel `_header.html` reste l'unique source du cadre.
- **Wallboard** : hauteur verrouillée à l'écran (`100dvh`,
`overflow: hidden`) — **plus aucune barre de défilement de
page** ; le pied de diapositive (« ✅ Fermées aujourd'hui »,
« 📋 Ouvertes », « ● EN DIRECT ») reste **toujours visible**,
au-dessus de la zone réservée à la navigation des diapos ; la
liste des cartes se tronque avec un fondu et une ligne
« … +N autres cartes en vol » ; mode compact sous 760 px de
hauteur (TV 720p).
6 tests adaptés/ajoutés (171 au total).
## 0.15.0 — 2026-10-11
### Nouveautés
- **Header refait** (issue #16, créée et spécifiée par Bruno) :
les 19 copies de markup de header sont remplacées par un
**partiel unique `_header.html`**. Desktop : une seule ligne —
5 entrées primaires (Projets, Focus, Aujourd'hui, Recherche,
Capture), menu « Plus ▾ » pour les 9 autres, Ctrl+K à droite,
page active marquée (y compris dans « Plus »). Mobile
(≤ 900 px) : burger + panneau plein largeur, cibles ≥ 44 px,
fermeture Échap / clic extérieur. La page active est déduite du
gabarit dans `render()` (`active_nav`) — une seule source de
vérité. Le wallboard garde son plein écran sans header et la
session triage utilise le même partiel. `static/js/nav.js`
partagé, aucune dépendance ajoutée.
### Corrigé
- Prévisions : les semaines se calent désormais sur le référentiel
du journal (UTC, comme les horodatages `board_events`) — une
fermeture journalisée en soirée locale (déjà le lendemain en
UTC) tombait hors de toutes les semaines et faussait le débit.
5 tests ajoutés (170 au total).
## 0.14.0 — 2026-10-11
### Nouveautés
- **Créer la branche depuis la carte** (issue #3) : le panneau
détail propose `issue-<n>-<slug du titre>` (slug ASCII, 40 car.
max, nom libre accepté après validation git-ref) et la crée
dans Gitea depuis la branche par défaut
(`POST /api/issues/{o}/{r}/{n}/branch`). La carte passe en
« En cours » (journalisé, source `branch`, étiquette synchronisée)
et la branche est aussitôt reconnue par le code lié (#1) et par
l'automatisation de fusion (#2). Réponse idempotente si la
branche existe déjà.
- **Santé du flux** (issue #9) : `app/flow.py` reconstruit les
séjours par colonne depuis le journal (entrée → déplacement /
fermeture) : temps médian et moyen par colonne, colonne la plus
lente (goulot, ≥ 2 séjours). **Cartes zombies** : ouvertes sans
activité (journal ou Gitea) depuis `KANBANTE_ZOMBIE_DAYS`
(défaut 30). Pages `/health` (résumé global) et
`/health/{owner}/{repo}` (détail + actions rapides sur les
zombies : commenter « toujours pertinent ? », remettre au
Backlog, fermer — via les API existantes).
8 tests ajoutés (165 au total). **La feuille de route
« Différenciation » est complète : 15/15.**
## 0.13.0 — 2026-10-11
### Nouveautés
+5
View File
@@ -23,6 +23,8 @@
- 🔀 **PR fusionnée → Terminé** — le webhook `pull_request` retrouve les issues liées (références `#n`, branche `issue-n-*`), les ferme et déplace leurs cartes, journalisé
- 🗣️ **Standup automatique** — `/standup` : fermé/déplacé hier, focus du jour, blocages (QA/Révision qui vieillissent), copiable d'un clic ; ajout optionnel au briefing (`KANBANTE_BRIEFING_INCLUDE_STANDUP=1`)
- ⚙️ **Règles d'automatisation** — moteur déclaratif `KANBANTE_RULES` (JSON) évalué toutes les 15 min : conditions colonne/âge/étiquette/WIP → étiquette, notification ntfy, tête de colonne ; jamais de fermeture par règle ; page `/rules`
- 🌿 **Branche depuis la carte** — le panneau détail crée `issue-<n>-<slug>` dans Gitea depuis la branche par défaut, déplace la carte en « En cours » et journalise ; la PR qui en naîtra sera reconnue automatiquement
- 🩺 **Santé du flux** — `/health` : temps médian par colonne (du journal), colonne la plus lente, et cartes zombies (aucune activité depuis `KANBANTE_ZOMBIE_DAYS`) avec actions rapides (commenter, remettre au backlog, fermer)
- 📥 **Mode Triage** — `/triage` : le backlog se vide carte par carte, plein cadre, au clavier (`1-5` colonnes, `L` étiquettes, `A` s'assigner, `X` fermer, `Entrée` suivante, `Z` annuler), progression et résumé de session ; décisions via les mêmes API que le board
- 🔄 **Board dynamique** — aucun refresh manuel : toute modification (autre session, Gitea, capture) est diffusée en SSE et appliquée **en place** avec animation des cartes (`/api/board/{owner}/{repo}` en JSON + fragments rendus serveur, diff DOM/FLIP) ; repli par sondage de 45 s sans webhooks ; drag local, filtres et panneau détail préservés
- ⌨️ **Palette de commandes** — `Ctrl+K` (ou le bouton ⌨️) sur toutes les pages : actions, projets et recherche d'issues globale, 100 % clavier
@@ -129,6 +131,7 @@ sudo systemctl enable --now docker
| `KANBANTE_CAPTURE_NTFY_TOPIC` | *(vide)* | Topic ntfy écouté : chaque message devient une issue dans `KANBANTE_CAPTURE_REPO` |
| `KANBANTE_BRIEFING_INCLUDE_STANDUP` | `0` | `1` = le standup automatique est ajouté au briefing ntfy du matin |
| `KANBANTE_RULES` | *(vide)* | Règles d'automatisation en JSON (voir `/rules`) : conditions colonne/âge/étiquette/WIP → add_label / notify / move_top |
| `KANBANTE_ZOMBIE_DAYS` | `30` | Jours sans activité après lesquels une carte ouverte est « zombie » sur `/health` |
| `KANBANTE_DIGEST_TIME` / `KANBANTE_DIGEST_WEEKDAY` | *(vide)* / `0` | Composition hebdo du digest (`HH:MM`, 0 = lundi) ; notification ntfy, jamais d'envoi automatique |
| `KANBANTE_OBSIGATE_MCP_URL` / `_TOKEN` / `_TOOL` / `_ARGS` | *(vide)* | Serveur MCP d'ObsiGate : URL, jeton optionnel, outil de création de note, gabarit JSON d'arguments (`{title}`, `{content}`) |
| `KANBANTE_DIGEST_WEBHOOK_URL` | *(vide)* | Webhook générique recevant le digest en JSON `{title, markdown, week}` |
@@ -183,6 +186,8 @@ kanbante/
| GET | `/api/issues/{o}/{r}/{n}/code` | Branches, PRs et commits liés à la carte |
| GET | `/standup` · `/api/standup` | Standup automatique (hier / aujourd'hui / blocages) |
| GET | `/rules` · POST `/api/rules/run` | Règles d'automatisation : état, dernière évaluation, lancement manuel |
| POST | `/api/issues/{o}/{r}/{n}/branch` | Créer la branche de la carte (`issue-<n>-<slug>` ou nom libre validé) et la passer en « En cours » |
| GET | `/health` · `/health/{o}/{r}` | Santé du flux : goulots (temps médian par colonne) et cartes zombies, global ou par projet |
| POST | `/api/today/pin` · `/api/today/unpin` | Épingler / retirer une carte du Focus du jour (max 3) |
| GET | `/forecast` | Prévisions : débit hebdo, ETA backlog et milestones par projet |
| GET | `/sprint/{owner}/{repo}` · `/api/sprint/{owner}/{repo}` | Récit de sprint d'un milestone (`?milestone=`), page rendue ou JSON markdown |
+21
View File
@@ -7,6 +7,7 @@ a token in a branch name (`feature/12-login`, `issue-12`, `12-fix`).
"""
import re
import unicodedata
_REF_RE = re.compile(r"(?<![\w#])#(\d+)\b")
_BRANCH_TOKEN_RE = re.compile(r"(?:^|[-_/])(\d+)(?:[-_/]|$)")
@@ -27,6 +28,26 @@ def branch_issue_number(branch_name: str | None) -> int | None:
return int(m.group(1)) if m else None
def slugify_title(title: str, max_len: int = 40) -> str:
"""ASCII slug for a branch name: 'Créer la branche !' -> 'creer-la-branche'."""
normalized = unicodedata.normalize("NFKD", title or "")
ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^a-z0-9]+", "-", ascii_text.lower()).strip("-")
slug = re.sub(r"-{2,}", "-", slug)
if len(slug) > max_len:
slug = slug[:max_len].rstrip("-")
return slug or "issue"
def branch_name_for(number: int, title: str) -> str:
"""The branch a card would create (issue #3): issue-<n>-<slug>.
The name is recognized back by branch_issue_number, which is
what links the future PR to this card (issue #2).
"""
return f"issue-{number}-{slugify_title(title)}"
def find_code_links(
branches: list[dict],
pulls: list[dict],
+6 -1
View File
@@ -78,6 +78,9 @@ Environment variables:
column, min_age_days, label, wip_over.
Actions: add_label, notify, move_top.
A rule can never close an issue.
KANBANTE_ZOMBIE_DAYS Days without any activity after
which an open card is a "zombie"
on /health (issue #9). Default 30.
"""
import os
@@ -86,7 +89,7 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.13.0"
APP_VERSION = "0.16.0"
def _env_bool(name: str, default: bool) -> bool:
@@ -154,6 +157,7 @@ class Settings:
obsigate_mcp_args: str = '{"title": "{title}", "content": "{content}"}'
briefing_include_standup: bool = False
rules_raw: str = ""
zombie_days: int = 30
@property
def auth_enabled(self) -> bool:
@@ -198,4 +202,5 @@ def get_settings() -> Settings:
),
briefing_include_standup=_env_bool("KANBANTE_BRIEFING_INCLUDE_STANDUP", False),
rules_raw=os.getenv("KANBANTE_RULES", ""),
zombie_days=_env_int("KANBANTE_ZOMBIE_DAYS", 30),
)
+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.13.0"},
"clientInfo": {"name": "kanbante", "version": "0.16.0"},
},
req_id=1,
)
+123
View File
@@ -0,0 +1,123 @@
"""Flow health (roadmap #9): bottlenecks and zombie cards.
Boards show state; this module reads the event journal for health:
how long cards actually stay in each column (dwell samples are the
intervals between an entry and the next move/close), and which
open cards have had no activity for a long time (zombies).
"""
import statistics
from datetime import datetime
from app.models import KANBAN_COLUMNS
def parse_ts(raw) -> datetime | None:
"""Parse journal ('YYYY-MM-DD HH:MM:SS') and Gitea ISO stamps."""
text = str(raw or "").strip()
if not text:
return None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
try:
return datetime.strptime(text[:19], fmt)
except ValueError:
continue
try:
return datetime.strptime(text[:10], "%Y-%m-%d")
except ValueError:
return None
def column_dwell(events: list[dict]) -> dict[str, list[float]]:
"""Dwell samples (in days) per column, rebuilt from the journal.
A stay starts when a card enters a column (created / moved /
reopened) and ends at its next move or close. The first stay of
a card whose journal starts mid-flight is unknowable and is
simply not sampled.
"""
by_issue: dict[tuple[str, int], list[dict]] = {}
for event in events:
by_issue.setdefault(
(event["repo_full_name"], event["issue_number"]), []
).append(event)
dwell: dict[str, list[float]] = {}
for issue_events in by_issue.values():
ordered = sorted(
issue_events, key=lambda e: str(e.get("created_at") or "")
)
current: str | None = None
since: datetime | None = None
for event in ordered:
at = parse_ts(event.get("created_at"))
if at is None:
continue
kind = event["event_type"]
if kind == "created":
if current is None:
current = event.get("to_column") or "backlog"
since = at
elif kind == "moved":
if current is not None and since is not None:
dwell.setdefault(current, []).append(
(at - since).total_seconds() / 86400
)
elif current is None and event.get("from_column"):
# The stay in from_column began before the journal
# window: only the new stay can be timed.
pass
current = event.get("to_column") or current
since = at
elif kind == "closed":
if current is not None and since is not None:
dwell.setdefault(current, []).append(
(at - since).total_seconds() / 86400
)
current = "done"
since = at
elif kind == "reopened":
current = event.get("to_column") or "backlog"
since = at
return dwell
def summarize_dwell(dwell: dict[str, list[float]]) -> list[dict]:
"""Median dwell per column, in board order."""
summary = []
for column in KANBAN_COLUMNS:
samples = dwell.get(column) or []
if not samples:
continue
summary.append(
{
"column": column,
"count": len(samples),
"median_days": round(statistics.median(samples), 1),
"avg_days": round(sum(samples) / len(samples), 1),
}
)
return summary
def slowest_column(summary: list[dict], min_samples: int = 2) -> dict | None:
"""The bottleneck: slowest column (done excluded) with data."""
candidates = [
row for row in summary
if row["column"] != "done" and row["count"] >= min_samples
]
if not candidates:
return None
return max(candidates, key=lambda r: r["median_days"])
def last_activity(events: list[dict]) -> dict[tuple[str, int], datetime]:
"""Most recent journal timestamp per (repo, issue)."""
latest: dict[tuple[str, int], datetime] = {}
for event in events:
at = parse_ts(event.get("created_at"))
if at is None:
continue
key = (event["repo_full_name"], event["issue_number"])
if key not in latest or at > latest[key]:
latest[key] = at
return latest
+11 -3
View File
@@ -7,7 +7,15 @@ date, and are withheld entirely when the journal has no closures
to learn from — "not enough history" is an answer, too.
"""
from datetime import date, datetime, timedelta
from datetime import date, datetime, timedelta, timezone
def _journal_today() -> date:
"""'Today' in the journal's frame: board_events stamps are UTC
(SQLite datetime('now')), so week buckets anchor to UTC —
otherwise closures logged in the local evening (already
tomorrow in UTC) fall outside every bucket."""
return datetime.now(timezone.utc).date()
MONTHS_FR = [
"janvier", "février", "mars", "avril", "mai", "juin",
@@ -35,7 +43,7 @@ def _event_date(event: dict) -> date | None:
def weekly_closures(events: list[dict], weeks: int = 8,
today: date | None = None) -> list[dict]:
"""Closure counts per ISO week, oldest first, current week last."""
today = today or date.today()
today = today or _journal_today()
current_monday = today - timedelta(days=today.weekday())
buckets: list[dict] = []
for i in range(weeks - 1, -1, -1):
@@ -83,7 +91,7 @@ def estimate(remaining: int, weekly: list[dict],
no throughput to project from, else dates: central (avg rate),
earliest (best week), latest (worst nonzero week, if any).
"""
today = today or date.today()
today = today or _journal_today()
if remaining <= 0:
return {"done": True}
stats = throughput_stats(weekly)
+13
View File
@@ -341,6 +341,19 @@ class GiteaClient:
self._cache_clear()
return r.json()
async def create_branch(
self, owner: str, repo: str, new_branch: str, old_branch: str
) -> dict:
"""Create a branch from another one (issue #3)."""
return await self._request(
"POST",
f"/repos/{owner}/{repo}/branches",
json={
"new_branch_name": new_branch,
"old_branch_name": old_branch,
},
)
async def list_branches(self, owner: str, repo: str) -> list[dict]:
"""All branches of a repo (code links, issue #1)."""
return await self._get_all_pages(f"/repos/{owner}/{repo}/branches")
+252 -1
View File
@@ -37,6 +37,7 @@ from app.config import APP_VERSION, get_settings
from app.gitea_client import GiteaClient
from app import code_links as code_links_service
from app import digest as digest_service
from app import flow as flow_service
from app import forecast as forecast_service
from app import rules as rules_service
from app import sprint as sprint_service
@@ -291,6 +292,27 @@ _tpl_env = Environment(
)
# Which main-nav entry a template represents (issue #16): the
# shared _header.html partial highlights it from this one place
# instead of 19 hand-maintained copies.
_NAV_BY_TEMPLATE = {
"dashboard.html": "home",
"focus.html": "focus",
"today.html": "today",
"my.html": "my",
"activity.html": "activity",
"briefing.html": "briefing",
"standup.html": "standup",
"search.html": "search",
"capture.html": "capture",
"digest.html": "digest",
"forecast.html": "forecast",
"rules.html": "rules",
"health.html": "health",
"health_project.html": "health",
}
def render(
request: Request, name: str, status_code: int = 200, **context
) -> HTMLResponse:
@@ -306,6 +328,7 @@ def render(
token = secrets.token_urlsafe(32)
context.setdefault("csrf_token", token)
context.setdefault("version", APP_VERSION)
context.setdefault("active_nav", _NAV_BY_TEMPLATE.get(name, ""))
html = _tpl_env.get_template(name).render(**context)
response = HTMLResponse(html, status_code=status_code)
if generated:
@@ -1252,6 +1275,94 @@ async def api_rules_run(request: Request, csrf_token: str = Form("")):
return {"ok": True, "summary": summary}
async def _project_health(owner: str, repo: str) -> dict:
"""Bottlenecks + zombies for one project (issue #9)."""
full_name = f"{owner}/{repo}"
settings = get_settings()
all_items = await gitea.get_repo_issues(owner, repo, state="all")
columns, display_issues, _bs = _board_columns_for(
owner, repo, all_items
)
events = get_events(repo_full_name=full_name, limit=2000)
dwell = flow_service.summarize_dwell(flow_service.column_dwell(events))
slowest = flow_service.slowest_column(dwell)
activity = flow_service.last_activity(events)
placement = {
issue["number"]: col
for col, cards in columns.items()
for issue in cards
}
now = datetime.now()
zombies = []
for issue in display_issues:
number = issue["number"]
candidates = [
ts for ts in (
activity.get((full_name, number)),
flow_service.parse_ts(issue.get("updated_at")),
flow_service.parse_ts(issue.get("created_at")),
)
if ts is not None
]
if not candidates:
continue
days = (now - max(candidates)).days
if days >= settings.zombie_days:
column = placement.get(number, "backlog")
zombies.append(
{
"number": number,
"title": issue.get("title", ""),
"column": column,
"column_label": COLUMN_LABELS[column],
"days_inactive": days,
"html_url": issue.get("html_url", ""),
}
)
zombies.sort(key=lambda z: -z["days_inactive"])
return {
"full_name": full_name,
"dwell": dwell,
"slowest": slowest,
"zombies": zombies,
"zombie_days": settings.zombie_days,
"open_count": len(display_issues),
"column_labels": COLUMN_LABELS,
}
@app.get("/health", response_class=HTMLResponse)
async def health_index(request: Request):
"""Flow health, global summary (issue #9)."""
repos = await gitea.search_repos()
active, _inactive, _archived = classify_repos(repos)
projects = []
for repo_info in active:
owner, _, name = repo_info["full_name"].partition("/")
try:
projects.append(await _project_health(owner, name))
except Exception as exc:
logger.warning("Health failed for %s: %s", repo_info["full_name"], exc)
return render(request, "health.html", projects=projects)
@app.get("/health/{owner}/{repo}", response_class=HTMLResponse)
async def health_project(request: Request, owner: str, repo: str):
"""Flow health of one project: dwell per column + zombies."""
repos = await gitea.search_repos()
known = {r["full_name"] for r in repos}
if f"{owner}/{repo}" not in known:
return render(
request, "health_project.html",
health=None, full_name=f"{owner}/{repo}",
)
health = await _project_health(owner, repo)
return render(
request, "health_project.html",
health=health, full_name=f"{owner}/{repo}",
)
@app.get("/api/standup")
async def api_standup():
"""The automatic standup as JSON text (issue #6)."""
@@ -1446,6 +1557,7 @@ async def _collect_wall_data(only_repo: str | None) -> list[dict]:
"counts": {col: len(columns[col]) for col in KANBAN_COLUMNS},
"open_total": len(open_issues),
"cards": cards[:9],
"cards_total": len(cards),
"closed_today": closed_today.get(full_name, 0),
}
)
@@ -2490,7 +2602,146 @@ async def api_issue_code(owner: str, repo: str, number: int):
"""Branches, PRs and commits linked to a card (issue #1)."""
branches, pulls, commits = await _fetch_code_sources(owner, repo)
links = code_links_service.find_code_links(branches, pulls, commits, number)
return {"ok": True, **links}
suggested = f"issue-{number}"
try:
issue = await gitea.get_issue(owner, repo, number)
suggested = code_links_service.branch_name_for(
number, issue.get("title", "")
)
except Exception:
pass
return {"ok": True, "suggested_branch": suggested, **links}
_BRANCH_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$")
async def _sync_kanban_label(
owner: str, repo: str, number: int, column: str
) -> None:
"""Mirror a column on the issue's kanban:* label (best effort)."""
if not get_settings().sync_labels:
return
try:
desired = (
f"kanban:{column}" if column in LABELLED_COLUMNS else None
)
issue = await gitea.get_issue(owner, repo, number)
current = [
label for label in issue.get("labels") or []
if str(label.get("name", "")).startswith("kanban:")
]
for label in current:
if label.get("name") != desired:
await gitea.remove_issue_label(
owner, repo, number, label["id"]
)
if desired and all(l.get("name") != desired for l in current):
label = await gitea.ensure_label(
owner, repo, desired, COLUMN_COLORS[column]
)
await gitea.add_issue_label(owner, repo, number, label["id"])
except Exception as exc:
logger.warning(
"Label sync failed for %s/%s#%s: %s", owner, repo, number, exc
)
@app.post("/api/issues/{owner}/{repo}/{number}/branch")
async def api_create_branch(
owner: str,
repo: str,
number: int,
request: Request,
name: str = Form(""),
csrf_token: str = Form(""),
):
"""Create the card's branch in Gitea (issue #3), then start the card.
Default name: issue-<n>-<slug of the title> — the convention the
merged-PR automation (issue #2) recognizes. Creating the branch
moves the card to In Progress and journals it.
"""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
try:
issue = await gitea.get_issue(owner, repo, number)
except Exception:
return JSONResponse(
{"ok": False, "error": "Issue introuvable"}, status_code=404
)
if not _is_issue(issue):
return JSONResponse(
{"ok": False, "error": "Pas une issue"}, status_code=400
)
branch = (name or "").strip() or code_links_service.branch_name_for(
number, issue.get("title", "")
)
if (
not _BRANCH_NAME_RE.match(branch)
or ".." in branch
or branch.endswith("/")
or branch.endswith(".lock")
):
return JSONResponse(
{"ok": False, "error": f"Nom de branche invalide : {branch}"},
status_code=400,
)
repo_info = {}
try:
repo_info = await gitea.get_repo(owner, repo)
except Exception:
pass
base_url = repo_info.get("html_url", "")
branch_url = f"{base_url}/src/branch/{branch}" if base_url else ""
existing = await _safe_list_branches(owner, repo)
if branch in existing:
return {
"ok": True, "branch": branch, "exists": True,
"url": branch_url, "moved": False,
}
default_branch = repo_info.get("default_branch") or "main"
try:
await gitea.create_branch(owner, repo, branch, default_branch)
except Exception as exc:
return JSONResponse(
{"ok": False, "error": f"Création impossible : {exc}"},
status_code=502,
)
gitea.invalidate_cache()
full_name = f"{owner}/{repo}"
moved = False
if issue.get("state") == "open":
board = get_board_state(full_name)
current = next(
(
col for col, cards in board.items()
if any(c["issue_number"] == number for c in cards)
),
"backlog",
)
if current != "in_progress":
move_issue(full_name, number, "in_progress")
log_event(
full_name, number, "moved",
from_column=current, to_column="in_progress",
source="branch",
)
await _sync_kanban_label(owner, repo, number, "in_progress")
moved = True
await _publish_board_changed(full_name)
return {
"ok": True, "branch": branch, "from": default_branch,
"url": branch_url, "moved": moved,
}
async def _safe_list_branches(owner: str, repo: str) -> list[str]:
try:
return [b.get("name", "") for b in await gitea.list_branches(owner, repo)]
except Exception:
return []
@app.get("/api/issues/{owner}/{repo}/{number}")
+85
View File
@@ -0,0 +1,85 @@
{# ── Cadre applicatif partagé (issue #16, refonte 0.16.0) ─────────
Barre latérale unique pour TOUTES les pages applicatives :
desktop = barre fixe repliable en rail d'icônes (état mémorisé),
mobile = tiroir hors-canvas ouvert par le burger de la topbar.
NE PAS dupliquer ce markup dans les pages.
Page active : `active_nav` (déduite du gabarit dans render()).
Fil d'Ariane : `owner`+`repo` (board) ou `full_name` (projet).
Le wallboard (TV) n'inclut pas ce partiel, volontairement. #}
<script>
document.body.classList.add('with-sidebar');
try {
if (localStorage.getItem('kanbante-sidebar') === 'collapsed') {
document.body.classList.add('sidebar-collapsed');
}
} catch (e) { /* stockage indisponible : état par défaut */ }
</script>
{% set nav_groups = [
("Principal", [
("home", "/", "🏠", "Projets"),
("focus", "/focus", "🎯", "Focus"),
("today", "/today", "🌅", "Aujourd'hui"),
("my", "/my", "👤", "Mes issues"),
]),
("Rituels", [
("briefing", "/briefing", "📊", "Briefing"),
("standup", "/standup", "🗣️", "Standup"),
("digest", "/digest", "🧠", "Digest"),
]),
("Pilotage", [
("forecast", "/forecast", "🔮", "Prévisions"),
("health", "/health", "🩺", "Santé"),
("rules", "/rules", "⚙️", "Règles"),
("activity", "/activity", "📰", "Activité"),
]),
("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>
</a>
{% endfor %}
{% endfor %}
</nav>
<div class="side-foot">
<button type="button" class="side-collapse" id="side-collapse" aria-label="Replier ou déplier le menu">
<span class="side-collapse-ico">«</span>
<span class="side-label">Replier</span>
</button>
<span class="side-version side-label">v{{ version }}</span>
</div>
</aside>
<div class="side-backdrop" id="side-backdrop" hidden></div>
<header class="app-header topbar">
<button type="button" class="nav-burger" id="nav-burger" aria-expanded="false" aria-controls="sidebar" aria-label="Ouvrir le menu">☰</button>
{% if owner is defined and repo is defined %}
<span class="crumb"><a href="/board/{{ owner }}/{{ repo }}">{{ owner }} / <strong>{{ repo }}</strong></a></span>
{% elif full_name is defined %}
<span class="crumb"><a href="/board/{{ full_name }}">{{ full_name }}</a></span>
{% endif %}
<div class="header-actions">
{% if repo_info is defined and repo_info and repo_info.html_url %}
<a href="{{ repo_info.html_url }}" target="_blank" rel="noopener" class="nav-pill" title="Ouvrir ce dépôt dans Gitea">🔗 Gitea ↗</a>
{% endif %}
<button type="button" class="nav-pill palette-trigger topbar-search" title="Recherche et commandes (Ctrl+K)">🔍</button>
</div>
<script src="/static/js/nav.js" defer></script>
</header>
+1 -22
View File
@@ -8,28 +8,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
<span class="crumb"><span class="sep">/</span> 📰 <strong>Activité</strong></span>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill active">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/standup" class="nav-pill">🗣️ Standup</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/rules" class="nav-pill">⚙️ Règles</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
<p class="focus-intro">Les {{ events|length }} derniers événements du journal — déplacements, créations, fermetures et commentaires, tous projets confondus.</p>
+1 -23
View File
@@ -12,29 +12,7 @@
<script src="/static/js/board.js" defer></script>
</head>
<body>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
<span class="crumb"><span class="sep">/</span> {{ owner }} / <strong>{{ repo }}</strong></span>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/rules" class="nav-pill">⚙️ Règles</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
{% if repo_info.html_url %}
<a href="{{ repo_info.html_url }}" target="_blank" class="nav-pill">🔗 Gitea ↗</a>
{% endif %}
</nav>
</header>
{% include "_header.html" %}
{% if milestones %}
<div class="milestone-bar">
+1 -20
View File
@@ -9,26 +9,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
<span class="crumb"><span class="sep">/</span> 📊 <strong>Briefing</strong></span>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/rules" class="nav-pill">⚙️ Règles</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
<p class="focus-intro">Le briefing du <strong>{{ data.date }}</strong> — le même contenu que celui poussé sur ntfy.</p>
+1 -19
View File
@@ -8,25 +8,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/standup" class="nav-pill">🗣️ Standup</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill active">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/rules" class="nav-pill">⚙️ Règles</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
<section class="briefing-card">
+1 -21
View File
@@ -9,27 +9,7 @@
<script defer src="/static/vendor/alpine.min.js"></script>
</head>
<body>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill active">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/standup" class="nav-pill">🗣️ Standup</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/rules" class="nav-pill">⚙️ Règles</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<section class="hero">
<div class="hero-text">
+1 -17
View File
@@ -8,23 +8,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/standup" class="nav-pill">🗣️ Standup</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill active">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
<section class="briefing-card">
+1 -12
View File
@@ -7,18 +7,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
</nav>
</header>
{% include "_header.html" %}
<main class="error-main">
<div class="error-box">
<h2>⚠️ Quelque chose s'est mal passé</h2>
+1 -22
View File
@@ -8,28 +8,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
<span class="crumb"><span class="sep">/</span> 🎯 <strong>Focus</strong></span>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill active">🎯 Focus</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/standup" class="nav-pill">🗣️ Standup</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/rules" class="nav-pill">⚙️ Règles</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
<span class="badge">{{ groups|map(attribute='cards')|map('length')|sum }} cartes en cours</span>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
<p class="focus-intro">Tout ce qui est <strong>En cours</strong> ou <strong>En révision</strong>, tous projets actifs confondus.</p>
+1 -18
View File
@@ -8,24 +8,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/standup" class="nav-pill">🗣️ Standup</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill active">🔮 Prévisions</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
<section class="briefing-card">
+41
View File
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Santé du flux — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<section class="briefing-card">
<h1>🩺 Santé du flux</h1>
<p>Les boards montrent l'état ; cette page montre la <strong>santé</strong> : quelle colonne accumule (temps médian passé dans chaque colonne, d'après le journal) et quelles cartes sont devenues des <strong>zombies</strong> (aucune activité depuis le seuil configuré).</p>
</section>
{% for project in projects %}
<section class="briefing-card forecast-card">
<h2>📂 {{ project.full_name }} <small>({{ project.open_count }} ouverte(s))</small></h2>
{% if project.slowest %}
<p>🐌 Colonne la plus lente : <strong>{{ project.column_labels[project.slowest.column] }}</strong> — médiane {{ project.slowest.median_days }} j ({{ project.slowest.count }} séjours mesurés)</p>
{% else %}
<p>🐌 Pas encore assez de séjours mesurés pour nommer un goulot.</p>
{% endif %}
<p>🧟 Zombies (≥ {{ project.zombie_days }} j sans activité) : <strong>{{ project.zombies|length }}</strong>
— <a href="/health/{{ project.full_name }}">voir le détail et agir →</a></p>
</section>
{% else %}
<div class="briefing-card"><p>Aucun projet actif.</p></div>
{% endfor %}
</main>
<footer>
<span>Kanbante v{{ version }} · l'hygiène du flux, mesurée sur le journal — pas au feeling</span>
</footer>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
<script src="/static/js/live.js" defer></script>
</body>
</html>
+74
View File
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Santé — {{ full_name }} — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
{% if health is none %}
<section class="briefing-card">
<h1>🩺 {{ full_name }}</h1>
<p>Projet introuvable.</p>
</section>
{% else %}
<section class="briefing-card">
<h1>🩺 {{ full_name }}</h1>
<h2>⏱️ Temps passé par colonne (journal)</h2>
{% if health.dwell %}
<table class="health-table">
<thead><tr><th>Colonne</th><th>Séjours mesurés</th><th>Médiane</th><th>Moyenne</th></tr></thead>
<tbody>
{% for row in health.dwell %}
<tr{% if health.slowest and row.column == health.slowest.column %} class="health-slowest"{% endif %}>
<td>{{ health.column_labels[row.column] }}{% if health.slowest and row.column == health.slowest.column %} 🐌{% endif %}</td>
<td>{{ row.count }}</td>
<td>{{ row.median_days }} j</td>
<td>{{ row.avg_days }} j</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if health.slowest %}
<p>🐌 <strong>Goulot : {{ health.column_labels[health.slowest.column] }}</strong> — c'est là que les cartes dorment le plus longtemps (médiane {{ health.slowest.median_days }} j).</p>
{% endif %}
{% else %}
<p>Pas encore de séjours mesurables dans le journal de ce projet — il faut des déplacements journalisés pour calculer les temps de colonne.</p>
{% endif %}
</section>
<section class="briefing-card">
<h2>🧟 Cartes zombies — aucune activité depuis ≥ {{ health.zombie_days }} jours</h2>
{% if health.zombies %}
<ul class="today-list">
{% for z in health.zombies %}
<li>
<span>#{{ z.number }} — {{ z.title }} <em>({{ z.column_label }} · inactive depuis {{ z.days_inactive }} j)</em></span>
<span class="zombie-actions">
<button type="button" class="btn-sm" data-zombie="comment" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">💬 Toujours pertinent ?</button>
<button type="button" class="btn-sm" data-zombie="backlog" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">↩️ Backlog</button>
<button type="button" class="btn-sm" data-zombie="close" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">✅ Fermer</button>
</span>
</li>
{% endfor %}
</ul>
<p class="notes-hint">« Fermer » déplace la carte en Terminé (l'issue est fermée dans Gitea par la synchro habituelle). « Toujours pertinent ? » poste un commentaire du même texte sur l'issue.</p>
{% else %}
<p>Aucune carte zombie. Le flux est propre. ✨</p>
{% endif %}
</section>
{% endif %}
</main>
<footer>
<span>Kanbante v{{ version }} · seuil zombie : KANBANTE_ZOMBIE_DAYS</span>
</footer>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
<script src="/static/js/health.js" defer></script>
</body>
</html>
+1 -20
View File
@@ -8,26 +8,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
<span class="crumb"><span class="sep">/</span> 👤 <strong>Mes issues</strong></span>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill active">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/rules" class="nav-pill">⚙️ Règles</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
{% if not user %}
+1 -12
View File
@@ -8,18 +8,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/rules" class="nav-pill active">⚙️ Règles</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
<section class="briefing-card">
+1 -20
View File
@@ -8,26 +8,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
<span class="crumb"><span class="sep">/</span> 🔍 <strong>Recherche</strong></span>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill active">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/rules" class="nav-pill">⚙️ Règles</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
<form method="get" action="/search" class="search-hero">
+1 -10
View File
@@ -10,16 +10,7 @@
<script src="/static/vendor/purify.min.js"></script>
</head>
<body>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/board/{{ full_name }}" class="nav-pill">📋 Board</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/rules" class="nav-pill">⚙️ Règles</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
{% if markdown %}
+1 -11
View File
@@ -8,17 +8,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/standup" class="nav-pill">🗣️ Standup</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
<section class="briefing-card">
+1 -19
View File
@@ -8,25 +8,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill active">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/standup" class="nav-pill">🗣️ Standup</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/rules" class="nav-pill">⚙️ Règles</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="today-main">
<h1 class="today-title">🌅 {{ date_label }}</h1>
+1 -19
View File
@@ -8,25 +8,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/standup" class="nav-pill">🗣️ Standup</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/rules" class="nav-pill">⚙️ Règles</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
<section class="briefing-card">
+1 -8
View File
@@ -8,14 +8,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/board/{{ full_name }}" class="nav-pill">📋 Board</a>
<a href="/triage" class="nav-pill">📥 Autre projet</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="triage-main" data-repo="{{ full_name }}">
<div class="triage-head">
+3
View File
@@ -45,6 +45,9 @@
{% endif %}
</li>
{% endfor %}
{% if slide.cards_total > slide.cards|length %}
<li class="wall-card wall-more">… +{{ slide.cards_total - slide.cards|length }} autres cartes en vol</li>
{% endif %}
</ul>
{% else %}
<p class="wall-idle">🌿 Rien en vol pour le moment — tout est au Backlog ou terminé.</p>
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "kanbante"
version = "0.13.0"
version = "0.16.0"
description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11"
dependencies = [
+137 -5
View File
@@ -900,15 +900,21 @@ footer {
color: #e5ecf5;
margin: 0;
font-family: inherit;
height: 100vh;
height: 100dvh;
overflow: hidden;
}
.wall-stage { position: relative; min-height: 100vh; }
.wall-stage { position: relative; height: 100vh; height: 100dvh; }
.wall-slide {
display: none;
min-height: 100vh;
height: 100vh;
height: 100dvh;
max-height: 100dvh;
overflow: hidden;
box-sizing: border-box;
padding: 4vh 4vw;
padding: 3.5vh 4vw 96px;
flex-direction: column;
gap: 3vh;
gap: 2.5vh;
}
.wall-slide.active { display: flex; animation: wall-in 0.45s ease; }
@keyframes wall-in { from { opacity: 0; } to { opacity: 1; } }
@@ -934,7 +940,14 @@ footer {
}
.wall-count-n { display: block; font-size: clamp(1.9rem, 3.6vw, 3rem); font-weight: 800; }
.wall-count-l { display: block; color: #aebdd2; font-size: clamp(0.78rem, 1.25vw, 1.02rem); margin-top: 2px; }
.wall-cards { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
.wall-cards {
list-style: none; margin: 0; padding: 0;
display: flex; flex-direction: column; gap: 12px;
flex: 1 1 auto; min-height: 0; overflow: hidden;
-webkit-mask-image: linear-gradient(to bottom, #000 88%, transparent);
mask-image: linear-gradient(to bottom, #000 88%, transparent);
}
.wall-more { justify-content: center; color: #8fa3bf; font-size: 1.1rem; background: transparent; }
.wall-card {
display: flex; align-items: center; gap: 18px;
background: rgba(255, 255, 255, 0.045);
@@ -954,8 +967,17 @@ footer {
.wall-foot {
margin-top: auto;
display: flex; gap: 36px; flex-wrap: wrap;
justify-content: space-between; align-items: center;
width: 100%; box-sizing: border-box;
font-size: clamp(1rem, 1.7vw, 1.35rem); color: #aebdd2;
}
@media (max-height: 760px) {
.wall-slide { gap: 1.6vh; padding-top: 2.5vh; }
.wall-card { padding: 10px 16px; }
.wall-count { padding: 8px 8px 7px; }
.wall-project { font-size: clamp(1.8rem, 4vw, 2.6rem); }
.wall-time { font-size: clamp(1.7rem, 3.6vw, 2.6rem); }
}
.wall-live { color: #34d399; font-weight: 700; letter-spacing: 0.08em; }
.wall-empty {
min-height: 100vh; display: flex; flex-direction: column;
@@ -1151,3 +1173,113 @@ footer {
.code-line { padding: 3px 0; font-size: 0.9rem; color: var(--text-2, #b6c2d2); }
.code-line a { color: var(--accent, #58a6ff); text-decoration: none; }
.code-line code { background: var(--surface-3, #1d2942); border-radius: 4px; padding: 0 5px; }
/* ── Branche depuis la carte (0.14.0, #3) ── */
.branch-input {
background: var(--surface-3, #1d2942); color: var(--text-1);
border: 1px solid var(--border, #22304a); border-radius: 6px;
padding: 3px 8px; font-family: monospace; font-size: 0.85rem;
}
/* ── Santé du flux (0.14.0, #9) ── */
.health-table { width: 100%; border-collapse: collapse; margin: 10px 0; }
.health-table th, .health-table td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--border, #22304a); }
.health-slowest td { color: var(--warn, #e8a33d); font-weight: 600; }
.zombie-actions { display: flex; gap: 6px; flex-shrink: 0; }
/* ── Header unifié (0.15.0, #16) ── */
.header-actions { display: flex; gap: 6px; align-items: center; margin-left: auto; }
.nav-burger {
display: none; width: 40px; height: 40px; border-radius: 10px;
background: var(--surface-2); border: 1px solid var(--border);
color: var(--text); font-size: 1.05rem; cursor: pointer;
align-items: center; justify-content: center;
}
.crumb a { color: inherit; text-decoration: none; }
.crumb a:hover { text-decoration: underline; }
@media (max-width: 900px) {
.nav-burger { display: inline-flex; }
.crumb { display: none; }
}
/* ── Barre latérale (0.16.0, #16 refonte) ── */
body.with-sidebar { padding-left: 238px; transition: padding-left 0.18s ease; }
body.with-sidebar.sidebar-collapsed { padding-left: 68px; }
.sidebar {
position: fixed; left: 0; top: 0; bottom: 0; z-index: 200;
width: 238px; box-sizing: border-box;
display: flex; flex-direction: column; gap: 6px;
padding: 16px 12px 12px; overflow-y: auto; overflow-x: hidden;
background: rgba(13, 16, 24, 0.96); backdrop-filter: blur(14px);
border-right: 1px solid var(--border);
transition: width 0.18s ease, transform 0.22s ease;
}
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-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;
}
.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-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-link {
display: flex; align-items: center; gap: 11px;
padding: 8px 10px; border-radius: 9px;
color: var(--text-2); font-size: 0.95rem; font-weight: 500;
white-space: nowrap; border-left: 3px solid transparent;
}
.side-link:hover { background: var(--surface-2); color: var(--text); text-decoration: none; }
.side-link.active {
color: #fff; border-left-color: #a78bfa;
background: linear-gradient(90deg, rgba(124,92,252,.32), rgba(91,138,247,.14));
}
.side-ico { font-size: 1.05rem; width: 22px; text-align: center; flex-shrink: 0; }
.side-foot {
margin-top: auto; padding-top: 12px;
display: flex; align-items: center; justify-content: space-between; gap: 8px;
}
.side-collapse {
display: flex; align-items: center; gap: 8px; cursor: pointer;
background: transparent; border: 1px solid var(--border); border-radius: 9px;
color: var(--text-2); padding: 6px 10px; font-size: 0.85rem; white-space: nowrap;
}
.side-collapse:hover { color: var(--text); }
.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-logo { justify-content: center; padding-left: 0; padding-right: 0; }
body.sidebar-collapsed .side-foot { justify-content: center; }
.side-backdrop {
position: fixed; inset: 0; z-index: 190;
background: rgba(4, 6, 10, 0.55);
}
.topbar { min-height: 52px; padding: 8px 18px; justify-content: flex-start; gap: 14px; }
.topbar-search { display: none; }
@media (max-width: 900px) {
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; }
.side-foot { display: none; }
.topbar-search { display: inline-flex; }
}
+31 -3
View File
@@ -397,9 +397,37 @@ document.addEventListener('DOMContentLoaded', () => {
for (const c of (data.commits || []).slice(0, 8)) {
parts.push(`⑂ <a href="${c.html_url}" target="_blank" rel="noopener"><code>${c.sha}</code></a> ${c.message}${c.date ? ' · ' + c.date : ''}`);
}
box.innerHTML = parts.length
? '<h3 class="detail-comments-title">🧬 Code lié</h3>' + parts.map(p => `<div class="code-line">${p}</div>`).join('')
: '';
const hasBranch = (data.branches || []).length > 0;
if (!hasBranch && data.suggested_branch) {
parts.push(`🌿 <input id="branch-name" class="branch-input" value="${data.suggested_branch}" size="36" spellcheck="false"> <button type="button" class="btn-sm" id="branch-create">Créer la branche</button>`);
}
box.innerHTML = '<h3 class="detail-comments-title">🧬 Code lié</h3>' + (
parts.length
? parts.map(p => `<div class="code-line">${p}</div>`).join('')
: '<div class="code-line">Aucun élément lié pour l\'instant.</div>'
);
const createBtn = document.getElementById('branch-create');
if (createBtn) {
createBtn.addEventListener('click', async () => {
const name = (document.getElementById('branch-name') || {}).value || '';
const form = new FormData();
form.append('name', name);
form.append('csrf_token', csrfToken);
createBtn.disabled = true;
try {
const r2 = await fetch(`/api/issues/${owner}/${repo}/${number}/branch`, { method: 'POST', body: form });
const body = await r2.json().catch(() => ({}));
if (!r2.ok || !body.ok) throw new Error(body.error || ('HTTP ' + r2.status));
showToast(body.exists
? `🌿 La branche ${body.branch} existait déjà`
: `🌿 Branche ${body.branch} créée depuis ${body.from} — carte en « En cours »`);
loadCodeLinks(repoFull, number);
} catch (err) {
createBtn.disabled = false;
showToast('❌ Branche non créée : ' + err.message, true);
}
});
}
} catch (err) { /* le code est un bonus, jamais bloquant */ }
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Kanbante — Flow health (issue #9): quick actions on zombie cards.
* Close/backlog reuse /api/move; the nudge reuses the comments API.
*/
(function () {
var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
function post(url, fields) {
var data = new FormData();
Object.keys(fields).forEach(function (k) { data.append(k, fields[k]); });
data.append('csrf_token', csrf);
return fetch(url, { method: 'POST', body: data }).then(function (r) {
return r.json().catch(function () { return {}; }).then(function (body) {
if (!r.ok || body.ok === false) throw new Error(body.error || ('HTTP ' + r.status));
return body;
});
});
}
document.addEventListener('click', function (e) {
var btn = e.target.closest('[data-zombie]');
if (!btn) return;
var repo = btn.getAttribute('data-repo');
var number = btn.getAttribute('data-number');
var column = btn.getAttribute('data-column');
var action = btn.getAttribute('data-zombie');
var parts = repo.split('/');
btn.disabled = true;
var done = function () { location.reload(); };
var fail = function (err) { btn.disabled = false; alert(err.message); };
if (action === 'comment') {
post('/api/issues/' + parts[0] + '/' + parts[1] + '/' + number + '/comments', {
body: '🧟 Cette carte est inactive depuis un moment — toujours pertinent ?'
}).then(done).catch(fail);
} else {
post('/api/move', {
repo_full_name: repo,
issue_number: number,
from_column: column,
to_column: action === 'close' ? 'done' : 'backlog',
to_order: number
}).then(done).catch(fail);
}
});
})();
+45
View File
@@ -0,0 +1,45 @@
/**
* Kanbante — sidebar behaviour (issue #16, 0.16.0 layout).
* Desktop: collapse to an icon rail (persisted in localStorage).
* Mobile: the same sidebar becomes an off-canvas drawer, toggled
* by the topbar burger, closed by backdrop / Escape / navigation.
*/
(function () {
var burger = document.getElementById('nav-burger');
var backdrop = document.getElementById('side-backdrop');
var collapseBtn = document.getElementById('side-collapse');
function closeDrawer() {
document.body.classList.remove('sidebar-open');
if (backdrop) backdrop.hidden = true;
if (burger) {
burger.setAttribute('aria-expanded', 'false');
burger.textContent = '☰';
}
}
if (burger) {
burger.addEventListener('click', function () {
var open = document.body.classList.toggle('sidebar-open');
burger.setAttribute('aria-expanded', open ? 'true' : 'false');
burger.textContent = open ? '✕' : '☰';
if (backdrop) backdrop.hidden = !open;
});
}
if (backdrop) backdrop.addEventListener('click', closeDrawer);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeDrawer();
});
window.addEventListener('resize', function () {
if (window.innerWidth > 900) closeDrawer();
});
if (collapseBtn) {
collapseBtn.addEventListener('click', function () {
var collapsed = document.body.classList.toggle('sidebar-collapsed');
try {
localStorage.setItem('kanbante-sidebar', collapsed ? 'collapsed' : 'open');
} catch (e) { /* private mode: state lasts for the page only */ }
});
}
})();
+1
View File
@@ -17,6 +17,7 @@
{ icon: '🔮', label: 'Prévisions — on finit quand ?', href: '/forecast' },
{ icon: '🗣️', label: 'Standup automatique — hier, aujourd\'hui, blocages', href: '/standup' },
{ icon: '⚙️', label: 'Règles d\'automatisation', href: '/rules' },
{ icon: '🩺', label: 'Santé du flux — goulots et zombies', href: '/health' },
{ icon: '👤', label: 'Mes issues', href: '/my' },
{ icon: '📰', label: 'Activité — journal des événements', href: '/activity' },
{ icon: '📊', label: 'Briefing quotidien', href: '/briefing' },
+9 -5
View File
@@ -38,6 +38,7 @@ def _repo(name: str, updated: datetime, archived: bool = False) -> dict:
"owner": {"login": "Projets"},
"archived": archived,
"html_url": f"http://gitea.test/Projets/{name}",
"default_branch": "main",
}
@@ -72,6 +73,7 @@ class FakeGitea:
_repo("vieux", now - timedelta(days=800)),
_repo("archive", now - timedelta(days=1), archived=True),
]
self.branches = ["main", "feature/1-tache-un", "issue-5-focus"]
self.issues = {
"Projets/demo": [
_issue(1, "Tâche un"),
@@ -224,11 +226,13 @@ class FakeGitea:
self.label_ops.append(("remove", number, label["name"]))
async def list_branches(self, owner, repo):
return [
{"name": "main"},
{"name": "feature/1-tache-un"},
{"name": "issue-5-focus"},
]
return [{"name": n} for n in self.branches]
async def create_branch(self, owner, repo, new_branch, old_branch):
if new_branch in self.branches:
raise RuntimeError("branch exists")
self.branches.append(new_branch)
return {"name": new_branch}
async def list_pulls(self, owner, repo, state="all"):
return [
+159
View File
@@ -0,0 +1,159 @@
"""Palier 11 (0.14.0): branche depuis la carte (#3), santé du flux (#9)."""
from datetime import datetime, timedelta
from app import code_links as cl
from app import flow
from app import models
from tests.conftest import csrf_from
# ── #3 Branche depuis la carte ────────────────────────────────────
def test_slugify_and_branch_name():
assert cl.slugify_title("Créer la branche depuis la carte") == \
"creer-la-branche-depuis-la-carte"
assert cl.slugify_title("Fix: l'été indien (urgent!)") == \
"fix-l-ete-indien-urgent"
assert cl.slugify_title("Un titre vraiment très long qui dépasse" * 3)
assert len(cl.slugify_title("x" * 100)) == 40
assert cl.slugify_title("!!!") == "issue"
assert cl.branch_name_for(12, "Capture éclair") == "issue-12-capture-eclair"
# La convention est reconnue par l'appariement (#1/#2).
assert cl.branch_issue_number(cl.branch_name_for(12, "Capture")) == 12
def test_create_branch_endpoint(client, fake_gitea):
token = csrf_from(client)
r = client.post("/api/issues/Projets/demo/5/branch",
data={"csrf_token": token})
assert r.status_code == 200
data = r.json()
assert data["branch"] == "issue-5-tache-focus"
assert data["from"] == "main"
assert data["moved"] is True
assert "issue-5-tache-focus" in fake_gitea.branches
board = models.get_board_state("Projets/demo")
assert 5 in [c["issue_number"] for c in board["in_progress"]]
events = models.get_events(repo_full_name="Projets/demo")
assert any(
e["event_type"] == "moved" and e["source"] == "branch"
for e in events
)
# Deuxième appel : la branche existe déjà, pas de doublon.
r2 = client.post("/api/issues/Projets/demo/5/branch",
data={"csrf_token": token})
assert r2.json()["exists"] is True
def test_create_branch_custom_name_and_validation(client, fake_gitea):
token = csrf_from(client)
r = client.post("/api/issues/Projets/demo/1/branch",
data={"csrf_token": token, "name": "feature/super-truc"})
assert r.status_code == 200
assert r.json()["branch"] == "feature/super-truc"
r = client.post("/api/issues/Projets/demo/1/branch",
data={"csrf_token": token, "name": "mauvais nom.."})
assert r.status_code == 400
def test_code_endpoint_suggests_branch(client, fake_gitea):
r = client.get("/api/issues/Projets/demo/3/code")
assert r.json()["suggested_branch"] == "issue-3-ancienne-tache"
# ── #9 Santé du flux : fonctions pures ────────────────────────────
def _ev(day_offset: float, kind: str, frm=None, to=None, number=1):
at = (datetime(2026, 9, 1) + timedelta(days=day_offset)).strftime(
"%Y-%m-%d %H:%M:%S"
)
return {
"repo_full_name": "Projets/demo", "issue_number": number,
"event_type": kind, "from_column": frm, "to_column": to,
"created_at": at,
}
def test_column_dwell_reconstruction():
events = [
_ev(0, "created", to="backlog"),
_ev(4, "moved", frm="backlog", to="in_progress"),
_ev(10, "closed", frm="in_progress", to="done"),
_ev(1, "created", to="backlog", number=2),
_ev(3, "moved", frm="backlog", to="review", number=2),
]
dwell = flow.column_dwell(events)
assert dwell["backlog"] == [4.0, 2.0]
assert dwell["in_progress"] == [6.0]
summary = flow.summarize_dwell(dwell)
by_col = {row["column"]: row for row in summary}
assert by_col["backlog"]["median_days"] == 3.0
assert by_col["backlog"]["count"] == 2
# backlog a 2 échantillons (médiane 3 j) : c'est le seul goulot
# nommable à ce stade (in_progress n'a qu'un séjour mesuré).
slowest = flow.slowest_column(summary)
assert slowest["column"] == "backlog"
events.append(_ev(20, "moved", frm="review", to="done", number=2))
events += [
_ev(0, "created", to="backlog", number=3),
_ev(2, "moved", frm="backlog", to="review", number=3),
_ev(19, "moved", frm="review", to="done", number=3),
]
dwell = flow.column_dwell(events)
summary = flow.summarize_dwell(dwell)
slowest = flow.slowest_column(summary)
assert slowest["column"] == "review"
assert slowest["median_days"] == 17.0
def test_last_activity():
events = [_ev(0, "created", to="backlog"), _ev(5, "moved", to="todo")]
latest = flow.last_activity(events)
assert latest[("Projets/demo", 1)] == datetime(2026, 9, 6)
# ── #9 Pages santé ────────────────────────────────────────────────
def _seed_stay(number: int, days_ago_start: int, days_ago_end: int,
frm="backlog", to="review"):
models.log_event("Projets/demo", number, "created", to_column="backlog")
models.log_event("Projets/demo", number, "moved",
from_column=frm, to_column=to)
now = datetime.now()
with models.get_db() as conn:
conn.execute(
"UPDATE board_events SET created_at = ? "
"WHERE issue_number = ? AND event_type = 'created'",
((now - timedelta(days=days_ago_start)).strftime("%Y-%m-%d %H:%M:%S"), number),
)
conn.execute(
"UPDATE board_events SET created_at = ? "
"WHERE issue_number = ? AND event_type = 'moved'",
((now - timedelta(days=days_ago_end)).strftime("%Y-%m-%d %H:%M:%S"), number),
)
def test_health_pages_and_bottleneck(client, fake_gitea):
_seed_stay(1, 40, 10) # 30 j en backlog
_seed_stay(5, 35, 5) # 30 j en backlog aussi
page = client.get("/health/Projets/demo")
assert page.status_code == 200
assert "backlog" in page.text.lower()
index = client.get("/health")
assert index.status_code == 200
assert "Projets/demo" in index.text
def test_health_zombies_threshold(client, fake_gitea, monkeypatch):
# Les issues du faux datent du 2026-09-02 : ~39 j d'inactivité,
# donc zombies au seuil par défaut de 30 j.
page = client.get("/health/Projets/demo")
assert "Tâche un" in page.text
assert 'data-zombie="close"' in page.text
monkeypatch.setenv("KANBANTE_ZOMBIE_DAYS", "10000")
page = client.get("/health/Projets/demo")
assert "Aucune carte zombie" in page.text
+76
View File
@@ -0,0 +1,76 @@
"""Palier 12 (0.15.0 → refonte 0.16.0): layout barre latérale (#16)
et wallboard sans défilement parasite."""
import app.main as main
def test_sidebar_on_app_pages(client, fake_gitea):
for path in ("/", "/focus", "/today", "/briefing", "/forecast",
"/rules", "/health", "/standup", "/capture"):
page = client.get(path)
assert page.status_code == 200, path
assert page.text.count('id="sidebar"') == 1, path
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.
for group in ("Principal", "Rituels", "Pilotage", "Outils"):
assert group in page.text, (path, group)
assert 'href="/health"' in page.text
assert 'id="side-collapse"' in page.text
def test_sidebar_active_states(client, fake_gitea):
home = client.get("/")
assert 'href="/" class="side-link active"' in home.text
forecast = client.get("/forecast")
assert 'href="/forecast" class="side-link active"' in forecast.text
today = client.get("/today")
assert 'href="/today" class="side-link active"' in today.text
def test_board_topbar_crumb_and_gitea_link(client, fake_gitea):
board = client.get("/board/Projets/demo")
assert board.status_code == 200
assert 'href="/board/Projets/demo"' in board.text # fil d'Ariane topbar
assert "Gitea ↗" in board.text
assert 'id="sidebar"' in board.text
def test_wall_has_no_sidebar(client, fake_gitea):
wall = client.get("/wall")
assert wall.status_code == 200
assert 'id="sidebar"' not in wall.text # plein écran TV assumé
assert "wall-foot" in wall.text
def test_nav_js_contract(client, fake_gitea):
js = client.get("/static/js/nav.js")
assert js.status_code == 200
assert "sidebar-collapsed" in js.text
assert "localStorage" in js.text
assert "side-backdrop" in js.text
assert "Escape" in js.text
def test_wall_more_cards_line(client, fake_gitea, monkeypatch):
async def fake_collect(only_repo):
return [{
"full_name": "Projets/demo",
"counts": {c: 0 for c in
("backlog", "todo", "in_progress", "qa", "review", "done")},
"open_total": 12,
"cards": [
{"number": i, "title": f"Carte {i}", "column": "in_progress",
"column_label": "🚧 En cours", "age_days": 2, "aging": False}
for i in range(1, 10)
],
"cards_total": 12,
"closed_today": 1,
}]
monkeypatch.setattr(main, "_collect_wall_data", fake_collect)
page = client.get("/wall")
assert page.status_code == 200
assert "+3 autres cartes en vol" in page.text
assert "📋 Ouvertes" in page.text