Sprint 14: Map-Fixes, City-Prewarm, Dog-Animation, Scan-Flash

Karte:
- Frankfurt-Fallback (Zoom 10→14 flyTo) mit _frankfurtTimer-Cancel
  wenn echter Standort eintrifft
- OSM-Tile-Fetch parallelisiert (asyncio.Semaphore(3))
- Bounds-Fix: invalidateSize() + pad(0.15) vor getBounds()
- map-pin-slash Icon für gesperrten Standort
- Scan-Done-Flash: Statusbar-Pill grün bei 100%
- Schnüffelhund: outer div (by-wander X) + inner SVG (by-sniff Y)
  für natürlichere zweiachsige Bewegung

Backend:
- City-Prewarm-Job: ~70 deutsche Großstädte beim Start (+90s) und
  wöchentlich (So 01:00), Fortschritts-Mails alle 5h an ADMIN_EMAIL
- ADMIN_EMAIL Env-Var in .env.example dokumentiert

Bugfixes:
- Profil-Edit: /api/profile → /profile (doppelter Prefix)
- Friends: Mobile-Portrait-Layout (flex-wrap, overflow-x:hidden)
- Trainingspläne: Pills text-wrap (flex + white-space:normal)
This commit is contained in:
rene 2026-04-17 14:06:10 +02:00
parent cd3f118113
commit 6fcf841594
10 changed files with 340 additions and 32 deletions

View file

@ -25,3 +25,6 @@ KI_CLOUD_MODEL=claude-opus-4-6
VAPID_PUBLIC_KEY= VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY= VAPID_PRIVATE_KEY=
VAPID_CONTACT=mailto:admin@banyaro.app VAPID_CONTACT=mailto:admin@banyaro.app
# Admin-Benachrichtigungen (z.B. Prewarm-Fortschritt)
ADMIN_EMAIL=

View file

@ -4,6 +4,7 @@ Cacht OSM-Daten lokal, erlaubt Nutzern eigene Marker und Meldungen.
""" """
import math import math
import asyncio
import httpx import httpx
import logging import logging
from typing import Optional from typing import Optional
@ -142,8 +143,11 @@ async def get_pois(
stale = _stale_tiles(type, tiles) stale = _stale_tiles(type, tiles)
if stale and not fast: if stale and not fast:
for (x, y) in stale: sem = asyncio.Semaphore(3)
await _fetch_and_store_tile(type, x, y) 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 fetched_fresh = True
with db() as conn: with db() as conn:
@ -309,9 +313,16 @@ async def analyze_region(
tiles = _covering_tiles(south, west, north, east, CACHE_ZOOM) tiles = _covering_tiles(south, west, north, east, CACHE_ZOOM)
async def _warmup(): async def _warmup():
for poi_type in OSM_QUERIES: sem = asyncio.Semaphore(3)
for (x, y) in _stale_tiles(poi_type, tiles): async def _limited(poi_type, x, y):
async with sem:
await _fetch_and_store_tile(poi_type, x, y) 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) background_tasks.add_task(_warmup)
return {'status': 'gestartet', 'tiles': len(tiles), 'types': list(OSM_QUERIES.keys())} return {'status': 'gestartet', 'tiles': len(tiles), 'types': list(OSM_QUERIES.keys())}

View file

@ -53,6 +53,13 @@ def start():
replace_existing=True, replace_existing=True,
misfire_grace_time=7200, 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 # Einmalig beim Start (nach 10s Verzögerung) für sofortige Befüllung
_scheduler.add_job( _scheduler.add_job(
_job_import_events, _job_import_events,
@ -61,6 +68,14 @@ def start():
id="import_events_startup", id="import_events_startup",
replace_existing=True, 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 # Einmalig beim Start (nach 15s Verzögerung) — Rassen aus TheDogAPI befüllen
_scheduler.add_job( _scheduler.add_job(
_job_seed_breeds, _job_seed_breeds,
@ -384,6 +399,178 @@ async def _job_seed_wikidata_breeds():
logger.error(f"Wikidata-Seed: Fehler: {e}") 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"""\
<div style="font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px">
<h2 style="color:#C4843A;margin:0 0 16px">🗺 City-Prewarm {subject_prefix}</h2>
<table style="border-collapse:collapse;width:100%">
<tr><td style="padding:6px 0;color:#666">Städte:</td>
<td style="padding:6px 0;font-weight:600">{cities_done} / {total_cities} ({pct}%)</td></tr>
<tr><td style="padding:6px 0;color:#666">Tiles geladen:</td>
<td style="padding:6px 0;font-weight:600">{total_fetched}</td></tr>
<tr><td style="padding:6px 0;color:#666">Laufzeit:</td>
<td style="padding:6px 0;font-weight:600">{elapsed_str}</td></tr>
{f'<tr><td style="padding:6px 0;color:#666">Verbleibend (ca.):</td><td style="padding:6px 0;font-weight:600">{eta_str}</td></tr>' if eta_str else ''}
</table>
</div>"""
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): def _compute_milestone(today: date, bday: date, dog_name: str):
""" """
Gibt (titel, text) zurück wenn heute ein Meilenstein-Tag ist, Gibt (titel, text) zurück wenn heute ein Meilenstein-Tag ist,

View file

@ -25,6 +25,7 @@
<symbol id="lock-open" viewBox="0 0 256 256"><path d="M208,80H96V56a32,32,0,0,1,32-32c15.37,0,29.2,11,32.16,25.59a8,8,0,0,0,15.68-3.18C171.32,24.15,151.2,8,128,8A48.05,48.05,0,0,0,80,56V80H48A16,16,0,0,0,32,96V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V96A16,16,0,0,0,208,80Zm0,128H48V96H208V208Zm-68-56a12,12,0,1,1-12-12A12,12,0,0,1,140,152Z"/></symbol> <symbol id="lock-open" viewBox="0 0 256 256"><path d="M208,80H96V56a32,32,0,0,1,32-32c15.37,0,29.2,11,32.16,25.59a8,8,0,0,0,15.68-3.18C171.32,24.15,151.2,8,128,8A48.05,48.05,0,0,0,80,56V80H48A16,16,0,0,0,32,96V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V96A16,16,0,0,0,208,80Zm0,128H48V96H208V208Zm-68-56a12,12,0,1,1-12-12A12,12,0,0,1,140,152Z"/></symbol>
<symbol id="magnifying-glass" viewBox="0 0 256 256"><path d="M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/></symbol> <symbol id="magnifying-glass" viewBox="0 0 256 256"><path d="M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/></symbol>
<symbol id="map-pin" viewBox="0 0 256 256"><path d="M128,64a40,40,0,1,0,40,40A40,40,0,0,0,128,64Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,128Zm0-112a88.1,88.1,0,0,0-88,88c0,31.4,14.51,64.68,42,96.25a254.19,254.19,0,0,0,41.45,38.3,8,8,0,0,0,9.18,0A254.19,254.19,0,0,0,174,200.25c27.45-31.57,42-64.85,42-96.25A88.1,88.1,0,0,0,128,16Zm0,206c-16.53-13-72-60.75-72-118a72,72,0,0,1,144,0C200,161.23,144.53,209,128,222Z"/></symbol> <symbol id="map-pin" viewBox="0 0 256 256"><path d="M128,64a40,40,0,1,0,40,40A40,40,0,0,0,128,64Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,128Zm0-112a88.1,88.1,0,0,0-88,88c0,31.4,14.51,64.68,42,96.25a254.19,254.19,0,0,0,41.45,38.3,8,8,0,0,0,9.18,0A254.19,254.19,0,0,0,174,200.25c27.45-31.57,42-64.85,42-96.25A88.1,88.1,0,0,0,128,16Zm0,206c-16.53-13-72-60.75-72-118a72,72,0,0,1,144,0C200,161.23,144.53,209,128,222Z"/></symbol>
<symbol id="map-pin-slash" viewBox="0 0 256 256"><path d="M128,64a40,40,0,1,0,40,40A40,40,0,0,0,128,64Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,128Zm0-112a88.1,88.1,0,0,0-88,88c0,31.4,14.51,64.68,42,96.25a254.19,254.19,0,0,0,41.45,38.3,8,8,0,0,0,9.18,0A254.19,254.19,0,0,0,174,200.25c27.45-31.57,42-64.85,42-96.25A88.1,88.1,0,0,0,128,16Zm0,206c-16.53-13-72-60.75-72-118a72,72,0,0,1,144,0C200,161.23,144.53,209,128,222Z"/><path d="M213.66,42.34a8,8,0,0,0-11.32,0L42.34,202.34a8,8,0,0,0,11.32,11.32L213.66,53.66A8,8,0,0,0,213.66,42.34Z"/></symbol>
<symbol id="map-trifold" viewBox="0 0 256 256"><path d="M228.92,49.69a8,8,0,0,0-6.86-1.45L160.93,63.52,99.58,32.84a8,8,0,0,0-5.52-.6l-64,16A8,8,0,0,0,24,56V200a8,8,0,0,0,9.94,7.76l61.13-15.28,61.35,30.68A8.15,8.15,0,0,0,160,224a8,8,0,0,0,1.94-.24l64-16A8,8,0,0,0,232,200V56A8,8,0,0,0,228.92,49.69ZM104,52.94l48,24V203.06l-48-24ZM40,62.25l48-12v127.5l-48,12Zm176,131.5-48,12V78.25l48-12Z"/></symbol> <symbol id="map-trifold" viewBox="0 0 256 256"><path d="M228.92,49.69a8,8,0,0,0-6.86-1.45L160.93,63.52,99.58,32.84a8,8,0,0,0-5.52-.6l-64,16A8,8,0,0,0,24,56V200a8,8,0,0,0,9.94,7.76l61.13-15.28,61.35,30.68A8.15,8.15,0,0,0,160,224a8,8,0,0,0,1.94-.24l64-16A8,8,0,0,0,232,200V56A8,8,0,0,0,228.92,49.69ZM104,52.94l48,24V203.06l-48-24ZM40,62.25l48-12v127.5l-48,12Zm176,131.5-48,12V78.25l48-12Z"/></symbol>
<symbol id="path" viewBox="0 0 256 256"><path d="M200,168a32.06,32.06,0,0,0-31,24H72a32,32,0,0,1,0-64h96a40,40,0,0,0,0-80H72a8,8,0,0,0,0,16h96a24,24,0,0,1,0,48H72a48,48,0,0,0,0,96h97a32,32,0,1,0,31-40Zm0,48a16,16,0,1,1,16-16A16,16,0,0,1,200,216Z"/></symbol> <symbol id="path" viewBox="0 0 256 256"><path d="M200,168a32.06,32.06,0,0,0-31,24H72a32,32,0,0,1,0-64h96a40,40,0,0,0,0-80H72a8,8,0,0,0,0,16h96a24,24,0,0,1,0,48H72a48,48,0,0,0,0,96h97a32,32,0,1,0,31-40Zm0,48a16,16,0,1,1,16-16A16,16,0,0,1,200,216Z"/></symbol>
<symbol id="paw-print" viewBox="0 0 256 256"><path d="M212,80a28,28,0,1,0,28,28A28,28,0,0,0,212,80Zm0,40a12,12,0,1,1,12-12A12,12,0,0,1,212,120ZM72,108a28,28,0,1,0-28,28A28,28,0,0,0,72,108ZM44,120a12,12,0,1,1,12-12A12,12,0,0,1,44,120ZM92,88A28,28,0,1,0,64,60,28,28,0,0,0,92,88Zm0-40A12,12,0,1,1,80,60,12,12,0,0,1,92,48Zm72,40a28,28,0,1,0-28-28A28,28,0,0,0,164,88Zm0-40a12,12,0,1,1-12,12A12,12,0,0,1,164,48Zm23.12,100.86a35.3,35.3,0,0,1-16.87-21.14,44,44,0,0,0-84.5,0A35.25,35.25,0,0,1,69,148.82,40,40,0,0,0,88,224a39.48,39.48,0,0,0,15.52-3.13,64.09,64.09,0,0,1,48.87,0,40,40,0,0,0,34.73-72ZM168,208a24,24,0,0,1-9.45-1.93,80.14,80.14,0,0,0-61.19,0,24,24,0,0,1-20.71-43.26,51.22,51.22,0,0,0,24.46-30.67,28,28,0,0,1,53.78,0,51.27,51.27,0,0,0,24.53,30.71A24,24,0,0,1,168,208Z"/></symbol> <symbol id="paw-print" viewBox="0 0 256 256"><path d="M212,80a28,28,0,1,0,28,28A28,28,0,0,0,212,80Zm0,40a12,12,0,1,1,12-12A12,12,0,0,1,212,120ZM72,108a28,28,0,1,0-28,28A28,28,0,0,0,72,108ZM44,120a12,12,0,1,1,12-12A12,12,0,0,1,44,120ZM92,88A28,28,0,1,0,64,60,28,28,0,0,0,92,88Zm0-40A12,12,0,1,1,80,60,12,12,0,0,1,92,48Zm72,40a28,28,0,1,0-28-28A28,28,0,0,0,164,88Zm0-40a12,12,0,1,1-12,12A12,12,0,0,1,164,48Zm23.12,100.86a35.3,35.3,0,0,1-16.87-21.14,44,44,0,0,0-84.5,0A35.25,35.25,0,0,1,69,148.82,40,40,0,0,0,88,224a39.48,39.48,0,0,0,15.52-3.13,64.09,64.09,0,0,1,48.87,0,40,40,0,0,0,34.73-72ZM168,208a24,24,0,0,1-9.45-1.93,80.14,80.14,0,0,0-61.19,0,24,24,0,0,1-20.71-43.26,51.22,51.22,0,0,0,24.46-30.67,28,28,0,0,1,53.78,0,51.27,51.27,0,0,0,24.53,30.71A24,24,0,0,1,168,208Z"/></symbol>

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Before After
Before After

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. 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 = (() => { const App = (() => {
@ -261,6 +261,7 @@ const App = (() => {
const item = e.target.closest('[data-page]'); const item = e.target.closest('[data-page]');
if (item) { if (item) {
navigate(item.dataset.page); navigate(item.dataset.page);
if (item.closest('#sidebar')) _closeSidebar();
return; return;
} }
@ -274,6 +275,7 @@ const App = (() => {
// Sidebar-User (kein data-page, damit keine Aktiv-Markierung) // Sidebar-User (kein data-page, damit keine Aktiv-Markierung)
if (e.target.closest('#sidebar-user')) { if (e.target.closest('#sidebar-user')) {
navigate('settings'); navigate('settings');
_closeSidebar();
return; return;
} }

View file

@ -36,7 +36,7 @@ window.Page_friends = (() => {
const myLink = `${location.origin}/#friends?suche=${encodeURIComponent(myName)}`; const myLink = `${location.origin}/#friends?suche=${encodeURIComponent(myName)}`;
_container.innerHTML = ` _container.innerHTML = `
<div style="max-width:520px;margin:0 auto;padding:var(--space-4)"> <div style="max-width:520px;margin:0 auto;padding:var(--space-4);overflow-x:hidden">
<!-- Mein Freundes-Link --> <!-- Mein Freundes-Link -->
<div class="card" style="margin-bottom:var(--space-5);padding:var(--space-4)"> <div class="card" style="margin-bottom:var(--space-5);padding:var(--space-4)">
@ -186,18 +186,20 @@ window.Page_friends = (() => {
${list.map(r => ` ${list.map(r => `
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3); <div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3);
border-left:3px solid var(--c-primary)"> border-left:3px solid var(--c-primary)">
<div style="display:flex;align-items:center;gap:var(--space-3)"> <div style="display:flex;align-items:center;gap:var(--space-3);flex-wrap:wrap">
${_userAvatar(r.requester_name, r.dogs?.[0], r.avatar_url)} ${_userAvatar(r.requester_name, r.dogs?.[0], r.avatar_url)}
<div style="flex:1;min-width:0"> <div style="flex:1;min-width:120px">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)"> <div style="font-weight:var(--weight-semibold);color:var(--c-text);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${_esc(r.requester_name)} ${_esc(r.requester_name)}
</div> </div>
${_dogPills(r.dogs, 2)} ${_dogPills(r.dogs, 2)}
</div> </div>
<div style="display:flex;gap:var(--space-2);flex-shrink:0"> <div style="display:flex;gap:var(--space-2);margin-left:auto">
<button class="btn btn-primary btn-sm" <button class="btn btn-primary btn-sm"
onclick="Page_friends._accept(${r.id})" title="Annehmen"> onclick="Page_friends._accept(${r.id})" title="Annehmen">
<svg class="ph-icon"><use href="/icons/phosphor.svg#check"></use></svg> <svg class="ph-icon"><use href="/icons/phosphor.svg#check"></use></svg>
Annehmen
</button> </button>
<button class="btn btn-ghost btn-sm" <button class="btn btn-ghost btn-sm"
onclick="Page_friends._decline(${r.id})" title="Ablehnen"> onclick="Page_friends._decline(${r.id})" title="Ablehnen">
@ -426,7 +428,7 @@ window.Page_friends = (() => {
</div>`); </div>`);
} }
if (profile.social_link) { if (profile.social_link) {
parts.push(`<div style="font-size:var(--text-xs)"> parts.push(`<div style="font-size:var(--text-xs);word-break:break-all">
<a href="${_esc(profile.social_link)}" target="_blank" rel="noopener noreferrer" <a href="${_esc(profile.social_link)}" target="_blank" rel="noopener noreferrer"
style="color:var(--c-primary)">${_esc(profile.social_link)}</a> style="color:var(--c-primary)">${_esc(profile.social_link)}</a>
</div>`); </div>`);
@ -508,10 +510,9 @@ window.Page_friends = (() => {
</div>` </div>`
: ''} : ''}
</div> </div>
<button class="btn btn-primary btn-sm fr-add-btn" <button class="btn btn-primary btn-sm fr-add-btn" title="Anfrage senden"
data-user-id="${u.id}" data-user-name="${_esc(u.name)}"> data-user-id="${u.id}" data-user-name="${_esc(u.name)}">
<svg class="ph-icon"><use href="/icons/phosphor.svg#user-plus"></use></svg> <svg class="ph-icon"><use href="/icons/phosphor.svg#user-plus"></use></svg>
Anfrage
</button> </button>
</div> </div>
`).join('')} `).join('')}

View file

@ -121,6 +121,8 @@ window.Page_map = (() => {
let _overpassTimer = null; let _overpassTimer = null;
let _overpassActive = false; let _overpassActive = false;
let _ringClosing = false;
let _frankfurtTimer = null;
// ---------------------------------------------------------- // ----------------------------------------------------------
// INIT // INIT
@ -133,11 +135,23 @@ window.Page_map = (() => {
// Alle-Button Initialzustand // Alle-Button Initialzustand
const anyOnInit = Object.entries(_visible).some(([k, v]) => v && k !== 'giftkoeder'); const anyOnInit = Object.entries(_visible).some(([k, v]) => v && k !== 'giftkoeder');
document.getElementById('map-legend-all')?.classList.toggle('all-off', !anyOnInit); document.getElementById('map-legend-all')?.classList.toggle('all-off', !anyOnInit);
try { _userPos = await API.getLocation(); } catch {}
await _loadLeaflet(); await _loadLeaflet();
_initMap(); _initMap(); // sofort mit Deutschland-Mitte starten
_startLocationTracking(); _startLocationTracking();
_loadAll(); _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 = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin-slash"></use></svg>';
}
});
} }
function refresh() { _loadAll(); } function refresh() { _loadAll(); }
@ -302,11 +316,15 @@ window.Page_map = (() => {
const el = document.getElementById('central-map'); const el = document.getElementById('central-map');
if (!el || !window.L || _map) return; if (!el || !window.L || _map) return;
const center = _userPos ? [_userPos.lat, _userPos.lon] : [51.1657, 10.4515]; const center = _userPos ? [_userPos.lat, _userPos.lon] : [50.1109, 8.6821]; // Frankfurt
const zoom = _userPos ? 14 : 6; const zoom = _userPos ? 14 : 10;
_map = L.map('central-map', { zoomControl: true, attributionControl: false }) _map = L.map('central-map', { zoomControl: true, attributionControl: false })
.setView(center, zoom); .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); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_map);
setTimeout(() => _map.invalidateSize(), 100); setTimeout(() => _map.invalidateSize(), 100);
@ -403,9 +421,89 @@ window.Page_map = (() => {
} }
function _setOsmStatus(text, pct = null) { 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; if (el) el.textContent = text;
_updateScanRing(text ? pct : null); _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 = `
<ellipse cx="33" cy="22" rx="14" ry="8" fill="currentColor"/>
<ellipse cx="16" cy="20" rx="6" ry="7" fill="currentColor"/>
<ellipse cx="8" cy="27" rx="7" ry="6" fill="currentColor"/>
<ellipse cx="14" cy="16" rx="3" ry="5" fill="currentColor" transform="rotate(15,14,16)" opacity=".85"/>
<ellipse cx="3" cy="30" rx="3.5" ry="2.5" fill="currentColor"/>
<ellipse cx="1.5" cy="29" rx="2" ry="1.5" fill="#7a4f2a"/>
<circle cx="9" cy="24" r="1.3" fill="white" opacity=".9"/>
<rect x="22" y="28" width="3" height="10" rx="1.5" fill="currentColor"/>
<rect x="29" y="29" width="3" height="9" rx="1.5" fill="currentColor"/>
<rect x="37" y="28" width="3" height="9" rx="1.5" fill="currentColor"/>
<rect x="42" y="27" width="3" height="9" rx="1.5" fill="currentColor"/>
<g class="by-tail" transform="translate(47,17)">
<path d="M0,0 Q6,-10 4,-18" stroke="currentColor" stroke-width="3" fill="none" stroke-linecap="round"/>
</g>
`;
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) { function _updateScanRing(pct) {
@ -418,6 +516,7 @@ window.Page_map = (() => {
// Ring ausblenden / entfernen // Ring ausblenden / entfernen
if (pct === null) { if (pct === null) {
if (_ringClosing) return;
if (svg) { svg.style.opacity = '0'; setTimeout(() => svg?.remove(), 600); } if (svg) { svg.style.opacity = '0'; setTimeout(() => svg?.remove(), 600); }
statusbar.style.border = ''; statusbar.style.border = '';
return; return;
@ -448,12 +547,13 @@ window.Page_map = (() => {
const p = 2; // Abstand zur inneren Kante const p = 2; // Abstand zur inneren Kante
// Umfang der Pill: gerades Stück + zwei Halbkreise // Umfang der Pill: gerades Stück + zwei Halbkreise
const perim = 2 * (w - h) + Math.PI * h; const perim = 2 * (w - h) + Math.PI * h;
// Stroke beginnt oben-links und läuft im Uhrzeigersinn // Natürlicher SVG-Start: linkes Ende der oberen Geraden
// Um bei 12 Uhr zu starten: Offset um das linke Halbkreis-Viertel + halbe Geraden verschieben // 12-Uhr-Position: Mitte der oberen Geraden → Abstand = (w-h)/2
const startShift = (w - h) / 2 + (Math.PI * h) / 4; // dashoffset = perim - S verschiebt den Dash-Start genau dorthin
const progress = Math.min(100, Math.max(0, pct)); const S = (w - h) / 2;
const dashOffset = perim * (1 - progress / 100) + startShift; 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.left = (sr.left - mr.left - p) + 'px';
svg.style.top = (sr.top - mr.top - 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('height', String(h));
rect.setAttribute('rx', String(r)); rect.setAttribute('rx', String(r));
rect.setAttribute('ry', String(r)); rect.setAttribute('ry', String(r));
rect.setAttribute('stroke-dasharray', perim.toFixed(2)); rect.setAttribute('stroke-dasharray', `${progressLen.toFixed(2)} ${(perim - progressLen).toFixed(2)}`);
rect.setAttribute('stroke-dashoffset', dashOffset.toFixed(2)); rect.setAttribute('stroke-dashoffset', (perim - S).toFixed(2));
// Original-Rahmen verstecken während Ring aktiv ist // Original-Rahmen verstecken während Ring aktiv ist
statusbar.style.border = 'none'; statusbar.style.border = 'none';
if (progress >= 100) { if (progress >= 100) {
_ringClosing = true;
setTimeout(() => { setTimeout(() => {
const s = document.getElementById('map-scan-ring'); const s = document.getElementById('map-scan-ring');
if (s) s.style.opacity = '0'; if (s) s.style.opacity = '0';
statusbar.style.border = ''; statusbar.style.border = '';
setTimeout(() => s?.remove(), 600); setTimeout(() => { s?.remove(); _ringClosing = false; }, 600);
}, 500); }, 500);
} }
} }
@ -517,7 +618,8 @@ window.Page_map = (() => {
} }
_overpassActive = true; _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() }; const bbox = { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() };
// Welche Layer bei diesem Zoom geladen werden // Welche Layer bei diesem Zoom geladen werden

View file

@ -246,7 +246,7 @@ window.Page_settings = (() => {
try { try {
const fd = new FormData(); const fd = new FormData();
fd.append('file', file); 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; _appState.user.avatar_url = res.avatar_url;
UI.toast.success('Avatar aktualisiert.'); UI.toast.success('Avatar aktualisiert.');
_render(); _render();
@ -326,7 +326,7 @@ window.Page_settings = (() => {
const btn = document.querySelector('[form="profile-form"]'); const btn = document.querySelector('[form="profile-form"]');
const fd = UI.formData(e.target); const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => { await UI.asyncButton(btn, async () => {
const updated = await API.patch('/api/profile', { const updated = await API.patch('/profile', {
bio: fd.bio || '', bio: fd.bio || '',
wohnort: fd.wohnort || '', wohnort: fd.wohnort || '',
erfahrung: fd.erfahrung || '', erfahrung: fd.erfahrung || '',

View file

@ -124,7 +124,8 @@ window.Page_trainingsplaene = (() => {
]; ];
const btns = plans.map(p => ` const btns = plans.map(p => `
<button class="by-tab${_activePlan === p.id ? ' active' : ''}" data-plan="${p.id}" <button class="by-tab${_activePlan === p.id ? ' active' : ''}" data-plan="${p.id}"
style="flex:1;min-width:90px;flex-direction:column;align-items:center;gap:4px"> style="flex:1;min-width:90px;display:flex;flex-direction:column;align-items:center;
justify-content:center;gap:2px;white-space:normal;text-align:center;line-height:1.2">
<span style="font-size:1.1rem">${p.label}</span> <span style="font-size:1.1rem">${p.label}</span>
<span style="font-size:var(--text-xs);opacity:0.8">${_esc(p.sub)}</span> <span style="font-size:var(--text-xs);opacity:0.8">${_esc(p.sub)}</span>
</button>`).join(''); </button>`).join('');

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v123'; const CACHE_VERSION = 'by-v141';
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