diff --git a/src/viewer/app.py b/src/viewer/app.py
index b1ccfa3..90660dd 100644
--- a/src/viewer/app.py
+++ b/src/viewer/app.py
@@ -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()
diff --git a/src/viewer/templates/base.html b/src/viewer/templates/base.html
index c955224..12d57a6 100644
--- a/src/viewer/templates/base.html
+++ b/src/viewer/templates/base.html
@@ -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;
+ }
diff --git a/src/viewer/templates/detail.html b/src/viewer/templates/detail.html
index a692a68..2056aa3 100644
--- a/src/viewer/templates/detail.html
+++ b/src/viewer/templates/detail.html
@@ -14,7 +14,7 @@
{% endif %}
Actions
-
+
{% endblock %}
{% block content %}
@@ -33,7 +33,7 @@
{% if dossier.processing_time_s is not none %}
- {{ dossier.processing_time_s }}s
+ {{ dossier.processing_time_s|format_duration }}
{% endif %}
@@ -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 = ' Traitement en cours...';
+ status.innerHTML = 'Demande envoyée, traitement lancé. Veuillez patienter...';
+
+ 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 = 'Traitement en cours... ' + timeStr + '';
+ }, 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 = 'Traitement terminé. Rechargement...';
+ btn.style.background = '#16a34a';
+ btn.innerHTML = 'Terminé';
+ setTimeout(() => location.reload(), 1000);
} else {
- status.textContent = (data.error || 'Erreur');
- status.style.color = '#dc2626';
+ status.innerHTML = '' + (data.error || 'Erreur') + '';
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 = 'Erreur réseau';
btn.disabled = false;
- btn.textContent = 'Relancer l\'étude';
+ btn.style.background = '#3b82f6';
+ btn.innerHTML = 'Relancer l\'étude';
}
});
diff --git a/src/viewer/templates/index.html b/src/viewer/templates/index.html
index a506272..0c951f7 100644
--- a/src/viewer/templates/index.html
+++ b/src/viewer/templates/index.html
@@ -35,7 +35,7 @@
{{ group_name }}
- {{ 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 %}
{% if stats %}
{{ stats.das_count }} DAS
@@ -84,7 +84,7 @@
{% endif %}
{% if item.dossier.processing_time_s is not none %}
- Traitement : {{ item.dossier.processing_time_s }}s
+ Traitement : {{ item.dossier.processing_time_s|format_duration }}
{% endif %}
diff --git a/tests/test_viewer.py b/tests/test_viewer.py
index 21e2a7f..67df608 100644
--- a/tests/test_viewer.py
+++ b/tests/test_viewer.py
@@ -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("/")