57 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
Spark 018bde0fa5 chore(release): Kanbante 0.17.0 — vague 2 : cartes projets enrichies et page Projet
CI / test (push) Successful in 1m46s
2026-10-11 22:52:22 -04:00
Spark 0883191f57 test(0.17.0): jalon, santé, étoiles, page projet — 7 tests, 178 au total 2026-10-11 22:52:22 -04:00
Spark ba47b1a9ac feat(projets): page Projet /project/{owner}/{repo} (#25)
Accueil du projet assemblé des briques existantes: jalons
ouverts (progression, échéances, ETA), santé en résumé, débit,
activité récente du journal, notes; actions board/capture/
triage/santé/wallboard/Gitea. Le titre des cartes y pointe.
2026-10-11 22:52:22 -04:00
Spark cf58bb9d0c feat(projets): épinglés par étoiles Gitea (#20)
list_starred/set_starred sur /user/starred (vérité dans la
forge), endpoint POST /api/projects/{o}/{r}/star (CSRF), section
Épinglés hors onglets, bouton étoile sur les cartes et bascule
sans rechargement; les épinglés sortent de leur grille d'onglet.
2026-10-11 22:52:22 -04:00
Spark b33b67fcb4 feat(dashboard): prochain jalon et pastille de santé sur les cartes (#17, #18)
Bloc jalon (progression Gitea, échéance FR, retard, ETA journal
avec signal après-échéance — mêmes définitions que le bandeau
du board) et point santé vert/orange/rouge dérivé de
_project_health, info-bulle et lien vers /health du projet.
2026-10-11 22:52:22 -04:00
Spark b4dc8f0d4f chore(release): Kanbante 0.16.1 — icône palette et accordéons du menu
CI / test (push) Successful in 1m50s
2026-10-11 21:12:09 -04:00
Spark e810f5668a test(0.16.1): contrat icône palette et sections accordéon 2026-10-11 21:12:09 -04:00
Spark 2290560627 feat(nav): palette en icône près du titre, sections accordéon (retour #16)
Le bouton Recherche & commandes devient une icône 🔍 seule à
droite du titre (sous le logo en rail replié). Les groupes
deviennent des accordéons: chevron animé, transition douce,
sections fermées mémorisées (appliquées avant le rendu), section
de la page courante toujours ouverte; rail replié = icônes.
2026-10-11 21:12:09 -04:00
Spark d4b3b324f5 chore(release): Kanbante 0.16.0 — layout barre latérale et wallboard verrouillé
CI / test (push) Successful in 1m46s
2026-10-11 20:48:46 -04:00
Spark d395642f16 test(0.16.0): contrat barre latérale et mur — 6 tests adaptés/ajoutés, 171 au total 2026-10-11 20:48:46 -04:00
Spark ec3590d25f feat(wall): hauteur verrouillée 100dvh, pied toujours visible
La diapositive ne grandit plus au-delà de l'écran: plus de barre
de défilement de page, « Ouvertes »/« Fermées »/« EN DIRECT »
toujours affichés au-dessus de la navigation des diapos; cartes
tronquées en fondu + ligne « +N autres cartes en vol »; mode
compact sous 760px de hauteur (TV 720p).
2026-10-11 20:48:46 -04:00
Spark 16f03184bf feat(nav): refonte du layout — barre latérale repliable, topbar mince (issue #16)
Retour de Bruno sur la 0.15.0 (disposition pas conviviale, trop
de place, mobile inchangé): la barre horizontale disparaît.
Barre latérale groupée (Principal/Rituels/Pilotage/Outils),
repliable en rail d'icônes mémorisé sur desktop, tiroir
hors-canvas sur mobile; topbar de 52px (fil d'Ariane + actions).
2026-10-11 20:48:46 -04:00
Spark 6fdf39959c chore(release): Kanbante 0.15.0 — header unifié desktop/mobile
CI / test (push) Successful in 1m54s
2026-10-11 20:15:23 -04:00
Spark 18e1dd0f3d test(0.15.0): header partagé, états actifs, exceptions wall — 5 tests, 170 au total 2026-10-11 20:15:23 -04:00
Spark 58204e621e fix(forecast): semaines calées sur le référentiel UTC du journal
Les horodatages board_events sont en UTC (SQLite): une fermeture
en soirée locale tombait hors des semaines locales et faussait
le débit hebdomadaire.
2026-10-11 20:15:23 -04:00
Spark 2fde103e93 feat(nav): header unifié desktop/mobile (issue #16)
Partiel _header.html unique pour les 19 pages (fin des copies qui
dérivaient): desktop une ligne — primaires + menu Plus + Ctrl K,
page active déduite du gabarit dans render(); mobile ≤900px
burger + panneau ≥44px, Échap/clic extérieur; wallboard sans
header et triage sur le même partiel. nav.js partagé.
2026-10-11 20:15:23 -04:00
Spark 24a1ebb9a6 chore(release): Kanbante 0.14.0 — branche depuis la carte, santé du flux
CI / test (push) Successful in 1m49s
2026-10-11 14:49:38 -04:00
Spark 4c807a4344 test(0.14.0): branche depuis la carte, dwell/zombies — 8 tests, 165 au total 2026-10-11 14:49:38 -04:00
Spark 6453b53df4 feat(health): santé du flux — goulots et cartes zombies (issue #9)
app/flow.py: séjours par colonne reconstruits du journal,
médianes, colonne la plus lente; zombies = aucune activité
(journal/Gitea) depuis KANBANTE_ZOMBIE_DAYS (30). Pages /health
et /health/{o}/{r} + actions rapides (commenter/backlog/fermer).
2026-10-11 14:49:38 -04:00
Spark 1932d11457 feat(branch): créer la branche depuis la carte (issue #3)
slugify + branch_name_for (issue-<n>-<slug>, reconnu par #1/#2);
client create_branch; POST /api/issues/{o}/{r}/{n}/branch (nom
libre validé, idempotent si existante); la carte passe en En
cours, journalisée source branch, étiquette synchronisée;
formulaire dans la section Code lié du panneau détail.
2026-10-11 14:49:38 -04:00
Spark 5b41c5ea74 chore(release): Kanbante 0.13.0 — code lié, PR fusionnée, standup, règles
CI / test (push) Successful in 1m43s
2026-10-11 14:21:11 -04:00
Spark 370dafd266 test(0.13.0): code lié, webhook PR, standup, règles — 10 tests, 157 au total 2026-10-11 14:21:11 -04:00
Spark 7eef8bb604 feat(rules): règles d'automatisation déclaratives (issue #8)
KANBANTE_RULES (JSON validé): conditions colonne/âge/étiquette/
WIP -> add_label / notify (garde quotidienne) / move_top;
évaluation 15 min + POST /api/rules/run; actions journalisées
(événement rule); page /rules. Aucune fermeture possible.
2026-10-11 14:21:11 -04:00
Spark 0b48026752 feat(standup): standup automatique hier/aujourd'hui/blocages (issue #6)
format_standup_text + _standup_data (journal de la veille, focus
du jour, vieillissantes QA/Révision); page /standup copiable,
/api/standup; inclusion optionnelle au briefing ntfy
(KANBANTE_BRIEFING_INCLUDE_STANDUP).
2026-10-11 14:21:11 -04:00
Spark 675ce09083 feat(code): cartes liées au code — branches, PRs, commits (issue #1)
app/code_links.py: appariement pur (#n dans titres/corps/messages,
numéro en jeton de branche). Client: list_branches/list_pulls/
list_commits. Badge sur les cartes (board + fragments live),
section Code lié du panneau détail, /api/issues/{o}/{r}/{n}/code.
2026-10-11 14:21:11 -04:00
Spark 03fa1eb44e chore(release): Kanbante 0.12.0 — focus du jour, prévisions, récit de sprint
CI / test (push) Successful in 1m43s
2026-10-11 13:55:03 -04:00
Spark 8e0c7b8f09 test(0.12.0): prévisions (fonctions pures + pages), focus du jour, récit — 11 tests, 147 au total 2026-10-11 13:55:03 -04:00
Spark 4c5d1dee23 feat(sprint): récit de sprint généré par le journal (issue #4)
app/sprint.py + /sprint/{o}/{r} (rendu markdown, copier/télécharger)
+ /api/sprint en JSON: livré avec temps de cycle, plus lentes,
reporté, chiffres de période. Lien depuis le bandeau milestone.
Navigation: pilules Aujourd'hui/Prévisions et actions palette.
2026-10-11 13:55:03 -04:00
Spark cdabc77ccd feat(forecast): prévisions au débit réel du journal (issue #7)
app/forecast.py: fermetures hebdo (8 sem.), débit moy/min/max sur
4 semaines complètes, ETA en fourchette, silence sans historique.
Dashboard (épuisement du backlog), bandeau milestone (alerte
échéance), page /forecast (sparkline SVG, verdicts), /api/metrics
étendu (weekly_closures, throughput_4w).
2026-10-11 13:55:03 -04:00
Spark fbe58bbf16 feat(today): focus du jour — le rituel des 3 cartes (issue #5)
Table today_picks; POST /api/today/pin (max 3, idempotent) et
/unpin; page /today (picks en direct, candidates de l'encours,
reprises d'hier, récap du jour depuis le journal); bouton 📌 sur
les cartes (délégation capture dans today.js, fragments live
compris); sections dédiées dans le briefing du matin.
2026-10-11 13:55:03 -04:00
Spark 6b9ec1ff99 chore(release): Kanbante 0.11.0 — wallboard navigable
CI / test (push) Successful in 1m42s
2026-10-11 13:37:54 -04:00
Spark 1d3794ba55 test(0.11.0): navigation wallboard (markup multi-diapos, contrat JS) — 3 tests, 136 au total 2026-10-11 13:37:54 -04:00
Spark 715687da66 feat(wall): navigation manuelle (flèches, boutons, pastilles) + position mémorisée + fix SSE
←/→ et contrôles à l'écran changent de projet; navigation manuelle
= pause de rotation 90 s; Espace = pause franche avec badge. La
position survit aux rechargements via sessionStorage (le cycle de
12 projets x 30 s dépassait le refresh de sécurité de 5 min: le
mur semblait figé). Fix: l'écoute SSE utilisait onmessage, inerte
pour les événements nommés 'gitea' du hub.
2026-10-11 13:37:54 -04:00
Spark 2bc8ccaf4f chore(release): Kanbante 0.10.0 — mode Triage
CI / test (push) Successful in 1m43s
2026-10-11 13:17:21 -04:00
Spark 17a131d71b test(0.10.0): triage (sélecteur, file backlog, assign-self, extraits) — 6 tests, 133 au total 2026-10-11 13:17:21 -04:00
Spark 8a32d080b5 feat(triage): mode triage — vider le backlog au clavier (issue #11)
/triage (sélecteur par taille de backlog) + /triage/{o}/{r}
(session plein cadre, file embarquée): 1-5 colonnes, X fermer,
L étiquettes, A s'assigner (nouvel endpoint assign-self +
client.set_issue_assignees, idempotent), Entrée suivante, Z annuler.
Progression + résumé de session; décisions via /api/move et les
API existantes. Entrées: board + palette.
2026-10-11 13:17:21 -04:00
66 changed files with 7389 additions and 348 deletions
+393
View File
@@ -1,5 +1,398 @@
# 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)
- **Prochain jalon sur la carte projet** (#17) : le jalon ouvert le
plus proche de son échéance s'affiche sur la carte du dashboard —
progression (compteurs Gitea), échéance en français, alerte si
en retard, et ETA du journal en regard avec signal « après
l'échéance » quand la prévision dépasse la date.
- **Pastille de santé par projet** (#18) : point vert/orange/rouge
sur chaque carte active, règles uniques partagées avec /health
(zombies, cartes coincées en QA/Révision, médiane du goulot),
info-bulle détaillée, clic vers la santé du projet.
- **Projets épinglés via les étoiles Gitea** (#20) : section
« ⭐ Épinglés » en tête du dashboard pour les dépôts étoilés ;
bouton ☆/⭐ sur chaque carte (PUT/DELETE /user/starred) — la
vérité vit dans Gitea, aucune table locale ; bascule sans
rechargement (stars.js). Échec de l'API : dashboard intact.
- **Page Projet** (#25) : `/project/{owner}/{repo}` — accueil du
projet : jalons ouverts avec progression/échéances/ETA, santé en
résumé (goulot, zombies), débit des prévisions, activité
récente du journal, notes du projet et actions (board, capture,
triage, santé, wallboard figé, Gitea). Le titre des cartes du
dashboard pointe désormais ici ; le board reste à un clic
(lien 📋 Board sur chaque carte et bouton principal).
7 tests ajoutés (178 au total).
## 0.16.1 — 2026-10-11
### Améliorations (retour de Bruno sur la barre latérale)
- Le bouton « Recherche & commandes » devient une **icône 🔍
seule**, placée **à droite du titre** dans l'en-tête de la barre
latérale (info-bulle et Ctrl+K inchangés) ; en rail replié, elle
passe sous le logo, toujours accessible.
- Les groupes du menu deviennent des **sections accordéon** :
en-têtes cliquables au look moderne (micro-libellés, chevron
animé), ouverture/fermeture en transition douce (technique
`grid-template-rows`), **état mémorisé par navigateur** appliqué
avant le premier rendu. La section de la page courante s'ouvre
toujours automatiquement. En rail replié, l'accordéon s'efface :
toutes les icônes restent visibles.
## 0.16.0 — 2026-10-11
### Nouveautés
- **Layout repensé** (issue #16, retour de Bruno sur la 0.15.0 :
disposition pas conviviale, trop de place, mobile inchangé) —
la barre de pilules horizontale disparaît au profit d'une
**barre latérale** : navigation groupée (Principal, Rituels,
Pilotage, Outils), bouton palette en tête, **repliable en rail
d'icônes** sur desktop (état mémorisé par navigateur), et
**tiroir hors-canvas** sur mobile (burger dans une topbar mince
de 52 px : fil d'Ariane + actions contextuelles seulement).
Le partiel `_header.html` reste l'unique source du cadre.
- **Wallboard** : hauteur verrouillée à l'écran (`100dvh`,
`overflow: hidden`) — **plus aucune barre de défilement de
page** ; le pied de diapositive (« ✅ Fermées aujourd'hui »,
« 📋 Ouvertes », « ● EN DIRECT ») reste **toujours visible**,
au-dessus de la zone réservée à la navigation des diapos ; la
liste des cartes se tronque avec un fondu et une ligne
« … +N autres cartes en vol » ; mode compact sous 760 px de
hauteur (TV 720p).
6 tests adaptés/ajoutés (171 au total).
## 0.15.0 — 2026-10-11
### Nouveautés
- **Header refait** (issue #16, créée et spécifiée par Bruno) :
les 19 copies de markup de header sont remplacées par un
**partiel unique `_header.html`**. Desktop : une seule ligne —
5 entrées primaires (Projets, Focus, Aujourd'hui, Recherche,
Capture), menu « Plus ▾ » pour les 9 autres, Ctrl+K à droite,
page active marquée (y compris dans « Plus »). Mobile
(≤ 900 px) : burger + panneau plein largeur, cibles ≥ 44 px,
fermeture Échap / clic extérieur. La page active est déduite du
gabarit dans `render()` (`active_nav`) — une seule source de
vérité. Le wallboard garde son plein écran sans header et la
session triage utilise le même partiel. `static/js/nav.js`
partagé, aucune dépendance ajoutée.
### Corrigé
- Prévisions : les semaines se calent désormais sur le référentiel
du journal (UTC, comme les horodatages `board_events`) — une
fermeture journalisée en soirée locale (déjà le lendemain en
UTC) tombait hors de toutes les semaines et faussait le débit.
5 tests ajoutés (170 au total).
## 0.14.0 — 2026-10-11
### Nouveautés
- **Créer la branche depuis la carte** (issue #3) : le panneau
détail propose `issue-<n>-<slug du titre>` (slug ASCII, 40 car.
max, nom libre accepté après validation git-ref) et la crée
dans Gitea depuis la branche par défaut
(`POST /api/issues/{o}/{r}/{n}/branch`). La carte passe en
« En cours » (journalisé, source `branch`, étiquette synchronisée)
et la branche est aussitôt reconnue par le code lié (#1) et par
l'automatisation de fusion (#2). Réponse idempotente si la
branche existe déjà.
- **Santé du flux** (issue #9) : `app/flow.py` reconstruit les
séjours par colonne depuis le journal (entrée → déplacement /
fermeture) : temps médian et moyen par colonne, colonne la plus
lente (goulot, ≥ 2 séjours). **Cartes zombies** : ouvertes sans
activité (journal ou Gitea) depuis `KANBANTE_ZOMBIE_DAYS`
(défaut 30). Pages `/health` (résumé global) et
`/health/{owner}/{repo}` (détail + actions rapides sur les
zombies : commenter « toujours pertinent ? », remettre au
Backlog, fermer — via les API existantes).
8 tests ajoutés (165 au total). **La feuille de route
« Différenciation » est complète : 15/15.**
## 0.13.0 — 2026-10-11
### Nouveautés
- **Cartes liées au code** (issue #1) : `app/code_links.py` apparie
branches (`feature/12-x`, `issue-12`), PRs (références `#n` dans
titre/corps ou branche) et commits récents (références `#n`).
Badge `⑂` sur les cartes (board + fragments temps réel), section
« 🧬 Code lié » dans le panneau détail, endpoint
`GET /api/issues/{o}/{r}/{n}/code`.
- **PR fusionnée → carte Terminé** (issue #2) : le webhook accepte
`pull_request` fermée + fusionnée ; les issues liées sont
fermées dans Gitea si besoin et leurs cartes passent en Terminé,
idempotent, journalisé source `webhook`.
- **Standup automatique** (issue #6) : page `/standup` +
`/api/standup` — fermé/déplacé **hier** (journal, titres
résolus), focus **aujourd'hui**, **blocages** (cartes qui
vieillissent en QA/Révision) ; bouton copier ; inclusion
optionnelle au briefing ntfy (`KANBANTE_BRIEFING_INCLUDE_STANDUP=1`).
- **Règles d'automatisation** (issue #8) : moteur déclaratif
`KANBANTE_RULES` (JSON, validé : conditions `column`,
`min_age_days`, `label`, `wip_over` ; actions `add_label`,
`notify`, `move_top`), évalué toutes les 15 min par le
planificateur et à la demande ; chaque action est journalisée
(événement `rule`, source `rule`) ; page `/rules` avec état et
dernière évaluation. Garde-fou : aucune action de fermeture
ou de déplacement entre colonnes.
10 tests ajoutés (157 au total).
## 0.12.0 — 2026-10-11
### Nouveautés
- **Focus du jour** (issue #5) : le rituel des 3 cartes. Table
`today_picks` ; bouton 📌 sur chaque carte du board (et sur les
candidates) ; page `/today` calme — picks du jour avec leur
colonne en direct, candidates de l'encours, **reprises d'hier
non terminées** reproposées, récapitulatif « ça a bougé
aujourd'hui » tiré du journal. Le briefing du matin inclut les
sections « Focus du jour » et « À reprendre d'hier ».
`POST /api/today/pin` (max 3, idempotent) et `/unpin`.
- **Prévisions** (issue #7) : `app/forecast.py` — fermetures par
semaine depuis le journal (8 semaines), débit moy/min/max sur
les 4 dernières semaines complètes, projection en **fourchette**
(jamais un faux chiffre précis) et silence explicite sans
historique. Affichées sur le dashboard (épuisement du backlog),
dans le bandeau milestone (avec alerte si l'ETA dépasse
l'échéance) et sur `/forecast` (sparkline SVG inline, verdict
par milestone). `/api/metrics` expose `weekly_closures` et
`throughput_4w`.
- **Récit de sprint** (issue #4) : `app/sprint.py` + page
`/sprint/{owner}/{repo}?milestone=` — le bilan markdown du
milestone écrit par le journal : livré (avec temps de cycle par
carte), cartes les plus lentes, reporté avec colonnes, chiffres
de la période ; boutons **Copier** et **Télécharger .md**,
endpoint `GET /api/sprint/...` en JSON, lien « 📖 Récit de
sprint » dans le bandeau milestone du board.
11 tests ajoutés (147 au total).
## 0.11.0 — 2026-10-11
### Nouveautés
- **Wallboard navigable** : flèches `←` / `→` du clavier, boutons
❮ ❯ et pastilles cliquables pour changer de projet à la demande ;
une navigation manuelle met la rotation en pause 90 s, `Espace`
bascule une pause franche (badge visible). La **position est
mémorisée** (sessionStorage) entre les rechargements : le mur ne
repart plus à la première diapo à chaque refresh — avec 12
projets actifs × 30 s, le cycle complet (6 min) dépassait le
rechargement de sécurité (5 min), rendant les dernières diapos
inatteignables et donnant l'impression d'un mur figé.
### Correction
- L'écoute temps réel du wallboard utilisait `onmessage`, qui ne
se déclenche jamais pour les événements SSE **nommés** (`gitea`)
émis par le hub : les rechargements en direct ne partaient pas,
seul le filet de 5 minutes fonctionnait. Corrigé en
`addEventListener('gitea', …)`.
3 tests ajoutés (136 au total).
## 0.10.0 — 2026-10-11
### Nouveautés
- **Mode Triage** (issue #11) : vider un backlog au clavier, façon
« inbox zero ». `/triage` liste les projets actifs et la taille
de leur backlog ; `/triage/{owner}/{repo}` présente les cartes
du Backlog **une par une, plein cadre** (titre, extrait du corps,
étiquettes, âge), file embarquée côté client — aucun rechargement
entre cartes. Décisions : `1`–`5` envoient dans À faire / En
cours / QA / Révision / Terminé, `X` ferme, `L` ouvre le panneau
d'étiquettes (bascule via l'API existante), `A` s'assigne l'issue
(nouvel endpoint `POST /api/issues/{o}/{r}/{n}/assign-self`,
idempotent), `Entrée` passe à la suivante, `Z` annule le dernier
déplacement. Barre de progression « Carte n / N » et résumé final
par décision. Les raccourcis sont affichés en permanence et
cliquables. Points d'entrée : bouton « Triage du backlog » sur le
board et action dans la palette Ctrl+K.
6 tests ajoutés (133 au total).
## 0.9.0 — 2026-10-11
### Nouveautés
+27 -1
View File
@@ -16,6 +16,16 @@
- ⏳ **Vieillissement + limites WIP** — badge d'ancienneté par colonne et limites souples configurables
- 🎯 **Vue Focus** — tout ce qui est En cours / en QA / En révision, tous projets confondus (`/focus`)
- 🖥️ **Wallboard** — mode TV plein écran (`/wall`) : rotation automatique entre les projets actifs, cartes en vol et compteurs en très grands caractères, rafraîchi en direct ; `?repo=Proprietaire/depot` fige un seul projet
- 🌅 **Focus du jour** — `/today` : jusqu'à 3 cartes épinglées (bouton 📌 sur les cartes), candidates de l'encours, reprises d'hier non terminées, récapitulatif du soir ; intégrées au briefing
- 🔮 **Prévisions** — débit réel du journal (fermetures/semaine), date d'épuisement du backlog et ETA des milestones en fourchette honnête, sur le dashboard, le bandeau milestone et `/forecast` (sparkline SVG)
- 📖 **Récit de sprint** — le bilan markdown d'un milestone (livré, cycles, plus lentes, reporté) généré depuis le journal, page `/sprint/...` + copie/téléchargement en un clic
- 🧬 **Code lié** — branches, PRs et commits référant `#n` sur la carte (badge `⑂`) et dans le panneau détail (section « Code lié »)
- 🔀 **PR fusionnée → Terminé** — le webhook `pull_request` retrouve les issues liées (références `#n`, branche `issue-n-*`), les ferme et déplace leurs cartes, journalisé
- 🗣️ **Standup automatique** — `/standup` : fermé/déplacé hier, focus du jour, blocages (QA/Révision qui vieillissent), copiable d'un clic ; ajout optionnel au briefing (`KANBANTE_BRIEFING_INCLUDE_STANDUP=1`)
- ⚙️ **Règles d'automatisation** — moteur déclaratif `KANBANTE_RULES` (JSON) évalué toutes les 15 min : conditions colonne/âge/étiquette/WIP → étiquette, notification ntfy, tête de colonne ; jamais de fermeture par règle ; page `/rules`
- 🌿 **Branche depuis la carte** — le panneau détail crée `issue-<n>-<slug>` dans Gitea depuis la branche par défaut, déplace la carte en « En cours » et journalise ; la PR qui en naîtra sera reconnue automatiquement
- 🩺 **Santé du flux** — `/health` : temps médian par colonne (du journal), colonne la plus lente, et cartes zombies (aucune activité depuis `KANBANTE_ZOMBIE_DAYS`) avec actions rapides (commenter, remettre au backlog, fermer)
- 📥 **Mode Triage** — `/triage` : le backlog se vide carte par carte, plein cadre, au clavier (`1-5` colonnes, `L` étiquettes, `A` s'assigner, `X` fermer, `Entrée` suivante, `Z` annuler), progression et résumé de session ; décisions via les mêmes API que le board
- 🔄 **Board dynamique** — aucun refresh manuel : toute modification (autre session, Gitea, capture) est diffusée en SSE et appliquée **en place** avec animation des cartes (`/api/board/{owner}/{repo}` en JSON + fragments rendus serveur, diff DOM/FLIP) ; repli par sondage de 45 s sans webhooks ; drag local, filtres et panneau détail préservés
- ⌨️ **Palette de commandes** — `Ctrl+K` (ou le bouton ⌨️) sur toutes les pages : actions, projets et recherche d'issues globale, 100 % clavier
- 🧠 **Digest hebdo → ObsiGate** — résumé markdown de la semaine (journal + focus) composé automatiquement, **livré seulement sur approbation** (page `/digest`) vers le serveur MCP d'ObsiGate (client MCP intégré, outil et gabarit d'arguments configurables) ou un webhook générique
@@ -102,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 |
@@ -119,6 +131,9 @@ sudo systemctl enable --now docker
| `KANBANTE_CAPTURE_REPO` | *(vide)* | Dépôt par défaut de la capture éclair (`Proprietaire/depot`) |
| `KANBANTE_CAPTURE_TOKEN` | *(vide)* | Jeton dédié de `POST /api/capture` ; vide = endpoint désactivé (503) |
| `KANBANTE_CAPTURE_NTFY_TOPIC` | *(vide)* | Topic ntfy écouté : chaque message devient une issue dans `KANBANTE_CAPTURE_REPO` |
| `KANBANTE_BRIEFING_INCLUDE_STANDUP` | `0` | `1` = le standup automatique est ajouté au briefing ntfy du matin |
| `KANBANTE_RULES` | *(vide)* | Règles d'automatisation en JSON (voir `/rules`) : conditions colonne/âge/étiquette/WIP → add_label / notify / move_top |
| `KANBANTE_ZOMBIE_DAYS` | `30` | Jours sans activité après lesquels une carte ouverte est « zombie » sur `/health` |
| `KANBANTE_DIGEST_TIME` / `KANBANTE_DIGEST_WEEKDAY` | *(vide)* / `0` | Composition hebdo du digest (`HH:MM`, 0 = lundi) ; notification ntfy, jamais d'envoi automatique |
| `KANBANTE_OBSIGATE_MCP_URL` / `_TOKEN` / `_TOOL` / `_ARGS` | *(vide)* | Serveur MCP d'ObsiGate : URL, jeton optionnel, outil de création de note, gabarit JSON d'arguments (`{title}`, `{content}`) |
| `KANBANTE_DIGEST_WEBHOOK_URL` | *(vide)* | Webhook générique recevant le digest en JSON `{title, markdown, week}` |
@@ -168,6 +183,17 @@ kanbante/
| GET | `/capture` | Page de capture éclair (formulaire + statut + instructions) |
| GET | `/api/palette` | Données de la palette Ctrl+K (projets + recherche d'issues `?q=`) |
| GET | `/api/board/{owner}/{repo}` | Board en JSON + fragments HTML des cartes (base du board dynamique, `?milestone=` accepté) |
| GET | `/triage` · `/triage/{owner}/{repo}` | Mode Triage : sélecteur de projet puis session clavier sur le backlog |
| GET | `/today` | Focus du jour : les 3 cartes épinglées, candidates, reprises d'hier |
| GET | `/api/issues/{o}/{r}/{n}/code` | Branches, PRs et commits liés à la carte |
| GET | `/standup` · `/api/standup` | Standup automatique (hier / aujourd'hui / blocages) |
| GET | `/rules` · POST `/api/rules/run` | Règles d'automatisation : état, dernière évaluation, lancement manuel |
| POST | `/api/issues/{o}/{r}/{n}/branch` | Créer la branche de la carte (`issue-<n>-<slug>` ou nom libre validé) et la passer en « En cours » |
| GET | `/health` · `/health/{o}/{r}` | Santé du flux : goulots (temps médian par colonne) et cartes zombies, global ou par projet |
| POST | `/api/today/pin` · `/api/today/unpin` | Épingler / retirer une carte du Focus du jour (max 3) |
| GET | `/forecast` | Prévisions : débit hebdo, ETA backlog et milestones par projet |
| GET | `/sprint/{owner}/{repo}` · `/api/sprint/{owner}/{repo}` | Récit de sprint d'un milestone (`?milestone=`), page rendue ou JSON markdown |
| POST | `/api/issues/{o}/{r}/{n}/assign-self` | S'assigner une issue (utilisateur du jeton) |
| GET | `/digest` | Digest hebdo : aperçu, statut des cibles, approbation d'envoi |
| POST | `/digest/send` | Livrer le digest (cible `obsigate` ou `webhook`) — approbation explicite |
| POST | `/api/capture` | Capture machine-à-machine (jeton `X-Capture-Token`, JSON ou texte : 1re ligne = titre) |
+55
View File
@@ -16,6 +16,43 @@ def ntfy_configured(settings: Settings) -> bool:
return bool(settings.ntfy_url and settings.ntfy_topic)
def format_standup_text(data: dict) -> str:
"""Automatic standup (issue #6): yesterday / today / blockers."""
lines = [f"🗣️ Standup — {data['today_label']}"]
closed = data["closed_yesterday"]
lines.append(
f"\n✅ Hier ({data['yesterday_label']}) : {len(closed)} fermée(s), "
f"{data['moved_yesterday']} déplacement(s)"
)
for item in closed[:8]:
lines.append(
f" • {item['repo']} #{item['number']} « {item['title']} »"
)
lines.append("\n📌 Aujourd'hui :")
if data["picks"]:
for pick in data["picks"]:
lines.append(
f" • {pick['repo']} #{pick['number']} « {pick['title']} »"
f" — {pick['column_label']}"
)
elif data["focus_titles"]:
for title in data["focus_titles"][:5]:
lines.append(f" • {title}")
lines.append(" (aucun focus épinglé — pense à /today)")
else:
lines.append(" • Rien en cours. Journée libre ou triage à faire !")
lines.append("\n🚧 Blocages :")
if data["blockers"]:
for card in data["blockers"][:6]:
lines.append(
f" • {card['repo']} #{card['number']} « {card['title']} »"
f" — {card['days']} j en {card['column_label']}"
)
else:
lines.append(" • Aucun blocage signalé. ✨")
return "\n".join(lines)
def format_briefing_text(data: dict) -> str:
"""Plain-text briefing (ntfy body, also shown on /briefing)."""
lines: list[str] = [f"📋 Kanbante — briefing du {data['date']}"]
@@ -27,6 +64,22 @@ def format_briefing_text(data: dict) -> str:
if activity.get("avg_cycle_days") is not None:
lines.append(f"⏱️ Temps de cycle moyen (30 j) : {activity['avg_cycle_days']} j.")
if data.get("today_picks"):
lines.append("\n🌅 Focus du jour :")
for pick in data["today_picks"]:
mark = "✅" if pick["state"] == "closed" else "📌"
lines.append(
f" {mark} {pick['repo']} #{pick['number']} « {pick['title']} »"
f" — {pick['column_label']}"
)
if data.get("carry_over"):
lines.append("\n↩️ À reprendre d'hier (non terminées) :")
for pick in data["carry_over"]:
lines.append(
f" • {pick['repo']} #{pick['number']} « {pick['title']} »"
f" — {pick['column_label']}"
)
lines.append(f"\n🎯 En cours / en révision : {data['focus_total']} carte(s)")
for group in data["focus_groups"][:6]:
lines.append(f" • {group['full_name']} : {group['count']}")
@@ -51,6 +104,8 @@ def format_briefing_text(data: dict) -> str:
if data["focus_total"] == 0 and not data["aging"]:
lines.append("\n🎉 Rien d'urgent : aucun encours, aucune carte vieillissante.")
if data.get("standup"):
lines.append("\n" + data["standup"])
return "\n".join(lines)
+127
View File
@@ -0,0 +1,127 @@
"""Code links (roadmap #1): what code belongs to a card?
Pure matching helpers — a branch, pull request or commit is linked
to an issue when it references the issue number the way developers
actually write it: `#12` in a title/body/message, or the number as
a token in a branch name (`feature/12-login`, `issue-12`, `12-fix`).
"""
import re
import unicodedata
_REF_RE = re.compile(r"(?<![\w#])#(\d+)\b")
_BRANCH_TOKEN_RE = re.compile(r"(?:^|[-_/])(\d+)(?:[-_/]|$)")
def extract_issue_refs(text: str | None) -> set[int]:
"""All #n references in a text (titles, bodies, messages)."""
if not text:
return set()
return {int(m.group(1)) for m in _REF_RE.finditer(text)}
def branch_issue_number(branch_name: str | None) -> int | None:
"""The issue number encoded in a branch name, if any."""
if not branch_name:
return None
m = _BRANCH_TOKEN_RE.search(branch_name)
return int(m.group(1)) if m else None
def slugify_title(title: str, max_len: int = 40) -> str:
"""ASCII slug for a branch name: 'Créer la branche !' -> 'creer-la-branche'."""
normalized = unicodedata.normalize("NFKD", title or "")
ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^a-z0-9]+", "-", ascii_text.lower()).strip("-")
slug = re.sub(r"-{2,}", "-", slug)
if len(slug) > max_len:
slug = slug[:max_len].rstrip("-")
return slug or "issue"
def branch_name_for(number: int, title: str) -> str:
"""The branch a card would create (issue #3): issue-<n>-<slug>.
The name is recognized back by branch_issue_number, which is
what links the future PR to this card (issue #2).
"""
return f"issue-{number}-{slugify_title(title)}"
def find_code_links(
branches: list[dict],
pulls: list[dict],
commits: list[dict],
number: int,
) -> dict:
"""Linked branches/PRs/commits for one issue number."""
linked_branches = [
b.get("name", "")
for b in branches
if branch_issue_number(b.get("name")) == number
]
linked_pulls = []
for pr in pulls:
head_ref = ((pr.get("head") or {}).get("ref")) or ""
refs = extract_issue_refs(pr.get("title")) | extract_issue_refs(
pr.get("body")
)
if number in refs or branch_issue_number(head_ref) == number:
linked_pulls.append(
{
"number": pr.get("number"),
"title": pr.get("title", ""),
"state": pr.get("state", "open"),
"merged": bool(pr.get("merged")),
"html_url": pr.get("html_url", ""),
"branch": head_ref,
}
)
linked_commits = []
for commit in commits:
message = ((commit.get("commit") or {}).get("message")) or ""
if number in extract_issue_refs(message):
linked_commits.append(
{
"sha": (commit.get("sha") or "")[:7],
"message": message.splitlines()[0] if message else "",
"html_url": commit.get("html_url", ""),
"date": (
((commit.get("commit") or {}).get("author") or {}).get(
"date", ""
)
or ""
)[:10],
}
)
# Best PR to badge: a merged one beats an open one beats closed.
pr_badge = None
for wanted in ("merged", "open", "closed"):
for pr in linked_pulls:
state = (
"merged" if pr["merged"]
else "open" if pr["state"] == "open" else "closed"
)
if state == wanted:
pr_badge = {
"number": pr["number"],
"state": state,
"label": {
"merged": "fusionnée",
"open": "ouverte",
"closed": "fermée",
}[state],
}
break
if pr_badge:
break
return {
"branches": linked_branches,
"pulls": linked_pulls,
"commits": linked_commits,
"summary": {
"branches": len(linked_branches),
"commits": len(linked_commits),
"pr": pr_badge,
},
}
+32 -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).
@@ -68,6 +74,19 @@ Environment variables:
KANBANTE_OBSIGATE_MCP_ARGS JSON argument template for the tool;
"{title}" and "{content}" placeholders
are substituted (JSON-escaped).
KANBANTE_BRIEFING_INCLUDE_STANDUP
"1" appends the automatic standup
(issue #6) to the morning briefing.
KANBANTE_RULES Automation rules (issue #8) as a JSON
array: [{"name": ..., "when":
{"column": "review", "min_age_days": 3},
"then": {"notify": true}}]. Conditions:
column, min_age_days, label, wip_over.
Actions: add_label, notify, move_top.
A rule can never close an issue.
KANBANTE_ZOMBIE_DAYS Days without any activity after
which an open card is a "zombie"
on /health (issue #9). Default 30.
"""
import os
@@ -76,7 +95,7 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.9.0"
APP_VERSION = "0.22.0"
def _env_bool(name: str, default: bool) -> bool:
@@ -142,6 +161,11 @@ class Settings:
obsigate_mcp_token: str = ""
obsigate_mcp_tool: str = ""
obsigate_mcp_args: str = '{"title": "{title}", "content": "{content}"}'
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:
@@ -184,4 +208,9 @@ def get_settings() -> Settings:
"KANBANTE_OBSIGATE_MCP_ARGS",
'{"title": "{title}", "content": "{content}"}',
),
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.9.0"},
"clientInfo": {"name": "kanbante", "version": "0.22.0"},
},
req_id=1,
)
+123
View File
@@ -0,0 +1,123 @@
"""Flow health (roadmap #9): bottlenecks and zombie cards.
Boards show state; this module reads the event journal for health:
how long cards actually stay in each column (dwell samples are the
intervals between an entry and the next move/close), and which
open cards have had no activity for a long time (zombies).
"""
import statistics
from datetime import datetime
from app.models import KANBAN_COLUMNS
def parse_ts(raw) -> datetime | None:
"""Parse journal ('YYYY-MM-DD HH:MM:SS') and Gitea ISO stamps."""
text = str(raw or "").strip()
if not text:
return None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
try:
return datetime.strptime(text[:19], fmt)
except ValueError:
continue
try:
return datetime.strptime(text[:10], "%Y-%m-%d")
except ValueError:
return None
def column_dwell(events: list[dict]) -> dict[str, list[float]]:
"""Dwell samples (in days) per column, rebuilt from the journal.
A stay starts when a card enters a column (created / moved /
reopened) and ends at its next move or close. The first stay of
a card whose journal starts mid-flight is unknowable and is
simply not sampled.
"""
by_issue: dict[tuple[str, int], list[dict]] = {}
for event in events:
by_issue.setdefault(
(event["repo_full_name"], event["issue_number"]), []
).append(event)
dwell: dict[str, list[float]] = {}
for issue_events in by_issue.values():
ordered = sorted(
issue_events, key=lambda e: str(e.get("created_at") or "")
)
current: str | None = None
since: datetime | None = None
for event in ordered:
at = parse_ts(event.get("created_at"))
if at is None:
continue
kind = event["event_type"]
if kind == "created":
if current is None:
current = event.get("to_column") or "backlog"
since = at
elif kind == "moved":
if current is not None and since is not None:
dwell.setdefault(current, []).append(
(at - since).total_seconds() / 86400
)
elif current is None and event.get("from_column"):
# The stay in from_column began before the journal
# window: only the new stay can be timed.
pass
current = event.get("to_column") or current
since = at
elif kind == "closed":
if current is not None and since is not None:
dwell.setdefault(current, []).append(
(at - since).total_seconds() / 86400
)
current = "done"
since = at
elif kind == "reopened":
current = event.get("to_column") or "backlog"
since = at
return dwell
def summarize_dwell(dwell: dict[str, list[float]]) -> list[dict]:
"""Median dwell per column, in board order."""
summary = []
for column in KANBAN_COLUMNS:
samples = dwell.get(column) or []
if not samples:
continue
summary.append(
{
"column": column,
"count": len(samples),
"median_days": round(statistics.median(samples), 1),
"avg_days": round(sum(samples) / len(samples), 1),
}
)
return summary
def slowest_column(summary: list[dict], min_samples: int = 2) -> dict | None:
"""The bottleneck: slowest column (done excluded) with data."""
candidates = [
row for row in summary
if row["column"] != "done" and row["count"] >= min_samples
]
if not candidates:
return None
return max(candidates, key=lambda r: r["median_days"])
def last_activity(events: list[dict]) -> dict[tuple[str, int], datetime]:
"""Most recent journal timestamp per (repo, issue)."""
latest: dict[tuple[str, int], datetime] = {}
for event in events:
at = parse_ts(event.get("created_at"))
if at is None:
continue
key = (event["repo_full_name"], event["issue_number"])
if key not in latest or at > latest[key]:
latest[key] = at
return latest
+127
View File
@@ -0,0 +1,127 @@
"""Forecasts (roadmap #7): honest ETAs from the event journal.
Throughput = issues closed per week, counted from board_events.
Projections are deliberately given as a RANGE (best/worst weekly
rate over the last 4 complete weeks) rather than a fake-precise
date, and are withheld entirely when the journal has no closures
to learn from — "not enough history" is an answer, too.
"""
from datetime import date, datetime, timedelta, timezone
def _journal_today() -> date:
"""'Today' in the journal's frame: board_events stamps are UTC
(SQLite datetime('now')), so week buckets anchor to UTC —
otherwise closures logged in the local evening (already
tomorrow in UTC) fall outside every bucket."""
return datetime.now(timezone.utc).date()
MONTHS_FR = [
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
]
MONTHS_FR_SHORT = [
"janv.", "févr.", "mars", "avr.", "mai", "juin",
"juil.", "août", "sept.", "oct.", "nov.", "déc.",
]
def french_date(d: date, short: bool = False) -> str:
months = MONTHS_FR_SHORT if short else MONTHS_FR
return f"{d.day} {months[d.month - 1]}"
def _event_date(event: dict) -> date | None:
raw = str(event.get("created_at") or "")
try:
return datetime.strptime(raw[:10], "%Y-%m-%d").date()
except ValueError:
return None
def weekly_closures(events: list[dict], weeks: int = 8,
today: date | None = None) -> list[dict]:
"""Closure counts per ISO week, oldest first, current week last."""
today = today or _journal_today()
current_monday = today - timedelta(days=today.weekday())
buckets: list[dict] = []
for i in range(weeks - 1, -1, -1):
start = current_monday - timedelta(weeks=i)
buckets.append({"start": start, "count": 0})
by_start = {b["start"]: b for b in buckets}
for event in events:
if event.get("event_type") != "closed":
continue
day = _event_date(event)
if day is None:
continue
monday = day - timedelta(days=day.weekday())
if monday in by_start:
by_start[monday]["count"] += 1
return buckets
def throughput_stats(weekly: list[dict], last: int = 4) -> dict | None:
"""avg/min/max closures per week over the last COMPLETE weeks.
The current (partial) week is excluded: on Monday morning it
would crush the average for no reason. Returns None when the
complete weeks contain no closure at all.
"""
complete = weekly[:-1][-last:] if len(weekly) > 1 else []
if not complete:
return None
counts = [b["count"] for b in complete]
if sum(counts) == 0:
return None
return {
"avg": sum(counts) / len(counts),
"min": min(counts),
"max": max(counts),
"weeks": len(counts),
}
def estimate(remaining: int, weekly: list[dict],
today: date | None = None) -> dict | None:
"""Projected completion for `remaining` items at the real pace.
Returns {"done": True} when nothing remains, None when there is
no throughput to project from, else dates: central (avg rate),
earliest (best week), latest (worst nonzero week, if any).
"""
today = today or _journal_today()
if remaining <= 0:
return {"done": True}
stats = throughput_stats(weekly)
if stats is None:
return None
def at_rate(rate: float) -> date:
days = remaining / rate * 7
return today + timedelta(days=max(1, round(days)))
result = {
"done": False,
"avg_per_week": round(stats["avg"], 1),
"central": at_rate(stats["avg"]),
"earliest": at_rate(stats["max"]) if stats["max"] > 0 else None,
"latest": at_rate(stats["min"]) if stats["min"] > 0 else None,
}
return result
def eta_label(est: dict | None, done_text: str = "terminé") -> str | None:
"""One-line French label for banners and cards (or None)."""
if est is None:
return None
if est.get("done"):
return f"✅ {done_text}"
label = f"à ce rythme ≈ {french_date(est['central'], short=True)}"
if est.get("earliest") and est.get("latest"):
label += (
f" (fourchette {french_date(est['earliest'], short=True)}"
f" – {french_date(est['latest'], short=True)})"
)
return label
+222 -32
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,14 +440,28 @@ 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)."""
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."""
if starred:
r = await self.client.put(f"/user/starred/{owner}/{repo}")
else:
r = await self.client.delete(f"/user/starred/{owner}/{repo}")
r.raise_for_status()
self._cache_clear()
async def create_issue(
self, owner: str, repo: str, title: str, body: str = ""
@@ -340,3 +486,47 @@ class GiteaClient:
r.raise_for_status()
self._cache_clear()
return r.json()
async def create_branch(
self, owner: str, repo: str, new_branch: str, old_branch: str
) -> dict:
"""Create a branch from another one (issue #3)."""
return await self._request(
"POST",
f"/repos/{owner}/{repo}/branches",
json={
"new_branch_name": new_branch,
"old_branch_name": old_branch,
},
)
async def list_branches(self, owner: str, repo: str) -> list[dict]:
"""All branches of a repo (code links, issue #1)."""
return await self._get_all_pages(f"/repos/{owner}/{repo}/branches")
async def list_pulls(
self, owner: str, repo: str, state: str = "all"
) -> list[dict]:
"""All pull requests of a repo (code links, issue #1)."""
return await self._get_all_pages(
f"/repos/{owner}/{repo}/pulls", state=state
)
async def list_commits(
self, owner: str, repo: str, limit: int = 75
) -> list[dict]:
"""Most recent commits of a repo (code links, issue #1)."""
data = await self._get(f"/repos/{owner}/{repo}/commits", limit=limit)
return data if isinstance(data, list) else []
async def set_issue_assignees(
self, owner: str, repo: str, issue_number: int, logins: list[str]
) -> dict:
"""Replace the assignee set of an issue (triage "assign me")."""
r = await self.client.patch(
f"/repos/{owner}/{repo}/issues/{issue_number}",
json={"assignees": logins},
)
r.raise_for_status()
self._cache_clear()
return r.json()
+1651 -47
View File
File diff suppressed because it is too large Load Diff
+118 -1
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
@@ -79,11 +80,24 @@ def init_db() -> None:
CREATE INDEX IF NOT EXISTS idx_board_repo
ON board_state(repo_full_name, column_name);
CREATE TABLE IF NOT EXISTS today_picks (
pick_date TEXT NOT NULL,
repo_full_name TEXT NOT NULL,
issue_number INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (pick_date, repo_full_name, issue_number)
);
CREATE TABLE IF NOT EXISTS kv_store (
key TEXT PRIMARY KEY,
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);
"""
@@ -239,6 +253,35 @@ def reconcile_board_state(
return changed
def get_picks(pick_date: str) -> list[dict]:
"""The day's focus picks (issue #5), in pinning order."""
with get_db() as conn:
rows = conn.execute(
"SELECT repo_full_name, issue_number FROM today_picks "
"WHERE pick_date = ? ORDER BY created_at, repo_full_name, issue_number",
(pick_date,),
).fetchall()
return [dict(row) for row in rows]
def add_pick(pick_date: str, repo_full_name: str, issue_number: int) -> None:
with get_db() as conn:
conn.execute(
"INSERT OR IGNORE INTO today_picks "
"(pick_date, repo_full_name, issue_number) VALUES (?, ?, ?)",
(pick_date, repo_full_name, issue_number),
)
def remove_pick(pick_date: str, repo_full_name: str, issue_number: int) -> None:
with get_db() as conn:
conn.execute(
"DELETE FROM today_picks WHERE pick_date = ? "
"AND repo_full_name = ? AND issue_number = ?",
(pick_date, repo_full_name, issue_number),
)
def get_kv(key: str) -> str | None:
with get_db() as conn:
row = conn.execute(
@@ -256,9 +299,51 @@ 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")
EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented", "rule")
def log_event(
@@ -332,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.
+111
View File
@@ -0,0 +1,111 @@
"""Automation rules (roadmap #8): small declarative IFTTT for the board.
Rules live in KANBANTE_RULES (JSON array) — deliberately boring:
[{"name": "Relance révision",
"when": {"column": "review", "min_age_days": 3},
"then": {"notify": true}},
{"name": "Urgent qui vieillit",
"when": {"column": "in_progress", "min_age_days": 7},
"then": {"add_label": "urgent"}}]
Conditions: column, min_age_days (time in the current column),
label (present on the card), wip_over (column holds MORE cards
than N — then every card of the column matches).
Actions: add_label, notify (ntfy), move_top (of its column).
Guardrail, by design: there is NO close/delete/move action — a
rule can never close an issue or move a card between columns.
"""
import json
from app.models import KANBAN_COLUMNS
CONDITION_KEYS = {"column", "min_age_days", "label", "wip_over"}
ACTION_KEYS = {"add_label", "notify", "move_top"}
def parse_rules(raw: str) -> tuple[list[dict], list[str]]:
"""Validate KANBANTE_RULES; returns (rules, human errors)."""
if not raw or not raw.strip():
return [], []
try:
data = json.loads(raw)
except ValueError as exc:
return [], [f"JSON invalide : {exc}"]
if not isinstance(data, list):
return [], ["KANBANTE_RULES doit être un tableau JSON de règles"]
rules: list[dict] = []
errors: list[str] = []
for i, item in enumerate(data):
label = f"règle #{i + 1}"
if not isinstance(item, dict):
errors.append(f"{label} : pas un objet")
continue
name = str(item.get("name") or label)
when = item.get("when")
then = item.get("then")
if not isinstance(when, dict) or not isinstance(then, dict):
errors.append(f"{name} : 'when' et 'then' (objets) sont requis")
continue
bad_when = set(when) - CONDITION_KEYS
bad_then = set(then) - ACTION_KEYS
if bad_when:
errors.append(f"{name} : conditions inconnues {sorted(bad_when)}")
continue
if bad_then:
errors.append(
f"{name} : actions inconnues {sorted(bad_then)} "
"(fermer/déplacer une carte par règle est interdit en v1)"
)
continue
if "column" in when and when["column"] not in KANBAN_COLUMNS:
errors.append(f"{name} : colonne inconnue « {when['column']} »")
continue
if "min_age_days" in when and (
not isinstance(when["min_age_days"], int) or when["min_age_days"] < 0
):
errors.append(f"{name} : min_age_days doit être un entier ≥ 0")
continue
if "wip_over" in when and (
not isinstance(when["wip_over"], int) or when["wip_over"] < 1
):
errors.append(f"{name} : wip_over doit être un entier ≥ 1")
continue
if "add_label" in then and (
not isinstance(then["add_label"], str) or not then["add_label"].strip()
):
errors.append(f"{name} : add_label doit être un nom d'étiquette")
continue
if not then:
errors.append(f"{name} : aucune action")
continue
rules.append({"name": name, "when": when, "then": then})
return rules, errors
def matching_cards(
rule: dict,
columns: dict[str, list[dict]],
ages: dict[int, dict],
) -> list[tuple[dict, str]]:
"""Cards of `columns` (merged board) matched by the rule."""
when = rule["when"]
targets = [when["column"]] if "column" in when else list(columns)
matches: list[tuple[dict, str]] = []
for column in targets:
cards = columns.get(column, [])
if "wip_over" in when and len(cards) <= when["wip_over"]:
continue
for issue in cards:
if "label" in when and when["label"] not in [
(lab or {}).get("name") for lab in issue.get("labels") or []
]:
continue
if "min_age_days" in when:
age = ages.get(issue["number"]) or {}
if (age.get("days") or 0) < when["min_age_days"]:
continue
matches.append((issue, column))
return matches
+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"
+79
View File
@@ -0,0 +1,79 @@
"""Sprint story (roadmap #4): the milestone's tale, written by the journal.
Nobody writes the end-of-milestone report by hand. Kanbante's
event journal already knows what shipped, how long it took and
what dragged — this module just turns that into clean markdown,
ready to archive in ObsiGate.
"""
def format_sprint_markdown(
repo: str,
milestone_title: str,
due_on: str | None,
period_label: str,
delivered: list[dict],
carried: list[dict],
counts: dict,
avg_cycle_days: float | None,
) -> str:
lines = [f"# 📖 Récit de sprint — {repo} · {milestone_title}", ""]
lines.append(f"*Période : {period_label}*")
if due_on:
lines.append(f"*Échéance du milestone : {due_on[:10]}*")
lines.append("")
lines.append(f"## ✅ Livré ({len(delivered)})")
if delivered:
for item in delivered:
cycle = (
f" — ⏱️ {item['cycle_days']} j de cycle"
if item.get("cycle_days") is not None
else ""
)
lines.append(
f"- [#{item['number']}]({item.get('html_url', '')}) "
f"{item.get('title', '')}{cycle}".rstrip()
)
else:
lines.append("- Rien n'a été fermé dans ce milestone pour l'instant.")
lines.append("")
slowest = sorted(
(d for d in delivered if d.get("cycle_days") is not None),
key=lambda d: -d["cycle_days"],
)[:3]
if slowest:
lines.append("## 🐌 Les plus lentes")
for item in slowest:
lines.append(
f"- #{item['number']} {item.get('title', '')} "
f"— {item['cycle_days']} j de cycle"
)
lines.append("")
lines.append(f"## ↩️ Reporté ({len(carried)})")
if carried:
for item in carried:
lines.append(
f"- #{item['number']} {item.get('title', '')} "
f"— encore en *{item.get('column_label', 'backlog')}*"
)
else:
lines.append("- Rien : tout le milestone est livré. 🎉")
lines.append("")
lines.append("## 📈 En chiffres")
lines.append(f"- Issues du milestone : {len(delivered) + len(carried)}")
lines.append(f"- Fermées : {len(delivered)}")
if avg_cycle_days is not None:
lines.append(f"- Temps de cycle moyen : {avg_cycle_days} j")
lines.append(f"- Déplacements de cartes : {counts.get('moved', 0)}")
lines.append(f"- Commentaires : {counts.get('commented', 0)}")
lines.append(f"- Réouvertures : {counts.get('reopened', 0)}")
lines.append("")
lines.append(
"*Récit généré par Kanbante à partir de son journal d'événements "
"et des issues Gitea du milestone.*"
)
return "\n".join(lines)
+4
View File
@@ -19,6 +19,9 @@
{% endfor %}
</div>
{% endif %}
{% if issue._code %}
<div class="card-code">⑂{% if issue._code.branches %} 🌿 {{ issue._code.branches }}{% endif %}{% if issue._code.commits %} · {{ issue._code.commits }} commit{{ 's' if issue._code.commits > 1 }}{% endif %}{% if issue._code.pr %} · PR #{{ issue._code.pr.number }} {{ issue._code.pr.label }}{% endif %}</div>
{% endif %}
<div class="card-meta">
{% if issue.assignee %}
<img src="{{ issue.assignee.avatar_url }}" class="avatar" alt="{{ issue.assignee.login }}">
@@ -29,5 +32,6 @@
<span class="age-badge{% if age.days >= aging_days * 2 %} stale{% endif %}"
title="Dans cette colonne depuis le {{ age.since[:10] }}">⏳ {{ age.days }} j</span>
{% endif %}
<button type="button" class="pin-btn" title="Épingler au Focus du jour (/today)">📌</button>
</div>
</div>
+118
View File
@@ -0,0 +1,118 @@
{# ── Cadre applicatif partagé (issue #16, refonte 0.16.0) ─────────
Barre latérale unique pour TOUTES les pages applicatives :
desktop = barre fixe repliable en rail d'icônes (état mémorisé),
mobile = tiroir hors-canvas ouvert par le burger de la topbar.
NE PAS dupliquer ce markup dans les pages.
Page active : `active_nav` (déduite du gabarit dans render()).
Fil d'Ariane : `owner`+`repo` (board) ou `full_name` (projet).
Le wallboard (TV) n'inclut pas ce partiel, volontairement. #}
{% set nav_groups = [
("principal", "Principal", [
("home", "/", "🏠", "Projets"),
("focus", "/focus", "🎯", "Focus"),
("today", "/today", "🌅", "Aujourd'hui"),
("my", "/my", "👤", "Mes issues"),
]),
("rituels", "Rituels", [
("briefing", "/briefing", "📊", "Briefing"),
("standup", "/standup", "🗣️", "Standup"),
("digest", "/digest", "🧠", "Digest"),
]),
("pilotage", "Pilotage", [
("forecast", "/forecast", "🔮", "Prévisions"),
("health", "/health", "🩺", "Santé"),
("rules", "/rules", "⚙️", "Règles"),
("activity", "/activity", "📰", "Activité"),
]),
("outils", "Outils", [
("search", "/search", "🔍", "Recherche"),
("capture", "/capture", "⚡", "Capture"),
("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">
<span class="logo-mark">📋</span>
<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 %}
<div class="side-section" data-group="{{ gkey }}">
<button type="button" class="side-group-btn" data-group-btn="{{ gkey }}" aria-expanded="true">
<span>{{ group }}</span>
<span class="side-chevron" aria-hidden="true">▾</span>
</button>
<div class="side-acc">
<div class="side-acc-inner">
{% for key, href, icon, label in links %}
<a href="{{ href }}" class="side-link{% if active_nav == key %} active{% endif %}" title="{{ label }}">
<span class="side-ico">{{ icon }}</span>
<span class="side-label">{{ label }}</span>
</a>
{% endfor %}
</div>
</div>
</div>
{% endfor %}
</nav>
<div class="side-foot">
{% 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>
<header class="app-header topbar">
<button type="button" class="nav-burger" id="nav-burger" aria-expanded="false" aria-controls="sidebar" aria-label="Ouvrir le menu">☰</button>
{% if owner is defined and repo is defined %}
<span class="crumb"><a href="/board/{{ owner }}/{{ repo }}">{{ owner }} / <strong>{{ repo }}</strong></a></span>
{% elif full_name is defined %}
<span class="crumb"><a href="/board/{{ full_name }}">{{ full_name }}</a></span>
{% endif %}
<div class="header-actions">
{% if repo_info is defined and repo_info and repo_info.html_url %}
<a href="{{ repo_info.html_url }}" target="_blank" rel="noopener" class="nav-pill" title="Ouvrir ce dépôt dans Gitea">🔗 Gitea ↗</a>
{% endif %}
<button type="button" class="nav-pill palette-trigger topbar-search" title="Recherche et commandes (Ctrl+K)">🔍</button>
</div>
<script src="/static/js/nav.js" defer></script>
</header>
+1 -18
View File
@@ -8,24 +8,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
<span class="crumb"><span class="sep">/</span> 📰 <strong>Activité</strong></span>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill active">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
<p class="focus-intro">Les {{ events|length }} derniers événements du journal — déplacements, créations, fermetures et commentaires, tous projets confondus.</p>
+8 -20
View File
@@ -12,26 +12,7 @@
<script src="/static/js/board.js" defer></script>
</head>
<body>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
<span class="crumb"><span class="sep">/</span> {{ owner }} / <strong>{{ repo }}</strong></span>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
{% if repo_info.html_url %}
<a href="{{ repo_info.html_url }}" target="_blank" class="nav-pill">🔗 Gitea ↗</a>
{% endif %}
</nav>
</header>
{% include "_header.html" %}
{% if milestones %}
<div class="milestone-bar">
@@ -51,6 +32,10 @@
<span class="milestone-due">📅 échéance {{ milestone_progress.due_on[:10] }}</span>
{% endif %}
<span class="milestone-count">{{ milestone_progress.closed }}/{{ milestone_progress.total }} issues terminées</span>
{% if milestone_forecast %}
<span class="milestone-forecast">🔮 {{ milestone_forecast }}</span>
{% endif %}
<a class="milestone-sprint" href="/sprint/{{ full_name }}?milestone={{ milestone_param|urlencode }}">📖 Récit de sprint</a>
</div>
<div class="progress progress-lg"><span style="width: {{ milestone_progress.pct }}%"></span></div>
<span class="milestone-pct">{{ milestone_progress.pct }}%</span>
@@ -67,6 +52,7 @@
{% endfor %}
</select>
<button type="button" id="labels-btn" class="btn-sm">🏷️ Gérer les étiquettes</button>
<a href="/triage/{{ full_name }}" class="btn-sm" style="text-decoration:none">📥 Triage du backlog</a>
<form id="create-issue-form">
<input type="text" id="new-issue-title" maxlength="255"
placeholder="➕ Nouvelle issue (titre)..." required>
@@ -157,6 +143,7 @@
<span class="notes-hint">Étiquettes de l'issue — cliquez pour ajouter / retirer :</span>
<div id="detail-label-toggles" class="label-toggles"></div>
</div>
<div id="detail-code" class="detail-code"></div>
<div id="detail-body" class="detail-body"></div>
<h3 class="detail-comments-title">💬 Commentaires</h3>
<div id="detail-comments"></div>
@@ -175,6 +162,7 @@
{% endif %}
</footer>
<script src="/static/js/board-live.js" defer></script>
<script src="/static/js/today.js" defer></script>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
+1 -17
View File
@@ -9,23 +9,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
<span class="crumb"><span class="sep">/</span> 📊 <strong>Briefing</strong></span>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
<p class="focus-intro">Le briefing du <strong>{{ data.date }}</strong> — le même contenu que celui poussé sur ntfy.</p>
+8 -16
View File
@@ -8,21 +8,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill active">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
<section class="briefing-card">
@@ -51,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>
@@ -98,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>
+184 -55
View File
@@ -9,70 +9,109 @@
<script defer src="/static/vendor/alpine.min.js"></script>
</head>
<body>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill active">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<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">
<span class="org-tag">{{ repo.owner.login }}</span>
{% if repo.health_dot %}
<a href="/health/{{ full }}" class="health-dot {{ repo.health_dot.level }}" title="Santé du flux — {{ repo.health_dot.tooltip }}" aria-label="Santé du flux : {{ repo.health_dot.tooltip }}"></a>
{% endif %}
<span class="date">{{ repo.updated_at[:10] }}</span>
{% if not archived %}
<button type="button" class="star-btn" data-full="{{ full }}" data-starred="{{ 1 if repo.starred else 0 }}" title="{{ 'Désépingler (étoile Gitea)' if repo.starred else 'Épingler (étoile Gitea)' }}" aria-label="Épingler {{ repo.name }}">{{ '⭐' if repo.starred else '☆' }}</button>
<div class="card-menu-wrap">
<button type="button" class="card-menu-btn" data-menu-btn aria-haspopup="true" aria-expanded="false" title="Actions rapides pour {{ repo.name }}" aria-label="Actions rapides pour {{ repo.name }}">⋯</button>
<div class="card-menu" hidden>
<a href="/board/{{ full }}">📋 Board</a>
<a href="/capture?repo={{ full|urlencode }}">⚡ Capturer ici</a>
<a href="/triage/{{ full }}">📥 Triage du backlog</a>
<a href="/health/{{ full }}">🩺 Santé</a>
<a href="/sprint/{{ full }}">📖 Récit de sprint</a>
{% if repo.html_url %}
<a href="{{ repo.html_url }}" target="_blank" rel="noopener">🔗 Gitea ↗</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
<h3>
<a href="/board/{{ repo.owner.login }}/{{ repo.name }}">{{ repo.name }}</a>
<a href="/project/{{ repo.owner.login }}/{{ repo.name }}">{{ repo.name }}</a>
</h3>
<p class="desc">{{ repo.description or '—' }}</p>
{% if full in stats %}
@@ -84,8 +123,25 @@
</div>
{% endif %}
{% endif %}
{% if repo.next_milestone %}
{% set ms = repo.next_milestone %}
<div class="ms-block">
<div class="ms-line">
<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 %}
<div class="progress ms-progress"><span style="width: {{ ms.pct }}%"></span></div>
<div class="ms-meta">{{ ms.closed }}/{{ ms.total }} terminées{% if ms.eta_label %} · 🔮 {{ ms.eta_label }}{% if ms.eta_late %} — ⚠️ après l'échéance{% endif %}{% endif %}</div>
{% endif %}
</div>
{% endif %}
<div class="card-footer">
<span>{{ repo.open_issues_count or 0 }} issues ouvertes</span>
<a href="/board/{{ full }}" class="card-board-link">📋 Board</a>
{% if repo.forecast_label %}
<span class="forecast-line" title="Prévision d'après le débit réel du journal (fermetures par semaine)">🔮 {{ repo.forecast_label }}</span>
{% endif %}
{% if full in stats %}
<div class="mini-progress">
{% for col in ['backlog','todo','in_progress','qa','review','done'] %}
@@ -99,35 +155,108 @@
</div>
{% endif %}
</div>
{% if repo.activity %}
<div class="activity-line">
{% if repo.activity.closed or repo.activity.moved %}
📈 {{ repo.activity.closed }} fermée(s) · {{ repo.activity.moved }} déplacement(s) cette semaine
{% elif repo.activity.idle_days is not none and repo.activity.idle_days > 7 %}
💤 aucune activité kanban depuis {{ repo.activity.idle_days }} j
{% endif %}
</div>
{% endif %}
</div>
{% endmacro %}
<div x-show="tab === 'active'" class="project-grid">
{% for repo in active_repos %}{{ project_card(repo) }}{% endfor %}
{% if not active_repos %}
{% macro project_row(repo, archived=False) %}
{% set full = repo.full_name %}
{% set st = stats.get(full, {}) %}
<tr data-name="{{ repo.name|lower }}" data-owner="{{ repo.owner.login|lower }}"
data-open="{{ repo.open_issues_count or 0 }}"
data-inflight="{{ (st.get('in_progress', 0) + st.get('qa', 0) + st.get('review', 0)) }}"
data-updated="{{ repo.updated_at }}"
data-search="{{ (repo.name ~ ' ' ~ repo.full_name ~ ' ' ~ (repo.description or ''))|lower }}"
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
<td><a href="/project/{{ full }}"><strong>{{ repo.name }}</strong></a></td>
<td>{{ repo.owner.login }}</td>
<td>{{ repo.open_issues_count or 0 }}</td>
<td>{{ st.get('in_progress', 0) + st.get('qa', 0) + st.get('review', 0) }}</td>
<td>{% if repo.next_milestone %}🎯 {{ repo.next_milestone.title }}{% if repo.next_milestone.late %} ⚠️{% endif %}{% else %}—{% endif %}</td>
<td>{% if repo.health_dot %}<a href="/health/{{ full }}" class="health-dot {{ repo.health_dot.level }}" title="{{ repo.health_dot.tooltip }}" aria-label="Santé : {{ repo.health_dot.tooltip }}"></a>{% else %}—{% endif %}</td>
<td>{{ repo.updated_at[:10] }}</td>
</tr>
{% endmacro %}
{% macro project_table(repos, tab_name, archived=False) %}
<div x-show="tab === '{{ tab_name }}'">
<table class="proj-table">
<thead>
<tr>
<th data-sort="name">Projet</th>
<th data-sort="owner">Propriétaire</th>
<th data-sort="open">Ouvertes</th>
<th data-sort="inflight">En vol</th>
<th>Prochain jalon</th>
<th>Santé</th>
<th data-sort="updated" 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>
<div class="project-grid" id="pinned-grid">
{% for repo in pinned_repos %}{{ project_card(repo) }}{% endfor %}
</div>
</section>
<div x-show="tab === 'active'" class="project-grid" id="grid-active">
{% for repo in active_listed %}{{ project_card(repo) }}{% endfor %}
{% if not active_listed and not pinned_repos %}
<div class="empty-state">🌱 Aucun projet actif en ce moment. Les projets bougent dès qu'une issue ou un commit arrive dans Gitea.</div>
{% endif %}
</div>
<div x-show="tab === 'inactive'" class="project-grid">
{% for repo in inactive_repos %}{{ project_card(repo) }}{% endfor %}
{% if not inactive_repos %}
<div x-show="tab === 'inactive'" class="project-grid" id="grid-inactive">
{% for repo in inactive_listed %}{{ project_card(repo) }}{% endfor %}
{% if not inactive_listed %}
<div class="empty-state">✨ Rien d'endormi : tous vos projets ont eu de l'activité récemment.</div>
{% endif %}
</div>
<div x-show="tab === 'archived'" class="project-grid">
<div x-show="tab === 'archived'" class="project-grid" id="grid-archived">
{% for repo in archived_repos %}{{ project_card(repo, archived=True) }}{% endfor %}
{% if not archived_repos %}
<div class="empty-state">🗄️ Aucun projet archivé. L'archivage se fait dans Gitea (Réglages du dépôt).</div>
{% endif %}
</div>
</div>
<!-- Vue liste dense (#22) : mêmes onglets, mêmes données. -->
<div x-show="view === 'list'">
{{ project_table(active_repos, 'active') }}
{{ project_table(inactive_repos, 'inactive') }}
{{ project_table(archived_repos, 'archived', archived=True) }}
</div>
</main>
<footer>
Kanbante v{{ version }} · {{ active_repos|length + inactive_repos|length + archived_repos|length }} projets sur Gitea · actifs = mis à jour depuis {{ active_window_days }} jours
</footer>
<script src="/static/js/live.js" defer></script>
<script>window.KANBANTE_CSRF = "{{ csrf_token }}";</script>
<script src="/static/js/stars.js" defer></script>
<script src="/static/js/dashboard.js" defer></script>
<script src="/static/js/tablesort.js" defer></script>
<script>
document.addEventListener('keydown', function (e) {
if (e.key === '/' && !/INPUT|TEXTAREA/.test(document.activeElement?.tagName || '')) {
+1 -15
View File
@@ -8,21 +8,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill active">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
<section class="briefing-card">
+1 -12
View File
@@ -7,18 +7,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
</nav>
</header>
{% include "_header.html" %}
<main class="error-main">
<div class="error-box">
<h2>⚠️ Quelque chose s'est mal passé</h2>
+1 -19
View File
@@ -8,25 +8,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
<span class="crumb"><span class="sep">/</span> 🎯 <strong>Focus</strong></span>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill active">🎯 Focus</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
<span class="badge">{{ groups|map(attribute='cards')|map('length')|sum }} cartes en cours</span>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
<p class="focus-intro">Tout ce qui est <strong>En cours</strong> ou <strong>En révision</strong>, tous projets actifs confondus.</p>
+120
View File
@@ -0,0 +1,120 @@
<!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>Prévisions — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<section class="briefing-card">
<h1>🔮 Prévisions — « on finit quand ? »</h1>
<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>
{% 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 %}
<rect x="{{ loop.index0 * 21 }}" y="{{ 44 - px }}" width="15" height="{{ px if px > 0 else 1 }}" rx="2">
<title>{{ bar.label }} : {{ bar.count }} fermée(s)</title>
</rect>
{% endfor %}
</svg>
<div class="forecast-nums">
{% if project.stats %}
<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>.</p>
{% endif %}
<p>Restant ouvert : <strong>{{ project.remaining }}</strong>{% if project.eta_label %} · 🔮 backlog {{ project.eta_label }}{% endif %}</p>
</div>
</div>
{% else %}
<p>Aucun projet actif.</p>
{% endfor %}
</section>
</main>
<footer>
<span>Kanbante v{{ version }} · prévisions = journal des fermetures, pas boule de cristal</span>
</footer>
{% 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>
+63
View File
@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Santé du flux — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<section class="briefing-card">
<h1>🩺 Santé du flux</h1>
<p>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>
{% 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 %}
<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>
</footer>
{% 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>
+86
View File
@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Santé — {{ full_name }} — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
{% if 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>
<p>Projet introuvable.</p>
</section>
{% else %}
<section class="briefing-card">
<h1>🩺 {{ full_name }}</h1>
<h2>⏱️ Temps passé par colonne (journal)</h2>
{% if health.dwell %}
<table class="health-table">
<thead><tr><th>Colonne</th><th>Séjours mesurés</th><th>Médiane</th><th>Moyenne</th></tr></thead>
<tbody>
{% for row in health.dwell %}
<tr{% if health.slowest and row.column == health.slowest.column %} class="health-slowest"{% endif %}>
<td>{{ health.column_labels[row.column] }}{% if health.slowest and row.column == health.slowest.column %} 🐌{% endif %}</td>
<td>{{ row.count }}</td>
<td>{{ row.median_days }} j</td>
<td>{{ row.avg_days }} j</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if health.slowest %}
<p>🐌 <strong>Goulot : {{ health.column_labels[health.slowest.column] }}</strong> — c'est là que les cartes dorment le plus longtemps (médiane {{ health.slowest.median_days }} j).</p>
{% endif %}
{% else %}
<p>Pas encore de séjours mesurables dans le journal de ce projet — il faut des déplacements journalisés pour calculer les temps de colonne.</p>
{% endif %}
</section>
<section class="briefing-card">
<h2>🧟 Cartes zombies — aucune activité depuis ≥ {{ health.zombie_days }} jours</h2>
{% if health.zombies %}
<ul class="today-list">
{% for z in health.zombies %}
<li>
<span>#{{ z.number }} — {{ z.title }} <em>({{ z.column_label }} · inactive depuis {{ z.days_inactive }} j)</em></span>
<span class="zombie-actions">
<button type="button" class="btn-sm" data-zombie="comment" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">💬 Toujours pertinent ?</button>
<button type="button" class="btn-sm" data-zombie="backlog" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">↩️ Backlog</button>
<button type="button" class="btn-sm" data-zombie="close" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">✅ Fermer</button>
</span>
</li>
{% endfor %}
</ul>
<p class="notes-hint">« Fermer » déplace la carte en Terminé (l'issue est fermée dans Gitea par la synchro habituelle). « Toujours pertinent ? » poste un commentaire du même texte sur l'issue.</p>
{% else %}
<p>Aucune carte zombie. Le flux est propre. ✨</p>
{% endif %}
</section>
{% endif %}
</main>
<footer>
<span>Kanbante v{{ version }} · seuil zombie : KANBANTE_ZOMBIE_DAYS</span>
</footer>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
<script src="/static/js/health.js" defer></script>
</body>
</html>
+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 -17
View File
@@ -8,23 +8,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
<span class="crumb"><span class="sep">/</span> 👤 <strong>Mes issues</strong></span>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/my" class="nav-pill active">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
{% if not user %}
+129
View File
@@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ full_name }} — Projet · Kanbante</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📋</text></svg>">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
{% include "_header.html" %}
<main class="project-main">
<div class="proj-head">
<div>
<h1>📦 {{ repo_info.name }}</h1>
<p class="proj-sub">{{ owner }} · {{ repo_info.description or 'Aucune description dans Gitea.' }}</p>
</div>
{% if health_dot %}
<a href="/health/{{ full_name }}" class="health-chip" title="{{ health_dot.tooltip }}">
<span class="health-dot {{ health_dot.level }}"></span> Santé du flux
</a>
{% endif %}
</div>
<div class="proj-actions">
<a href="/board/{{ full_name }}" class="nav-pill proj-primary">📋 Ouvrir le board</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/triage/{{ full_name }}" class="nav-pill">📥 Triage</a>
<a href="/health/{{ full_name }}" class="nav-pill">🩺 Santé</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/wall?repo={{ full_name }}" class="nav-pill">🖥️ Wallboard</a>
{% if repo_info.html_url %}
<a href="{{ repo_info.html_url }}" target="_blank" rel="noopener" class="nav-pill">🔗 Gitea ↗</a>
{% endif %}
</div>
<div class="proj-grid">
<section class="proj-panel">
<h2>🎯 Jalons ouverts</h2>
{% if milestones %}
{% for ms in milestones %}
<div class="proj-ms">
<div class="ms-line">
<a href="/board/{{ full_name }}?milestone={{ ms.title|urlencode }}"><strong>{{ ms.title }}</strong></a>
{% if ms.due_label %}<span class="ms-due{% if ms.late %} late{% endif %}">{{ '⚠️ ' if ms.late }}échéance {{ ms.due_label }}</span>{% endif %}
</div>
{% if ms.total %}
<div class="progress ms-progress"><span style="width: {{ ms.pct }}%"></span></div>
<div class="ms-meta">{{ ms.closed }}/{{ ms.total }} terminées{% if ms.eta_label %} · 🔮 {{ ms.eta_label }}{% endif %}</div>
{% else %}
<div class="ms-meta">Aucune issue dans ce jalon pour le moment.</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<p class="proj-empty">Aucun jalon ouvert. Les jalons se créent dans Gitea — le board et le récit de sprint s'appuient dessus.</p>
{% endif %}
</section>
<section class="proj-panel">
<h2>🩺 Santé du flux</h2>
{% if health %}
<ul class="proj-list">
<li>📂 <strong>{{ health.open_count }}</strong> cartes ouvertes</li>
{% if health.slowest %}
<li>🐢 Goulot : <strong>{{ COLUMN_LABELS[health.slowest.column] }}</strong> — séjour médian {{ health.slowest.median_days }} j</li>
{% else %}
<li>🐢 Pas encore de goulot mesurable (historique trop court).</li>
{% endif %}
<li>🧟 Zombies (≥ {{ health.zombie_days }} j sans activité) : <strong>{{ health.zombies|length }}</strong></li>
</ul>
{% if health.zombies %}
<ul class="proj-list proj-zombies">
{% for z in health.zombies[:5] %}
<li><a href="{{ z.html_url }}" target="_blank" rel="noopener">#{{ z.number }}</a> {{ z.title }} <span class="proj-dim">· {{ z.column_label }} · {{ z.days_inactive }} j</span></li>
{% endfor %}
</ul>
{% endif %}
<p><a href="/health/{{ full_name }}">Détail de la santé →</a></p>
{% else %}
<p class="proj-empty">Santé indisponible pour le moment.</p>
{% endif %}
</section>
<section class="proj-panel">
<h2>🔮 Prévisions</h2>
{% if throughput %}
<ul class="proj-list">
<li>Débit : <strong>{{ throughput.avg }}</strong> fermetures / semaine en moyenne (min {{ throughput.min }} · max {{ throughput.max }}, 4 dernières semaines complètes)</li>
<li>Issues ouvertes : <strong>{{ repo_info.open_issues_count or 0 }}</strong></li>
</ul>
{% else %}
<p class="proj-empty">Pas assez d'historique de fermetures pour prévoir — le journal se remplit au fil des cartes terminées.</p>
{% endif %}
<p><a href="/forecast">Toutes les prévisions →</a></p>
</section>
<section class="proj-panel">
<h2>🕘 Activité récente</h2>
{% if events %}
<ul class="proj-list">
{% for e in events %}
<li>{{ EVENT_ICONS.get(e.event_type, '•') }} <a href="/board/{{ full_name }}">#{{ e.issue_number }}</a> — {{ e.event_type }}{% if e.to_column %} → {{ COLUMN_LABELS.get(e.to_column, e.to_column) }}{% endif %} <span class="proj-dim">· {{ e.created_at[:16].replace('T', ' ') }}</span></li>
{% endfor %}
</ul>
{% else %}
<p class="proj-empty">Aucun événement du journal pour ce projet pour le moment.</p>
{% endif %}
<p><a href="/activity">Journal complet →</a></p>
</section>
<section class="proj-panel proj-notes">
<h2>📝 Notes du projet</h2>
{% if notes %}
<div class="proj-notes-body">{{ notes }}</div>
{% else %}
<p class="proj-empty">Aucune note. Les notes s'écrivent depuis le panneau « 🗒️ Notes » du board et vivent avec le projet.</p>
{% endif %}
<p><a href="/board/{{ full_name }}">Ouvrir le board pour éditer →</a></p>
</section>
</div>
</main>
<footer>Kanbante v{{ version }} · Page projet — jalons, santé, prévisions, activité et notes en un coup d'œil.</footer>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+86
View File
@@ -0,0 +1,86 @@
<!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>Règles d'automatisation — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<section class="briefing-card">
<h1>⚙️ Règles d'automatisation</h1>
<p>Des réflexes déclaratifs, évalués toutes les 15 minutes par le planificateur (et à la demande ci-dessous). Chaque action est journalisée comme événement <code>rule</code>. <strong>Garde-fou v1 : une règle ne peut ni fermer ni déplacer une carte entre colonnes</strong> — seulement étiqueter, notifier (ntfy) et remonter en tête de colonne.</p>
{% if not configured %}
<p>Aucune règle configurée. Définis <code>KANBANTE_RULES</code> en JSON, par exemple :</p>
<pre class="briefing-text">[{"name": "Relance révision",
"when": {"column": "review", "min_age_days": 3},
"then": {"notify": true}},
{"name": "Urgent qui vieillit",
"when": {"column": "in_progress", "min_age_days": 7},
"then": {"add_label": "urgent"}}]</pre>
<p class="notes-hint">Conditions : <code>column</code>, <code>min_age_days</code> (temps dans la colonne), <code>label</code> (présente), <code>wip_over</code> (colonne au-delà de N cartes). Actions : <code>add_label</code>, <code>notify</code>, <code>move_top</code>.</p>
{% endif %}
{% for err in errors %}
<p class="chip-warn">⚠️ {{ err }}</p>
{% endfor %}
<p><button type="button" class="btn-save btn-inline" id="rules-run">▶️ Évaluer maintenant</button>
<span id="rules-run-result" class="notes-hint"></span></p>
</section>
{% if rules %}
<section class="briefing-card">
<h2>📜 Règles actives ({{ rules|length }})</h2>
<ul class="forecast-milestones">
{% for rule in rules %}
<li><strong>{{ rule.name }}</strong> — quand
{% for k, v in rule.when.items() %}<code>{{ k }} = {{ v }}</code>{% if not loop.last %}, {% endif %}{% endfor %}
→ {% for k, v in rule.then.items() %}<code>{{ k }}{% if v is not boolean %} = {{ v }}{% endif %}</code>{% if not loop.last %}, {% endif %}{% endfor %}</li>
{% endfor %}
</ul>
</section>
{% endif %}
{% if last_run %}
<section class="briefing-card">
<h2>🕐 Dernière évaluation — {{ last_run.at }}</h2>
{% if last_run.actions %}
<ul class="forecast-milestones">
{% for action in last_run.actions %}
<li><strong>{{ action.rule }}</strong> : {{ action.repo }} #{{ action.number }} ({{ action.column }}) — {{ action.did|join(', ') }}</li>
{% endfor %}
</ul>
{% else %}
<p>Aucune action déclenchée.</p>
{% endif %}
</section>
{% endif %}
</main>
<footer>
<span>Kanbante v{{ version }} · l'automatisation maison, près des données, jamais destructive</span>
</footer>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
<script>
document.getElementById('rules-run').addEventListener('click', async () => {
const out = document.getElementById('rules-run-result');
out.textContent = 'Évaluation en cours…';
const csrf = document.querySelector('meta[name="csrf-token"]').content;
const data = new FormData();
data.append('csrf_token', csrf);
try {
const r = await fetch('/api/rules/run', { method: 'POST', body: data });
const body = await r.json();
if (!r.ok || !body.ok) throw new Error((body && body.error) || ('HTTP ' + r.status));
out.textContent = '✔ ' + body.summary.actions.length + ' action(s) — rechargement…';
setTimeout(() => location.reload(), 700);
} catch (e) {
out.textContent = '❌ ' + e.message;
}
});
</script>
</body>
</html>
+1 -17
View File
@@ -8,23 +8,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
<span class="crumb"><span class="sep">/</span> 🔍 <strong>Recherche</strong></span>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill active">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
<form method="get" action="/search" class="search-hero">
+66
View File
@@ -0,0 +1,66 @@
<!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>Récit de sprint — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
<script src="/static/vendor/marked.min.js"></script>
<script src="/static/vendor/purify.min.js"></script>
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
{% if markdown %}
<section class="briefing-card">
<h1>📖 {{ milestone_title }}</h1>
<p>
<button type="button" class="btn-save btn-inline" id="sprint-copy">📋 Copier le markdown</button>
<button type="button" class="btn-save btn-inline" id="sprint-download">⬇️ Télécharger (.md)</button>
<a class="btn-save btn-inline" style="text-decoration:none" href="/board/{{ full_name }}?milestone={{ milestone_param|urlencode }}">🎯 Voir le board du milestone</a>
</p>
<p class="notes-hint">Le texte est archivable tel quel dans ObsiGate — c'est le markdown ci-dessous, rendu pour lecture.</p>
</section>
<section class="briefing-card sprint-render" id="sprint-render"></section>
<script>
const SPRINT_MD = {{ markdown|tojson }};
document.getElementById('sprint-render').innerHTML =
DOMPurify.sanitize(marked.parse(SPRINT_MD));
document.getElementById('sprint-copy').addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(SPRINT_MD);
document.getElementById('sprint-copy').textContent = '✅ Copié !';
} catch (e) { alert('Copie impossible : ' + e); }
});
document.getElementById('sprint-download').addEventListener('click', () => {
const blob = new Blob([SPRINT_MD], { type: 'text/markdown' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'recit-{{ full_name|replace('/', '-') }}-{{ milestone_param|urlencode }}.md';
a.click();
URL.revokeObjectURL(a.href);
});
</script>
{% else %}
<section class="briefing-card">
<h1>📖 Récit de sprint — {{ full_name }}</h1>
<p>Choisis un milestone pour générer son récit :</p>
<ul>
{% for m in milestones %}
<li><a href="/sprint/{{ full_name }}?milestone={{ m.title|urlencode }}">🎯 {{ m.title }}</a> — {{ m.closed_issues or 0 }} fermée(s), {{ m.open_issues or 0 }} ouverte(s)</li>
{% else %}
<li>Aucun milestone dans ce projet.</li>
{% endfor %}
</ul>
</section>
{% endif %}
</main>
<footer>
<span>Kanbante v{{ version }} · le récit est écrit par le journal, pas par la mémoire des gens</span>
</footer>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+45
View File
@@ -0,0 +1,45 @@
<!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>Standup — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<section class="briefing-card">
<h1>🗣️ Standup automatique</h1>
<p>Écrit par le journal, pas par la mémoire : fermé/déplacé <strong>hier</strong>, focus <strong>aujourd'hui</strong>, cartes qui vieillissent en QA/Révision en <strong>blocages</strong>.</p>
<p>
<button type="button" class="btn-save btn-inline" id="standup-copy">📋 Copier le standup</button>
{% if include_in_briefing %}
<span class="chip-ok">Inclus dans le briefing ntfy du matin ✔</span>
{% else %}
<span class="notes-hint">Astuce : <code>KANBANTE_BRIEFING_INCLUDE_STANDUP=1</code> l'ajoute au briefing ntfy du matin.</span>
{% endif %}
</p>
</section>
<section class="briefing-card">
<pre class="briefing-text" id="standup-text">{{ text }}</pre>
</section>
</main>
<footer>
<span>Kanbante v{{ version }} · zéro saisie, toujours factuel, daté</span>
</footer>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
<script>
document.getElementById('standup-copy').addEventListener('click', async () => {
const text = document.getElementById('standup-text').textContent;
try {
await navigator.clipboard.writeText(text);
document.getElementById('standup-copy').textContent = '✅ Copié !';
} catch (e) { alert('Copie impossible : ' + e); }
});
</script>
</body>
</html>
+87
View File
@@ -0,0 +1,87 @@
<!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>Aujourd'hui — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
{% include "_header.html" %}
<main class="today-main">
<h1 class="today-title">🌅 {{ date_label }}</h1>
<p class="today-sub">Trois cartes, pas une de plus. C'est le plan de la journée.</p>
{% if picks %}
<section class="today-picks">
{% for pick in picks %}
<article class="today-card{% if pick.state == 'closed' %} done{% endif %}">
<div class="today-card-top">
<span class="today-card-repo">{{ pick.repo }}</span>
<span class="today-card-col">{{ pick.column_label }}</span>
</div>
<h2>{% if pick.state == 'closed' %}🎉 {% endif %}#{{ pick.number }} — {{ pick.title }}</h2>
<div class="today-card-actions">
<a href="/board/{{ pick.repo }}">📋 Ouvrir le board</a>
{% if pick.html_url %}<a href="{{ pick.html_url }}" target="_blank" rel="noopener">Gitea ↗</a>{% endif %}
<button type="button" class="btn-sm" data-unpin data-repo="{{ pick.repo }}" data-number="{{ pick.number }}">📌 Retirer</button>
</div>
</article>
{% endfor %}
</section>
{% else %}
<section class="briefing-card">
<p>Aucune carte épinglée pour aujourd'hui. Épingle-en jusqu'à 3 depuis un board (bouton 📌 sur les cartes) ou depuis les candidates ci-dessous.</p>
</section>
{% endif %}
{% if recap %}
<section class="briefing-card">
<h2>🌆 Ça a bougé aujourd'hui</h2>
<ul>
{% for event in recap %}
<li>{{ event.created_at[11:16] }} — {{ event.repo_full_name }} #{{ event.issue_number }} : {{ event.event_type }}{% if event.to_column %} → {{ column_labels.get(event.to_column, event.to_column) }}{% endif %}</li>
{% endfor %}
</ul>
</section>
{% endif %}
{% if carry_over %}
<section class="briefing-card">
<h2>↩️ À reprendre d'hier</h2>
<ul class="today-list">
{% for pick in carry_over %}
<li>
<span>{{ pick.repo }} #{{ pick.number }} — {{ pick.title }} <em>({{ pick.column_label }})</em></span>
{% if picks|length < 3 %}<button type="button" class="btn-sm" data-pin data-repo="{{ pick.repo }}" data-number="{{ pick.number }}">📌 Reprendre aujourd'hui</button>{% endif %}
</li>
{% endfor %}
</ul>
</section>
{% endif %}
{% if candidates and picks|length < 3 %}
<section class="briefing-card">
<h2>🎯 Candidates (encours actuel)</h2>
<ul class="today-list">
{% for card in candidates %}
<li>
<span>{{ card.repo }} #{{ card.number }} — {{ card.title }} <em>({{ card.column_label }})</em></span>
<button type="button" class="btn-sm" data-pin data-repo="{{ card.repo }}" data-number="{{ card.number }}">📌 Épingler</button>
</li>
{% endfor %}
</ul>
</section>
{% endif %}
</main>
<footer>
<span>Kanbante v{{ version }} · le focus du jour se réinitialise chaque matin — les cartes non terminées reviennent en proposition</span>
</footer>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
<script src="/static/js/today.js" defer></script>
<script src="/static/js/live.js" defer></script>
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
<!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>Triage — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<section class="briefing-card">
<h1>📥 Mode Triage</h1>
<p>Le backlog se vide carte par carte, au clavier : une décision par carte, aucune souris requise. Choisis un projet pour commencer une session.</p>
</section>
{% if projects %}
<div class="triage-projects">
{% for p in projects %}
<a class="triage-project" href="/triage/{{ p.full_name }}">
<span class="triage-project-name">📂 {{ p.full_name }}</span>
<span class="triage-project-count">{{ p.backlog }} carte{{ 's' if p.backlog > 1 }} au backlog</span>
</a>
{% endfor %}
</div>
{% else %}
<div class="briefing-card"><p>🎉 Aucun backlog à trier : tous les projets actifs sont à zéro. Inbox zero, déjà.</p></div>
{% endif %}
</main>
<footer>
<span>Kanbante v{{ version }} · triage : 1-5 colonnes · L étiquettes · A s'assigner · X fermer · Entrée suivante · Z annuler</span>
</footer>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
<script src="/static/js/live.js" defer></script>
</body>
</html>
+74
View File
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Triage {{ full_name }} — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
{% include "_header.html" %}
<main class="triage-main" data-repo="{{ full_name }}">
<div class="triage-head">
<h1>📥 Triage — {{ full_name }}</h1>
<div class="triage-progress-wrap">
<div class="triage-progress"><div class="triage-progress-bar" id="triage-bar"></div></div>
<span class="triage-progress-label" id="triage-progress-label"></span>
</div>
</div>
<section class="triage-card" id="triage-card" aria-live="polite">
<div class="triage-card-top">
<span class="triage-card-num" id="tc-num"></span>
<span class="triage-card-date" id="tc-date"></span>
<a class="triage-card-link" id="tc-link" href="#" target="_blank" rel="noopener">Ouvrir dans Gitea ↗</a>
</div>
<h2 class="triage-card-title" id="tc-title"></h2>
<div class="triage-card-labels" id="tc-labels"></div>
<p class="triage-card-excerpt" id="tc-excerpt"></p>
<p class="triage-card-assign" id="tc-assign"></p>
</section>
<section class="triage-done" id="triage-done" hidden>
<h2>🎉 Session terminée</h2>
<ul class="triage-summary" id="triage-summary"></ul>
<p>
<a class="btn-save btn-inline" href="/board/{{ full_name }}">📋 Retour au board</a>
<a class="btn-save btn-inline" href="/triage/{{ full_name }}">🔁 Nouvelle session</a>
</p>
</section>
<div class="triage-status" id="triage-status" role="status"></div>
<section class="triage-keys" id="triage-keys">
<button type="button" data-act="move" data-col="todo"><kbd>1</kbd> 📋 À faire</button>
<button type="button" data-act="move" data-col="in_progress"><kbd>2</kbd> 🚧 En cours</button>
<button type="button" data-act="move" data-col="qa"><kbd>3</kbd> 🧪 QA</button>
<button type="button" data-act="move" data-col="review"><kbd>4</kbd> 👀 Révision</button>
<button type="button" data-act="move" data-col="done"><kbd>5</kbd> ✅ Terminé</button>
<button type="button" data-act="labels"><kbd>L</kbd> 🏷️ Étiquettes</button>
<button type="button" data-act="assign"><kbd>A</kbd> 👤 M'assigner</button>
<button type="button" data-act="close"><kbd>X</kbd> 🚫 Fermer</button>
<button type="button" data-act="next"><kbd>Entrée</kbd> ⏭️ Suivante</button>
<button type="button" data-act="undo"><kbd>Z</kbd> ↩️ Annuler</button>
</section>
<div class="triage-labels" id="triage-labels" hidden>
<div class="triage-labels-head">
<strong>🏷️ Étiquettes de la carte</strong>
<button type="button" class="btn-sm" id="triage-labels-close">✕ (Échap)</button>
</div>
<div class="triage-labels-list" id="triage-labels-list">Chargement…</div>
</div>
</main>
<footer>
<span>Kanbante v{{ version }} · session de triage — les décisions partent dans Gitea immédiatement</span>
</footer>
<script id="triage-queue" type="application/json">{{ queue_json|safe }}</script>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
<script src="/static/js/triage.js" defer></script>
</body>
</html>
+11
View File
@@ -45,6 +45,9 @@
{% endif %}
</li>
{% endfor %}
{% if slide.cards_total > slide.cards|length %}
<li class="wall-card wall-more">… +{{ slide.cards_total - slide.cards|length }} autres cartes en vol</li>
{% endif %}
</ul>
{% else %}
<p class="wall-idle">🌿 Rien en vol pour le moment — tout est au Backlog ou terminé.</p>
@@ -58,6 +61,14 @@
</section>
{% endfor %}
</div>
{% if not single and slides|length > 1 %}
<nav class="wall-nav" aria-label="Navigation des projets">
<button type="button" class="wall-nav-btn" id="wall-prev" title="Projet précédent (←)">❮</button>
<div class="wall-dots" id="wall-dots"></div>
<button type="button" class="wall-nav-btn" id="wall-next" title="Projet suivant (→)">❯</button>
<div class="wall-paused" id="wall-paused" hidden>⏸️ Rotation en pause — Espace pour reprendre</div>
</nav>
{% endif %}
{% else %}
<div class="wall-empty">
<h1>🖥️ Wallboard</h1>
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "kanbante"
version = "0.9.0"
version = "0.22.0"
description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11"
dependencies = [
+627 -45
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); }
@@ -900,15 +907,21 @@ footer {
color: #e5ecf5;
margin: 0;
font-family: inherit;
height: 100vh;
height: 100dvh;
overflow: hidden;
}
.wall-stage { position: relative; min-height: 100vh; }
.wall-stage { position: relative; height: 100vh; height: 100dvh; }
.wall-slide {
display: none;
min-height: 100vh;
height: 100vh;
height: 100dvh;
max-height: 100dvh;
overflow: hidden;
box-sizing: border-box;
padding: 4vh 4vw;
padding: 3.5vh 4vw 96px;
flex-direction: column;
gap: 3vh;
gap: 2.5vh;
}
.wall-slide.active { display: flex; animation: wall-in 0.45s ease; }
@keyframes wall-in { from { opacity: 0; } to { opacity: 1; } }
@@ -934,7 +947,14 @@ footer {
}
.wall-count-n { display: block; font-size: clamp(1.9rem, 3.6vw, 3rem); font-weight: 800; }
.wall-count-l { display: block; color: #aebdd2; font-size: clamp(0.78rem, 1.25vw, 1.02rem); margin-top: 2px; }
.wall-cards { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
.wall-cards {
list-style: none; margin: 0; padding: 0;
display: flex; flex-direction: column; gap: 12px;
flex: 1 1 auto; min-height: 0; overflow: hidden;
-webkit-mask-image: linear-gradient(to bottom, #000 88%, transparent);
mask-image: linear-gradient(to bottom, #000 88%, transparent);
}
.wall-more { justify-content: center; color: #8fa3bf; font-size: 1.1rem; background: transparent; }
.wall-card {
display: flex; align-items: center; gap: 18px;
background: rgba(255, 255, 255, 0.045);
@@ -954,8 +974,17 @@ footer {
.wall-foot {
margin-top: auto;
display: flex; gap: 36px; flex-wrap: wrap;
justify-content: space-between; align-items: center;
width: 100%; box-sizing: border-box;
font-size: clamp(1rem, 1.7vw, 1.35rem); color: #aebdd2;
}
@media (max-height: 760px) {
.wall-slide { gap: 1.6vh; padding-top: 2.5vh; }
.wall-card { padding: 10px 16px; }
.wall-count { padding: 8px 8px 7px; }
.wall-project { font-size: clamp(1.8rem, 4vw, 2.6rem); }
.wall-time { font-size: clamp(1.7rem, 3.6vw, 2.6rem); }
}
.wall-live { color: #34d399; font-weight: 700; letter-spacing: 0.08em; }
.wall-empty {
min-height: 100vh; display: flex; flex-direction: column;
@@ -1026,3 +1055,556 @@ footer {
color: var(--text-3); font-size: 0.78rem; text-align: right;
}
.palette-trigger { cursor: pointer; font: inherit; }
/* ── Mode Triage (0.10.0, #11) ── */
.triage-main { max-width: 860px; margin: 0 auto; padding: 24px 16px 40px; }
.triage-head h1 { margin: 0 0 12px; font-size: 1.4rem; }
.triage-progress-wrap { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; }
.triage-progress { flex: 1; height: 10px; border-radius: 99px; background: var(--surface-3, #1d2942); overflow: hidden; }
.triage-progress-bar { height: 100%; width: 0; background: var(--good, #3fb950); border-radius: 99px; transition: width 0.25s ease; }
.triage-progress-label { color: var(--text-3); font-size: 0.85rem; white-space: nowrap; }
.triage-card {
background: var(--surface-1, #131a29);
border: 1px solid var(--border-strong, #2c3a52);
border-radius: 16px; padding: 26px 28px; min-height: 220px;
}
.triage-card-top { display: flex; gap: 14px; align-items: baseline; color: var(--text-3); font-size: 0.88rem; }
.triage-card-num { font-weight: 700; color: var(--accent, #58a6ff); }
.triage-card-link { margin-left: auto; color: var(--text-3); text-decoration: none; }
.triage-card-title { margin: 10px 0 8px; font-size: 1.5rem; line-height: 1.3; }
.triage-card-labels { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
.triage-card-excerpt { color: var(--text-2, #b6c2d2); line-height: 1.55; white-space: pre-wrap; }
.triage-card-assign { color: var(--text-3); }
.triage-keys { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 18px; }
.triage-keys button {
background: var(--surface-2, #182236); color: var(--text-1);
border: 1px solid var(--border, #22304a); border-radius: 10px;
padding: 9px 13px; font: inherit; font-size: 0.9rem; cursor: pointer;
}
.triage-keys button:hover { border-color: var(--accent, #58a6ff); }
.triage-keys kbd {
background: var(--surface-3, #1d2942); border: 1px solid var(--border-strong, #2c3a52);
border-radius: 5px; padding: 1px 6px; font-size: 0.78rem; margin-right: 4px;
}
.triage-status { min-height: 22px; margin-top: 12px; color: var(--good, #3fb950); }
.triage-labels {
margin-top: 14px; background: var(--surface-1, #131a29);
border: 1px solid var(--border-strong, #2c3a52); border-radius: 14px; padding: 16px 18px;
}
.triage-labels-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.triage-labels-list { display: flex; gap: 8px; flex-wrap: wrap; }
.triage-label-chip {
background: var(--surface-3, #1d2942); color: var(--text-1);
border: 1px solid var(--border, #22304a); border-radius: 99px;
padding: 5px 12px; font: inherit; font-size: 0.85rem; cursor: pointer;
}
.triage-label-chip.on { border-color: var(--good, #3fb950); color: var(--good, #3fb950); }
.triage-done { background: var(--surface-1, #131a29); border: 1px solid var(--border-strong, #2c3a52); border-radius: 16px; padding: 26px 28px; }
.triage-summary { line-height: 1.9; }
.triage-projects { display: grid; gap: 10px; }
.triage-project {
display: flex; justify-content: space-between; align-items: center;
background: var(--surface-1, #131a29); border: 1px solid var(--border, #22304a);
border-radius: 12px; padding: 16px 20px; color: var(--text-1); text-decoration: none;
}
.triage-project:hover { border-color: var(--accent, #58a6ff); }
.triage-project-count { color: var(--text-3); }
/* ── Wallboard : navigation manuelle (0.11.0) ── */
.wall-nav {
position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%);
display: flex; align-items: center; gap: 14px; z-index: 50;
}
.wall-nav-btn {
width: 46px; height: 46px; border-radius: 50%;
background: rgba(19, 26, 41, 0.72); color: #dbe4f0;
border: 1px solid rgba(143, 163, 191, 0.45);
font-size: 1.15rem; cursor: pointer; backdrop-filter: blur(4px);
}
.wall-nav-btn:hover { border-color: #58a6ff; color: #fff; }
.wall-dots { display: flex; gap: 8px; }
.wall-dot {
width: 13px; height: 13px; border-radius: 50%; padding: 0;
background: rgba(143, 163, 191, 0.35); border: 1px solid rgba(143, 163, 191, 0.5);
cursor: pointer;
}
.wall-dot.on { background: #58a6ff; border-color: #58a6ff; }
.wall-paused {
position: fixed; top: 18px; left: 50%; transform: translateX(-50%);
background: rgba(19, 26, 41, 0.85); color: #f0b429;
border: 1px solid rgba(240, 180, 41, 0.55); border-radius: 99px;
padding: 6px 16px; font-size: 0.95rem; z-index: 51;
}
/* ── Focus du jour (0.12.0, #5) ── */
.pin-btn {
background: transparent; border: none; cursor: pointer;
font-size: 0.82rem; padding: 0 2px; margin-left: auto; opacity: 0.45;
}
.pin-btn:hover { opacity: 1; transform: scale(1.15); }
.today-main { max-width: 880px; margin: 0 auto; padding: 28px 16px 44px; }
.today-title { font-size: 1.9rem; margin: 0 0 4px; text-transform: capitalize; }
.today-sub { color: var(--text-3); margin: 0 0 22px; }
.today-picks { display: grid; gap: 14px; margin-bottom: 20px; }
.today-card {
background: var(--surface-1, #131a29); border: 1px solid var(--border-strong, #2c3a52);
border-left: 5px solid var(--accent, #58a6ff); border-radius: 14px; padding: 20px 24px;
}
.today-card.done { border-left-color: var(--good, #3fb950); opacity: 0.85; }
.today-card-top { display: flex; justify-content: space-between; color: var(--text-3); font-size: 0.85rem; }
.today-card h2 { margin: 8px 0 12px; font-size: 1.3rem; line-height: 1.35; }
.today-card-actions { display: flex; gap: 16px; align-items: center; }
.today-card-actions a { color: var(--accent, #58a6ff); text-decoration: none; font-size: 0.9rem; }
.today-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 10px; }
.today-list li { display: flex; justify-content: space-between; gap: 12px; align-items: center; }
.today-list em { color: var(--text-3); font-style: normal; font-size: 0.85rem; }
/* ── Prévisions + Récit (0.12.0, #7 #4) ── */
.forecast-flex { display: flex; gap: 22px; align-items: center; flex-wrap: wrap; }
.sparkline { width: 168px; height: 48px; }
.sparkline rect { fill: var(--accent, #58a6ff); opacity: 0.85; }
.forecast-nums p { margin: 4px 0; }
.forecast-milestones { margin: 12px 0 0; padding-left: 18px; line-height: 1.8; }
.forecast-line { color: var(--accent, #58a6ff); }
.milestone-forecast { color: var(--accent, #58a6ff); font-weight: 600; }
.milestone-sprint { color: var(--text-1); text-decoration: none; border: 1px solid var(--border, #22304a); border-radius: 8px; padding: 2px 10px; }
.milestone-sprint:hover { border-color: var(--accent, #58a6ff); }
.sprint-render { line-height: 1.65; }
.sprint-render h1 { font-size: 1.5rem; }
.sprint-render h2 { font-size: 1.15rem; margin-top: 22px; }
.sprint-render a { color: var(--accent, #58a6ff); }
/* ── Code lié (0.13.0, #1) ── */
.card-code { font-size: 0.78rem; color: var(--accent, #58a6ff); margin-top: 5px; }
.detail-code { margin: 10px 0; }
.code-line { padding: 3px 0; font-size: 0.9rem; color: var(--text-2, #b6c2d2); }
.code-line a { color: var(--accent, #58a6ff); text-decoration: none; }
.code-line code { background: var(--surface-3, #1d2942); border-radius: 4px; padding: 0 5px; }
/* ── Branche depuis la carte (0.14.0, #3) ── */
.branch-input {
background: var(--surface-3, #1d2942); color: var(--text-1);
border: 1px solid var(--border, #22304a); border-radius: 6px;
padding: 3px 8px; font-family: monospace; font-size: 0.85rem;
}
/* ── Santé du flux (0.14.0, #9) ── */
.health-table { width: 100%; border-collapse: collapse; margin: 10px 0; }
.health-table th, .health-table td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--border, #22304a); }
.health-slowest td { color: var(--warn, #e8a33d); font-weight: 600; }
.zombie-actions { display: flex; gap: 6px; flex-shrink: 0; }
/* ── Header unifié (0.15.0, #16) ── */
.header-actions { display: flex; gap: 6px; align-items: center; margin-left: auto; }
.nav-burger {
display: none; width: 40px; height: 40px; border-radius: 10px;
background: var(--surface-2); border: 1px solid var(--border);
color: var(--text); font-size: 1.05rem; cursor: pointer;
align-items: center; justify-content: center;
}
.crumb a { color: inherit; text-decoration: none; }
.crumb a:hover { text-decoration: underline; }
@media (max-width: 900px) {
.nav-burger { display: inline-flex; }
.crumb { display: none; }
}
/* ── Barre latérale (0.16.0, #16 refonte) ── */
body.with-sidebar { padding-left: 238px; transition: padding-left 0.18s ease; }
body.with-sidebar.sidebar-collapsed { padding-left: 68px; }
.sidebar {
position: fixed; left: 0; top: 0; bottom: 0; z-index: 200;
width: 238px; box-sizing: border-box;
display: flex; flex-direction: column; gap: 6px;
padding: 16px 12px 12px; overflow-y: auto; overflow-x: hidden;
background: rgba(13, 16, 24, 0.96); backdrop-filter: blur(14px);
border-right: 1px solid var(--border);
transition: width 0.18s ease, transform 0.22s ease;
}
body.sidebar-collapsed .sidebar { width: 68px; }
.side-logo { display: flex; align-items: center; gap: 10px; padding: 4px 8px 12px; color: var(--text); font-size: 1.18rem; font-weight: 700; white-space: nowrap; }
.side-logo:hover { text-decoration: none; }
.side-head {
display: flex; align-items: center; gap: 10px;
padding: 4px 6px 12px;
}
.side-head .side-logo { padding: 0; }
.side-search {
margin-left: auto; flex-shrink: 0;
width: 38px; height: 38px; border-radius: 10px; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
background: var(--surface-2); color: var(--text-2); font-size: 1.02rem;
border: 1px solid var(--border);
transition: border-color 0.15s, color 0.15s, transform 0.15s;
}
.side-search:hover {
color: var(--text); border-color: var(--accent, #58a6ff);
transform: translateY(-1px);
}
body.sidebar-collapsed .side-head { flex-direction: column; padding-left: 0; padding-right: 0; }
body.sidebar-collapsed .side-search { margin-left: 0; }
.side-nav { display: flex; flex-direction: column; gap: 2px; }
.side-section { margin-top: 10px; }
.side-section:first-child { margin-top: 2px; }
.side-group-btn {
display: flex; align-items: center; justify-content: space-between;
width: 100%; padding: 7px 10px; cursor: pointer;
background: transparent; border: none; border-radius: 8px;
font-size: 0.7rem; font-weight: 700; letter-spacing: 0.14em;
text-transform: uppercase; color: var(--text-3); white-space: nowrap;
transition: color 0.15s, background 0.15s;
}
.side-group-btn:hover { color: var(--text); background: var(--surface-2); }
.side-chevron {
font-size: 0.62rem; transition: transform 0.25s ease; opacity: 0.8;
}
.side-acc {
display: grid; grid-template-rows: 1fr;
transition: grid-template-rows 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}
.side-acc-inner {
overflow: hidden; min-height: 0;
display: flex; flex-direction: column; gap: 2px;
}
body.nav-closed-principal [data-group="principal"] .side-acc,
body.nav-closed-rituels [data-group="rituels"] .side-acc,
body.nav-closed-pilotage [data-group="pilotage"] .side-acc,
body.nav-closed-outils [data-group="outils"] .side-acc { grid-template-rows: 0fr; }
body.nav-closed-principal [data-group="principal"] .side-chevron,
body.nav-closed-rituels [data-group="rituels"] .side-chevron,
body.nav-closed-pilotage [data-group="pilotage"] .side-chevron,
body.nav-closed-outils [data-group="outils"] .side-chevron { transform: rotate(-90deg); }
@media (min-width: 901px) {
/* Rail replié : pas d'accordéon, toutes les icônes visibles. */
body.sidebar-collapsed .side-acc { grid-template-rows: 1fr !important; }
body.sidebar-collapsed .side-group-btn { display: none; }
body.sidebar-collapsed .side-section { margin-top: 4px; }
}
.side-link {
display: flex; align-items: center; gap: 11px;
padding: 8px 10px; border-radius: 9px;
color: var(--text-2); font-size: 0.95rem; font-weight: 500;
white-space: nowrap; border-left: 3px solid transparent;
}
.side-link:hover { background: var(--surface-2); color: var(--text); text-decoration: none; }
.side-link.active {
color: #fff; border-left-color: #a78bfa;
background: linear-gradient(90deg, rgba(124,92,252,.32), rgba(91,138,247,.14));
}
.side-ico { font-size: 1.05rem; width: 22px; text-align: center; flex-shrink: 0; }
.side-foot {
margin-top: auto; padding-top: 12px;
display: flex; align-items: center; justify-content: space-between; gap: 8px;
}
.side-collapse {
/* 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); 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; }
body.sidebar-collapsed .side-label { display: none; }
body.sidebar-collapsed .side-link { justify-content: center; padding-left: 0; padding-right: 0; }
body.sidebar-collapsed .side-logo { justify-content: center; padding-left: 0; padding-right: 0; }
body.sidebar-collapsed .side-foot { justify-content: center; }
.side-backdrop {
position: fixed; inset: 0; z-index: 190;
background: rgba(4, 6, 10, 0.55);
}
.topbar { min-height: 52px; padding: 8px 18px; justify-content: flex-start; gap: 14px; }
.topbar-search { display: none; }
@media (max-width: 900px) {
body.with-sidebar, body.with-sidebar.sidebar-collapsed { padding-left: 0; }
.sidebar { width: 264px; transform: translateX(-105%); box-shadow: none; }
body.sidebar-open .sidebar { transform: none; box-shadow: 18px 0 44px rgba(0,0,0,.5); }
body.sidebar-collapsed .side-label { display: initial; }
body.sidebar-collapsed .side-link { justify-content: flex-start; padding-left: 10px; padding-right: 10px; }
body.sidebar-collapsed .side-head { flex-direction: row; padding: 4px 6px 12px; }
body.sidebar-collapsed .side-search { margin-left: auto; }
.side-foot { display: none; }
.topbar-search { display: inline-flex; }
}
/* ── Dashboard vague 2 (0.17.0, #17 #18 #20) + page Projet (#25) ── */
.health-dot {
width: 11px; height: 11px; border-radius: 50%;
display: inline-block; flex-shrink: 0;
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.06);
}
.health-dot.green { background: #34d399; }
.health-dot.orange { background: #f59e0b; }
.health-dot.red { background: #f87171; }
.star-btn {
background: transparent; border: none; cursor: pointer;
font-size: 1rem; line-height: 1; padding: 2px 4px; margin-left: auto;
color: #fbbf24;
}
.star-btn:hover { transform: scale(1.2); }
.star-btn:disabled { opacity: 0.5; }
.card-board-link { font-weight: 600; }
.ms-block { margin: 10px 0 2px; }
.ms-line { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }
.ms-title { font-weight: 600; font-size: 0.92rem; }
.ms-due { font-size: 0.8rem; color: var(--text-2); white-space: nowrap; }
.ms-due.late { color: #f87171; font-weight: 700; }
.ms-progress { margin: 6px 0 4px; }
.ms-meta { font-size: 0.8rem; color: var(--text-2); }
.pinned-title { margin: 18px 0 10px; font-size: 1.05rem; }
.project-main { max-width: 1180px; margin: 0 auto; padding: 26px 22px 10px; }
.proj-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; }
.proj-head h1 { margin: 0; font-size: 1.7rem; }
.proj-sub { color: var(--text-2); margin: 6px 0 0; }
.health-chip {
display: inline-flex; align-items: center; gap: 8px;
border: 1px solid var(--border); border-radius: 999px;
padding: 7px 14px; color: var(--text); background: var(--surface-1);
font-size: 0.88rem; font-weight: 600;
}
.health-chip:hover { text-decoration: none; border-color: var(--accent, #58a6ff); }
.proj-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 18px 0 22px; }
.proj-actions .nav-pill { border: 1px solid var(--border); }
.proj-primary { background: linear-gradient(135deg, rgba(124,92,252,.85), rgba(91,138,247,.85)); color: #fff !important; }
.proj-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
.proj-panel {
background: var(--surface-1); border: 1px solid var(--border);
border-radius: 14px; padding: 18px 20px;
}
.proj-panel h2 { margin: 0 0 12px; font-size: 1.05rem; }
.proj-ms { margin-bottom: 14px; }
.proj-ms:last-child { margin-bottom: 0; }
.proj-list { list-style: none; margin: 0 0 10px; padding: 0; display: flex; flex-direction: column; gap: 7px; font-size: 0.93rem; }
.proj-dim { color: var(--text-3); }
.proj-empty { color: var(--text-2); font-size: 0.92rem; }
.proj-notes-body { white-space: pre-wrap; font-size: 0.93rem; background: var(--surface-2); border-radius: 10px; padding: 12px 14px; }
/* ── Dashboard vague 2 suite (0.18.0, #19 #21 #22 #23) ── */
.activity-line { margin-top: 8px; font-size: 0.82rem; color: var(--text-2); }
/* 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; }
+56
View File
@@ -369,12 +369,68 @@ document.addEventListener('DOMContentLoaded', () => {
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
fillDetail(data.issue, data.comments);
loadCodeLinks(repoFull, number);
} catch (err) {
document.getElementById('detail-title').textContent = 'Erreur de chargement';
showToast('❌ Détail indisponible : ' + err.message, true);
}
}
async function loadCodeLinks(repoFull, number) {
const box = document.getElementById('detail-code');
if (!box) return;
box.innerHTML = '';
const [owner, repo] = repoFull.split('/', 2);
try {
const r = await fetch(`/api/issues/${owner}/${repo}/${number}/code`);
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) return;
const parts = [];
if ((data.branches || []).length) {
parts.push('🌿 Branches : ' + data.branches.map(b => `<code>${b}</code>`).join(', '));
}
for (const pr of data.pulls || []) {
const state = pr.merged ? 'fusionnée' : (pr.state === 'open' ? 'ouverte' : 'fermée');
parts.push(`🔀 <a href="${pr.html_url}" target="_blank" rel="noopener">PR #${pr.number}</a> ${pr.title} — ${state}`);
}
for (const c of (data.commits || []).slice(0, 8)) {
parts.push(`⑂ <a href="${c.html_url}" target="_blank" rel="noopener"><code>${c.sha}</code></a> ${c.message}${c.date ? ' · ' + c.date : ''}`);
}
const hasBranch = (data.branches || []).length > 0;
if (!hasBranch && data.suggested_branch) {
parts.push(`🌿 <input id="branch-name" class="branch-input" value="${data.suggested_branch}" size="36" spellcheck="false"> <button type="button" class="btn-sm" id="branch-create">Créer la branche</button>`);
}
box.innerHTML = '<h3 class="detail-comments-title">🧬 Code lié</h3>' + (
parts.length
? parts.map(p => `<div class="code-line">${p}</div>`).join('')
: '<div class="code-line">Aucun élément lié pour l\'instant.</div>'
);
const createBtn = document.getElementById('branch-create');
if (createBtn) {
createBtn.addEventListener('click', async () => {
const name = (document.getElementById('branch-name') || {}).value || '';
const form = new FormData();
form.append('name', name);
form.append('csrf_token', csrfToken);
createBtn.disabled = true;
try {
const r2 = await fetch(`/api/issues/${owner}/${repo}/${number}/branch`, { method: 'POST', body: form });
const body = await r2.json().catch(() => ({}));
if (!r2.ok || !body.ok) throw new Error(body.error || ('HTTP ' + r2.status));
showToast(body.exists
? `🌿 La branche ${body.branch} existait déjà`
: `🌿 Branche ${body.branch} créée depuis ${body.from} — carte en « En cours »`);
loadCodeLinks(repoFull, number);
} catch (err) {
createBtn.disabled = false;
showToast('❌ Branche non créée : ' + err.message, true);
}
});
}
} catch (err) { /* le code est un bonus, jamais bloquant */ }
}
function fillDetail(issue, comments) {
document.getElementById('detail-title').textContent = issue.title;
const stateChip = document.getElementById('detail-state');
+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). */
})();
+54
View File
@@ -0,0 +1,54 @@
/**
* Kanbante — Flow health (issue #9): quick actions on zombie cards.
* Close/backlog reuse /api/move; the nudge reuses the comments API.
*/
(function () {
var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
/* 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]); });
data.append('csrf_token', csrf);
return fetch(url, { method: 'POST', body: data }).then(function (r) {
return r.json().catch(function () { return {}; }).then(function (body) {
if (!r.ok || body.ok === false) throw new Error(body.error || ('HTTP ' + r.status));
return body;
});
});
}
document.addEventListener('click', function (e) {
var btn = e.target.closest('[data-zombie]');
if (!btn) return;
var repo = btn.getAttribute('data-repo');
var number = btn.getAttribute('data-number');
var column = btn.getAttribute('data-column');
var action = btn.getAttribute('data-zombie');
var parts = repo.split('/');
btn.disabled = true;
var done = function () { location.reload(); };
var fail = function (err) { btn.disabled = false; alert(err.message); };
if (action === 'comment') {
post('/api/issues/' + parts[0] + '/' + parts[1] + '/' + number + '/comments', {
body: '🧟 Cette carte est inactive depuis un moment — toujours pertinent ?'
}).then(done).catch(fail);
} else {
post('/api/move', {
repo_full_name: repo,
issue_number: number,
from_column: column,
to_column: action === 'close' ? 'done' : 'backlog',
to_order: number
}).then(done).catch(fail);
}
});
})();
+75
View File
@@ -0,0 +1,75 @@
/**
* Kanbante — sidebar behaviour (issue #16, 0.16.0 layout).
* Desktop: collapse to an icon rail (persisted in localStorage).
* Mobile: the same sidebar becomes an off-canvas drawer, toggled
* by the topbar burger, closed by backdrop / Escape / navigation.
*/
(function () {
var burger = document.getElementById('nav-burger');
var backdrop = document.getElementById('side-backdrop');
var collapseBtn = document.getElementById('side-collapse');
function closeDrawer() {
document.body.classList.remove('sidebar-open');
if (backdrop) backdrop.hidden = true;
if (burger) {
burger.setAttribute('aria-expanded', 'false');
burger.textContent = '☰';
}
}
if (burger) {
burger.addEventListener('click', function () {
var open = document.body.classList.toggle('sidebar-open');
burger.setAttribute('aria-expanded', open ? 'true' : 'false');
burger.textContent = open ? '✕' : '☰';
if (backdrop) backdrop.hidden = !open;
});
}
if (backdrop) backdrop.addEventListener('click', closeDrawer);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeDrawer();
});
window.addEventListener('resize', function () {
if (window.innerWidth > 900) closeDrawer();
});
if (collapseBtn) {
collapseBtn.addEventListener('click', function () {
var collapsed = document.body.classList.toggle('sidebar-collapsed');
try {
localStorage.setItem('kanbante-sidebar', collapsed ? 'collapsed' : 'open');
} catch (e) { /* private mode: state lasts for the page only */ }
});
}
/* 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 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 () {
var key = btn.getAttribute('data-group-btn');
var isOpen = !document.body.classList.contains('nav-closed-' + key);
setOpenGroup(isOpen ? '' : key);
});
});
})();
+6
View File
@@ -13,10 +13,16 @@
var ACTIONS = [
{ icon: '🏠', label: 'Projets — dashboard', href: '/' },
{ icon: '🎯', label: 'Focus — en cours, QA, révision', href: '/focus' },
{ icon: '🌅', label: "Focus du jour — les 3 cartes d'aujourd'hui", href: '/today' },
{ icon: '🔮', label: 'Prévisions — on finit quand ?', href: '/forecast' },
{ icon: '🗣️', label: 'Standup automatique — hier, aujourd\'hui, blocages', href: '/standup' },
{ icon: '⚙️', label: 'Règles d\'automatisation', href: '/rules' },
{ icon: '🩺', label: 'Santé du flux — goulots et zombies', href: '/health' },
{ icon: '👤', label: 'Mes issues', href: '/my' },
{ icon: '📰', label: 'Activité — journal des événements', href: '/activity' },
{ icon: '📊', label: 'Briefing quotidien', href: '/briefing' },
{ icon: '⚡', label: 'Capture éclair — nouvelle idée', href: '/capture' },
{ icon: '📥', label: 'Mode Triage — vider un backlog au clavier', href: '/triage' },
{ icon: '🧠', label: 'Digest hebdo — vers ObsiGate', href: '/digest' },
{ icon: '🖥️', label: 'Wallboard — mode TV', href: '/wall' },
{ icon: '🔍', label: 'Recherche avancée', href: '/search' },
+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);
})();
+56
View File
@@ -0,0 +1,56 @@
/**
* Kanbante — pin projects via Gitea stars (issue #20).
* The star lives in Gitea (source of truth); this only toggles it
* through the API and moves the card between the pinned section
* and its home tab grid, without a page reload.
*/
(function () {
var pinnedSection = document.getElementById('pinned-section');
var pinnedGrid = document.getElementById('pinned-grid');
function refreshPinnedVisibility() {
if (!pinnedSection || !pinnedGrid) return;
pinnedSection.hidden = pinnedGrid.querySelectorAll('.project-card').length === 0;
}
document.querySelectorAll('.star-btn').forEach(function (btn) {
btn.addEventListener('click', async function (e) {
e.preventDefault();
e.stopPropagation();
var full = btn.getAttribute('data-full');
var parts = full.split('/');
var want = btn.getAttribute('data-starred') !== '1';
btn.disabled = true;
try {
var fd = new FormData();
fd.append('starred', want ? '1' : '0');
fd.append('csrf_token', window.KANBANTE_CSRF || '');
var res = await fetch('/api/projects/' + parts[0] + '/' + parts[1] + '/star', {
method: 'POST', body: fd,
});
var data = await res.json();
if (!data.ok) throw new Error(data.error || 'échec');
btn.setAttribute('data-starred', want ? '1' : '0');
btn.textContent = want ? '⭐' : '☆';
btn.title = want ? 'Désépingler (étoile Gitea)' : 'Épingler (étoile Gitea)';
var card = btn.closest('.project-card');
if (card) {
if (want && pinnedGrid) {
pinnedGrid.appendChild(card);
} else {
var home = document.getElementById('grid-' + (card.getAttribute('data-home') || 'active'));
if (home) home.appendChild(card);
}
refreshPinnedVisibility();
}
} catch (err) {
btn.textContent = '⚠️';
setTimeout(function () {
btn.textContent = btn.getAttribute('data-starred') === '1' ? '⭐' : '☆';
}, 1600);
} finally {
btn.disabled = false;
}
});
});
})();
+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); });
});
});
});
})();
+48
View File
@@ -0,0 +1,48 @@
/**
* Kanbante — Focus of the day (issue #5): pin/unpin from /today and,
* via a delegated handler, the 📌 button on board cards.
*/
(function () {
var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
function post(url, repo, number) {
var data = new FormData();
data.append('repo_full_name', repo);
data.append('issue_number', number);
data.append('csrf_token', csrf);
return fetch(url, { method: 'POST', body: data }).then(function (r) {
return r.json().catch(function () { return {}; }).then(function (body) {
if (!r.ok || body.ok === false) throw new Error(body.error || ('HTTP ' + r.status));
return body;
});
});
}
// Capture phase: a 📌 click on a board card must be swallowed
// before board.js sees it (it would open the detail drawer).
document.addEventListener('click', function (e) {
var pinBtn = e.target.closest('[data-pin]');
var unpinBtn = e.target.closest('[data-unpin]');
var cardPin = e.target.closest('.pin-btn');
if (pinBtn) {
post('/api/today/pin', pinBtn.getAttribute('data-repo'), pinBtn.getAttribute('data-number'))
.then(function () { location.reload(); })
.catch(function (err) { alert(err.message); });
} else if (unpinBtn) {
post('/api/today/unpin', unpinBtn.getAttribute('data-repo'), unpinBtn.getAttribute('data-number'))
.then(function () { location.reload(); })
.catch(function (err) { alert(err.message); });
} else if (cardPin) {
// Board card 📌: never open the detail drawer for this click.
e.stopPropagation();
e.preventDefault();
var card = cardPin.closest('.kanban-card');
post('/api/today/pin', card.getAttribute('data-repo'), card.getAttribute('data-issue'))
.then(function () {
cardPin.textContent = '📌✓';
cardPin.title = 'Épinglée au Focus du jour (/today)';
})
.catch(function (err) { alert(err.message); });
}
}, true);
})();
+236
View File
@@ -0,0 +1,236 @@
/**
* Kanbante — Triage session (issue #11).
*
* One backlog card at a time, decided from the keyboard:
* 1-5 move to a column (5 = done, closes), L labels panel,
* A assign me, X close, Enter next (skip), Z undo last move.
* Decisions hit the same APIs as the board (/api/move, label
* toggles, assign-self) — the queue lives entirely client-side.
*/
(function () {
var main = document.querySelector('.triage-main');
if (!main) return;
var repo = main.getAttribute('data-repo');
var parts = repo.split('/');
var owner = parts[0], name = parts.slice(1).join('/');
var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
var queue = JSON.parse(document.getElementById('triage-queue').textContent || '[]');
var idx = 0;
var history = []; // {number, to} for undoable moves
var counts = {}; // decision tally for the summary
var busy = false;
var el = function (id) { return document.getElementById(id); };
var cardEl = el('triage-card'), doneEl = el('triage-done');
var statusEl = el('triage-status'), labelsPanel = el('triage-labels');
function current() { return queue[idx] || null; }
function status(msg, isError) {
statusEl.textContent = msg || '';
statusEl.style.color = isError ? '#ff7b72' : '';
}
function render() {
var card = current();
var total = queue.length;
el('triage-bar').style.width = total ? Math.round(100 * idx / total) + '%' : '100%';
el('triage-progress-label').textContent = total
? 'Carte ' + Math.min(idx + 1, total) + ' / ' + total
: '0 carte';
if (!card) {
cardEl.hidden = true;
el('triage-keys').hidden = true;
doneEl.hidden = false;
var summary = el('triage-summary');
summary.innerHTML = '';
var labels = {
todo: '📋 À faire', in_progress: '🚧 En cours', qa: '🧪 QA',
review: '👀 Révision', done: '✅ Terminé / fermée',
skip: '⏭️ Passées (restent au backlog)',
};
Object.keys(labels).forEach(function (key) {
if (counts[key]) {
var li = document.createElement('li');
li.textContent = labels[key] + ' : ' + counts[key];
summary.appendChild(li);
}
});
if (!Object.keys(counts).length) {
var li = document.createElement('li');
li.textContent = 'Aucune décision prise.';
summary.appendChild(li);
}
return;
}
cardEl.hidden = false;
doneEl.hidden = true;
el('tc-num').textContent = '#' + card.number;
el('tc-date').textContent = card.created_at
? 'créée le ' + card.created_at + (card.age_days != null ? ' · ⏳ ' + card.age_days + ' j' : '')
: '';
el('tc-link').href = card.html_url || '#';
el('tc-title').textContent = card.title;
el('tc-excerpt').textContent = card.excerpt || '(aucune description)';
var labelsBox = el('tc-labels');
labelsBox.innerHTML = '';
(card.labels || []).forEach(function (l) {
var chip = document.createElement('span');
chip.className = 'label-tag';
chip.style.background = '#' + l.color + '30';
chip.style.color = '#' + l.color;
chip.textContent = l.name;
labelsBox.appendChild(chip);
});
el('tc-assign').textContent = (card.assignees && card.assignees.length)
? '👤 Assignée à : ' + card.assignees.join(', ')
: '';
status('');
}
function post(url, fields) {
var data = new FormData();
Object.keys(fields).forEach(function (k) { data.append(k, fields[k]); });
data.append('csrf_token', csrf);
return fetch(url, { method: 'POST', body: data }).then(function (r) {
return r.json().catch(function () { return {}; }).then(function (body) {
if (!r.ok || body.ok === false) throw new Error(body.error || ('HTTP ' + r.status));
return body;
});
});
}
function advance(kind) {
counts[kind] = (counts[kind] || 0) + 1;
idx += 1;
render();
}
function decide(toColumn) {
var card = current();
if (!card || busy) return;
busy = true;
status('⏳ Envoi…');
post('/api/move', {
repo_full_name: repo, issue_number: card.number,
from_column: 'backlog', to_column: toColumn, from_order: '', to_order: '',
}).then(function () {
history.push({ number: card.number, to: toColumn });
busy = false;
advance(toColumn);
}).catch(function (err) {
busy = false;
status('❌ ' + err.message + ' — réessaie.', true);
});
}
function undo() {
var last = history.pop();
var card = current();
if (!last || busy) return;
// The undone card is the one just before the current index.
var target = queue[idx - 1];
if (!target || target.number !== last.number) return;
busy = true;
status('⏳ Annulation…');
post('/api/move', {
repo_full_name: repo, issue_number: last.number,
from_column: last.to, to_column: 'backlog', from_order: '', to_order: '',
}).then(function () {
counts[last.to] = Math.max(0, (counts[last.to] || 1) - 1);
if (!counts[last.to]) delete counts[last.to];
idx -= 1;
busy = false;
render();
}).catch(function (err) {
busy = false;
history.push(last);
status('❌ Annulation impossible : ' + err.message, true);
});
void card;
}
function assignSelf() {
var card = current();
if (!card || busy) return;
busy = true;
status('⏳ Assignation…');
post('/api/issues/' + owner + '/' + name + '/' + card.number + '/assign-self', {})
.then(function (body) {
card.assignees = body.assignees || card.assignees;
busy = false;
render();
status('👤 Assignée à : ' + (body.assignees || []).join(', '));
})
.catch(function (err) { busy = false; status('❌ ' + err.message, true); });
}
function openLabels() {
var card = current();
if (!card) return;
labelsPanel.hidden = false;
var list = el('triage-labels-list');
list.textContent = 'Chargement…';
fetch('/api/repos/' + owner + '/' + name + '/labels')
.then(function (r) { return r.json(); })
.then(function (data) {
list.innerHTML = '';
var mine = (card.labels || []).map(function (l) { return l.name; });
(data.labels || []).forEach(function (label) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'triage-label-chip' + (mine.includes(label.name) ? ' on' : '');
btn.textContent = (mine.includes(label.name) ? '✓ ' : '') + label.name;
btn.addEventListener('click', function () {
var action = mine.includes(label.name) ? 'remove' : 'add';
post('/api/issues/' + owner + '/' + name + '/' + card.number + '/labels',
{ label_id: label.id, action: action })
.then(function (body) {
card.labels = body.labels || [];
render();
openLabels();
})
.catch(function (err) { status('❌ ' + err.message, true); });
});
list.appendChild(btn);
});
if (!(data.labels || []).length) list.textContent = 'Aucune étiquette dans ce dépôt.';
})
.catch(function () { list.textContent = 'Échec du chargement des étiquettes.'; });
}
function closeLabels() { labelsPanel.hidden = true; }
document.querySelectorAll('.triage-keys button').forEach(function (btn) {
btn.addEventListener('click', function () {
var act = btn.getAttribute('data-act');
if (act === 'move') decide(btn.getAttribute('data-col'));
else if (act === 'close') decide('done');
else if (act === 'next') { if (current()) advance('skip'); }
else if (act === 'undo') undo();
else if (act === 'assign') assignSelf();
else if (act === 'labels') { labelsPanel.hidden ? openLabels() : closeLabels(); }
});
});
el('triage-labels-close').addEventListener('click', closeLabels);
document.addEventListener('keydown', function (e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
var tag = (document.activeElement || {}).tagName || '';
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (!labelsPanel.hidden) {
if (e.key === 'Escape' || e.key.toLowerCase() === 'l') { e.preventDefault(); closeLabels(); }
return;
}
var colByKey = { '1': 'todo', '2': 'in_progress', '3': 'qa', '4': 'review', '5': 'done' };
if (colByKey[e.key]) { e.preventDefault(); decide(colByKey[e.key]); }
else if (e.key === 'Enter') { e.preventDefault(); if (current()) advance('skip'); }
else if (e.key.toLowerCase() === 'x') { e.preventDefault(); decide('done'); }
else if (e.key.toLowerCase() === 'l') { e.preventDefault(); openLabels(); }
else if (e.key.toLowerCase() === 'a') { e.preventDefault(); assignSelf(); }
else if (e.key.toLowerCase() === 'z') { e.preventDefault(); undo(); }
});
render();
})();
+80 -10
View File
@@ -1,21 +1,90 @@
/**
* Kanbante — Wallboard: slide rotation, clock, live refresh.
* Kanbante — Wallboard: rotation, manual navigation, clock, live.
*
* The wall is a display, not a workspace: no interaction to protect,
* so a realtime event simply reloads the page (plus a slow safety
* reload in case the stream drops silently).
* One slide per active project, rotating every 30 s. The viewer can
* take over at any time: ←/→ keys (or the on-screen ❮ ❯ buttons and
* dots) switch projects immediately and pause the rotation for
* 90 s; Space toggles a hard pause. The current position survives
* the live/safety reloads via sessionStorage, so the wall no longer
* appears stuck on the first project after every refresh.
*/
(function () {
var slides = Array.from(document.querySelectorAll('.wall-slide'));
var ROTATE_MS = 30000;
var current = 0;
var MANUAL_PAUSE_MS = 90000;
var STORAGE_KEY = 'kanbante-wall-index';
var current = 0;
var paused = false;
var pausedUntil = 0;
var pausedBadge = document.getElementById('wall-paused');
var dotsBox = document.getElementById('wall-dots');
function updateBadge() {
if (pausedBadge) {
pausedBadge.hidden = !(paused || Date.now() < pausedUntil);
}
}
function updateDots() {
if (!dotsBox) return;
Array.from(dotsBox.children).forEach(function (dot, i) {
dot.classList.toggle('on', i === current);
});
}
function show(index, manual) {
if (!slides.length) return;
slides[current].classList.remove('active');
current = ((index % slides.length) + slides.length) % slides.length;
slides[current].classList.add('active');
try { sessionStorage.setItem(STORAGE_KEY, String(current)); } catch (e) { /* private mode */ }
updateDots();
if (manual) {
pausedUntil = Date.now() + MANUAL_PAUSE_MS;
updateBadge();
}
}
// Restore the position kept across reloads.
if (slides.length > 1) {
var saved = 0;
try { saved = parseInt(sessionStorage.getItem(STORAGE_KEY) || '0', 10) || 0; } catch (e) { saved = 0; }
if (saved) show(saved, false);
// Dots (one per project, clickable).
if (dotsBox) {
slides.forEach(function (_slide, i) {
var dot = document.createElement('button');
dot.type = 'button';
dot.className = 'wall-dot';
dot.title = 'Projet ' + (i + 1);
dot.setAttribute('aria-label', 'Afficher le projet ' + (i + 1));
dot.addEventListener('click', function () { show(i, true); });
dotsBox.appendChild(dot);
});
updateDots();
}
var prev = document.getElementById('wall-prev');
var next = document.getElementById('wall-next');
if (prev) prev.addEventListener('click', function () { show(current - 1, true); });
if (next) next.addEventListener('click', function () { show(current + 1, true); });
document.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') { e.preventDefault(); show(current + 1, true); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); show(current - 1, true); }
else if (e.key === ' ') {
e.preventDefault();
paused = !paused;
if (!paused) pausedUntil = 0;
updateBadge();
}
});
setInterval(function () {
slides[current].classList.remove('active');
current = (current + 1) % slides.length;
slides[current].classList.add('active');
if (!paused && Date.now() >= pausedUntil) show(current + 1, false);
}, ROTATE_MS);
setInterval(updateBadge, 5000);
}
function tickClock() {
@@ -30,10 +99,11 @@
tickClock();
setInterval(tickClock, 15000);
// Live: any board activity refreshes the wall.
// Live: board activity reloads the wall. The hub emits NAMED
// "gitea" events — onmessage would never fire (fixed in 0.11.0).
try {
var source = new EventSource('/api/stream');
source.onmessage = function () { location.reload(); };
source.addEventListener('gitea', function () { location.reload(); });
} catch (err) {
console.warn('Wallboard live stream unavailable:', err);
}
+86
View File
@@ -38,6 +38,7 @@ def _repo(name: str, updated: datetime, archived: bool = False) -> dict:
"owner": {"login": "Projets"},
"archived": archived,
"html_url": f"http://gitea.test/Projets/{name}",
"default_branch": "main",
}
@@ -67,11 +68,13 @@ class FakeGitea:
def __init__(self) -> None:
now = datetime.now(timezone.utc)
self.starred = set()
self.repos = [
_repo("demo", now - timedelta(days=1)),
_repo("vieux", now - timedelta(days=800)),
_repo("archive", now - timedelta(days=1), archived=True),
]
self.branches = ["main", "feature/1-tache-un", "issue-5-focus"]
self.issues = {
"Projets/demo": [
_issue(1, "Tâche un"),
@@ -85,6 +88,7 @@ class FakeGitea:
self._label_seq = 100
self.cache_invalidations = 0
self.state_changes: list[tuple] = []
self.assignee_changes: list[tuple] = []
self.created: list[tuple] = []
self.comments: dict[tuple, list[dict]] = {
("Projets/demo", 1): [
@@ -222,6 +226,80 @@ class FakeGitea:
for label in removed:
self.label_ops.append(("remove", number, label["name"]))
async def list_branches(self, owner, repo):
return [{"name": n} for n in self.branches]
async def create_branch(self, owner, repo, new_branch, old_branch):
if new_branch in self.branches:
raise RuntimeError("branch exists")
self.branches.append(new_branch)
return {"name": new_branch}
async def list_pulls(self, owner, repo, state="all"):
return [
{
"number": 16, "title": "Corrige la tâche un",
"body": "Fixes #1", "state": "open", "merged": False,
"html_url": "http://gitea.test/pr/16",
"head": {"ref": "feature/1-tache-un"},
},
{
"number": 17, "title": "Focus", "body": "",
"state": "closed", "merged": True,
"html_url": "http://gitea.test/pr/17",
"head": {"ref": "issue-5-focus"},
},
]
async def list_commits(self, owner, repo, limit=75):
return [
{
"sha": "abcdef1234567890",
"html_url": "http://gitea.test/c/abcdef1",
"commit": {
"message": "Début de la tâche #1\n\nDétails",
"author": {"date": "2026-10-10T10:00:00Z"},
},
},
{
"sha": "1234567abcdef",
"html_url": "http://gitea.test/c/1234567",
"commit": {
"message": "Commit sans rapport",
"author": {"date": "2026-10-09T10:00:00Z"},
},
},
]
# ── 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]
async def set_starred(self, owner, repo, starred):
if starred:
self.starred.add(f"{owner}/{repo}")
else:
self.starred.discard(f"{owner}/{repo}")
async def get_milestones(self, owner, repo, state="open"):
return [
{
@@ -239,6 +317,14 @@ class FakeGitea:
self.created.append((owner, repo, title))
return issue
async def set_issue_assignees(self, owner, repo, issue_number, logins):
self.assignee_changes.append((owner, repo, issue_number, list(logins)))
for issue in self.issues.get(f"{owner}/{repo}", []):
if issue["number"] == issue_number:
issue["assignees"] = [{"login": name} for name in logins]
return issue
return {}
async def set_issue_state(self, owner, repo, issue_number, state):
if self.fail_state_change:
raise httpx.HTTPError("simulated write-back failure")
+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
+202
View File
@@ -0,0 +1,202 @@
"""Palier 10 (0.13.0): code lié (#1), PR fusionnée (#2),
standup automatique (#6), règles d'automatisation (#8)."""
import json
from datetime import datetime, timedelta
from app import code_links as cl
from app import models
from app import rules as rules_mod
from tests.conftest import csrf_from
# ── #1 Code lié : fonctions pures ─────────────────────────────────
def test_extract_refs_and_branch_numbers():
assert cl.extract_issue_refs("Fixes #12 et rapport #3, pas #x") == {12, 3}
assert cl.extract_issue_refs("a#b #12") == {12}
assert cl.extract_issue_refs(None) == set()
assert cl.branch_issue_number("feature/12-login") == 12
assert cl.branch_issue_number("issue-7") == 7
assert cl.branch_issue_number("42") == 42
assert cl.branch_issue_number("release-2026") == 2026
assert cl.branch_issue_number("main") is None
assert cl.branch_issue_number("hotfix/no-number") is None
def test_code_endpoint(client, fake_gitea):
r = client.get("/api/issues/Projets/demo/1/code")
assert r.status_code == 200
data = r.json()
assert data["branches"] == ["feature/1-tache-un"]
assert [p["number"] for p in data["pulls"]] == [16]
assert [c["sha"] for c in data["commits"]] == ["abcdef1"]
assert data["summary"]["pr"]["label"] == "ouverte"
board = client.get("/board/Projets/demo")
assert "PR #16 ouverte" in board.text
assert "1 commit" in board.text
# ── #2 PR fusionnée → carte Terminé ───────────────────────────────
def _pr_payload(merged=True, title="Corrige tout", body="Fixes #1",
ref="feature/1-tache-un"):
return {
"action": "closed",
"repository": {"full_name": "Projets/demo"},
"pull_request": {
"number": 16, "title": title, "body": body,
"merged": merged, "head": {"ref": ref},
},
}
def _post_webhook(client, payload, event="pull_request"):
return client.post(
"/webhooks/gitea", json=payload, headers={"X-Gitea-Event": event}
)
def test_pr_merged_moves_card_and_closes_issue(client, fake_gitea):
token = csrf_from(client)
client.post("/api/move", data={
"repo_full_name": "Projets/demo", "issue_number": 1,
"from_column": "backlog", "to_column": "in_progress",
"to_order": "1", "csrf_token": token,
})
r = _post_webhook(client, _pr_payload())
assert r.status_code == 200
assert r.json()["handled"] == "pr-merged:#1:closed"
assert ("Projets", "demo", 1, "closed") in fake_gitea.state_changes
board = models.get_board_state("Projets/demo")
assert 1 in [c["issue_number"] for c in board["done"]]
events = models.get_events(repo_full_name="Projets/demo")
assert any(
e["event_type"] == "closed" and e["source"] == "webhook"
for e in events
)
def test_pr_merged_by_branch_only_and_idempotent(client, fake_gitea):
payload = _pr_payload(title="Sans référence", body="", ref="issue-5-focus")
r = _post_webhook(client, payload)
assert "#5:closed" in r.json()["handled"]
r2 = _post_webhook(client, payload) # echo: no double journal entry
closed_events = [
e for e in models.get_events(repo_full_name="Projets/demo")
if e["event_type"] == "closed" and e["issue_number"] == 5
]
assert len(closed_events) == 1
assert r2.json()["handled"] in ("pr-merged:#5:noop", "pr-merged:#5:closed")
def test_pr_not_merged_ignored(client, fake_gitea):
r = _post_webhook(client, _pr_payload(merged=False))
assert r.json()["handled"] == "ignored"
assert models.get_events(repo_full_name="Projets/demo") == []
# ── #6 Standup ────────────────────────────────────────────────────
def test_standup_from_journal(client, fake_gitea):
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S")
models.log_event("Projets/demo", 3, "closed", to_column="done")
with models.get_db() as conn:
conn.execute(
"UPDATE board_events SET created_at = ? WHERE issue_number = 3",
(yesterday,),
)
r = client.get("/api/standup")
assert r.status_code == 200
text = r.json()["text"]
assert "Standup" in text
assert "1 fermée(s)" in text
assert "Ancienne tâche" in text
page = client.get("/standup")
assert page.status_code == 200
assert "Copier le standup" in page.text
# ── #8 Règles ─────────────────────────────────────────────────────
def test_parse_rules_validation():
rules, errors = rules_mod.parse_rules(
'[{"name": "ok", "when": {"column": "review"}, "then": {"notify": true}}]'
)
assert errors == [] and rules[0]["name"] == "ok"
_r, errors = rules_mod.parse_rules("{pas json")
assert errors and "JSON invalide" in errors[0]
_r, errors = rules_mod.parse_rules(
'[{"when": {"column": "review"}, "then": {"close_issue": true}}]'
)
assert errors and "interdit" in errors[0]
_r, errors = rules_mod.parse_rules(
'[{"when": {"column": "nope"}, "then": {"notify": true}}]'
)
assert errors and "colonne inconnue" in errors[0]
def test_matching_cards_conditions():
columns = {
"review": [
{"number": 1, "labels": [{"name": "bloqué"}]},
{"number": 2, "labels": []},
],
"in_progress": [{"number": 3, "labels": []}],
}
ages = {1: {"days": 5}, 2: {"days": 1}, 3: {"days": 9}}
rule = {"name": "r", "when": {"column": "review", "min_age_days": 3},
"then": {"notify": True}}
assert [i["number"] for i, _ in rules_mod.matching_cards(rule, columns, ages)] == [1]
rule2 = {"name": "r2", "when": {"column": "review", "label": "bloqué"},
"then": {"notify": True}}
assert [i["number"] for i, _ in rules_mod.matching_cards(rule2, columns, ages)] == [1]
rule3 = {"name": "r3", "when": {"column": "review", "wip_over": 1},
"then": {"notify": True}}
assert len(rules_mod.matching_cards(rule3, columns, ages)) == 2
rule4 = {"name": "r4", "when": {"column": "in_progress", "wip_over": 3},
"then": {"notify": True}}
assert rules_mod.matching_cards(rule4, columns, ages) == []
def test_rules_run_end_to_end(client, fake_gitea, monkeypatch):
monkeypatch.setenv(
"KANBANTE_RULES",
json.dumps([{
"name": "Marque le focus",
"when": {"column": "in_progress"},
"then": {"add_label": "urgent"},
}]),
)
token = csrf_from(client)
client.post("/api/move", data={
"repo_full_name": "Projets/demo", "issue_number": 5,
"from_column": "backlog", "to_column": "in_progress",
"to_order": "5", "csrf_token": token,
})
r = client.post("/api/rules/run", data={"csrf_token": token})
assert r.status_code == 200
actions = r.json()["summary"]["actions"]
assert len(actions) == 1
assert actions[0]["number"] == 5
assert "urgent" in actions[0]["did"][0]
detail = client.get("/api/issues/Projets/demo/5")
assert "urgent" in [l["name"] for l in detail.json()["issue"]["labels"]]
events = models.get_events(repo_full_name="Projets/demo")
assert any(e["event_type"] == "rule" for e in events)
# Second run: label already present -> no new action.
r2 = client.post("/api/rules/run", data={"csrf_token": token})
assert r2.json()["summary"]["actions"] == []
page = client.get("/rules")
assert page.status_code == 200
assert "Marque le focus" in page.text
def test_rules_page_unconfigured(client, fake_gitea):
page = client.get("/rules")
assert page.status_code == 200
assert "KANBANTE_RULES" in page.text
+159
View File
@@ -0,0 +1,159 @@
"""Palier 11 (0.14.0): branche depuis la carte (#3), santé du flux (#9)."""
from datetime import datetime, timedelta
from app import code_links as cl
from app import flow
from app import models
from tests.conftest import csrf_from
# ── #3 Branche depuis la carte ────────────────────────────────────
def test_slugify_and_branch_name():
assert cl.slugify_title("Créer la branche depuis la carte") == \
"creer-la-branche-depuis-la-carte"
assert cl.slugify_title("Fix: l'été indien (urgent!)") == \
"fix-l-ete-indien-urgent"
assert cl.slugify_title("Un titre vraiment très long qui dépasse" * 3)
assert len(cl.slugify_title("x" * 100)) == 40
assert cl.slugify_title("!!!") == "issue"
assert cl.branch_name_for(12, "Capture éclair") == "issue-12-capture-eclair"
# La convention est reconnue par l'appariement (#1/#2).
assert cl.branch_issue_number(cl.branch_name_for(12, "Capture")) == 12
def test_create_branch_endpoint(client, fake_gitea):
token = csrf_from(client)
r = client.post("/api/issues/Projets/demo/5/branch",
data={"csrf_token": token})
assert r.status_code == 200
data = r.json()
assert data["branch"] == "issue-5-tache-focus"
assert data["from"] == "main"
assert data["moved"] is True
assert "issue-5-tache-focus" in fake_gitea.branches
board = models.get_board_state("Projets/demo")
assert 5 in [c["issue_number"] for c in board["in_progress"]]
events = models.get_events(repo_full_name="Projets/demo")
assert any(
e["event_type"] == "moved" and e["source"] == "branch"
for e in events
)
# Deuxième appel : la branche existe déjà, pas de doublon.
r2 = client.post("/api/issues/Projets/demo/5/branch",
data={"csrf_token": token})
assert r2.json()["exists"] is True
def test_create_branch_custom_name_and_validation(client, fake_gitea):
token = csrf_from(client)
r = client.post("/api/issues/Projets/demo/1/branch",
data={"csrf_token": token, "name": "feature/super-truc"})
assert r.status_code == 200
assert r.json()["branch"] == "feature/super-truc"
r = client.post("/api/issues/Projets/demo/1/branch",
data={"csrf_token": token, "name": "mauvais nom.."})
assert r.status_code == 400
def test_code_endpoint_suggests_branch(client, fake_gitea):
r = client.get("/api/issues/Projets/demo/3/code")
assert r.json()["suggested_branch"] == "issue-3-ancienne-tache"
# ── #9 Santé du flux : fonctions pures ────────────────────────────
def _ev(day_offset: float, kind: str, frm=None, to=None, number=1):
at = (datetime(2026, 9, 1) + timedelta(days=day_offset)).strftime(
"%Y-%m-%d %H:%M:%S"
)
return {
"repo_full_name": "Projets/demo", "issue_number": number,
"event_type": kind, "from_column": frm, "to_column": to,
"created_at": at,
}
def test_column_dwell_reconstruction():
events = [
_ev(0, "created", to="backlog"),
_ev(4, "moved", frm="backlog", to="in_progress"),
_ev(10, "closed", frm="in_progress", to="done"),
_ev(1, "created", to="backlog", number=2),
_ev(3, "moved", frm="backlog", to="review", number=2),
]
dwell = flow.column_dwell(events)
assert dwell["backlog"] == [4.0, 2.0]
assert dwell["in_progress"] == [6.0]
summary = flow.summarize_dwell(dwell)
by_col = {row["column"]: row for row in summary}
assert by_col["backlog"]["median_days"] == 3.0
assert by_col["backlog"]["count"] == 2
# backlog a 2 échantillons (médiane 3 j) : c'est le seul goulot
# nommable à ce stade (in_progress n'a qu'un séjour mesuré).
slowest = flow.slowest_column(summary)
assert slowest["column"] == "backlog"
events.append(_ev(20, "moved", frm="review", to="done", number=2))
events += [
_ev(0, "created", to="backlog", number=3),
_ev(2, "moved", frm="backlog", to="review", number=3),
_ev(19, "moved", frm="review", to="done", number=3),
]
dwell = flow.column_dwell(events)
summary = flow.summarize_dwell(dwell)
slowest = flow.slowest_column(summary)
assert slowest["column"] == "review"
assert slowest["median_days"] == 17.0
def test_last_activity():
events = [_ev(0, "created", to="backlog"), _ev(5, "moved", to="todo")]
latest = flow.last_activity(events)
assert latest[("Projets/demo", 1)] == datetime(2026, 9, 6)
# ── #9 Pages santé ────────────────────────────────────────────────
def _seed_stay(number: int, days_ago_start: int, days_ago_end: int,
frm="backlog", to="review"):
models.log_event("Projets/demo", number, "created", to_column="backlog")
models.log_event("Projets/demo", number, "moved",
from_column=frm, to_column=to)
now = datetime.now()
with models.get_db() as conn:
conn.execute(
"UPDATE board_events SET created_at = ? "
"WHERE issue_number = ? AND event_type = 'created'",
((now - timedelta(days=days_ago_start)).strftime("%Y-%m-%d %H:%M:%S"), number),
)
conn.execute(
"UPDATE board_events SET created_at = ? "
"WHERE issue_number = ? AND event_type = 'moved'",
((now - timedelta(days=days_ago_end)).strftime("%Y-%m-%d %H:%M:%S"), number),
)
def test_health_pages_and_bottleneck(client, fake_gitea):
_seed_stay(1, 40, 10) # 30 j en backlog
_seed_stay(5, 35, 5) # 30 j en backlog aussi
page = client.get("/health/Projets/demo")
assert page.status_code == 200
assert "backlog" in page.text.lower()
index = client.get("/health")
assert index.status_code == 200
assert "Projets/demo" in index.text
def test_health_zombies_threshold(client, fake_gitea, monkeypatch):
# Les issues du faux datent du 2026-09-02 : ~39 j d'inactivité,
# donc zombies au seuil par défaut de 30 j.
page = client.get("/health/Projets/demo")
assert "Tâche un" in page.text
assert 'data-zombie="close"' in page.text
monkeypatch.setenv("KANBANTE_ZOMBIE_DAYS", "10000")
page = client.get("/health/Projets/demo")
assert "Aucune carte zombie" in page.text
+92
View File
@@ -0,0 +1,92 @@
"""Palier 12 (0.15.0 → refonte 0.16.0): layout barre latérale (#16)
et wallboard sans défilement parasite."""
import app.main as main
def test_sidebar_on_app_pages(client, fake_gitea):
for path in ("/", "/focus", "/today", "/briefing", "/forecast",
"/rules", "/health", "/standup", "/capture"):
page = client.get(path)
assert page.status_code == 200, path
assert page.text.count('id="sidebar"') == 1, path
assert page.text.count('id="nav-burger"') == 1, path
assert "with-sidebar" in page.text, path
assert "/static/js/nav.js" in page.text, path
# Les quatre sections accordéon de la barre latérale.
for group in ("Principal", "Rituels", "Pilotage", "Outils"):
assert group in page.text, (path, group)
for gkey in ("principal", "rituels", "pilotage", "outils"):
assert f'data-group-btn="{gkey}"' in page.text, (path, gkey)
assert page.text.count('class="side-acc"') == 4, path
assert "kanbante-nav-closed" in page.text # bootstrap pré-rendu
assert 'href="/health"' in page.text
assert 'id="side-collapse"' in page.text
# Bouton palette : icône seule, à droite du titre — pas de
# libellé texte « Recherche & commandes » visible.
assert 'class="side-head"' in page.text
assert ">🔍</button>" in page.text
# Le libellé de l'ancien bouton palette a disparu (le lien de
# navigation « Recherche », lui, existe toujours — c'est le
# texte complet « Recherche & commandes » qui doit manquer).
assert 'side-label">Recherche &amp; commandes' not in page.text
assert "Recherche & commandes</span>" not in page.text
def test_sidebar_active_states(client, fake_gitea):
home = client.get("/")
assert 'href="/" class="side-link active"' in home.text
forecast = client.get("/forecast")
assert 'href="/forecast" class="side-link active"' in forecast.text
today = client.get("/today")
assert 'href="/today" class="side-link active"' in today.text
def test_board_topbar_crumb_and_gitea_link(client, fake_gitea):
board = client.get("/board/Projets/demo")
assert board.status_code == 200
assert 'href="/board/Projets/demo"' in board.text # fil d'Ariane topbar
assert "Gitea ↗" in board.text
assert 'id="sidebar"' in board.text
def test_wall_has_no_sidebar(client, fake_gitea):
wall = client.get("/wall")
assert wall.status_code == 200
assert 'id="sidebar"' not in wall.text # plein écran TV assumé
assert "wall-foot" in wall.text
def test_nav_js_contract(client, fake_gitea):
js = client.get("/static/js/nav.js")
assert js.status_code == 200
assert "sidebar-collapsed" in js.text
assert "localStorage" in js.text
assert "side-backdrop" in js.text
assert "Escape" in js.text
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
def test_wall_more_cards_line(client, fake_gitea, monkeypatch):
async def fake_collect(only_repo):
return [{
"full_name": "Projets/demo",
"counts": {c: 0 for c in
("backlog", "todo", "in_progress", "qa", "review", "done")},
"open_total": 12,
"cards": [
{"number": i, "title": f"Carte {i}", "column": "in_progress",
"column_label": "🚧 En cours", "age_days": 2, "aging": False}
for i in range(1, 10)
],
"cards_total": 12,
"closed_today": 1,
}]
monkeypatch.setattr(main, "_collect_wall_data", fake_collect)
page = client.get("/wall")
assert page.status_code == 200
assert "+3 autres cartes en vol" in page.text
assert "📋 Ouvertes" in page.text
+87
View File
@@ -0,0 +1,87 @@
"""Palier 13 (0.17.0): vague 2 — jalon sur la carte (#17), pastille
de santé (#18), épinglés via étoiles Gitea (#20), page Projet (#25)."""
from tests.conftest import csrf_from
def test_dashboard_shows_next_milestone(client, fake_gitea):
page = client.get("/")
assert page.status_code == 200
# Faux : Sprint 1, échéance 2026-10-20, 1 fermée / 2 au total.
assert "🎯 Sprint 1" in page.text
assert "1/2 terminées" in page.text
assert "échéance" in page.text
def test_dashboard_health_dot(client, fake_gitea):
page = client.get("/")
assert 'class="health-dot ' in page.text
assert 'href="/health/Projets/demo"' in page.text
def test_no_starred_section_by_default(client, fake_gitea):
page = client.get("/")
assert 'id="pinned-section" hidden' in page.text
assert 'data-starred="0"' in page.text
def test_starred_repo_is_pinned(client, fake_gitea):
fake_gitea.starred.add("Projets/demo")
page = client.get("/")
assert 'id="pinned-section" hidden' not in page.text
assert 'data-starred="1"' in page.text
# La carte épinglée sort de la grille de son onglet d'origine :
# une seule carte (div) pour demo — le bouton étoile porte aussi
# un data-full, on compte donc le motif de la carte elle-même.
assert page.text.count('data-full="Projets/demo" data-home') == 1
def test_star_toggle_endpoint(client, fake_gitea):
csrf = csrf_from(client)
denied = client.post(
"/api/projects/Projets/demo/star", data={"starred": "1"}
)
assert denied.status_code == 403
resp = client.post(
"/api/projects/Projets/demo/star",
data={"starred": "1", "csrf_token": csrf},
)
assert resp.status_code == 200
assert resp.json() == {"ok": True, "starred": True}
assert "Projets/demo" in fake_gitea.starred
resp = client.post(
"/api/projects/Projets/demo/star",
data={"starred": "0", "csrf_token": csrf},
)
assert resp.json() == {"ok": True, "starred": False}
assert "Projets/demo" not in fake_gitea.starred
def test_card_title_links_to_project_page(client, fake_gitea):
page = client.get("/")
assert 'href="/project/Projets/demo"' in page.text
assert 'href="/board/Projets/demo"' in page.text # accès board conservé
def test_project_page(client, fake_gitea):
from app import models
models.log_event("Projets/demo", 1, "created", to_column="backlog")
models.log_event(
"Projets/demo", 1, "moved",
from_column="backlog", to_column="in_progress",
)
page = client.get("/project/Projets/demo")
assert page.status_code == 200
assert "🎯 Jalons ouverts" in page.text
assert "Sprint 1" in page.text
assert "🩺 Santé du flux" in page.text
assert "🔮 Prévisions" in page.text
assert "🕘 Activité récente" in page.text
assert "📝 Notes du projet" in page.text
assert 'href="/board/Projets/demo"' in page.text
assert "/wall?repo=Projets/demo" in page.text
# Titre du projet et lien Gitea du faux.
assert "demo" in page.text
+74
View File
@@ -0,0 +1,74 @@
"""Palier 14 (0.18.0): vague 2 suite — activité 7 jours (#19),
tri/regroupement (#21), vue liste dense (#22), actions rapides (#23)."""
from datetime import datetime, timedelta, timezone
from app import models
def _ts(days_ago: int) -> str:
dt = datetime.now(timezone.utc) - timedelta(days=days_ago)
return dt.strftime("%Y-%m-%d 12:00:00")
def test_activity_line_recent_week(client, fake_gitea):
for _ in range(2):
models.log_event("Projets/demo", 1, "closed",
to_column="done", created_at=_ts(1))
models.log_event("Projets/demo", 5, "moved",
from_column="backlog", to_column="todo",
created_at=_ts(0))
page = client.get("/")
assert "2 fermée(s) · 1 déplacement(s) cette semaine" in page.text
def test_activity_line_idle_project(client, fake_gitea):
models.log_event("Projets/vieux", 9, "moved",
from_column="backlog", to_column="todo",
created_at=_ts(35))
page = client.get("/")
assert "aucune activité kanban depuis 35 j" in page.text
def test_dashboard_display_tools_markup(client, fake_gitea):
page = client.get("/")
assert "dashState" in page.text
assert 'id="dash-sort"' in page.text
assert 'id="dash-group"' in page.text
assert "Grouper par propriétaire" in page.text
assert "setView('grid')" in page.text
assert "setView('list')" in page.text
def test_dense_list_view_tables(client, fake_gitea):
page = client.get("/")
assert page.text.count('class="proj-table"') == 3
assert 'data-sort="open"' in page.text
assert 'data-sort="inflight"' in page.text
# La ligne dense de demo : lien page projet + compteurs.
assert 'href="/project/Projets/demo"' in page.text
def test_dashboard_js_contract(client, fake_gitea):
js = client.get("/static/js/dashboard.js")
assert js.status_code == 200
assert "kanbante-dash-sort" in js.text
assert "kanbante-dash-group" in js.text
assert "owner-group" in js.text
assert "data-menu-btn" in js.text
def test_card_quick_actions_menu(client, fake_gitea):
page = client.get("/")
assert "data-menu-btn" in page.text
# urlencode de Jinja conserve les « / » : le lien est valide tel quel.
assert 'href="/capture?repo=Projets/demo"' in page.text
assert 'href="/triage/Projets/demo"' in page.text
assert 'href="/sprint/Projets/demo"' in page.text
assert 'href="/health/Projets/demo"' in page.text
def test_capture_prefilled_repo(client, fake_gitea):
page = client.get("/capture?repo=Projets/kanbante")
assert page.status_code == 200
assert 'value="Projets/kanbante"' in page.text
+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
+81
View File
@@ -0,0 +1,81 @@
"""Palier 7 (0.10.0): mode Triage (issue #11)."""
import json
import re
from tests.conftest import csrf_from
def _queue_from(page_html: str) -> list:
m = re.search(
r'<script id="triage-queue" type="application/json">(.*?)</script>',
page_html, re.S,
)
assert m, "file de triage absente de la page"
return json.loads(m.group(1))
def test_triage_picker_lists_project_with_backlog(client, fake_gitea):
r = client.get("/triage")
assert r.status_code == 200
assert "Projets/demo" in r.text
assert "au backlog" in r.text
def test_triage_session_queue_is_backlog_only(client, fake_gitea):
r = client.get("/triage/Projets/demo")
assert r.status_code == 200
queue = _queue_from(r.text)
numbers = [c["number"] for c in queue]
# Open issues of the fake repo: #1 and #5, both unplaced -> backlog.
# #3 is closed (goes to done), #2 is a PR (excluded).
assert numbers == [1, 5]
card = queue[0]
assert card["title"] == "Tâche un"
assert "excerpt" in card and "html_url" in card
def test_triage_session_empty_when_nothing_to_triage(client, fake_gitea):
for issue in fake_gitea.issues["Projets/demo"]:
if issue["number"] in (1, 5):
issue["state"] = "closed"
r = client.get("/triage/Projets/demo")
assert r.status_code == 200
assert _queue_from(r.text) == []
def test_assign_self_adds_token_user(client, fake_gitea):
token = csrf_from(client)
# Issue #5 is unassigned in the fake; #1 already belongs to bruno.
r = client.post(
"/api/issues/Projets/demo/5/assign-self",
data={"csrf_token": token},
)
assert r.status_code == 200
assert r.json()["ok"] is True
assert r.json()["assignees"] == ["bruno"]
assert fake_gitea.assignee_changes == [("Projets", "demo", 5, ["bruno"])]
# Idempotent: assigning #1 (already bruno's) writes nothing.
r = client.post(
"/api/issues/Projets/demo/1/assign-self",
data={"csrf_token": token},
)
assert r.status_code == 200
assert r.json()["assignees"] == ["bruno"]
assert len(fake_gitea.assignee_changes) == 1
def test_assign_self_requires_csrf(client, fake_gitea):
r = client.post("/api/issues/Projets/demo/1/assign-self", data={})
assert r.status_code == 403
assert fake_gitea.assignee_changes == []
def test_excerpt_strips_markdown():
from app.main import _excerpt
text = _excerpt("# Titre\n\nDu **gras** et un [lien](http://x) ici.", 280)
assert "Titre" in text and "gras" in text and "lien" in text
assert "#" not in text and "**" not in text
assert _excerpt(None) == ""
assert len(_excerpt("mot " * 200, 50)) <= 51
+41
View File
@@ -0,0 +1,41 @@
"""Palier 8 (0.11.0): navigation manuelle du Wallboard."""
from datetime import datetime, timezone
def _make_two_active_projects(fake_gitea):
"""The fake's second repo is 800 days old (inactive): revive it."""
now = datetime.now(timezone.utc)
for repo in fake_gitea.repos:
if repo["full_name"] == "Projets/vieux":
repo["updated_at"] = now.strftime("%Y-%m-%dT%H:%M:%SZ")
def test_wall_nav_markup_with_multiple_slides(client, fake_gitea):
_make_two_active_projects(fake_gitea)
r = client.get("/wall")
assert r.status_code == 200
assert r.text.count("wall-slide") >= 2
assert 'id="wall-prev"' in r.text
assert 'id="wall-next"' in r.text
assert 'id="wall-dots"' in r.text
assert 'id="wall-paused"' in r.text
assert "1 / 2" in r.text and "2 / 2" in r.text
def test_wall_no_nav_with_single_slide(client, fake_gitea):
r = client.get("/wall")
assert r.status_code == 200
assert 'id="wall-prev"' not in r.text
def test_wall_js_contract():
js = open("static/js/wall.js", encoding="utf-8").read()
# Arrow navigation + hard pause + position persistence.
assert "ArrowRight" in js and "ArrowLeft" in js
assert "sessionStorage" in js
assert "MANUAL_PAUSE_MS" in js
# The live listener must use the NAMED gitea event (onmessage
# never fires for named SSE events — the 0.6.0 bug).
assert "addEventListener('gitea'" in js
assert "source.onmessage" not in js
+176
View File
@@ -0,0 +1,176 @@
"""Palier 9 (0.12.0): Focus du jour (#5), Prévisions (#7), Récit (#4)."""
from datetime import date, timedelta
from app import forecast as fc
from app import models
from tests.conftest import csrf_from
def _ev(day: date, kind: str = "closed") -> dict:
return {"event_type": kind, "created_at": day.strftime("%Y-%m-%d 10:00:00")}
# ── Prévisions : fonctions pures ──────────────────────────────────
def test_weekly_closures_buckets():
today = date(2026, 10, 14) # a Wednesday
monday = today - timedelta(days=2)
events = [
_ev(monday), _ev(monday + timedelta(days=1)), # this week: 2
_ev(monday - timedelta(days=7)), # last week: 1
_ev(monday - timedelta(days=30), "moved"), # not a closure
_ev(monday - timedelta(days=90)), # too old
]
weekly = fc.weekly_closures(events, weeks=8, today=today)
assert len(weekly) == 8
assert weekly[-1]["count"] == 2
assert weekly[-2]["count"] == 1
assert sum(b["count"] for b in weekly) == 3
def test_throughput_excludes_current_week_and_needs_history():
today = date(2026, 10, 14)
monday = today - timedelta(days=2)
events = []
# 4 complete weeks: 2, 4, 0, 6 closures; current week: 99 (ignored)
for weeks_ago, n in ((1, 2), (2, 4), (3, 0), (4, 6)):
for k in range(n):
events.append(_ev(monday - timedelta(weeks=weeks_ago) + timedelta(days=k % 5)))
for k in range(9):
events.append(_ev(monday + timedelta(days=k % 2)))
weekly = fc.weekly_closures(events, weeks=8, today=today)
stats = fc.throughput_stats(weekly)
assert stats == {"avg": 3.0, "min": 0, "max": 6, "weeks": 4}
assert fc.throughput_stats(fc.weekly_closures([], today=today)) is None
def test_estimate_dates_and_labels():
today = date(2026, 10, 14)
monday = today - timedelta(days=2)
events = [_ev(monday - timedelta(weeks=w)) for w in (1, 2, 3, 4) for _ in range(2)]
weekly = fc.weekly_closures(events, weeks=8, today=today)
est = fc.estimate(6, weekly, today=today)
assert est["done"] is False
assert est["avg_per_week"] == 2.0
assert est["central"] == today + timedelta(days=21)
# min == max == 2/week -> no spread: latest==earliest==central
assert est["earliest"] == est["central"] == est["latest"]
label = fc.eta_label(est)
assert "à ce rythme ≈" in label
assert fc.estimate(0, weekly, today=today) == {"done": True}
assert fc.estimate(5, [], today=today) is None
assert fc.french_date(date(2026, 11, 3)) == "3 novembre"
def test_metrics_include_weekly_closures(client, fake_gitea):
models.log_event("Projets/demo", 3, "closed", to_column="done")
r = client.get("/api/metrics", params={"repo": "Projets/demo"})
assert r.status_code == 200
weekly = r.json()["metrics"]["weekly_closures"]
assert len(weekly) == 8
assert weekly[-1] == 1 # the closure we just logged is this week
def test_forecast_page_and_milestone_banner(client, fake_gitea):
r = client.get("/forecast")
assert r.status_code == 200
assert "Prévisions" in r.text
assert "Projets/demo" in r.text
assert "Sprint 1" in r.text
board = client.get("/board/Projets/demo", params={"milestone": "Sprint 1"})
assert board.status_code == 200
assert "🔮" in board.text
assert "historique pour prévoir" in board.text # empty journal
assert "/sprint/Projets/demo" in board.text
# ── Focus du jour ─────────────────────────────────────────────────
def _pin(client, token, number, repo="Projets/demo"):
return client.post("/api/today/pin", data={
"repo_full_name": repo, "issue_number": number, "csrf_token": token,
})
def test_today_pin_unpin_flow(client, fake_gitea):
token = csrf_from(client)
r = _pin(client, token, 1)
assert r.status_code == 200 and r.json()["count"] == 1
r = _pin(client, token, 1) # idempotent
assert r.json().get("already") is True
page = client.get("/today")
assert page.status_code == 200
assert "Tâche un" in page.text
r = client.post("/api/today/unpin", data={
"repo_full_name": "Projets/demo", "issue_number": 1, "csrf_token": token,
})
assert r.status_code == 200
assert models.get_picks(models_now()) == []
def models_now() -> str:
from datetime import datetime
return datetime.now().strftime("%Y-%m-%d")
def test_today_pin_cap_three(client, fake_gitea):
token = csrf_from(client)
today = models_now()
for n in (11, 12, 13):
models.add_pick(today, "Projets/demo", n)
r = _pin(client, token, 1)
assert r.status_code == 400
assert "3 cartes max" in r.json()["error"]
def test_briefing_includes_today_picks(client, fake_gitea):
models.add_pick(models_now(), "Projets/demo", 5)
r = client.get("/api/briefing")
assert r.status_code == 200
picks = r.json()["briefing"]["today_picks"]
assert [p["number"] for p in picks] == [5]
assert picks[0]["title"] == "Tâche focus"
def test_today_page_shows_candidates(client, fake_gitea):
token = csrf_from(client)
r = client.post("/api/move", data={
"repo_full_name": "Projets/demo", "issue_number": 1,
"from_column": "backlog", "to_column": "in_progress",
"to_order": "1", "csrf_token": token,
})
assert r.status_code == 200
page = client.get("/today")
assert page.status_code == 200
assert "Candidates" in page.text
assert "Tâche un" in page.text
# ── Récit de sprint ───────────────────────────────────────────────
def test_sprint_markdown_from_fake_milestone(client, fake_gitea):
models.log_event("Projets/demo", 3, "moved", to_column="in_progress")
models.log_event("Projets/demo", 3, "closed", to_column="done")
r = client.get("/api/sprint/Projets/demo", params={"milestone": "Sprint 1"})
assert r.status_code == 200
md = r.json()["markdown"]
assert "# 📖 Récit de sprint — Projets/demo · Sprint 1" in md
assert "## ✅ Livré (1)" in md
assert "Ancienne tâche" in md
assert "## ↩️ Reporté (1)" in md
assert "Tâche un" in md
page = client.get("/sprint/Projets/demo", params={"milestone": "Sprint 1"})
assert page.status_code == 200
assert "Copier le markdown" in page.text
def test_sprint_unknown_milestone_404(client, fake_gitea):
r = client.get("/api/sprint/Projets/demo", params={"milestone": "Nope"})
assert r.status_code == 404
page = client.get("/sprint/Projets/demo")
assert page.status_code == 200
assert "Sprint 1" in page.text # picker lists the milestones