diff --git a/.env.example b/.env.example index 3deda7d..74d4688 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,6 @@ KI_CLOUD_MODEL=claude-opus-4-6 VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= VAPID_CONTACT=mailto:admin@banyaro.app + +# Admin-Benachrichtigungen (z.B. Prewarm-Fortschritt) +ADMIN_EMAIL= diff --git a/backend/routes/osm.py b/backend/routes/osm.py index 3056824..51c1f17 100644 --- a/backend/routes/osm.py +++ b/backend/routes/osm.py @@ -4,6 +4,7 @@ Cacht OSM-Daten lokal, erlaubt Nutzern eigene Marker und Meldungen. """ import math +import asyncio import httpx import logging from typing import Optional @@ -142,8 +143,11 @@ async def get_pois( stale = _stale_tiles(type, tiles) if stale and not fast: - for (x, y) in stale: - await _fetch_and_store_tile(type, x, y) + sem = asyncio.Semaphore(3) + async def _limited(x, y): + async with sem: + await _fetch_and_store_tile(type, x, y) + await asyncio.gather(*[_limited(x, y) for (x, y) in stale]) fetched_fresh = True with db() as conn: @@ -309,9 +313,16 @@ async def analyze_region( tiles = _covering_tiles(south, west, north, east, CACHE_ZOOM) async def _warmup(): - for poi_type in OSM_QUERIES: - for (x, y) in _stale_tiles(poi_type, tiles): + sem = asyncio.Semaphore(3) + async def _limited(poi_type, x, y): + async with sem: await _fetch_and_store_tile(poi_type, x, y) + tasks = [ + _limited(pt, x, y) + for pt in OSM_QUERIES + for (x, y) in _stale_tiles(pt, tiles) + ] + await asyncio.gather(*tasks) background_tasks.add_task(_warmup) return {'status': 'gestartet', 'tiles': len(tiles), 'types': list(OSM_QUERIES.keys())} diff --git a/backend/scheduler.py b/backend/scheduler.py index b8aa803..4304909 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -53,6 +53,13 @@ def start(): replace_existing=True, misfire_grace_time=7200, ) + _scheduler.add_job( + _job_prewarm_cities, + CronTrigger(day_of_week='sun', hour=1), # jeden Sonntag 01:00 Uhr + id="prewarm_cities", + replace_existing=True, + misfire_grace_time=7200, + ) # Einmalig beim Start (nach 10s Verzögerung) für sofortige Befüllung _scheduler.add_job( _job_import_events, @@ -61,6 +68,14 @@ def start(): id="import_events_startup", replace_existing=True, ) + # Einmalig beim Start (nach 90s) — OSM-Tiles für Großstädte vorwärmen + _scheduler.add_job( + _job_prewarm_cities, + 'date', + run_date=datetime.now() + timedelta(seconds=90), + id="prewarm_cities_startup", + replace_existing=True, + ) # Einmalig beim Start (nach 15s Verzögerung) — Rassen aus TheDogAPI befüllen _scheduler.add_job( _job_seed_breeds, @@ -384,6 +399,178 @@ async def _job_seed_wikidata_breeds(): logger.error(f"Wikidata-Seed: Fehler: {e}") +# ------------------------------------------------------------------ +# JOB: OSM-Tiles für deutsche Großstädte vorwärmen +# Läuft einmalig 90s nach Start + wöchentlich So 01:00 Uhr. +# Nur stale Tiles werden abgerufen (bereits gecachte werden übersprungen). +# ------------------------------------------------------------------ + +# Deutsche Städte mit >50.000 Einwohnern (lat, lon, name) +_CITIES_DE = [ + (52.5200, 13.4050, "Berlin"), + (53.5753, 10.0153, "Hamburg"), + (48.1372, 11.5755, "München"), + (51.2217, 6.7762, "Düsseldorf"), + (50.9333, 6.9500, "Köln"), + (50.1109, 8.6821, "Frankfurt"), + (48.7775, 9.1800, "Stuttgart"), + (51.4566, 7.0116, "Dortmund"), + (51.5136, 7.4653, "Dortmund-Ost"), + (51.4508, 7.0131, "Essen"), + (51.3388, 12.3799, "Leipzig"), + (51.2254, 6.7762, "Düsseldorf"), + (51.0534, 13.7373, "Dresden"), + (52.3759, 9.7320, "Hannover"), + (51.4818, 7.2162, "Bochum"), + (51.9607, 7.6261, "Münster"), + (51.3670, 7.4595, "Hagen"), + (50.7753, 6.0839, "Aachen"), + (51.2563, 7.1500, "Wuppertal"), + (49.4521, 11.0767, "Nürnberg"), + (53.0758, 8.8072, "Bremen"), + (50.7323, 7.0955, "Bonn"), + (49.0069, 8.4037, "Karlsruhe"), + (51.9607, 7.6261, "Münster"), + (51.4344, 6.7623, "Duisburg"), + (51.6667, 6.1667, "Moers"), + (48.3705, 10.8978, "Augsburg"), + (52.2689, 10.5268, "Braunschweig"), + (50.9287, 11.5861, "Jena"), + (53.8655, 10.6866, "Lübeck"), + (54.3233, 10.1394, "Kiel"), + (53.1435, 8.2146, "Oldenburg"), + (52.0302, 8.5325, "Bielefeld"), + (51.3167, 9.5000, "Kassel"), + (50.0000, 8.2731, "Mainz"), + (49.8728, 8.6512, "Darmstadt"), + (49.0047, 12.0949, "Regensburg"), + (48.9960, 8.4025, "Pforzheim"), + (53.4706, 9.9817, "Hamburg-Süd"), + (50.8283, 12.9209, "Chemnitz"), + (51.7227, 8.7559, "Paderborn"), + (52.1205, 11.6276, "Magdeburg"), + (52.6367, 11.8683, "Magdeburg-Ost"), + (50.3569, 7.5890, "Koblenz"), + (48.4010, 9.9876, "Ulm"), + (51.0504, 13.7373, "Dresden-Mitte"), + (49.4875, 8.4660, "Mannheim"), + (49.2354, 7.0038, "Kaiserslautern"), + (50.1155, 8.6782, "Frankfurt-Mitte"), + (50.0782, 8.2398, "Wiesbaden"), + (52.4227, 10.7865, "Wolfsburg"), + (51.9607, 8.8693, "Gütersloh"), + (53.5753, 9.8500, "Hamburg-West"), + (48.5216, 9.0576, "Reutlingen"), + (48.9522, 9.4358, "Heilbronn"), + (49.4478, 7.7691, "Kaiserslautern-W"), + (53.6333, 9.9833, "Hamburg-Nord"), + (52.3905, 13.0645, "Potsdam"), + (54.0924, 12.1407, "Rostock"), + (53.4339, 14.5508, "Szczecin-grenze"), + (51.7563, 14.3329, "Cottbus"), + (50.4782, 12.3598, "Zwickau"), + (53.5507, 9.9967, "Hamburg-Mitte"), + (51.8127, 10.3354, "Goslar"), + (48.6843, 9.0061, "Böblingen"), + (48.7761, 9.1775, "Stuttgart-Mitte"), + (49.4521, 8.4660, "Heidelberg"), + (50.8088, 8.7667, "Marburg"), + (51.9607, 7.6261, "Münster-Mitte"), + (52.2763, 8.0479, "Osnabrück"), + (53.8755, 10.7000, "Lübeck-Ost"), + (51.9333, 6.8667, "Borken"), +] + +async def _job_prewarm_cities(): + import os, asyncio, time + from routes.osm import _covering_tiles, _stale_tiles, _fetch_and_store_tile, OSM_QUERIES, CACHE_ZOOM + from mailer import send_email + + ADMIN = os.getenv("ADMIN_EMAIL", "") + REPORT_INTERVAL = 5 * 3600 # alle 5 Stunden + + logger.info("City-Prewarm Job startet…") + sem = asyncio.Semaphore(2) + total_fetched = 0 + cities_done = 0 + start_time = time.monotonic() + last_report = start_time + + async def _fetch(poi_type, x, y): + nonlocal total_fetched + async with sem: + await _fetch_and_store_tile(poi_type, x, y) + total_fetched += 1 + await asyncio.sleep(1.5) + + async def _send_progress(subject_prefix, cities_done, total_cities, eta_str=""): + if not ADMIN: + return + elapsed = int(time.monotonic() - start_time) + h, m = divmod(elapsed // 60, 60) + elapsed_str = f"{h}h {m:02d}min" if h else f"{m}min" + pct = round(cities_done / total_cities * 100) + body_plain = ( + f"City-Prewarm Fortschritt\n\n" + f"Städte: {cities_done}/{total_cities} ({pct}%)\n" + f"Tiles geladen: {total_fetched}\n" + f"Laufzeit: {elapsed_str}\n" + f"{('Verbleibend (ca.): ' + eta_str) if eta_str else ''}" + ) + body_html = f"""\ +
+

🗺️ City-Prewarm {subject_prefix}

+ + + + + + + + {f'' if eta_str else ''} +
Städte:{cities_done} / {total_cities} ({pct}%)
Tiles geladen:{total_fetched}
Laufzeit:{elapsed_str}
Verbleibend (ca.):{eta_str}
+
""" + try: + await send_email(ADMIN, f"Ban Yaro — City-Prewarm {subject_prefix}", body_html, body_plain) + except Exception as e: + logger.warning(f"City-Prewarm Mail fehlgeschlagen: {e}") + + total_cities = len(_CITIES_DE) + for lat, lon, city in _CITIES_DE: + dlat = 0.18 + dlon = 0.25 + south, west, north, east = lat - dlat, lon - dlon, lat + dlat, lon + dlon + tiles = _covering_tiles(south, west, north, east, CACHE_ZOOM) + + tasks = [] + for poi_type in OSM_QUERIES: + stale = _stale_tiles(poi_type, tiles) + for (x, y) in stale: + tasks.append(_fetch(poi_type, x, y)) + + if tasks: + logger.info(f"City-Prewarm: {city} — {len(tasks)} Tiles zu laden") + await asyncio.gather(*tasks) + else: + logger.debug(f"City-Prewarm: {city} — alle Tiles frisch") + + cities_done += 1 + + # Fortschritts-Mail alle 5 Stunden + now = time.monotonic() + if ADMIN and (now - last_report) >= REPORT_INTERVAL: + elapsed = now - start_time + rate = cities_done / elapsed if elapsed > 0 else 0 + remaining = int((total_cities - cities_done) / rate) if rate > 0 else 0 + rh, rm = divmod(remaining // 60, 60) + eta_str = f"{rh}h {rm:02d}min" if rh else f"{rm}min" + await _send_progress("Fortschritt", cities_done, total_cities, eta_str) + last_report = now + + logger.info(f"City-Prewarm Job fertig — {total_fetched} Tiles geladen.") + await _send_progress("abgeschlossen ✓", cities_done, total_cities) + + def _compute_milestone(today: date, bday: date, dog_name: str): """ Gibt (titel, text) zurück wenn heute ein Meilenstein-Tag ist, diff --git a/backend/static/icons/phosphor.svg b/backend/static/icons/phosphor.svg index 4733778..36ad812 100644 --- a/backend/static/icons/phosphor.svg +++ b/backend/static/icons/phosphor.svg @@ -25,6 +25,7 @@ + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 24c0abe..80cb9d7 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '98'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '116'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const App = (() => { @@ -261,6 +261,7 @@ const App = (() => { const item = e.target.closest('[data-page]'); if (item) { navigate(item.dataset.page); + if (item.closest('#sidebar')) _closeSidebar(); return; } @@ -274,6 +275,7 @@ const App = (() => { // Sidebar-User (kein data-page, damit keine Aktiv-Markierung) if (e.target.closest('#sidebar-user')) { navigate('settings'); + _closeSidebar(); return; } diff --git a/backend/static/js/pages/friends.js b/backend/static/js/pages/friends.js index 17a4ff2..b939086 100644 --- a/backend/static/js/pages/friends.js +++ b/backend/static/js/pages/friends.js @@ -36,7 +36,7 @@ window.Page_friends = (() => { const myLink = `${location.origin}/#friends?suche=${encodeURIComponent(myName)}`; _container.innerHTML = ` -
+
@@ -186,18 +186,20 @@ window.Page_friends = (() => { ${list.map(r => `
-
+
${_userAvatar(r.requester_name, r.dogs?.[0], r.avatar_url)} -
-
+
+
${_esc(r.requester_name)}
${_dogPills(r.dogs, 2)}
-
+
`); } if (profile.social_link) { - parts.push(`
+ parts.push(``); @@ -508,10 +510,9 @@ window.Page_friends = (() => {
` : ''}
-
`).join('')} diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js index ede2804..8d913de 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -121,6 +121,8 @@ window.Page_map = (() => { let _overpassTimer = null; let _overpassActive = false; + let _ringClosing = false; + let _frankfurtTimer = null; // ---------------------------------------------------------- // INIT @@ -133,11 +135,23 @@ window.Page_map = (() => { // Alle-Button Initialzustand const anyOnInit = Object.entries(_visible).some(([k, v]) => v && k !== 'giftkoeder'); document.getElementById('map-legend-all')?.classList.toggle('all-off', !anyOnInit); - try { _userPos = await API.getLocation(); } catch {} await _loadLeaflet(); - _initMap(); + _initMap(); // sofort mit Deutschland-Mitte starten _startLocationTracking(); _loadAll(); + // Standort im Hintergrund holen — bei Erfolg zur Position fliegen + API.getLocation().then(pos => { + _userPos = pos; + if (_frankfurtTimer) { clearTimeout(_frankfurtTimer); _frankfurtTimer = null; } + _map?.flyTo([pos.lat, pos.lon], 14, { duration: 1.2 }); + }).catch(() => { + const btn = document.getElementById('map-locate-btn'); + if (btn) { + btn.title = 'Standort nicht verfügbar'; + btn.style.opacity = '0.55'; + btn.innerHTML = ''; + } + }); } function refresh() { _loadAll(); } @@ -302,11 +316,15 @@ window.Page_map = (() => { const el = document.getElementById('central-map'); if (!el || !window.L || _map) return; - const center = _userPos ? [_userPos.lat, _userPos.lon] : [51.1657, 10.4515]; - const zoom = _userPos ? 14 : 6; + const center = _userPos ? [_userPos.lat, _userPos.lon] : [50.1109, 8.6821]; // Frankfurt + const zoom = _userPos ? 14 : 10; _map = L.map('central-map', { zoomControl: true, attributionControl: false }) .setView(center, zoom); + + if (!_userPos) { + _frankfurtTimer = setTimeout(() => _map.flyTo(center, 14, { duration: 2.5 }), 1200); + } L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_map); setTimeout(() => _map.invalidateSize(), 100); @@ -403,9 +421,89 @@ window.Page_map = (() => { } function _setOsmStatus(text, pct = null) { - const el = document.getElementById('map-osm-status'); + const el = document.getElementById('map-osm-status'); + const statusbar = document.getElementById('map-statusbar'); if (el) el.textContent = text; _updateScanRing(text ? pct : null); + _updateScanDog(text ? pct : null); + + if (pct === 100 && statusbar) { + statusbar.classList.add('scan-done'); + setTimeout(() => statusbar.classList.remove('scan-done'), 2200); + } + } + + function _injectDogStyles() { + if (document.getElementById('by-dog-style')) return; + const s = document.createElement('style'); + s.id = 'by-dog-style'; + s.textContent = [ + '@keyframes by-sniff{0%,100%{transform:translateY(0) rotate(0deg)}30%{transform:translateY(2.5px) rotate(-1.5deg)}70%{transform:translateY(1px) rotate(1deg)}}', + '@keyframes by-wander{0%,100%{transform:translateX(0)}20%{transform:translateX(-7px)}45%{transform:translateX(5px)}68%{transform:translateX(-5px)}85%{transform:translateX(7px)}}', + '@keyframes by-wag{0%,100%{transform:rotate(-22deg)}50%{transform:rotate(22deg)}}', + '#map-scan-dog{animation:by-wander 1.75s ease-in-out infinite;transition:opacity .5s ease;color:#C4843A;position:absolute;pointer-events:none;z-index:1003;width:42px;height:32px}', + '#map-scan-dog svg{display:block;animation:by-sniff .42s ease-in-out infinite}', + '#map-scan-dog .by-tail{transform-box:fill-box;transform-origin:0% 100%;animation:by-wag .32s ease-in-out infinite}', + '#map-statusbar{transition:background .35s ease,color .35s ease,border-color .35s ease}', + '#map-statusbar.scan-done{background:#22C55E!important;color:#fff!important;border-color:#16A34A!important}', + ].join(''); + document.head.appendChild(s); + } + + function _updateScanDog(pct) { + _injectDogStyles(); + const statusbar = document.getElementById('map-statusbar'); + if (!statusbar) return; + const mapEl = statusbar.closest('.map-main') || statusbar.parentElement; + if (!mapEl) return; + + let dog = document.getElementById('map-scan-dog'); + + if (pct === null) { + if (_ringClosing) return; + if (dog) { dog.style.opacity = '0'; setTimeout(() => dog?.remove(), 550); } + return; + } + + if (!dog) { + dog = document.createElement('div'); + dog.id = 'map-scan-dog'; + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', '42'); + svg.setAttribute('height', '32'); + svg.setAttribute('viewBox', '0 0 54 40'); + svg.innerHTML = ` + + + + + + + + + + + + + + + `; + dog.appendChild(svg); + mapEl.appendChild(dog); + } + + const sr = statusbar.getBoundingClientRect(); + const mr = mapEl.getBoundingClientRect(); + dog.style.left = (sr.left - mr.left + sr.width - 36) + 'px'; + dog.style.top = (sr.top - mr.top - 35) + 'px'; + dog.style.opacity = '1'; + + if (pct >= 100) { + setTimeout(() => { + const d = document.getElementById('map-scan-dog'); + if (d) { d.style.opacity = '0'; setTimeout(() => d?.remove(), 550); } + }, 500); + } } function _updateScanRing(pct) { @@ -418,6 +516,7 @@ window.Page_map = (() => { // Ring ausblenden / entfernen if (pct === null) { + if (_ringClosing) return; if (svg) { svg.style.opacity = '0'; setTimeout(() => svg?.remove(), 600); } statusbar.style.border = ''; return; @@ -448,12 +547,13 @@ window.Page_map = (() => { const p = 2; // Abstand zur inneren Kante // Umfang der Pill: gerades Stück + zwei Halbkreise - const perim = 2 * (w - h) + Math.PI * h; - // Stroke beginnt oben-links und läuft im Uhrzeigersinn - // Um bei 12 Uhr zu starten: Offset um das linke Halbkreis-Viertel + halbe Geraden verschieben - const startShift = (w - h) / 2 + (Math.PI * h) / 4; - const progress = Math.min(100, Math.max(0, pct)); - const dashOffset = perim * (1 - progress / 100) + startShift; + const perim = 2 * (w - h) + Math.PI * h; + // Natürlicher SVG-Start: linkes Ende der oberen Geraden + // 12-Uhr-Position: Mitte der oberen Geraden → Abstand = (w-h)/2 + // dashoffset = perim - S verschiebt den Dash-Start genau dorthin + const S = (w - h) / 2; + const progress = Math.min(100, Math.max(0, pct)); + const progressLen = progress * perim / 100; svg.style.left = (sr.left - mr.left - p) + 'px'; svg.style.top = (sr.top - mr.top - p) + 'px'; @@ -468,18 +568,19 @@ window.Page_map = (() => { rect.setAttribute('height', String(h)); rect.setAttribute('rx', String(r)); rect.setAttribute('ry', String(r)); - rect.setAttribute('stroke-dasharray', perim.toFixed(2)); - rect.setAttribute('stroke-dashoffset', dashOffset.toFixed(2)); + rect.setAttribute('stroke-dasharray', `${progressLen.toFixed(2)} ${(perim - progressLen).toFixed(2)}`); + rect.setAttribute('stroke-dashoffset', (perim - S).toFixed(2)); // Original-Rahmen verstecken während Ring aktiv ist statusbar.style.border = 'none'; if (progress >= 100) { + _ringClosing = true; setTimeout(() => { const s = document.getElementById('map-scan-ring'); if (s) s.style.opacity = '0'; statusbar.style.border = ''; - setTimeout(() => s?.remove(), 600); + setTimeout(() => { s?.remove(); _ringClosing = false; }, 600); }, 500); } } @@ -517,7 +618,8 @@ window.Page_map = (() => { } _overpassActive = true; - const b = _map.getBounds(); + _map.invalidateSize(); + const b = _map.getBounds().pad(0.15); const bbox = { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() }; // Welche Layer bei diesem Zoom geladen werden diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 7f9a172..385caee 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -246,7 +246,7 @@ window.Page_settings = (() => { try { const fd = new FormData(); fd.append('file', file); - const res = await API.post('/api/profile/avatar', fd); + const res = await API.post('/profile/avatar', fd); _appState.user.avatar_url = res.avatar_url; UI.toast.success('Avatar aktualisiert.'); _render(); @@ -326,7 +326,7 @@ window.Page_settings = (() => { const btn = document.querySelector('[form="profile-form"]'); const fd = UI.formData(e.target); await UI.asyncButton(btn, async () => { - const updated = await API.patch('/api/profile', { + const updated = await API.patch('/profile', { bio: fd.bio || '', wohnort: fd.wohnort || '', erfahrung: fd.erfahrung || '', diff --git a/backend/static/js/pages/trainingsplaene.js b/backend/static/js/pages/trainingsplaene.js index b074ad4..037a559 100644 --- a/backend/static/js/pages/trainingsplaene.js +++ b/backend/static/js/pages/trainingsplaene.js @@ -124,7 +124,8 @@ window.Page_trainingsplaene = (() => { ]; const btns = plans.map(p => ` `).join(''); diff --git a/backend/static/sw.js b/backend/static/sw.js index bbe91f9..5f08bc8 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-v123'; +const CACHE_VERSION = 'by-v141'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten