Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
593f076555 | ||
|
|
311a14090d | ||
|
|
c67a14da65 | ||
|
|
7ec34245c5 |
@@ -1,5 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
## 0.18.0 — 2026-10-11
|
||||
|
||||
### Nouveautés — vague 2, suite (4 cartes de Bruno)
|
||||
- **Activité récente par projet** (#19) : ligne sur la carte du
|
||||
dashboard calculée par UNE requête agrégée sur le journal —
|
||||
« 📈 X fermées · Y déplacements cette semaine », ou « 💤 aucune
|
||||
activité kanban depuis N j » pour les projets endormis.
|
||||
- **Actions rapides sur la carte** (#23) : menu ⋯ sur chaque
|
||||
carte — Board, ⚡ Capturer ici (la page capture accepte
|
||||
`?repo=` et pré-remplit le dépôt), Triage du backlog, Santé,
|
||||
Récit de sprint, Gitea. Clavier et tactile soignés (Échap,
|
||||
clic extérieur).
|
||||
- **Tri et regroupement** (#21) : tri par activité Gitea,
|
||||
activité kanban, ouvertes ou nom ; regroupement par
|
||||
propriétaire en sections repliables ; préférences mémorisées
|
||||
par navigateur (localStorage).
|
||||
- **Vue liste dense** (#22) : bascule Grille/Liste persistée —
|
||||
tableau triable par colonne (Projet, Propriétaire, Ouvertes,
|
||||
En vol, Prochain jalon, Santé, Dernière activité) réutilisant
|
||||
les enrichissements des cartes, mêmes onglets et même filtre.
|
||||
|
||||
Note : le lien Triage du menu ⋯ est constant — la taille du
|
||||
backlog « stats » du dashboard ne compte que les cartes placées,
|
||||
pas le backlog réel ; la page Triage affiche l'état exact.
|
||||
|
||||
7 tests ajoutés (185 au total).
|
||||
|
||||
## 0.17.0 — 2026-10-11
|
||||
|
||||
### Nouveautés — vague 2 « Gestion de projets » (4 cartes de Bruno)
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
APP_VERSION = "0.17.0"
|
||||
APP_VERSION = "0.18.0"
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
|
||||
+1
-1
@@ -143,7 +143,7 @@ async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None:
|
||||
{
|
||||
"protocolVersion": MCP_PROTOCOL_VERSION,
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "kanbante", "version": "0.17.0"},
|
||||
"clientInfo": {"name": "kanbante", "version": "0.18.0"},
|
||||
},
|
||||
req_id=1,
|
||||
)
|
||||
|
||||
+26
-3
@@ -58,6 +58,7 @@ from app.models import (
|
||||
get_metrics,
|
||||
get_notes,
|
||||
get_picks,
|
||||
get_project_activity,
|
||||
get_stats,
|
||||
init_db,
|
||||
log_event,
|
||||
@@ -600,6 +601,24 @@ async def dashboard(request: Request):
|
||||
repo_info["starred"] = full_name in starred_names
|
||||
for repo_info in inactive:
|
||||
repo_info["starred"] = repo_info["full_name"] in starred_names
|
||||
activity_by_repo = get_project_activity()
|
||||
today_utc = datetime.now(timezone.utc).date()
|
||||
for repo_info in active + inactive + archived:
|
||||
act = activity_by_repo.get(repo_info["full_name"])
|
||||
if act is None:
|
||||
repo_info["activity"] = None
|
||||
continue
|
||||
idle_days = None
|
||||
if act["last_at"]:
|
||||
last_dt = _parse_datetime(act["last_at"])
|
||||
if last_dt is not None:
|
||||
idle_days = (today_utc - last_dt.date()).days
|
||||
repo_info["activity"] = {
|
||||
"closed": act["closed"],
|
||||
"moved": act["moved"],
|
||||
"idle_days": idle_days,
|
||||
"last_at": act["last_at"] or "",
|
||||
}
|
||||
for repo_info in active:
|
||||
repo_info["home_tab"] = "active"
|
||||
for repo_info in inactive:
|
||||
@@ -2305,13 +2324,17 @@ async def api_capture(request: Request):
|
||||
|
||||
|
||||
@app.get("/capture", response_class=HTMLResponse)
|
||||
async def capture_page(request: Request):
|
||||
"""Quick-capture page: form, status and usage instructions."""
|
||||
async def capture_page(request: Request, repo: str = ""):
|
||||
"""Quick-capture page: form, status and usage instructions.
|
||||
|
||||
`?repo=Owner/name` pre-fills the target repo (issue #23: the
|
||||
project card's quick actions capture straight into a project).
|
||||
"""
|
||||
settings = get_settings()
|
||||
return render(
|
||||
request,
|
||||
"capture.html",
|
||||
capture_repo=settings.capture_repo,
|
||||
capture_repo=repo.strip() or settings.capture_repo,
|
||||
api_enabled=bool(settings.capture_token),
|
||||
ntfy_topic=settings.capture_ntfy_topic,
|
||||
ntfy_url=settings.ntfy_url,
|
||||
|
||||
@@ -368,6 +368,38 @@ def column_entry_times(repo_full_name: str) -> dict[int, str]:
|
||||
return {row["issue_number"]: row["entered_at"] for row in rows}
|
||||
|
||||
|
||||
def get_project_activity(days: int = 7) -> dict:
|
||||
"""Per-repo journal activity in ONE aggregated pass (issue #19).
|
||||
|
||||
For every repo present in the journal: closures and moves of
|
||||
the last `days` days (journal stamps are UTC strings), plus
|
||||
the most recent event ever seen for that repo.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT repo_full_name,
|
||||
SUM(CASE WHEN event_type = 'closed' AND created_at >= ?
|
||||
THEN 1 ELSE 0 END),
|
||||
SUM(CASE WHEN event_type = 'moved' AND created_at >= ?
|
||||
THEN 1 ELSE 0 END),
|
||||
MAX(created_at)
|
||||
FROM board_events
|
||||
GROUP BY repo_full_name
|
||||
""",
|
||||
(cutoff, cutoff),
|
||||
).fetchall()
|
||||
return {
|
||||
r[0]: {"closed": r[1] or 0, "moved": r[2] or 0, "last_at": r[3]}
|
||||
for r in rows
|
||||
}
|
||||
|
||||
|
||||
def get_metrics(repo_full_name: str | None = None, window_days: int = 30) -> dict:
|
||||
"""Throughput and cycle time computed from the journal.
|
||||
|
||||
|
||||
@@ -24,7 +24,22 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main x-data="{ tab: 'active', search: '' }">
|
||||
<script>
|
||||
// État du dashboard (#21/#22) : la vue grille/liste survit au
|
||||
// rechargement (localStorage), lue avant qu'Alpine démarre.
|
||||
function dashState() {
|
||||
var view = 'grid';
|
||||
try { view = localStorage.getItem('kanbante-dash-view') || 'grid'; } catch (e) { /* défaut */ }
|
||||
return {
|
||||
tab: 'active', search: '', view: view,
|
||||
setView: function (v) {
|
||||
this.view = v;
|
||||
try { localStorage.setItem('kanbante-dash-view', v); } catch (e) { /* non-fatal */ }
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<main x-data="dashState()">
|
||||
<!-- Tabs: "actif" = mis à jour dans la fenêtre glissante;
|
||||
"archivé" = le vrai drapeau Gitea (repo.archived). -->
|
||||
<div class="tabs">
|
||||
@@ -46,10 +61,34 @@
|
||||
<kbd class="kbd">/</kbd>
|
||||
</div>
|
||||
|
||||
<!-- Outils d'affichage (#21 tri/regroupement, #22 vue liste) :
|
||||
pilotés par dashboard.js, préférences en localStorage. -->
|
||||
<div class="dash-tools">
|
||||
<label class="dash-tool">↕️ Tri
|
||||
<select id="dash-sort">
|
||||
<option value="default">Par défaut</option>
|
||||
<option value="updated">Activité Gitea récente</option>
|
||||
<option value="activity">Activité kanban récente</option>
|
||||
<option value="open">Issues ouvertes ↓</option>
|
||||
<option value="name">Nom A → Z</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="dash-tool dash-tool-check">
|
||||
<input type="checkbox" id="dash-group"> 🗂️ Grouper par propriétaire
|
||||
</label>
|
||||
<div class="dash-view-toggle" role="group" aria-label="Mode d'affichage">
|
||||
<button type="button" :class="{ active: view === 'grid' }" @click="setView('grid')">▦ Grille</button>
|
||||
<button type="button" :class="{ active: view === 'list' }" @click="setView('list')">☰ Liste</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% macro project_card(repo, archived=False) %}
|
||||
{% set full = repo.full_name %}
|
||||
<div class="project-card{% if archived %} archived{% endif %}"
|
||||
data-full="{{ full }}" data-home="{{ repo.home_tab or '' }}"
|
||||
data-name="{{ repo.name|lower }}" data-owner="{{ repo.owner.login }}"
|
||||
data-updated="{{ repo.updated_at }}" data-open="{{ repo.open_issues_count or 0 }}"
|
||||
data-activity="{{ repo.activity.last_at if repo.activity else '' }}"
|
||||
data-search="{{ (repo.name ~ ' ' ~ repo.full_name ~ ' ' ~ (repo.description or ''))|lower }}"
|
||||
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
|
||||
<div class="card-header">
|
||||
@@ -60,6 +99,19 @@
|
||||
<span class="date">{{ repo.updated_at[:10] }}</span>
|
||||
{% if not archived %}
|
||||
<button type="button" class="star-btn" data-full="{{ full }}" data-starred="{{ 1 if repo.starred else 0 }}" title="{{ 'Désépingler (étoile Gitea)' if repo.starred else 'Épingler (étoile Gitea)' }}" aria-label="Épingler {{ repo.name }}">{{ '⭐' if repo.starred else '☆' }}</button>
|
||||
<div class="card-menu-wrap">
|
||||
<button type="button" class="card-menu-btn" data-menu-btn aria-haspopup="true" aria-expanded="false" title="Actions rapides pour {{ repo.name }}" aria-label="Actions rapides pour {{ repo.name }}">⋯</button>
|
||||
<div class="card-menu" hidden>
|
||||
<a href="/board/{{ full }}">📋 Board</a>
|
||||
<a href="/capture?repo={{ full|urlencode }}">⚡ Capturer ici</a>
|
||||
<a href="/triage/{{ full }}">📥 Triage du backlog</a>
|
||||
<a href="/health/{{ full }}">🩺 Santé</a>
|
||||
<a href="/sprint/{{ full }}">📖 Récit de sprint</a>
|
||||
{% if repo.html_url %}
|
||||
<a href="{{ repo.html_url }}" target="_blank" rel="noopener">🔗 Gitea ↗</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h3>
|
||||
@@ -107,9 +159,62 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if repo.activity %}
|
||||
<div class="activity-line">
|
||||
{% if repo.activity.closed or repo.activity.moved %}
|
||||
📈 {{ repo.activity.closed }} fermée(s) · {{ repo.activity.moved }} déplacement(s) cette semaine
|
||||
{% elif repo.activity.idle_days is not none and repo.activity.idle_days > 7 %}
|
||||
💤 aucune activité kanban depuis {{ repo.activity.idle_days }} j
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro project_row(repo, archived=False) %}
|
||||
{% set full = repo.full_name %}
|
||||
{% set st = stats.get(full, {}) %}
|
||||
<tr data-name="{{ repo.name|lower }}" data-owner="{{ repo.owner.login|lower }}"
|
||||
data-open="{{ repo.open_issues_count or 0 }}"
|
||||
data-inflight="{{ (st.get('in_progress', 0) + st.get('qa', 0) + st.get('review', 0)) }}"
|
||||
data-updated="{{ repo.updated_at }}"
|
||||
data-search="{{ (repo.name ~ ' ' ~ repo.full_name ~ ' ' ~ (repo.description or ''))|lower }}"
|
||||
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
|
||||
<td><a href="/project/{{ full }}"><strong>{{ repo.name }}</strong></a></td>
|
||||
<td>{{ repo.owner.login }}</td>
|
||||
<td>{{ repo.open_issues_count or 0 }}</td>
|
||||
<td>{{ st.get('in_progress', 0) + st.get('qa', 0) + st.get('review', 0) }}</td>
|
||||
<td>{% if repo.next_milestone %}🎯 {{ repo.next_milestone.title }}{% if repo.next_milestone.late %} ⚠️{% endif %}{% else %}—{% endif %}</td>
|
||||
<td>{% if repo.health_dot %}<a href="/health/{{ full }}" class="health-dot {{ repo.health_dot.level }}" title="{{ repo.health_dot.tooltip }}" aria-label="Santé : {{ repo.health_dot.tooltip }}"></a>{% else %}—{% endif %}</td>
|
||||
<td>{{ repo.updated_at[:10] }}</td>
|
||||
</tr>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro project_table(repos, tab_name, archived=False) %}
|
||||
<div x-show="tab === '{{ tab_name }}'">
|
||||
<table class="proj-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="name">Projet</th>
|
||||
<th data-sort="owner">Propriétaire</th>
|
||||
<th data-sort="open">Ouvertes</th>
|
||||
<th data-sort="inflight">En vol</th>
|
||||
<th>Prochain jalon</th>
|
||||
<th>Santé</th>
|
||||
<th data-sort="updated">Dernière activité</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for repo in repos %}{{ project_row(repo, archived) }}{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if not repos %}
|
||||
<div class="empty-state">Aucun projet dans cette vue.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
<div x-show="view === 'grid'">
|
||||
<!-- Épinglés (#20) : les dépôts étoilés dans Gitea, hors onglets. -->
|
||||
<section id="pinned-section"{% if not pinned_repos %} hidden{% endif %}>
|
||||
<h3 class="pinned-title">⭐ Épinglés</h3>
|
||||
@@ -138,6 +243,14 @@
|
||||
<div class="empty-state">🗄️ Aucun projet archivé. L'archivage se fait dans Gitea (Réglages du dépôt).</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vue liste dense (#22) : mêmes onglets, mêmes données. -->
|
||||
<div x-show="view === 'list'">
|
||||
{{ project_table(active_repos, 'active') }}
|
||||
{{ project_table(inactive_repos, 'inactive') }}
|
||||
{{ project_table(archived_repos, 'archived', archived=True) }}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
@@ -146,6 +259,7 @@
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
<script>window.KANBANTE_CSRF = "{{ csrf_token }}";</script>
|
||||
<script src="/static/js/stars.js" defer></script>
|
||||
<script src="/static/js/dashboard.js" defer></script>
|
||||
<script>
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === '/' && !/INPUT|TEXTAREA/.test(document.activeElement?.tagName || '')) {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "kanbante"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
description = "Kanban project manager integrated with Gitea"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -1371,3 +1371,75 @@ body.sidebar-collapsed .side-foot { justify-content: center; }
|
||||
.proj-dim { color: var(--text-3); }
|
||||
.proj-empty { color: var(--text-2); font-size: 0.92rem; }
|
||||
.proj-notes-body { white-space: pre-wrap; font-size: 0.93rem; background: var(--surface-2); border-radius: 10px; padding: 12px 14px; }
|
||||
|
||||
/* ── Dashboard vague 2 suite (0.18.0, #19 #21 #22 #23) ── */
|
||||
.activity-line { margin-top: 8px; font-size: 0.82rem; color: var(--text-2); }
|
||||
.dash-tools {
|
||||
display: flex; align-items: center; gap: 18px; flex-wrap: wrap;
|
||||
padding: 0 24px; margin: 4px 0 14px;
|
||||
}
|
||||
.dash-tool { display: inline-flex; align-items: center; gap: 8px; font-size: 0.88rem; color: var(--text-2); }
|
||||
.dash-tool select {
|
||||
background: var(--surface-2); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 8px; padding: 6px 8px;
|
||||
}
|
||||
.dash-tool-check input { width: 17px; height: 17px; accent-color: #7c5cfc; }
|
||||
.dash-view-toggle { margin-left: auto; display: inline-flex; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
||||
.dash-view-toggle button {
|
||||
background: var(--surface-1); color: var(--text-2); border: none;
|
||||
padding: 7px 14px; cursor: pointer; font-size: 0.88rem;
|
||||
}
|
||||
.dash-view-toggle button.active { background: var(--surface-3, #1d2942); color: var(--text); font-weight: 700; }
|
||||
.owner-group { grid-column: 1 / -1; }
|
||||
.owner-head {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
width: 100%; padding: 9px 12px; margin-bottom: 10px; cursor: pointer;
|
||||
background: var(--surface-1); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
font-size: 0.95rem; font-weight: 700;
|
||||
}
|
||||
.owner-head:hover { border-color: var(--accent, #58a6ff); }
|
||||
.owner-head .owner-chevron { transition: transform 0.2s ease; font-size: 0.7rem; }
|
||||
.owner-head[aria-expanded="false"] .owner-chevron { transform: rotate(-90deg); }
|
||||
.owner-grid { padding: 0 !important; }
|
||||
.card-menu-wrap { position: relative; }
|
||||
.card-menu-btn {
|
||||
background: transparent; border: none; cursor: pointer;
|
||||
color: var(--text-2); font-size: 1.15rem; font-weight: 800;
|
||||
padding: 2px 7px; border-radius: 8px; line-height: 1;
|
||||
}
|
||||
.card-menu-btn:hover { background: var(--surface-2); color: var(--text); }
|
||||
.card-menu {
|
||||
position: absolute; right: 0; top: calc(100% + 6px); z-index: 60;
|
||||
min-width: 210px; display: flex; flex-direction: column; gap: 2px;
|
||||
background: var(--surface-1); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 6px;
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.card-menu[hidden] { display: none !important; }
|
||||
.card-menu a {
|
||||
display: flex; align-items: center; min-height: 32px;
|
||||
padding: 8px 10px; border-radius: 8px;
|
||||
color: var(--text-2); font-size: 0.92rem;
|
||||
}
|
||||
.card-menu a:hover { background: var(--surface-2); color: var(--text); text-decoration: none; }
|
||||
.proj-table {
|
||||
width: calc(100% - 48px); margin: 0 24px 18px;
|
||||
border-collapse: collapse; font-size: 0.93rem;
|
||||
background: var(--surface-1); border: 1px solid var(--border);
|
||||
border-radius: 12px; overflow: hidden;
|
||||
}
|
||||
.proj-table th, .proj-table td {
|
||||
text-align: left; padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.proj-table thead th { color: var(--text-2); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
.proj-table th.sortable { cursor: pointer; }
|
||||
.proj-table th.sortable:hover { color: var(--text); }
|
||||
.proj-table th[data-dir="asc"]::after { content: " ↑"; }
|
||||
.proj-table th[data-dir="desc"]::after { content: " ↓"; }
|
||||
.proj-table tbody tr:hover { background: var(--surface-2); }
|
||||
@media (max-width: 900px) {
|
||||
.proj-table { display: block; overflow-x: auto; }
|
||||
.dash-view-toggle { margin-left: 0; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Kanbante — dashboard display tools (issues #21, #22, #23).
|
||||
* Sort + group-by-owner for the card grids, sortable dense tables,
|
||||
* and the per-card quick-actions menu. Preferences live in
|
||||
* localStorage; the server markup stays the single source.
|
||||
*/
|
||||
(function () {
|
||||
var LS_SORT = 'kanbante-dash-sort';
|
||||
var LS_GROUP = 'kanbante-dash-group';
|
||||
var LS_OWNERS = 'kanbante-dash-owners-closed';
|
||||
|
||||
function lsGet(key, fallback) {
|
||||
try {
|
||||
var v = localStorage.getItem(key);
|
||||
return v === null ? fallback : v;
|
||||
} catch (e) { return fallback; }
|
||||
}
|
||||
function lsSet(key, value) {
|
||||
try { localStorage.setItem(key, value); } catch (e) { /* non-fatal */ }
|
||||
}
|
||||
|
||||
/* ── Tri des grilles de cartes ─────────────────────────── */
|
||||
var sortSelect = document.getElementById('dash-sort');
|
||||
var grids = ['grid-active', 'grid-inactive', 'grid-archived']
|
||||
.map(function (id) { return document.getElementById(id); })
|
||||
.filter(Boolean);
|
||||
|
||||
function cardKey(card, mode) {
|
||||
if (mode === 'name') return (card.getAttribute('data-name') || '').toLowerCase();
|
||||
if (mode === 'open') return -(parseInt(card.getAttribute('data-open') || '0', 10));
|
||||
if (mode === 'updated') return card.getAttribute('data-updated') || '';
|
||||
if (mode === 'activity') return card.getAttribute('data-activity') || '';
|
||||
return null; // 'default' : ordre serveur, on ne touche à rien
|
||||
}
|
||||
function sortCards(container, mode) {
|
||||
var cards = Array.prototype.slice.call(
|
||||
container.querySelectorAll('.project-card'));
|
||||
if (mode === 'default' || !cards.length) return;
|
||||
cards.sort(function (a, b) {
|
||||
var ka = cardKey(a, mode), kb = cardKey(b, mode);
|
||||
if (ka < kb) return mode === 'name' ? -1 : 1;
|
||||
if (ka > kb) return mode === 'name' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
cards.forEach(function (c) { container.appendChild(c); });
|
||||
}
|
||||
function applySort() {
|
||||
var mode = sortSelect ? sortSelect.value : 'default';
|
||||
lsSet(LS_SORT, mode);
|
||||
grids.forEach(function (grid) {
|
||||
if (groupToggle && groupToggle.checked) {
|
||||
grid.querySelectorAll('.owner-grid').forEach(function (og) {
|
||||
sortCards(og, mode);
|
||||
});
|
||||
} else {
|
||||
sortCards(grid, mode);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Regroupement par propriétaire ─────────────────────── */
|
||||
var groupToggle = document.getElementById('dash-group');
|
||||
function closedOwners() {
|
||||
try { return JSON.parse(lsGet(LS_OWNERS, '[]')); } catch (e) { return []; }
|
||||
}
|
||||
function setOwnerClosed(owner, closed) {
|
||||
var list = closedOwners().filter(function (o) { return o !== owner; });
|
||||
if (closed) list.push(owner);
|
||||
lsSet(LS_OWNERS, JSON.stringify(list));
|
||||
}
|
||||
function groupGrid(grid) {
|
||||
var cards = Array.prototype.slice.call(
|
||||
grid.querySelectorAll(':scope > .project-card'));
|
||||
if (!cards.length) return;
|
||||
var byOwner = {};
|
||||
cards.forEach(function (c) {
|
||||
var o = c.getAttribute('data-owner') || '—';
|
||||
(byOwner[o] = byOwner[o] || []).push(c);
|
||||
});
|
||||
Object.keys(byOwner).sort().forEach(function (owner) {
|
||||
var wrap = document.createElement('div');
|
||||
wrap.className = 'owner-group';
|
||||
var head = document.createElement('button');
|
||||
head.type = 'button';
|
||||
head.className = 'owner-head';
|
||||
var closed = closedOwners().indexOf(owner) >= 0;
|
||||
head.setAttribute('aria-expanded', closed ? 'false' : 'true');
|
||||
head.innerHTML = '<span>🗂️ ' + owner + ' (' + byOwner[owner].length + ')</span><span class="owner-chevron">▾</span>';
|
||||
var body = document.createElement('div');
|
||||
body.className = 'project-grid owner-grid';
|
||||
if (closed) body.hidden = true;
|
||||
head.addEventListener('click', function () {
|
||||
body.hidden = !body.hidden;
|
||||
head.setAttribute('aria-expanded', body.hidden ? 'false' : 'true');
|
||||
setOwnerClosed(owner, body.hidden);
|
||||
});
|
||||
byOwner[owner].forEach(function (c) { body.appendChild(c); });
|
||||
wrap.appendChild(head);
|
||||
wrap.appendChild(body);
|
||||
grid.appendChild(wrap);
|
||||
});
|
||||
}
|
||||
function ungroupGrid(grid) {
|
||||
grid.querySelectorAll('.owner-group').forEach(function (wrap) {
|
||||
var body = wrap.querySelector('.owner-grid');
|
||||
Array.prototype.slice.call(body.querySelectorAll('.project-card'))
|
||||
.forEach(function (c) { grid.appendChild(c); });
|
||||
wrap.remove();
|
||||
});
|
||||
}
|
||||
function applyGrouping() {
|
||||
var on = !!(groupToggle && groupToggle.checked);
|
||||
lsSet(LS_GROUP, on ? '1' : '0');
|
||||
grids.forEach(function (grid) {
|
||||
ungroupGrid(grid);
|
||||
if (on) groupGrid(grid);
|
||||
});
|
||||
applySort();
|
||||
}
|
||||
|
||||
if (sortSelect) {
|
||||
sortSelect.value = lsGet(LS_SORT, 'default');
|
||||
sortSelect.addEventListener('change', applySort);
|
||||
}
|
||||
if (groupToggle) {
|
||||
groupToggle.checked = lsGet(LS_GROUP, '0') === '1';
|
||||
groupToggle.addEventListener('change', applyGrouping);
|
||||
}
|
||||
applyGrouping(); // applique regroupement + tri mémorisés
|
||||
|
||||
/* ── Menu ⋯ d'actions rapides (#23) ────────────────────── */
|
||||
function closeMenus() {
|
||||
document.querySelectorAll('.card-menu').forEach(function (m) {
|
||||
m.hidden = true;
|
||||
});
|
||||
document.querySelectorAll('[data-menu-btn]').forEach(function (b) {
|
||||
b.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
}
|
||||
document.querySelectorAll('[data-menu-btn]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
var menu = btn.parentElement.querySelector('.card-menu');
|
||||
var willOpen = menu.hidden;
|
||||
closeMenus();
|
||||
if (willOpen) {
|
||||
menu.hidden = false;
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
});
|
||||
});
|
||||
document.addEventListener('click', closeMenus);
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') closeMenus();
|
||||
});
|
||||
|
||||
/* ── Tri des tableaux denses (#22) ─────────────────────── */
|
||||
document.querySelectorAll('.proj-table').forEach(function (table) {
|
||||
var tbody = table.querySelector('tbody');
|
||||
table.querySelectorAll('th[data-sort]').forEach(function (th) {
|
||||
th.classList.add('sortable');
|
||||
th.addEventListener('click', function () {
|
||||
var key = th.getAttribute('data-sort');
|
||||
var dir = th.getAttribute('data-dir') === 'asc' ? 'desc' : 'asc';
|
||||
table.querySelectorAll('th').forEach(function (h) {
|
||||
h.removeAttribute('data-dir');
|
||||
});
|
||||
th.setAttribute('data-dir', dir);
|
||||
var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
|
||||
rows.sort(function (a, b) {
|
||||
var va = a.getAttribute('data-' + key) || '';
|
||||
var vb = b.getAttribute('data-' + key) || '';
|
||||
var na = parseFloat(va), nb = parseFloat(vb);
|
||||
var cmp;
|
||||
if (!isNaN(na) && !isNaN(nb) && va !== '' && vb !== '') {
|
||||
cmp = na - nb;
|
||||
} else {
|
||||
cmp = va < vb ? -1 : (va > vb ? 1 : 0);
|
||||
}
|
||||
return dir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
rows.forEach(function (r) { tbody.appendChild(r); });
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Palier 14 (0.18.0): vague 2 suite — activité 7 jours (#19),
|
||||
tri/regroupement (#21), vue liste dense (#22), actions rapides (#23)."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app import models
|
||||
|
||||
|
||||
def _ts(days_ago: int) -> str:
|
||||
dt = datetime.now(timezone.utc) - timedelta(days=days_ago)
|
||||
return dt.strftime("%Y-%m-%d 12:00:00")
|
||||
|
||||
|
||||
def test_activity_line_recent_week(client, fake_gitea):
|
||||
for _ in range(2):
|
||||
models.log_event("Projets/demo", 1, "closed",
|
||||
to_column="done", created_at=_ts(1))
|
||||
models.log_event("Projets/demo", 5, "moved",
|
||||
from_column="backlog", to_column="todo",
|
||||
created_at=_ts(0))
|
||||
page = client.get("/")
|
||||
assert "2 fermée(s) · 1 déplacement(s) cette semaine" in page.text
|
||||
|
||||
|
||||
def test_activity_line_idle_project(client, fake_gitea):
|
||||
models.log_event("Projets/vieux", 9, "moved",
|
||||
from_column="backlog", to_column="todo",
|
||||
created_at=_ts(35))
|
||||
page = client.get("/")
|
||||
assert "aucune activité kanban depuis 35 j" in page.text
|
||||
|
||||
|
||||
def test_dashboard_display_tools_markup(client, fake_gitea):
|
||||
page = client.get("/")
|
||||
assert "dashState" in page.text
|
||||
assert 'id="dash-sort"' in page.text
|
||||
assert 'id="dash-group"' in page.text
|
||||
assert "Grouper par propriétaire" in page.text
|
||||
assert "setView('grid')" in page.text
|
||||
assert "setView('list')" in page.text
|
||||
|
||||
|
||||
def test_dense_list_view_tables(client, fake_gitea):
|
||||
page = client.get("/")
|
||||
assert page.text.count('class="proj-table"') == 3
|
||||
assert 'data-sort="open"' in page.text
|
||||
assert 'data-sort="inflight"' in page.text
|
||||
# La ligne dense de demo : lien page projet + compteurs.
|
||||
assert 'href="/project/Projets/demo"' in page.text
|
||||
|
||||
|
||||
def test_dashboard_js_contract(client, fake_gitea):
|
||||
js = client.get("/static/js/dashboard.js")
|
||||
assert js.status_code == 200
|
||||
assert "kanbante-dash-sort" in js.text
|
||||
assert "kanbante-dash-group" in js.text
|
||||
assert "owner-group" in js.text
|
||||
assert "data-menu-btn" in js.text
|
||||
|
||||
|
||||
def test_card_quick_actions_menu(client, fake_gitea):
|
||||
page = client.get("/")
|
||||
assert "data-menu-btn" in page.text
|
||||
# urlencode de Jinja conserve les « / » : le lien est valide tel quel.
|
||||
assert 'href="/capture?repo=Projets/demo"' in page.text
|
||||
assert 'href="/triage/Projets/demo"' in page.text
|
||||
assert 'href="/sprint/Projets/demo"' in page.text
|
||||
assert 'href="/health/Projets/demo"' in page.text
|
||||
|
||||
|
||||
def test_capture_prefilled_repo(client, fake_gitea):
|
||||
page = client.get("/capture?repo=Projets/kanbante")
|
||||
assert page.status_code == 200
|
||||
assert 'value="Projets/kanbante"' in page.text
|
||||
Reference in New Issue
Block a user