Navigation, Karten-FABs, Nearby-Alerts

- Bottom-Nav neu: Karte | Routen | + | Tagebuch | Forum
- + reduziert auf: Giftköder, Gassi-Treffen, Verlorener Hund
- Notification-Badge auf User-Avatar (Header)
- Nearby-Alerts: Nav-Leiste pulsiert rot/orange bei Giftköder/vermisstm Hund in 20km
- SW postMessage bei poison/lost_alert → sofortiger Alert-Check
- Karten-FABs: nur Marker setzen + Standort (Route aufzeichnen + Offline entfernt)
- SW by-v272, APP_VER 262
This commit is contained in:
rene 2026-04-20 19:46:34 +02:00
parent 5141ba9969
commit 65d1cf6c7f
7 changed files with 105 additions and 27 deletions

View file

@ -93,6 +93,7 @@ from routes.import_data import router as import_router
from routes.sharing import dog_router as sharing_dog_router, share_router as sharing_share_router from routes.sharing import dog_router as sharing_dog_router, share_router as sharing_share_router
from routes.widget import router as widget_router from routes.widget import router as widget_router
from routes.notifications import router as notifications_router from routes.notifications import router as notifications_router
from routes.alerts import router as alerts_router
from routes.services import router as services_router from routes.services import router as services_router
from routes.ratings import router as ratings_router from routes.ratings import router as ratings_router
from routes.sitting_access import router as sitting_access_router from routes.sitting_access import router as sitting_access_router
@ -129,6 +130,7 @@ app.include_router(sharing_dog_router, prefix="/api/dogs", tags=["Teilen"
app.include_router(sharing_share_router, prefix="/api/share", tags=["Teilen"]) app.include_router(sharing_share_router, prefix="/api/share", tags=["Teilen"])
app.include_router(widget_router, prefix="/api/widget", tags=["Widget"]) app.include_router(widget_router, prefix="/api/widget", tags=["Widget"])
app.include_router(notifications_router, prefix="/api/notifications", tags=["Notifications"]) app.include_router(notifications_router, prefix="/api/notifications", tags=["Notifications"])
app.include_router(alerts_router, prefix="/api/alerts", tags=["Alerts"])
app.include_router(services_router, prefix="/api/services", tags=["Services"]) app.include_router(services_router, prefix="/api/services", tags=["Services"])
app.include_router(ratings_router, prefix="/api/ratings", tags=["Ratings"]) app.include_router(ratings_router, prefix="/api/ratings", tags=["Ratings"])
app.include_router(sitting_access_router, prefix="/api/sitting-access", tags=["SittingAccess"]) app.include_router(sitting_access_router, prefix="/api/sitting-access", tags=["SittingAccess"])

36
backend/routes/alerts.py Normal file
View file

@ -0,0 +1,36 @@
"""BAN YARO — Nearby Alerts (Giftköder + Vermisste Hunde)"""
import math
from datetime import datetime
from fastapi import APIRouter
from database import db
router = APIRouter()
_RADIUS_M = 20_000 # 20 km
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
R = 6_371_000
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
@router.get("")
async def nearby_alerts(lat: float, lon: float):
now = datetime.utcnow().isoformat()
with db() as conn:
poisons = conn.execute(
"SELECT lat, lon FROM poison WHERE geloest=0 AND expires_at > ?", (now,)
).fetchall()
lost = conn.execute(
"SELECT lat, lon FROM lost_dogs WHERE is_active=1"
).fetchall()
has_poison = any(_haversine(lat, lon, r["lat"], r["lon"]) <= _RADIUS_M for r in poisons)
has_lost = any(_haversine(lat, lon, r["lat"], r["lon"]) <= _RADIUS_M for r in lost)
return {"poison": has_poison, "lost": has_lost}

View file

@ -176,6 +176,22 @@
display: flex; display: flex;
align-items: stretch; align-items: stretch;
box-shadow: 0 -2px 12px rgba(42, 31, 20, 0.08); box-shadow: 0 -2px 12px rgba(42, 31, 20, 0.08);
transition: border-top-color 0.4s ease;
}
@keyframes nav-alert-pulse {
0%, 100% { box-shadow: 0 -2px 12px rgba(42,31,20,0.08); }
50% { box-shadow: 0 -4px 20px var(--nav-alert-color, rgba(220,38,38,0.4)); }
}
#bottom-nav.alert-poison {
border-top: 3px solid var(--c-danger);
--nav-alert-color: rgba(220, 38, 38, 0.4);
animation: nav-alert-pulse 2s ease-in-out infinite;
}
#bottom-nav.alert-lost {
border-top: 3px solid #f59e0b;
--nav-alert-color: rgba(245, 158, 11, 0.4);
animation: nav-alert-pulse 2s ease-in-out infinite;
} }
.nav-item { .nav-item {

View file

@ -168,6 +168,11 @@
style="width:18px;height:18px;color:var(--c-text-muted)"> style="width:18px;height:18px;color:var(--c-text-muted)">
<use href="/icons/phosphor.svg#user"></use> <use href="/icons/phosphor.svg#user"></use>
</svg> </svg>
<span id="notif-nav-badge" class="hidden"
style="position:absolute;top:-3px;right:-3px;min-width:16px;height:16px;
border-radius:8px;background:var(--c-danger);color:#fff;font-size:10px;
font-weight:700;line-height:16px;text-align:center;padding:0 3px;
pointer-events:none;z-index:1"></span>
</button> </button>
<button class="header-menu-btn" id="header-menu-btn" aria-label="Menü"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#list"></use></svg></button> <button class="header-menu-btn" id="header-menu-btn" aria-label="Menü"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#list"></use></svg></button>
</header> </header>
@ -290,9 +295,9 @@
<!-- MOBILE BOTTOM NAVIGATION --> <!-- MOBILE BOTTOM NAVIGATION -->
<nav id="bottom-nav" role="navigation" aria-label="Hauptnavigation"> <nav id="bottom-nav" role="navigation" aria-label="Hauptnavigation">
<div class="nav-item active" data-page="diary"> <div class="nav-item" data-page="map">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-trifold"></use></svg>
<span class="nav-item-label">Tagebuch</span> <span class="nav-item-label">Karte</span>
</div> </div>
<div class="nav-item" data-page="routes"> <div class="nav-item" data-page="routes">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#path"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#path"></use></svg>
@ -302,17 +307,14 @@
<div class="nav-item nav-item-center" id="nav-add"> <div class="nav-item nav-item-center" id="nav-add">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#plus"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#plus"></use></svg>
</div> </div>
<div class="nav-item active" data-page="diary">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>
<span class="nav-item-label">Tagebuch</span>
</div>
<div class="nav-item" data-page="forum"> <div class="nav-item" data-page="forum">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>
<span class="nav-item-label">Forum</span> <span class="nav-item-label">Forum</span>
</div> </div>
<div class="nav-item" data-page="notifications">
<span style="position:relative;display:inline-flex">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bell"></use></svg>
<span class="nav-badge hidden" id="notif-nav-badge">0</span>
</span>
<span class="nav-item-label">Aktuelles</span>
</div>
</nav> </nav>
</div><!-- #app --> </div><!-- #app -->
@ -376,6 +378,11 @@
navigator.serviceWorker.addEventListener('controllerchange', () => { navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload(); window.location.reload();
}); });
navigator.serviceWorker.addEventListener('message', e => {
if (e.data?.type === 'CHECK_NEARBY_ALERTS') {
window.App?._checkNearbyAlerts?.();
}
});
} }
</script> </script>

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. Router, State-Management, Navigation, Initialisierung.
============================================================ */ ============================================================ */
const APP_VER = '261'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VER = '262'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => { const App = (() => {
@ -361,17 +361,14 @@ const App = (() => {
</button>`; </button>`;
UI.modal.open({ UI.modal.open({
title: 'Was möchtest du hinzufügen?', title: 'Schnellmeldung',
body: ` body: `
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
${authBtn('diary', 'btn-secondary', 'book-open', 'Tagebuch-Eintrag')}
${authBtn('health', 'btn-secondary', 'syringe', 'Gesundheits-Eintrag')}
${authBtn('chat', 'btn-secondary', 'chat-circle-dots','Neue Nachricht')}
${authBtn('forum', 'btn-secondary', 'push-pin', 'Forenbeitrag erstellen')}
<button class="btn btn-danger w-full" data-quick="poison"> <button class="btn btn-danger w-full" data-quick="poison">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning-octagon"></use></svg> Giftköder melden <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning-octagon"></use></svg> Giftköder melden
</button> </button>
${authBtn('walk', 'btn-nature', 'paw-print', 'Gassi-Treffen erstellen')} ${authBtn('walk', 'btn-secondary', 'paw-print', 'Gassi-Treffen erstellen')}
${authBtn('lost', 'btn-secondary', 'magnifying-glass','Verlorener Hund melden')}
</div> </div>
${!loggedIn ? `<p style="font-size:var(--text-xs);color:var(--c-text-muted);text-align:center;margin-top:var(--space-3)"> ${!loggedIn ? `<p style="font-size:var(--text-xs);color:var(--c-text-muted);text-align:center;margin-top:var(--space-3)">
<svg class="ph-icon" style="width:12px;height:12px" aria-hidden="true"><use href="/icons/phosphor.svg#info"></use></svg> <svg class="ph-icon" style="width:12px;height:12px" aria-hidden="true"><use href="/icons/phosphor.svg#info"></use></svg>
@ -391,12 +388,9 @@ const App = (() => {
// Ohne Delay trifft es das neu geöffnete Modal und schließt es sofort. // Ohne Delay trifft es das neu geöffnete Modal und schließt es sofort.
setTimeout(() => { setTimeout(() => {
if (action.startsWith('auth-')) { navigate('settings'); return; } if (action.startsWith('auth-')) { navigate('settings'); return; }
if (action === 'diary') { navigate('diary'); pages['diary'].module?.openNew?.(); }
if (action === 'health') { navigate('health'); pages['health'].module?.openNew?.(); }
if (action === 'poison') { navigate('poison'); pages['poison'].module?.openNew?.(); } if (action === 'poison') { navigate('poison'); pages['poison'].module?.openNew?.(); }
if (action === 'walk') { navigate('walks'); pages['walks'].module?.openNew?.(); } if (action === 'walk') { navigate('walks'); pages['walks'].module?.openNew?.(); }
if (action === 'chat') { navigate('chat'); setTimeout(() => pages['chat'].module?._showNewMessagePicker?.(), 400); } if (action === 'lost') { navigate('lost'); setTimeout(() => pages['lost'].module?.openNew?.(), 400); }
if (action === 'forum') { navigate('forum'); setTimeout(() => pages['forum'].module?.openNew?.(), 400); }
}, 350); }, 350);
}, { once: true }); }, { once: true });
} }
@ -434,11 +428,14 @@ const App = (() => {
_updateNotifBadge(); _updateNotifBadge();
_updateChatBadge(); _updateChatBadge();
_checkNearbyAlerts();
setInterval(() => { _updateNotifBadge(); _updateChatBadge(); }, 30_000); setInterval(() => { _updateNotifBadge(); _updateChatBadge(); }, 30_000);
setInterval(_checkNearbyAlerts, 5 * 60_000);
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') { if (document.visibilityState === 'visible') {
_updateNotifBadge(); _updateNotifBadge();
_updateChatBadge(); _updateChatBadge();
_checkNearbyAlerts();
if (state.page === 'chat') { if (state.page === 'chat') {
pages['chat']?.module?.refresh?.(); pages['chat']?.module?.refresh?.();
} }
@ -452,6 +449,22 @@ const App = (() => {
} }
} }
async function _checkNearbyAlerts() {
const nav = document.getElementById('bottom-nav');
if (!nav) return;
try {
const pos = await new Promise((resolve, reject) =>
navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 5000, maximumAge: 120_000 })
);
const { latitude: lat, longitude: lon } = pos.coords;
const data = await API.get(`/alerts?lat=${lat}&lon=${lon}`);
nav.classList.toggle('alert-poison', !!data.poison);
nav.classList.toggle('alert-lost', !data.poison && !!data.lost);
} catch {
// Kein Standort verfügbar — kein Alert anzeigen
}
}
async function _updateNotifBadge() { async function _updateNotifBadge() {
if (!state.user) return; if (!state.user) return;
try { try {
@ -820,7 +833,8 @@ const App = (() => {
renderDogSwitcher: _renderDogSwitcher, renderDogSwitcher: _renderDogSwitcher,
getInstallPrompt: () => _installPrompt, requireAuth, getInstallPrompt: () => _installPrompt, requireAuth,
showOnboarding: _showOnboardingModal, showOnboarding: _showOnboardingModal,
updateNotifBadge: _updateNotifBadge }; updateNotifBadge: _updateNotifBadge,
_checkNearbyAlerts };
})(); })();

View file

@ -191,8 +191,6 @@ window.Page_map = (() => {
</div> </div>
<div class="map-fabs"> <div class="map-fabs">
<button class="map-fab map-fab--rec" id="map-rec-btn" title="Route aufzeichnen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#path"></use></svg></button>
<button class="map-fab map-fab--offline" id="map-offline-btn" title="Karte offline speichern"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#floppy-disk"></use></svg></button>
<button class="map-fab map-fab--pin" id="map-pin-btn" title="Marker setzen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg></button> <button class="map-fab map-fab--pin" id="map-pin-btn" title="Marker setzen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg></button>
<button class="map-fab" id="map-locate-btn" title="Meinen Standort"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg></button> <button class="map-fab" id="map-locate-btn" title="Meinen Standort"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg></button>
</div> </div>
@ -270,8 +268,6 @@ window.Page_map = (() => {
}); });
document.getElementById('map-pin-btn').addEventListener('click', _togglePlacementMode); document.getElementById('map-pin-btn').addEventListener('click', _togglePlacementMode);
document.getElementById('map-offline-btn').addEventListener('click', _cacheTiles);
document.getElementById('map-rec-btn').addEventListener('click', _toggleRecording);
} }
// ---------------------------------------------------------- // ----------------------------------------------------------

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v271'; const CACHE_VERSION = 'by-v272';
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
@ -223,6 +223,13 @@ self.addEventListener('push', event => {
event.waitUntil( event.waitUntil(
self.registration.showNotification(data.title || 'Ban Yaro', options) self.registration.showNotification(data.title || 'Ban Yaro', options)
); );
// App informieren damit sie Nearby-Alerts neu prüft
if (data.type === 'poison_alert' || data.type === 'lost_dog_alert') {
self.clients.matchAll({ type: 'window' }).then(clients => {
clients.forEach(c => c.postMessage({ type: 'CHECK_NEARBY_ALERTS' }));
});
}
}); });
// ---------------------------------------------------------- // ----------------------------------------------------------