Compare commits

..

2 commits

Author SHA1 Message Date
2f021f54c2 Fix: Hund-Limit Standard-User (max 1, Pro required für weitere), nacho_sarah → Pro (SW by-v834) 2026-05-10 13:00:39 +02:00
70af387147 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)
2026-05-10 12:52:55 +02:00
13 changed files with 220 additions and 43 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.challenges import router as challenges_router
from routes.gassi_zeiten import router as gassi_zeiten_router from routes.gassi_zeiten import router as gassi_zeiten_router
from routes.help import router as help_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(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) 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(challenges_router, prefix="/api/challenges", tags=["Foto-Challenge"])
app.include_router(gassi_zeiten_router, prefix="/api/gassi-zeiten", tags=["Gassi-Zeiten"]) 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(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) os.makedirs(MEDIA_DIR, exist_ok=True)
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
APP_VER = "826" # muss mit APP_VER in app.js übereinstimmen APP_VER = "834" # muss mit APP_VER in app.js übereinstimmen
@app.get("/.well-known/assetlinks.json") @app.get("/.well-known/assetlinks.json")
async def assetlinks(): async def assetlinks():

View file

@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
from database import db from database import db
from auth import get_current_user from auth import get_current_user, has_pro_access
from routes.push import send_push_to_user from routes.push import send_push_to_user
from media_utils import safe_media_path, preview_url_from from media_utils import safe_media_path, preview_url_from
@ -131,6 +131,14 @@ def _is_plausible_dog(name: str, rasse: str, geburtstag) -> tuple[bool, str]:
@router.post("") @router.post("")
async def create_dog(data: DogCreate, user=Depends(get_current_user)): async def create_dog(data: DogCreate, user=Depends(get_current_user)):
with db() as conn: with db() as conn:
existing = conn.execute(
"SELECT COUNT(*) FROM dogs WHERE user_id=?", (user["id"],)
).fetchone()[0]
if existing >= 1 and not has_pro_access(user):
raise HTTPException(
status_code=403,
detail="Mehrere Hunde sind ein Pro-Feature. Upgrade auf Ban Yaro Pro, um weitere Hunde anzulegen."
)
conn.execute( conn.execute(
"""INSERT INTO dogs (user_id, name, rasse, geburtstag, geschlecht, """INSERT INTO dogs (user_id, name, rasse, geburtstag, geschlecht,
gewicht_kg, chip_nr, bio, is_public) gewicht_kg, chip_nr, bio, is_public)

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

View file

@ -58,7 +58,10 @@ const API = (() => {
try { data = await response.json(); } catch { data = null; } try { data = await response.json(); } catch { data = null; }
if (!response.ok) { 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'); const isSwOffline = response.status === 503 && message.startsWith('Offline');
// Retry: GET auf echte 5xx (nicht SW-generierte Offline-503) // Retry: GET auf echte 5xx (nicht SW-generierte Offline-503)

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. Router, State-Management, Navigation, Initialisierung.
============================================================ */ ============================================================ */
const APP_VER = '826'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VER = '834'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.5.0'; // ← semantische Version, wird bei make release gesetzt const APP_VERSION = '1.5.0'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app'; const IS_STAGING = location.hostname === 'staging.banyaro.app';
// Cache-Bust-Parameter nach Update-Reload sofort entfernen // Cache-Bust-Parameter nach Update-Reload sofort entfernen

View file

@ -534,22 +534,22 @@ window.Page_admin = (() => {
}; };
el.innerHTML = ` el.innerHTML = `
<div class="adm-stats-grid"> <div class="adm-stats-grid" id="adm-overview-grid">
${_statCard('users', 'Nutzer gesamt', s.users_total, 'var(--c-primary)')} ${_statCard('users', 'Nutzer gesamt', s.users_total, 'var(--c-primary)', 'nutzer')}
${_statCard('user-plus', 'Neu heute', s.users_today, 'var(--c-success)')} ${_statCard('user-plus', 'Neu heute', s.users_today, 'var(--c-success)', 'nutzer')}
${_statCard('activity', 'Aktiv (7 Tage)', s.active_users_7d, 'var(--c-primary)')} ${_statCard('activity', 'Aktiv (7 Tage)', s.active_users_7d, 'var(--c-primary)', 'nutzer')}
${_statCard('paw-print', 'Hunde', s.dogs_total, 'var(--c-primary)')} ${_statCard('paw-print', 'Hunde', s.dogs_total, 'var(--c-primary)', 'nutzer')}
${_statCard('chat-circle-dots','Threads', s.threads, 'var(--c-text-secondary)')} ${_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)')} ${_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)')} ${_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)')} ${_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)')} ${_statCard('warning-octagon', 'Giftk. aktiv', s.poison_active, 'var(--c-danger)', 'system')}
${_statCard('bell', 'Push-Abos', s.push_subscriptions, 'var(--c-text-secondary)')} ${_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('image', 'Media-Einträge', s.media_count, 'var(--c-text-secondary)')}
${_statCard('map-pin', 'Routen', s.routes_total, '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('calendar', 'Events', s.events_total, 'var(--c-text-secondary)')}
${_statCard('map-trifold', 'OSM-Marker', s.osm_total.toLocaleString('de'), 'var(--c-success)')} ${_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)')} ${_statCard('squares-four', 'Gecachte Tiles', s.osm_tiles.toLocaleString('de'), 'var(--c-text-secondary)', 'system')}
</div> </div>
<div class="card" style="padding:var(--space-4)"> <div class="card" style="padding:var(--space-4)">
@ -705,11 +705,19 @@ window.Page_admin = (() => {
</p> </p>
</div> </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 ` 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)" <svg class="ph-icon" style="width:24px;height:24px;color:${color};margin-bottom:var(--space-2)"
aria-hidden="true"> aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use> <use href="/icons/phosphor.svg#${icon}"></use>

View file

@ -11,6 +11,7 @@ window.Page_map = (() => {
let _map = null; let _map = null;
let _leafletLoaded = false; let _leafletLoaded = false;
let _userPos = null; let _userPos = null;
let _weatherLoaded = false;
let _placingMarker = false; let _placingMarker = false;
let _tempMarker = null; let _tempMarker = null;
@ -147,6 +148,7 @@ window.Page_map = (() => {
_userPos = pos; _userPos = pos;
if (_frankfurtTimer) { clearTimeout(_frankfurtTimer); _frankfurtTimer = null; } if (_frankfurtTimer) { clearTimeout(_frankfurtTimer); _frankfurtTimer = null; }
_map?.flyTo([pos.lat, pos.lon], 14, { duration: 1.2 }); _map?.flyTo([pos.lat, pos.lon], 14, { duration: 1.2 });
_weatherLoaded = true;
_loadWeather(pos.lat, pos.lon); _loadWeather(pos.lat, pos.lon);
}).catch(() => { }).catch(() => {
const btn = document.getElementById('map-locate-btn'); const btn = document.getElementById('map-locate-btn');
@ -373,6 +375,7 @@ window.Page_map = (() => {
pos => { pos => {
const { latitude: lat, longitude: lon, accuracy: acc } = pos.coords; const { latitude: lat, longitude: lon, accuracy: acc } = pos.coords;
_userPos = { lat, lon }; _userPos = { lat, lon };
if (!_weatherLoaded) { _weatherLoaded = true; _loadWeather(lat, lon); }
if (_locationMarker) { if (_locationMarker) {
_locationMarker.setLatLng([lat, lon]); _locationMarker.setLatLng([lat, lon]);
_locationAccuracy?.setLatLng([lat, lon]).setRadius(acc); _locationAccuracy?.setLatLng([lat, lon]).setRadius(acc);
@ -1628,7 +1631,9 @@ window.Page_map = (() => {
const w = await API.weather.get(lat, lon); const w = await API.weather.get(lat, lon);
const temp = w.temp_c != null ? `${Math.round(w.temp_c)}°` : ''; 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 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 = ''; let zecken = '';
if (w.zecken_warnung) { if (w.zecken_warnung) {
const col = w.zecken_warnung === 'hoch' ? '#991B1B' : '#92400E'; const col = w.zecken_warnung === 'hoch' ? '#991B1B' : '#92400E';

View file

@ -269,6 +269,12 @@ window.Page_settings = (() => {
<span>Hilfe &amp; FAQ</span> <span>Hilfe &amp; FAQ</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span> <span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div> </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' ? ` ${!_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); <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); background:rgba(196,132,58,0.1);border-radius:var(--radius-md);
@ -790,6 +796,52 @@ window.Page_settings = (() => {
App.navigate('hilfe'); 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 () => { document.getElementById('settings-logout-btn')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({ const ok = await UI.modal.confirm({
title : 'Abmelden?', title : 'Abmelden?',

View file

@ -79,15 +79,34 @@ const UI = (() => {
document.getElementById('modal-container').appendChild(overlay); document.getElementById('modal-container').appendChild(overlay);
document.documentElement.classList.add('modal-open'); 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'); return overlay.querySelector('.modal');
} }
function close() { function close() {
if (!_current) return; if (!_current) return;
const { onClose } = _current; const { onClose, _vvCleanup } = _current;
onClose?.(); onClose?.();
_vvCleanup?.();
_current.overlay.remove(); _current.overlay.remove();
document.documentElement.classList.remove('modal-open'); document.documentElement.classList.remove('modal-open');
_current = null; _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> <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>` : ''} ${gassiScore ? `<span style="font-size:var(--text-xs);color:rgba(255,255,255,0.4);font-weight:600">/10</span>` : ''}
</div> </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> </div>
</div> </div>

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v826'; const CACHE_VERSION = 'by-v834';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache 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}" f"?latitude={lat}&longitude={lon}"
"&current=temperature_2m,apparent_temperature,weathercode,windspeed_10m,is_day" "&current=temperature_2m,apparent_temperature,weathercode,windspeed_10m,is_day"
"&daily=precipitation_probability_max,uv_index_max" "&daily=precipitation_probability_max,uv_index_max"
"&hourly=precipitation_probability"
"&timezone=Europe%2FBerlin&forecast_days=1" "&timezone=Europe%2FBerlin&forecast_days=1"
) )
async with httpx.AsyncClient(timeout=8.0) as client: async with httpx.AsyncClient(timeout=8.0) as client:
@ -67,6 +68,7 @@ async def get_weather_for_location(lat: float, lon: float) -> dict:
cur = raw.get('current', {}) cur = raw.get('current', {})
daily = raw.get('daily', {}) daily = raw.get('daily', {})
hourly = raw.get('hourly', {})
temp = cur.get('temperature_2m') temp = cur.get('temperature_2m')
feels_like = cur.get('apparent_temperature') feels_like = cur.get('apparent_temperature')
@ -85,6 +87,24 @@ async def get_weather_for_location(lat: float, lon: float) -> dict:
if temp is not None and temp > 7.0 and 3 <= month <= 10: 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') 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 = { data = {
'temp_c': temp, 'temp_c': temp,
'feels_like_c': feels_like, 'feels_like_c': feels_like,
@ -96,6 +116,7 @@ async def get_weather_for_location(lat: float, lon: float) -> dict:
'uv_index': uv, 'uv_index': uv,
'is_day': bool(is_day), 'is_day': bool(is_day),
'zecken_warnung': zecken, 'zecken_warnung': zecken,
'next_rain_time': next_rain_time,
} }
_location_cache[key] = (now, data) _location_cache[key] = (now, data)
return data return data