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("/")