21 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
Spark 900590efed chore(release): Kanbante 0.19.0 — performance et pilotage (A + C)
CI / test (push) Successful in 1m50s
2026-10-12 13:53:59 -04:00
Spark 0aa991047f test: palier 15 — tableaux pilotage, ancres, SWR (frais/périmé/mutation) 2026-10-12 13:53:59 -04:00
Spark 79122c20d0 feat(pilotage): Prévisions en tableau des jalons, Santé en tableau de sévérité (chantier C)
- /forecast : une ligne par jalon, tous projets, triée par
  risque (écart décroissant), filtrable, ancres ms-<owner>-<repo>
  pointées par le bloc jalon des cartes du dashboard ; débit
  par projet conservé en tableau compact.
- /health : tableau trié par sévérité (pastille, zombies,
  goulot) ; page détail : sélecteur de projet (health.js).
- Tri des tableaux mutualisé dans static/js/tablesort.js
  (dashboard, forecast, health).
2026-10-12 13:53:59 -04:00
Spark 3e3187d597 feat(perf): boucles par dépôt parallélisées et cache stale-while-revalidate (chantier A)
- _parallel_map : asyncio.gather borné (16) pour dashboard,
  /health, /forecast, /triage et le mur — le patron de /focus,
  généralisé. Avant : la page attendait la somme des allers-
  retours Gitea de tous les dépôts, en séquentiel.
- Client Gitea : les listes (dépôts, issues, jalons, étoiles)
  passent en stale-while-revalidate — une entrée périmée est
  servie immédiatement et rafraîchie en tâche de fond. Les
  mutations vident le cache et bumpent une génération qui
  désarme les rafraîchissements en vol.
- /forecast calcule des lignes de jalons tous projets (écart
  ETA/échéance) ; /health trie par sévérité et expose la
  pastille ; le détail santé reçoit la liste des projets.
2026-10-12 13:53:59 -04:00
Spark 593f076555 chore(release): Kanbante 0.18.0 — vague 2 suite : activité, actions rapides, tri, liste dense
CI / test (push) Successful in 1m47s
2026-10-11 23:18:01 -04:00
Spark 311a14090d test(0.18.0): activité, outils d'affichage, liste dense, actions rapides — 7 tests, 185 au total 2026-10-11 23:18:01 -04:00
Spark c67a14da65 feat(dashboard): ligne d'activité, menu ⋯, tri/regroupement, vue liste (#19, #21, #22, #23)
Menu d'actions rapides par carte; barre d'outils tri +
regroupement par propriétaire (sections repliables, préférences
localStorage); bascule Grille/Liste persistée avec tableaux
triables réutilisant jalon/santé des cartes; dashboard.js dédié.
2026-10-11 23:18:01 -04:00
Spark 7ec34245c5 feat(dashboard): activité 7 jours agrégée et capture pré-remplie (#19, #23)
get_project_activity(): fermetures/déplacements 7 j et dernier
événement par dépôt en une seule requête sur board_events;
la page /capture accepte ?repo= pour pré-remplir le dépôt.
2026-10-11 23:18:01 -04:00
31 changed files with 2514 additions and 303 deletions
+178
View File
@@ -1,5 +1,183 @@
# 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)
- **Parallélisation (A1)** : les pages inter-projets (dashboard,
/health, /forecast, /triage, /wall) attendaient chaque dépôt à
la queue leu leu ; elles utilisent maintenant un `asyncio.gather`
borné (16) — le patron que /focus appliquait déjà.
- **Cache stale-while-revalidate (A2)** : les listes Gitea
(dépôts, issues, jalons, étoiles) sont servies immédiatement
même périmées, et rafraîchies en tâche de fond. Fini le
« mur » de rechargement à chaque expiration du cache (30 s) :
seul le tout premier chargement après un démarrage paie le
coût complet. Les mutations vident le cache et désarment les
rafraîchissements en vol (aucune donnée périmée ressuscitée).
- **Prévisions refondues (C1)** : /forecast devient un tableau de
bord des jalons **tous projets** — une ligne par jalon (projet,
échéance, progression, ETA, **écart**, débit), triée par risque,
filtrable, colonnes triables ; le bloc jalon des cartes du
dashboard y pointe directement (ancres). Le débit par projet
reste en dessous, en tableau compact.
- **Santé refondue (C2)** : /health devient un tableau trié par
sévérité (pastille, ouvertes, zombies, goulot) ; la page santé
d'un projet gagne un **sélecteur de projet** pour basculer sans
repasser par l'index. Le tri des tableaux est mutualisé
(`static/js/tablesort.js`, dashboard inclus).
- Mesuré sur l'instance réelle (depuis le réseau de test) :
pages chaudes 0,04–0,16 s ; le premier chargement à froid
reste de quelques secondes — le chantier B (instantanés
d'arrière-plan) demeure le levier pour le supprimer, si voulu.
## 0.18.0 — 2026-10-11
### Nouveautés — vague 2, suite (4 cartes de Bruno)
- **Activité récente par projet** (#19) : ligne sur la carte du
dashboard calculée par UNE requête agrégée sur le journal —
« 📈 X fermées · Y déplacements cette semaine », ou « 💤 aucune
activité kanban depuis N j » pour les projets endormis.
- **Actions rapides sur la carte** (#23) : menu ⋯ sur chaque
carte — Board, ⚡ Capturer ici (la page capture accepte
`?repo=` et pré-remplit le dépôt), Triage du backlog, Santé,
Récit de sprint, Gitea. Clavier et tactile soignés (Échap,
clic extérieur).
- **Tri et regroupement** (#21) : tri par activité Gitea,
activité kanban, ouvertes ou nom ; regroupement par
propriétaire en sections repliables ; préférences mémorisées
par navigateur (localStorage).
- **Vue liste dense** (#22) : bascule Grille/Liste persistée —
tableau triable par colonne (Projet, Propriétaire, Ouvertes,
En vol, Prochain jalon, Santé, Dernière activité) réutilisant
les enrichissements des cartes, mêmes onglets et même filtre.
Note : le lien Triage du menu ⋯ est constant — la taille du
backlog « stats » du dashboard ne compte que les cartes placées,
pas le backlog réel ; la page Triage affiche l'état exact.
7 tests ajoutés (185 au total).
## 0.17.0 — 2026-10-11
### Nouveautés — vague 2 « Gestion de projets » (4 cartes de Bruno)
+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.17.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.17.0"},
"clientInfo": {"name": "kanbante", "version": "0.22.0"},
},
req_id=1,
)
+165 -39
View File
@@ -17,8 +17,10 @@ Changes from the 0.1.0 client:
- .env loading targets the project root only (no hardcoded home path).
"""
import asyncio
import logging
import time
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any
@@ -60,6 +62,12 @@ class GiteaClient:
)
self._user: dict | None = None
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()
@@ -105,24 +113,146 @@ class GiteaClient:
page += 1
return items
def _cache_get(self, key: str) -> Any | None:
if self.cache_ttl <= 0:
return None
hit = self._cache.get(key)
if hit and (time.monotonic() - hit[0]) < self.cache_ttl:
return hit[1]
return None
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()
# Invalidate in-flight background refreshes: a refresh that
# started before a mutation must not resurrect stale data.
self._cache_gen += 1
async def _cached_list(
self, key: str, fetcher: Callable[[], Awaitable[Any]]
) -> Any:
"""Stale-while-revalidate cache for list endpoints (0.19.0).
Fresh (< TTL): serve the cache. Stale: serve the cached
value IMMEDIATELY and refresh it in the background (once
per key) — the user never waits on Gitea for a page when
any earlier answer exists. Absent: fetch and wait.
Mutations clear the cache (see _cache_clear), so writes are
never hidden by a stale entry.
"""
if self.cache_ttl <= 0:
return await fetcher()
hit = self._cache.get(key)
if hit is not None:
if (time.monotonic() - hit[0]) < self.cache_ttl:
return hit[1]
if key not in self._refreshing:
self._refreshing.add(key)
asyncio.create_task(
self._refresh_entry(key, fetcher, self._cache_gen)
)
return hit[1]
value = await fetcher()
self._cache_put(key, value)
return value
async def _refresh_entry(
self,
key: str,
fetcher: Callable[[], Awaitable[Any]],
generation: int,
) -> None:
try:
value = await fetcher()
if generation == self._cache_gen:
self._cache_put(key, value)
except Exception as exc:
logger.debug("background refresh failed for %s: %s", key, exc)
finally:
self._refreshing.discard(key)
def invalidate_cache(self) -> None:
"""Drop every cached list response (called on webhook events)."""
self._cache.clear()
self._cache_clear()
# ── API ────────────────────────────────────────────────
@@ -140,21 +270,25 @@ class GiteaClient:
self._user = await self._get("/user")
return self._user
async def search_repos(self) -> list[dict]:
"""All repos visible to the token owner (paginated, cached)."""
cached = self._cache_get("search_repos")
if cached is not None:
return cached
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)
repos = await self._get_all_pages("/repos/search", **params)
self._cache_put("search_repos", repos)
return repos
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 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}")
@@ -178,14 +312,12 @@ class GiteaClient:
(see app.main._is_issue).
"""
key = f"issues:{owner}/{repo}:{state}"
cached = self._cache_get(key)
if cached is not None:
return cached
issues = await self._get_all_pages(
f"/repos/{owner}/{repo}/issues", state=state
return await self._cached_list(
key,
lambda: self._get_all_pages(
f"/repos/{owner}/{repo}/issues", state=state
),
)
self._cache_put(key, issues)
return issues
async def get_all_issues(
self, repos: list[dict], state: str = "open"
@@ -308,25 +440,19 @@ class GiteaClient:
self, owner: str, repo: str, state: str = "open"
) -> list[dict]:
key = f"milestones:{owner}/{repo}:{state}"
cached = self._cache_get(key)
if cached is not None:
return cached
milestones = await self._get_all_pages(
f"/repos/{owner}/{repo}/milestones", state=state
return await self._cached_list(
key,
lambda: self._get_all_pages(
f"/repos/{owner}/{repo}/milestones", state=state
),
)
self._cache_put(key, milestones)
return milestones
async def list_starred(self) -> list[dict]:
"""Repos starred by the token user (issue #20: pinning rides
on Gitea stars — the forge stays the source of truth)."""
key = "user:starred"
cached = self._cache_get(key)
if cached is not None:
return cached
repos = await self._get_all_pages("/user/starred")
self._cache_put(key, repos)
return repos
return await self._cached_list(
"user:starred", lambda: self._get_all_pages("/user/starred")
)
async def set_starred(self, owner: str, repo: str, starred: bool) -> None:
"""Star / unstar a repo for the token user."""
+403 -101
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
@@ -58,6 +67,7 @@ from app.models import (
get_metrics,
get_notes,
get_picks,
get_project_activity,
get_stats,
init_db,
log_event,
@@ -154,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())
@@ -178,6 +195,7 @@ async def lifespan(app: FastAPI):
settings.capture_ntfy_topic,
)
yield
snapshot_task.cancel()
scheduler.cancel()
digest_task.cancel()
rules_task.cancel()
@@ -191,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:
@@ -219,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:
@@ -329,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:
@@ -377,6 +510,27 @@ def _parse_datetime(value: str | None) -> datetime | None:
return None
async def _parallel_map(items, fn, limit: int = 16) -> list:
"""Run an async fn over items with bounded concurrency (0.19.0).
Cross-project pages used to await each repo in sequence, so the
page took the SUM of every Gitea round trip. Gathering with a
per-call semaphore (created here, bound to the current loop)
cuts that to roughly the slowest single repo, without letting
a big instance stampede Gitea. Exceptions come back in place —
callers decide what a failed repo means for them.
"""
semaphore = asyncio.Semaphore(limit)
async def _run(item):
async with semaphore:
return await fn(item)
return await asyncio.gather(
*(_run(item) for item in items), return_exceptions=True
)
def classify_repos(
repos: list[dict], now: datetime | None = None
) -> tuple[list[dict], list[dict], list[dict]]:
@@ -584,7 +738,7 @@ async def dashboard(request: Request):
except Exception as exc: # stars are decoration, never fatal (#20)
logger.debug("starred list failed: %s", exc)
starred_names = set()
for repo_info in active:
async def _enrich_active(repo_info: dict) -> None:
full_name = repo_info["full_name"]
owner, _, repo = full_name.partition("/")
repo_info["forecast_label"] = _repo_forecast_label(
@@ -598,8 +752,28 @@ async def dashboard(request: Request):
logger.debug("health dot failed for %s: %s", full_name, exc)
repo_info["health_dot"] = None
repo_info["starred"] = full_name in starred_names
await _parallel_map(active, _enrich_active)
for repo_info in inactive:
repo_info["starred"] = repo_info["full_name"] in starred_names
activity_by_repo = get_project_activity()
today_utc = datetime.now(timezone.utc).date()
for repo_info in active + inactive + archived:
act = activity_by_repo.get(repo_info["full_name"])
if act is None:
repo_info["activity"] = None
continue
idle_days = None
if act["last_at"]:
last_dt = _parse_datetime(act["last_at"])
if last_dt is not None:
idle_days = (today_utc - last_dt.date()).days
repo_info["activity"] = {
"closed": act["closed"],
"moved": act["moved"],
"idle_days": idle_days,
"last_at": act["last_at"] or "",
}
for repo_info in active:
repo_info["home_tab"] = "active"
for repo_info in inactive:
@@ -994,19 +1168,18 @@ async def triage_picker(request: Request):
"""Triage mode (issue #11): pick a project by its backlog size."""
repos = await gitea.search_repos()
active, _inactive, _archived = classify_repos(repos)
projects = []
for repo_info in active:
async def _one_triage(repo_info: dict) -> dict | None:
owner, _, name = repo_info["full_name"].partition("/")
try:
all_items = await gitea.get_repo_issues(owner, name, state="all")
except Exception:
continue
all_items = await gitea.get_repo_issues(owner, name, state="all")
columns, _display, _state = _board_columns_for(owner, name, all_items)
backlog = len(columns["backlog"])
if backlog:
projects.append(
{"full_name": repo_info["full_name"], "backlog": backlog}
)
return {"full_name": repo_info["full_name"], "backlog": backlog}
return None
results = await _parallel_map(active, _one_triage)
projects = [r for r in results if isinstance(r, dict)]
projects.sort(key=lambda p: -p["backlog"])
return render(request, "triage.html", projects=projects)
@@ -1535,30 +1708,52 @@ 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:
async def _one_health(repo_info: dict) -> dict:
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 await _project_health(owner, name)
results = await _parallel_map(active, _one_health)
projects = []
for repo_info, result in zip(active, results):
if isinstance(result, Exception):
logger.warning(
"Health failed for %s: %s", repo_info["full_name"], result
)
continue
result["dot"] = _health_dot(result)
projects.append(result)
severity_rank = {"red": 0, "orange": 1, "green": 2}
projects.sort(
key=lambda p: (
severity_rank.get(p["dot"]["level"], 3),
-len(p["zombies"]),
)
)
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."""
"""Flow health of one project: dwell per column + zombies.
Passes the active project list so the page can offer a project
switcher — no more back-to-the-index round trip (0.19.0, C).
"""
repos = await gitea.search_repos()
known = {r["full_name"] for r in repos}
if f"{owner}/{repo}" not in known:
active, _inactive, _archived = classify_repos(repos)
project_names = [r["full_name"] for r in active]
if f"{owner}/{repo}" not in {r["full_name"] for r in repos}:
return render(
request, "health_project.html",
health=None, full_name=f"{owner}/{repo}",
project_names=project_names,
)
health = await _project_health(owner, repo)
return render(
request, "health_project.html",
health=health, full_name=f"{owner}/{repo}",
project_names=project_names,
)
@@ -1585,11 +1780,29 @@ async def standup_page(request: Request):
@app.get("/forecast", response_class=HTMLResponse)
async def forecast_page(request: Request):
"""Forecasts (issue #7): real-pace ETAs per project + milestone."""
"""Forecasts (issue #7; portfolio table in 0.19.0, chantier C).
One row per open milestone across every active project,
sorted by risk (largest ETA-vs-deadline gap first) — the
cross-project question the old per-project card wall could
not answer without reading everything. Per-project pace
(weekly bars + backlog ETA) stays as a compact second table.
"""
repos = await gitea.search_repos()
active, _inactive, _archived = classify_repos(repos)
projects = []
for repo_info in active:
async def _fetch_milestones(repo_info: dict) -> list[dict]:
owner, _, name = repo_info["full_name"].partition("/")
try:
return await gitea.get_milestones(owner, name)
except Exception:
return []
ms_results = await _parallel_map(active, _fetch_milestones)
rows: list[dict] = []
projects: list[dict] = []
for repo_info, ms_list in zip(active, ms_results):
full = repo_info["full_name"]
owner, _, name = full.partition("/")
weekly = forecast_service.weekly_closures(
@@ -1604,50 +1817,100 @@ async def forecast_page(request: Request):
}
for b in weekly
]
stats = forecast_service.throughput_stats(weekly)
remaining = repo_info.get("open_issues_count") or 0
est = forecast_service.estimate(remaining, weekly)
milestones = []
try:
ms_list = await gitea.get_milestones(owner, name)
except Exception:
ms_list = []
for ms in ms_list:
if ms.get("state") not in (None, "open"):
continue
rem = ms.get("open_issues") or 0
if rem == 0 and not (ms.get("closed_issues") or 0):
continue
m_est = forecast_service.estimate(rem, weekly)
verdict = None
due_raw = ms.get("due_on")
if m_est and not m_est.get("done") and due_raw:
due = _parse_datetime(str(due_raw))
if due is not None:
verdict = (
"⚠️ en retard à ce rythme"
if m_est["central"] > due.date()
else "✅ dans les temps à ce rythme"
)
milestones.append(
{
"title": ms.get("title", ""),
"due_on": (due_raw or "")[:10],
"remaining": rem,
"eta": forecast_service.eta_label(m_est),
"verdict": verdict,
}
)
projects.append(
{
"full_name": full,
"bars": bars,
"stats": forecast_service.throughput_stats(weekly),
"stats": stats,
"remaining": remaining,
"eta_label": forecast_service.eta_label(est),
"milestones": milestones,
}
)
return render(request, "forecast.html", projects=projects)
if isinstance(ms_list, Exception):
ms_list = []
next_title = None
next_due = None
for ms in ms_list:
if ms.get("state") not in (None, "open"):
continue
rem = ms.get("open_issues") or 0
closed_n = ms.get("closed_issues") or 0
if rem == 0 and not closed_n:
continue
total = rem + closed_n
m_est = forecast_service.estimate(rem, weekly)
due_raw = ms.get("due_on")
due_dt = _parse_datetime(str(due_raw)) if due_raw else None
due_date = due_dt.date() if due_dt else None
gap_days = None
verdict = "noeta"
if due_date is None:
verdict = "nodue"
elif m_est and not m_est.get("done"):
gap_days = (m_est["central"] - due_date).days
verdict = "late" if gap_days > 0 else "ontime"
elif m_est and m_est.get("done"):
verdict = "ontime"
rows.append(
{
"project": full,
"owner": owner,
"repo": name,
"title": ms.get("title", ""),
"due_label": (
forecast_service.french_date(due_date, short=True)
if due_date else "—"
),
"due_sort": due_date.isoformat() if due_date else "9999",
"remaining": rem,
"closed": closed_n,
"total": total,
"pct": round(100 * closed_n / total) if total else 0,
"eta": forecast_service.eta_label(m_est),
"gap_days": gap_days,
"verdict": verdict,
"throughput": stats["avg"] if stats else None,
"anchor": False,
}
)
if (
due_date is not None
and (next_due is None or due_date < next_due)
):
next_due, next_title = due_date, ms.get("title", "")
for row in rows:
if (
row["project"] == full
and row["title"] == next_title
and next_title is not None
):
row["anchor"] = True
def _risk_key(row: dict):
# Late first (worst gap first), then dated on-time by due
# date, then no-ETA, then no due date.
order = {"late": 0, "ontime": 1, "noeta": 2, "nodue": 3}
return (
order.get(row["verdict"], 4),
-(row["gap_days"] or 0) if row["verdict"] == "late" else 0,
row["due_sort"],
row["project"],
row["title"],
)
rows.sort(key=_risk_key)
summary = {
"late": sum(1 for r in rows if r["verdict"] == "late"),
"ontime": sum(1 for r in rows if r["verdict"] == "ontime"),
"total": len(rows),
}
return render(
request, "forecast.html",
rows=rows, projects=projects, summary=summary,
)
@app.get("/focus", response_class=HTMLResponse)
@@ -1720,15 +1983,14 @@ async def _collect_wall_data(only_repo: str | None) -> list[dict]:
closed_today.get(event["repo_full_name"], 0) + 1
)
slides = []
for repo in repos:
async def _one_slide(repo: dict) -> dict | None:
full_name = repo["full_name"]
owner, _, name = full_name.partition("/")
try:
items = await gitea.get_repo_issues(owner, name, state="open")
except Exception as exc:
logger.debug("wall: issues failed for %s: %s", full_name, exc)
continue
return None
open_issues = [i for i in items if _is_issue(i)]
_adopt_label_columns(full_name, open_issues, get_board_state(full_name))
board_state = get_board_state(full_name)
@@ -1750,17 +2012,17 @@ async def _collect_wall_data(only_repo: str | None) -> list[dict]:
"aging": days is not None and days >= aging_days,
}
)
slides.append(
{
"full_name": full_name,
"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),
}
)
return slides
return {
"full_name": full_name,
"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),
}
results = await _parallel_map(repos, _one_slide)
return [r for r in results if isinstance(r, dict)]
@app.get("/wall", response_class=HTMLResponse)
@@ -2173,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}
)
@@ -2305,13 +2574,17 @@ async def api_capture(request: Request):
@app.get("/capture", response_class=HTMLResponse)
async def capture_page(request: Request):
"""Quick-capture page: form, status and usage instructions."""
async def capture_page(request: Request, repo: str = ""):
"""Quick-capture page: form, status and usage instructions.
`?repo=Owner/name` pre-fills the target repo (issue #23: the
project card's quick actions capture straight into a project).
"""
settings = get_settings()
return render(
request,
"capture.html",
capture_repo=settings.capture_repo,
capture_repo=repo.strip() or settings.capture_repo,
api_enabled=bool(settings.capture_token),
ntfy_topic=settings.capture_ntfy_topic,
ntfy_url=settings.ntfy_url,
@@ -3379,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).
+81
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")
@@ -368,6 +417,38 @@ def column_entry_times(repo_full_name: str) -> dict[int, str]:
return {row["issue_number"]: row["entered_at"] for row in rows}
def get_project_activity(days: int = 7) -> dict:
"""Per-repo journal activity in ONE aggregated pass (issue #19).
For every repo present in the journal: closures and moves of
the last `days` days (journal stamps are UTC strings), plus
the most recent event ever seen for that repo.
"""
from datetime import datetime, timedelta, timezone
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).strftime(
"%Y-%m-%d %H:%M:%S"
)
with get_db() as conn:
rows = conn.execute(
"""
SELECT repo_full_name,
SUM(CASE WHEN event_type = 'closed' AND created_at >= ?
THEN 1 ELSE 0 END),
SUM(CASE WHEN event_type = 'moved' AND created_at >= ?
THEN 1 ELSE 0 END),
MAX(created_at)
FROM board_events
GROUP BY repo_full_name
""",
(cutoff, cutoff),
).fetchall()
return {
r[0]: {"closed": r[1] or 0, "moved": r[2] or 0, "last_at": r[3]}
for r in rows
}
def get_metrics(repo_full_name: str | None = None, window_days: int = 30) -> dict:
"""Throughput and cycle time computed from the journal.
+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>
+142 -31
View File
@@ -11,45 +11,80 @@
<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>
<main x-data="{ tab: 'active', search: '' }">
<!-- 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>
<script>
// État du dashboard (#21/#22) : la vue grille/liste survit au
// rechargement (localStorage), lue avant qu'Alpine démarre.
function dashState() {
var view = 'grid';
try { view = localStorage.getItem('kanbante-dash-view') || 'grid'; } catch (e) { /* défaut */ }
return {
tab: 'active', search: '', view: view,
setView: function (v) {
this.view = v;
try { localStorage.setItem('kanbante-dash-view', v); } catch (e) { /* non-fatal */ }
}
};
}
</script>
<main x-data="dashState()">
<!-- 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>
<option value="activity">Activité kanban récente</option>
<option value="open">Issues ouvertes ↓</option>
<option value="name">Nom A → Z</option>
</select>
</label>
<label class="dash-tool dash-tool-check">
<input type="checkbox" id="dash-group"> Grouper par propriétaire
</label>
<div class="dash-view-toggle" role="group" aria-label="Mode d'affichage">
<button type="button" :class="{ active: view === 'grid' }" @click="setView('grid')">▦ Grille</button>
<button type="button" :class="{ active: view === 'list' }" @click="setView('list')">☰ Liste</button>
</div>
</div>
{% macro project_card(repo, archived=False) %}
{% set full = repo.full_name %}
<div class="project-card{% if archived %} archived{% endif %}"
data-full="{{ full }}" data-home="{{ repo.home_tab or '' }}"
data-name="{{ repo.name|lower }}" data-owner="{{ repo.owner.login }}"
data-updated="{{ repo.updated_at }}" data-open="{{ repo.open_issues_count or 0 }}"
data-activity="{{ repo.activity.last_at if repo.activity else '' }}"
data-search="{{ (repo.name ~ ' ' ~ repo.full_name ~ ' ' ~ (repo.description or ''))|lower }}"
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
<div class="card-header">
@@ -60,6 +95,19 @@
<span class="date">{{ repo.updated_at[:10] }}</span>
{% if not archived %}
<button type="button" class="star-btn" data-full="{{ full }}" data-starred="{{ 1 if repo.starred else 0 }}" title="{{ 'Désépingler (étoile Gitea)' if repo.starred else 'Épingler (étoile Gitea)' }}" aria-label="Épingler {{ repo.name }}">{{ '⭐' if repo.starred else '☆' }}</button>
<div class="card-menu-wrap">
<button type="button" class="card-menu-btn" data-menu-btn aria-haspopup="true" aria-expanded="false" title="Actions rapides pour {{ repo.name }}" aria-label="Actions rapides pour {{ repo.name }}">⋯</button>
<div class="card-menu" hidden>
<a href="/board/{{ full }}">📋 Board</a>
<a href="/capture?repo={{ full|urlencode }}">⚡ Capturer ici</a>
<a href="/triage/{{ full }}">📥 Triage du backlog</a>
<a href="/health/{{ full }}">🩺 Santé</a>
<a href="/sprint/{{ full }}">📖 Récit de sprint</a>
{% if repo.html_url %}
<a href="{{ repo.html_url }}" target="_blank" rel="noopener">🔗 Gitea ↗</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
<h3>
@@ -79,7 +127,7 @@
{% set ms = repo.next_milestone %}
<div class="ms-block">
<div class="ms-line">
<span class="ms-title">🎯 {{ ms.title }}</span>
<a href="/forecast#ms-{{ repo.owner.login }}-{{ repo.name }}" class="ms-title" title="Voir ce jalon dans le tableau des prévisions">🎯 {{ ms.title }}</a>
{% if ms.due_label %}<span class="ms-due{% if ms.late %} late{% endif %}">{{ '⚠️ ' if ms.late }}échéance {{ ms.due_label }}</span>{% endif %}
</div>
{% if ms.total %}
@@ -107,9 +155,62 @@
</div>
{% endif %}
</div>
{% if repo.activity %}
<div class="activity-line">
{% if repo.activity.closed or repo.activity.moved %}
📈 {{ repo.activity.closed }} fermée(s) · {{ repo.activity.moved }} déplacement(s) cette semaine
{% elif repo.activity.idle_days is not none and repo.activity.idle_days > 7 %}
💤 aucune activité kanban depuis {{ repo.activity.idle_days }} j
{% endif %}
</div>
{% endif %}
</div>
{% endmacro %}
{% macro project_row(repo, archived=False) %}
{% set full = repo.full_name %}
{% set st = stats.get(full, {}) %}
<tr data-name="{{ repo.name|lower }}" data-owner="{{ repo.owner.login|lower }}"
data-open="{{ repo.open_issues_count or 0 }}"
data-inflight="{{ (st.get('in_progress', 0) + st.get('qa', 0) + st.get('review', 0)) }}"
data-updated="{{ repo.updated_at }}"
data-search="{{ (repo.name ~ ' ' ~ repo.full_name ~ ' ' ~ (repo.description or ''))|lower }}"
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
<td><a href="/project/{{ full }}"><strong>{{ repo.name }}</strong></a></td>
<td>{{ repo.owner.login }}</td>
<td>{{ repo.open_issues_count or 0 }}</td>
<td>{{ st.get('in_progress', 0) + st.get('qa', 0) + st.get('review', 0) }}</td>
<td>{% if repo.next_milestone %}🎯 {{ repo.next_milestone.title }}{% if repo.next_milestone.late %} ⚠️{% endif %}{% else %}—{% endif %}</td>
<td>{% if repo.health_dot %}<a href="/health/{{ full }}" class="health-dot {{ repo.health_dot.level }}" title="{{ repo.health_dot.tooltip }}" aria-label="Santé : {{ repo.health_dot.tooltip }}"></a>{% else %}—{% endif %}</td>
<td>{{ repo.updated_at[:10] }}</td>
</tr>
{% endmacro %}
{% macro project_table(repos, tab_name, archived=False) %}
<div x-show="tab === '{{ tab_name }}'">
<table class="proj-table">
<thead>
<tr>
<th data-sort="name">Projet</th>
<th data-sort="owner">Propriétaire</th>
<th data-sort="open">Ouvertes</th>
<th data-sort="inflight">En vol</th>
<th>Prochain jalon</th>
<th>Santé</th>
<th data-sort="updated" data-first-dir="desc">Dernière activité</th>
</tr>
</thead>
<tbody>
{% for repo in repos %}{{ project_row(repo, archived) }}{% endfor %}
</tbody>
</table>
{% if not repos %}
<div class="empty-state">Aucun projet dans cette vue.</div>
{% endif %}
</div>
{% endmacro %}
<div x-show="view === 'grid'">
<!-- Épinglés (#20) : les dépôts étoilés dans Gitea, hors onglets. -->
<section id="pinned-section"{% if not pinned_repos %} hidden{% endif %}>
<h3 class="pinned-title">⭐ Épinglés</h3>
@@ -138,6 +239,14 @@
<div class="empty-state">🗄️ Aucun projet archivé. L'archivage se fait dans Gitea (Réglages du dépôt).</div>
{% endif %}
</div>
</div>
<!-- Vue liste dense (#22) : mêmes onglets, mêmes données. -->
<div x-show="view === 'list'">
{{ project_table(active_repos, 'active') }}
{{ project_table(inactive_repos, 'inactive') }}
{{ project_table(archived_repos, 'archived', archived=True) }}
</div>
</main>
<footer>
@@ -146,6 +255,8 @@
<script src="/static/js/live.js" defer></script>
<script>window.KANBANTE_CSRF = "{{ csrf_token }}";</script>
<script src="/static/js/stars.js" defer></script>
<script src="/static/js/dashboard.js" defer></script>
<script src="/static/js/tablesort.js" defer></script>
<script>
document.addEventListener('keydown', function (e) {
if (e.key === '/' && !/INPUT|TEXTAREA/.test(document.activeElement?.tagName || '')) {
+78 -18
View File
@@ -13,13 +13,72 @@
<main class="focus-main">
<section class="briefing-card">
<h1>🔮 Prévisions — « on finit quand ? »</h1>
<p>Réponse honnête, calculée depuis le journal : fermetures réelles par semaine (8 dernières), débit moyen/min/max sur les 4 dernières semaines complètes, et fourchette plutôt que faux chiffre précis. Sans historique de fermetures, aucune prévision n'est affichée.</p>
<p>Le tableau de bord des jalons, <strong>tous projets confondus</strong>, trié par risque : l'écart entre la date estimée (au débit réel du journal) et l'échéance. Réponse honnête — fourchettes plutôt que faux chiffres précis, et aucune prévision sans historique de fermetures.</p>
<p class="forecast-summary">
<span class="chip chip-total">🎯 {{ summary.total }} jalon(s) ouvert(s)</span>
{% if summary.late %}<span class="chip chip-late">⚠️ {{ summary.late }} en retard à ce rythme</span>{% endif %}
{% if summary.ontime %}<span class="chip chip-ok">✅ {{ summary.ontime }} dans les temps</span>{% endif %}
</p>
<div class="search-bar forecast-filter">
<input type="text" id="ms-filter" placeholder="🔍 Filtrer par projet ou jalon..." autocomplete="off">
</div>
</section>
{% for project in projects %}
<section class="briefing-card forecast-card">
<h2>📂 {{ project.full_name }}</h2>
{% if rows %}
<table class="proj-table pilot-table">
<thead>
<tr>
<th data-sort="project">Projet</th>
<th data-sort="title">Jalon</th>
<th data-sort="due">Échéance</th>
<th>Progression</th>
<th>ETA</th>
<th data-sort="gap" data-first-dir="desc">Écart</th>
<th data-sort="throughput">Débit / sem.</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr{% if row.anchor %} id="ms-{{ row.owner }}-{{ row.repo }}"{% endif %}
data-project="{{ row.project|lower }}" data-title="{{ row.title|lower }}"
data-due="{{ row.due_sort }}" data-gap="{{ row.gap_days if row.gap_days is not none else '' }}"
data-throughput="{{ row.throughput if row.throughput is not none else '' }}"
data-search="{{ (row.project ~ ' ' ~ row.title)|lower }}">
<td><a href="/project/{{ row.project }}">{{ row.project }}</a></td>
<td><a href="/board/{{ row.project }}?milestone={{ row.title|urlencode }}"><strong>{{ row.title }}</strong></a></td>
<td>{{ row.due_label }}</td>
<td>
{% if row.total %}
<div class="progress ms-progress pilot-progress"><span style="width: {{ row.pct }}%"></span></div>
<span class="ms-meta">{{ row.closed }}/{{ row.total }}</span>
{% else %}—{% endif %}
</td>
<td>{{ row.eta or '—' }}</td>
<td>
{% if row.verdict == 'late' %}
<span class="verdict verdict-late">⚠️ +{{ row.gap_days }} j</span>
{% elif row.verdict == 'ontime' %}
<span class="verdict verdict-ok">✅ dans les temps{% if row.gap_days is not none and row.gap_days < 0 %} ({{ -row.gap_days }} j d'avance){% endif %}</span>
{% elif row.verdict == 'nodue' %}
<span class="verdict">sans échéance</span>
{% else %}
<span class="verdict">pas d'ETA</span>
{% endif %}
</td>
<td>{{ row.throughput if row.throughput is not none else '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<section class="briefing-card"><p>Aucun jalon ouvert dans les projets actifs. Crée des jalons dans Gitea pour piloter par échéances.</p></section>
{% endif %}
<section class="briefing-card">
<h2>📈 Débit par projet</h2>
{% for project in projects %}
<div class="forecast-flex">
<div class="forecast-proj-name">📂 <a href="/project/{{ project.full_name }}">{{ project.full_name }}</a></div>
<svg class="sparkline" viewBox="0 0 168 48" role="img" aria-label="Fermetures par semaine">
{% for bar in project.bars %}
{% set px = (bar.h * 0.42)|round|int %}
@@ -30,25 +89,17 @@
</svg>
<div class="forecast-nums">
{% if project.stats %}
<p>Débit : <strong>{{ project.stats.avg }} / semaine</strong> en moyenne (min {{ project.stats.min }}, max {{ project.stats.max }})</p>
<p>Débit : <strong>{{ project.stats.avg }} / semaine</strong> (min {{ project.stats.min }}, max {{ project.stats.max }})</p>
{% else %}
<p>Débit : <strong>pas assez d'historique</strong> — aucune fermeture récente dans le journal.</p>
<p>Débit : <strong>pas assez d'historique</strong>.</p>
{% endif %}
<p>Restant ouvert : <strong>{{ project.remaining }}</strong>
{% if project.eta_label %} · 🔮 backlog {{ project.eta_label }}{% endif %}</p>
<p>Restant ouvert : <strong>{{ project.remaining }}</strong>{% if project.eta_label %} · 🔮 backlog {{ project.eta_label }}{% endif %}</p>
</div>
</div>
{% if project.milestones %}
<ul class="forecast-milestones">
{% for ms in project.milestones %}
<li>🎯 <strong>{{ ms.title }}</strong> — {{ ms.remaining }} restante(s){% if ms.due_on %} · échéance {{ ms.due_on }}{% endif %}{% if ms.eta %} · 🔮 {{ ms.eta }}{% endif %}{% if ms.verdict %} · {{ ms.verdict }}{% endif %}</li>
{% endfor %}
</ul>
{% endif %}
{% else %}
<p>Aucun projet actif.</p>
{% endfor %}
</section>
{% else %}
<div class="briefing-card"><p>Aucun projet actif.</p></div>
{% endfor %}
</main>
<footer>
<span>Kanbante v{{ version }} · prévisions = journal des fermetures, pas boule de cristal</span>
@@ -56,5 +107,14 @@
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
<script src="/static/js/live.js" defer></script>
<script src="/static/js/tablesort.js" defer></script>
<script>
document.getElementById('ms-filter')?.addEventListener('input', function (e) {
var q = e.target.value.toLowerCase();
document.querySelectorAll('.pilot-table tbody tr').forEach(function (row) {
row.style.display = !q || (row.dataset.search || '').includes(q) ? '' : 'none';
});
});
</script>
</body>
</html>
+36 -14
View File
@@ -13,23 +13,44 @@
<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>
<p>Tous les projets actifs, <strong>triés par sévérité</strong> — le rouge d'abord. Quelle colonne accumule (temps médian par colonne, d'après le journal) et quelles cartes sont devenues des <strong>zombies</strong>. Cliquez un projet pour le détail et les actions rapides.</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>
{% if projects %}
<table class="proj-table pilot-table">
<thead>
<tr>
<th data-sort="severity">Santé</th>
<th data-sort="project">Projet</th>
<th data-sort="open">Ouvertes</th>
<th data-sort="zombies" data-first-dir="desc">🧟 Zombies</th>
<th data-sort="slowest">🐌 Goulot</th>
<th></th>
</tr>
</thead>
<tbody>
{% for project in projects %}
<tr data-severity="{{ {'red': 0, 'orange': 1, 'green': 2}.get(project.dot.level, 3) }}"
data-project="{{ project.full_name|lower }}" data-open="{{ project.open_count }}"
data-zombies="{{ project.zombies|length }}"
data-slowest="{{ project.slowest.median_days if project.slowest else '' }}">
<td><span class="health-dot {{ project.dot.level }}" title="{{ project.dot.title }}"></span></td>
<td><a href="/health/{{ project.full_name }}"><strong>{{ project.full_name }}</strong></a></td>
<td>{{ project.open_count }}</td>
<td>{{ project.zombies|length }}{% if project.zombies %} <span class="ms-meta">(≥ {{ project.zombie_days }} j)</span>{% endif %}</td>
<td>
{% if project.slowest %}
{{ project.column_labels[project.slowest.column] }} — médiane {{ project.slowest.median_days }} j
{% else %}<span class="ms-meta">pas assez de données</span>{% endif %}
</td>
<td><a href="/health/{{ project.full_name }}">détail →</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="briefing-card"><p>Aucun projet actif.</p></div>
{% endfor %}
<section class="briefing-card"><p>Aucun projet actif.</p></section>
{% endif %}
</main>
<footer>
<span>Kanbante v{{ version }} · l'hygiène du flux, mesurée sur le journal — pas au feeling</span>
@@ -37,5 +58,6 @@
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
<script src="/static/js/live.js" defer></script>
<script src="/static/js/tablesort.js" defer></script>
</body>
</html>
+12
View File
@@ -11,6 +11,18 @@
{% include "_header.html" %}
<main class="focus-main">
{% if project_names %}
<div class="health-switch-bar">
<label for="health-switch">🩺 Projet :</label>
<select id="health-switch">
{% for name in project_names %}
<option value="{{ name }}"{% if name == full_name %} selected{% endif %}>{{ name }}</option>
{% endfor %}
{% if full_name not in project_names %}<option value="{{ full_name }}" selected>{{ full_name }}</option>{% endif %}
</select>
<a href="/health" class="health-back">← tous les projets</a>
</div>
{% endif %}
{% if health is none %}
<section class="briefing-card">
<h1>🩺 {{ full_name }}</h1>
+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.17.0"
version = "0.22.0"
description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11"
dependencies = [
+281 -44
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; }
@@ -1371,3 +1389,222 @@ body.sidebar-collapsed .side-foot { justify-content: center; }
.proj-dim { color: var(--text-3); }
.proj-empty { color: var(--text-2); font-size: 0.92rem; }
.proj-notes-body { white-space: pre-wrap; font-size: 0.93rem; background: var(--surface-2); border-radius: 10px; padding: 12px 14px; }
/* ── Dashboard vague 2 suite (0.18.0, #19 #21 #22 #23) ── */
.activity-line { margin-top: 8px; font-size: 0.82rem; color: var(--text-2); }
/* 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;
}
.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: 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; 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; }
.owner-head {
display: flex; justify-content: space-between; align-items: center;
width: 100%; padding: 9px 12px; margin-bottom: 10px; cursor: pointer;
background: var(--surface-1); color: var(--text);
border: 1px solid var(--border); border-radius: 10px;
font-size: 0.95rem; font-weight: 700;
}
.owner-head:hover { border-color: var(--accent, #58a6ff); }
.owner-head .owner-chevron { transition: transform 0.2s ease; font-size: 0.7rem; }
.owner-head[aria-expanded="false"] .owner-chevron { transform: rotate(-90deg); }
.owner-grid { padding: 0 !important; }
.card-menu-wrap { position: relative; }
.card-menu-btn {
background: transparent; border: none; cursor: pointer;
color: var(--text-2); font-size: 1.15rem; font-weight: 800;
padding: 2px 7px; border-radius: 8px; line-height: 1;
}
.card-menu-btn:hover { background: var(--surface-2); color: var(--text); }
.card-menu {
position: absolute; right: 0; top: calc(100% + 6px); z-index: 60;
min-width: 210px; display: flex; flex-direction: column; gap: 2px;
background: var(--surface-1); border: 1px solid var(--border);
border-radius: 12px; padding: 6px;
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.45);
}
.card-menu[hidden] { display: none !important; }
.card-menu a {
display: flex; align-items: center; min-height: 32px;
padding: 8px 10px; border-radius: 8px;
color: var(--text-2); font-size: 0.92rem;
}
.card-menu a:hover { background: var(--surface-2); color: var(--text); text-decoration: none; }
.proj-table {
width: calc(100% - 48px); margin: 0 24px 18px;
border-collapse: collapse; font-size: 0.93rem;
background: var(--surface-1); border: 1px solid var(--border);
border-radius: 12px; overflow: hidden;
}
.proj-table th, .proj-table td {
text-align: left; padding: 10px 14px;
border-bottom: 1px solid var(--border);
}
.proj-table thead th { color: var(--text-2); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em; }
.proj-table th.sortable { cursor: pointer; }
.proj-table th.sortable:hover { color: var(--text); }
.proj-table th[data-dir="asc"]::after { content: " ↑"; }
.proj-table th[data-dir="desc"]::after { content: " ↓"; }
.proj-table tbody tr:hover { background: var(--surface-2); }
@media (max-width: 900px) {
.proj-table { display: block; overflow-x: auto; }
.dash-view-toggle { margin-left: 0; }
}
/* ── Pilotage 0.19.0 : tableaux Prévisions/Santé (chantier C) ── */
.pilot-table { margin-bottom: 18px; }
.pilot-table tr[id] { scroll-margin-top: 76px; }
.pilot-progress { min-width: 90px; }
.forecast-summary { display: flex; gap: 8px; flex-wrap: wrap; margin: 10px 0 4px; }
.forecast-summary .chip {
font-size: 12.5px; padding: 3px 10px; border-radius: 999px;
background: rgba(255, 255, 255, .07); border: 1px solid var(--border, #333);
}
.forecast-summary .chip-late { background: rgba(248, 81, 73, .15); }
.forecast-summary .chip-ok { background: rgba(63, 185, 80, .15); }
.forecast-filter { margin-top: 10px; max-width: 420px; }
.forecast-filter input { width: 100%; }
.forecast-proj-name { font-weight: 600; min-width: 180px; }
.verdict {
font-size: 12.5px; padding: 2px 9px; border-radius: 999px; white-space: nowrap;
background: rgba(255, 255, 255, .06);
}
.verdict-late { background: rgba(248, 81, 73, .18); }
.verdict-ok { background: rgba(63, 185, 80, .16); }
a.ms-title { color: inherit; text-decoration: none; }
a.ms-title:hover { text-decoration: underline; }
.health-switch-bar {
display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap;
}
.health-switch-bar select {
padding: 8px 10px; border-radius: 8px; font-size: 14px; min-height: 38px;
background: var(--panel-2, #1c2330); color: inherit;
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; }
+173
View File
@@ -0,0 +1,173 @@
/**
* Kanbante — dashboard display tools (issues #21, #22, #23).
* Sort + group-by-owner for the card grids, sortable dense tables,
* and the per-card quick-actions menu. Preferences live in
* localStorage; the server markup stays the single source.
*/
(function () {
var LS_SORT = 'kanbante-dash-sort';
var LS_GROUP = 'kanbante-dash-group';
var LS_OWNERS = 'kanbante-dash-owners-closed';
function lsGet(key, fallback) {
try {
var v = localStorage.getItem(key);
return v === null ? fallback : v;
} catch (e) { return fallback; }
}
function lsSet(key, value) {
try { localStorage.setItem(key, value); } catch (e) { /* non-fatal */ }
}
/* ── Tri des grilles de cartes ─────────────────────────── */
var sortSelect = document.getElementById('dash-sort');
// 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);
function cardKey(card, mode) {
if (mode === 'name') return (card.getAttribute('data-name') || '').toLowerCase();
if (mode === 'open') return -(parseInt(card.getAttribute('data-open') || '0', 10));
if (mode === 'updated') return card.getAttribute('data-updated') || '';
if (mode === 'activity') return card.getAttribute('data-activity') || '';
return null; // 'default' : ordre serveur, on ne touche à rien
}
function sortCards(container, mode) {
var cards = Array.prototype.slice.call(
container.querySelectorAll('.project-card'));
if (mode === 'default' || !cards.length) return;
cards.sort(function (a, b) {
var ka = cardKey(a, mode), kb = cardKey(b, mode);
if (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;
});
cards.forEach(function (c) { container.appendChild(c); });
}
function applySort() {
var mode = sortSelect ? sortSelect.value : 'default';
lsSet(LS_SORT, mode);
grids.forEach(function (grid) {
if (groupToggle && groupToggle.checked) {
grid.querySelectorAll('.owner-grid').forEach(function (og) {
sortCards(og, mode);
});
} else {
sortCards(grid, mode);
}
});
}
/* ── Regroupement par propriétaire ─────────────────────── */
var groupToggle = document.getElementById('dash-group');
function closedOwners() {
try { return JSON.parse(lsGet(LS_OWNERS, '[]')); } catch (e) { return []; }
}
function setOwnerClosed(owner, closed) {
var list = closedOwners().filter(function (o) { return o !== owner; });
if (closed) list.push(owner);
lsSet(LS_OWNERS, JSON.stringify(list));
}
function groupGrid(grid) {
var cards = Array.prototype.slice.call(
grid.querySelectorAll(':scope > .project-card'));
if (!cards.length) return;
var byOwner = {};
cards.forEach(function (c) {
var o = c.getAttribute('data-owner') || '—';
(byOwner[o] = byOwner[o] || []).push(c);
});
Object.keys(byOwner).sort().forEach(function (owner) {
var wrap = document.createElement('div');
wrap.className = 'owner-group';
var head = document.createElement('button');
head.type = 'button';
head.className = 'owner-head';
var closed = closedOwners().indexOf(owner) >= 0;
head.setAttribute('aria-expanded', closed ? 'false' : 'true');
head.innerHTML = '<span>🗂️ ' + owner + ' (' + byOwner[owner].length + ')</span><span class="owner-chevron">▾</span>';
var body = document.createElement('div');
body.className = 'project-grid owner-grid';
if (closed) body.hidden = true;
head.addEventListener('click', function () {
body.hidden = !body.hidden;
head.setAttribute('aria-expanded', body.hidden ? 'false' : 'true');
setOwnerClosed(owner, body.hidden);
});
byOwner[owner].forEach(function (c) { body.appendChild(c); });
wrap.appendChild(head);
wrap.appendChild(body);
grid.appendChild(wrap);
});
}
function ungroupGrid(grid) {
grid.querySelectorAll('.owner-group').forEach(function (wrap) {
var body = wrap.querySelector('.owner-grid');
Array.prototype.slice.call(body.querySelectorAll('.project-card'))
.forEach(function (c) { grid.appendChild(c); });
wrap.remove();
});
}
function applyGrouping() {
var on = !!(groupToggle && groupToggle.checked);
lsSet(LS_GROUP, on ? '1' : '0');
grids.forEach(function (grid) {
ungroupGrid(grid);
if (on) groupGrid(grid);
});
applySort();
}
if (sortSelect) {
sortSelect.value = lsGet(LS_SORT, 'default');
sortSelect.addEventListener('change', applySort);
}
if (groupToggle) {
groupToggle.checked = lsGet(LS_GROUP, '0') === '1';
groupToggle.addEventListener('change', applyGrouping);
}
applyGrouping(); // applique regroupement + tri mémorisés
/* ── Menu ⋯ d'actions rapides (#23) ────────────────────── */
function closeMenus() {
document.querySelectorAll('.card-menu').forEach(function (m) {
m.hidden = true;
});
document.querySelectorAll('[data-menu-btn]').forEach(function (b) {
b.setAttribute('aria-expanded', 'false');
});
}
document.querySelectorAll('[data-menu-btn]').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
var menu = btn.parentElement.querySelector('.card-menu');
var willOpen = menu.hidden;
closeMenus();
if (willOpen) {
menu.hidden = false;
btn.setAttribute('aria-expanded', 'true');
}
});
});
document.addEventListener('click', closeMenus);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeMenus();
});
/* Le tri des tableaux denses (#22) vit dans tablesort.js,
partagé avec /forecast et /health (0.19.0). */
})();
+9
View File
@@ -5,6 +5,15 @@
(function () {
var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
/* Project switcher (0.19.0): jump between projects without
going back to the health index. */
var switcher = document.getElementById('health-switch');
if (switcher) {
switcher.addEventListener('change', function () {
window.location.href = '/health/' + switcher.value;
});
}
function post(url, fields) {
var data = new FormData();
Object.keys(fields).forEach(function (k) { data.append(k, fields[k]); });
+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);
})();
+48
View File
@@ -0,0 +1,48 @@
/**
* Kanbante — tri des tableaux « pilotage » (.proj-table), partagé
* entre le dashboard, /forecast et /health (0.19.0).
* Tri client par colonne : les lignes portent leurs valeurs dans
* des attributs data-<clé> ; cliquer un <th data-sort="clé">
* trie dans un sens puis dans l'autre.
*/
(function () {
document.querySelectorAll('.proj-table').forEach(function (table) {
var tbody = table.querySelector('tbody');
if (!tbody) return;
table.querySelectorAll('th[data-sort]').forEach(function (th) {
th.classList.add('sortable');
th.addEventListener('click', function () {
var key = th.getAttribute('data-sort');
// 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');
});
th.setAttribute('data-dir', dir);
var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
rows.sort(function (a, b) {
var va = a.getAttribute('data-' + key) || '';
var vb = b.getAttribute('data-' + key) || '';
var cmp;
// 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);
}
return dir === 'asc' ? cmp : -cmp;
});
rows.forEach(function (r) { tbody.appendChild(r); });
});
});
});
})();
+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
+74
View File
@@ -0,0 +1,74 @@
"""Palier 14 (0.18.0): vague 2 suite — activité 7 jours (#19),
tri/regroupement (#21), vue liste dense (#22), actions rapides (#23)."""
from datetime import datetime, timedelta, timezone
from app import models
def _ts(days_ago: int) -> str:
dt = datetime.now(timezone.utc) - timedelta(days=days_ago)
return dt.strftime("%Y-%m-%d 12:00:00")
def test_activity_line_recent_week(client, fake_gitea):
for _ in range(2):
models.log_event("Projets/demo", 1, "closed",
to_column="done", created_at=_ts(1))
models.log_event("Projets/demo", 5, "moved",
from_column="backlog", to_column="todo",
created_at=_ts(0))
page = client.get("/")
assert "2 fermée(s) · 1 déplacement(s) cette semaine" in page.text
def test_activity_line_idle_project(client, fake_gitea):
models.log_event("Projets/vieux", 9, "moved",
from_column="backlog", to_column="todo",
created_at=_ts(35))
page = client.get("/")
assert "aucune activité kanban depuis 35 j" in page.text
def test_dashboard_display_tools_markup(client, fake_gitea):
page = client.get("/")
assert "dashState" in page.text
assert 'id="dash-sort"' in page.text
assert 'id="dash-group"' in page.text
assert "Grouper par propriétaire" in page.text
assert "setView('grid')" in page.text
assert "setView('list')" in page.text
def test_dense_list_view_tables(client, fake_gitea):
page = client.get("/")
assert page.text.count('class="proj-table"') == 3
assert 'data-sort="open"' in page.text
assert 'data-sort="inflight"' in page.text
# La ligne dense de demo : lien page projet + compteurs.
assert 'href="/project/Projets/demo"' in page.text
def test_dashboard_js_contract(client, fake_gitea):
js = client.get("/static/js/dashboard.js")
assert js.status_code == 200
assert "kanbante-dash-sort" in js.text
assert "kanbante-dash-group" in js.text
assert "owner-group" in js.text
assert "data-menu-btn" in js.text
def test_card_quick_actions_menu(client, fake_gitea):
page = client.get("/")
assert "data-menu-btn" in page.text
# urlencode de Jinja conserve les « / » : le lien est valide tel quel.
assert 'href="/capture?repo=Projets/demo"' in page.text
assert 'href="/triage/Projets/demo"' in page.text
assert 'href="/sprint/Projets/demo"' in page.text
assert 'href="/health/Projets/demo"' in page.text
def test_capture_prefilled_repo(client, fake_gitea):
page = client.get("/capture?repo=Projets/kanbante")
assert page.status_code == 200
assert 'value="Projets/kanbante"' in page.text
+112
View File
@@ -0,0 +1,112 @@
"""Palier 15 (0.19.0) — Performance + navigation Pilotage.
- A : les boucles par dépôt sont parallélisées (le comportement
visible ne change pas — couvert par les paliers précédents) et
le cache du client passe en stale-while-revalidate (testé dans
test_gitea_client.py).
- C : /forecast devient un tableau de bord des jalons (une ligne
par jalon, ancres depuis le dashboard) et /health un tableau
trié par sévérité, avec sélecteur de projet sur le détail.
"""
import asyncio
import httpx
from app.gitea_client import GiteaClient
def test_forecast_is_a_portfolio_table(client):
r = client.get("/forecast")
assert r.status_code == 200
assert "pilot-table" in r.text
assert "jalon(s) ouvert(s)" in r.text
# One row for the demo milestone, anchored for dashboard links.
assert 'id="ms-Projets-demo"' in r.text
assert "Sprint 1" in r.text
# The per-project pace table is still there.
assert "Débit par projet" in r.text
def test_dashboard_milestone_links_into_forecast(client):
r = client.get("/")
assert r.status_code == 200
assert 'href="/forecast#ms-Projets-demo"' in r.text
def test_health_index_is_a_severity_table(client):
r = client.get("/health")
assert r.status_code == 200
assert "pilot-table" in r.text
assert "data-severity" in r.text
assert "Projets/demo" in r.text
def test_health_detail_has_project_switcher(client):
r = client.get("/health/Projets/demo")
assert r.status_code == 200
assert 'id="health-switch"' in r.text
assert '<option value="Projets/demo" selected>' in r.text
assert "tous les projets" in r.text
async def test_swr_serves_stale_then_refreshes_in_background():
"""Stale entries are served instantly; the refresh lands
asynchronously and the next read sees the fresh value."""
calls = {"n": 0}
def handler(request):
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"id": 7})
calls["n"] += 1
return httpx.Response(200, json={"data": [{"id": calls["n"]}]})
client = GiteaClient(
base_url="http://gitea.test",
token="",
cache_ttl=60,
transport=httpx.MockTransport(handler),
)
first = await client.search_repos()
assert first == [{"id": 1}]
# Fresh cache: no new fetch.
assert await client.search_repos() == [{"id": 1}]
assert calls["n"] == 1
# Age the entry beyond the TTL: the stale value comes back
# immediately (same content, no wait)…
ts, value = client._cache["search_repos"]
client._cache["search_repos"] = (ts - 120, value)
assert await client.search_repos() == [{"id": 1}]
# …and the background refresh replaces it.
await asyncio.sleep(0.3)
assert await client.search_repos() == [{"id": 2}]
await client.aclose()
async def test_swr_mutation_clear_blocks_stale_resurrection():
"""A refresh started before a cache clear (mutation) must not
put its now-stale result back."""
calls = {"n": 0}
def handler(request):
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"id": 7})
calls["n"] += 1
return httpx.Response(200, json={"data": [{"id": calls["n"]}]})
client = GiteaClient(
base_url="http://gitea.test",
token="",
cache_ttl=60,
transport=httpx.MockTransport(handler),
)
await client.search_repos()
ts, value = client._cache["search_repos"]
client._cache["search_repos"] = (ts - 120, value)
await client.search_repos() # triggers background refresh
client._cache_clear() # a mutation lands first
await asyncio.sleep(0.3)
# The cache was cleared and the in-flight refresh was disarmed:
# the next read fetches fresh instead of serving the old value.
assert await client.search_repos() == [{"id": 3}]
await client.aclose()
+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