feat(sprint): récit de sprint généré par le journal (issue #4)

app/sprint.py + /sprint/{o}/{r} (rendu markdown, copier/télécharger)
+ /api/sprint en JSON: livré avec temps de cycle, plus lentes,
reporté, chiffres de période. Lien depuis le bandeau milestone.
Navigation: pilules Aujourd'hui/Prévisions et actions palette.
This commit is contained in:
Spark
2026-10-11 13:55:03 -04:00
parent cdabc77ccd
commit 4c5d1dee23
12 changed files with 207 additions and 0 deletions
+79
View File
@@ -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)
+2
View File
@@ -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>
+2
View File
@@ -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>
+2
View File
@@ -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>
+1
View File
@@ -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>
+1
View File
@@ -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>
+2
View File
@@ -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>
+2
View File
@@ -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>
+74
View File
@@ -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>
+2
View File
@@ -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>