Implement timezone-aware datetime display across all pages

All datetime fields now automatically convert from UTC (database storage)
to the configured UI timezone (Settings > General > UI Timezone) for display.

Changes:
- Added local_datetime Jinja2 template filter in app/__init__.py
  - Converts UTC datetime to UI timezone using zoneinfo.ZoneInfo
  - Accepts optional strftime format parameter (default: '%Y-%m-%d %H:%M:%S')
  - Returns empty string for None values
  - Falls back to UTC display if conversion fails
- Updated all datetime displays across 15+ templates:
  - customers.html: autotask_last_sync_at
  - settings.html: autotask_last_sync_at
  - feedback.html, feedback_detail.html: created_at, updated_at, resolved_at
  - logging.html: created_at
  - overrides.html: start_at, end_at
  - archived_jobs.html: archived_at
  - tickets.html, ticket_detail.html: resolved_at, run_at
  - inbox.html, inbox_deleted.html, admin_all_mail.html: received_at, parsed_at, deleted_at
  - job_detail.html: run_at, reviewed_at
  - remark_detail.html: run_at
  - settings_news_reads.html: read_at
- Database values remain in UTC for consistency
- Default timezone: Europe/Amsterdam (configurable via SystemSettings.ui_timezone)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Ivo Oskamp 2026-02-07 00:12:59 +01:00
parent 86308d2001
commit 8e8b7a4412
17 changed files with 85 additions and 26 deletions

View File

@ -63,6 +63,57 @@ def create_app():
app.register_blueprint(auth_bp)
app.register_blueprint(main_bp)
@app.template_filter("local_datetime")
def format_local_datetime(utc_dt, format="%Y-%m-%d %H:%M:%S"):
"""Convert UTC datetime to UI timezone and format as string.
Args:
utc_dt: datetime object in UTC (or None)
format: strftime format string (default: '%Y-%m-%d %H:%M:%S')
Returns:
Formatted datetime string in UI timezone, or empty string if input is None
"""
if utc_dt is None:
return ""
from datetime import datetime
try:
from zoneinfo import ZoneInfo
except ImportError:
ZoneInfo = None # type: ignore
# Get UI timezone from settings
tz_name = "Europe/Amsterdam"
try:
from .models import SystemSettings
settings = SystemSettings.query.first()
if settings and getattr(settings, "ui_timezone", None):
tz_name = settings.ui_timezone
except Exception:
pass
# Convert UTC to UI timezone
if ZoneInfo:
try:
utc_tz = ZoneInfo("UTC")
local_tz = ZoneInfo(tz_name)
# Ensure utc_dt is timezone-aware (assume UTC if naive)
if utc_dt.tzinfo is None:
utc_dt = utc_dt.replace(tzinfo=utc_tz)
# Convert to local timezone
local_dt = utc_dt.astimezone(local_tz)
return local_dt.strftime(format)
except Exception:
pass
# Fallback: return UTC time if conversion fails
return utc_dt.strftime(format)
@app.before_request
def _redirect_to_dashboard_on_first_open_each_day():
"""Redirect the first authenticated page view of the day to the dashboard.

View File

@ -126,7 +126,7 @@
{% if rows %}
{% for row in rows %}
<tr class="mail-row" data-message-id="{{ row.id }}" style="cursor: pointer;">
<td>{{ row.received_at }}</td>
<td>{{ row.received_at|local_datetime }}</td>
<td>{{ row.from_address }}</td>
<td>{{ row.subject }}</td>
<td>{{ row.backup_software }}</td>
@ -139,7 +139,7 @@
<span class="badge bg-warning text-dark">Unlinked</span>
{% endif %}
</td>
<td>{{ row.parsed_at }}</td>
<td>{{ row.parsed_at|local_datetime }}</td>
<td>
{% if row.has_eml %}
<a class="eml-download" href="{{ url_for('main.inbox_message_eml', message_id=row.id) }}" onclick="event.stopPropagation();">EML</a>

View File

@ -24,7 +24,7 @@
<td>
<a class="text-decoration-none" href="{{ url_for('main.job_detail', job_id=j.id) }}">{{ j.job_name }}</a>
</td>
<td>{{ j.archived_at }}</td>
<td>{{ j.archived_at|local_datetime }}</td>
<td class="text-end">
<form method="post" action="{{ url_for('main.unarchive_job', job_id=j.id) }}" style="display:inline;">
<button type="submit" class="btn btn-sm btn-outline-secondary">Restore</button>

View File

@ -88,7 +88,7 @@
{% endif %}
{% if c.autotask_last_sync_at %}
<div class="text-muted small">Checked: {{ c.autotask_last_sync_at }}</div>
<div class="text-muted small">Checked: {{ c.autotask_last_sync_at|local_datetime }}</div>
{% endif %}
</td>
{% if can_manage %}
@ -104,7 +104,7 @@
data-autotask-company-id="{{ c.autotask_company_id or '' }}"
data-autotask-company-name="{{ c.autotask_company_name or '' }}"
data-autotask-mapping-status="{{ c.autotask_mapping_status or '' }}"
data-autotask-last-sync-at="{{ c.autotask_last_sync_at or '' }}"
data-autotask-last-sync-at="{{ c.autotask_last_sync_at|local_datetime if c.autotask_last_sync_at else '' }}"
>
Edit
</button>

View File

@ -87,8 +87,8 @@
{% endif %}
</td>
<td>
<div>{{ i.created_at }}</div>
<div class="text-muted" style="font-size: 0.85rem;">Updated {{ i.updated_at }}</div>
<div>{{ i.created_at|local_datetime }}</div>
<div class="text-muted" style="font-size: 0.85rem;">Updated {{ i.updated_at|local_datetime }}</div>
</td>
</tr>
{% endfor %}

View File

@ -32,12 +32,12 @@
</div>
<div class="card-footer d-flex justify-content-between align-items-center">
<div class="text-muted" style="font-size: 0.9rem;">
Created {{ item.created_at }}
Created {{ item.created_at|local_datetime }}
<span class="mx-1"></span>
Updated {{ item.updated_at }}
Updated {{ item.updated_at|local_datetime }}
{% if item.status == 'resolved' and item.resolved_at %}
<span class="mx-1"></span>
Resolved {{ item.resolved_at }}{% if resolved_by_name %} by {{ resolved_by_name }}{% endif %}
Resolved {{ item.resolved_at|local_datetime }}{% if resolved_by_name %} by {{ resolved_by_name }}{% endif %}
{% endif %}
</div>
<form method="post" action="{{ url_for('main.feedback_vote', item_id=item.id) }}">
@ -59,7 +59,7 @@
<div class="d-flex justify-content-between align-items-start">
<strong>{{ reply_user_map.get(r.user_id, '') or ('User #' ~ r.user_id) }}</strong>
<span class="text-muted" style="font-size: 0.85rem;">
{{ r.created_at.strftime('%d-%m-%Y %H:%M:%S') if r.created_at else '' }}
{{ r.created_at|local_datetime('%d-%m-%Y %H:%M:%S') if r.created_at else '' }}
</span>
</div>
<div style="white-space: pre-wrap;">{{ r.message }}</div>

View File

@ -98,12 +98,12 @@
{% endif %}
<td>{{ row.from_address }}</td>
<td>{{ row.subject }}</td>
<td>{{ row.received_at }}</td>
<td>{{ row.received_at|local_datetime }}</td>
<td>{{ row.backup_software }}</td>
<td>{{ row.backup_type }}</td>
<td>{{ row.job_name }}</td>
<td>{{ row.overall_status }}</td>
<td>{{ row.parsed_at }}</td>
<td>{{ row.parsed_at|local_datetime }}</td>
<td>
{% if row.has_eml %}
<a class="eml-download" href="{{ url_for('main.inbox_message_eml', message_id=row.id) }}">EML</a>

View File

@ -60,9 +60,9 @@
<tr class="deleted-mail-row" data-message-id="{{ row.id }}" style="cursor: pointer;">
<td>{{ row.from_address }}</td>
<td>{{ row.subject }}</td>
<td>{{ row.received_at }}</td>
<td>{{ row.received_at|local_datetime }}</td>
<td>{{ row.deleted_by }}</td>
<td>{{ row.deleted_at }}</td>
<td>{{ row.deleted_at|local_datetime }}</td>
<td>
{% if row.has_eml %}
<a class="eml-download" href="{{ url_for('main.inbox_message_eml', message_id=row.id) }}">EML</a>

View File

@ -82,7 +82,7 @@
{% for r in history_rows %}
<tr{% if r.mail_message_id %} class="jobrun-row" data-message-id="{{ r.mail_message_id }}" data-run-id="{{ r.id }}" data-ticket-codes="{{ (r.ticket_codes or [])|tojson|forceescape }}" data-remark-items="{{ (r.remark_items or [])|tojson|forceescape }}" style="cursor: pointer;"{% endif %}>
<td>{{ r.run_day }}</td>
<td>{{ r.run_at }}</td>
<td>{{ r.run_at|local_datetime }}</td>
{% set _s = (r.status or "")|lower %}
{% set _is_override = (r.override_applied is defined and r.override_applied) or ('override' in _s) %}
{% set _dot = '' %}
@ -99,7 +99,7 @@
<td class="status-text {% if r.override_applied %}status-override{% endif %}">{% if r.override_applied %}<span class="status-dot dot-override me-2" aria-hidden="true"></span>Override{% endif %}</td>
{% if active_role == 'admin' %}
<td>{{ r.reviewed_by }}</td>
<td>{{ r.reviewed_at }}</td>
<td>{{ r.reviewed_at|local_datetime }}</td>
{% endif %}
</tr>
{% endfor %}

View File

@ -46,7 +46,7 @@
{% if logs %}
{% for log in logs %}
<tr>
<td>{{ log.created_at }}</td>
<td>{{ log.created_at|local_datetime }}</td>
<td>{{ log.user or "-" }}</td>
<td>{{ log.event_type }}</td>
<td>{{ log.message }}</td>

View File

@ -130,8 +130,8 @@
<tr>
<td>{{ ov.level }}</td>
<td>{{ ov.scope }}</td>
<td>{{ ov.start_at }}</td>
<td>{{ ov.end_at or "-" }}</td>
<td>{{ ov.start_at|local_datetime }}</td>
<td>{{ ov.end_at|local_datetime if ov.end_at else "-" }}</td>
<td>
{% if ov.active %}
<span class="badge bg-success">Active</span>

View File

@ -76,7 +76,7 @@
<tbody>
{% for r in runs %}
<tr>
<td class="text-nowrap">{{ r.run_at }}</td>
<td class="text-nowrap">{{ r.run_at|local_datetime }}</td>
<td>{{ r.customer_name }}</td>
<td>{{ r.job_name }}</td>
<td>{{ r.status }}</td>

View File

@ -476,7 +476,7 @@
<div class="text-muted small">Last reference data sync</div>
<div class="fw-semibold">
{% if autotask_last_sync_at %}
{{ autotask_last_sync_at }}
{{ autotask_last_sync_at|local_datetime }}
{% else %}
never
{% endif %}

View File

@ -26,7 +26,7 @@
{% for read, user in reads %}
<tr>
<td>{{ user.username }}</td>
<td>{{ read.read_at }}</td>
<td>{{ read.read_at|local_datetime }}</td>
</tr>
{% endfor %}
</tbody>

View File

@ -73,7 +73,7 @@
<tbody>
{% for r in runs %}
<tr>
<td class="text-nowrap">{{ r.run_at }}</td>
<td class="text-nowrap">{{ r.run_at|local_datetime }}</td>
<td>{{ r.customer_name }}</td>
<td>{{ r.job_name }}</td>
<td>{{ r.status }}</td>

View File

@ -87,7 +87,7 @@
<td class="text-end">{{ t.linked_runs }}</td>
<td class="text-nowrap">{{ t.active_from_date }}</td>
<td class="text-nowrap">{{ t.start_date }}</td>
<td class="text-nowrap">{{ t.resolved_at }}</td>
<td class="text-nowrap">{{ t.resolved_at|local_datetime }}</td>
<td class="text-nowrap">
<a class="btn btn-sm btn-outline-primary" href="{{ url_for('main.ticket_detail', ticket_id=t.id) }}">View</a>
{% if t.active and t.job_id %}
@ -142,7 +142,7 @@
<td>{{ r.scope_summary }}</td>
<td class="text-end">{{ r.linked_runs }}</td>
<td class="text-nowrap">{{ r.start_date }}</td>
<td class="text-nowrap">{{ r.resolved_at }}</td>
<td class="text-nowrap">{{ r.resolved_at|local_datetime }}</td>
<td class="text-nowrap">
<a class="btn btn-sm btn-outline-primary" href="{{ url_for('main.remark_detail', remark_id=r.id) }}">View</a>
{% if r.active and r.job_id %}

View File

@ -44,6 +44,14 @@ This file documents all changes made to this project via Claude Code.
- Schema remains `approved_jobs_export_v1` for backwards compatibility
- Import message now shows both created and updated customer counts
- Enables preservation of Autotask company mappings during system reset/migration workflows
- Implemented timezone-aware datetime display across all pages:
- **Template Filter**: Added `local_datetime` Jinja2 filter to convert UTC datetimes to UI timezone
- **Automatic Conversion**: All datetime fields now automatically display in configured timezone (Settings > General > UI Timezone)
- **Database**: All datetime values remain stored in UTC for consistency
- **Affected Pages**: Customers (Autotask sync time), Settings (reference data sync), Feedback, Logging, Overrides, Archived Jobs, Tickets, Remarks, Inbox, Job Detail, Admin Mail
- **Custom Format Support**: Filter accepts strftime format parameter (e.g., `|local_datetime('%d-%m-%Y %H:%M')`)
- **Graceful Fallback**: Falls back to UTC display if timezone conversion fails
- **Default Timezone**: Europe/Amsterdam (configurable via SystemSettings.ui_timezone)
### Changed
- Renamed "Refresh" button to "Search" in Link existing Autotask ticket modal for better clarity (the button performs a search operation)