Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03fa1eb44e | ||
|
|
8e0c7b8f09 | ||
|
|
4c5d1dee23 | ||
|
|
cdabc77ccd | ||
|
|
fbe58bbf16 |
@@ -1,5 +1,35 @@
|
||||
# Changelog
|
||||
|
||||
## 0.12.0 — 2026-10-11
|
||||
|
||||
### Nouveautés
|
||||
- **Focus du jour** (issue #5) : le rituel des 3 cartes. Table
|
||||
`today_picks` ; bouton 📌 sur chaque carte du board (et sur les
|
||||
candidates) ; page `/today` calme — picks du jour avec leur
|
||||
colonne en direct, candidates de l'encours, **reprises d'hier
|
||||
non terminées** reproposées, récapitulatif « ça a bougé
|
||||
aujourd'hui » tiré du journal. Le briefing du matin inclut les
|
||||
sections « Focus du jour » et « À reprendre d'hier ».
|
||||
`POST /api/today/pin` (max 3, idempotent) et `/unpin`.
|
||||
- **Prévisions** (issue #7) : `app/forecast.py` — fermetures par
|
||||
semaine depuis le journal (8 semaines), débit moy/min/max sur
|
||||
les 4 dernières semaines complètes, projection en **fourchette**
|
||||
(jamais un faux chiffre précis) et silence explicite sans
|
||||
historique. Affichées sur le dashboard (épuisement du backlog),
|
||||
dans le bandeau milestone (avec alerte si l'ETA dépasse
|
||||
l'échéance) et sur `/forecast` (sparkline SVG inline, verdict
|
||||
par milestone). `/api/metrics` expose `weekly_closures` et
|
||||
`throughput_4w`.
|
||||
- **Récit de sprint** (issue #4) : `app/sprint.py` + page
|
||||
`/sprint/{owner}/{repo}?milestone=` — le bilan markdown du
|
||||
milestone écrit par le journal : livré (avec temps de cycle par
|
||||
carte), cartes les plus lentes, reporté avec colonnes, chiffres
|
||||
de la période ; boutons **Copier** et **Télécharger .md**,
|
||||
endpoint `GET /api/sprint/...` en JSON, lien « 📖 Récit de
|
||||
sprint » dans le bandeau milestone du board.
|
||||
|
||||
11 tests ajoutés (147 au total).
|
||||
|
||||
## 0.11.0 — 2026-10-11
|
||||
|
||||
### Nouveautés
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
- ⏳ **Vieillissement + limites WIP** — badge d'ancienneté par colonne et limites souples configurables
|
||||
- 🎯 **Vue Focus** — tout ce qui est En cours / en QA / En révision, tous projets confondus (`/focus`)
|
||||
- 🖥️ **Wallboard** — mode TV plein écran (`/wall`) : rotation automatique entre les projets actifs, cartes en vol et compteurs en très grands caractères, rafraîchi en direct ; `?repo=Proprietaire/depot` fige un seul projet
|
||||
- 🌅 **Focus du jour** — `/today` : jusqu'à 3 cartes épinglées (bouton 📌 sur les cartes), candidates de l'encours, reprises d'hier non terminées, récapitulatif du soir ; intégrées au briefing
|
||||
- 🔮 **Prévisions** — débit réel du journal (fermetures/semaine), date d'épuisement du backlog et ETA des milestones en fourchette honnête, sur le dashboard, le bandeau milestone et `/forecast` (sparkline SVG)
|
||||
- 📖 **Récit de sprint** — le bilan markdown d'un milestone (livré, cycles, plus lentes, reporté) généré depuis le journal, page `/sprint/...` + copie/téléchargement en un clic
|
||||
- 📥 **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
|
||||
@@ -170,6 +173,10 @@ kanbante/
|
||||
| GET | `/api/palette` | Données de la palette Ctrl+K (projets + recherche d'issues `?q=`) |
|
||||
| GET | `/api/board/{owner}/{repo}` | Board en JSON + fragments HTML des cartes (base du board dynamique, `?milestone=` accepté) |
|
||||
| GET | `/triage` · `/triage/{owner}/{repo}` | Mode Triage : sélecteur de projet puis session clavier sur le backlog |
|
||||
| GET | `/today` | Focus du jour : les 3 cartes épinglées, candidates, reprises d'hier |
|
||||
| POST | `/api/today/pin` · `/api/today/unpin` | Épingler / retirer une carte du Focus du jour (max 3) |
|
||||
| GET | `/forecast` | Prévisions : débit hebdo, ETA backlog et milestones par projet |
|
||||
| GET | `/sprint/{owner}/{repo}` · `/api/sprint/{owner}/{repo}` | Récit de sprint d'un milestone (`?milestone=`), page rendue ou JSON markdown |
|
||||
| POST | `/api/issues/{o}/{r}/{n}/assign-self` | S'assigner une issue (utilisateur du jeton) |
|
||||
| GET | `/digest` | Digest hebdo : aperçu, statut des cibles, approbation d'envoi |
|
||||
| POST | `/digest/send` | Livrer le digest (cible `obsigate` ou `webhook`) — approbation explicite |
|
||||
|
||||
@@ -27,6 +27,22 @@ def format_briefing_text(data: dict) -> str:
|
||||
if activity.get("avg_cycle_days") is not None:
|
||||
lines.append(f"⏱️ Temps de cycle moyen (30 j) : {activity['avg_cycle_days']} j.")
|
||||
|
||||
if data.get("today_picks"):
|
||||
lines.append("\n🌅 Focus du jour :")
|
||||
for pick in data["today_picks"]:
|
||||
mark = "✅" if pick["state"] == "closed" else "📌"
|
||||
lines.append(
|
||||
f" {mark} {pick['repo']} #{pick['number']} « {pick['title']} »"
|
||||
f" — {pick['column_label']}"
|
||||
)
|
||||
if data.get("carry_over"):
|
||||
lines.append("\n↩️ À reprendre d'hier (non terminées) :")
|
||||
for pick in data["carry_over"]:
|
||||
lines.append(
|
||||
f" • {pick['repo']} #{pick['number']} « {pick['title']} »"
|
||||
f" — {pick['column_label']}"
|
||||
)
|
||||
|
||||
lines.append(f"\n🎯 En cours / en révision : {data['focus_total']} carte(s)")
|
||||
for group in data["focus_groups"][:6]:
|
||||
lines.append(f" • {group['full_name']} : {group['count']}")
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
APP_VERSION = "0.11.0"
|
||||
APP_VERSION = "0.12.0"
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
|
||||
+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.11.0"},
|
||||
"clientInfo": {"name": "kanbante", "version": "0.12.0"},
|
||||
},
|
||||
req_id=1,
|
||||
)
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
"""Forecasts (roadmap #7): honest ETAs from the event journal.
|
||||
|
||||
Throughput = issues closed per week, counted from board_events.
|
||||
Projections are deliberately given as a RANGE (best/worst weekly
|
||||
rate over the last 4 complete weeks) rather than a fake-precise
|
||||
date, and are withheld entirely when the journal has no closures
|
||||
to learn from — "not enough history" is an answer, too.
|
||||
"""
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
MONTHS_FR = [
|
||||
"janvier", "février", "mars", "avril", "mai", "juin",
|
||||
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
|
||||
]
|
||||
MONTHS_FR_SHORT = [
|
||||
"janv.", "févr.", "mars", "avr.", "mai", "juin",
|
||||
"juil.", "août", "sept.", "oct.", "nov.", "déc.",
|
||||
]
|
||||
|
||||
|
||||
def french_date(d: date, short: bool = False) -> str:
|
||||
months = MONTHS_FR_SHORT if short else MONTHS_FR
|
||||
return f"{d.day} {months[d.month - 1]}"
|
||||
|
||||
|
||||
def _event_date(event: dict) -> date | None:
|
||||
raw = str(event.get("created_at") or "")
|
||||
try:
|
||||
return datetime.strptime(raw[:10], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def weekly_closures(events: list[dict], weeks: int = 8,
|
||||
today: date | None = None) -> list[dict]:
|
||||
"""Closure counts per ISO week, oldest first, current week last."""
|
||||
today = today or date.today()
|
||||
current_monday = today - timedelta(days=today.weekday())
|
||||
buckets: list[dict] = []
|
||||
for i in range(weeks - 1, -1, -1):
|
||||
start = current_monday - timedelta(weeks=i)
|
||||
buckets.append({"start": start, "count": 0})
|
||||
by_start = {b["start"]: b for b in buckets}
|
||||
for event in events:
|
||||
if event.get("event_type") != "closed":
|
||||
continue
|
||||
day = _event_date(event)
|
||||
if day is None:
|
||||
continue
|
||||
monday = day - timedelta(days=day.weekday())
|
||||
if monday in by_start:
|
||||
by_start[monday]["count"] += 1
|
||||
return buckets
|
||||
|
||||
|
||||
def throughput_stats(weekly: list[dict], last: int = 4) -> dict | None:
|
||||
"""avg/min/max closures per week over the last COMPLETE weeks.
|
||||
|
||||
The current (partial) week is excluded: on Monday morning it
|
||||
would crush the average for no reason. Returns None when the
|
||||
complete weeks contain no closure at all.
|
||||
"""
|
||||
complete = weekly[:-1][-last:] if len(weekly) > 1 else []
|
||||
if not complete:
|
||||
return None
|
||||
counts = [b["count"] for b in complete]
|
||||
if sum(counts) == 0:
|
||||
return None
|
||||
return {
|
||||
"avg": sum(counts) / len(counts),
|
||||
"min": min(counts),
|
||||
"max": max(counts),
|
||||
"weeks": len(counts),
|
||||
}
|
||||
|
||||
|
||||
def estimate(remaining: int, weekly: list[dict],
|
||||
today: date | None = None) -> dict | None:
|
||||
"""Projected completion for `remaining` items at the real pace.
|
||||
|
||||
Returns {"done": True} when nothing remains, None when there is
|
||||
no throughput to project from, else dates: central (avg rate),
|
||||
earliest (best week), latest (worst nonzero week, if any).
|
||||
"""
|
||||
today = today or date.today()
|
||||
if remaining <= 0:
|
||||
return {"done": True}
|
||||
stats = throughput_stats(weekly)
|
||||
if stats is None:
|
||||
return None
|
||||
|
||||
def at_rate(rate: float) -> date:
|
||||
days = remaining / rate * 7
|
||||
return today + timedelta(days=max(1, round(days)))
|
||||
|
||||
result = {
|
||||
"done": False,
|
||||
"avg_per_week": round(stats["avg"], 1),
|
||||
"central": at_rate(stats["avg"]),
|
||||
"earliest": at_rate(stats["max"]) if stats["max"] > 0 else None,
|
||||
"latest": at_rate(stats["min"]) if stats["min"] > 0 else None,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def eta_label(est: dict | None, done_text: str = "terminé") -> str | None:
|
||||
"""One-line French label for banners and cards (or None)."""
|
||||
if est is None:
|
||||
return None
|
||||
if est.get("done"):
|
||||
return f"✅ {done_text}"
|
||||
label = f"à ce rythme ≈ {french_date(est['central'], short=True)}"
|
||||
if est.get("earliest") and est.get("latest"):
|
||||
label += (
|
||||
f" (fourchette {french_date(est['earliest'], short=True)}"
|
||||
f" – {french_date(est['latest'], short=True)})"
|
||||
)
|
||||
return label
|
||||
+416
-1
@@ -36,22 +36,27 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from app.config import APP_VERSION, get_settings
|
||||
from app.gitea_client import GiteaClient
|
||||
from app import digest as digest_service
|
||||
from app import forecast as forecast_service
|
||||
from app import sprint as sprint_service
|
||||
from app.briefing import format_briefing_text, ntfy_configured, send_ntfy
|
||||
from app.capture import ntfy_capture_loop, parse_capture_text
|
||||
from app.models import (
|
||||
KANBAN_COLUMNS,
|
||||
add_pick,
|
||||
column_entry_times,
|
||||
get_board_state,
|
||||
get_events,
|
||||
get_kv,
|
||||
get_metrics,
|
||||
get_notes,
|
||||
get_picks,
|
||||
get_stats,
|
||||
init_db,
|
||||
log_event,
|
||||
move_issue,
|
||||
ping_db,
|
||||
reconcile_board_state,
|
||||
remove_pick,
|
||||
set_column_order,
|
||||
set_kv,
|
||||
set_notes,
|
||||
@@ -457,6 +462,10 @@ 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:
|
||||
repo_info["forecast_label"] = _repo_forecast_label(
|
||||
repo_info["full_name"], repo_info.get("open_issues_count") or 0
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"dashboard.html",
|
||||
@@ -494,6 +503,7 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""):
|
||||
milestones: list[dict] = []
|
||||
selected_milestone = None
|
||||
milestone_progress = None
|
||||
milestone_forecast = None
|
||||
display_issues = open_issues
|
||||
try:
|
||||
milestones = await gitea.get_milestones(owner, repo)
|
||||
@@ -527,6 +537,22 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""):
|
||||
"due_on": (selected_milestone or {}).get("due_on"),
|
||||
"title": (selected_milestone or {}).get("title", milestone),
|
||||
}
|
||||
# Forecast (issue #7): ETA of the remaining milestone work
|
||||
# at this repo's real closure pace.
|
||||
remaining = total - closed_count
|
||||
weekly = forecast_service.weekly_closures(
|
||||
get_events(repo_full_name=full_name, limit=2000)
|
||||
)
|
||||
est = forecast_service.estimate(remaining, weekly)
|
||||
if est is None:
|
||||
milestone_forecast = "pas assez d'historique pour prévoir"
|
||||
else:
|
||||
milestone_forecast = forecast_service.eta_label(est)
|
||||
due_raw = milestone_progress["due_on"]
|
||||
if due_raw and not est.get("done"):
|
||||
due_date = _parse_datetime(str(due_raw))
|
||||
if due_date and est["central"] > due_date.date():
|
||||
milestone_forecast += " — ⚠️ après l'échéance"
|
||||
|
||||
board_state = get_board_state(full_name)
|
||||
columns = _merge_issues_with_board(display_issues, board_state)
|
||||
@@ -561,6 +587,7 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""):
|
||||
milestones=milestones,
|
||||
selected_milestone=selected_milestone,
|
||||
milestone_progress=milestone_progress,
|
||||
milestone_forecast=milestone_forecast,
|
||||
milestone_param=milestone,
|
||||
)
|
||||
|
||||
@@ -788,6 +815,354 @@ async def triage_session(request: Request, owner: str, repo: str):
|
||||
)
|
||||
|
||||
|
||||
def _today_str(offset_days: int = 0) -> str:
|
||||
return (datetime.now() + timedelta(days=offset_days)).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
async def _enrich_pick(pick: dict) -> dict | None:
|
||||
"""A today pick + its live issue data and current column."""
|
||||
owner, _, name = pick["repo_full_name"].partition("/")
|
||||
try:
|
||||
issue = await gitea.get_issue(owner, name, pick["issue_number"])
|
||||
except Exception:
|
||||
return None
|
||||
if issue.get("state") == "closed":
|
||||
column = "done"
|
||||
else:
|
||||
placement = {
|
||||
card["issue_number"]: col
|
||||
for col, cards in get_board_state(pick["repo_full_name"]).items()
|
||||
for card in cards
|
||||
}
|
||||
column = placement.get(pick["issue_number"], "backlog")
|
||||
return {
|
||||
"repo": pick["repo_full_name"],
|
||||
"number": pick["issue_number"],
|
||||
"title": issue.get("title", ""),
|
||||
"state": issue.get("state", "open"),
|
||||
"column": column,
|
||||
"column_label": COLUMN_LABELS[column],
|
||||
"html_url": issue.get("html_url", ""),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/today/pin")
|
||||
async def api_today_pin(
|
||||
request: Request,
|
||||
repo_full_name: str = Form(...),
|
||||
issue_number: int = Form(...),
|
||||
csrf_token: str = Form(""),
|
||||
):
|
||||
"""Pin a card in today's focus (max 3, issue #5)."""
|
||||
if not _csrf_valid(request, csrf_token):
|
||||
return _csrf_error()
|
||||
if _split_full_name(repo_full_name) is None:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Invalid repo_full_name"}, status_code=400
|
||||
)
|
||||
today = _today_str()
|
||||
picks = get_picks(today)
|
||||
if any(
|
||||
p["repo_full_name"] == repo_full_name and p["issue_number"] == issue_number
|
||||
for p in picks
|
||||
):
|
||||
return {"ok": True, "already": True, "count": len(picks)}
|
||||
if len(picks) >= 3:
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "Focus du jour complet (3 cartes max) — "
|
||||
"retire d'abord une carte sur /today.",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
add_pick(today, repo_full_name, issue_number)
|
||||
return {"ok": True, "count": len(picks) + 1}
|
||||
|
||||
|
||||
@app.post("/api/today/unpin")
|
||||
async def api_today_unpin(
|
||||
request: Request,
|
||||
repo_full_name: str = Form(...),
|
||||
issue_number: int = Form(...),
|
||||
csrf_token: str = Form(""),
|
||||
):
|
||||
"""Remove a card from today's focus."""
|
||||
if not _csrf_valid(request, csrf_token):
|
||||
return _csrf_error()
|
||||
remove_pick(_today_str(), repo_full_name, issue_number)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/today", response_class=HTMLResponse)
|
||||
async def today_page(request: Request):
|
||||
"""Focus of the day (issue #5): up to 3 pinned cards, calmly."""
|
||||
today = _today_str()
|
||||
picks = []
|
||||
for pick in get_picks(today):
|
||||
enriched = await _enrich_pick(pick)
|
||||
if enriched:
|
||||
picks.append(enriched)
|
||||
picked_keys = {(p["repo"], p["number"]) for p in picks}
|
||||
|
||||
carry_over = []
|
||||
for pick in get_picks(_today_str(-1)):
|
||||
if (pick["repo_full_name"], pick["issue_number"]) in picked_keys:
|
||||
continue
|
||||
enriched = await _enrich_pick(pick)
|
||||
if enriched and enriched["state"] != "closed":
|
||||
carry_over.append(enriched)
|
||||
|
||||
candidates = []
|
||||
if len(picks) < 3:
|
||||
for group in await _collect_focus_groups():
|
||||
for card in group["cards"]:
|
||||
key = (group["full_name"], card["issue"]["number"])
|
||||
if key in picked_keys:
|
||||
continue
|
||||
candidates.append(
|
||||
{
|
||||
"repo": group["full_name"],
|
||||
"number": card["issue"]["number"],
|
||||
"title": card["issue"].get("title", ""),
|
||||
"column_label": COLUMN_LABELS[card["column"]],
|
||||
}
|
||||
)
|
||||
candidates = candidates[:8]
|
||||
|
||||
recap = []
|
||||
picked_numbers = {(p["repo"], p["number"]) for p in picks}
|
||||
for event in get_events(limit=300):
|
||||
if not str(event.get("created_at") or "").startswith(today):
|
||||
continue
|
||||
if (event["repo_full_name"], event["issue_number"]) in picked_numbers:
|
||||
recap.append(event)
|
||||
return render(
|
||||
request,
|
||||
"today.html",
|
||||
date_label=datetime.now().strftime("%A %d %B %Y"),
|
||||
picks=picks,
|
||||
carry_over=carry_over,
|
||||
candidates=candidates,
|
||||
recap=recap,
|
||||
column_labels=COLUMN_LABELS,
|
||||
)
|
||||
|
||||
|
||||
async def _sprint_markdown(owner: str, repo: str, milestone: str):
|
||||
"""Assemble the sprint story; (title, markdown) or None."""
|
||||
full_name = f"{owner}/{repo}"
|
||||
try:
|
||||
milestones = await gitea.get_milestones(owner, repo)
|
||||
except Exception:
|
||||
milestones = []
|
||||
selected = next(
|
||||
(
|
||||
m for m in milestones
|
||||
if str(m.get("id")) == milestone or m.get("title") == milestone
|
||||
),
|
||||
None,
|
||||
)
|
||||
if selected is None:
|
||||
return None
|
||||
|
||||
def _in_ms(issue: dict) -> bool:
|
||||
ms = issue.get("milestone") or {}
|
||||
return bool(ms) and (
|
||||
str(ms.get("id")) == str(selected.get("id"))
|
||||
or ms.get("title") == selected.get("title")
|
||||
)
|
||||
|
||||
all_items = await gitea.get_repo_issues(owner, repo, state="all")
|
||||
scoped = [i for i in all_items if _is_issue(i) and _in_ms(i)]
|
||||
numbers = {i["number"] for i in scoped}
|
||||
|
||||
events = [
|
||||
e for e in get_events(repo_full_name=full_name, limit=2000)
|
||||
if e["issue_number"] in numbers
|
||||
]
|
||||
first_in_progress: dict[int, object] = {}
|
||||
closed_at: dict[int, object] = {}
|
||||
counts = {"moved": 0, "commented": 0, "reopened": 0, "created": 0}
|
||||
event_dates = []
|
||||
for event in events:
|
||||
at = _parse_datetime(str(event.get("created_at") or ""))
|
||||
if at is not None:
|
||||
event_dates.append(at)
|
||||
if event["event_type"] in counts:
|
||||
counts[event["event_type"]] += 1
|
||||
if (
|
||||
event["event_type"] == "moved"
|
||||
and event.get("to_column") == "in_progress"
|
||||
):
|
||||
first_in_progress.setdefault(event["issue_number"], at)
|
||||
if event["event_type"] == "closed":
|
||||
closed_at[event["issue_number"]] = at
|
||||
|
||||
columns, _display, _state = _board_columns_for(owner, repo, all_items)
|
||||
placement = {
|
||||
card["number"]: col for col, cards in columns.items() for card in cards
|
||||
}
|
||||
|
||||
delivered, carried, cycles = [], [], []
|
||||
for issue in scoped:
|
||||
if issue.get("state") == "closed":
|
||||
cycle = None
|
||||
start = first_in_progress.get(issue["number"])
|
||||
end = closed_at.get(issue["number"])
|
||||
if start is None:
|
||||
start = _parse_datetime(str(issue.get("created_at") or ""))
|
||||
if end is None:
|
||||
end = _parse_datetime(str(issue.get("closed_at") or ""))
|
||||
if start is not None and end is not None:
|
||||
cycle = round((end - start).total_seconds() / 86400, 1)
|
||||
if cycle < 0:
|
||||
cycle = None
|
||||
else:
|
||||
cycles.append(cycle)
|
||||
delivered.append(
|
||||
{
|
||||
"number": issue["number"],
|
||||
"title": issue.get("title", ""),
|
||||
"html_url": issue.get("html_url", ""),
|
||||
"cycle_days": cycle,
|
||||
}
|
||||
)
|
||||
else:
|
||||
col = placement.get(issue["number"], "backlog")
|
||||
carried.append(
|
||||
{
|
||||
"number": issue["number"],
|
||||
"title": issue.get("title", ""),
|
||||
"column_label": COLUMN_LABELS[col],
|
||||
}
|
||||
)
|
||||
delivered.sort(key=lambda d: d["number"])
|
||||
carried.sort(key=lambda d: d["number"])
|
||||
|
||||
if event_dates:
|
||||
period = (
|
||||
f"du {min(event_dates).strftime('%Y-%m-%d')} "
|
||||
f"au {max(event_dates).strftime('%Y-%m-%d')}"
|
||||
)
|
||||
else:
|
||||
period = "aucun événement journalisé pour ce milestone"
|
||||
avg_cycle = round(sum(cycles) / len(cycles), 1) if cycles else None
|
||||
markdown = sprint_service.format_sprint_markdown(
|
||||
full_name,
|
||||
selected.get("title", milestone),
|
||||
selected.get("due_on"),
|
||||
period,
|
||||
delivered,
|
||||
carried,
|
||||
counts,
|
||||
avg_cycle,
|
||||
)
|
||||
return selected.get("title", milestone), markdown
|
||||
|
||||
|
||||
@app.get("/api/sprint/{owner}/{repo}")
|
||||
async def api_sprint(owner: str, repo: str, milestone: str = ""):
|
||||
"""The sprint story as JSON markdown (issue #4)."""
|
||||
result = await _sprint_markdown(owner, repo, milestone)
|
||||
if result is None:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Milestone introuvable"}, status_code=404
|
||||
)
|
||||
title, markdown = result
|
||||
return {"ok": True, "milestone": title, "markdown": markdown}
|
||||
|
||||
|
||||
@app.get("/sprint/{owner}/{repo}", response_class=HTMLResponse)
|
||||
async def sprint_page(request: Request, owner: str, repo: str, milestone: str = ""):
|
||||
"""Sprint story page: rendered narrative + copy/download."""
|
||||
full_name = f"{owner}/{repo}"
|
||||
result = await _sprint_markdown(owner, repo, milestone) if milestone else None
|
||||
milestones = []
|
||||
if result is None:
|
||||
try:
|
||||
milestones = await gitea.get_milestones(owner, repo)
|
||||
except Exception:
|
||||
milestones = []
|
||||
title, markdown = result if result else ("", "")
|
||||
return render(
|
||||
request,
|
||||
"sprint.html",
|
||||
full_name=full_name,
|
||||
milestone_title=title,
|
||||
markdown=markdown,
|
||||
milestones=milestones,
|
||||
milestone_param=milestone,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/forecast", response_class=HTMLResponse)
|
||||
async def forecast_page(request: Request):
|
||||
"""Forecasts (issue #7): real-pace ETAs per project + milestone."""
|
||||
repos = await gitea.search_repos()
|
||||
active, _inactive, _archived = classify_repos(repos)
|
||||
projects = []
|
||||
for repo_info in active:
|
||||
full = repo_info["full_name"]
|
||||
owner, _, name = full.partition("/")
|
||||
weekly = forecast_service.weekly_closures(
|
||||
get_events(repo_full_name=full, limit=2000)
|
||||
)
|
||||
max_count = max((b["count"] for b in weekly), default=0)
|
||||
bars = [
|
||||
{
|
||||
"label": b["start"].strftime("S%V"),
|
||||
"count": b["count"],
|
||||
"h": round(100 * b["count"] / max_count) if max_count else 0,
|
||||
}
|
||||
for b in 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),
|
||||
"remaining": remaining,
|
||||
"eta_label": forecast_service.eta_label(est),
|
||||
"milestones": milestones,
|
||||
}
|
||||
)
|
||||
return render(request, "forecast.html", projects=projects)
|
||||
|
||||
|
||||
@app.get("/focus", response_class=HTMLResponse)
|
||||
async def focus(request: Request):
|
||||
"""Cross-project view: everything in progress or in review."""
|
||||
@@ -1033,8 +1408,27 @@ async def _briefing_data() -> dict:
|
||||
|
||||
metrics_24h = get_metrics(window_days=1)
|
||||
metrics_30d = get_metrics(window_days=30)
|
||||
|
||||
# Focus of the day (issue #5): today's pins + yesterday's
|
||||
# unfinished picks, proposed again in the morning briefing.
|
||||
today_picks: list[dict] = []
|
||||
for pick in get_picks(_today_str()):
|
||||
enriched = await _enrich_pick(pick)
|
||||
if enriched:
|
||||
today_picks.append(enriched)
|
||||
picked_keys = {(p["repo"], p["number"]) for p in today_picks}
|
||||
carry_over: list[dict] = []
|
||||
for pick in get_picks(_today_str(-1)):
|
||||
if (pick["repo_full_name"], pick["issue_number"]) in picked_keys:
|
||||
continue
|
||||
enriched = await _enrich_pick(pick)
|
||||
if enriched and enriched["state"] != "closed":
|
||||
carry_over.append(enriched)
|
||||
|
||||
return {
|
||||
"date": datetime.now().strftime("%d/%m/%Y"),
|
||||
"today_picks": today_picks,
|
||||
"carry_over": carry_over,
|
||||
"focus_total": sum(len(g["cards"]) for g in focus_groups),
|
||||
"focus_groups": [
|
||||
{
|
||||
@@ -1822,7 +2216,28 @@ async def api_events(repo: str = "", limit: int = 100):
|
||||
@app.get("/api/metrics")
|
||||
async def api_metrics(repo: str = "", window: int = 30):
|
||||
"""Throughput / cycle-time metrics from the journal."""
|
||||
return {"ok": True, "metrics": get_metrics(repo or None, window)}
|
||||
metrics = get_metrics(repo or None, window)
|
||||
weekly = forecast_service.weekly_closures(
|
||||
get_events(repo_full_name=repo or None, limit=2000)
|
||||
)
|
||||
metrics["weekly_closures"] = [b["count"] for b in weekly]
|
||||
stats = forecast_service.throughput_stats(weekly)
|
||||
metrics["throughput_4w"] = stats
|
||||
return {"ok": True, "metrics": metrics}
|
||||
|
||||
|
||||
def _repo_forecast_label(repo_full_name: str, remaining: int) -> str | None:
|
||||
"""Backlog burn-down ETA label for one repo (journal-based)."""
|
||||
weekly = forecast_service.weekly_closures(
|
||||
get_events(repo_full_name=repo_full_name, limit=2000)
|
||||
)
|
||||
est = forecast_service.estimate(remaining, weekly)
|
||||
if est is None:
|
||||
return None
|
||||
if est.get("done"):
|
||||
return "✅ rien d'ouvert"
|
||||
label = forecast_service.eta_label(est)
|
||||
return f"backlog épuisé {label}" if label else None
|
||||
|
||||
|
||||
# ── Label management ───────────────────────────────────────
|
||||
|
||||
@@ -79,6 +79,13 @@ def init_db() -> None:
|
||||
CREATE INDEX IF NOT EXISTS idx_board_repo
|
||||
ON board_state(repo_full_name, column_name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS today_picks (
|
||||
pick_date TEXT NOT NULL,
|
||||
repo_full_name TEXT NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (pick_date, repo_full_name, issue_number)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS kv_store (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
@@ -239,6 +246,35 @@ def reconcile_board_state(
|
||||
return changed
|
||||
|
||||
|
||||
def get_picks(pick_date: str) -> list[dict]:
|
||||
"""The day's focus picks (issue #5), in pinning order."""
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT repo_full_name, issue_number FROM today_picks "
|
||||
"WHERE pick_date = ? ORDER BY created_at, repo_full_name, issue_number",
|
||||
(pick_date,),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def add_pick(pick_date: str, repo_full_name: str, issue_number: int) -> None:
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO today_picks "
|
||||
"(pick_date, repo_full_name, issue_number) VALUES (?, ?, ?)",
|
||||
(pick_date, repo_full_name, issue_number),
|
||||
)
|
||||
|
||||
|
||||
def remove_pick(pick_date: str, repo_full_name: str, issue_number: int) -> None:
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM today_picks WHERE pick_date = ? "
|
||||
"AND repo_full_name = ? AND issue_number = ?",
|
||||
(pick_date, repo_full_name, issue_number),
|
||||
)
|
||||
|
||||
|
||||
def get_kv(key: str) -> str | None:
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Sprint story (roadmap #4): the milestone's tale, written by the journal.
|
||||
|
||||
Nobody writes the end-of-milestone report by hand. Kanbante's
|
||||
event journal already knows what shipped, how long it took and
|
||||
what dragged — this module just turns that into clean markdown,
|
||||
ready to archive in ObsiGate.
|
||||
"""
|
||||
|
||||
|
||||
def format_sprint_markdown(
|
||||
repo: str,
|
||||
milestone_title: str,
|
||||
due_on: str | None,
|
||||
period_label: str,
|
||||
delivered: list[dict],
|
||||
carried: list[dict],
|
||||
counts: dict,
|
||||
avg_cycle_days: float | None,
|
||||
) -> str:
|
||||
lines = [f"# 📖 Récit de sprint — {repo} · {milestone_title}", ""]
|
||||
lines.append(f"*Période : {period_label}*")
|
||||
if due_on:
|
||||
lines.append(f"*Échéance du milestone : {due_on[:10]}*")
|
||||
lines.append("")
|
||||
|
||||
lines.append(f"## ✅ Livré ({len(delivered)})")
|
||||
if delivered:
|
||||
for item in delivered:
|
||||
cycle = (
|
||||
f" — ⏱️ {item['cycle_days']} j de cycle"
|
||||
if item.get("cycle_days") is not None
|
||||
else ""
|
||||
)
|
||||
lines.append(
|
||||
f"- [#{item['number']}]({item.get('html_url', '')}) "
|
||||
f"{item.get('title', '')}{cycle}".rstrip()
|
||||
)
|
||||
else:
|
||||
lines.append("- Rien n'a été fermé dans ce milestone pour l'instant.")
|
||||
lines.append("")
|
||||
|
||||
slowest = sorted(
|
||||
(d for d in delivered if d.get("cycle_days") is not None),
|
||||
key=lambda d: -d["cycle_days"],
|
||||
)[:3]
|
||||
if slowest:
|
||||
lines.append("## 🐌 Les plus lentes")
|
||||
for item in slowest:
|
||||
lines.append(
|
||||
f"- #{item['number']} {item.get('title', '')} "
|
||||
f"— {item['cycle_days']} j de cycle"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
lines.append(f"## ↩️ Reporté ({len(carried)})")
|
||||
if carried:
|
||||
for item in carried:
|
||||
lines.append(
|
||||
f"- #{item['number']} {item.get('title', '')} "
|
||||
f"— encore en *{item.get('column_label', 'backlog')}*"
|
||||
)
|
||||
else:
|
||||
lines.append("- Rien : tout le milestone est livré. 🎉")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## 📈 En chiffres")
|
||||
lines.append(f"- Issues du milestone : {len(delivered) + len(carried)}")
|
||||
lines.append(f"- Fermées : {len(delivered)}")
|
||||
if avg_cycle_days is not None:
|
||||
lines.append(f"- Temps de cycle moyen : {avg_cycle_days} j")
|
||||
lines.append(f"- Déplacements de cartes : {counts.get('moved', 0)}")
|
||||
lines.append(f"- Commentaires : {counts.get('commented', 0)}")
|
||||
lines.append(f"- Réouvertures : {counts.get('reopened', 0)}")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"*Récit généré par Kanbante à partir de son journal d'événements "
|
||||
"et des issues Gitea du milestone.*"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
@@ -29,5 +29,6 @@
|
||||
<span class="age-badge{% if age.days >= aging_days * 2 %} stale{% endif %}"
|
||||
title="Dans cette colonne depuis le {{ age.since[:10] }}">⏳ {{ age.days }} j</span>
|
||||
{% endif %}
|
||||
<button type="button" class="pin-btn" title="Épingler au Focus du jour (/today)">📌</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,12 +16,14 @@
|
||||
<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="/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="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
|
||||
@@ -20,11 +20,13 @@
|
||||
<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="/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 %}
|
||||
@@ -51,6 +53,10 @@
|
||||
<span class="milestone-due">📅 échéance {{ milestone_progress.due_on[:10] }}</span>
|
||||
{% endif %}
|
||||
<span class="milestone-count">{{ milestone_progress.closed }}/{{ milestone_progress.total }} issues terminées</span>
|
||||
{% if milestone_forecast %}
|
||||
<span class="milestone-forecast">🔮 {{ milestone_forecast }}</span>
|
||||
{% endif %}
|
||||
<a class="milestone-sprint" href="/sprint/{{ full_name }}?milestone={{ milestone_param|urlencode }}">📖 Récit de sprint</a>
|
||||
</div>
|
||||
<div class="progress progress-lg"><span style="width: {{ milestone_progress.pct }}%"></span></div>
|
||||
<span class="milestone-pct">{{ milestone_progress.pct }}%</span>
|
||||
@@ -176,6 +182,7 @@
|
||||
{% endif %}
|
||||
</footer>
|
||||
<script src="/static/js/board-live.js" defer></script>
|
||||
<script src="/static/js/today.js" defer></script>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
</body>
|
||||
|
||||
@@ -17,11 +17,13 @@
|
||||
<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="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
|
||||
@@ -13,12 +13,14 @@
|
||||
<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="/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="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
|
||||
@@ -16,12 +16,14 @@
|
||||
<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="/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="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
@@ -86,6 +88,9 @@
|
||||
{% endif %}
|
||||
<div class="card-footer">
|
||||
<span>{{ repo.open_issues_count or 0 }} issues ouvertes</span>
|
||||
{% if repo.forecast_label %}
|
||||
<span class="forecast-line" title="Prévision d'après le débit réel du journal (fermetures par semaine)">🔮 {{ repo.forecast_label }}</span>
|
||||
{% endif %}
|
||||
{% if full in stats %}
|
||||
<div class="mini-progress">
|
||||
{% for col in ['backlog','todo','in_progress','qa','review','done'] %}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<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>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<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="/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>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Prévisions — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<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="/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>
|
||||
|
||||
<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>
|
||||
</section>
|
||||
|
||||
{% for project in projects %}
|
||||
<section class="briefing-card forecast-card">
|
||||
<h2>📂 {{ project.full_name }}</h2>
|
||||
<div class="forecast-flex">
|
||||
<svg class="sparkline" viewBox="0 0 168 48" role="img" aria-label="Fermetures par semaine">
|
||||
{% for bar in project.bars %}
|
||||
{% set px = (bar.h * 0.42)|round|int %}
|
||||
<rect x="{{ loop.index0 * 21 }}" y="{{ 44 - px }}" width="15" height="{{ px if px > 0 else 1 }}" rx="2">
|
||||
<title>{{ bar.label }} : {{ bar.count }} fermée(s)</title>
|
||||
</rect>
|
||||
{% endfor %}
|
||||
</svg>
|
||||
<div class="forecast-nums">
|
||||
{% if project.stats %}
|
||||
<p>Débit : <strong>{{ project.stats.avg }} / semaine</strong> en moyenne (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>
|
||||
{% endif %}
|
||||
<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 %}
|
||||
</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>
|
||||
</footer>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -16,11 +16,13 @@
|
||||
<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="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
|
||||
@@ -16,11 +16,13 @@
|
||||
<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="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Récit de sprint — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<script src="/static/vendor/marked.min.js"></script>
|
||||
<script src="/static/vendor/purify.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="focus-main">
|
||||
{% if markdown %}
|
||||
<section class="briefing-card">
|
||||
<h1>📖 {{ milestone_title }}</h1>
|
||||
<p>
|
||||
<button type="button" class="btn-save btn-inline" id="sprint-copy">📋 Copier le markdown</button>
|
||||
<button type="button" class="btn-save btn-inline" id="sprint-download">⬇️ Télécharger (.md)</button>
|
||||
<a class="btn-save btn-inline" style="text-decoration:none" href="/board/{{ full_name }}?milestone={{ milestone_param|urlencode }}">🎯 Voir le board du milestone</a>
|
||||
</p>
|
||||
<p class="notes-hint">Le texte est archivable tel quel dans ObsiGate — c'est le markdown ci-dessous, rendu pour lecture.</p>
|
||||
</section>
|
||||
<section class="briefing-card sprint-render" id="sprint-render"></section>
|
||||
<script>
|
||||
const SPRINT_MD = {{ markdown|tojson }};
|
||||
document.getElementById('sprint-render').innerHTML =
|
||||
DOMPurify.sanitize(marked.parse(SPRINT_MD));
|
||||
document.getElementById('sprint-copy').addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(SPRINT_MD);
|
||||
document.getElementById('sprint-copy').textContent = '✅ Copié !';
|
||||
} catch (e) { alert('Copie impossible : ' + e); }
|
||||
});
|
||||
document.getElementById('sprint-download').addEventListener('click', () => {
|
||||
const blob = new Blob([SPRINT_MD], { type: 'text/markdown' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'recit-{{ full_name|replace('/', '-') }}-{{ milestone_param|urlencode }}.md';
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
});
|
||||
</script>
|
||||
{% else %}
|
||||
<section class="briefing-card">
|
||||
<h1>📖 Récit de sprint — {{ full_name }}</h1>
|
||||
<p>Choisis un milestone pour générer son récit :</p>
|
||||
<ul>
|
||||
{% for m in milestones %}
|
||||
<li><a href="/sprint/{{ full_name }}?milestone={{ m.title|urlencode }}">🎯 {{ m.title }}</a> — {{ m.closed_issues or 0 }} fermée(s), {{ m.open_issues or 0 }} ouverte(s)</li>
|
||||
{% else %}
|
||||
<li>Aucun milestone dans ce projet.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
</main>
|
||||
<footer>
|
||||
<span>Kanbante v{{ version }} · le récit est écrit par le journal, pas par la mémoire des gens</span>
|
||||
</footer>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,103 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Aujourd'hui — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<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="/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="/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>
|
||||
|
||||
<main class="today-main">
|
||||
<h1 class="today-title">🌅 {{ date_label }}</h1>
|
||||
<p class="today-sub">Trois cartes, pas une de plus. C'est le plan de la journée.</p>
|
||||
|
||||
{% if picks %}
|
||||
<section class="today-picks">
|
||||
{% for pick in picks %}
|
||||
<article class="today-card{% if pick.state == 'closed' %} done{% endif %}">
|
||||
<div class="today-card-top">
|
||||
<span class="today-card-repo">{{ pick.repo }}</span>
|
||||
<span class="today-card-col">{{ pick.column_label }}</span>
|
||||
</div>
|
||||
<h2>{% if pick.state == 'closed' %}🎉 {% endif %}#{{ pick.number }} — {{ pick.title }}</h2>
|
||||
<div class="today-card-actions">
|
||||
<a href="/board/{{ pick.repo }}">📋 Ouvrir le board</a>
|
||||
{% if pick.html_url %}<a href="{{ pick.html_url }}" target="_blank" rel="noopener">Gitea ↗</a>{% endif %}
|
||||
<button type="button" class="btn-sm" data-unpin data-repo="{{ pick.repo }}" data-number="{{ pick.number }}">📌 Retirer</button>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="briefing-card">
|
||||
<p>Aucune carte épinglée pour aujourd'hui. Épingle-en jusqu'à 3 depuis un board (bouton 📌 sur les cartes) ou depuis les candidates ci-dessous.</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if recap %}
|
||||
<section class="briefing-card">
|
||||
<h2>🌆 Ça a bougé aujourd'hui</h2>
|
||||
<ul>
|
||||
{% for event in recap %}
|
||||
<li>{{ event.created_at[11:16] }} — {{ event.repo_full_name }} #{{ event.issue_number }} : {{ event.event_type }}{% if event.to_column %} → {{ column_labels.get(event.to_column, event.to_column) }}{% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if carry_over %}
|
||||
<section class="briefing-card">
|
||||
<h2>↩️ À reprendre d'hier</h2>
|
||||
<ul class="today-list">
|
||||
{% for pick in carry_over %}
|
||||
<li>
|
||||
<span>{{ pick.repo }} #{{ pick.number }} — {{ pick.title }} <em>({{ pick.column_label }})</em></span>
|
||||
{% if picks|length < 3 %}<button type="button" class="btn-sm" data-pin data-repo="{{ pick.repo }}" data-number="{{ pick.number }}">📌 Reprendre aujourd'hui</button>{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if candidates and picks|length < 3 %}
|
||||
<section class="briefing-card">
|
||||
<h2>🎯 Candidates (encours actuel)</h2>
|
||||
<ul class="today-list">
|
||||
{% for card in candidates %}
|
||||
<li>
|
||||
<span>{{ card.repo }} #{{ card.number }} — {{ card.title }} <em>({{ card.column_label }})</em></span>
|
||||
<button type="button" class="btn-sm" data-pin data-repo="{{ card.repo }}" data-number="{{ card.number }}">📌 Épingler</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
</main>
|
||||
<footer>
|
||||
<span>Kanbante v{{ version }} · le focus du jour se réinitialise chaque matin — les cartes non terminées reviennent en proposition</span>
|
||||
</footer>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
<script src="/static/js/today.js" defer></script>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -13,12 +13,14 @@
|
||||
<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="/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="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "kanbante"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
description = "Kanban project manager integrated with Gitea"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -1106,3 +1106,41 @@ footer {
|
||||
border: 1px solid rgba(240, 180, 41, 0.55); border-radius: 99px;
|
||||
padding: 6px 16px; font-size: 0.95rem; z-index: 51;
|
||||
}
|
||||
|
||||
/* ── Focus du jour (0.12.0, #5) ── */
|
||||
.pin-btn {
|
||||
background: transparent; border: none; cursor: pointer;
|
||||
font-size: 0.82rem; padding: 0 2px; margin-left: auto; opacity: 0.45;
|
||||
}
|
||||
.pin-btn:hover { opacity: 1; transform: scale(1.15); }
|
||||
.today-main { max-width: 880px; margin: 0 auto; padding: 28px 16px 44px; }
|
||||
.today-title { font-size: 1.9rem; margin: 0 0 4px; text-transform: capitalize; }
|
||||
.today-sub { color: var(--text-3); margin: 0 0 22px; }
|
||||
.today-picks { display: grid; gap: 14px; margin-bottom: 20px; }
|
||||
.today-card {
|
||||
background: var(--surface-1, #131a29); border: 1px solid var(--border-strong, #2c3a52);
|
||||
border-left: 5px solid var(--accent, #58a6ff); border-radius: 14px; padding: 20px 24px;
|
||||
}
|
||||
.today-card.done { border-left-color: var(--good, #3fb950); opacity: 0.85; }
|
||||
.today-card-top { display: flex; justify-content: space-between; color: var(--text-3); font-size: 0.85rem; }
|
||||
.today-card h2 { margin: 8px 0 12px; font-size: 1.3rem; line-height: 1.35; }
|
||||
.today-card-actions { display: flex; gap: 16px; align-items: center; }
|
||||
.today-card-actions a { color: var(--accent, #58a6ff); text-decoration: none; font-size: 0.9rem; }
|
||||
.today-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 10px; }
|
||||
.today-list li { display: flex; justify-content: space-between; gap: 12px; align-items: center; }
|
||||
.today-list em { color: var(--text-3); font-style: normal; font-size: 0.85rem; }
|
||||
|
||||
/* ── Prévisions + Récit (0.12.0, #7 #4) ── */
|
||||
.forecast-flex { display: flex; gap: 22px; align-items: center; flex-wrap: wrap; }
|
||||
.sparkline { width: 168px; height: 48px; }
|
||||
.sparkline rect { fill: var(--accent, #58a6ff); opacity: 0.85; }
|
||||
.forecast-nums p { margin: 4px 0; }
|
||||
.forecast-milestones { margin: 12px 0 0; padding-left: 18px; line-height: 1.8; }
|
||||
.forecast-line { color: var(--accent, #58a6ff); }
|
||||
.milestone-forecast { color: var(--accent, #58a6ff); font-weight: 600; }
|
||||
.milestone-sprint { color: var(--text-1); text-decoration: none; border: 1px solid var(--border, #22304a); border-radius: 8px; padding: 2px 10px; }
|
||||
.milestone-sprint:hover { border-color: var(--accent, #58a6ff); }
|
||||
.sprint-render { line-height: 1.65; }
|
||||
.sprint-render h1 { font-size: 1.5rem; }
|
||||
.sprint-render h2 { font-size: 1.15rem; margin-top: 22px; }
|
||||
.sprint-render a { color: var(--accent, #58a6ff); }
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
var ACTIONS = [
|
||||
{ icon: '🏠', label: 'Projets — dashboard', href: '/' },
|
||||
{ icon: '🎯', label: 'Focus — en cours, QA, révision', href: '/focus' },
|
||||
{ icon: '🌅', label: "Focus du jour — les 3 cartes d'aujourd'hui", href: '/today' },
|
||||
{ icon: '🔮', label: 'Prévisions — on finit quand ?', href: '/forecast' },
|
||||
{ icon: '👤', label: 'Mes issues', href: '/my' },
|
||||
{ icon: '📰', label: 'Activité — journal des événements', href: '/activity' },
|
||||
{ icon: '📊', label: 'Briefing quotidien', href: '/briefing' },
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Kanbante — Focus of the day (issue #5): pin/unpin from /today and,
|
||||
* via a delegated handler, the 📌 button on board cards.
|
||||
*/
|
||||
(function () {
|
||||
var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
|
||||
|
||||
function post(url, repo, number) {
|
||||
var data = new FormData();
|
||||
data.append('repo_full_name', repo);
|
||||
data.append('issue_number', number);
|
||||
data.append('csrf_token', csrf);
|
||||
return fetch(url, { method: 'POST', body: data }).then(function (r) {
|
||||
return r.json().catch(function () { return {}; }).then(function (body) {
|
||||
if (!r.ok || body.ok === false) throw new Error(body.error || ('HTTP ' + r.status));
|
||||
return body;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Capture phase: a 📌 click on a board card must be swallowed
|
||||
// before board.js sees it (it would open the detail drawer).
|
||||
document.addEventListener('click', function (e) {
|
||||
var pinBtn = e.target.closest('[data-pin]');
|
||||
var unpinBtn = e.target.closest('[data-unpin]');
|
||||
var cardPin = e.target.closest('.pin-btn');
|
||||
if (pinBtn) {
|
||||
post('/api/today/pin', pinBtn.getAttribute('data-repo'), pinBtn.getAttribute('data-number'))
|
||||
.then(function () { location.reload(); })
|
||||
.catch(function (err) { alert(err.message); });
|
||||
} else if (unpinBtn) {
|
||||
post('/api/today/unpin', unpinBtn.getAttribute('data-repo'), unpinBtn.getAttribute('data-number'))
|
||||
.then(function () { location.reload(); })
|
||||
.catch(function (err) { alert(err.message); });
|
||||
} else if (cardPin) {
|
||||
// Board card 📌: never open the detail drawer for this click.
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
var card = cardPin.closest('.kanban-card');
|
||||
post('/api/today/pin', card.getAttribute('data-repo'), card.getAttribute('data-issue'))
|
||||
.then(function () {
|
||||
cardPin.textContent = '📌✓';
|
||||
cardPin.title = 'Épinglée au Focus du jour (/today)';
|
||||
})
|
||||
.catch(function (err) { alert(err.message); });
|
||||
}
|
||||
}, true);
|
||||
})();
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Palier 8 (0.11.0): navigation manuelle du Wallboard."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def _make_two_active_projects(fake_gitea):
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Palier 9 (0.12.0): Focus du jour (#5), Prévisions (#7), Récit (#4)."""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
from app import forecast as fc
|
||||
from app import models
|
||||
from tests.conftest import csrf_from
|
||||
|
||||
|
||||
def _ev(day: date, kind: str = "closed") -> dict:
|
||||
return {"event_type": kind, "created_at": day.strftime("%Y-%m-%d 10:00:00")}
|
||||
|
||||
|
||||
# ── Prévisions : fonctions pures ──────────────────────────────────
|
||||
|
||||
|
||||
def test_weekly_closures_buckets():
|
||||
today = date(2026, 10, 14) # a Wednesday
|
||||
monday = today - timedelta(days=2)
|
||||
events = [
|
||||
_ev(monday), _ev(monday + timedelta(days=1)), # this week: 2
|
||||
_ev(monday - timedelta(days=7)), # last week: 1
|
||||
_ev(monday - timedelta(days=30), "moved"), # not a closure
|
||||
_ev(monday - timedelta(days=90)), # too old
|
||||
]
|
||||
weekly = fc.weekly_closures(events, weeks=8, today=today)
|
||||
assert len(weekly) == 8
|
||||
assert weekly[-1]["count"] == 2
|
||||
assert weekly[-2]["count"] == 1
|
||||
assert sum(b["count"] for b in weekly) == 3
|
||||
|
||||
|
||||
def test_throughput_excludes_current_week_and_needs_history():
|
||||
today = date(2026, 10, 14)
|
||||
monday = today - timedelta(days=2)
|
||||
events = []
|
||||
# 4 complete weeks: 2, 4, 0, 6 closures; current week: 99 (ignored)
|
||||
for weeks_ago, n in ((1, 2), (2, 4), (3, 0), (4, 6)):
|
||||
for k in range(n):
|
||||
events.append(_ev(monday - timedelta(weeks=weeks_ago) + timedelta(days=k % 5)))
|
||||
for k in range(9):
|
||||
events.append(_ev(monday + timedelta(days=k % 2)))
|
||||
weekly = fc.weekly_closures(events, weeks=8, today=today)
|
||||
stats = fc.throughput_stats(weekly)
|
||||
assert stats == {"avg": 3.0, "min": 0, "max": 6, "weeks": 4}
|
||||
assert fc.throughput_stats(fc.weekly_closures([], today=today)) is None
|
||||
|
||||
|
||||
def test_estimate_dates_and_labels():
|
||||
today = date(2026, 10, 14)
|
||||
monday = today - timedelta(days=2)
|
||||
events = [_ev(monday - timedelta(weeks=w)) for w in (1, 2, 3, 4) for _ in range(2)]
|
||||
weekly = fc.weekly_closures(events, weeks=8, today=today)
|
||||
est = fc.estimate(6, weekly, today=today)
|
||||
assert est["done"] is False
|
||||
assert est["avg_per_week"] == 2.0
|
||||
assert est["central"] == today + timedelta(days=21)
|
||||
# min == max == 2/week -> no spread: latest==earliest==central
|
||||
assert est["earliest"] == est["central"] == est["latest"]
|
||||
label = fc.eta_label(est)
|
||||
assert "à ce rythme ≈" in label
|
||||
assert fc.estimate(0, weekly, today=today) == {"done": True}
|
||||
assert fc.estimate(5, [], today=today) is None
|
||||
assert fc.french_date(date(2026, 11, 3)) == "3 novembre"
|
||||
|
||||
|
||||
def test_metrics_include_weekly_closures(client, fake_gitea):
|
||||
models.log_event("Projets/demo", 3, "closed", to_column="done")
|
||||
r = client.get("/api/metrics", params={"repo": "Projets/demo"})
|
||||
assert r.status_code == 200
|
||||
weekly = r.json()["metrics"]["weekly_closures"]
|
||||
assert len(weekly) == 8
|
||||
assert weekly[-1] == 1 # the closure we just logged is this week
|
||||
|
||||
|
||||
def test_forecast_page_and_milestone_banner(client, fake_gitea):
|
||||
r = client.get("/forecast")
|
||||
assert r.status_code == 200
|
||||
assert "Prévisions" in r.text
|
||||
assert "Projets/demo" in r.text
|
||||
assert "Sprint 1" in r.text
|
||||
board = client.get("/board/Projets/demo", params={"milestone": "Sprint 1"})
|
||||
assert board.status_code == 200
|
||||
assert "🔮" in board.text
|
||||
assert "historique pour prévoir" in board.text # empty journal
|
||||
assert "/sprint/Projets/demo" in board.text
|
||||
|
||||
|
||||
# ── Focus du jour ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _pin(client, token, number, repo="Projets/demo"):
|
||||
return client.post("/api/today/pin", data={
|
||||
"repo_full_name": repo, "issue_number": number, "csrf_token": token,
|
||||
})
|
||||
|
||||
|
||||
def test_today_pin_unpin_flow(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = _pin(client, token, 1)
|
||||
assert r.status_code == 200 and r.json()["count"] == 1
|
||||
r = _pin(client, token, 1) # idempotent
|
||||
assert r.json().get("already") is True
|
||||
page = client.get("/today")
|
||||
assert page.status_code == 200
|
||||
assert "Tâche un" in page.text
|
||||
r = client.post("/api/today/unpin", data={
|
||||
"repo_full_name": "Projets/demo", "issue_number": 1, "csrf_token": token,
|
||||
})
|
||||
assert r.status_code == 200
|
||||
assert models.get_picks(models_now()) == []
|
||||
|
||||
|
||||
def models_now() -> str:
|
||||
from datetime import datetime
|
||||
return datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def test_today_pin_cap_three(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
today = models_now()
|
||||
for n in (11, 12, 13):
|
||||
models.add_pick(today, "Projets/demo", n)
|
||||
r = _pin(client, token, 1)
|
||||
assert r.status_code == 400
|
||||
assert "3 cartes max" in r.json()["error"]
|
||||
|
||||
|
||||
def test_briefing_includes_today_picks(client, fake_gitea):
|
||||
models.add_pick(models_now(), "Projets/demo", 5)
|
||||
r = client.get("/api/briefing")
|
||||
assert r.status_code == 200
|
||||
picks = r.json()["briefing"]["today_picks"]
|
||||
assert [p["number"] for p in picks] == [5]
|
||||
assert picks[0]["title"] == "Tâche focus"
|
||||
|
||||
|
||||
def test_today_page_shows_candidates(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post("/api/move", data={
|
||||
"repo_full_name": "Projets/demo", "issue_number": 1,
|
||||
"from_column": "backlog", "to_column": "in_progress",
|
||||
"to_order": "1", "csrf_token": token,
|
||||
})
|
||||
assert r.status_code == 200
|
||||
page = client.get("/today")
|
||||
assert page.status_code == 200
|
||||
assert "Candidates" in page.text
|
||||
assert "Tâche un" in page.text
|
||||
|
||||
|
||||
# ── Récit de sprint ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_sprint_markdown_from_fake_milestone(client, fake_gitea):
|
||||
models.log_event("Projets/demo", 3, "moved", to_column="in_progress")
|
||||
models.log_event("Projets/demo", 3, "closed", to_column="done")
|
||||
r = client.get("/api/sprint/Projets/demo", params={"milestone": "Sprint 1"})
|
||||
assert r.status_code == 200
|
||||
md = r.json()["markdown"]
|
||||
assert "# 📖 Récit de sprint — Projets/demo · Sprint 1" in md
|
||||
assert "## ✅ Livré (1)" in md
|
||||
assert "Ancienne tâche" in md
|
||||
assert "## ↩️ Reporté (1)" in md
|
||||
assert "Tâche un" in md
|
||||
page = client.get("/sprint/Projets/demo", params={"milestone": "Sprint 1"})
|
||||
assert page.status_code == 200
|
||||
assert "Copier le markdown" in page.text
|
||||
|
||||
|
||||
def test_sprint_unknown_milestone_404(client, fake_gitea):
|
||||
r = client.get("/api/sprint/Projets/demo", params={"milestone": "Nope"})
|
||||
assert r.status_code == 404
|
||||
page = client.get("/sprint/Projets/demo")
|
||||
assert page.status_code == 200
|
||||
assert "Sprint 1" in page.text # picker lists the milestones
|
||||
Reference in New Issue
Block a user