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 @@