mirror of
https://github.com/skoelle/calender_sync.git
synced 2026-09-18 02:30:24 +00:00
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.
This commit is contained in:
@@ -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
@@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user