diff --git a/PLAN.md b/PLAN.md index 510c817..6bcc756 100644 --- a/PLAN.md +++ b/PLAN.md @@ -128,6 +128,25 @@ jinja2==3.1.4 ## 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] Template Styling - einfaches CSS, kein Framework - [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 diff --git a/api/main.py b/api/main.py index 40a7484..d684e57 100644 --- a/api/main.py +++ b/api/main.py @@ -1,6 +1,8 @@ import logging +import os from datetime import datetime, timezone from pathlib import Path +from zoneinfo import ZoneInfo from fastapi import FastAPI, HTTPException, Query 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" +# 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): id: int @@ -25,6 +50,7 @@ class EventResponse(BaseModel): location: str | None start_at: str end_at: str | None + timezone: str all_day: bool status: str @@ -33,6 +59,7 @@ class EventsListResponse(BaseModel): events: list[EventResponse] count: int query_time: str + timezone: str def row_to_event(row) -> EventResponse: @@ -41,8 +68,9 @@ def row_to_event(row) -> EventResponse: summary=row[1], description=row[2], location=row[3], - start_at=row[4].isoformat() if row[4] else None, - end_at=row[5].isoformat() if row[5] else None, + start_at=to_iso_local(row[4]) if row[4] else None, + end_at=to_iso_local(row[5]) if row[5] else None, + timezone=TIMEZONE_NAME, all_day=bool(row[6]), status=row[7], ) @@ -143,8 +171,9 @@ def index( "summary": row[1], "description": row[2], "location": row[3], - "start_at": row[4], - "end_at": row[5], + "start_at": to_iso_local(row[4]) if row[4] else None, + "end_at": to_iso_local(row[5]) if row[5] else None, + "timezone": TIMEZONE_NAME, "all_day": bool(row[6]), "status": row[7], }) diff --git a/api/templates/index.html b/api/templates/index.html index 6be85c1..c3fda4b 100644 --- a/api/templates/index.html +++ b/api/templates/index.html @@ -116,7 +116,7 @@ } .event-location::before { - content: "\1F4CD "; + content: "\\1F4CD "; } .badge { @@ -181,11 +181,11 @@
{% if event.all_day %} - {{ event.start_at.strftime('%d.%m.%Y') }} Ganztägig + {{ event.start_at.split('T')[0] }} Ganztägig {% 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 %} - {{ 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 %}
{{ event.summary or '(Kein Titel)' }}