feat: durées en minutes + feedback visuel du retraitement

- Filtre format_duration : affiche les temps en min/s au lieu de secondes brutes
- Bouton reprocess : spinner animé, compteur temps réel, confirmation immédiate

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-02-11 17:18:03 +01:00
parent 9d07894c6f
commit 86a26b9f8c
5 changed files with 72 additions and 17 deletions

View File

@@ -161,6 +161,19 @@ _SEVERITY_STYLES = {
}
def format_duration(seconds: float | None) -> str:
"""Formate une durée en secondes vers un format lisible (ex: 2min 30s)."""
if seconds is None:
return ""
if seconds < 60:
return f"{seconds:.1f}s"
minutes = int(seconds // 60)
secs = int(seconds % 60)
if secs == 0:
return f"{minutes}min"
return f"{minutes}min {secs:02d}s"
def severity_badge(value: str | None) -> Markup:
if not value or value not in _SEVERITY_STYLES:
return Markup("")
@@ -182,6 +195,7 @@ def create_app() -> Flask:
app.jinja_env.filters["confidence_badge"] = confidence_badge
app.jinja_env.filters["confidence_label"] = confidence_label
app.jinja_env.filters["severity_badge"] = severity_badge
app.jinja_env.filters["format_duration"] = format_duration
ccam_dict = load_ccam_dict()

View File

@@ -198,6 +198,18 @@
/* Source files */
.source-files { font-size: 0.8rem; color: #64748b; margin-top: 0.5rem; }
.source-files code { background: #f1f5f9; padding: 1px 4px; border-radius: 3px; }
/* Spinner animation */
@keyframes spin { to { transform: rotate(360deg); } }
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
</style>
</head>
<body>

View File

@@ -14,7 +14,7 @@
{% endif %}
<div class="group-title" style="margin-top:1.5rem;">Actions</div>
<button id="reprocess-btn" style="width:100%;padding:0.6rem;background:#3b82f6;color:white;border:none;border-radius:0.375rem;cursor:pointer;font-size:0.875rem;font-weight:600;margin-bottom:0.5rem;">Relancer l'étude</button>
<div id="reprocess-status" style="font-size:0.75rem;padding:0.25rem;"></div>
<div id="reprocess-status" style="font-size:0.75rem;padding:0.25rem;min-height:1.5rem;"></div>
{% endblock %}
{% block content %}
@@ -33,7 +33,7 @@
{% if dossier.processing_time_s is not none %}
<div class="info-item">
<label>Temps de traitement</label>
<span>{{ dossier.processing_time_s }}s</span>
<span>{{ dossier.processing_time_s|format_duration }}</span>
</div>
{% endif %}
</div>
@@ -286,29 +286,41 @@ document.getElementById('reprocess-btn').addEventListener('click', async () => {
const status = document.getElementById('reprocess-status');
btn.disabled = true;
btn.textContent = 'Traitement en cours...';
status.textContent = '';
status.style.color = '#3b82f6';
btn.style.background = '#64748b';
btn.innerHTML = '<span style="display:inline-flex;align-items:center;gap:0.4rem;"><span class="spinner"></span> Traitement en cours...</span>';
status.innerHTML = '<span style="color:#3b82f6;">Demande envoyée, traitement lancé. Veuillez patienter...</span>';
const startTime = Date.now();
const timer = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const min = Math.floor(elapsed / 60);
const sec = elapsed % 60;
const timeStr = min > 0 ? min + 'min ' + String(sec).padStart(2, '0') + 's' : sec + 's';
status.innerHTML = '<span style="color:#3b82f6;">Traitement en cours... ' + timeStr + '</span>';
}, 1000);
try {
const response = await fetch('/reprocess/{{ filepath }}', { method: 'POST' });
clearInterval(timer);
const data = await response.json();
if (data.ok) {
status.textContent = data.message;
status.style.color = '#16a34a';
setTimeout(() => location.reload(), 1500);
status.innerHTML = '<span style="color:#16a34a;font-weight:600;">Traitement terminé. Rechargement...</span>';
btn.style.background = '#16a34a';
btn.innerHTML = 'Terminé';
setTimeout(() => location.reload(), 1000);
} else {
status.textContent = (data.error || 'Erreur');
status.style.color = '#dc2626';
status.innerHTML = '<span style="color:#dc2626;">' + (data.error || 'Erreur') + '</span>';
btn.disabled = false;
btn.textContent = 'Relancer l\'étude';
btn.style.background = '#3b82f6';
btn.innerHTML = 'Relancer l\'étude';
}
} catch (err) {
status.textContent = 'Erreur réseau';
status.style.color = '#dc2626';
clearInterval(timer);
status.innerHTML = '<span style="color:#dc2626;">Erreur réseau</span>';
btn.disabled = false;
btn.textContent = 'Relancer l\'étude';
btn.style.background = '#3b82f6';
btn.innerHTML = 'Relancer l\'étude';
}
});
</script>

View File

@@ -35,7 +35,7 @@
<h3 style="display:flex;align-items:baseline;gap:0.75rem;flex-wrap:wrap;">
{{ group_name }}
<span style="font-size:0.75rem;font-weight:400;color:#64748b;">
{{ items|length }} fichier(s){% if ns.count %} — total : {{ ns.total|round(1) }}s{% endif %}
{{ items|length }} fichier(s){% if ns.count %} — total : {{ ns.total|format_duration }}{% endif %}
</span>
{% if stats %}
<span class="badge-count badge-das">{{ stats.das_count }} DAS</span>
@@ -84,7 +84,7 @@
{% endif %}
{% if item.dossier.processing_time_s is not none %}
<div style="margin-top:0.5rem;font-size:0.75rem;color:#64748b;">
Traitement : {{ item.dossier.processing_time_s }}s
Traitement : {{ item.dossier.processing_time_s|format_duration }}
</div>
{% endif %}
</div>

View File

@@ -2,7 +2,7 @@
import pytest
from src.viewer.app import create_app, compute_group_stats, severity_badge
from src.viewer.app import create_app, compute_group_stats, severity_badge, format_duration
from src.config import DossierMedical, Diagnostic, ActeCCAM
@@ -80,6 +80,23 @@ class TestSeverityBadgeFilter:
assert result == ""
class TestFormatDuration:
def test_none(self):
assert format_duration(None) == ""
def test_seconds_only(self):
assert format_duration(45.3) == "45.3s"
def test_minutes(self):
assert format_duration(150.0) == "2min 30s"
def test_exact_minutes(self):
assert format_duration(120.0) == "2min"
def test_large_duration(self):
assert format_duration(1257.65) == "20min 57s"
class TestIndexPageLoads:
def test_index_page_loads(self, client):
response = client.get("/")