Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
900590efed | ||
|
|
0aa991047f | ||
|
|
79122c20d0 | ||
|
|
3e3187d597 | ||
|
|
593f076555 | ||
|
|
311a14090d | ||
|
|
c67a14da65 | ||
|
|
7ec34245c5 | ||
|
|
018bde0fa5 | ||
|
|
0883191f57 | ||
|
|
ba47b1a9ac | ||
|
|
cf58bb9d0c | ||
|
|
b33b67fcb4 | ||
|
|
b4dc8f0d4f | ||
|
|
e810f5668a | ||
|
|
2290560627 | ||
|
|
d4b3b324f5 | ||
|
|
d395642f16 | ||
|
|
ec3590d25f | ||
|
|
16f03184bf | ||
|
|
6fdf39959c | ||
|
|
18e1dd0f3d | ||
|
|
58204e621e | ||
|
|
2fde103e93 | ||
|
|
24a1ebb9a6 | ||
|
|
4c807a4344 | ||
|
|
6453b53df4 | ||
|
|
1932d11457 |
+172
@@ -1,5 +1,177 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
- 🔀 **PR fusionnée → Terminé** — le webhook `pull_request` retrouve les issues liées (références `#n`, branche `issue-n-*`), les ferme et déplace leurs cartes, journalisé
|
||||
- 🗣️ **Standup automatique** — `/standup` : fermé/déplacé hier, focus du jour, blocages (QA/Révision qui vieillissent), copiable d'un clic ; ajout optionnel au briefing (`KANBANTE_BRIEFING_INCLUDE_STANDUP=1`)
|
||||
- ⚙️ **Règles d'automatisation** — moteur déclaratif `KANBANTE_RULES` (JSON) évalué toutes les 15 min : conditions colonne/âge/étiquette/WIP → étiquette, notification ntfy, tête de colonne ; jamais de fermeture par règle ; page `/rules`
|
||||
- 🌿 **Branche depuis la carte** — le panneau détail crée `issue-<n>-<slug>` dans Gitea depuis la branche par défaut, déplace la carte en « En cours » et journalise ; la PR qui en naîtra sera reconnue automatiquement
|
||||
- 🩺 **Santé du flux** — `/health` : temps médian par colonne (du journal), colonne la plus lente, et cartes zombies (aucune activité depuis `KANBANTE_ZOMBIE_DAYS`) avec actions rapides (commenter, remettre au backlog, fermer)
|
||||
- 📥 **Mode Triage** — `/triage` : le backlog se vide carte par carte, plein cadre, au clavier (`1-5` colonnes, `L` étiquettes, `A` s'assigner, `X` fermer, `Entrée` suivante, `Z` annuler), progression et résumé de session ; décisions via les mêmes API que le board
|
||||
- 🔄 **Board dynamique** — aucun refresh manuel : toute modification (autre session, Gitea, capture) est diffusée en SSE et appliquée **en place** avec animation des cartes (`/api/board/{owner}/{repo}` en JSON + fragments rendus serveur, diff DOM/FLIP) ; repli par sondage de 45 s sans webhooks ; drag local, filtres et panneau détail préservés
|
||||
- ⌨️ **Palette de commandes** — `Ctrl+K` (ou le bouton ⌨️) sur toutes les pages : actions, projets et recherche d'issues globale, 100 % clavier
|
||||
@@ -129,6 +131,7 @@ sudo systemctl enable --now docker
|
||||
| `KANBANTE_CAPTURE_NTFY_TOPIC` | *(vide)* | Topic ntfy écouté : chaque message devient une issue dans `KANBANTE_CAPTURE_REPO` |
|
||||
| `KANBANTE_BRIEFING_INCLUDE_STANDUP` | `0` | `1` = le standup automatique est ajouté au briefing ntfy du matin |
|
||||
| `KANBANTE_RULES` | *(vide)* | Règles d'automatisation en JSON (voir `/rules`) : conditions colonne/âge/étiquette/WIP → add_label / notify / move_top |
|
||||
| `KANBANTE_ZOMBIE_DAYS` | `30` | Jours sans activité après lesquels une carte ouverte est « zombie » sur `/health` |
|
||||
| `KANBANTE_DIGEST_TIME` / `KANBANTE_DIGEST_WEEKDAY` | *(vide)* / `0` | Composition hebdo du digest (`HH:MM`, 0 = lundi) ; notification ntfy, jamais d'envoi automatique |
|
||||
| `KANBANTE_OBSIGATE_MCP_URL` / `_TOKEN` / `_TOOL` / `_ARGS` | *(vide)* | Serveur MCP d'ObsiGate : URL, jeton optionnel, outil de création de note, gabarit JSON d'arguments (`{title}`, `{content}`) |
|
||||
| `KANBANTE_DIGEST_WEBHOOK_URL` | *(vide)* | Webhook générique recevant le digest en JSON `{title, markdown, week}` |
|
||||
@@ -183,6 +186,8 @@ kanbante/
|
||||
| GET | `/api/issues/{o}/{r}/{n}/code` | Branches, PRs et commits liés à la carte |
|
||||
| GET | `/standup` · `/api/standup` | Standup automatique (hier / aujourd'hui / blocages) |
|
||||
| GET | `/rules` · POST `/api/rules/run` | Règles d'automatisation : état, dernière évaluation, lancement manuel |
|
||||
| POST | `/api/issues/{o}/{r}/{n}/branch` | Créer la branche de la carte (`issue-<n>-<slug>` ou nom libre validé) et la passer en « En cours » |
|
||||
| GET | `/health` · `/health/{o}/{r}` | Santé du flux : goulots (temps médian par colonne) et cartes zombies, global ou par projet |
|
||||
| POST | `/api/today/pin` · `/api/today/unpin` | Épingler / retirer une carte du Focus du jour (max 3) |
|
||||
| GET | `/forecast` | Prévisions : débit hebdo, ETA backlog et milestones par projet |
|
||||
| GET | `/sprint/{owner}/{repo}` · `/api/sprint/{owner}/{repo}` | Récit de sprint d'un milestone (`?milestone=`), page rendue ou JSON markdown |
|
||||
|
||||
@@ -7,6 +7,7 @@ a token in a branch name (`feature/12-login`, `issue-12`, `12-fix`).
|
||||
"""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
_REF_RE = re.compile(r"(?<![\w#])#(\d+)\b")
|
||||
_BRANCH_TOKEN_RE = re.compile(r"(?:^|[-_/])(\d+)(?:[-_/]|$)")
|
||||
@@ -27,6 +28,26 @@ def branch_issue_number(branch_name: str | None) -> int | None:
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def slugify_title(title: str, max_len: int = 40) -> str:
|
||||
"""ASCII slug for a branch name: 'Créer la branche !' -> 'creer-la-branche'."""
|
||||
normalized = unicodedata.normalize("NFKD", title or "")
|
||||
ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", ascii_text.lower()).strip("-")
|
||||
slug = re.sub(r"-{2,}", "-", slug)
|
||||
if len(slug) > max_len:
|
||||
slug = slug[:max_len].rstrip("-")
|
||||
return slug or "issue"
|
||||
|
||||
|
||||
def branch_name_for(number: int, title: str) -> str:
|
||||
"""The branch a card would create (issue #3): issue-<n>-<slug>.
|
||||
|
||||
The name is recognized back by branch_issue_number, which is
|
||||
what links the future PR to this card (issue #2).
|
||||
"""
|
||||
return f"issue-{number}-{slugify_title(title)}"
|
||||
|
||||
|
||||
def find_code_links(
|
||||
branches: list[dict],
|
||||
pulls: list[dict],
|
||||
|
||||
+6
-1
@@ -78,6 +78,9 @@ Environment variables:
|
||||
column, min_age_days, label, wip_over.
|
||||
Actions: add_label, notify, move_top.
|
||||
A rule can never close an issue.
|
||||
KANBANTE_ZOMBIE_DAYS Days without any activity after
|
||||
which an open card is a "zombie"
|
||||
on /health (issue #9). Default 30.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -86,7 +89,7 @@ from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
APP_VERSION = "0.13.0"
|
||||
APP_VERSION = "0.19.0"
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
@@ -154,6 +157,7 @@ class Settings:
|
||||
obsigate_mcp_args: str = '{"title": "{title}", "content": "{content}"}'
|
||||
briefing_include_standup: bool = False
|
||||
rules_raw: str = ""
|
||||
zombie_days: int = 30
|
||||
|
||||
@property
|
||||
def auth_enabled(self) -> bool:
|
||||
@@ -198,4 +202,5 @@ def get_settings() -> Settings:
|
||||
),
|
||||
briefing_include_standup=_env_bool("KANBANTE_BRIEFING_INCLUDE_STANDUP", False),
|
||||
rules_raw=os.getenv("KANBANTE_RULES", ""),
|
||||
zombie_days=_env_int("KANBANTE_ZOMBIE_DAYS", 30),
|
||||
)
|
||||
|
||||
+1
-1
@@ -143,7 +143,7 @@ async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None:
|
||||
{
|
||||
"protocolVersion": MCP_PROTOCOL_VERSION,
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "kanbante", "version": "0.13.0"},
|
||||
"clientInfo": {"name": "kanbante", "version": "0.19.0"},
|
||||
},
|
||||
req_id=1,
|
||||
)
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
"""Flow health (roadmap #9): bottlenecks and zombie cards.
|
||||
|
||||
Boards show state; this module reads the event journal for health:
|
||||
how long cards actually stay in each column (dwell samples are the
|
||||
intervals between an entry and the next move/close), and which
|
||||
open cards have had no activity for a long time (zombies).
|
||||
"""
|
||||
|
||||
import statistics
|
||||
from datetime import datetime
|
||||
|
||||
from app.models import KANBAN_COLUMNS
|
||||
|
||||
|
||||
def parse_ts(raw) -> datetime | None:
|
||||
"""Parse journal ('YYYY-MM-DD HH:MM:SS') and Gitea ISO stamps."""
|
||||
text = str(raw or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
|
||||
try:
|
||||
return datetime.strptime(text[:19], fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
return datetime.strptime(text[:10], "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def column_dwell(events: list[dict]) -> dict[str, list[float]]:
|
||||
"""Dwell samples (in days) per column, rebuilt from the journal.
|
||||
|
||||
A stay starts when a card enters a column (created / moved /
|
||||
reopened) and ends at its next move or close. The first stay of
|
||||
a card whose journal starts mid-flight is unknowable and is
|
||||
simply not sampled.
|
||||
"""
|
||||
by_issue: dict[tuple[str, int], list[dict]] = {}
|
||||
for event in events:
|
||||
by_issue.setdefault(
|
||||
(event["repo_full_name"], event["issue_number"]), []
|
||||
).append(event)
|
||||
dwell: dict[str, list[float]] = {}
|
||||
for issue_events in by_issue.values():
|
||||
ordered = sorted(
|
||||
issue_events, key=lambda e: str(e.get("created_at") or "")
|
||||
)
|
||||
current: str | None = None
|
||||
since: datetime | None = None
|
||||
for event in ordered:
|
||||
at = parse_ts(event.get("created_at"))
|
||||
if at is None:
|
||||
continue
|
||||
kind = event["event_type"]
|
||||
if kind == "created":
|
||||
if current is None:
|
||||
current = event.get("to_column") or "backlog"
|
||||
since = at
|
||||
elif kind == "moved":
|
||||
if current is not None and since is not None:
|
||||
dwell.setdefault(current, []).append(
|
||||
(at - since).total_seconds() / 86400
|
||||
)
|
||||
elif current is None and event.get("from_column"):
|
||||
# The stay in from_column began before the journal
|
||||
# window: only the new stay can be timed.
|
||||
pass
|
||||
current = event.get("to_column") or current
|
||||
since = at
|
||||
elif kind == "closed":
|
||||
if current is not None and since is not None:
|
||||
dwell.setdefault(current, []).append(
|
||||
(at - since).total_seconds() / 86400
|
||||
)
|
||||
current = "done"
|
||||
since = at
|
||||
elif kind == "reopened":
|
||||
current = event.get("to_column") or "backlog"
|
||||
since = at
|
||||
return dwell
|
||||
|
||||
|
||||
def summarize_dwell(dwell: dict[str, list[float]]) -> list[dict]:
|
||||
"""Median dwell per column, in board order."""
|
||||
summary = []
|
||||
for column in KANBAN_COLUMNS:
|
||||
samples = dwell.get(column) or []
|
||||
if not samples:
|
||||
continue
|
||||
summary.append(
|
||||
{
|
||||
"column": column,
|
||||
"count": len(samples),
|
||||
"median_days": round(statistics.median(samples), 1),
|
||||
"avg_days": round(sum(samples) / len(samples), 1),
|
||||
}
|
||||
)
|
||||
return summary
|
||||
|
||||
|
||||
def slowest_column(summary: list[dict], min_samples: int = 2) -> dict | None:
|
||||
"""The bottleneck: slowest column (done excluded) with data."""
|
||||
candidates = [
|
||||
row for row in summary
|
||||
if row["column"] != "done" and row["count"] >= min_samples
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
return max(candidates, key=lambda r: r["median_days"])
|
||||
|
||||
|
||||
def last_activity(events: list[dict]) -> dict[tuple[str, int], datetime]:
|
||||
"""Most recent journal timestamp per (repo, issue)."""
|
||||
latest: dict[tuple[str, int], datetime] = {}
|
||||
for event in events:
|
||||
at = parse_ts(event.get("created_at"))
|
||||
if at is None:
|
||||
continue
|
||||
key = (event["repo_full_name"], event["issue_number"])
|
||||
if key not in latest or at > latest[key]:
|
||||
latest[key] = at
|
||||
return latest
|
||||
+11
-3
@@ -7,7 +7,15 @@ date, and are withheld entirely when the journal has no closures
|
||||
to learn from — "not enough history" is an answer, too.
|
||||
"""
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
|
||||
def _journal_today() -> date:
|
||||
"""'Today' in the journal's frame: board_events stamps are UTC
|
||||
(SQLite datetime('now')), so week buckets anchor to UTC —
|
||||
otherwise closures logged in the local evening (already
|
||||
tomorrow in UTC) fall outside every bucket."""
|
||||
return datetime.now(timezone.utc).date()
|
||||
|
||||
MONTHS_FR = [
|
||||
"janvier", "février", "mars", "avril", "mai", "juin",
|
||||
@@ -35,7 +43,7 @@ def _event_date(event: dict) -> date | None:
|
||||
def weekly_closures(events: list[dict], weeks: int = 8,
|
||||
today: date | None = None) -> list[dict]:
|
||||
"""Closure counts per ISO week, oldest first, current week last."""
|
||||
today = today or date.today()
|
||||
today = today or _journal_today()
|
||||
current_monday = today - timedelta(days=today.weekday())
|
||||
buckets: list[dict] = []
|
||||
for i in range(weeks - 1, -1, -1):
|
||||
@@ -83,7 +91,7 @@ def estimate(remaining: int, weekly: list[dict],
|
||||
no throughput to project from, else dates: central (avg rate),
|
||||
earliest (best week), latest (worst nonzero week, if any).
|
||||
"""
|
||||
today = today or date.today()
|
||||
today = today or _journal_today()
|
||||
if remaining <= 0:
|
||||
return {"done": True}
|
||||
stats = throughput_stats(weekly)
|
||||
|
||||
+105
-37
@@ -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,8 @@ class GiteaClient:
|
||||
)
|
||||
self._user: dict | None = None
|
||||
self._cache: dict[str, tuple[float, Any]] = {}
|
||||
self._refreshing: set[str] = set()
|
||||
self._cache_gen = 0
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self.client.aclose()
|
||||
@@ -105,24 +109,62 @@ 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)
|
||||
|
||||
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 ────────────────────────────────────────────────
|
||||
|
||||
@@ -141,20 +183,21 @@ class GiteaClient:
|
||||
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
|
||||
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
|
||||
"""All repos visible to the token owner (paginated, SWR-cached)."""
|
||||
|
||||
async def _fetch() -> list[dict]:
|
||||
params: dict[str, Any] = {}
|
||||
try:
|
||||
user = await self.get_user()
|
||||
if user and user.get("id"):
|
||||
params["uid"] = user["id"]
|
||||
except Exception as exc: # no/invalid token → public search
|
||||
logger.debug(
|
||||
"get_user failed, falling back to public search: %s", exc
|
||||
)
|
||||
return await self._get_all_pages("/repos/search", **params)
|
||||
|
||||
return await self._cached_list("search_repos", _fetch)
|
||||
|
||||
async def get_repo(self, owner: str, repo: str) -> dict:
|
||||
return await self._get(f"/repos/{owner}/{repo}")
|
||||
@@ -178,14 +221,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 +349,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 = ""
|
||||
@@ -341,6 +396,19 @@ class GiteaClient:
|
||||
self._cache_clear()
|
||||
return r.json()
|
||||
|
||||
async def create_branch(
|
||||
self, owner: str, repo: str, new_branch: str, old_branch: str
|
||||
) -> dict:
|
||||
"""Create a branch from another one (issue #3)."""
|
||||
return await self._request(
|
||||
"POST",
|
||||
f"/repos/{owner}/{repo}/branches",
|
||||
json={
|
||||
"new_branch_name": new_branch,
|
||||
"old_branch_name": old_branch,
|
||||
},
|
||||
)
|
||||
|
||||
async def list_branches(self, owner: str, repo: str) -> list[dict]:
|
||||
"""All branches of a repo (code links, issue #1)."""
|
||||
return await self._get_all_pages(f"/repos/{owner}/{repo}/branches")
|
||||
|
||||
+649
-65
@@ -37,6 +37,7 @@ from app.config import APP_VERSION, get_settings
|
||||
from app.gitea_client import GiteaClient
|
||||
from app import code_links as code_links_service
|
||||
from app import digest as digest_service
|
||||
from app import flow as flow_service
|
||||
from app import forecast as forecast_service
|
||||
from app import rules as rules_service
|
||||
from app import sprint as sprint_service
|
||||
@@ -57,6 +58,7 @@ from app.models import (
|
||||
get_metrics,
|
||||
get_notes,
|
||||
get_picks,
|
||||
get_project_activity,
|
||||
get_stats,
|
||||
init_db,
|
||||
log_event,
|
||||
@@ -291,6 +293,27 @@ _tpl_env = Environment(
|
||||
)
|
||||
|
||||
|
||||
# Which main-nav entry a template represents (issue #16): the
|
||||
# shared _header.html partial highlights it from this one place
|
||||
# instead of 19 hand-maintained copies.
|
||||
_NAV_BY_TEMPLATE = {
|
||||
"dashboard.html": "home",
|
||||
"focus.html": "focus",
|
||||
"today.html": "today",
|
||||
"my.html": "my",
|
||||
"activity.html": "activity",
|
||||
"briefing.html": "briefing",
|
||||
"standup.html": "standup",
|
||||
"search.html": "search",
|
||||
"capture.html": "capture",
|
||||
"digest.html": "digest",
|
||||
"forecast.html": "forecast",
|
||||
"rules.html": "rules",
|
||||
"health.html": "health",
|
||||
"health_project.html": "health",
|
||||
}
|
||||
|
||||
|
||||
def render(
|
||||
request: Request, name: str, status_code: int = 200, **context
|
||||
) -> HTMLResponse:
|
||||
@@ -306,6 +329,7 @@ def render(
|
||||
token = secrets.token_urlsafe(32)
|
||||
context.setdefault("csrf_token", token)
|
||||
context.setdefault("version", APP_VERSION)
|
||||
context.setdefault("active_nav", _NAV_BY_TEMPLATE.get(name, ""))
|
||||
html = _tpl_env.get_template(name).render(**context)
|
||||
response = HTMLResponse(html, status_code=status_code)
|
||||
if generated:
|
||||
@@ -354,6 +378,27 @@ def _parse_datetime(value: str | None) -> datetime | None:
|
||||
return None
|
||||
|
||||
|
||||
async def _parallel_map(items, fn, limit: int = 16) -> list:
|
||||
"""Run an async fn over items with bounded concurrency (0.19.0).
|
||||
|
||||
Cross-project pages used to await each repo in sequence, so the
|
||||
page took the SUM of every Gitea round trip. Gathering with a
|
||||
per-call semaphore (created here, bound to the current loop)
|
||||
cuts that to roughly the slowest single repo, without letting
|
||||
a big instance stampede Gitea. Exceptions come back in place —
|
||||
callers decide what a failed repo means for them.
|
||||
"""
|
||||
semaphore = asyncio.Semaphore(limit)
|
||||
|
||||
async def _run(item):
|
||||
async with semaphore:
|
||||
return await fn(item)
|
||||
|
||||
return await asyncio.gather(
|
||||
*(_run(item) for item in items), return_exceptions=True
|
||||
)
|
||||
|
||||
|
||||
def classify_repos(
|
||||
repos: list[dict], now: datetime | None = None
|
||||
) -> tuple[list[dict], list[dict], list[dict]]:
|
||||
@@ -466,21 +511,153 @@ def _parse_order(raw: str) -> list[int]:
|
||||
|
||||
# ── Routes: Pages ─────────────────────────────────────────
|
||||
|
||||
async def _next_milestone(owner: str, repo: str) -> dict | None:
|
||||
"""Next open milestone for a project card (issue #17).
|
||||
|
||||
The dated milestone with the nearest deadline wins (undated
|
||||
ones last). Progress and counts come from Gitea's own milestone
|
||||
counters; the ETA reuses the repo's journal throughput, exactly
|
||||
like the board banner — one definition of "late", everywhere.
|
||||
"""
|
||||
try:
|
||||
milestones = await gitea.get_milestones(owner, repo, state="open")
|
||||
except Exception as exc:
|
||||
logger.debug("next milestone failed for %s/%s: %s", owner, repo, exc)
|
||||
return None
|
||||
open_ms = [m for m in milestones if m.get("state", "open") == "open"]
|
||||
if not open_ms:
|
||||
return None
|
||||
|
||||
def _key(m: dict):
|
||||
due = _parse_datetime(str(m.get("due_on") or ""))
|
||||
return (0, due) if due is not None else (1, None)
|
||||
|
||||
open_ms.sort(key=_key)
|
||||
m = open_ms[0]
|
||||
opened = m.get("open_issues") or 0
|
||||
closed = m.get("closed_issues") or 0
|
||||
total = opened + closed
|
||||
due_dt = _parse_datetime(str(m.get("due_on") or ""))
|
||||
due_date = due_dt.date() if due_dt else None
|
||||
today = datetime.now(timezone.utc).date()
|
||||
weekly = forecast_service.weekly_closures(
|
||||
get_events(repo_full_name=f"{owner}/{repo}", limit=2000)
|
||||
)
|
||||
est = forecast_service.estimate(opened, weekly)
|
||||
return {
|
||||
"title": m.get("title", ""),
|
||||
"total": total,
|
||||
"closed": closed,
|
||||
"pct": round(100 * closed / total) if total else 0,
|
||||
"due_label": (
|
||||
forecast_service.french_date(due_date, short=True) if due_date else None
|
||||
),
|
||||
"late": bool(due_date and due_date < today and opened > 0),
|
||||
"eta_label": forecast_service.eta_label(est) if est else None,
|
||||
"eta_late": bool(
|
||||
est and due_date and not est.get("done") and est["central"] > due_date
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _health_dot(health: dict) -> dict:
|
||||
"""Traffic-light summary of a project's health (issue #18).
|
||||
|
||||
Rules (single definition, shared with /health): red when
|
||||
zombies pile up (≥3, or ≥2 stuck in QA/Review); orange on any
|
||||
zombie or when the slowest column's median dwell reaches
|
||||
twice the aging threshold; green otherwise.
|
||||
"""
|
||||
zombies = health.get("zombies") or []
|
||||
stuck = [z for z in zombies if z.get("column") in ("qa", "review")]
|
||||
slowest = health.get("slowest")
|
||||
median = slowest.get("median_days") if slowest else None
|
||||
aging = get_settings().aging_days
|
||||
if len(zombies) >= 3 or len(stuck) >= 2:
|
||||
level = "red"
|
||||
elif zombies or (median is not None and median >= 2 * aging):
|
||||
level = "orange"
|
||||
else:
|
||||
level = "green"
|
||||
parts = []
|
||||
if zombies:
|
||||
parts.append(f"{len(zombies)} zombie(s)")
|
||||
if slowest:
|
||||
parts.append(
|
||||
f"goulot : {COLUMN_LABELS[slowest['column']]}"
|
||||
f" (médiane {median} j)"
|
||||
)
|
||||
return {
|
||||
"level": level,
|
||||
"tooltip": " · ".join(parts) if parts
|
||||
else "Aucun signal — voir la santé du projet",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request):
|
||||
"""Main dashboard — all projects at a glance."""
|
||||
repos = await gitea.search_repos()
|
||||
active, inactive, archived = classify_repos(repos)
|
||||
for repo_info in active:
|
||||
try:
|
||||
starred_names = {
|
||||
r.get("full_name") for r in await gitea.list_starred()
|
||||
}
|
||||
except Exception as exc: # stars are decoration, never fatal (#20)
|
||||
logger.debug("starred list failed: %s", exc)
|
||||
starred_names = set()
|
||||
async def _enrich_active(repo_info: dict) -> None:
|
||||
full_name = repo_info["full_name"]
|
||||
owner, _, repo = full_name.partition("/")
|
||||
repo_info["forecast_label"] = _repo_forecast_label(
|
||||
repo_info["full_name"], repo_info.get("open_issues_count") or 0
|
||||
full_name, repo_info.get("open_issues_count") or 0
|
||||
)
|
||||
repo_info["next_milestone"] = await _next_milestone(owner, repo)
|
||||
try:
|
||||
health = await _project_health(owner, repo)
|
||||
repo_info["health_dot"] = _health_dot(health)
|
||||
except Exception as exc:
|
||||
logger.debug("health dot failed for %s: %s", full_name, exc)
|
||||
repo_info["health_dot"] = None
|
||||
repo_info["starred"] = full_name in starred_names
|
||||
|
||||
await _parallel_map(active, _enrich_active)
|
||||
for repo_info in inactive:
|
||||
repo_info["starred"] = repo_info["full_name"] in starred_names
|
||||
activity_by_repo = get_project_activity()
|
||||
today_utc = datetime.now(timezone.utc).date()
|
||||
for repo_info in active + inactive + archived:
|
||||
act = activity_by_repo.get(repo_info["full_name"])
|
||||
if act is None:
|
||||
repo_info["activity"] = None
|
||||
continue
|
||||
idle_days = None
|
||||
if act["last_at"]:
|
||||
last_dt = _parse_datetime(act["last_at"])
|
||||
if last_dt is not None:
|
||||
idle_days = (today_utc - last_dt.date()).days
|
||||
repo_info["activity"] = {
|
||||
"closed": act["closed"],
|
||||
"moved": act["moved"],
|
||||
"idle_days": idle_days,
|
||||
"last_at": act["last_at"] or "",
|
||||
}
|
||||
for repo_info in active:
|
||||
repo_info["home_tab"] = "active"
|
||||
for repo_info in inactive:
|
||||
repo_info["home_tab"] = "inactive"
|
||||
for repo_info in archived:
|
||||
repo_info["home_tab"] = "archived"
|
||||
pinned = [r for r in active + inactive if r.get("starred")]
|
||||
return render(
|
||||
request,
|
||||
"dashboard.html",
|
||||
active_repos=active,
|
||||
inactive_repos=inactive,
|
||||
archived_repos=archived,
|
||||
active_listed=[r for r in active if not r.get("starred")],
|
||||
inactive_listed=[r for r in inactive if not r.get("starred")],
|
||||
pinned_repos=pinned,
|
||||
stats=get_stats(),
|
||||
COLUMN_LABELS=COLUMN_LABELS,
|
||||
COLUMN_COLORS=COLUMN_COLORS,
|
||||
@@ -489,6 +666,22 @@ async def dashboard(request: Request):
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/projects/{owner}/{repo}/star")
|
||||
async def api_project_star(
|
||||
request: Request,
|
||||
owner: str,
|
||||
repo: str,
|
||||
starred: str = Form(...),
|
||||
csrf_token: str = Form(""),
|
||||
):
|
||||
"""Pin / unpin a project via its Gitea star (issue #20)."""
|
||||
if not _csrf_valid(request, csrf_token):
|
||||
return _csrf_error()
|
||||
want = starred == "1"
|
||||
await gitea.set_starred(owner, repo, want)
|
||||
return JSONResponse({"ok": True, "starred": want})
|
||||
|
||||
|
||||
@app.get("/board/{owner}/{repo}", response_class=HTMLResponse)
|
||||
async def board(request: Request, owner: str, repo: str, milestone: str = ""):
|
||||
"""Kanban board for a specific repo (optionally one milestone)."""
|
||||
@@ -767,24 +960,94 @@ def _excerpt(body: str | None, limit: int = 280) -> str:
|
||||
return text[:limit] + ("…" if len(text) > limit else "")
|
||||
|
||||
|
||||
@app.get("/project/{owner}/{repo}", response_class=HTMLResponse)
|
||||
async def project_page(request: Request, owner: str, repo: str):
|
||||
"""Project home (issue #25): milestones, health summary,
|
||||
forecast, recent journal activity and notes for one repo —
|
||||
an assembly of existing bricks, one truth per block."""
|
||||
full_name = f"{owner}/{repo}"
|
||||
repo_info = await gitea.get_repo(owner, repo)
|
||||
try:
|
||||
raw_milestones = await gitea.get_milestones(owner, repo, state="open")
|
||||
except Exception as exc:
|
||||
logger.debug("project milestones failed for %s: %s", full_name, exc)
|
||||
raw_milestones = []
|
||||
weekly = forecast_service.weekly_closures(
|
||||
get_events(repo_full_name=full_name, limit=2000)
|
||||
)
|
||||
throughput = forecast_service.throughput_stats(weekly)
|
||||
today = datetime.now(timezone.utc).date()
|
||||
milestones = []
|
||||
for m in raw_milestones:
|
||||
if m.get("state", "open") != "open":
|
||||
continue
|
||||
opened = m.get("open_issues") or 0
|
||||
closed = m.get("closed_issues") or 0
|
||||
total = opened + closed
|
||||
due_dt = _parse_datetime(str(m.get("due_on") or ""))
|
||||
due_date = due_dt.date() if due_dt else None
|
||||
est = forecast_service.estimate(opened, weekly)
|
||||
milestones.append(
|
||||
{
|
||||
"title": m.get("title", ""),
|
||||
"total": total,
|
||||
"closed": closed,
|
||||
"open": opened,
|
||||
"pct": round(100 * closed / total) if total else 0,
|
||||
"due_label": (
|
||||
forecast_service.french_date(due_date, short=True)
|
||||
if due_date else None
|
||||
),
|
||||
"late": bool(due_date and due_date < today and opened > 0),
|
||||
"eta_label": forecast_service.eta_label(est) if est else None,
|
||||
}
|
||||
)
|
||||
try:
|
||||
health = await _project_health(owner, repo)
|
||||
health_dot = _health_dot(health)
|
||||
except Exception as exc:
|
||||
logger.debug("project health failed for %s: %s", full_name, exc)
|
||||
health, health_dot = None, None
|
||||
events = [
|
||||
e for e in get_events(limit=300)
|
||||
if e["repo_full_name"] == full_name
|
||||
][:10]
|
||||
return render(
|
||||
request,
|
||||
"project.html",
|
||||
repo_info=repo_info,
|
||||
full_name=full_name,
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
milestones=milestones,
|
||||
throughput=throughput,
|
||||
health=health,
|
||||
health_dot=health_dot,
|
||||
events=events,
|
||||
notes=get_notes(full_name),
|
||||
EVENT_ICONS=EVENT_ICONS,
|
||||
COLUMN_LABELS=COLUMN_LABELS,
|
||||
COLUMN_COLORS=COLUMN_COLORS,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/triage", response_class=HTMLResponse)
|
||||
async def triage_picker(request: Request):
|
||||
"""Triage mode (issue #11): pick a project by its backlog size."""
|
||||
repos = await gitea.search_repos()
|
||||
active, _inactive, _archived = classify_repos(repos)
|
||||
projects = []
|
||||
for repo_info in active:
|
||||
|
||||
async def _one_triage(repo_info: dict) -> dict | None:
|
||||
owner, _, name = repo_info["full_name"].partition("/")
|
||||
try:
|
||||
all_items = await gitea.get_repo_issues(owner, name, state="all")
|
||||
except Exception:
|
||||
continue
|
||||
all_items = await gitea.get_repo_issues(owner, name, state="all")
|
||||
columns, _display, _state = _board_columns_for(owner, name, all_items)
|
||||
backlog = len(columns["backlog"])
|
||||
if backlog:
|
||||
projects.append(
|
||||
{"full_name": repo_info["full_name"], "backlog": backlog}
|
||||
)
|
||||
return {"full_name": repo_info["full_name"], "backlog": backlog}
|
||||
return None
|
||||
|
||||
results = await _parallel_map(active, _one_triage)
|
||||
projects = [r for r in results if isinstance(r, dict)]
|
||||
projects.sort(key=lambda p: -p["backlog"])
|
||||
return render(request, "triage.html", projects=projects)
|
||||
|
||||
@@ -1252,6 +1515,116 @@ async def api_rules_run(request: Request, csrf_token: str = Form("")):
|
||||
return {"ok": True, "summary": summary}
|
||||
|
||||
|
||||
async def _project_health(owner: str, repo: str) -> dict:
|
||||
"""Bottlenecks + zombies for one project (issue #9)."""
|
||||
full_name = f"{owner}/{repo}"
|
||||
settings = get_settings()
|
||||
all_items = await gitea.get_repo_issues(owner, repo, state="all")
|
||||
columns, display_issues, _bs = _board_columns_for(
|
||||
owner, repo, all_items
|
||||
)
|
||||
events = get_events(repo_full_name=full_name, limit=2000)
|
||||
dwell = flow_service.summarize_dwell(flow_service.column_dwell(events))
|
||||
slowest = flow_service.slowest_column(dwell)
|
||||
activity = flow_service.last_activity(events)
|
||||
placement = {
|
||||
issue["number"]: col
|
||||
for col, cards in columns.items()
|
||||
for issue in cards
|
||||
}
|
||||
now = datetime.now()
|
||||
zombies = []
|
||||
for issue in display_issues:
|
||||
number = issue["number"]
|
||||
candidates = [
|
||||
ts for ts in (
|
||||
activity.get((full_name, number)),
|
||||
flow_service.parse_ts(issue.get("updated_at")),
|
||||
flow_service.parse_ts(issue.get("created_at")),
|
||||
)
|
||||
if ts is not None
|
||||
]
|
||||
if not candidates:
|
||||
continue
|
||||
days = (now - max(candidates)).days
|
||||
if days >= settings.zombie_days:
|
||||
column = placement.get(number, "backlog")
|
||||
zombies.append(
|
||||
{
|
||||
"number": number,
|
||||
"title": issue.get("title", ""),
|
||||
"column": column,
|
||||
"column_label": COLUMN_LABELS[column],
|
||||
"days_inactive": days,
|
||||
"html_url": issue.get("html_url", ""),
|
||||
}
|
||||
)
|
||||
zombies.sort(key=lambda z: -z["days_inactive"])
|
||||
return {
|
||||
"full_name": full_name,
|
||||
"dwell": dwell,
|
||||
"slowest": slowest,
|
||||
"zombies": zombies,
|
||||
"zombie_days": settings.zombie_days,
|
||||
"open_count": len(display_issues),
|
||||
"column_labels": COLUMN_LABELS,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health", response_class=HTMLResponse)
|
||||
async def health_index(request: Request):
|
||||
"""Flow health, global summary (issue #9)."""
|
||||
repos = await gitea.search_repos()
|
||||
active, _inactive, _archived = classify_repos(repos)
|
||||
|
||||
async def _one_health(repo_info: dict) -> dict:
|
||||
owner, _, name = repo_info["full_name"].partition("/")
|
||||
return await _project_health(owner, name)
|
||||
|
||||
results = await _parallel_map(active, _one_health)
|
||||
projects = []
|
||||
for repo_info, result in zip(active, results):
|
||||
if isinstance(result, Exception):
|
||||
logger.warning(
|
||||
"Health failed for %s: %s", repo_info["full_name"], result
|
||||
)
|
||||
continue
|
||||
result["dot"] = _health_dot(result)
|
||||
projects.append(result)
|
||||
severity_rank = {"red": 0, "orange": 1, "green": 2}
|
||||
projects.sort(
|
||||
key=lambda p: (
|
||||
severity_rank.get(p["dot"]["level"], 3),
|
||||
-len(p["zombies"]),
|
||||
)
|
||||
)
|
||||
return render(request, "health.html", projects=projects)
|
||||
|
||||
|
||||
@app.get("/health/{owner}/{repo}", response_class=HTMLResponse)
|
||||
async def health_project(request: Request, owner: str, repo: str):
|
||||
"""Flow health of one project: dwell per column + zombies.
|
||||
|
||||
Passes the active project list so the page can offer a project
|
||||
switcher — no more back-to-the-index round trip (0.19.0, C).
|
||||
"""
|
||||
repos = await gitea.search_repos()
|
||||
active, _inactive, _archived = classify_repos(repos)
|
||||
project_names = [r["full_name"] for r in active]
|
||||
if f"{owner}/{repo}" not in {r["full_name"] for r in repos}:
|
||||
return render(
|
||||
request, "health_project.html",
|
||||
health=None, full_name=f"{owner}/{repo}",
|
||||
project_names=project_names,
|
||||
)
|
||||
health = await _project_health(owner, repo)
|
||||
return render(
|
||||
request, "health_project.html",
|
||||
health=health, full_name=f"{owner}/{repo}",
|
||||
project_names=project_names,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/standup")
|
||||
async def api_standup():
|
||||
"""The automatic standup as JSON text (issue #6)."""
|
||||
@@ -1275,11 +1648,29 @@ async def standup_page(request: Request):
|
||||
|
||||
@app.get("/forecast", response_class=HTMLResponse)
|
||||
async def forecast_page(request: Request):
|
||||
"""Forecasts (issue #7): real-pace ETAs per project + milestone."""
|
||||
"""Forecasts (issue #7; portfolio table in 0.19.0, chantier C).
|
||||
|
||||
One row per open milestone across every active project,
|
||||
sorted by risk (largest ETA-vs-deadline gap first) — the
|
||||
cross-project question the old per-project card wall could
|
||||
not answer without reading everything. Per-project pace
|
||||
(weekly bars + backlog ETA) stays as a compact second table.
|
||||
"""
|
||||
repos = await gitea.search_repos()
|
||||
active, _inactive, _archived = classify_repos(repos)
|
||||
projects = []
|
||||
for repo_info in active:
|
||||
|
||||
async def _fetch_milestones(repo_info: dict) -> list[dict]:
|
||||
owner, _, name = repo_info["full_name"].partition("/")
|
||||
try:
|
||||
return await gitea.get_milestones(owner, name)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
ms_results = await _parallel_map(active, _fetch_milestones)
|
||||
|
||||
rows: list[dict] = []
|
||||
projects: list[dict] = []
|
||||
for repo_info, ms_list in zip(active, ms_results):
|
||||
full = repo_info["full_name"]
|
||||
owner, _, name = full.partition("/")
|
||||
weekly = forecast_service.weekly_closures(
|
||||
@@ -1294,50 +1685,100 @@ async def forecast_page(request: Request):
|
||||
}
|
||||
for b in weekly
|
||||
]
|
||||
stats = forecast_service.throughput_stats(weekly)
|
||||
remaining = repo_info.get("open_issues_count") or 0
|
||||
est = forecast_service.estimate(remaining, weekly)
|
||||
milestones = []
|
||||
try:
|
||||
ms_list = await gitea.get_milestones(owner, name)
|
||||
except Exception:
|
||||
ms_list = []
|
||||
for ms in ms_list:
|
||||
if ms.get("state") not in (None, "open"):
|
||||
continue
|
||||
rem = ms.get("open_issues") or 0
|
||||
if rem == 0 and not (ms.get("closed_issues") or 0):
|
||||
continue
|
||||
m_est = forecast_service.estimate(rem, weekly)
|
||||
verdict = None
|
||||
due_raw = ms.get("due_on")
|
||||
if m_est and not m_est.get("done") and due_raw:
|
||||
due = _parse_datetime(str(due_raw))
|
||||
if due is not None:
|
||||
verdict = (
|
||||
"⚠️ en retard à ce rythme"
|
||||
if m_est["central"] > due.date()
|
||||
else "✅ dans les temps à ce rythme"
|
||||
)
|
||||
milestones.append(
|
||||
{
|
||||
"title": ms.get("title", ""),
|
||||
"due_on": (due_raw or "")[:10],
|
||||
"remaining": rem,
|
||||
"eta": forecast_service.eta_label(m_est),
|
||||
"verdict": verdict,
|
||||
}
|
||||
)
|
||||
projects.append(
|
||||
{
|
||||
"full_name": full,
|
||||
"bars": bars,
|
||||
"stats": forecast_service.throughput_stats(weekly),
|
||||
"stats": stats,
|
||||
"remaining": remaining,
|
||||
"eta_label": forecast_service.eta_label(est),
|
||||
"milestones": milestones,
|
||||
}
|
||||
)
|
||||
return render(request, "forecast.html", projects=projects)
|
||||
if isinstance(ms_list, Exception):
|
||||
ms_list = []
|
||||
next_title = None
|
||||
next_due = None
|
||||
for ms in ms_list:
|
||||
if ms.get("state") not in (None, "open"):
|
||||
continue
|
||||
rem = ms.get("open_issues") or 0
|
||||
closed_n = ms.get("closed_issues") or 0
|
||||
if rem == 0 and not closed_n:
|
||||
continue
|
||||
total = rem + closed_n
|
||||
m_est = forecast_service.estimate(rem, weekly)
|
||||
due_raw = ms.get("due_on")
|
||||
due_dt = _parse_datetime(str(due_raw)) if due_raw else None
|
||||
due_date = due_dt.date() if due_dt else None
|
||||
gap_days = None
|
||||
verdict = "noeta"
|
||||
if due_date is None:
|
||||
verdict = "nodue"
|
||||
elif m_est and not m_est.get("done"):
|
||||
gap_days = (m_est["central"] - due_date).days
|
||||
verdict = "late" if gap_days > 0 else "ontime"
|
||||
elif m_est and m_est.get("done"):
|
||||
verdict = "ontime"
|
||||
rows.append(
|
||||
{
|
||||
"project": full,
|
||||
"owner": owner,
|
||||
"repo": name,
|
||||
"title": ms.get("title", ""),
|
||||
"due_label": (
|
||||
forecast_service.french_date(due_date, short=True)
|
||||
if due_date else "—"
|
||||
),
|
||||
"due_sort": due_date.isoformat() if due_date else "9999",
|
||||
"remaining": rem,
|
||||
"closed": closed_n,
|
||||
"total": total,
|
||||
"pct": round(100 * closed_n / total) if total else 0,
|
||||
"eta": forecast_service.eta_label(m_est),
|
||||
"gap_days": gap_days,
|
||||
"verdict": verdict,
|
||||
"throughput": stats["avg"] if stats else None,
|
||||
"anchor": False,
|
||||
}
|
||||
)
|
||||
if (
|
||||
due_date is not None
|
||||
and (next_due is None or due_date < next_due)
|
||||
):
|
||||
next_due, next_title = due_date, ms.get("title", "")
|
||||
for row in rows:
|
||||
if (
|
||||
row["project"] == full
|
||||
and row["title"] == next_title
|
||||
and next_title is not None
|
||||
):
|
||||
row["anchor"] = True
|
||||
|
||||
def _risk_key(row: dict):
|
||||
# Late first (worst gap first), then dated on-time by due
|
||||
# date, then no-ETA, then no due date.
|
||||
order = {"late": 0, "ontime": 1, "noeta": 2, "nodue": 3}
|
||||
return (
|
||||
order.get(row["verdict"], 4),
|
||||
-(row["gap_days"] or 0) if row["verdict"] == "late" else 0,
|
||||
row["due_sort"],
|
||||
row["project"],
|
||||
row["title"],
|
||||
)
|
||||
|
||||
rows.sort(key=_risk_key)
|
||||
summary = {
|
||||
"late": sum(1 for r in rows if r["verdict"] == "late"),
|
||||
"ontime": sum(1 for r in rows if r["verdict"] == "ontime"),
|
||||
"total": len(rows),
|
||||
}
|
||||
return render(
|
||||
request, "forecast.html",
|
||||
rows=rows, projects=projects, summary=summary,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/focus", response_class=HTMLResponse)
|
||||
@@ -1410,15 +1851,14 @@ async def _collect_wall_data(only_repo: str | None) -> list[dict]:
|
||||
closed_today.get(event["repo_full_name"], 0) + 1
|
||||
)
|
||||
|
||||
slides = []
|
||||
for repo in repos:
|
||||
async def _one_slide(repo: dict) -> dict | None:
|
||||
full_name = repo["full_name"]
|
||||
owner, _, name = full_name.partition("/")
|
||||
try:
|
||||
items = await gitea.get_repo_issues(owner, name, state="open")
|
||||
except Exception as exc:
|
||||
logger.debug("wall: issues failed for %s: %s", full_name, exc)
|
||||
continue
|
||||
return None
|
||||
open_issues = [i for i in items if _is_issue(i)]
|
||||
_adopt_label_columns(full_name, open_issues, get_board_state(full_name))
|
||||
board_state = get_board_state(full_name)
|
||||
@@ -1440,16 +1880,17 @@ async def _collect_wall_data(only_repo: str | None) -> list[dict]:
|
||||
"aging": days is not None and days >= aging_days,
|
||||
}
|
||||
)
|
||||
slides.append(
|
||||
{
|
||||
"full_name": full_name,
|
||||
"counts": {col: len(columns[col]) for col in KANBAN_COLUMNS},
|
||||
"open_total": len(open_issues),
|
||||
"cards": cards[:9],
|
||||
"closed_today": closed_today.get(full_name, 0),
|
||||
}
|
||||
)
|
||||
return slides
|
||||
return {
|
||||
"full_name": full_name,
|
||||
"counts": {col: len(columns[col]) for col in KANBAN_COLUMNS},
|
||||
"open_total": len(open_issues),
|
||||
"cards": cards[:9],
|
||||
"cards_total": len(cards),
|
||||
"closed_today": closed_today.get(full_name, 0),
|
||||
}
|
||||
|
||||
results = await _parallel_map(repos, _one_slide)
|
||||
return [r for r in results if isinstance(r, dict)]
|
||||
|
||||
|
||||
@app.get("/wall", response_class=HTMLResponse)
|
||||
@@ -1994,13 +2435,17 @@ async def api_capture(request: Request):
|
||||
|
||||
|
||||
@app.get("/capture", response_class=HTMLResponse)
|
||||
async def capture_page(request: Request):
|
||||
"""Quick-capture page: form, status and usage instructions."""
|
||||
async def capture_page(request: Request, repo: str = ""):
|
||||
"""Quick-capture page: form, status and usage instructions.
|
||||
|
||||
`?repo=Owner/name` pre-fills the target repo (issue #23: the
|
||||
project card's quick actions capture straight into a project).
|
||||
"""
|
||||
settings = get_settings()
|
||||
return render(
|
||||
request,
|
||||
"capture.html",
|
||||
capture_repo=settings.capture_repo,
|
||||
capture_repo=repo.strip() or settings.capture_repo,
|
||||
api_enabled=bool(settings.capture_token),
|
||||
ntfy_topic=settings.capture_ntfy_topic,
|
||||
ntfy_url=settings.ntfy_url,
|
||||
@@ -2490,7 +2935,146 @@ async def api_issue_code(owner: str, repo: str, number: int):
|
||||
"""Branches, PRs and commits linked to a card (issue #1)."""
|
||||
branches, pulls, commits = await _fetch_code_sources(owner, repo)
|
||||
links = code_links_service.find_code_links(branches, pulls, commits, number)
|
||||
return {"ok": True, **links}
|
||||
suggested = f"issue-{number}"
|
||||
try:
|
||||
issue = await gitea.get_issue(owner, repo, number)
|
||||
suggested = code_links_service.branch_name_for(
|
||||
number, issue.get("title", "")
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "suggested_branch": suggested, **links}
|
||||
|
||||
|
||||
_BRANCH_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$")
|
||||
|
||||
|
||||
async def _sync_kanban_label(
|
||||
owner: str, repo: str, number: int, column: str
|
||||
) -> None:
|
||||
"""Mirror a column on the issue's kanban:* label (best effort)."""
|
||||
if not get_settings().sync_labels:
|
||||
return
|
||||
try:
|
||||
desired = (
|
||||
f"kanban:{column}" if column in LABELLED_COLUMNS else None
|
||||
)
|
||||
issue = await gitea.get_issue(owner, repo, number)
|
||||
current = [
|
||||
label for label in issue.get("labels") or []
|
||||
if str(label.get("name", "")).startswith("kanban:")
|
||||
]
|
||||
for label in current:
|
||||
if label.get("name") != desired:
|
||||
await gitea.remove_issue_label(
|
||||
owner, repo, number, label["id"]
|
||||
)
|
||||
if desired and all(l.get("name") != desired for l in current):
|
||||
label = await gitea.ensure_label(
|
||||
owner, repo, desired, COLUMN_COLORS[column]
|
||||
)
|
||||
await gitea.add_issue_label(owner, repo, number, label["id"])
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Label sync failed for %s/%s#%s: %s", owner, repo, number, exc
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/issues/{owner}/{repo}/{number}/branch")
|
||||
async def api_create_branch(
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int,
|
||||
request: Request,
|
||||
name: str = Form(""),
|
||||
csrf_token: str = Form(""),
|
||||
):
|
||||
"""Create the card's branch in Gitea (issue #3), then start the card.
|
||||
|
||||
Default name: issue-<n>-<slug of the title> — the convention the
|
||||
merged-PR automation (issue #2) recognizes. Creating the branch
|
||||
moves the card to In Progress and journals it.
|
||||
"""
|
||||
if not _csrf_valid(request, csrf_token):
|
||||
return _csrf_error()
|
||||
try:
|
||||
issue = await gitea.get_issue(owner, repo, number)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Issue introuvable"}, status_code=404
|
||||
)
|
||||
if not _is_issue(issue):
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Pas une issue"}, status_code=400
|
||||
)
|
||||
branch = (name or "").strip() or code_links_service.branch_name_for(
|
||||
number, issue.get("title", "")
|
||||
)
|
||||
if (
|
||||
not _BRANCH_NAME_RE.match(branch)
|
||||
or ".." in branch
|
||||
or branch.endswith("/")
|
||||
or branch.endswith(".lock")
|
||||
):
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": f"Nom de branche invalide : {branch}"},
|
||||
status_code=400,
|
||||
)
|
||||
repo_info = {}
|
||||
try:
|
||||
repo_info = await gitea.get_repo(owner, repo)
|
||||
except Exception:
|
||||
pass
|
||||
base_url = repo_info.get("html_url", "")
|
||||
branch_url = f"{base_url}/src/branch/{branch}" if base_url else ""
|
||||
existing = await _safe_list_branches(owner, repo)
|
||||
if branch in existing:
|
||||
return {
|
||||
"ok": True, "branch": branch, "exists": True,
|
||||
"url": branch_url, "moved": False,
|
||||
}
|
||||
default_branch = repo_info.get("default_branch") or "main"
|
||||
try:
|
||||
await gitea.create_branch(owner, repo, branch, default_branch)
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": f"Création impossible : {exc}"},
|
||||
status_code=502,
|
||||
)
|
||||
gitea.invalidate_cache()
|
||||
|
||||
full_name = f"{owner}/{repo}"
|
||||
moved = False
|
||||
if issue.get("state") == "open":
|
||||
board = get_board_state(full_name)
|
||||
current = next(
|
||||
(
|
||||
col for col, cards in board.items()
|
||||
if any(c["issue_number"] == number for c in cards)
|
||||
),
|
||||
"backlog",
|
||||
)
|
||||
if current != "in_progress":
|
||||
move_issue(full_name, number, "in_progress")
|
||||
log_event(
|
||||
full_name, number, "moved",
|
||||
from_column=current, to_column="in_progress",
|
||||
source="branch",
|
||||
)
|
||||
await _sync_kanban_label(owner, repo, number, "in_progress")
|
||||
moved = True
|
||||
await _publish_board_changed(full_name)
|
||||
return {
|
||||
"ok": True, "branch": branch, "from": default_branch,
|
||||
"url": branch_url, "moved": moved,
|
||||
}
|
||||
|
||||
|
||||
async def _safe_list_branches(owner: str, repo: str) -> list[str]:
|
||||
try:
|
||||
return [b.get("name", "") for b in await gitea.list_branches(owner, repo)]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
@app.get("/api/issues/{owner}/{repo}/{number}")
|
||||
|
||||
@@ -368,6 +368,38 @@ def column_entry_times(repo_full_name: str) -> dict[int, str]:
|
||||
return {row["issue_number"]: row["entered_at"] for row in rows}
|
||||
|
||||
|
||||
def get_project_activity(days: int = 7) -> dict:
|
||||
"""Per-repo journal activity in ONE aggregated pass (issue #19).
|
||||
|
||||
For every repo present in the journal: closures and moves of
|
||||
the last `days` days (journal stamps are UTC strings), plus
|
||||
the most recent event ever seen for that repo.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT repo_full_name,
|
||||
SUM(CASE WHEN event_type = 'closed' AND created_at >= ?
|
||||
THEN 1 ELSE 0 END),
|
||||
SUM(CASE WHEN event_type = 'moved' AND created_at >= ?
|
||||
THEN 1 ELSE 0 END),
|
||||
MAX(created_at)
|
||||
FROM board_events
|
||||
GROUP BY repo_full_name
|
||||
""",
|
||||
(cutoff, cutoff),
|
||||
).fetchall()
|
||||
return {
|
||||
r[0]: {"closed": r[1] or 0, "moved": r[2] or 0, "last_at": r[3]}
|
||||
for r in rows
|
||||
}
|
||||
|
||||
|
||||
def get_metrics(repo_full_name: str | None = None, window_days: int = 30) -> dict:
|
||||
"""Throughput and cycle time computed from the journal.
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
{# ── Cadre applicatif partagé (issue #16, refonte 0.16.0) ─────────
|
||||
Barre latérale unique pour TOUTES les pages applicatives :
|
||||
desktop = barre fixe repliable en rail d'icônes (état mémorisé),
|
||||
mobile = tiroir hors-canvas ouvert par le burger de la topbar.
|
||||
NE PAS dupliquer ce markup dans les pages.
|
||||
Page active : `active_nav` (déduite du gabarit dans render()).
|
||||
Fil d'Ariane : `owner`+`repo` (board) ou `full_name` (projet).
|
||||
Le wallboard (TV) n'inclut pas ce partiel, volontairement. #}
|
||||
<script>
|
||||
document.body.classList.add('with-sidebar');
|
||||
try {
|
||||
if (localStorage.getItem('kanbante-sidebar') === 'collapsed') {
|
||||
document.body.classList.add('sidebar-collapsed');
|
||||
}
|
||||
JSON.parse(localStorage.getItem('kanbante-nav-closed') || '[]')
|
||||
.forEach(function (k) {
|
||||
document.body.classList.add('nav-closed-' + k);
|
||||
});
|
||||
} catch (e) { /* stockage indisponible : état par défaut */ }
|
||||
</script>
|
||||
{% set nav_groups = [
|
||||
("principal", "Principal", [
|
||||
("home", "/", "🏠", "Projets"),
|
||||
("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"),
|
||||
]),
|
||||
] %}
|
||||
<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>
|
||||
</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">
|
||||
<button type="button" class="side-collapse" id="side-collapse" aria-label="Replier ou déplier le menu">
|
||||
<span class="side-collapse-ico">«</span>
|
||||
<span class="side-label">Replier</span>
|
||||
</button>
|
||||
<span class="side-version side-label">v{{ version }}</span>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="side-backdrop" id="side-backdrop" hidden></div>
|
||||
<header class="app-header topbar">
|
||||
<button type="button" class="nav-burger" id="nav-burger" aria-expanded="false" aria-controls="sidebar" aria-label="Ouvrir le menu">☰</button>
|
||||
{% if owner is defined and repo is defined %}
|
||||
<span class="crumb"><a href="/board/{{ owner }}/{{ repo }}">{{ owner }} / <strong>{{ repo }}</strong></a></span>
|
||||
{% elif full_name is defined %}
|
||||
<span class="crumb"><a href="/board/{{ full_name }}">{{ full_name }}</a></span>
|
||||
{% endif %}
|
||||
<div class="header-actions">
|
||||
{% if repo_info is defined and repo_info and repo_info.html_url %}
|
||||
<a href="{{ repo_info.html_url }}" target="_blank" rel="noopener" class="nav-pill" title="Ouvrir ce dépôt dans Gitea">🔗 Gitea ↗</a>
|
||||
{% endif %}
|
||||
<button type="button" class="nav-pill palette-trigger topbar-search" title="Recherche et commandes (Ctrl+K)">🔍</button>
|
||||
</div>
|
||||
<script src="/static/js/nav.js" defer></script>
|
||||
</header>
|
||||
@@ -8,28 +8,7 @@
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
<span class="crumb"><span class="sep">/</span> 📰 <strong>Activité</strong></span>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill active">📰 Activité</a>
|
||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||
<a href="/standup" class="nav-pill">🗣️ Standup</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<p class="focus-intro">Les {{ events|length }} derniers événements du journal — déplacements, créations, fermetures et commentaires, tous projets confondus.</p>
|
||||
|
||||
@@ -12,29 +12,7 @@
|
||||
<script src="/static/js/board.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
<span class="crumb"><span class="sep">/</span> {{ owner }} / <strong>{{ repo }}</strong></span>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
{% if repo_info.html_url %}
|
||||
<a href="{{ repo_info.html_url }}" target="_blank" class="nav-pill">🔗 Gitea ↗</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</header>
|
||||
{% include "_header.html" %}
|
||||
|
||||
{% if milestones %}
|
||||
<div class="milestone-bar">
|
||||
|
||||
@@ -9,26 +9,7 @@
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
<span class="crumb"><span class="sep">/</span> 📊 <strong>Briefing</strong></span>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<p class="focus-intro">Le briefing du <strong>{{ data.date }}</strong> — le même contenu que celui poussé sur ntfy.</p>
|
||||
|
||||
@@ -8,25 +8,7 @@
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<a href="/" class="logo">📋 Kanbante</a>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||
<a href="/standup" class="nav-pill">🗣️ Standup</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill active">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
|
||||
+156
-30
@@ -9,27 +9,7 @@
|
||||
<script defer src="/static/vendor/alpine.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill active">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||
<a href="/standup" class="nav-pill">🗣️ Standup</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<section class="hero">
|
||||
<div class="hero-text">
|
||||
@@ -44,7 +24,22 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main x-data="{ tab: 'active', search: '' }">
|
||||
<script>
|
||||
// État du dashboard (#21/#22) : la vue grille/liste survit au
|
||||
// rechargement (localStorage), lue avant qu'Alpine démarre.
|
||||
function dashState() {
|
||||
var view = 'grid';
|
||||
try { view = localStorage.getItem('kanbante-dash-view') || 'grid'; } catch (e) { /* défaut */ }
|
||||
return {
|
||||
tab: 'active', search: '', view: view,
|
||||
setView: function (v) {
|
||||
this.view = v;
|
||||
try { localStorage.setItem('kanbante-dash-view', v); } catch (e) { /* non-fatal */ }
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<main x-data="dashState()">
|
||||
<!-- Tabs: "actif" = mis à jour dans la fenêtre glissante;
|
||||
"archivé" = le vrai drapeau Gitea (repo.archived). -->
|
||||
<div class="tabs">
|
||||
@@ -66,17 +61,61 @@
|
||||
<kbd class="kbd">/</kbd>
|
||||
</div>
|
||||
|
||||
<!-- Outils d'affichage (#21 tri/regroupement, #22 vue liste) :
|
||||
pilotés par dashboard.js, préférences en localStorage. -->
|
||||
<div class="dash-tools">
|
||||
<label class="dash-tool">↕️ Tri
|
||||
<select id="dash-sort">
|
||||
<option value="default">Par défaut</option>
|
||||
<option value="updated">Activité Gitea récente</option>
|
||||
<option value="activity">Activité kanban récente</option>
|
||||
<option value="open">Issues ouvertes ↓</option>
|
||||
<option value="name">Nom A → Z</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="dash-tool dash-tool-check">
|
||||
<input type="checkbox" id="dash-group"> 🗂️ Grouper par propriétaire
|
||||
</label>
|
||||
<div class="dash-view-toggle" role="group" aria-label="Mode d'affichage">
|
||||
<button type="button" :class="{ active: view === 'grid' }" @click="setView('grid')">▦ Grille</button>
|
||||
<button type="button" :class="{ active: view === 'list' }" @click="setView('list')">☰ Liste</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% macro project_card(repo, archived=False) %}
|
||||
{% set full = repo.full_name %}
|
||||
<div class="project-card{% if archived %} archived{% endif %}"
|
||||
data-full="{{ full }}" data-home="{{ repo.home_tab or '' }}"
|
||||
data-name="{{ repo.name|lower }}" data-owner="{{ repo.owner.login }}"
|
||||
data-updated="{{ repo.updated_at }}" data-open="{{ repo.open_issues_count or 0 }}"
|
||||
data-activity="{{ repo.activity.last_at if repo.activity else '' }}"
|
||||
data-search="{{ (repo.name ~ ' ' ~ repo.full_name ~ ' ' ~ (repo.description or ''))|lower }}"
|
||||
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
|
||||
<div class="card-header">
|
||||
<span class="org-tag">{{ repo.owner.login }}</span>
|
||||
{% if repo.health_dot %}
|
||||
<a href="/health/{{ full }}" class="health-dot {{ repo.health_dot.level }}" title="Santé du flux — {{ repo.health_dot.tooltip }}" aria-label="Santé du flux : {{ repo.health_dot.tooltip }}"></a>
|
||||
{% endif %}
|
||||
<span class="date">{{ repo.updated_at[:10] }}</span>
|
||||
{% if not archived %}
|
||||
<button type="button" class="star-btn" data-full="{{ full }}" data-starred="{{ 1 if repo.starred else 0 }}" title="{{ 'Désépingler (étoile Gitea)' if repo.starred else 'Épingler (étoile Gitea)' }}" aria-label="Épingler {{ repo.name }}">{{ '⭐' if repo.starred else '☆' }}</button>
|
||||
<div class="card-menu-wrap">
|
||||
<button type="button" class="card-menu-btn" data-menu-btn aria-haspopup="true" aria-expanded="false" title="Actions rapides pour {{ repo.name }}" aria-label="Actions rapides pour {{ repo.name }}">⋯</button>
|
||||
<div class="card-menu" hidden>
|
||||
<a href="/board/{{ full }}">📋 Board</a>
|
||||
<a href="/capture?repo={{ full|urlencode }}">⚡ Capturer ici</a>
|
||||
<a href="/triage/{{ full }}">📥 Triage du backlog</a>
|
||||
<a href="/health/{{ full }}">🩺 Santé</a>
|
||||
<a href="/sprint/{{ full }}">📖 Récit de sprint</a>
|
||||
{% if repo.html_url %}
|
||||
<a href="{{ repo.html_url }}" target="_blank" rel="noopener">🔗 Gitea ↗</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h3>
|
||||
<a href="/board/{{ repo.owner.login }}/{{ repo.name }}">{{ repo.name }}</a>
|
||||
<a href="/project/{{ repo.owner.login }}/{{ repo.name }}">{{ repo.name }}</a>
|
||||
</h3>
|
||||
<p class="desc">{{ repo.description or '—' }}</p>
|
||||
{% if full in stats %}
|
||||
@@ -88,8 +127,22 @@
|
||||
</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 %}
|
||||
@@ -106,35 +159,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">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 || '')) {
|
||||
|
||||
@@ -8,23 +8,7 @@
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<a href="/" class="logo">📋 Kanbante</a>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||
<a href="/standup" class="nav-pill">🗣️ Standup</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill active">🧠 Digest</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,28 +8,7 @@
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
<span class="crumb"><span class="sep">/</span> 🎯 <strong>Focus</strong></span>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill active">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||
<a href="/standup" class="nav-pill">🗣️ Standup</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
<span class="badge">{{ groups|map(attribute='cards')|map('length')|sum }} cartes en cours</span>
|
||||
</nav>
|
||||
</header>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<p class="focus-intro">Tout ce qui est <strong>En cours</strong> ou <strong>En révision</strong>, tous projets actifs confondus.</p>
|
||||
|
||||
+79
-36
@@ -8,35 +8,77 @@
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<a href="/" class="logo">📋 Kanbante</a>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||
<a href="/standup" class="nav-pill">🗣️ Standup</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/forecast" class="nav-pill active">🔮 Prévisions</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
<h1>🔮 Prévisions — « on finit quand ? »</h1>
|
||||
<p>Réponse honnête, calculée depuis le journal : fermetures réelles par semaine (8 dernières), débit moyen/min/max sur les 4 dernières semaines complètes, et fourchette plutôt que faux chiffre précis. Sans historique de fermetures, aucune prévision n'est affichée.</p>
|
||||
<p>Le tableau de bord des jalons, <strong>tous projets confondus</strong>, trié par risque : l'écart entre la date estimée (au débit réel du journal) et l'échéance. Réponse honnête — fourchettes plutôt que faux chiffres précis, et aucune prévision sans historique de fermetures.</p>
|
||||
<p class="forecast-summary">
|
||||
<span class="chip chip-total">🎯 {{ summary.total }} jalon(s) ouvert(s)</span>
|
||||
{% if summary.late %}<span class="chip chip-late">⚠️ {{ summary.late }} en retard à ce rythme</span>{% endif %}
|
||||
{% if summary.ontime %}<span class="chip chip-ok">✅ {{ summary.ontime }} dans les temps</span>{% endif %}
|
||||
</p>
|
||||
<div class="search-bar forecast-filter">
|
||||
<input type="text" id="ms-filter" placeholder="🔍 Filtrer par projet ou jalon..." autocomplete="off">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% for project in projects %}
|
||||
<section class="briefing-card forecast-card">
|
||||
<h2>📂 {{ project.full_name }}</h2>
|
||||
{% if rows %}
|
||||
<table class="proj-table pilot-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="project">Projet</th>
|
||||
<th data-sort="title">Jalon</th>
|
||||
<th data-sort="due">Échéance</th>
|
||||
<th>Progression</th>
|
||||
<th>ETA</th>
|
||||
<th data-sort="gap">É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 %}
|
||||
@@ -47,25 +89,17 @@
|
||||
</svg>
|
||||
<div class="forecast-nums">
|
||||
{% if project.stats %}
|
||||
<p>Débit : <strong>{{ project.stats.avg }} / semaine</strong> en moyenne (min {{ project.stats.min }}, max {{ project.stats.max }})</p>
|
||||
<p>Débit : <strong>{{ project.stats.avg }} / semaine</strong> (min {{ project.stats.min }}, max {{ project.stats.max }})</p>
|
||||
{% else %}
|
||||
<p>Débit : <strong>pas assez d'historique</strong> — aucune fermeture récente dans le journal.</p>
|
||||
<p>Débit : <strong>pas assez d'historique</strong>.</p>
|
||||
{% endif %}
|
||||
<p>Restant ouvert : <strong>{{ project.remaining }}</strong>
|
||||
{% if project.eta_label %} · 🔮 backlog {{ project.eta_label }}{% endif %}</p>
|
||||
<p>Restant ouvert : <strong>{{ project.remaining }}</strong>{% if project.eta_label %} · 🔮 backlog {{ project.eta_label }}{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% if project.milestones %}
|
||||
<ul class="forecast-milestones">
|
||||
{% for ms in project.milestones %}
|
||||
<li>🎯 <strong>{{ ms.title }}</strong> — {{ ms.remaining }} restante(s){% if ms.due_on %} · échéance {{ ms.due_on }}{% endif %}{% if ms.eta %} · 🔮 {{ ms.eta }}{% endif %}{% if ms.verdict %} · {{ ms.verdict }}{% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p>Aucun projet actif.</p>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% else %}
|
||||
<div class="briefing-card"><p>Aucun projet actif.</p></div>
|
||||
{% endfor %}
|
||||
</main>
|
||||
<footer>
|
||||
<span>Kanbante v{{ version }} · prévisions = journal des fermetures, pas boule de cristal</span>
|
||||
@@ -73,5 +107,14 @@
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
<script src="/static/js/tablesort.js" defer></script>
|
||||
<script>
|
||||
document.getElementById('ms-filter')?.addEventListener('input', function (e) {
|
||||
var q = e.target.value.toLowerCase();
|
||||
document.querySelectorAll('.pilot-table tbody tr').forEach(function (row) {
|
||||
row.style.display = !q || (row.dataset.search || '').includes(q) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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">🧟 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>
|
||||
@@ -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>
|
||||
+1
-20
@@ -8,26 +8,7 @@
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
<span class="crumb"><span class="sep">/</span> 👤 <strong>Mes issues</strong></span>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||
<a href="/my" class="nav-pill active">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
{% if not user %}
|
||||
|
||||
@@ -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>
|
||||
@@ -8,18 +8,7 @@
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<a href="/" class="logo">📋 Kanbante</a>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||
<a href="/rules" class="nav-pill active">⚙️ Règles</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
|
||||
@@ -8,26 +8,7 @@
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
<span class="crumb"><span class="sep">/</span> 🔍 <strong>Recherche</strong></span>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/search" class="nav-pill active">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<form method="get" action="/search" class="search-hero">
|
||||
|
||||
@@ -10,16 +10,7 @@
|
||||
<script src="/static/vendor/purify.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<a href="/" class="logo">📋 Kanbante</a>
|
||||
<nav class="nav-pills">
|
||||
<a href="/board/{{ full_name }}" class="nav-pill">📋 Board</a>
|
||||
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
{% if markdown %}
|
||||
|
||||
@@ -8,17 +8,7 @@
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<a href="/" class="logo">📋 Kanbante</a>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||
<a href="/standup" class="nav-pill">🗣️ Standup</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
|
||||
@@ -8,25 +8,7 @@
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<a href="/" class="logo">📋 Kanbante</a>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/today" class="nav-pill active">🌅 Aujourd'hui</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||
<a href="/standup" class="nav-pill">🗣️ Standup</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="today-main">
|
||||
<h1 class="today-title">🌅 {{ date_label }}</h1>
|
||||
|
||||
@@ -8,25 +8,7 @@
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<a href="/" class="logo">📋 Kanbante</a>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||
<a href="/standup" class="nav-pill">🗣️ Standup</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
|
||||
@@ -8,14 +8,7 @@
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<a href="/" class="logo">📋 Kanbante</a>
|
||||
<nav class="nav-pills">
|
||||
<a href="/board/{{ full_name }}" class="nav-pill">📋 Board</a>
|
||||
<a href="/triage" class="nav-pill">📥 Autre projet</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="triage-main" data-repo="{{ full_name }}">
|
||||
<div class="triage-head">
|
||||
|
||||
@@ -45,6 +45,9 @@
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if slide.cards_total > slide.cards|length %}
|
||||
<li class="wall-card wall-more">… +{{ slide.cards_total - slide.cards|length }} autres cartes en vol</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="wall-idle">🌿 Rien en vol pour le moment — tout est au Backlog ou terminé.</p>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "kanbante"
|
||||
version = "0.13.0"
|
||||
version = "0.19.0"
|
||||
description = "Kanban project manager integrated with Gitea"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
+329
-5
@@ -900,15 +900,21 @@ footer {
|
||||
color: #e5ecf5;
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wall-stage { position: relative; min-height: 100vh; }
|
||||
.wall-stage { position: relative; height: 100vh; height: 100dvh; }
|
||||
.wall-slide {
|
||||
display: none;
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
padding: 4vh 4vw;
|
||||
padding: 3.5vh 4vw 96px;
|
||||
flex-direction: column;
|
||||
gap: 3vh;
|
||||
gap: 2.5vh;
|
||||
}
|
||||
.wall-slide.active { display: flex; animation: wall-in 0.45s ease; }
|
||||
@keyframes wall-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
@@ -934,7 +940,14 @@ footer {
|
||||
}
|
||||
.wall-count-n { display: block; font-size: clamp(1.9rem, 3.6vw, 3rem); font-weight: 800; }
|
||||
.wall-count-l { display: block; color: #aebdd2; font-size: clamp(0.78rem, 1.25vw, 1.02rem); margin-top: 2px; }
|
||||
.wall-cards { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||
.wall-cards {
|
||||
list-style: none; margin: 0; padding: 0;
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
flex: 1 1 auto; min-height: 0; overflow: hidden;
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000 88%, transparent);
|
||||
mask-image: linear-gradient(to bottom, #000 88%, transparent);
|
||||
}
|
||||
.wall-more { justify-content: center; color: #8fa3bf; font-size: 1.1rem; background: transparent; }
|
||||
.wall-card {
|
||||
display: flex; align-items: center; gap: 18px;
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
@@ -954,8 +967,17 @@ footer {
|
||||
.wall-foot {
|
||||
margin-top: auto;
|
||||
display: flex; gap: 36px; flex-wrap: wrap;
|
||||
justify-content: space-between; align-items: center;
|
||||
width: 100%; box-sizing: border-box;
|
||||
font-size: clamp(1rem, 1.7vw, 1.35rem); color: #aebdd2;
|
||||
}
|
||||
@media (max-height: 760px) {
|
||||
.wall-slide { gap: 1.6vh; padding-top: 2.5vh; }
|
||||
.wall-card { padding: 10px 16px; }
|
||||
.wall-count { padding: 8px 8px 7px; }
|
||||
.wall-project { font-size: clamp(1.8rem, 4vw, 2.6rem); }
|
||||
.wall-time { font-size: clamp(1.7rem, 3.6vw, 2.6rem); }
|
||||
}
|
||||
.wall-live { color: #34d399; font-weight: 700; letter-spacing: 0.08em; }
|
||||
.wall-empty {
|
||||
min-height: 100vh; display: flex; flex-direction: column;
|
||||
@@ -1151,3 +1173,305 @@ footer {
|
||||
.code-line { padding: 3px 0; font-size: 0.9rem; color: var(--text-2, #b6c2d2); }
|
||||
.code-line a { color: var(--accent, #58a6ff); text-decoration: none; }
|
||||
.code-line code { background: var(--surface-3, #1d2942); border-radius: 4px; padding: 0 5px; }
|
||||
|
||||
/* ── Branche depuis la carte (0.14.0, #3) ── */
|
||||
.branch-input {
|
||||
background: var(--surface-3, #1d2942); color: var(--text-1);
|
||||
border: 1px solid var(--border, #22304a); border-radius: 6px;
|
||||
padding: 3px 8px; font-family: monospace; font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ── Santé du flux (0.14.0, #9) ── */
|
||||
.health-table { width: 100%; border-collapse: collapse; margin: 10px 0; }
|
||||
.health-table th, .health-table td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--border, #22304a); }
|
||||
.health-slowest td { color: var(--warn, #e8a33d); font-weight: 600; }
|
||||
.zombie-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||||
|
||||
/* ── Header unifié (0.15.0, #16) ── */
|
||||
.header-actions { display: flex; gap: 6px; align-items: center; margin-left: auto; }
|
||||
.nav-burger {
|
||||
display: none; width: 40px; height: 40px; border-radius: 10px;
|
||||
background: var(--surface-2); border: 1px solid var(--border);
|
||||
color: var(--text); font-size: 1.05rem; cursor: pointer;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.crumb a { color: inherit; text-decoration: none; }
|
||||
.crumb a:hover { text-decoration: underline; }
|
||||
@media (max-width: 900px) {
|
||||
.nav-burger { display: inline-flex; }
|
||||
.crumb { display: none; }
|
||||
}
|
||||
|
||||
/* ── Barre latérale (0.16.0, #16 refonte) ── */
|
||||
body.with-sidebar { padding-left: 238px; transition: padding-left 0.18s ease; }
|
||||
body.with-sidebar.sidebar-collapsed { padding-left: 68px; }
|
||||
.sidebar {
|
||||
position: fixed; left: 0; top: 0; bottom: 0; z-index: 200;
|
||||
width: 238px; box-sizing: border-box;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
padding: 16px 12px 12px; overflow-y: auto; overflow-x: hidden;
|
||||
background: rgba(13, 16, 24, 0.96); backdrop-filter: blur(14px);
|
||||
border-right: 1px solid var(--border);
|
||||
transition: width 0.18s ease, transform 0.22s ease;
|
||||
}
|
||||
body.sidebar-collapsed .sidebar { width: 68px; }
|
||||
.side-logo { display: flex; align-items: center; gap: 10px; padding: 4px 8px 12px; color: var(--text); font-size: 1.18rem; font-weight: 700; white-space: nowrap; }
|
||||
.side-logo:hover { text-decoration: none; }
|
||||
.side-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 {
|
||||
display: flex; align-items: center; gap: 8px; cursor: pointer;
|
||||
background: transparent; border: 1px solid var(--border); border-radius: 9px;
|
||||
color: var(--text-2); padding: 6px 10px; font-size: 0.85rem; white-space: nowrap;
|
||||
}
|
||||
.side-collapse:hover { color: var(--text); }
|
||||
.side-collapse-ico { display: inline-block; transition: transform 0.18s ease; }
|
||||
body.sidebar-collapsed .side-collapse-ico { transform: rotate(180deg); }
|
||||
.side-version { color: var(--text-3); font-size: 0.8rem; }
|
||||
body.sidebar-collapsed .side-label { 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); }
|
||||
.dash-tools {
|
||||
display: flex; align-items: center; gap: 18px; flex-wrap: wrap;
|
||||
padding: 0 24px; margin: 4px 0 14px;
|
||||
}
|
||||
.dash-tool { display: inline-flex; align-items: center; gap: 8px; font-size: 0.88rem; color: var(--text-2); }
|
||||
.dash-tool select {
|
||||
background: var(--surface-2); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 8px; padding: 6px 8px;
|
||||
}
|
||||
.dash-tool-check input { width: 17px; height: 17px; accent-color: #7c5cfc; }
|
||||
.dash-view-toggle { margin-left: auto; display: inline-flex; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
||||
.dash-view-toggle button {
|
||||
background: var(--surface-1); color: var(--text-2); border: none;
|
||||
padding: 7px 14px; cursor: pointer; font-size: 0.88rem;
|
||||
}
|
||||
.dash-view-toggle button.active { background: var(--surface-3, #1d2942); color: var(--text); font-weight: 700; }
|
||||
.owner-group { grid-column: 1 / -1; }
|
||||
.owner-head {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
width: 100%; padding: 9px 12px; margin-bottom: 10px; cursor: pointer;
|
||||
background: var(--surface-1); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
font-size: 0.95rem; font-weight: 700;
|
||||
}
|
||||
.owner-head:hover { border-color: var(--accent, #58a6ff); }
|
||||
.owner-head .owner-chevron { transition: transform 0.2s ease; font-size: 0.7rem; }
|
||||
.owner-head[aria-expanded="false"] .owner-chevron { transform: rotate(-90deg); }
|
||||
.owner-grid { padding: 0 !important; }
|
||||
.card-menu-wrap { position: relative; }
|
||||
.card-menu-btn {
|
||||
background: transparent; border: none; cursor: pointer;
|
||||
color: var(--text-2); font-size: 1.15rem; font-weight: 800;
|
||||
padding: 2px 7px; border-radius: 8px; line-height: 1;
|
||||
}
|
||||
.card-menu-btn:hover { background: var(--surface-2); color: var(--text); }
|
||||
.card-menu {
|
||||
position: absolute; right: 0; top: calc(100% + 6px); z-index: 60;
|
||||
min-width: 210px; display: flex; flex-direction: column; gap: 2px;
|
||||
background: var(--surface-1); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 6px;
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.card-menu[hidden] { display: none !important; }
|
||||
.card-menu a {
|
||||
display: flex; align-items: center; min-height: 32px;
|
||||
padding: 8px 10px; border-radius: 8px;
|
||||
color: var(--text-2); font-size: 0.92rem;
|
||||
}
|
||||
.card-menu a:hover { background: var(--surface-2); color: var(--text); text-decoration: none; }
|
||||
.proj-table {
|
||||
width: calc(100% - 48px); margin: 0 24px 18px;
|
||||
border-collapse: collapse; font-size: 0.93rem;
|
||||
background: var(--surface-1); border: 1px solid var(--border);
|
||||
border-radius: 12px; overflow: hidden;
|
||||
}
|
||||
.proj-table th, .proj-table td {
|
||||
text-align: left; padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.proj-table thead th { color: var(--text-2); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
.proj-table th.sortable { cursor: pointer; }
|
||||
.proj-table th.sortable:hover { color: var(--text); }
|
||||
.proj-table th[data-dir="asc"]::after { content: " ↑"; }
|
||||
.proj-table th[data-dir="desc"]::after { content: " ↓"; }
|
||||
.proj-table tbody tr:hover { background: var(--surface-2); }
|
||||
@media (max-width: 900px) {
|
||||
.proj-table { display: block; overflow-x: auto; }
|
||||
.dash-view-toggle { margin-left: 0; }
|
||||
}
|
||||
|
||||
/* ── 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; }
|
||||
|
||||
+31
-3
@@ -397,9 +397,37 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
for (const c of (data.commits || []).slice(0, 8)) {
|
||||
parts.push(`⑂ <a href="${c.html_url}" target="_blank" rel="noopener"><code>${c.sha}</code></a> ${c.message}${c.date ? ' · ' + c.date : ''}`);
|
||||
}
|
||||
box.innerHTML = parts.length
|
||||
? '<h3 class="detail-comments-title">🧬 Code lié</h3>' + parts.map(p => `<div class="code-line">${p}</div>`).join('')
|
||||
: '';
|
||||
const hasBranch = (data.branches || []).length > 0;
|
||||
if (!hasBranch && data.suggested_branch) {
|
||||
parts.push(`🌿 <input id="branch-name" class="branch-input" value="${data.suggested_branch}" size="36" spellcheck="false"> <button type="button" class="btn-sm" id="branch-create">Créer la branche</button>`);
|
||||
}
|
||||
box.innerHTML = '<h3 class="detail-comments-title">🧬 Code lié</h3>' + (
|
||||
parts.length
|
||||
? parts.map(p => `<div class="code-line">${p}</div>`).join('')
|
||||
: '<div class="code-line">Aucun élément lié pour l\'instant.</div>'
|
||||
);
|
||||
const createBtn = document.getElementById('branch-create');
|
||||
if (createBtn) {
|
||||
createBtn.addEventListener('click', async () => {
|
||||
const name = (document.getElementById('branch-name') || {}).value || '';
|
||||
const form = new FormData();
|
||||
form.append('name', name);
|
||||
form.append('csrf_token', csrfToken);
|
||||
createBtn.disabled = true;
|
||||
try {
|
||||
const r2 = await fetch(`/api/issues/${owner}/${repo}/${number}/branch`, { method: 'POST', body: form });
|
||||
const body = await r2.json().catch(() => ({}));
|
||||
if (!r2.ok || !body.ok) throw new Error(body.error || ('HTTP ' + r2.status));
|
||||
showToast(body.exists
|
||||
? `🌿 La branche ${body.branch} existait déjà`
|
||||
: `🌿 Branche ${body.branch} créée depuis ${body.from} — carte en « En cours »`);
|
||||
loadCodeLinks(repoFull, number);
|
||||
} catch (err) {
|
||||
createBtn.disabled = false;
|
||||
showToast('❌ Branche non créée : ' + err.message, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (err) { /* le code est un bonus, jamais bloquant */ }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Kanbante — dashboard display tools (issues #21, #22, #23).
|
||||
* Sort + group-by-owner for the card grids, sortable dense tables,
|
||||
* and the per-card quick-actions menu. Preferences live in
|
||||
* localStorage; the server markup stays the single source.
|
||||
*/
|
||||
(function () {
|
||||
var LS_SORT = 'kanbante-dash-sort';
|
||||
var LS_GROUP = 'kanbante-dash-group';
|
||||
var LS_OWNERS = 'kanbante-dash-owners-closed';
|
||||
|
||||
function lsGet(key, fallback) {
|
||||
try {
|
||||
var v = localStorage.getItem(key);
|
||||
return v === null ? fallback : v;
|
||||
} catch (e) { return fallback; }
|
||||
}
|
||||
function lsSet(key, value) {
|
||||
try { localStorage.setItem(key, value); } catch (e) { /* non-fatal */ }
|
||||
}
|
||||
|
||||
/* ── Tri des grilles de cartes ─────────────────────────── */
|
||||
var sortSelect = document.getElementById('dash-sort');
|
||||
var grids = ['grid-active', 'grid-inactive', 'grid-archived']
|
||||
.map(function (id) { return document.getElementById(id); })
|
||||
.filter(Boolean);
|
||||
|
||||
function cardKey(card, mode) {
|
||||
if (mode === 'name') return (card.getAttribute('data-name') || '').toLowerCase();
|
||||
if (mode === 'open') return -(parseInt(card.getAttribute('data-open') || '0', 10));
|
||||
if (mode === 'updated') return card.getAttribute('data-updated') || '';
|
||||
if (mode === 'activity') return card.getAttribute('data-activity') || '';
|
||||
return null; // 'default' : ordre serveur, on ne touche à rien
|
||||
}
|
||||
function sortCards(container, mode) {
|
||||
var cards = Array.prototype.slice.call(
|
||||
container.querySelectorAll('.project-card'));
|
||||
if (mode === 'default' || !cards.length) return;
|
||||
cards.sort(function (a, b) {
|
||||
var ka = cardKey(a, mode), kb = cardKey(b, mode);
|
||||
if (ka < kb) return mode === 'name' ? -1 : 1;
|
||||
if (ka > kb) return mode === 'name' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
cards.forEach(function (c) { container.appendChild(c); });
|
||||
}
|
||||
function applySort() {
|
||||
var mode = sortSelect ? sortSelect.value : 'default';
|
||||
lsSet(LS_SORT, mode);
|
||||
grids.forEach(function (grid) {
|
||||
if (groupToggle && groupToggle.checked) {
|
||||
grid.querySelectorAll('.owner-grid').forEach(function (og) {
|
||||
sortCards(og, mode);
|
||||
});
|
||||
} else {
|
||||
sortCards(grid, mode);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Regroupement par propriétaire ─────────────────────── */
|
||||
var groupToggle = document.getElementById('dash-group');
|
||||
function closedOwners() {
|
||||
try { return JSON.parse(lsGet(LS_OWNERS, '[]')); } catch (e) { return []; }
|
||||
}
|
||||
function setOwnerClosed(owner, closed) {
|
||||
var list = closedOwners().filter(function (o) { return o !== owner; });
|
||||
if (closed) list.push(owner);
|
||||
lsSet(LS_OWNERS, JSON.stringify(list));
|
||||
}
|
||||
function groupGrid(grid) {
|
||||
var cards = Array.prototype.slice.call(
|
||||
grid.querySelectorAll(':scope > .project-card'));
|
||||
if (!cards.length) return;
|
||||
var byOwner = {};
|
||||
cards.forEach(function (c) {
|
||||
var o = c.getAttribute('data-owner') || '—';
|
||||
(byOwner[o] = byOwner[o] || []).push(c);
|
||||
});
|
||||
Object.keys(byOwner).sort().forEach(function (owner) {
|
||||
var wrap = document.createElement('div');
|
||||
wrap.className = 'owner-group';
|
||||
var head = document.createElement('button');
|
||||
head.type = 'button';
|
||||
head.className = 'owner-head';
|
||||
var closed = closedOwners().indexOf(owner) >= 0;
|
||||
head.setAttribute('aria-expanded', closed ? 'false' : 'true');
|
||||
head.innerHTML = '<span>🗂️ ' + owner + ' (' + byOwner[owner].length + ')</span><span class="owner-chevron">▾</span>';
|
||||
var body = document.createElement('div');
|
||||
body.className = 'project-grid owner-grid';
|
||||
if (closed) body.hidden = true;
|
||||
head.addEventListener('click', function () {
|
||||
body.hidden = !body.hidden;
|
||||
head.setAttribute('aria-expanded', body.hidden ? 'false' : 'true');
|
||||
setOwnerClosed(owner, body.hidden);
|
||||
});
|
||||
byOwner[owner].forEach(function (c) { body.appendChild(c); });
|
||||
wrap.appendChild(head);
|
||||
wrap.appendChild(body);
|
||||
grid.appendChild(wrap);
|
||||
});
|
||||
}
|
||||
function ungroupGrid(grid) {
|
||||
grid.querySelectorAll('.owner-group').forEach(function (wrap) {
|
||||
var body = wrap.querySelector('.owner-grid');
|
||||
Array.prototype.slice.call(body.querySelectorAll('.project-card'))
|
||||
.forEach(function (c) { grid.appendChild(c); });
|
||||
wrap.remove();
|
||||
});
|
||||
}
|
||||
function applyGrouping() {
|
||||
var on = !!(groupToggle && groupToggle.checked);
|
||||
lsSet(LS_GROUP, on ? '1' : '0');
|
||||
grids.forEach(function (grid) {
|
||||
ungroupGrid(grid);
|
||||
if (on) groupGrid(grid);
|
||||
});
|
||||
applySort();
|
||||
}
|
||||
|
||||
if (sortSelect) {
|
||||
sortSelect.value = lsGet(LS_SORT, 'default');
|
||||
sortSelect.addEventListener('change', applySort);
|
||||
}
|
||||
if (groupToggle) {
|
||||
groupToggle.checked = lsGet(LS_GROUP, '0') === '1';
|
||||
groupToggle.addEventListener('change', applyGrouping);
|
||||
}
|
||||
applyGrouping(); // applique regroupement + tri mémorisés
|
||||
|
||||
/* ── Menu ⋯ d'actions rapides (#23) ────────────────────── */
|
||||
function closeMenus() {
|
||||
document.querySelectorAll('.card-menu').forEach(function (m) {
|
||||
m.hidden = true;
|
||||
});
|
||||
document.querySelectorAll('[data-menu-btn]').forEach(function (b) {
|
||||
b.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
}
|
||||
document.querySelectorAll('[data-menu-btn]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
var menu = btn.parentElement.querySelector('.card-menu');
|
||||
var willOpen = menu.hidden;
|
||||
closeMenus();
|
||||
if (willOpen) {
|
||||
menu.hidden = false;
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
});
|
||||
});
|
||||
document.addEventListener('click', closeMenus);
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') closeMenus();
|
||||
});
|
||||
|
||||
/* Le tri des tableaux denses (#22) vit dans tablesort.js,
|
||||
partagé avec /forecast et /health (0.19.0). */
|
||||
})();
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 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 */ }
|
||||
});
|
||||
}
|
||||
|
||||
/* Accordion sections: the closed set lives in body classes
|
||||
(applied pre-paint by the bootstrap script in _header.html)
|
||||
and is persisted here on every toggle. */
|
||||
var groupBtns = document.querySelectorAll('[data-group-btn]');
|
||||
function persistClosedGroups() {
|
||||
var closed = [];
|
||||
groupBtns.forEach(function (btn) {
|
||||
if (document.body.classList.contains('nav-closed-' + btn.getAttribute('data-group-btn'))) {
|
||||
closed.push(btn.getAttribute('data-group-btn'));
|
||||
}
|
||||
});
|
||||
try {
|
||||
localStorage.setItem('kanbante-nav-closed', JSON.stringify(closed));
|
||||
} catch (e) { /* non-fatal */ }
|
||||
}
|
||||
function syncGroupBtn(btn) {
|
||||
var closed = document.body.classList.contains(
|
||||
'nav-closed-' + btn.getAttribute('data-group-btn'));
|
||||
btn.setAttribute('aria-expanded', closed ? 'false' : 'true');
|
||||
}
|
||||
groupBtns.forEach(function (btn) {
|
||||
syncGroupBtn(btn);
|
||||
btn.addEventListener('click', function () {
|
||||
document.body.classList.toggle(
|
||||
'nav-closed-' + btn.getAttribute('data-group-btn'));
|
||||
syncGroupBtn(btn);
|
||||
persistClosedGroups();
|
||||
});
|
||||
});
|
||||
/* The section holding the current page is always opened. */
|
||||
var activeLink = document.querySelector('.sidebar .side-link.active');
|
||||
if (activeLink) {
|
||||
var section = activeLink.closest('.side-section');
|
||||
if (section) {
|
||||
var key = section.getAttribute('data-group');
|
||||
if (document.body.classList.contains('nav-closed-' + key)) {
|
||||
document.body.classList.remove('nav-closed-' + key);
|
||||
groupBtns.forEach(syncGroupBtn);
|
||||
persistClosedGroups();
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -17,6 +17,7 @@
|
||||
{ icon: '🔮', label: 'Prévisions — on finit quand ?', href: '/forecast' },
|
||||
{ icon: '🗣️', label: 'Standup automatique — hier, aujourd\'hui, blocages', href: '/standup' },
|
||||
{ icon: '⚙️', label: 'Règles d\'automatisation', href: '/rules' },
|
||||
{ icon: '🩺', label: 'Santé du flux — goulots et zombies', href: '/health' },
|
||||
{ icon: '👤', label: 'Mes issues', href: '/my' },
|
||||
{ icon: '📰', label: 'Activité — journal des événements', href: '/activity' },
|
||||
{ icon: '📊', label: 'Briefing quotidien', href: '/briefing' },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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');
|
||||
var dir = th.getAttribute('data-dir') === 'asc' ? 'desc' : 'asc';
|
||||
table.querySelectorAll('th').forEach(function (h) {
|
||||
h.removeAttribute('data-dir');
|
||||
});
|
||||
th.setAttribute('data-dir', dir);
|
||||
var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
|
||||
rows.sort(function (a, b) {
|
||||
var va = a.getAttribute('data-' + key) || '';
|
||||
var vb = b.getAttribute('data-' + key) || '';
|
||||
var na = parseFloat(va), nb = parseFloat(vb);
|
||||
var cmp;
|
||||
if (!isNaN(na) && !isNaN(nb) && va !== '' && vb !== '') {
|
||||
cmp = na - nb;
|
||||
} else {
|
||||
cmp = va < vb ? -1 : (va > vb ? 1 : 0);
|
||||
}
|
||||
return dir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
rows.forEach(function (r) { tbody.appendChild(r); });
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
+19
-5
@@ -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"),
|
||||
@@ -224,11 +227,13 @@ class FakeGitea:
|
||||
self.label_ops.append(("remove", number, label["name"]))
|
||||
|
||||
async def list_branches(self, owner, repo):
|
||||
return [
|
||||
{"name": "main"},
|
||||
{"name": "feature/1-tache-un"},
|
||||
{"name": "issue-5-focus"},
|
||||
]
|
||||
return [{"name": n} for n in self.branches]
|
||||
|
||||
async def create_branch(self, owner, repo, new_branch, old_branch):
|
||||
if new_branch in self.branches:
|
||||
raise RuntimeError("branch exists")
|
||||
self.branches.append(new_branch)
|
||||
return {"name": new_branch}
|
||||
|
||||
async def list_pulls(self, owner, repo, state="all"):
|
||||
return [
|
||||
@@ -266,6 +271,15 @@ class FakeGitea:
|
||||
},
|
||||
]
|
||||
|
||||
async def list_starred(self):
|
||||
return [r for r in self.repos if r["full_name"] in self.starred]
|
||||
|
||||
async def set_starred(self, owner, repo, starred):
|
||||
if starred:
|
||||
self.starred.add(f"{owner}/{repo}")
|
||||
else:
|
||||
self.starred.discard(f"{owner}/{repo}")
|
||||
|
||||
async def get_milestones(self, owner, repo, state="open"):
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,91 @@
|
||||
"""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 & 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-closed" in js.text # accordéons persistés
|
||||
assert "data-group-btn" in js.text
|
||||
|
||||
|
||||
def test_wall_more_cards_line(client, fake_gitea, monkeypatch):
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user