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"""
+
- ${_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 `
-
+
+
${!_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