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:
@@ -27,6 +27,22 @@ def format_briefing_text(data: dict) -> str:
|
|||||||
if activity.get("avg_cycle_days") is not None:
|
if activity.get("avg_cycle_days") is not None:
|
||||||
lines.append(f"⏱️ Temps de cycle moyen (30 j) : {activity['avg_cycle_days']} j.")
|
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)")
|
lines.append(f"\n🎯 En cours / en révision : {data['focus_total']} carte(s)")
|
||||||
for group in data["focus_groups"][:6]:
|
for group in data["focus_groups"][:6]:
|
||||||
lines.append(f" • {group['full_name']} : {group['count']}")
|
lines.append(f" • {group['full_name']} : {group['count']}")
|
||||||
|
|||||||
+416
-1
@@ -36,22 +36,27 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||||||
from app.config import APP_VERSION, get_settings
|
from app.config import APP_VERSION, get_settings
|
||||||
from app.gitea_client import GiteaClient
|
from app.gitea_client import GiteaClient
|
||||||
from app import digest as digest_service
|
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.briefing import format_briefing_text, ntfy_configured, send_ntfy
|
||||||
from app.capture import ntfy_capture_loop, parse_capture_text
|
from app.capture import ntfy_capture_loop, parse_capture_text
|
||||||
from app.models import (
|
from app.models import (
|
||||||
KANBAN_COLUMNS,
|
KANBAN_COLUMNS,
|
||||||
|
add_pick,
|
||||||
column_entry_times,
|
column_entry_times,
|
||||||
get_board_state,
|
get_board_state,
|
||||||
get_events,
|
get_events,
|
||||||
get_kv,
|
get_kv,
|
||||||
get_metrics,
|
get_metrics,
|
||||||
get_notes,
|
get_notes,
|
||||||
|
get_picks,
|
||||||
get_stats,
|
get_stats,
|
||||||
init_db,
|
init_db,
|
||||||
log_event,
|
log_event,
|
||||||
move_issue,
|
move_issue,
|
||||||
ping_db,
|
ping_db,
|
||||||
reconcile_board_state,
|
reconcile_board_state,
|
||||||
|
remove_pick,
|
||||||
set_column_order,
|
set_column_order,
|
||||||
set_kv,
|
set_kv,
|
||||||
set_notes,
|
set_notes,
|
||||||
@@ -457,6 +462,10 @@ async def dashboard(request: Request):
|
|||||||
"""Main dashboard — all projects at a glance."""
|
"""Main dashboard — all projects at a glance."""
|
||||||
repos = await gitea.search_repos()
|
repos = await gitea.search_repos()
|
||||||
active, inactive, archived = classify_repos(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(
|
return render(
|
||||||
request,
|
request,
|
||||||
"dashboard.html",
|
"dashboard.html",
|
||||||
@@ -494,6 +503,7 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""):
|
|||||||
milestones: list[dict] = []
|
milestones: list[dict] = []
|
||||||
selected_milestone = None
|
selected_milestone = None
|
||||||
milestone_progress = None
|
milestone_progress = None
|
||||||
|
milestone_forecast = None
|
||||||
display_issues = open_issues
|
display_issues = open_issues
|
||||||
try:
|
try:
|
||||||
milestones = await gitea.get_milestones(owner, repo)
|
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"),
|
"due_on": (selected_milestone or {}).get("due_on"),
|
||||||
"title": (selected_milestone or {}).get("title", milestone),
|
"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)
|
board_state = get_board_state(full_name)
|
||||||
columns = _merge_issues_with_board(display_issues, board_state)
|
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,
|
milestones=milestones,
|
||||||
selected_milestone=selected_milestone,
|
selected_milestone=selected_milestone,
|
||||||
milestone_progress=milestone_progress,
|
milestone_progress=milestone_progress,
|
||||||
|
milestone_forecast=milestone_forecast,
|
||||||
milestone_param=milestone,
|
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)
|
@app.get("/focus", response_class=HTMLResponse)
|
||||||
async def focus(request: Request):
|
async def focus(request: Request):
|
||||||
"""Cross-project view: everything in progress or in review."""
|
"""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_24h = get_metrics(window_days=1)
|
||||||
metrics_30d = get_metrics(window_days=30)
|
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 {
|
return {
|
||||||
"date": datetime.now().strftime("%d/%m/%Y"),
|
"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_total": sum(len(g["cards"]) for g in focus_groups),
|
||||||
"focus_groups": [
|
"focus_groups": [
|
||||||
{
|
{
|
||||||
@@ -1822,7 +2216,28 @@ async def api_events(repo: str = "", limit: int = 100):
|
|||||||
@app.get("/api/metrics")
|
@app.get("/api/metrics")
|
||||||
async def api_metrics(repo: str = "", window: int = 30):
|
async def api_metrics(repo: str = "", window: int = 30):
|
||||||
"""Throughput / cycle-time metrics from the journal."""
|
"""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 ───────────────────────────────────────
|
# ── Label management ───────────────────────────────────────
|
||||||
|
|||||||
@@ -79,6 +79,13 @@ def init_db() -> None:
|
|||||||
CREATE INDEX IF NOT EXISTS idx_board_repo
|
CREATE INDEX IF NOT EXISTS idx_board_repo
|
||||||
ON board_state(repo_full_name, column_name);
|
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 (
|
CREATE TABLE IF NOT EXISTS kv_store (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
@@ -239,6 +246,35 @@ def reconcile_board_state(
|
|||||||
return changed
|
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:
|
def get_kv(key: str) -> str | None:
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
|
|||||||
@@ -29,5 +29,6 @@
|
|||||||
<span class="age-badge{% if age.days >= aging_days * 2 %} stale{% endif %}"
|
<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>
|
title="Dans cette colonne depuis le {{ age.since[:10] }}">⏳ {{ age.days }} j</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<button type="button" class="pin-btn" title="Épingler au Focus du jour (/today)">📌</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,11 +20,13 @@
|
|||||||
<nav class="nav-pills">
|
<nav class="nav-pills">
|
||||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||||
<a href="/focus" class="nav-pill">🎯 Focus</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="/my" class="nav-pill">👤 Mes issues</a>
|
||||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||||
<a href="/digest" class="nav-pill">🧠 Digest</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>
|
<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>
|
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||||
{% if repo_info.html_url %}
|
{% if repo_info.html_url %}
|
||||||
@@ -51,6 +53,10 @@
|
|||||||
<span class="milestone-due">📅 échéance {{ milestone_progress.due_on[:10] }}</span>
|
<span class="milestone-due">📅 échéance {{ milestone_progress.due_on[:10] }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="milestone-count">{{ milestone_progress.closed }}/{{ milestone_progress.total }} issues terminées</span>
|
<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>
|
||||||
<div class="progress progress-lg"><span style="width: {{ milestone_progress.pct }}%"></span></div>
|
<div class="progress progress-lg"><span style="width: {{ milestone_progress.pct }}%"></span></div>
|
||||||
<span class="milestone-pct">{{ milestone_progress.pct }}%</span>
|
<span class="milestone-pct">{{ milestone_progress.pct }}%</span>
|
||||||
@@ -176,6 +182,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</footer>
|
</footer>
|
||||||
<script src="/static/js/board-live.js" defer></script>
|
<script src="/static/js/board-live.js" defer></script>
|
||||||
|
<script src="/static/js/today.js" defer></script>
|
||||||
{% include "_palette.html" %}
|
{% include "_palette.html" %}
|
||||||
<script src="/static/js/palette.js" defer></script>
|
<script src="/static/js/palette.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user