From 297bd22f96261d540cf50d14650d6e745465a18f Mon Sep 17 00:00:00 2001 From: rene Date: Wed, 27 May 2026 11:19:06 +0200 Subject: [PATCH] =?UTF-8?q?B=C3=BCndel=202:=20Zentrale=20Helper=20f=C3=BCr?= =?UTF-8?q?=20DRY-Cleanup,=20SW=20by-v1114?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NEUE BACKEND-MODULE: math_utils.py - haversine_km(lat1, lon1, lat2, lon2) — Distanz in km - haversine_m(...) — Convenience-Wrapper in Metern - bbox_deg_from_km(lat, radius_km) — Bounding-Box-Approximation für SQL-Vorfilter (statt Haversine im Python-Loop) config.py - DB_PATH, MEDIA_DIR, BREEDER_DOCS_DIR, SCANINPUT_DIR - API_TIMEOUT_SHORT (5s) / DEFAULT (10s) / LONG (30s) - HTTP_USER_AGENT, HTTP_HEADERS errors.py - not_found(msg), forbidden(msg), bad_request(msg), unauthorized(msg) - conflict(msg), too_many_requests(msg, retry_after), service_unavailable(msg) - require_or_404(row, msg) — Convenience-Helper UI.JS ERWEITERUNGEN: UI.time erweitert: - formatDate(d) → "15.03.2026" - formatDateTime(d) → "15.03.2026, 14:30" - weekday(d) → "Di" - parseISO(str) → {year, month, day} UI.text (neu): - truncate(str, maxLen, ellipsis='…') - slug(str) — URL-Slug aus String (mit DE-Umlauten) UI.money (neu): - format(value) → "12,34 €" (de-DE, EUR) - formatWithSuffix(value, '/Jahr') HAVERSINE-MIGRATION (13 Backend-Routen): alerts.py, services.py, places.py, events.py, diary.py, playdate.py, lost.py, poison.py, adoption.py, gassi_zeiten.py, sitting.py, routen.py, walks.py - Alle lokalen def _haversine/haversine_km entfernt - Aufrufe ersetzt durch haversine_km/haversine_m je nach Einheit - from math_utils import haversine_km|haversine_m in jeder Datei Tests 19/19 grün. Hinweis: Migrationen für MEDIA_DIR (19 Stellen), API-Timeouts (12), Date-Formatter im Frontend (24) und UI.text.truncate (5) sind als Folge-Sprints möglich. Helper sind verfügbar. --- VERSION | 2 +- backend/config.py | 20 +++++++++++ backend/errors.py | 47 ++++++++++++++++++++++++ backend/math_utils.py | 37 +++++++++++++++++++ backend/routes/adoption.py | 20 +++-------- backend/routes/alerts.py | 20 +++-------- backend/routes/diary.py | 21 ++++------- backend/routes/events.py | 13 ++----- backend/routes/gassi_zeiten.py | 13 ++----- backend/routes/lost.py | 18 ++-------- backend/routes/places.py | 14 ++------ backend/routes/playdate.py | 17 ++------- backend/routes/poison.py | 18 ++-------- backend/routes/routen.py | 20 +++-------- backend/routes/services.py | 14 ++------ backend/routes/sitting.py | 13 ++----- backend/routes/walks.py | 24 ++++--------- backend/static/index.html | 24 ++++++------- backend/static/js/app.js | 2 +- backend/static/js/ui.js | 66 ++++++++++++++++++++++++++++++++-- backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- 22 files changed, 225 insertions(+), 202 deletions(-) create mode 100644 backend/config.py create mode 100644 backend/errors.py create mode 100644 backend/math_utils.py diff --git a/VERSION b/VERSION index 176746d..e9fa9e7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1113 \ No newline at end of file +1114 \ No newline at end of file diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..a05b6be --- /dev/null +++ b/backend/config.py @@ -0,0 +1,20 @@ +"""Zentrale Konfiguration — vermeidet 19× duplizierte os.getenv-Aufrufe +für MEDIA_DIR und gibt einheitliche Timeout-Konstanten für externe APIs.""" +import os + + +# Speicher-Pfade +DB_PATH = os.getenv("DB_PATH", "/data/banyaro.db") +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") +BREEDER_DOCS_DIR = os.getenv("BREEDER_DOCS_DIR", "/data/breeder_docs") +SCANINPUT_DIR = os.getenv("SCANINPUT_DIR", "/data/scaninput") + +# HTTP-Timeouts für externe APIs (in Sekunden) +# Verwendung: httpx.AsyncClient(timeout=API_TIMEOUT_DEFAULT) +API_TIMEOUT_SHORT = 5 # Schnelle Lookups (Geocoding, Reverse, einzelne Werte) +API_TIMEOUT_DEFAULT = 10 # Standardfall (Wetter, Wikipedia) +API_TIMEOUT_LONG = 30 # Größere Antworten (Overpass-Tiles, KI-Calls) + +# Standard-Header für externe Requests (Höflichkeit + Fair-Use) +HTTP_USER_AGENT = "BanYaro/1.0 (https://banyaro.app)" +HTTP_HEADERS = {"User-Agent": HTTP_USER_AGENT} diff --git a/backend/errors.py b/backend/errors.py new file mode 100644 index 0000000..2cbaf3f --- /dev/null +++ b/backend/errors.py @@ -0,0 +1,47 @@ +"""Standardisierte HTTP-Exceptions — vermeidet inkonsistente Texte +in 200+ raise-Statements.""" +from fastapi import HTTPException + + +def not_found(msg: str = "Nicht gefunden") -> HTTPException: + """404. Beispiel: `raise not_found('Hund nicht gefunden')`.""" + return HTTPException(404, msg) + + +def forbidden(msg: str = "Kein Zugriff") -> HTTPException: + """403.""" + return HTTPException(403, msg) + + +def bad_request(msg: str = "Ungültige Eingabe") -> HTTPException: + """400.""" + return HTTPException(400, msg) + + +def unauthorized(msg: str = "Nicht angemeldet") -> HTTPException: + """401.""" + return HTTPException(401, msg) + + +def conflict(msg: str = "Konflikt") -> HTTPException: + """409.""" + return HTTPException(409, msg) + + +def too_many_requests(msg: str = "Zu viele Anfragen", retry_after: int | None = None) -> HTTPException: + """429. Optional mit Retry-After Header (in Sekunden).""" + headers = {"Retry-After": str(retry_after)} if retry_after else None + return HTTPException(429, msg, headers=headers) + + +def service_unavailable(msg: str = "Dienst gerade nicht verfügbar") -> HTTPException: + """503.""" + return HTTPException(503, msg) + + +def require_or_404(row, msg: str = "Nicht gefunden"): + """Convenience: wirft 404 wenn row None/falsy, sonst gibt row zurück. + Beispiel: `dog = require_or_404(conn.execute(...).fetchone(), 'Hund nicht gefunden')`""" + if not row: + raise not_found(msg) + return row diff --git a/backend/math_utils.py b/backend/math_utils.py new file mode 100644 index 0000000..a111ec5 --- /dev/null +++ b/backend/math_utils.py @@ -0,0 +1,37 @@ +"""Mathematische Helper-Funktionen — zentral statt 13× dupliziert.""" +import math + + +# Erdradius in Kilometern +EARTH_RADIUS_KM = 6371.0 + + +def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Distanz zwischen zwei GPS-Koordinaten in km (Haversine-Formel). + + Funktioniert für beliebige Punkte auf der Erde. Genauigkeit reicht + für App-Zwecke (Umkreissuche etc.). + """ + lat1_rad = math.radians(lat1) + lat2_rad = math.radians(lat2) + dlat = math.radians(lat2 - lat1) + dlon = math.radians(lon2 - lon1) + a = (math.sin(dlat / 2) ** 2 + + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2) ** 2) + return 2 * EARTH_RADIUS_KM * math.asin(math.sqrt(a)) + + +def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Distanz in Metern (Convenience-Wrapper).""" + return haversine_km(lat1, lon1, lat2, lon2) * 1000.0 + + +def bbox_deg_from_km(lat: float, radius_km: float): + """Bounding-Box-Approximation in Grad für radius_km um (lat, lon). + + Returns (lat_delta, lon_delta) — beide in Grad. + Verwendung: WHERE lat BETWEEN ?-lat_delta AND ?+lat_delta etc. + """ + lat_delta = radius_km / 111.0 + lon_delta = radius_km / (111.0 * max(abs(math.cos(math.radians(lat))), 0.01)) + return lat_delta, lon_delta diff --git a/backend/routes/adoption.py b/backend/routes/adoption.py index bde0986..96888c7 100644 --- a/backend/routes/adoption.py +++ b/backend/routes/adoption.py @@ -10,7 +10,6 @@ Caching: adoption_cache Tabelle, 24h TTL. """ import os -import math import logging import asyncio import uuid @@ -22,6 +21,7 @@ from typing import Optional from database import db from auth import get_current_user from routes.push import send_push_to_user +from math_utils import haversine_km MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") @@ -31,18 +31,6 @@ router = APIRouter() PETFINDER_KEY = os.getenv("PETFINDER_API_KEY", "") PETFINDER_SECRET = os.getenv("PETFINDER_API_SECRET", "") -# ------------------------------------------------------------------ -# Haversine — Distanz in km -# ------------------------------------------------------------------ -def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: - R = 6371.0 - p1 = math.radians(lat1) - p2 = 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)) - # ------------------------------------------------------------------ # Statische Tierheim-Daten (große deutsche Tierheime) @@ -234,7 +222,7 @@ async def adoption_nearby( for row in rows: d = dict(row) if d.get("tierheim_lat") and d.get("tierheim_lon"): - dist = _haversine(lat, lon, d["tierheim_lat"], d["tierheim_lon"]) + dist = haversine_km(lat, lon, d["tierheim_lat"], d["tierheim_lon"]) if dist <= radius: d["distanz_km"] = round(dist, 1) cached_animals.append(d) @@ -250,7 +238,7 @@ async def adoption_nearby( # ------ Statische Tierheime (immer) ------ shelters = [] for sid, name, plz, stadt, slat, slon, url in GERMAN_SHELTERS: - dist = _haversine(lat, lon, slat, slon) + dist = haversine_km(lat, lon, slat, slon) if dist <= radius: shelters.append({ "id": sid, @@ -354,7 +342,7 @@ def community_list( d = dict(row) d["user_interested"] = bool(d.pop("_user_interested", 0)) if lat is not None and lon is not None and d.get("lat") and d.get("lon"): - dist = _haversine(lat, lon, d["lat"], d["lon"]) + dist = haversine_km(lat, lon, d["lat"], d["lon"]) d["distanz_km"] = round(dist, 1) if dist > radius: continue diff --git a/backend/routes/alerts.py b/backend/routes/alerts.py index 0065d18..7f1a0a0 100644 --- a/backend/routes/alerts.py +++ b/backend/routes/alerts.py @@ -1,10 +1,10 @@ """BAN YARO — Nearby Alerts (Giftköder + Vermisste Hunde)""" -import math from datetime import datetime from fastapi import APIRouter, Depends from database import db from auth import get_current_user_optional as get_optional_user +from math_utils import haversine_m, bbox_deg_from_km router = APIRouter() @@ -12,21 +12,9 @@ _RADIUS_M = 20_000 # 20 km _RADIUS_KM = _RADIUS_M / 1000.0 -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)) - - def _bbox(lat: float, lon: float, radius_km: float) -> tuple[float, float, float, float]: """Bounding-Box-Approximation für lat/lon innerhalb radius_km.""" - lat_delta = radius_km / 111.0 - # cos darf bei Polen nicht 0 werden → mit kleinem Minimum absichern - cos_lat = max(abs(math.cos(math.radians(lat))), 0.01) - lon_delta = radius_km / (111.0 * cos_lat) + lat_delta, lon_delta = bbox_deg_from_km(lat, radius_km) return (lat - lat_delta, lat + lat_delta, lon - lon_delta, lon + lon_delta) @@ -60,7 +48,7 @@ async def nearby_alerts(lat: float, lon: float, user=Depends(get_optional_user)) (lat, lon, user["id"]) ) - 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) + has_poison = any(haversine_m(lat, lon, r["lat"], r["lon"]) <= _RADIUS_M for r in poisons) + has_lost = any(haversine_m(lat, lon, r["lat"], r["lon"]) <= _RADIUS_M for r in lost) return {"poison": has_poison, "lost": has_lost} diff --git a/backend/routes/diary.py b/backend/routes/diary.py index baf2586..a17bf4d 100644 --- a/backend/routes/diary.py +++ b/backend/routes/diary.py @@ -1,6 +1,6 @@ """BAN YARO — Tagebuch Routes""" -import os, uuid, json, math, logging, asyncio +import os, uuid, json, logging, asyncio from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from pydantic import BaseModel from typing import Optional @@ -11,6 +11,7 @@ import httpx import weather as weather_mod from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload, extract_gps_from_exif, generate_preview, preview_url_from, get_image_size from timeutils import safe_client_time +from math_utils import haversine_km logger = logging.getLogger(__name__) @@ -409,7 +410,7 @@ async def _fetch_pois_for_coords(lat: float, lon: float, limit: int = 5) -> list elat = el.get("lat") or el.get("center", {}).get("lat") elon = el.get("lon") or el.get("center", {}).get("lon") if elat and elon: - km = _haversine_km(lat, lon, elat, elon) + km = haversine_km(lat, lon, elat, elon) typ = next((el["tags"].get(k) for k in ["tourism", "historic", "leisure", "amenity", "shop"] if el["tags"].get(k)), "place") @@ -422,16 +423,6 @@ async def _fetch_pois_for_coords(lat: float, lon: float, limit: int = 5) -> list return results[:limit] -def _haversine_km(lat1, lon1, lat2, lon2) -> float: - R = 6371 - dlat = math.radians(lat2 - lat1) - dlon = math.radians(lon2 - lon1) - a = (math.sin(dlat / 2) ** 2 - + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) - * math.sin(dlon / 2) ** 2) - return R * 2 * math.asin(math.sqrt(a)) - - @router.get("/{dog_id}/diary/nearby") async def nearby_places(dog_id: int, lat: float, lon: float, user=Depends(get_current_user)): @@ -445,7 +436,7 @@ async def nearby_places(dog_id: int, lat: float, lon: float, (user["id"],) ).fetchall() for p in places: - km = _haversine_km(lat, lon, p["lat"], p["lon"]) + km = haversine_km(lat, lon, p["lat"], p["lon"]) if km <= 5: results.append({"name": p["name"], "type": p["typ"] or "place", "lat": p["lat"], "lon": p["lon"], @@ -456,7 +447,7 @@ async def nearby_places(dog_id: int, lat: float, lon: float, "SELECT name, type, lat, lon FROM osm_pois WHERE name IS NOT NULL AND name != ''" ).fetchall() for p in osm: - km = _haversine_km(lat, lon, p["lat"], p["lon"]) + km = haversine_km(lat, lon, p["lat"], p["lon"]) if km <= 2: results.append({"name": p["name"], "type": p["type"], "lat": p["lat"], "lon": p["lon"], @@ -503,7 +494,7 @@ async def nearby_places(dog_id: int, lat: float, lon: float, elat = el.get("lat") or el.get("center", {}).get("lat") elon = el.get("lon") or el.get("center", {}).get("lon") if elat and elon: - km = _haversine_km(lat, lon, elat, elon) + km = haversine_km(lat, lon, elat, elon) typ = next((el["tags"].get(k) for k in ["tourism","historic","leisure","amenity","shop"] if el["tags"].get(k)), "place") diff --git a/backend/routes/events.py b/backend/routes/events.py index c066959..0b6de27 100644 --- a/backend/routes/events.py +++ b/backend/routes/events.py @@ -1,27 +1,18 @@ """BAN YARO — Events (Hundeveranstaltungen)""" -import math from datetime import date from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from typing import Optional from database import db from auth import get_current_user +from math_utils import haversine_m router = APIRouter() TYPEN = {'ausstellung', 'training', 'treffen', 'markt', 'wettkampf', 'sonstiges'} -def _haversine(lat1, lon1, lat2, lon2): - 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)) - - # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ @@ -86,7 +77,7 @@ async def list_events( result = [dict(r) for r in rows] if lat is not None and lon is not None: result = [r for r in result - if r['lat'] is None or _haversine(lat, lon, r['lat'], r['lon']) <= radius] + if r['lat'] is None or haversine_m(lat, lon, r['lat'], r['lon']) <= radius] return result diff --git a/backend/routes/gassi_zeiten.py b/backend/routes/gassi_zeiten.py index 77ff52f..536e1a1 100644 --- a/backend/routes/gassi_zeiten.py +++ b/backend/routes/gassi_zeiten.py @@ -1,28 +1,19 @@ """BAN YARO — Gassi-Zeiten-Pool (regelmäßige Gassi-Zeiten mit Gleichgesinnten)""" import json -import math import logging from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from typing import Optional, List from database import db from auth import get_current_user +from math_utils import haversine_m logger = logging.getLogger(__name__) router = APIRouter() -def _haversine(lat1, lon1, lat2, lon2): - 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)) - - class GassiZeitCreate(BaseModel): dog_id: Optional[int] = None wochentage: List[str] # ["mo", "mi", "fr"] @@ -83,7 +74,7 @@ async def list_gassi_zeiten( # Distanz-Filter if lat is not None and lon is not None and d.get("lat") and d.get("lon"): - dist = _haversine(lat, lon, d["lat"], d["lon"]) + dist = haversine_m(lat, lon, d["lat"], d["lon"]) if not nur_eigene and dist > radius: continue d["distance_m"] = int(dist) diff --git a/backend/routes/lost.py b/backend/routes/lost.py index 3b02ed3..1522944 100644 --- a/backend/routes/lost.py +++ b/backend/routes/lost.py @@ -1,6 +1,6 @@ """BAN YARO — Verlorener Hund Routes""" -import os, uuid, math +import os, uuid from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from pydantic import BaseModel @@ -10,24 +10,12 @@ from auth import get_current_user from timeutils import safe_client_time from routes.push import send_push_to_all from media_utils import convert_media +from math_utils import haversine_m router = APIRouter() MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") -# ------------------------------------------------------------------ -# Haversine-Distanz in Metern -# ------------------------------------------------------------------ -def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: - R = 6_371_000 - p1 = math.radians(lat1) - p2 = 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)) - - # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ @@ -60,7 +48,7 @@ async def list_lost(lat: Optional[float] = None, lon: Optional[float] = None, for r in rows: entry = dict(r) if lat is not None and lon is not None: - dist = _haversine(lat, lon, entry["lat"], entry["lon"]) + dist = haversine_m(lat, lon, entry["lat"], entry["lon"]) if dist > radius_km * 1000: continue entry["distanz_m"] = round(dist) diff --git a/backend/routes/places.py b/backend/routes/places.py index c8ca526..bb1f86b 100644 --- a/backend/routes/places.py +++ b/backend/routes/places.py @@ -1,27 +1,17 @@ """BAN YARO — Hundefreundliche Orte""" -import math from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from typing import Optional from database import db from auth import get_current_user +from math_utils import haversine_m router = APIRouter() TYPEN = {'restaurant', 'shop', 'freilauf', 'kotbeutel', 'tierarzt', 'hundesalon', 'hundeschule'} -def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: - R = 6_371_000 - p1 = math.radians(lat1) - p2 = 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)) - - # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ @@ -79,7 +69,7 @@ async def list_places( result = [_row_to_dict(r) for r in rows] if lat is not None and lon is not None: - result = [r for r in result if _haversine(lat, lon, r['lat'], r['lon']) <= radius] + result = [r for r in result if haversine_m(lat, lon, r['lat'], r['lon']) <= radius] return result diff --git a/backend/routes/playdate.py b/backend/routes/playdate.py index 01d57ae..60f1d2a 100644 --- a/backend/routes/playdate.py +++ b/backend/routes/playdate.py @@ -1,30 +1,17 @@ """BAN YARO — Playdate-Matching""" -import math import logging from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from typing import Optional from database import db from auth import get_current_user +from math_utils import haversine_km router = APIRouter() logger = logging.getLogger(__name__) -# ------------------------------------------------------------------ -# Haversine -# ------------------------------------------------------------------ -def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: - R = 6371.0 - dlat = math.radians(lat2 - lat1) - dlon = math.radians(lon2 - lon1) - a = (math.sin(dlat / 2) ** 2 - + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) - * math.sin(dlon / 2) ** 2) - return R * 2 * math.asin(math.sqrt(a)) - - def _calc_alter(geburtstag: Optional[str]) -> Optional[str]: """Gibt lesbares Alter zurück z.B. '2 Jahre' oder '5 Monate'.""" if not geburtstag: @@ -107,7 +94,7 @@ async def nearby(lat: float, lon: float, radius: int = 10, result = [] for r in rows: - dist = _haversine(lat, lon, r["lat"], r["lon"]) + dist = haversine_km(lat, lon, r["lat"], r["lon"]) if dist <= radius: result.append({ "listing_id": r["listing_id"], diff --git a/backend/routes/poison.py b/backend/routes/poison.py index 2372e74..15fe392 100644 --- a/backend/routes/poison.py +++ b/backend/routes/poison.py @@ -1,6 +1,6 @@ """BAN YARO — Giftköder-Alarm Routes""" -import os, uuid, math +import os, uuid from datetime import datetime, timedelta from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File from pydantic import BaseModel @@ -10,24 +10,12 @@ from auth import get_current_user from routes.push import send_push_nearby from media_utils import convert_media from ratelimit import check as rl_check +from math_utils import haversine_m router = APIRouter() MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") -# ------------------------------------------------------------------ -# Haversine-Distanz in Metern -# ------------------------------------------------------------------ -def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: - R = 6_371_000 - p1 = math.radians(lat1) - p2 = 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)) - - # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ @@ -62,7 +50,7 @@ async def list_poison(lat: float, lon: float, radius: int = 5000): results = [] for r in rows: entry = dict(r) - dist = _haversine(lat, lon, entry["lat"], entry["lon"]) + dist = haversine_m(lat, lon, entry["lat"], entry["lon"]) if dist <= radius: entry["distanz_m"] = round(dist) results.append(entry) diff --git a/backend/routes/routen.py b/backend/routes/routen.py index e1060ef..57c345d 100644 --- a/backend/routes/routen.py +++ b/backend/routes/routen.py @@ -1,7 +1,7 @@ """BAN YARO — Gassi-Routen""" import datetime as _dt -import json, math, os, uuid +import json, os, uuid import httpx import polyline as _polyline from fastapi import APIRouter, Depends, HTTPException, UploadFile, File @@ -13,6 +13,7 @@ from routes.achievements import update_streak, check_and_award from timeutils import safe_client_time from media_utils import convert_media from routes.push import send_push_to_user +from math_utils import haversine_km, haversine_m router = APIRouter() @@ -27,16 +28,6 @@ def _check_speed(distanz_km, dauer_min) -> bool: return (distanz_km / (dauer_min / 60)) <= _MAX_AVG_KMH -def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: - R = 6_371_000 - p1 = math.radians(lat1) - p2 = 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)) - - # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ @@ -137,7 +128,7 @@ async def list_routes( if lat is not None and lon is not None: result = [ r for r in result - if r['start_lat'] and _haversine(lat, lon, r['start_lat'], r['start_lon']) <= radius + if r['start_lat'] and haversine_m(lat, lon, r['start_lat'], r['start_lon']) <= radius ] user_id = user['id'] if user else None @@ -429,10 +420,7 @@ async def trim_route(route_id: int, data: RouteTrim, user=Depends(get_current_us new_km = 0.0 for i in range(1, len(new_track)): p1, p2 = new_track[i-1], new_track[i] - dlat = math.radians(p2['lat'] - p1['lat']) - dlon = math.radians(p2['lon'] - p1['lon']) - a = math.sin(dlat/2)**2 + math.cos(math.radians(p1['lat'])) * math.cos(math.radians(p2['lat'])) * math.sin(dlon/2)**2 - new_km += 6371 * 2 * math.atan2(math.sqrt(a), math.sqrt(1-a)) + new_km += haversine_km(p1['lat'], p1['lon'], p2['lat'], p2['lon']) new_km = round(new_km, 2) # Dauer proportional schätzen (Original-Pace) diff --git a/backend/routes/services.py b/backend/routes/services.py index 0696f5f..a0409e9 100644 --- a/backend/routes/services.py +++ b/backend/routes/services.py @@ -1,27 +1,17 @@ """BAN YARO — Service-Angebote (Sitting & Walks Matching)""" -import math from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from typing import Optional from database import db from auth import get_current_user +from math_utils import haversine_km router = APIRouter() ALLOWED_TYPES = {'sitting', 'walks'} -def _haversine(lat1, lon1, lat2, lon2): - R = 6371.0 - dlat = math.radians(lat2 - lat1) - dlon = math.radians(lon2 - lon1) - a = (math.sin(dlat / 2) ** 2 - + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) - * math.sin(dlon / 2) ** 2) - return R * 2 * math.asin(math.sqrt(a)) - - # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ @@ -60,7 +50,7 @@ async def list_services( for r in rows: d = dict(r) if lat is not None and lon is not None and d['lat'] and d['lon']: - dist = _haversine(lat, lon, d['lat'], d['lon']) + dist = haversine_km(lat, lon, d['lat'], d['lon']) if dist > radius: continue d['distanz_km'] = round(dist, 1) diff --git a/backend/routes/sitting.py b/backend/routes/sitting.py index acfa2e6..dc2e96c 100644 --- a/backend/routes/sitting.py +++ b/backend/routes/sitting.py @@ -1,27 +1,18 @@ """BAN YARO — Hundesitting""" import json -import math from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from typing import Optional, List from database import db from auth import get_current_user +from math_utils import haversine_m router = APIRouter() SERVICES = {'tagesbetreuung', 'uebernachtung', 'gassi', 'hausbesuch'} -def _haversine(lat1, lon1, lat2, lon2): - 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)) - - # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ @@ -80,7 +71,7 @@ async def list_sitters( if service and service not in d['services']: continue if lat is not None and lon is not None and d['lat'] and d['lon']: - dist = _haversine(lat, lon, d['lat'], d['lon']) + dist = haversine_m(lat, lon, d['lat'], d['lon']) if dist > radius: continue d['distanz_m'] = round(dist) diff --git a/backend/routes/walks.py b/backend/routes/walks.py index 3a0c48b..83f6221 100644 --- a/backend/routes/walks.py +++ b/backend/routes/walks.py @@ -1,6 +1,6 @@ """BAN YARO — Gassi-Treffen""" -import math, os, uuid +import os, uuid import httpx from datetime import date from fastapi import APIRouter, Depends, HTTPException, UploadFile, File @@ -9,25 +9,13 @@ from typing import Optional, List from database import db from auth import get_current_user from routes.push import send_push_to_user +from math_utils import haversine_km, haversine_m MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") router = APIRouter() -def _haversine(lat1, lon1, lat2, lon2): - 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)) - - -def _haversine_km(lat1, lon1, lat2, lon2): - return _haversine(lat1, lon1, lat2, lon2) / 1000 - - # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ @@ -91,7 +79,7 @@ async def list_walks( # Umkreis-Filter if lat is not None and lon is not None: - result = [r for r in result if _haversine(lat, lon, r['lat'], r['lon']) <= radius] + result = [r for r in result if haversine_m(lat, lon, r['lat'], r['lon']) <= radius] return result @@ -131,7 +119,7 @@ async def nearby_places(lat: float, lon: float, user=Depends(get_current_user)): "SELECT name, typ, lat, lon FROM places WHERE lat IS NOT NULL", ).fetchall() for p in places: - km = _haversine_km(lat, lon, p["lat"], p["lon"]) + km = haversine_km(lat, lon, p["lat"], p["lon"]) if km <= 5: results.append({"name": p["name"], "type": p["typ"] or "place", "lat": p["lat"], "lon": p["lon"], @@ -142,7 +130,7 @@ async def nearby_places(lat: float, lon: float, user=Depends(get_current_user)): "SELECT name, type, lat, lon FROM osm_pois WHERE name IS NOT NULL AND name != ''" ).fetchall() for p in osm: - km = _haversine_km(lat, lon, p["lat"], p["lon"]) + km = haversine_km(lat, lon, p["lat"], p["lon"]) if km <= 2: results.append({"name": p["name"], "type": p["type"], "lat": p["lat"], "lon": p["lon"], @@ -170,7 +158,7 @@ async def nearby_places(lat: float, lon: float, user=Depends(get_current_user)): elon = el.get("lon") or el.get("center", {}).get("lon") if elat is None or elon is None: continue - km = _haversine_km(lat, lon, elat, elon) + km = haversine_km(lat, lon, elat, elon) if km <= 1: results.append({"name": name, "type": "osm", "lat": elat, "lon": elon, diff --git a/backend/static/index.html b/backend/static/index.html index d2b53fe..eee2b4e 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -617,11 +617,11 @@ - - - - - + + + + + @@ -631,7 +631,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index f9e13fb..542e7d8 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 = '1113'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1114'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator) window.APP_VERSION = APP_VERSION; diff --git a/backend/static/js/ui.js b/backend/static/js/ui.js index 6b94eee..2c5486a 100644 --- a/backend/static/js/ui.js +++ b/backend/static/js/ui.js @@ -490,13 +490,73 @@ const UI = (() => { return fmtDate.format(new Date(dateStr)); } + // Datum: "15.03.2026" / "15.03.2026, 14:30" + const _fmtDateNumeric = new Intl.DateTimeFormat('de-DE', { + day: '2-digit', month: '2-digit', year: 'numeric' + }); + const _fmtDateTimeNumeric = new Intl.DateTimeFormat('de-DE', { + day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit' + }); + // Wochentag: "Di." / Tag im Monat: "15" + const _fmtWeekday = new Intl.DateTimeFormat('de-DE', { weekday: 'short' }); + return { relative, - format: d => fmtDate.format(new Date(d)), - formatShort: d => fmtDateShort.format(new Date(d)), + format: d => fmtDate.format(new Date(d)), + formatShort: d => fmtDateShort.format(new Date(d)), + formatDate: d => _fmtDateNumeric.format(new Date(d)), // 15.03.2026 + formatDateTime:d => _fmtDateTimeNumeric.format(new Date(d)), // 15.03.2026, 14:30 + weekday: d => _fmtWeekday.format(new Date(d)).replace('.', ''), + // ISO-Parser: "2026-03-15" → { year, month, day } + parseISO(str) { + if (!str) return null; + const m = String(str).match(/^(\d{4})-(\d{2})-(\d{2})/); + return m ? { year: +m[1], month: +m[2], day: +m[3] } : null; + }, }; })(); + // ---------------------------------------------------------- + // TEXT — String-Helper + // ---------------------------------------------------------- + const text = { + /** Schneidet str auf maxLen ab und hängt ellipsis an. */ + truncate(str, maxLen = 80, ellipsis = '…') { + if (!str) return ''; + const s = String(str); + return s.length <= maxLen ? s : s.slice(0, maxLen - ellipsis.length) + ellipsis; + }, + /** Slug aus String — für URL-Pfade. */ + slug(str) { + return String(str || '') + .toLowerCase() + .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss') + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); + }, + }; + + // ---------------------------------------------------------- + // MONEY — Currency-Formatierung (de-DE, EUR) + // ---------------------------------------------------------- + const _fmtEur = new Intl.NumberFormat('de-DE', { + style: 'currency', currency: 'EUR', + minimumFractionDigits: 2, maximumFractionDigits: 2, + }); + const money = { + /** Formatiert Zahl als "12,34 €" — null/undefined ergibt "—". */ + format(value) { + if (value == null || value === '' || isNaN(value)) return '—'; + return _fmtEur.format(Number(value)); + }, + /** Mit Suffix wie "/Jahr". */ + formatWithSuffix(value, suffix = '') { + if (value == null || value === '' || isNaN(value)) return '—'; + return _fmtEur.format(Number(value)) + (suffix ? ' ' + suffix : ''); + }, + }; + // ---------------------------------------------------------- // FOTO-VORSCHAU (Input[type=file] → img) // ---------------------------------------------------------- @@ -1272,7 +1332,7 @@ const UI = (() => { toast, modal, setLoading, asyncButton, formData, setFormError, clearFormErrors, - emptyState, errorState, time, + emptyState, errorState, time, text, money, previewUrl, previewFallback, setupPhotoPreview, scrollTop, skeleton, skeletonList, moneyInput, parseMoney, datePicker, diff --git a/backend/static/landing.html b/backend/static/landing.html index 7ff2cac..c545b18 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index ec32c89..ebc46f7 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1113'; +const VER = '1114'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten