13 Commits
Author SHA1 Message Date
Spark 9e2096c9b6 chore(release): Kanbante 0.22.0 — login, sidebar, chantier B
CI / test (push) Successful in 2m1s
2026-10-14 08:02:49 -04:00
Spark e63fe39578 test: palier 18 — login/session, sidebar, hydratation des instantanés 2026-10-14 08:02:49 -04:00
Spark b7d21f80df feat(perf): chantier B — instantanés SQLite et rafraîchisseur d'arrière-plan
- Table snapshots + écriture traversante du cache client,
  hydratation au démarrage (SWR : servi + rafraîchi en fond).
- Boucle de rafraîchissement (120 s par défaut) : index, étoiles,
  issues all+open et jalons des projets actifs ; webhook =
  rafraîchissement immédiat du dépôt ; fraîcheur affichée.
2026-10-14 08:02:49 -04:00
Spark c7711c0ca2 feat(sidebar): replier en haut à droite de la recherche, accordéon exclusif
- Bouton replier : icône seule dans l'en-tête, gabarit du bouton
  recherche ; le pied garde version + fraîcheur + Sortir.
- Une seule section ouverte à la fois (kanbante-nav-open),
  amorce serveur selon la page courante, plus de kanbante-nav-closed.
2026-10-14 08:02:49 -04:00
Spark 319619d200 feat(auth): écran de connexion + session signée (le Basic reste pour les scripts)
- /login professionnel, cookie HMAC 7 jours, next local seulement,
  CSRF double-submit réutilisé, /logout réel + lien Sortir.
- Middleware : session OU Basic ; navigateurs redirigés vers
  /login, API en 401 JSON ; /static et /login publics.
2026-10-14 08:02:49 -04:00
Spark 0bdde28b79 chore(release): Kanbante 0.21.0 — sélecteur de dépôt Capture
CI / test (push) Successful in 1m54s
2026-10-12 15:47:28 -04:00
Spark 4b38acdcba test: palier 17 — /api/repos exhaustif et sélecteur branché sur /capture 2026-10-12 15:47:28 -04:00
Spark 760fbd8c6d feat(capture): sélecteur de dépôt intelligent — suggestions, Tab par segment, validation
- GET /api/repos exhaustif (épinglés, récents, archivés),
  la palette garde son plafond de 60.
- repo-picker.js : liste filtrée sur les deux segments,
  complétion Tab façon shell (pr Tab → Projets/ avec le
  séparateur posé, kan Tab → kanbante), ↑/↓/Entrée/clic,
  avertissement doux si le dépôt n'existe pas.
2026-10-12 15:47:28 -04:00
Spark f5d2d7d716 chore(release): Kanbante 0.20.1 — correctifs tri et regroupement
CI / test (push) Successful in 1m50s
2026-10-12 14:54:24 -04:00
Spark 71e705ea64 fix(dashboard): tri par date des tableaux, tri/regroupement sur les Épinglés
- tablesort.js : parseFloat lisait l'année des dates ISO (2026)
  et mettait toutes les lignes à égalité — comparaison numérique
  seulement pour les valeurs entièrement numériques.
- dashboard.js : pinned-grid rejoint les grilles triées et
  regroupées (son exclusion rendait les deux outils invisibles
  là où l'œil se pose) ; dates comparées via Date.parse.
- data-first-dir=desc sur Dernière activité / Écart / Zombies.
2026-10-12 14:54:24 -04:00
Spark 7918b54943 chore(release): Kanbante 0.20.0 — en-tête Projets professionnel
CI / test (push) Successful in 2m13s
2026-10-12 14:40:05 -04:00
Spark 3695bfac8a test: palier 16 — en-tête professionnel (KPI, barre unique, crochets préservés) 2026-10-12 14:40:05 -04:00
Spark 57d93c4f4b feat(ui): en-tête Projets professionnel — titre sobre, bande KPI unifiée, barre d'outils unique
- Titre « Projets » + sous-titre discret (fini le Bonjour 👋),
  en-tête deux fois plus compact.
- Les 4 statistiques deviennent une bande KPI bordée unique
  (séparateurs fins, chiffres tabulaires, KPI journal cliquables
  vers /activity).
- Onglets/filtre/tri/regroupement/vue réunis dans UNE barre :
  contrôle segmenté avec pastilles de compte, filtre rectangle.
  Crochets JS/Alpine inchangés ; .stat-tile conservée (briefing).
2026-10-12 14:40:05 -04:00
27 changed files with 1438 additions and 191 deletions
+120
View File
@@ -1,5 +1,125 @@
# Changelog
## 0.22.0 — 2026-10-14
### Écran de connexion (demande de Bruno)
- Fini la fenêtre Basic Auth du navigateur : page **/login**
professionnelle (carte centrée, marque dégradée, erreur
claire, retour à la page demandée après connexion).
- Session par **cookie signé HMAC** (7 jours, HttpOnly,
SameSite=Lax) ; clé `KANBANTE_SESSION_SECRET` ou dérivée des
identifiants — changer le mot de passe invalide les sessions.
- L'en-tête **Basic reste accepté** pour les scripts ; les API
reçoivent 401, seuls les navigateurs sont redirigés. Lien
**⏻ Sortir** dans le pied de la barre latérale, `/logout`
réel. Protection CSRF sur le formulaire, redirection `next`
limitée aux chemins locaux.
### Barre latérale (demandes de Bruno)
- Le bouton **Replier** quitte le bas de la barre : c'est une
**icône seule, en haut, à droite du bouton recherche**.
- **Accordéon exclusif** : ouvrir une section ferme les
autres — une seule ouverte à la fois (état mémorisé dans
`kanbante-nav-open` ; la section de la page courante prime).
### Chantier B — instantanés d'arrière-plan (validé par Bruno)
- Un **rafraîchisseur** recalcule périodiquement les listes
coûteuses (index des dépôts, étoiles, issues `all` **et**
`open`, jalons — pour chaque projet actif) et les persiste
dans SQLite (table `snapshots`, écriture à chaque réponse du
client). `KANBANTE_SNAPSHOT_INTERVAL` (défaut 120 s, 0 = off).
- Au démarrage, le cache du client est **hydraté** depuis les
instantanés (servis périmés + rafraîchis en tâche de fond,
mécanique SWR) : la première page après un redémarrage ou un
redéploiement ne paie plus Gitea.
- Un **webhook Gitea** rafraîchit immédiatement les
instantanés du dépôt touché. La fraîcheur est affichée dans
le pied de la barre (« 🕐 à l'instant / il y a N min ») :
les données Gitea peuvent dater d'un intervalle au plus,
le journal local reste instantané.
- **Mesuré sur l'instance réelle** (via le proxy de Spark,
latences gonflées) : premier chargement à froid 9,4 s sans
instantanés → après redémarrage avec instantanés : Projets
0,31 s, Focus 0,16 s, Triage 0,07 s, Santé 0,22 s,
Prévisions 0,08 s.
## 0.21.0 — 2026-10-12
### Sélecteur de dépôt intelligent (Capture éclair, demande de Bruno)
- Le champ **Dépôt** de /capture propose les dépôts réels dès
la frappe : liste filtrée en direct, correspondance sur les
deux segments — « kan » suffit pour trouver
`Projets/kanbante`, « pr/kan » aussi. Au focus, épinglés (⭐)
et dépôts récents d'abord.
- **Complétion Tab segment par segment**, comme un shell :
« pr » Tab → `Projets/` (le séparateur est posé dès que le
nom d'organisation est complet), « kan » Tab → `kanbante`.
↑/↓ + Entrée et la souris choisissent dans la liste.
- **Validation douce** : un nom qui n'existe pas dans Gitea est
signalé sous le champ avant l'envoi.
- Nouvel endpoint `GET /api/repos` : liste **exhaustive**
(~130 dépôts chez Bruno — la palette Ctrl+K reste plafonnée
à 60 pour son propre usage), triée épinglés → récents →
archivés. Sélecteur dans `static/js/repo-picker.js`,
réutilisable par d'autres formulaires.
- Vérifié en Playwright sur les données réelles, geste exact
de Bruno inclus : `pr` Tab → `Projets/`, `kan` Tab →
`Projets/kanbante`.
## 0.20.1 — 2026-10-12
### Correctifs tri/regroupement du dashboard (rapportés par Bruno)
- **Tri par date des tableaux réparé** : le comparateur partagé
lisait les dates ISO avec `parseFloat` — il ne retenait que
l'année (« 2026 »), toutes les lignes d'une même année se
retrouvaient à égalité et la colonne « Dernière activité » (vue
Liste) triait dans un ordre arbitraire. La comparaison n'est
numérique que si la valeur entière est un nombre ; les dates
se comparent en chaînes ISO. Même piège écarté pour l'échéance
des Prévisions.
- **Tri et regroupement appliqués aux Épinglés** : la grille
épinglée était exclue des deux outils (choix de la 0.18.0) —
comme c'est la section regardée en premier, changer le Tri ou
cocher « Grouper par propriétaire » semblait ne rien faire.
Les quatre grilles sont maintenant traitées pareil.
- **Sens de premier clic utile** : « Dernière activité »,
« Écart » (Prévisions) et « Zombies » (Santé) trient
décroissant dès le premier clic (`data-first-dir`).
- Tri grille par dates comparé en instants réels (fuseaux
Gitea -04:00/-05:00), cartes sans activité en bas.
- Vérifié en Playwright sur les données réelles : ordres
strictement décroissant/croissant, regroupement et
dégroupement des Épinglés, aucune erreur JS.
## 0.20.0 — 2026-10-12
### Refonte de l'en-tête de la page Projets (demande de Bruno)
- **Titre sobre** : fini le « Bonjour 👋 Voici vos projets
Gitea… » — un « Projets » net, un sous-titre discret, et
l'en-tête occupe deux fois moins de hauteur qu'avant.
- **Bande KPI unifiée** : les 4 statistiques (projets actifs,
fermées · 30 j, déplacements · 30 j, temps de cycle moyen)
vivent dans UNE barre bordée à séparateurs fins, chiffres en
largeur tabulaire ; les indicateurs du journal mènent à la
page Activité au clic.
- **Barre d'outils unique** : onglets, filtre, tri,
regroupement et bascule Grille/Liste — auparavant éparpillés
sur trois rangées — sont alignés dans un seul cadre. Les
onglets deviennent un contrôle segmenté avec pastilles de
compte (plus de dégradé violet ni d'emojis 🟢💤🗄️), le filtre
passe en rectangle sobre.
- Aucun crochet fonctionnel touché (ids et liaisons Alpine
inchangés) ; responsive vérifié en captures 1600 px et
430 px sur les données réelles : aucun débordement, onglets
fonctionnels. Les tuiles `.stat-tile` restent utilisées par
le briefing quotidien.
## 0.19.0 — 2026-10-12
### Performance + navigation « Pilotage » (chantiers A + C, décidés par Bruno)
+3 -1
View File
@@ -112,8 +112,10 @@ sudo systemctl enable --now docker
| `GITEA_URL` | `https://git.dracodev.net` | Instance Gitea |
| `GITEA_TOKEN` | *(vide)* | Jeton API Gitea — **portée conseillée : lecture + écriture des issues, sur un compte dédié, pas un jeton admin**. Sans jeton : contenu public seulement |
| `KANBANTE_DB_PATH` | `~/.kanbante/kanbante.db` | Base SQLite locale (en Docker : `/data/kanbante.db`) |
| `KANBANTE_AUTH_USER` / `KANBANTE_AUTH_PASSWORD` | *(vide)* | Active la Basic Auth dès que les deux sont définis |
| `KANBANTE_AUTH_USER` / `KANBANTE_AUTH_PASSWORD` | *(vide)* | Active l'authentification dès que les deux sont définis : écran de connexion + session signée pour les navigateurs ; l'en-tête Basic reste accepté pour les scripts |
| `KANBANTE_REQUIRE_AUTH` | `0` | `1` = l'app refuse de servir (503) si l'auth n'est pas configurée |
| `KANBANTE_SESSION_SECRET` | *(dérivé)* | Clé HMAC du cookie de session ; par défaut dérivée des identifiants (changer le mot de passe invalide les sessions) |
| `KANBANTE_SNAPSHOT_INTERVAL` | `120` | Secondes entre les rafraîchissements d'instantanés d'arrière-plan (chantier B) ; `0` = désactivé |
| `KANBANTE_ACTIVE_WINDOW_DAYS` | `365` | Un dépôt non archivé mis à jour depuis moins de N jours est « actif » |
| `KANBANTE_CACHE_TTL` | `30` | Secondes de cache des listes Gitea (dépôts, issues) |
| `KANBANTE_MAX_NOTE_CHARS` | `20000` | Taille maximale d'une note de projet |
+13 -3
View File
@@ -8,9 +8,15 @@ Environment variables:
GITEA_URL Base URL of the Gitea instance.
GITEA_TOKEN Gitea API token (read/write issues).
KANBANTE_DB_PATH Path of the local SQLite database.
KANBANTE_AUTH_USER Basic-auth username (with _PASSWORD: enables auth).
KANBANTE_AUTH_PASSWORD Basic-auth password.
KANBANTE_AUTH_USER Login username (with _PASSWORD: enables auth).
KANBANTE_AUTH_PASSWORD Login password.
KANBANTE_REQUIRE_AUTH "1" = refuse to serve if auth is not configured.
KANBANTE_SESSION_SECRET HMAC key for the login session cookie
(default: derived from the credentials —
rotating the password invalidates sessions).
KANBANTE_SNAPSHOT_INTERVAL Seconds between background snapshot
refreshes (chantier B, default 120;
0 disables the refresher).
KANBANTE_ACTIVE_WINDOW_DAYS A non-archived repo updated within this many
days counts as "active" (default 365).
KANBANTE_CACHE_TTL Seconds to cache Gitea list responses (default 30).
@@ -89,7 +95,7 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.19.0"
APP_VERSION = "0.22.0"
def _env_bool(name: str, default: bool) -> bool:
@@ -158,6 +164,8 @@ class Settings:
briefing_include_standup: bool = False
rules_raw: str = ""
zombie_days: int = 30
session_secret: str = ""
snapshot_interval: int = 120
@property
def auth_enabled(self) -> bool:
@@ -203,4 +211,6 @@ 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),
session_secret=os.getenv("KANBANTE_SESSION_SECRET", ""),
snapshot_interval=_env_int("KANBANTE_SNAPSHOT_INTERVAL", 120),
)
+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.19.0"},
"clientInfo": {"name": "kanbante", "version": "0.22.0"},
},
req_id=1,
)
+104 -13
View File
@@ -64,6 +64,10 @@ class GiteaClient:
self._cache: dict[str, tuple[float, Any]] = {}
self._refreshing: set[str] = set()
self._cache_gen = 0
# Snapshot bookkeeping (chantier B): cache key -> UTC time
# the payload was fetched from Gitea, for the freshness
# label and the persisted `snapshots` table.
self._snapshot_times: dict[str, str] = {}
async def aclose(self) -> None:
await self.client.aclose()
@@ -112,6 +116,90 @@ class GiteaClient:
def _cache_put(self, key: str, value: Any) -> None:
if self.cache_ttl > 0:
self._cache[key] = (time.monotonic(), value)
self._persist_snapshot(key, value)
# ── Snapshots (chantier B, 0.22.0) ──────────────────────
def _persist_snapshot(self, key: str, value: Any) -> None:
"""Write-through to the SQLite snapshots table.
Persistence must never break a read: any failure (DB
locked, unserializable payload) is logged and swallowed.
"""
try:
from app import models
models.save_snapshot(key, value)
import datetime
self._snapshot_times[key] = datetime.datetime.now(
datetime.timezone.utc
).strftime("%Y-%m-%d %H:%M:%S")
except Exception as exc:
logger.debug("snapshot persist failed for %s: %s", key, exc)
def hydrate_from_snapshots(self) -> int:
"""Preload the cache from persisted snapshots (startup).
Entries are inserted with an already-stale timestamp: the
SWR layer serves them instantly AND schedules a background
refresh, so the first page after a restart renders from
local data (tens of ms) while Gitea data catches up.
Returns the number of entries hydrated.
"""
if self.cache_ttl <= 0:
return 0
try:
from app import models
snapshots = models.load_snapshots()
except Exception as exc:
logger.warning("snapshot hydration failed: %s", exc)
return 0
stale_at = time.monotonic() - self.cache_ttl - 1.0
for key, (payload, updated_at) in snapshots.items():
self._cache[key] = (stale_at, payload)
self._snapshot_times[key] = updated_at
if snapshots:
logger.info("hydrated %d snapshot(s) from SQLite", len(snapshots))
return len(snapshots)
def latest_snapshot_at(self) -> str | None:
"""Freshest Gitea fetch time across tracked keys (UTC str)."""
if not self._snapshot_times:
return None
return max(self._snapshot_times.values())
async def _force(self, key: str, fetcher: Callable[[], Awaitable[Any]]) -> Any:
"""Fetch now, bypassing freshness (background refresher)."""
value = await fetcher()
self._cache_put(key, value)
return value
async def refresh_starred(self) -> list[dict]:
return await self._force(
"user:starred", lambda: self._get_all_pages("/user/starred")
)
async def refresh_repo_issues(
self, owner: str, repo: str, state: str = "all"
) -> list[dict]:
return await self._force(
f"issues:{owner}/{repo}:{state}",
lambda: self._get_all_pages(
f"/repos/{owner}/{repo}/issues", state=state
),
)
async def refresh_milestones(
self, owner: str, repo: str
) -> list[dict]:
return await self._force(
f"milestones:{owner}/{repo}:open",
lambda: self._get_all_pages(
f"/repos/{owner}/{repo}/milestones", state="open"
),
)
def _cache_clear(self) -> None:
self._cache.clear()
@@ -182,22 +270,25 @@ class GiteaClient:
self._user = await self._get("/user")
return self._user
async def _fetch_search_repos(self) -> list[dict]:
params: dict[str, Any] = {}
try:
user = await self.get_user()
if user and user.get("id"):
params["uid"] = user["id"]
except Exception as exc: # no/invalid token → public search
logger.debug(
"get_user failed, falling back to public search: %s", exc
)
return await self._get_all_pages("/repos/search", **params)
async def search_repos(self) -> list[dict]:
"""All repos visible to the token owner (paginated, SWR-cached)."""
return await self._cached_list("search_repos", self._fetch_search_repos)
async def _fetch() -> list[dict]:
params: dict[str, Any] = {}
try:
user = await self.get_user()
if user and user.get("id"):
params["uid"] = user["id"]
except Exception as exc: # no/invalid token → public search
logger.debug(
"get_user failed, falling back to public search: %s", exc
)
return await self._get_all_pages("/repos/search", **params)
return await self._cached_list("search_repos", _fetch)
async def refresh_search_repos(self) -> list[dict]:
"""Force-fetch the repo index (background refresher)."""
return await self._force("search_repos", self._fetch_search_repos)
async def get_repo(self, owner: str, repo: str) -> dict:
return await self._get(f"/repos/{owner}/{repo}")
+196 -28
View File
@@ -22,13 +22,21 @@ import json
import logging
import re
import secrets
import time
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import quote
import httpx
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
from fastapi.responses import (
HTMLResponse,
JSONResponse,
RedirectResponse,
Response,
StreamingResponse,
)
from fastapi.staticfiles import StaticFiles
from jinja2 import Environment, FileSystemLoader, select_autoescape
from starlette.middleware.base import BaseHTTPMiddleware
@@ -37,6 +45,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 snapshots as snapshots_service
from app import flow as flow_service
from app import forecast as forecast_service
from app import rules as rules_service
@@ -155,6 +164,13 @@ async def lifespan(app: FastAPI):
logger.warning(
"GITEA_TOKEN is not set: only public Gitea content will be visible."
)
# Chantier B: hydrate the client cache from persisted
# snapshots BEFORE serving, then keep them warm in the
# background — pages never wait on Gitea when data exists.
gitea.hydrate_from_snapshots()
snapshot_task = asyncio.create_task(
snapshots_service.snapshot_loop(gitea, classify_repos)
)
scheduler = asyncio.create_task(_briefing_scheduler())
digest_task = asyncio.create_task(_digest_scheduler())
rules_task = asyncio.create_task(_rules_scheduler())
@@ -179,6 +195,7 @@ async def lifespan(app: FastAPI):
settings.capture_ntfy_topic,
)
yield
snapshot_task.cancel()
scheduler.cancel()
digest_task.cancel()
rules_task.cancel()
@@ -192,20 +209,81 @@ app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static")
gitea = GiteaClient()
# ── Auth middleware ────────────────────────────────────────
# ── Auth: login form + signed session cookie (0.22.0) ──────
# Before 0.22.0 the browser got HTTP Basic's native prompt.
# Browsers now use a real login page; the session rides in an
# HMAC-signed cookie. An Authorization: Basic header is still
# honoured for scripts and API clients. /api/health stays
# public for Docker's HEALTHCHECK; /webhooks/gitea is
# authenticated by its HMAC signature (Gitea cannot log in) and
# /api/capture by its dedicated token.
SESSION_COOKIE = "kanbante_session"
SESSION_DAYS = 7
_PUBLIC_PATHS = ("/api/health", "/webhooks/gitea", "/api/capture", "/login")
def _session_secret() -> bytes:
settings = get_settings()
if settings.session_secret:
return settings.session_secret.encode("utf-8")
# Derived from the credentials: rotating the password
# invalidates every outstanding session (documented trade-off).
material = f"kanbante-session:{settings.auth_user}:{settings.auth_password}"
return hashlib.sha256(material.encode("utf-8")).digest()
def _make_session(user: str) -> str:
exp = int(time.time()) + SESSION_DAYS * 86400
payload = f"{user}.{exp}"
sig = hmac.new(
_session_secret(), payload.encode("utf-8"), hashlib.sha256
).hexdigest()
return f"{payload}.{sig}"
def _session_user(request: Request) -> str | None:
raw = request.cookies.get(SESSION_COOKIE, "")
user, _, rest = raw.partition(".")
exp, _, sig = rest.partition(".")
if not user or not exp or not sig:
return None
expected = hmac.new(
_session_secret(), f"{user}.{exp}".encode("utf-8"), hashlib.sha256
).hexdigest()
if not secrets.compare_digest(sig, expected):
return None
try:
if int(exp) < int(time.time()):
return None
except ValueError:
return None
if not secrets.compare_digest(user, get_settings().auth_user):
return None
return user
def _basic_ok(request: Request) -> bool:
settings = get_settings()
header = request.headers.get("authorization", "")
if not header.lower().startswith("basic "):
return False
try:
decoded = base64.b64decode(header[6:]).decode("utf-8")
except Exception:
return False
user, _, password = decoded.partition(":")
return secrets.compare_digest(
user, settings.auth_user
) and secrets.compare_digest(password, settings.auth_password)
class AuthMiddleware(BaseHTTPMiddleware):
"""HTTP Basic auth, enabled only when credentials are configured.
/api/health stays public so Docker's HEALTHCHECK (liveness) works.
When KANBANTE_REQUIRE_AUTH=1 and no credentials are configured, every
other route answers 503: failing closed beats failing open.
"""
"""Session cookie (browser) or Basic header (scripts)."""
async def dispatch(self, request: Request, call_next):
# /api/health: liveness for Docker. /webhooks/gitea: authenticated
# by its HMAC signature, not by Basic auth (Gitea cannot log in).
if request.url.path in ("/api/health", "/webhooks/gitea", "/api/capture"):
path = request.url.path
if path in _PUBLIC_PATHS or path.startswith("/static/"):
return await call_next(request)
settings = get_settings()
if not settings.auth_enabled:
@@ -220,30 +298,82 @@ class AuthMiddleware(BaseHTTPMiddleware):
status_code=503,
)
return await call_next(request)
ok = False
header = request.headers.get("authorization", "")
if header.lower().startswith("basic "):
try:
decoded = base64.b64decode(header[6:]).decode("utf-8")
except Exception:
decoded = ""
user, _, password = decoded.partition(":")
ok = secrets.compare_digest(
user, settings.auth_user
) and secrets.compare_digest(password, settings.auth_password)
if not ok:
return Response(
"Authentication required",
if _session_user(request) is not None or _basic_ok(request):
return await call_next(request)
# API consumers get a plain 401 (no browser prompt, no
# redirect they cannot follow); browsers get the login page.
if path.startswith("/api/") or "text/html" not in request.headers.get(
"accept", ""
):
return JSONResponse(
{"ok": False, "error": "Authentication required"},
status_code=401,
headers={"WWW-Authenticate": 'Basic realm="Kanbante"'},
)
return await call_next(request)
target = path
if request.url.query:
target = f"{path}?{request.url.query}"
return RedirectResponse(f"/login?next={quote(target, safe='')}", status_code=303)
app.add_middleware(AuthMiddleware)
def _safe_next(raw: str) -> str:
"""Only local paths may be a post-login destination."""
if raw.startswith("/") and not raw.startswith("//") and "://" not in raw:
return raw
return "/"
@app.get("/login")
async def login_page(request: Request, next: str = "/"):
if not get_settings().auth_enabled or _session_user(request) is not None:
return RedirectResponse(_safe_next(next), status_code=303)
return render(request, "login.html", next=_safe_next(next), error="")
@app.post("/login")
async def login_submit(
request: Request,
username: str = Form(""),
password: str = Form(""),
next: str = Form("/"),
csrf_token: str = Form(""),
):
settings = get_settings()
if not settings.auth_enabled:
return RedirectResponse("/", status_code=303)
if not _csrf_valid(request, csrf_token):
return render(
request, "login.html", status_code=403,
next=_safe_next(next), error="Session expirée — réessaie.",
)
ok = secrets.compare_digest(
username, settings.auth_user
) and secrets.compare_digest(password, settings.auth_password)
if not ok:
return render(
request, "login.html", status_code=401,
next=_safe_next(next), error="Identifiants incorrects.",
)
response = RedirectResponse(_safe_next(next), status_code=303)
response.set_cookie(
SESSION_COOKIE,
_make_session(username),
max_age=SESSION_DAYS * 86400,
httponly=True,
samesite="lax",
)
return response
@app.get("/logout")
async def logout():
response = RedirectResponse("/login", status_code=303)
response.delete_cookie(SESSION_COOKIE)
return response
# ── Live updates (SSE hub) ─────────────────────────────────
class LiveHub:
@@ -330,6 +460,8 @@ def render(
context.setdefault("csrf_token", token)
context.setdefault("version", APP_VERSION)
context.setdefault("active_nav", _NAV_BY_TEMPLATE.get(name, ""))
context.setdefault("auth_enabled", get_settings().auth_enabled)
context.setdefault("data_freshness", snapshots_service.freshness_label())
html = _tpl_env.get_template(name).render(**context)
response = HTMLResponse(html, status_code=status_code)
if generated:
@@ -2303,6 +2435,13 @@ async def gitea_webhook(request: Request):
handled = "pr-merged:" + (",".join(results) if results else "none")
gitea.invalidate_cache()
# Chantier B: a webhook means this repo just changed — refresh
# its snapshots right away instead of waiting for the interval.
if full_name and "/" in full_name:
_owner, _, _repo = full_name.partition("/")
asyncio.create_task(
snapshots_service.refresh_repo_snapshots(gitea, _owner, _repo)
)
await hub.broadcast(
{"repo": full_name, "event": event, "action": action, "issue": number}
)
@@ -3513,6 +3652,35 @@ async def api_stats():
return get_stats()
@app.get("/api/repos")
async def api_repos():
"""Every repo, for pickers (capture page repo selector, 0.21.0).
Unlike /api/palette (capped at 60 for a launcher), a picker
must be exhaustive — Bruno has 130+ repos. Ranked for the
empty-query case: starred first, archived last, most
recently updated first in between.
"""
repos = await gitea.search_repos()
active, inactive, archived = classify_repos(repos)
try:
starred = {r["full_name"] for r in await gitea.list_starred()}
except Exception:
starred = set()
out = [
{
"full_name": r["full_name"],
"starred": r["full_name"] in starred,
"updated_at": r.get("updated_at") or "",
"archived": bool(r.get("archived")),
}
for r in active + inactive + archived
]
out.sort(key=lambda x: x["updated_at"], reverse=True)
out.sort(key=lambda x: (not x["starred"], x["archived"]))
return {"repos": out}
@app.get("/api/palette")
async def api_palette(q: str = ""):
"""Data for the Ctrl+K command palette (roadmap #10).
+49
View File
@@ -11,6 +11,7 @@ and upgrades older databases in place (position column, notes
updated_at, cleanup of rows carrying an unknown column name).
"""
import json
import os
import sqlite3
from contextlib import contextmanager
@@ -91,6 +92,12 @@ def init_db() -> None:
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS snapshots (
key TEXT PRIMARY KEY,
payload TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_events_repo
ON board_events(repo_full_name, issue_number, created_at);
"""
@@ -292,6 +299,48 @@ def set_kv(key: str, value: str) -> None:
)
# ── Snapshots (chantier B, 0.22.0) ─────────────────────────
# Persisted copies of the Gitea list responses the pages read
# (repos, issues, milestones, starred). The client cache is
# hydrated from these rows at startup, so even the first page
# after a restart renders from local data while the background
# refresher re-fetches from Gitea.
def save_snapshot(key: str, payload) -> None:
with get_db() as conn:
conn.execute(
"INSERT INTO snapshots (key, payload, updated_at) "
"VALUES (?, ?, datetime('now')) "
"ON CONFLICT(key) DO UPDATE SET payload = excluded.payload, "
"updated_at = excluded.updated_at",
(key, json.dumps(payload)),
)
def load_snapshots() -> dict:
"""All snapshots as {key: (payload, updated_at_utc_str)}."""
with get_db() as conn:
rows = conn.execute(
"SELECT key, payload, updated_at FROM snapshots"
).fetchall()
out = {}
for row in rows:
try:
out[row["key"]] = (json.loads(row["payload"]), row["updated_at"])
except (ValueError, TypeError):
continue
return out
def latest_snapshot_at() -> str | None:
"""UTC timestamp (SQLite format) of the freshest snapshot."""
with get_db() as conn:
row = conn.execute(
"SELECT MAX(updated_at) AS latest FROM snapshots"
).fetchone()
return row["latest"] if row else None
# ── Event journal ──────────────────────────────────────────
EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented", "rule")
+116
View File
@@ -0,0 +1,116 @@
"""Background snapshot refresher — chantier B (0.22.0).
The expensive part of Kanbante is the fan-out of Gitea list
calls behind the portfolio pages (repo index, then issues and
milestones for every active project). Since 0.19.0 those calls
are parallel and stale-while-revalidate, which fixed repeat
loads, but the FIRST page after a restart still paid seconds.
The refresher pays that cost instead of the user: every
KANBANTE_SNAPSHOT_INTERVAL seconds it force-refreshes the hot
keys (repo index, starred, issues + milestones of active
repos). Every fetch write-throughs to the SQLite `snapshots`
table, and the client cache is hydrated from that table at
startup — so pages read local data from the very first request
and only ever wait on Gitea when no snapshot exists yet.
Freshness contract, shown in the UI: Gitea-side data may lag
by up to one interval; a Gitea webhook refreshes the touched
repo immediately (see refresh_repo_snapshots); anything the
user does locally (board journal) is always instant.
"""
import asyncio
import datetime
import logging
from app import models
from app.config import get_settings
logger = logging.getLogger("kanbante.snapshots")
_REFRESH_CONCURRENCY = 8
async def refresh_repo_snapshots(gitea, owner: str, repo: str) -> None:
"""Refresh one repo's hot keys (webhooks call this).
Both issue states are refreshed: most pages read "all", but
Focus/Triage/Mes issues read "open" — a snapshot set missing
either leaves a slow first hit on the table.
"""
try:
await gitea.refresh_repo_issues(owner, repo, "all")
await gitea.refresh_repo_issues(owner, repo, "open")
await gitea.refresh_milestones(owner, repo)
except Exception as exc:
logger.debug("repo snapshot refresh failed for %s/%s: %s", owner, repo, exc)
async def refresh_snapshots(gitea, classify_repos) -> int:
"""One full refresh cycle. Returns the active-repo count."""
repos = await gitea.refresh_search_repos()
try:
await gitea.refresh_starred()
except Exception as exc:
logger.debug("starred refresh failed: %s", exc)
active, _inactive, _archived = classify_repos(repos)
semaphore = asyncio.Semaphore(_REFRESH_CONCURRENCY)
async def _one(repo: dict) -> None:
owner, _, name = repo["full_name"].partition("/")
async with semaphore:
await refresh_repo_snapshots(gitea, owner, name)
await asyncio.gather(*(_one(repo) for repo in active))
logger.info("snapshot refresh complete (%d active repos)", len(active))
return len(active)
async def snapshot_loop(gitea, classify_repos) -> None:
"""Periodic refresher, started by the app lifespan.
Sleeps briefly first: hydration already serves the pages,
the loop only has to keep the snapshots warm.
"""
interval = get_settings().snapshot_interval
if interval <= 0:
logger.info("snapshot refresher disabled (interval <= 0)")
return
await asyncio.sleep(10)
while True:
try:
await refresh_snapshots(gitea, classify_repos)
except Exception as exc:
logger.warning("snapshot refresh cycle failed: %s", exc)
await asyncio.sleep(interval)
def freshness_label() -> str | None:
"""« à l'instant » / « il y a N min » from the freshest snapshot.
Honest freshness display (chantier B contract): the sidebar
shows how old the Gitea data on screen can be.
"""
try:
latest = models.latest_snapshot_at()
except Exception:
return None
if not latest:
return None
try:
fetched = datetime.datetime.strptime(
latest, "%Y-%m-%d %H:%M:%S"
).replace(tzinfo=datetime.timezone.utc)
except ValueError:
return None
age = (
datetime.datetime.now(datetime.timezone.utc) - fetched
).total_seconds()
if age < 90:
return "à l'instant"
if age < 3600:
return f"il y a {int(age // 60)} min"
if age < 86400:
return f"il y a {int(age // 3600)} h"
return f"il y a {int(age // 86400)} j"
+38 -16
View File
@@ -6,18 +6,6 @@
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');
}
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", [
("home", "/", "🏠", "Projets"),
@@ -42,6 +30,37 @@
("wall", "/wall", "🖥️", "Wallboard"),
]),
] %}
{% set active_group = namespace(key="principal") %}
{% for gkey, group, links in nav_groups %}
{% for key, href, icon, label in links %}
{% if active_nav == key %}{% set active_group.key = gkey %}{% endif %}
{% endfor %}
{% endfor %}
<script>
document.body.classList.add('with-sidebar');
try {
if (localStorage.getItem('kanbante-sidebar') === 'collapsed') {
document.body.classList.add('sidebar-collapsed');
}
/* Accordéon exclusif (0.22.0) : une seule section ouverte.
La section de la page courante prime, sinon celle
mémorisée, sinon « principal ». */
localStorage.removeItem('kanbante-nav-closed');
var openGroup = {{ active_group.key | tojson }};
var stored = localStorage.getItem('kanbante-nav-open');
var groups = ['principal', 'rituels', 'pilotage', 'outils'];
if ({{ (active_nav != "") | tojson }}) {
localStorage.setItem('kanbante-nav-open', openGroup);
} else if (stored === '') {
openGroup = '';
} else if (stored !== null && groups.indexOf(stored) >= 0) {
openGroup = stored;
}
groups.forEach(function (k) {
if (k !== openGroup) document.body.classList.add('nav-closed-' + k);
});
} catch (e) { /* stockage indisponible : état par défaut */ }
</script>
<aside class="sidebar" id="sidebar" aria-label="Navigation principale">
<div class="side-head">
<a href="/" class="side-logo" aria-label="Kanbante — accueil">
@@ -49,6 +68,7 @@
<span class="side-label gradient-text">Kanbante</span>
</a>
<button type="button" class="side-search palette-trigger" title="Recherche & commandes (Ctrl+K)" aria-label="Recherche et commandes (Ctrl+K)">🔍</button>
<button type="button" class="side-collapse" id="side-collapse" title="Replier ou déplier le menu" aria-label="Replier ou déplier le menu"><span class="side-collapse-ico">«</span></button>
</div>
<nav class="side-nav">
{% for gkey, group, links in nav_groups %}
@@ -71,11 +91,13 @@
{% 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>
{% if data_freshness %}
<span class="side-fresh side-label" title="Dernière actualisation des données Gitea (instantanés d'arrière-plan)">🕐 {{ data_freshness }}</span>
{% endif %}
<span class="side-version side-label">v{{ version }}</span>
{% if auth_enabled %}
<a href="/logout" class="side-logout" title="Se déconnecter">⏻<span class="side-label"> Sortir</span></a>
{% endif %}
</div>
</aside>
<div class="side-backdrop" id="side-backdrop" hidden></div>
+7 -1
View File
@@ -37,7 +37,12 @@
<form method="post" action="/capture" class="capture-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Dépôt
<input type="text" name="repo_full_name" value="{{ capture_repo }}" placeholder="Projets/kanbante" required>
<span class="repo-picker">
<input type="text" name="repo_full_name" id="capture-repo" value="{{ capture_repo }}" placeholder="Projets/kanbante" required autocomplete="off" role="combobox" aria-expanded="false" aria-controls="repo-listbox" aria-autocomplete="list">
<span class="repo-list" id="repo-listbox" role="listbox" hidden></span>
</span>
<span class="repo-hint" id="repo-hint" hidden>⚠️ Aucun dépôt de ce nom dans Gitea — vérifie l'orthographe, la capture échouera sinon.</span>
<span class="repo-tip">Astuce : tape un début, <kbd>Tab</kbd> complète segment par segment (<code>pr</code> → <code>Projets/</code> → <code>kan</code> → <code>kanbante</code>), ↑↓ + Entrée choisissent dans la liste.</span>
</label>
<label>Titre de l'issue
<input type="text" name="title" maxlength="255" placeholder="Ce qu'il faut retenir…" required autofocus>
@@ -84,6 +89,7 @@
<span>Kanbante v{{ version }} · capture éclair : formulaire web, API à jeton dédié, écoute ntfy</span>
</footer>
<script src="/static/js/live.js" defer></script>
<script src="/static/js/repo-picker.js" defer></script>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
+33 -37
View File
@@ -11,16 +11,21 @@
<body>
{% include "_header.html" %}
<section class="hero">
<div class="hero-text">
<h2>Bonjour 👋 Voici vos projets Gitea, en un coup d'œil.</h2>
<p>Glissez les cartes, suivez ce qui avance, et laissez Gitea rester la source de vérité.</p>
</div>
<div class="stat-tiles">
<div class="stat-tile"><span class="stat-num">{{ active_repos|length }}</span><span class="stat-label">projets actifs</span></div>
<div class="stat-tile"><span class="stat-num">{{ metrics.closed }}</span><span class="stat-label">issues fermées · 30 j</span></div>
<div class="stat-tile"><span class="stat-num">{{ metrics.moved }}</span><span class="stat-label">déplacements · 30 j</span></div>
<div class="stat-tile"><span class="stat-num">{% if metrics.avg_cycle_days is not none %}{{ metrics.avg_cycle_days }} j{% else %}—{% endif %}</span><span class="stat-label">temps de cycle moyen</span></div>
<!-- En-tête de page (refonte 0.20.0) : titre sobre, bande KPI
unifiée, puis UNE barre d'outils (dans <main>) qui regroupe
onglets, filtre, tri et vue — fini les rangées éparses. -->
<section class="page-head">
<div class="page-head-row">
<div class="page-title">
<h1>Projets</h1>
<p class="page-sub">Vue d'ensemble de vos dépôts Gitea — Gitea reste la source de vérité.</p>
</div>
<div class="kpi-strip" aria-label="Indicateurs des 30 derniers jours">
<div class="kpi"><span class="kpi-value">{{ active_repos|length }}</span><span class="kpi-label">Projets actifs</span></div>
<a class="kpi" href="/activity"><span class="kpi-value">{{ metrics.closed }}</span><span class="kpi-label">Issues fermées · 30 j</span></a>
<a class="kpi" href="/activity"><span class="kpi-value">{{ metrics.moved }}</span><span class="kpi-label">Déplacements · 30 j</span></a>
<div class="kpi"><span class="kpi-value">{% if metrics.avg_cycle_days is not none %}{{ metrics.avg_cycle_days }}<span class="kpi-unit"> j</span>{% else %}—{% endif %}</span><span class="kpi-label">Temps de cycle moyen</span></div>
</div>
</div>
</section>
@@ -40,31 +45,22 @@
}
</script>
<main x-data="dashState()">
<!-- Tabs: "actif" = mis à jour dans la fenêtre glissante;
"archivé" = le vrai drapeau Gitea (repo.archived). -->
<div class="tabs">
<button :class="{ active: tab === 'active' }" @click="tab = 'active'">
🟢 Actifs ({{ active_repos|length }})
</button>
<button :class="{ active: tab === 'inactive' }" @click="tab = 'inactive'">
💤 Inactifs ({{ inactive_repos|length }})
</button>
<button :class="{ active: tab === 'archived' }" @click="tab = 'archived'">
🗄️ Archivés ({{ archived_repos|length }})
</button>
</div>
<!-- Search: the repo name lives in a data-* attribute (HTML-escaped
by Jinja), never interpolated into a JS string literal. -->
<div class="search-bar">
<input type="text" id="project-search" placeholder="🔍 Filtrer les projets..." x-model="search" autocomplete="off">
<kbd class="kbd">/</kbd>
</div>
<!-- Outils d'affichage (#21 tri/regroupement, #22 vue liste) :
pilotés par dashboard.js, préférences en localStorage. -->
<div class="dash-tools">
<label class="dash-tool">↕️ Tri
<!-- Barre d'outils unique (0.20.0) : onglets segmentés,
filtre, tri/regroupement (#21) et vue (#22) alignés.
Onglets : "actif" = fenêtre glissante ; "archivé" =
le vrai drapeau Gitea (repo.archived). Le filtre lit
des data-* échappés par Jinja, jamais des littéraux JS. -->
<div class="dash-toolbar">
<div class="seg-tabs" role="tablist" aria-label="État des projets">
<button type="button" role="tab" :class="{ active: tab === 'active' }" @click="tab = 'active'">Actifs <span class="seg-count">{{ active_repos|length }}</span></button>
<button type="button" role="tab" :class="{ active: tab === 'inactive' }" @click="tab = 'inactive'">Inactifs <span class="seg-count">{{ inactive_repos|length }}</span></button>
<button type="button" role="tab" :class="{ active: tab === 'archived' }" @click="tab = 'archived'">Archivés <span class="seg-count">{{ archived_repos|length }}</span></button>
</div>
<div class="search-bar toolbar-search">
<input type="text" id="project-search" placeholder="Filtrer les projets..." x-model="search" autocomplete="off">
<kbd class="kbd">/</kbd>
</div>
<label class="dash-tool">Tri
<select id="dash-sort">
<option value="default">Par défaut</option>
<option value="updated">Activité Gitea récente</option>
@@ -74,7 +70,7 @@
</select>
</label>
<label class="dash-tool dash-tool-check">
<input type="checkbox" id="dash-group"> 🗂️ Grouper par propriétaire
<input type="checkbox" id="dash-group"> Grouper par propriétaire
</label>
<div class="dash-view-toggle" role="group" aria-label="Mode d'affichage">
<button type="button" :class="{ active: view === 'grid' }" @click="setView('grid')">▦ Grille</button>
@@ -201,7 +197,7 @@
<th data-sort="inflight">En vol</th>
<th>Prochain jalon</th>
<th>Santé</th>
<th data-sort="updated">Dernière activité</th>
<th data-sort="updated" data-first-dir="desc">Dernière activité</th>
</tr>
</thead>
<tbody>
+1 -1
View File
@@ -33,7 +33,7 @@
<th data-sort="due">Échéance</th>
<th>Progression</th>
<th>ETA</th>
<th data-sort="gap">Écart</th>
<th data-sort="gap" data-first-dir="desc">Écart</th>
<th data-sort="throughput">Débit / sem.</th>
</tr>
</thead>
+1 -1
View File
@@ -23,7 +23,7 @@
<th data-sort="severity">Santé</th>
<th data-sort="project">Projet</th>
<th data-sort="open">Ouvertes</th>
<th data-sort="zombies">🧟 Zombies</th>
<th data-sort="zombies" data-first-dir="desc">🧟 Zombies</th>
<th data-sort="slowest">🐌 Goulot</th>
<th></th>
</tr>
+36
View File
@@ -0,0 +1,36 @@
<!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>Connexion — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body class="login-body">
<main class="login-wrap">
<section class="login-card">
<div class="login-brand">
<span class="login-mark" aria-hidden="true">📋</span>
<h1>Kanbante</h1>
<p class="login-sub">Pilotage de projets, adossé à votre Gitea</p>
</div>
{% if error %}
<p class="login-error" role="alert">⚠️ {{ error }}</p>
{% endif %}
<form method="post" action="/login" class="login-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="next" value="{{ next }}">
<label>Nom d'utilisateur
<input type="text" name="username" autocomplete="username" required autofocus>
</label>
<label>Mot de passe
<input type="password" name="password" autocomplete="current-password" required>
</label>
<button type="submit" class="login-submit">Se connecter</button>
</form>
<p class="login-foot">Accès restreint — session de {{ 7 }} jours, déconnexion depuis le menu latéral.</p>
</section>
</main>
</body>
</html>
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "kanbante"
version = "0.19.0"
version = "0.22.0"
description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11"
dependencies = [
+183 -50
View File
@@ -150,17 +150,44 @@ header h1 { font-size: 1.2rem; font-weight: 600; }
.btn-sm:hover { background: var(--surface-3); border-color: var(--border-strong); text-decoration: none; }
.btn-sm:active { transform: scale(0.97); }
/* ── Hero (dashboard) ───────────────────────── */
.hero {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 24px;
flex-wrap: wrap;
padding: 30px 24px 6px;
/* ── En-tête de page Projets (0.20.0) ─────────
Titre sobre + bande KPI unifiée ; les contrôles vivent dans
.dash-toolbar (plus bas). Les .stat-tile restent utilisés par
le briefing quotidien. */
.page-head { padding: 22px 24px 0; }
.page-head-row {
display: flex; justify-content: space-between; align-items: flex-end;
gap: 20px; flex-wrap: wrap;
}
.page-title h1 {
font-size: 1.55rem; font-weight: 700; letter-spacing: -0.02em;
line-height: 1.15; margin: 0;
}
.page-sub { color: var(--text-2); font-size: 0.92rem; margin-top: 5px; }
.kpi-strip {
display: flex; align-items: stretch;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.kpi {
display: flex; flex-direction: column; gap: 1px;
padding: 9px 18px 10px;
color: var(--text);
}
.kpi + .kpi { border-left: 1px solid var(--border); }
a.kpi { transition: background 0.15s; }
a.kpi:hover { background: var(--surface-2); text-decoration: none; }
.kpi-value {
font-size: 1.22rem; font-weight: 700; letter-spacing: -0.01em;
font-variant-numeric: tabular-nums; line-height: 1.2;
}
.kpi-unit { font-size: 0.85rem; font-weight: 600; color: var(--text-2); }
.kpi-label {
font-size: 0.68rem; color: var(--text-2);
text-transform: uppercase; letter-spacing: 0.55px; white-space: nowrap;
}
.hero-text h2 { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.01em; margin-bottom: 6px; }
.hero-text p { color: var(--text-2); font-size: 0.95rem; max-width: 520px; }
.stat-tiles { display: flex; gap: 10px; flex-wrap: wrap; }
.stat-tile {
background: linear-gradient(180deg, var(--surface-2), var(--surface));
@@ -175,38 +202,13 @@ header h1 { font-size: 1.2rem; font-weight: 600; }
.stat-num { font-size: 1.35rem; font-weight: 700; letter-spacing: -0.01em; }
.stat-label { font-size: 0.72rem; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.4px; }
/* ── Tabs + search (dashboard) ──────────────── */
.tabs {
display: flex;
gap: 6px;
padding: 18px 24px 0;
flex-wrap: wrap;
}
.tabs button {
padding: 8px 16px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
color: var(--text-2);
cursor: pointer;
font-size: 0.88rem;
font-weight: 500;
transition: all 0.15s;
}
.tabs button.active {
color: #fff;
background: linear-gradient(135deg, rgba(124,92,252,.9), rgba(91,138,247,.9));
border-color: transparent;
}
.tabs button:hover { color: var(--text); }
.tabs button.active:hover { color: #fff; }
/* ── Recherche (dashboard + filtre prévisions) ── */
.search-bar { padding: 14px 24px 2px; display: flex; align-items: center; gap: 10px; }
.search-bar input {
width: 100%;
max-width: 430px;
padding: 11px 16px;
border-radius: 999px;
padding: 9px 13px;
border-radius: 9px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
@@ -869,13 +871,18 @@ footer {
/* ── Responsive ─────────────────────────────── */
@media (max-width: 900px) {
.hero { flex-direction: column; align-items: flex-start; }
.page-head-row { flex-direction: column; align-items: flex-start; }
.kpi-strip { width: 100%; }
.kpi { flex: 1 1 0; padding: 9px 12px 10px; }
.kpi-label { white-space: normal; }
.kanban-column { max-height: none; }
}
@media (max-width: 768px) {
.app-header, header { padding: 10px 14px; }
.crumb { display: none; }
.hero, .tabs, .search-bar, .project-grid, .board-toolbar, .milestone-bar { padding-left: 14px; padding-right: 14px; }
.page-head, .project-grid, .board-toolbar, .milestone-bar { padding-left: 14px; padding-right: 14px; }
.dash-toolbar { margin-left: 14px; margin-right: 14px; }
.search-bar { padding-left: 14px; padding-right: 14px; }
.milestone-banner { margin-left: 14px; margin-right: 14px; }
.board-container { flex-direction: column; overflow: visible; }
.notes-panel { width: 100%; max-height: 240px; border-left: none; border-top: 1px solid var(--border); }
@@ -1290,11 +1297,22 @@ body.nav-closed-outils [data-group="outils"] .side-chevron { transform: rotate(-
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;
/* Depuis la 0.22.0 : bouton icône seule dans l'en-tête de la
barre, à droite de la recherche (même gabarit qu'elle). */
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-collapse:hover { color: var(--text); }
.side-collapse:hover {
color: var(--text); border-color: var(--accent, #58a6ff);
transform: translateY(-1px);
}
.side-fresh { color: var(--text-3); font-size: 0.76rem; white-space: nowrap; }
.side-logout { color: var(--text-3); font-size: 0.85rem; white-space: nowrap; }
.side-logout:hover { color: var(--text); text-decoration: none; }
.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; }
@@ -1374,20 +1392,51 @@ body.sidebar-collapsed .side-foot { justify-content: center; }
/* ── Dashboard vague 2 suite (0.18.0, #19 #21 #22 #23) ── */
.activity-line { margin-top: 8px; font-size: 0.82rem; color: var(--text-2); }
.dash-tools {
display: flex; align-items: center; gap: 18px; flex-wrap: wrap;
padding: 0 24px; margin: 4px 0 14px;
/* Barre d'outils unique du dashboard (0.20.0) : onglets
segmentés, filtre, tri, regroupement et vue dans UN cadre. */
.dash-toolbar {
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
margin: 16px 24px 18px; padding: 8px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
}
.dash-tool { display: inline-flex; align-items: center; gap: 8px; font-size: 0.88rem; color: var(--text-2); }
.seg-tabs {
display: inline-flex; gap: 2px; padding: 3px;
background: var(--surface-2); border-radius: 9px;
}
.seg-tabs button {
display: inline-flex; align-items: center; gap: 7px;
padding: 6px 13px; border: none; border-radius: 7px;
background: transparent; color: var(--text-2);
cursor: pointer; font-size: 0.88rem; font-weight: 500;
transition: background 0.15s, color 0.15s;
min-height: 32px;
}
.seg-tabs button:hover { color: var(--text); }
.seg-tabs button.active {
background: var(--surface); color: var(--text); font-weight: 600;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
}
.seg-count {
font-size: 0.74rem; font-weight: 600; font-variant-numeric: tabular-nums;
background: rgba(255, 255, 255, 0.08); color: var(--text-2);
border-radius: 999px; padding: 1px 7px;
}
.seg-tabs button.active .seg-count { background: rgba(124, 92, 252, 0.22); color: var(--text); }
.dash-toolbar .toolbar-search { padding: 0; flex: 1 1 200px; }
.dash-toolbar .toolbar-search input { max-width: none; background: var(--surface-2); }
.dash-tool { display: inline-flex; align-items: center; gap: 8px; font-size: 0.88rem; color: var(--text-2); white-space: nowrap; }
.dash-tool select {
background: var(--surface-2); color: var(--text);
border: 1px solid var(--border); border-radius: 8px; padding: 6px 8px;
min-height: 36px;
}
.dash-tool-check input { width: 17px; height: 17px; accent-color: #7c5cfc; }
.dash-view-toggle { margin-left: auto; display: inline-flex; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
.dash-view-toggle { margin-left: auto; display: inline-flex; border: 1px solid var(--border); border-radius: 9px; overflow: hidden; }
.dash-view-toggle button {
background: var(--surface-1); color: var(--text-2); border: none;
padding: 7px 14px; cursor: pointer; font-size: 0.88rem;
padding: 7px 14px; cursor: pointer; font-size: 0.88rem; min-height: 36px;
}
.dash-view-toggle button.active { background: var(--surface-3, #1d2942); color: var(--text); font-weight: 700; }
.owner-group { grid-column: 1 / -1; }
@@ -1475,3 +1524,87 @@ a.ms-title:hover { text-decoration: underline; }
border: 1px solid var(--border, #333);
}
.health-back { font-size: 13.5px; }
/* ── Sélecteur de dépôt Capture (0.21.0) ────── */
.repo-picker { position: relative; display: block; }
.repo-list {
position: absolute; left: 0; right: 0; top: calc(100% + 6px); z-index: 80;
display: flex; flex-direction: column;
background: var(--surface-1, #141a26);
border: 1px solid var(--border);
border-radius: 12px; padding: 5px;
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.5);
max-height: 264px; overflow-y: auto;
}
.repo-list[hidden] { display: none !important; }
.repo-item {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 8px 10px; border-radius: 8px; cursor: pointer;
font-weight: 400; font-size: 0.94rem;
}
.repo-item.active { background: var(--surface-2, #1c2330); }
.repo-item-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.repo-item-meta { color: var(--text-3, #8b94a5); font-size: 0.78rem; white-space: nowrap; }
.repo-hint { font-weight: 400; font-size: 0.85rem; color: #f0a35e; }
.repo-tip { font-weight: 400; font-size: 0.82rem; color: var(--text-3, #8b94a5); }
.repo-tip kbd {
font-size: 0.72rem; border: 1px solid var(--border); border-bottom-width: 2px;
border-radius: 5px; padding: 0 5px; background: var(--surface);
}
.capture-form input.repo-unknown { border-color: #f0a35e; }
/* ── Écran de connexion (0.22.0) ────────────── */
.login-body {
min-height: 100dvh; margin: 0;
display: flex; align-items: center; justify-content: center;
background:
radial-gradient(640px 420px at 18% 12%, rgba(124, 92, 252, 0.20), transparent 62%),
radial-gradient(560px 380px at 84% 88%, rgba(91, 138, 247, 0.16), transparent 60%),
var(--bg, #0a0f16);
padding: 24px 16px;
}
.login-wrap { width: 100%; max-width: 400px; }
.login-card {
background: var(--surface, #10151f);
border: 1px solid var(--border);
border-radius: 18px;
padding: 34px 32px 26px;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5);
}
.login-brand { text-align: center; margin-bottom: 22px; }
.login-mark {
display: inline-flex; align-items: center; justify-content: center;
width: 56px; height: 56px; border-radius: 16px; font-size: 1.7rem;
background: linear-gradient(135deg, rgba(124,92,252,.35), rgba(91,138,247,.25));
border: 1px solid var(--border);
}
.login-brand h1 {
margin: 12px 0 4px; font-size: 1.55rem; font-weight: 700;
background: linear-gradient(120deg, #a78bfa, #7dd3fc);
-webkit-background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent;
}
.login-sub { margin: 0; color: var(--text-3, #8b94a5); font-size: 0.9rem; }
.login-error {
margin: 0 0 14px; padding: 10px 12px; border-radius: 10px;
background: rgba(240, 120, 90, 0.12); border: 1px solid rgba(240, 120, 90, 0.45);
color: #f5a58c; font-size: 0.9rem;
}
.login-form { display: flex; flex-direction: column; gap: 14px; }
.login-form label { display: flex; flex-direction: column; gap: 6px; font-weight: 600; font-size: 0.92rem; }
.login-form input {
padding: 11px 13px; border: 1px solid var(--border); border-radius: 10px;
background: var(--bg-2, #0d1220); color: var(--text-1, #e6ebf2);
font: inherit; font-weight: 400;
}
.login-form input:focus { outline: 2px solid rgba(124, 92, 252, 0.55); outline-offset: 0; border-color: transparent; }
.login-submit {
margin-top: 4px; padding: 12px 18px; cursor: pointer;
background: linear-gradient(135deg, var(--accent, #7c5cfc), var(--accent-2, #5b8af7));
color: #fff; border: none; border-radius: 10px;
font: inherit; font-weight: 600; font-size: 1rem;
transition: filter 0.15s, transform 0.1s;
}
.login-submit:hover { filter: brightness(1.12); }
.login-submit:active { transform: scale(0.98); }
.login-foot { margin: 18px 0 0; text-align: center; color: var(--text-3, #8b94a5); font-size: 0.8rem; }
+15 -1
View File
@@ -21,7 +21,11 @@
/* ── Tri des grilles de cartes ─────────────────────────── */
var sortSelect = document.getElementById('dash-sort');
var grids = ['grid-active', 'grid-inactive', 'grid-archived']
// 0.20.1 : la grille des Épinglés est incluse — l'exclure
// (choix de la 0.18.0) donnait l'impression que le tri et le
// regroupement « ne faisaient rien », la section épinglée
// étant celle qu'on regarde en premier.
var grids = ['pinned-grid', 'grid-active', 'grid-inactive', 'grid-archived']
.map(function (id) { return document.getElementById(id); })
.filter(Boolean);
@@ -38,6 +42,16 @@
if (mode === 'default' || !cards.length) return;
cards.sort(function (a, b) {
var ka = cardKey(a, mode), kb = cardKey(b, mode);
if (mode === 'updated' || mode === 'activity') {
// Comparaison en instants réels (les fuseaux des
// dates Gitea varient, -04:00/-05:00) ; une carte
// sans date coule en bas du tri décroissant.
var ta = Date.parse(ka), tb = Date.parse(kb);
if (isNaN(ta) && isNaN(tb)) return 0;
if (isNaN(ta)) return 1;
if (isNaN(tb)) return -1;
return tb - ta;
}
if (ka < kb) return mode === 'name' ? -1 : 1;
if (ka > kb) return mode === 'name' ? 1 : -1;
return 0;
+18 -31
View File
@@ -43,46 +43,33 @@
});
}
/* 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. */
/* Accordéon EXCLUSIF (0.22.0) : une seule section ouverte
à la fois. Ouvrir une section ferme les autres ; cliquer
la section ouverte la referme. L'état initial vient du
script d'amorce de _header.html (section de la page
courante ou choix mémorisé dans kanbante-nav-open). */
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');
}
function setOpenGroup(openKey) {
groupBtns.forEach(function (btn) {
var key = btn.getAttribute('data-group-btn');
document.body.classList.toggle('nav-closed-' + key, key !== openKey);
syncGroupBtn(btn);
});
try {
localStorage.setItem('kanbante-nav-open', openKey);
} catch (e) { /* non-fatal */ }
}
groupBtns.forEach(function (btn) {
syncGroupBtn(btn);
btn.addEventListener('click', function () {
document.body.classList.toggle(
'nav-closed-' + btn.getAttribute('data-group-btn'));
syncGroupBtn(btn);
persistClosedGroups();
var key = btn.getAttribute('data-group-btn');
var isOpen = !document.body.classList.contains('nav-closed-' + key);
setOpenGroup(isOpen ? '' : key);
});
});
/* 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();
}
}
}
})();
+245
View File
@@ -0,0 +1,245 @@
/**
* Kanbante — sélecteur de dépôt de la Capture éclair (0.21.0).
*
* Le champ Dépôt devient intelligent :
* - suggestions des dépôts réels dès la frappe (données /api/repos,
* épinglés et récents d'abord) ;
* - correspondance sur les deux segments : « kan » suffit pour
* trouver Projets/kanbante, « pr/kan » aussi ;
* - complétion Tab segment par segment, comme un shell :
* « pr » Tab → « Projets/ », puis « kan » Tab → « kanbante » ;
* - ↑/↓ + Entrée, clic souris, Échap ;
* - avertissement doux si le nom final n'existe pas dans Gitea.
*/
(function () {
var input = document.getElementById('capture-repo');
if (!input) return;
var list = document.getElementById('repo-listbox');
var hint = document.getElementById('repo-hint');
var repos = null;
var candidates = [];
var activeIndex = -1;
var isOpen = false;
function load() {
if (repos) return Promise.resolve(repos);
return fetch('/api/repos', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (data) { repos = data.repos || []; return repos; })
.catch(function () { repos = []; return repos; });
}
/* ── Correspondance ──────────────────────────────────── */
function matchRepos(value) {
var raw = value.trim().toLowerCase();
if (!raw) return repos.slice(0, 12);
var parts = raw.split('/').map(function (s) { return s.trim(); });
var scored = [];
repos.forEach(function (repo) {
var full = repo.full_name.toLowerCase();
var slash = full.indexOf('/');
var owner = full.slice(0, slash);
var name = full.slice(slash + 1);
var score = null;
if (parts.length === 1) {
var q = parts[0];
if (!q) score = 4;
else if (name.indexOf(q) === 0) score = 0;
else if (owner.indexOf(q) === 0) score = 1;
else if (name.indexOf(q) > -1) score = 2;
else if (full.indexOf(q) > -1) score = 3;
} else {
if (owner.indexOf(parts[0]) === 0 && name.indexOf(parts[1]) === 0) score = 0;
else if (owner.indexOf(parts[0]) > -1 && name.indexOf(parts[1]) > -1) score = 1;
}
if (score !== null) scored.push({ repo: repo, score: score });
});
scored.sort(function (a, b) {
if (a.score !== b.score) return a.score - b.score;
if (a.repo.starred !== b.repo.starred) return a.repo.starred ? -1 : 1;
return (b.repo.updated_at || '').localeCompare(a.repo.updated_at || '');
});
return scored.slice(0, 12).map(function (s) { return s.repo; });
}
/* ── Rendu / ouverture ───────────────────────────────── */
function highlight() {
Array.prototype.forEach.call(list.children, function (item, i) {
item.classList.toggle('active', i === activeIndex);
});
}
function render() {
list.innerHTML = '';
candidates.forEach(function (repo, i) {
var item = document.createElement('span');
item.className = 'repo-item';
item.setAttribute('role', 'option');
var star = repo.starred ? '⭐ ' : '';
var date = repo.updated_at ? repo.updated_at.slice(0, 10) : '';
var nameEl = document.createElement('span');
nameEl.className = 'repo-item-name';
nameEl.textContent = star + repo.full_name;
var metaEl = document.createElement('span');
metaEl.className = 'repo-item-meta';
metaEl.textContent = (repo.archived ? 'archivé · ' : '') + date;
item.appendChild(nameEl);
item.appendChild(metaEl);
item.addEventListener('mousedown', function (e) {
e.preventDefault();
choose(repo);
});
item.addEventListener('mousemove', function () {
if (activeIndex !== i) { activeIndex = i; highlight(); }
});
list.appendChild(item);
});
highlight();
list.hidden = !isOpen || candidates.length === 0;
input.setAttribute('aria-expanded', isOpen && candidates.length ? 'true' : 'false');
}
function refresh(openList) {
if (!repos) return;
candidates = matchRepos(input.value);
if (activeIndex >= candidates.length) activeIndex = candidates.length - 1;
isOpen = openList;
render();
validate();
}
function close() {
isOpen = false;
activeIndex = -1;
render();
}
function choose(repo) {
input.value = repo.full_name;
close();
validate();
input.focus();
}
function validate() {
if (!hint || !repos) return;
var value = input.value.trim().toLowerCase();
var exact = !value || repos.some(function (r) {
return r.full_name.toLowerCase() === value;
});
hint.hidden = exact;
input.classList.toggle('repo-unknown', !exact);
}
/* ── Complétion Tab, segment par segment ─────────────── */
function commonPrefix(strings) {
if (!strings.length) return '';
var prefix = strings[0];
for (var i = 1; i < strings.length; i++) {
while (strings[i].indexOf(prefix) !== 0) {
prefix = prefix.slice(0, -1);
if (!prefix) return '';
}
}
return prefix;
}
function completeSegment() {
if (!repos) return false;
var value = input.value;
var slash = value.lastIndexOf('/');
var head = slash >= 0 ? value.slice(0, slash + 1) : '';
var segment = slash >= 0 ? value.slice(slash + 1) : value;
var pool = [];
if (slash < 0) {
var owners = {};
repos.forEach(function (r) {
var o = r.full_name.split('/')[0];
if (o.toLowerCase().indexOf(segment.toLowerCase()) === 0) owners[o] = true;
});
pool = Object.keys(owners);
} else {
var ownerPart = value.slice(0, slash).toLowerCase();
repos.forEach(function (r) {
var parts = r.full_name.split('/');
if (parts[0].toLowerCase() === ownerPart &&
parts[1].toLowerCase().indexOf(segment.toLowerCase()) === 0) {
pool.push(parts[1]);
}
});
}
if (!pool.length) return false;
var lcp = commonPrefix(pool);
if (lcp.length > segment.length &&
lcp.toLowerCase().indexOf(segment.toLowerCase()) === 0) {
var completed = head + lcp;
// La complétion tombe sur un nom d'organisation exact :
// poser le séparateur dans le même Tab (comme un shell
// qui complète un répertoire) — le geste « pr » Tab
// donne « Projets/ », prêt pour le segment du dépôt.
if (slash < 0 && pool.indexOf(lcp) !== -1) completed += '/';
input.value = completed;
refresh(true);
return true;
}
// Segment d'organisation déjà complet et sans ambiguïté :
// poser le séparateur et passer au segment du dépôt.
if (slash < 0 && pool.length === 1 &&
lcp.toLowerCase() === segment.toLowerCase()) {
input.value = head + lcp + '/';
refresh(true);
return true;
}
return false;
}
/* ── Événements ──────────────────────────────────────── */
input.addEventListener('focus', function () {
load().then(function () { refresh(true); });
});
input.addEventListener('input', function () {
activeIndex = -1;
load().then(function () { refresh(true); });
});
input.addEventListener('blur', function () {
close();
validate();
});
input.addEventListener('keydown', function (e) {
if (e.key === 'Tab') {
if (completeSegment()) {
e.preventDefault();
} else {
close();
}
return;
}
if (e.key === 'Escape') {
close();
return;
}
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
load().then(function () {
if (!isOpen) { refresh(true); activeIndex = 0; highlight(); return; }
var delta = e.key === 'ArrowDown' ? 1 : -1;
activeIndex = Math.max(0, Math.min(candidates.length - 1, activeIndex + delta));
highlight();
});
return;
}
if (e.key === 'Enter') {
if (isOpen && activeIndex >= 0 && candidates[activeIndex]) {
e.preventDefault();
choose(candidates[activeIndex]);
} else if (isOpen && candidates.length === 1) {
e.preventDefault();
choose(candidates[0]);
}
}
});
// Si le champ est pré-rempli (lien ?repo= ou défaut), valider
// dès que la liste est connue.
if (input.value) load().then(validate);
})();
+14 -4
View File
@@ -13,7 +13,12 @@
th.classList.add('sortable');
th.addEventListener('click', function () {
var key = th.getAttribute('data-sort');
var dir = th.getAttribute('data-dir') === 'asc' ? 'desc' : 'asc';
// Premier clic : sens par défaut de la colonne
// (data-first-dir="desc" pour les dates d'activité —
// on veut le plus récent d'abord) ; ensuite on alterne.
var dir = th.getAttribute('data-dir')
? (th.getAttribute('data-dir') === 'asc' ? 'desc' : 'asc')
: (th.getAttribute('data-first-dir') || 'asc');
table.querySelectorAll('th').forEach(function (h) {
h.removeAttribute('data-dir');
});
@@ -22,10 +27,15 @@
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;
// Numérique SEULEMENT si la valeur entière est
// un nombre. L'ancien parseFloat lisait « 2026 »
// dans les dates ISO : toutes les lignes d'une
// même année se retrouvaient à égalité et le tri
// par date partait dans un ordre arbitraire
// (bug rapporté par Bruno, corrigé en 0.20.1).
if (/^-?\d+(\.\d+)?$/.test(va) && /^-?\d+(\.\d+)?$/.test(vb)) {
cmp = parseFloat(va) - parseFloat(vb);
} else {
cmp = va < vb ? -1 : (va > vb ? 1 : 0);
}
+20
View File
@@ -271,6 +271,26 @@ class FakeGitea:
},
]
# ── Snapshots (chantier B, 0.22.0) : le faux reflète
# l'interface du vrai client.
def hydrate_from_snapshots(self):
return 0
def latest_snapshot_at(self):
return None
async def refresh_search_repos(self):
return await self.search_repos()
async def refresh_starred(self):
return await self.list_starred()
async def refresh_repo_issues(self, owner, repo, state="all"):
return await self.get_repo_issues(owner, repo, state=state)
async def refresh_milestones(self, owner, repo):
return await self.get_milestones(owner, repo)
async def list_starred(self):
return [r for r in self.repos if r["full_name"] in self.starred]
+8
View File
@@ -20,9 +20,17 @@ def test_auth_required_when_configured(monkeypatch, fake_gitea):
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
with TestClient(main.app) as client:
# Sans en-tête navigateur : 401 (comportement historique).
assert client.get("/").status_code == 401
# Le Basic reste accepté pour les scripts (0.22.0).
assert client.get("/", headers=_basic("bruno", "mauvais")).status_code == 401
assert client.get("/", headers=_basic("bruno", "motdepasse")).status_code == 200
# Un navigateur, lui, est envoyé vers l'écran de connexion.
r = client.get(
"/", headers={"Accept": "text/html"}, follow_redirects=False
)
assert r.status_code == 303
assert r.headers["location"].startswith("/login")
# Liveness stays public for Docker HEALTHCHECK.
assert client.get("/api/health").status_code == 200
+2 -1
View File
@@ -64,7 +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 "kanbante-nav-open" in js.text # accordéon exclusif persisté (0.22.0)
assert "setOpenGroup" in js.text
assert "data-group-btn" in js.text
+36
View File
@@ -0,0 +1,36 @@
"""Palier 16 (0.20.0) — refonte professionnelle de l'entête Projets.
Fini le « Bonjour 👋 » et les rangées éparses : titre sobre,
bande KPI unifiée, et une seule barre d'outils (onglets
segmentés, filtre, tri, regroupement, vue). Les crochets
fonctionnels (ids, liaisons Alpine) sont inchangés.
"""
def test_dashboard_professional_header(client):
r = client.get("/")
assert r.status_code == 200
# Titre sobre + sous-titre, plus de formule familière.
assert "<h1>Projets</h1>" in r.text
assert "Bonjour" not in r.text
# Bande KPI unifiée : valeurs + libellés.
assert "kpi-strip" in r.text
assert "Projets actifs" in r.text
assert "Issues fermées · 30 j" in r.text
assert "Temps de cycle moyen" in r.text
# Les KPI journal mènent à l'activité.
assert 'href="/activity"' in r.text
def test_dashboard_single_toolbar_keeps_its_hooks(client):
r = client.get("/")
assert "dash-toolbar" in r.text
# Onglets segmentés avec compteurs, mêmes liaisons Alpine.
assert "seg-tabs" in r.text
assert "seg-count" in r.text
assert "tab = 'inactive'" in r.text
# Filtre, tri, regroupement et vue : ids préservés pour
# dashboard.js et l'état Alpine.
for hook in ('id="project-search"', 'id="dash-sort"',
'id="dash-group"', "setView('list')"):
assert hook in r.text, hook
+36
View File
@@ -0,0 +1,36 @@
"""Palier 17 (0.21.0) — sélecteur de dépôt intelligent (Capture).
- GET /api/repos : liste exhaustive des dépôts pour les
sélecteurs (la palette reste plafonnée à 60), épinglés
d'abord, archivés en dernier.
- La page /capture branche le sélecteur sur le champ Dépôt
(liste filtrée + complétion Tab par segment, repo-picker.js).
"""
def test_api_repos_lists_everything_ranked(client, fake_gitea):
fake_gitea.starred.add("Projets/vieux")
r = client.get("/api/repos")
assert r.status_code == 200
repos = r.json()["repos"]
names = [x["full_name"] for x in repos]
# Exhaustif : actifs + inactifs + archivés du faux Gitea.
assert names == ["Projets/vieux", "Projets/demo", "Projets/archive"] or set(names) == {
"Projets/demo", "Projets/vieux", "Projets/archive",
}
# L'épinglé passe devant malgré son inactivité.
assert names[0] == "Projets/vieux"
by_name = {x["full_name"]: x for x in repos}
assert by_name["Projets/vieux"]["starred"] is True
assert by_name["Projets/demo"]["starred"] is False
assert by_name["Projets/archive"]["archived"] is True
def test_capture_page_wires_the_repo_picker(client):
r = client.get("/capture")
assert r.status_code == 200
assert 'id="capture-repo"' in r.text
assert 'id="repo-listbox"' in r.text
assert "repo-picker.js" in r.text
# Le contrat du formulaire ne change pas.
assert 'name="repo_full_name"' in r.text
+139
View File
@@ -0,0 +1,139 @@
"""Palier 18 (0.22.0) — login screen, sidebar, chantier B.
- Connexion par formulaire + cookie de session signé (le Basic
reste accepté pour les scripts) ; les navigateurs sont
redirigés vers /login, les API reçoivent 401.
- Bouton replier dans l'en-tête de la barre latérale (icône
seule, à droite de la recherche) ; accordéon exclusif.
- Instantanés : persistance des réponses du client, hydratation
au démarrage, étiquette de fraîcheur.
"""
import re
import httpx
from fastapi.testclient import TestClient
import app.main as main
from app import models
from app.gitea_client import GiteaClient
from app.snapshots import freshness_label
def _auth_client(monkeypatch, fake_gitea):
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
return TestClient(main.app)
def _csrf_from(html: str) -> str:
m = re.search(r'name="csrf_token" value="([^"]+)"', html)
assert m, "le formulaire de connexion doit porter le jeton CSRF"
return m.group(1)
def test_browser_is_redirected_to_login(monkeypatch, fake_gitea):
with _auth_client(monkeypatch, fake_gitea) as client:
r = client.get("/", headers={"Accept": "text/html"}, follow_redirects=False)
assert r.status_code == 303
assert r.headers["location"].startswith("/login")
# Les API, elles, reçoivent un 401 sec (pas de redirection).
r = client.get("/api/repos")
assert r.status_code == 401
def test_login_flow_and_logout(monkeypatch, fake_gitea):
with _auth_client(monkeypatch, fake_gitea) as client:
page = client.get("/login")
assert page.status_code == 200
assert "login-card" in page.text
token = _csrf_from(page.text)
bad = client.post(
"/login",
data={"username": "bruno", "password": "non", "csrf_token": token},
follow_redirects=False,
)
assert bad.status_code == 401
assert "Identifiants incorrects" in bad.text
ok = client.post(
"/login",
data={
"username": "bruno",
"password": "motdepasse",
"csrf_token": token,
"next": "/focus",
},
follow_redirects=False,
)
assert ok.status_code == 303
assert ok.headers["location"] == "/focus"
# La session ouvre les pages sans autre en-tête.
assert client.get("/").status_code == 200
client.get("/logout")
r = client.get("/", headers={"Accept": "text/html"}, follow_redirects=False)
assert r.status_code == 303
def test_login_next_is_local_only(monkeypatch, fake_gitea):
with _auth_client(monkeypatch, fake_gitea) as client:
token = _csrf_from(client.get("/login").text)
r = client.post(
"/login",
data={
"username": "bruno",
"password": "motdepasse",
"csrf_token": token,
"next": "https://evil.example/phish",
},
follow_redirects=False,
)
assert r.status_code == 303
assert r.headers["location"] == "/"
def test_sidebar_head_and_exclusive_accordion(client):
r = client.get("/")
assert r.status_code == 200
head = r.text.split('class="side-nav"')[0]
assert 'id="side-collapse"' in head # replier dans l'en-tête…
assert head.index("side-search") < head.index('id="side-collapse"') # …à droite de la recherche
assert '<span class="side-label">Replier</span>' not in r.text # icône seule
assert "kanbante-nav-open" in r.text # accordéon exclusif (amorce)
async def test_snapshot_write_through_and_hydration():
calls = {"n": 0}
def handler(request):
calls["n"] += 1
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"id": 7})
return httpx.Response(
200,
json={"data": [{"full_name": "Projets/demo", "archived": False}]},
headers={"X-Total-Count": "1"},
)
writer = GiteaClient(
base_url="http://gitea.test", cache_ttl=30,
transport=httpx.MockTransport(handler),
)
repos = await writer.search_repos()
assert repos and repos[0]["full_name"] == "Projets/demo"
stored = models.load_snapshots()
assert "search_repos" in stored
def boom(request):
raise AssertionError("Gitea ne doit pas être appelé : l'instantané suffit")
reader = GiteaClient(
base_url="http://gitea.test", cache_ttl=30,
transport=httpx.MockTransport(boom),
)
assert reader.hydrate_from_snapshots() >= 1
served = await reader.search_repos()
assert served[0]["full_name"] == "Projets/demo"
assert freshness_label() == "à l'instant"
+3 -1
View File
@@ -198,5 +198,7 @@ def test_dashboard_uses_new_design_system(client):
r = client.get("/")
assert "app-header" in r.text
assert "nav-pill" in r.text
assert "stat-tile" in r.text
# En-tête refondu en 0.20.0 : bande KPI unifiée (les stat-tile
# ne servent plus qu'au briefing).
assert "kpi-strip" in r.text
assert "live.js" in r.text