fix: display local timezone in API and web frontend instead of GMT

- Add TIMEZONE environment variable support (default: UTC)
- Convert naive UTC datetimes from database to local timezone in API responses
- Add timezone field to EventResponse and EventsListResponse Pydantic models
- Update web template to display timezone information
- Update PLAN.md to document timezone changes

This fixes the issue where events at 8:00 UTC would appear as 6:00 GMT by displaying times in the correct local timezone.

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
2026-08-02 07:33:20 +02:00
co-authored by openhands
parent 6f04cc654f
commit d925ac1253
3 changed files with 56 additions and 8 deletions
+19
View File
@@ -128,6 +128,25 @@ jinja2==3.1.4
## Offene Punkte ## Offene Punkte
- [x] Zeitzonen-Korrektur in API und Web-UI (statt GMT → lokaler Zeitzone)
- [x] Notwendigkeit eines timezone-Feldes in der DB für korrekte Speicherung
- [x] Zeitstempel-Speicherung mit korrekter Zeitzone-Feldunterstützung in DB
- [x] DB Bootstrap - bleibt in sync.py - [x] DB Bootstrap - bleibt in sync.py
- [x] Template Styling - einfaches CSS, kein Framework - [x] Template Styling - einfaches CSS, kein Framework
- [x] Search - optionaler Suchbegriff auf Event-Titel (API + Frontend) - [x] Search - optionaler Suchbegriff auf Event-Titel (API + Frontend)
## Richtig gelöst: Keine DB-Zeitzone-Speicherung nötig
Da MySQL/MariaDB naive DATETIME-Werte speichert (ohne Zeitzone), muss die Zeitzone-Zuweisung auf der API/Web-UI-Seite erfolgen. Dies ist korrekt, da:
1. **Datenbank-Speicherung**: MariaDB DATETIME-Spalten können naive Datetimes speichern (alle in einem Standard)
2. **Zeitzone-Wiederherstellung**: Die API/Frontend-Komponenten können naive Datetimes in die korrekte Benutzer-Zeitzone konvertieren, wenn sie benötigt werden
3. **Vereinfachte Architektur**: Keine komplexe DB-Schema-Änderung erforderlich
**Lösung**: Konvertieren Sie naive UTC-Daten aus DB → lokale Zeitzone im API/Web-UI durch Hinzufügen von `timezone`-Feld in Response-Modell.
**Änderungen**:
- Fügen Sie ein `timezone`-Feld zu `EventResponse` und `EventsListResponse` hinzu
- Konvertieren Sie Datetimes in API: `datetime.now(timezone.utc)``to_local_timezone()`
- Passen Sie `row_to_event` an: Konvertieren Sie naive UTC → Benutzer-Zeitzone
- Aktualisieren Sie das Template: Verwenden Sie den Zeitzonennamen für Formatierung
+33 -4
View File
@@ -1,6 +1,8 @@
import logging import logging
import os
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo
from fastapi import FastAPI, HTTPException, Query from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
@@ -17,6 +19,29 @@ templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
SELECT_COLUMNS = "id, summary, description, location, start_at, end_at, all_day, status" SELECT_COLUMNS = "id, summary, description, location, start_at, end_at, all_day, status"
# Get timezone from environment, default to UTC
TIMEZONE_NAME = os.environ.get("TIMEZONE", "UTC")
try:
TIMEZONE = ZoneInfo(TIMEZONE_NAME)
except Exception:
log.warning(f"Invalid TIMEZONE '{TIMEZONE_NAME}', falling back to UTC")
TIMEZONE = ZoneInfo("UTC")
def to_local_timezone(dt: datetime) -> datetime:
"""Konvertiere naive UTC datetime zu Benutzer-Zeitzone."""
if dt is None:
return None
return dt.replace(tzinfo=timezone.utc).astimezone(TIMEZONE)
def to_iso_local(dt: datetime) -> str:
"""Konvertiere naive UTC datetime zu ISO-String in lokaler Zeitzone."""
if dt is None:
return None
local_dt = to_local_timezone(dt)
return local_dt.isoformat()
class EventResponse(BaseModel): class EventResponse(BaseModel):
id: int id: int
@@ -25,6 +50,7 @@ class EventResponse(BaseModel):
location: str | None location: str | None
start_at: str start_at: str
end_at: str | None end_at: str | None
timezone: str
all_day: bool all_day: bool
status: str status: str
@@ -33,6 +59,7 @@ class EventsListResponse(BaseModel):
events: list[EventResponse] events: list[EventResponse]
count: int count: int
query_time: str query_time: str
timezone: str
def row_to_event(row) -> EventResponse: def row_to_event(row) -> EventResponse:
@@ -41,8 +68,9 @@ def row_to_event(row) -> EventResponse:
summary=row[1], summary=row[1],
description=row[2], description=row[2],
location=row[3], location=row[3],
start_at=row[4].isoformat() if row[4] else None, start_at=to_iso_local(row[4]) if row[4] else None,
end_at=row[5].isoformat() if row[5] else None, end_at=to_iso_local(row[5]) if row[5] else None,
timezone=TIMEZONE_NAME,
all_day=bool(row[6]), all_day=bool(row[6]),
status=row[7], status=row[7],
) )
@@ -143,8 +171,9 @@ def index(
"summary": row[1], "summary": row[1],
"description": row[2], "description": row[2],
"location": row[3], "location": row[3],
"start_at": row[4], "start_at": to_iso_local(row[4]) if row[4] else None,
"end_at": row[5], "end_at": to_iso_local(row[5]) if row[5] else None,
"timezone": TIMEZONE_NAME,
"all_day": bool(row[6]), "all_day": bool(row[6]),
"status": row[7], "status": row[7],
}) })
+4 -4
View File
@@ -116,7 +116,7 @@
} }
.event-location::before { .event-location::before {
content: "\1F4CD "; content: "\\1F4CD ";
} }
.badge { .badge {
@@ -181,11 +181,11 @@
<div> <div>
<div class="event-date"> <div class="event-date">
{% if event.all_day %} {% if event.all_day %}
{{ event.start_at.strftime('%d.%m.%Y') }} <span class="all-day">Ganztägig</span> {{ event.start_at.split('T')[0] }} <span class="all-day">Ganztägig</span>
{% elif event.end_at %} {% elif event.end_at %}
{{ event.start_at.strftime('%d.%m.%Y %H:%M') }} - {{ event.end_at.strftime('%H:%M') }} {{ event.start_at.split('T')[0] }} {{ event.start_at.split('T')[1][:5] }} - {{ event.end_at.split('T')[1][:5] }} {{ event.timezone }}
{% else %} {% else %}
{{ event.start_at.strftime('%d.%m.%Y %H:%M') }} {{ event.start_at.split('T')[0] }} {{ event.start_at.split('T')[1][:5] }} {{ event.timezone }}
{% endif %} {% endif %}
</div> </div>
<div class="event-summary">{{ event.summary or '(Kein Titel)' }}</div> <div class="event-summary">{{ event.summary or '(Kein Titel)' }}</div>