diff --git a/src/frontend/flask_app/app.py b/src/frontend/flask_app/app.py
index 7dfe09d..d1942be 100644
--- a/src/frontend/flask_app/app.py
+++ b/src/frontend/flask_app/app.py
@@ -2,7 +2,8 @@
import json
import os
import re
-from datetime import date
+from datetime import date, datetime, timezone
+from zoneinfo import ZoneInfo
from urllib.parse import urlparse
from flask import Flask, flash, g, jsonify, redirect, url_for, session, request
@@ -154,6 +155,26 @@ def create_app() -> Flask:
bolded = _BOLD_RX.sub(r"\1", escaped)
return Markup(bolded.replace("\n", "
"))
+ @app.template_filter("local_dt")
+ def local_dt_filter(value, fmt="%d/%m/%Y %H:%M"):
+ """Show a UTC datetime (ISO string or datetime) in DISPLAY_TIMEZONE.
+
+ A value without timezone is taken as UTC. None or empty gives "-".
+ """
+ if value is None or value == "":
+ return "-"
+ if isinstance(value, str):
+ try:
+ value = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
+ except ValueError:
+ return value
+ if not isinstance(value, datetime):
+ return str(value)
+ if value.tzinfo is None:
+ value = value.replace(tzinfo=timezone.utc)
+ tz = ZoneInfo(app.config.get("DISPLAY_TIMEZONE") or "Europe/Rome")
+ return value.astimezone(tz).strftime(fmt)
+
@app.template_filter("tojson_attr")
def tojson_attr_filter(value):
"""JSON encode safe for HTML attributes (x-data, etc.).
diff --git a/src/frontend/flask_app/config.py b/src/frontend/flask_app/config.py
index c1528a4..6533c08 100644
--- a/src/frontend/flask_app/config.py
+++ b/src/frontend/flask_app/config.py
@@ -56,6 +56,8 @@ class Config:
# Babel i18n
BABEL_DEFAULT_LOCALE = "it"
BABEL_DEFAULT_TIMEZONE = "Europe/Rome"
+ # Il DB e le API parlano UTC; si converte solo alla presentazione.
+ DISPLAY_TIMEZONE: str = os.getenv("DISPLAY_TIMEZONE", "Europe/Rome")
LANGUAGES = {"it": "Italiano", "en": "English"}
diff --git a/src/frontend/flask_app/static/js/csv-export.js b/src/frontend/flask_app/static/js/csv-export.js
index d618707..bd5c685 100644
--- a/src/frontend/flask_app/static/js/csv-export.js
+++ b/src/frontend/flask_app/static/js/csv-export.js
@@ -271,7 +271,7 @@ function csvExport() {
*/
generateFilename(prefix) {
const now = new Date();
- const dateStr = now.toISOString().slice(0, 10); // YYYY-MM-DD
+ const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; // YYYY-MM-DD locale
const timeStr = now.toTimeString().slice(0, 5).replace(':', ''); // HHMM
return `${prefix}_${dateStr}_${timeStr}.csv`;
diff --git a/src/frontend/flask_app/templates/measure/task_complete.html b/src/frontend/flask_app/templates/measure/task_complete.html
index bb4da8c..daf69b2 100644
--- a/src/frontend/flask_app/templates/measure/task_complete.html
+++ b/src/frontend/flask_app/templates/measure/task_complete.html
@@ -183,7 +183,7 @@
{% for m in measurements|sort(attribute='subtask.marker_number')|sort(attribute='task_info.order_index') %}
|
- {{ m.measured_at[:16]|replace('T', ' ') if m.measured_at else '-' }}
+ {{ m.measured_at|local_dt }}
|
{{ m.task_info.title if m.task_info else '-' }}
diff --git a/src/frontend/flask_app/templates/statistics/dashboard.html b/src/frontend/flask_app/templates/statistics/dashboard.html
index 888d919..5945cad 100644
--- a/src/frontend/flask_app/templates/statistics/dashboard.html
+++ b/src/frontend/flask_app/templates/statistics/dashboard.html
@@ -312,8 +312,8 @@ function spcDashboard() {
const params = new URLSearchParams();
params.set('recipe_id', this.selectedRecipeId);
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
- if (this.dateFrom) params.set('date_from', this.dateFrom + 'T00:00:00');
- if (this.dateTo) params.set('date_to', this.dateTo + 'T23:59:59');
+ if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
+ if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
try {
// Parallel fetch of all endpoints
@@ -417,8 +417,8 @@ function spcDashboard() {
const params = new URLSearchParams();
params.set('recipe_id', this.selectedRecipeId);
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
- if (this.dateFrom) params.set('date_from', this.dateFrom + 'T00:00:00');
- if (this.dateTo) params.set('date_to', this.dateTo + 'T23:59:59');
+ if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
+ if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
const url = '/statistics/api/report-' + type + '?' + params.toString();
const resp = await fetch(url);
diff --git a/src/frontend/flask_app/tests/test_local_dt.py b/src/frontend/flask_app/tests/test_local_dt.py
new file mode 100644
index 0000000..a9f315b
--- /dev/null
+++ b/src/frontend/flask_app/tests/test_local_dt.py
@@ -0,0 +1,31 @@
+"""Filtro local_dt: il backend parla UTC, la presentazione e' in Europe/Rome (#42)."""
+from datetime import datetime, timezone
+
+import pytest
+
+
+@pytest.fixture
+def local_dt(flask_app):
+ return flask_app.jinja_env.filters["local_dt"]
+
+
+def test_summer_time(local_dt):
+ assert local_dt("2026-10-06T08:00:00+00:00") == "06/10/2026 10:00"
+
+
+def test_winter_time(local_dt):
+ assert local_dt("2026-01-15T08:00:00+00:00") == "15/01/2026 09:00"
+
+
+def test_naive_is_utc(local_dt):
+ assert local_dt("2026-10-06T08:00:00") == "06/10/2026 10:00"
+
+
+def test_datetime_object_and_format(local_dt):
+ dt = datetime(2026, 10, 6, 8, 0, tzinfo=timezone.utc)
+ assert local_dt(dt, "%H:%M") == "10:00"
+
+
+@pytest.mark.parametrize("empty", [None, ""])
+def test_empty(local_dt, empty):
+ assert local_dt(empty) == "-"
diff --git a/src/frontend/flask_app/tests/test_measure.py b/src/frontend/flask_app/tests/test_measure.py
index 8188050..69379a1 100644
--- a/src/frontend/flask_app/tests/test_measure.py
+++ b/src/frontend/flask_app/tests/test_measure.py
@@ -407,6 +407,21 @@ class TestTaskComplete:
assert resp.status_code == 200
assert b"productionClock(" in resp.data
+ def test_measured_at_shown_in_local_time(self, logged_in_client, mock_api_client):
+ """#42: the API sends UTC; the summary shows Europe/Rome."""
+ mock_api_client.get.side_effect = [
+ {"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
+ [{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": [
+ {"id": 1, "marker_number": 1, "description": "d", "nominal": 1.0,
+ "unit": "mm"}]}],
+ None,
+ {"items": [{"id": 1, "subtask_id": 1, "value": 1.0, "pass_fail": "pass",
+ "measured_at": "2026-10-06T08:00:00+00:00"}]},
+ ]
+ resp = logged_in_client.get("/measure/complete/1?version_id=5")
+ assert resp.status_code == 200
+ assert "06/10/2026 10:00" in resp.get_data(as_text=True)
+
def test_measurements_filtered_by_lot_serial_and_operator(
self, logged_in_client, mock_api_client,
):
|