diff --git a/backend/database.py b/backend/database.py index 01dfc17..ab82594 100644 --- a/backend/database.py +++ b/backend/database.py @@ -189,6 +189,13 @@ def init_db(): ); CREATE INDEX IF NOT EXISTS idx_route_walks_user ON route_walks(user_id); + CREATE TABLE IF NOT EXISTS route_dogs ( + route_id INTEGER NOT NULL REFERENCES routes(id) ON DELETE CASCADE, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + PRIMARY KEY (route_id, dog_id) + ); + CREATE INDEX IF NOT EXISTS idx_route_dogs_dog ON route_dogs(dog_id); + CREATE TABLE IF NOT EXISTS exercise_progress ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -1974,6 +1981,38 @@ def _migrate(conn_factory): """) logger.info("Migration: futter_profil bereit.") + # Futter-Einträge & Reaktionen (Verträglichkeits-Tracking) + try: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS futter_eintraege ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + datum TEXT NOT NULL, + uhrzeit TEXT NOT NULL, + futter_name TEXT NOT NULL, + futter_typ TEXT NOT NULL DEFAULT 'trockenfutter', + menge_g INTEGER, + notiz TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_futter_eintraege_dog ON futter_eintraege(dog_id, datum DESC); + + CREATE TABLE IF NOT EXISTS futter_reaktionen ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + datum TEXT NOT NULL, + uhrzeit TEXT NOT NULL, + reaktion_typ TEXT NOT NULL, + intensitaet INTEGER NOT NULL DEFAULT 3, + notiz TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_futter_reaktionen_dog ON futter_reaktionen(dog_id, datum DESC); + """) + logger.info("Migration: futter_eintraege + futter_reaktionen bereit.") + except Exception as e: + logger.warning(f"Migration futter_eintraege/reaktionen: {e}") + # Wiederkehrende Ausgaben (Daueraufträge) conn.executescript(""" CREATE TABLE IF NOT EXISTS recurring_expenses ( @@ -2104,6 +2143,85 @@ def _migrate(conn_factory): except Exception: pass # Spalte existiert bereits + # exercise_progress + training_plan_progress: dog_id ergänzen + existing_ep = [r[1] for r in conn.execute("PRAGMA table_info(exercise_progress)").fetchall()] + if 'dog_id' not in existing_ep: + try: + # Neue Tabelle mit dog_id erstellen + conn.execute(""" + CREATE TABLE exercise_progress_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER REFERENCES dogs(id) ON DELETE CASCADE, + exercise_id TEXT NOT NULL, + status TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(dog_id, exercise_id) + ) + """) + # Bestehende Daten migrieren: dog_id = erster Hund des Users + conn.execute(""" + INSERT INTO exercise_progress_new (user_id, dog_id, exercise_id, status, updated_at) + SELECT ep.user_id, + (SELECT id FROM dogs WHERE user_id=ep.user_id ORDER BY id LIMIT 1), + ep.exercise_id, ep.status, ep.updated_at + FROM exercise_progress ep + """) + conn.execute("DROP TABLE exercise_progress") + conn.execute("ALTER TABLE exercise_progress_new RENAME TO exercise_progress") + conn.execute("CREATE INDEX IF NOT EXISTS idx_exercise_progress_user ON exercise_progress(user_id)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_exercise_progress_dog ON exercise_progress(dog_id)") + logger.info("Migration: exercise_progress.dog_id hinzugefügt.") + except Exception as e: + logger.warning(f"Migration exercise_progress.dog_id fehlgeschlagen: {e}") + + existing_tp = [r[1] for r in conn.execute("PRAGMA table_info(training_plan_progress)").fetchall()] + if 'dog_id' not in existing_tp: + try: + conn.execute(""" + CREATE TABLE training_plan_progress_new ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER REFERENCES dogs(id) ON DELETE CASCADE, + item_key TEXT NOT NULL, + checked INTEGER NOT NULL DEFAULT 1, + checked_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (dog_id, item_key) + ) + """) + conn.execute(""" + INSERT INTO training_plan_progress_new (user_id, dog_id, item_key, checked, checked_at) + SELECT tp.user_id, + (SELECT id FROM dogs WHERE user_id=tp.user_id ORDER BY id LIMIT 1), + tp.item_key, tp.checked, tp.checked_at + FROM training_plan_progress tp + """) + conn.execute("DROP TABLE training_plan_progress") + conn.execute("ALTER TABLE training_plan_progress_new RENAME TO training_plan_progress") + logger.info("Migration: training_plan_progress.dog_id hinzugefügt.") + except Exception as e: + logger.warning(f"Migration training_plan_progress.dog_id fehlgeschlagen: {e}") + + # verstorben_am: Hund als verstorben markierbar + try: + conn.execute("ALTER TABLE dogs ADD COLUMN verstorben_am TEXT") + logger.info("Migration: dogs.verstorben_am hinzugefügt.") + except Exception: + pass + + # route_dogs: bestehende Routen allen Hunden des Users zuweisen + try: + existing = conn.execute("SELECT COUNT(*) FROM route_dogs").fetchone()[0] + if existing == 0: + conn.execute(""" + INSERT OR IGNORE INTO route_dogs (route_id, dog_id) + SELECT r.id, d.id + FROM routes r + JOIN dogs d ON d.user_id = r.user_id + """) + logger.info("Migration: route_dogs mit bestehenden Routen befüllt.") + except Exception as e: + logger.warning(f"Migration route_dogs fehlgeschlagen: {e}") + def _seed_help_articles(conn): """Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist.""" diff --git a/backend/main.py b/backend/main.py index dc86828..00027f4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1414,7 +1414,7 @@ async def ausweis_page(dog_id: int, request: Request):
- `; + UI.bindDogChip(_container, _appState); _container.querySelector('#diary-milestone-filter') ?.addEventListener('click', async () => { diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js index 85ce02d..6de34d5 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -1060,6 +1060,12 @@ window.Page_dog_profile = (() => {
+
@@ -1279,6 +1285,11 @@ window.Page_dog_profile = (() => { document.getElementById('dp-form-cancel') ?.addEventListener('click', UI.modal.close); + document.getElementById('dp-gedenken-btn')?.addEventListener('click', async () => { + UI.modal.close(); + _openGedenkenFlow(dog); + }); + document.getElementById('dp-delete-btn')?.addEventListener('click', async () => { const ok = await UI.modal.confirm({ title : `${dog.name} löschen?`, @@ -2414,6 +2425,178 @@ window.Page_dog_profile = (() => { // ---------------------------------------------------------- // PUBLIC // ---------------------------------------------------------- + // ---------------------------------------------------------- + // GEDENKEN-FLOW + // ---------------------------------------------------------- + async function _openGedenkenFlow(dog) { + // Schritt 1: Würdevoller Übergangsdialog mit Datum-Eingabe + UI.modal.open({ + title: `Abschied von ${dog.name}`, + body: ` +
+ +

+ ${dog.name} hinterlässt eine riesige Lücke.
+ Die gemeinsamen Erinnerungen bleiben für immer. +

+
+
+ + +
`, + footer: ` +
+ + +
`, + }); + + document.getElementById('gedenken-form')?.addEventListener('submit', async e => { + e.preventDefault(); + const btn = document.getElementById('gedenken-save-btn'); + const datum = document.getElementById('gedenken-datum').value; + await UI.asyncButton(btn, async () => { + await API.post(`/dogs/${dog.id}/gedenken`, { verstorben_am: datum }); + // Aus aktiver Hundeliste entfernen + _appState.dogs = _appState.dogs.filter(d => d.id !== dog.id); + _appState.activeDog = _appState.dogs[0] || null; + UI.modal.close(); + // Gedenkseite öffnen + await _openGedenkseite(dog.id, dog.name); + await _render(); + }); + }); + } + + async function _openGedenkseite(dogId, dogName) { + UI.modal.open({ title: `Erinnerungen an ${dogName}`, body: ` +
+ + + +
` }); + + let data; + try { data = await API.get(`/dogs/${dogId}/gedenkseite`); } + catch { UI.modal.close(); return; } + + const d = data; + const av = d.dog.foto_url + ? `` + : `
`; + + const photoGrid = d.photos.length ? ` +
+ ${d.photos.map(url => ``).join('')} +
` : ''; + + const statsHtml = ` +
+ ${d.km_total ? `
+ +
${d.km_total}
+
km zusammen
+
` : ''} + ${d.diary_count ? `
+ +
${d.diary_count}
+
Tagebucheinträge
+
` : ''} + ${d.media_count ? `
+ +
${d.media_count}
+
Fotos
+
` : ''} + ${d.gemeinsam_tage ? `
+ +
${d.gemeinsam_tage}
+
gemeinsame Tage
+
` : ''} +
`; + + // Trauer-Support-Texte + const supportHtml = ` +
+
+ + Für dich in dieser Zeit +
+

+ Der Schmerz über den Verlust eines Hundes ist real und tief. Du musst nicht stark sein. + Lass dich trauern — so lange du brauchst. Die Erinnerungen bleiben immer bei dir. +

+
+
+
+ + Sprich mit Freunden oder der Familie über ${d.dog.name} — Geschichten lebendig halten hilft. +
+
+ + Das Tagebuch bleibt erhalten — es ist ein kostbares Stück gemeinsamer Geschichte. +
+
+ + Professionelle Hilfe bei Tiertrauer: Tiertrauer-Hotline 0800 111 0 111 (kostenlos) +
+
+
+ +
`; + + const modal = UI.modal.open({ + title: `🌈 Erinnerungen an ${UI.escape(d.dog.name)}`, + body: ` +
+ ${av} +
${UI.escape(d.dog.name)}
+ ${d.dog.rasse ? `
${UI.escape(d.dog.rasse)}
` : ''} + ${d.dog.verstorben_am ? `
+ + Über die Regenbogenbrücke am ${new Date(d.dog.verstorben_am).toLocaleDateString('de-DE')} +
` : ''} +
+ ${photoGrid} + ${statsHtml} + ${supportHtml}`, + }); + + document.getElementById('gedenk-ki-btn')?.addEventListener('click', async () => { + const btn = document.getElementById('gedenk-ki-btn'); + await UI.asyncButton(btn, async () => { + const result = await API.post('/ki/abschied', { + dog_id: dogId, + name: d.dog.name, + rasse: d.dog.rasse, + km_total: d.km_total, + diary_count: d.diary_count, + gemeinsam_tage: d.gemeinsam_tage, + }); + const wrap = document.getElementById('gedenk-ki-wrap'); + if (wrap) wrap.innerHTML = ` +
+ "${UI.escape(result.text)}" +
`; + }); + }); + } + return { init, refresh, onDogChange, addNew: _openCreateModal }; })(); diff --git a/backend/static/js/pages/health.js b/backend/static/js/pages/health.js index 91f082f..b984515 100644 --- a/backend/static/js/pages/health.js +++ b/backend/static/js/pages/health.js @@ -50,10 +50,6 @@ window.Page_health = (() => { async function refresh() { if (!_appState.activeDog) return; - if (_appState.dogs.length > 1) { - _renderDogPicker(); - return; - } _data = {}; await _renderHealth(); } @@ -81,52 +77,7 @@ window.Page_health = (() => { return; } - if (_appState.dogs.length > 1) { - _renderDogPicker(); - } else { - await _renderHealth(); - } - } - - // ---------------------------------------------------------- - // HUNDE-PICKER - // ---------------------------------------------------------- - function _renderDogPicker() { - const activeDogId = _appState.activeDog?.id; - - const cards = _appState.dogs.map(dog => { - const isActive = dog.id === activeDogId; - const av = dog.foto_url - ? `${_esc(dog.name)}` - : `${UI.icon('dog')}`; - return ` -
-
${av}
-
${_esc(dog.name)}
- ${dog.rasse ? `
${_esc(dog.rasse)}
` : ''} -
`; - }).join(''); - - _container.innerHTML = ` -
-

Wessen Gesundheitsakte?

-
${cards}
-
`; - - _container.querySelectorAll('.diary-picker-card').forEach(el => { - el.addEventListener('click', async () => { - const id = parseInt(el.dataset.dogId); - if (id === _appState.activeDog?.id) { - // Bereits aktiver Hund → direkt Health laden - _data = {}; - await _renderHealth(); - } else { - App.setActiveDog(id); - // onDogChange() → _renderHealth() via _notifyDogChange() - } - }); - }); + await _renderHealth(); } // ---------------------------------------------------------- @@ -147,6 +98,7 @@ window.Page_health = (() => {
`; _container.innerHTML = ` + ${UI.dogChip(_appState)}
+ + `; + UI.modal.open({ title: `${UI.icon('dog')} Hunde bearbeiten`, body, footer }); + + // Checkbox-Pill Styling + document.querySelectorAll('.rd-dog-cb').forEach(cb => { + const label = cb.closest('label'); + cb.addEventListener('change', () => { + label.style.borderColor = cb.checked ? 'var(--c-primary)' : 'var(--c-border)'; + label.style.background = cb.checked ? 'var(--c-primary-subtle)' : ''; + label.style.color = cb.checked ? 'var(--c-primary)' : ''; + }); + }); + + document.getElementById('rd-dogs-cancel')?.addEventListener('click', UI.modal.close); + + document.getElementById('rd-dogs-save')?.addEventListener('click', async () => { + const btn = document.getElementById('rd-dogs-save'); + await UI.asyncButton(btn, async () => { + const dogIds = [...document.querySelectorAll('.rd-dog-cb:checked')].map(c => parseInt(c.value)); + await API.routes.updateDogs(route.id, dogIds); + route.dog_ids = dogIds; + UI.modal.close(); + UI.toast.success('Hunde aktualisiert.'); + }); + }); + } + // Richtungspfeile gleichmäßig entlang des Tracks platzieren function _addRouteArrows(map, track, color = '#fff') { if (track.length < 2) return; diff --git a/backend/static/js/pages/trainingsplaene.js b/backend/static/js/pages/trainingsplaene.js index 35383f3..346a4ab 100644 --- a/backend/static/js/pages/trainingsplaene.js +++ b/backend/static/js/pages/trainingsplaene.js @@ -40,7 +40,8 @@ window.Page_trainingsplaene = (() => { } function _lsKey(planId, goalIdx) { - return `tp_${planId}_${goalIdx}`; + const dogId = _dogId() || 'x'; + return `tp_d${dogId}_${planId}_${goalIdx}`; } function _saveGoal(key, checked) { @@ -537,6 +538,8 @@ window.Page_trainingsplaene = (() => { // BIND EVENTS // ---------------------------------------------------------- function _bindEvents() { + UI.bindDogChip(_container, _appState); + // Notiz-Button const dogId = _dogId(); _container.querySelector('#tp-note-btn')?.addEventListener('click', e => { @@ -612,8 +615,9 @@ window.Page_trainingsplaene = (() => { : `Erwachsener Hund – ${_activeAdultTab}`; _container.innerHTML = ` -
-
+
+ ${UI.dogChip(_appState)} +

${_icon('clipboard-text')} Trainingspläne

diff --git a/backend/static/js/pages/uebungen.js b/backend/static/js/pages/uebungen.js index d8639e6..c6ba308 100644 --- a/backend/static/js/pages/uebungen.js +++ b/backend/static/js/pages/uebungen.js @@ -75,6 +75,7 @@ window.Page_uebungen = (() => { // In-memory cache (loaded from API on init) let _progressCache = {}; // key → statusId + let _progressLoaded = false; let _exerciseStats = {}; // exercise_id → {recent_avg, session_count, trend} function _progressKey(tab, name) { @@ -83,17 +84,13 @@ window.Page_uebungen = (() => { function _getStatus(tab, name) { const k = _progressKey(tab, name); - // Fallback to localStorage while API loads - return _progressCache[k] !== undefined - ? _progressCache[k] - : localStorage.getItem(_statusKey(tab, name)) || null; + return _progressCache[k] ?? null; } function _setStatus(tab, name, statusId) { const k = _progressKey(tab, name); _progressCache[k] = statusId; - localStorage.setItem(_statusKey(tab, name), statusId || ''); // keep localStorage in sync - API.training.setProgress(k, statusId).catch(() => {}); + API.training.setProgress(k, statusId, _dogId()).catch(() => {}); } function _nextStatus(currentId) { @@ -504,28 +501,19 @@ window.Page_uebungen = (() => { _scrollTarget = { exercise_id: params.exercise_id || '', name: params.name || '' }; } - // Progress vom Server laden - API.training.getProgress().then(rows => { - rows.forEach(r => { _progressCache[r.exercise_id] = r.status; }); - // localStorage-Daten migrieren falls noch nicht im Backend - Object.keys(localStorage).filter(k => k.startsWith('ub_status_')).forEach(lsKey => { - const parts = lsKey.replace('ub_status_', '').split('_'); - const tab = parts[0]; - const name = parts.slice(1).join('_'); - const apiKey = `${tab}_${name}`; - if (_progressCache[apiKey] === undefined) { - const val = localStorage.getItem(lsKey); - if (val) { - _progressCache[apiKey] = val; - API.training.setProgress(apiKey, val).catch(() => {}); - } - } - }); - _renderContent(); // Re-render with loaded progress - }).catch(() => {}); + // Progress vom Server laden (hund-spezifisch) + const _did = _dogId(); + _progressLoaded = false; + API.training.getProgress(_did) + .then(rows => { + _progressCache = {}; + rows.forEach(r => { _progressCache[r.exercise_id] = r.status; }); + _progressLoaded = true; + _renderContent(); + }).catch(() => { _progressLoaded = true; _renderContent(); }); // Empfehlungen laden - API.training.getSuggestions().then(suggestions => { + API.training.getSuggestions(_did).then(suggestions => { if (suggestions.length) _showSuggestions(suggestions); }).catch(() => {}); @@ -556,6 +544,7 @@ window.Page_uebungen = (() => { _statsData = null; _badgesData = null; _progressCache = {}; + _progressLoaded = false; _exerciseStats = {}; _render(); _loadStatsAndBadges(); @@ -568,6 +557,7 @@ window.Page_uebungen = (() => { function _render() { _container.innerHTML = `
+
${UI.dogChip(_appState)}
@@ -604,6 +594,7 @@ window.Page_uebungen = (() => {
`; + UI.bindDogChip(_container, _appState); _container.querySelector('#ueb-quicksetup-btn').addEventListener('click', _openQuickSetupModal); _container.querySelector('#ueb-tabs')?.style.setProperty('--ueb-tab-cols', Math.ceil(TABS.length / 2)); _container.querySelector('#ueb-search')?.addEventListener('input', e => { @@ -613,7 +604,12 @@ window.Page_uebungen = (() => { _renderContent(); }); _bindTabs(); - _renderContent(); + if (_progressLoaded) { + _renderContent(); + } else { + const el = _container.querySelector('#ueb-content'); + if (el) el.innerHTML = `
`; + } _renderStatsBanner(); } @@ -782,7 +778,18 @@ window.Page_uebungen = (() => { // ---------------------------------------------------------- // SCHNELL-SETUP: Stand aller Übungen erfassen // ---------------------------------------------------------- - function _openQuickSetupModal() { + async function _openQuickSetupModal() { + // Sicherstellen dass Progress geladen ist bevor das Modal öffnet + if (!_progressLoaded) { + const did = _dogId(); + try { + const rows = await API.training.getProgress(did); + _progressCache = {}; + rows.forEach(r => { _progressCache[r.exercise_id] = r.status; }); + _progressLoaded = true; + _renderContent(); + } catch { _progressLoaded = true; } + } const ALL = [ { group: 'Grundkommandos', tab: 'grundkommandos', items: GRUNDKOMMANDOS }, { group: 'Tricks', tab: 'tricks', items: TRICKS }, @@ -883,11 +890,8 @@ window.Page_uebungen = (() => { // Alle geänderten Status speichern const parts = Object.entries(pending).map(([key, val]) => { - const [tab, ...rest] = key.split('_'); - const name = rest.join('_').replace(/_/g, ' '); _progressCache[key] = val || null; - localStorage.setItem(`ub_status_${key}`, val || ''); - return API.training.setProgress(key, val || null); + return API.training.setProgress(key, val || null, _dogId()); }); await Promise.allSettled(parts); diff --git a/backend/static/js/pages/wetter.js b/backend/static/js/pages/wetter.js index 8eae462..2dceccd 100644 --- a/backend/static/js/pages/wetter.js +++ b/backend/static/js/pages/wetter.js @@ -397,7 +397,9 @@ window.Page_wetter = (() => { : 0; } + const locName = _data.location_name ? `
${_esc(_data.location_name)}
` : ''; el.innerHTML = ` + ${locName}
${_wmoIcon(d.weathercode, '3.5rem')}
diff --git a/backend/static/js/ui.js b/backend/static/js/ui.js index 5a580f4..38d6528 100644 --- a/backend/static/js/ui.js +++ b/backend/static/js/ui.js @@ -995,6 +995,30 @@ const UI = (() => { _load(); } + function dogChip(appState) { + const dog = appState?.activeDog; + const dogs = appState?.dogs || []; + if (!dog) return ''; + const av = dog.foto_url + ? `` + : ``; + const sw = dogs.length > 1 + ? `` : ''; + return `
${av}${escape(dog.name)}${sw}
`; + } + + function bindDogChip(container, appState) { + if ((appState?.dogs?.length || 0) < 2) return; + container.querySelector('[data-dog-chip]')?.addEventListener('click', () => { + const dogs = appState.dogs; + const next = dogs.find(d => d.id !== appState.activeDog?.id) || dogs[0]; + if (next) App.setActiveDog(next.id); + }); + } + // Öffentliche API return { toast, modal, @@ -1009,6 +1033,10 @@ const UI = (() => { leafletMarker, locationPicker, ratingStars, + dogChip, + bindDogChip, + dogChip, + bindDogChip, }; })(); diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index e7ab1ab..52dddee 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -1114,6 +1114,7 @@ window.Worlds = (() => { ${gassiScore ? `/10` : ''}
${w ? `
+ ${w.location_name ? `
${w.location_name}
` : ''}
${Math.round(w.temp_c ?? 0)}° · ${w.precip_prob ?? 0}% Regen
${w.rain_warning_time ? `
⚠ Umschwung ab ${w.rain_warning_time}
` : w.next_rain_time ? `
ab ${w.next_rain_time} Uhr
` : ''}
` : ''} diff --git a/backend/weather.py b/backend/weather.py index afde8a2..cdf09d8 100644 --- a/backend/weather.py +++ b/backend/weather.py @@ -4,6 +4,7 @@ BAN YARO — Wetter via Open-Meteo - get_weather_for_location(): API-Endpoint, beliebiger Standort mit TTL-Cache """ +import asyncio import time import logging import httpx @@ -62,9 +63,28 @@ async def get_weather_for_location(lat: float, lon: float) -> dict: "&timezone=Europe%2FBerlin&forecast_days=1" ) async with httpx.AsyncClient(timeout=8.0) as client: - resp = await client.get(url) - resp.raise_for_status() - raw = resp.json() + resp, geo_resp = await asyncio.gather( + client.get(url), + client.get( + f"https://nominatim.openstreetmap.org/reverse?lat={lat}&lon={lon}&format=json&zoom=10", + headers={"User-Agent": "BanYaro/1.0 support@banyaro.app"} + ), + return_exceptions=True, + ) + resp.raise_for_status() + raw = resp.json() + + # Ortsname aus Reverse-Geocoding + location_name = None + try: + if not isinstance(geo_resp, Exception) and geo_resp.status_code == 200: + geo = geo_resp.json() + addr = geo.get("address", {}) + location_name = (addr.get("city") or addr.get("town") or + addr.get("village") or addr.get("municipality") or + addr.get("county") or geo.get("name")) + except Exception: + pass cur = raw.get('current', {}) daily = raw.get('daily', {}) @@ -130,9 +150,10 @@ async def get_weather_for_location(lat: float, lon: float) -> dict: 'precip_prob': precip, 'uv_index': uv, 'is_day': bool(is_day), - 'zecken_warnung': zecken, - 'next_rain_time': next_rain_time, + 'zecken_warnung': zecken, + 'next_rain_time': next_rain_time, 'rain_warning_time': rain_warning_time, + 'location_name': location_name, } _location_cache[key] = (now, data) return data @@ -272,9 +293,23 @@ async def get_forecast(lat: float, lon: float) -> dict: async with httpx.AsyncClient(timeout=10.0) as client: forecast_task = client.get(forecast_url) pollen_task = client.get(pollen_url) - forecast_resp, pollen_resp = await asyncio.gather( - forecast_task, pollen_task, return_exceptions=True + geo_task = client.get( + f"https://nominatim.openstreetmap.org/reverse?lat={lat}&lon={lon}&format=json&zoom=10", + headers={"User-Agent": "BanYaro/1.0 support@banyaro.app"} ) + forecast_resp, pollen_resp, geo_resp_fc = await asyncio.gather( + forecast_task, pollen_task, geo_task, return_exceptions=True + ) + + location_name_fc = None + try: + if not isinstance(geo_resp_fc, Exception) and geo_resp_fc.status_code == 200: + addr = geo_resp_fc.json().get("address", {}) + location_name_fc = (addr.get("city") or addr.get("town") or + addr.get("village") or addr.get("municipality") or + addr.get("county")) + except Exception: + pass # --- Forecast (required) --- if isinstance(forecast_resp, Exception): @@ -421,7 +456,7 @@ async def get_forecast(lat: float, lon: float) -> dict: 'hourly': _hourly_by_day.get(date_str, []), }) - result = {'timezone': timezone, 'days': days} + result = {'timezone': timezone, 'days': days, 'location_name': location_name_fc} _forecast_cache[key] = (now, result) _log_forecast(round(lat, 1), round(lon, 1), days) return result