feat(health): santé du flux — goulots et cartes zombies (issue #9)
app/flow.py: séjours par colonne reconstruits du journal,
médianes, colonne la plus lente; zombies = aucune activité
(journal/Gitea) depuis KANBANTE_ZOMBIE_DAYS (30). Pages /health
et /health/{o}/{r} + actions rapides (commenter/backlog/fermer).
This commit is contained in:
+6
-1
@@ -78,6 +78,9 @@ Environment variables:
|
||||
column, min_age_days, label, wip_over.
|
||||
Actions: add_label, notify, move_top.
|
||||
A rule can never close an issue.
|
||||
KANBANTE_ZOMBIE_DAYS Days without any activity after
|
||||
which an open card is a "zombie"
|
||||
on /health (issue #9). Default 30.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -86,7 +89,7 @@ from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
APP_VERSION = "0.13.0"
|
||||
APP_VERSION = "0.14.0"
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
@@ -154,6 +157,7 @@ class Settings:
|
||||
obsigate_mcp_args: str = '{"title": "{title}", "content": "{content}"}'
|
||||
briefing_include_standup: bool = False
|
||||
rules_raw: str = ""
|
||||
zombie_days: int = 30
|
||||
|
||||
@property
|
||||
def auth_enabled(self) -> bool:
|
||||
@@ -198,4 +202,5 @@ def get_settings() -> Settings:
|
||||
),
|
||||
briefing_include_standup=_env_bool("KANBANTE_BRIEFING_INCLUDE_STANDUP", False),
|
||||
rules_raw=os.getenv("KANBANTE_RULES", ""),
|
||||
zombie_days=_env_int("KANBANTE_ZOMBIE_DAYS", 30),
|
||||
)
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
"""Flow health (roadmap #9): bottlenecks and zombie cards.
|
||||
|
||||
Boards show state; this module reads the event journal for health:
|
||||
how long cards actually stay in each column (dwell samples are the
|
||||
intervals between an entry and the next move/close), and which
|
||||
open cards have had no activity for a long time (zombies).
|
||||
"""
|
||||
|
||||
import statistics
|
||||
from datetime import datetime
|
||||
|
||||
from app.models import KANBAN_COLUMNS
|
||||
|
||||
|
||||
def parse_ts(raw) -> datetime | None:
|
||||
"""Parse journal ('YYYY-MM-DD HH:MM:SS') and Gitea ISO stamps."""
|
||||
text = str(raw or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
|
||||
try:
|
||||
return datetime.strptime(text[:19], fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
return datetime.strptime(text[:10], "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def column_dwell(events: list[dict]) -> dict[str, list[float]]:
|
||||
"""Dwell samples (in days) per column, rebuilt from the journal.
|
||||
|
||||
A stay starts when a card enters a column (created / moved /
|
||||
reopened) and ends at its next move or close. The first stay of
|
||||
a card whose journal starts mid-flight is unknowable and is
|
||||
simply not sampled.
|
||||
"""
|
||||
by_issue: dict[tuple[str, int], list[dict]] = {}
|
||||
for event in events:
|
||||
by_issue.setdefault(
|
||||
(event["repo_full_name"], event["issue_number"]), []
|
||||
).append(event)
|
||||
dwell: dict[str, list[float]] = {}
|
||||
for issue_events in by_issue.values():
|
||||
ordered = sorted(
|
||||
issue_events, key=lambda e: str(e.get("created_at") or "")
|
||||
)
|
||||
current: str | None = None
|
||||
since: datetime | None = None
|
||||
for event in ordered:
|
||||
at = parse_ts(event.get("created_at"))
|
||||
if at is None:
|
||||
continue
|
||||
kind = event["event_type"]
|
||||
if kind == "created":
|
||||
if current is None:
|
||||
current = event.get("to_column") or "backlog"
|
||||
since = at
|
||||
elif kind == "moved":
|
||||
if current is not None and since is not None:
|
||||
dwell.setdefault(current, []).append(
|
||||
(at - since).total_seconds() / 86400
|
||||
)
|
||||
elif current is None and event.get("from_column"):
|
||||
# The stay in from_column began before the journal
|
||||
# window: only the new stay can be timed.
|
||||
pass
|
||||
current = event.get("to_column") or current
|
||||
since = at
|
||||
elif kind == "closed":
|
||||
if current is not None and since is not None:
|
||||
dwell.setdefault(current, []).append(
|
||||
(at - since).total_seconds() / 86400
|
||||
)
|
||||
current = "done"
|
||||
since = at
|
||||
elif kind == "reopened":
|
||||
current = event.get("to_column") or "backlog"
|
||||
since = at
|
||||
return dwell
|
||||
|
||||
|
||||
def summarize_dwell(dwell: dict[str, list[float]]) -> list[dict]:
|
||||
"""Median dwell per column, in board order."""
|
||||
summary = []
|
||||
for column in KANBAN_COLUMNS:
|
||||
samples = dwell.get(column) or []
|
||||
if not samples:
|
||||
continue
|
||||
summary.append(
|
||||
{
|
||||
"column": column,
|
||||
"count": len(samples),
|
||||
"median_days": round(statistics.median(samples), 1),
|
||||
"avg_days": round(sum(samples) / len(samples), 1),
|
||||
}
|
||||
)
|
||||
return summary
|
||||
|
||||
|
||||
def slowest_column(summary: list[dict], min_samples: int = 2) -> dict | None:
|
||||
"""The bottleneck: slowest column (done excluded) with data."""
|
||||
candidates = [
|
||||
row for row in summary
|
||||
if row["column"] != "done" and row["count"] >= min_samples
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
return max(candidates, key=lambda r: r["median_days"])
|
||||
|
||||
|
||||
def last_activity(events: list[dict]) -> dict[tuple[str, int], datetime]:
|
||||
"""Most recent journal timestamp per (repo, issue)."""
|
||||
latest: dict[tuple[str, int], datetime] = {}
|
||||
for event in events:
|
||||
at = parse_ts(event.get("created_at"))
|
||||
if at is None:
|
||||
continue
|
||||
key = (event["repo_full_name"], event["issue_number"])
|
||||
if key not in latest or at > latest[key]:
|
||||
latest[key] = at
|
||||
return latest
|
||||
@@ -26,6 +26,7 @@
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||
<a href="/health" class="nav-pill">🩺 Santé</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>
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||
<a href="/health" class="nav-pill">🩺 Santé</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>
|
||||
{% if repo_info.html_url %}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||
<a href="/health" class="nav-pill">🩺 Santé</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>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||
<a href="/health" class="nav-pill">🩺 Santé</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>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||
<a href="/health" class="nav-pill">🩺 Santé</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>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||
<a href="/health" class="nav-pill">🩺 Santé</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>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<!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>Santé du flux — 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">🌅 Aujourd'hui</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||
<a href="/health" class="nav-pill active">🩺 Santé</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
<h1>🩺 Santé du flux</h1>
|
||||
<p>Les boards montrent l'état ; cette page montre la <strong>santé</strong> : quelle colonne accumule (temps médian passé dans chaque colonne, d'après le journal) et quelles cartes sont devenues des <strong>zombies</strong> (aucune activité depuis le seuil configuré).</p>
|
||||
</section>
|
||||
|
||||
{% for project in projects %}
|
||||
<section class="briefing-card forecast-card">
|
||||
<h2>📂 {{ project.full_name }} <small>({{ project.open_count }} ouverte(s))</small></h2>
|
||||
{% if project.slowest %}
|
||||
<p>🐌 Colonne la plus lente : <strong>{{ project.column_labels[project.slowest.column] }}</strong> — médiane {{ project.slowest.median_days }} j ({{ project.slowest.count }} séjours mesurés)</p>
|
||||
{% else %}
|
||||
<p>🐌 Pas encore assez de séjours mesurés pour nommer un goulot.</p>
|
||||
{% endif %}
|
||||
<p>🧟 Zombies (≥ {{ project.zombie_days }} j sans activité) : <strong>{{ project.zombies|length }}</strong>
|
||||
— <a href="/health/{{ project.full_name }}">voir le détail et agir →</a></p>
|
||||
</section>
|
||||
{% else %}
|
||||
<div class="briefing-card"><p>Aucun projet actif.</p></div>
|
||||
{% endfor %}
|
||||
</main>
|
||||
<footer>
|
||||
<span>Kanbante v{{ version }} · l'hygiène du flux, mesurée sur le journal — pas au feeling</span>
|
||||
</footer>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,82 @@
|
||||
<!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>Santé — {{ full_name }} — 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="/board/{{ full_name }}" class="nav-pill">📋 Board</a>
|
||||
<a href="/health" class="nav-pill active">🩺 Santé</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 health is none %}
|
||||
<section class="briefing-card">
|
||||
<h1>🩺 {{ full_name }}</h1>
|
||||
<p>Projet introuvable.</p>
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="briefing-card">
|
||||
<h1>🩺 {{ full_name }}</h1>
|
||||
<h2>⏱️ Temps passé par colonne (journal)</h2>
|
||||
{% if health.dwell %}
|
||||
<table class="health-table">
|
||||
<thead><tr><th>Colonne</th><th>Séjours mesurés</th><th>Médiane</th><th>Moyenne</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in health.dwell %}
|
||||
<tr{% if health.slowest and row.column == health.slowest.column %} class="health-slowest"{% endif %}>
|
||||
<td>{{ health.column_labels[row.column] }}{% if health.slowest and row.column == health.slowest.column %} 🐌{% endif %}</td>
|
||||
<td>{{ row.count }}</td>
|
||||
<td>{{ row.median_days }} j</td>
|
||||
<td>{{ row.avg_days }} j</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if health.slowest %}
|
||||
<p>🐌 <strong>Goulot : {{ health.column_labels[health.slowest.column] }}</strong> — c'est là que les cartes dorment le plus longtemps (médiane {{ health.slowest.median_days }} j).</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p>Pas encore de séjours mesurables dans le journal de ce projet — il faut des déplacements journalisés pour calculer les temps de colonne.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>🧟 Cartes zombies — aucune activité depuis ≥ {{ health.zombie_days }} jours</h2>
|
||||
{% if health.zombies %}
|
||||
<ul class="today-list">
|
||||
{% for z in health.zombies %}
|
||||
<li>
|
||||
<span>#{{ z.number }} — {{ z.title }} <em>({{ z.column_label }} · inactive depuis {{ z.days_inactive }} j)</em></span>
|
||||
<span class="zombie-actions">
|
||||
<button type="button" class="btn-sm" data-zombie="comment" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">💬 Toujours pertinent ?</button>
|
||||
<button type="button" class="btn-sm" data-zombie="backlog" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">↩️ Backlog</button>
|
||||
<button type="button" class="btn-sm" data-zombie="close" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">✅ Fermer</button>
|
||||
</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<p class="notes-hint">« Fermer » déplace la carte en Terminé (l'issue est fermée dans Gitea par la synchro habituelle). « Toujours pertinent ? » poste un commentaire du même texte sur l'issue.</p>
|
||||
{% else %}
|
||||
<p>Aucune carte zombie. Le flux est propre. ✨</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</main>
|
||||
<footer>
|
||||
<span>Kanbante v{{ version }} · seuil zombie : KANBANTE_ZOMBIE_DAYS</span>
|
||||
</footer>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
<script src="/static/js/health.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -24,6 +24,7 @@
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||
<a href="/health" class="nav-pill">🩺 Santé</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>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||
<a href="/health" class="nav-pill">🩺 Santé</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>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||
<a href="/health" class="nav-pill">🩺 Santé</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||
<a href="/health" class="nav-pill">🩺 Santé</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>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||
<a href="/health" class="nav-pill">🩺 Santé</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>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Kanbante — Flow health (issue #9): quick actions on zombie cards.
|
||||
* Close/backlog reuse /api/move; the nudge reuses the comments API.
|
||||
*/
|
||||
(function () {
|
||||
var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
|
||||
|
||||
function post(url, fields) {
|
||||
var data = new FormData();
|
||||
Object.keys(fields).forEach(function (k) { data.append(k, fields[k]); });
|
||||
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;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('[data-zombie]');
|
||||
if (!btn) return;
|
||||
var repo = btn.getAttribute('data-repo');
|
||||
var number = btn.getAttribute('data-number');
|
||||
var column = btn.getAttribute('data-column');
|
||||
var action = btn.getAttribute('data-zombie');
|
||||
var parts = repo.split('/');
|
||||
btn.disabled = true;
|
||||
var done = function () { location.reload(); };
|
||||
var fail = function (err) { btn.disabled = false; alert(err.message); };
|
||||
if (action === 'comment') {
|
||||
post('/api/issues/' + parts[0] + '/' + parts[1] + '/' + number + '/comments', {
|
||||
body: '🧟 Cette carte est inactive depuis un moment — toujours pertinent ?'
|
||||
}).then(done).catch(fail);
|
||||
} else {
|
||||
post('/api/move', {
|
||||
repo_full_name: repo,
|
||||
issue_number: number,
|
||||
from_column: column,
|
||||
to_column: action === 'close' ? 'done' : 'backlog',
|
||||
to_order: number
|
||||
}).then(done).catch(fail);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -17,6 +17,7 @@
|
||||
{ icon: '🔮', label: 'Prévisions — on finit quand ?', href: '/forecast' },
|
||||
{ icon: '🗣️', label: 'Standup automatique — hier, aujourd\'hui, blocages', href: '/standup' },
|
||||
{ icon: '⚙️', label: 'Règles d\'automatisation', href: '/rules' },
|
||||
{ icon: '🩺', label: 'Santé du flux — goulots et zombies', href: '/health' },
|
||||
{ icon: '👤', label: 'Mes issues', href: '/my' },
|
||||
{ icon: '📰', label: 'Activité — journal des événements', href: '/activity' },
|
||||
{ icon: '📊', label: 'Briefing quotidien', href: '/briefing' },
|
||||
|
||||
Reference in New Issue
Block a user