Feature: User-Feedback, Regen-Uhrzeit im Wetter-Chip, Admin-Karten klickbar (SW by-v833)

- 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)
This commit is contained in:
rene 2026-05-10 12:52:55 +02:00
parent d18c592ef0
commit 70af387147
12 changed files with 211 additions and 42 deletions

View file

@ -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():

View file

@ -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"""
<p style="margin:0 0 16px">
Neues Feedback aus der App:
</p>
<table style="width:100%;border-collapse:collapse;font-size:13px;margin-bottom:20px">
<tr><td style="padding:6px 10px;background:#f5f0ea;font-weight:600;width:120px">Kategorie</td>
<td style="padding:6px 10px;border-bottom:1px solid #eee">{kat_label}</td></tr>
<tr><td style="padding:6px 10px;background:#f5f0ea;font-weight:600">User</td>
<td style="padding:6px 10px;border-bottom:1px solid #eee">@{username} ({email})</td></tr>
<tr><td style="padding:6px 10px;background:#f5f0ea;font-weight:600">Tier</td>
<td style="padding:6px 10px;border-bottom:1px solid #eee">{tier}</td></tr>
</table>
<div style="background:#fdf6ef;border-left:4px solid #C4843A;padding:14px 16px;
border-radius:0 8px 8px 0;white-space:pre-wrap;font-size:14px;line-height:1.6">
{payload.text}
</div>"""
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}

View file

@ -101,9 +101,9 @@
</script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=826">
<link rel="stylesheet" href="/css/layout.css?v=826">
<link rel="stylesheet" href="/css/components.css?v=826">
<link rel="stylesheet" href="/css/design-system.css?v=833">
<link rel="stylesheet" href="/css/layout.css?v=833">
<link rel="stylesheet" href="/css/components.css?v=833">
</head>
<body>
@ -583,10 +583,10 @@
<div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=826"></script>
<script src="/js/ui.js?v=826"></script>
<script src="/js/app.js?v=826"></script>
<script src="/js/worlds.js?v=826"></script>
<script src="/js/api.js?v=833"></script>
<script src="/js/ui.js?v=833"></script>
<script src="/js/app.js?v=833"></script>
<script src="/js/worlds.js?v=833"></script>
<!-- Feature-Seiten werden lazy geladen -->

View file

@ -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)

View file

@ -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

View file

@ -534,22 +534,22 @@ window.Page_admin = (() => {
};
el.innerHTML = `
<div class="adm-stats-grid">
${_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)')}
<div class="adm-stats-grid" id="adm-overview-grid">
${_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')}
</div>
<div class="card" style="padding:var(--space-4)">
@ -705,11 +705,19 @@ window.Page_admin = (() => {
</p>
</div>
`;
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 `
<div class="card" style="padding:var(--space-4);text-align:center">
<div class="card" ${clickable}>
<svg class="ph-icon" style="width:24px;height:24px;color:${color};margin-bottom:var(--space-2)"
aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>

View file

@ -11,6 +11,7 @@ window.Page_map = (() => {
let _map = null;
let _leafletLoaded = false;
let _userPos = null;
let _weatherLoaded = false;
let _placingMarker = false;
let _tempMarker = null;
@ -147,6 +148,7 @@ window.Page_map = (() => {
_userPos = pos;
if (_frankfurtTimer) { clearTimeout(_frankfurtTimer); _frankfurtTimer = null; }
_map?.flyTo([pos.lat, pos.lon], 14, { duration: 1.2 });
_weatherLoaded = true;
_loadWeather(pos.lat, pos.lon);
}).catch(() => {
const btn = document.getElementById('map-locate-btn');
@ -373,6 +375,7 @@ window.Page_map = (() => {
pos => {
const { latitude: lat, longitude: lon, accuracy: acc } = pos.coords;
_userPos = { lat, lon };
if (!_weatherLoaded) { _weatherLoaded = true; _loadWeather(lat, lon); }
if (_locationMarker) {
_locationMarker.setLatLng([lat, lon]);
_locationAccuracy?.setLatLng([lat, lon]).setRadius(acc);
@ -1628,7 +1631,9 @@ window.Page_map = (() => {
const w = await API.weather.get(lat, lon);
const temp = w.temp_c != null ? `${Math.round(w.temp_c)}°` : '';
const icon = `<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px;vertical-align:-2px"><use href="/icons/phosphor.svg#${w.icon}"></use></svg>`;
const regen = w.precip_prob != null ? ` · 💧 ${w.precip_prob}%` : '';
const regen = w.precip_prob != null
? (w.next_rain_time ? ` · 💧 ${w.precip_prob}% ab ${w.next_rain_time}` : ` · 💧 ${w.precip_prob}%`)
: '';
let zecken = '';
if (w.zecken_warnung) {
const col = w.zecken_warnung === 'hoch' ? '#991B1B' : '#92400E';

View file

@ -269,6 +269,12 @@ window.Page_settings = (() => {
<span>Hilfe &amp; FAQ</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
<div class="sidebar-item" id="settings-feedback-btn"
style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border);cursor:pointer">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#chat-dots"></use></svg>
<span>Feedback geben</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
${!_appState.user?.subscription_tier || _appState.user.subscription_tier === 'standard' || _appState.user.subscription_tier === 'standard_test' ? `
<div style="margin:var(--space-3) 0;padding:var(--space-3) var(--space-4);
background:rgba(196,132,58,0.1);border-radius:var(--radius-md);
@ -790,6 +796,52 @@ window.Page_settings = (() => {
App.navigate('hilfe');
});
document.getElementById('settings-feedback-btn')?.addEventListener('click', () => {
const sel = (id) => document.getElementById(id);
const inputStyle = 'width:100%;padding:10px 12px;border:1.5px solid var(--c-border);border-radius:var(--radius-md);background:var(--c-bg-card);color:var(--c-text);font-size:var(--text-sm);box-sizing:border-box';
UI.modal.open({
title: 'Feedback geben',
body: `
<form id="feedback-form" style="display:flex;flex-direction:column;gap:var(--space-4)">
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Kategorie</label>
<select id="feedback-kat" name="kategorie" style="${inputStyle}">
<option value="idee">💡 Idee / Wunsch</option>
<option value="bug">🐛 Bug / Fehler</option>
<option value="lob">🎉 Lob</option>
<option value="sonstiges">💬 Sonstiges</option>
</select>
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Deine Nachricht</label>
<textarea id="feedback-text" name="text" rows="5" maxlength="2000"
placeholder="Was möchtest du uns mitteilen?"
style="${inputStyle};resize:vertical"></textarea>
</div>
</form>
`,
footer: `
<div class="w3-btn-stack">
<button type="submit" form="feedback-form" id="feedback-submit-btn" class="btn btn-primary" style="width:100%">Absenden</button>
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
</div>
`,
});
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?',

View file

@ -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;

View file

@ -1097,7 +1097,10 @@ window.Worlds = (() => {
<span style="font-size:1.25rem;font-weight:800;color:${gassiColor};line-height:1">${gassiScore ?? '—'}</span>
${gassiScore ? `<span style="font-size:var(--text-xs);color:rgba(255,255,255,0.4);font-weight:600">/10</span>` : ''}
</div>
${w ? `<span style="font-size:9px;color:rgba(255,255,255,0.75);font-weight:500;margin-top:1px;white-space:nowrap">${Math.round(w.temp_c ?? 0)}° · ${w.precip_prob ?? 0}% Regen</span>` : ''}
${w ? `<div style="font-size:9px;color:rgba(255,255,255,0.75);font-weight:500;margin-top:2px;line-height:1.5">
<div>${Math.round(w.temp_c ?? 0)}° · ${w.precip_prob ?? 0}% Regen</div>
${w.next_rain_time ? `<div style="color:rgba(255,255,255,0.5)">ab ${w.next_rain_time} Uhr</div>` : ''}
</div>` : ''}
</div>
</div>
</div>

View file

@ -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

View file

@ -58,6 +58,7 @@ async def get_weather_for_location(lat: float, lon: float) -> dict:
f"?latitude={lat}&longitude={lon}"
"&current=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