From fbe58bbf168dc7611ee16999a40dfef6f6f967e6 Mon Sep 17 00:00:00 2001
From: Spark
Date: Sun, 11 Oct 2026 13:55:03 -0400
Subject: [PATCH] =?UTF-8?q?feat(today):=20focus=20du=20jour=20=E2=80=94=20?=
=?UTF-8?q?le=20rituel=20des=203=20cartes=20(issue=20#5)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
app/briefing.py | 16 ++
app/main.py | 417 ++++++++++++++++++++++++++++++++++++++-
app/models.py | 36 ++++
app/templates/_card.html | 1 +
app/templates/board.html | 7 +
app/templates/today.html | 103 ++++++++++
static/js/today.js | 48 +++++
7 files changed, 627 insertions(+), 1 deletion(-)
create mode 100644 app/templates/today.html
create mode 100644 static/js/today.js
diff --git a/app/briefing.py b/app/briefing.py
index 459c93f..6adafd5 100644
--- a/app/briefing.py
+++ b/app/briefing.py
@@ -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']}")
diff --git a/app/main.py b/app/main.py
index d5467a3..0e5f57d 100644
--- a/app/main.py
+++ b/app/main.py
@@ -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 ───────────────────────────────────────
diff --git a/app/models.py b/app/models.py
index 62914dc..e9891dc 100644
--- a/app/models.py
+++ b/app/models.py
@@ -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(
diff --git a/app/templates/_card.html b/app/templates/_card.html
index 63db4e7..ba57513 100644
--- a/app/templates/_card.html
+++ b/app/templates/_card.html
@@ -29,5 +29,6 @@
⏳ {{ age.days }} j
{% endif %}
+
diff --git a/app/templates/board.html b/app/templates/board.html
index 153184b..400de53 100644
--- a/app/templates/board.html
+++ b/app/templates/board.html
@@ -20,11 +20,13 @@
+
+
+
+ 🌅 {{ date_label }}
+ Trois cartes, pas une de plus. C'est le plan de la journée.
+
+ {% if picks %}
+
+ {% for pick in picks %}
+
+
+ {{ pick.repo }}
+ {{ pick.column_label }}
+
+ {% if pick.state == 'closed' %}🎉 {% endif %}#{{ pick.number }} — {{ pick.title }}
+
+
+ {% endfor %}
+
+ {% else %}
+
+ Aucune carte épinglée pour aujourd'hui. Épingle-en jusqu'à 3 depuis un board (bouton 📌 sur les cartes) ou depuis les candidates ci-dessous.
+
+ {% endif %}
+
+ {% if recap %}
+
+ 🌆 Ça a bougé aujourd'hui
+
+ {% for event in recap %}
+ - {{ 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 %}
+ {% endfor %}
+
+
+ {% endif %}
+
+ {% if carry_over %}
+
+ ↩️ À reprendre d'hier
+
+ {% for pick in carry_over %}
+ -
+ {{ pick.repo }} #{{ pick.number }} — {{ pick.title }} ({{ pick.column_label }})
+ {% if picks|length < 3 %}{% endif %}
+
+ {% endfor %}
+
+
+ {% endif %}
+
+ {% if candidates and picks|length < 3 %}
+
+ 🎯 Candidates (encours actuel)
+
+ {% for card in candidates %}
+ -
+ {{ card.repo }} #{{ card.number }} — {{ card.title }} ({{ card.column_label }})
+
+
+ {% endfor %}
+
+
+ {% endif %}
+
+
+ {% include "_palette.html" %}
+
+
+
+
diff --git a/app/templates/today.html b/app/templates/today.html
new file mode 100644
index 0000000..08ee5ab
--- /dev/null
+++ b/app/templates/today.html
@@ -0,0 +1,103 @@
+
+
+