feat(today): focus du jour — le rituel des 3 cartes (issue #5)

Table today_picks; POST /api/today/pin (max 3, idempotent) et
/unpin; page /today (picks en direct, candidates de l'encours,
reprises d'hier, récap du jour depuis le journal); bouton 📌 sur
les cartes (délégation capture dans today.js, fragments live
compris); sections dédiées dans le briefing du matin.
This commit is contained in:
Spark
2026-10-11 13:55:03 -04:00
parent 6b9ec1ff99
commit fbe58bbf16
7 changed files with 627 additions and 1 deletions
+416 -1
View File
@@ -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 ───────────────────────────────────────