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:
parent
d18c592ef0
commit
70af387147
12 changed files with 211 additions and 42 deletions
|
|
@ -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():
|
||||
|
|
|
|||
56
backend/routes/feedback.py
Normal file
56
backend/routes/feedback.py
Normal 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}
|
||||
|
|
@ -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 -->
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -269,6 +269,12 @@ window.Page_settings = (() => {
|
|||
<span>Hilfe & 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?',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue