diff --git a/VERSION b/VERSION
index b9f7ba3..880a8fb 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1304
\ No newline at end of file
+1305
\ No newline at end of file
diff --git a/backend/routes/diary.py b/backend/routes/diary.py
index c122e92..5450fef 100644
--- a/backend/routes/diary.py
+++ b/backend/routes/diary.py
@@ -591,6 +591,13 @@ async def update_diary(dog_id: int, entry_id: int, data: DiaryUpdate,
if not exists:
raise HTTPException(404, "Eintrag nicht gefunden.")
+ # GPS-Stand VOR dem Update merken (für Anreicherungs-Entscheidung unten)
+ old = conn.execute(
+ "SELECT gps_lat, gps_lon FROM diary WHERE id=?", (entry_id,)
+ ).fetchone()
+ old_lat = old["gps_lat"] if old else None
+ old_lon = old["gps_lon"] if old else None
+
# Felder updaten — location_name/gps_* dürfen explizit auf None gesetzt werden
raw = data.model_dump(exclude={"dog_ids"})
NULLABLE = {"location_name", "gps_lat", "gps_lon"}
@@ -617,6 +624,49 @@ async def update_diary(dog_id: int, entry_id: int, data: DiaryUpdate,
dogs_map = _fetch_dog_ids(conn, [entry_id])
media_map = _fetch_media_items(conn, [entry_id])
+ # Nachträgliche Positionierung: wurde GPS neu gesetzt oder geändert, POIs
+ # (immer) + Wetter fürs Eintragsdatum (historisch korrekt) nachladen —
+ # analog zum Create-Pfad, aber NACH dem DB-Commit (async HTTP).
+ new_lat, new_lon = row["gps_lat"], row["gps_lon"]
+ coords_changed = (
+ old_lat is None or old_lon is None
+ or round(old_lat, 5) != round(new_lat, 5)
+ or round(old_lon, 5) != round(new_lon, 5)
+ ) if (new_lat is not None and new_lon is not None) else False
+
+ if coords_changed:
+ weather_json = None
+ poi_json = None
+ # Stunde fürs historische Wetter aus created_at, falls selber Tag wie datum.
+ hour = None
+ ca = row["created_at"]
+ if ca and len(ca) >= 13 and ca[:10] == (row["datum"] or "")[:10]:
+ try: hour = int(ca[11:13])
+ except (ValueError, TypeError): hour = None
+ try:
+ wd = await weather_mod.get_weather_for_date(new_lat, new_lon, row["datum"], hour)
+ weather_json = json.dumps(wd)
+ except Exception as exc:
+ logger.warning("Wetter-Anreicherung beim Diary-Update fehlgeschlagen: %s", exc)
+ try:
+ pois = await _fetch_pois_for_coords(new_lat, new_lon, limit=5)
+ if pois:
+ poi_json = json.dumps(pois)
+ except Exception as exc:
+ logger.warning("POI-Anreicherung beim Diary-Update fehlgeschlagen: %s", exc)
+
+ # Nur erfolgreich geholte Felder schreiben — ein API-Fehler überschreibt
+ # vorhandene Daten nicht (kein Datenverlust).
+ sets, vals = [], []
+ if weather_json is not None:
+ sets.append("weather_json=?"); vals.append(weather_json)
+ if poi_json is not None:
+ sets.append("poi_json=?"); vals.append(poi_json)
+ if sets:
+ with db() as conn:
+ conn.execute(f"UPDATE diary SET {', '.join(sets)} WHERE id=?", vals + [entry_id])
+ row = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
+
return _entry_dict(row, dogs_map, media_map)
diff --git a/backend/static/index.html b/backend/static/index.html
index 34168f4..e6747d5 100644
--- a/backend/static/index.html
+++ b/backend/static/index.html
@@ -86,14 +86,14 @@
Ban Yaro
-
+
-
-
-
-
-
+
+
+
+
+
@@ -624,12 +624,12 @@
-
-
-
-
-
-
+
+
+
+
+
+
@@ -639,7 +639,7 @@
-
+
diff --git a/backend/static/js/app.js b/backend/static/js/app.js
index aa38bcc..c6129a0 100644
--- a/backend/static/js/app.js
+++ b/backend/static/js/app.js
@@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
-const APP_VER = '1304'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
+const APP_VER = '1305'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
window.APP_VERSION = APP_VERSION;
diff --git a/backend/static/landing.html b/backend/static/landing.html
index 92c19ef..a75bb78 100644
--- a/backend/static/landing.html
+++ b/backend/static/landing.html
@@ -4,7 +4,7 @@
-
+
Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz
diff --git a/backend/static/sw.js b/backend/static/sw.js
index c398305..da22d33 100644
--- a/backend/static/sw.js
+++ b/backend/static/sw.js
@@ -4,7 +4,7 @@
============================================================ */
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab
-const VER = '1304';
+const VER = '1305';
const CACHE_VERSION = `by-v${VER}`;
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
diff --git a/backend/weather.py b/backend/weather.py
index f9a8ed2..918dab9 100644
--- a/backend/weather.py
+++ b/backend/weather.py
@@ -164,6 +164,88 @@ async def get_weather_for_location(lat: float, lon: float) -> dict:
_location_cache[key] = (now, data)
return data
+
+async def get_weather_for_date(lat: float, lon: float, datum: str,
+ hour: int | None = None) -> dict:
+ """Wetter für ein bestimmtes Datum (YYYY-MM-DD) an einem Standort.
+
+ Für nachträgliches Positionieren alter Tagebucheinträge: heutiges Datum
+ → aktuelles Wetter; vergangene Tage → stündliche Historie. Open-Meteo
+ Forecast-API deckt die jüngsten ~90 Tage Vergangenheit ab, ältere Tage
+ holt die Archive-API. `hour` (0–23, Default 12) wählt die Tagesstunde.
+ Rückgabe hat dieselbe Struktur wie get_weather_for_location (die nach
+ vorn gerichteten Felder next_rain_time/rain_warning_time sind None)."""
+ from datetime import date as _date
+ try:
+ target = _date.fromisoformat(datum[:10])
+ except (ValueError, TypeError):
+ return await get_weather_for_location(lat, lon)
+
+ today = datetime.now().date()
+ if target >= today:
+ # Heute/Zukunft → aktuelles Wetter (inkl. Vorhersage-Felder + Ortsname).
+ return await get_weather_for_location(lat, lon)
+
+ hour = 12 if hour is None else max(0, min(23, hour))
+ age_days = (today - target).days
+ base = ("https://archive-api.open-meteo.com/v1/archive"
+ if age_days > 90 else
+ "https://api.open-meteo.com/v1/forecast")
+ url = (
+ f"{base}?latitude={lat}&longitude={lon}"
+ f"&start_date={target.isoformat()}&end_date={target.isoformat()}"
+ "&hourly=temperature_2m,apparent_temperature,weathercode,windspeed_10m,is_day"
+ "&daily=precipitation_probability_max,uv_index_max"
+ "&timezone=Europe%2FBerlin"
+ )
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.get(url)
+ resp.raise_for_status()
+ raw = resp.json()
+ hourly = raw.get('hourly', {})
+ daily = raw.get('daily', {})
+
+ def _at(arr):
+ try:
+ return arr[hour]
+ except (IndexError, TypeError, KeyError):
+ return None
+
+ temp = _at(hourly.get('temperature_2m', []))
+ feels_like = _at(hourly.get('apparent_temperature', []))
+ wcode = _at(hourly.get('weathercode', [])) or 0
+ wind = _at(hourly.get('windspeed_10m', []))
+ is_day_v = _at(hourly.get('is_day', []))
+ is_day = 1 if is_day_v is None else is_day_v
+ precip = (daily.get('precipitation_probability_max') or [None])[0]
+ uv = (daily.get('uv_index_max') or [None])[0]
+
+ desc, icon = _WMO.get(wcode, ('Unbekannt', 'cloud'))
+ if wcode == 0 and not is_day:
+ icon = 'moon'
+
+ zecken = None
+ if temp is not None and temp > 7.0 and 3 <= target.month <= 10:
+ zecken = 'hoch' if temp > 20 else ('mittel' if temp > 12 else 'niedrig')
+
+ return {
+ 'temp_c': temp,
+ 'feels_like_c': feels_like,
+ 'weathercode': wcode,
+ 'desc': desc,
+ 'icon': icon,
+ 'wind_kmh': wind,
+ 'precip_prob': precip,
+ 'uv_index': uv,
+ 'is_day': bool(is_day),
+ 'zecken_warnung': zecken,
+ 'next_rain_time': None,
+ 'rain_warning_time': None,
+ 'location_name': None,
+ 'historical': True,
+ }
+
+
# WMO-Wettercodes 95–99 = Gewitter
THUNDERSTORM_CODES = {95, 96, 99}
diff --git a/tests/test_diary_location_enrich.py b/tests/test_diary_location_enrich.py
new file mode 100644
index 0000000..4ea72c5
--- /dev/null
+++ b/tests/test_diary_location_enrich.py
@@ -0,0 +1,95 @@
+"""Nachträgliches Positionieren eines Tagebucheintrags reichert POIs + Wetter an.
+
+Deckt die Lücke ab: Der PATCH-/Update-Pfad holte bisher kein Wetter/POI, wenn
+man einem Eintrag (z.B. Indoor-Foto ohne EXIF-GPS) nachträglich einen Standort
+gibt. Jetzt: GPS neu gesetzt/geändert → POIs (immer) + Wetter fürs Eintragsdatum
+(historisch korrekt via get_weather_for_date).
+
+Wetter- und POI-Fetch sind gestubbt (kein echtes Netzwerk im Test).
+"""
+
+from __future__ import annotations
+
+import json
+import pytest
+
+
+@pytest.fixture
+def stub_enrich(monkeypatch, app):
+ # WICHTIG: routes.diary erst HIER importieren (nach dem app-Fixture, das
+ # DB_PATH setzt) — ein Top-Level-Import würde database mit dem Default-Pfad
+ # binden und die ganze Test-Session vergiften.
+ import routes.diary as diary_mod
+ calls = {}
+
+ async def _fake_weather(lat, lon, datum, hour=None):
+ calls["weather"] = {"lat": lat, "lon": lon, "datum": datum, "hour": hour}
+ return {"temp_c": 18.5, "weathercode": 61, "desc": "Regen",
+ "is_day": True, "historical": True}
+
+ async def _fake_pois(lat, lon, limit=5):
+ calls["poi"] = {"lat": lat, "lon": lon}
+ return [{"name": "Stadtpark", "type": "park", "distance_m": 120}]
+
+ monkeypatch.setattr(diary_mod.weather_mod, "get_weather_for_date", _fake_weather)
+ monkeypatch.setattr(diary_mod, "_fetch_pois_for_coords", _fake_pois)
+ return calls
+
+
+def _create_without_gps(client, user, dog, datum="2026-06-12"):
+ r = client.post(
+ f"/api/dogs/{dog['id']}/diary",
+ headers=user["headers"],
+ json={"titel": "Ohne Standort", "text": "Indoor-Eintrag ohne GPS.", "datum": datum},
+ )
+ assert r.status_code == 201, r.text
+ e = r.json()
+ assert not e.get("weather_json") and not e.get("poi_json")
+ return e
+
+
+def _loads(v):
+ return json.loads(v) if isinstance(v, str) else v
+
+
+def test_manual_position_enriches_weather_and_pois(client, user, dog, stub_enrich):
+ entry = _create_without_gps(client, user, dog)
+ r = client.patch(
+ f"/api/dogs/{dog['id']}/diary/{entry['id']}",
+ headers=user["headers"],
+ json={"gps_lat": 48.137, "gps_lon": 11.575, "location_name": "München"},
+ )
+ assert r.status_code == 200, r.text
+ out = r.json()
+ w = _loads(out["weather_json"])
+ p = _loads(out["poi_json"])
+ assert w["weathercode"] == 61
+ assert p[0]["name"] == "Stadtpark"
+ assert out["location_name"] == "München"
+ # Wetter wurde fürs EINTRAGSDATUM geholt (historisch korrekt), nicht "heute".
+ assert stub_enrich["weather"]["datum"] == "2026-06-12"
+
+
+def test_edit_without_gps_does_not_enrich(client, user, dog, stub_enrich):
+ entry = _create_without_gps(client, user, dog)
+ r = client.patch(
+ f"/api/dogs/{dog['id']}/diary/{entry['id']}",
+ headers=user["headers"],
+ json={"titel": "Nur Titel geändert"},
+ )
+ assert r.status_code == 200, r.text
+ assert "weather" not in stub_enrich and "poi" not in stub_enrich
+
+
+def test_resave_same_coords_does_not_refetch(client, user, dog, stub_enrich):
+ entry = _create_without_gps(client, user, dog)
+ # 1. Positionierung → reichert an
+ client.patch(f"/api/dogs/{dog['id']}/diary/{entry['id']}", headers=user["headers"],
+ json={"gps_lat": 48.137, "gps_lon": 11.575})
+ assert "weather" in stub_enrich
+ stub_enrich.clear()
+ # erneut speichern mit IDENTISCHEN Koordinaten → keine erneute Anreicherung
+ r = client.patch(f"/api/dogs/{dog['id']}/diary/{entry['id']}", headers=user["headers"],
+ json={"gps_lat": 48.137, "gps_lon": 11.575})
+ assert r.status_code == 200
+ assert "weather" not in stub_enrich and "poi" not in stub_enrich