Bündel 2: Zentrale Helper für DRY-Cleanup, SW by-v1114

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.
This commit is contained in:
rene 2026-05-27 11:19:06 +02:00
parent c517c9281d
commit 297bd22f96
22 changed files with 225 additions and 202 deletions

View file

@ -1 +1 @@
1113
1114

20
backend/config.py Normal file
View file

@ -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}

47
backend/errors.py Normal file
View file

@ -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

37
backend/math_utils.py Normal file
View file

@ -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

View file

@ -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

View file

@ -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}

View file

@ -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")

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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"],

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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,

View file

@ -86,14 +86,14 @@
<title>Ban Yaro</title>
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
<script src="/js/boot-early.js?v=1113"></script>
<script src="/js/boot-early.js?v=1114"></script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=1113">
<link rel="stylesheet" href="/css/layout.css?v=1113">
<link rel="stylesheet" href="/css/components.css?v=1113">
<link rel="stylesheet" href="/css/utilities.css?v=1113">
<link rel="stylesheet" href="/css/lists.css?v=1113">
<link rel="stylesheet" href="/css/design-system.css?v=1114">
<link rel="stylesheet" href="/css/layout.css?v=1114">
<link rel="stylesheet" href="/css/components.css?v=1114">
<link rel="stylesheet" href="/css/utilities.css?v=1114">
<link rel="stylesheet" href="/css/lists.css?v=1114">
</head>
<body>
@ -617,11 +617,11 @@
<div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=1113"></script>
<script src="/js/ui.js?v=1113"></script>
<script src="/js/app.js?v=1113"></script>
<script src="/js/worlds.js?v=1113"></script>
<script src="/js/offline-indicator.js?v=1113"></script>
<script src="/js/api.js?v=1114"></script>
<script src="/js/ui.js?v=1114"></script>
<script src="/js/app.js?v=1114"></script>
<script src="/js/worlds.js?v=1114"></script>
<script src="/js/offline-indicator.js?v=1114"></script>
<!-- Feature-Seiten werden lazy geladen -->
@ -631,7 +631,7 @@
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
<script src="/js/boot.js?v=1113"></script>
<script src="/js/boot.js?v=1114"></script>
</body>

View file

@ -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;

View file

@ -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,

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<script src="/js/landing-init.js?v=1113"></script>
<script src="/js/landing-init.js?v=1114"></script>
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, ohne App Store.">
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">

View file

@ -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