From 70af387147afd2ad6fdb9665e7f103d4dc60714e Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 10 May 2026 12:52:55 +0200 Subject: [PATCH] Feature: User-Feedback, Regen-Uhrzeit im Wetter-Chip, Admin-Karten klickbar (SW by-v833) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Feedback-Modal im Settings (Kategorie + Text → E-Mail an support@banyaro.app) - Wetter-Chip (Karte + Gassi-Score): zeigt nächste Regenstunde ab ≥20% Wahrscheinlichkeit - Gassi-Score-Chip: zweizeilige Wetter-Info, linksbündig, volle Chipbreite - Admin-Übersicht: Stat-Karten anklickbar → navigiert direkt zum jeweiligen Tab - ui.js: visualViewport-Listener hebt Modal über Tastatur (alle Modals) - api.js: Pydantic v2 Array-Detail korrekt als Fehlermeldung extrahiert - map.js: Wetter-Fallback über watchPosition wenn getCurrentPosition scheitert - Update-Loop-Fix: index.html ?v= synchron mit APP_VER halten (alle 4 Stellen) --- backend/main.py | 4 ++- backend/routes/feedback.py | 56 +++++++++++++++++++++++++++++ backend/static/index.html | 14 ++++---- backend/static/js/api.js | 5 ++- backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 38 ++++++++++++-------- backend/static/js/pages/map.js | 7 +++- backend/static/js/pages/settings.js | 52 +++++++++++++++++++++++++++ backend/static/js/ui.js | 23 ++++++++++-- backend/static/js/worlds.js | 5 ++- backend/static/sw.js | 2 +- backend/weather.py | 45 ++++++++++++++++------- 12 files changed, 211 insertions(+), 42 deletions(-) create mode 100644 backend/routes/feedback.py diff --git a/backend/main.py b/backend/main.py index 3ebd1fa..77adb91 100644 --- a/backend/main.py +++ b/backend/main.py @@ -250,6 +250,7 @@ from routes.ernaehrung import router as ernaehrung_router from routes.challenges import router as challenges_router from routes.gassi_zeiten import router as gassi_zeiten_router from routes.help import router as help_router +from routes.feedback import router as feedback_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -312,6 +313,7 @@ app.include_router(ernaehrung_router, prefix="/api/dogs", tag app.include_router(challenges_router, prefix="/api/challenges", tags=["Foto-Challenge"]) app.include_router(gassi_zeiten_router, prefix="/api/gassi-zeiten", tags=["Gassi-Zeiten"]) app.include_router(help_router, prefix="/api/help", tags=["Hilfe/FAQ"]) +app.include_router(feedback_router, prefix="/api/feedback", tags=["Feedback"]) # ------------------------------------------------------------------ @@ -341,7 +343,7 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") os.makedirs(MEDIA_DIR, exist_ok=True) app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") -APP_VER = "826" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "833" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/routes/feedback.py b/backend/routes/feedback.py new file mode 100644 index 0000000..a5c4792 --- /dev/null +++ b/backend/routes/feedback.py @@ -0,0 +1,56 @@ +""" +BAN YARO — User-Feedback per E-Mail an support@banyaro.app +""" + +from typing import Annotated, Literal +from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field + +from auth import get_current_user +from mailer import send_email, email_html + +router = APIRouter() + +SUPPORT_MAIL = "support@banyaro.app" + +KATEGORIEN = {"bug": "🐛 Bug / Fehler", "idee": "💡 Idee / Wunsch", "lob": "🎉 Lob", "sonstiges": "💬 Sonstiges"} + + +class FeedbackIn(BaseModel): + kategorie: Literal["bug", "idee", "lob", "sonstiges"] + text: Annotated[str, Field(min_length=5, max_length=2000)] + + +@router.post("") +async def submit_feedback( + payload: FeedbackIn, + user=Depends(get_current_user), +): + kat_label = KATEGORIEN.get(payload.kategorie, payload.kategorie) + username = user.get("name", "?") + email = user.get("email", "") + tier = user.get("subscription_tier", "standard") + + subject = f"[Feedback] {kat_label} von @{username}" + + body = f""" +

+ Neues Feedback aus der App: +

+ + + + + + + +
Kategorie{kat_label}
User@{username} ({email})
Tier{tier}
+
+{payload.text} +
""" + + plain = f"Feedback [{kat_label}] von @{username} ({email})\n\n{payload.text}" + + await send_email(SUPPORT_MAIL, subject, email_html(body), plain) + return {"ok": True} diff --git a/backend/static/index.html b/backend/static/index.html index cf2117f..ad18d4c 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -101,9 +101,9 @@ - - - + + + @@ -583,10 +583,10 @@ - - - - + + + + diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 893f1b4..0b78595 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -58,7 +58,10 @@ const API = (() => { try { data = await response.json(); } catch { data = null; } if (!response.ok) { - const message = data?.detail || data?.message || `Fehler ${response.status}`; + const _d = data?.detail; + const message = (typeof _d === 'string' ? _d + : Array.isArray(_d) ? (_d[0]?.msg || 'Ungültige Eingabe') + : null) || data?.message || `Fehler ${response.status}`; const isSwOffline = response.status === 503 && message.startsWith('Offline'); // Retry: GET auf echte 5xx (nicht SW-generierte Offline-503) diff --git a/backend/static/js/app.js b/backend/static/js/app.js index db6b183..ef48e17 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 = '826'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '833'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.5.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 0dfa8c7..8877953 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -534,22 +534,22 @@ window.Page_admin = (() => { }; el.innerHTML = ` -
- ${_statCard('users', 'Nutzer gesamt', s.users_total, 'var(--c-primary)')} - ${_statCard('user-plus', 'Neu heute', s.users_today, 'var(--c-success)')} - ${_statCard('activity', 'Aktiv (7 Tage)', s.active_users_7d, 'var(--c-primary)')} - ${_statCard('paw-print', 'Hunde', s.dogs_total, 'var(--c-primary)')} - ${_statCard('chat-circle-dots','Threads', s.threads, 'var(--c-text-secondary)')} - ${_statCard('warning', 'Offene Meldungen', s.open_reports, s.open_reports > 0 ? 'var(--c-danger)' : 'var(--c-text-muted)')} - ${_statCard('camera', 'Fotos freizugeben', s.pending_fotos ?? 0, (s.pending_fotos ?? 0) > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)')} - ${_statCard('skull', 'Gesperrte User', s.banned, s.banned > 0 ? '#f59e0b' : 'var(--c-text-muted)')} - ${_statCard('warning-octagon', 'Giftk. aktiv', s.poison_active, 'var(--c-danger)')} - ${_statCard('bell', 'Push-Abos', s.push_subscriptions, 'var(--c-text-secondary)')} +
+ ${_statCard('users', 'Nutzer gesamt', s.users_total, 'var(--c-primary)', 'nutzer')} + ${_statCard('user-plus', 'Neu heute', s.users_today, 'var(--c-success)', 'nutzer')} + ${_statCard('activity', 'Aktiv (7 Tage)', s.active_users_7d, 'var(--c-primary)', 'nutzer')} + ${_statCard('paw-print', 'Hunde', s.dogs_total, 'var(--c-primary)', 'nutzer')} + ${_statCard('chat-circle-dots','Threads', s.threads, 'var(--c-text-secondary)','forum')} + ${_statCard('warning', 'Offene Meldungen', s.open_reports, s.open_reports > 0 ? 'var(--c-danger)' : 'var(--c-text-muted)', 'moderation')} + ${_statCard('camera', 'Fotos freizugeben', s.pending_fotos ?? 0, (s.pending_fotos ?? 0) > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)', 'moderation')} + ${_statCard('skull', 'Gesperrte User', s.banned, s.banned > 0 ? '#f59e0b' : 'var(--c-text-muted)', 'nutzer')} + ${_statCard('warning-octagon', 'Giftk. aktiv', s.poison_active, 'var(--c-danger)', 'system')} + ${_statCard('bell', 'Push-Abos', s.push_subscriptions, 'var(--c-text-secondary)','system')} ${_statCard('image', 'Media-Einträge', s.media_count, 'var(--c-text-secondary)')} ${_statCard('map-pin', 'Routen', s.routes_total, 'var(--c-text-secondary)')} ${_statCard('calendar', 'Events', s.events_total, 'var(--c-text-secondary)')} - ${_statCard('map-trifold', 'OSM-Marker', s.osm_total.toLocaleString('de'), 'var(--c-success)')} - ${_statCard('squares-four', 'Gecachte Tiles', s.osm_tiles.toLocaleString('de'), 'var(--c-text-secondary)')} + ${_statCard('map-trifold', 'OSM-Marker', s.osm_total.toLocaleString('de'), 'var(--c-success)', 'system')} + ${_statCard('squares-four', 'Gecachte Tiles', s.osm_tiles.toLocaleString('de'), 'var(--c-text-secondary)', 'system')}
@@ -705,11 +705,19 @@ window.Page_admin = (() => {

`; + + el.querySelector('#adm-overview-grid')?.addEventListener('click', e => { + const card = e.target.closest('[data-adm-tab]'); + if (!card) return; + const tab = card.dataset.admTab; + _container.querySelector(`#adm-tabs .by-tab[data-tab="${tab}"]`)?.click(); + }); } - function _statCard(icon, label, value, color) { + function _statCard(icon, label, value, color, tab = null) { + const clickable = tab ? `data-adm-tab="${tab}" style="padding:var(--space-4);text-align:center;cursor:pointer"` : `style="padding:var(--space-4);text-align:center"`; return ` -
+
Hilfe & FAQ
+ ${!_appState.user?.subscription_tier || _appState.user.subscription_tier === 'standard' || _appState.user.subscription_tier === 'standard_test' ? `
+
+ + +
+
+ + +
+ + `, + footer: ` +
+ + +
+ `, + }); + + sel('feedback-form')?.addEventListener('submit', async e => { + e.preventDefault(); + const btn = sel('feedback-submit-btn'); + const kat = sel('feedback-kat')?.value; + const text = sel('feedback-text')?.value?.trim(); + if (!text) { UI.toast.error('Bitte schreib etwas.'); return; } + await UI.asyncButton(btn, async () => { + await API.post('/feedback', { kategorie: kat, text }); + UI.modal.close?.(); + UI.toast.success('Vielen Dank für dein Feedback!'); + }); + }); + }); + document.getElementById('settings-logout-btn')?.addEventListener('click', async () => { const ok = await UI.modal.confirm({ title : 'Abmelden?', diff --git a/backend/static/js/ui.js b/backend/static/js/ui.js index 7ec576a..5a580f4 100644 --- a/backend/static/js/ui.js +++ b/backend/static/js/ui.js @@ -79,15 +79,34 @@ const UI = (() => { document.getElementById('modal-container').appendChild(overlay); document.documentElement.classList.add('modal-open'); - _current = { overlay, onClose }; + + // Tastatur auf Mobilgeräten: Modal nach oben schieben wenn Keyboard erscheint + let _vvCleanup = null; + const vv = window.visualViewport; + if (vv) { + const adjust = () => { + const kb = Math.max(0, window.innerHeight - vv.height - vv.offsetTop); + overlay.style.paddingBottom = (kb + 16) + 'px'; + }; + vv.addEventListener('resize', adjust); + vv.addEventListener('scroll', adjust); + _vvCleanup = () => { + vv.removeEventListener('resize', adjust); + vv.removeEventListener('scroll', adjust); + overlay.style.paddingBottom = ''; + }; + } + + _current = { overlay, onClose, _vvCleanup }; return overlay.querySelector('.modal'); } function close() { if (!_current) return; - const { onClose } = _current; + const { onClose, _vvCleanup } = _current; onClose?.(); + _vvCleanup?.(); _current.overlay.remove(); document.documentElement.classList.remove('modal-open'); _current = null; diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index f23f983..850bdbc 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -1097,7 +1097,10 @@ window.Worlds = (() => { ${gassiScore ?? '—'} ${gassiScore ? `/10` : ''}
- ${w ? `${Math.round(w.temp_c ?? 0)}° · ${w.precip_prob ?? 0}% Regen` : ''} + ${w ? `
+
${Math.round(w.temp_c ?? 0)}° · ${w.precip_prob ?? 0}% Regen
+ ${w.next_rain_time ? `
ab ${w.next_rain_time} Uhr
` : ''} +
` : ''}
diff --git a/backend/static/sw.js b/backend/static/sw.js index 5901c29..b7eb104 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v826'; +const CACHE_VERSION = 'by-v833'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache diff --git a/backend/weather.py b/backend/weather.py index 5e836c6..467f657 100644 --- a/backend/weather.py +++ b/backend/weather.py @@ -58,6 +58,7 @@ async def get_weather_for_location(lat: float, lon: float) -> dict: f"?latitude={lat}&longitude={lon}" "¤t=temperature_2m,apparent_temperature,weathercode,windspeed_10m,is_day" "&daily=precipitation_probability_max,uv_index_max" + "&hourly=precipitation_probability" "&timezone=Europe%2FBerlin&forecast_days=1" ) async with httpx.AsyncClient(timeout=8.0) as client: @@ -65,8 +66,9 @@ async def get_weather_for_location(lat: float, lon: float) -> dict: resp.raise_for_status() raw = resp.json() - cur = raw.get('current', {}) - daily = raw.get('daily', {}) + cur = raw.get('current', {}) + daily = raw.get('daily', {}) + hourly = raw.get('hourly', {}) temp = cur.get('temperature_2m') feels_like = cur.get('apparent_temperature') @@ -85,17 +87,36 @@ async def get_weather_for_location(lat: float, lon: float) -> dict: if temp is not None and temp > 7.0 and 3 <= month <= 10: zecken = 'hoch' if temp > 20 else ('mittel' if temp > 12 else 'niedrig') + # Nächste Regenstunde: erstes stündliches Fenster (jetzt+1h bis +12h) mit ≥60% Niederschlag + next_rain_time = None + already_raining = wcode >= 51 + if not already_raining: + now_h = datetime.now().hour + h_times = hourly.get('time', []) + h_precip = hourly.get('precipitation_probability', []) + for t, p in zip(h_times, h_precip): + try: + entry_h = int(t[11:13]) + except Exception: + continue + if entry_h <= now_h or entry_h > now_h + 12: + continue + if p is not None and p >= 20: + next_rain_time = f"{entry_h:02d}:00" + break + data = { - '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, + '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': next_rain_time, } _location_cache[key] = (now, data) return data